Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,74 @@ records what is verified and what is not.

Open:

- [ ] Buy from any live offer, found by link or in Explore. Buying and
reading become two workflows: a purchase reads the item and every seller's
terms from the chain and answers `terms_changed` instead of charging at
terms the buyer did not see; opening keeps the existing read path, fed from
the shared Elacity `metadata.json` rather than an ElastOS-only document.
A listing is rebuildable from a `tokenURI`, a content id or
`(ledger, tokenId)`. Amends `PROTECTED_CONTENT.md` steps 7–8, done.
Design: [market buy](docs/audits/2026-09-24-protected-content-market-buy-design.md).
Branch `feat/protected-content-listing-buy`, stacked on PR #62. Source is
implemented and verified on the branch; open is installed proof only, and
needs funded principals: buy by shared link, buy after a seller changes
terms mid-flow, buy an item found only through the index (no prior link),
resale where two offers exist for the same item, a purchase of a
foreign (non-ElastOS-scheme) item, and a pending adoption finished with
Download copy. Installed 25 September on Base mainnet: an index-found
(Explore) ERC-20 buy of a foreign, Lit-custody item minted on
base.ela.city completed (`0x4d8ba231…71bc12`, access proven, adoption
`foreign`) — see the
[implementation report](docs/audits/2026-09-25-protected-content-market-buy-implementation-report.md).
Still open: shared-link buy, terms change mid-flow, two-offer resale, and
an ElastOS v1 item proving adoption, Download copy and playback.
- [ ] Follow-ups recorded from the final fix wave (R37), not required for
this slice:
- [ ] Share one stage driver between `buy` and `buy_offer` on the money
path, after the proof journeys land.
- [ ] Unify the duplicated text-bound helper.
- [ ] Replace text-matched chain errors with typed chain-provider error
codes.
- [ ] Add a chain-provider corroborate helper — also removing the three
`_at` variant duplicates — with source logging.
- [ ] Hint a buyer to wait for finality after a revert within the
finality lag, instead of a plain failure.
- [ ] Read the buy receipt at the finalized block instead of latest.
- [ ] Decode ERC-20 approval/buy calldata server-side instead of
trusting the chain-provider's steps.
- [ ] Adopt a market purchase when this Home already holds a listing of
the same mint on different terms. Adoption reuses an existing listing
only when its terms equal the purchase's (R39); with different terms
it stays `pending`. Supporting it needs per-purchase terms on the open
path.
- [ ] Cap the shared-document read inside `ipfs-provider`. The server
asks for a 256 KiB range and trims the answer (R30), but the provider's
`cat` still reads the whole file first; a true cap needs a range or
`max_bytes` on `cat`.
- [ ] Give content providers a typed not-found error. The listing tells
a missing `metadata.json` from an unreadable one by matching the
provider's error text, and probes `listing.json` only on not-found.
- [ ] Let the Marketplace capsule list adopted (`Asset`-origin) rows in
My listings, which today requires `published_at` — accept a null
`published_at` / origin `asset` row.
- [ ] Recommend a keyed RPC endpoint for production market and rights
reads. Public Base endpoints answer HTTP 429 under one Buy press; the
bounded retry and source cooldown (R47) spread the load, and a keyed
endpoint removes the limit itself.
- [ ] Reduce the chain reads per Buy by reusing the item read (operative
and `tokenURI`) across the listing, `buy_offer` and the purchase
builder's verified-listing read, instead of reading it again at each
step.
- [ ] Show the purchase transaction on a bought item's Details view.
The page does not receive it today; it needs a wire field.
- [ ] Ask the chain (item-keyed `hasAccess`) for a KID-less listing's
`access_state`; the Buy already does (R50), the listing badge does
not.
- [ ] Log a market-index failure once (the transport layer and the
catalog handler each log it today).
- [ ] Give `buy_offer`'s unavailable refusal a typed code. Marketplace
recognizes it today by the `library_error` code together with the
stable `Runtime custody purchase is unavailable` sentence (R48).
- [ ] Let a mint recover from a declined wallet approval. A rejected or expired
approval is reported accurately now instead of reading as "pending" forever,
but it leaves the mint at `EffectRaised`, and `discard_creator_state` refuses
Expand Down
5 changes: 4 additions & 1 deletion capsules/browser-engine-adapter/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2320,7 +2320,10 @@ fn launch_reconciliation_accepts_restart_persistent_terminal_cleanup_proof() {
.unwrap();

assert_eq!(response["status"], "ok");
assert_eq!(response["data"]["state"], "terminal_post_effect_cleanup");
assert_eq!(
response["data"]["state"], "terminal_post_effect_cleanup",
"{response}"
);
assert_eq!(response["data"]["effects"]["page_acquired"], true);
assert_eq!(response["data"]["effects"]["vm_acquired"], true);
}
Expand Down
132 changes: 132 additions & 0 deletions capsules/chain-provider/src/abi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ pub(super) const CHANNEL_HAS_ROLE_SELECTOR: &str = "0x91d14854";
pub(super) const CHANNEL_DEFAULT_ADMIN_ROLE_WORD: [u8; 32] = [0u8; 32];
pub(super) const ACCESS_TOKEN_ID_HEX: &str = "0x1";
pub(super) const PROTECTED_CONTENT_PURCHASE_QUANTITY_HEX: &str = "0x1";
pub(super) const AUTHORITY_GATEWAY_CSTORE_SELECTOR: &str = "0xd36f509d";
pub(super) const IP_TRACKER_IP_REFERENCE_SELECTOR: &str = "0x93d9f5ab";
pub(super) const OPERATIVE_TOKEN_URI_SELECTOR: &str = "0xc87b56dd";
pub(super) const AUTHORITY_GATEWAY_SELLERS_OF_SELECTOR: &str = "0x997eab2d";
/// `AuthorityGateway.hasAccess(address accessor, address ledger, uint256
/// tokenId)`: the item-keyed twin of `hasAccessByContentId`, answered by the
/// same `_checkUserAccess` (R50). It needs no KID, so it answers for an item
/// whose KID is unknown.
pub(super) const AUTHORITY_GATEWAY_HAS_ACCESS_SELECTOR: &str = "0xcf56b4eb";
pub(super) const EVM_ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000";

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ProtectedContentAssetCreatedLog {
Expand Down Expand Up @@ -182,6 +192,22 @@ pub(super) fn encode_authority_gateway_operative_call(
Ok(format!("0x{}", encode_hex(&bytes)))
}

pub(super) fn encode_authority_gateway_has_access_call(
accessor: &str,
ledger: &str,
token_id: &str,
) -> Result<String, String> {
let mut bytes = decode_hex(
AUTHORITY_GATEWAY_HAS_ACCESS_SELECTOR,
Some(4),
"hasAccess selector",
)?;
bytes.extend_from_slice(&abi_word_address(accessor)?);
bytes.extend_from_slice(&abi_word_address(ledger)?);
bytes.extend_from_slice(&abi_word_hex_quantity(token_id, "token_id")?);
Ok(format!("0x{}", encode_hex(&bytes)))
}

pub(super) fn encode_authority_gateway_listing_call(
operative: &str,
seller: &str,
Expand All @@ -200,6 +226,112 @@ pub(super) fn encode_authority_gateway_listing_call(
Ok(format!("0x{}", encode_hex(&bytes)))
}

pub(super) fn encode_authority_gateway_cstore_call() -> Result<String, String> {
let bytes = decode_hex(
AUTHORITY_GATEWAY_CSTORE_SELECTOR,
Some(4),
"cstore selector",
)?;
Ok(format!("0x{}", encode_hex(&bytes)))
}

/// `ipReference(bytes16)`: a bytes16 is LEFT-aligned in its word, which the
/// existing `abi_word_bytes16` already does.
pub(super) fn encode_ip_reference_call(content_access_id: &str) -> Result<String, String> {
let mut bytes = decode_hex(
IP_TRACKER_IP_REFERENCE_SELECTOR,
Some(4),
"ipReference selector",
)?;
let kid: [u8; 16] = decode_hex(content_access_id, Some(16), "content_access_id")?
.try_into()
.map_err(|_| "content_access_id must be 16 bytes".to_string())?;
bytes.extend_from_slice(&abi_word_bytes16(&kid));
Ok(format!("0x{}", encode_hex(&bytes)))
}

pub(super) fn encode_operative_token_uri_call() -> Result<String, String> {
let mut bytes = decode_hex(OPERATIVE_TOKEN_URI_SELECTOR, Some(4), "tokenURI selector")?;
bytes.extend_from_slice(&abi_word_hex_quantity(
ACCESS_TOKEN_ID_HEX,
"access_token_id",
)?);
Ok(format!("0x{}", encode_hex(&bytes)))
}

pub(super) fn encode_authority_gateway_sellers_of_call(operative: &str) -> Result<String, String> {
let mut bytes = decode_hex(
AUTHORITY_GATEWAY_SELLERS_OF_SELECTOR,
Some(4),
"sellersOf selector",
)?;
bytes.extend_from_slice(&abi_word_address(operative)?);
bytes.extend_from_slice(&abi_word_hex_quantity(
ACCESS_TOKEN_ID_HEX,
"access_token_id",
)?);
Ok(format!("0x{}", encode_hex(&bytes)))
}

/// `(address channel, uint256 tokenId)`. `word_to_address` refuses a word
/// with non-zero high bytes, exactly as `decode_evm_address_word` does.
pub(super) fn decode_ip_reference(value: &Value) -> Result<(String, String), String> {
let value = value
.as_str()
.ok_or_else(|| "ipReference result must be hex string".to_string())?;
let bytes = decode_hex(value, Some(64), "ipReference result")?;
Ok((
word_to_address(&bytes[0..32])?,
normalize_hex_quantity_bytes(&bytes[32..64]),
))
}

/// A single ABI `string` return value: word 0 is the offset of the length
/// word, which `decode_abi_string` then reads.
pub(super) fn decode_evm_string(value: &Value, label: &str) -> Result<String, String> {
let value = value
.as_str()
.ok_or_else(|| format!("{label} result must be hex string"))?;
let bytes = decode_hex(value, None, label)?;
if bytes.len() < 64 {
return Err(format!("{label} result is too short for an ABI string"));
}
let offset = usize_from_word(&bytes[0..32], label)?;
decode_abi_string(&bytes, offset, label)
}

/// A single ABI `address[]` return value. The length is bounded before
/// anything is allocated, so a hostile RPC cannot make this reserve memory.
pub(super) fn decode_evm_address_array(value: &Value, label: &str) -> Result<Vec<String>, String> {
const MAX: usize = 1024;
let value = value
.as_str()
.ok_or_else(|| format!("{label} result must be hex string"))?;
let bytes = decode_hex(value, None, label)?;
if bytes.len() < 64 {
return Err(format!("{label} result is too short for an ABI array"));
}
let offset = usize_from_word(&bytes[0..32], label)?;
let len_end = offset
.checked_add(32)
.ok_or_else(|| format!("{label} offset overflows"))?;
if len_end > bytes.len() {
return Err(format!("{label} length word is out of bounds"));
}
let length = usize_from_word(&bytes[offset..len_end], label)?;
if length > MAX {
return Err(format!("{label} has more than {MAX} entries"));
}
let end = len_end + length * 32;
if end > bytes.len() {
return Err(format!("{label} entries are truncated"));
}
bytes[len_end..end]
.chunks_exact(32)
.map(word_to_address)
.collect()
}

pub(super) fn encode_operatives_payment_processor_call() -> Result<String, String> {
let bytes = decode_hex(
PROTECTED_CONTENT_PAYMENT_PROCESSOR_SELECTOR,
Expand Down
Loading
Loading