From 40086d36cd0b8776e931c631a8b10c84aed48b64 Mon Sep 17 00:00:00 2001 From: Irzhy Ranaivoarivony Date: Mon, 29 Jun 2026 09:39:28 +0300 Subject: [PATCH 01/16] dev: added debug profile at build --- elastos/Cargo.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/elastos/Cargo.toml b/elastos/Cargo.toml index e2f0c4d9..7d6f257d 100644 --- a/elastos/Cargo.toml +++ b/elastos/Cargo.toml @@ -31,6 +31,13 @@ debug = "line-tables-only" # Enough for backtraces, ~60% less artifact size [profile.dev.package."*"] opt-level = 1 # Optimize deps (compiled once, run many times) +# Full debug info for the gateway/runtime binary only (the rest of the workspace stays +# "line-tables-only" for build size). Lets a debugger (lldb/CodeLLDB) inspect locals/params in +# elastos-server without bloating every crate. Provider capsules are separate crates and already +# build with full debug info in dev, so they need no override here. +[profile.dev.package.elastos-server] +debug = 2 + [profile.test] opt-level = 0 debug = "line-tables-only" From 238e1e5aaf3d95ecf1e539ee30f75125a4db0599 Mon Sep 17 00:00:00 2001 From: Irzhy Ranaivoarivony Date: Thu, 2 Jul 2026 11:21:17 +0300 Subject: [PATCH 02/16] feat(dash): MPEG-DASH / CENC compliance for media assets (ELACITY-2283) Produce a single MPEG-DASH/CENC-compliant asset (ISO-IEC 23001-7) for every media (DASH) mint, while keeping the server-decrypt rail's own player working by down-converting back to a plaintext-looking init at the fetch point. - ddrm-envelope: shared `pssh` module -- single source of truth for producer, runtime decrypt read-path, and playback clients. ELASTOS_PQ_SYSTEM_ID (b6e254ef-0dc5-47fe-94e7-0e72ed1dc7b0); build_pssh (v1 box, default-KID + opaque .asset.protections JSON) / parse_pssh (v0/1, trailing-moov tolerant). - ddrm-media: cenc_signal_init() (avc1->encv / mp4a->enca + sinf(frma/schm/tenc) + pssh moov child) and strip_cenc_signal() as a byte-exact inverse. Roundtrip tests assert strip(signal(x)) == x, no-op on unsignaled, fail-closed on double. - encrypt-provider: CencSignalInits op -- pure public box surgery (no CEK/secret), wraps the runtime-built PSSH envelope and rewrites each per-track init; returns transformed inits + pssh_b64 for the MPD. - creator (producer): after the threshold seal, build the PSSH envelope from dkms_protection, CENC-signal each init, and patch stream.mpd with (mp4protection:2011 + cenc:default_KID + per-system pssh). - ddrm-media-authority: read_dash_init strips CENC signaling at the fetch point so the seal-bound AAD init and the runtime player's served init both match the plaintext init the mint sealed (no AAD mismatch). - Flip on by default: drop the ELASTOS_DDRM_CENC_PSSH gate -- CENC signaling + MPD ContentProtection are now standard output. Additive; existing playback unchanged. Squashed from: d012fc4 047d38f 4d26798 d6fb99f 3ac5fdc 4edfd9e elastos-server 782+95 green; helper 15 green; fmt clean. --- capsules/ddrm-envelope/src/lib.rs | 146 +++++++ capsules/ddrm-media/src/mp4.rs | 359 ++++++++++++++++++ capsules/encrypt-provider/src/main.rs | 114 ++++++ .../crates/elastos-server/src/api/creator.rs | 171 +++++++++ .../dev/ddrm-media-authority/src/quorum.rs | 14 +- 5 files changed, 802 insertions(+), 2 deletions(-) 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..baeff1a3 100644 --- a/capsules/ddrm-media/src/mp4.rs +++ b/capsules/ddrm-media/src/mp4.rs @@ -372,6 +372,267 @@ pub fn strip_senc(frag: &[u8]) -> Result, String> { } /// Wrap arbitrary bytes as a single-sample fragmented-MP4 fragment so a NON-MEDIA +/// Audio sample-entry 4CCs — everything else is treated as video. Used to pick the +/// protected entry type (`enca` vs `encv`) per ISO/IEC 23001-7 §4. +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"enca" + ) +} + +/// 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), +} + +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; + 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), + }) +} + +/// 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_fourcc(&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), @@ -1177,4 +1438,102 @@ 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 + } + + #[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/encrypt-provider/src/main.rs b/capsules/encrypt-provider/src/main.rs index f5891e46..e7ce5316 100644 --- a/capsules/encrypt-provider/src/main.rs +++ b/capsules/encrypt-provider/src/main.rs @@ -157,9 +157,58 @@ 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); + if s.len() != 32 { + return Err(format!("kid must be 32 hex chars, got {}", s.len())); + } + 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 +298,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 +831,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 diff --git a/elastos/crates/elastos-server/src/api/creator.rs b/elastos/crates/elastos-server/src/api/creator.rs index 64e0b1fc..403ec154 100644 --- a/elastos/crates/elastos-server/src/api/creator.rs +++ b/elastos/crates/elastos-server/src/api/creator.rs @@ -49,6 +49,11 @@ const CREATOR_APP: &str = "creator"; const THRESHOLD_PROTECTION_TYPE: &str = "cenc:elastos-pq-hybrid-threshold-v0"; const THRESHOLD_SCHEME: &str = "elastos-pq-hybrid-threshold-v0"; +/// The registered MPEG-DASH/CENC DRM SystemID (UUID form) for the threshold scheme — used in the +/// MPD per-system ``. Matches +/// `ddrm_envelope::pssh::ELASTOS_PQ_SYSTEM_ID` (base64 `tuJU7w3FR/6U5w5y7R3HsA==`). +const ELASTOS_PQ_SYSTEM_UUID: &str = "b6e254ef-0dc5-47fe-94e7-0e72ed1dc7b0"; + /// The canonical DASH manifest filename inside the published media directory. PC2 (and /// base.ela.city) fetch `{dirCid}/stream.mpd` (`pc2-node/src/services/media/dashPackager.ts`, /// `src/api/media.ts`), so runtime-minted media MUST use this exact name to index + play there. @@ -1854,6 +1859,59 @@ async fn run_prepare_mint_media( return Err(stage_err("encrypt", "encrypted segment count mismatch")); } + // 2c) MPEG-DASH/CENC (ISO/IEC 23001-7) compliance — the standard for every media (DASH) mint. + // CENC-signal each per-track init (encv/enca + sinf + tenc(default_KID) + the Elacity pssh) + // and patch the MPD ContentProtection, so the published asset is a first-class CENC + // protection system (Bento4/PC2 wire format). This is ADDITIVE: the server-decrypt rail + // strips the signaling back for the runtime's own player, so existing playback is unchanged. + let (init_files, manifest) = { + let protections_slot = dkms_protection(&node_set_id_b64, &producer_vk_b64, &shares); + let pssh_envelope = build_pssh_envelope(&protections_slot, mint_chain_id()); + let pssh_data_b64 = b64.encode( + serde_json::to_vec(&pssh_envelope).map_err(|e| stage_err("encrypt", e.to_string()))?, + ); + let signal_req = json!({ + "op": "cenc_signal_inits", + "inits": init_files + .iter() + .map(|(p, b)| json!({ "path": p, "b64": b })) + .collect::>(), + "kid_hex": kid_hex, + "iv_size": 8, + "pssh_data_b64": pssh_data_b64, + }); + let signaled = provider_data(registry, "encrypt", &signal_req) + .await + .map_err(|e| stage_err("encrypt", e))?; + let signaled_inits = signaled + .get("inits") + .and_then(Value::as_array) + .ok_or_else(|| stage_err("encrypt", "cenc_signal_inits returned no inits"))?; + let new_inits: Vec<(String, String)> = signaled_inits + .iter() + .filter_map(|v| { + Some(( + v.get("path")?.as_str()?.to_string(), + v.get("b64")?.as_str()?.to_string(), + )) + }) + .collect(); + if new_inits.len() != init_files.len() { + return Err(stage_err( + "encrypt", + "cenc_signal_inits returned wrong init count", + )); + } + let pssh_b64 = signaled + .get("pssh_b64") + .and_then(Value::as_str) + .ok_or_else(|| stage_err("encrypt", "cenc_signal_inits returned no pssh_b64"))?; + ( + new_inits, + patch_mpd_content_protection(&manifest, &kid_hex, pssh_b64), + ) + }; + // 3) assemble the DASH directory: plaintext per-track inits + ENCRYPTED segments (at their // MPD paths) + the manifest. Inits are NOT encrypted (CENC encrypts media fragments only). let mut files: Vec = Vec::with_capacity(init_files.len() + enc_segments.len() + 1); @@ -2298,6 +2356,73 @@ fn dkms_protection(node_set_id_b64: &str, producer_vk_b64: &str, shares: &Value) }) } +/// Wrap the asset's protections slot into the PSSH `Data` JSON envelope the clients consume +/// (PC2 / media-player shape: `protocolVersion` / `protectionType` / `variant` / `data`). +/// Single-sourced from [`dkms_protection`] so the PSSH payload and metadata.json protections can +/// never drift (Principle 12): `data` is the slot minus the hoisted `protectionType`, plus `chainId`. +fn build_pssh_envelope(protections_slot: &Value, chain_id: u64) -> Value { + let mut data = protections_slot.clone(); + if let Some(obj) = data.as_object_mut() { + obj.remove("protectionType"); + obj.insert("chainId".to_string(), json!(chain_id)); + } + json!({ + "protocolVersion": "3.0", + "protectionType": THRESHOLD_PROTECTION_TYPE, + "variant": "eth.web3.clearkey", + "data": data, + }) +} + +/// Format a 32-hex `default_KID` as the dashed UUID form CENC `cenc:default_KID` expects. +fn format_kid_uuid(kid_hex: &str) -> String { + let s = kid_hex.strip_prefix("0x").unwrap_or(kid_hex); + if s.len() != 32 { + return s.to_string(); + } + format!( + "{}-{}-{}-{}-{}", + &s[0..8], + &s[8..12], + &s[12..16], + &s[16..20], + &s[20..32] + ) +} + +/// Inject MPEG-DASH/CENC `` into every `` of `mpd`: the common +/// `urn:mpeg:dash:mp4protection:2011` (value `cenc`, `cenc:default_KID`) plus the per-system Elacity +/// descriptor carrying the base64 `pssh`. Adds the `cenc` namespace to ``. String-level insert +/// (the media-provider MPD is ffmpeg-generated, plain `` elements). +fn patch_mpd_content_protection(mpd: &str, kid_hex: &str, pssh_box_b64: &str) -> String { + let default_kid = format_kid_uuid(kid_hex); + let block = format!( + "\ +{pssh_box_b64}" + ); + // Ensure the cenc namespace on . + let out = if mpd.contains("xmlns:cenc=") { + mpd.to_string() + } else { + mpd.replacen(" open tag. + let mut result = String::with_capacity(out.len() + block.len() * 2); + let mut cursor = 0usize; + while let Some(rel) = out[cursor..].find("') else { + break; + }; + let insert_at = tag_start + close_rel + 1; + result.push_str(&out[cursor..insert_at]); + result.push_str(&block); + cursor = insert_at; + } + result.push_str(&out[cursor..]); + result +} + /// The non-empty effective MIME (`application/octet-stream` when the frame sent none). fn effective_mime(meta: &MintMeta) -> &str { if meta.mime.trim().is_empty() { @@ -3048,6 +3173,52 @@ fn staged_error(status: StatusCode, stage: &str, message: &str) -> Response { mod tests { use super::*; + #[test] + fn format_kid_uuid_dashes_a_32_hex_kid() { + assert_eq!( + format_kid_uuid("ecc00fbfe967bf0e091532f2492bbd09"), + "ecc00fbf-e967-bf0e-0915-32f2492bbd09" + ); + // 0x prefix tolerated; non-32-hex returned as-is. + assert_eq!( + format_kid_uuid("0xecc00fbfe967bf0e091532f2492bbd09"), + "ecc00fbf-e967-bf0e-0915-32f2492bbd09" + ); + assert_eq!(format_kid_uuid("short"), "short"); + } + + #[test] + fn build_pssh_envelope_hoists_protection_type_and_adds_chain_id() { + let slot = dkms_protection("nodeset", "prodvk", &json!([{"x": 1}])); + let env = build_pssh_envelope(&slot, 8453); + assert_eq!(env["protectionType"], THRESHOLD_PROTECTION_TYPE); + assert_eq!(env["variant"], "eth.web3.clearkey"); + assert_eq!(env["protocolVersion"], "3.0"); + // data carries the slot fields + chainId, WITHOUT the hoisted protectionType. + assert_eq!(env["data"]["chainId"], 8453); + assert_eq!(env["data"]["scheme"], THRESHOLD_SCHEME); + assert_eq!(env["data"]["node_set_id_b64"], "nodeset"); + assert!(env["data"].get("protectionType").is_none()); + } + + #[test] + fn patch_mpd_injects_content_protection_per_adaptationset() { + let mpd = r#""#; + let out = + patch_mpd_content_protection(mpd, "ecc00fbfe967bf0e091532f2492bbd09", "UFNTSC1CT1g="); + // cenc namespace added once. + assert_eq!(out.matches("xmlns:cenc=").count(), 1); + // common mp4protection + per-system descriptor injected into BOTH AdaptationSets. + assert_eq!(out.matches("urn:mpeg:dash:mp4protection:2011").count(), 2); + assert_eq!( + out.matches(&format!("urn:uuid:{ELASTOS_PQ_SYSTEM_UUID}")) + .count(), + 2 + ); + assert!(out.contains("cenc:default_KID=\"ecc00fbf-e967-bf0e-0915-32f2492bbd09\"")); + assert!(out.contains("UFNTSC1CT1g=")); + } + fn descriptor(node_count: usize, with_seed: bool) -> Vec { let mut nodes = Vec::new(); for i in 0..node_count { diff --git a/scripts/dev/ddrm-media-authority/src/quorum.rs b/scripts/dev/ddrm-media-authority/src/quorum.rs index 4f6f151d..5dd4438a 100644 --- a/scripts/dev/ddrm-media-authority/src/quorum.rs +++ b/scripts/dev/ddrm-media-authority/src/quorum.rs @@ -651,7 +651,7 @@ fn compute_open_payload(capsule: &Value, args: &QuorumArgs) -> Result Result, String> { std::fs::read(&path).map_err(|e| format!("read DASH file {}: {e}", path.display())) } +/// Read a DASH **init** segment from the dir and down-convert it for the server-decrypt rail: +/// strip any MPEG-DASH/CENC signaling (`encv`/`enca` -> original, drop `sinf`/`tenc`/`pssh`) so it +/// matches the PLAINTEXT init the mint sealed (the seal binds `first_init` BEFORE PSSH injection) +/// AND the clear, `senc`-stripped segments this rail serves. No-op on unsignaled inits (demo / +/// pre-compliance assets); best-effort — a malformed init falls back to the raw bytes. +fn read_dash_init(dir: &std::path::Path, rel: &str) -> Result, String> { + let raw = read_dash_file(dir, rel)?; + Ok(ddrm_media::mp4::strip_cenc_signal(&raw).unwrap_or(raw)) +} + /// A warm quorum open: the CEK is recovered + sealed into the live decrypt boundary. Drives /// the boundary for either a one-shot raw object read (media/other) or repeated in-boundary /// page renders (pixel-lock), reusing the same sealed material — no extra quorum round-trips. From 939e44a63b0fd6d0c8e14ee635e3d74cb1654691 Mon Sep 17 00:00:00 2001 From: Irzhy Ranaivoarivony Date: Thu, 2 Jul 2026 11:21:25 +0300 Subject: [PATCH 03/16] fix(runtime): DKMS quorum reliability + host-independent tests (ELACITY-2282) Stop the dKMS quorum path from wedging and leaking processes under playback+reload, and make the local test suite pass off the Linux x86_64 gate. - dkms-authority (Defect A): serve each accepted connection on its own thread (serve_unix_listener / serve_tcp_listener) so an idle/slow/leaked client can no longer head-of-line-block the daemon in read_frame; revoked_callers becomes daemon-lifetime Arc shared state (additive+idempotent); 30s per-conn read timeout on both transports. Regression test drives the real Unix accept loop (RED pre-fix, GREEN after); 35/35 green. - key-provider: bound the Unix recover read in establish_dkms_session with the same DKMS_TCP_READ_TIMEOUT_MS (5s) the tcp/carrier branches use, so a wedged node fails fail-closed within a bounded window. 18/18 green. - dkms (Defect B): reap leaked quorum helper/provider processes -- add Drop for the helper Capsule (kills+reaps key-provider/decrypt-provider children on every path), and guard MediaAuthorityProc launch/launch_quorum with a ChildReaper so early-return/error paths no longer orphan the raw Child. - browser: keep the runtime stream socket path within the macOS sun_path limit (104) -- fall back to a short "/tmp" base when temp_dir() would overflow, fixing the 6 browser-open route tests on macOS arm64 (Linux unaffected). - test(elastos-server): key component-checksum fixtures by detect_platform() so verify/stamp and agent-binary tests run on any host without masking the check. Squashed from: 50cdc46 0c22718 46a7ba4 d93f673 a9283b5 --- capsules/dkms-authority/src/main.rs | 145 +++++++++++++++++- capsules/key-provider/src/main.rs | 10 ++ .../elastos-server/src/api/media_authority.rs | 77 +++++++--- elastos/crates/elastos-server/src/setup.rs | 19 ++- .../dev/ddrm-media-authority/src/quorum.rs | 12 ++ 5 files changed, 231 insertions(+), 32 deletions(-) diff --git a/capsules/dkms-authority/src/main.rs b/capsules/dkms-authority/src/main.rs index 14900209..0fef453e 100644 --- a/capsules/dkms-authority/src/main.rs +++ b/capsules/dkms-authority/src/main.rs @@ -97,6 +97,12 @@ 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); + /// 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 @@ -2078,11 +2084,30 @@ 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}"); + serve_unix_listener(listener, allowed_callers, operator_vk); +} + +/// The Unix accept loop, factored out of [`serve_socket`] so it can be driven over a test-owned +/// listener. Each accepted connection is served on its OWN thread so a single idle/slow/leaked +/// client can never head-of-line-block the others (ELACITY-2282): the daemon always returns to +/// `accept`. Revocations are daemon-lifetime state shared across the connection threads. +#[cfg(unix)] +fn serve_unix_listener( + listener: std::os::unix::net::UnixListener, + allowed_callers: Option>>, + operator_vk: Option>, +) { + use std::sync::{Arc, Mutex}; + let allowed_callers = Arc::new(allowed_callers); + let operator_vk = Arc::new(operator_vk); + let revoked_callers: Arc>>> = Arc::new(Mutex::new(Vec::new())); for stream in listener.incoming() { match stream { Ok(stream) => { + // Insurance (ELACITY-2282 Defect A): bound reads so a stalled/abandoned client can't + // camp its connection thread forever — read_frame then errors and the thread ends. + let _ = stream.set_read_timeout(Some(CONNECTION_READ_TIMEOUT)); let reader = match stream.try_clone() { Ok(s) => io::BufReader::new(s), Err(err) => { @@ -2090,9 +2115,14 @@ fn serve_socket(path: &str) { continue; } }; + let allowed = Arc::clone(&allowed_callers); + let operator = Arc::clone(&operator_vk); + let revoked = Arc::clone(&revoked_callers); // 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); + std::thread::spawn(move || { + serve_connection_threaded(reader, stream, &allowed, &operator, &revoked, false); + }); } Err(err) => { eprintln!("dkms-authority: accept error: {err}"); @@ -2102,6 +2132,39 @@ fn serve_socket(path: &str) { } } +/// Per-connection wrapper (ELACITY-2282): snapshot the daemon-lifetime revoked-caller set under the +/// lock, serve the connection, then union any revocations this connection performed back into the +/// shared set (revocations are additive + idempotent, so a union is safe under concurrency and a +/// caller revoked on one connection stays revoked for every future one). +fn serve_connection_threaded( + reader: R, + writer: W, + allowed_callers: &Option>>, + operator_vk: &Option>, + revoked_callers: &std::sync::Mutex>>, + require_channel: bool, +) { + let mut local = revoked_callers + .lock() + .map(|g| g.clone()) + .unwrap_or_default(); + serve_connection_io( + reader, + writer, + allowed_callers, + operator_vk, + &mut local, + require_channel, + ); + if let Ok(mut guard) = revoked_callers.lock() { + for vk in local { + if !guard.iter().any(|existing| existing == &vk) { + guard.push(vk); + } + } + } +} + /// 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,14 +2190,29 @@ 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}"); + serve_tcp_listener(listener, allowed_callers, operator_vk); +} + +/// The TCP accept loop, factored out of [`serve_tcp`]. Like the Unix loop, each accepted connection +/// is served on its OWN thread (ELACITY-2282) so a stalled remote peer can never wedge the daemon; +/// 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>, +) { + use std::sync::{Arc, Mutex}; + let allowed_callers = Arc::new(allowed_callers); + let operator_vk = Arc::new(operator_vk); + let revoked_callers: Arc>>> = Arc::new(Mutex::new(Vec::new())); 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))); + // NETWORK-FAULT SEMANTICS: bound every read so a stalled peer can't camp its thread. + let _ = stream.set_read_timeout(Some(CONNECTION_READ_TIMEOUT)); let reader = match stream.try_clone() { Ok(s) => io::BufReader::new(s), Err(err) => { @@ -2142,7 +2220,12 @@ fn serve_tcp(addr: &str) { continue; } }; - serve_connection_io(reader, stream, &allowed_callers, &operator_vk, &mut revoked_callers, true); + let allowed = Arc::clone(&allowed_callers); + let operator = Arc::clone(&operator_vk); + let revoked = Arc::clone(&revoked_callers); + std::thread::spawn(move || { + serve_connection_threaded(reader, stream, &allowed, &operator, &revoked, true); + }); } Err(err) => { eprintln!("dkms-authority: accept error: {err}"); @@ -2415,6 +2498,54 @@ 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); + } + const CONTENT: &str = "bafybeigdyrcontent"; const PRINCIPAL: &str = "did:key:zViewer"; const SESSION: &str = "session:abc"; diff --git a/capsules/key-provider/src/main.rs b/capsules/key-provider/src/main.rs index 72b39766..07613130 100644 --- a/capsules/key-provider/src/main.rs +++ b/capsules/key-provider/src/main.rs @@ -801,6 +801,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() diff --git a/elastos/crates/elastos-server/src/api/media_authority.rs b/elastos/crates/elastos-server/src/api/media_authority.rs index 3edaeb38..9558001a 100644 --- a/elastos/crates/elastos-server/src/api/media_authority.rs +++ b/elastos/crates/elastos-server/src/api/media_authority.rs @@ -109,6 +109,45 @@ struct ProcIo { stdout: BufReader, } +/// Kills + reaps its child on drop unless `disarm`ed. A helper is spawned and then several fallible +/// steps read its one-line descriptor BEFORE it's wrapped in a (Drop-reaping) `MediaAuthorityProc`; +/// on any of those error paths the raw `Child` would otherwise leak — Rust's `Child::drop` neither +/// kills nor waits, so it lingers as a running proc OR an unreaped zombie ("exited before publishing +/// a session"), and the 3-attempt open retry loop piles these up (ELACITY-2282 Defect B). This guard +/// closes that gap: on success `disarm()` hands the child to the proc; on any early return it's reaped. +struct ChildReaper(Option); + +impl ChildReaper { + fn arm(child: Child) -> Self { + Self(Some(child)) + } + fn take_stdin(&mut self) -> Result { + self.0 + .as_mut() + .and_then(|c| c.stdin.take()) + .ok_or_else(|| "no stdin".to_string()) + } + fn take_stdout(&mut self) -> Result { + self.0 + .as_mut() + .and_then(|c| c.stdout.take()) + .ok_or_else(|| "no stdout".to_string()) + } + /// Success path: transfer the child out to the caller, disarming the reaper. + fn disarm(mut self) -> Child { + self.0.take().expect("child present until disarm") + } +} + +impl Drop for ChildReaper { + fn drop(&mut self) { + if let Some(mut child) = self.0.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + impl MediaAuthorityProc { /// Spawn the helper, read its one-line session descriptor, and return a handle. /// `object_cid`, when set, binds the decrypt transcript to the real owned object's @@ -132,14 +171,15 @@ impl MediaAuthorityProc { if let Some(binding) = rights_binding { cmd.args(["--rights-binding", binding]); } - let mut child = cmd - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn() - .map_err(|e| format!("spawn media-authority ({helper_bin}): {e}"))?; - let stdin = child.stdin.take().ok_or("no stdin")?; - let mut stdout = BufReader::new(child.stdout.take().ok_or("no stdout")?); + let mut reaper = ChildReaper::arm( + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| format!("spawn media-authority ({helper_bin}): {e}"))?, + ); + let stdin = reaper.take_stdin()?; + let mut stdout = BufReader::new(reaper.take_stdout()?); let mut line = String::new(); let n = stdout @@ -175,7 +215,7 @@ impl MediaAuthorityProc { .unwrap_or_default(); Ok(Self { - child, + child: reaper.disarm(), io: Mutex::new(ProcIo { stdin, stdout }), mime, segment_count, @@ -231,14 +271,15 @@ impl MediaAuthorityProc { if let Some(grant) = access_grant_b64.filter(|s| !s.trim().is_empty()) { cmd.args(["--access-grant", grant]); } - let mut child = cmd - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn() - .map_err(|e| format!("spawn quorum media-authority ({helper_bin}): {e}"))?; - let stdin = child.stdin.take().ok_or("no stdin")?; - let mut stdout = BufReader::new(child.stdout.take().ok_or("no stdout")?); + let mut reaper = ChildReaper::arm( + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| format!("spawn quorum media-authority ({helper_bin}): {e}"))?, + ); + let stdin = reaper.take_stdin()?; + let mut stdout = BufReader::new(reaper.take_stdout()?); let mut line = String::new(); let n = stdout @@ -309,7 +350,7 @@ impl MediaAuthorityProc { }; Ok(Self { - child, + child: reaper.disarm(), io: Mutex::new(ProcIo { stdin, stdout }), mime, segment_count, diff --git a/elastos/crates/elastos-server/src/setup.rs b/elastos/crates/elastos-server/src/setup.rs index 630b5b0c..d383c597 100644 --- a/elastos/crates/elastos-server/src/setup.rs +++ b/elastos/crates/elastos-server/src/setup.rs @@ -2995,17 +2995,22 @@ mod tests { fs::write(&source_path, b"arm64-kernel").unwrap(); fs::write(&install_path, b"arm64-kernel").unwrap(); + // Host-independent: stamp + verify under the resolved host platform, not a fixed + // linux-amd64 (json! needs a literal key, so build the platforms map dynamically). + let mut platforms = serde_json::Map::new(); + platforms.insert( + platform.clone(), + serde_json::json!({ + "strategy": "local-copy", + "source": source_path.to_string_lossy(), + "install_path": "bin/vmlinux" + }), + ); let manifest: ComponentsManifest = serde_json::from_value(serde_json::json!({ "external": { "vmlinux": { "install_path": "bin/vmlinux", - "platforms": { - platform.clone(): { - "strategy": "local-copy", - "source": source_path.to_string_lossy(), - "install_path": "bin/vmlinux" - } - } + "platforms": platforms } }, "capsules": {}, diff --git a/scripts/dev/ddrm-media-authority/src/quorum.rs b/scripts/dev/ddrm-media-authority/src/quorum.rs index 5dd4438a..8b812ba8 100644 --- a/scripts/dev/ddrm-media-authority/src/quorum.rs +++ b/scripts/dev/ddrm-media-authority/src/quorum.rs @@ -120,6 +120,18 @@ impl Capsule { } } +impl Drop for Capsule { + fn drop(&mut self) { + // Safety net (ELACITY-2282 Defect B): Rust's `Child::drop` does NOT kill the process, so a + // Capsule dropped WITHOUT an explicit `shutdown()` — any `?`/early-return/panic path — would + // ORPHAN its key-provider/decrypt-provider child (the lingering procs that pile up across + // retry attempts). Best-effort kill + reap so these never accumulate. Idempotent after a + // graceful `shutdown()` (the child has already exited; kill/wait on it are harmless no-ops). + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + /// Run the dKMS `release` (2-of-3 recover + re-seal to the per-open decrypt session) against the WARM /// key-provider daemon over its Unix socket when the gateway wired one up (Phase A — node handshake /// sessions reused across opens, no per-open process spawn or init+hello), falling back to spawning a From 67989421f4b6c140531e7d7c4559c2ebe1330b73 Mon Sep 17 00:00:00 2001 From: Irzhy Ranaivoarivony Date: Thu, 2 Jul 2026 14:20:21 +0300 Subject: [PATCH 04/16] fix(runtime): harden DKMS lifecycle + CENC/socket safety from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address correctness, security, and robustness findings across the DKMS and encryption/decryption workflows, each with regression tests. - dkms-authority: revocations now share ONE live Arc> across all connection threads (was a per-connection snapshot merged only on close), so a revoke binds every open connection immediately — "revocation outranks a live session" holds under concurrency. Unify the Unix/TCP accept loops into one generic serve_accept_loop with a MAX_ACTIVE_CONNECTIONS cap + RAII slot guard, bounding the thread/memory-exhaustion (slow-loris) vector on the network node. - key-provider: distinguish a transport fault from a node rejection (NodeRecoverError). A warm pooled connection the node's idle timeout closed is re-established and retried ONCE; a genuine rejection still fails closed with no retry. Fixes the first open after a >30s idle gap failing below quorum. - ddrm-media: drive the enca/encv choice off the authoritative hdlr handler type (fallback to an expanded audio-4CC allowlist), and make parse_codec_string use the same allowlist so the two classifiers can't diverge — an uncommon audio codec is no longer mis-signaled as video (non-compliant init + strip missize). - ddrm-media-authority: read_dash_init propagates strip_cenc_signal errors instead of unwrap_or(raw), so a malformed init fails with a precise diagnosis rather than an opaque downstream decrypt/quorum failure. - encrypt-provider: decode_kid16 validates length AND ASCII-hex charset before byte-slicing, rejecting a multibyte KID instead of panicking the capsule. - elastos-server: browser stream sockets use a per-euid dir created 0700 and refuse any pre-existing dir not owned by us or group/other-writable, closing the world-writable /tmp squatting / socket-hijack vector. --- capsules/ddrm-media/src/mp4.rs | 145 ++++++- capsules/dkms-authority/src/main.rs | 358 ++++++++++++------ capsules/encrypt-provider/src/main.rs | 35 +- capsules/key-provider/src/main.rs | 142 +++++-- .../src/api/gateway_browser_route_tests.rs | 6 +- .../src/api/gateway_browser_stream.rs | 133 ++++++- .../dev/ddrm-media-authority/src/quorum.rs | 52 ++- 7 files changed, 717 insertions(+), 154 deletions(-) diff --git a/capsules/ddrm-media/src/mp4.rs b/capsules/ddrm-media/src/mp4.rs index baeff1a3..4572b05a 100644 --- a/capsules/ddrm-media/src/mp4.rs +++ b/capsules/ddrm-media/src/mp4.rs @@ -371,16 +371,49 @@ pub fn strip_senc(frag: &[u8]) -> Result, String> { Ok(out) } -/// Wrap arbitrary bytes as a single-sample fragmented-MP4 fragment so a NON-MEDIA -/// Audio sample-entry 4CCs — everything else is treated as video. Used to pick the -/// protected entry type (`enca` vs `encv`) per ISO/IEC 23001-7 §4. +/// 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"enca" + 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). @@ -413,6 +446,9 @@ struct StsdPath { 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 { @@ -427,6 +463,16 @@ fn locate_stsd_path(init: &[u8]) -> Result { 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; @@ -450,6 +496,7 @@ fn locate_stsd_path(init: &[u8]) -> Result { stbl: (stbl_off, stbl_h), stsd: (stsd_off, stsd_h), entry: (entry_off, entry_h), + handler_type, }) } @@ -500,7 +547,7 @@ pub fn cenc_signal_init( 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_fourcc(&orig_fourcc) { + let prot_fourcc: &[u8; 4] = if is_audio_sample_entry(&p.handler_type, &orig_fourcc) { b"enca" } else { b"encv" @@ -987,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 { @@ -1467,6 +1516,90 @@ mod meta_tests { 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( diff --git a/capsules/dkms-authority/src/main.rs b/capsules/dkms-authority/src/main.rs index 0fef453e..a8659b7d 100644 --- a/capsules/dkms-authority/src/main.rs +++ b/capsules/dkms-authority/src/main.rs @@ -103,6 +103,26 @@ const SESSION_TTL_SECONDS: u64 = 300; /// 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 @@ -597,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 { @@ -819,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", @@ -969,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", @@ -1271,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 @@ -2088,81 +2124,124 @@ fn serve_socket(path: &str) { serve_unix_listener(listener, allowed_callers, operator_vk); } -/// The Unix accept loop, factored out of [`serve_socket`] so it can be driven over a test-owned -/// listener. Each accepted connection is served on its OWN thread so a single idle/slow/leaked -/// client can never head-of-line-block the others (ELACITY-2282): the daemon always returns to -/// `accept`. Revocations are daemon-lifetime state shared across the connection threads. +/// 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)] -fn serve_unix_listener( - listener: std::os::unix::net::UnixListener, +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>, -) { - use std::sync::{Arc, Mutex}; + 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: Arc>>> = Arc::new(Mutex::new(Vec::new())); - for stream in listener.incoming() { - match stream { - Ok(stream) => { - // Insurance (ELACITY-2282 Defect A): bound reads so a stalled/abandoned client can't - // camp its connection thread forever — read_frame then errors and the thread ends. - let _ = stream.set_read_timeout(Some(CONNECTION_READ_TIMEOUT)); - let reader = match stream.try_clone() { - Ok(s) => io::BufReader::new(s), - Err(err) => { - eprintln!("dkms-authority: connection clone failed: {err}"); - continue; - } - }; - let allowed = Arc::clone(&allowed_callers); - let operator = Arc::clone(&operator_vk); - let revoked = Arc::clone(&revoked_callers); - // 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. - std::thread::spawn(move || { - serve_connection_threaded(reader, stream, &allowed, &operator, &revoked, false); - }); - } + 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); + }); } } -/// Per-connection wrapper (ELACITY-2282): snapshot the daemon-lifetime revoked-caller set under the -/// lock, serve the connection, then union any revocations this connection performed back into the -/// shared set (revocations are additive + idempotent, so a union is safe under concurrency and a -/// caller revoked on one connection stays revoked for every future one). -fn serve_connection_threaded( - reader: R, - writer: W, - allowed_callers: &Option>>, - operator_vk: &Option>, - revoked_callers: &std::sync::Mutex>>, - require_channel: bool, +/// 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>, ) { - let mut local = revoked_callers - .lock() - .map(|g| g.clone()) - .unwrap_or_default(); - serve_connection_io( - reader, - writer, - allowed_callers, - operator_vk, - &mut local, - require_channel, - ); - if let Ok(mut guard) = revoked_callers.lock() { - for vk in local { - if !guard.iter().any(|existing| existing == &vk) { - guard.push(vk); - } - } - } + 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 @@ -2194,45 +2273,16 @@ fn serve_tcp(addr: &str) { serve_tcp_listener(listener, allowed_callers, operator_vk); } -/// The TCP accept loop, factored out of [`serve_tcp`]. Like the Unix loop, each accepted connection -/// is served on its OWN thread (ELACITY-2282) so a stalled remote peer can never wedge the daemon; -/// every recover on this hostile transport still REQUIRES the encrypted, mutually-authenticated -/// channel (`require_channel = true`). +/// 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>, ) { - use std::sync::{Arc, Mutex}; - let allowed_callers = Arc::new(allowed_callers); - let operator_vk = Arc::new(operator_vk); - let revoked_callers: Arc>>> = Arc::new(Mutex::new(Vec::new())); - for stream in listener.incoming() { - match stream { - Ok(stream) => { - // NETWORK-FAULT SEMANTICS: bound every read so a stalled peer can't camp its thread. - let _ = stream.set_read_timeout(Some(CONNECTION_READ_TIMEOUT)); - let reader = match stream.try_clone() { - Ok(s) => io::BufReader::new(s), - Err(err) => { - eprintln!("dkms-authority: connection clone failed: {err}"); - continue; - } - }; - let allowed = Arc::clone(&allowed_callers); - let operator = Arc::clone(&operator_vk); - let revoked = Arc::clone(&revoked_callers); - std::thread::spawn(move || { - serve_connection_threaded(reader, stream, &allowed, &operator, &revoked, true); - }); - } - Err(err) => { - eprintln!("dkms-authority: accept error: {err}"); - continue; - } - } - } + 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 @@ -2295,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() }; @@ -2431,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 @@ -2546,6 +2596,86 @@ mod tests { 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"; @@ -3188,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 { @@ -3256,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 _; @@ -3344,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 e7ce5316..d2b05282 100644 --- a/capsules/encrypt-provider/src/main.rs +++ b/capsules/encrypt-provider/src/main.rs @@ -198,8 +198,12 @@ fn default_iv_size() -> u8 { #[cfg(feature = "escrow")] fn decode_kid16(kid_hex: &str) -> Result<[u8; 16], String> { let s = kid_hex.strip_prefix("0x").unwrap_or(kid_hex); - if s.len() != 32 { - return Err(format!("kid must be 32 hex chars, got {}", s.len())); + // 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() { @@ -1606,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/key-provider/src/main.rs b/capsules/key-provider/src/main.rs index 07613130..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 @@ -1842,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, @@ -1852,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 = @@ -1873,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(), + )), } } @@ -1909,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())?; @@ -1919,7 +1960,7 @@ impl KeyProvider { "key-provider timing: reuse warm session ({})", client.endpoint ); - c + (c, true) } _ => { drop(guard); @@ -1930,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 @@ -4177,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/elastos/crates/elastos-server/src/api/gateway_browser_route_tests.rs b/elastos/crates/elastos-server/src/api/gateway_browser_route_tests.rs index 9deee4f4..e009b75d 100644 --- a/elastos/crates/elastos-server/src/api/gateway_browser_route_tests.rs +++ b/elastos/crates/elastos-server/src/api/gateway_browser_route_tests.rs @@ -813,7 +813,11 @@ async fn test_browser_open_launches_engine_with_attached_stream_receipt() { let runtime_stream_path = browser_runtime_stream_socket_path(dir.path(), stream_id).unwrap(); #[cfg(unix)] { - assert!(runtime_stream_path.starts_with("/tmp/elastos-browser-streams")); + // String prefix, not Path::starts_with: the socket dir is euid-scoped + // (`/tmp/elastos-browser-streams-`), which component-wise matching would reject. + assert!(runtime_stream_path + .to_string_lossy() + .starts_with("/tmp/elastos-browser-streams")); assert!( runtime_stream_path.to_string_lossy().len() < 100, "runtime stream socket path must fit conservative Unix sun_path budget: {}", diff --git a/elastos/crates/elastos-server/src/api/gateway_browser_stream.rs b/elastos/crates/elastos-server/src/api/gateway_browser_stream.rs index 4d5f6491..c170ff25 100644 --- a/elastos/crates/elastos-server/src/api/gateway_browser_stream.rs +++ b/elastos/crates/elastos-server/src/api/gateway_browser_stream.rs @@ -860,8 +860,14 @@ fn browser_stream_socket_path(stream_id: &str, directory: &str) -> anyhow::Resul let socket_name = format!("{}.sock", hex::encode(&digest[..16])); // Unix socket paths have a small platform limit. Keep Browser stream sockets // in /tmp rather than platform temp roots like macOS /var/folders/.../T. - let stream_dir = browser_runtime_stream_root().join(directory); - std::fs::create_dir_all(&stream_dir)?; + // SECURITY: on unix that base is the SHARED, world-writable `/tmp`. A fixed directory + // name there could be pre-created and owned by any local user, who could then unlink our + // deterministically-named socket and re-bind their own to MITM or DoS the decrypted media + // stream. Scope the directory name to our euid, and (in `ensure_private_stream_dir`) + // create it 0700-owned-by-us and REFUSE any pre-existing directory we do not own or that + // is group/other-writable. + let stream_dir = browser_runtime_stream_root().join(stream_dir_name(directory)); + ensure_private_stream_dir(&stream_dir)?; Ok(stream_dir.join(socket_name)) } @@ -875,6 +881,69 @@ fn browser_runtime_stream_root() -> PathBuf { std::env::temp_dir() } +/// The base directory name for Browser stream sockets. On unix it is scoped to the euid so the +/// (shared `/tmp`) base is not a fixed name any local user could pre-create and own. +#[cfg(unix)] +fn stream_dir_name(directory: &str) -> String { + let euid = unsafe { libc::geteuid() }; + format!("{directory}-{euid}") +} +#[cfg(not(unix))] +fn stream_dir_name(directory: &str) -> String { + directory.to_string() +} + +/// Create — or validate the reuse of — a PRIVATE per-user directory for Browser stream sockets. +/// Because the base is the world-writable `/tmp`, this is the gate that stops a local +/// attacker from squatting the directory and hijacking the (deterministically-named) socket: the +/// directory is created `0700` and, if it already exists, must be a real directory (not a planted +/// symlink), owned by this process's euid, and NOT group/other-writable — otherwise we fail closed +/// rather than bind a socket carrying decrypted media into a directory someone else controls. +#[cfg(unix)] +fn ensure_private_stream_dir(dir: &FsPath) -> anyhow::Result<()> { + use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _}; + match std::fs::DirBuilder::new().mode(0o700).create(dir) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + // symlink_metadata: do NOT follow a symlink an attacker may have planted in its place. + let meta = std::fs::symlink_metadata(dir) + .map_err(|e| anyhow::anyhow!("stat browser stream dir {}: {e}", dir.display()))?; + if !meta.file_type().is_dir() { + anyhow::bail!( + "browser stream dir {} is not a directory (possible squatting) — refusing", + dir.display() + ); + } + let euid = unsafe { libc::geteuid() }; + if meta.uid() != euid { + anyhow::bail!( + "browser stream dir {} is owned by uid {}, not this process ({}) — refusing to reuse a foreign directory", + dir.display(), + meta.uid(), + euid + ); + } + if meta.mode() & 0o022 != 0 { + anyhow::bail!( + "browser stream dir {} is group/other-writable (mode {:o}) — refusing an unsafe directory", + dir.display(), + meta.mode() & 0o777 + ); + } + Ok(()) + } + Err(err) => Err(anyhow::anyhow!( + "create browser stream dir {}: {err}", + dir.display() + )), + } +} +#[cfg(not(unix))] +fn ensure_private_stream_dir(dir: &FsPath) -> anyhow::Result<()> { + std::fs::create_dir_all(dir) + .map_err(|e| anyhow::anyhow!("create browser stream dir {}: {e}", dir.display())) +} + pub(in crate::api::gateway) fn validate_browser_stream_receipt( receipt: serde_json::Value, ) -> anyhow::Result { @@ -1080,9 +1149,12 @@ mod tests { .and_then(|value| value.as_str()) .unwrap(); - assert!(path.starts_with("/tmp/elastos-browser-adapter-ipc/")); + // The socket dirs are euid-scoped (see `stream_dir_name`) to prevent /tmp squatting. + let adapter_prefix = format!("/tmp/{}/", stream_dir_name(BROWSER_ADAPTER_IPC_TMP_DIR)); + let stream_prefix = format!("/tmp/{}/", stream_dir_name(BROWSER_RUNTIME_STREAM_TMP_DIR)); + assert!(path.starts_with(&adapter_prefix)); assert!(path.ends_with(".sock")); - assert!(runtime_stream_path.starts_with("/tmp/elastos-browser-streams/")); + assert!(runtime_stream_path.starts_with(&stream_prefix)); assert_ne!(path, runtime_stream_path); } @@ -1417,3 +1489,56 @@ mod tests { remote_node.endpoint.close().await; } } + +#[cfg(all(test, unix))] +mod private_dir_tests { + use super::*; + use std::os::unix::fs::PermissionsExt as _; + + fn unique_tmp(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "elastos-priv-dir-test-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + /// The happy path: the directory is created private (not group/other-accessible) and reusing + /// our own directory on a subsequent call is idempotent. + #[test] + fn ensure_private_stream_dir_creates_0700_and_reuses_our_own() { + let dir = unique_tmp("ok"); + let _ = std::fs::remove_dir_all(&dir); + ensure_private_stream_dir(&dir).expect("create private dir"); + let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode & 0o077, + 0, + "a freshly created stream dir must not be group/other-accessible, got {mode:o}" + ); + // Reusing the same (still ours, still 0700) directory succeeds. + ensure_private_stream_dir(&dir).expect("reuse our own private dir"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Regression (security): a pre-existing directory that is group/other-writable — the shape a + /// local attacker would create to squat the shared `/tmp` base and hijack the deterministically + /// named socket — must be REFUSED, not silently reused. + #[test] + fn ensure_private_stream_dir_refuses_a_world_writable_dir() { + let dir = unique_tmp("hostile"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap(); + let err = ensure_private_stream_dir(&dir) + .expect_err("a world-writable stream dir must be refused"); + assert!( + format!("{err:#}").contains("writable"), + "expected a group/other-writable refusal, got: {err:#}" + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/scripts/dev/ddrm-media-authority/src/quorum.rs b/scripts/dev/ddrm-media-authority/src/quorum.rs index 8b812ba8..c408986a 100644 --- a/scripts/dev/ddrm-media-authority/src/quorum.rs +++ b/scripts/dev/ddrm-media-authority/src/quorum.rs @@ -810,11 +810,18 @@ fn read_dash_file(dir: &std::path::Path, rel: &str) -> Result, String> { /// Read a DASH **init** segment from the dir and down-convert it for the server-decrypt rail: /// strip any MPEG-DASH/CENC signaling (`encv`/`enca` -> original, drop `sinf`/`tenc`/`pssh`) so it /// matches the PLAINTEXT init the mint sealed (the seal binds `first_init` BEFORE PSSH injection) -/// AND the clear, `senc`-stripped segments this rail serves. No-op on unsignaled inits (demo / -/// pre-compliance assets); best-effort — a malformed init falls back to the raw bytes. +/// AND the clear, `senc`-stripped segments this rail serves. +/// +/// `strip_cenc_signal` returns the init UNCHANGED for unsignaled inits (demo / pre-compliance +/// assets), so an `Err` here means the init is malformed or only partially CENC-signaled — exactly +/// the case where silently falling back to the raw signaled bytes would bind a NON-canonical init +/// (breaking the seal's plaintext-`first_init` match) and surface downstream as an opaque +/// decrypt/quorum failure. We propagate the precise strip error instead of swallowing it, so the +/// producer-side format fault is diagnosed at the right layer rather than misattributed to the CEK. fn read_dash_init(dir: &std::path::Path, rel: &str) -> Result, String> { let raw = read_dash_file(dir, rel)?; - Ok(ddrm_media::mp4::strip_cenc_signal(&raw).unwrap_or(raw)) + ddrm_media::mp4::strip_cenc_signal(&raw) + .map_err(|e| format!("strip CENC signaling from init {rel:?}: {e}")) } /// A warm quorum open: the CEK is recovered + sealed into the live decrypt boundary. Drives @@ -1472,6 +1479,45 @@ fn reply(out: &mut impl Write, value: &Value) -> Result<(), String> { out.flush().map_err(|e| format!("flush reply: {e}")) } +#[cfg(test)] +mod init_read_tests { + use super::*; + + /// Regression (ELACITY-2282/2283): a malformed or partially-CENC-signaled init makes + /// `strip_cenc_signal` fail; `read_dash_init` must PROPAGATE that error rather than silently + /// falling back to the raw bytes (the old `unwrap_or(raw)`), which would bind a NON-canonical + /// init into the transcript and surface downstream as an opaque decrypt/quorum failure instead of + /// the precise producer-side format fault. + #[test] + fn read_dash_init_propagates_strip_errors_instead_of_swallowing() { + let dir = std::env::temp_dir().join(format!( + "ddrm-init-read-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + + // A well-formed `ftyp` box but NO `moov` → strip_cenc_signal errs ("init has no moov"), + // which the old `unwrap_or(raw)` would have silently swallowed back to the raw bytes. + let mut init = vec![0u8, 0, 0, 12]; + init.extend_from_slice(b"ftyp"); + init.extend_from_slice(b"isom"); + std::fs::write(dir.join("init.mp4"), &init).unwrap(); + + let err = + read_dash_init(&dir, "init.mp4").expect_err("a malformed init must not be swallowed"); + assert!( + err.contains("strip CENC signaling"), + "the strip error must be surfaced precisely, got: {err}" + ); + + let _ = std::fs::remove_dir_all(&dir); + } +} + #[cfg(test)] mod av_selection_tests { use super::*; From 04052ef00a8824d34ba5cf6c56210775b1ec8f67 Mon Sep 17 00:00:00 2001 From: Irzhy Ranaivoarivony Date: Thu, 2 Jul 2026 17:39:02 +0300 Subject: [PATCH 05/16] ci: port verify-ci/verify-capsules justfile recipes The ci.yml on this line (inherited from flint-0.5) invokes 'just verify-ci' and 'just verify-capsules', but the justfile never got the recipes; both CI jobs fail on every run with 'justfile does not contain recipe'. Port the recipes from feat/ddrm-hardening-and-creator-parity, whose feature sets and paths all exist on this branch (verified locally: verify-capsules, alignment-check, command-smoke, candidate-command-audit all pass). --- justfile | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/justfile b/justfile index c7ef1ecf..0bf1ca43 100644 --- a/justfile +++ b/justfile @@ -64,6 +64,35 @@ verify-release: just verify just home-frontdoor-smoke +# CI gate: the full `verify` MINUS the Carrier-network setup smoke (`local-carrier-setup-smoke`), +# which a stock GitHub runner cannot reach. Everything else a clean runner CAN verify runs here; +# the carrier smoke is covered separately on a Carrier-capable Linux box / self-hosted runner. +verify-ci: + just alignment-check + just _verify-tail + +# (hidden) gate steps shared by `verify` and `verify-ci` — everything after the alignment-check +# + (verify-only) carrier-smoke preamble. +_verify-tail: + ./scripts/command-smoke.sh + just candidate-command-audit + cd elastos && cargo fmt --all -- --check + cd elastos && cargo clippy --workspace --all-targets -- -D warnings + cd elastos && cargo test --workspace + just verify-capsules + +# Build + test the dDRM capsule crates the elastos-workspace gate does not reach. These crates +# carry the protected-content surface (watermark codec, grant-digest envelope, media-authority), are +# exercised under their CANONICAL feature sets (matching scripts/dev/run-creator-gateway.sh), and +# gated by build+test only (clippy -D warnings is held back for now: pre-existing lint debt). +verify-capsules: + cd capsules/decrypt-provider && cargo test --features rail-stream,rail-mint,pdf-render,pq-envelope + cd capsules/ddrm-envelope && cargo test --features access-grant,av-variants + cd scripts/dev/ddrm-media-authority && cargo test + # AV forensic cross-language weld: the Python extractor's canonical codeword must match the Rust + # serve selector byte-for-byte (golden vectors on both sides). Pure stdlib — no numpy/ffmpeg. + python3 tools/av-forensics/test_canonical.py + # Fail-closed check for rooted-localhost and Home-first contract drift alignment-check: ./scripts/check-wci-alignment.sh From b419da25486050f5be1d308e5f4c7f6fded8b408 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 20:17:27 +0000 Subject: [PATCH 06/16] Add the Elacity Bible: unified narrative canon for Elacity, ElastOS, and Elacity Labs A narrative and brand document synthesizing the runtime architecture, the ela.city marketplace, and the Elacity Labs mission into one vision: creed, story arc, technical truth layer, category position, language system, and an honest works-today/in-progress/direction ledger reconciled against state.md, PRINCIPLES.md, and the code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NhEEeB9pRzGRGPcy7Lc1sN --- docs/narrative/ELACITY_BIBLE.md | 559 ++++++++++++++++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 docs/narrative/ELACITY_BIBLE.md diff --git a/docs/narrative/ELACITY_BIBLE.md b/docs/narrative/ELACITY_BIBLE.md new file mode 100644 index 00000000..655931da --- /dev/null +++ b/docs/narrative/ELACITY_BIBLE.md @@ -0,0 +1,559 @@ +# THE ELACITY BIBLE + +> Narrative and brand canon for Elacity, ElastOS, and Elacity Labs. This is a +> story and messaging document, not a runtime behavior contract. For current +> proven behavior, see [state.md](../../state.md). + +*In this book, shipped claims are real — you can check the code — and direction is marked as direction. Our engineering law says: "no pretending a feature is supported when it is only half-implemented." The same law binds these words.* + +--- + +## I. The Creed + +*(To be read aloud. Ninety seconds.)* + +We owned our first computers. + +The machine on the desk answered to the person in the chair. Your files were yours because they sat on your disk. Your programs ran on your silicon. Ownership was not a license clause. It was a fact you could touch. + +Then, one convenience at a time, we were moved out. Our documents went into someone else's building. Our names became rows in someone else's database. Our work fed platforms that keep half and can erase us overnight, without appeal. The cloud called it your account. + +It was always their house. + +Now the machines have begun to act. AI agents hold our keys, our money, our words — and they run as tenants in other people's clouds, on credentials that leak by the million. The industry has admitted the hard part out loud: an agent can always be fooled. So the only safety left is an old idea, older than the internet: limit what a fooled servant can touch. + +We are building the house where that is law. + +A computer that answers to you. Where nothing — no app, no browser, no AI — acts without a key you granted. Where every key opens one door, for one purpose, for a limited time, and every turn of every key leaves a receipt. Where your work travels with its own lock, its own rights, and its own till. Where humans and AI live under one law, and the machine's rules are stricter, never looser. Where even the landlord cannot enter the guest's room. + +We publish what is not done yet. We fail closed, then explain. We would rather stop than pretend. + +The internet made you a tenant. + +This is the deed. + +--- + +## II. The Story + +*(This is the heart of the book. It is written to be read, not skimmed.)* + +### The question + +Every era of computing has been an answer to a single question: who does the machine work for? + +For the first thirty years the answer was plain and disappointing. The mainframe was a temple. You did not own a computer; you petitioned one. Your job deck went in through a priesthood of operators, and the machine's loyalty ran upward, to the institution that paid for the raised floor. Computing was something done *to* people — at best, on their behalf. + +Then came the heresy. In 1977 the Apple II shipped, and the phrase *personal computer* stopped being a contradiction. The claim was not about transistor counts. It was moral. Alan Kay's Dynabook, Engelbart's augmentation research, the whole Xerox PARC ferment — these were arguments that a computer should amplify one person, and be owned the way a book or a bicycle is owned: an instrument whose loyalty runs to the hand that holds it. For about fifteen years, the industry believed it. The files were yours because they sat on your disk. Authority and ownership lived together in a beige box on a desk. + +Kay's later verdict — the computer revolution hasn't happened yet — still stands. We shipped the hardware of personal computing without its deeper idea. And he delivered it, with hindsight's cruelty, at the very moment the counter-revolution was gathering. + +### The exile + +The web, which promised to connect personal computers, quietly inverted them. + +The browser began as a window and became a landlord's office. Software moved off your disk and into "the cloud" — which is to say, onto someone else's mainframe. The pendulum swung back to the temple, this time with better fonts. By the 2010s the settlement was complete, and it deserves its plain name: platform feudalism. You farm your own attention on land you do not own. The platform keeps 45 percent of the harvest — that number is YouTube's own published split; other landlords post 30, or steeper. An algorithm change can cut your reach in half overnight, with no warning and no appeal. An account ban is exile without trial. + +The purest architecture of this settlement was ChromeOS — a "computer" that is, by design, nothing but a browser. A terminal to somebody else's machine. + +Understand the trade that got us here, because it was not stupid. The old personal computer had a fatal flaw: every program you ran inherited *all* of your power. Any app could read any file, touch the whole network, act with your entire authority. That is why the PC era drowned in viruses. The cloud fixed the fragility — by confiscating the sovereignty. You got safety as a subscription, in someone else's house, under someone else's law. + +Nobody finished the third option: a machine you own that does not trust its own software. Hold that thought. It is the whole book. + +### The man who remembered + +Every exile story carries someone who remembers the homeland. Here, he is literal. + +Rong Chen arrived in New York on January 4, 1984 — the ARPANET era, the year of the Macintosh — and spent some seven years studying operating systems at the University of Illinois Urbana-Champaign. In 1987 he interned at NCSA, the lab that would soon birth the Mosaic browser, writing code to pull data off Cray supercomputers and draw it on SUN workstations. In 1992 he joined Microsoft, where his credited work runs through the heart of component software — OLE Automation, DCOM, ActiveX. In 1995 he became — by his own count — the tenth member of the Internet Explorer team. + +He was standing at the exact hinge where the browser began to swallow the operating system. + +And in April 2000 he resigned over it — over Microsoft's decision, as he tells it, on the future of the COM component model — and went back to China to build the road not taken: an operating system designed *for the network* rather than for the box. His doctrine, stated in nearly the same words for a quarter century: third-party apps must be sandboxed so they cannot abuse the first-party user's data, and the operating system, as the second party, must provide that secure environment. Apps never touch the internet directly. The network hides beneath them like a computer's internal bus. + +Read that doctrine again. It is a description of ElastOS, written before ElastOS had a name. + +### Too early, twice + +Through the 2000s, in Beijing and then Shanghai, Chen's team built an operating system from scratch — boot loader, kernel, graphics, network stack — around CAR, a C++ component runtime descended directly from his COM work. Ecosystem histories say an Elastos smartphone reached the edge of mass production by 2007; treat that as an attempted commercialization, not a market victory. Foxconn invested roughly 200 million RMB in 2013 for industrial and smart-home work. The system was real. The moment was wrong. An operating system for the network needs something no single company can manufacture: a trust layer that belongs to no one. + +In 2017, Chen found it. Blockchain was the missing piece — not as a casino, but as the neutral root of trust his architecture had always lacked. The Elastos Foundation launched that year; a large ICO followed in January 2018; and then, unusually for that era, the project actually built things: + +- A mainchain **merge-mined with Bitcoin** since August 26, 2018. Bitcoin's own miners secure the Elastos chain as a byproduct of work they already do — an arrangement Satoshi himself sketched in December 2010. The chain's native asset, ELA, is a working part, not a mascot: it settles the Elastos chains and denominates the DAO treasury that funds the work in this book. Its function gets accounted for here; its price never does. +- A **DID sidechain** implementing the W3C decentralized-identity standard: identity as something you hold, not something you are issued. +- **Carrier**, a serverless peer-to-peer network, identity-addressed and encrypted, with no raw IP addresses at its surface. By January 2019 it counted over a million nodes — though honesty keeps the fine print: those nodes were TV-box deployments through a single hardware partner. Distribution, not adoption. + +And then, the stall. The 2018 crash gutted the token. The consumer surface was abandoned in a 2021 pivot toward a wallet. Elastos entered the 2020s as the strangest artifact in the industry: Bitcoin-grade security, standards-grade identity, planetary-scale transport — and no product. + +World-class plumbing for a house nobody had built. + +### The second wound + +Every return story needs a second protagonist with a fresher wound. + +Sasha Mitchell ran 3-D capture work — for Disney, Warner Bros., Universal, and Netflix, as he tells it — digitizing actors' faces, bodies, and performances with photogrammetry and LiDAR. In those capture rooms, the exile story compressed to a single human face. One famous actor described being scanned as having his soul taken. Others asked for a copy of their own avatar — and were told the studio owned it. + +Your likeness. Your work. Your data. Held in a house that is not yours. + +Everything Mitchell has built since is one long answer to that room. In 2021 he founded Elacity inside the Elastos community — a marketplace on the Elastos Smart Chain. In September 2022 he took a rights-management proposal to the ecosystem's own DAO, asking that funds be released only against delivered work. The elected council passed it unanimously, twelve to zero — the vote sits in the DAO's public record. By January 2024 Elacity's decentralized rights system was live and commercial, and the marketplace could do something genuinely new. A creator uploads a work. The work is encrypted into a **Digital Capsule**. And what is sold is not a copy but a key — access, royalties split to a tenth of a percent and paid in the instant of sale, resale terms written into the asset itself. Elacity's published fee is 2 percent. The platforms it replaces publish cuts of 30 to 55. + +The NFT era had just died of a specific disease — roughly 96 percent of collections dead, by one widely reported study — and its autopsy reads in three words: receipts without locks. Tokens that asserted ownership of content nothing enforced. A Capsule is the answer to that autopsy: the work travels sealed, with its rights and its payment built in. + +### The joining + +By early 2025 the two arcs — the exiled architect and the wounded craftsman — converged into one plan. On January 31, 2025 the Elastos community approved the World Computer Initiative — proposal and vote on-chain, like every mandate in this story: turn twenty-five years of hidden infrastructure into a computer a person can actually hold. Elacity Labs — Mitchell as CEO, Anders Alm as CTO — forked the open-source internet OS Puter into **PC2**, the Personal Cloud Computer: your files, identity, wallets, and AI on hardware you control, reachable from anywhere. And beneath it they began the deeper work: a from-scratch, capability-secured core — the **ElastOS Runtime** — written in Rust, built to be small enough to reason about and strict enough to deserve trust. + +On February 10, 2026, Elastos World Computer V1 launched, and Rong Chen put three million dollars of his own conviction behind it — the "Keystone Gift," announced with the launch, held in DAO custody, released tranche by tranche only by public on-chain vote. The official launch announcement carried the most honest sentence in the project's history: + +> "For the first time in eight years, Elastos has a working core product." + +The dream never changed; the missing pieces arrived in stages. The 1980s gave the scholarship. The 1990s gave component software. The 2000s gave a full OS, built too early. 2017 gave the trust layer. 2024 gave the rights engine. And 2026 gave what none of the pieces could be alone: a shipping product line, a live marketplace, and a hardening sovereign core — funded not by a venture round but by the ecosystem's own treasury, in public, vote by vote. + +### The agent age + +Then the world changed shape again, and a twenty-five-year-old doctrine stopped being philosophy and became emergency response. + +In 2025 and 2026, AI agents arrived at consumer scale — and their first mass deployment was an uncontrolled experiment in ambient authority. OpenClaw, a viral open-source personal agent, gathered 135,000 GitHub stars in weeks — the counter is GitHub's own. Within days, published security scans counted roughly twenty-one thousand instances exposed on the open internet, leaking the API keys and OAuth tokens their owners had handed them, while hundreds of malicious skills seeded its marketplace. Cisco's headline said it plainly: personal AI agents like this are a security nightmare. The public CVE registry logged more than thirty entries against the agent-tool protocol MCP in the first two months of 2026 alone. Industry surveys put agent over-permissioning near nine in ten. Secret-scanning firms counted credentials leaked into public code by the tens of millions in a single year. + +And then came the concession that reframes everything. OpenAI's own security chief called prompt injection a frontier, unsolved problem — one unlikely ever to be fully solved. The admission is public and on the record. + +If an agent's *inputs* can always be poisoned — if some sufficiently clever string of text can turn your assistant into an adversary's — then the input side of agent safety is lost, by the industry's own admission, permanently. The only durable defense is on the *output* side: bounding what a fooled agent can do. Scoped. Expiring. Revocable. Audited. Fail-closed. + +There is a name for that discipline. It has run like recessive DNA through computer science since 1966 — capability security — and it is the exact thing Rong Chen bet his career on in 2000, and the exact thing Elacity Labs has been compiling into Rust ever since. Even Microsoft now agrees with the diagnosis: at Build 2026 it reframed Windows around per-agent identity and containment. But look where Microsoft roots the authority — in its own cloud tenant, with your agents' workspaces offered for rent. The feudal answer to the agent age is to make your AI a tenant of their house. + +Your AI works for whoever holds its credentials. + +So hold them. + +### Where the story stands + +The homecoming is not finished, and this book will not pretend otherwise. The runtime is pre-release; its own ledger calls version 0.5.0 a review candidate, not a release. The browser surface has not yet passed its own product proofs, and the team says so in public. The sovereign rights pipeline ships deliberately refusing to run until its backends are real. The house is framed, wired, and inspected — and the builders have nailed their unfinished-work list to the front door. + +But the direction is no longer slideware. It is code that fails closed rather than promising open. The exile lasted a generation. The door, at last, is being hung on its hinges — and it is yours. + +--- + +## III. What We Are Building + +### One architecture, three surfaces + +We build one thing that shows three faces. + +**ElastOS** is the house: a sovereign operating layer for a person's whole digital life. Two codebases carry that name today, and we keep them honest. The shipped ElastOS product line — launched as Elastos World Computer V1 in February 2026, carrying desktop, personal storage, private AI, and wallets — runs on the earlier PC2 lineage. Beneath it, built from scratch in Rust, is the **ElastOS Runtime**: the capability-secured core this book describes, pre-release today. The rule of the handover is written down: the new core inherits PC2's protocol boundaries and its acceptance tests, never its monoliths. A hosted runtime lives at elastos.elacitylabs.com for anyone who wants to touch it. + +**Elacity** is the market: ela.city, a live marketplace where creators encrypt work into Digital Capsules and sell keys instead of copies. Walk the shop as it runs today. A creator uploads a work and seals it into a Capsule. She lists the keys — per Elacity's published mechanics, three instruments deep: access tokens, minted from one to billions; royalty tokens, where a thousand tokens is one hundred percent of a work's revenue, splittable to a tenth of a percent; distribution tokens, writing resale terms into the asset itself. A fan buys in an ordinary checkout, and the work streams in Elacity's player, unlocked by the fan's key. The royalty split pays every named hand in the instant of the sale. Channels and subscriptions bundle ongoing access. The published fee is 2 percent. All of it runs today on Elacity's existing rights stack, while the runtime's stricter fail-closed pipeline is built to receive it. + +**Elacity Labs** is the workshop: the company, led by Sasha Mitchell and Anders Alm, that builds both. It is a separate company from the legacy Foundation, and its money arrives in public: on-chain DAO mandates, every proposal published, every vote recorded, funds released tranche by tranche against delivered work — the same custody discipline that holds the Keystone Gift. Note what that structure does to truth-telling. A workshop paid only for proven work cannot afford overclaims, because an unproven claim is an unpaid tranche. The honesty discipline running through this book is not a virtue we advertise; it is the funding model we live under. + +One sentence holds them together: the market gives the house a living economy, and the house gives the market a floor that cannot be seized. + +And one note on chains, stated once so every later mention can be exact. Elacity's marketplace grew up on the Elastos Smart Chain — a sidechain with its own consensus, anchored to the Elastos mainchain that Bitcoin's miners merge-mine. The runtime's one proven live purchase path today runs against contracts on Base; the repository's own smoke tests pin that chain id, and the roadmap lists Base, ESC, and EID side by side as proof adapters. The direction, marked as direction: chains are providers behind one interface. No chain is the login. No chain is the lock. + +### The four quadrants + +The system balances across four planes. Each is defined as much by what it must never become as by what it does. + +- **Home** — the human front door: your desktop, your Library, your people, your apps. It must never become policy logic or protocol plumbing. +- **Runtime** — isolation, verification, identity, keys, audit. It must never become app business logic, a social bridge, a wallet app, or a storage backend. +- **Carrier** — the roads between houses: an authenticated peer-to-peer plane for messages, objects, and streams. It must never become raw gossip exposed to apps, and it never replaces keys. It is the one piece that cannot be an app, for a bootstrapping reason with teeth: a capsule can't provide the transport needed to download itself. +- **Blockchain** — the land registry: identity anchors, provenance, publisher identity, settlement — anchored by the Bitcoin-merge-mined Elastos mainchain, with other chains attached as adapters. It must never become the app database or a mandatory gate on ordinary use. It is deliberately last in the build order: wallets and DIDs are proofs attached to a person's local authority — never the login itself. + +Every effect in the system, local or remote, compresses to one line: + +**capsule → runtime capability → provider plane → object or service.** + +Whether the target is a file on your disk or a peer across the planet, the sentence is the same. That single sentence is the operating system. + +### The house rules, in plain words + +A few terms of art, each defined once, cleanly. + +A **principal** is an owner of authority — a person, or an agent a person created. + +A **proof binding** is a way of proving you are that principal: a passkey, a wallet signature, a decentralized identifier. The doctrine, verbatim from the wallet's own documentation: "A wallet address is a proof binding on a Runtime principal, not the principal itself." You are not your wallet. Your wallet is one of your keys to being you. + +A **capsule** is sealed software — or sealed content. Signed, sandboxed, and born with nothing: no network, no files, no powers it did not ask for in writing. + +A **capability** is the key. Not a badge that gets you past every guard, but a key cut for one lock: one resource, one action, a limited time, a counted number of uses, revocable at any moment. Lending is bounded like a physical key — a delegated capability can only open *fewer* doors than its parent, and can never be lent onward. + +A **provider** is a licensed specialist: wallet, chain, content, rights, key-release, decrypt, network exit. Ordinary apps are forbidden by manifest law from even requesting raw power; providers earn their exception by declaring, in their signed manifest, why they hold it, exactly which operations they expose, and which audit events they emit. Authority here is declared, scoped, and inspectable — per capsule, in writing. + +**Objects come before apps.** A photo is not `~/Photos/IMG_001.jpg`. It is a thing you own, with identity, provenance, and access control. Apps don't own content; they view it. Users open objects; the runtime picks the viewer. (Home as a full object browser is direction, marked as such — today Home launches apps, and the object model is being built underneath it.) + +**Two namespaces carry the world.** `localhost://` is your local sovereign machine world — your rooms. `elastos://` is the shared world: identities, peers, and content addressed by what it *is*, not where it sits. The content is the identity — not the address. A gateway URL is convenience transport, never truth; content fetched from anywhere is verified against its own hash and signature before it is allowed to exist for you. + +And the inversion that names the whole era: **the browser is a capsule, not the platform.** ChromeOS put the computer inside the browser. ElastOS puts the browser inside a computer you own — one sandboxed viewer among many, dangerous and treated as such, with no ambient off-box network of its own. + +### The technical truth layer + +For the reader who trusts nothing but code, here is what is actually built, in the repository, today. + +The capability token is real cryptography: an Ed25519-signed structure binding a specific capsule, an issuer key, a resource pattern, an action, and constraints — expiry, revocation epoch, per-token use limits, delegation flag. Its byte layout is length-prefixed field by field so no two distinct tokens can collide under signature, with regression tests proving it. Every use passes **twelve validation checks in strict sequence** — and every failure, at every check, emits an audit event. Use counting is atomic: a concurrency test fires twenty simultaneous validations at a use-limited token and asserts that exactly the permitted number succeed. Enforcement is wired at every bridge a capsule can reach — the microVM channel, the HTTP handlers, the shell protocol — not decorated onto one path. + +Isolation is not one sandbox but three, behind one contract: WebAssembly (fuel-metered, memory-capped, every guest pointer bounds-checked), Linux microVMs on KVM — where, if hardware virtualization is absent, the launch *fails* rather than degrading to something weaker — and macOS microVMs through a ten-thousand-line hand-written binding to Apple's virtualization framework. Same token, same wire protocol, same guest-visible world — with macOS proven today for browser-VM workloads, not yet at full parity as a general capsule substrate. The security model is built to outlive any particular sandbox technology. + +The agent is a first-class citizen under the same law. The repo's agent capsule holds its own persona DID — cryptographically distinct from its human owner's, with the owner recorded; a test literally asserts "Persona DID must differ from owner." It signs every message it sends. It verifies signatures on every message it receives *before* its language model is ever invoked — unsigned input never reaches the AI. And when AI participates in authority decisions, the split is absolute: an advisory proposer may suggest a grant; the deciding verifier is bound by its own trait contract to be deterministic — "no async, no network, no LLM" — and it can only tighten a decision, never loosen one. A shadow verifier runs in parallel and logs every disagreement, an evaluation harness built before the AI is trusted with anything. + +The wallet states its red lines in its own documentation: "No address-only login. No wallet-address-derived encryption keys. No arbitrary signing. No app-visible wallet RPC." There is no `sign(data)` operation anywhere — only typed intents that route through human approval. Apps never hold keys; they hold narrowly scoped permission to ask. + +The protected-content path — the runtime's rights machinery — is a chain of receipts. Opening a sealed object takes eight steps, and each step is gated by the signed receipt of the one before: the key provider refuses release unless it holds a rights receipt bound to the exact same content, person, session, and right; the decrypt sandbox refuses without a matching release receipt; and the release receipt, by construction, carries zero key material — "a receipt, not a key carrier." Every wire structure rejects unknown fields, so a request smuggling a raw key fails to even parse. The component that briefly sees a live content key is designed to hold the smallest possible authority, use it, and erase it. + +And here is the sentence that makes all of the above believable: **every rights, key, and decrypt backend in the runtime today returns `not_configured` — on purpose.** The rights provider's own status string is `fail_closed_until_policy_backend_configured`. No decryption happens in this repository yet, and the code says so about itself, at runtime, in a machine-readable voice. The boundary is proven first. The economics are wired second. That ordering is the discipline. + +The accounting is equally plain. About 1,700 automated tests across the core crates as of June 2026, including hash-collision specs and permission-bit assertions. A trusted core of roughly sixteen thousand lines against a five-to-seven-thousand-line target the architecture document itself flags as not yet met — beside a server binary of roughly 145 thousand lines that the end-state design says must move outward. An audit log that is comprehensive, runtime-owned, and append-only — with tamper-evident chaining explicitly labeled "later." Cryptographic envelopes that *require* hybrid post-quantum algorithm listings as policy, while the running ciphers today remain classical. No third-party audit yet; this system can cite the capability-security lineage, not borrow its proofs. + +Most systems ask you to trust the adjectives. This one hands you the checklist of what it refuses to do. + +--- + +## IV. Why It Matters + +### Property: the largest pool of dead capital ever created + +The economist Hernando de Soto showed that poverty is often not a shortage of assets but a shortage of *title*. A house that cannot be deeded cannot be mortgaged, sold at distance, or divided into shares. The asset is real; the capital is dead. His remedy was not new assets but a representation system — registries, deeds, receipts — boring bureaucracy that makes ownership legible and enforceable. + +Digital content is the largest pool of dead capital ever created. A song, a film, a dataset, a scan of an actor's face — each is an asset, and almost none of it is capital. A file carries no title. It is infinitely copyable, so it cannot be scarce. It has no enforceable rights attached, so it cannot be licensed without a platform standing in the middle. Its future income cannot be divided, pledged, or sold forward by the person who made it. The creator economy's answer has been tenancy: park the asset inside a platform and accept the rent. + +A Digital Capsule is a titling system for digital property, with the bureaucracy implemented in cryptography. Strip the branding and you find instruments any property lawyer would recognize. The deed: a content identifier plus the creator's signature — self-authenticating from any source. The lock: encryption of the payload itself, so the sealed bytes can sit in a public square; access is enforced by rights checks and key release, not by hiding where the file lives. The registry: rights recorded on a chain rather than in a platform's private database. The recorder of deeds: signed receipts at every transfer of authority. And the split: royalty tokens that make a work's revenue division explicit and programmable — a collaborator paid in a recorded share of the work's own revenue rather than in promises, with the division executing itself at every sale. + +That last instrument is the quiet revolution. The NFT era gestured at it and failed, because it sold receipts without locks. A Capsule binds the token to the lock. And the doctrine underneath is worth underlining, because it inverts a lazy assumption: **enforcement is not the enemy of openness; it is the precondition of markets.** Mitchell's own analogy is the honest one — your home is private property, and being able to protect it is precisely what lets you rent it out. + +One division of labor makes the whole design legible: **availability stores bytes; rights decide who may use them.** Storage becomes a commodity anyone can supply, evidenced by signed receipts. Rights remain property, held by the creator. + +### The access economy: what happens to the 45 percent + +Why do the platforms' published cuts run from 30 to 55 percent? Not from malice — from function. A platform is a trust factory. It verifies content, custodies it, enforces access, collects payment, splits revenue, adjudicates disputes. The take-rate is the price of manufactured trust, and as long as only a firm can manufacture it, the firm's rent expands to match. + +The Capsule architecture moves those functions out of the firm and into the object. Verification: the content proves itself, by hash and signature, from any transport. Enforcement: key release is mechanically bound to a rights check, not to a moderator's mood. Settlement: royalty splits execute inside the purchase itself, instantly, to every named hand. Record-keeping: signed receipts, produced once by code instead of forever by staff. What remains for a marketplace to sell is discovery and experience — real services, thin ones, priced by competition. That is how a 2 percent fee is a business model rather than a subsidy. + +Say it plainly: Elacity cannot charge 45 percent, because it no longer performs the functions that justified 45 percent. + +### The agent economy: commerce needs a constitution + +The strongest argument for this architecture arrived from outside it. + +In 2025 and 2026 the industry built payment rails for AI agents — checkout protocols from the largest labs and card networks, crypto rails settling tens of millions of machine payments — while simultaneously demonstrating, in public, that agents cannot be trusted with credentials. Exposed instances by the tens of thousands. Protocol CVEs by the dozen. Nine in ten agents over-permissioned. The rails all assume the agent lives *somewhere* trustworthy, holding keys that mean something. Nobody built the somewhere. + +Machine commerce has a specific economic shape. Transaction costs fall toward zero, so volume explodes, so per-transaction human oversight becomes impossible. Which means authorization must become **a fixed cost per grant, not a marginal cost per transaction** — decided once by a human, enforced mechanically a million times. That is precisely what a capability token is. + +And when the counterparties are machines, receipts stop being paperwork and become the substrate of liability. An agent with your session cookie cannot be a counterparty. A payment without a rights receipt is a tip, not a license. Accountability without signed audit is a subpoena, not a system. The receipt chain in the runtime — rights receipt, key-release receipt, decrypt session, audit event, each bound to the last — is what a machine-speed license needs to be disputable, auditable, and one day insurable. + +To be exact about today: ElastOS does not interoperate with any of the agent-payment rails, and no end-to-end agent purchase runs inside the runtime yet. The rails are context, not integration. The claim is narrower and stronger: the rails built the roads and the money; the authority seat — where the agent lives, holds its keys, and is governed — stands empty. That seat is the product. + +### Why now, in three beats + +One. Agents arrived at consumer scale — the demand is proven, loudly. + +Two. Their credential model failed in public, with numbers attached — exposed servers, leaked secrets, over-permissioned by default. + +Three. The incumbents conceded the input side cannot be fixed — prompt injection, in their own words, may never be fully solved. So the only durable defense is bounding what a fooled agent can do: explicit, narrow, revocable, audited, fail-closed authority, at the operating-system level, owned by the person. + +We did not manufacture the crisis of rented computing. We were, improbably, already building the answer when it arrived. + +--- + +## V. How We Build + +### The law of the house + +The repository opens with seventeen principles and calls them "the set of constraints that should decide ambiguous implementation choices." Not a roadmap — a constitution. The spine of it: + +**Local first.** "Public exposure is layered on top of local truth, not the other way around." Your machine is the primary world; the internet is an adapter. + +**No ambient authority.** Nothing acts because of where it is running. Missing authority fails closed. "Opening a page and holding a capability are different things" — screen position, routes, and DOM presence are never power. + +**One canonical path per operation.** No silent fallbacks, no hidden alternate routes. When the intended path is not ready, the system says so instead of quietly downgrading. + +**Trust travels with signed content.** Hashes, signatures, and identifiers anchor trust — never gateway locations or host paths. And "encrypted content should be normal, not a special exception." + +**Docs, code, tests, and ops must agree.** "The architecture is only real when the repo surfaces teach the same contract. Drift should be treated as a bug." A system that lies about its own state — even by omission, even by soft fallback — is broken. + +And when two choices both work, the Decision Rule breaks the tie: prefer the one that strengthens local and content identity, reduces ambient authority, removes hidden paths, keeps the trusted core smaller, and makes the user's mental model clearer. Sovereignty, minimalism, and legibility are the tiebreakers for every ambiguous call. + +### Honesty as engineering + +Most projects treat honesty as a virtue. This one treats it as a primitive — architected into files, status strings, and CI. + +The public state ledger records proven truth only, and reads like a confession: "0.5.0 is a review candidate, not a release tag." "Product Browser completion is not claimed." The marketplace's Install button is honestly disabled, and says why on its face: "Signed install pending." Providers announce their own incompleteness at runtime, in machine-readable voice: `fail_closed_until_policy_backend_configured`. The security file publishes *open* vulnerabilities, for transparency. The checklist culture is explicit: "if a story is not proven, hide or demote the surface instead of overclaiming." + +This is not modesty. It is the same property the runtime enforces on software, applied to speech: fail closed, then explain. In a market drowning in vaporware agent operating systems, verifiable self-criticism is the scarcest luxury good — an expensive signal no competitor can fake without first surviving it. + +An engineering culture that refuses to lie to its users starts by refusing to lie to itself. + +### One law for humans and machines + +Principle seven is the charter: "Humans, bots, and AI should not get separate magical trust systems." If humans and agents live under different laws, the agent path becomes the bypass. Symmetry is a security property. + +So the authority chain is identical for a person clicking a button and an agent calling an API: principal → verified proof → short-lived session → scoped capability → provider-mediated effect → signed audit. And where the industry lets automation run looser than people, this house inverts it, in five words that should be carved above the door: **automation gets more explicit, never more ambient.** + +In practice: an agent is a delegated principal a human creates and can revoke. It has its own name — a DID cryptographically distinct from its owner's, with the ownership recorded where everyone can read it. It signs what it says. It verifies what it hears before its model ever runs. It holds narrower grants than its human, not broader ones. High-risk acts — signing money, exporting recovery material, installing providers — route to a human's explicit approval. Agents never borrow human cookies, never automate a person's real passkey. And the house's own advisory AI may only propose; a deterministic verifier decides, and can only tighten. + +Every culture tells the warning story twice: the golem that serves while the true word is written on it, the genie that grants exactly what is asked, catastrophically, because a wish is an unbounded grant. 2026 supplied the modern telling, at scale, with CVE numbers. Our answer is the grammar of the old stories: the servant is named, bounded, and watched — and safe to keep. + +If the servant can always be tricked, safety lives in what the servant is permitted to touch. + +### The guest room + +The oldest law of the house is hospitality. In archaic Greece it was sacred before writing was common: the host owes the guest protection *even from the host himself*. + +ElastOS encodes that law in cryptography, and states it as an engineering requirement: "Guest privacy must be real, not courtesy UI." The first passkey on a machine becomes the admin — but guests enroll themselves, and "the admin controls the enrollment policy but does not create or hold the guest's authenticator." Each guest gets their own principal, their own encrypted root, wrapped only to protectors the guest holds. The radical clause follows, quoted with its own exceptions intact rather than improved for effect: the admin may operate the runtime, but should not be able to decrypt a guest's personal root without that guest's explicit recovery, sharing, legal or operator policy, or a future threshold authorization path. The mechanics to make that structural are being built. "Passkey removal revokes access, not storage." And the starkest sentence in the roadmap: "If every protector is lost, encrypted data should be unrecoverable by design rather than silently accessible through a device-global bypass." + +Data loss, preferred to backdoors. That is a moral position expressed as key management. + +The guest also keeps the ancient right of departure: export your recovery material, migrate your encrypted root to your own machine. The right of exit is what makes staying a choice. + +Status, stated plainly: this covenant is design doctrine with partial coverage today. Selected state lives under protected envelopes; some — notably browser-VM profile disks — does not yet, and the repo tracks that gap in public, alongside a live obligation: "keep proving admins never receive guest authenticator, recovery phrase, or principal data-key material." A host who posts his unfinished obligations on the door is practicing hospitality already. + +### Economics last, on purpose + +The build order is moral, not just tactical: principals, then packages and interfaces, then availability, then protected content, then — only then — economics. The repo's own sequencing rule defers rich DRM economics, token mechanics, and DeFi integrations "after principals, packages, interfaces, availability receipts, and spaces are real." Publishing must mean availability, not just minting an identifier — and payment incentives come only after receipts, quotas, and abuse controls exist. "Do not call a single pinning service decentralized storage." + +Economics are the roof, never the foundation. Every crypto-era failure you can name poured the roof first. + +--- + +## VI. The Category and the Position + +### The name + +Markets file new things under the nearest familiar label, and every label near us is a grave. So we name the ground ourselves: + +**The sovereign runtime** — a computing layer you own, where every actor, human or AI, acts on explicit, revocable, audited authority, and where property carries its own lock, rights, and till. + +Why "runtime" and not "OS"? Because the name must fail closed too. The repo is pre-release; the browser proof is open; the docs admit the core still carries more than its end-state weight. "Runtime" claims exactly the layer that exists in code — signed capsules, capability tokens, one authority model, three isolation substrates. When the OS is earned, the category grows into it. + +And "sovereign" answers the one question every competitor answers wrong: *whose root?* Microsoft — the closest, best-funded neighbor — cannot say this word. Its agent identities root in its own cloud tenant; its agent workspaces are rented by the month. The company with the resources to contest the category is structurally disqualified from its defining word. + +### The traps we refuse + +Six categories would bury us, and we decline them all. Not a *crypto project* — judge the Rust, not the token; the chain is one provider among many, and passkeys, not wallets, are the front door. Not an *NFT marketplace* — that era sold receipts without locks, and we are the autopsy's answer, not its sequel. Not *DePIN* — the invention here is the authority model, not node incentives. Not a *personal server* — those are real and niche, and increasingly they host agents with no authority model at all. Not an *AI OS* — the label is 2026's loudest vaporware, and our own honesty discipline couldn't survive it. And never, in self-description, a *DRM company* — centralized DRM is the incumbent's word for the incumbent's cage. What we build is creator-controlled rights: the encrypted bytes may be public; the receipts carry no keys; the lock belongs to the maker. + +### The enemy + +One enemy, named calmly, like a diagnosis: **rented computing.** + +Its mechanism is ambient authority — the god-scoped token, the master badge, the agent with your whole life in its environment variables. Its economics are platform feudalism — the 30-to-55-percent cuts the platforms publish themselves, the overnight demonetization, the ban without appeal. Its newest face is the rented agent — your AI, tenant of someone else's cloud. + +We do not wage crusades against companies; houses hold ground, they don't march. Microsoft's move validates the category and defines its ceiling. Our position needs one line: they rent your agent a room in their cloud; we hand you the keys to yours. + +The other neighbors get one calm line each. Urbit asked people to move to a new world; we secure the one they already live in — passkeys, files, browsers, wallets. Apple keeps your data private on its silicon — under its root and its store. Self-hosted agent stacks put owned hardware under ambient authority; OpenClaw taught that lesson at scale. + +### From → to + +**FROM ambient authority on rented computers TO explicit authority on owned computers.** + +For builders: from credentials to capabilities. For creators: from platforms that hold your work to Capsules where the rights and the royalties are properties of the work itself. For the agent age: from AI as a tenant of a vendor's cloud to AI as a named, bounded member of your household. + +### The point of view, in one breath + +AI agents arrived at consumer scale and broke the internet's authority model on arrival. The input side can never be fully secured — the incumbents say so themselves. So the only durable defense is bounding what a fooled agent can do. Every incumbent answer draws that boundary inside its own cloud. Your agents become tenants. Your rights become rows in someone else's database. The sovereign runtime is the opposite root: a computer you own. One explicit, revocable, fail-closed law for humans and AI. Property that carries its own rights and pays its own maker. + +### One message per audience + +Each audience gets one message, one proof, one ask. Resist the urge to show everyone everything. + +**The Elastos faithful.** Message: the twenty-five-year dream finally has a working core — Rong Chen's 2000 doctrine is now enforced Rust, not a whitepaper. Proof: the Keystone Gift, and the official line — "for the first time in eight years, Elastos has a working core product." Ask: participate in the DAO mandates; every proposal public, every vote on-chain. + +**Creators.** Message: you don't post your work, you keep it — and sell the key, with the rights and the royalties traveling inside the work itself. Proof: the live ela.city pipeline — sealed Capsules, instant royalty splits to a tenth of a percent, a published 2 percent fee against the platforms' published 30 to 55. Ask: publish one piece of protected work this week. + +**Developers.** Message: capabilities, not credentials — deny-by-default in the lineage of the great capability systems, extended to a whole personal computer, agents included. Proof: the code — twelve checks per use, every failure audited, about 1,700 tests, delegation that can only narrow. Ask: clone the repo, run the smoke tests, read the principles — then try to find where we lied. + +**Agent builders.** Message: OpenClaw proved people want a personal agent; we make wanting it survivable. Proof: the agent capsule — own DID, signed speech, verification before inference, scoped grants, human approval on high-risk acts. A compromised agent here has a blast radius the size of its token, not the size of your life. Ask: run the agent against a local model and read its audit trail. + +**Investors.** Message: Build 2026 validated OS-level agent authority; we are the self-custody counterpart, holding the one intersection no one else holds — owned substrate, one human/AI authority model, native rights and payments, peer-to-peer transport. Proof: a shipping cadence you can audit commit by commit, DAO-funded runway, and a documentation culture that makes every claim checkable. Ask: a working session on the hosted runtime, with the state ledger open in the next tab. + +**Mainstream creators and families.** Message: your things, in your house, under your key, reachable from any screen you trust — and money that arrives without a landlord. Proof: the fee schedule is public — Elacity keeps 2 percent where the platforms' published cuts run 30 to 55 — and royalties arrive in the instant of the sale. Ask: a five-minute onboarding — upload one file, watch the agent fill in the listing. Never "join Web3." Sell outcomes; sovereignty is the mechanism, never the pitch. + +--- + +## VII. The Language + +The codebase already has a voice — plain, exact, quietly stubborn. "Fail closed, then explain." "Make Home a boring front door." The writing must sound like the code, because the whole promise is that the words and the system tell the same truth. + +### The voice, in five rules + +**1. Short old words.** Key, lock, own, house, rules, keep, prove, refuse. Not "leverage," not "utilize," not "ecosystem synergies." When a Latin word and a Saxon word compete, the Saxon word wins. + +**2. One picture per idea, and the picture never changes.** A *key* is a capability. A *receipt* is an audit record. A *sealed package* is a Capsule. A *permission slip* is an agent's grant. The *house* is your runtime. A *tenant* rents; an *owner* holds the deed. Every abstraction in the stack maps to one of these, and the mapping is permanent. Where the mechanism ends, the poetry stops — a metaphor may describe direction only when it is flagged as direction. + +**3. Short sentences. Declarative.** The system fails closed; so do our sentences. If a sentence needs a semicolon to survive, it is two sentences. + +**4. Zero crypto jargon by default.** Blockchain, token, DID, CID, DRM — these appear only for audiences that already use them, and even then after the plain version. The test: would a filmmaker, a teacher, or a fourteen-year-old know what we mean? The origin story — an actor told the studio owned his own face — needs no jargon. Neither does the product. + +**5. Quiet confidence.** No exclamation marks. No "revolutionary." People who have the goods don't shout. The strongest sentence in the canon is understated: "For the first time in eight years, Elastos has a working core product." + +### Master lines + +The roof line, then the catalog. Each line earns its place by being true today or marked as direction. + +- **A computer that answers to you.** — The roof. Homepage, keynote close. +- **Own the room your AI works in.** — The agent-era master line. +- **Your work. Your keys. Your terms.** — Creator-facing roof line for Elacity. +- **Your things, in your house, under your key.** — The object model for ordinary people. +- **Reachable from any screen. Owned from exactly one place.** — The personal cloud, said without the acronym. +- **Nothing moves without a key. Every key leaves a receipt.** — Capability plus audit, for anyone. +- **A key, not a badge.** — The whole capability doctrine in five words. +- **No ambient internet.** — Developers and security audiences; it is literally the doctrine's own phrase. +- **The AI may propose. Only deterministic code decides.** — Devastating because it is quotable *and* compiled. +- **Automation gets more explicit, never more ambient.** — The inversion of the industry default. +- **The content is the identity — not the address.** — Content addressing without saying CID. +- **Fail closed, then explain.** — The engineering brand, three words and a comma. +- **We publish what is not done yet.** — The honesty signature, stated as a promise. +- **The lock, the rights, and the till travel with the work.** — Capsules, for creators and press. +- **They sold receipts without locks. A Capsule is the lock, the rights, and the payment in one object.** — For audiences digesting the NFT winter. +- **The browser is an app here, not the landlord.** — The inversion, for consumers. +- **Your AI works for whoever holds its credentials. So hold them.** — Talks, threat-model writing. +- **They rent your agent a room in their cloud. We hand you the keys to yours.** — The Microsoft sentence. +- **Judge the Rust, not the token.** — The crypto-baggage sentence. +- **Sovereignty should feel boring.** — Design philosophy; pairs with "Make Home a boring front door." +- **The internet made you a tenant. This is the deed.** — Manifesto register. Use sparingly; it must be earned by the honesty around it. + +### The elevator + +**Ten words.** A computer you truly own — where even AI needs permission. + +**Thirty words.** ElastOS is a sovereign runtime — a computing layer you own — where every app, browser, and AI agent acts only on explicit, signed, revocable permission. Your files, keys, rights: yours. The proofs are published. + +**One hundred words.** Every platform you use holds your keys: your files, your logins, your audience, and now your AI's credentials. Elacity builds the alternative — ElastOS, a sovereign runtime where authority is never ambient by design: every app, browser session, and AI agent acts only on an explicit, signed, auditable, revocable permission, under the same rules for humans and machines. Alongside it, Elacity already runs a live marketplace where creators seal work into Capsules and sell the keys directly — today on Elacity's existing rights stack, with the runtime's stricter fail-closed pipeline built to receive it. The security core is built, open, and tested. The rest we publish honestly: including what is not done yet. + +**Three hundred words.** In 2026 the industry admitted two things. AI agents at consumer scale hold catastrophic ambient credentials — exposed servers by the tens of thousands, leaked keys, thirty-plus CVEs against the standard agent-tool protocol in two months, all of it public record. And the input side can't be fixed: OpenAI's own security chief says prompt injection may never be fully solved. If an agent can always be tricked, the only durable defense is bounding what a tricked agent can do. + +That is what Elacity builds. ElastOS is a sovereign runtime — the core of a personal operating system — with one law: no ambient authority. Every app, browser session, and AI agent acts on cryptographically signed, narrowly scoped, revocable permissions — checked twelve ways on every use, with every use and every refusal logged. Humans and AI agents live under the same law; an agent gets its own identity, owned by a person, and its permissions are more explicit than a human's, never more ambient. The AI may propose; only deterministic code decides. This core is real today: open source, running on three isolation substrates, with about 1,700 automated tests. + +Alongside the runtime, Elacity runs a live marketplace, ela.city, where creators encrypt work into Capsules and sell access directly — rights, royalties, and resale rules traveling with the work itself, recorded and settled on-chain. It runs on Elacity's existing rights stack today; the runtime's fail-closed pipeline is being built to receive it. The founder spent years digitizing actors for Hollywood studios and watched them learn the studio owned their own likeness. This is the answer he went off to build. + +The idea is older than us: Rong Chen left Microsoft in 2000 to build an OS where apps never touch the network directly. Twenty-five years later, the pieces finally exist. We ship them in the open, fail closed where we aren't finished, and publish what is not done yet. + +**ela.city, standing alone.** A marketplace where creators sell keys, not copies — rights and royalties travel with the work, splits pay in the instant of sale, and the published fee is 2 percent. + +**Elacity Labs, for press and hiring.** Elacity Labs builds ElastOS and ela.city — funded in public by on-chain community mandates, paid against delivered work, shipped fail-closed. + +### The system of names + +One rule above all, from the principles themselves: one visible concept, one primary name. A newcomer should meet at most two proper nouns in their first five minutes: **Elacity** and **ElastOS**. + +- **Elacity** — the brand people meet first: the marketplace (ela.city) and, by extension, the mission. +- **Elacity Labs** — the company. Corporate, hiring, governance, and press contexts only. Never in product UI. +- **ElastOS** — exactly two capitals — the operating system product. "ElastOS by Elacity Labs" in formal contexts. +- **Elastos** — one capital — the broader twenty-five-year ecosystem: the Bitcoin-merge-mined chain, the DAO, Rong Chen's lineage. Historical and ecosystem contexts only. Lowercase `elastos` is the binary and the URI scheme; developers only. +- **ELA** — the Elastos ecosystem's native asset. Ecosystem and investor contexts only, and always by function: it settles the Elastos chains and denominates the DAO treasury that funds this work. Its price is never discussed. Anywhere. Ever. +- **Home** — what users see when ElastOS opens. Always the plain word, capitalized like a place. Its siblings stay human: Library, Documents, Apps, Marketplace, Messages, People. +- **Apps vs. capsules** — the load-bearing register rule: "Apps" is the public word; "capsule" is the internal and developer word. A user installs and opens Apps. A developer builds and signs capsules. +- **Capsule, creator sense** — on Elacity, a **Capsule** (formally, Digital Capsule) is a creator's sealed work: content, lock, rights, and payment rules in one object. This is the only public use of "capsule," and it earns it — it is genuinely a sealed thing you can hold, trade, and open with a key. Guard the collision: creator documents say Capsule; developer documents say capsule; no document uses both meanings without a one-line note. +- **Carrier** — developer and architecture term for the peer-to-peer plane. Publicly: "the private network between your devices and your people." Never lead a consumer sentence with it. +- **PC2** — internal and ecosystem shorthand for the Personal Cloud Computer idea and the Puter-fork lineage. In public writing, spend the idea, not the acronym. + +The introduction ladder: first contact — Elacity and ElastOS, plain words only. Second — Home, Apps, Capsule (creator sense). Third, for developers — capsules, providers, capabilities, principals, Carrier. Fourth, for the ecosystem — Elastos, ELA, DAO, merge-mining, DIDs. Never skip rungs. Anyone forced to learn "capability token" before they have felt "your things, in your house, under your key" was handed the ladder upside down. + +### Forbidden language + +Using these is a bug, and gets fixed like one. + +**Hype words, banned outright:** revolutionary, game-changing, paradigm, disruptive, cutting-edge, next-generation, seamless, frictionless, military-grade, unhackable, unbreakable, bulletproof, world-class, "the future of X," "10x." + +**Category words, banned by default:** "Web3" as identity (we are not "a Web3 OS"); "blockchain-powered" as a lead; "trustless"; "NFT" (say tokenized access and rights, explain the mechanics, skip the word); "DRM" unqualified (say creator-controlled rights); "metaverse"; "decentralized equals secure" (the agent crisis falsified it in public). + +**Overclaims, banned as false today:** +- "Post-quantum encryption." We have crypto-agile envelopes that *require* post-quantum algorithm listings as policy; no post-quantum cipher runs yet. Say: designed for the post-quantum migration. +- "Tamper-proof audit log." The audit plane is comprehensive and runtime-owned; cryptographic chaining is explicitly "later." +- "Production dDRM" or "decentralized key management is live." The runtime's rights path fails closed by design; backends are not configured. Today's live marketplace rights run on Elacity's earlier stack. +- "Even the admin can't read your data," stated as shipped fact. It is the design doctrine, with partial coverage. Say: designed so that. +- "Install apps from the marketplace." Install is honestly disabled — "Signed install pending." +- "A finished consumer OS." The runtime is a pre-release review candidate; its README says not for production. +- "First ever" anything, without adversarial verification. "No competitors" — Microsoft announced theirs on stage. +- "Users demand sovereignty." Users demand outcomes. Every sovereignty-maximalist OS before us starved proving it. +- Unreconciled numbers stated as hard fact — revenue-share percentages, the podcaster's exact earnings. Reconcile first or say "roughly," with the source. +- Any talk of token price, in any form, ever. + +**Structural bans:** no exclamation marks. No feature in the present tense unless it passes its own proof today. No roadmap item without a "direction" marking or a named proof path. And never delete the "not yet" section to make a page prettier. + +--- + +## VIII. What Is True Today + +*(This section is the credibility engine of the whole book. It is reconciled against the repository's own ledgers as of mid-2026; where this book and the ledger ever disagree, the ledger wins. One bullet reconciles against Elacity's published materials instead of this repository, and says so on its face. Read the middle column carefully — most companies hide it. We ship it, labeled, with its own status strings.)* + +### Works today — proven + +- **The capability core.** Ed25519-signed capability tokens bound to a specific capsule; twelve validation checks in strict sequence on every use; every failure emits an audit event; enforcement wired at the microVM bridge, the HTTP handlers, and the shell protocol. Revocation by epoch and by token. Atomic use-counting, proven race-free under a twenty-way concurrency test. Delegation that can only narrow, never re-delegate. +- **Three isolation substrates, one contract.** WebAssembly with fuel metering and bounds-checked host calls; Linux microVMs that refuse to launch without hardware virtualization rather than degrade; macOS microVMs through a hand-written binding to Apple's virtualization framework (proven today for browser-VM workloads, not yet at full parity as a general capsule substrate). +- **Passkey-fronted accounts.** A from-scratch, minimal WebAuthn implementation; credentials encrypted at rest; device identity as a self-certifying DID; recovery kits that wrap the user's data key to a phrase. Wallets and DIDs attach as proofs on the principal — never as the login itself. +- **A working agent under the law.** A persona DID cryptographically distinct from its human owner's, with ownership recorded; every outbound message signed; every inbound message verified before the language model runs; scoped grants narrowed by mode. +- **The policy split.** Advisory proposer, deterministic verifier ("no async, no network, no LLM"), tighten-only decisions, and a shadow verifier whose disagreements are audited. +- **The wallet's red lines.** No address-only login, no arbitrary signing, no app-visible RPC, no `sign(data)` — typed intents behind fresh passkey approval, with signed receipts. +- **Manifest law.** Ordinary app capsules cannot even request raw networking or provider powers; provider capsules must declare their authority, operations, and expected audit events in their signed manifests. +- **Signed publish, install, and update.** The command-line flow works today over configured trusted sources — the README lists it under "What Works Today." What remains disabled is the Marketplace UI's one-click Install, pending end-to-end verification of signed manifests, publisher identity, and receipts. +- **A live marketplace (external surface).** ela.city operates today, per Elacity's published product materials: work encrypted into Digital Capsules, access sold from one key to billions, royalty tokens splitting revenue instantly down to a tenth of a percent, distribution rights for resale, channels and subscriptions, a published 2 percent fee — running on Elacity's existing rights stack, outside this repository. This bullet reconciles against Elacity's materials, not this repo's ledgers. +- **The proof culture itself.** About 1,700 automated tests across the core crates (as of June 2026); a public state ledger of proven truth; published open findings; and a smoke test that drives the hosted runtime through the real Home passkey ceremony — exercised with a WebAuthn virtual authenticator, the same protocol path CI uses — to open a known protected title on ela.city, proving the journey surface end to end (today's decryption is performed by ela.city's own stack). + +### Built, but fail-closed or partial — in progress + +- **The runtime rights pipeline.** The eight-step protected-content contract exists and is enforced — receipt chained to receipt, no key material in any receipt, unknown fields rejected on every wire structure — and every rights, key, and decrypt backend deliberately returns `not_configured`. No sealed-content producer, no live key release, no runtime decryption yet. The boundary first; the economics second. +- **Marketplace installs.** Browsing, trust ledgers, and launching work; the Install button is honestly disabled — "Signed install pending" — until signed manifests, publisher identity, and receipts are verified end to end. +- **On-chain purchase through the runtime.** A funded live purchase and playback has been proven once, on the known ela.city test path through Runtime Browser, against live contracts on Base. One blocker stays open in public: the buy executes on-chain and unlocks content, but the dApp still displays a failure for a return-path reason — the task ledger tracks it. No arbitrary buy-and-trade readiness is claimed. +- **Protected roots.** Principal-root encryption covers selected state; browser-VM profile disks are explicitly not yet under the protected envelope, and the receipts say so: `principal_owned_reset_scoped_unprotected`. +- **The audit plane.** Comprehensive, runtime-owned, append-only — with tamper-evident cryptographic chaining an explicitly documented "later." +- **The trusted-core diet.** Roughly sixteen thousand lines of core against a five-to-seven-thousand-line target, beside a server binary of roughly 145 thousand lines that the architecture says must move outward. The repo audits its own waistline. +- **The browser.** Runs as a proof; fails its own product bar — audio proof and hash-bound UX evidence are open blockers, and the current baseline is labeled, in its own receipts, "managed baseline, not final product." +- **Guest privacy.** Doctrine plus partial mechanics, with a standing public obligation: keep proving that admins never receive guest authenticators, recovery phrases, or data keys. +- **Known open findings, published.** Chat signatures lack replay protection; host-plane infrastructure services receive empty capability tokens by documented design. Listed in the security file, for transparency. + +### Direction — declared, not built + +- **Post-quantum cryptography.** Envelopes *require* hybrid classical-plus-post-quantum algorithm listings as enforced policy; no post-quantum cipher executes yet. The honest phrase: crypto-agile, designed for the migration. +- **The decentralized key network (dKMS).** Threshold nodes, share ceremonies, the sealed decrypt-material envelope — specified, not running. +- **Home as a full object browser**; mounted third-party spaces; the cloud-provider bridge. +- **Ecosystem identity adapters.** Resolver-backed `did:elastos` verification, DID-only recovery. +- **Carrier lineage interop.** Today's transport is iroh; native Elastos Carrier and Boson backends are stated targets, not started. +- **The lineage map, for the faithful.** Native Carrier and Boson are honored transport targets, as above. The Elastos Smart Chain and EID sit among the chain adapters, beside Base. Legacy Hive storage has no adapter in the new map today — objects, availability receipts, and spaces are the runtime's own storage story — and if that changes, it will arrive as a provider, labeled. +- **Agent-payment rails.** No MCP, x402, AP2, or ACP integration exists; the rails are market context, and the empty seat beside them is the strategy. +- **Deferred on principle.** Rich rights economics, token mechanics, DeFi and BtcFi, storage markets — sequenced after principals, packages, availability, and receipts are real. +- **Adoption metrics.** None exist publicly — no volumes, no user counts — and we will publish none until they are real, with the same receipts discipline as everything else. + +Early is the honest word. + +*A note on sources. Claims about the runtime reconcile against this repository — code, tests, ledgers, status strings. Claims about ela.city reconcile against Elacity's published materials. Ecosystem dates, votes, and funding reconcile against the DAO's public on-chain records. Founder biography follows the founders' own public accounts, and says so in the text. Numbers from outside — platform take-rates, star counts, exposure scans, CVE tallies, survey percentages — are reported figures from published terms, public registries, and named security research; each carries its qualifier where it stands, and a number we cannot pin, we cut.* + +--- + +## IX. The Road + +*(Everything in this section is direction, and says so.)* + +The sequence is already written into the project's planning gates, and it is a morality as much as a plan: + +1. **Authority first.** Passkey-first accounts, human-created and human-revocable agent principals, delegation with human approval on high-risk scopes. +2. **Availability second.** Publishing that means *available*, evidenced by signed receipts — never a bare identifier and a hope. +3. **Protected content third.** The sealed-object pipeline wired for real: sealed publish, on-chain rights reads against pinned contracts, key release against receipts, decryption inside the smallest possible boundary — replacing today's compatibility stack step by step, fail-closed at every unfinished edge. +4. **Adapters fourth.** Wallet, DID, and chain proofs attached where identity and settlement genuinely need them. +5. **Spaces fifth.** Network drives and shared places that make Carrier a real object plane and Home a real object browser. +6. **The signed registry sixth.** Publish, install, update — which is the day the marketplace's Install button turns on, because its promise can finally be kept. +7. **Economics last.** Markets for storage, distribution, and rights — after the receipts, quotas, and abuse controls that make them honest. + +Three lanes run beside the gates, each as much direction as the gates themselves. + +**The market's road.** ela.city keeps running on its current rights stack for as long as creators depend on it — continuity first. The cutover to the runtime pipeline happens surface by surface, and only behind fail-closed parity proofs: sealed publish, rights reads, key release, and playback must each pass on the runtime path before any creator's flow moves. What a creator should feel when it happens: nothing, except more receipts. + +**The product line's road.** The shipped PC2-lineage product converges on the runtime by the written rule — inherit the protocol boundaries and the acceptance tests, never the monoliths. The convergence is observable, not atmospheric: surfaces re-homed onto runtime services one at a time, each behind its acceptance tests, while the oversized server binary sheds weight in public. The repo already audits its own waistline; this road is that audit trending toward its target. + +**The workshop's road.** Labs' mandate cadence stays public — proposal, vote, delivery, tranche — and the first third-party security audit of the capability core is a stated milestone, not a settled fact. Until it lands, this system cites the capability-security lineage; it does not borrow its proofs. + +Three futures, held as scenarios rather than prophecies. The bear: the platforms absorb the lesson, agent tenancy becomes "good enough," and this work becomes the reference the absorbers copy — correct, admired, niche; even then it compounds. The base: agent incidents keep arriving on schedule — the input side is conceded, so they will — and a meaningful minority of creators, professionals, and self-hosters adopts owned authority the way businesses adopted firewalls: not from ideology, from insurance logic. The best: one machine-commerce failure with a dollar figure large enough to name makes "where does your agent live, and who can prove what it may do?" a compliance question — with exactly one self-custodial answer. + +Discipline for all three: claim the gradient, not the demand curve. People do not want sovereignty; they want the file to be theirs, the sale to settle, the agent to act without becoming the next breach headline. Sovereignty is how. It is never the pitch. + +And one picture of where the road points, marked as the picture it is. A filmmaker's agent, some year soon, is approached by a fan's agent asking to license a scene. Policy checks policy at machine speed; the rights are already bound to the sealed work; the split pays every named hand in the same breath as the sale; a chain of signed receipts records the grant, the release, and the render; either human can inspect, dispute, or revoke — and neither was interrupted at dinner. Every noun in that paragraph has a schema, a contract, or a fail-closed skeleton in the repository today. None of it is wired end to end. That is exactly what the middle column of Section VIII is for — and why it exists in public. + +--- + +## X. Benediction + +May your keys open exactly what they say, and nothing more. +May every grant expire, every receipt survive, and every refusal explain itself. +May your work travel with its lock, its rights, and its till — and come home paid. +May your servants be named, your guests be safe even from their host, and your house be boring the way strong things are boring. + +We owned our first computers. +We intend to own our last ones. + +The internet made you a tenant. +This is the deed. From ab10be0f422fac44eeaa3439abd318208fb5b53a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 01:49:10 +0000 Subject: [PATCH 07/16] docs(consolidation): bank the w2 gateway consent-request patch + decision note The w2-consent-source branch retires into flint-0.5. Two of its three commits are content-superseded; the third (3694975, gateway 202-consent seam) collides with flint-0.5's pinned flat-403 posture and the newer intent-envelope consent path, so it is preserved verbatim as a patch with the architecture decision recorded instead of silently merged or lost. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BnpmuD7RtQ3NuTfRQJGrQb --- .../W2_CONSENT_GATEWAY_SEAM_DECISION.md | 48 ++ .../w2-gateway-consent-request-3694975.patch | 627 ++++++++++++++++++ 2 files changed, 675 insertions(+) create mode 100644 docs/patches/W2_CONSENT_GATEWAY_SEAM_DECISION.md create mode 100644 docs/patches/w2-gateway-consent-request-3694975.patch diff --git a/docs/patches/W2_CONSENT_GATEWAY_SEAM_DECISION.md b/docs/patches/W2_CONSENT_GATEWAY_SEAM_DECISION.md new file mode 100644 index 00000000..87e8a7d1 --- /dev/null +++ b/docs/patches/W2_CONSENT_GATEWAY_SEAM_DECISION.md @@ -0,0 +1,48 @@ +# Preserved for decision: gateway consent-request seam (w2-consent-source) + +**Status: DECISION NEEDED — deliberately not applied.** + +## What this is + +The `w2-consent-source` branch was retired during the 2026-07-03 branch +consolidation (all value folded into `flint-0.5`). A content audit found 2 of +its 3 commits fully superseded by `flint-0.5` (`canonical_input_hash` lives in +`elastos-common/src/canonical_hash.rs`; the pending-request consent binding +lives in `capability/pending.rs` and evolved further into +`validate_and_consume` + `AffordanceGrantReceiptV1`). + +One commit is genuinely absent and is preserved verbatim as +`w2-gateway-consent-request-3694975.patch` (author: SashaMIT, 2026-06-27): + +> feat(gateway): consent-request path replaces the flat 403 for gated +> affordances (W2 steps 4b.2, 4b.3) — `InvocationGate{Direct,Consent}`, +> `affordance_consent_descriptor`, `request_affordance_consent`, +> `AffordanceConsentPending` 202 (~498 lines in +> `elastos-server/src/api/gateway_capsule_catalog.rs`). + +## Why it was NOT merged mechanically + +It collides with a **deliberate** `flint-0.5` posture, not an accidental gap: + +- `flint-0.5`'s `enforce_affordance_invocation_policy` still dead-rejects + gated affordances with the flat 403 (`FORBIDDEN "approval_required"`), and a + test **affirmatively pins that behavior** + (`gateway_capsule_catalog.rs`, `assert_eq!(err.1, "approval_required")`). +- `flint-0.5` carries a *more advanced* alternative consent architecture the + branch never had: runtime intent-envelope redemption + (`capability/intent.rs` `IntentDeclarationV1`, `validate_and_consume`, + `AffordanceGrantReceiptV1`). + +## The decision to make + +Choose one, then act: + +1. **Runtime intent-envelope path wins (likely):** the gateway seam stays + fail-closed 403 by design; delete this patch and the pinned test stands. +2. **Gateway 202-consent seam wanted after all:** apply the banked patch + (`git apply --3way docs/patches/w2-gateway-consent-request-3694975.patch` + — it applied cleanly as of 2026-07-03), update the pinned flat-403 test, + and reconcile with the intent-envelope path so there is ONE consent story. + +Until decided, the patch file is the single source of this work; the +`w2-consent-source` branch is safe to delete. diff --git a/docs/patches/w2-gateway-consent-request-3694975.patch b/docs/patches/w2-gateway-consent-request-3694975.patch new file mode 100644 index 00000000..b6001dc1 --- /dev/null +++ b/docs/patches/w2-gateway-consent-request-3694975.patch @@ -0,0 +1,627 @@ +From 3694975026767b2d6bb3fd669d0bb9b0e755139b Mon Sep 17 00:00:00 2001 +From: SashaMIT +Date: Sat, 27 Jun 2026 11:33:34 -0700 +Subject: [PATCH] feat(gateway): consent-request path replaces the flat 403 for + gated affordances (W2 steps 4b.2, 4b.3) + +Replaces the dead 403 in enforce_affordance_invocation_policy with InvocationGate{Direct,Consent}: User-approval +and the four high-risk classes now raise a real consent request instead of dead-rejecting. affordance_consent_descriptor +derives (resource, action) from the method's declared resource (fail-closed if absent) and a narrowed risk/operation +-> Action table -- Admin only via an authoritative admin/manage operation, never from risk alone, because the grant +is scoped to (resource, action) today. request_affordance_consent posts the binding to the runtime over the +home_attach_shell seam and returns a token-incapable AffordanceConsentPending 202 (default-deny status; no token; +runtime prose kept to audit only). HONEST: the per-affordance/per-argument binding is recorded-but-not-yet-enforced +until the grant path reads it (W2 steps 6-8). Gated: gateway_capsule_catalog 9, clippy clean, alignment-check OK. + +Co-Authored-By: Claude Opus 4.8 +--- + .../src/api/gateway_capsule_catalog.rs | 537 ++++++++++++++++-- + 1 file changed, 498 insertions(+), 39 deletions(-) + +diff --git a/elastos/crates/elastos-server/src/api/gateway_capsule_catalog.rs b/elastos/crates/elastos-server/src/api/gateway_capsule_catalog.rs +index c26635c..f1b09af 100644 +--- a/elastos/crates/elastos-server/src/api/gateway_capsule_catalog.rs ++++ b/elastos/crates/elastos-server/src/api/gateway_capsule_catalog.rs +@@ -4,6 +4,7 @@ use elastos_common::{ + AffordanceApprovalMode, AffordanceRisk, CapsuleAffordanceDescriptor, + CapsuleInterfaceDescriptor, CapsuleManifest, CapsuleRole, CapsuleType, + }; ++use elastos_runtime::capability::Action; + use serde::{Deserialize, Serialize}; + + use super::*; +@@ -11,6 +12,31 @@ use super::*; + const CAPSULE_CATALOG_SCHEMA: &str = "elastos.capsules.catalog/v1"; + const CAPSULE_INTERFACE_REGISTRY_SCHEMA: &str = "elastos.capsules.interfaces/v1"; + const CAPSULE_INTERFACE_INVOKE_RESULT_SCHEMA: &str = "elastos.capsules.invoke-result/v1"; ++const CAPSULE_AFFORDANCE_CONSENT_PENDING_SCHEMA: &str = ++ "elastos.capsules.affordance-consent-pending/v1"; ++ ++/// The 202 body returned when a consent-gated affordance is invoked: a pending ++/// consent request the user must approve in the shell. Token-INCAPABLE by type — ++/// there is no token/output field, so the gateway can never leak a capability ++/// here. NOTE (W2): the eventual grant is scoped to `(resource, action, ++/// gateway-attach session)`; `principal_id` and the argument `input_hash` are ++/// recorded for audit and future per-principal/per-argument binding but are NOT ++/// yet enforced by the runtime at grant time, so a shell MUST NOT imply stronger ++/// per-invocation or per-principal consent than the token actually carries. ++#[derive(Debug, Serialize)] ++struct AffordanceConsentPending { ++ schema: String, ++ status: String, ++ request_id: String, ++ resource: String, ++ action: String, ++ risk: AffordanceRisk, ++ approval: AffordanceApprovalMode, ++ capsule: String, ++ interface: String, ++ method: String, ++ principal_id: String, ++} + + pub(super) async fn capsule_catalog( + State(state): State, +@@ -102,38 +128,110 @@ pub(super) async fn capsule_interface_invoke( + } + + let output = match enforce_affordance_invocation_policy(&resolved) { +- Ok(()) => match dispatch_capsule_affordance(&state, &context, &resolved, &request).await { +- Ok(output) => output, +- Err((status, code, message)) => { +- let _ = append_provider_effect_audit( +- &state.data_dir, +- ProviderEffectAuditInput { +- capsule_id: &resolved.capsule, +- event_type: "capsule.affordance.failed", +- principal_id: &context.principal_id, +- session_id: &context.session_id, +- request_id: &request_id, +- result: "failed", +- reason: &message, +- }, +- ); +- return capsule_invoke_error(&resolved, status, code, &message); ++ InvocationGate::Direct => { ++ match dispatch_capsule_affordance(&state, &context, &resolved, &request).await { ++ Ok(output) => output, ++ Err((status, code, message)) => { ++ let _ = append_provider_effect_audit( ++ &state.data_dir, ++ ProviderEffectAuditInput { ++ capsule_id: &resolved.capsule, ++ event_type: "capsule.affordance.failed", ++ principal_id: &context.principal_id, ++ session_id: &context.session_id, ++ request_id: &request_id, ++ result: "failed", ++ reason: &message, ++ }, ++ ); ++ return capsule_invoke_error(&resolved, status, code, &message); ++ } + } +- }, +- Err((status, code, message)) => { ++ } ++ InvocationGate::Consent => { ++ // Consent-gated affordance: derive the (resource, action) scope, raise a ++ // consent request through the runtime, return 202 + request_id. Never a ++ // token and never dispatch; every path in this arm diverges (returns). ++ let (resource, action, risk) = match affordance_consent_descriptor(&resolved) { ++ Ok(value) => value, ++ Err((status, code, message)) => { ++ let _ = append_provider_effect_audit( ++ &state.data_dir, ++ ProviderEffectAuditInput { ++ capsule_id: &resolved.capsule, ++ event_type: "capsule.affordance.consent_failed", ++ principal_id: &context.principal_id, ++ session_id: &context.session_id, ++ request_id: &request_id, ++ result: "failed", ++ reason: &message, ++ }, ++ ); ++ return capsule_invoke_error(&resolved, status, code, &message); ++ } ++ }; ++ let input_hash = elastos_common::canonical_input_hash(&request.input); ++ let action_str = action.to_string(); ++ let consent_request_id = match request_affordance_consent( ++ &state.data_dir, ++ &resource, ++ &action_str, ++ &resolved.capsule, ++ &context.principal_id, ++ &resolved.method.id, ++ &input_hash, ++ ) ++ .await ++ { ++ Ok(id) => id, ++ Err((status, code, message)) => { ++ let _ = append_provider_effect_audit( ++ &state.data_dir, ++ ProviderEffectAuditInput { ++ capsule_id: &resolved.capsule, ++ event_type: "capsule.affordance.consent_failed", ++ principal_id: &context.principal_id, ++ session_id: &context.session_id, ++ request_id: &request_id, ++ result: "failed", ++ reason: &message, ++ }, ++ ); ++ return capsule_invoke_error(&resolved, status, code, &message); ++ } ++ }; + let _ = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: &resolved.capsule, +- event_type: "capsule.affordance.failed", ++ event_type: "capsule.affordance.consent_requested", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, +- result: "failed", +- reason: message, ++ result: "approval_pending", ++ reason: &format!( ++ "{} requires user approval for {} ({}, {})", ++ resolved.capsule, resolved.method.id, resource, action_str ++ ), + }, + ); +- return capsule_invoke_error(&resolved, status, code, message); ++ return ( ++ StatusCode::ACCEPTED, ++ Json(AffordanceConsentPending { ++ schema: CAPSULE_AFFORDANCE_CONSENT_PENDING_SCHEMA.to_string(), ++ status: "approval_pending".to_string(), ++ request_id: consent_request_id, ++ resource, ++ action: action_str, ++ risk, ++ approval: resolved.method.approval.clone(), ++ capsule: resolved.capsule.clone(), ++ interface: resolved.interface_id.clone(), ++ method: resolved.method.id.clone(), ++ principal_id: context.principal_id.clone(), ++ }), ++ ) ++ .into_response(); + } + }; + +@@ -326,15 +424,20 @@ fn resolve_capsule_affordance( + }) + } + +-fn enforce_affordance_invocation_policy( +- resolved: &ResolvedCapsuleAffordance, +-) -> Result<(), (StatusCode, &'static str, &'static str)> { ++/// Whether an affordance invocation dispatches directly or must first pass a ++/// user-consent round-trip. The flat 403 is gone: consent-gated methods now raise ++/// a consent request instead of dead-rejecting. ++#[derive(Debug, Clone, Copy, PartialEq, Eq)] ++enum InvocationGate { ++ /// Low-risk, runtime-policy method — dispatch directly. ++ Direct, ++ /// `AffordanceApprovalMode::User` or a high-risk class — require user consent. ++ Consent, ++} ++ ++fn enforce_affordance_invocation_policy(resolved: &ResolvedCapsuleAffordance) -> InvocationGate { + if resolved.method.approval == AffordanceApprovalMode::User { +- return Err(( +- StatusCode::FORBIDDEN, +- "approval_required", +- "user-approved affordance invocation is not enabled yet", +- )); ++ return InvocationGate::Consent; + } + if matches!( + resolved.method.risk, +@@ -343,13 +446,250 @@ fn enforce_affordance_invocation_policy( + | AffordanceRisk::Actuator + | AffordanceRisk::Privileged + ) { ++ return InvocationGate::Consent; ++ } ++ InvocationGate::Direct ++} ++ ++/// Narrowest [`Action`] implied by a declared [`AffordanceRisk`] class. Total and ++/// exhaustive so the first real high-risk affordance forces a compile-time review ++/// of this table. NOTE: risk alone NEVER yields `Admin` — because the eventual ++/// grant is scoped purely to this `(resource, action)` today, an `Admin` token on ++/// a wildcard resource would be an over-grant; `Admin` is reachable only via an ++/// authoritative admin/manage operation (see [`affordance_consent_descriptor`]). ++fn action_from_risk(risk: &AffordanceRisk) -> Action { ++ match risk { ++ AffordanceRisk::Read => Action::Read, ++ AffordanceRisk::Write => Action::Write, ++ AffordanceRisk::Launch => Action::Execute, ++ AffordanceRisk::Payment => Action::Execute, ++ AffordanceRisk::Rights => Action::Execute, ++ AffordanceRisk::Actuator => Action::Write, ++ AffordanceRisk::Privileged => Action::Execute, ++ } ++} ++ ++/// Map a declared method `operation` to an [`Action`] by substring, first match ++/// wins. `None` means the operation maps to no known action, which the caller ++/// treats as a fail-closed `operation_unmapped` (never a default). ++fn action_from_operation(operation: &str) -> Option { ++ let op = operation.to_lowercase(); ++ let has = |needles: &[&str]| needles.iter().any(|n| op.contains(n)); ++ if has(&["admin", "manage"]) { ++ Some(Action::Admin) ++ } else if has(&["delete", "remove"]) { ++ Some(Action::Delete) ++ } else if has(&["execute", "invoke", "launch", "call", "run"]) { ++ Some(Action::Execute) ++ } else if has(&["send", "post", "message"]) { ++ Some(Action::Message) ++ } else if has(&["write", "create", "update", "set", "put"]) { ++ Some(Action::Write) ++ } else if has(&["read", "get", "list", "query"]) { ++ Some(Action::Read) ++ } else { ++ None ++ } ++} ++ ++/// Privilege rank used to take the stronger of two actions. Higher = more power. ++fn action_rank(action: &Action) -> u8 { ++ match action { ++ Action::Read => 0, ++ Action::Message => 1, ++ Action::Write => 2, ++ Action::Delete => 3, ++ Action::Execute => 4, ++ Action::Admin => 5, ++ } ++} ++ ++/// The stronger (higher-privilege) of two actions. ++fn max_privilege(a: Action, b: Action) -> Action { ++ if action_rank(&a) >= action_rank(&b) { ++ a ++ } else { ++ b ++ } ++} ++ ++/// Derive the `(resource, action, risk)` a consent-gated affordance must request, ++/// fail-closed. `resource` comes from the method's DECLARED resource (no default); ++/// `action` is the narrowest action implied by the operation and risk. `Admin` is ++/// reachable ONLY when the operation is authoritatively admin/manage — risk alone ++/// is capped at `Execute`, because today this `(resource, action)` pair is the ++/// entire scope of the token the user later approves. ++fn affordance_consent_descriptor( ++ resolved: &ResolvedCapsuleAffordance, ++) -> Result<(String, Action, AffordanceRisk), (StatusCode, &'static str, String)> { ++ let resource = resolved ++ .method ++ .resource ++ .as_deref() ++ .map(str::trim) ++ .filter(|r| !r.is_empty()) ++ .ok_or(( ++ StatusCode::BAD_REQUEST, ++ "descriptor_resource_missing", ++ "consent-gated affordance declares no resource; cannot scope consent".to_string(), ++ ))? ++ .to_string(); ++ ++ let risk_action = action_from_risk(&resolved.method.risk); ++ let action = match resolved.method.operation.as_deref() { ++ Some(operation) => { ++ let op_action = action_from_operation(operation).ok_or(( ++ StatusCode::BAD_REQUEST, ++ "operation_unmapped", ++ format!("method operation '{operation}' does not map to a known action"), ++ ))?; ++ let operation_is_admin = matches!(op_action, Action::Admin); ++ let combined = max_privilege(risk_action, op_action); ++ // Operation is the only authority that may mint Admin; risk may only ++ // tighten within non-Admin. ++ if matches!(combined, Action::Admin) && !operation_is_admin { ++ Action::Execute ++ } else { ++ combined ++ } ++ } ++ None => match risk_action { ++ Action::Admin => Action::Execute, ++ other => other, ++ }, ++ }; ++ ++ Ok((resource, action, resolved.method.risk.clone())) ++} ++ ++/// Raise a user-consent request through the runtime for a consent-gated ++/// affordance, returning the runtime's `request_id`. NEVER returns or holds a ++/// token: the gateway is a thin adapter; the runtime (key holder) owns minting. ++/// Mirrors the inbox approve/deny seam (load coords -> attach shell token -> ++/// Bearer POST). All four binding fields are sent together (the runtime rejects a ++/// partial set 400); the status is default-deny (only "pending" yields an id). ++async fn request_affordance_consent( ++ data_dir: &std::path::Path, ++ resource: &str, ++ action: &str, ++ capsule: &str, ++ principal_id: &str, ++ method_id: &str, ++ input_hash: &str, ++) -> Result { ++ // The runtime stores binding fields verbatim with no emptiness check; an empty ++ // field would silently weaken the binding, so reject before the POST. ++ for (label, value) in [ ++ ("capsule", capsule), ++ ("principal_id", principal_id), ++ ("method_id", method_id), ++ ("input_hash", input_hash), ++ ] { ++ if value.trim().is_empty() { ++ return Err(( ++ StatusCode::INTERNAL_SERVER_ERROR, ++ "binding_field_empty", ++ format!("consent binding field '{label}' is empty"), ++ )); ++ } ++ } ++ ++ let coords = load_live_runtime_coords(data_dir).await.ok_or(( ++ StatusCode::SERVICE_UNAVAILABLE, ++ "runtime_unavailable", ++ "local runtime is not running".to_string(), ++ ))?; ++ let client = reqwest::Client::builder() ++ .timeout(Duration::from_secs(5)) ++ .build() ++ .map_err(|err| { ++ ( ++ StatusCode::INTERNAL_SERVER_ERROR, ++ "consent_client_failed", ++ err.to_string(), ++ ) ++ })?; ++ let shell_token = home_attach_shell(&client, &coords.api_url, &coords.attach_secret) ++ .await ++ .map_err(|err| { ++ ( ++ StatusCode::INTERNAL_SERVER_ERROR, ++ "consent_attach_failed", ++ err.to_string(), ++ ) ++ })?; ++ let response = client ++ .post(format!("{}/api/capability/request", coords.api_url)) ++ .header(AUTHORIZATION, format!("Bearer {shell_token}")) ++ .json(&serde_json::json!({ ++ "resource": resource, ++ "action": action, ++ "capsule": capsule, ++ "principal_id": principal_id, ++ "method_id": method_id, ++ "input_hash": input_hash, ++ })) ++ .send() ++ .await ++ .map_err(|err| { ++ ( ++ StatusCode::BAD_GATEWAY, ++ "consent_post_failed", ++ err.to_string(), ++ ) ++ })?; ++ ++ if !response.status().is_success() { ++ // Keep the runtime refusal in the gateway audit only; never forward raw ++ // runtime prose to the caller (avoids leaking internal resource topology). ++ let runtime_status = response.status(); ++ let runtime_body = response.text().await.unwrap_or_default(); + return Err(( +- StatusCode::FORBIDDEN, +- "approval_required", +- "high-risk affordances require explicit user approval before invocation", ++ StatusCode::BAD_GATEWAY, ++ "consent_rejected", ++ format!("runtime rejected consent request ({runtime_status}): {runtime_body}"), + )); + } +- Ok(()) ++ ++ let body: serde_json::Value = response.json().await.map_err(|err| { ++ ( ++ StatusCode::BAD_GATEWAY, ++ "consent_parse_failed", ++ err.to_string(), ++ ) ++ })?; ++ ++ // Default-deny: only an explicit "pending" status yields a request_id; every ++ // other status (including a token-bearing "granted") fails closed. ++ match body.get("status").and_then(|s| s.as_str()) { ++ Some("pending") => body ++ .get("request_id") ++ .and_then(|r| r.as_str()) ++ .map(str::to_string) ++ .ok_or(( ++ StatusCode::BAD_GATEWAY, ++ "consent_missing_request_id", ++ "runtime returned pending without a request_id".to_string(), ++ )), ++ Some("granted") => Err(( ++ StatusCode::INTERNAL_SERVER_ERROR, ++ "unexpected_grant", ++ "runtime auto-granted a consent-gated affordance".to_string(), ++ )), ++ Some("denied") => Err(( ++ StatusCode::FORBIDDEN, ++ "consent_denied", ++ "runtime denied the consent request".to_string(), ++ )), ++ other => Err(( ++ StatusCode::BAD_GATEWAY, ++ "unexpected_status", ++ format!( ++ "unexpected consent status: {}", ++ other.unwrap_or("") ++ ), ++ )), ++ } + } + + async fn dispatch_capsule_affordance( +@@ -1082,8 +1422,9 @@ mod tests { + } + + #[test] +- fn capsule_affordance_policy_rejects_high_risk_without_approval_binding() { +- let resolved = ResolvedCapsuleAffordance { ++ fn capsule_affordance_policy_routes_consent_vs_direct() { ++ // High-risk (Payment) now routes to consent instead of a flat 403. ++ let high_risk = ResolvedCapsuleAffordance { + capsule: "wallet".to_string(), + interface_id: "elastos.wallet.payment".to_string(), + method: CapsuleAffordanceDescriptor { +@@ -1098,9 +1439,127 @@ mod tests { + output_schema: None, + }, + }; ++ assert_eq!( ++ enforce_affordance_invocation_policy(&high_risk), ++ InvocationGate::Consent ++ ); ++ ++ // AffordanceApprovalMode::User routes to consent regardless of (low) risk. ++ let user_gated = resolved_with(AffordanceRisk::Read, Some("elastos://x"), Some("read")); ++ assert_eq!( ++ enforce_affordance_invocation_policy(&user_gated), ++ InvocationGate::Consent ++ ); + +- let err = enforce_affordance_invocation_policy(&resolved).unwrap_err(); +- assert_eq!(err.0, StatusCode::FORBIDDEN); +- assert_eq!(err.1, "approval_required"); ++ // Low-risk + RuntimePolicy dispatches directly (no consent). ++ let direct = ResolvedCapsuleAffordance { ++ capsule: "viewer".to_string(), ++ interface_id: "elastos.viewer.media".to_string(), ++ method: CapsuleAffordanceDescriptor { ++ id: "catalog.list".to_string(), ++ description: None, ++ risk: AffordanceRisk::Read, ++ approval: AffordanceApprovalMode::RuntimePolicy, ++ audit: elastos_common::AffordanceAuditMode::Summary, ++ resource: Some("elastos://capsules/*".to_string()), ++ operation: Some("list".to_string()), ++ input_schema: None, ++ output_schema: None, ++ }, ++ }; ++ assert_eq!( ++ enforce_affordance_invocation_policy(&direct), ++ InvocationGate::Direct ++ ); ++ } ++ ++ fn resolved_with( ++ risk: AffordanceRisk, ++ resource: Option<&str>, ++ operation: Option<&str>, ++ ) -> ResolvedCapsuleAffordance { ++ ResolvedCapsuleAffordance { ++ capsule: "viewer".to_string(), ++ interface_id: "elastos.viewer.media".to_string(), ++ method: CapsuleAffordanceDescriptor { ++ id: "open".to_string(), ++ description: None, ++ risk, ++ approval: AffordanceApprovalMode::User, ++ audit: elastos_common::AffordanceAuditMode::Full, ++ resource: resource.map(str::to_string), ++ operation: operation.map(str::to_string), ++ input_schema: None, ++ output_schema: None, ++ }, ++ } ++ } ++ ++ #[test] ++ fn consent_descriptor_fails_closed_without_resource() { ++ let none = affordance_consent_descriptor(&resolved_with( ++ AffordanceRisk::Payment, ++ None, ++ Some("send"), ++ )); ++ assert_eq!(none.unwrap_err().1, "descriptor_resource_missing"); ++ let blank = affordance_consent_descriptor(&resolved_with( ++ AffordanceRisk::Payment, ++ Some(" "), ++ Some("send"), ++ )); ++ assert_eq!(blank.unwrap_err().1, "descriptor_resource_missing"); ++ } ++ ++ #[test] ++ fn consent_descriptor_fails_closed_on_unmapped_operation() { ++ let err = affordance_consent_descriptor(&resolved_with( ++ AffordanceRisk::Write, ++ Some("elastos://content/x"), ++ Some("frobnicate"), ++ )) ++ .unwrap_err(); ++ assert_eq!(err.1, "operation_unmapped"); ++ } ++ ++ #[test] ++ fn consent_descriptor_never_grants_admin_from_risk_alone() { ++ // Privileged risk with no operation must NOT yield Admin (capped at Execute). ++ let (_, action, _) = affordance_consent_descriptor(&resolved_with( ++ AffordanceRisk::Privileged, ++ Some("elastos://sys/*"), ++ None, ++ )) ++ .unwrap(); ++ assert_eq!(action, Action::Execute); ++ // Privileged risk with a non-admin operation also stays capped at Execute. ++ let (_, action2, _) = affordance_consent_descriptor(&resolved_with( ++ AffordanceRisk::Privileged, ++ Some("elastos://sys/*"), ++ Some("read"), ++ )) ++ .unwrap(); ++ assert_eq!(action2, Action::Execute); ++ // Admin is reachable ONLY via an authoritative admin/manage operation. ++ let (_, action3, _) = affordance_consent_descriptor(&resolved_with( ++ AffordanceRisk::Privileged, ++ Some("elastos://sys/*"), ++ Some("admin.reset"), ++ )) ++ .unwrap(); ++ assert_eq!(action3, Action::Admin); ++ } ++ ++ #[test] ++ fn consent_descriptor_maps_resource_and_action() { ++ let (resource, action, risk) = affordance_consent_descriptor(&resolved_with( ++ AffordanceRisk::Read, ++ Some("elastos://content/film-x"), ++ Some("get"), ++ )) ++ .unwrap(); ++ assert_eq!(resource, "elastos://content/film-x"); ++ assert_eq!(action, Action::Read); ++ assert_eq!(risk, AffordanceRisk::Read); + } + } +-- +2.43.0 + From 84ae217d89960ad77b64bb7498e084c9aa952e32 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 02:08:47 +0000 Subject: [PATCH 08/16] restore(wasm): epoch-based operator termination lost in the mpeg-dash squash (B2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix/mpeg-dash-compliance re-authoring replaced the ddrm line's epoch interruption (operator-triggered stop of an in-flight runaway capsule, 8bf1d29) with fuel + wall-clock bounds — a passive property, not an on-demand kill. stop() only removed the instance from the map; a spinning capsule kept burning its blocking thread. Restore the epoch machinery UNIFIED with the successor's work so both properties hold: consume_fuel + StoreLimits + wall-clock stay as-is, and stop() once again sets the per-instance should_stop flag and bumps that capsule's engine epoch so the deadline callback traps it at the next backedge. The restored runaway test pins the operator kill specifically (fuel set effectively unbounded so only the stop signal can trap). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BnpmuD7RtQ3NuTfRQJGrQb --- .../elastos-compute/src/providers/wasm.rs | 92 ++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/elastos/crates/elastos-compute/src/providers/wasm.rs b/elastos/crates/elastos-compute/src/providers/wasm.rs index 6246f086..68a0f2fb 100644 --- a/elastos/crates/elastos-compute/src/providers/wasm.rs +++ b/elastos/crates/elastos-compute/src/providers/wasm.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::sync::RwLock; @@ -78,6 +79,11 @@ struct RunningInstance { /// Per-launch carrier directory (only set when bridge ran via FIFO transport). /// Cleaned up on `Drop` so dropped instances don't leak FIFOs into /tmp. carrier_dir: Option, + /// Set by `stop()` to terminate an in-flight execution. The execution's epoch-deadline + /// callback checks this flag and traps the capsule — the only way to halt a runaway, + /// since execution runs in a `spawn_blocking` task that cannot be cancelled. Fuel and + /// the wall-clock deadline bound a runaway passively; this is the operator's on-demand kill. + should_stop: Arc, } impl Drop for RunningInstance { @@ -228,6 +234,9 @@ impl WasmProvider { fn engine() -> Result { let mut config = Config::new(); config.consume_fuel(true); + // Epoch-based interruption so a runaway capsule can be trapped on demand by `stop()` + // (fuel and the wall-clock deadline only bound it passively, after the fact). + config.epoch_interruption(true); Engine::new(&config) .map_err(|e| ElastosError::Compute(format!("Failed to configure WASM engine: {}", e))) } @@ -612,6 +621,7 @@ impl WasmProvider { capsule_id: String, principal_id: Option, limits: WasmExecutionLimits, + should_stop: Arc, ) -> Result<()> { let mut store = Store::new( engine, @@ -628,6 +638,19 @@ impl WasmProvider { .set_fuel(limits.fuel) .map_err(|e| ElastosError::Compute(format!("Failed to set WASM fuel: {}", e)))?; + // Terminability: arm an epoch deadline whose callback traps the capsule when `stop()` has + // set `should_stop` (and bumped the engine epoch). With no stop signal the deadline simply + // keeps extending, so a legitimate (even long-running) capsule runs untouched. The epoch + // check is a cheap load+compare at loop backedges; fuel/wall-clock still bound the run. + store.set_epoch_deadline(1); + store.epoch_deadline_callback(move |_| { + if should_stop.load(Ordering::Relaxed) { + Err(wasmtime::Error::msg("capsule terminated by stop request")) + } else { + Ok(wasmtime::UpdateDeadline::Continue(1)) + } + }); + // Create linker and bind WASI preview1 host functions. let mut linker = Linker::new(engine); Self::register_carrier_hostcall(&mut linker)?; @@ -710,6 +733,7 @@ impl ComputeProvider for WasmProvider { manifest: manifest.clone(), _data_dir: data_dir, carrier_dir: None, + should_stop: Arc::new(AtomicBool::new(false)), }; self.instances.write().await.insert(id.clone(), instance); @@ -725,7 +749,7 @@ impl ComputeProvider for WasmProvider { async fn start(&self, handle: &CapsuleHandle) -> Result<()> { // Get instance data - let (engine, module, manifest, limits) = { + let (engine, module, manifest, limits, should_stop) = { let instances = self.instances.read().await; let instance = instances .get(&handle.id) @@ -736,8 +760,11 @@ impl ComputeProvider for WasmProvider { instance.module.clone(), instance.manifest.clone(), self.execution_limits, + instance.should_stop.clone(), ) }; + // A fresh run must not inherit a stale stop signal. + should_stop.store(false, Ordering::Relaxed); // Mark as running before execution { @@ -790,6 +817,7 @@ impl ComputeProvider for WasmProvider { // Execute in a blocking task since wasmtime execution is synchronous let capsule_id = handle.id.0.clone(); + let exec_stop = should_stop.clone(); let result = tokio::task::spawn_blocking(move || { Self::execute_wasm( &engine, @@ -799,6 +827,7 @@ impl ComputeProvider for WasmProvider { capsule_id, principal_id, limits, + exec_stop, ) }); let result = tokio::time::timeout(limits.wall_clock_timeout, result) @@ -830,6 +859,13 @@ impl ComputeProvider for WasmProvider { let mut instances = self.instances.write().await; if let Some(instance) = instances.remove(&handle.id) { + // Signal any in-flight execution to terminate: set the flag the capsule's + // epoch-deadline callback checks, then bump this capsule's engine epoch so a + // spinning capsule hits its next epoch check and traps. `instance` is the removed + // (owned) value, but its `should_stop` and `engine` are shared with the running + // task (Arc / cloned Engine), so the signal reaches it. + instance.should_stop.store(true, Ordering::Relaxed); + instance.engine.increment_epoch(); // Dropping the RunningInstance releases the wasmtime Engine, Module, // and any compiled code buffers. This is the primary memory-clearing // step for multi-tenant safety — no residual WASM heap survives. @@ -952,6 +988,7 @@ mod tests { "fuel-test".to_string(), None, limits, + Arc::new(AtomicBool::new(false)), ) .expect_err("busy-loop capsule must exhaust fuel"); @@ -961,6 +998,57 @@ mod tests { ); } + /// B2a (restored from the ddrm-hardening line): a runaway capsule MUST be terminable on + /// demand. With no stop signal the epoch deadline keeps extending (legitimate capsules run + /// free); when `stop()` sets the flag and bumps the epoch, the capsule traps on its next + /// backedge. Fuel is set effectively-unbounded here so the trap can ONLY come from the stop + /// signal — this proves the operator kill, not passive fuel exhaustion. The loop is finite + /// (a CI-hang backstop): if termination were broken, `recv_timeout` fails the test in 5 s + /// and the thread still ends on its own. + #[test] + fn runaway_capsule_is_terminable_via_stop_signal() { + let engine = WasmProvider::engine().expect("engine"); + let module = Module::new( + &engine, + r#"(module (func (export "_start") + (local $i i64) (local.set $i (i64.const 2000000000)) + (loop $l + (local.set $i (i64.sub (local.get $i) (i64.const 1))) + (br_if $l (i64.ne (local.get $i) (i64.const 0))))))"#, + ) + .expect("compile wat"); + let limits = WasmExecutionLimits { + fuel: u64::MAX, + ..WasmExecutionLimits::default() + }; + let should_stop = Arc::new(AtomicBool::new(false)); + let (tx, rx) = std::sync::mpsc::channel(); + let (ss, eng, module2) = (should_stop.clone(), engine.clone(), module.clone()); + std::thread::spawn(move || { + let r = WasmProvider::execute_wasm( + &eng, + &module2, + WasiCtxBuilder::new().build_p1(), + None, + "runaway-test".to_string(), + None, + limits, + ss, + ); + let _ = tx.send(r.is_err()); + }); + std::thread::sleep(std::time::Duration::from_millis(50)); // let it start spinning + should_stop.store(true, Ordering::Relaxed); + engine.increment_epoch(); + let trapped = rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("a stopped runaway must terminate within 5s, not run forever"); + assert!( + trapped, + "a stopped runaway must trap (Err), not complete normally" + ); + } + #[test] fn test_execute_wasm_rejects_memory_above_limit() { let engine = WasmProvider::engine().expect("engine"); @@ -986,6 +1074,7 @@ mod tests { "memory-limit-test".to_string(), None, limits, + Arc::new(AtomicBool::new(false)), ) .expect_err("capsule memory must be limited"); @@ -1182,6 +1271,7 @@ mod tests { manifest: dummy_manifest, _data_dir: None, carrier_dir: Some(dir.clone()), + should_stop: Arc::new(AtomicBool::new(false)), }; assert!(dir.exists(), "dir must still exist while instance is alive"); } From 451faab3cbb248c2bebf5ad5138197e56267e9aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 02:08:48 +0000 Subject: [PATCH 09/16] restore(media+creator): measured transcode-progress reporting lost in the mpeg-dash squash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ddrm line's Improvement A (media-provider streams ffmpeg -progress to a caller-granted sink; the creator progress endpoint merges the measured % onto the active 'package' stage) was dropped in the re-authoring: the successor's package_dash has no progress path and /prepare-progress shows an indeterminate stage for the mint's long pole. Restored surgically from the ddrm generation — ONLY the progress hunks; the raw branch diff would also have reverted the successor's CENC/DASH signaling (which postdates the ddrm fork) and stays untouched here. Capability-passing invariants preserved: host-generated sink path (no path-injection from the client job id), provider writes only that one path, atomic temp+rename publish, best-effort throughout (a progress failure never blocks the mint). Tests: ffmpeg out_time parsing/clamping, atomic publish, and pct surfacing only while 'package' is active. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BnpmuD7RtQ3NuTfRQJGrQb --- capsules/media-provider/src/main.rs | 176 +++++++++++++++++- .../crates/elastos-server/src/api/creator.rs | 143 ++++++++++++-- 2 files changed, 295 insertions(+), 24 deletions(-) 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/elastos/crates/elastos-server/src/api/creator.rs b/elastos/crates/elastos-server/src/api/creator.rs index 403ec154..9c10659c 100644 --- a/elastos/crates/elastos-server/src/api/creator.rs +++ b/elastos/crates/elastos-server/src/api/creator.rs @@ -349,6 +349,7 @@ pub struct PrepareMintRequest { pub mod mint_progress { use serde_json::{json, Value}; use std::collections::HashMap; + use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -359,6 +360,10 @@ pub mod mint_progress { done: bool, error: Option, updated: u64, + // Caller-granted sink the media provider writes measured transcode % to (Improvement A). + // Read lazily at UI-poll cadence and merged onto the active `package` stage — no extra + // host task. Absent ⇒ the stage carries no pct and the UI stays indeterminate. + pkg_progress_path: Option, } fn now() -> u64 { @@ -388,10 +393,21 @@ pub mod mint_progress { done: false, error: None, updated: now(), + pkg_progress_path: None, }, ); } + /// Register the caller-granted transcode progress sink for a job (no-op without an id). + pub fn set_pkg_progress_path(job_id: Option<&str>, path: &Path) { + let Some(id) = job_id else { return }; + let mut g = store().lock().expect("mint progress store poisoned"); + if let Some(job) = g.get_mut(id) { + job.pkg_progress_path = Some(path.to_path_buf()); + job.updated = now(); + } + } + /// Mark `stage` active and every earlier stage done (no-op without a job id). pub fn advance(job_id: Option<&str>, stage: &str) { let Some(id) = job_id else { return }; @@ -441,18 +457,51 @@ pub mod mint_progress { /// JSON snapshot for the status route (`None` ⇒ unknown/expired job). pub fn snapshot(job_id: &str) -> Option { - let g = store().lock().expect("mint progress store poisoned"); - g.get(job_id).map(|job| { - json!({ - "schema": "elastos.creator.progress/v1", - "done": job.done, - "error": job.error, - "stages": job.stages.iter().map(|(name, status)| json!({ - "name": name, - "status": status, - })).collect::>(), - }) - }) + // Copy the small state out under the lock, then do any file I/O lock-free. + let (stages, done, error, pkg_path) = { + let g = store().lock().expect("mint progress store poisoned"); + let job = g.get(job_id)?; + ( + job.stages.clone(), + job.done, + job.error.clone(), + job.pkg_progress_path.clone(), + ) + }; + + // Lazily read the measured transcode % only while `package` is active — exactly when + // the UI is polling. Best-effort: a missing/torn/garbage file just omits pct. + let pkg_pct = if stages + .iter() + .any(|(name, status)| name == "package" && status == "active") + { + pkg_path.as_deref().and_then(read_pkg_pct) + } else { + None + }; + + Some(json!({ + "schema": "elastos.creator.progress/v1", + "done": done, + "error": error, + "stages": stages.iter().map(|(name, status)| { + let mut row = json!({ "name": name, "status": status }); + if name == "package" { + if let Some(p) = pkg_pct { + row["pct"] = json!(p); + } + } + row + }).collect::>(), + })) + } + + /// Read `{"stage":"transcode","pct":N}` from the provider's sink, returning a clamped 0..=99. + fn read_pkg_pct(path: &Path) -> Option { + let body = std::fs::read_to_string(path).ok()?; + let v: Value = serde_json::from_str(&body).ok()?; + let pct = v.get("pct").and_then(Value::as_i64)?; + Some(pct.clamp(0, 99) as u8) } } @@ -1692,6 +1741,22 @@ async fn finalize_mint( })) } +/// A unique, host-owned temp path for the media provider's transcode-progress sink. The nonce +/// is host-generated (process id + clock + a counter) — never derived from the client job id — +/// so there is no path-injection surface from request input. +fn pkg_progress_sink_path() -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join("elastos-creator-progress"); + let _ = std::fs::create_dir_all(&dir); + dir.join(format!("pkg-{}-{}-{}.json", std::process::id(), nonce, seq)) +} + /// The MEDIA producer spine: DASH-package (per-track inits + plaintext segments + MPD) -> /// CENC every fragment under ONE asset CEK (single default_KID) + escrow to the quorum -> /// publish the DASH directory (plaintext inits + ENCRYPTED segments + manifest.mpd) -> media @@ -1711,15 +1776,27 @@ async fn run_prepare_mint_media( // 1) DASH-package the source: per-track standalone inits + PLAINTEXT segments + manifest. // This is the long pole (transcode + fragment); the UI shows "package" active throughout. mint_progress::advance(job_id, "package"); + // Measured transcode progress (Improvement A): grant the media provider a single host-owned + // sink (capability passing — it writes ONLY here) and surface its % on /prepare-progress. + // Host-owned temp path with a random nonce: never derived from the client job id, so there + // is no path-injection surface. Best-effort throughout — never blocks the mint. + let progress_sink = job_id.map(|_| pkg_progress_sink_path()); + if let Some(path) = progress_sink.as_deref() { + mint_progress::set_pkg_progress_path(job_id, path); + } let pkg_req = json!({ "op": "package_dash", "content_b64": b64.encode(file_bytes), "filename": meta.file_name, "preview_duration": meta.preview_duration.unwrap_or(0), + "progress_path": progress_sink.as_ref().map(|p| p.to_string_lossy().into_owned()), }); - let pkg = provider_data(registry, "media", &pkg_req) - .await - .map_err(|e| stage_err("encrypt", e))?; + let pkg = provider_data(registry, "media", &pkg_req).await; + // The transcode is done (or failed): the sink is no longer read — clean it up. + if let Some(path) = progress_sink.as_deref() { + let _ = std::fs::remove_file(path); + } + let pkg = pkg.map_err(|e| stage_err("encrypt", e))?; let manifest = pkg .get("mpd") .and_then(Value::as_str) @@ -3248,6 +3325,42 @@ mod tests { assert!(err.contains("PUBLIC-ONLY"), "got: {err}"); } + #[test] + fn progress_snapshot_surfaces_measured_pct_only_while_package_active() { + let job = format!("test-prog-{}", std::process::id()); + mint_progress::start(&job, &["analyze", "package", "encrypt"]); + + // A granted sink with a measured value, but `package` not yet active ⇒ no pct leaks. + let sink = pkg_progress_sink_path(); + std::fs::write(&sink, b"{\"stage\":\"transcode\",\"pct\":42}").unwrap(); + mint_progress::set_pkg_progress_path(Some(&job), &sink); + mint_progress::advance(Some(&job), "analyze"); + let snap = mint_progress::snapshot(&job).unwrap(); + let pkg = snap["stages"] + .as_array() + .unwrap() + .iter() + .find(|s| s["name"] == "package") + .unwrap(); + assert!( + pkg.get("pct").is_none(), + "pct must not show before package is active" + ); + + // Once `package` is active, the measured % is merged onto that stage. + mint_progress::advance(Some(&job), "package"); + let snap = mint_progress::snapshot(&job).unwrap(); + let pkg = snap["stages"] + .as_array() + .unwrap() + .iter() + .find(|s| s["name"] == "package") + .unwrap(); + assert_eq!(pkg["pct"], json!(42)); + + let _ = std::fs::remove_file(&sink); + } + #[test] fn refuses_a_quorum_that_is_not_three_nodes() { assert!(parse_quorum_descriptor(&descriptor(2, false)).is_err()); From 8d142301e35292fb3f816b1efce64821c3800880 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 02:08:48 +0000 Subject: [PATCH 10/16] =?UTF-8?q?feat(chain-provider):=20marketplace=20sli?= =?UTF-8?q?ce=201=20=E2=80=94=20KID=E2=86=92ledger-tokenId=20resolver=20+?= =?UTF-8?q?=20ABI/protocol=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transplanted from feat/marketplace-runtime (9bd5489 + 6bde757 + e175c1d + 2a24139): the ResolveTokenId op pivoted to AssetCreated+calldata (the DigitalAssetRegistered event does not emit on Base — verified empirically on the live chain), fail-closed on an ambiguous KID→tokenId binding, with checked arithmetic + length caps in abi_string. Applied 3-way onto the flint-0.5 generation; clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BnpmuD7RtQ3NuTfRQJGrQb --- capsules/chain-provider/src/abi.rs | 106 +++++++++++++++++ capsules/chain-provider/src/main.rs | 145 ++++++++++++++++++++++++ capsules/chain-provider/src/protocol.rs | 18 +++ 3 files changed, 269 insertions(+) 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`. From 26abf3c700c081cbab28b8b512e2093c34317b44 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 02:13:47 +0000 Subject: [PATCH 11/16] =?UTF-8?q?feat(market):=20slice=202=20=E2=80=94=20b?= =?UTF-8?q?uy/trade=20authorities=20(buy-invariant=20core=20+=20resale/wit?= =?UTF-8?q?hdraw=20calldata)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transplanted from feat/marketplace-runtime onto the flint-0.5 generation: buy_authority Phase-1 invariant (value*qty via paymentProcessor approve, fail-closed tokenId resolution, abort-on-drift re-reading listings live, listings() qty/price word order per SSOT, fail-closed non-hex decode, SCOPE unsigned-only hard-gate, fail-closed on missing seller); trade_authority resale/withdraw/approval calldata assembler (new module, wired in api/mod.rs so it compiles with the slice — route wiring lands in slice 3 with the gateway); chain_tx alignment. 3-way apply, clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BnpmuD7RtQ3NuTfRQJGrQb --- .../elastos-server/src/api/buy_authority.rs | 750 ++++++++++++++++-- .../crates/elastos-server/src/api/chain_tx.rs | 113 +++ elastos/crates/elastos-server/src/api/mod.rs | 1 + .../elastos-server/src/api/trade_authority.rs | 213 +++++ 4 files changed, 1015 insertions(+), 62 deletions(-) create mode 100644 elastos/crates/elastos-server/src/api/trade_authority.rs diff --git a/elastos/crates/elastos-server/src/api/buy_authority.rs b/elastos/crates/elastos-server/src/api/buy_authority.rs index 1d5a2755..81bf2ff4 100644 --- a/elastos/crates/elastos-server/src/api/buy_authority.rs +++ b/elastos/crates/elastos-server/src/api/buy_authority.rs @@ -22,13 +22,13 @@ //! JSON-RPC mock (the real broadcast code path runs), then record the purchase in //! the ledger so the subsequent open's `chain-mock` rights read (`…=ledger`) returns //! owned. Proves not-owned → buy → own → open end to end on a Mac, no network. -//! - `chain` — assemble the `{ to, value, data }` against the configured Base contract. -//! With `ELASTOS_DDRM_BUY_SIGN=wallet` (RECOMMENDED), the gateway sources real -//! nonce/gas via `chain-provider.prepare_transaction`, signs inside `wallet-provider` -//! with a managed account (the key never leaves the capsule), and broadcasts the -//! signed bytes through the real `chain-provider` — genuinely live. Absent that -//! opt-in, it broadcasts an EXTERNALLY-signed tx (`ELASTOS_DDRM_BUY_SIGNED_TX`) or — -//! absent one — returns the assembled unsigned tx for an external signer (fail-closed). +//! - `chain` — assemble the `{ to, value, data }` against the configured Base contract and +//! return it UNSIGNED for the user's external wallet. SCOPE hard-gate (SCOPE.md:32/53): the +//! production buy path is unsigned -> external-wallet ONLY. A `dev-modes` build may instead +//! (with `ELASTOS_DDRM_BUY_SIGN=wallet`) source nonce/gas via `chain-provider.prepare_transaction`, +//! sign inside `wallet-provider` with a managed account (key never leaves the capsule), and +//! broadcast — for offline/live TESTING only. A release build ignores that opt-in and either +//! broadcasts an EXTERNALLY-signed tx (`ELASTOS_DDRM_BUY_SIGNED_TX`) or hands back the unsigned tx. //! //! Runtime signing (`ELASTOS_DDRM_BUY_SIGN=wallet`) also applies to `chain-mock`: the //! wallet capsule signs a well-formed buyAccess tx and the genuine signed bytes are @@ -67,17 +67,25 @@ const BUY_ACCESS_ERC20_SELECTOR: &str = "0x0ede2294"; // + address payToken /// USDC on Base (6 decimals) — the default Elacity payment token. const BASE_USDC: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +/// `approve(address spender, uint256 amount)` selector (`keccak256(sig)[..4]`). The ERC-20 buy's +/// prerequisite approve, where `spender` is the operative's `paymentProcessor()` (NOT the gateway). +const ERC20_APPROVE_SELECTOR: &str = "0x095ea7b3"; + /// Default offline fees for the `chain-mock` wallet-signing path (no RPC to source them). /// The mock does not execute the transaction, so these only need to be well-formed. const MOCK_NONCE: &str = "0x0"; const MOCK_GAS_PRICE: &str = "0x3b9aca00"; // 1 gwei const MOCK_GAS_LIMIT: &str = "0x186a0"; // 100k — comfortably covers contract calldata -/// True when the operator opted into runtime EVM signing through the wallet capsule -/// (`ELASTOS_DDRM_BUY_SIGN=wallet`): the buy is signed by a managed account whose key -/// never leaves `wallet-provider`. Absent this, the legacy externally-signed path applies. +/// True when managed-account EVM signing through the wallet capsule is active +/// (`ELASTOS_DDRM_BUY_SIGN=wallet`). SCOPE.md:32/53 HARD-GATE: the production buy path is UNSIGNED -> +/// external wallet, never the managed-account autosign mode (it self-approves the signature +/// server-side, P16). Managed signing is therefore reachable ONLY in a `dev-modes` build (the offline +/// chain-mock sign->broadcast proof + live-chain dev testing); a release build always returns the +/// assembled unsigned tx for the user's external wallet, so this is unconditionally `false` there. fn wallet_signing() -> bool { - env_nonempty("ELASTOS_DDRM_BUY_SIGN").as_deref() == Some("wallet") + cfg!(feature = "dev-modes") + && env_nonempty("ELASTOS_DDRM_BUY_SIGN").as_deref() == Some("wallet") } /// The EVM chain id for the buy (default Base mainnet); overridable for other deployments. @@ -87,6 +95,23 @@ fn chain_id_default() -> u64 { .unwrap_or(8453) } +/// The asset identity + buyer-agreed terms the storefront supplies for a live buy (it has these from the +/// discovery index / `/api/market/get`). On the `chain` path the seller/price/payToken are sourced LIVE +/// from `sellersOf`/`listings` (keyed at ACCESS_TOKEN id=1) using this identity — NO `ELASTOS_DDRM_BUY_*` +/// pins required. `expected_price`/`expected_pay_token` are what the buyer saw in the UI: when present +/// they arm abort-on-drift (P11) against the live re-read. All fields optional; env values still override +/// (dev/fixtures) and an empty target falls back to the env/resolve path. +#[derive(Debug, Clone, Default)] +pub struct BuyTarget { + pub operative: Option, + pub token_id: Option, + pub ledger: Option, + pub quantity: Option, + pub seller: Option, + pub expected_price: Option, + pub expected_pay_token: Option, +} + /// Buy an access token for `content_id` on behalf of `subject` (the principal's linked /// EVM wallet). `content_id` MUST be the same identifier the rights gate keys on, so the /// recorded ownership matches the subsequent open. @@ -95,6 +120,7 @@ pub fn buy_access( content_id: &str, subject: &str, now_unix: u64, + target: &BuyTarget, ) -> Result { let mode = super::rights_authority::rights_mode(); @@ -109,7 +135,7 @@ pub fn buy_access( return Err("wallet not linked: a buy needs the principal's EVM address".to_string()); } - let unsigned_tx = assemble_buy_tx(content_id, subject); + let unsigned_tx = assemble_buy_tx(content_id, subject, None); match mode { RightsMode::Dev => { @@ -163,19 +189,34 @@ pub fn buy_access( }) } RightsMode::Chain => { + // Phase-1 (P11 fail closed): a LIVE buy binds the REAL ledger tokenId (resolved from the KID + // or supplied by the storefront — NEVER word_from_id, a content hash) and sources the listing + // seller/price/payToken LIVE from sellersOf/listings (id=1). No ELASTOS_DDRM_BUY_* pins + // required; env still overrides for dev. Fails closed on a missing channel, an unresolved + // tokenId, or no active listing. The CEK path is untouched (P15). + let sourced = source_buy_terms(content_id, target)?; + if sourced.supply == 0 { + return Err( + "listing sold out (on-chain supply 0) — buy aborted (fail closed)".to_string(), + ); + } + // Abort-on-drift (P11): if the buyer agreed to a price/pay-token in the UI, the live re-read + // MUST match — else fail closed (the listing changed under them). + if let Some(expected) = sourced.expected.as_ref() { + ensure_no_drift(expected, &sourced.live)?; + } + let unsigned = assemble_buy_tx_core(content_id, subject, &sourced.terms); if wallet_signing() { - // Live path: source real nonce/gas + assemble the intent via the REAL - // chain-provider `prepare_transaction`, sign it inside the wallet capsule - // (key never leaves), and broadcast the signed bytes through the REAL - // chain-provider. No externally-signed tx required — this is the seam that - // makes `chain` mode genuinely live. + // Live path: source real nonce/gas + assemble the intent via the REAL chain-provider + // `prepare_transaction`, sign inside the wallet capsule (key never leaves), and broadcast + // the signed bytes through the REAL chain-provider — the seam that makes `chain` live. let chain_id = chain_id_default(); let mut intent_seen = Value::Null; let sig = super::wallet_signer::sign_with_managed_account( principal_id, chain_id, |from| { - let intent = prepare_live_intent(from, content_id)?; + let intent = prepare_live_intent(from, content_id, &sourced.terms)?; intent_seen = intent.clone(); Ok(intent) }, @@ -190,14 +231,14 @@ pub fn buy_access( unsigned_tx: buy_audit_view(&intent_seen, &sig), }); } - // Real chain, no runtime signing: broadcast an externally-signed tx if provided, - // else hand back the assembled unsigned tx for an external signer. + // Real chain, no runtime signing: broadcast an externally-signed tx if provided, else hand + // back the live-assembled unsigned tx for the user's external wallet (the release path). let Some(signed) = env_nonempty("ELASTOS_DDRM_BUY_SIGNED_TX") else { return Err(format!( "live buy needs a signature: either opt into runtime signing with \ ELASTOS_DDRM_BUY_SIGN=wallet (the wallet capsule signs with a managed \ key), or sign this assembled tx externally and resubmit via \ - ELASTOS_DDRM_BUY_SIGNED_TX. unsigned_tx={unsigned_tx}" + ELASTOS_DDRM_BUY_SIGNED_TX. unsigned_tx={unsigned}" )); }; let tx_hash = super::chain_tx::broadcast_signed_live(&signed)?; @@ -208,52 +249,255 @@ pub fn buy_access( tx_hash, owned_now: false, mode: "chain".to_string(), - unsigned_tx, + unsigned_tx: unsigned, }) } } } -/// Assemble the REAL `buyAccess` transaction the wallet signs, matching PC2's -/// `wallet.js`: -/// `buyAccess(address seller, address ledger, uint256 tokenId, uint256 quantity, -/// uint256 pricePerToken[, address payToken])` -/// sent to the AuthorityGateway. The ERC-20 form (default, USDC on Base) carries a -/// `payToken` and `value = 0` and requires a prior `approve` of the operative's -/// `paymentProcessor`; the native form omits `payToken` and pays `value = price`. -/// -/// Listing terms (seller / ledger / tokenId / price / payToken) are sourced from the -/// marketplace listing in production (the Market portal, B5); here they're overridable via -/// env so a live buy is byte-correct once a listing is pinned. The `tokenId` defaults to a -/// 32-byte word derived from `content_id` (the ledger's content-hash tokenId); pin the -/// real value with `ELASTOS_DDRM_BUY_TOKEN_ID`. Pure: no RPC, no keys. -fn assemble_buy_tx(content_id: &str, subject: &str) -> Value { - let to = +/// The zero EVM address (a native/ETH listing's `payToken`). +const ZERO_ADDR: &str = "0x0000000000000000000000000000000000000000"; +/// Cap the live seller scan when sourcing the lowest active listing (bounded RPC fan-out). +const MAX_SELLERS_SCAN: usize = 8; + +/// The live-sourced buy: the assembled `terms`, the asset's `operative`, the live-read listing `live` +/// terms + `supply`, and the buyer-agreed `expected` terms (for abort-on-drift, when the UI supplied a +/// price). +#[derive(Debug)] +struct SourcedBuy { + terms: BuyTerms, + live: BoundTerms, + supply: u128, + expected: Option, +} + +/// Pick the lowest-priced ACTIVE seller for `(operative, ACCESS_TOKEN=1)` from `sellersOf` + per-seller +/// `listings` (both keyed at id=1; READ-ONLY). Mirrors `/api/market/get`'s selection so the buy binds the +/// same listing the storefront showed. Fails closed when no active listing exists. +fn pick_lowest_active_seller( + gateway: &str, + operative: &str, + token_id_word: &str, +) -> Result { + let sellers = super::market_reads::sellers_of_live(gateway, operative, token_id_word)?; + let mut best: Option<(String, u128)> = None; + for s in sellers.iter().take(MAX_SELLERS_SCAN) { + if let Ok((terms, supply)) = read_listing_terms(gateway, operative, token_id_word, s) { + if supply == 0 { + continue; + } + let price: u128 = terms.price.parse().unwrap_or(u128::MAX); + if best.as_ref().is_none_or(|(_, bp)| price < *bp) { + best = Some((s.clone(), price)); + } + } + } + best.map(|(s, _)| s).ok_or_else(|| { + "no active listing for this asset (sellersOf/listings empty at ACCESS_TOKEN id=1) — buy \ + aborted (fail closed)" + .to_string() + }) +} + +/// Live-source the buyAccess terms for `content_id` from the asset identity the storefront supplies +/// (`target`) + on-chain `sellersOf`/`listings` (keyed at ACCESS_TOKEN id=1). No `ELASTOS_DDRM_BUY_*` +/// pins required on the live path; env values still override (dev/fixtures). Resolves the real ledger +/// tokenId/operative (target → pinned env → KID→AssetCreated via the channel), picks the lowest active +/// seller, and binds price/payToken from that seller's LIVE listing. Fails closed on a missing channel, +/// an unresolved tokenId, or no active listing (P11). +fn source_buy_terms(content_id: &str, target: &BuyTarget) -> Result { + let gateway = env_nonempty("ELASTOS_DDRM_BUY_TO").unwrap_or_else(|| BASE_AUTHORITY_GATEWAY.to_string()); - // Default ledger to the AuthorityGateway is wrong; default it to the (overridable) - // channel. With no channel pinned we fall back to `to` so calldata stays well-formed. - let ledger = env_nonempty("ELASTOS_DDRM_BUY_LEDGER").unwrap_or_else(|| to.clone()); + // The asset's channel ledger — required to resolve KID→tokenId AND as the buyAccess `ledger` arg. + let ledger = target + .ledger + .clone() + .or_else(|| env_nonempty("ELASTOS_DDRM_BUY_LEDGER")) + .ok_or( + "live buy requires the asset's channel/ledger (the storefront supplies it from the \ + listing) — fail closed", + )?; + + // (tokenId, operative): supplied by the storefront > pinned env > resolved from the KID via + // chain-provider (scan AssetCreated + bind the mint calldata). NEVER word_from_id (a content hash, + // not the ledger tokenId): a fabricated tokenId debits the wallet for access never granted. + let (token_id, operative) = match (target.token_id.clone(), target.operative.clone()) { + (Some(t), Some(o)) => (t, o), + _ => match env_nonempty("ELASTOS_DDRM_BUY_TOKEN_ID") { + Some(pinned) => { + let op = target + .operative + .clone() + .or_else(|| env_nonempty("ELASTOS_DDRM_BUY_OPERATIVE")) + .ok_or( + "pinned ELASTOS_DDRM_BUY_TOKEN_ID also needs the operative (per-asset \ + ERC-1155) to re-read listing terms", + )?; + (pinned, op) + } + None => super::chain_tx::resolve_token_id_live(content_id, &ledger).map_err(|e| { + format!( + "live buy requires the resolved on-chain tokenId (KID->AssetCreated mint \ + calldata) — word_from_id(content_id) is NOT the real ledger tokenId: {e}" + ) + })?, + }, + }; + if operative.trim().is_empty() { + return Err( + "abort-on-drift requires the asset's operative; none resolved — fail closed" + .to_string(), + ); + } + let token_id_word = token_id_to_word(&token_id); + + // The on-chain listing seller keys the `listings` re-read. Supplied by the storefront > pinned env > + // the lowest active seller read live from `sellersOf` (id=1). NEVER defaults to the buyer (a + // self-query returns nothing and the drift echo can't detect a forged seller). + let seller = match target + .seller + .clone() + .or_else(|| env_nonempty("ELASTOS_DDRM_BUY_SELLER")) + { + Some(s) => s, + None => pick_lowest_active_seller(&gateway, &operative, &token_id_word)?, + }; + + // Bind price/payToken from the chosen seller's LIVE listing (read at id=1). + let (live, supply) = read_listing_terms(&gateway, &operative, &token_id_word, &seller)?; + + let quantity = target + .quantity + .clone() + .or_else(|| env_nonempty("ELASTOS_DDRM_BUY_QUANTITY")) + .unwrap_or_else(|| "1".to_string()); + + // A zero-address payToken is a native listing; map it to the native form for assembly. + let pay_token = if live.pay_token.eq_ignore_ascii_case(ZERO_ADDR) { + String::new() + } else { + live.pay_token.clone() + }; + let terms = BuyTerms { + gateway, + ledger, + seller: seller.clone(), + token_id_word: token_id_word.clone(), + quantity, + price: live.price.clone(), + pay_token, + }; + + // Abort-on-drift arms only when the buyer agreed to a price in the UI; compared against `live` below. + let expected = target.expected_price.clone().map(|price| BoundTerms { + seller, + token_id: format!("0x{token_id_word}"), + price, + pay_token: target + .expected_pay_token + .clone() + .unwrap_or_else(|| live.pay_token.clone()), + }); + + Ok(SourcedBuy { + terms, + live, + supply, + expected, + }) +} + +/// The concrete buyAccess listing terms a tx is assembled from. On the `chain` path these are sourced +/// LIVE (`source_buy_terms`); on dev/mock they come from env (`buy_terms_from_env`). `pay_token` empty or +/// `"native"` selects the native form; any other value is the ERC-20 token address. +#[derive(Debug, Clone)] +struct BuyTerms { + gateway: String, + ledger: String, + seller: String, + token_id_word: String, + quantity: String, + price: String, + pay_token: String, +} + +/// Build `BuyTerms` from `ELASTOS_DDRM_BUY_*` env (dev/mock/fixtures). The `tokenId` defaults to a 32-byte +/// word derived from `content_id` (a content hash, NOT the ledger tokenId) — dev/mock ONLY; the live +/// `chain` path sources a resolved tokenId via `source_buy_terms`, so the fallback never binds a real buy. +fn buy_terms_from_env( + content_id: &str, + subject: &str, + token_id_override: Option<&str>, +) -> BuyTerms { + let gateway = + env_nonempty("ELASTOS_DDRM_BUY_TO").unwrap_or_else(|| BASE_AUTHORITY_GATEWAY.to_string()); + // Default ledger to the AuthorityGateway is wrong; default it to the (overridable) channel. With no + // channel pinned we fall back to `gateway` so calldata stays well-formed. + let ledger = env_nonempty("ELASTOS_DDRM_BUY_LEDGER").unwrap_or_else(|| gateway.clone()); let seller = env_nonempty("ELASTOS_DDRM_BUY_SELLER").unwrap_or_else(|| subject.to_string()); let quantity = env_nonempty("ELASTOS_DDRM_BUY_QUANTITY").unwrap_or_else(|| "1".to_string()); let price = env_nonempty("ELASTOS_DDRM_BUY_PRICE").unwrap_or_else(|| "0".to_string()); let pay_token = env_nonempty("ELASTOS_DDRM_BUY_PAYTOKEN").unwrap_or_else(|| BASE_USDC.to_string()); - let native = pay_token.is_empty() || pay_token.eq_ignore_ascii_case("native"); - - // tokenId: a pinned decimal/hex, else derived from content_id. - let token_id_word = match env_nonempty("ELASTOS_DDRM_BUY_TOKEN_ID") { - Some(t) => word_from_uint(&t), + let token_id_word = match token_id_override + .map(str::to_string) + .or_else(|| env_nonempty("ELASTOS_DDRM_BUY_TOKEN_ID")) + { + Some(t) => token_id_to_word(&t), None => word_from_id(content_id), }; + BuyTerms { + gateway, + ledger, + seller, + token_id_word, + quantity, + price, + pay_token, + } +} + +/// Assemble the REAL `buyAccess` transaction the wallet signs, matching PC2's +/// `wallet.js`: +/// `buyAccess(address seller, address ledger, uint256 tokenId, uint256 quantity, +/// uint256 pricePerToken[, address payToken])` +/// sent to the AuthorityGateway. The ERC-20 form (default, USDC on Base) carries a +/// `payToken` and `value = 0` and requires a prior `approve` of the operative's +/// `paymentProcessor`; the native form omits `payToken` and pays `value = price`. Pure: no RPC, no keys. +fn assemble_buy_tx(content_id: &str, subject: &str, token_id_override: Option<&str>) -> Value { + assemble_buy_tx_core( + content_id, + subject, + &buy_terms_from_env(content_id, subject, token_id_override), + ) +} + +/// Pure buyAccess assembly from explicit `BuyTerms` (the single calldata path for env + live-sourced +/// terms). Pure: no RPC, no keys. +fn assemble_buy_tx_core(content_id: &str, subject: &str, terms: &BuyTerms) -> Value { + let to = terms.gateway.clone(); + let ledger = terms.ledger.clone(); + let seller = terms.seller.clone(); + let quantity = terms.quantity.clone(); + let price = terms.price.clone(); + let pay_token = terms.pay_token.clone(); + let native = pay_token.is_empty() || pay_token.eq_ignore_ascii_case("native"); + let token_id_word = terms.token_id_word.clone(); + // Phase-1: the order TOTAL = pricePerToken * quantity (the *quantity multiplier was missing — a + // single-unit price underpaid a multi-unit buy). Used as the native `value` AND the ERC-20 approve + // amount. Fail-closed to 0 on parse/overflow (a 0 value reverts at the contract, never overpays). + let total_hex = match (price.parse::(), quantity.parse::()) { + (Ok(p), Ok(q)) => p + .checked_mul(q) + .map(|n| format!("0x{n:x}")) + .unwrap_or_else(|| "0x0".to_string()), + _ => "0x0".to_string(), + }; let (selector, value, mut data) = if native { ( BUY_ACCESS_NATIVE_SELECTOR.to_string(), - // Native payment: value carries the price (decimal wei → hex quantity). - price - .parse::() - .map(|n| format!("0x{n:x}")) - .unwrap_or_else(|_| "0x0".to_string()), + total_hex.clone(), BUY_ACCESS_NATIVE_SELECTOR .trim_start_matches("0x") .to_string(), @@ -278,6 +522,25 @@ fn assemble_buy_tx(content_id: &str, subject: &str) -> Value { data.push_str(&word_from_address(&pay_token)); } + // Phase-1: surface the ERC-20 approve leg as a concrete tx. spender = the asset's Operative + // `paymentProcessor()` (read live; NOT the gateway). Pinned via env until the gateway resolves it + // from the operative; amount = the order total (price * quantity). Null on the native path. + let approve = if native { + Value::Null + } else { + let processor = env_nonempty("ELASTOS_DDRM_BUY_PAYMENT_PROCESSOR").unwrap_or_default(); + if processor.is_empty() { + json!({ + "required": true, + "spender": "Operative.paymentProcessor() — resolve live (pin ELASTOS_DDRM_BUY_PAYMENT_PROCESSOR)", + "pay_token": pay_token, + "amount": total_hex, + }) + } else { + erc20_approve_call(&pay_token, &processor, &total_hex) + } + }; + json!({ "to": to, "value": value, @@ -288,9 +551,9 @@ fn assemble_buy_tx(content_id: &str, subject: &str) -> Value { "seller": seller, "ledger": ledger, "pay_token": if native { Value::Null } else { json!(pay_token) }, - // Production prerequisite for the ERC-20 path (the Market portal batches it): - // approve(paymentProcessor, price) on payToken before buyAccess. - "requires_erc20_approve": !native, + // The ERC-20 approve leg (spender = Operative.paymentProcessor, NOT the gateway), batched + // before buyAccess; null on the native path. + "approve": approve, }) } @@ -299,7 +562,7 @@ fn assemble_buy_tx(content_id: &str, subject: &str) -> Value { /// well-formed constants (the mock never executes the call) and `to` falls back to a valid /// placeholder when no real contract is pinned, so the capsule's address validation passes. fn mock_transaction_intent(from: &str, content_id: &str, chain_id: u64) -> Value { - let tx = assemble_buy_tx(content_id, from); + let tx = assemble_buy_tx(content_id, from, None); let to = tx .get("to") .and_then(Value::as_str) @@ -326,8 +589,8 @@ fn mock_transaction_intent(from: &str, content_id: &str, chain_id: u64) -> Value /// Source real nonce/gas and assemble the live `unsigned_transaction_intent/v1` for the /// buy via the shared chain plumbing. The returned intent is exactly what the wallet /// capsule's `transaction_intent` consumes. -fn prepare_live_intent(from: &str, content_id: &str) -> Result { - let tx = assemble_buy_tx(content_id, from); +fn prepare_live_intent(from: &str, content_id: &str, terms: &BuyTerms) -> Result { + let tx = assemble_buy_tx_core(content_id, from, terms); let to = tx .get("to") .and_then(Value::as_str) @@ -392,6 +655,153 @@ fn word_from_uint(dec: &str) -> String { format!("{n:064x}") } +/// A tokenId string -> 32-byte ABI word. Accepts a resolved `0x`+64-hex word (chain-provider +/// `resolve_token_id` output, used verbatim — tokenIds exceed u128) or a decimal uint (a pinned +/// override), falling back to `word_from_uint` for decimals. +pub(crate) fn token_id_to_word(t: &str) -> String { + let clean = t.trim().trim_start_matches("0x"); + if clean.len() == 64 && clean.bytes().all(|b| b.is_ascii_hexdigit()) { + clean.to_ascii_lowercase() + } else { + word_from_uint(t) + } +} + +/// `approve(spender, amount)` calldata on the pay-token ERC-20. `spender` = the asset's Operative +/// `paymentProcessor()` (read live; NOT the gateway). `amount_hex` is the order total (`0x…`). PURE. +fn erc20_approve_call(pay_token: &str, spender: &str, amount_hex: &str) -> Value { + let amount = u128::from_str_radix(amount_hex.trim().trim_start_matches("0x"), 16).unwrap_or(0); + let data = format!( + "{}{}{:064x}", + ERC20_APPROVE_SELECTOR.trim_start_matches("0x"), + word_from_address(spender), + amount, + ); + json!({ + "to": pay_token, + "value": "0x0", + "data": format!("0x{data}"), + "selector": ERC20_APPROVE_SELECTOR, + "spender": spender, + "note": "spender = Operative.paymentProcessor() (read live), NOT the gateway", + }) +} + +/// `listings(address operative, uint256 tokenId, address seller) -> (uint256 qty, uint256 +/// pricePerToken, address payToken)` selector (verified live on the AuthorityGateway bytecode; the +/// return word order confirmed against real Base listings + the `ItemListed` event — see +/// `decode_listing_return`). The pre-broadcast re-read for abort-on-drift. +const LISTINGS_SELECTOR: &str = "0x6bd3a64b"; + +/// The ERC-1155 ACCESS_TOKEN sub-token id (== 1) inside every per-asset Operative. `listings`/`sellersOf` +/// on the AuthorityGateway are ALWAYS keyed at THIS id — never the asset's content/ledger tokenId (which +/// `buyAccess` uses). Confirmed live on Base: `listings(op, 1, seller)` is populated while +/// `listings(op, contentTokenId, seller)` is empty (CONTRACTS.md §1.3; PC2 wallet.js TOKEN_ID_ACCESS=1). +/// Keying these reads at the content tokenId reads an empty slot → abort-on-drift / sold-out aborts +/// EVERY live buy and shows no `/get` price. +pub(crate) const ACCESS_TOKEN_ID_WORD: &str = + "0000000000000000000000000000000000000000000000000000000000000001"; + +/// The listing terms a buy is bound to at assembly. Phase-1 abort-on-drift: immediately before +/// broadcast the gateway re-reads these live (`listings`) and MUST find them identical — else fail +/// closed (P11). Terms that silently changed = wrong price / pay-token (or sold out). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BoundTerms { + pub seller: String, + pub token_id: String, + pub price: String, + pub pay_token: String, +} + +/// Fail-closed term comparison: `Ok(())` iff every field matches (addresses/hex case-insensitive). +/// Any drift between assembly and the pre-broadcast re-read aborts the buy, naming the drifted field. +pub(crate) fn ensure_no_drift(bound: &BoundTerms, reread: &BoundTerms) -> Result<(), String> { + let checks = [ + ("seller", &bound.seller, &reread.seller), + ("tokenId", &bound.token_id, &reread.token_id), + ("price", &bound.price, &reread.price), + ("payToken", &bound.pay_token, &reread.pay_token), + ]; + for (field, a, b) in checks { + if !a.trim().eq_ignore_ascii_case(b.trim()) { + return Err(format!( + "listing drift on {field}: bound {a:?} != re-read {b:?} — buy aborted (fail closed)" + )); + } + } + Ok(()) +} + +/// Parse a 32-byte ABI word (64 hex, no `0x`) as a `u128`. Fail-closed if the high 128 bits are +/// non-zero (a price/quantity beyond u128 is absurd for a real listing — treat as malformed). +fn word_to_u128(word_hex: &str) -> Result { + let w = word_hex.trim(); + if w.len() != 64 || !w.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!("not a 32-byte hex word: {word_hex}")); + } + if w[..32].bytes().any(|b| b != b'0') { + return Err("uint exceeds u128 (malformed listing word)".to_string()); + } + u128::from_str_radix(&w[32..], 16).map_err(|e| e.to_string()) +} + +/// Decode a `listings(op, tokenId, seller)` return — SSOT word layout `(uint256 qty, uint256 +/// pricePerToken, address payToken)` (CONTRACTS.md:106/230) — into the re-read `BoundTerms` plus the +/// remaining `quantity`. PURE (unit-tested); the live `eth_call` is `read_listing_terms`. Fails closed +/// (never panics) on a short or non-hex return so a hostile/compromised RPC cannot straddle a `&str` +/// byte-slice on a non-char boundary. Live-confirm the word order against a real listings() return. +pub(crate) fn decode_listing_return( + result: &str, + seller: &str, + token_id_word: &str, +) -> Result<(BoundTerms, u128), String> { + let clean = result.trim().trim_start_matches("0x"); + if clean.len() < 192 || !clean.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!("listings() return is not 3+ hex words: {result}")); + } + let quantity = word_to_u128(&clean[0..64])?; // word 0 = qty (SSOT) + let price = word_to_u128(&clean[64..128])?; // word 1 = pricePerToken (SSOT) + let pay_token = format!("0x{}", &clean[128 + 24..192]); // word 2 = payToken (last 20 bytes) + Ok(( + BoundTerms { + seller: seller.to_string(), + token_id: format!("0x{token_id_word}"), + price: price.to_string(), + pay_token, + }, + quantity, + )) +} + +/// Encode `listings(operative, ACCESS_TOKEN=1, seller)` calldata. The AuthorityGateway keys listings at +/// the ERC-1155 ACCESS_TOKEN sub-id (== 1), NOT the asset content tokenId (`ACCESS_TOKEN_ID_WORD`). PURE +/// (unit-tested) so the id-keying invariant can't silently regress. +pub(crate) fn encode_listings(operative: &str, seller: &str) -> String { + format!( + "0x{}{}{}{}", + LISTINGS_SELECTOR.trim_start_matches("0x"), + word_from_address(operative), + ACCESS_TOKEN_ID_WORD, + word_from_address(seller), + ) +} + +/// Re-read the on-chain listing terms for `(operative, seller)` via the gateway's `listings` view +/// (chain-provider `eth_call`), keyed at the ACCESS_TOKEN id (== 1) like the live chain. Live; the +/// decode is `decode_listing_return` (unit-tested). `token_id_word` is the asset's content tokenId — +/// echoed into the returned `BoundTerms.token_id` for display/drift (`buyAccess` binds it), NOT used to +/// key the read. +pub(crate) fn read_listing_terms( + gateway: &str, + operative: &str, + token_id_word: &str, + seller: &str, +) -> Result<(BoundTerms, u128), String> { + let data = encode_listings(operative, seller); + let result = super::chain_tx::contract_call_live(gateway, &data)?; + decode_listing_return(&result, seller, token_id_word) +} + /// A minimal, even-length-hex "signed tx" that satisfies the real broadcast validator in /// the mock path (the mock never inspects it; it is not a real signature). fn representative_signed_tx(unsigned_tx: &Value) -> String { @@ -497,12 +907,209 @@ mod tests { fn chain_buy_without_wallet_fails_closed() { let _g = crate::api::ddrm_env_lock(); std::env::set_var("ELASTOS_DDRM_RIGHTS", "chain"); - let result = buy_access("did:test:nowallet", "bafyX", "", 1_700_000_000); + let result = buy_access( + "did:test:nowallet", + "bafyX", + "", + 1_700_000_000, + &BuyTarget::default(), + ); std::env::remove_var("ELASTOS_DDRM_RIGHTS"); let err = result.expect_err("chain buy with no wallet must error"); assert!(err.contains("wallet not linked"), "unexpected error: {err}"); } + fn clear_buy_env() { + for k in [ + "ELASTOS_DDRM_BUY_TO", + "ELASTOS_DDRM_BUY_LEDGER", + "ELASTOS_DDRM_BUY_SELLER", + "ELASTOS_DDRM_BUY_QUANTITY", + "ELASTOS_DDRM_BUY_PRICE", + "ELASTOS_DDRM_BUY_PAYTOKEN", + "ELASTOS_DDRM_BUY_TOKEN_ID", + "ELASTOS_DDRM_BUY_PAYMENT_PROCESSOR", + ] { + std::env::remove_var(k); + } + } + + #[test] + fn native_value_is_price_times_quantity() { + let _g = ENV_LOCK.lock().unwrap(); + clear_buy_env(); + std::env::set_var("ELASTOS_DDRM_BUY_PRICE", "100"); + std::env::set_var("ELASTOS_DDRM_BUY_QUANTITY", "3"); + std::env::set_var("ELASTOS_DDRM_BUY_PAYTOKEN", "native"); + let tx = assemble_buy_tx("bafyX", SUBJECT, None); + // value = 100 * 3 = 300 = 0x12c (the *quantity multiplier the Phase-1 fix adds). + assert_eq!(tx["value"], "0x12c"); + assert!(tx["data"].as_str().unwrap().starts_with("0xf7580ad9")); // native selector + clear_buy_env(); + } + + #[test] + fn erc20_path_emits_approve_to_payment_processor() { + let _g = ENV_LOCK.lock().unwrap(); + clear_buy_env(); + let processor = "0x1111111111111111111111111111111111111111"; + std::env::set_var("ELASTOS_DDRM_BUY_PRICE", "1000000"); // 1 USDC (6 dp) + std::env::set_var("ELASTOS_DDRM_BUY_QUANTITY", "2"); + std::env::set_var("ELASTOS_DDRM_BUY_PAYMENT_PROCESSOR", processor); + let tx = assemble_buy_tx("bafyX", SUBJECT, None); + assert_eq!(tx["value"], "0x0"); // ERC-20 path pays via approve, not value + let approve = &tx["approve"]; + assert_eq!(approve["to"], BASE_USDC); // approve is on the pay-token + assert_eq!(approve["selector"], "0x095ea7b3"); + assert_eq!(approve["spender"], processor); // spender = paymentProcessor, NOT the gateway + assert_ne!(approve["spender"], BASE_AUTHORITY_GATEWAY); + clear_buy_env(); + } + + #[test] + fn chain_buy_without_resolved_tokenid_fails_closed() { + let _g = ENV_LOCK.lock().unwrap(); + clear_buy_env(); + std::env::set_var("ELASTOS_DDRM_RIGHTS", "chain"); + std::env::set_var("ELASTOS_DDRM_BUY_SIGN", "wallet"); // pass the top wallet-linked check + // A channel/ledger but NO pinned tokenId -> the Chain arm must resolve KID->tokenId and, with no + // live chain in the unit test, fail closed (never falling back to word_from_id). + std::env::set_var( + "ELASTOS_DDRM_BUY_LEDGER", + "0x807f9eb55a165c2daa74a5baefc6f47324a2825d", + ); + let err = buy_access( + "did:test:alice", + "bafyX", + SUBJECT, + 1_700_000_000, + &BuyTarget::default(), + ) + .expect_err("live buy without a resolved tokenId must fail closed"); + assert!( + err.contains("resolved on-chain tokenId"), + "unexpected error: {err}" + ); + std::env::remove_var("ELASTOS_DDRM_RIGHTS"); + std::env::remove_var("ELASTOS_DDRM_BUY_SIGN"); + clear_buy_env(); + } + + #[test] + fn ensure_no_drift_passes_on_match_and_fails_on_change() { + let bound = BoundTerms { + seller: "0xAbC".to_string(), + token_id: "0x07".to_string(), + price: "1000000".to_string(), + pay_token: BASE_USDC.to_string(), + }; + // Identical (address/hex case-insensitive) -> Ok. + let mut same = bound.clone(); + same.seller = "0xabc".to_string(); + assert!(ensure_no_drift(&bound, &same).is_ok()); + // Price drift -> fail closed, naming the field. + let mut drift = bound.clone(); + drift.price = "2000000".to_string(); + let err = ensure_no_drift(&bound, &drift).expect_err("price drift must abort"); + assert!( + err.contains("price"), + "error should name the drifted field: {err}" + ); + // Pay-token drift -> fail closed. + let mut pt = bound.clone(); + pt.pay_token = "0x0000000000000000000000000000000000000000".to_string(); + assert!(ensure_no_drift(&bound, &pt).is_err()); + } + + #[test] + fn decode_listing_return_reads_qty_price_paytoken_and_fails_closed() { + // listings() SSOT layout -> (qty = 5, pricePerToken = 1_000_000, payToken = USDC). Three ABI words. + let price = format!("{:064x}", 1_000_000u128); + let qty = format!("{:064x}", 5u128); + let pay = format!("{:0>64}", &BASE_USDC[2..].to_lowercase()); + let result = format!("0x{qty}{price}{pay}"); + let token_id_word = format!("{:064x}", 7u128); + let (terms, supply) = decode_listing_return(&result, "0xseller", &token_id_word) + .expect("valid listings decode"); + assert_eq!(terms.price, "1000000"); + assert_eq!(supply, 5); + assert_eq!(terms.pay_token.to_lowercase(), BASE_USDC.to_lowercase()); + assert_eq!(terms.token_id, format!("0x{token_id_word}")); + // Truncated return -> fail closed. + assert!(decode_listing_return("0x1234", "0xseller", &token_id_word).is_err()); + // A uint beyond u128 (high word set) -> fail closed. + let huge = format!("{}{price}{pay}", "f".repeat(64)); + assert!(decode_listing_return(&format!("0x{huge}"), "0xseller", &token_id_word).is_err()); + // A non-hex multibyte body that straddles the word-0 slice boundary (byte 64 mid-`é`) must + // fail closed, NOT panic on a `&str` non-char-boundary slice (compromised-RPC hardening). + let straddle = format!("0x{}\u{e9}{}", "a".repeat(63), "a".repeat(127)); + assert!(decode_listing_return(&straddle, "0xseller", &token_id_word).is_err()); + } + + #[test] + fn listings_read_is_keyed_at_access_token_id_one_not_content_tokenid() { + // The AuthorityGateway keys listings/sellersOf at the ACCESS_TOKEN sub-id (==1), NOT the asset's + // content tokenId (confirmed live on Base: a content-tokenId read returns an empty slot). A + // regression here makes every live buy abort (empty re-read => drift/sold-out) and /get show no + // price. Words: selector | operative | tokenId | seller. + let op = "0x8b0ae79abf9b41dfe8aabf3c791dd52fe7713530"; + let seller = "0x34daf31b99b5a59ceb18e424dbc112fa6e5f3dc3"; + let body = encode_listings(op, seller); + let body = body.trim_start_matches("0x"); + assert_eq!(&body[..8], "6bd3a64b", "listings selector"); + let token_word = &body[8 + 64..8 + 128]; + assert_eq!( + token_word, ACCESS_TOKEN_ID_WORD, + "listings must be keyed at ACCESS_TOKEN id=1, not the content tokenId" + ); + assert!(body.contains(&op[2..].to_lowercase()), "operative present"); + assert!(body.contains(&seller[2..].to_lowercase()), "seller present"); + } + + #[test] + fn assemble_from_live_terms_needs_no_env_and_binds_terms() { + let _g = ENV_LOCK.lock().unwrap(); + clear_buy_env(); // the live path assembles from explicit terms — NO ELASTOS_DDRM_BUY_* required. + let terms = BuyTerms { + gateway: BASE_AUTHORITY_GATEWAY.to_string(), + ledger: "0x807f9eb55a165c2daa74a5baefc6f47324a2825d".to_string(), + seller: "0x34daf31b99b5a59ceb18e424dbc112fa6e5f3dc3".to_string(), + token_id_word: format!("{:064x}", 7u128), + quantity: "2".to_string(), + price: "1000000".to_string(), + pay_token: BASE_USDC.to_string(), + }; + let tx = assemble_buy_tx_core("bafyLIVE", SUBJECT, &terms); + assert!(tx["data"].as_str().unwrap().starts_with("0x0ede2294")); // ERC-20 buyAccess + assert_eq!(tx["value"], "0x0"); // ERC-20 pays via approve, not value + assert_eq!(tx["ledger"], terms.ledger); + assert_eq!(tx["seller"], terms.seller); + let data = tx["data"].as_str().unwrap().to_lowercase(); + assert!( + data.contains(&terms.seller[2..].to_lowercase()), + "calldata binds seller" + ); + assert!( + data.contains(&terms.ledger[2..].to_lowercase()), + "calldata binds ledger" + ); + assert!( + data.contains(&terms.token_id_word), + "calldata binds content tokenId" + ); + clear_buy_env(); + } + + #[test] + fn source_buy_terms_fails_closed_without_channel_ledger() { + let _g = ENV_LOCK.lock().unwrap(); + clear_buy_env(); // no target, no env -> the very first gate (channel/ledger) fails closed. + let err = source_buy_terms("bafyX", &BuyTarget::default()) + .expect_err("a live buy with no channel/ledger must fail closed"); + assert!(err.contains("channel/ledger"), "unexpected error: {err}"); + clear_buy_env(); + } + /// DEV INTEGRATION (opt-in): proves the offline buy->own->open ledger loop end to end /// against the REAL chain-provider broadcast op + the chain-mock rights read. Requires /// the dev-tree chain-provider binary: @@ -519,8 +1126,14 @@ mod tests { // Not owned before the buy. assert!(!super::super::owned_ledger::contains("bafyBUY", SUBJECT)); - let out = buy_access("did:test:alice", "bafyBUY", SUBJECT, 1_700_000_000) - .expect("chain-mock buy"); + let out = buy_access( + "did:test:alice", + "bafyBUY", + SUBJECT, + 1_700_000_000, + &BuyTarget::default(), + ) + .expect("chain-mock buy"); assert!(out.tx_hash.starts_with("0x") && out.tx_hash.len() == 66); // Owned after the buy — the ledger the chain-mock rights read consults. assert!(super::super::owned_ledger::contains("bafyBUY", SUBJECT)); @@ -567,7 +1180,14 @@ mod tests { assert!(!before.allowed, "unowned content must be denied before buy"); // Buy the access token (real broadcast + ledger record). - let out = buy_access("did:test:alice", cid, SUBJECT, 1_700_000_000).expect("buy"); + let out = buy_access( + "did:test:alice", + cid, + SUBJECT, + 1_700_000_000, + &BuyTarget::default(), + ) + .expect("buy"); assert!(out.owned_now); // After the buy: ledger has it -> rights gate ALLOWS. @@ -599,8 +1219,14 @@ mod tests { std::env::set_var("ELASTOS_DDRM_RIGHTS", "chain-mock"); std::env::set_var("ELASTOS_DDRM_BUY_SIGN", "wallet"); // No external wallet linked — the managed account is authoritative. - let out = buy_access("did:test:alice", "bafyWALLET", "", 1_700_000_000) - .expect("wallet-signed chain-mock buy"); + let out = buy_access( + "did:test:alice", + "bafyWALLET", + "", + 1_700_000_000, + &BuyTarget::default(), + ) + .expect("wallet-signed chain-mock buy"); assert_eq!(out.mode, "chain-mock+wallet"); assert!(out.owned_now); diff --git a/elastos/crates/elastos-server/src/api/chain_tx.rs b/elastos/crates/elastos-server/src/api/chain_tx.rs index 738d54dd..7954997d 100644 --- a/elastos/crates/elastos-server/src/api/chain_tx.rs +++ b/elastos/crates/elastos-server/src/api/chain_tx.rs @@ -45,6 +45,40 @@ pub(crate) fn live_chain_init() -> Result<(String, Value), String> { let chain_id: i64 = env_nonempty("ELASTOS_DDRM_CHAIN_ID") .and_then(|s| s.parse().ok()) .unwrap_or(8453); + // Public Base endpoints the chain-provider ROTATES to when the primary rate-limits (HTTP 429 / + // JSON-RPC -32016). Without these the single-endpoint config has nothing to fail over to, so a burst + // (discovery getLogs sweep + a detail view's sellersOf/listings/royalty reads) drains one bucket and + // every read fails — surfacing as a false "not listed". Mirrors the chain-provider's own PC2 pool. + // `eth_call` fallbacks are broad (any keyless endpoint serves a call); the log pool is RANGE-CAPABLE + // ONLY (publicnode silently truncates getLogs, so it must never serve a discovery scan). Override via + // ELASTOS_CHAIN_BASE_RPC_FALLBACKS / _LOG_RPCS (comma-separated). + let split = |s: String| -> Vec { + s.split(',') + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty() && *u != rpc_url) + .collect() + }; + let call_fallbacks = env_nonempty("ELASTOS_CHAIN_BASE_RPC_FALLBACKS") + .map(split) + .unwrap_or_else(|| { + [ + "https://base-rpc.publicnode.com", + "https://base.drpc.org", + "https://base.meowrpc.com", + "https://1rpc.io/base", + ] + .iter() + .map(|s| s.to_string()) + .filter(|u| *u != rpc_url) + .collect() + }); + let log_rpcs = env_nonempty("ELASTOS_CHAIN_BASE_LOG_RPCS") + .map(split) + .unwrap_or_else(|| { + std::iter::once(rpc_url.clone()) + .chain(["https://base.gateway.tenderly.co".to_string()]) + .collect() + }); let init = json!({ "op": "init", "config": { "networks": [{ @@ -57,6 +91,8 @@ pub(crate) fn live_chain_init() -> Result<(String, Value), String> { "mainnet": true, "explorer_url": null, "rpc_url": rpc_url, + "rpc_fallback_urls": call_fallbacks, + "log_query_rpc_urls": log_rpcs, }]} }); Ok((network, init)) @@ -84,6 +120,83 @@ pub(crate) fn prepare_intent_live( run_chain_capsule(&chain_bin, &init, &prepare) } +/// Resolve the REAL on-chain ledger `tokenId` for a `bytes16` KID via the REAL chain-provider +/// `resolve_token_id` op (scans `AssetCreated` on the channel/`ledger` + binds the KID through the +/// mint calldata). READ-ONLY (`eth_getLogs` + `eth_getTransactionByHash`); no keys. Returns the +/// `0x…` tokenId, or an error if unresolved — the live buy then fails closed (P11). This is the +/// Phase-1 fix: the buy binds THIS, never `word_from_id(content_id)`. +pub(crate) fn resolve_token_id_live( + content_id: &str, + ledger: &str, +) -> Result<(String, String), String> { + let (network, init) = live_chain_init()?; + let mut request = json!({ + "op": "resolve_token_id", + "network": network, + "ledger": ledger, + "content_id": content_id, + }); + if let Some(from_block) = env_nonempty("ELASTOS_DDRM_BUY_FROM_BLOCK") { + request["from_block"] = json!(from_block); + } + let chain_bin = resolve_chain_bin(); + let resp = run_chain_capsule(&chain_bin, &init, &request)?; + let token_id = resp + .get("token_id") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("chain-provider resolve_token_id returned no token_id: {resp}"))?; + // The operative (the per-asset ERC-1155) is needed to re-read the listing terms before broadcast. + let operative = resp + .get("operative") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + Ok((token_id, operative)) +} + +/// A read-only `eth_call` through the REAL chain-provider `contract_call` op (live Base RPC); returns +/// the raw `0x…` return data. No keys (P3). Used by the buy's pre-broadcast listing re-read (abort-on-drift). +pub(crate) fn contract_call_live(to: &str, data: &str) -> Result { + let (network, init) = live_chain_init()?; + let request = json!({ "op": "contract_call", "network": network, "to": to, "data": data }); + let chain_bin = resolve_chain_bin(); + let resp = run_chain_capsule(&chain_bin, &init, &request)?; + resp.get("result") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| format!("chain-provider contract_call returned no result: {resp}")) +} + +/// Read the latest block number via the REAL chain-provider `block_number` op (live Base RPC). +pub(crate) fn block_number_live() -> Result { + let (network, init) = live_chain_init()?; + let request = json!({ "op": "block_number", "network": network }); + let chain_bin = resolve_chain_bin(); + let resp = run_chain_capsule(&chain_bin, &init, &request)?; + let bn = resp.get("block_number"); + if let Some(s) = bn.and_then(Value::as_str) { + return u64::from_str_radix(s.trim_start_matches("0x"), 16).map_err(|e| e.to_string()); + } + bn.and_then(Value::as_u64) + .ok_or_else(|| format!("chain-provider block_number returned no usable value: {resp}")) +} + +/// Fetch `eth_getLogs` entries via the REAL chain-provider `logs` op (one window; the caller bounds +/// the range). READ-ONLY; no keys (P3). Returns the raw log array (empty on no matches). +pub(crate) fn get_logs_live(filter: Value) -> Result, String> { + let (network, init) = live_chain_init()?; + let request = json!({ "op": "logs", "network": network, "filter": filter }); + let chain_bin = resolve_chain_bin(); + let resp = run_chain_capsule(&chain_bin, &init, &request)?; + Ok(resp + .get("logs") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default()) +} + /// Broadcast a signed tx through the REAL chain-provider against an in-process JSON-RPC /// mock that answers `eth_sendRawTransaction` with a deterministic canned tx hash. The /// signed bytes are really validated and sent; `seed` only derives the canned hash. diff --git a/elastos/crates/elastos-server/src/api/mod.rs b/elastos/crates/elastos-server/src/api/mod.rs index 2f735f67..50d7c2e0 100644 --- a/elastos/crates/elastos-server/src/api/mod.rs +++ b/elastos/crates/elastos-server/src/api/mod.rs @@ -23,6 +23,7 @@ pub mod owned_ledger; pub mod rights_authority; pub mod routes; pub mod server; +pub mod trade_authority; pub mod viewer_gateway; pub mod viewer_media; pub mod viewer_object; diff --git a/elastos/crates/elastos-server/src/api/trade_authority.rs b/elastos/crates/elastos-server/src/api/trade_authority.rs new file mode 100644 index 00000000..26762631 --- /dev/null +++ b/elastos/crates/elastos-server/src/api/trade_authority.rs @@ -0,0 +1,213 @@ +//! Gateway-side SECONDARY-MARKET (access-token resale) calldata assembly: list / withdraw access for resale. +//! +//! The inverse-discipline twin of `buy_authority`: PURE — no RPC, no keys, no signing. It builds the +//! UNSIGNED `eth_sendTransaction`-shaped calldata for the ERC-1155 access-token resale flow and hands it +//! to `wallet-provider` for a human-approved signature (Principle 16). Selectors are keccak-pinned and were +//! confirmed PRESENT in the deployed Base AuthorityGateway / Operative bytecode (docs/marketplace/CONTRACTS.md +//! and verify-selectors.mjs). The caller must FIRST re-verify ownership live (`hasAccessByContentId`) and resolve +//! the real ledger `tokenId` — same Phase-1 discipline as buy. +//! +//! Verified signatures (keccak256 4-byte selector) — ARG SEMANTICS GROUNDED IN elacity-web v3 (MediaContext.tsx): +//! sellAccess(address,uint256,uint256,uint256,address) -> 0x9a3fa9f5 (LEDGER, tokenId, quantity, pricePerToken, payToken) +//! withdrawListing(address,uint256,uint256) -> 0x3e65bbba (OPERATIVE, tokenId, quantity) <- arg0 differs from sellAccess +//! setApprovalForAll(address,bool) -> 0xa22cb465 (operator=GATEWAY, approved) <- SENT TO the OPERATIVE ERC-1155 +//! +//! ASYMMETRY (real, from v3): `sellAccess` arg0 = **ledger** (DIGITAL_ASSET_LEDGER); `withdrawListing` arg0 = +//! **operative** — the gateway maps ledger<->operative internally and listings are keyed by operative. The +//! access-token resale `tokenId` is the ERC-1155 ACCESS_TOKEN **role id** (commonly `1`), distinct from +//! `buyAccess`'s media tokenId — confirm against a real listing before mainnet. The ERC-1155 approval is +//! `setApprovalForAll(operator = AuthorityGateway, true)` sent to the **Operative** contract, guarded by +//! `isApprovedForAll(account, gateway)`. `resellerCut` is NOT an arg — the chain reads it from the Operative's +//! stored config. Royalty-share resale (tokenId = ROYALTY_SHARE = 2) is a SEPARATE TradeGateway path +//! (`sellToken`/`createOffer`/`buyToken`), not handled here. +//! +//! Standalone-verifiable: std-only, so `rustc --test` runs the encoding unit tests without a workspace build. +//! Integration (wire into gateway.rs as POST /api/market/order/{sell,withdraw} + the approval leg; broadcast via +//! `chain-provider.broadcast_transaction` after the wallet signs) is a follow-up. NOT live-chain tested. + +pub const SEL_SELL_ACCESS: &str = "9a3fa9f5"; +pub const SEL_WITHDRAW_LISTING: &str = "3e65bbba"; +pub const SEL_SET_APPROVAL_FOR_ALL: &str = "a22cb465"; + +/// An UNSIGNED transaction: target + calldata only. Carries no secret, no signer, no nonce/gas +/// (sourced by `chain-provider.prepare_transaction` at sign time). `value` is "0" — resale is ERC-20 priced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnsignedTx { + pub to: String, + pub data: String, + pub value: String, +} + +/// Normalize a 20-byte hex address to a left-padded 32-byte ABI word (lowercase, no 0x). +fn word_addr(addr: &str) -> Result { + let clean = addr.strip_prefix("0x").unwrap_or(addr).to_lowercase(); + if clean.len() != 40 || !clean.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!("invalid 20-byte address: {addr}")); + } + Ok(format!("{:0>64}", clean)) +} + +/// A u128 (quantity / price in minor units) as a 32-byte ABI word. +fn word_u128(value: u128) -> String { + format!("{value:064x}") +} + +/// A pre-validated big integer (e.g. a 256-bit tokenId) given as hex (no 0x) -> 32-byte word. +fn word_u256_hex(hex: &str) -> Result { + let clean = hex.strip_prefix("0x").unwrap_or(hex).to_lowercase(); + if clean.is_empty() || clean.len() > 64 || !clean.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!("invalid uint256 hex: {hex}")); + } + Ok(format!("{clean:0>64}")) +} + +fn word_bool(b: bool) -> String { + word_u128(if b { 1 } else { 0 }) +} + +/// `sellAccess(ledger, tokenId, quantity, pricePerToken, payToken)` — list owned access for resale. +/// **arg0 = the LEDGER (DIGITAL_ASSET_LEDGER)**; `token_id_hex` = the access-token id (hex, no 0x; the ERC-1155 +/// ACCESS_TOKEN role id, commonly `1`); `price_per_token`/`quantity` in token minor units (USDC = 6 decimals); +/// `pay_token` = canonical Base USDC `0x833589fC…`. +pub fn build_sell_access( + gateway: &str, + ledger: &str, + token_id_hex: &str, + quantity: u128, + price_per_token: u128, + pay_token: &str, +) -> Result { + let data = format!( + "0x{sel}{ledger}{token}{qty}{price}{pay}", + sel = SEL_SELL_ACCESS, + ledger = word_addr(ledger)?, + token = word_u256_hex(token_id_hex)?, + qty = word_u128(quantity), + price = word_u128(price_per_token), + pay = word_addr(pay_token)?, + ); + Ok(UnsignedTx { + to: gateway.to_string(), + data, + value: "0".to_string(), + }) +} + +/// `withdrawListing(operative, tokenId, quantity)` — cancel a resale listing. The access right is unaffected. +/// **arg0 = the OPERATIVE** (NOT the ledger — listings are keyed by operative; this is the asymmetry vs sellAccess). +pub fn build_withdraw_listing( + gateway: &str, + operative: &str, + token_id_hex: &str, + quantity: u128, +) -> Result { + let data = format!( + "0x{sel}{operative}{token}{qty}", + sel = SEL_WITHDRAW_LISTING, + operative = word_addr(operative)?, + token = word_u256_hex(token_id_hex)?, + qty = word_u128(quantity), + ); + Ok(UnsignedTx { + to: gateway.to_string(), + data, + value: "0".to_string(), + }) +} + +/// `setApprovalForAll(operator, approved)` — the PRE step: approve the gateway to move the seller's ERC-1155 +/// access tokens. **Sent to the OPERATIVE (ERC-1155) contract** (`to = operative`); `operator = AuthorityGateway`. +/// Guard with `isApprovedForAll(account, gateway)` and only emit when not already approved. +pub fn build_set_approval_for_all( + operative: &str, + operator_gateway: &str, + approved: bool, +) -> Result { + let data = format!( + "0x{sel}{op}{flag}", + sel = SEL_SET_APPROVAL_FOR_ALL, + op = word_addr(operator_gateway)?, + flag = word_bool(approved), + ); + Ok(UnsignedTx { + to: operative.to_string(), + data, + value: "0".to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GW: &str = "0x09dBe796f40ECEffEAccf243c3d758C4c1d8D87D"; + const LEDGER: &str = "0x6756e1407164ae34f8df5334d48d0e45c094b8b9"; + const OPERATIVE: &str = "0x483adcf310d9344cc017536810d65a87ebcc1760"; + const USDC: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + + #[test] + fn sell_access_arg0_is_ledger_then_four_words() { + // ACCESS_TOKEN role id = 1, quantity = 2, price = 10_000 (0.01 USDC * 1e6) + let tx = build_sell_access(GW, LEDGER, "1", 2, 10_000, USDC).unwrap(); + assert_eq!(tx.to.to_lowercase(), GW.to_lowercase()); + assert_eq!(tx.value, "0"); + let body = tx.data.strip_prefix("0x").unwrap(); + assert_eq!(&body[..8], SEL_SELL_ACCESS); + assert_eq!(body.len(), 8 + 5 * 64); + assert_eq!( + &body[8..72], + &format!("{:0>64}", &LEDGER[2..].to_lowercase()) + ); // arg0 = ledger + assert_eq!(&body[72..136], &format!("{:064x}", 1u128)); // ACCESS_TOKEN tokenId + assert_eq!(&body[136..200], &format!("{:064x}", 2u128)); // quantity + assert_eq!(&body[200..264], &format!("{:064x}", 10_000u128)); // pricePerToken + assert_eq!( + &body[264..328], + &format!("{:0>64}", &USDC[2..].to_lowercase()) + ); // payToken + } + + #[test] + fn withdraw_listing_arg0_is_operative_not_ledger() { + let tx = build_withdraw_listing(GW, OPERATIVE, "1", 3).unwrap(); + let body = tx.data.strip_prefix("0x").unwrap(); + assert_eq!(&body[..8], SEL_WITHDRAW_LISTING); + assert_eq!(body.len(), 8 + 3 * 64); + assert_eq!( + &body[8..72], + &format!("{:0>64}", &OPERATIVE[2..].to_lowercase()) + ); // arg0 = OPERATIVE (the asymmetry) + assert_ne!( + &body[8..72], + &format!("{:0>64}", &LEDGER[2..].to_lowercase()) + ); // NOT the ledger + assert_eq!(&body[72..136], &format!("{:064x}", 1u128)); // tokenId + assert_eq!(&body[136..200], &format!("{:064x}", 3u128)); // quantity + } + + #[test] + fn set_approval_sent_to_operative_operator_is_gateway() { + let tx = build_set_approval_for_all(OPERATIVE, GW, true).unwrap(); + assert_eq!(tx.to.to_lowercase(), OPERATIVE.to_lowercase()); // sent to the Operative ERC-1155 + let body = tx.data.strip_prefix("0x").unwrap(); + assert_eq!(&body[..8], SEL_SET_APPROVAL_FOR_ALL); + assert_eq!(body.len(), 8 + 2 * 64); + assert_eq!(&body[8..72], &format!("{:0>64}", &GW[2..].to_lowercase())); // operator = gateway + assert_eq!(&body[72..136], &format!("{:064x}", 1u128)); // approved = true + } + + #[test] + fn rejects_malformed_address_and_tokenid() { + assert!(build_sell_access(GW, "0xnothex", "1", 1, 1, USDC).is_err()); + assert!(build_sell_access(GW, LEDGER, "0xZZ", 1, 1, USDC).is_err()); + assert!(build_sell_access(GW, LEDGER, &"f".repeat(65), 1, 1, USDC).is_err()); + // >256 bits + } + + #[test] + fn full_256bit_tokenid_round_trips() { + let big = "f".repeat(64); + let tx = build_withdraw_listing(GW, OPERATIVE, &big, 1).unwrap(); + assert_eq!(&tx.data.strip_prefix("0x").unwrap()[72..136], &big); + } +} From 2e8c3dbb822c896c8cfbbff23ec90eef4fd50ba0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 02:18:30 +0000 Subject: [PATCH 12/16] =?UTF-8?q?feat(market):=20slice=203=20=E2=80=94=20/?= =?UTF-8?q?api/market/*=20+=20content-index=20+=20storefront=20capsule=20+?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transplanted from feat/marketplace-runtime onto the workbench: - market API: search/sections/get/history/preview/vault/listed/me/acquire/ acquire-status/order-{sell,withdraw,approve} routes (gateway.rs both-added conflict resolved keeping the flint-0.5 services routes AND the market routes); handlers in gateway_marketplace.rs; content_index (AssetCreated decode + cache + short-TTL discovery collapse) and market_reads (live sellersOf/listings reads) modules wired in api/mod.rs - the marketplace-content storefront capsule (pure UI shell, no authority — Principle 16 posture; compiles natively and builds wasm32-wasip1 release) - docs/marketplace/* spec set; the retired ddrm branch's contracts doc is preserved as CONTRACTS_LEGACY_ABI_REFERENCE.md (ABI appendix + Lit notes) under its successor - fixes the slice-2 gate findings: buy_authority live-reads now resolve (market_reads landed) and its tests use the unified crate ddrm_env_lock() instead of the branch-local ENV_LOCK static Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BnpmuD7RtQ3NuTfRQJGrQb --- capsules/marketplace-content/Cargo.lock | 121 + capsules/marketplace-content/Cargo.toml | 19 + capsules/marketplace-content/README.md | 21 + capsules/marketplace-content/browser/api.js | 324 +++ capsules/marketplace-content/browser/app.js | 732 +++++ .../marketplace-content/browser/index.html | 65 + capsules/marketplace-content/browser/mock.js | 52 + .../marketplace-content/browser/styles.css | 289 ++ capsules/marketplace-content/capsule.json | 14 + capsules/marketplace-content/wasm/main.rs | 20 + docs/marketplace/API_CONTRACT.md | 60 + docs/marketplace/CONTRACTS.md | 415 +++ .../CONTRACTS_LEGACY_ABI_REFERENCE.md | 441 +++ docs/marketplace/PHASE1_BUY_INVARIANT.md | 54 + docs/marketplace/PHASE2_ENRICHMENT.md | 52 + docs/marketplace/PHASE2_INDEX_AND_API.md | 32 + docs/marketplace/PHASE3_ACQUIRE.md | 83 + docs/marketplace/PHASE3_ACQUIRE_AND_TRADE.md | 53 + docs/marketplace/PLAN.md | 73 + docs/marketplace/README.md | 15 + docs/marketplace/ROADMAP.md | 63 + docs/marketplace/SCOPE.md | 61 + docs/marketplace/UI_STRATEGY.md | 63 + docs/marketplace/design-tokens.json | 65 + docs/marketplace/index-proto.mjs | 95 + docs/marketplace/layouts.html | 191 ++ docs/marketplace/verify-selectors.mjs | 58 + .../elastos-server/src/api/buy_authority.rs | 10 +- .../elastos-server/src/api/content_index.rs | 406 +++ .../crates/elastos-server/src/api/gateway.rs | 17 + .../src/api/gateway_marketplace.rs | 2459 +++++++++++++++++ .../elastos-server/src/api/market_reads.rs | 1370 +++++++++ elastos/crates/elastos-server/src/api/mod.rs | 2 + 33 files changed, 7790 insertions(+), 5 deletions(-) create mode 100644 capsules/marketplace-content/Cargo.lock create mode 100644 capsules/marketplace-content/Cargo.toml create mode 100644 capsules/marketplace-content/README.md create mode 100644 capsules/marketplace-content/browser/api.js create mode 100644 capsules/marketplace-content/browser/app.js create mode 100644 capsules/marketplace-content/browser/index.html create mode 100644 capsules/marketplace-content/browser/mock.js create mode 100644 capsules/marketplace-content/browser/styles.css create mode 100644 capsules/marketplace-content/capsule.json create mode 100644 capsules/marketplace-content/wasm/main.rs create mode 100644 docs/marketplace/API_CONTRACT.md create mode 100644 docs/marketplace/CONTRACTS.md create mode 100644 docs/marketplace/CONTRACTS_LEGACY_ABI_REFERENCE.md create mode 100644 docs/marketplace/PHASE1_BUY_INVARIANT.md create mode 100644 docs/marketplace/PHASE2_ENRICHMENT.md create mode 100644 docs/marketplace/PHASE2_INDEX_AND_API.md create mode 100644 docs/marketplace/PHASE3_ACQUIRE.md create mode 100644 docs/marketplace/PHASE3_ACQUIRE_AND_TRADE.md create mode 100644 docs/marketplace/PLAN.md create mode 100644 docs/marketplace/README.md create mode 100644 docs/marketplace/ROADMAP.md create mode 100644 docs/marketplace/SCOPE.md create mode 100644 docs/marketplace/UI_STRATEGY.md create mode 100644 docs/marketplace/design-tokens.json create mode 100644 docs/marketplace/index-proto.mjs create mode 100644 docs/marketplace/layouts.html create mode 100644 docs/marketplace/verify-selectors.mjs create mode 100644 elastos/crates/elastos-server/src/api/content_index.rs create mode 100644 elastos/crates/elastos-server/src/api/market_reads.rs 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/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 `