diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 1dc4c79f..f754d6e1 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -16,6 +16,7 @@ This runbook produces a **mainnet-forward** Sepolia deployment: the Sepolia stru - **1 SystemAccount proxy** (Transparent, CREATE3-deterministic): the neutral system write-identity (ADR-0053). Has its own `ProxyAdmin`. Separate from the resolver proxies; its burn requires additional verification (see burn checklist). - **3 stateless views** (redeployable, in no UID): EFSRouter, EFSFileView, ListReader. - **9 EAS schemas** registered against the proxy addresses: ANCHOR, PROPERTY, DATA(empty), PIN, TAG, MIRROR, LIST, LIST_ENTRY, REDIRECT. (SORT_INFO + EFSSortOverlay are **deferred**, added later additively.) +- **WHITEOUT — additive post-freeze (ADR-0055):** a **10th** schema + a **7th** resolver proxy (`WhiteoutResolver`, with its own `ProxyAdmin`) registered **additively** after the nine are live — NOT part of the original freeze set. Deployed via the additive path (`deploy-lib` `detectMissingResolvers`/`buildAdditivePlan`: deploys ONLY WhiteoutResolver + registers ONLY WHITEOUT, core untouched). When present, the deployed set is 7 resolver proxies + SystemAccount (**8** CREATE3 contracts / **8** ProxyAdmins) and 10 schemas; the burn ceremony covers WhiteoutResolver's ProxyAdmin (it is **not** `OwnableUpgradeable`, so no contract-owner renounce). Full sign-off row + burn coverage: `docs/SEPOLIA_FREEZE_TABLE.md` §WHITEOUT. --- @@ -198,7 +199,7 @@ yarn deploy:efs-views --network localhost **Upgrading a resolver later (from the Safe):** in Safe{Wallet}, propose a tx to `ProxyAdmin.upgradeAndCall(proxy, newImpl, 0x)`; sign; execute. Or script it with `@safe-global/protocol-kit`. -**Burn to immutable (pre-mainnet, after a ≥14-day soak):** the pre-burn checklist is in `docs/SEPOLIA_FREEZE_TABLE.md`; when satisfied, the Safe executes `ProxyAdmin.renounceOwnership()` per proxy — all **7** proxies (6 resolver proxies + SystemAccount proxy). **The contract-owner vs ProxyAdmin distinction (PR #24 50yr-review):** each proxied contract has two independent owners — its external ProxyAdmin (controls upgrades) and, where the logic is `Ownable`/`OwnableUpgradeable`, the contract's own owner (controls its setters). `ProxyAdmin.renounceOwnership()` does NOT zero the contract-owner; both must be renounced or the Safe retains live authority over a nominally "frozen" contract. So the burn also calls **`EFSIndexer.renounceOwnership()`** and **`MirrorResolver.renounceOwnership()`** — the two `OwnableUpgradeable` resolvers. This is load-bearing for EFSIndexer: its one-shot `setSortsAnchor(...)` is never set in this freeze (SORT_INFO deferred), so until the kernel owner is renounced a post-burn Safe could weld a permanent value into the immutable kernel. (EdgeResolver/ListResolver/ListEntryResolver/AliasResolver have no owner — config is `initialize()`-only — so they need no renounce.) For SystemAccount specifically: (1) call `SystemAccount.sealModules()` **before** burning, verify `SystemAccount.modulesSealed() == true` (permanently prevents any new system-writer module from being authorized post-burn — ADR-0053 "pre-burn only" membership); (2) burn its ProxyAdmin like the others; and (3) call `SystemAccount.renounceOwnership()` to zero its own `OwnableUpgradeable` owner. Post-burn verify `EFSIndexer.owner()==0`, `MirrorResolver.owner()==0`, `SystemAccount.owner()==0` (+ every ProxyAdmin), and that `setSortsAnchor`/`upgradeAndCall` now revert. Address + UID + data unchanged; logic frozen forever. +**Burn to immutable (pre-mainnet, after a ≥14-day soak):** the pre-burn checklist is in `docs/SEPOLIA_FREEZE_TABLE.md`; when satisfied, the Safe executes `ProxyAdmin.renounceOwnership()` per proxy — all **7** proxies (6 resolver proxies + SystemAccount proxy), or **8** once the additive WhiteoutResolver (ADR-0055) is deployed (7 resolver proxies + SystemAccount). **The contract-owner vs ProxyAdmin distinction (PR #24 50yr-review):** each proxied contract has two independent owners — its external ProxyAdmin (controls upgrades) and, where the logic is `Ownable`/`OwnableUpgradeable`, the contract's own owner (controls its setters). `ProxyAdmin.renounceOwnership()` does NOT zero the contract-owner; both must be renounced or the Safe retains live authority over a nominally "frozen" contract. So the burn also calls **`EFSIndexer.renounceOwnership()`** and **`MirrorResolver.renounceOwnership()`** — the two `OwnableUpgradeable` resolvers. This is load-bearing for EFSIndexer: its one-shot `setSortsAnchor(...)` is never set in this freeze (SORT_INFO deferred), so until the kernel owner is renounced a post-burn Safe could weld a permanent value into the immutable kernel. (EdgeResolver/ListResolver/ListEntryResolver/AliasResolver/WhiteoutResolver have no owner — config is `initialize()`-only — so they need no renounce; the additive WhiteoutResolver needs only its ProxyAdmin renounced in the per-proxy step above.) For SystemAccount specifically: (1) call `SystemAccount.sealModules()` **before** burning, verify `SystemAccount.modulesSealed() == true` (permanently prevents any new system-writer module from being authorized post-burn — ADR-0053 "pre-burn only" membership); (2) burn its ProxyAdmin like the others; and (3) call `SystemAccount.renounceOwnership()` to zero its own `OwnableUpgradeable` owner. Post-burn verify `EFSIndexer.owner()==0`, `MirrorResolver.owner()==0`, `SystemAccount.owner()==0` (+ every ProxyAdmin), and that `setSortsAnchor`/`upgradeAndCall` now revert. Address + UID + data unchanged; logic frozen forever. --- diff --git a/docs/FS_OPERATIONS_AUDIT.md b/docs/FS_OPERATIONS_AUDIT.md new file mode 100644 index 00000000..590ebd53 --- /dev/null +++ b/docs/FS_OPERATIONS_AUDIT.md @@ -0,0 +1,93 @@ +# Filesystem-Operations Capability Audit (pre-launch freeze window) + +**Date:** 2026-06-20 +**Question asked:** Do we have solid plans for renaming, moving, deleting, symlinks/shortcuts, hardlinks, absolute-vs-relative paths — and anything else a filesystem needs? Should we change the frozen schemas or contracts to make the design good for the long term *while we still can* (schemas frozen on Sepolia but redeployable since no one uses them yet)? + +**Method:** 7 parallel expert subagents (one per operation + a POSIX-completeness sweep + a frozen-field economist), then 2 adversarial reviewers (a "you'll regret this" critic and an additivity-claim verifier). Every structural claim is grounded in `file:line` / ADR. This doc is the synthesis. + +--- + +## TL;DR + +**Recommend zero changes to the 9 frozen field strings or revocable flags. They are right for the long term.** Every filesystem operation either works today by composing existing primitives, or extends *additively* later (new schema / new view contract / pre-burn proxy upgrade) without orphaning data. The "redeploy while we still can" window is **mostly not the binding constraint** — only a tiny set of things actually close at first durable use, and none of them is a capability gap. + +The real to-do list is **documentation and spec reservations**, plus **two opportunistic-now proxy tweaks** you can fold into the redeploy you're already doing. Nothing here is a launch blocker; everything is on a clear, classified path. + +--- + +## The framing that matters: three tiers of "still changeable" + +"While we still can" is not one deadline — it's three. A schema's EAS UID = `keccak256(fieldString, resolverProxyAddress, revocable)` ([schemas.ts:85-87](packages/hardhat/deploy-lib/schemas.ts)). The six resolvers run behind **upgradeable proxies** until "burn" (ProxyAdmin ownership renounced). That gives three distinct change budgets: + +| Tier | What it covers | Deadline | Cost | +|---|---|---|---| +| **(a) Free forever — additive** | A *new* schema, a *new*/redeployed view contract (`EFSRouter`, `EFSFileView`, `ListReader`), a new `/transports/*` anchor, a new PROPERTY key, a new REDIRECT `kind` convention | never closes | orphans nothing | +| **(b) Free until burn — proxy upgrade** | Resolver *logic*: `MAX_ANCHOR_DEPTH`, anchor-name length cap, `expirationTime` policy, new typed REDIRECT-kind write-guards, reverse indexes in the kernel, read-time followers placed in a resolver | the **burn**, not the freeze | redeploy an impl behind the existing proxy; address + UIDs unchanged | +| **(c) Free only now — true one-way door** | The **9 field strings**, the **9 revocable flags**, and **which resolver-proxy address backs each schema** | **first durable attestation** | irreversible; orphans all prior data | + +The only things you are about to lose forever are the tier-(c) items — and the audit's finding is that **all nine field strings and flags are correct**, so tier (c) requires *no* action. Almost everything people think of as "we'd better lock this in now" is actually tier (a) or (b). + +> **Confirmed directly during review:** `MAX_ANCHOR_DEPTH = 32` is a `public constant` in implementation bytecode ([EFSIndexer.sol:141](packages/hardhat/contracts/EFSIndexer.sol)) behind the upgradeable proxy — so it is **tier (b), free until burn**, not a hard freeze door. One reviewer initially mis-classified it as irreversible; the proxy mechanics say otherwise. It's still worth deciding now (below), just not under freeze pressure. + +--- + +## Capability status — what works, what's missing + +| Operation | Works today? | Mechanism / gap | Fix tier for the gap | +|---|---|---|---| +| **Rename file** | ✅ (own content) | New name ANCHOR + re-PIN the DATA + revoke old PIN; batchable atomically in one `multiAttest`. Old anchor persists forever as a harmless husk (anchors non-revocable). | — | +| **Rename inherited file** | ⚠️ | Can add the new name but **cannot suppress** the inherited old name → shows under both. Needs **WHITEOUT** (ADR-0055). | (a) additive schema | +| **Rename folder** | ⚠️ expensive | No reparent (children bind to parent by immutable `refUID`); today = rebuild the whole subtree (~3N+3F attestations). Cheap path = read-time symlink follow. | (a) additive view | +| **Move file** | ✅ (own content) | Re-PIN under the new folder's anchor + ancestor-walk visibility TAGs + revoke old PIN. DATA / mirrors / properties preserved (path-independent). | — | +| **Move folder** | ⚠️ expensive | Same subtree-rebuild cost as folder rename, or future REDIRECT-symlink follow. | (a) additive view | +| **Delete** | ✅ | Revoke the placement PIN (→ unreachable) and/or mirrors. DATA/ANCHOR/PROPERTY are non-revocable → **"unreachable," never "gone."** Default reads exclude revoked (ADR-0051), honored by view + router. | — | +| **Delete inherited / system content from your view** | ⚠️ | Can't revoke someone else's attestation; lenses are additive-only. Needs **WHITEOUT** cross-lens negative mask (ADR-0055). | (a) additive schema | +| **Symlink / shortcut** | ⚠️ half-built | REDIRECT `kind=2` is frozen and write-time-typed by AliasResolver, **but no contract follows it at read time** ([EFSRouter `_findDataAtPath`](packages/hardhat/contracts/EFSRouter.sol) reads only the DATA-pin slot). On-chain clients get nothing until a follower ships. | (a) additive view + spec | +| **Hardlink** | ✅ native | Same DATA UID under many anchors = many PINs (PIN slot keyed on `(definition, attester, targetSchema)`, not on DATA). First-class and documented. | — | +| **Copy** | ✅ | Mint new DATA + new PIN (don't share the DATA → independent future edits). | — | +| **Versioning** | ✅ write / ⚠️ read | REDIRECT `supersededBy` (kind=1) chains DATA→DATA; "shortcut to latest" needs the same read-time follower as symlink. | (a) additive view | +| **Absolute paths** | ✅ | Container-relative-from-a-`bytes32` model: top segment classifies into address / schemaUID / attestationUID / anchor-name (ADR-0033), each seeding a parent; walk is identical. Path nodes are immutable + permanent; only their lens-scoped *content* varies. | — | +| **Relative paths (`.`/`..`)** | ❌ deliberate | `.`/`..` rejected as anchor names (ADR-0025). Everything is relative-to-a-bytes32-container natively; `../x` = `getParent` + `resolveAnchor` client-side. Sound non-goal — just undocumented. | doc | +| **URL portability** | ⚠️ | Router address is in the `web3:///…` URL; a router redeploy rots URLs. No router-independent canonical form yet (ENS `efs.eth` proposed). | (a) additive (ENS + seed) | + +**Cross-cutting "what else" sweep** (copy, stat/size/contentType/mtime/ctime/owner, mkdir/rmdir/list/dir-metadata, xattrs/tags, search, events, federation, snapshots, atomicity, quotas) all came back **supported-today or cleanly additive**. The only true *absences* are permissions (deliberate — permissionless write + lens-scoped read + client-side encryption; do **not** add a write-gate field) and the overlayfs whiteout (decided as the additive WHITEOUT schema). + +--- + +## What to actually do — classified, none are field-string changes + +### NOW — doc/spec only, before durable seeding +1. **WHITEOUT "negative-terminal" reservation + seeding ban.** The one binding pre-freeze act (ADR-0055): the REDIRECT read-time resolution spec being pinned now must leave room for a *stop-don't-follow* terminal, and no whiteout may be encoded via any sentinel (reserved `kind`, `weight<0` TAG, sentinel PIN, tombstone DATA) into durable data. No contract change. This is the single most important interaction to confirm before launch — because a positive-only resolution order frozen now could otherwise contradict lens-local delete/rename later. +2. **Pin the REDIRECT read-time resolution spec + conformance vectors** (depth cap, cycle = lowest-UID-in-SCC, lens precedence, kind-following) **before any redirect is seeded** (ADR-0050 action item 2). Frozen fields are fine; this gates *minting redirects*, not the freeze. +3. **Pin the `contentHash`/`size`/`cid` self-describing encoding** (multibase-multihash / CID, not bare keccak hex) **before any durable hash is minted.** PROPERTY is non-revocable → an early bare digest is permanent and algorithm-ambiguous. Schema (`string value`) is fine; this is a convention gate. +4. **Document three things for users/clients:** (a) deletion = *unreachable, not erased* (DATA/ANCHOR/PROPERTY persist forever — a trust/legal expectation for an archival system); (b) a cross-attester hardlink must re-attest a MIRROR + `contentType` under the placing lens, or the file resolves to unreachable bytes (lens-scoped mirror resolution); (c) no relative paths — the bytes32 container is the cursor. + +### OPPORTUNISTIC NOW — fold into the redeploy you're already doing (technically tier-b, free until burn) +5. **Raise `MAX_ANCHOR_DEPTH = 32` — it is anomalously low.** A dedicated OS/filesystem survey (2026-06-20) found **no modern filesystem imposes an explicit depth cap at all** — depth is an emergent consequence of path-length limits, never a declared maximum. ext4, XFS, Btrfs, ZFS, APFS, NTFS: *no depth limit*. The only confirmed hard depth cap in the wild is ISO 9660's 8 levels (a 1988 CD-ROM format, since lifted). Real path-length ceilings imply far deeper trees than 32: Linux `PATH_MAX` 4096 B ⇒ ~2048 levels of minimal components; Windows long-path mode 32,767 chars ⇒ ~16,000 levels; macOS `PATH_MAX` 1024 B. EFS's 32 is a hard *creation* revert (`AnchorTooDeep`, [EFSIndexer.sol:426](packages/hardhat/contracts/EFSIndexer.sol)) that would reject deep trees every real OS legally holds — directly violating the mirror-external-filesystems goal. (Real trees stay shallow in practice — worst cited `node_modules` ≈11 levels, Java/Hive 4–6 — but the cap must not reject what an OS *can* address.) + + **Why a cap exists at all:** to bound gas on the ancestor-chain walks (`_propagateContains`, `_indexGlobal`) — linear in depth, amortized-O(1) in steady state via early-break (ADR-0021). That purpose is best served by a **depth counter** (a path-byte-length cap, as real OSes use, does *not* bound the walk — 2048 single-char components = 2048 iterations). So keep the counter form; just raise the number. Gas is the only cost and it stays well under block limit: ~2.1K gas/level ⇒ ~537K at 256, ~2.1M at 1024 (a one-time cost borne only by whoever creates a pathologically deep chain). + + **✅ DECIDED & DONE (2026-06-20): raised to 1024** ([ADR-0065](docs/adr/0065-raise-max-anchor-depth-and-no-name-length-cap.md), supersedes ADR-0021). Clean power of two, ~90× the deepest real-world tree ever cited, gas-safe (~2.1M worst-case one-time, self-paid), keeps the depth-counter form. Free-until-burn proxy upgrade, folded into the redeploy. (256 was the conservative fallback; 32/128/256 all rejected as they'd reject legal real-OS trees.) + + **↳ CORRECTION (2026-06-23): lowered to 256** ([ADR-0068](docs/adr/0068-lower-max-anchor-depth-to-256-for-propagation-gas.md), supersedes the depth value above). The "~2.1K gas/level / gas-safe at 1024" figure here was wrong — it's a warm SLOAD, but the walk does ~4 **cold** zero→nonzero SSTOREs/level (~90k gas/level). At 1024 a first-time deep placement (a fresh lens hardlinking/moving inherited content) needs ~92M gas, over the ~30M block limit → it reverts. 256 (~23M worst-case) is the gas-safe-but-still-generous cap — exactly the "conservative fallback" this item parenthesized. The no-name-length-cap decision (item 6) is unaffected. +6. **Anchor-name length cap — ✅ DECIDED AGAINST (2026-06-20):** do NOT cap ([ADR-0065](docs/adr/0065-raise-max-anchor-depth-and-no-name-length-cap.md)). It's not required for correctness; a natural ceiling already exists (validation + calldata cost is O(length), so absurd names exceed block gas and revert, self-paid); lens-scoping means a huge name only burdens viewers who trust its creator (no public grief surface); and display truncation is a client concern (ADR-0056 principle). Generosity future-proofs legitimate long Schelling-point names (hashes, CIDs, descriptors). Note: every real OS *does* cap per-component names at ~255, so a name >255 can't round-trip *out* to a conventional filesystem — but that's a client/export concern and mirror-*in* never produces one. A generous 8192-byte backstop (matching `MAX_URI_LENGTH`) remains available if pure anti-bloat is ever wanted; deliberately not added now. + +### PRE-BURN — decide before immutability, not before freeze +7. **`expirationTime == 0` stance.** Every resolver rejects expiry today; keep it for v1 (avoids a third active/revoked/expired filter state). Explicitly revisit allowing expiry on **MIRROR** specifically (already revocable; expired ≈ revoked) before burn. It's resolver logic (tier b), so the deadline is burn. +8. **Event-versioning slot** for subgraph consumers (event ABIs harden at burn). +9. **SORT_INFO** registration — verified genuinely additive (the kernel stores it zeroed and the overlay reaches the kernel through *permissionless* `index()`, not the authorized path). Register the 10th schema whenever sorting is ready. + +### DEFER — post-launch additive, no freeze impact +- WHITEOUT schema + `WhiteoutResolver` + `EFSFileView` redeploy. +- Read-time symlink/redirect follower in the redeployable `EFSFileView`/`EFSRouter` (so on-chain clients aren't each re-implementing graph-walking). +- On-chain reverse content index (find-DATA-by-hash; enumerate-hardlinks / link-count). The kernel already keeps forward reverse-reference indices; this is a view or pre-burn kernel upgrade. +- ENS `efs.eth` router naming for URL portability across router redeploys. + +### One sign-off worth making explicit +**The revocable-flag symmetry is the most load-bearing schema decision in the set:** value/identity schemas non-revocable (ANCHOR, DATA, LIST, PROPERTY); claim/edge schemas revocable (PIN, TAG, MIRROR, LIST_ENTRY, REDIRECT) (ADR-0052). It's what makes shared values safe (can't be yanked from bindings) and deletion possible (revoke the edge). Every flip is a tier-(c) one-way door. They currently all match the principle — worth a deliberate human "yes, confirmed" against the table before first durable use. + +--- + +## Bottom line + +The frozen 9-schema surface is **future-proof as-is**, and the FS-operations lens confirms the repo's earlier "zero freeze-blockers" verdict. The architecture's payoff — minimal core identity/edge schemas with everything optional pushed into PROPERTYs, sibling schemas, and open `bytes32`/enum vocabularies — is exactly what makes the minimalism safe: DATA is empty (nothing can be missing), MIRROR/TAG/REDIRECT/ANCHOR carry open vocabularies (new kinds need no new UID), and LIST_ENTRY already proved a field can be *extracted* into a PROPERTY (ADR-0046). The only items that genuinely close at launch (field strings, flags, resolver bindings) are all correct. Everything else is additive-forever or free-until-burn. **Don't spend the freeze window on schema changes — spend it on the four NOW doc/spec reservations, and opportunistically bump `MAX_ANCHOR_DEPTH` + add the name-length cap into the redeploy.** diff --git a/docs/FUTURE_WORK.md b/docs/FUTURE_WORK.md index b03fcd90..5acad65a 100644 --- a/docs/FUTURE_WORK.md +++ b/docs/FUTURE_WORK.md @@ -79,9 +79,11 @@ from these findings, don't re-derive:** `_walkPhase1` helpers (already refactored for the viaIR stack limit); a unified cursor needs a phase/index-path discriminator so the all-children vs by-schema index paths don't collide. -### WHITEOUT — cross-lens negative masking (overlay deletion) [post-freeze additive; reserve concept pre-freeze] +### WHITEOUT opaque-directory variant — "show only my children here" [DEFERRED; post-freeze additive] -EFS lenses are additive-only: there is no way to assert "render this path empty in my view without substituting my own content" — the overlayfs whiteout, i.e. a lens-local _delete_ of inherited content and the other half of a lens-local _rename_. Decided (multi-agent verification 2026-06-18): a **dedicated WHITEOUT schema + resolver, registered additively post-freeze** — NOT a REDIRECT `kind`, NOT TAG `weight<0` (both rejected). The only pre-freeze act is a reservation: the REDIRECT read-time resolution spec must leave room for a "negative terminal" in the lens scan, and no sentinel-kind / `weight<0` whiteout may be seeded into durable data before the schema ships. [ADR-0055] +The per-name **WHITEOUT** schema (the suppressed child-path ANCHOR marker) is **IMPLEMENTED** (ADR-0055; `WhiteoutResolver.sol` + negative-terminal resolution in `EFSFileView`/`EFSRouter` + the folder re-add fix). The **opaque-directory variant** — _"show only MY children in this folder; suppress all lower-lens children, including ones added later"_ — was built and then **DROPPED 2026-06-21** (no concrete use case; James's call). It re-adds **additively** if a real curation need appears: a new schema (distinct field string, e.g. `"bool opaque"`, to avoid colliding with the per-name UID) on the SAME `WhiteoutResolver`, plus the read-side cut in `EFSFileView` (page-level opaque cut-line folded into the listing predicate) and `EFSRouter` (per-segment cut). A new schema + resolver orphans nothing, so this is a clean later addition. + +When/if opaque re-adds, two consistency/defense-in-depth follow-ups apply (both moot until then): a **`MAX_PATH_SEGMENTS` cap** on the EFSRouter URL walk (the per-segment opaque cut is a constant-factor on an already-O(segments) `view`-only read; the cap bounds the worst case), and a **paged `getOpaqueDirs(attester, start, length)` reader** on WhiteoutResolver co-shaped with the per-name `getChildrenWhitedOut` enumerator (additive view ABI — must ship before the resolver is burned to immutable if wanted on-chain). [ADR-0055] ### Kernel read-DoS: budgeted scan on the address-list getters [pre-mainnet, upgrade-safe] @@ -132,13 +134,15 @@ ADR-0050 defers multi-hop cycle/chain resolution (cycle → lowest-UID-in-SCC, d **Done (2026-06-17):** EdgeResolver emits `PinSet`/`PinCleared`/`TagSet`/`TagCleared` (TagSet carries `supersededTagUID`, symmetric with PinSet); MirrorResolver emits `MirrorSet`/`MirrorCleared` (both carry indexed `transportDefinition`); `AnchorCreated` carries `string name`; AliasResolver `RedirectAttested`/`RedirectRevoked` index `redirectUID`. EFS is now reconstructable from event logs alone. Residual (optional, not blocking): the forward-compatible event-versioning slot below. Original analysis retained for context: A subgraph-indexability audit found EFS is **not** reconstructable from event logs as-is. Kernel-native schemas (ANCHOR/DATA/PROPERTY) and the partner resolvers (LIST/LIST_ENTRY/REDIRECT/SORT) emit rich events and are subgraph-ready. But **EdgeResolver (PIN/TAG) and MirrorResolver emit zero events of their own** — they pass through only the data-less `AttestationIndexed(uid, schema, attester)`, and EAS's own `Attested` carries no field data. So file placement (PIN), which DATA resolves at a path, **PIN supersession** (silent re-pin), TAG sets + weights, and **MIRROR uri** are invisible to a pure-event indexer — i.e. the core question EFS answers can't be indexed without per-attestation `eth_call`s (not a real subgraph). These are resolver-LOGIC additions (not schema changes) → upgrade-safe until the mainnet burn, after which event ABIs harden permanently; the PIN-supersession signal in particular is unrecoverable post-burn. Add before burn (before the Sepolia freeze if a hackathon subgraph is wanted, so indexers bind to the final ABI): `PinSet(definition indexed, attester indexed, targetSchema indexed, pinUID, targetID, supersededPinUID)`, `PinCleared(...)`, `TagSet(... , targetID, int256 weight)`, `TagCleared(...)`, `MirrorURISet(dataUID indexed, mirrorUID indexed, attester indexed, transportDefinition, string uri)`, and enrich `AnchorCreated` with `string name`. Consider a forward-compatible event-versioning slot (see "Forward-compatible event schema" above) since event signatures are Etched at burn. Also fixes a Tier-2 spec/code gap: specs/03 claims directory state reconstructs "without additional contract reads," which is currently false for the edge/mirror layer. -### contentHash self-describing encoding spec + vectors [before durable seeding] +### contentHash self-describing encoding — uploaders must emit canonical `f1220…` [before durable seeding] + +(From the 2026-06-17 CS/crypto sweep.) **Spec side DONE (PR #37):** the canonical encoding is pinned — `specs/10-file-metadata-encoding.md` + ADR-0064 mandate a multibase-base16 multihash (`f1220…`, sha2-256 canonical, sharing the IPFS CID digest), and the workflow docs (`specs/04`) were updated off the old bare-`keccak256` prescription. **Remaining gap (Ephemeral writers): the same-repo debug-UI uploader and the SDK still compute a bare `keccak256` hex** — `packages/nextjs/utils/efs/transports.ts` `computeContentHash` returns `keccak256(data)`, bound as the `contentHash` PROPERTY in `lib/efs/uploadOnchainFile.ts` + `components/explorer/CreateItemModal.tsx`; the production client (separate repo `efs-project/client`) is the conformant writer that matters at launch. Update these writers to emit the canonical `f1220…` form (viem `sha256` → `f1220` + 64 hex) so any pre-launch-seeded `contentHash` is recognized by conforming readers/dedup. Not contained to the hash util: `contentHash` is currently typed `0x${string}` and is also fed as a **bytes32** arg to the legacy `dataByContentKey()` dedup call (`uploadOnchainFile.ts:311`, already removed in the modal) — that dead read must be dropped (or kept on a separate keccak bytes32) when the PROPERTY value moves to the non-`0x` multihash string. PROPERTY values are non-revocable, but the debug UI runs on the weekly-reset devnet, so this is pre-mainnet hardening, not a permanent-data emergency. Flagged again in PR #37 review (deferred — Ephemeral surface, unenforced convention). Also consider self-labeling the algorithm in the etched `PropertyCreated.valueHash` story. -(From the 2026-06-17 CS/crypto sweep.) ADR-0049 reserves `contentHash`/`size`/`cid` as PROPERTY values and calls for a multibase-multihash (self-describing) encoding, but the conventions spec + reference vectors are unwritten and the workflow docs (`specs/04`) currently prescribe a bare `keccak256` hex with no algorithm prefix. A bare digest is algorithm-ambiguous: a 2050 reader (or an SDK verifier) can't tell keccak256 from sha2-256, and PROPERTY values are non-revocable so early-seeded bare hashes are permanent. Pin a self-describing format (e.g. multihash/CID, or a `keccak256:` prefix) + conformance vectors, and update the workflow docs, **before any real data is minted under `contentHash`**. The frozen schema is fine (`string value` carries anything); this is a convention/spec gap. Also consider self-labeling the algorithm in the etched `PropertyCreated.valueHash` story. +### `index()` `_containsAttestations` spam filter [DONE 2026-06-23 — ADR-0066] -### `index()` `_containsAttestations` spam filter [upgrade-safe hardening] +**Done (PR #37, ADR-0066):** `index()`/`indexBatch()` are now **discovery-only** — `_indexGlobal` takes a `propagateFolderPresence` flag that is `true` only on the native `onAttest` path and `false` for the permissionless `index()` path, so a discovery call no longer walks `_containsAttestations`/`_childrenByAttester`. Universal discovery is preserved (every schema, incl. WHITEOUT + foreign, is findable + keeps its accurate schema-scoped `_containsSchemaAttestations` fact); folder presence stays owned by intentional placement (PIN/TAG via `EdgeResolver.propagateContains`, anchor creation). This closes BOTH the general spam/bloat vector below AND the PR #37 WHITEOUT-positive-presence finding. Original write-up retained for context: -(From the 2026-06-17 STRIDE sweep.) The permissionless `index()`/`indexBatch()` path runs `_indexGlobal`, which sets `_containsAttestations[ancestor][attester]` and appends to `_childrenByAttester` for ANY EAS attestation whose `refUID` hits an EFS anchor — including a garbage foreign-schema attestation. So an attacker can make themselves appear as a "contributor" in a popular folder's lens-scoped directory listing (and permanently bloat the append-only `_childrenByAttester` arrays) without placing real EFS content. Not a content-injection (PIN reads stay empty/lens-gated); a listing-noise + state-bloat vector. Mitigation: gate the `_containsAttestations`/`_childrenByAttester` propagation behind an EFS-wired-schema allowlist (PIN/TAG/MIRROR/LIST_ENTRY/REDIRECT) before the propagation loop. Upgrade-safe (resolver/kernel logic, no schema change). +(From the 2026-06-17 STRIDE sweep.) The permissionless `index()`/`indexBatch()` path runs `_indexGlobal`, which sets `_containsAttestations[ancestor][attester]` and appends to `_childrenByAttester` for ANY EAS attestation whose `refUID` hits an EFS anchor — including a garbage foreign-schema attestation. So an attacker can make themselves appear as a "contributor" in a popular folder's lens-scoped directory listing (and permanently bloat the append-only `_childrenByAttester` arrays) without placing real EFS content. Not a content-injection (PIN reads stay empty/lens-gated); a listing-noise + state-bloat vector. **WHITEOUT is a concrete instance of this general property, flagged in PR #37 review:** because WHITEOUT's `refUID` is a path anchor, feeding a WHITEOUT UID to `index()` makes a purely-*negative* marker contribute *positive* folder presence in the schema-blind `getChildrenByAddressList`/`getDirectoryPageByAddressList` view (self-lens only — `_indexGlobal` keys on the attester; the per-schema listing, router, and `getFilesAtPath` are unaffected; `WhiteoutResolver` never calls `index()`, so only deliberate `index()`/bulk-helper misuse triggers it). The allowlist mitigation below covers WHITEOUT for free — and is strictly better than teaching the kernel WHITEOUT's UID, which the frozen kernel can't know (WHITEOUT is additive post-freeze). Mitigation: gate the `_containsAttestations`/`_childrenByAttester` propagation behind an EFS-wired-schema **allowlist** (PIN/TAG/MIRROR/LIST_ENTRY/REDIRECT — the positive-contribution schemas the kernel already knows) before the propagation loop; everything else (foreign schemas, WHITEOUT) is indexed for discovery but does not propagate folder presence. Upgrade-safe (resolver/kernel logic, no schema change). ### `expirationTime` policy: keep `== 0` or allow time-bounded? [pre-burn decision] @@ -481,9 +485,9 @@ Public ingress is gateway-only (`/arweave/` reads succeed; `POST /arweave/ Single most important pre-mainnet item. EFSIndexer is permanent and the kernel of the system — one external pass from a credentialed firm (Trail of Bits, OpenZeppelin, Code4rena contest) is worth the cost. See `docs/LAUNCH_CHECKLIST.md`. -### Anchor name length cap +### Anchor name length cap [DECIDED AGAINST 2026-06-20 — ADR-0065] -`_isValidAnchorName` (ADR-0025) doesn't currently cap byte length. A 100KB filename is technically allowed. Add a cap (e.g. 255 bytes) to prevent storage-cost griefing. +~~`_isValidAnchorName` (ADR-0025) doesn't currently cap byte length. Add a cap (e.g. 255 bytes).~~ **Resolved: no cap** ([ADR-0065](adr/0065-raise-max-anchor-depth-and-no-name-length-cap.md)). Not needed for correctness; calldata + O(length) validation cost makes absurd names exceed block gas (self-paid), and lens-scoping means a huge name only burdens viewers who trust its creator — no public grief surface. Presentation/truncation is a client concern (ADR-0056 principle); long names can be legitimate Schelling points. An 8192-byte backstop (matching `MAX_URI_LENGTH`) stays available if pure anti-bloat is ever wanted. ### Fuzzing / property tests for path resolution diff --git a/docs/SEPOLIA_FREEZE_TABLE.md b/docs/SEPOLIA_FREEZE_TABLE.md index ef7da7e1..2b71b39f 100644 --- a/docs/SEPOLIA_FREEZE_TABLE.md +++ b/docs/SEPOLIA_FREEZE_TABLE.md @@ -26,6 +26,16 @@ A first-principles + adversarial durability review (28 candidate cracks, all ref **REDIRECT (ADR-0050):** canonical/`sameAs`/`supersededBy`/`symlink` redirect. `refUID` = source; `target` = destination. Only `uint16 kind` is frozen — the kind *taxonomy* is upgradeable resolver logic + convention. (`uint16` not `uint8`: widening is free under ABI padding, and `kind` is an open relationship vocabulary, so the irreversible field takes zero-cost headroom; see ADR-0050.) Write-time guards in `AliasResolver`; multi-hop cycle/chain resolution is a Durable read-time spec (cycle → lowest-UID-in-SCC), pinned with conformance vectors before durable seeding. Hardlinks remain native (one DATA, many PINs). +## WHITEOUT — additive 10th schema (post-freeze, ADR-0055) + +WHITEOUT is **NOT part of the signed 9-schema freeze above** — it is an **additive post-freeze** schema (ADR-0055): a new schema + a new `WhiteoutResolver` proxy registered *after* the original nine are live, via the additive deploy path (`deploy-lib` `detectMissingResolvers`/`buildAdditivePlan`; deploys ONLY WhiteoutResolver + registers ONLY WHITEOUT, the core untouched). Adding it orphans nothing — a new schema UID is independent of the nine. It gets its **own one-row sign-off** at the additive deploy (same gate discipline: addresses + UID filled after the atomic CREATE3 deploy+init and verify gate, signed before register). + +| # | Schema | Field string (exact) | revocable | Resolver (proxy) | Schema UID | +|---|---|---|---|---|---| +| 10 | WHITEOUT | `` (empty — pure-identity negative marker) | `true` | WhiteoutResolver proxy `` | `` | + +**WHITEOUT (ADR-0055):** empty field string = pure-identity negative marker (same idiom as DATA); `revocable: true` (revoke == un-hide). `WhiteoutResolver` self-derives its WHITEOUT schema UID in `initialize()` and write-guards source/payload/revocability. It is **not** `OwnableUpgradeable` (no deployer/owner-gated functions — like EdgeResolver/ListResolver/ListEntryResolver/AliasResolver), so at burn only its **ProxyAdmin** is renounced; there is no contract-owner to zero. Its proxy + ProxyAdmin **join the burn ceremony** (see the burn checklist — 8 ProxyAdmins once WHITEOUT is deployed). + ## Realized deploy facts (Sepolia — chainId 11155111) Filled from the live Safe-native deploy (`deploy:efs --via-safe --network sepolia`). Addresses/UIDs are @@ -80,8 +90,8 @@ are not part of the freeze. - [ ] Human sign-off on the FREEZE_LEDGER ("no pending field changes"). - [ ] **SystemAccount pre-burn:** call `SystemAccount.sealModules()` — permanently prevents any new system-writer module from being authorized post-burn (ADR-0053 "pre-burn only" membership). Verify `SystemAccount.modulesSealed() == true` before proceeding. (Do this while the Safe is still owner; the `renounceOwnership()` in (b) below is the final owner action.) - [ ] BURN — LAST actions. Each proxied contract has TWO independent owners — its external **ProxyAdmin** (controls `upgradeAndCall`) and, where the logic is `Ownable`/`OwnableUpgradeable`, the **contract's own owner** (controls its privileged setters). Renouncing the ProxyAdmin does NOT touch the contract-owner; both must be zeroed or the system is not actually immutable (PR #24 50yr-review). LAST actions: - - (a) `ProxyAdmin.renounceOwnership()` for all **7** ProxyAdmins (6 resolver proxies + the SystemAccount proxy) — freezes logic (no further `upgradeAndCall`). - - (b) `EFSIndexer.renounceOwnership()` and `MirrorResolver.renounceOwnership()` — the two `OwnableUpgradeable` resolvers. **Required:** until this, the Safe can still call their owner-only setters on a "frozen" contract — notably the one-shot `EFSIndexer.setSortsAnchor(...)`, which is **never set during this freeze** (SORT_INFO deferred → `sortsAnchorUID == 0`, guard still open), so a post-burn Safe could weld a permanent kernel value into the immutable kernel. Renouncing closes it. (EdgeResolver/ListResolver/ListEntryResolver/AliasResolver have no owner — config is set once in `initialize()` — so they need no renounce.) + - (a) `ProxyAdmin.renounceOwnership()` for all ProxyAdmins — **7** without WHITEOUT (6 resolver proxies + the SystemAccount proxy), or **8** once the additive WhiteoutResolver proxy (ADR-0055) is deployed (7 resolver proxies + SystemAccount). Freezes logic (no further `upgradeAndCall`). + - (b) `EFSIndexer.renounceOwnership()` and `MirrorResolver.renounceOwnership()` — the two `OwnableUpgradeable` resolvers. **Required:** until this, the Safe can still call their owner-only setters on a "frozen" contract — notably the one-shot `EFSIndexer.setSortsAnchor(...)`, which is **never set during this freeze** (SORT_INFO deferred → `sortsAnchorUID == 0`, guard still open), so a post-burn Safe could weld a permanent kernel value into the immutable kernel. Renouncing closes it. (EdgeResolver/ListResolver/ListEntryResolver/AliasResolver/**WhiteoutResolver** have no owner — config is set once in `initialize()` — so they need no renounce; only their ProxyAdmin in (a).) - (c) `SystemAccount.renounceOwnership()` — zeroes the proxied contract's own `OwnableUpgradeable` owner (distinct from its ProxyAdmin). Safe to renounce: `sealModules()` + `seal()` have already frozen every meaningful owner power, and ongoing system-content authoring runs through the sealed `onlyAuthorizedModule` relay, not the owner. - Post-burn verify: `owner()==0` for each ProxyAdmin, **`EFSIndexer.owner()==0`, `MirrorResolver.owner()==0`, `SystemAccount.owner()==0`**, `SystemAccount.modulesSealed()==true`, ex-owner `upgradeAndCall` reverts, `EFSIndexer.setSortsAnchor(...)` reverts, attestations still succeed; record burn txs + blocks. **After this, every privileged function across all contracts is frozen — the system is ownerless and immutable.** diff --git a/docs/adr/0021-max-anchor-depth.md b/docs/adr/0021-max-anchor-depth.md index 4142413b..e062aee3 100644 --- a/docs/adr/0021-max-anchor-depth.md +++ b/docs/adr/0021-max-anchor-depth.md @@ -1,8 +1,8 @@ # ADR-0021: `MAX_ANCHOR_DEPTH = 32` -**Status:** Accepted +**Status:** Superseded by ADR-0065 (value raised 32 → 1024; the bounded-walk reasoning still holds, only the number changed) **Date:** 2026-04-16 (formalized retroactively) -**Related:** ADR-0008, ADR-0010 +**Related:** ADR-0008, ADR-0010, ADR-0065 ## Context diff --git a/docs/adr/0055-whiteout-cross-lens-negative-mask.md b/docs/adr/0055-whiteout-cross-lens-negative-mask.md index 30af1210..6ad15238 100644 --- a/docs/adr/0055-whiteout-cross-lens-negative-mask.md +++ b/docs/adr/0055-whiteout-cross-lens-negative-mask.md @@ -1,6 +1,6 @@ # ADR-0055: WHITEOUT — cross-lens negative masking (overlay deletion) -**Status:** Accepted (2026-06-18 — ratified by James in review of this PR; whiteout = dedicated post-freeze schema. The `kind >= 3` guard question is resolved **NO** — see Pre-freeze reservation §3.) +**Status:** Accepted (2026-06-18 — ratified by James; whiteout = dedicated post-freeze schema; the `kind >= 3` guard question is resolved **NO**, see Pre-freeze reservation §3). **Amended 2026-06-21 — shipped scope (Status-line amendment per `docs/adr/README.md`; the Decision/Consequences/Alternatives body is preserved unedited as the historical record):** WHITEOUT shipped as a **per-name** negative marker on a path **ANCHOR only**, with negative-terminal resolution (listing + router walk) and the folder re-add (visibility-TAG-beats-own-whiteout) fix. The **`refUID = DATA` variant and the opaque-directory marker that the Decision (§intended shape) and Required-semantics below still describe are NOT in the shipped scope**: DATA-`refUID` whiteout is **dropped** (not a unionfs concept — overlayfs has no inode-level whiteout), and the opaque-directory marker is **deferred** (no concrete use case yet). Both re-add additively (the same `WhiteoutResolver`, a read-time rule or sibling schema) per this ADR's own "leave room" design if a real need appears. **The body below is the original full design vision; `specs/02` / `specs/04` are authoritative for shipped behavior.** Implementation: `WhiteoutResolver.sol`, `EFSFileView.sol`, `EFSRouter.sol`, `test/Whiteout.test.ts`. **Date:** 2026-06-18 **Deciders:** James (schema freeze is human-gated) **Verification:** A 12-agent system-design workflow (2026-06-18 — 6 independent expert lenses → synthesis → 4 adversarial verifiers against the live frozen contracts → decision) evaluated the dedicated-schema choice against the lighter alternatives (reuse-PIN/sentinel, tombstone DATA, kernel-co-indexed). Consensus: candidate (a), this ADR's pick, is the only encoding that is simultaneously write-safe, self-describing, and post-freeze-additive. Three of four adversarial attacks (post-freeze-additivity, read-cost-doubling, opaque-directory/viewer-sovereignty) were refuted against source; the fourth surfaced the `kind >= 3` write-hole now recorded as Pre-freeze reservation §3. The read-cost concern resolved to a build requirement (single-pass / co-indexed reads), folded into Required semantics — not a reason to change encoding. diff --git a/docs/adr/0064-content-hash-self-describing-encoding.md b/docs/adr/0064-content-hash-self-describing-encoding.md new file mode 100644 index 00000000..8dae3692 --- /dev/null +++ b/docs/adr/0064-content-hash-self-describing-encoding.md @@ -0,0 +1,128 @@ +# ADR-0064: `contentHash`/`size`/`cid` use a self-describing encoding (multibase-multihash + CID) + +**Status:** Accepted (James ratified 2026-06-20) +**Date:** 2026-06-20 +**Deciders:** James (convention pinned before durable seeding is human-gated) +**Permanence-tier:** Durable (convention + reference vectors; no schema change) +**Related:** ADR-0049 (DATA empty; `contentHash`/`size`/`cid` as reserved-key PROPERTYs), ADR-0052 (PROPERTY non-revocable — the PIN is the revocable claim), ADR-0005 (`contentType` reserved-key precedent), ADR-0034 (`name` reserved key), ADR-0014 (lens-scoped PROPERTY reads), ADR-0019 (non-reverting hex parser), specs/10 (the normative convention + conformance vectors), `EFSIndexer.PropertyCreated` (the `valueHash` "canonical-hashing spec" reference), `docs/FUTURE_WORK.md` ("contentHash self-describing encoding spec + vectors"), `docs/FS_OPERATIONS_AUDIT.md` §gate. + +## Context + +ADR-0049 reshaped DATA to an empty schema and moved `contentHash` and `size` out +of DATA into **reserved-key PROPERTYs** bound to the DATA UID (with `cid` for the +IPFS side), lens-scoped per attester. The PROPERTY schema field is `string value` +— **frozen** and format-agnostic, so the *schema* is fine. The unsettled piece is +the **encoding convention** for these values, and two facts make getting it right +urgent and one-shot: + +1. **PROPERTY values are non-revocable** (ADR-0052). A value, once minted and + interned, is permanent; only the *binding* (the PIN) is revocable. An early + bad/ambiguous value string can never be cleaned up — it persists forever and + pollutes the `valueHash` interning/dedup index. +2. **The current workflow prose is algorithm-ambiguous.** `specs/04` implies a + **bare keccak256 hex** for `contentHash`. A bare 64-hex digest is + indistinguishable between keccak-256 (EVM-native) and sha2-256 (IPFS-native): + a future SDK or Solidity verifier cannot tell which function to recompute + against the bytes, so the claim is unverifiable. `EFSIndexer.PropertyCreated` + already defers to a "forthcoming canonical-hashing spec"; this is that spec's + decision record. The gap is flagged in `docs/FUTURE_WORK.md` and the FS-ops + audit as a **gate before durable seeding**. + +Because EFS already targets IPFS/IPLD (it has `ipfs://` mirrors, a `/transports/ipfs` +anchor, and a zero-download remote-pin flow that lifts the sha2-256 digest out of +a CID), the encoding should be the one the IPFS/IPLD world already speaks. + +## Decision + +Adopt a **self-describing, algorithm-tagged** encoding for the file-stat values. +The full normative spec — exact byte layout, the closed reserved-key registry, +verification semantics, and conformance vectors — is **specs/10**. The decision: + +1. **`contentHash` is a multibase-prefixed [multihash].** The hash-function code + (`0x12` sha2-256, `0x1b` keccak-256) and digest length travel inside the + string, so it is self-describing. **Canonical emit form is multibase `base16` + lowercase (prefix `f`)** — e.g. `f1220<64 hex>` for sha2-256 — because it + round-trips trivially to a Solidity `bytes32` via the existing non-reverting + hex parser (ADR-0019), needing no on-chain base32 decoder. Readers SHOULD also + accept `base32` (prefix `b`). **sha2-256 is the canonical/default function** + (James ratified 2026-06-20) so a file's EFS `contentHash` and its IPFS CID + **share the same digest** — one hash, not two. keccak-256 remains an optional + alternate the self-describing format CAN carry (EVM-native), not the default. + +2. **`cid` is a distinct reserved key** carrying a full IPFS CID + (`CID.toString()`, CIDv1 `base32` `bafkrei…`). A CID is a superset of a + multihash (it adds the IPLD content codec), so `cid` is the right key when the + content is IPFS-addressable and doubles as the `ipfs://` mirror key and + the zero-download remote-pin value. `contentHash` (bare digest, EVM side) and + `cid` (IPFS-framed) are **complementary**; both MAY coexist on one DATA. + +3. **`size` is a base-10 ASCII byte count** — no leading zeros, no sign, no unit, + no fixed width. One canonical string per length (so values intern/dedup); + decimal because it is the obvious unambiguous default and needs no algorithm + tag. + +4. **The reserved-key set is closed and registered** (specs/10 §5): + `contentType`, `name`, `contentHash`, `size`, `cid`. Multiple coexisting + hashes (keccak + sha2) are expressed **by encoding** — a self-describing + `contentHash` slot plus the separate `cid` key — not by minting an + algorithm-suffixed keyspace. + +5. **No schema change.** PROPERTY stays `string value` (frozen); DATA stays empty + (ADR-0049). This ADR is a Durable *convention* gated to land **before any + durable data is seeded** under these keys — after seeding, the format is + permanent for those values. + +Verification stays a **lens-scoped, read-time, off-chain** consumer +responsibility (ADR-0049/0014): the kernel never checks hash↔bytes; a reader +decodes the multihash code, recomputes that function over fetched bytes, and +compares. Retraction is by **revoking the PIN binding, never the value** +(ADR-0052). + +## Consequences + +- **Enables.** Permanently verifiable claims — a 2050 verifier reads the + function out of the value. With **sha2-256 canonical**, a file's `contentHash` + and its IPFS `cid` carry **one shared digest**, not two — EVM and IPFS hash + worlds coexist with the common case needing only one hash; keccak-256 stays + available as the optional alternate the format can carry. Cheap Solidity + verification (base16 → `bytes32` with the existing parser). Conformance vectors + let an SDK author and a Solidity verifier produce byte-identical values. +- **Costs / implies.** **SDK and any verifying contract MUST agree** on specs/10 + exactly — a deviation produces a distinct, permanent, non-deduping value. + `specs/04` workflow prose must change from bare keccak hex to the canonical + sha2-256 `f1220…` form (owned by another agent; specs/10 §8 is the target). + Writers must emit the single canonical encoding (base16 lowercase; no leading + zeros on `size`) or fragment the interning/dedup index. +- **Gate.** This convention is **pinned (Status: Accepted, James ratified + 2026-06-20) before durable data is seeded** under `contentHash`/`size`/`cid`, + per `docs/FUTURE_WORK.md` and the FS-ops audit — durable seeding is now + unblocked. +- **Revisit.** Adding hash functions (blake3, sha3-256) is a future specs/10 + revision — additive (new multihash codes), not a break, since old values stay + valid and self-describe. The single-`contentHash`-slot vs algorithm-suffixed-keys + question (specs/10 §5.1) is **resolved**: single self-describing slot, sha2-256 + canonical (James ratified 2026-06-20). + +## Alternatives considered + +- **Bare keccak-256 hex (`0x…`, status quo in `specs/04`).** *Rejected.* + Algorithm-ambiguous (keccak vs sha2 indistinguishable) and, being + non-revocable, permanently unverifiable. This is the exact gap the ADR closes. +- **A `keccak256:`/`sha2-256:` ASCII-prefix scheme.** *Rejected.* Self-describing + but bespoke — not the IPFS/IPLD vocabulary EFS already targets, no shared + tooling, and invites prefix-spelling drift (`keccak-256:` vs `keccak256:`), + re-introducing ambiguity. Multihash is the standardized form of the same idea. +- **CID as the sole canonical form for `contentHash`.** *Rejected as the default.* + A CID forces an IPLD content codec (`raw`/`dag-pb`) choice that is meaningless + for a pure EVM keccak digest, and CIDv1 default base32 does not round-trip to + `bytes32` as cheaply as base16. CID is kept as the distinct `cid` key for the + IPFS-addressable case, where the content codec is meaningful. +- **An on-chain `contentHash` field on DATA (pre-ADR-0049 shape).** *Rejected by + ADR-0049 and not reopened here.* The bytes are never on-chain, so a hash field + welds an unverifiable client claim into permanent identity and blocks + zero-download remote pins. Keeping hash as a lens-scoped PROPERTY claim is the + settled position; this ADR only fixes that claim's *string encoding*. +- **Mandate a single hash algorithm (keccak-only).** *Rejected.* IPFS dedup and + the zero-download remote pin need the sha2-256 digest that lives inside a CID; + forcing keccak-only would forfeit IPFS interop. Self-describing values let both + coexist at no schema cost. diff --git a/docs/adr/0065-raise-max-anchor-depth-and-no-name-length-cap.md b/docs/adr/0065-raise-max-anchor-depth-and-no-name-length-cap.md new file mode 100644 index 00000000..0f4bd855 --- /dev/null +++ b/docs/adr/0065-raise-max-anchor-depth-and-no-name-length-cap.md @@ -0,0 +1,46 @@ +# ADR-0065: Raise `MAX_ANCHOR_DEPTH` to 1024; do not cap anchor-name length + +**Status:** Accepted (depth value **superseded in part by ADR-0068** — lowered 1024 → 256 after the propagation gas estimate in Consequences §Depth was found wrong: the walk does ~90k gas/level of cold SSTOREs, not the ~2.1k warm SLOAD assumed, making a first-time deep placement at 1024 exceed the block gas limit. The **no-anchor-name-length-cap decision (§2) still holds.** Body preserved unedited as the historical record.) +**Date:** 2026-06-20 +**Related:** ADR-0021 (superseded), ADR-0025 (anchor name validation), ADR-0030 (mainnet permanence), ADR-0048 (proxy-ready resolvers / burn), ADR-0056 (kernel-minimal, client owns presentation/safety), `docs/FS_OPERATIONS_AUDIT.md` + +## Context + +`MAX_ANCHOR_DEPTH = 32` (ADR-0021) capped folder nesting at 32 absolute levels, enforced as a hard `AnchorTooDeep()` revert at anchor creation plus two defense-in-depth `break`s in the read-side ancestor walks. The pre-launch filesystem-operations audit (`docs/FS_OPERATIONS_AUDIT.md`) and a dedicated OS/filesystem depth survey (2026-06-20) found 32 to be **anomalously low and in conflict with EFS's mirror-external-filesystems goal**: + +- **No modern filesystem imposes an explicit directory-depth cap.** ext4, XFS, Btrfs, ZFS, APFS, NTFS: none. Depth is bounded only *emergently* by path-length conventions — Linux `PATH_MAX` 4096 B ⇒ ~2048 levels of minimal components; Windows long-path mode 32,767 chars ⇒ ~16,000 levels; macOS 1024 B. The only confirmed hard depth cap in the wild is ISO 9660's 8 levels (a 1988 CD-ROM format, since lifted by extensions). +- A hard *creation* cap at 32 would **reject deep trees every real OS legally holds**, breaking EFS's ability to mirror external/machine-generated/date-partitioned (`/yyyy/mm/dd/hh`)/content-sharded archives. +- The cap's *only* purpose is anti-grief gas-bounding on the ancestor-chain walks (`_propagateContains`, `_indexGlobal`, creation-time depth check) — all linear in depth, amortized-O(1) in steady state via early-break. That purpose does not require a number as low as 32. + +A second, separate question surfaced from the same audit: should anchor-name **length** be capped (proposed `MAX_ANCHOR_NAME_LENGTH ≈ 255`, matching the near-universal OS per-component limit)? `_isValidAnchorName` (ADR-0025) validates name *content* but not *length*. + +## Decision + +**1. Raise `MAX_ANCHOR_DEPTH` from 32 to 1024.** Keep the depth-*counter* form (a path-byte-length cap would not bound the walks — 2048 single-character components are still 2048 iterations). 1024 is ~90× the deepest tree ever observed in the wild (worst-cited `node_modules` ≈ 11 levels), so no real archival/machine-generated/mirrored tree will ever hit it, while a cap still exists so on-chain depth is never unbounded. Single source of truth: the `public constant`; all three enforcement sites read it. + +**2. Do NOT cap anchor-name length.** Anchor names stay length-unbounded at the kernel. + +## Consequences + +### Depth +- Bounded gas preserved: ~2,100 gas per warm SLOAD per level ⇒ ~2.1M gas worst-case to create a fresh 1024-deep chain — a one-time cost **self-paid by whoever creates the deep chain**, well under the block limit; steady-state ancestor walks stay O(1) via early-break on an already-flagged ancestor. +- EFS can now mirror essentially any real-world tree by absolute path without hitting the cap. +- **This is a free-until-burn change**, not a frozen-schema change: `MAX_ANCHOR_DEPTH` is a `constant` in `EFSIndexer` implementation bytecode behind the upgradeable proxy. The proxy address (and therefore the ANCHOR/DATA/PROPERTY schema UIDs) is unchanged, so raising it **orphans nothing** — old shallow anchors stay valid, newly-allowed deep anchors simply become creatable. After burn it is fixed forever; doing it now (pre-launch redeploy) is simply the cheapest moment. + +### No name-length cap +A cap is **not required for correctness**, and the reasons to add one are weak enough to reject: +- **A natural ceiling already exists.** Name validation (`_isValidAnchorName`) and the mapping-key hash are O(name length), and calldata costs 16 gas/byte — so a name large enough to matter (hundreds of KB+) exceeds block gas at creation and reverts on its own. Absurd names are physically self-limited and self-paid. +- **Lens-scoping neutralizes the grief vector.** Directory listings are lens-scoped: a maliciously huge name only burdens viewers who *explicitly trust its creator* (viewer sovereignty), never the general public. There is no public-good griefing surface. +- **Presentation is a client concern.** Clients must truncate/escape names for display anyway — the same kernel-minimal / client-owns-presentation principle as ADR-0056 (render safety) and ADR-0034 (display-name overlay). The kernel storing a long string is not the kernel endorsing how it renders. +- **Generosity is future-proofing.** Long names can be legitimate Schelling points — SHA-256 hex (64), CIDs (~46–59), structured/descriptive identifiers — and capping forecloses uses we can't foresee. A 255 cap would match OS *per-component* limits, but EFS is not obligated to adopt them and a too-tight cap risks rejecting a legitimate future name. + +The one real tradeoff: a name >255 bytes cannot round-trip *out* to a conventional OS filesystem (a mirror-export tool would hash or truncate it). This is a client/export concern, not a kernel constraint — and mirror-*in* from a real OS never produces a >255 name in the first place, so legitimate mirroring is unaffected. + +If a purely defensive backstop against permanent non-revocable bloat is ever wanted, the generous option is to reuse `MAX_URI_LENGTH = 8192` (ADR-0022) as a symmetric name ceiling — 32× the OS norm, so still never rejecting anything real. We deliberately do **not** add it now. + +## Alternatives considered + +- **Depth 128 / 256.** Rejected as the headline: only ~4–8× the cap, still rejects legal Windows-long-path trees, reintroducing the mirror-rejection problem. 256 remains a valid conservative fallback if tighter worst-case gas were ever prioritized over completeness. +- **Replace the depth cap with a path-byte-length cap (à la `PATH_MAX`).** Rejected: it models real-OS behavior but does *not* bound the EFS ancestor walks (the actual gas concern), since many short components still mean many iterations. +- **No depth cap at all.** Rejected: an on-chain system cannot have an unbounded ancestor walk — gas would be the only limit and a pathological chain could load cost onto sibling operations. A high cap (1024) keeps a clean, predictable creation-time invariant. +- **Cap anchor names at 255 (OS per-component norm).** Rejected per the reasoning above — not required for correctness, no public grief surface, and forecloses legitimate long-name uses. diff --git a/docs/adr/0066-index-discovery-only-no-folder-presence.md b/docs/adr/0066-index-discovery-only-no-folder-presence.md new file mode 100644 index 00000000..fb7b452e --- /dev/null +++ b/docs/adr/0066-index-discovery-only-no-folder-presence.md @@ -0,0 +1,55 @@ +# ADR-0066: `index()` is discovery-only — folder presence is placement-driven + +**Status:** Accepted +**Date:** 2026-06-23 +**Permanence-tier:** Etched (kernel index semantics — `EFSIndexer` is non-redeployable; the rule constrains what permissionless callers can write into the append-only contributor index) +**Related:** PR #37, ADR-0009 (append-only indices), ADR-0010 (sticky `_containsAttestations`), ADR-0038 (tag-only folder visibility), ADR-0055 (WHITEOUT), ADR-0065 (anchor depth) + +## Context + +`EFSIndexer` exposes a permissionless `index(uid)` / `indexBatch(uids)` so any attestation — including foreign schemas and EFS schemas the kernel is not the resolver for (WHITEOUT, REDIRECT, …) — can be made **discoverable** through the standard query surface (`getAttestationsBySchema`, `getReferencingAttestations`, `getOutgoingAttestations`, …). This is correct and wanted: every EFS schema should be index-worthy. + +The problem was that `index()` did **two** jobs through the shared `_indexGlobal`: + +1. **Discovery indexing** — append the UID to the by-schema / by-refUID / by-attester indices. Universal, intended. +2. **Folder-presence propagation** — walk the `_parents` chain from `refUID` to root, setting `_containsAttestations[ancestor][attester]` and pushing each ancestor into `_childrenByAttester[parent][attester]`. This is the **schema-blind** "navigable tree" that `getChildrenByAddressList` / `getDirectoryPageByAddressList` read. + +Because (2) fired for **any** attestation whose `refUID` is a path anchor, a permissionless caller could manufacture *positive* folder presence without placing real content: + +- index a **negative WHITEOUT** (whose `refUID` is the suppressed anchor) → a purely-negative marker makes its ancestor folders appear as "containing the caller's content" (the PR #37 review finding); or +- index a **junk foreign attestation** pointed at a popular anchor → fake "contributor" presence + permanent `_childrenByAttester` bloat (the 2026-06-17 STRIDE-sweep finding, FUTURE_WORK "`index()` `_containsAttestations` spam filter"). + +It is self-lens only (`_indexGlobal` keys on `attestation.attester`) and the per-schema listing / router / `getFilesAtPath` are unaffected (they read PIN/TAG, not the schema-blind index), but it is still a real semantic break — a discovery call writing a *placement* signal — and it is sticky past revoke (ADR-0010). + +The frozen kernel cannot special-case the WHITEOUT schema UID (WHITEOUT is additive post-freeze; its UID isn't known at the kernel's deploy), so "skip WHITEOUT in `index()`" is not implementable. The right fix is more general anyway. + +## Decision + +**`index()` / `indexBatch()` are discovery-only. They never propagate schema-blind folder presence.** + +`_indexGlobal` takes a `bool propagateFolderPresence`: + +- The native `onAttest` path passes **true** (anchor creation builds the creator's navigable tree; DATA/PROPERTY have `refUID == 0`, so the walk is a no-op for them). +- The permissionless `index()` / `indexBatch()` path passes **false**. + +Discovery indices — including the **schema-scoped** `_containsSchemaAttestations[refUID][attester][schema]` direct fact — stay universal for every schema. Only the **schema-blind** upward walk (`_containsAttestations` + `_childrenByAttester`) is gated. + +**Folder presence is an intentional placement signal**, owned exclusively by: + +- `EdgeResolver` PIN/TAG writes → `EFSIndexer.propagateContains` (access-controlled to `edgeResolver` / `sortOverlay`), and +- native anchor creation in `onAttest`. + +No schema legitimately needs `index()`-driven propagation — real content placement never flows through `index()` — so "never propagate from `index()`" is both correct and simpler than a per-schema allowlist. + +## Consequences + +- **`index()` stays universal + safe:** any attestation (WHITEOUT, REDIRECT, foreign) is fully discoverable, but no caller can fake positive folder presence or bloat `_childrenByAttester`. Resolves the PR #37 WHITEOUT-positive-presence finding **and** the general STRIDE spam-filter item in one rule (the latter is now DONE, not deferred). +- **No behavior change for real placement:** PIN/TAG (EdgeResolver) and anchor creation (onAttest) propagate exactly as before. The full suite is unchanged except the one index() test that asserted the old (flawed) propagation, which now asserts the discovery-only invariant. +- **Does not address deep-placement gas:** the legitimate `_propagateContains` walk (EdgeResolver) is still `O(depth)`; the anchor-depth-vs-block-gas tradeoff is a separate decision (ADR-0065 / its follow-up). +- Self-describing intent for a 50-year reader: a discovery index records "this exists / points here"; a placement index records "someone put content here." Conflating them let a discovery call forge placement. This ADR keeps the two indices semantically distinct. + +## Alternatives considered + +- **Per-schema allowlist on the propagation loop** (PIN/TAG/MIRROR/LIST_ENTRY/REDIRECT) — the originally-tracked mitigation. Rejected as unnecessarily complex: those positive signals already propagate through their own resolver paths, not `index()`, so the correct `index()` answer is "propagate for none," not "enumerate which." +- **Teach the kernel the WHITEOUT schema UID and skip it** — not implementable on the frozen kernel (additive post-freeze UID), and WHITEOUT-specific rather than fixing the general class. +- **Leave it, document "don't index whiteouts"** — relies on every client/helper honoring a social convention; exactly the kind of unenforced promise EFS designs out. diff --git a/docs/adr/0067-redirect-read-time-resolution.md b/docs/adr/0067-redirect-read-time-resolution.md new file mode 100644 index 00000000..219a6315 --- /dev/null +++ b/docs/adr/0067-redirect-read-time-resolution.md @@ -0,0 +1,45 @@ +# ADR-0067: REDIRECT read-time resolution rules + +> Renumbered from ADR-0063 (2026-06-23) to avoid a collision with main's ADR-0063 (`data:` inline-mirror transport) merged into this branch concurrently. References elsewhere updated to 0067. + +**Status:** Accepted (James ratified 2026-06-20) — rules accepted; on-chain follower IMPLEMENTATION DEFERRED (built when REDIRECTs are actually used — see Consequences) +**Date:** 2026-06-20 +**Deciders:** James (this pins behavior before durable seeding; human-gated) +**Permanence-tier:** Durable (the resolution algorithm + conformance vectors — ADR-governed, NOT frozen in a UID; the on-chain follower is a redeployable view) +**Related:** ADR-0050 (REDIRECT schema — requires this spec before durable seeding), ADR-0055 (WHITEOUT negative terminal — now live as a separate schema, applied OUTSIDE this follower; §Decision 6-7), ADR-0031 (lens first-attester-wins), ADR-0021/0062 (depth-cap precedent), ADR-0051 (reads exclude revoked), spec `specs/09-redirect-resolution.md` (the normative algorithm + vectors) + +## Context + +REDIRECT is a frozen schema (`"bytes32 target, uint16 kind"`, ADR-0050) with `AliasResolver` enforcing **write-time guards only** — no self-loop, per-kind typing (`AliasResolver.sol:161-198`). The resolver deliberately does **not** follow chains, detect multi-hop cycles, cap depth, or apply lens precedence: it cannot afford to graph-walk on every write. + +The production read path follows **nothing** today: `EFSRouter._findDataAtPath` (`EFSRouter.sol:889-895`) reads the active placement PIN target in O(1) and returns; `EFSIndexer.resolvePath` (`EFSIndexer.sol:531-533`) is a pure name→anchor map lookup. So REDIRECT-following is *new* read-side logic that must be specified before it can be built — and, critically, before any durable REDIRECT data is seeded on Sepolia. ADR-0050 §"Write-time guards vs read-time resolution" and ADR-0055 §"Pre-freeze reservation" both make this spec a **gate on seeding**: a redirect minted before the rules are fixed carries undefined read behavior, and an ad-hoc suppression convention minted now would harden into a forever-fact that forecloses the clean WHITEOUT schema. + +## Decision + +Adopt the read-time resolution rules in `specs/09-redirect-resolution.md` as the normative contract for the on-chain navigational follower and every conformant client/indexer: + +1. **Following by kind — `symlink`-only (James ratified 2026-06-20).** `symlink` (2) is the **only** auto-followed (navigational) kind. `supersededBy` (1) is **NOT auto-followed** — it is a non-followed terminal: a version chain is a discoverable on-chain breadcrumb (read by clients/indexers), not auto-navigation. "Latest version" is reached by the **path/placement PIN** (which the publisher re-points), not by chasing `supersededBy` — this is the **path = newest, UID = exact** model, and it preserves EFS's "no silent revision" property (a fixed UID/link never silently advances). `sameAs` (0) is **canonicalization-only** (never navigated). `kind ≥ 3` is never auto-followed. All non-`symlink` kinds return `Resolved` at the node holding them. +2. **Hop cap `D_MAX = 16`** (James ratified 2026-06-20; was 8), hard ceiling **32**. A "hop" is a followed `symlink` edge (the only auto-followed kind) — independent of `MAX_ANCHOR_DEPTH = 256` path descent. 16 covers any long real symlink chain with trivial gas cost; `supersededBy`/`sameAs` consume zero hops (non-followed terminals). Exceeding `D_MAX` stops and returns status `DepthExceeded` surfacing the last node; never reverts. +3. **Cycle handling.** The on-chain follower keeps a **visited-set** within the walk and **stops** on revisit (`CycleStopped`) — catches direct and multi-hop cycles the resolver's `SelfLoop` guard cannot. Separately, `sameAs`-cluster **canonicalization** elects the **lowest UID in the SCC** (start-independent, deterministic) — a **client/indexer** dedup concern; the on-chain navigational follower does NOT run SCC analysis. +4. **Lens precedence (ADR-0031).** A redirect is followable only if its attester is in the active lens set; foreign redirects are invisible. Competing edges resolve first-attester-wins by lens order, ties by lowest redirect UID. A symlink resolves within the same lens scope as the surrounding walk. +5. **Dangling targets.** A target that is missing, revoked, or fails read-time kind-typing → status `Dangling` surfacing the last good node; never reverts. +6. **WHITEOUT is a live, separate schema — the redirect follower never produces suppression (ADR-0055).** WHITEOUT now ships as its own EAS schema + resolver; its negative terminal ("suppressed/empty in this lens — STOP, no fall-through") is applied by the router / file view against `WhiteoutResolver`, **outside** this redirect follower. The follower reserves a `Suppressed-reserved` status that is **defined-but-never-returned** — not because WHITEOUT is unbuilt, but because suppression lives in a separate schema handled elsewhere; the slot was kept so the follower never had to change when WHITEOUT shipped. This spec's *follower* does not implement WHITEOUT. +7. **Seeding ban (PERMANENT — does not expire now that WHITEOUT ships).** Suppression is expressed ONLY by the dedicated WHITEOUT schema. NO durable data may encode whiteout/suppression via any sentinel — reserved REDIRECT kind, `weight < 0` TAG, sentinel PIN, tombstone DATA — **ever**: using a follow/placement vocabulary as a stop terminal is a category error whose forever-fact cost is identical before and after WHITEOUT exists. A stray reserved-kind REDIRECT is inert/ignored (no on-chain guard per ADR-0055 §3); the permanent ban is the protection. (Authoritative: `specs/09-redirect-resolution.md` §10.) + +## Consequences + +- **Implementation deferred — the rules are pinned now, the follower is built later.** This ADR ratifies the *rules*; the on-chain read-time follower is **not built yet** and is deferred until REDIRECTs are actually used (there is no redirect data to resolve today). Pinning the rules now is the point — durable seeding is gated on the rules, not on the code (see below) — and the spec/ADR may be refined when the follower is actually implemented (implementation tends to surface details). An earlier draft follower (a `resolveRedirect` view on `EFSFileView`, backed by an additive `AliasResolver.getActiveRedirect` reverse-by-source index) was written and then **removed** to avoid carrying unused, undeployed read-time machinery — notably, removing it returns `AliasResolver` to **zero storage change vs. its pre-follower state** (the only frozen-proxy storage write the draft introduced). The reverse-by-source read the follower needs will be re-added additively (off the frozen REDIRECT schema UID) when the follower is built. +- **The on-chain follower (once built) is a redeployable, stateless view = purely additive.** It adds no kernel storage and is baked into no schema UID, so it can be deployed (and re-deployed) without touching the frozen nine schemas. Its exact landing site (EFSFileView vs EFSRouter vs a dedicated follower) is not frozen here. +- **Clients and off-chain indexers implement the same conformance vectors** (spec §9). On-chain navigation and off-chain dedup converge because both pin the same kind-following, lens-precedence, depth, cycle-stop, and lowest-UID-in-SCC rules. +- **Durable REDIRECT seeding is gated on this ADR's sign-off.** This is the linchpin ADR-0050 and ADR-0055 both name. +- **Read gas is bounded** to `O(D_MAX)` per resolution (≤ 16 hops × per-hop lens scan), independent of path depth. +- **Cost:** the follower, the conformance-vector test suite, and client/indexer parity work are real, and must precede seeding. The two formerly-open items — `supersededBy` follow semantics and `D_MAX` — are now **ratified** (James, 2026-06-20): `supersededBy` is a non-followed terminal, `D_MAX = 16`. The follower's exact landing contract (EFSFileView vs EFSRouter vs a dedicated follower) remains a redeployable-view detail, not frozen here. + +## Alternatives considered + +- **Kernel-side following (resolver walks on write) — REJECTED.** The resolver cannot graph-walk per write (multi-hop cycles span attesters and writes; ADR-0050). Following is inherently a read-time, lens-scoped concern; baking it into the immutable resolver would also freeze the follow rules forever. +- **No hop cap (or path-depth cap reuse) — REJECTED.** An on-chain walk must be bounded; reusing `MAX_ANCHOR_DEPTH = 256` over-budgets a redirect chain (real chains are short) and conflates two independent budgets. `D_MAX = 16` / ceiling 32 bounds gas while never truncating a legitimate chain. +- **Auto-following `supersededBy` (a bare DATA UID read returns the latest version) — REJECTED** (James ratified 2026-06-20). It would make a fixed DATA UID silently resolve to a different, newer version, violating EFS's "no silent revision" property. "Latest" is reached the EFS way — by the path's placement PIN, which the publisher re-points — so the path is newest and a specific UID is exact. `supersededBy` stays a discoverable on-chain breadcrumb (clients/indexers may walk it deliberately); the navigational follower treats it as a non-followed terminal. +- **Full Tarjan SCC on-chain for navigation — REJECTED.** SCC analysis is unbounded-in-general and unnecessary for *navigation*: a bounded walk with a visited-set stops cleanly on any cycle. Lowest-UID-in-SCC is needed only for `sameAs` **dedup**, which is a client/indexer concern off the per-read on-chain path. +- **Encode whiteout as a REDIRECT sentinel kind now — REJECTED** (ADR-0055): a *follow* vocabulary used as a *stop* terminal, unenforceable by the frozen resolver, and a forever-fact once seeded. WHITEOUT gets its own schema additively; this spec only reserves the terminal slot. +- **`sameAs` followed navigationally — REJECTED.** Equivalence has no direction or terminal; navigating it teleports reads arbitrarily. Keep equivalence out of the follow path (SKOS discipline, ADR-0050); resolve it by canonicalization instead. diff --git a/docs/adr/0068-lower-max-anchor-depth-to-256-for-propagation-gas.md b/docs/adr/0068-lower-max-anchor-depth-to-256-for-propagation-gas.md new file mode 100644 index 00000000..89808e10 --- /dev/null +++ b/docs/adr/0068-lower-max-anchor-depth-to-256-for-propagation-gas.md @@ -0,0 +1,35 @@ +# ADR-0068: Lower `MAX_ANCHOR_DEPTH` from 1024 to 256 (derive the cap from the propagation gas budget) + +**Status:** Accepted +**Date:** 2026-06-23 +**Permanence-tier:** Etched (kernel depth invariant — bounds the per-placement ancestor walk; a value too high makes legal deep placements unexecutable) +**Related:** ADR-0065 (raised 32 → 1024 — depth value superseded here; its no-name-length-cap decision still holds), ADR-0021 (original `MAX_ANCHOR_DEPTH = 32`), ADR-0048 (proxy-ready resolvers / burn), PR #37 review + +## Context + +ADR-0065 raised `MAX_ANCHOR_DEPTH` from 32 to 1024, justified by "bounded gas preserved: ~2,100 gas per warm SLOAD per level ⇒ ~2.1M gas worst-case … well under the block limit." **That gas estimate was wrong, and it drove the number.** + +The cap bounds the ancestor-chain walk that runs on a content *placement* — `EdgeResolver.onAttest` → `EFSIndexer.propagateContains` → `_propagateContains` (and the symmetric walk in `_indexGlobal`). For a **first-time** placement by a lens at a level not yet flagged for it, each level performs **~4 cold (zero→nonzero) SSTOREs** — `_containsAttestations`, the `_childrenByAttester` array push (length slot + element), and the `_childInChildrenByAttester` dedup flag — at ~22,100 gas each, i.e. **~90k gas/level**, not ~2.1k. (The ~2.1k figure is a *warm SLOAD*, not the cold writes the walk actually does.) + +The PR #37 review surfaced the consequence: a fresh lens placing — or hardlinking/moving inherited content — at a deep anchor walks the full depth with no early-break, so at 1024 the worst case is **~92M gas, well over the ~30M block limit → the placement reverts**, even though anchor *creation* (one anchor per tx) succeeded. Deep trees could be *built* (incrementally, each level its own cheap tx) but could **not be cross-lens-placed-into** past roughly depth ~300. That breaks lenses/hardlinks — a core EFS operation — for deep paths. + +ADR-0065 itself anticipated this exit: its Alternatives section records that "256 remains a valid conservative fallback if tighter worst-case gas were ever prioritized over completeness." Tighter worst-case gas is now required, not hypothetical. + +## Decision + +**Lower `MAX_ANCHOR_DEPTH` from 1024 to 256.** Keep everything else ADR-0065 decided: the depth-*counter* form (a path-byte cap does not bound the walk), the single-source-of-truth `public constant` read by all three enforcement sites, and **the no-anchor-name-length-cap decision (ADR-0065 §2) is unchanged.** + +256 is derived from the propagation budget, not picked for aesthetics: worst-case first-placement propagation ≈ 256 × ~90k ≈ **~23M gas**, which fits inside a ~30M block with headroom for the rest of the placement tx (the EAS attestation + PIN resolver logic). It is still **8× the original 32** and far beyond any real-world tree (deepest cited ≈ 11 levels), so it rejects nothing legitimate — the mirror-external-filesystems goal of ADR-0065 is preserved while every legal placement stays executable. + +## Consequences + +- **Every legal placement is now executable in one block**, including a fresh lens hardlinking/moving inherited content at the deepest allowed path. This is the property 1024 silently broke. +- **Free-until-burn, orphans nothing** (same as ADR-0065): `MAX_ANCHOR_DEPTH` is a `constant` in `EFSIndexer` implementation bytecode behind the upgradeable proxy. The proxy address — and therefore the ANCHOR/DATA/PROPERTY schema UIDs — is unchanged, so lowering it is a pre-launch redeploy that keeps all existing anchors valid. (Anchors created between 256 and 1024 deep do not exist yet — pre-launch, no data — so nothing is orphaned.) +- The cap is a *creation-time* and *walk-time* invariant; existing shallow anchors are unaffected. Trees deeper than 256 cannot be created — acceptable, since none are real and the alternative (1024) makes them unplaceable anyway. +- **Lesson folded in:** size an on-chain depth/iteration cap from the *write-side* worst case (cold SSTOREs × depth vs block gas), not from a warm-SLOAD read estimate. The `_propagateContains`/`_indexGlobal` walks are the binding cost, and they write, not read. + +## Alternatives considered + +- **128.** More headroom (~11.5M worst-case), also gas-safe. Rejected as the headline only because 256 already fits comfortably and stays more generous; 128 remains a fine tighter fallback if block-gas assumptions change. +- **Keep 1024, make propagation lazy/bounded.** Redesign the ancestor walk so a deep placement does partial/lazy propagation and backfills later. Rejected pre-launch: it touches an append-only kernel index (ADR-0009/0010) and adds materially more complexity than lowering a constant, for the sole benefit of supporting trees no real filesystem produces. +- **No cap / path-byte cap.** Already rejected by ADR-0065 for reasons that still hold (an on-chain walk must be bounded; a byte cap doesn't bound iterations). diff --git a/docs/adr/README.md b/docs/adr/README.md index d5cfbd37..9fbf0f3a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -76,6 +76,7 @@ What else we looked at and why it lost. - [ADR-0049 — DATA is pure identity; hash and size are data, not identity](./0049-file-content-identity-hash-as-data.md) - [ADR-0050 — REDIRECT: first-class canonical / sameAs / symlink edge](./0050-redirect-canonical-symlink-schema.md) - [ADR-0052 — PROPERTY is non-revocable (interned shared value)](./0052-property-is-non-revocable.md) +- [ADR-0064 — `contentHash`/`size`/`cid` self-describing encoding (multibase-multihash + CID)](./0064-content-hash-self-describing-encoding.md) _(Accepted — sha2-256 canonical so `contentHash` shares the IPFS CID digest; keccak-256 optional alternate)_ ### Index Design @@ -83,6 +84,7 @@ What else we looked at and why it lost. - [ADR-0008 — Qualifying-folder write-time index](./0008-qualifying-folder-write-time-index.md) - [ADR-0009 — Append-only indices stay append-only](./0009-append-only-indices.md) - [ADR-0010 — `_containsAttestations` propagation is one-way (sticky)](./0010-contains-attestations-sticky-propagation.md) +- [ADR-0066 — `index()` is discovery-only; folder presence is placement-driven](./0066-index-discovery-only-no-folder-presence.md) ### Transports & Mirrors @@ -104,11 +106,14 @@ What else we looked at and why it lost. - [ADR-0058 — Harden the EFSRouter web3:// serving path (pagination round-trip, parity, sanitization)](./0058-router-web3-serving-hardening.md) - [ADR-0059 — web3:// reads depend on extcodecopy; EOF/EVM-evolution survival posture](./0059-extcodecopy-eof-survival-posture.md) - [ADR-0060 — Multi-chain web3:// addressing: per-chain ENS subdomains + movable default](./0060-multichain-web3-ens-addressing.md) _(Proposed)_ +- [ADR-0067 — REDIRECT read-time resolution rules](./0067-redirect-read-time-resolution.md) _(Accepted — symlink-only following (supersededBy is a non-followed terminal; path=newest/UID=exact), D_MAX=16/ceiling 32, cycle-stop, lowest-UID-in-SCC canonicalization, lens precedence, WHITEOUT negative-terminal reservation + seeding ban; gates durable REDIRECT seeding)_ ### Security Limits -- [ADR-0021 — `MAX_ANCHOR_DEPTH = 32`](./0021-max-anchor-depth.md) +- [ADR-0021 — `MAX_ANCHOR_DEPTH = 32`](./0021-max-anchor-depth.md) _(Superseded by ADR-0065 → ADR-0068 — now 256)_ - [ADR-0022 — `MAX_URI_LENGTH = 8192` in MirrorResolver](./0022-max-uri-length.md) +- [ADR-0065 — Raise `MAX_ANCHOR_DEPTH` to 1024; no anchor-name length cap](./0065-raise-max-anchor-depth-and-no-name-length-cap.md) _(depth value superseded by ADR-0068 → 256; no-name-cap still holds)_ +- [ADR-0068 — Lower `MAX_ANCHOR_DEPTH` to 256 (derive the cap from the propagation gas budget)](./0068-lower-max-anchor-depth-to-256-for-propagation-gas.md) - [ADR-0023 — URI scheme allowlist in MirrorResolver](./0023-uri-scheme-allowlist.md) - [ADR-0024 — Content-Type sanitization](./0024-content-type-sanitization.md) - [ADR-0025 — Anchor name validation](./0025-anchor-name-validation.md) diff --git a/packages/hardhat/contracts/AliasResolver.sol b/packages/hardhat/contracts/AliasResolver.sol index 907e3a3e..1e471040 100644 --- a/packages/hardhat/contracts/AliasResolver.sol +++ b/packages/hardhat/contracts/AliasResolver.sol @@ -201,9 +201,11 @@ contract AliasResolver is EFSUpgradeableResolver { // revocable == true: a REDIRECT can always be retracted. Guard the schema for symmetry with // onAttest (reject foreign-schema revokes), then accept. No state to unwind — reverse // fan-in is off-chain (see contract NatSpec). - if (a.schema != _cfg().redirectSchemaUID) revert WrongSchema(); + AliasConfig storage $ = _cfg(); + if (a.schema != $.redirectSchemaUID) revert WrongSchema(); if (a.data.length != EXPECTED_DATA_LEN) revert BadPayload(); (bytes32 target, uint16 kind) = abi.decode(a.data, (bytes32, uint16)); + emit RedirectRevoked(a.refUID, target, kind, a.uid); return true; } diff --git a/packages/hardhat/contracts/EFSFileView.sol b/packages/hardhat/contracts/EFSFileView.sol index cfe27339..9d57ff78 100644 --- a/packages/hardhat/contracts/EFSFileView.sol +++ b/packages/hardhat/contracts/EFSFileView.sol @@ -80,6 +80,7 @@ interface IEFSIndexer { bool reverseOrder, bool showRevoked ) external view returns (bytes32[] memory results, uint256 nextCursor); + function getParent(bytes32 anchorUID) external view returns (bytes32); function getChildrenCount(bytes32 anchorUID) external view returns (uint256); function getChildCountBySchema(bytes32 parentAnchor, bytes32 schema) external view returns (uint256); function getReferencingAttestationCount(bytes32 targetUID, bytes32 schemaUID) external view returns (uint256); @@ -109,6 +110,17 @@ interface IEFSIndexer { ) external view returns (bytes32[] memory); } +/// @notice Minimal read view over the WhiteoutResolver (ADR-0055) the directory walk needs. The view +/// binds the whiteout resolver as a constructor immutable (it is stateless/redeployable), so +/// only the O(1) liveness predicate is declared here. `address(0)` ⇒ whiteout disabled (a +/// pre-WHITEOUT view redeploy / a test harness that doesn't wire one) and the predicate is +/// never called — the directory walk degrades to the no-whiteout behavior. +interface IWhiteoutResolverForFileView { + /// @notice True iff `attester` has an ACTIVE whiteout suppressing `child` under `parent`. + /// See `WhiteoutResolver.isWhitedOut`. + function isWhitedOut(bytes32 parent, address attester, bytes32 child) external view returns (bool); +} + contract EFSFileView { struct FileSystemItem { bytes32 uid; @@ -156,11 +168,21 @@ contract EFSFileView { IEFSIndexer public immutable indexer; IEAS public immutable eas; IEdgeResolverForFileView public immutable edgeResolver; - - constructor(IEFSIndexer _indexer, IEdgeResolverForFileView _edgeResolver) { + /// @notice The WhiteoutResolver (proxy) for the cross-lens negative mask (ADR-0055). A constructor + /// immutable because the view is stateless/redeployable. `address(0)` ⇒ whiteout disabled: + /// the directory walk skips the negative-mask predicate entirely (pre-WHITEOUT views and + /// harnesses that don't wire one keep their exact prior behavior). + IWhiteoutResolverForFileView public immutable whiteoutResolver; + + constructor( + IEFSIndexer _indexer, + IEdgeResolverForFileView _edgeResolver, + IWhiteoutResolverForFileView _whiteoutResolver + ) { indexer = _indexer; eas = _indexer.getEAS(); edgeResolver = _edgeResolver; + whiteoutResolver = _whiteoutResolver; } function getDirectoryPage( @@ -180,6 +202,14 @@ contract EFSFileView { uint256 startingCursor, uint256 pageSize ) external view returns (FileSystemItem[] memory items, uint256 nextCursor) { + // Parent-folder terminal (ADR-0055): if the listed folder is itself whited out for this viewer, + // the page is empty — consistent with the router 404ing a deep link into a whited folder. This + // schema-agnostic view uses DATA_SCHEMA_UID as the folder visibility-TAG def (standard browsing), + // matching its per-item filter below. + if (_isListedFolderWhitedOut(parentAnchor, attesters, indexer.DATA_SCHEMA_UID())) { + return (new FileSystemItem[](0), 0); + } + (bytes32[] memory resolvedUIDs, uint256 nextCur) = indexer.getChildrenByAddressList( parentAnchor, attesters, @@ -189,6 +219,35 @@ contract EFSFileView { false ); + // Cross-lens negative mask (ADR-0055) — viewer-sovereignty consistency with the schema-aware + // listings. This attester-scoped, schema-AGNOSTIC view returns a FIXED indexer page (it does not + // run the phase walkers), so we filter the page in place: drop any child a lens in the stack + // whited out and no higher-precedence lens re-asserts. The `nextCursor` is the indexer's OWN + // source cursor (`nextCur`), independent of how many items we drop from this page — it advances + // over source positions, not result slots — so trimming whited children leaves pagination correct + // (no dup/drop across pages; the next call resumes at `nextCur` regardless). + // + // This view is schema-agnostic (returns all children), so the predicate's PIN/folder-visibility-TAG + // positive checks key on DATA_SCHEMA_UID — matching what the schema-aware path passes for standard + // file/folder browsing (`getDirectoryPageBySchemaAndAddressList` calls the predicate with + // `anchorSchema`, which IS dataSchemaUID on the standard listing). Zero-cost when whiteout is + // disabled: `_isItemWhitedOutForListing` short-circuits to false before any read when + // `whiteoutResolver == address(0)`. + if (address(whiteoutResolver) != address(0)) { + bytes32 dataSchemaUID = indexer.DATA_SCHEMA_UID(); + uint256 kept = 0; + for (uint256 i = 0; i < resolvedUIDs.length; i++) { + bytes32 uid = resolvedUIDs[i]; + // Advance the source walker (the loop) but consume no result slot for a whited child — + // same skip the phase walkers apply. visibilityTagDef == dataSchemaUID (standard browsing). + if (_isItemWhitedOutForListing(parentAnchor, uid, attesters, dataSchemaUID, dataSchemaUID)) continue; + resolvedUIDs[kept++] = uid; // compact in place (kept <= i, so this never overwrites unread) + } + assembly ("memory-safe") { + mstore(resolvedUIDs, kept) + } + } + items = _buildFileSystemItems( resolvedUIDs, parentAnchor, @@ -231,15 +290,13 @@ contract EFSFileView { /// guard without seeding thousands of items. Production default is unchanged. uint256 private constant _FOLDER_SCAN_BUDGET_PER_CALL = 2048; - /// @dev Hard cap on phase-1 entries inspected per call in `getDirectoryPageFiltered`. - /// Symmetric to `_FOLDER_SCAN_BUDGET_PER_CALL` (phase 0). The plain - /// `getDirectoryPageBySchemaAndAddressList` does not need a phase-1 budget because every - /// phase-1 candidate the indexer returns becomes a result item (no per-item drop), so its - /// inner loop is naturally bounded by `maxItems`. The filtered variant can DROP phase-1 - /// items (the exclusion predicate), so a page that is 100%-excluded under the lens would - /// otherwise loop the entire phase-1 source in one eth_call. This budget bounds per-call - /// work; the opaque cursor (ADR-0036) continues progress across calls — same pattern as - /// the phase-0 budget and ADR-0020's mirror-scan cap. + /// @dev Hard cap on phase-1 entries inspected per call. Symmetric to `_FOLDER_SCAN_BUDGET_PER_CALL` + /// (phase 0). Used by BOTH directory views: `getDirectoryPageFiltered` (the exclusion + /// predicate can DROP phase-1 items) AND the plain `getDirectoryPageBySchemaAndAddressList` + /// (the ADR-0055 whited-out skip likewise drops phase-1 items). In either case a page that is + /// ~100% dropped under the lens would otherwise loop the entire phase-1 source in one + /// eth_call. This budget bounds per-call work; the opaque cursor (ADR-0036) continues + /// progress across calls — same pattern as the phase-0 budget and ADR-0020's mirror-scan cap. /// /// Read through `_fileScanBudgetPerCall()` (not the bare constant) so a test-only /// subclass can override it to a small value and exercise the budget guard without @@ -302,6 +359,16 @@ contract EFSFileView { require(attesters.length <= MAX_ATTESTERS_PER_QUERY, "Too many attesters"); require(maxItems > 0, "maxItems must be > 0"); + // Parent-folder terminal (ADR-0055): if the listed folder is itself whited out for this viewer, + // the whole page is empty — consistent with the router 404ing a deep link into a whited folder. + // Pass `anchorSchema` as the folder visibility-TAG def, matching the per-item walker (schema-aware + // folder re-adds). + if (_isListedFolderWhitedOut(parentAnchor, attesters, anchorSchema)) { + page.items = new FileSystemItem[](0); + page.nextCursor = ""; + return page; + } + // Decode cursor — empty OR malformed = fresh start at (phase=0, folderIdx=0, // fileIdx=0). ADR-0036 treats the cursor as opaque caller-supplied bytes, so a // buggy or malicious client must not be able to brick the view with an @@ -350,6 +417,18 @@ contract EFSFileView { bytes32 uid = batch[k]; if (indexer.isRevoked(uid)) continue; if (!edgeResolver.hasActiveTagFromAny(uid, anchorSchema, attesters)) continue; + // Cross-lens negative mask (ADR-0055): a whited-out child advances the walker but + // consumes no slot — same skip as revoked / out-of-lens. Unconditional viewer + // sovereignty (applies to plain AND filtered listings). + if ( + _isItemWhitedOutForListing( + parentAnchor, + uid, + attesters, + indexer.DATA_SCHEMA_UID(), + anchorSchema + ) + ) continue; buf[count++] = uid; if (count == maxItems) break; } @@ -366,8 +445,19 @@ contract EFSFileView { // ───── Phase 1: direct children by schema ───── bool fileSourceDone = false; if (phase == 1) { - while (count < maxItems) { + // Phase-1 scan budget (ADR-0055): the whited-out skip below DROPS items without consuming + // a result slot, so a directory with many hidden direct files could otherwise loop the + // entire remaining phase-1 source in one eth_call. Mirror the filtered walker's guard — + // cap candidates fetched per call to the remaining budget, break at the budget, and return + // the opaque cursor so the next page resumes mid-source (no dup/drop across pages). + uint256 fileBudget = _fileScanBudgetPerCall(); + uint256 scanned = 0; // phase-1 entries inspected this call — bounded by budget + while (count < maxItems && scanned < fileBudget) { + uint256 remainingBudget = fileBudget - scanned; uint256 want = maxItems - count; + // Fetch at most `remainingBudget` candidates so the per-call inspection count can't + // exceed the budget regardless of how many get whited out below. + if (want > remainingBudget) want = remainingBudget; (bytes32[] memory batch, uint256 nextFileCur) = indexer.getAnchorsBySchemaAndAddressList( parentAnchor, anchorSchema, @@ -378,7 +468,20 @@ contract EFSFileView { false // showRevoked ); for (uint256 k = 0; k < batch.length; k++) { - buf[count++] = batch[k]; + scanned++; + bytes32 uid = batch[k]; + // Cross-lens negative mask (ADR-0055): drop whited-out files; the walker advances + // (and the scan budget) but the slot is not consumed (same as the filtered variant). + if ( + _isItemWhitedOutForListing( + parentAnchor, + uid, + attesters, + indexer.DATA_SCHEMA_UID(), + anchorSchema + ) + ) continue; + buf[count++] = uid; if (count == maxItems) break; } fileIdx = nextFileCur; @@ -480,6 +583,14 @@ contract EFSFileView { require(excludeTagDefs.length <= MAX_EXCLUDE_TAGS_PER_QUERY, "Too many exclude tags"); require(maxItems > 0, "maxItems must be > 0"); + // Parent-folder terminal (ADR-0055): a whited-out listed folder yields an empty page (router + // parity). `anchorSchema` is the folder visibility-TAG def, matching the per-item walker. + if (_isListedFolderWhitedOut(parentAnchor, attesters, anchorSchema)) { + page.items = new FileSystemItem[](0); + page.nextCursor = ""; + return page; + } + // Decode cursor — same defensive contract as getDirectoryPageBySchemaAndAddressList: // wrong length OR out-of-range phase = fresh walk at (phase=0, folderIdx=0, fileIdx=0). uint8 phase = 0; @@ -572,13 +683,29 @@ contract EFSFileView { uint256 remainingBudget = folderBudget - scanned; uint256 chunk = remainingSource < _FOLDER_SCAN_CHUNK ? remainingSource : _FOLDER_SCAN_CHUNK; if (chunk > remainingBudget) chunk = remainingBudget; - bytes32[] memory batch = edgeResolver.getChildrenWithEdge(w.parentAnchor, w.anchorSchema, w.folderIdx, chunk); + bytes32[] memory batch = edgeResolver.getChildrenWithEdge( + w.parentAnchor, + w.anchorSchema, + w.folderIdx, + chunk + ); for (uint256 k = 0; k < batch.length; k++) { w.folderIdx++; // advance walker for every inspected entry scanned++; bytes32 uid = batch[k]; if (indexer.isRevoked(uid)) continue; if (!edgeResolver.hasActiveTagFromAny(uid, w.anchorSchema, w.attesters)) continue; + // Cross-lens negative mask (ADR-0055): whited-out items advance the walker but consume + // no slot. Checked alongside the tag-exclusion predicate (all are post-filter skips). + if ( + _isItemWhitedOutForListing( + w.parentAnchor, + uid, + w.attesters, + w.dataSchemaUID, + w.anchorSchema // visibility-TAG definition for this page (NOT ANCHOR schema UID) + ) + ) continue; // Exclusion predicate (post-filter slot accounting): excluded items advance // the walker but consume no slot, identical to a revoked/out-of-lens skip. if (_isItemExcluded(uid, w.attesters, w.filter, w.dataSchemaUID, w.anchorSchemaUID)) continue; @@ -617,6 +744,17 @@ contract EFSFileView { for (uint256 k = 0; k < batch.length; k++) { scanned++; bytes32 uid = batch[k]; + // Cross-lens negative mask (ADR-0055): whited-out items advance the walker (and the scan + // budget) but consume no slot — same post-filter accounting as the exclusion skip. + if ( + _isItemWhitedOutForListing( + w.parentAnchor, + uid, + w.attesters, + w.dataSchemaUID, + w.anchorSchema // visibility-TAG definition for this page (NOT ANCHOR schema UID) + ) + ) continue; if (_isItemExcluded(uid, w.attesters, w.filter, w.dataSchemaUID, w.anchorSchemaUID)) continue; w.buf[w.count++] = uid; if (w.count == w.maxItems) break; @@ -744,6 +882,145 @@ contract EFSFileView { return false; } + /// @dev Per-item cross-lens negative-mask predicate (ADR-0055). Returns true iff the directory + /// entry `childAnchor` (a child of `parentAnchor`) must be rendered EMPTY for the viewer — + /// i.e. a lens in the stack whited it out and no higher-precedence lens re-asserts it with + /// its own placement. Same topology + budget gate as `_isItemExcluded` (O(1)-class per item, + /// one read per lens, bounded by MAX_ATTESTERS_PER_QUERY ≤ 20). Skip = advance the walker, + /// consume no result slot — identical to a revoked / tag-excluded skip. + /// + /// Walk the lenses in PRECEDENCE order. Within each lens (ADR-0055 same-lens-override + + /// first-lens-wins; start/index-order-independent because the winner is re-derived per item): + /// - if that lens has an ACTIVE positive placement PIN at this child + /// (`getActivePinTarget(child, lens, dataSchemaUID) != 0`) ⇒ the lens substitutes its own + /// content here ⇒ VISIBLE, terminate (return false). A newer same-lens positive PIN thus + /// beats that lens's own earlier whiteout (positive-before-whiteout within a lens). + /// - else if that lens has an ACTIVE whiteout on this child ⇒ NEGATIVE terminal: the lens + /// masks the entry and stops fall-through to lower lenses ⇒ DROP, terminate (return true). + /// - else continue to the next (lower) lens — the whiteout/PIN of a higher lens already + /// terminated, so a lower lens's whiteout is transparent to a higher lens that asserts. + /// After the loop (no lens asserted either) ⇒ transparent ⇒ return false. + /// + /// `whiteoutResolver == address(0)` (disabled) short-circuits to false before any read — a + /// pre-WHITEOUT view redeploy keeps its exact prior listing behavior. + /// @dev Single-element-array wrapper for `edgeResolver.hasActiveTagFromAny` (which takes an + /// `address[] calldata`). The folder-fix listing predicate needs a TAG check scoped to ONE + /// lens at a time (precedence-ordered same-lens override), but EdgeResolver only exposes the + /// lens-aware ANY-of-many form — and it is FROZEN-by-UID (its address is hashed into the PIN/ + /// TAG schema UIDs), so we cannot add a single-lens getter there. Wrapping the lens in a + /// 1-element memory array reuses the frozen reader without touching it (ADR-0055 folder-fix). + function _one(address lens) private pure returns (address[] memory arr) { + arr = new address[](1); + arr[0] = lens; + } + + /// @dev The per-item LISTING-side negative-mask predicate (ADR-0055). The RESOLUTION-side terminal + /// (PIN-only positive; same-lens positive-before-whiteout; a whiteout suppresses strictly-lower + /// lenses) is applied inline in `getFilesAtPath`'s cursor walk and the router's `_findDataAtPath`, + /// NOT here. This listing predicate differs: its positive terminal is a file PIN OR — for GENERIC FOLDER + /// anchors only — a folder visibility TAG (the FOLDER RE-ADD FIX, via the `_one(lens)` wrapper + /// around `hasActiveTagFromAny`), so a lens that whites out a folder then re-asserts it with its + /// OWN visibility TAG is un-hidden in listings. `visibilityTagDef` is the listing's `anchorSchema` + /// param (the visibility TAG's definition — `dataSchemaUID` in the standard file/folder listing, + /// NOT the ANCHOR schema UID). + /// + /// The TAG branch is gated to generic folder anchors (`forSchema == 0`, the same discriminator + /// `_buildFileSystemItems` uses for `isFolder`). A FILE anchor's only positive re-assertion is its + /// placement PIN — matching the PIN-gated resolution path (`getFilesAtPath`'s cursor walk and the + /// router's `_findDataAtPath`). Without this gate a same-lens TAG (`definition = dataSchemaUID`) on a + /// whited-out FILE anchor would un-hide it in directory listings while resolution still returns it + /// as deleted — a listing/resolution inconsistency (PR #37 review). + /// + /// Walk the lenses in precedence order: + /// - the FIRST lens that asserts a positive (file PIN, or folder visibility TAG when the child is + /// a generic folder) is the item's contributing lens ⇒ VISIBLE (return false), terminate. A + /// newer same-lens positive thus beats that lens's own earlier whiteout (positive-before- + /// whiteout within a lens). + /// - else if that lens has an ACTIVE whiteout on this child ⇒ NEGATIVE terminal: the lens + /// masks the entry and stops fall-through to lower lenses ⇒ DROP (return true), terminate. + /// - else continue to the next (lower) lens. + /// `whiteoutResolver == address(0)` (disabled) short-circuits to false — a pre-WHITEOUT view + /// redeploy keeps its exact prior listing behavior. + function _isItemWhitedOutForListing( + bytes32 parentAnchor, + bytes32 childAnchor, + address[] memory attesters, + bytes32 dataSchemaUID, + bytes32 visibilityTagDef + ) internal view returns (bool) { + IWhiteoutResolverForFileView wr = whiteoutResolver; + if (address(wr) == address(0)) return false; + + // Read-cost guard (specs/02 WHITEOUT invariant — whiteout must NOT double per-item EAS reads). + // The ONLY way this predicate drops an item is an active whiteout on it; positives merely + // OVERRIDE a whiteout. So scan the lens stack for ANY whiteout FIRST — O(1)-class WhiteoutResolver + // mapping reads, NO `eas.getAttestation`. If none, the item is never dropped → return false + // WITHOUT the folder-classification decode below. Ordinary pages (no active whiteouts) thus pay + // ZERO extra EAS reads — only the per-item batched index reads they already did; the second + // `getAttestation` happens once, in `_buildFileSystemItems`, for kept items. Only an item that + // actually carries a whiteout pays the one decode needed to evaluate the folder re-add override. + bool anyWhiteout = false; + for (uint256 i = 0; i < attesters.length; i++) { + if (wr.isWhitedOut(parentAnchor, attesters[i], childAnchor)) { + anyWhiteout = true; + break; + } + } + if (!anyWhiteout) return false; + + // A whiteout exists in the stack — run the full precedence walk (positive-before-whiteout), which + // needs the folder-vs-file classification for the folder-TAG re-add positive. Classify once: a + // generic folder anchor has `forSchema == 0` (a file anchor has `forSchema == DATA_SCHEMA_UID`, a + // typed/alias anchor a schema UID). Only a generic folder may be re-asserted by a visibility TAG; + // everything else is PIN-positive only (resolution parity). + bool childIsGenericFolder; + { + Attestation memory ca = eas.getAttestation(childAnchor); + if (ca.data.length > 0) { + (, bytes32 childForSchema) = abi.decode(ca.data, (string, bytes32)); + childIsGenericFolder = (childForSchema == bytes32(0)); + } + } + for (uint256 i = 0; i < attesters.length; i++) { + address lens = attesters[i]; + // Positive terminal: file placement PIN (Shape A) OR — folders only — folder visibility TAG + // (Shape B, the folder re-add fix) → visible, stop. + if ( + edgeResolver.getActivePinTarget(childAnchor, lens, dataSchemaUID) != bytes32(0) || + (childIsGenericFolder && edgeResolver.hasActiveTagFromAny(childAnchor, visibilityTagDef, _one(lens))) + ) { + return false; + } + // Negative terminal: this lens whites the entry out → drop, stop (no fall-through). + if (wr.isWhitedOut(parentAnchor, lens, childAnchor)) return true; + } + return false; // no lens asserted a positive or a whiteout → transparent. + } + + /// @dev Listing-side PARENT-folder terminal (ADR-0055). The per-item filters above suppress whited + /// CHILDREN, but if the FOLDER BEING LISTED is itself whited out by a lens under ITS OWN parent + /// (and not re-added), the whole page must be suppressed for that viewer — otherwise listing the + /// known `/dir` anchor would still return lower-lens children while the router 404s `/dir/child` + /// (the per-segment terminal in EFSRouter). This is the listing analogue of that router terminal: + /// it evaluates the same `_isItemWhitedOutForListing` predicate (PIN OR folder visibility-TAG + /// re-add positive) with (grandparent, folderAnchor). Cheap + short-circuits when whiteout is + /// disabled; root / address-root (no parent) can't be whited (OrphanAnchor guard) → false. + /// @param visibilityTagDef The listing's `anchorSchema` (the folder visibility-TAG definition) — + /// MUST match what the per-item walker uses, so a folder re-added under a non-DATA schema + /// (`TAG(definition=anchorSchema, refUID=folder)`) is recognized as visible by the parent check + /// too, not just by the per-item check below it. The PIN-positive arg stays `dataSchemaUID` + /// (file placement is always DATA-schema; a folder has no DATA PIN anyway). + function _isListedFolderWhitedOut(bytes32 folderAnchor, address[] memory attesters, bytes32 visibilityTagDef) + internal + view + returns (bool) + { + if (address(whiteoutResolver) == address(0)) return false; + bytes32 grandparent = indexer.getParent(folderAnchor); + if (grandparent == bytes32(0)) return false; + return _isItemWhitedOutForListing(grandparent, folderAnchor, attesters, indexer.DATA_SCHEMA_UID(), visibilityTagDef); + } + function _buildFileSystemItems( bytes32[] memory uids, bytes32 parentAnchor, @@ -822,6 +1099,27 @@ contract EFSFileView { require(attesters.length <= MAX_ATTESTERS_PER_QUERY, "Too many attesters"); require(maxItems > 0, "maxItems must be > 0"); + // Cross-lens negative mask (ADR-0055) — view/router consistency. This single-anchor file + // reader resolves DATA AT `anchorUID` across the lens stack, the SAME shape as the router's + // `_findDataAtPath` negative terminal (the resolved anchor here plays the router's + // `targetAnchor` role). Apply the identical terminal so a whited-out anchor never serves DATA + // through the view that the router would 404: precedence-ordered scan, positive placement PIN + // FIRST then whiteout (same-lens positive-before-whiteout override, higher-lens transparency). + // `_isItemWhitedOut` re-derives the winner per-item and short-circuits when the resolver is + // disabled (`address(0)`), so a pre-WHITEOUT view keeps its exact prior behavior. The whiteout + // key is (parent, lens, anchor) with parent = `indexer.getParent(anchorUID)`, exactly as the + // router computes it; the positive-PIN override bucket is `schema` (the slot being read). The + // `getParent` SLOAD is guarded on the resolver being wired — disabled views read nothing extra, + // mirroring the router's `_findDataAtPath` guard. + // Cross-lens negative mask (ADR-0055) is applied PER-LENS INSIDE the cursor walk below, NOT as a + // whole-anchor pre-gate: a lens's whiteout suppresses lenses STRICTLY BELOW it even when a HIGHER + // lens stays visible, so a higher positive PIN must not mask a lower-lens whiteout (the pre-gate + // bug). PIN-only positive terminal (a direct file lookup is PIN-gated — a folder visibility TAG + // must NOT un-gate a DATA read), mirroring the router's `_findDataAtPath`. The `getParent` SLOAD + // is guarded on the resolver being wired — a disabled view reads nothing extra. + IWhiteoutResolverForFileView wr = whiteoutResolver; + bytes32 parentAnchor = address(wr) == address(0) ? bytes32(0) : indexer.getParent(anchorUID); + // Decode cursor — empty OR malformed = fresh start at attesterIdx=0. // Same defensive pattern as `getDirectoryPageBySchemaAndAddressList`: length-check // protects against `abi.decode` Panics on arbitrary caller-supplied bytes. @@ -830,6 +1128,29 @@ contract EFSFileView { attesterIdx = abi.decode(cursor, (uint256)); } + // Cursor-bypass guard (ADR-0055 / specs/04): the per-lens whiteout terminal in the walk below only + // fires for lenses at index >= attesterIdx. A caller-supplied opaque cursor could resume PAST a + // whiteout-terminal lens (a stale or hand-crafted `abi.encode(k)`), skipping the terminal and + // serving a lower lens's DATA — while the router's cursorless `_findDataAtPath` would 404. So + // re-evaluate the SKIPPED prefix [0, attesterIdx): if any skipped lens whites out this anchor with + // NO own positive PIN (it would have been a negative terminal), everything strictly below it is + // suppressed → return the terminal empty page. A legitimate forward cursor never points past a + // whiteout terminal (the walk sets `attesterIdx = attesters.length` on the first one), so this + // only trips on a fabricated cursor. Bounded by MAX_ATTESTERS_PER_QUERY; zero when disabled. + if (address(wr) != address(0)) { + uint256 skipEnd = attesterIdx < attesters.length ? attesterIdx : attesters.length; + for (uint256 j = 0; j < skipEnd; j++) { + if ( + edgeResolver.getActivePinTarget(anchorUID, attesters[j], schema) == bytes32(0) && + wr.isWhitedOut(parentAnchor, attesters[j], anchorUID) + ) { + page.items = new FileSystemItem[](0); + page.nextCursor = ""; + return page; + } + } + } + bytes32[] memory buf = new bytes32[](maxItems); // Winning placement (lens) attester for each buffered target, kept in lockstep with // `buf`. The item's `attester` must be the attester whose active PIN won the walk — @@ -846,7 +1167,18 @@ contract EFSFileView { // O(1) PIN read — per-attester slot holds 0 or 1 target. bytes32 target = edgeResolver.getActivePinTarget(anchorUID, currentAttester, schema); - if (target == bytes32(0)) continue; + if (target == bytes32(0)) { + // No positive placement by this lens. If it whites out this anchor, it is a NEGATIVE + // TERMINAL (ADR-0055): suppress all strictly-lower lenses — keep the higher positives + // already buffered, then STOP and force the cursor terminal so a later page can't resume + // past the whiteout into the lower lenses. (A lens's own positive PIN above is emitted via + // the `target != 0` branch, so same-lens positive-before-whiteout still holds.) + if (address(wr) != address(0) && wr.isWhitedOut(parentAnchor, currentAttester, anchorUID)) { + attesterIdx = attesters.length; // cursor terminal — never resume past the whiteout + break; + } + continue; + } // Cross-attester dedup (ADR-0031 first-attester-wins): if an earlier attester // already has an active PIN placing this DATA at this anchor, skip — this lens diff --git a/packages/hardhat/contracts/EFSIndexer.sol b/packages/hardhat/contracts/EFSIndexer.sol index a674dc04..ea7e28b7 100644 --- a/packages/hardhat/contracts/EFSIndexer.sol +++ b/packages/hardhat/contracts/EFSIndexer.sol @@ -137,8 +137,17 @@ contract EFSIndexer is EFSUpgradeableResolver, OwnableUpgradeable { return owner(); } - // Maximum anchor nesting depth — prevents gas griefing in propagateContains - uint256 public constant MAX_ANCHOR_DEPTH = 32; + // Maximum anchor nesting depth — bounds gas on the ancestor-chain walks (_propagateContains, + // _indexGlobal, creation-time depth check). The cap's ONLY purpose is anti-grief gas-bounding; + // it is set far above any real tree (deepest cited ~11 levels) yet within the WRITE-SIDE block-gas + // budget. History: 32 (ADR-0021) -> 1024 (ADR-0065) -> 256 (ADR-0068). 32 wrongly rejected deep + // trees EFS must mirror; 1024 was unexecutable for a first-time deep placement — _propagateContains + // does ~4 cold (zero->nonzero) SSTOREs/level (~90k gas/level, NOT the ~2.1k warm-SLOAD figure + // ADR-0065 assumed), so a fresh lens hardlinking/placing at depth 1024 needs ~92M gas > ~30M block + // limit and reverts. 256 fits (~23M worst-case, self-paid by the creator) while still being 8x the + // original and beyond any real tree. A depth COUNTER (not a path-byte cap) is the right tool because + // it is what actually bounds these walks. + uint256 public constant MAX_ANCHOR_DEPTH = 256; // Content-addressed deduplication: keccak256(contentHash) => first DATA UID. // AGENT-NOTE: this is a RETAINED DEAD SLOT, kept for storage-layout stability, no longer @@ -365,8 +374,9 @@ contract EFSIndexer is EFSUpgradeableResolver, OwnableUpgradeable { } function onAttest(Attestation calldata attestation, uint256 /*value*/) internal override returns (bool) { - // 1. GLOBAL INDEXING (ALL ATTESTATIONS) - _indexGlobal(attestation); + // 1. GLOBAL INDEXING (ALL ATTESTATIONS). Native path → folder-presence propagation ON (anchor + // creation builds the creator's navigable tree; DATA/PROPERTY have refUID 0 so it is a no-op). + _indexGlobal(attestation, true); // 2. EFS CORE LOGIC (ANCHORS) bytes32 schema = attestation.schema; @@ -1111,7 +1121,14 @@ contract EFSIndexer is EFSUpgradeableResolver, OwnableUpgradeable { /// Writes all generic discovery indices: schema, attester, sent, received, /// referencing, and the upward _childrenByAttester propagation chain. /// Does NOT run EFS-specific logic (Anchor tree, DATA content, Property validation). - function _indexGlobal(Attestation memory attestation) private { + /// @param propagateFolderPresence When true, also walk the `_parents` chain marking + /// `_containsAttestations`/`_childrenByAttester` (the SCHEMA-BLIND "navigable tree" that + /// `getChildrenByAddressList` reads). Folder presence is an INTENTIONAL placement signal, so + /// only the native `onAttest` path passes true; the permissionless `index()`/`indexBatch()` + /// discovery path passes FALSE — every schema (incl. WHITEOUT + foreign) stays findable via the + /// global discovery indices below, but a discovery call must not manufacture positive folder + /// presence (ADR-0066; PR #37 review). + function _indexGlobal(Attestation memory attestation, bool propagateFolderPresence) private { // Cache struct fields to avoid repeated memory reads across multiple mappings. bytes32 uid = attestation.uid; bytes32 schema = attestation.schema; @@ -1152,33 +1169,41 @@ contract EFSIndexer is EFSUpgradeableResolver, OwnableUpgradeable { _containsSchemaAttestations[currentUID][attester][schema] = true; } - // Propagate generic "active in this structure" flag all the way up the tree. - // Depth-capped at MAX_ANCHOR_DEPTH (symmetric with _propagateContains, ADR-0021): anchor - // creation already bounds _parents chains, but this guard makes the walk self-limiting - // regardless of how currentUID was reached (notably the permissionless index()/indexBatch() - // paths), so no chain can make this loop unbounded. - uint256 depth = 0; - while (currentUID != bytes32(0)) { - // If this level is already flagged true, the rest of the chain above it must be too. - // Break early to save gas (amortized O(1) for repeat contributions by same user). - if (_containsAttestations[currentUID][attester]) { - break; - } - if (depth++ > MAX_ANCHOR_DEPTH) break; - - _containsAttestations[currentUID][attester] = true; - - // Drive the structural index: push this child into the parent's - // Lens array, guarded by the append-only dedup flag so a - // remove-then-readd cycle doesn't duplicate (`clearContains` - // resets `_containsAttestations` but never this flag). - bytes32 parentUID = _parents[currentUID]; - if (parentUID != bytes32(0) && !_childInChildrenByAttester[parentUID][attester][currentUID]) { - _childrenByAttester[parentUID][attester].push(currentUID); - _childInChildrenByAttester[parentUID][attester][currentUID] = true; + // Propagate the generic "active in this structure" flag up the tree — but ONLY for an + // intentional placement (`propagateFolderPresence`). Discovery via index()/indexBatch() passes + // false: the schema-blind `_containsAttestations`/`_childrenByAttester` index must reflect real + // content placement (PIN/TAG via EdgeResolver.propagateContains, or anchor creation here), NOT + // any attestation a permissionless caller chooses to index. Without this gate, index()-ing a + // negative WHITEOUT — or a junk foreign attestation whose refUID points at a popular anchor — + // would mark that anchor's ancestors as "containing the caller's content" (self-lens, and + // sticky past revoke), surfacing folders in getChildrenByAddressList that hold no real content + // (ADR-0066; PR #37 review). The `_containsSchemaAttestations` direct set above stays universal + // (it is an accurate, schema-scoped discovery fact); only the schema-blind upward walk is gated. + // Depth-capped at MAX_ANCHOR_DEPTH (symmetric with _propagateContains) so the walk self-limits. + if (propagateFolderPresence) { + uint256 depth = 0; + while (currentUID != bytes32(0)) { + // If this level is already flagged true, the rest of the chain above it must be too. + // Break early to save gas (amortized O(1) for repeat contributions by same user). + if (_containsAttestations[currentUID][attester]) { + break; + } + if (depth++ > MAX_ANCHOR_DEPTH) break; + + _containsAttestations[currentUID][attester] = true; + + // Drive the structural index: push this child into the parent's + // Lens array, guarded by the append-only dedup flag so a + // remove-then-readd cycle doesn't duplicate (`clearContains` + // resets `_containsAttestations` but never this flag). + bytes32 parentUID = _parents[currentUID]; + if (parentUID != bytes32(0) && !_childInChildrenByAttester[parentUID][attester][currentUID]) { + _childrenByAttester[parentUID][attester].push(currentUID); + _childInChildrenByAttester[parentUID][attester][currentUID] = true; + } + + currentUID = parentUID; } - - currentUID = parentUID; } } } @@ -1219,9 +1244,16 @@ contract EFSIndexer is EFSUpgradeableResolver, OwnableUpgradeable { * - getAttestationsBySchema(schema, ...) * - getOutgoingAttestations(attester, schema, ...) * - getIncomingAttestations(recipient, schema, ...) - * - containsAttestations / containsSchemaAttestations + * - containsSchemaAttestations(refUID, attester, schema) // schema-scoped discovery fact * - getReferencingSchemas(refUID) * + * DISCOVERY ONLY (ADR-0066): index() makes any attestation FINDABLE, but does NOT propagate + * schema-blind FOLDER PRESENCE (`_containsAttestations` / `_childrenByAttester`, read by + * getChildrenByAddressList). Folder presence is an intentional placement signal driven by real + * content writes (PIN/TAG via EdgeResolver.propagateContains, or anchor creation in onAttest) — a + * permissionless discovery call must not let a caller manufacture positive presence (e.g. by + * index()-ing a negative WHITEOUT, or a junk foreign attestation pointed at a popular anchor). + * * Reverts if the UID does not exist in EAS (uid == bytes32(0) on the returned attestation). * Silently skips EFS-native schemas (ANCHOR, DATA, PROPERTY) — those are indexed * atomically in onAttest and must not be double-indexed. @@ -1243,7 +1275,10 @@ contract EFSIndexer is EFSUpgradeableResolver, OwnableUpgradeable { } _indexed[uid] = true; - _indexGlobal(att); + // Discovery only — folder-presence propagation OFF. A permissionless index() makes any + // attestation FINDABLE (global indices) but must not manufacture positive folder presence + // (ADR-0066). Real placement signals come from onAttest / EdgeResolver.propagateContains. + _indexGlobal(att, false); // Mirror revocation state: if the attestation was already revoked in EAS when indexed, // mark it revoked now so callers don't need a separate indexRevocation() call. @@ -1284,7 +1319,7 @@ contract EFSIndexer is EFSUpgradeableResolver, OwnableUpgradeable { } _indexed[uid] = true; - _indexGlobal(att); + _indexGlobal(att, false); // discovery only — no folder-presence propagation (ADR-0066) if (att.revocationTime != 0) { _isRevoked[uid] = true; } diff --git a/packages/hardhat/contracts/EFSRouter.sol b/packages/hardhat/contracts/EFSRouter.sol index c497367b..7af6e03a 100644 --- a/packages/hardhat/contracts/EFSRouter.sol +++ b/packages/hardhat/contracts/EFSRouter.sol @@ -58,6 +58,10 @@ interface IEFSIndexer { function isRevoked(bytes32 uid) external view returns (bool); + /// @notice The parent anchor of `anchorUID` (ADR-0055 whiteout key: a whiteout on a file anchor + /// keys on (parent, fileAnchor)). Returns bytes32(0) for root / unknown. + function getParent(bytes32 anchorUID) external view returns (bytes32); + function DATA_SCHEMA_UID() external view returns (bytes32); function MIRROR_SCHEMA_UID() external view returns (bytes32); function PROPERTY_SCHEMA_UID() external view returns (bytes32); @@ -73,6 +77,26 @@ interface IEdgeResolverForRouter { address attester, bytes32 targetSchema ) external view returns (bytes32); + + /// @notice Lens-aware, TAG-specific: true iff ANY of `attesters` has an active TAG on + /// (targetID, definition). One SLOAD per attester. Used for folder-visibility checks + /// (ADR-0038): folder visibility is TAG-only — `TAG(definition=dataSchemaUID, refUID=folder)`. + /// Mirrors `EFSFileView._isItemWhitedOutForListing`'s positive terminal so a deep-link walk + /// treats a re-asserted folder visibility TAG as a traversal positive (ADR-0055 folder-fix). + function hasActiveTagFromAny( + bytes32 targetID, + bytes32 definition, + address[] calldata attesters + ) external view returns (bool); +} + +/// @notice Minimal read view over the WhiteoutResolver (ADR-0055) for the router's negative terminal. +/// `address(0)` ⇒ whiteout disabled: the lens scan never calls it and serves exactly as +/// before WHITEOUT existed (pre-WHITEOUT router redeploys / harnesses that don't wire one). +interface IWhiteoutResolverForRouter { + /// @notice True iff `attester` has an ACTIVE whiteout suppressing `child` under `parent`. + /// See `WhiteoutResolver.isWhitedOut`. + function isWhitedOut(bytes32 parent, address attester, bytes32 child) external view returns (bool); } interface IEAS { @@ -99,6 +123,12 @@ contract EFSRouter is IDecentralizedApp { ISchemaRegistry public schemaRegistry; bytes32 public dataSchemaUID; + /// @notice The WhiteoutResolver (proxy) for the cross-lens negative mask (ADR-0055). The router is + /// redeployable (in no schema UID), so this is a constructor arg, not a frozen wire. + /// `address(0)` ⇒ whiteout disabled: the lens scan serves exactly as before WHITEOUT + /// existed (pre-WHITEOUT router redeploys + tests that don't wire one keep prior behavior). + IWhiteoutResolverForRouter public whiteoutResolver; + /// @notice The SystemAccount (ADR-0053) — the neutral, code-governed `system` lens. Used as /// the tail of the default-lens chain (ADR-0039): when no `?lenses=` is given, content /// falls back to `system` (the bootstrap scaffolding / official defaults author), not @@ -125,13 +155,16 @@ contract EFSRouter is IDecentralizedApp { address _edgeResolver, address _schemaRegistry, bytes32 _dataSchemaUID, - address _systemAccount + address _systemAccount, + address _whiteoutResolver ) { indexer = IEFSIndexer(_indexer); eas = IEAS(_eas); edgeResolver = IEdgeResolverForRouter(_edgeResolver); schemaRegistry = ISchemaRegistry(_schemaRegistry); dataSchemaUID = _dataSchemaUID; + // ADR-0055: the cross-lens negative mask. Zero = disabled (pre-WHITEOUT router / tests). + whiteoutResolver = IWhiteoutResolverForRouter(_whiteoutResolver); // ADR-0053: the default-lens fallback points at SystemAccount, not the deployer EOA. Falls // back to indexer.DEPLOYER() only if a zero address is passed (pre-ADR-0053 deploys / tests // that don't wire a SystemAccount), preserving the old behavior in that degenerate case. @@ -213,6 +246,48 @@ contract EFSRouter is IDecentralizedApp { // the JSON fallback below still serves raw EAS info for either case. (ContainerFlavor flavor, bytes32 rawUID) = _classifyTopLevel(resource[0]); + // Parse params (lenses / caller / chunk) + apply the address-default lens block before the + // segment walk. The data-resolution path below reads the same `lenses`. + address[] memory lenses; + bool lensesExplicit = false; + address caller = msg.sender; // non-zero if web3:// client sets `from` on eth_call + string memory chunkIndexStr = ""; + for (uint i = 0; i < params.length; i++) { + if (_stringsEqual(params[i].key, "lenses")) { + lenses = _parseAddressList(params[i].value); + lensesExplicit = true; + } else if (_stringsEqual(params[i].key, "chunk")) { + chunkIndexStr = params[i].value; + } else if (_stringsEqual(params[i].key, "caller")) { + address[] memory parsed = _parseAddressList(params[i].value); + if (parsed.length > 0) caller = parsed[0]; + } + } + + // Address-default lenses: when browsing an address container with no explicit `?lenses=`, + // default to `[caller, segmentAddr, system]` (matches the data-resolution comment below). + if (flavor == ContainerFlavor.Address && !lensesExplicit) { + address segmentAddr = address(uint160(uint256(rawUID))); + address sys = _systemLens(); + if (caller != address(0) && caller != segmentAddr) { + lenses = new address[](3); + lenses[0] = caller; + lenses[1] = segmentAddr; + lenses[2] = sys; + } else { + lenses = new address[](2); + lenses[0] = segmentAddr; + lenses[1] = sys; + } + } + + // Build the effective lens stack ONCE (ADR-0031/0039/0053) and reuse it for both the + // per-segment deep-link whiteout terminal in the walk below and the final-target DATA read + // (`_findDataAtPath`). Computing it here — rather than recomputing a possibly-different stack + // inside `_findDataAtPath` — keeps the deep-link 404 (spec/04) and the leaf read on the exact + // same lens precedence (ADR-0055). An empty stack (viewer removed every lens) means no data. + address[] memory effLenses = _effectiveLenses(lenses, caller, lensesExplicit); + bytes32 currentParent; bytes32 targetAnchor; uint256 startIdx; @@ -233,6 +308,22 @@ contract EFSRouter is IDecentralizedApp { currentParent = aliasUID != bytes32(0) ? aliasUID : rawUID; targetAnchor = currentParent; startIdx = 1; + + // ADR-0055 deep-link terminal for the ALIAS seed. The loop below starts at startIdx=1 and + // never checks segment 0 (the alias anchor). When an alias is used AND there are deeper + // segments, the alias is an INTERMEDIATE folder — apply the same per-segment whiteout terminal + // (parent = root, child = aliasUID) so a viewer who whiteouts a schema/attestation alias anchor + // 404s a deep link `/0x/child.txt` instead of descending through the whited alias into + // lower-lens content. Only when an alias was actually used (a raw-UID seed is not a tree anchor, + // so there is nothing to whiteout); the bare-alias leaf (resource.length == 1) follows the + // loop's leaf rule and stays PIN-gated via `_findDataAtPath` below. + if ( + aliasUID != bytes32(0) && + resource.length > 1 && + _isSegmentWhitedOut(indexer.rootAnchorUID(), aliasUID, effLenses) + ) { + return (404, bytes("Not Found: Path does not exist"), new KeyValue[](0)); + } } for (uint i = startIdx; i < resource.length; i++) { @@ -250,51 +341,34 @@ contract EFSRouter is IDecentralizedApp { if (targetAnchor == bytes32(0)) { return (404, bytes("Not Found: Path does not exist"), new KeyValue[](0)); } - currentParent = targetAnchor; - } - // Combine parameter checks - address[] memory lenses; - bool lensesExplicit = false; - address caller = msg.sender; // non-zero if web3:// client sets `from` on eth_call - string memory chunkIndexStr = ""; - for (uint i = 0; i < params.length; i++) { - if ( - _stringsEqual(params[i].key, "lenses") - ) { - lenses = _parseAddressList(params[i].value); - lensesExplicit = true; - } else if (_stringsEqual(params[i].key, "chunk")) { - chunkIndexStr = params[i].value; - } else if (_stringsEqual(params[i].key, "caller")) { - address[] memory parsed = _parseAddressList(params[i].value); - if (parsed.length > 0) caller = parsed[0]; + // ADR-0055 deep-link terminal (spec/04): apply the per-name whiteout terminal to each + // INTERMEDIATE (non-leaf) segment against the SAME effective lens stack — so a deep link + // into a whited-out folder (e.g. a viewer whiteouts `/dir`; `GET /dir/child.txt`) 404s + // instead of resolving a lower lens's content. Checked against (currentParent = the + // segment's parent, targetAnchor = the just-resolved child) BEFORE descending. + // + // Intermediate folder segments use the LISTING positive (PIN OR folder visibility TAG) — + // a folder is made visible by a TAG, not a placement PIN (ADR-0038; upload emits + // `TAG(definition=dataSchemaUID, refUID=folder)`). So a lens that whiteouts an inherited + // folder then RE-ADDS it (uploading its own child re-emits the folder's visibility TAG) + // makes the folder traversable again for that lens — exactly matching + // `EFSFileView._isItemWhitedOutForListing`. The LEAF is deliberately excluded here: its + // DATA resolution stays PIN-only, gated by `_findDataAtPath` below (a folder visibility + // TAG must NOT un-gate a direct DATA read — overlayfs lookup semantics). Cost is + // O(segments × lenses), bounded (MAX_LENSES ≤ 20); zero when whiteout is disabled. + if (i != resource.length - 1 && _isSegmentWhitedOut(currentParent, targetAnchor, effLenses)) { + return (404, bytes("Not Found: Path does not exist"), new KeyValue[](0)); } - } - // Address-default lenses: when browsing an address container with no explicit - // `?lenses=`, default to `[caller, segmentAddr, system]` — "Vitalik's files, with my - // overrides on top, then the system defaults". Consistent with ADR-0031 (explicit lenses always - // override). The `system` tail (ADR-0053/0039) is what every other container flavor gets via the - // `_findDataAtPath` fallback; including it here keeps address browsing from silently losing the - // canonical SystemAccount defaults. Explicit `?lenses=` still bypasses this block entirely. - if (flavor == ContainerFlavor.Address && !lensesExplicit) { - address segmentAddr = address(uint160(uint256(rawUID))); - address sys = _systemLens(); - if (caller != address(0) && caller != segmentAddr) { - lenses = new address[](3); - lenses[0] = caller; - lenses[1] = segmentAddr; - lenses[2] = sys; - } else { - lenses = new address[](2); - lenses[0] = segmentAddr; - lenses[1] = sys; - } + currentParent = targetAnchor; } - // 2. Find DATA via TAG query: resolve lenses → TAG → DATA → MIRROR - (bytes32 dataUID, address dataAttester) = _findDataAtPath(targetAnchor, lenses, caller, lensesExplicit); + // 2. Find DATA via TAG query: resolve lenses → TAG → DATA → MIRROR. An empty effective stack + // (viewer removed every lens) returns (0,0) → falls into the no-data branch below, which + // still serves the raw schema/attestation JSON fallback for a bare container (resource + // length 1), preserving pre-whiteout behavior. + (bytes32 dataUID, address dataAttester) = _findDataAtPath(targetAnchor, effLenses); if (dataUID == bytes32(0)) { // Schema/Attestation containers with no DATA attached fall back to raw-info JSON // instead of 404. Only fires when the user typed the container itself (no sub-path) @@ -1012,25 +1086,24 @@ contract EFSRouter is IDecentralizedApp { // removed every lens, including system, so we must return no data — never silently fall // back to caller/system). Without this flag an empty `?lenses=` would be indistinguishable // from an absent one, violating viewer sovereignty. - function _findDataAtPath( - bytes32 targetAnchor, + /// @dev Build the effective attester stack from the parsed `lenses` + caller fallback (ADR-0031 / + /// ADR-0039 / ADR-0053). Returns an EMPTY array when the user supplied an explicit `?lenses=` + /// that parsed to zero valid lenses (every lens, including system, removed): no data, no + /// fallback (viewer sovereignty). + function _effectiveLenses( address[] memory lenses, address caller, bool lensesExplicit - ) private view returns (bytes32, address) { - bytes32 dataSchema = indexer.DATA_SCHEMA_UID(); - address systemLens = _systemLens(); - - address[] memory attesters; + ) private view returns (address[] memory attesters) { if (lenses.length > 0) { - attesters = lenses; - } else if (lensesExplicit) { - // User supplied `?lenses=` but it parsed to zero valid lenses — every lens - // (including the system default) has been removed. Return no data rather than - // falling back to caller/system. - return (bytes32(0), address(0)); - } else if (caller != address(0)) { - // Try caller first, then the system lens as fallback + return lenses; + } + if (lensesExplicit) { + // Explicit `?lenses=` that parsed to zero valid lenses — viewer removed every lens. + return new address[](0); + } + address systemLens = _systemLens(); + if (caller != address(0)) { attesters = new address[](2); attesters[0] = caller; attesters[1] = systemLens; @@ -1038,19 +1111,106 @@ contract EFSRouter is IDecentralizedApp { attesters = new address[](1); attesters[0] = systemLens; } + } + + /// @dev DATA resolution at a single (already path-resolved) anchor over a PRECOMPUTED effective + /// lens stack (`attesters`). The caller (`request`) builds the stack once via + /// `_effectiveLenses` and reuses it for BOTH the per-segment deep-link whiteout terminal + /// and this final-target read, so the two never diverge (ADR-0055 spec/04: a deep link into + /// a whited folder 404s on the SAME stack the leaf read uses). An empty stack means the + /// viewer removed all lenses → no data (viewer sovereignty), so the caller returns early. + function _findDataAtPath( + bytes32 targetAnchor, + address[] memory attesters + ) private view returns (bytes32, address) { + // Empty stack — the viewer removed all lenses → no data (viewer sovereignty). Returning (0,0) + // here lets the caller still serve the raw schema/attestation JSON fallback for a bare + // container (resource length 1), matching pre-whiteout behavior. + if (attesters.length == 0) return (bytes32(0), address(0)); + bytes32 dataSchema = indexer.DATA_SCHEMA_UID(); // File placement is Shape A — a file Anchor holds at most one DATA per attester. // Read the active PIN's target in O(1); skip attesters with an empty slot. // (Per ADR-0041 the cardinality lives in the schema UID itself.) + // + // ADR-0055 negative terminal: within each lens, in precedence order, check the POSITIVE + // placement PIN FIRST, then the WHITEOUT. (1) A positive PIN serves that lens's DATA (existing + // behavior; same-lens positive-before-whiteout override). (2) Otherwise, if the lens has an + // ACTIVE whiteout on this anchor, that is a negative terminal: serve empty (the router's + // existing not-found path → 404) and STOP — no fall-through to lower lenses, no system gap-fill. + // A whiteout by Lk is transparent to any lens ABOVE Lk because a higher lens's positive PIN + // (or its own whiteout) terminates the scan first. `whiteoutResolver == 0` ⇒ skip the whiteout + // read entirely (disabled). + IWhiteoutResolverForRouter wr = whiteoutResolver; + bytes32 parentAnchor = address(wr) != address(0) ? indexer.getParent(targetAnchor) : bytes32(0); for (uint256 i = 0; i < attesters.length; i++) { bytes32 target = edgeResolver.getActivePinTarget(targetAnchor, attesters[i], dataSchema); - if (target == bytes32(0)) continue; - return (target, attesters[i]); + if (target != bytes32(0)) return (target, attesters[i]); + // Negative terminal: this lens masks the path. Stop — serve empty, no fall-through. + if (address(wr) != address(0) && wr.isWhitedOut(parentAnchor, attesters[i], targetAnchor)) { + return (bytes32(0), address(0)); + } } return (bytes32(0), address(0)); } + /// @dev Single-element-array wrapper for `edgeResolver.hasActiveTagFromAny` (which takes an + /// `address[] calldata`). The intermediate-folder traversal terminal needs a TAG check scoped + /// to ONE lens at a time (precedence-ordered same-lens override), but EdgeResolver only exposes + /// the lens-aware ANY-of-many form — and it is FROZEN-by-UID (its address is hashed into the + /// PIN/TAG schema UIDs), so we cannot add a single-lens getter there. Wrapping the lens in a + /// 1-element memory array reuses the frozen reader without touching it (ADR-0055 folder-fix); + /// mirrors `EFSFileView._one`. + function _one(address lens) private pure returns (address[] memory arr) { + arr = new address[](1); + arr[0] = lens; + } + + /// @dev INTERMEDIATE-FOLDER per-name whiteout terminal (ADR-0055), for the per-SEGMENT deep-link + /// walk on NON-LEAF segments. Shaped identically to `EFSFileView._isItemWhitedOutForListing`: + /// its positive terminal is a file placement PIN OR a folder VISIBILITY TAG + /// (`TAG(definition=dataSchemaUID, refUID=folder)`, ADR-0038) — folders are made visible by a + /// TAG, not a placement PIN, so a lens that whiteouts an inherited folder then RE-ADDS it (its + /// own child upload re-emits the folder's visibility TAG) makes the folder traversable again + /// for that lens (THE FOLDER RE-ADD FIX). The leaf is NOT routed through here — its DATA read + /// stays PIN-only in `_findDataAtPath` (a folder TAG must not un-gate a direct DATA read). + /// Returns true iff, walking `attesters` in precedence order, some lens whites out + /// `childAnchor` under `parentAnchor` with no higher-precedence lens asserting a positive + /// (PIN or visibility TAG) there first: + /// - the FIRST lens with a positive (file PIN OR folder visibility TAG) at `childAnchor` ⇒ + /// traversable, return false (same-lens positive-before-whiteout; a higher lens's positive + /// is transparent to a lower lens's whiteout — spec/04 deep-link case); + /// - else if that lens has an ACTIVE whiteout on `childAnchor` ⇒ negative terminal, return + /// true (stop fall-through — the deep link 404s); + /// - else continue to the next (lower) lens. + /// `whiteoutResolver == 0` (disabled) short-circuits to false before any read — a pre-WHITEOUT + /// router redeploy keeps its exact prior traversal behavior (zero cost when disabled). + function _isSegmentWhitedOut( + bytes32 parentAnchor, + bytes32 childAnchor, + address[] memory attesters + ) private view returns (bool) { + IWhiteoutResolverForRouter wr = whiteoutResolver; + if (address(wr) == address(0)) return false; + bytes32 dataSchema = indexer.DATA_SCHEMA_UID(); + for (uint256 i = 0; i < attesters.length; i++) { + address lens = attesters[i]; + // Positive terminal: this lens places its own content here (file PIN) OR re-asserts the + // folder's visibility (TAG, ADR-0038) → traversable, stop. The visibility TAG is the + // folder re-add fix — listing-positive parity with EFSFileView._isItemWhitedOutForListing. + if ( + edgeResolver.getActivePinTarget(childAnchor, lens, dataSchema) != bytes32(0) || + edgeResolver.hasActiveTagFromAny(childAnchor, dataSchema, _one(lens)) + ) { + return false; + } + // Negative terminal: this lens whites the segment out → 404, stop (no fall-through). + if (wr.isWhitedOut(parentAnchor, lens, childAnchor)) return true; + } + return false; // no lens asserted a positive or a whiteout → transparent. + } + // Get the best mirror URI for a DATA attestation, scoped to the lens attester. // Only mirrors attached by `attester` are considered — prevents third parties from // injecting mirrors onto a DATA that is served under someone else's lens. diff --git a/packages/hardhat/contracts/WhiteoutResolver.sol b/packages/hardhat/contracts/WhiteoutResolver.sol new file mode 100644 index 00000000..91806b95 --- /dev/null +++ b/packages/hardhat/contracts/WhiteoutResolver.sol @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IEAS, Attestation } from "@ethereum-attestation-service/eas-contracts/contracts/IEAS.sol"; +import { EFSUpgradeableResolver } from "./base/EFSUpgradeableResolver.sol"; + +/// @notice Minimal EFSIndexer read view the WhiteoutResolver needs. It reads ONLY — it never writes +/// kernel state (no `index`/`propagateContains`). `getParent` derives the suppressed child's +/// parent anchor (the per-name whiteout key); `ANCHOR_SCHEMA_UID` types the refUID at write +/// time. Both are pure reads of the append-only kernel. +interface IEFSIndexerForWhiteout { + function getParent(bytes32 anchorUID) external view returns (bytes32); + + function ANCHOR_SCHEMA_UID() external view returns (bytes32); +} + +/** + * @title WhiteoutResolver + * @dev Resolver for the EFS WHITEOUT schema (ADR-0055). Write-time enforcement engine + per-parent + * discovery index for the cross-lens negative mask (overlayfs whiteout / lens-local delete): a + * lens asserts "render this path empty in MY view; stop fall-through to lower lenses" WITHOUT + * substituting its own content. The one filesystem primitive additive-only lenses (ADR-0031) + * otherwise lack. + * + * WHITEOUT schema: "" (EMPTY — a pure-identity negative marker, same idiom as DATA, ADR-0049). + * The assertion is "this source is suppressed in this lens"; it needs no payload — the meaning + * is carried entirely by (schema, attester, refUID). + * revocable: true (revoke == un-hide; onRevoke returns true). + * + * Field semantics: + * refUID = the suppressed source. v1: a path **ANCHOR ONLY** (per-name whiteout). The parent + * is `indexer.getParent(refUID)` and the suppressed child is `refUID` itself, so the + * whiteout keys on `(parent, suppressedChildAnchor)` — NOT definition-scoped (ADR-0055: + * whiteouts are not edges, so they never share EdgeResolver's `(parent, definition)` + * key shape or its storage). + * + * v1 SCOPE (narrows ADR-0055's "ANCHOR or DATA" — the ADR left v1 scope open): refUID MUST be an + * ANCHOR. DATA-whiteout is DEFERRED — additive later (widening the guard never orphans data). + * Suppressing a PROPERTY / MIRROR / DATA / another whiteout is meaningless and rejected at write + * time (SourceNotAnchor). + * + * WRITE-TIME GUARDS ONLY + a read-side discovery index. This resolver does NOT write kernel + * state — it reads `getParent` (ADR-0055: read-side only; the kernel's indices and + * weight-neutrality are untouched). Read-time resolution (the negative terminal in the lens + * scan; readdir participation) lives in EFSRouter / EFSFileView, NOT here. + * + * Upgradeable (ADR-0048): runs behind an ERC1967 proxy whose ADDRESS is the EAS resolver baked + * into the WHITEOUT schema UID. Per-deployment config (the self-derived WHITEOUT schema UID plus + * the indexer ref + ANCHOR schema UID used for typing) lives in ERC-7201 namespaced storage + * (`efs.whiteout.config`), set once via initialize(). + * + * CRITICAL self-UID fix (same as AliasResolver): the WHITEOUT schema UID self-derivation + * (keccak256(WHITEOUT_DEFINITION, address(this), true)) MUST run in initialize(), where the + * proxy's delegatecall makes address(this) == the PROXY (the resolver baked into the schema UID + * at EAS registration). Deriving it in the constructor would use the IMPLEMENTATION address, + * producing a UID that diverges from the registered one — onAttest/onRevoke would then reject + * EVERY genuine WHITEOUT with WrongSchema. See initialize() and whiteoutSchemaUID(). + * + * WhiteoutResolver has no deployer/owner-gated functions, so it does NOT inherit OwnableUpgradeable. + */ +contract WhiteoutResolver is EFSUpgradeableResolver { + // ── Errors ────────────────────────────────────────────────────────────────── + error WrongSchema(); + error BadPayload(); + error ZeroRef(); + error NotRevocable(); + error HasExpiration(); + error SourceNotAnchor(); + error OrphanAnchor(); + + // ── Events ────────────────────────────────────────────────────────────────── + // Indexed topics: parent, suppressedChild, whiteoutUID. The attester is left non-indexed + // (low-cardinality vs the join keys, and always co-filtered with parent/child off the log data). + // `whiteoutUID` (the attestation's own UID) is the join key for correlating with the native EAS + // `Attested`/`Revoked` logs and for "was THIS whiteout retracted?" lookups, so it earns the 3rd + // indexed slot. (EVM caps non-anonymous events at 3 topics.) + event WhiteoutAttested( + bytes32 indexed parent, + bytes32 indexed suppressedChild, + address attester, + bytes32 indexed whiteoutUID + ); + event WhiteoutRevoked( + bytes32 indexed parent, + bytes32 indexed suppressedChild, + address attester, + bytes32 indexed whiteoutUID + ); + + // ── Constants ─────────────────────────────────────────────────────────────── + // The WHITEOUT field string — FROZEN (ADR-0055) and MUST match the deploy registration exactly + // (it's hashed into the schema UID). Empty: a pure-identity negative marker (ADR-0049 idiom). + // Used to self-derive this resolver's own WHITEOUT schema UID so onAttest/onRevoke can reject + // attestations from any OTHER schema an attacker registers pointing at this resolver (which would + // otherwise bypass write-time enforcement). + string private constant WHITEOUT_DEFINITION = ""; + + // ============================================================================================ + // ERC-7201 NAMESPACED CONFIG (per-deployment, set in initialize()) + // ============================================================================================ + /// @custom:storage-location erc7201:efs.whiteout.config + struct WhiteoutConfig { + bytes32 whiteoutSchemaUID; // self-derived against the PROXY in initialize() + IEFSIndexerForWhiteout indexer; // read-only kernel ref (getParent + ANCHOR_SCHEMA_UID) + bytes32 anchorSchemaUID; // typing reference: refUID MUST be an ANCHOR (v1) + // ───────────────────────────────────────────────────────────────────────────────────── + // TWO-STRUCTURE STORAGE (mirrors AliasResolver's additive index + EdgeResolver's + // discovery-list-plus-marker split): an append-only discovery list for paging/readdir, plus + // an active-marker map for O(1) liveness. The list entry may be STALE (marker cleared to 0 + // after revoke); readers/predicate filter on the marker. NEVER pop the list (ADR-0009). + // + // childrenWhitedOut[parent][attester] — append-only discovery list of suppressed child + // anchors (a per-name whiteout keys on (parent, suppressedChildAnchor)). + mapping(bytes32 parent => mapping(address attester => bytes32[] children)) childrenWhitedOut; + // isChildWhitedOut[parent][attester][child] — append-once membership guard so re-whiteout of + // the same child by the same attester doesn't double-push the discovery list (idempotent). + mapping(bytes32 parent => mapping(address attester => mapping(bytes32 child => bool))) + isChildWhitedOut; + // activeWhiteout[parent][attester][child] — the LIVE whiteout UID for this slot, or 0 when + // none/revoked. The O(1) liveness predicate (`isWhitedOut` != 0) reads this; cleared on + // revoke (last-writer-wins). cardinality-1 per (parent, attester, child). + mapping(bytes32 parent => mapping(address attester => mapping(bytes32 child => bytes32 uid))) + activeWhiteout; + } + + // keccak256(abi.encode(uint256(keccak256("efs.whiteout.config")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant WHITEOUT_CONFIG_SLOT = 0xa5c53223ecaa41b7a6fdaddbcfa4b4cf6a476f663272302d45b97d6a62ebbb00; + + function _cfg() private pure returns (WhiteoutConfig storage $) { + assembly { + $.slot := WHITEOUT_CONFIG_SLOT + } + } + + // ── Constructor / initializer ───────────────────────────────────────────────── + /// @param eas The canonical EAS for the target chain (immutable on the base; see + /// EFSUpgradeableResolver NatSpec). The base constructor runs `_disableInitializers()` + /// so the implementation itself can never be initialized — only a proxy can. + constructor(IEAS eas) EFSUpgradeableResolver(eas) {} + + /// @notice One-time per-deployment initialization, run behind the proxy. + /// @dev Guarded by `initializer` — callable exactly once per proxy. Stores the read-only indexer + /// ref, snapshots the ANCHOR schema UID (used to type refUID), and self-derives this + /// resolver's own WHITEOUT schema UID. + /// + /// The self-UID derivation MUST live here, NOT in the constructor: under the proxy's + /// delegatecall `address(this)` is the PROXY, which is the resolver address baked into the + /// WHITEOUT schema UID at EAS registration. Deriving it in the constructor would use the + /// IMPLEMENTATION address, producing a UID that diverges from the registered one — and + /// onAttest/onRevoke (which compare `a.schema` against the stored UID) would then reject + /// EVERY genuine WHITEOUT with WrongSchema. (ADR-0048.) + /// @param indexer_ The EFSIndexer (proxy) — read-only (getParent + ANCHOR_SCHEMA_UID). + function initialize(IEFSIndexerForWhiteout indexer_) external initializer { + require(address(indexer_) != address(0), "indexer is zero"); + WhiteoutConfig storage $ = _cfg(); + $.indexer = indexer_; + bytes32 anchorUID = indexer_.ANCHOR_SCHEMA_UID(); + require(anchorUID != bytes32(0), "anchorSchemaUID is zero"); + $.anchorSchemaUID = anchorUID; + // address(this) == proxy here (delegatecall) — matches the EAS-registered resolver. The + // per-name WHITEOUT self-UID is derived here against the proxy (empty field string). + $.whiteoutSchemaUID = keccak256(abi.encodePacked(WHITEOUT_DEFINITION, address(this), true)); + } + + // ── Getters ─────────────────────────────────────────────────────────────────── + + /// @notice This resolver's own WHITEOUT schema UID, self-derived in initialize() against the + /// PROXY address (keccak256(WHITEOUT_DEFINITION, address(this), true)). + /// @dev EAS UID = keccak256(abi.encodePacked(schemaString, resolver, revocable)); resolver (the + /// proxy) and revocable (true) are fixed, so the value is deterministic once the proxy + /// address is known. onAttest/onRevoke use this to reject foreign schemas pointed here. The + /// deploy verify gate (deploy-lib/verify.ts) asserts this equals the computed WHITEOUT UID. + function whiteoutSchemaUID() external view returns (bytes32) { + return _cfg().whiteoutSchemaUID; + } + + /// @notice The EFSIndexer (proxy) this resolver reads (getParent + ANCHOR_SCHEMA_UID). Read-only. + function indexer() external view returns (address) { + return address(_cfg().indexer); + } + + /// @notice The ANCHOR schema UID used to type-check refUID (v1: refUID MUST be an ANCHOR). + function anchorSchemaUID() external view returns (bytes32) { + return _cfg().anchorSchemaUID; + } + + // ── Read views (co-shaped with EdgeResolver.getChildrenWithEdge) ──────────────────────────── + + /// @notice O(1) liveness predicate: true iff `attester` has an ACTIVE whiteout suppressing + /// `child` under `parent`. The read-time negative terminal (EFSRouter / EFSFileView) and + /// readdir filter call this once per item per lens. A revoked whiteout reads false (the + /// marker was cleared); a re-attested one reads true under the new UID. + /// @param parent The parent anchor (`indexer.getParent(child)` at attest time). + /// @param attester The lens whose whiteout to read. + /// @param child The suppressed child anchor (the refUID of the whiteout). + function isWhitedOut(bytes32 parent, address attester, bytes32 child) external view returns (bool) { + return _cfg().activeWhiteout[parent][attester][child] != bytes32(0); + } + + /// @notice The LIVE WHITEOUT attestation UID for a slot, or `bytes32(0)` if none is active. This is + /// the UID `eas.revoke()` needs to UN-DELETE (specs/04 §8f) — recoverable on-chain from the + /// `(parent, attester, child)` slot a reloaded client already knows, so un-delete never + /// depends on off-chain event-log replay. After a re-whiteout this returns the CURRENT UID + /// (a prior UID intentionally no-ops on revoke), so a client always revokes the right one. + /// @param parent The parent anchor (`indexer.getParent(child)` at attest time). + /// @param attester The lens whose whiteout to read. + /// @param child The suppressed child anchor (the refUID of the whiteout). + function getActiveWhiteout(bytes32 parent, address attester, bytes32 child) external view returns (bytes32) { + return _cfg().activeWhiteout[parent][attester][child]; + } + + /// @notice Paged discovery: child anchors `attester` has an ACTIVE whiteout on under `parent`. + /// Walks the append-only list and filters to live markers (a stale entry — revoked — is + /// skipped). Co-shaped with `EdgeResolver.getChildrenWithEdge` for readdir participation. + /// @dev The slice is taken over the RAW append-only list (so `start`/`length` page the list, + /// not the filtered view); revoked entries inside the page are dropped, so the returned + /// array may be shorter than `length`. A caller paging the full set walks until + /// `start >= getChildrenWhitedOutCount`. + function getChildrenWhitedOut( + bytes32 parent, + address attester, + uint256 start, + uint256 length + ) external view returns (bytes32[] memory) { + WhiteoutConfig storage $ = _cfg(); + bytes32[] storage all = $.childrenWhitedOut[parent][attester]; + uint256 total = all.length; + if (total == 0 || start >= total) return new bytes32[](0); + uint256 end = start + length; + if (end > total) end = total; + + // First pass: count live entries in the window so the result array is exact-sized. + uint256 live = 0; + for (uint256 i = start; i < end; ++i) { + if ($.activeWhiteout[parent][attester][all[i]] != bytes32(0)) live++; + } + bytes32[] memory res = new bytes32[](live); + uint256 j = 0; + for (uint256 i = start; i < end; ++i) { + bytes32 child = all[i]; + if ($.activeWhiteout[parent][attester][child] != bytes32(0)) res[j++] = child; + } + return res; + } + + /// @notice Length of the append-only discovery list for `(parent, attester)` — INCLUDING stale + /// (revoked) entries (ADR-0009: the list never pops). Use as the paging bound for + /// `getChildrenWhitedOut`; the live count is obtained by filtering the pages. + function getChildrenWhitedOutCount(bytes32 parent, address attester) external view returns (uint256) { + return _cfg().childrenWhitedOut[parent][attester].length; + } + + // ── Resolver hooks ────────────────────────────────────────────────────────── + + function onAttest(Attestation calldata a, uint256) internal override returns (bool) { + WhiteoutConfig storage $ = _cfg(); + // EAS lets anyone register a NEW schema pointing at this resolver; reject any attestation whose + // schema is not this resolver's own self-derived per-name WHITEOUT UID. + if (a.schema != $.whiteoutSchemaUID) revert WrongSchema(); + // Empty field string ⇒ zero-length payload (pure-identity marker). A non-empty data field is + // not a WHITEOUT this resolver authored. + if (a.data.length != 0) revert BadPayload(); + // refUID is the suppressed source; a whiteout with no source is meaningless. + if (a.refUID == bytes32(0)) revert ZeroRef(); + // Lifecycle invariants (ADR-0055) — a WHITEOUT is "active until explicitly revoked" (revoke + // == un-hide), with no expiry. A revocable *schema* only PERMITS revocable attestations; EAS + // still accepts revocable=false (the whiteout becomes permanent/uncorrectable — un-hide would + // be impossible) and nonzero expirationTime (it silently expires while reads filter on + // revocation, not expiry, leaving a "stuck" hide). Reject both. + if (!a.revocable) revert NotRevocable(); + if (a.expirationTime != 0) revert HasExpiration(); + + // v1 typing: the suppressed source MUST be an ANCHOR (per-name whiteout). DATA/PROPERTY/ + // MIRROR/another-whiteout refUIDs are meaningless to suppress and rejected (ADR-0055). + if (_eas.getAttestation(a.refUID).schema != $.anchorSchemaUID) revert SourceNotAnchor(); + + // Derive the parent (the whiteout key is (parent, suppressedChild)). A root-level anchor has + // no parent — whiting out root is meaningless (there is no lens below root to suppress at the + // parent slot), so reject it rather than key a whiteout on parent==0. + bytes32 parent = $.indexer.getParent(a.refUID); + if (parent == bytes32(0)) revert OrphanAnchor(); + + bytes32 child = a.refUID; + address attester = a.attester; + + // Append-only discovery push, guarded so re-whiteout of the same child is idempotent (the + // list never holds a duplicate; ADR-0009 never pops). The activeWhiteout marker below is what + // carries last-writer-wins liveness even when the list entry already exists. + if (!$.isChildWhitedOut[parent][attester][child]) { + $.isChildWhitedOut[parent][attester][child] = true; + $.childrenWhitedOut[parent][attester].push(child); + } + // Last-writer-wins: record THIS attestation as the live whiteout for the slot. A second + // whiteout from the same attester on the same child overwrites the marker (cardinality-1), + // and revoking the OLDER UID will then no-op (the marker no longer points at it). + $.activeWhiteout[parent][attester][child] = a.uid; + + emit WhiteoutAttested(parent, child, attester, a.uid); + return true; + } + + function onRevoke(Attestation calldata a, uint256) internal override returns (bool) { + // revocable == true: a WHITEOUT can always be retracted (un-hide). Guard the schema for + // symmetry with onAttest (reject foreign-schema revokes), then clear the live marker — but + // ONLY if it still points at THIS whiteout (last-writer-wins, like AliasResolver.onRevoke): + // a newer whiteout from the same attester on the same child may already own the marker, and + // revoking the stale older one must not wipe the live newer pointer. The append-only + // discovery list entry is left in place (ADR-0009 never pops); readers filter on the marker. + WhiteoutConfig storage $ = _cfg(); + // Mirror onAttest: reject foreign-schema revokes pointed at this resolver (WrongSchema). + if (a.schema != $.whiteoutSchemaUID) revert WrongSchema(); + if (a.data.length != 0) revert BadPayload(); + if (a.refUID == bytes32(0)) revert ZeroRef(); + + // AGENT-NOTE (ADR-0055): onRevoke intentionally does NOT re-validate the refUID's typing + // (SourceNotAnchor) or re-derive/re-check its parent against the one used at attest time. It is + // safe to trust `getParent(a.refUID)` here because anchors are NON-revocable and their parent + // (`refUID` at mint) is IMMUTABLE — so `getParent(a.refUID)` returns the exact same parent it + // returned in onAttest, and the (parent, attester, child) slot key is stable across the + // attest→revoke lifetime. Even in the impossible event of a mismatch, the `== a.uid` guard + // below makes it a harmless no-op: a key that never held this UID simply isn't cleared. Skipping + // the re-typing keeps revoke (un-hide) cheap and unconditional, matching revocable=true intent. + bytes32 parent = $.indexer.getParent(a.refUID); + bytes32 child = a.refUID; + address attester = a.attester; + + if ($.activeWhiteout[parent][attester][child] == a.uid) { + $.activeWhiteout[parent][attester][child] = bytes32(0); + } + + emit WhiteoutRevoked(parent, child, attester, a.uid); + return true; + } +} diff --git a/packages/hardhat/contracts/test/EFSFileViewTestable.sol b/packages/hardhat/contracts/test/EFSFileViewTestable.sol index 76e61d4a..612ff1e9 100644 --- a/packages/hardhat/contracts/test/EFSFileViewTestable.sol +++ b/packages/hardhat/contracts/test/EFSFileViewTestable.sol @@ -1,7 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; -import { EFSFileView, IEFSIndexer, IEdgeResolverForFileView } from "../EFSFileView.sol"; +import { + EFSFileView, + IEFSIndexer, + IEdgeResolverForFileView, + IWhiteoutResolverForFileView +} from "../EFSFileView.sol"; /// @title EFSFileViewTestable /// @notice Test-only subclass of `EFSFileView` that shrinks the per-call scan budgets so the @@ -18,8 +23,9 @@ contract EFSFileViewTestable is EFSFileView { constructor( IEFSIndexer _indexer, IEdgeResolverForFileView _edgeResolver, + IWhiteoutResolverForFileView _whiteoutResolver, uint256 testBudget - ) EFSFileView(_indexer, _edgeResolver) { + ) EFSFileView(_indexer, _edgeResolver, _whiteoutResolver) { _testBudget = testBudget; } diff --git a/packages/hardhat/deploy-lib/create3.ts b/packages/hardhat/deploy-lib/create3.ts index b2e1620a..633a7438 100644 --- a/packages/hardhat/deploy-lib/create3.ts +++ b/packages/hardhat/deploy-lib/create3.ts @@ -40,6 +40,7 @@ export const RESOLVER_SALT_ENTROPY: Record = { ListResolver: "0x000000000000006c697374", // "list" (11 bytes) ListEntryResolver: "0x00006c697374656e747279", // "listentry" (11 bytes) AliasResolver: "0x000000000000616c696173", // "alias" (11 bytes) + WhiteoutResolver: "0x00000077686974656f7574", // "whiteout" (11 bytes) — ADR-0055, additive 7th resolver SystemAccount: "0x000000000073797374656d", // "system" (11 bytes) — ADR-0053, frozen for address stability }; diff --git a/packages/hardhat/deploy-lib/orchestrate.ts b/packages/hardhat/deploy-lib/orchestrate.ts index 994bfef4..0099af80 100644 --- a/packages/hardhat/deploy-lib/orchestrate.ts +++ b/packages/hardhat/deploy-lib/orchestrate.ts @@ -4,17 +4,17 @@ // RPC, no private key). // // Sequence: -// 1. Predict all 6 proxy CREATE3 addresses (depend only on deployer+salt) → compute all 9 UIDs. +// 1. Predict all 7 proxy CREATE3 addresses (depend only on deployer+salt) → compute all 10 UIDs. // 2. Deploy each resolver impl + its CREATE3 proxy (atomic initialize via the proxy constructor), // passing the precomputed UIDs/partner refs as init args. // 3. VERIFY GATE (deploy-lib/verify.ts) — abort on any failure. // 4. Wire: EFSIndexer.wireContracts(...) ONLY — pure storage, no EAS call (safe pre-register). The // /transports/* anchors + MirrorResolver.setTransportsAnchor need ANCHOR registered first → step 6. // --- FREEZE GATE (human on real Sepolia; auto on fork) --- -// 6. Register the 9 schemas LAST; assert getSchema(uid).resolver==proxy. THEN SystemAccount.bootstrap +// 6. Register the 10 schemas LAST; assert getSchema(uid).resolver==proxy. THEN SystemAccount.bootstrap // authors root + /transports/* (then seal) and MirrorResolver.setTransportsAnchor binds the UID. // 7. Transfer every ProxyAdmin owner + resolver Ownable owner to the Safe; assert owner()==Safe. -// 8. Per-schema smoke: push one attestation through each of the 9 schemas; assert no revert. +// 8. Per-schema smoke: push one attestation through each of the 10 schemas; assert no revert. import { Contract, Signer, ZeroAddress, ZeroHash } from "ethers"; import { ethers } from "hardhat"; @@ -153,7 +153,7 @@ export async function orchestrate( // ── Step 1: predict all proxy addresses, compute all UIDs ────────────────────────────────── // SystemAccount (ADR-0053) is predicted/deployed alongside the resolvers (own committed salt, // frozen for address stability) but is NOT in any schema UID — so it does NOT feed - // computeAllSchemaUIDs. The schema UIDs depend only on the six resolver proxy addresses. + // computeAllSchemaUIDs. The schema UIDs depend only on the seven resolver proxy addresses. l("EFS deploy: predicting CREATE3 proxy addresses..."); const proxies = {} as Record; const rawSalts = {} as Record; @@ -186,6 +186,9 @@ export async function orchestrate( ListResolver: { fn: "initialize", args: [] }, ListEntryResolver: { fn: "initialize", args: [schemaUIDs.LIST] }, AliasResolver: { fn: "initialize", args: [schemaUIDs.DATA, schemaUIDs.ANCHOR] }, + // WhiteoutResolver (ADR-0055): read-only kernel ref. It self-derives its WHITEOUT schema UID + + // snapshots ANCHOR_SCHEMA_UID from the indexer in initialize(); no kernel writes, no Ownable. + WhiteoutResolver: { fn: "initialize", args: [proxies.EFSIndexer] }, }; const deploys: Record = {}; @@ -339,7 +342,7 @@ export async function orchestrate( return result; } -/// Steps 6 + (post-register anchors) + 7 + 8: register the 9 schemas LAST, create the /transports/* +/// Steps 6 + (post-register anchors) + 7 + 8: register the 10 schemas LAST, create the /transports/* /// anchors + setTransportsAnchor, transfer all ownership to the Safe, then the per-schema smoke. export async function registerAndTransfer( result: OrchestrationResult, @@ -383,6 +386,20 @@ export async function registerAndTransfer( `REDIRECT UID ${schemaUIDs.REDIRECT} — proxy/schema drift between freeze-gate runs.`, ); } + // WHITEOUT (ADR-0055): the WhiteoutResolver self-derives its UID — re-assert it before the + // irreversible register (same ListEntry-class drift guard). + const whiteoutProxy = (await ethers.getContractAt( + "WhiteoutResolver", + proxies.WhiteoutResolver, + deployer, + )) as unknown as Contract; + const onchainWhiteoutUID: string = await whiteoutProxy.whiteoutSchemaUID(); + if (onchainWhiteoutUID.toLowerCase() !== schemaUIDs.WHITEOUT.toLowerCase()) { + throw new Error( + `REGISTER ABORT: WhiteoutResolver.whiteoutSchemaUID() ${onchainWhiteoutUID} != to-be-registered ` + + `WHITEOUT UID ${schemaUIDs.WHITEOUT} — proxy/schema drift between freeze-gate runs.`, + ); + } // ── Step 6: register LAST ───────────────────────────────────────────────────────────────── // FIX (PR #24 P1): the EOA path registers each schema in a SEPARATE tx and only creates the @@ -407,7 +424,7 @@ export async function registerAndTransfer( `(docs/DEPLOYMENT.md §3). The EOA path may still deploy+wire (--until-freeze-gate) anywhere.`, ); } - l("EFS deploy: registering 9 schemas LAST..."); + l("EFS deploy: registering 10 schemas LAST..."); for (const s of SCHEMAS) { const resolver = proxies[s.resolver]; const uid = schemaUIDs[s.name]; @@ -482,11 +499,12 @@ export async function registerAndTransfer( l(` MirrorResolver.transportsAnchorUID = ${transportsUID}`); } - // ── Step 8 (smoke): push one attestation through each of the 9 schemas BEFORE handing off - // ownership. Smoke runs on EVERY path — including post-seal retries — because seal() fires + // ── Step 8 (smoke): push one attestation through each of the 10 schemas (9 frozen + WHITEOUT, + // ADR-0055) BEFORE handing off ownership (deployer is the attester; works on the fork without the + // Safe signer). Smoke runs on EVERY path — including post-seal retries — because seal() fires // BEFORE smoke, so alreadySealed==true does not imply smoke completed. A run that reaches // seal() and then fails during smoke leaves the ceremony sealed but unverified; skipping smoke - // on the retry would silently bypass the only pre-transfer proof that all 9 schemas are wired + // on the retry would silently bypass the only pre-transfer proof that all 10 schemas are wired // and reachable, and ownership would transfer to the Safe against potentially broken resolvers. // Re-running smoke is safe: the ANCHOR case is already idempotent (smoke.txt check inside // perSchemaSmoke), and no other smoke schema has a uniqueness constraint that rejects a second @@ -647,7 +665,7 @@ async function assertIsSafe(addr: string, networkName: string): Promise { } } -/// Step 8 (subset): push one attestation through each of the 9 schemas; assert no revert + index write. +/// Step 8 (subset): push one attestation through each of the 10 schemas; assert no revert + index write. async function perSchemaSmoke( result: OrchestrationResult, deployer: Signer, @@ -721,6 +739,11 @@ async function perSchemaSmoke( // REDIRECT (symlink: source ANCHOR -> target). kind=2 (symlink) requires ANCHOR source. await attest(schemaUIDs.REDIRECT, abi.encode(["bytes32", "uint16"], [rootUID, 2]), fileAnchor, true); + // WHITEOUT (ADR-0055): empty payload, refUID = an ANCHOR with a parent. `fileAnchor` is under + // root, so getParent != 0 (OrphanAnchor would reject a root-level whiteout). Idempotent / no-op- + // safe: whiting out a real anchor is legal even with no lower-lens content to suppress. + await attest(schemaUIDs.WHITEOUT, "0x", fileAnchor, true); + // index write proof: the file anchor resolves under root, and the PIN placed DATA there. const resolved = await indexer.resolvePath(rootUID, "smoke.txt"); if (resolved.toLowerCase() !== fileAnchor.toLowerCase()) { @@ -728,5 +751,5 @@ async function perSchemaSmoke( } void attesterAddr; void propUID; - l(" 9/9 per-schema smokes passed; index write confirmed."); + l(` ${SCHEMAS.length}/${SCHEMAS.length} per-schema smokes passed; index write confirmed.`); } diff --git a/packages/hardhat/deploy-lib/orchestrateSafe.ts b/packages/hardhat/deploy-lib/orchestrateSafe.ts index edf06301..3edf9f71 100644 --- a/packages/hardhat/deploy-lib/orchestrateSafe.ts +++ b/packages/hardhat/deploy-lib/orchestrateSafe.ts @@ -26,10 +26,13 @@ import { ResolverName, SCHEMAS } from "./schemas"; import { Create3DeployResult, Create3Name } from "./create3"; import { OrchestrationResult } from "./orchestrate"; import { + AdditivePlan, + buildAdditivePlan, buildBatch2, buildSafePlan, buildSetTransportsAnchorCall, detectDeployPhase, + detectMissingResolvers, DeployPhase, PredictedPlan, predictPlan, @@ -142,6 +145,33 @@ export async function orchestrateViaSafe( return proposeViaSafe(deployer, safeContract, safe, predicted, opts.proposeArtifactPath, log); } + // ── ADDITIVE execute branch (ADR-0055 — adding WHITEOUT to a LIVE core) ─────────────────────────── + // When the core EFSIndexer is already live but a later-appended resolver is missing, the execute path + // must NOT run a fresh Phase-0 `buildSafePlan` (which deploys all 7 proxies and would revert on the + // already-taken core CREATE3 addresses). Deploy ONLY the missing resolver(s) + register their schema(s) + // through the Safe, leaving the core ceremony untouched. (The fork rehearsal of the additive case, and + // any execute-mode resume, lands here.) A fresh chain has no live core, so this is skipped and the + // normal full Phase-0 deploy below runs unchanged. + { + const predictedForAdditive = await predictPlan(deployer, safe, false); + const indexerLive = (await ethers.provider.getCode(predictedForAdditive.proxies.EFSIndexer)) !== "0x"; + // Additive mode runs ONLY when the EXISTING CORE is COMPLETE (Phase 3 — every core schema registered, + // bootstrap sealed, transports wired). `detectDeployPhase` is additive-aware (it skips schemas whose + // resolver proxy is absent), so a complete core with only a later-appended resolver missing resolves + // to 3, while an OLDER ceremony paused mid-core (Batch 1 → Phase 1, Batch 2 → Phase 2 — e.g. a + // six-resolver ceremony resumed under this seven-resolver code) does NOT. Without this gate that + // paused ceremony would enter additive, deploy/register only WHITEOUT, return `registered: true`, and + // ABANDON the owed core Batch 2/3 while falsely reporting success (Codex review). At Phase 1/2 we fall + // through to the normal path below instead. (The additive A1→A2 resume is unaffected: once the missing + // proxy lands, its schema registration routes through the normal Phase-1 register-omit path, which + // proposes exactly the one owed leg — see proposeViaSafe + DeploySafeAdditive.fork test 4.) + const corePhase = indexerLive ? await detectDeployPhase(deployer, predictedForAdditive) : 0; + const missingAdditive = corePhase === 3 ? await detectMissingResolvers(deployer, predictedForAdditive) : []; + if (missingAdditive.length > 0) { + return executeAdditive(deployer, safeContract, safe, predictedForAdditive, missingAdditive, owners, log); + } + } + // ── Execute path (fork rehearsal): always a Phase-0 self-deploy of the auto-test-Safe. Precompute the // whole graph + assemble all batches (deploys impls — Batch 1 needs them as CreateX initcode). ── const plan = await buildSafePlan(deployer, safe, log); @@ -350,6 +380,128 @@ export async function orchestrateViaSafe( return result; } +/// ADDITIVE execute (ADR-0055 — adding WHITEOUT to a LIVE core). The execute-mode counterpart of +/// `proposeAdditive`: deploy ONLY the missing resolver(s) + register their schema(s) FROM the Safe, with +/// the new proxy born Safe-owned, leaving the live core (wiring / scaffolding / transports) untouched. +/// Reached only when the core EFSIndexer is live but a later-appended resolver has no on-chain code (a +/// fresh chain runs the full Phase-0 deploy instead). Idempotent: Batch A1 is skipped if the proxies are +/// already deployed; Batch A2 register legs are omitted for already-registered schemas (so a re-run after +/// it landed is a clean no-op). The verify gate runs against the full live proxy set before any register. +async function executeAdditive( + deployer: Signer, + safeContract: Contract, + safe: string, + predicted: PredictedPlan, + missing: ResolverName[], + owners: Signer[], + log: boolean, +): Promise { + const l = (...a: unknown[]) => log && console.log(...a); + const safeLc = safe.toLowerCase(); + l(`Safe-native deploy (additive): core live; deploying missing resolver(s) [${missing.join(", ")}] FROM the Safe.`); + + // ── Batch A1: deploy the missing resolver proxies (born Safe-owned). Skip any already on-chain. ──── + const stillMissing: ResolverName[] = []; + for (const r of missing) { + if ((await ethers.provider.getCode(predicted.proxies[r])) === "0x") stillMissing.push(r); + } + let a1TxHash = "0x0 (Batch A1 skipped — additive proxies already deployed)"; + if (stillMissing.length > 0) { + const additive = await buildAdditivePlan(deployer, predicted, stillMissing, log); + l(`Safe-native deploy (additive): executing Batch A1 (${additive.batchA1.length} deploy legs) as the Safe...`); + const a1 = await executeBatchAsSafe(safeContract, additive.batchA1, owners, deployer); + a1TxHash = a1.txHash; + l(` Batch A1 executed (SafeTx ${a1.txHash})`); + } else { + l("Safe-native deploy (additive): Batch A1 already landed (additive proxies deployed) — SKIPPING."); + } + + // Every missing resolver proxy must now have code at its Safe-keyed predicted address. + for (const r of missing) { + if ((await ethers.provider.getCode(predicted.proxies[r])) === "0x") + throw new Error(`SAFE-DEPLOY (additive): no code at Safe-keyed predicted ${predicted.proxies[r]} for ${r}`); + } + + // ── VERIFY GATE against the now-live FULL proxy set (core + additive) before any register. ──────── + l("Safe-native deploy (additive): running verify gate against the on-chain proxy set (pre-register)..."); + const deploys = await buildDeploysFromOnchain(deployer, predicted); + await runVerifyGate({ deploys, schemaUIDs: predicted.schemaUIDs, deployer }); + + // ── Batch A2: register ONLY the missing resolvers' schemas (idempotency-omit already registered). ─ + const additive = await buildAdditivePlan(deployer, predicted, missing, log); + let a2TxHash = "0x0 (Batch A2 skipped — additive schemas already registered)"; + if (additive.batchA2.length > 0) { + l(`Safe-native deploy (additive): executing Batch A2 (${additive.batchA2.length} register legs) as the Safe...`); + const a2 = await executeBatchAsSafe(safeContract, additive.batchA2, owners, deployer); + a2TxHash = a2.txHash; + l(` Batch A2 executed (SafeTx ${a2.txHash})`); + } else { + l("Safe-native deploy (additive): Batch A2 omitted — additive schemas already registered (clean no-op)."); + } + + // ── Assert: each additive schema is registered against its Safe-keyed proxy. ────────────────────── + const reg = await ethers.getContractAt( + "@ethereum-attestation-service/eas-contracts/contracts/ISchemaRegistry.sol:ISchemaRegistry", + "0x0a7E2Ff54e76B8E6659aedc9103FB21c038050D0", + deployer, + ); + for (const s of SCHEMAS) { + if (!missing.includes(s.resolver)) continue; + const rec = await reg.getSchema(predicted.schemaUIDs[s.name]); + if (rec.resolver.toLowerCase() !== predicted.proxies[s.resolver].toLowerCase()) + throw new Error(`SAFE-DEPLOY (additive): ${s.name} getSchema.resolver ${rec.resolver} != Safe-keyed proxy`); + } + + // ── Assert: the new additive proxies are BORN Safe-owned (ProxyAdmin owner == Safe). ────────────── + for (const r of missing) { + const admin = await readProxyAdmin(predicted.proxies[r]); + const pa = await ethers.getContractAt( + "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol:ProxyAdmin", + admin, + deployer, + ); + if ((await pa.owner()).toLowerCase() !== safeLc) + throw new Error(`SAFE-DEPLOY (additive): ${r} ProxyAdmin owner != Safe`); + } + l("Safe-native deploy (additive): missing resolver(s) deployed + registered, born Safe-owned ✓."); + + // Read the existing core scaffolding UIDs for the result shape (the core ceremony was untouched). + const indexer = (await ethers.getContractAt( + "EFSIndexer", + predicted.proxies.EFSIndexer, + deployer, + )) as unknown as Contract; + const rootRealized: string = await indexer.rootAnchorUID(); + const transportsRealized: string = + rootRealized === ethers.ZeroHash ? ethers.ZeroHash : await indexer.resolvePath(rootRealized, "transports"); + const scaffoldingUIDs: Record = { root: rootRealized, transports: transportsRealized }; + + const plan: SafePlan = { + ...predicted, + impls: {} as Record, + batch1: [], + batch2: [], + batch2RegistersOmitted: 0, + batch2BootstrapOmitted: false, + }; + + return { + mode: "execute", + deploys, + proxies: predicted.proxies, + systemAccount: predicted.systemAccount, + schemaUIDs: predicted.schemaUIDs, + transportsAnchorUID: transportsRealized, + safe, + registered: true, + ownershipTransferred: false, + plan, + // A1/A2 reuse the batch1/batch2 slots of the result-tx-hash record (Batch 3 is N/A here). + safeTxHashes: { batch1: a1TxHash, batch2: a2TxHash, batch3: "0x0 (additive — no setTransportsAnchor)" }, + scaffoldingUIDs, + }; +} + /// Build the `deploys` map (resolver name → {proxy, predicted, impl, proxyAdmin, rawSalt}) that /// runVerifyGate consumes, from a plan whose proxies are already on-chain. Identical in shape to the /// record the execute path assembles inline after Batch 1 lands — extracted so the Phase-1 propose @@ -454,6 +606,23 @@ async function proposeViaSafe( const batches: ProposedBatch[] = []; let ceremony: string[]; + // ── ADDITIVE post-freeze branch (ADR-0055 — adding WHITEOUT to a LIVE core) ─────────────────────── + // Fires ONLY when the core is COMPLETE (Phase 3 — sealed + transports wired + every core schema + // registered) and a later-appended resolver (WhiteoutResolver / any future additive primitive) has no + // on-chain code: the only owed work is to deploy that resolver + register its schema. `detectDeployPhase` + // is additive-aware (it skips schemas whose resolver proxy is absent), so on a complete core with only + // WHITEOUT missing it resolves to 3 and we land here. Gating on `phase === 3` (NOT merely `!= 0`) is the + // fix for an OLDER ceremony paused mid-core (Phase 1/2 — e.g. a six-resolver ceremony resumed under this + // seven-resolver code): without it that paused ceremony would jump to additive and strand the owed core + // batches (Codex review). At Phase 1/2 we fall through to the core-phase branch below instead. This is + // ALSO the additive A1→A2 boundary: once A1 lands, the WhiteoutResolver proxy has code so the schema is + // no longer "missing" AND `detectDeployPhase` returns 1, so the A2 register routes through the normal + // Phase-1 register-omit path below (it proposes exactly the one owed WHITEOUT leg) — not back here. + const missingAdditive = phase === 3 ? await detectMissingResolvers(deployer, predicted) : []; + if (missingAdditive.length > 0) { + return proposeAdditive(deployer, safeContract, safe, predicted, missingAdditive, artifactPath, log); + } + if (phase === 0) { // Phase 0 — no proxies. This is the ONLY phase that deploys impls: Batch 1's CreateX initcode embeds // them. The verify gate cannot run yet (nothing deployed); it runs next invocation, at Phase 1, @@ -580,3 +749,140 @@ async function proposeViaSafe( ceremony, }; } + +/// ADDITIVE post-freeze build/propose (ADR-0055 — adding WHITEOUT to a LIVE core). Reached ONLY when the +/// core EFSIndexer is live but one or more later-appended resolvers have no on-chain code. Emits the +/// next pending ADDITIVE batch — never a core ceremony batch, never a full-core redeploy: +/// A1 — missing resolver proxy NOT deployed → propose Batch A1 (CreateX deploy, born Safe-owned). +/// A2 — missing resolver proxy live, schema → VERIFY GATE (full live proxy set), then Batch A2 +/// not yet registered (register ONLY the missing resolvers' schemas). +/// done — proxy live + schema registered → nothing (clean no-op; the additive schema is in). +/// `phase` is reported as the core DeployPhase (the core is complete = 3 in the documented case) so the +/// result/artifact shape is unchanged; the `additive` ceremony lines + batch labels distinguish it. +async function proposeAdditive( + deployer: Signer, + safeContract: Contract, + safe: string, + predicted: PredictedPlan, + missing: ResolverName[], + artifactPath: string | undefined, + log: boolean, +): Promise { + const l = (...a: unknown[]) => log && console.log(...a); + const chainId = (await ethers.provider.getNetwork()).chainId.toString(); + const phase = await detectDeployPhase(deployer, predicted); + + l(""); + l(`EFS Safe-native deploy — ADDITIVE BUILD/PROPOSE. Missing resolver(s): [${missing.join(", ")}].`); + + const batches: ProposedBatch[] = []; + let ceremony: string[]; + + // Sub-phase A1: any missing resolver proxy still absent → propose the deploy batch (deploy ALL the + // currently-missing resolvers in one batch; re-running after it lands advances to A2). + const stillMissing: ResolverName[] = []; + for (const r of missing) { + if ((await ethers.provider.getCode(predicted.proxies[r])) === "0x") stillMissing.push(r); + } + + let additive: AdditivePlan; + if (stillMissing.length > 0) { + additive = await buildAdditivePlan(deployer, predicted, stillMissing, log); + batches.push( + await buildProposedBatch( + safeContract, + additive.batchA1, + `Batch A1 (additive deploy ×${stillMissing.length})`, + 0n, + ), + ); + ceremony = [ + `Additive — core live, ${stillMissing.length} resolver(s) missing: [${stillMissing.join(", ")}].`, + " • Propose + sign + execute Batch A1 (deploy the missing resolver proxy, born Safe-owned).", + " • Then RE-RUN `deploy:efs --via-safe` — it runs the VERIFY GATE against the now-live proxy set", + " and emits Batch A2 (register ONLY the missing resolvers' schemas). No core redeploy.", + ]; + } else { + // Sub-phase A2: every missing resolver proxy is now deployed → verify-gate the full live set, then + // register only the missing resolvers' schemas. The verify gate runs against ALL proxies (the core + // ones are live too), so the existing full gate applies unchanged — read-only, throws on drift. + l("Safe-native deploy (additive): running verify gate against the on-chain proxy set (pre-register)..."); + const deploys = await buildDeploysFromOnchain(deployer, predicted); + await runVerifyGate({ deploys, schemaUIDs: predicted.schemaUIDs, deployer }); + + additive = await buildAdditivePlan(deployer, predicted, missing, log); + if (additive.batchA2.length > 0) { + batches.push( + await buildProposedBatch( + safeContract, + additive.batchA2, + `Batch A2 (additive register ×${additive.batchA2.length})`, + 0n, + ), + ); + ceremony = [ + "Additive — missing resolver(s) deployed. VERIFY GATE PASSED (read-only; aborts on drift).", + " • Propose + sign + execute Batch A2 (register ONLY the missing resolvers' schemas).", + " • Then RE-RUN `deploy:efs --via-safe` — it will report the additive resolver complete.", + ]; + } else { + ceremony = ["Additive — missing resolver(s) deployed AND their schemas already registered. Nothing to propose."]; + } + } + + const plan: SafePlan = { + ...predicted, + impls: {} as Record, + batch1: [], + batch2: [], + batch2RegistersOmitted: 0, + batch2BootstrapOmitted: false, + }; + + const artifact = { + note: + "EFS Safe-native deploy — ADDITIVE build/propose artifact (ADR-0055). The core is already live; " + + "this adds ONLY the missing resolver(s) + their schema(s). Import into Safe{Wallet}; sign with the " + + "real owner keys; execute it; then RE-RUN `deploy:efs --via-safe` for the next additive batch.", + additive: true, + missing, + phase, + chainId, + safe, + proxies: plan.proxies, + systemAccount: plan.systemAccount, + schemaUIDs: plan.schemaUIDs, + ceremony, + batches, + }; + + if (artifactPath) { + mkdirSync(dirname(artifactPath), { recursive: true }); + writeFileSync(artifactPath, JSON.stringify(artifact, null, 2)); + l(""); + l(` SAFE ADDITIVE BATCH ARTIFACT (import into Safe{Wallet}): ${artifactPath}`); + } + + l(""); + for (const line of ceremony) l(line); + l(""); + for (const b of batches) { + l(` ${b.label}: nonce=${b.nonce} safeTxHash=${b.safeTxHash} to=${b.to} (${b.legs.length} legs)`); + } + if (batches.length === 0) l(" (no batch — additive resolver complete)"); + l(""); + l(`Safe-native deploy (additive propose): batch BUILT, no execTransaction sent (non-owner EOA).`); + + return { + mode: "propose", + safe, + chainId, + phase, + proxies: plan.proxies, + systemAccount: plan.systemAccount, + schemaUIDs: plan.schemaUIDs, + plan, + batches, + ceremony, + }; +} diff --git a/packages/hardhat/deploy-lib/safePlan.ts b/packages/hardhat/deploy-lib/safePlan.ts index 3d5b1279..66e0fbb0 100644 --- a/packages/hardhat/deploy-lib/safePlan.ts +++ b/packages/hardhat/deploy-lib/safePlan.ts @@ -157,6 +157,9 @@ function initSpecs( ListResolver: { fn: "initialize", args: [] }, ListEntryResolver: { fn: "initialize", args: [schemaUIDs.LIST] }, AliasResolver: { fn: "initialize", args: [schemaUIDs.DATA, schemaUIDs.ANCHOR] }, + // WhiteoutResolver (ADR-0055): read-only kernel ref. It self-derives its WHITEOUT schema UID + + // snapshots ANCHOR_SCHEMA_UID from the indexer in initialize(); no kernel writes, no Ownable. + WhiteoutResolver: { fn: "initialize", args: [proxies.EFSIndexer] }, // SystemAccount: born Safe-owned so the Safe (its owner) can author the scaffolding in Batch 2. SystemAccount: { fn: "initialize", args: [safe] }, }; @@ -467,6 +470,123 @@ export async function buildSafePlan(deployer: Signer, safe: string, log = true): }; } +// ── Additive resolver deploy (ADR-0055 — adding WHITEOUT to a LIVE core) ────────────────────────────── +// +// The documented additive post-freeze case: the original frozen core (the 9 schemas on the 6 core +// resolvers) is ALREADY live on Sepolia, and only a later-appended resolver (WhiteoutResolver, or any +// future additive primitive) + its schema are missing. There is no full-core redeploy: we deploy ONLY +// the missing resolver proxies (born Safe-owned, same CreateX path Batch 1 uses) and register ONLY their +// schemas. None of the core ceremony (wireContracts / bootstrap / seal / setTransportsAnchor) re-runs — +// an additive resolver is self-contained (WhiteoutResolver's initialize(indexer) only READS the live +// kernel; its WHITEOUT schema has no scaffolding dependency). This is exactly how the local/devnet path +// (deploy/08_whiteout.ts) already handles it; this is its Safe-native counterpart. + +/// The additive deploy plan: deploy + register ONLY the named missing resolvers. Two batches mirroring +/// the fresh split — Batch A1 = CreateX proxy deploys (atomic init, born Safe-owned); Batch A2 = register +/// the missing resolvers' schemas. No core ceremony. `impls` holds ONLY the missing resolvers' impls +/// (deployed from the EOA, content-addressed, in no UID). `registersOmitted` counts schema registers +/// dropped because the schema was already registered (idempotent re-run after Batch A2 landed). +export interface AdditivePlan { + resolvers: ResolverName[]; + impls: Record; + batchA1: SafeCall[]; + batchA2: SafeCall[]; + registersOmitted: number; +} + +/// Ensure the impls for ONLY the named additive resolvers exist on-chain (EOA, CreateX CREATE2, +/// content-addressed, in no UID) — the resolver-scoped sibling of `ensureImpls`. Idempotent + crash-safe +/// (reuses an impl already on-chain; converges on a concurrent-deploy "address taken" revert). +async function ensureImplsFor( + deployer: Signer, + resolvers: ResolverName[], + log = true, +): Promise> { + const l = (...a: unknown[]) => log && console.log(...a); + const createx = await getCreateX(deployer); + const deployerAddr = await deployer.getAddress(); + const impls: Record = {}; + for (const name of resolvers) { + const { rawSalt, initCode, predicted } = await predictImplAddress(createx, deployerAddr, name); + if ((await ethers.provider.getCode(predicted)) !== "0x") { + l(` ${name} impl: ${predicted} (reused — already on-chain)`); + impls[name] = predicted; + continue; + } + try { + const tx = await createx["deployCreate2(bytes32,bytes)"](rawSalt, initCode); + await tx.wait(); + l(` ${name} impl: ${predicted} (deployed)`); + } catch (e) { + if ((await ethers.provider.getCode(predicted)) === "0x") throw e; + l(` ${name} impl: ${predicted} (converged — deployed by a concurrent run)`); + } + if ((await ethers.provider.getCode(predicted)) === "0x") { + throw new Error(`CREATE2 ${name}: no code at predicted impl ${predicted} after deployCreate2`); + } + impls[name] = predicted; + } + return impls; +} + +/// Build the additive deploy plan for the named missing resolvers against a LIVE core. Predicts (reuses) +/// the impl-free `predicted` plan, deploys ONLY the missing resolvers' impls, assembles Batch A1 +/// (CreateX proxy deploys, atomic init, born Safe-owned) + Batch A2 (register only the missing +/// resolvers' schemas, idempotency-omitting any already registered). Throws if `resolvers` is empty +/// (caller must gate on `detectMissingResolvers`). +export async function buildAdditivePlan( + deployer: Signer, + predicted: PredictedPlan, + resolvers: ResolverName[], + log = true, +): Promise { + const l = (...a: unknown[]) => log && console.log(...a); + if (resolvers.length === 0) throw new Error("buildAdditivePlan: no additive resolvers (caller must gate)"); + const { safe, proxies, schemaUIDs } = predicted; + const createx = await getCreateX(deployer); + const registryAddr = SCHEMA_REGISTRY_ADDRESS; + + l(`Safe-native deploy (additive): deploying ${resolvers.length} missing resolver(s): [${resolvers.join(", ")}]`); + const impls = await ensureImplsFor(deployer, resolvers, log); + const specs = initSpecs(safe, proxies, schemaUIDs, registryAddr); + + // ── Batch A1: CreateX proxy deploys (atomic init, born Safe-owned) for the missing resolvers only ── + const batchA1: SafeCall[] = []; + for (const name of resolvers) { + const implIface = (await ethers.getContractFactory(name, deployer)).interface; + const initCalldata = implIface.encodeFunctionData(specs[name].fn, specs[name].args); + // initialOwner of the proxy (→ its ProxyAdmin owner) = the SAFE: born-owned, exactly like Batch 1. + const proxyInitCode = await buildProxyInitCode(impls[name], safe, initCalldata); + const data = CREATEX_IFACE.encodeFunctionData("deployCreate3", [predicted.rawSalts[name], proxyInitCode]); + batchA1.push({ to: await createx.getAddress(), data, label: `deployCreate3 ${name} (additive)` }); + } + + // ── Batch A2: register ONLY the missing resolvers' schemas (idempotency-omit already-registered) ── + const batchA2: SafeCall[] = []; + const registryIface = new Interface([ + "function register(string schema, address resolver, bool revocable) returns (bytes32)", + ]); + const registry = await ethers.getContractAt( + "@ethereum-attestation-service/eas-contracts/contracts/ISchemaRegistry.sol:ISchemaRegistry", + registryAddr, + deployer, + ); + const additiveSchemas = SCHEMAS.filter(s => resolvers.includes(s.resolver)); + let registersOmitted = 0; + for (const s of additiveSchemas) { + const existing = await registry.getSchema(schemaUIDs[s.name]); + if (existing.uid !== ZeroHash) { + registersOmitted++; + l(` register ${s.name} OMITTED — already registered (${schemaUIDs[s.name]})`); + continue; + } + const data = registryIface.encodeFunctionData("register", [s.fieldString, proxies[s.resolver], s.revocable]); + batchA2.push({ to: registryAddr, data, label: `register ${s.name} (additive)` }); + } + + return { resolvers, impls, batchA1, batchA2, registersOmitted }; +} + /// The deploy phase of a (Safe-keyed) EFS system, derived from the SAME on-chain signals the execute /// path reads (proxy code present; schema registered via SchemaRegistry.getSchema; bootstrapSealed(); /// MirrorResolver.transportsAnchorUID()). PR #24 P1: the propose path computes this to emit ONLY the @@ -477,6 +597,23 @@ export async function buildSafePlan(deployer: Signer, safe: string, log = true): /// 3 — transports wired (system complete) → next: nothing (clean no-op) export type DeployPhase = 0 | 1 | 2 | 3; +/// Detect the resolvers in the canonical set whose Safe-keyed proxy has NO on-chain code while the core +/// EFSIndexer IS live (ADR-0055 additive post-freeze case). On a FRESH deploy all proxies land atomically +/// in Batch 1, so this is empty for an in-progress fresh deploy. It is non-empty ONLY in the documented +/// additive case: the original frozen core is already live on-chain and a later-appended resolver +/// (WhiteoutResolver / any future additive primitive) is the only thing missing. Caller MUST have already +/// confirmed the EFSIndexer proxy has code (i.e. NOT Phase 0) — a fresh chain has no core to be additive +/// against. Read-only. +export async function detectMissingResolvers(deployer: Signer, plan: PredictedPlan): Promise { + void deployer; + const missing: ResolverName[] = []; + for (const r of RESOLVERS) { + if (r === "EFSIndexer") continue; // the kernel; its presence is the precondition, not a candidate. + if ((await ethers.provider.getCode(plan.proxies[r])) === "0x") missing.push(r); + } + return missing; +} + /// Detect the current deploy phase of `plan`'s Safe-keyed system from on-chain state. Reuses the exact /// detection signals the execute path keys off (orchestrateSafe.ts): EFSIndexer proxy code presence /// (all 7 proxies deploy atomically in Batch 1 — indexer code ⇒ Batch 1 landed), every schema's @@ -485,19 +622,31 @@ export type DeployPhase = 0 | 1 | 2 | 3; /// yet registered" — Batch 2's per-leg omits (batch2RegistersOmitted / batch2BootstrapOmitted, resolved /// in buildSafePlan) handle a partially-landed Batch 2 so re-emitting Batch 2 only proposes the /// remaining register/bootstrap/seal legs, never a duplicate of an already-landed leg. +/// +/// ADDITIVE-AWARE (ADR-0055): a schema only forces Phase 1 if ITS RESOLVER PROXY IS DEPLOYED but the +/// schema isn't registered (a genuinely incomplete fresh-deploy Batch 2). A schema whose resolver proxy +/// has NO code is the additive post-freeze case (the core is already live; a later-appended resolver + +/// its schema were never deployed) — that is handled by the dedicated additive step (deploy the missing +/// resolver + register only its schema), NOT by treating the complete core as a fresh Phase-1 deploy. So +/// on a live core where only WhiteoutResolver is missing, the core phase correctly resolves to 3 +/// (sealed + transports wired) and `detectMissingResolvers` drives the additive deploy. export async function detectDeployPhase(deployer: Signer, plan: PredictedPlan): Promise { // Phase 0 — proxies not deployed. Key off EFSIndexer (all 7 proxies land atomically in Batch 1). const indexerCode = await ethers.provider.getCode(plan.proxies.EFSIndexer); if (indexerCode === "0x") return 0; - // Phase 1 — proxies live but not all 9 schemas registered yet. EAS register is not idempotent, so a - // single missing registration means Batch 2's register legs (minus the per-leg omits) are still owed. + // Phase 1 — proxies live but not all of their schemas registered yet. EAS register is not idempotent, + // so a single missing registration (for a schema whose resolver proxy IS deployed) means Batch 2's + // register legs (minus the per-leg omits) are still owed. A schema whose resolver proxy is ABSENT is + // the additive case — skipped here, handled by the additive step — so it never masquerades as a + // fresh-deploy Phase 1 on an already-complete core. const registry = await ethers.getContractAt( "@ethereum-attestation-service/eas-contracts/contracts/ISchemaRegistry.sol:ISchemaRegistry", SCHEMA_REGISTRY_ADDRESS, deployer, ); for (const s of SCHEMAS) { + if ((await ethers.provider.getCode(plan.proxies[s.resolver])) === "0x") continue; // additive — not a Phase-1 owe const existing = await registry.getSchema(plan.schemaUIDs[s.name]); if (existing.uid === ZeroHash) return 1; } diff --git a/packages/hardhat/deploy-lib/schemas.ts b/packages/hardhat/deploy-lib/schemas.ts index c68ed72c..534e3d18 100644 --- a/packages/hardhat/deploy-lib/schemas.ts +++ b/packages/hardhat/deploy-lib/schemas.ts @@ -1,6 +1,11 @@ // EFS frozen-schema source of truth (Phase D deploy core). // -// The nine schemas frozen at Sepolia registration (ADR-0048; docs/SEPOLIA_FREEZE_TABLE.md). +// The original NINE schemas frozen at Sepolia registration (ADR-0048; docs/SEPOLIA_FREEZE_TABLE.md): +// ANCHOR, PROPERTY, DATA, PIN, TAG, MIRROR, LIST, LIST_ENTRY, REDIRECT — those nine and their UIDs +// are FROZEN. WHITEOUT (ADR-0055) is a 10th schema added ADDITIVELY post-freeze on the WhiteoutResolver +// proxy (a new schema + resolver orphans nothing — exactly how SORT_INFO / any future primitive is +// added; ADR-0055 Consequences). It is not part of the original frozen nine. +// // Each schema's UID is `keccak256(abi.encodePacked(fieldString, resolverProxyAddr, revocable))`. // The field strings here are the GOLDEN VECTORS — they must be byte-identical to the // corresponding constants in the resolver contracts. The verify gate (deploy-lib/verify.ts) @@ -11,14 +16,16 @@ import { solidityPackedKeccak256 } from "ethers"; -/// The six resolver contracts behind CREATE3 proxies. The key is the artifact/contract name. +/// The seven resolver contracts behind CREATE3 proxies. The key is the artifact/contract name. +/// WhiteoutResolver (ADR-0055) is the 7th — additive post-freeze, appended last. export type ResolverName = | "EFSIndexer" | "EdgeResolver" | "MirrorResolver" | "ListResolver" | "ListEntryResolver" - | "AliasResolver"; + | "AliasResolver" + | "WhiteoutResolver"; export interface SchemaDef { /// Canonical EFS schema name (matches the freeze table). @@ -31,8 +38,9 @@ export interface SchemaDef { resolver: ResolverName; } -// The 9 frozen schemas, in deploy/register order. Field strings + revocable flags are FROZEN -// (changing any orphans the schema's data on a chain where it is registered). +// The 9 frozen schemas + the additive post-freeze WHITEOUT schema (10 total), in deploy/register +// order. Field strings + revocable flags are FROZEN (changing any orphans the schema's data on a chain +// where it is registered). export const SCHEMAS: SchemaDef[] = [ // `forSchema` (not `schemaUID`): the EAS attestation already carries a top-level `.schema` (always // ANCHOR_SCHEMA_UID here), so a field literally named `schemaUID` shadows it and is a permanent @@ -68,6 +76,10 @@ export const SCHEMAS: SchemaDef[] = [ resolver: "ListEntryResolver", }, { name: "REDIRECT", fieldString: "bytes32 target, uint16 kind", revocable: true, resolver: "AliasResolver" }, + // WHITEOUT (ADR-0055) — the 10th schema, ADDITIVE post-freeze (NOT one of the original frozen nine). + // Empty field string (pure-identity negative marker, DATA/ADR-0049 idiom); revocable (revoke == + // un-hide). Cross-lens negative mask / lens-local delete. Resolver: WhiteoutResolver (7th proxy). + { name: "WHITEOUT", fieldString: "", revocable: true, resolver: "WhiteoutResolver" }, ]; /// The distinct resolver contracts, in deploy order (impls first, then proxies). @@ -78,6 +90,7 @@ export const RESOLVERS: ResolverName[] = [ "ListResolver", "ListEntryResolver", "AliasResolver", + "WhiteoutResolver", ]; /// Compute an EAS schema UID for a field string against a resolver proxy address. diff --git a/packages/hardhat/deploy-lib/verify.ts b/packages/hardhat/deploy-lib/verify.ts index 12781050..2dd949ec 100644 --- a/packages/hardhat/deploy-lib/verify.ts +++ b/packages/hardhat/deploy-lib/verify.ts @@ -72,7 +72,7 @@ export async function runVerifyGate(input: VerifyInput): Promise { // these resolvers derives the schema UID it enforces from its OWN baked-in field-string constant // + its (proxy) address; reading the getter back catches a stale/edited resolver artifact whose // constant disagrees with schemaUIDs before the irreversible register (PR #24 P2). - console.log(" [verify] self-UID getters (List/ListEntry/Alias) == computed UID..."); + console.log(" [verify] self-UID getters (List/ListEntry/Alias/Whiteout) == computed UID..."); const list = await ethers.getContractAt("ListResolver", deploys.ListResolver.proxy, deployer); const onchainListUID: string = await list.listSchemaUID(); assertEq(onchainListUID, schemaUIDs.LIST, "ListResolver.listSchemaUID"); @@ -85,6 +85,13 @@ export async function runVerifyGate(input: VerifyInput): Promise { const onchainRedirectUID: string = await aliasR.redirectSchemaUID(); assertEq(onchainRedirectUID, schemaUIDs.REDIRECT, "AliasResolver.redirectSchemaUID"); + // WHITEOUT (ADR-0055): WhiteoutResolver self-derives its schema UID from its own empty field-string + // constant + the proxy address in initialize() — assert it equals the to-be-registered WHITEOUT UID + // before the irreversible register (same ListEntry-class bug guard as the three above). + const whiteoutR = await ethers.getContractAt("WhiteoutResolver", deploys.WhiteoutResolver.proxy, deployer); + const onchainWhiteoutUID: string = await whiteoutR.whiteoutSchemaUID(); + assertEq(onchainWhiteoutUID, schemaUIDs.WHITEOUT, "WhiteoutResolver.whiteoutSchemaUID"); + // (3b) init-supplied cross-reference UIDs (PR #24 50yr-review, M-1). (3) above only checks the two // SELF-DERIVED UIDs (ListEntry/Alias). The UIDs threaded INTO initialize() as typing/config // cross-references were unchecked — a mis-threaded init arg would register a correct schema UID @@ -102,6 +109,9 @@ export async function runVerifyGate(input: VerifyInput): Promise { assertEq(await edge.TAG_SCHEMA_UID(), schemaUIDs.TAG, "EdgeResolver.TAG_SCHEMA_UID"); assertEq(await aliasR.dataSchemaUID(), schemaUIDs.DATA, "AliasResolver.dataSchemaUID"); assertEq(await aliasR.anchorSchemaUID(), schemaUIDs.ANCHOR, "AliasResolver.anchorSchemaUID"); + // WhiteoutResolver (ADR-0055): typing + read-only kernel ref threaded through initialize(indexer). + assertEq(await whiteoutR.anchorSchemaUID(), schemaUIDs.ANCHOR, "WhiteoutResolver.anchorSchemaUID"); + assertEq(await whiteoutR.indexer(), deploys.EFSIndexer.proxy, "WhiteoutResolver.indexer()"); // (3c) one-shot partner WIRING addresses (PR #24 P2) — checked IFF the indexer is already wired. // (3b) checks the UID cross-refs; this checks the ADDRESSES set by EFSIndexer.wireContracts() — diff --git a/packages/hardhat/deploy-lib/views.ts b/packages/hardhat/deploy-lib/views.ts index e41b869c..7f91bf06 100644 --- a/packages/hardhat/deploy-lib/views.ts +++ b/packages/hardhat/deploy-lib/views.ts @@ -3,7 +3,7 @@ // The three stateless read views — EFSFileView, EFSRouter, ListReader — are in NO schema UID and are // freely redeployable (docs/DEPLOYMENT.md §0, specs/overview.md "Core contracts"). On a CreateX // network (Sepolia/mainnet/pinned fork) `yarn deploy:efs` (the EFSCore tag → deploy/00_efs_core.ts) -// stands up the frozen foundation (6 CREATE3 proxies + 9 registered schemas) but deploys NONE of the +// stands up the frozen foundation (7 CREATE3 proxies + 10 registered schemas) but deploys NONE of the // views. This module is the single source for the views on those networks: the operator runs it AFTER // the freeze ceremony via `yarn deploy:efs-views`. // @@ -44,6 +44,21 @@ export async function deployViews(hre: HardhatRuntimeEnvironment): Promise("EFSFileView", deployer); @@ -92,6 +109,7 @@ export async function deployViews(hre: HardhatRuntimeEnvironment): Promise("Indexer", deployer); + console.log("Using EFSIndexer at:", indexer.target); + + // 3. WHITEOUT schema params. The resolver proxy address (baked into the schema UID) is read from + // the ACTUAL deployment AFTER deploy below — never predicted: a nonce+1 prediction is wrong on a + // retry where hardhat-deploy reuses an unchanged impl without sending a tx (proxy not at nonce+1). + const whiteoutDefinition = ""; // empty field string (ADR-0055) — pure-identity negative marker. + const whiteoutRevocable = true; + + // 4. Deploy the implementation (constructor: IEAS) then the proxy, initialized with the indexer. + await deploy("WhiteoutResolverImpl", { + contract: "WhiteoutResolver", + from: deployer, + args: [EAS_ADDRESS], + log: true, + autoMine: true, + }); + const impl = await ethers.getContract("WhiteoutResolverImpl", deployer); + + const initData = impl.interface.encodeFunctionData("initialize", [indexer.target]); + await deploy("WhiteoutResolver", { + contract: "TestERC1967Proxy", + from: deployer, + args: [impl.target, initData], + log: true, + autoMine: true, + }); + const proxy = await ethers.getContract("WhiteoutResolver", deployer); + const deployerSigner = await ethers.getSigner(deployer); + const whiteoutResolver = await ethers.getContractAt("WhiteoutResolver", proxy.target as string, deployerSigner); + console.log("WhiteoutResolver (proxy) deployed at:", whiteoutResolver.target); + + // Compute the WHITEOUT schema UID from the ACTUAL proxy address (correct whether the proxy was + // newly deployed or reused on a retry — the self-UID gate below is the cross-check). + const whiteoutSchemaUID = ethers.solidityPackedKeccak256( + ["string", "address", "bool"], + [whiteoutDefinition, whiteoutResolver.target, whiteoutRevocable], + ); + console.log("WHITEOUT_SCHEMA_UID:", whiteoutSchemaUID); + + // 5. Register the WHITEOUT schema against the proxy. Tolerate ONLY a genuine "already registered" + // state — verified by reading the schema back from the SchemaRegistry, NOT by assuming every revert + // means AlreadyExists. Any other failure (transient RPC, wrong / code-less registry address, + // insufficient funds, or a nonce-consuming revert) is re-thrown so a broken deploy never silently + // wires the router / file view to a WhiteoutResolver whose schema can't be attested (Codex review; + // mirrors deploy/09_lists.ts). Read-back returns true (registered), false (readable but absent), or + // null (registry UNREADABLE — no code at the address, e.g. CI's no-EAS hardhat node → getSchema + // returns "0x" and ethers throws BAD_DATA → degrade gracefully per ADR-0028). + const registerWhiteout = async () => { + const isRegistered = async (): Promise => { + try { + const rec = await schemaRegistry.getSchema(whiteoutSchemaUID); + return !!(rec?.uid && rec.uid.toLowerCase() === whiteoutSchemaUID.toLowerCase()); + } catch { + return null; + } + }; + try { + const tx = await schemaRegistry.register(whiteoutDefinition, whiteoutResolver.target, whiteoutRevocable); + await tx.wait(); + } catch (err) { + const reg = await isRegistered(); + if (reg === true) { + console.log("WHITEOUT schema already registered — skipping."); + return; + } + if (reg === null) { + console.warn("WHITEOUT: SchemaRegistry unreadable (no EAS?) — skipping registration (ADR-0028)."); + return; + } + console.error("WHITEOUT schema registration failed and the schema is NOT registered."); + throw err; // reg === false — a real failure, not idempotent re-run + } + // Verify after success too — a tx to a wrong / code-less SchemaRegistry can "succeed" without + // registering anything, leaving a deploy that looks fine but can't attest WHITEOUTs. + const reg = await isRegistered(); + if (reg === false) { + throw new Error( + `WHITEOUT schema is not registered at ${whiteoutSchemaUID} after register() — wrong SchemaRegistry address?`, + ); + } + if (reg === null) { + console.warn("WHITEOUT: SchemaRegistry unreadable — skipping post-register verify (ADR-0028)."); + } else { + console.log("Registered WHITEOUT schema:", whiteoutSchemaUID); + } + }; + await registerWhiteout(); + + // 6. Verify gate (local mirror of deploy-lib/verify.ts): the resolver self-derived the UID. + const onchainUID = await whiteoutResolver.whiteoutSchemaUID(); + if (onchainUID.toLowerCase() !== whiteoutSchemaUID.toLowerCase()) { + throw new Error( + `WHITEOUT verify gate: WhiteoutResolver.whiteoutSchemaUID() ${onchainUID} != computed ${whiteoutSchemaUID}`, + ); + } + console.log("WhiteoutResolver deployment complete (self-UID verified)."); +}; + +export default deployWhiteout; +// Tag "WhiteoutResolver" (alongside "Whiteout") so 02_fileview / 03_router can declare it as a +// dependency — hardhat-deploy only recurses a script's DECLARED dependencies, and it otherwise runs +// scripts lexicographically (08 AFTER 02/03), which would make the views read getOrNull( +// "WhiteoutResolver") as null and deploy with ZeroAddress (whiteout silently disabled). The +// dependency forces this script to run BEFORE the views on a plain local `hardhat deploy`. +deployWhiteout.tags = ["Whiteout", "WhiteoutResolver"]; +// Deploy before the view redeploys (02_fileview / 03_router) so they can wire the whiteout proxy. +deployWhiteout.dependencies = ["Indexer"]; diff --git a/packages/hardhat/scripts/simulate-freeze-set.ts b/packages/hardhat/scripts/simulate-freeze-set.ts index 3cdb2d44..0cd9bbd0 100644 --- a/packages/hardhat/scripts/simulate-freeze-set.ts +++ b/packages/hardhat/scripts/simulate-freeze-set.ts @@ -51,8 +51,11 @@ async function main() { const aliasResolver = (await ethers.getContract("AliasResolver", deployer)) as unknown as AliasResolver; const listReader = (await ethers.getContract("ListReader", deployer)) as unknown as ListReader; - // The live resolver-proxy address baked into each schema's UID, keyed by the schemas.ts ResolverName. - const proxies: Record = { + // The live resolver-proxy address baked into each FROZEN-NINE schema's UID, keyed by the schemas.ts + // ResolverName. WhiteoutResolver (the additive post-freeze WHITEOUT schema, ADR-0055) is NOT part of + // this frozen-nine conformance check, so it is intentionally absent — the loop below filters to + // `frozenNine`, which never indexes it. Hence a Partial map. + const proxies: Partial> = { EFSIndexer: await indexer.getAddress(), EdgeResolver: await (await ethers.getContract("EdgeResolver", deployer)).getAddress(), MirrorResolver: await (await ethers.getContract("MirrorResolver", deployer)).getAddress(), @@ -87,13 +90,17 @@ async function main() { registryAddr, )) as any; - assert("freeze set has exactly 9 schemas (schemas.ts)", SCHEMAS.length === 9, `got ${SCHEMAS.length}`); + // The FROZEN nine (ADR-0048) are the schemas NOT on the WhiteoutResolver — WHITEOUT (ADR-0055) is an + // additive post-freeze schema on a separate proxy, so it is excluded from the frozen-nine conformance + // check (it has its own self-UID getter asserted in the deploy verify gate, not here). + const frozenNine = SCHEMAS.filter(s => s.resolver !== "WhiteoutResolver"); + assert("freeze set has exactly 9 schemas (schemas.ts)", frozenNine.length === 9, `got ${frozenNine.length}`); const seen = new Map(); // uid -> schema name (distinctness check) - for (const s of SCHEMAS) { + for (const s of frozenNine) { console.log(`\n── ${s.name} ──`); - const computed = computeSchemaUID(s.fieldString, proxies[s.resolver], s.revocable); + const computed = computeSchemaUID(s.fieldString, proxies[s.resolver]!, s.revocable); const onChain = await liveUID[s.name](); // 1. on-chain self-derived UID == off-chain golden recomputation. @@ -112,7 +119,7 @@ async function main() { assert(`${s.name}: registered in EAS registry`, rec.uid.toLowerCase() === onChain.toLowerCase(), rec.uid); assert( `${s.name}: registry resolver == ${s.resolver} proxy`, - rec.resolver.toLowerCase() === proxies[s.resolver].toLowerCase(), + rec.resolver.toLowerCase() === proxies[s.resolver]!.toLowerCase(), rec.resolver, ); assert(`${s.name}: registry revocable == ${s.revocable}`, rec.revocable === s.revocable, String(rec.revocable)); diff --git a/packages/hardhat/test/BurnToImmutable.fork.test.ts b/packages/hardhat/test/BurnToImmutable.fork.test.ts index a7862576..54c6c0ec 100644 --- a/packages/hardhat/test/BurnToImmutable.fork.test.ts +++ b/packages/hardhat/test/BurnToImmutable.fork.test.ts @@ -3,6 +3,7 @@ import { ethers } from "hardhat"; import { Signer } from "ethers"; import { CREATEX_ADDRESS, EAS_ADDRESS } from "../deploy-lib/addresses"; import { orchestrate, OrchestrationResult } from "../deploy-lib/orchestrate"; +import { RESOLVERS } from "../deploy-lib/schemas"; import { upgradeProxy } from "./helpers/deployUpgradeableProxy"; // Burn-to-immutable ceremony (docs/DEPLOYMENT.md §4, docs/SEPOLIA_FREEZE_TABLE.md; ADR-0048/0030/0053). @@ -13,10 +14,10 @@ import { upgradeProxy } from "./helpers/deployUpgradeableProxy"; // halves of the promise end to end against a real deploy, so when this PR merges we know the // contracts are (a) genuinely upgradeable beforehand and (b) can DEFINITELY be made immutable: // -// 1. PRE-BURN — every one of the 7 proxies (6 resolvers + SystemAccount) is upgradeable by the -// owner: all 7 ProxyAdmins are owner-controlled, and a real EFSIndexer V1→V2→V1 +// 1. PRE-BURN — every one of the 8 proxies (7 resolvers + SystemAccount) is upgradeable by the +// owner: all 8 ProxyAdmins are owner-controlled, and a real EFSIndexer V1→V2→V1 // ProxyAdmin.upgradeAndCall round-trip succeeds. -// 2. BURN — the exact documented sequence: SystemAccount.sealModules(), then renounce all 7 +// 2. BURN — the exact documented sequence: SystemAccount.sealModules(), then renounce all 8 // ProxyAdmins, then renounce the three Ownable contracts (EFSIndexer, MirrorResolver, // SystemAccount). Every owner ends at address(0). // 3. POST-BURN — immutability is real: every ProxyAdmin.upgradeAndCall and every owner-gated setter @@ -71,16 +72,20 @@ describe("BurnToImmutable.fork — upgradeable pre-burn, permanently immutable p process.env.EFS_SAFE_ADDRESS = ownerAddr; result = await orchestrate(deployer, "full", false); - expect(result.registered, "9 schemas registered").to.equal(true); + expect(result.registered, "all 10 schemas registered").to.equal(true); expect(result.ownershipTransferred, "ownership handed to the stand-in Safe").to.equal(true); expect(result.safe.toLowerCase(), "owner is the stand-in Safe signer").to.equal(ownerAddr.toLowerCase()); }); - it("is UPGRADEABLE pre-burn: all 7 ProxyAdmins owner-controlled + a real EFSIndexer V1→V2→V1 upgrade", async function () { - // (a) Every proxy is upgrade-controllable by the owner: all 7 ProxyAdmins are owned by the owner, + it("is UPGRADEABLE pre-burn: all 8 ProxyAdmins owner-controlled + a real EFSIndexer V1→V2→V1 upgrade", async function () { + // (a) Every proxy is upgrade-controllable by the owner: all 8 ProxyAdmins are owned by the owner, // and the deployer (who created them) holds nothing. const deployerAddr = await deployer.getAddress(); - expect(Object.keys(result.deploys), "7 CREATE3 deploys (6 resolvers + SystemAccount)").to.have.lengthOf(7); + // Bound to RESOLVERS.length + 1 (resolvers + SystemAccount) so the additive WhiteoutResolver + // (ADR-0055) — the 7th resolver, making 8 CREATE3 deploys — doesn't rot this count. + expect(Object.keys(result.deploys), "CREATE3 deploys = RESOLVERS.length + SystemAccount").to.have.lengthOf( + RESOLVERS.length + 1, + ); for (const [name, d] of Object.entries(result.deploys)) { const pa = await proxyAdminAs(d.proxyAdmin, owner); expect((await pa.owner()).toLowerCase(), `${name} ProxyAdmin owned by Safe`).to.equal(ownerAddr.toLowerCase()); @@ -114,7 +119,7 @@ describe("BurnToImmutable.fork — upgradeable pre-burn, permanently immutable p ); }); - it("BURNS to immutable: sealModules + renounce 7 ProxyAdmins + 3 contract owners → every owner == 0", async function () { + it("BURNS to immutable: sealModules + renounce 8 ProxyAdmins + 3 contract owners → every owner == 0", async function () { const sa = await ethers.getContractAt("SystemAccount", result.systemAccount, owner); // Pre-conditions documented in SEPOLIA_FREEZE_TABLE.md: bootstrap already sealed by the deploy, @@ -126,7 +131,7 @@ describe("BurnToImmutable.fork — upgradeable pre-burn, permanently immutable p await (await sa.sealModules()).wait(); expect(await sa.modulesSealed(), "modules sealed").to.equal(true); - // Step 2: renounce all 7 ProxyAdmins — freezes the upgrade capability of every proxy. + // Step 2: renounce all 8 ProxyAdmins — freezes the upgrade capability of every proxy. for (const [name, d] of Object.entries(result.deploys)) { const pa = await proxyAdminAs(d.proxyAdmin, owner); await (await pa.renounceOwnership()).wait(); diff --git a/packages/hardhat/test/Deploy.fork.test.ts b/packages/hardhat/test/Deploy.fork.test.ts index 41f7b3e0..dc04e164 100644 --- a/packages/hardhat/test/Deploy.fork.test.ts +++ b/packages/hardhat/test/Deploy.fork.test.ts @@ -32,14 +32,16 @@ describe("Deploy.fork — orchestrated CREATE3 deploy + register-last", function cleanFork = await takeSnapshot(); }); - it("stands up the whole ceremony: 6 proxies @predicted, verify green, 9 schemas registered, owner==Safe, 9 smokes", async function () { + it("stands up the whole ceremony: 7 proxies @predicted, verify green, 10 schemas registered, owner==Safe, 10 smokes", async function () { const [deployer, safeSigner] = await ethers.getSigners(); process.env.EFS_SAFE_ADDRESS = await safeSigner.getAddress(); const result = await orchestrate(deployer, "full", false); - // 6 proxies at their predicted CREATE3 addresses, with code. - expect(Object.keys(result.proxies)).to.have.lengthOf(6); + // One proxy per RESOLVERS entry at its predicted CREATE3 address, with code. Bound to + // RESOLVERS.length (not a hardcoded count) so appending an additive resolver — e.g. WhiteoutResolver + // (ADR-0055) — doesn't silently rot this assertion. + expect(Object.keys(result.proxies)).to.have.lengthOf(RESOLVERS.length); for (const r of RESOLVERS) { const d = result.deploys[r]; expect(d.proxy.toLowerCase(), `${r} realized==predicted`).to.equal(d.predicted.toLowerCase()); diff --git a/packages/hardhat/test/DeploySafe.fork.test.ts b/packages/hardhat/test/DeploySafe.fork.test.ts index 7f8a8e54..8dd04ccc 100644 --- a/packages/hardhat/test/DeploySafe.fork.test.ts +++ b/packages/hardhat/test/DeploySafe.fork.test.ts @@ -97,8 +97,10 @@ describe("DeploySafe.fork — Safe-native deploy, born Safe-owned", function () expect(batch2Idx, "Batch 2 executed").to.be.greaterThan(-1); expect(gateGreenIdx, "verify gate ran BEFORE Batch 2 (before any register leg)").to.be.lessThan(batch2Idx); - // ── 7 proxies at the Safe-keyed predicted CREATE3 addresses, with code ────────────────────────── - expect(Object.keys(result.proxies)).to.have.lengthOf(6); + // ── One proxy per RESOLVERS entry at the Safe-keyed predicted CREATE3 addresses, with code ─────── + // Bound to RESOLVERS.length (not a hardcoded count) so appending an additive resolver — e.g. + // WhiteoutResolver (ADR-0055) — doesn't silently rot this assertion. + expect(Object.keys(result.proxies)).to.have.lengthOf(RESOLVERS.length); for (const r of RESOLVERS) { const proxy = result.proxies[r]; const predicted = (await predictProxyAddress(createx, safe, r)).predicted; @@ -291,13 +293,15 @@ describe("DeploySafe.fork — Safe-native deploy, born Safe-owned", function () // Re-build the plan against the deployed+sealed system. This is the post-seal re-run path. const plan = await buildSafePlan(deployer, deployedSafe, false); - // ── The omit flags are set: bootstrap+seal omitted, and all 9 registers omitted ───────────────── + // ── The omit flags are set: bootstrap+seal omitted, and all register legs omitted ─────────────── + // SCHEMAS is the frozen nine PLUS the additive post-freeze WHITEOUT (ADR-0055), so the count is the + // full SCHEMAS.length, not a hardcoded 9 — every register leg is omitted on a post-seal re-run + // (register is NOT idempotent). expect(plan.batch2BootstrapOmitted, "batch2BootstrapOmitted on a post-seal re-run").to.equal(true); - expect(SCHEMAS.length, "freeze set is 9 schemas").to.equal(9); expect( plan.batch2RegistersOmitted, - "all 9 register legs omitted — the schemas are already registered (register is NOT idempotent)", - ).to.equal(9); + "all register legs omitted — the schemas are already registered (register is NOT idempotent)", + ).to.equal(SCHEMAS.length); // ── Batch 2 is EMPTY — every register leg + bootstrap + seal omitted (full idempotency) ───────── expect(plan.batch2, "Batch 2 is an empty no-op (all registers + bootstrap + seal omitted)").to.have.lengthOf(0); @@ -437,8 +441,13 @@ describe("DeploySafe.fork — Safe-native deploy, born Safe-owned", function () expect(res.batches[0].safeTxHash, "SafeTx hash present").to.match(/^0x[0-9a-fA-F]{64}$/); expect(res.batches[0].operation, "MultiSend is a delegatecall (operation 1)").to.equal(1); expect(res.batches[0].to.toLowerCase(), "targets MultiSendCallOnly").to.not.equal(ethers.ZeroAddress); - // Batch 1 carries the 7 proxy deploys + wireContracts (8 legs). No register leg is proposed at Phase 0. - expect(res.batches[0].legs.length, "Batch 1 has 8 legs (7 deploys + wire)").to.equal(8); + // Batch 1 carries one deploy leg per RESOLVERS entry + the SystemAccount deploy + wireContracts. + // Bound to RESOLVERS.length (deploys) + 2 (SystemAccount + wire) so an additive resolver — e.g. + // WhiteoutResolver (ADR-0055) — doesn't rot the count. No register leg is proposed at Phase 0. + expect( + res.batches[0].legs.length, + "Batch 1 has RESOLVERS.length + 2 legs (per-resolver deploys + SystemAccount + wire)", + ).to.equal(RESOLVERS.length + 2); expect( res.batches[0].legs.filter(leg => /^register /.test(leg.label ?? "")).length, "no register legs proposed at Phase 0 (Batch 2 is not emitted until Phase 1)", diff --git a/packages/hardhat/test/DeploySafeAdditive.fork.test.ts b/packages/hardhat/test/DeploySafeAdditive.fork.test.ts new file mode 100644 index 00000000..150604f4 --- /dev/null +++ b/packages/hardhat/test/DeploySafeAdditive.fork.test.ts @@ -0,0 +1,429 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { takeSnapshot } from "@nomicfoundation/hardhat-network-helpers"; +import { CREATEX_ADDRESS } from "../deploy-lib/addresses"; +import { orchestrateViaSafe } from "../deploy-lib/orchestrateSafe"; +import { getCreateX, predictProxyAddress } from "../deploy-lib/create3"; +import { RESOLVERS, SCHEMAS } from "../deploy-lib/schemas"; +import { + buildAdditivePlan, + buildSafePlan, + detectDeployPhase, + detectMissingResolvers, + predictPlan, +} from "../deploy-lib/safePlan"; +import { deployTestSafe, executeBatchAsSafe, getSafe, SAFE_PROXY_FACTORY_141 } from "../deploy-lib/safe"; + +// Fork rehearsal for the ADDITIVE post-freeze Safe deploy (ADR-0055 — adding WHITEOUT to a LIVE core). +// +// The bug this guards: WhiteoutResolver was appended to the core RESOLVERS set (schemas.ts), but the +// Safe deploy machinery keyed Phase 0 only on EFSIndexer code + looped ALL proxies in +// `buildDeploysFromOnchain`. In the documented additive case (the original frozen nine-schema core is +// ALREADY LIVE on Sepolia and only WHITEOUT is missing), the flow skipped Batch 1, then threw because +// there was no code at the predicted WhiteoutResolver address — so the new schema could not be +// registered without redeploying the whole core. This blocks the actual planned Sepolia deployment. +// +// The fix: detect missing additive resolvers (resolvers with no on-chain code while the core EFSIndexer +// IS live) and deploy ONLY those (+ register ONLY their schemas) as a dedicated step BEFORE the register +// batch — no full-core redeploy. This file proves: +// 1. `buildAdditivePlan(["WhiteoutResolver"])` deploys ONLY WhiteoutResolver + registers ONLY WHITEOUT. +// 2. On a COMPLETE live system, `detectMissingResolvers` is empty + `detectDeployPhase` == 3 — the +// additive branch is inert, so the existing fresh-deploy path is unchanged. +// 3. END-TO-END: a manufactured LIVE CORE with WhiteoutResolver MISSING (the real additive shape) is +// completed by `orchestrateViaSafe` deploying ONLY WhiteoutResolver + registering ONLY WHITEOUT, +// born Safe-owned, with the 9 core schemas + 6 core resolvers untouched (no redeploy). +// +// MAINNET_FORKING_ENABLED=true npx hardhat test test/DeploySafeAdditive.fork.test.ts --network hardhat +// When not forking, self-skips so the default unit suite is unaffected. +describe("DeploySafeAdditive.fork — additive WhiteoutResolver deploy onto a live core (ADR-0055)", function () { + this.timeout(240_000); + + let forked = false; + + before(async function () { + const createxCode = await ethers.provider.getCode(CREATEX_ADDRESS); + const safeFactoryCode = await ethers.provider.getCode(SAFE_PROXY_FACTORY_141); + forked = createxCode !== "0x" && safeFactoryCode !== "0x"; + if (!forked) { + console.log( + " (skipping DeploySafeAdditive.fork — CreateX/Safe factory not present; run with MAINNET_FORKING_ENABLED=true)", + ); + this.skip(); + } + }); + + // ── 1. buildAdditivePlan deploys ONLY WhiteoutResolver + registers ONLY WHITEOUT ────────────────── + // Pure plan-shape proof (no on-chain core needed): the additive plan for the single missing additive + // resolver carries exactly one CreateX deploy leg (WhiteoutResolver) and exactly one register leg + // (WHITEOUT) — never a core resolver / core schema. This is the "no full-core redeploy" guarantee at + // the plan layer. + it("buildAdditivePlan(WhiteoutResolver) → exactly 1 deploy leg + 1 register leg, none core", async function () { + const [deployer, ownerSigner] = await ethers.getSigners(); + const safe = await deployTestSafe(deployer, [await ownerSigner.getAddress()], 1); + const predicted = await predictPlan(deployer, safe, false); + + const additive = await buildAdditivePlan(deployer, predicted, ["WhiteoutResolver"], false); + + // Batch A1: exactly one deploy leg, for WhiteoutResolver — no core resolver. + expect(additive.batchA1, "Batch A1 deploys exactly one resolver").to.have.lengthOf(1); + expect(additive.batchA1[0].label).to.match(/deployCreate3 WhiteoutResolver/); + const coreResolvers = RESOLVERS.filter(r => r !== "WhiteoutResolver"); + for (const r of coreResolvers) { + expect( + additive.batchA1.some(leg => (leg.label ?? "").includes(`deployCreate3 ${r}`)), + `Batch A1 must NOT redeploy core resolver ${r}`, + ).to.equal(false); + } + + // Batch A2: exactly one register leg, for WHITEOUT — no core schema. + expect(additive.batchA2, "Batch A2 registers exactly one schema").to.have.lengthOf(1); + expect(additive.batchA2[0].label).to.match(/register WHITEOUT/); + const coreSchemas = SCHEMAS.filter(s => s.resolver !== "WhiteoutResolver"); + for (const s of coreSchemas) { + expect( + additive.batchA2.some(leg => (leg.label ?? "").includes(`register ${s.name}`)), + `Batch A2 must NOT re-register core schema ${s.name}`, + ).to.equal(false); + } + }); + + // ── 2. On a COMPLETE live system the additive branch is inert (fresh path unchanged) ────────────── + it("complete system: detectMissingResolvers is empty + phase 3 (additive branch inert)", async function () { + const snap = await takeSnapshot(); + try { + const [deployer, ownerSigner] = await ethers.getSigners(); + const safe = await deployTestSafe(deployer, [await ownerSigner.getAddress()], 1); + + // Full happy-path deploy (all 7 resolvers incl. WhiteoutResolver land in one Batch 1). + const raw = await orchestrateViaSafe(deployer, safe, [ownerSigner], { log: false }); + expect(raw.mode).to.equal("execute"); + + const predicted = await predictPlan(deployer, safe, false); + const missing = await detectMissingResolvers(deployer, predicted); + expect(missing, "no resolver missing on a complete system").to.have.lengthOf(0); + const phase = await detectDeployPhase(deployer, predicted); + expect(phase, "complete system is phase 3").to.equal(3); + + // Re-running orchestrate is a clean no-op (does NOT enter the additive branch, does NOT redeploy). + const reraw = await orchestrateViaSafe(deployer, safe, [ownerSigner], { log: false }); + expect(reraw.mode).to.equal("execute"); + if (reraw.mode !== "execute") throw new Error("unreachable"); + expect(reraw.safeTxHashes.batch1, "Batch 1 skipped on a complete system").to.match(/skipped/i); + } finally { + await snap.restore(); + } + }); + + // ── 3. END-TO-END: live core with WhiteoutResolver missing → additive deploy completes it ───────── + // Manufacture the real additive shape by standing up the core WITHOUT WhiteoutResolver: build the full + // Safe plan, then drive Batch 1 / Batch 2 / Batch 3 with the WhiteoutResolver deploy leg and the + // WHITEOUT register leg FILTERED OUT. The result is a genuine live core (9 core schemas on 6 core + // resolvers, sealed, transports wired) with the WhiteoutResolver proxy absent + WHITEOUT unregistered — + // exactly the documented Sepolia state. Then `orchestrateViaSafe` must deploy ONLY WhiteoutResolver + + // register ONLY WHITEOUT, born Safe-owned, leaving the core untouched. + it("live core missing WhiteoutResolver: orchestrate deploys ONLY WhiteoutResolver + registers ONLY WHITEOUT (no core redeploy)", async function () { + const snap = await takeSnapshot(); + try { + const [deployer, ownerSigner] = await ethers.getSigners(); + const safe = await deployTestSafe(deployer, [await ownerSigner.getAddress()], 1); + const safeContract = await getSafe(safe, deployer); + const safeLc = safe.toLowerCase(); + const createx = await getCreateX(deployer); + + // Build the full plan (deploys all impls), then strip the WhiteoutResolver/WHITEOUT legs so the + // executed core is genuinely missing the additive resolver. + const plan = await buildSafePlan(deployer, safe, false); + const coreBatch1 = plan.batch1.filter(leg => !(leg.label ?? "").includes("deployCreate3 WhiteoutResolver")); + const coreBatch2 = plan.batch2.filter(leg => (leg.label ?? "") !== "register WHITEOUT"); + // Sanity: we actually stripped exactly the two additive legs. + expect(plan.batch1.length - coreBatch1.length, "stripped 1 deploy leg (WhiteoutResolver)").to.equal(1); + expect(plan.batch2.length - coreBatch2.length, "stripped 1 register leg (WHITEOUT)").to.equal(1); + + // Execute the core-only Batch 1 (6 resolvers + SystemAccount + wire), then Batch 2 (register 9 core + // schemas + bootstrap + seal). Then Batch 3 (setTransportsAnchor) — same ceremony, minus WHITEOUT. + await executeBatchAsSafe(safeContract, coreBatch1, [ownerSigner], deployer); + await executeBatchAsSafe(safeContract, coreBatch2, [ownerSigner], deployer); + const indexer = await ethers.getContractAt("EFSIndexer", plan.proxies.EFSIndexer, deployer); + const root = await indexer.rootAnchorUID(); + const transportsUID = await indexer.resolvePath(root, "transports"); + const { buildSetTransportsAnchorCall } = await import("../deploy-lib/safePlan"); + await executeBatchAsSafe( + safeContract, + [await buildSetTransportsAnchorCall(deployer, plan.proxies.MirrorResolver, transportsUID)], + [ownerSigner], + deployer, + ); + + // ── The manufactured state: core live, WhiteoutResolver MISSING. ──────────────────────────────── + const whiteoutProxy = (await predictProxyAddress(createx, safe, "WhiteoutResolver")).predicted; + expect(await ethers.provider.getCode(plan.proxies.EFSIndexer), "core EFSIndexer live").to.not.equal("0x"); + expect(await ethers.provider.getCode(whiteoutProxy), "WhiteoutResolver proxy absent").to.equal("0x"); + const reg = await ethers.getContractAt( + "@ethereum-attestation-service/eas-contracts/contracts/ISchemaRegistry.sol:ISchemaRegistry", + "0x0a7E2Ff54e76B8E6659aedc9103FB21c038050D0", + deployer, + ); + // 9 core schemas registered; WHITEOUT not. + for (const s of SCHEMAS) { + const rec = await reg.getSchema(plan.schemaUIDs[s.name]); + if (s.resolver === "WhiteoutResolver") { + expect(rec.uid, "WHITEOUT NOT registered (the manufactured additive gap)").to.equal(ethers.ZeroHash); + } else { + expect(rec.uid.toLowerCase(), `core schema ${s.name} registered`).to.equal( + plan.schemaUIDs[s.name].toLowerCase(), + ); + } + } + // Detection: phase 3 (core complete, additive-aware) + WhiteoutResolver flagged missing. + const predicted = await predictPlan(deployer, safe, false); + expect(await detectDeployPhase(deployer, predicted), "core complete → phase 3").to.equal(3); + expect(await detectMissingResolvers(deployer, predicted), "WhiteoutResolver detected missing").to.deep.equal([ + "WhiteoutResolver", + ]); + + // Capture which CREATE3 proxy addresses already had code BEFORE the additive run — proof that the + // additive step does NOT redeploy any core proxy (its CreateX deploy is one-shot: a redeploy would + // revert "address taken"). Only WhiteoutResolver should transition from no-code to code. + const codeBefore: Record = {}; + for (const r of RESOLVERS) codeBefore[r] = (await ethers.provider.getCode(plan.proxies[r])) !== "0x"; + const saCodeBefore = (await ethers.provider.getCode(plan.systemAccount)) !== "0x"; + + // ── THE FIX: orchestrateViaSafe now completes the additive resolver instead of throwing. ──────── + const raw = await orchestrateViaSafe(deployer, safe, [ownerSigner], { log: false }); + expect(raw.mode).to.equal("execute"); + if (raw.mode !== "execute") throw new Error("unreachable"); + + // WhiteoutResolver proxy now has code, born Safe-owned; WHITEOUT registered against it. + expect( + await ethers.provider.getCode(whiteoutProxy), + "WhiteoutResolver deployed by the additive step", + ).to.not.equal("0x"); + const whiteoutResolver = await ethers.getContractAt("WhiteoutResolver", whiteoutProxy, deployer); + const whiteoutUID = plan.schemaUIDs.WHITEOUT; + expect( + (await whiteoutResolver.whiteoutSchemaUID()).toLowerCase(), + "self-UID matches the registered WHITEOUT", + ).to.equal(whiteoutUID.toLowerCase()); + const whiteoutRec = await reg.getSchema(whiteoutUID); + expect(whiteoutRec.uid.toLowerCase(), "WHITEOUT now registered").to.equal(whiteoutUID.toLowerCase()); + expect(whiteoutRec.resolver.toLowerCase(), "WHITEOUT registered against the WhiteoutResolver proxy").to.equal( + whiteoutProxy.toLowerCase(), + ); + + // The additive deploy is born Safe-owned (its ProxyAdmin owner == Safe). + const EIP1967_ADMIN_SLOT = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103"; + const adminRaw = await ethers.provider.getStorage(whiteoutProxy, EIP1967_ADMIN_SLOT); + const admin = ethers.getAddress("0x" + adminRaw.slice(-40)); + const pa = await ethers.getContractAt( + "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol:ProxyAdmin", + admin, + deployer, + ); + expect((await pa.owner()).toLowerCase(), "WhiteoutResolver born Safe-owned").to.equal(safeLc); + + // NO full-core redeploy: only the WhiteoutResolver proxy transitioned no-code → code. Every core + // proxy + SystemAccount that already existed still exists (and was NOT redeployed — a CreateX + // redeploy is one-shot and would have reverted). Only WhiteoutResolver was missing before and is + // present now. + for (const r of RESOLVERS) { + const after = (await ethers.provider.getCode(plan.proxies[r])) !== "0x"; + expect(after, `${r} proxy present after additive`).to.equal(true); + if (r === "WhiteoutResolver") { + expect(codeBefore[r], "WhiteoutResolver was missing before").to.equal(false); + } else { + expect(codeBefore[r], `core ${r} present before (not redeployed)`).to.equal(true); + } + } + expect(saCodeBefore, "SystemAccount present before additive (not redeployed)").to.equal(true); + + // The core is byte-for-byte untouched: the 9 core schemas still resolve to the same proxies, the + // bootstrap is still sealed, transports still wired, EFSIndexer still Safe-owned. + for (const s of SCHEMAS.filter(x => x.resolver !== "WhiteoutResolver")) { + const rec = await reg.getSchema(plan.schemaUIDs[s.name]); + expect(rec.resolver.toLowerCase(), `core schema ${s.name} resolver unchanged`).to.equal( + plan.proxies[s.resolver].toLowerCase(), + ); + } + const systemAccount = await ethers.getContractAt("SystemAccount", plan.systemAccount, deployer); + expect(await systemAccount.bootstrapSealed(), "core bootstrap still sealed").to.equal(true); + const mirror = await ethers.getContractAt("MirrorResolver", plan.proxies.MirrorResolver, deployer); + expect((await mirror.transportsAnchorUID()).toLowerCase(), "core transports still wired").to.equal( + transportsUID.toLowerCase(), + ); + expect((await indexer.owner()).toLowerCase(), "core EFSIndexer still Safe-owned").to.equal(safeLc); + + // Re-running the additive orchestrate is a clean no-op (proxy deployed + schema registered). + const reraw = await orchestrateViaSafe(deployer, safe, [ownerSigner], { log: false }); + expect(reraw.mode).to.equal("execute"); + if (reraw.mode !== "execute") throw new Error("unreachable"); + expect(reraw.safeTxHashes.batch1, "additive Batch A1 skipped on re-run").to.match(/skipped|additive/i); + expect( + await detectMissingResolvers(deployer, predicted), + "no resolver missing after additive deploy", + ).to.have.lengthOf(0); + } finally { + await snap.restore(); + } + }); + + // ── 4. PROPOSE path (the REAL Sepolia path): additive sub-phases A1 → A2 ─────────────────────────── + // Real Sepolia uses mode "propose" (the gas EOA is not a Safe owner). Drive the additive propose walk + // against the manufactured live-core-missing-WhiteoutResolver state, APPLYING each proposed batch with + // the test owner key to advance (the operator's Safe{Wallet} step). Assert each invocation emits ONLY + // the next additive batch — A1 (deploy WhiteoutResolver), then A2 (register WHITEOUT) — never a core + // ceremony batch, never a duplicate. + it("propose path: additive walk emits Batch A1 (deploy) then Batch A2 (register WHITEOUT), never a core batch", async function () { + const snap = await takeSnapshot(); + try { + const [deployer, ownerSigner] = await ethers.getSigners(); + const safe = await deployTestSafe(deployer, [await ownerSigner.getAddress()], 1); + const safeContract = await getSafe(safe, deployer); + const createx = await getCreateX(deployer); + + // Manufacture the live core missing WhiteoutResolver (same construction as test 3). + const plan = await buildSafePlan(deployer, safe, false); + const coreBatch1 = plan.batch1.filter(leg => !(leg.label ?? "").includes("deployCreate3 WhiteoutResolver")); + const coreBatch2 = plan.batch2.filter(leg => (leg.label ?? "") !== "register WHITEOUT"); + await executeBatchAsSafe(safeContract, coreBatch1, [ownerSigner], deployer); + await executeBatchAsSafe(safeContract, coreBatch2, [ownerSigner], deployer); + const indexer = await ethers.getContractAt("EFSIndexer", plan.proxies.EFSIndexer, deployer); + const root = await indexer.rootAnchorUID(); + const transportsUID = await indexer.resolvePath(root, "transports"); + const { buildSetTransportsAnchorCall } = await import("../deploy-lib/safePlan"); + await executeBatchAsSafe( + safeContract, + [await buildSetTransportsAnchorCall(deployer, plan.proxies.MirrorResolver, transportsUID)], + [ownerSigner], + deployer, + ); + const whiteoutProxy = (await predictProxyAddress(createx, safe, "WhiteoutResolver")).predicted; + expect(await ethers.provider.getCode(whiteoutProxy), "WhiteoutResolver absent (manufactured gap)").to.equal("0x"); + + // ── Additive sub-phase A1: propose emits ONLY Batch A1 (deploy WhiteoutResolver). ─────────────── + const a1 = await orchestrateViaSafe(deployer, safe, [], { mode: "propose", log: false }); + expect(a1.mode).to.equal("propose"); + if (a1.mode !== "propose") throw new Error("unreachable"); + expect(a1.batches, "additive A1 emits exactly one batch").to.have.lengthOf(1); + expect(a1.batches[0].label, "additive A1 deploys WhiteoutResolver").to.match(/Batch A1 \(additive deploy/); + expect( + a1.batches[0].legs.every(leg => (leg.label ?? "").includes("WhiteoutResolver")), + "A1 carries ONLY the WhiteoutResolver deploy leg (no core deploy)", + ).to.equal(true); + // Nothing executed by propose — the Safe-keyed WhiteoutResolver proxy is still absent. + expect(await ethers.provider.getCode(whiteoutProxy), "propose A1 did not self-execute").to.equal("0x"); + + // Apply Batch A1 (operator's Safe{Wallet} step) → WhiteoutResolver proxy now live. + const a1plan = await buildAdditivePlan( + deployer, + await predictPlan(deployer, safe, false), + ["WhiteoutResolver"], + false, + ); + await executeBatchAsSafe(safeContract, a1plan.batchA1, [ownerSigner], deployer); + expect(await ethers.provider.getCode(whiteoutProxy), "WhiteoutResolver live after A1").to.not.equal("0x"); + + // ── Sub-phase A2: re-run propose. Once the WhiteoutResolver proxy has code, `detectMissingResolvers` + // no longer flags it, so the re-run routes the register through the existing (additive-aware) core + // Phase-1 path — which by design registers ONLY the not-yet-registered schema (WHITEOUT). The 9 core + // schemas are register-omitted (already registered) and bootstrap+seal are omitted (already sealed), + // so the proposed batch is EXACTLY the one WHITEOUT register leg. This reuses the battle-tested + // register-omit machinery rather than a parallel additive register path — the WHITEOUT schema is + // registered, and nothing core is touched. ──────────────────────────────────────────────────────── + const a2 = await orchestrateViaSafe(deployer, safe, [], { mode: "propose", log: false }); + expect(a2.mode).to.equal("propose"); + if (a2.mode !== "propose") throw new Error("unreachable"); + expect(a2.batches, "A2 emits exactly one batch").to.have.lengthOf(1); + expect(a2.batches[0].legs, "A2 carries exactly one leg").to.have.lengthOf(1); + expect( + a2.batches[0].legs[0].label, + "A2 registers ONLY WHITEOUT (core registers + bootstrap/seal omitted)", + ).to.match(/register WHITEOUT/); + // No core ceremony leg leaks in (no core register, no bootstrap, no seal, no deployCreate3). + expect( + a2.batches[0].legs.some( + leg => /deployCreate3|bootstrap|seal/i.test(leg.label ?? "") || /register (?!WHITEOUT)/.test(leg.label ?? ""), + ), + "A2 carries no core deploy/register/bootstrap/seal leg", + ).to.equal(false); + + // Apply Batch A2 → WHITEOUT registered. (Rebuild the executable register leg — the propose result's + // `legs` carry only to/value/dataLength for review, not calldata, so re-derive the SafeCall[].) + const a2plan = await buildAdditivePlan( + deployer, + await predictPlan(deployer, safe, false), + ["WhiteoutResolver"], + false, + ); + await executeBatchAsSafe(safeContract, a2plan.batchA2, [ownerSigner], deployer); + const reg = await ethers.getContractAt( + "@ethereum-attestation-service/eas-contracts/contracts/ISchemaRegistry.sol:ISchemaRegistry", + "0x0a7E2Ff54e76B8E6659aedc9103FB21c038050D0", + deployer, + ); + expect( + (await reg.getSchema(plan.schemaUIDs.WHITEOUT)).uid.toLowerCase(), + "WHITEOUT registered after A2", + ).to.equal(plan.schemaUIDs.WHITEOUT.toLowerCase()); + + // ── Additive done: re-run propose → clean no-op (no batch). ───────────────────────────────────── + const done = await orchestrateViaSafe(deployer, safe, [], { mode: "propose", log: false }); + expect(done.mode).to.equal("propose"); + if (done.mode !== "propose") throw new Error("unreachable"); + expect(done.batches, "additive complete → no batch").to.have.lengthOf(0); + } finally { + await snap.restore(); + } + }); + + // ── 5. REGRESSION (Codex review): an INCOMPLETE core must NOT enter additive mode ───────────────── + // The additive branch must fire ONLY when the existing core is COMPLETE (Phase 3). We manufacture a core + // paused after Batch 1 (proxies live + wired, NO schemas registered → Phase 1) WITHOUT WhiteoutResolver — + // the shape of an older six-resolver ceremony resumed under this seven-resolver code. `detectMissingResolvers` + // DOES flag WhiteoutResolver (so the prior `phase != 0` / `indexerLive` gate WOULD have wrongly jumped to + // additive, deployed/registered only WHITEOUT, and abandoned the owed core Batch 2/3 while reporting + // success). The fix: the additive-aware phase is 1 (core incomplete), so the `phase === 3` gate stays shut + // and orchestrate never emits an additive batch. + it("incomplete core (Phase 1) missing WhiteoutResolver does NOT enter additive mode", async function () { + const snap = await takeSnapshot(); + try { + const [deployer, ownerSigner] = await ethers.getSigners(); + const safe = await deployTestSafe(deployer, [await ownerSigner.getAddress()], 1); + const safeContract = await getSafe(safe, deployer); + const createx = await getCreateX(deployer); + + // Execute ONLY core Batch 1 (proxies + wire), stripped of the WhiteoutResolver deploy leg. Do NOT + // register schemas / seal / wire transports → the core genuinely sits at Phase 1. + const plan = await buildSafePlan(deployer, safe, false); + const coreBatch1 = plan.batch1.filter(leg => !(leg.label ?? "").includes("deployCreate3 WhiteoutResolver")); + expect(plan.batch1.length - coreBatch1.length, "stripped 1 deploy leg (WhiteoutResolver)").to.equal(1); + await executeBatchAsSafe(safeContract, coreBatch1, [ownerSigner], deployer); + + const predicted = await predictPlan(deployer, safe, false); + const whiteoutProxy = (await predictProxyAddress(createx, safe, "WhiteoutResolver")).predicted; + expect(await ethers.provider.getCode(predicted.proxies.EFSIndexer), "core EFSIndexer live").to.not.equal("0x"); + expect(await ethers.provider.getCode(whiteoutProxy), "WhiteoutResolver proxy absent").to.equal("0x"); + + // The bug shape: WhiteoutResolver IS flagged missing (the prior gate would have fired additive)… + expect(await detectMissingResolvers(deployer, predicted), "WhiteoutResolver flagged missing").to.deep.equal([ + "WhiteoutResolver", + ]); + // …but the additive-aware phase sees the core as INCOMPLETE (schemas unregistered) → Phase 1, not 3, + // so the `phase === 3` additive gate stays closed. + expect(await detectDeployPhase(deployer, predicted), "incomplete core → Phase 1 (not 3)").to.equal(1); + + // Behavioral proof: a propose-mode resume never emits an additive batch. (At Phase 1 with the + // additive proxy absent the normal core path's verify gate throws on the missing proxy — a loud + // failure surfacing the cross-version mismatch — rather than silently completing as additive.) + let additiveEmitted = false; + try { + const res = await orchestrateViaSafe(deployer, safe, [], { mode: "propose", log: false }); + if (res.mode === "propose") additiveEmitted = res.batches.some(b => /additive/i.test(b.label ?? "")); + } catch { + // threw in the normal core path (verify gate on the missing proxy) — definitively NOT additive. + } + expect(additiveEmitted, "Phase-1 core must NOT emit an additive deploy batch").to.equal(false); + } finally { + await snap.restore(); + } + }); +}); diff --git a/packages/hardhat/test/EFSDataModel.e2e.test.ts b/packages/hardhat/test/EFSDataModel.e2e.test.ts index d11a8334..d397c3b9 100644 --- a/packages/hardhat/test/EFSDataModel.e2e.test.ts +++ b/packages/hardhat/test/EFSDataModel.e2e.test.ts @@ -417,7 +417,7 @@ describe("EFS Data Model — E2E Integration", function () { // Deploy FileView const FileViewFactory = await ethers.getContractFactory("EFSFileView"); - fileView = await FileViewFactory.deploy(await indexer.getAddress(), await edgeResolver.getAddress()); + fileView = await FileViewFactory.deploy(await indexer.getAddress(), await edgeResolver.getAddress(), ZeroAddress); // Wire contracts await indexer.wireContracts( @@ -1722,12 +1722,17 @@ describe("EFS Data Model — E2E Integration", function () { for (const slot of slots) expect(uniqueUIDs.has(slot)).to.equal(true); }); - it("MAX_ANCHOR_DEPTH is enforced (prevents gas griefing)", async function () { + it("MAX_ANCHOR_DEPTH allows deep trees (256, ADR-0068)", async function () { + // Value lock + positive regression: deep trees beyond the old 32-level cap now create. + // The boundary-revert proof (build to cap+1) is the opt-in RUN_SLOW_TESTS test in + // EFSTransports.test.ts — building 1025 anchors here would blow the mocha timeout. + this.timeout(180000); // dozens of sequential attestations + expect(await indexer.MAX_ANCHOR_DEPTH()).to.equal(256n); let parent = rootUID; - for (let i = 0; i < 32; i++) { + for (let i = 0; i < 40; i++) { parent = await createAnchor(parent, `d${i}`); } - await expect(createAnchor(parent, "too-deep")).to.be.revertedWithCustomError(indexer, "AnchorTooDeep"); + expect(parent).to.not.equal(rootUID); // 40 deep — old cap of 32 would have reverted }); }); diff --git a/packages/hardhat/test/EFSFileView.test.ts b/packages/hardhat/test/EFSFileView.test.ts index 88ef4056..1a41b4c6 100644 --- a/packages/hardhat/test/EFSFileView.test.ts +++ b/packages/hardhat/test/EFSFileView.test.ts @@ -128,7 +128,7 @@ describe("EFSFileView", function () { // Deploy FileView (with EdgeResolver) const FileViewFactory = await ethers.getContractFactory("EFSFileView"); - fileView = await FileViewFactory.deploy(await indexer.getAddress(), await edgeResolver.getAddress()); + fileView = await FileViewFactory.deploy(await indexer.getAddress(), await edgeResolver.getAddress(), ZeroAddress); await fileView.waitForDeployment(); // Wire the indexer -> edgeResolver link so EdgeResolver.onAttest can call diff --git a/packages/hardhat/test/EFSFileViewFiltered.test.ts b/packages/hardhat/test/EFSFileViewFiltered.test.ts index 3f98c6c8..47d27367 100644 --- a/packages/hardhat/test/EFSFileViewFiltered.test.ts +++ b/packages/hardhat/test/EFSFileViewFiltered.test.ts @@ -115,7 +115,7 @@ describe("EFSFileView — getDirectoryPageFiltered (ADR-0054)", function () { excludeTagDef = (await tx7.wait())!.logs[0].topics[1]; const FileViewFactory = await ethers.getContractFactory("EFSFileView"); - fileView = await FileViewFactory.deploy(await indexer.getAddress(), await edgeResolver.getAddress()); + fileView = await FileViewFactory.deploy(await indexer.getAddress(), await edgeResolver.getAddress(), ZeroAddress); await fileView.waitForDeployment(); await indexer.wireContracts( @@ -240,7 +240,12 @@ describe("EFSFileView — getDirectoryPageFiltered (ADR-0054)", function () { */ const deployTestableFileView = async (budget: number = 4): Promise => { const Factory = await ethers.getContractFactory("EFSFileViewTestable"); - const tv = await Factory.deploy(await indexer.getAddress(), await edgeResolver.getAddress(), BigInt(budget)); + const tv = await Factory.deploy( + await indexer.getAddress(), + await edgeResolver.getAddress(), + ZeroAddress, + BigInt(budget), + ); await tv.waitForDeployment(); return tv as unknown as EFSFileView; }; diff --git a/packages/hardhat/test/EFSIndexer.index.test.ts b/packages/hardhat/test/EFSIndexer.index.test.ts index a2a64d57..4bac34c2 100644 --- a/packages/hardhat/test/EFSIndexer.index.test.ts +++ b/packages/hardhat/test/EFSIndexer.index.test.ts @@ -345,7 +345,7 @@ describe("EFSIndexer — public index() API", function () { expect(byAttester).to.include(uid); }); - it("containsAttestations returns true after index with refUID", async function () { + it("index() is DISCOVERY-ONLY: a foreign attestation does NOT manufacture folder presence (ADR-0066)", async function () { const rootTx = await eas.connect(owner).attest({ schema: anchorSchemaUID, data: { @@ -363,7 +363,23 @@ describe("EFSIndexer — public index() API", function () { expect(await indexer.containsAttestations(rootUID, aliceAddr)).to.be.false; const uid = await attestThirdParty(alice, "linked", rootUID); await indexer.index(uid); - expect(await indexer.containsAttestations(rootUID, aliceAddr)).to.be.true; + + // Schema-blind FOLDER PRESENCE is NOT propagated by a permissionless discovery call (ADR-0066). + // A foreign/junk attestation pointed at an anchor must not make that anchor "contain alice's + // content" — that is the index() pollution vector (and the WHITEOUT positive-presence case). Real + // presence comes only from intentional placement (PIN/TAG via EdgeResolver, or anchor creation). + expect( + await indexer.containsAttestations(rootUID, aliceAddr), + "index() must NOT set schema-blind folder presence", + ).to.be.false; + + // …but DISCOVERY did happen: the attestation is findable, and the accurate schema-SCOPED fact is set. + const refs = await indexer.getReferencingAttestations(rootUID, thirdPartySchemaUID, 0, 10, false, false); + expect(refs, "index() makes the attestation discoverable via getReferencingAttestations").to.include(uid); + expect( + await indexer.containsSchemaAttestations(rootUID, aliceAddr, thirdPartySchemaUID), + "the schema-scoped discovery fact is accurate (alice has a thirdParty-schema attestation here)", + ).to.be.true; }); it("does not double-index: counts stay stable on re-index", async function () { diff --git a/packages/hardhat/test/EFSRouter.test.ts b/packages/hardhat/test/EFSRouter.test.ts index 90557f16..dcf4a201 100644 --- a/packages/hardhat/test/EFSRouter.test.ts +++ b/packages/hardhat/test/EFSRouter.test.ts @@ -177,6 +177,9 @@ describe("EFSRouter Web3 Capabilities", function () { // ADR-0053 systemAccount: zero here → router falls back to indexer.DEPLOYER() (= owner = // deployer in these unit tests), preserving the pre-ADR-0053 default-lens behavior. ethers.ZeroAddress, + // ADR-0055 whiteoutResolver: zero → whiteout disabled (this suite predates WHITEOUT and + // exercises only the positive resolution path), so the negative-mask reads are skipped. + ethers.ZeroAddress, ); await router.waitForDeployment(); @@ -1745,6 +1748,7 @@ describe("EFSRouter Web3 Capabilities", function () { await registry.getAddress(), dataSchemaUID, await _user2.getAddress(), // distinct systemAccount — not owner, not the segment address + ethers.ZeroAddress, // ADR-0055 whiteoutResolver: disabled (positive-path-only suite) ); await router2.waitForDeployment(); diff --git a/packages/hardhat/test/EFSTransports.test.ts b/packages/hardhat/test/EFSTransports.test.ts index 3313b56e..d764ff20 100644 --- a/packages/hardhat/test/EFSTransports.test.ts +++ b/packages/hardhat/test/EFSTransports.test.ts @@ -174,7 +174,7 @@ describe("EFS Transports & Data Model", function () { // Deploy EFSFileView const FileViewFactory = await ethers.getContractFactory("EFSFileView"); - fileView = await FileViewFactory.deploy(await indexer.getAddress(), await edgeResolver.getAddress()); + fileView = await FileViewFactory.deploy(await indexer.getAddress(), await edgeResolver.getAddress(), ZeroAddress); // Wire contracts await indexer.wireContracts( @@ -895,16 +895,38 @@ describe("EFS Transports & Data Model", function () { // ─── MAX_ANCHOR_DEPTH Tests ─────────────────────────────────────────────── describe("MAX_ANCHOR_DEPTH", function () { - it("should enforce maximum anchor depth", async function () { + it("MAX_ANCHOR_DEPTH constant is 256 (ADR-0068)", async function () { + // Value lock: catches an accidental change to the cap. 32 -> 1024 per ADR-0065. + expect(await indexer.MAX_ANCHOR_DEPTH()).to.equal(256n); + }); + + it("allows trees far deeper than the old 32-level cap (ADR-0065)", async function () { + // Regression for the raise: a 40-deep chain (past the old cap of 32) must now create successfully. + this.timeout(180000); // dozens of sequential attestations let parent = rootUID; - // Create a chain of 32 levels (root + 32 = 33 total, but root doesn't count as depth) - for (let i = 0; i < 32; i++) { + for (let i = 0; i < 40; i++) { parent = await createAnchor(parent, `level${i}`); } - - // 33rd level should fail - await expect(createAnchor(parent, "too-deep")).to.be.revertedWithCustomError(indexer, "AnchorTooDeep"); + // 40 levels deep, no revert — the old MAX_ANCHOR_DEPTH=32 would have failed at level 33. + expect(parent).to.not.equal(rootUID); }); + + // Boundary-revert proof: builds a chain to exactly the cap and asserts the next create reverts. + // Opt-in only — creating 1025 sequential anchors is ~60s, and the enforcement logic itself is + // value-parametric and unchanged, so it's covered by the value-lock + positive tests above. + // Run with RUN_SLOW_TESTS=true to exercise it. + (process.env.RUN_SLOW_TESTS === "true" ? it : it.skip)( + "reverts one level past the depth cap (slow)", + async function () { + this.timeout(600000); + const maxDepth = Number(await indexer.MAX_ANCHOR_DEPTH()); + let parent = rootUID; + for (let i = 0; i < maxDepth; i++) { + parent = await createAnchor(parent, `level${i}`); + } + await expect(createAnchor(parent, "too-deep")).to.be.revertedWithCustomError(indexer, "AnchorTooDeep"); + }, + ); }); // ─── EFSFileView Integration Tests ──────────────────────────────────────── diff --git a/packages/hardhat/test/SchemaGoldenVectors.test.ts b/packages/hardhat/test/SchemaGoldenVectors.test.ts index 2897f3d7..3abe480b 100644 --- a/packages/hardhat/test/SchemaGoldenVectors.test.ts +++ b/packages/hardhat/test/SchemaGoldenVectors.test.ts @@ -2,8 +2,8 @@ import { expect } from "chai"; import { getAddress } from "ethers"; import { SCHEMAS, computeSchemaUID } from "../deploy-lib/schemas"; -// I-1 (frozen golden vectors). This guards an ETCHED surface: the 9 field strings hash into the -// permanent Sepolia schema UIDs. The verify gate's golden-vector step recomputes UIDs from +// I-1 (frozen golden vectors). This guards an ETCHED surface: the 9 frozen field strings (+ the +// additive post-freeze WHITEOUT field string, ADR-0055) hash into the permanent Sepolia schema UIDs. The verify gate's golden-vector step recomputes UIDs from // deploy-lib/schemas.ts against itself, which is circular for the 7 schemas whose field strings only // appear as NatSpec comments in the resolver contracts (ANCHOR/PROPERTY/DATA/PIN/TAG/MIRROR/LIST) — // a typo in schemas.ts for those would not be caught on-chain. This test breaks that tautology: it @@ -30,7 +30,7 @@ interface GoldenVector { uid: string; // computeSchemaUID(fieldString, MOCK_RESOLVER, revocable) } -// FROZEN. Order matches docs/SEPOLIA_FREEZE_TABLE.md (1–9). +// FROZEN. Order matches docs/SEPOLIA_FREEZE_TABLE.md (1–9), then the additive WHITEOUT schema (ADR-0055). const GOLDEN: GoldenVector[] = [ { name: "ANCHOR", @@ -86,10 +86,21 @@ const GOLDEN: GoldenVector[] = [ revocable: true, uid: "0xd448aa033fb2d32840169f6a0a8a6bf8d5dcff5a7768b7a7d9fa9b48217342ce", }, + // ── ADDITIVE post-freeze (ADR-0055) — NOT part of the frozen nine, but its field string is + // still hashed into a permanent UID once registered, so it is golden-pinned here too. ── + { + name: "WHITEOUT", + fieldString: "", + revocable: true, + uid: "0xcad2a30531b688969bef16f8ac631a94da7e320bed0023136cc1f9b4c6226e7a", + }, ]; describe("Schema golden vectors (frozen — no fork)", function () { - it("schemas.ts has exactly the 9 frozen schemas in the freeze-table order", function () { + it("schemas.ts has exactly the frozen nine + the additive WHITEOUT schema, in order", function () { + // GOLDEN = the frozen nine (freeze-table order) followed by the additive post-freeze WHITEOUT + // schema (ADR-0055). The full equality also pins that the additive schema stays appended LAST + // (never reordered into the frozen nine). expect(SCHEMAS.map(s => s.name)).to.deep.equal(GOLDEN.map(g => g.name)); }); diff --git a/packages/hardhat/test/Whiteout.test.ts b/packages/hardhat/test/Whiteout.test.ts new file mode 100644 index 00000000..8c9dec83 --- /dev/null +++ b/packages/hardhat/test/Whiteout.test.ts @@ -0,0 +1,1599 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { + WhiteoutResolver, + EFSIndexer, + EdgeResolver, + EFSFileView, + EFSRouter, + EAS, + SchemaRegistry, +} from "../typechain-types"; +import { Signer, ZeroAddress, ZeroHash } from "ethers"; +import { deployIndexerProxy } from "./helpers/deployIndexerProxy"; +import { deployResolverProxy } from "./helpers/deployResolverProxy"; + +/** + * Whiteout — conformance suite for the WHITEOUT cross-lens negative mask (ADR-0055). + * + * WHITEOUT = "render this path empty in MY view; stop fall-through to lower lenses" WITHOUT + * substituting the lens's own content (overlayfs whiteout / lens-local delete). The one filesystem + * primitive additive-only lenses otherwise lack. + * + * Covers the per-name vectors (1–12) PLUS the per-name folder + folder-fix vectors (13–14, ADR-0055): + * 1 per-name whiteout hides one child in a directory listing + * 2 lens-scoped below — Lk's whiteout suppresses a lower lens's placement + * 3 lens-scoped above transparent — higher lens's positive PIN beats lower lens's whiteout + * 4 same-lens positive override — a lens's own newer PIN beats its own earlier whiteout + * 5 revoke un-hides + * 6 idempotent / no-op-safe — whiteout of an empty path resolves empty, no revert + * 7 cross-lens unaffected — viewer not including Lk still sees the item + * 8 re-whiteout last-writer-wins — revoking a stale UID doesn't clear a newer marker + * 9 write-guard rejections — each invalid write reverts with the resolver's specific error + * 10 router negative-terminal — router serves not-found, no fall-through to a lower lens + * 11 getFilesAtPath negative terminal — view/router consistency + * 12 whiteout in the FILTERED listing — doubly-suppressed skipped exactly once + * 13 folder whiteout hides a (visibility-TAG-listed) folder + * 14 folder whiteout + same-lens visibility-TAG re-add → visible (THE FOLDER-FIX) + * 15 plain phase-1 listing scan is BUDGET-BOUNDED when many direct files are whited out + * 16 getDirectoryPageByAddressList (attester-scoped, schema-agnostic) honors whiteout + * 17 the folder-visibility-TAG positive is FOLDER-gated — a TAG on a whited FILE anchor doesn't un-hide + * 18 getFilesAtPath stops at a MID-STACK whiteout (higher positive kept, lower lens suppressed) + * 19 getActiveWhiteout returns the live UID for un-delete (0 when none; new UID after re-whiteout) + * 20 a fabricated getFilesAtPath cursor cannot resume PAST a mid-stack whiteout terminal + * 21 listing a folder that is ITSELF whited out returns empty across all listing entrypoints + * 22 the parent-folder terminal uses the LISTING schema (non-DATA folder re-add recognized) + * + * HARNESS: the beforeEach predicts deterministic CREATE addresses via the deployer nonce, so this + * file MUST be run as a FULL FILE (`yarn test test/Whiteout.test.ts`), NEVER via `--grep` — a grep + * run that skips deploy txs desyncs the nonce predictions. + */ + +const ZERO_BYTES32 = ZeroHash; +const NO_EXPIRATION = 0n; + +// FROZEN schema field strings (must be byte-identical to the deploy registrations — hashed into UID). +const ANCHOR_SCHEMA = "string name, bytes32 forSchema"; +const PROPERTY_SCHEMA = "string value"; +const DATA_SCHEMA = ""; // empty (pure identity, ADR-0049) +const PIN_SCHEMA = "bytes32 definition"; +const TAG_SCHEMA = "bytes32 definition, int256 weight"; +const WHITEOUT_SCHEMA = ""; // empty (pure-identity negative marker, ADR-0055) + +describe("Whiteout (WHITEOUT cross-lens negative mask, ADR-0055)", function () { + let indexer: EFSIndexer; + let edgeResolver: EdgeResolver; + let whiteoutResolver: WhiteoutResolver; + let fileView: EFSFileView; + let eas: EAS; + let registry: SchemaRegistry; + + let owner: Signer; // primary lens (Lk) + let alice: Signer; // lower / other lens + let bob: Signer; // third lens + + let ownerAddr: string; + let aliceAddr: string; + let bobAddr: string; + + let anchorSchemaUID: string; + let dataSchemaUID: string; + let propertySchemaUID: string; + let pinSchemaUID: string; + let tagSchemaUID: string; + let whiteoutSchemaUID: string; + let mirrorSchemaUID: string; + + let whiteoutResolverAddr: string; + + const enc = new ethers.AbiCoder(); + + const getUID = (receipt: any): string => { + for (const log of receipt.logs) { + try { + const parsed = eas.interface.parseLog(log); + if (parsed?.name === "Attested") return parsed.args.uid; + } catch { + /* ignore */ + } + } + throw new Error("No Attested event found"); + }; + + // ── Mint helpers ─────────────────────────────────────────────────────────── + + /** Create an anchor (generic folder when schema=0, file anchor when schema=dataSchemaUID). */ + const createAnchor = async ( + name: string, + parentUID: string, + forSchema: string = ZERO_BYTES32, + signer: Signer = owner, + ): Promise => { + const tx = await eas.connect(signer).attest({ + schema: anchorSchemaUID, + data: { + recipient: ZeroAddress, + expirationTime: NO_EXPIRATION, + revocable: false, + refUID: parentUID, + data: enc.encode(["string", "bytes32"], [name, forSchema]), + value: 0n, + }, + }); + return getUID(await tx.wait()); + }; + + /** Mint a DATA attestation (empty, pure identity, ADR-0049). */ + const mintData = async (signer: Signer = owner): Promise => { + const tx = await eas.connect(signer).attest({ + schema: dataSchemaUID, + data: { + recipient: ZeroAddress, + expirationTime: NO_EXPIRATION, + revocable: false, + refUID: ZERO_BYTES32, + data: "0x", + value: 0n, + }, + }); + return getUID(await tx.wait()); + }; + + /** Mint a free-floating PROPERTY value (non-revocable, ADR-0052). */ + const mintProperty = async (value: string, signer: Signer = owner): Promise => { + const tx = await eas.connect(signer).attest({ + schema: propertySchemaUID, + data: { + recipient: ZeroAddress, + expirationTime: NO_EXPIRATION, + revocable: false, + refUID: ZERO_BYTES32, + data: enc.encode(["string"], [value]), + value: 0n, + }, + }); + return getUID(await tx.wait()); + }; + + /** Create a TAG (cardinality N). definition=def, refUID=target, with weight (default 1). */ + const createTag = async ( + definition: string, + target: string, + signer: Signer = owner, + weight: bigint = 1n, + ): Promise => { + const tx = await eas.connect(signer).attest({ + schema: tagSchemaUID, + data: { + recipient: ZeroAddress, + expirationTime: NO_EXPIRATION, + revocable: true, + refUID: target, + data: enc.encode(["bytes32", "int256"], [definition, weight]), + value: 0n, + }, + }); + return getUID(await tx.wait()); + }; + + /** Create a PIN (cardinality 1 — file placement). definition=anchor, refUID=DATA. */ + const createPin = async (definition: string, target: string, signer: Signer = owner): Promise => { + const tx = await eas.connect(signer).attest({ + schema: pinSchemaUID, + data: { + recipient: ZeroAddress, + expirationTime: NO_EXPIRATION, + revocable: true, + refUID: target, + data: enc.encode(["bytes32"], [definition]), + value: 0n, + }, + }); + return getUID(await tx.wait()); + }; + + /** + * Attest a WHITEOUT. refUID = the suppressed source (a child path ANCHOR). The WHITEOUT field + * string is empty (pure-identity marker), revocable=true, expiration=0 (the canonical valid shape). + * The optional `override` lets the write-guard vectors deliberately produce invalid shapes. + */ + const attestWhiteout = async ( + refUID: string, + signer: Signer = owner, + override: { + data?: string; + revocable?: boolean; + expirationTime?: bigint; + schema?: string; + } = {}, + ): Promise => { + const tx = await eas.connect(signer).attest({ + schema: override.schema ?? whiteoutSchemaUID, + data: { + recipient: ZeroAddress, + expirationTime: override.expirationTime ?? NO_EXPIRATION, + revocable: override.revocable ?? true, + refUID, + data: override.data ?? "0x", + value: 0n, + }, + }); + return getUID(await tx.wait()); + }; + + const revoke = async (schema: string, uid: string, signer: Signer = owner): Promise => { + await eas.connect(signer).revoke({ schema, data: { uid, value: 0n } }); + }; + + beforeEach(async function () { + [owner, alice, bob] = await ethers.getSigners(); + ownerAddr = await owner.getAddress(); + aliceAddr = await alice.getAddress(); + bobAddr = await bob.getAddress(); + + const RegistryFactory = await ethers.getContractFactory("SchemaRegistry"); + registry = await RegistryFactory.deploy(); + await registry.waitForDeployment(); + + const EASFactory = await ethers.getContractFactory("EAS"); + eas = await EASFactory.deploy(await registry.getAddress()); + await eas.waitForDeployment(); + + // ── Nonce-predicted deploy plan ──────────────────────────────────────────── + // The WhiteoutResolver self-derives its WHITEOUT schema UID against its PROXY address in + // initialize() (delegatecall ⇒ address(this) == proxy), and its initialize() reads + // indexer.ANCHOR_SCHEMA_UID() + getParent — so the indexer must already exist when the whiteout + // PROXY is initialized. Deploy order (each *Proxy helper runs impl-then-proxy = 2 txs): + // +0 EdgeResolver impl + // +1 EdgeResolver proxy ← resolver baked into PIN/TAG UIDs + // +2 EFSIndexer impl + // +3 EFSIndexer proxy ← resolver baked into ANCHOR/PROPERTY/DATA UIDs + // +4..+9 6 schema registrations (ANCHOR, PROPERTY, DATA, PIN, TAG, WHITEOUT) + // +10 WhiteoutResolver impl + // +11 WhiteoutResolver proxy ← resolver baked into the WHITEOUT UID; initialized last + const n = await ethers.provider.getTransactionCount(ownerAddr); + const futureEdgeResolverAddr = ethers.getCreateAddress({ from: ownerAddr, nonce: n + 1 }); + const futureIndexerAddr = ethers.getCreateAddress({ from: ownerAddr, nonce: n + 3 }); + whiteoutResolverAddr = ethers.getCreateAddress({ from: ownerAddr, nonce: n + 11 }); + + anchorSchemaUID = ethers.solidityPackedKeccak256( + ["string", "address", "bool"], + [ANCHOR_SCHEMA, futureIndexerAddr, false], + ); + propertySchemaUID = ethers.solidityPackedKeccak256( + ["string", "address", "bool"], + [PROPERTY_SCHEMA, futureIndexerAddr, false], + ); + dataSchemaUID = ethers.solidityPackedKeccak256( + ["string", "address", "bool"], + [DATA_SCHEMA, futureIndexerAddr, false], + ); + pinSchemaUID = ethers.solidityPackedKeccak256( + ["string", "address", "bool"], + [PIN_SCHEMA, futureEdgeResolverAddr, true], + ); + tagSchemaUID = ethers.solidityPackedKeccak256( + ["string", "address", "bool"], + [TAG_SCHEMA, futureEdgeResolverAddr, true], + ); + whiteoutSchemaUID = ethers.solidityPackedKeccak256( + ["string", "address", "bool"], + [WHITEOUT_SCHEMA, whiteoutResolverAddr, true], + ); + + // (+0,+1) EdgeResolver behind a proxy — initialize sets PIN/TAG UIDs + partner refs. + edgeResolver = await deployResolverProxy( + "EdgeResolver", + [await eas.getAddress()], + [pinSchemaUID, tagSchemaUID, futureIndexerAddr, await registry.getAddress()], + owner, + ); + expect(await edgeResolver.getAddress()).to.equal(futureEdgeResolverAddr); + + // (+2,+3) EFSIndexer behind a proxy. + indexer = await deployIndexerProxy( + await eas.getAddress(), + anchorSchemaUID, + propertySchemaUID, + dataSchemaUID, + owner, + ); + expect(await indexer.getAddress()).to.equal(futureIndexerAddr); + + // (+4..+9) Register the 6 schemas against their (predicted) resolvers. + await (await registry.register(ANCHOR_SCHEMA, futureIndexerAddr, false)).wait(); + await (await registry.register(PROPERTY_SCHEMA, futureIndexerAddr, false)).wait(); + await (await registry.register(DATA_SCHEMA, futureIndexerAddr, false)).wait(); + await (await registry.register(PIN_SCHEMA, futureEdgeResolverAddr, true)).wait(); + await (await registry.register(TAG_SCHEMA, futureEdgeResolverAddr, true)).wait(); + await (await registry.register(WHITEOUT_SCHEMA, whiteoutResolverAddr, true)).wait(); + + // (+10,+11) WhiteoutResolver behind a proxy. initialize(indexer) reads ANCHOR_SCHEMA_UID + + // getParent — the indexer already exists, so this resolves cleanly. + whiteoutResolver = await deployResolverProxy( + "WhiteoutResolver", + [await eas.getAddress()], + [await indexer.getAddress()], + owner, + ); + expect(await whiteoutResolver.getAddress()).to.equal(whiteoutResolverAddr); + // Self-UID verify gate: the resolver derived the same WHITEOUT UID we registered. + expect(await whiteoutResolver.whiteoutSchemaUID()).to.equal(whiteoutSchemaUID); + expect(await whiteoutResolver.anchorSchemaUID()).to.equal(anchorSchemaUID); + + // Wire the indexer → edgeResolver link so EdgeResolver.onAttest can call propagateContains. + await indexer.wireContracts( + await edgeResolver.getAddress(), + pinSchemaUID, + tagSchemaUID, + ZeroAddress, + ZERO_BYTES32, + ZeroAddress, + ZERO_BYTES32, + await registry.getAddress(), + ); + + // EFSFileView wired WITH the whiteout resolver (3rd ctor arg). + const FileViewFactory = await ethers.getContractFactory("EFSFileView"); + fileView = (await FileViewFactory.deploy( + await indexer.getAddress(), + await edgeResolver.getAddress(), + await whiteoutResolver.getAddress(), + )) as EFSFileView; + await fileView.waitForDeployment(); + + void mirrorSchemaUID; + void propertySchemaUID; + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 1 — per-name whiteout hides one child in a directory listing + // ════════════════════════════════════════════════════════════════════════════ + it("vector 1: per-name whiteout hides exactly one child in a folder listing", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + + // Two file anchors under dir, both placed (PIN) by ALICE so they list as files. Owner views + // [owner, alice] and whites out one child — the canonical per-name hide of a lower lens's + // content (a lens whiting out its OWN placement is the same-lens-override case, vector 4). + const keepAnchor = await createAnchor("keep.txt", dir, dataSchemaUID); + const hideAnchor = await createAnchor("hide.txt", dir, dataSchemaUID); + const dKeep = await mintData(alice); + const dHide = await mintData(alice); + await createPin(keepAnchor, dKeep, alice); + await createPin(hideAnchor, dHide, alice); + + // Sanity: both visible before any whiteout (viewer = [owner, alice]). + let page = await fileView.getDirectoryPageBySchemaAndAddressList( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + "0x", + 10, + ); + expect(page.items.map(i => i.uid)).to.have.members([keepAnchor, hideAnchor]); + + // Owner whites out hide.txt in his own view. + await attestWhiteout(hideAnchor, owner); + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, hideAnchor)).to.equal(true); + + page = await fileView.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [ownerAddr, aliceAddr], "0x", 10); + expect(page.items.map(i => i.uid)).to.deep.equal([keepAnchor]); // exactly one hidden + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 2 — lens-scoped below: Lk's whiteout suppresses a LOWER lens's placement + // ════════════════════════════════════════════════════════════════════════════ + it("vector 2: Lk's whiteout suppresses a lower lens's placement (no fall-through)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const fileAnchor = await createAnchor("doc.txt", dir, dataSchemaUID); + + // ALICE (lower lens) places the file; OWNER (higher lens) has no placement of his own. + const dAlice = await mintData(alice); + await createPin(fileAnchor, dAlice, alice); + + // Viewer = [owner, alice]. Owner whites out the entry → suppresses alice's placement below. + await attestWhiteout(fileAnchor, owner); + + const page = await fileView.getDirectoryPageBySchemaAndAddressList( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + "0x", + 10, + ); + expect(page.items.map(i => i.uid)).to.not.include(fileAnchor); + expect(page.items.length).to.equal(0); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 3 — lens-scoped above transparent: higher lens's positive PIN beats a lower + // lens's whiteout (the item is visible) + // ════════════════════════════════════════════════════════════════════════════ + it("vector 3: higher lens's positive PIN beats a lower lens's whiteout (visible)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const fileAnchor = await createAnchor("doc.txt", dir, dataSchemaUID); + + // ALICE (lower lens) whites the entry out; OWNER (higher lens) places his own content there. + await attestWhiteout(fileAnchor, alice); + const dOwner = await mintData(owner); + await createPin(fileAnchor, dOwner, owner); + + // Viewer = [owner, alice]: owner's positive PIN terminates the scan before alice's whiteout. + const page = await fileView.getDirectoryPageBySchemaAndAddressList( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + "0x", + 10, + ); + expect(page.items.map(i => i.uid)).to.deep.equal([fileAnchor]); // visible + + // Symmetry: with ONLY alice as the lens, her whiteout DOES suppress it. + const aliceOnly = await fileView.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [aliceAddr], "0x", 10); + expect(aliceOnly.items.length).to.equal(0); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 4 — same-lens positive override: a lens's own NEWER PIN beats its own earlier whiteout + // ════════════════════════════════════════════════════════════════════════════ + it("vector 4: a lens's own newer PIN beats its own earlier whiteout (positive-before-whiteout)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const fileAnchor = await createAnchor("doc.txt", dir, dataSchemaUID); + + // Owner first whites it out, THEN places his own content at the same anchor. + await attestWhiteout(fileAnchor, owner); + const dOwner = await mintData(owner); + await createPin(fileAnchor, dOwner, owner); + + // The marker is still live (whiteout was not revoked), but the predicate checks the positive + // PIN FIRST within the lens → visible. + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, fileAnchor)).to.equal(true); + const page = await fileView.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [ownerAddr], "0x", 10); + expect(page.items.map(i => i.uid)).to.deep.equal([fileAnchor]); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 5 — revoke un-hides + // ════════════════════════════════════════════════════════════════════════════ + it("vector 5: revoking the whiteout un-hides the entry", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const fileAnchor = await createAnchor("doc.txt", dir, dataSchemaUID); + const dAlice = await mintData(alice); + await createPin(fileAnchor, dAlice, alice); + + const woUID = await attestWhiteout(fileAnchor, owner); + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, fileAnchor)).to.equal(true); + let page = await fileView.getDirectoryPageBySchemaAndAddressList( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + "0x", + 10, + ); + expect(page.items.length).to.equal(0); + + await revoke(whiteoutSchemaUID, woUID, owner); + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, fileAnchor)).to.equal(false); + + page = await fileView.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [ownerAddr, aliceAddr], "0x", 10); + expect(page.items.map(i => i.uid)).to.deep.equal([fileAnchor]); // alice's placement now falls through + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 6 — idempotent / no-op-safe: whiteout of a path with no lower content + // ════════════════════════════════════════════════════════════════════════════ + it("vector 6: whiteout of a path with no lower content succeeds, resolves empty, no revert", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + // A bare anchor with NO placement under any lens. + const emptyAnchor = await createAnchor("ghost.txt", dir, dataSchemaUID); + + // Whiting out an empty path does not revert (attestWhiteout throws on revert) and marks active. + await attestWhiteout(emptyAnchor, owner); + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, emptyAnchor)).to.equal(true); + + // Re-whiteout the SAME child by the SAME attester: idempotent (no double-push, no revert). + await attestWhiteout(emptyAnchor, owner); + // Discovery list holds the child exactly once (append-once membership guard). + expect(await whiteoutResolver.getChildrenWhitedOutCount(dir, ownerAddr)).to.equal(1n); + + // Listing resolves to empty (the anchor never had a placement anyway). + const page = await fileView.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [ownerAddr], "0x", 10); + expect(page.items.length).to.equal(0); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 7 — cross-lens unaffected: a viewer NOT including Lk still sees the item + // ════════════════════════════════════════════════════════════════════════════ + it("vector 7: a viewer not including the whiteout author still sees the item", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const fileAnchor = await createAnchor("doc.txt", dir, dataSchemaUID); + + // Alice places the file; OWNER whites it out (owner = Lk). + const dAlice = await mintData(alice); + await createPin(fileAnchor, dAlice, alice); + await attestWhiteout(fileAnchor, owner); + + // Viewer = [alice] only (does NOT include owner) → owner's whiteout is invisible → item shows. + const aliceView = await fileView.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [aliceAddr], "0x", 10); + expect(aliceView.items.map(i => i.uid)).to.deep.equal([fileAnchor]); + + // Viewer = [bob] (unrelated) → also unaffected: bob has no placement so nothing lists, but + // owner's whiteout still doesn't apply to bob's lens (no cross-lens leakage either way). + expect(await whiteoutResolver.isWhitedOut(dir, bobAddr, fileAnchor)).to.equal(false); + expect(await whiteoutResolver.isWhitedOut(dir, aliceAddr, fileAnchor)).to.equal(false); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 8 — re-whiteout last-writer-wins: revoking a STALE uid doesn't clear a newer marker + // ════════════════════════════════════════════════════════════════════════════ + it("vector 8: revoking a stale whiteout UID does not clear the marker held by a newer one", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const fileAnchor = await createAnchor("doc.txt", dir, dataSchemaUID); + const dAlice = await mintData(alice); + await createPin(fileAnchor, dAlice, alice); + + // Owner whites it out TWICE on the same (parent, child) slot — the 2nd overwrites the marker. + const woFirst = await attestWhiteout(fileAnchor, owner); + const woSecond = await attestWhiteout(fileAnchor, owner); + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, fileAnchor)).to.equal(true); + // Append-once: the discovery list still holds the child exactly once despite two attests. + expect(await whiteoutResolver.getChildrenWhitedOutCount(dir, ownerAddr)).to.equal(1n); + + // Revoke the STALE (first) UID — the live marker is owned by the second, so it must survive. + await revoke(whiteoutSchemaUID, woFirst, owner); + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, fileAnchor)).to.equal(true); + + // The entry stays hidden under [owner, alice]. + let page = await fileView.getDirectoryPageBySchemaAndAddressList( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + "0x", + 10, + ); + expect(page.items.length).to.equal(0); + + // Now revoke the LIVE (second) UID — the marker clears, the entry un-hides. + await revoke(whiteoutSchemaUID, woSecond, owner); + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, fileAnchor)).to.equal(false); + page = await fileView.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [ownerAddr, aliceAddr], "0x", 10); + expect(page.items.map(i => i.uid)).to.deep.equal([fileAnchor]); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 9 — write-guard rejections (each invalid write reverts with the specific error) + // ════════════════════════════════════════════════════════════════════════════ + describe("vector 9: write-guard rejections", function () { + let root: string; + let dir: string; + let validChild: string; + + beforeEach(async function () { + root = await createAnchor("root", ZERO_BYTES32); + dir = await createAnchor("dir", root); + validChild = await createAnchor("doc.txt", dir, dataSchemaUID); + }); + + it("refUID = 0 → ZeroRef", async function () { + await expect(attestWhiteout(ZERO_BYTES32, owner)).to.be.revertedWithCustomError(whiteoutResolver, "ZeroRef"); + }); + + it("non-empty payload → BadPayload", async function () { + await expect( + attestWhiteout(validChild, owner, { data: enc.encode(["uint256"], [1n]) }), + ).to.be.revertedWithCustomError(whiteoutResolver, "BadPayload"); + }); + + it("non-revocable attestation → NotRevocable", async function () { + await expect(attestWhiteout(validChild, owner, { revocable: false })).to.be.revertedWithCustomError( + whiteoutResolver, + "NotRevocable", + ); + }); + + it("non-zero expirationTime → HasExpiration", async function () { + const future = BigInt((await ethers.provider.getBlock("latest"))!.timestamp) + 100000n; + await expect(attestWhiteout(validChild, owner, { expirationTime: future })).to.be.revertedWithCustomError( + whiteoutResolver, + "HasExpiration", + ); + }); + + it("refUID is a DATA attestation (not an anchor) → SourceNotAnchor", async function () { + const data = await mintData(); + await expect(attestWhiteout(data, owner)).to.be.revertedWithCustomError(whiteoutResolver, "SourceNotAnchor"); + }); + + it("refUID is a PROPERTY attestation → SourceNotAnchor", async function () { + const prop = await mintProperty("hello"); + await expect(attestWhiteout(prop, owner)).to.be.revertedWithCustomError(whiteoutResolver, "SourceNotAnchor"); + }); + + it("refUID is a PIN attestation → SourceNotAnchor", async function () { + const data = await mintData(); + const pin = await createPin(validChild, data, owner); + await expect(attestWhiteout(pin, owner)).to.be.revertedWithCustomError(whiteoutResolver, "SourceNotAnchor"); + }); + + it("refUID is another WHITEOUT attestation → SourceNotAnchor", async function () { + const wo = await attestWhiteout(validChild, owner); + await expect(attestWhiteout(wo, owner)).to.be.revertedWithCustomError(whiteoutResolver, "SourceNotAnchor"); + }); + + it("refUID is a root-level anchor (no parent) → OrphanAnchor", async function () { + // `root` was minted with refUID=0 ⇒ getParent(root)=0 ⇒ root-level ⇒ OrphanAnchor. + await expect(attestWhiteout(root, owner)).to.be.revertedWithCustomError(whiteoutResolver, "OrphanAnchor"); + }); + + it("foreign schema pointed at this resolver → WrongSchema", async function () { + // Register a DIFFERENT schema (non-empty field) against the SAME WhiteoutResolver proxy. EAS + // lets anyone do this; the resolver's onAttest must reject it (schema != self-derived UID). + const foreignField = "uint256 attack"; + await (await registry.register(foreignField, whiteoutResolverAddr, true)).wait(); + const foreignUID = ethers.solidityPackedKeccak256( + ["string", "address", "bool"], + [foreignField, whiteoutResolverAddr, true], + ); + await expect( + attestWhiteout(validChild, owner, { schema: foreignUID, data: enc.encode(["uint256"], [1n]) }), + ).to.be.revertedWithCustomError(whiteoutResolver, "WrongSchema"); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 10 — router negative-terminal: serve not-found, no fall-through to a lower lens + // ════════════════════════════════════════════════════════════════════════════ + describe("vector 10: router negative-terminal", function () { + let router: EFSRouter; + let mirrorResolver: any; + let dirRoot: string; + let fileAnchor: string; + let dir: string; + let childAnchor: string; + let onchainUID: string; + let eas2: EAS; // the isolated-stack EAS (also used by the alias-seed it-block to attest a mirror) + + // The router beforeEach builds a fresh stack INCLUDING a MirrorResolver (the router needs a + // wired MIRROR schema to serve content), the WhiteoutResolver, and a /transports/ tree, then + // seeds a single file placed by ALICE with a web3:// mirror so a non-whited read serves 200. + beforeEach(async function () { + const RegistryFactory = await ethers.getContractFactory("SchemaRegistry"); + const reg2 = await RegistryFactory.deploy(); + await reg2.waitForDeployment(); + const EASFactory = await ethers.getContractFactory("EAS"); + eas2 = (await EASFactory.deploy(await reg2.getAddress())) as EAS; + await eas2.waitForDeployment(); + + // Rebind the suite-level helpers' `eas`/`registry` to this isolated stack for vector 10. + eas = eas2; + registry = reg2; + + const a = ownerAddr; + // Predict each resolver/indexer PROXY address from a FRESHLY read nonce right before its + // deploy (each *Proxy helper = exactly 2 deployer txs: impl, then proxy). Computing offsets + // immediately before each deploy is more robust than one cumulative offset chain. + const edgeNonce = await ethers.provider.getTransactionCount(a); + const edgeAddr = ethers.getCreateAddress({ from: a, nonce: edgeNonce + 1 }); + const mirrorAddr = ethers.getCreateAddress({ from: a, nonce: edgeNonce + 3 }); + const idxAddr = ethers.getCreateAddress({ from: a, nonce: edgeNonce + 5 }); + + anchorSchemaUID = ethers.solidityPackedKeccak256(["string", "address", "bool"], [ANCHOR_SCHEMA, idxAddr, false]); + propertySchemaUID = ethers.solidityPackedKeccak256( + ["string", "address", "bool"], + [PROPERTY_SCHEMA, idxAddr, false], + ); + dataSchemaUID = ethers.solidityPackedKeccak256(["string", "address", "bool"], [DATA_SCHEMA, idxAddr, false]); + pinSchemaUID = ethers.solidityPackedKeccak256(["string", "address", "bool"], [PIN_SCHEMA, edgeAddr, true]); + tagSchemaUID = ethers.solidityPackedKeccak256(["string", "address", "bool"], [TAG_SCHEMA, edgeAddr, true]); + mirrorSchemaUID = ethers.solidityPackedKeccak256( + ["string", "address", "bool"], + ["bytes32 transportDefinition, string uri", mirrorAddr, true], + ); + + edgeResolver = await deployResolverProxy( + "EdgeResolver", + [await eas2.getAddress()], + [pinSchemaUID, tagSchemaUID, idxAddr, await reg2.getAddress()], + owner, + ); + expect(await edgeResolver.getAddress()).to.equal(edgeAddr); + mirrorResolver = await deployResolverProxy("MirrorResolver", [await eas2.getAddress()], [idxAddr, a], owner); + expect(await mirrorResolver.getAddress()).to.equal(mirrorAddr); + indexer = await deployIndexerProxy( + await eas2.getAddress(), + anchorSchemaUID, + propertySchemaUID, + dataSchemaUID, + owner, + ); + expect(await indexer.getAddress()).to.equal(idxAddr); + + // Predict the WHITEOUT proxy fresh (after the 6 non-whiteout registrations), then register + // WHITEOUT against it and deploy it last (its initialize reads the now-existing indexer). + await (await reg2.register(ANCHOR_SCHEMA, idxAddr, false)).wait(); + await (await reg2.register(PROPERTY_SCHEMA, idxAddr, false)).wait(); + await (await reg2.register(DATA_SCHEMA, idxAddr, false)).wait(); + await (await reg2.register(PIN_SCHEMA, edgeAddr, true)).wait(); + await (await reg2.register(TAG_SCHEMA, edgeAddr, true)).wait(); + await (await reg2.register("bytes32 transportDefinition, string uri", mirrorAddr, true)).wait(); + + const woNonce = await ethers.provider.getTransactionCount(a); + // +0 register WHITEOUT, +1 impl, +2 proxy. + const woAddr = ethers.getCreateAddress({ from: a, nonce: woNonce + 2 }); + whiteoutSchemaUID = ethers.solidityPackedKeccak256( + ["string", "address", "bool"], + [WHITEOUT_SCHEMA, woAddr, true], + ); + await (await reg2.register(WHITEOUT_SCHEMA, woAddr, true)).wait(); + + whiteoutResolver = await deployResolverProxy( + "WhiteoutResolver", + [await eas2.getAddress()], + [await indexer.getAddress()], + owner, + ); + expect(await whiteoutResolver.getAddress()).to.equal(woAddr); + expect(await whiteoutResolver.whiteoutSchemaUID()).to.equal(whiteoutSchemaUID); + + await indexer.wireContracts( + await edgeResolver.getAddress(), + pinSchemaUID, + tagSchemaUID, + ZeroAddress, + ZERO_BYTES32, + await mirrorResolver.getAddress(), + mirrorSchemaUID, + await reg2.getAddress(), + ); + + const RouterFactory = await ethers.getContractFactory("EFSRouter"); + router = (await RouterFactory.deploy( + await indexer.getAddress(), + await eas2.getAddress(), + await edgeResolver.getAddress(), + await reg2.getAddress(), + dataSchemaUID, + ZeroAddress, // systemAccount → falls back to indexer.DEPLOYER() (= owner) + await whiteoutResolver.getAddress(), + )) as EFSRouter; + await router.waitForDeployment(); + + // Build /dir/doc.txt with a /transports/onchain ancestry so the web3:// mirror is accepted. + dirRoot = await createAnchor("root", ZERO_BYTES32); + const transportsUID = await createAnchor("transports", dirRoot); + onchainUID = await createAnchor("onchain", transportsUID); + await mirrorResolver.setTransportsAnchor(transportsUID); + + dir = await createAnchor("dir", dirRoot); + fileAnchor = await createAnchor("doc.txt", dir, dataSchemaUID); + // A SECOND file under the SAME folder, for the deep-link-into-a-whited-folder vector. + childAnchor = await createAnchor("child.txt", dir, dataSchemaUID); + + // ALICE (lower lens) places real content with a web3:// mirror so an un-whited read serves 200. + // transportDefinition must be the /transports/onchain anchor (a descendant of /transports/), + // and the URI must carry a parseable address — point it at the EAS contract as a stand-in + // chunk-manager (the router extcodecopies it; non-empty code ⇒ 200). + const dAlice = await mintData(alice); + await createPin(fileAnchor, dAlice, alice); + await eas2.connect(alice).attest({ + schema: mirrorSchemaUID, + data: { + recipient: ZeroAddress, + expirationTime: NO_EXPIRATION, + revocable: true, + refUID: dAlice, + data: enc.encode(["bytes32", "string"], [onchainUID, `web3://${await eas2.getAddress()}`]), + value: 0n, + }, + }); + // Alice (lower lens) also places /dir/child.txt with a web3:// mirror so /dir/child.txt is 200 + // when not whited; the deep-link vector then whiteouts the FOLDER /dir and expects 404. + const dChild = await mintData(alice); + await createPin(childAnchor, dChild, alice); + await eas2.connect(alice).attest({ + schema: mirrorSchemaUID, + data: { + recipient: ZeroAddress, + expirationTime: NO_EXPIRATION, + revocable: true, + refUID: dChild, + data: enc.encode(["bytes32", "string"], [onchainUID, `web3://${await eas2.getAddress()}`]), + value: 0n, + }, + }); + }); + + it("baseline: alice's placement serves (200) when no whiteout is present", async function () { + const res = await router.request(["dir", "doc.txt"], [{ key: "lenses", value: `${aliceAddr}` }]); + expect(res.statusCode).to.equal(200n); + }); + + it("owner's whiteout makes the router serve not-found and NOT fall through to alice", async function () { + // Owner (higher lens) whites the path out. Viewer = [owner, alice]: the negative terminal + // stops the scan at owner, NO fall-through to alice's placement, NO system gap-fill → 404. + await attestWhiteout(fileAnchor, owner); + + const res = await router.request(["dir", "doc.txt"], [{ key: "lenses", value: `${ownerAddr},${aliceAddr}` }]); + expect(res.statusCode).to.equal(404n); + + // Cross-lens unaffected: a viewer of [alice] only still gets 200 (owner's whiteout invisible). + const aliceRes = await router.request(["dir", "doc.txt"], [{ key: "lenses", value: `${aliceAddr}` }]); + expect(aliceRes.statusCode).to.equal(200n); + }); + + it("revoking the router whiteout restores fall-through to the lower lens (un-hide)", async function () { + const woUID = await attestWhiteout(fileAnchor, owner); + let res = await router.request(["dir", "doc.txt"], [{ key: "lenses", value: `${ownerAddr},${aliceAddr}` }]); + expect(res.statusCode).to.equal(404n); + + await revoke(whiteoutSchemaUID, woUID, owner); + res = await router.request(["dir", "doc.txt"], [{ key: "lenses", value: `${ownerAddr},${aliceAddr}` }]); + expect(res.statusCode).to.equal(200n); // alice's placement now falls through + }); + + // ADR-0055 deep-link terminal (spec/04): a whiteout on the FOLDER /dir must 404 a deep link + // GET /dir/child.txt — the per-SEGMENT terminal fires while walking the path, not only on the + // leaf. Without it the leaf check passes (child.txt isn't itself whited) and a lower lens's + // content leaks through. + it("deep link into a whited-out folder 404s (no fall-through to a lower lens's child)", async function () { + // Control: with NO whiteout, the deep link serves alice's child.txt (200). + let res = await router.request(["dir", "child.txt"], [{ key: "lenses", value: `${ownerAddr},${aliceAddr}` }]); + expect(res.statusCode).to.equal(200n); + + // Owner (higher lens) whiteouts the FOLDER /dir itself. + await attestWhiteout(dir, owner); + expect(await whiteoutResolver.isWhitedOut(dirRoot, ownerAddr, dir)).to.equal(true); + + // Viewer [owner, alice]: the per-segment terminal fires at the `dir` segment → 404, with NO + // fall-through to alice's child.txt placement. + res = await router.request(["dir", "child.txt"], [{ key: "lenses", value: `${ownerAddr},${aliceAddr}` }]); + expect(res.statusCode).to.equal(404n); + + // Control: a viewer whose lens list EXCLUDES the whiteout author (alice only) is unaffected → 200. + const aliceRes = await router.request(["dir", "child.txt"], [{ key: "lenses", value: `${aliceAddr}` }]); + expect(aliceRes.statusCode).to.equal(200n); + }); + + // ADR-0055 deep-link terminal for the ALIAS SEED (PR #37 review). A schema/attestation container + // seeds `currentParent` from a root *alias anchor* and starts the path walk at startIdx=1, so the + // per-segment loop never checks segment 0 (the alias). A viewer who whiteouts the alias anchor must + // still 404 a deep link `//child.txt` — not descend through the whited alias into lower content. + it("deep link through a whited-out schema-alias anchor 404s (alias seed, segment 0)", async function () { + // Build a schema-alias container at root: an anchor whose NAME is the lowercase 0x-hex of a real + // registered schema UID (DATA's), so the router classifies the top segment as a schema and seeds + // from this alias (startIdx=1). Put alice's (lower-lens) child under it, with a web3:// mirror. + const aliasAnchor = await createAnchor(dataSchemaUID.toLowerCase(), dirRoot); // root-child alias + const aliasChild = await createAnchor("child.txt", aliasAnchor, dataSchemaUID); + const dAlias = await mintData(alice); + await createPin(aliasChild, dAlias, alice); + await eas2.connect(alice).attest({ + schema: mirrorSchemaUID, + data: { + recipient: ZeroAddress, + expirationTime: NO_EXPIRATION, + revocable: true, + refUID: dAlias, + data: enc.encode(["bytes32", "string"], [onchainUID, `web3://${await eas2.getAddress()}`]), + value: 0n, + }, + }); + + // Control: no whiteout → the deep link //child.txt serves alice's content (200). + let res = await router.request( + [dataSchemaUID, "child.txt"], + [{ key: "lenses", value: `${ownerAddr},${aliceAddr}` }], + ); + expect(res.statusCode).to.equal(200n); + + // owner (higher lens) whiteouts the ALIAS anchor itself (a root child, segment 0). + await attestWhiteout(aliasAnchor, owner); + expect(await whiteoutResolver.isWhitedOut(dirRoot, ownerAddr, aliasAnchor)).to.equal(true); + + // Viewer [owner, alice]: the alias-seed terminal fires at segment 0 → 404, no fall-through. + res = await router.request([dataSchemaUID, "child.txt"], [{ key: "lenses", value: `${ownerAddr},${aliceAddr}` }]); + expect(res.statusCode).to.equal(404n); + + // Control: a viewer whose lens list EXCLUDES the whiteout author (alice only) → 200. + const aliceOnly = await router.request([dataSchemaUID, "child.txt"], [{ key: "lenses", value: `${aliceAddr}` }]); + expect(aliceOnly.statusCode).to.equal(200n); + }); + + // ADR-0055 folder re-add fix: the per-SEGMENT intermediate-folder whiteout terminal uses the + // LISTING positive (PIN OR folder visibility TAG), NOT the PIN-only resolution positive. Folders + // are made visible by a visibility TAG (ADR-0038; upload emits `TAG(definition=dataSchemaUID, + // refUID=folder)`), so a lens that whiteouts an inherited folder and then RE-ADDS it (by uploading + // its own child, which re-emits the folder's visibility TAG) makes the folder TRAVERSABLE again + // for that lens — listings show it via `EFSFileView._isItemWhitedOutForListing`, and the router + // must traverse it consistently. Regression vector: before this fix the intermediate `dir` segment + // used a PIN-only positive that never saw the folder TAG, so the router still 404'd the owner's + // own re-added child. The leaf DATA read stays PIN-only (a folder TAG must NOT un-gate it). + it("folder whiteout + same-lens visibility-TAG re-add → owner's own re-added child traverses (200); lower lens's non-whited child still shows (200); leaf whiteout still 404s", async function () { + // alice (lower lens) already placed /dir/child.txt in the beforeEach (her visibility TAG on /dir + // + her file). owner (higher lens) whiteouts the FOLDER /dir, THEN "uploads" /dir/own.txt: + // owner re-emits his OWN visibility TAG on /dir (the re-add) and places his own DATA at own.txt + // with a web3:// mirror so the leaf serves 200. + const ownChildAnchor = await createAnchor("own.txt", dir, dataSchemaUID); + + await attestWhiteout(dir, owner); // owner whiteouts the inherited folder /dir + expect(await whiteoutResolver.isWhitedOut(dirRoot, ownerAddr, dir)).to.equal(true); + + // owner RE-ADDS /dir via his own folder visibility TAG (definition=dataSchemaUID, refUID=dir), + // exactly what the ancestor-walk visibility-TAG step of an upload emits (ADR-0038 / overview §7). + await createTag(dataSchemaUID, dir, owner); + // owner places his own DATA at /dir/own.txt with a web3:// mirror (so the leaf can serve 200). + const dOwn = await mintData(owner); + await createPin(ownChildAnchor, dOwn, owner); + await eas.connect(owner).attest({ + schema: mirrorSchemaUID, + data: { + recipient: ZeroAddress, + expirationTime: NO_EXPIRATION, + revocable: true, + refUID: dOwn, + data: enc.encode(["bytes32", "string"], [onchainUID, `web3://${await eas.getAddress()}`]), + value: 0n, + }, + }); + + // GET /dir/own.txt (owner's re-added child) → 200: owner re-asserted /dir via his visibility + // TAG, so the intermediate `dir` segment is traversable for owner (listing positive), and the + // leaf own.txt has owner's placement PIN. + const ownRes = await router.request(["dir", "own.txt"], [{ key: "lenses", value: `${ownerAddr},${aliceAddr}` }]); + expect(ownRes.statusCode).to.equal(200n); + + // GET /dir/other.txt (alice's child.txt — owner did NOT place it under owner) → 200, and this + // is the CORRECT overlayfs semantics, not 404. Once owner re-adds /dir (its visibility TAG), the + // intermediate `dir` segment is traversable for owner, so the walk descends to the leaf. The + // leaf DATA read is PIN-only over [owner, alice], keyed (parent=/dir, child=child.txt): owner has + // no placement at child.txt AND owner's only whiteout is on the FOLDER /dir (keyed on (dirRoot, + // owner, dir)) — owner never whited child.txt itself — so owner is NOT a leaf negative terminal + // and the scan falls through to alice's placement. Whiteout-then-readd of a DIRECTORY exposes the + // lower layer's children that were not INDIVIDUALLY whited; to also hide child.txt owner would + // whiteout child.txt specifically. (The leaf terminal stays PIN-only — the folder TAG did not + // un-gate it; it stayed gated and simply found alice's lower-lens PIN.) + const otherRes = await router.request( + ["dir", "child.txt"], + [{ key: "lenses", value: `${ownerAddr},${aliceAddr}` }], + ); + expect(otherRes.statusCode).to.equal(200n); + + // To actually hide alice's child under owner's lens, owner whiteouts the LEAF child.txt itself. + // Now the leaf PIN-only terminal fires at owner (negative terminal on (parent=/dir, child.txt)) + // → 404, with NO fall-through to alice — the leaf gating is intact and unweakened by this fix. + await attestWhiteout(childAnchor, owner); + const hiddenRes = await router.request( + ["dir", "child.txt"], + [{ key: "lenses", value: `${ownerAddr},${aliceAddr}` }], + ); + expect(hiddenRes.statusCode).to.equal(404n); + + // Control: a viewer of [alice] only (no whiteout in her lens) sees alice's child.txt → 200. + const aliceOnly = await router.request(["dir", "child.txt"], [{ key: "lenses", value: `${aliceAddr}` }]); + expect(aliceOnly.statusCode).to.equal(200n); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 11 — getFilesAtPath negative terminal (view/router consistency, ADR-0055) + // The single-anchor file reader applies the SAME (parent, lens, anchor) whiteout terminal the + // router's `_findDataAtPath` does, so a viewer can't see DATA via the view that the router 404s. + // ════════════════════════════════════════════════════════════════════════════ + describe("vector 11: getFilesAtPath negative terminal (view/router consistency)", function () { + it("a whited anchor returns empty from getFilesAtPath for the lens that whited it; an unaffected lens still sees the file", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const fileAnchor = await createAnchor("doc.txt", dir, dataSchemaUID); + + // ALICE places real content at the anchor (her DATA via her placement PIN). + const dAlice = await mintData(alice); + await createPin(fileAnchor, dAlice, alice); + + // Baseline: viewer [owner, alice] resolves alice's DATA (owner has no placement of his own). + let page = await fileView.getFilesAtPath(fileAnchor, [ownerAddr, aliceAddr], dataSchemaUID, "0x", 10); + expect(page.items.map(i => i.uid)).to.deep.equal([dAlice]); + + // Owner (higher lens) whites the anchor out. Viewer [owner, alice]: the negative terminal stops + // the scan at owner — NO fall-through to alice's placement → empty (router would 404 here too). + await attestWhiteout(fileAnchor, owner); + page = await fileView.getFilesAtPath(fileAnchor, [ownerAddr, aliceAddr], dataSchemaUID, "0x", 10); + expect(page.items.length).to.equal(0); + expect(page.nextCursor).to.equal("0x"); + + // Cross-lens unaffected: a lens that did NOT white it out (alice alone) still sees the file. + const aliceOnly = await fileView.getFilesAtPath(fileAnchor, [aliceAddr], dataSchemaUID, "0x", 10); + expect(aliceOnly.items.map(i => i.uid)).to.deep.equal([dAlice]); + }); + + it("same-lens positive PIN beats that lens's own earlier whiteout (positive-before-whiteout)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const fileAnchor = await createAnchor("doc.txt", dir, dataSchemaUID); + + // Owner whites the anchor out, THEN places his own content at the same anchor (marker stays live). + await attestWhiteout(fileAnchor, owner); + const dOwner = await mintData(owner); + await createPin(fileAnchor, dOwner, owner); + + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, fileAnchor)).to.equal(true); + // The terminal checks the positive PIN FIRST within the lens → owner's DATA is served. + const page = await fileView.getFilesAtPath(fileAnchor, [ownerAddr], dataSchemaUID, "0x", 10); + expect(page.items.map(i => i.uid)).to.deep.equal([dOwner]); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 12 — whiteout participates in the FILTERED listing too (ADR-0055 + ADR-0054) + // The ADR makes whiteout load-bearing in BOTH plain and filtered listings. Here the view is wired + // WITH a real WhiteoutResolver (the suite harness), so we assert: (a) whiteout drops an item from + // a FILTERED listing, and (b) an item dropped by BOTH a tag-exclusion AND a whiteout is skipped + // exactly once (no double-decrement / pagination corruption). + // ════════════════════════════════════════════════════════════════════════════ + describe("vector 12: whiteout in getDirectoryPageFiltered (filtered walker)", function () { + let excludeTagDef: string; + + beforeEach(async function () { + // Registered after all suite deploys (in the test body's beforeEach) so it cannot shift any + // CREATE nonce the suite's address predictions depend on. A standalone schema UID is a valid + // bytes32 exclude-tag definition (matches EFSFileViewFiltered.test.ts). + const tx = await registry.register("string whiteoutFilterLabel", ZeroAddress, false); + excludeTagDef = (await tx.wait())!.logs[0].topics[1]; + }); + + it("(a) whiteout drops an item from a FILTERED listing (zero exclude tags ⇒ whiteout is the only filter)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + + // Two files placed by ALICE; OWNER views [owner, alice] and whites out one. + const keepAnchor = await createAnchor("keep.txt", dir, dataSchemaUID); + const hideAnchor = await createAnchor("hide.txt", dir, dataSchemaUID); + const dKeep = await mintData(alice); + const dHide = await mintData(alice); + await createPin(keepAnchor, dKeep, alice); + await createPin(hideAnchor, dHide, alice); + + await attestWhiteout(hideAnchor, owner); + + // Empty exclude policy → the ONLY drop is the whiteout (proves the filtered walker honors it). + const page = await fileView.getDirectoryPageFiltered( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + [], // no exclude tags + [], + "0x", + 10, + ); + expect(page.items.map(i => i.uid)).to.deep.equal([keepAnchor]); + expect(page.nextCursor).to.equal("0x"); + }); + + it("(b) an item dropped by BOTH a tag-exclusion AND a whiteout is skipped exactly once (no pagination corruption)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + + // Three files placed by ALICE: `both` is BOTH whited-out AND tag-excluded; `taggedOnly` is only + // tag-excluded; `clean` survives. Owner views [owner, alice]. + const bothAnchor = await createAnchor("both.txt", dir, dataSchemaUID); + const taggedAnchor = await createAnchor("tagged.txt", dir, dataSchemaUID); + const cleanAnchor = await createAnchor("clean.txt", dir, dataSchemaUID); + const dBoth = await mintData(alice); + const dTagged = await mintData(alice); + const dClean = await mintData(alice); + await createPin(bothAnchor, dBoth, alice); + await createPin(taggedAnchor, dTagged, alice); + await createPin(cleanAnchor, dClean, alice); + + // Exclude tag on the DATA (file branch bucket = dataSchemaUID), by alice (a viewed lens). + await createTag(excludeTagDef, dBoth, alice, 1n); + await createTag(excludeTagDef, dTagged, alice, 1n); + // AND owner whites out `both` — the doubly-suppressed item. + await attestWhiteout(bothAnchor, owner); + + // One-shot page: only clean.txt survives. `both` being suppressed by two independent predicates + // must not double-decrement the slot accounting or corrupt the walk. + const page = await fileView.getDirectoryPageFiltered( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + [excludeTagDef], + [0n], + "0x", + 10, + ); + expect(page.items.map(i => i.uid)).to.deep.equal([cleanAnchor]); + expect(page.nextCursor).to.equal("0x"); + + // Paginate at size 1 across the whole source: every surviving item is yielded exactly once and + // the doubly-suppressed item is never emitted (skip-exactly-once across page boundaries). + const seen: string[] = []; + let cursor = "0x"; + let calls = 0; + while (true) { + calls++; + if (calls > 20) throw new Error("filtered pagination did not terminate"); + const p = await fileView.getDirectoryPageFiltered( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + [excludeTagDef], + [0n], + cursor, + 1, + ); + for (const it of p.items) seen.push(it.uid); + if (p.nextCursor === "0x") break; + cursor = p.nextCursor; + } + expect(seen).to.deep.equal([cleanAnchor]); // exactly one survivor, emitted once + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 13 — folder whiteout hides a FOLDER from a listing (per-name on a folder anchor) + // A folder is visible via a visibility TAG (definition = dataSchemaUID, ADR-0038). A per-name + // whiteout on the folder anchor drops it from the listing, same as a file. + // ════════════════════════════════════════════════════════════════════════════ + it("vector 13: a per-name whiteout hides a FOLDER (visibility-TAG-listed) from the listing", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + + // Two generic FOLDERS under dir, made visible by ALICE's visibility TAGs (definition=dataSchemaUID). + const keepFolder = await createAnchor("keep", dir, ZERO_BYTES32); + const hideFolder = await createAnchor("hide", dir, ZERO_BYTES32); + await createTag(dataSchemaUID, keepFolder, alice); + await createTag(dataSchemaUID, hideFolder, alice); + + // Baseline: both folders list for viewer [owner, alice] (phase 0). + let page = await fileView.getDirectoryPageBySchemaAndAddressList( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + "0x", + 10, + ); + expect(page.items.map(i => i.uid)).to.have.members([keepFolder, hideFolder]); + + // OWNER whites out the `hide` folder anchor → dropped from his view. + await attestWhiteout(hideFolder, owner); + page = await fileView.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [ownerAddr, aliceAddr], "0x", 10); + expect(page.items.map(i => i.uid)).to.deep.equal([keepFolder]); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 14 — folder whiteout + same-lens re-add via a visibility TAG → folder un-hidden (THE FIX) + // The folder-fix: a lens's own NEWER folder-visibility TAG beats that lens's own earlier whiteout + // in LISTINGS (folder positive assertion is a visibility TAG — Shape B — not a PIN). + // ════════════════════════════════════════════════════════════════════════════ + it("vector 14: folder whiteout + same-lens visibility-TAG re-add → folder visible (folder-fix)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const folder = await createAnchor("sub", dir, ZERO_BYTES32); + + // OWNER whites out the folder, THEN re-asserts its visibility with his OWN TAG (marker stays live). + await attestWhiteout(folder, owner); + await createTag(dataSchemaUID, folder, owner); + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, folder)).to.equal(true); + + // The folder-fix: the listing predicate's positive terminal now includes the visibility TAG, so + // owner's own TAG beats his own whiteout → the folder is VISIBLE for [owner]. + const page = await fileView.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [ownerAddr], "0x", 10); + expect(page.items.map(i => i.uid)).to.deep.equal([folder]); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 15 — plain phase-1 scan budget under mass whiteout (ADR-0055 / ADR-0036) + // The whited-out skip in `getDirectoryPageBySchemaAndAddressList` phase 1 DROPS items without + // consuming a result slot. Without a per-call scan budget, a directory with many hidden direct + // files would loop the entire remaining phase-1 source in ONE eth_call. This vector deploys a + // testable view with a tiny budget (4), whites out every direct file, and asserts: the first + // call returns a BOUNDED page (≤ budget scanned) with a NON-EMPTY cursor (forward progress), + // and paginating to exhaustion TERMINATES (no infinite loop, no full-source scan in one call). + // Fails if the phase-1 budget guard is absent: the first call would return an empty cursor (or + // never return) instead of a bounded one. + // ════════════════════════════════════════════════════════════════════════════ + it("vector 15: plain phase-1 listing scan is budget-bounded when many direct files are whited out", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + + // 6 direct files, all placed by ALICE (so they qualify in phase 1) and all whited out by OWNER. + const N = 6; + const fileAnchors: string[] = []; + for (let i = 0; i < N; i++) { + const fa = await createAnchor(`f${i}.txt`, dir, dataSchemaUID); + const d = await mintData(alice); + await createPin(fa, d, alice); + await attestWhiteout(fa, owner); // owner (higher lens) hides every direct file + fileAnchors.push(fa); + } + + // Testable view with a tiny per-call budget (4), wired to the SAME stack incl. the whiteout + // resolver — so the phase-1 budget guard can trip with a handful of seeded items. + const TestableFactory = await ethers.getContractFactory("EFSFileViewTestable"); + const tv = (await TestableFactory.deploy( + await indexer.getAddress(), + await edgeResolver.getAddress(), + await whiteoutResolver.getAddress(), + 4n, // small budget + )) as unknown as EFSFileView; + await tv.waitForDeployment(); + + // Sanity: with the whiteout resolver wired, the production view (budget 2048) returns 0 items and + // a CLEAN cursor (source < budget, so it fully exhausts in one call). + const full = await fileView.getDirectoryPageBySchemaAndAddressList( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + "0x", + 10, + ); + expect(full.items.length).to.equal(0); + expect(full.nextCursor).to.equal("0x"); + + // First testable call: maxItems=10, budget=4 → budget hit before source exhausted → 0 items but + // a NON-EMPTY cursor (bounded page, forward progress). This is the guard the fix adds. + const first = await tv.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [ownerAddr, aliceAddr], "0x", 10); + expect(first.items.length).to.equal(0); + expect(first.nextCursor).to.not.equal("0x"); // budget hit, source not exhausted → cursor emitted + + // Paginate to exhaustion: each call is budget-bounded; the loop MUST terminate with an empty + // cursor and zero items (all whited out), proving no full-source scan and no infinite loop. + let cursor = first.nextCursor; + let calls = 1; + let totalItems = 0; + while (cursor !== "0x") { + if (calls > 20) throw new Error("budget pagination did not terminate"); + const page = await tv.getDirectoryPageBySchemaAndAddressList( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + cursor, + 10, + ); + totalItems += page.items.length; + cursor = page.nextCursor; + calls++; + } + expect(totalItems).to.equal(0); // every direct file whited out → nothing surfaces + expect(calls).to.be.greaterThan(1); // proves it took multiple budget-bounded calls (no one-shot scan) + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 16 — whiteout honored by the attester-scoped getDirectoryPageByAddressList (P2 fix) + // The schema-AGNOSTIC, attester-scoped directory view returns a fixed indexer page. Before the fix + // it returned `_buildFileSystemItems(...)` directly, with NO whiteout filter — so a child whited + // out by a higher lens still surfaced through this exposed API even though the schema-aware listings + // suppressed it (an ADR-0055 viewer-sovereignty hole). The fix applies the same + // `_isItemWhitedOutForListing` predicate (DATA_SCHEMA_UID for both the PIN positive and the folder + // visibility-TAG positive, matching standard browsing), dropping whited children from the page while + // leaving the indexer's source cursor (the page's nextCursor) untouched. + // ════════════════════════════════════════════════════════════════════════════ + it("vector 16: getDirectoryPageByAddressList honors whiteout (drops a higher-lens-whited child; control lens still sees it)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + + // ALICE publishes a child file under dir (creates the file anchor + places her DATA via a PIN), so + // `_containsAttestations[hideAnchor][alice]` is set and the child qualifies for an alice-scoped walk. + const keepAnchor = await createAnchor("keep.txt", dir, dataSchemaUID); + const hideAnchor = await createAnchor("hide.txt", dir, dataSchemaUID); + const dKeep = await mintData(alice); + const dHide = await mintData(alice); + await createPin(keepAnchor, dKeep, alice); + await createPin(hideAnchor, dHide, alice); + + // Baseline: viewer [owner, alice] sees both children before any whiteout. + let [items] = await fileView.getDirectoryPageByAddressList(dir, [ownerAddr, aliceAddr], 0, 10); + expect(items.map(i => i.uid)).to.have.members([keepAnchor, hideAnchor]); + + // OWNER (higher lens) whites out hide.txt. Viewer [owner, alice]: owner's whiteout terminates the + // per-name scan (no PIN / visibility TAG of his own re-asserts it) → the child is DROPPED. + await attestWhiteout(hideAnchor, owner); + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, hideAnchor)).to.equal(true); + [items] = await fileView.getDirectoryPageByAddressList(dir, [ownerAddr, aliceAddr], 0, 10); + expect( + items.map(i => i.uid), + "higher-lens whiteout drops the child from the attester-scoped view", + ).to.deep.equal([keepAnchor]); + + // Control: a viewer whose lens list EXCLUDES the whiteout author (alice only) is unaffected — alice's + // own PIN is the positive terminal, so both children still show. + const [aliceItems] = await fileView.getDirectoryPageByAddressList(dir, [aliceAddr], 0, 10); + expect( + aliceItems.map(i => i.uid), + "control lens (excludes owner) still sees the child", + ).to.have.members([keepAnchor, hideAnchor]); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 17 — the folder-visibility-TAG positive is gated to FOLDER anchors (P2 fix) + // The folder re-add fix (vector 14) lets a lens's own visibility TAG beat its own whiteout in a + // listing. That positive must apply ONLY to generic folder anchors (forSchema == 0). On a FILE anchor + // (forSchema == dataSchemaUID), the only valid positive re-assertion is a placement PIN — matching the + // PIN-gated resolution path. Without the gate, a same-lens TAG (definition = dataSchemaUID) on a + // whited-out FILE anchor would un-hide it in directory listings while `getFilesAtPath` / the router + // still return it deleted — a listing/resolution inconsistency. This vector reproduces that exploit + // shape and asserts the file STAYS hidden in BOTH listing views, AND that resolution agrees. + // ════════════════════════════════════════════════════════════════════════════ + it("vector 17: a same-lens visibility-TAG on a whited-out FILE anchor does NOT un-hide it (TAG positive is folder-only)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + + // A lower-lens FILE: alice's DATA placed at file.txt (forSchema == dataSchemaUID ⇒ a file, not a folder). + const fileAnchor = await createAnchor("file.txt", dir, dataSchemaUID); + const d = await mintData(alice); + await createPin(fileAnchor, d, alice); + + // OWNER whites out the file, THEN attempts the exploit: a visibility TAG (definition = dataSchemaUID) + // on the FILE anchor — the same shape that legitimately re-adds a FOLDER (vector 14). + await attestWhiteout(fileAnchor, owner); + await createTag(dataSchemaUID, fileAnchor, owner); + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, fileAnchor)).to.equal(true); + + // The TAG positive is folder-gated → it does NOT count on a file anchor → owner's whiteout stands. + // (1) Schema-aware listing (phase-1 file walk) for [owner, alice]: file stays hidden. + const schemaPage = await fileView.getDirectoryPageBySchemaAndAddressList( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + "0x", + 10, + ); + expect( + schemaPage.items.map(i => i.uid), + "file stays hidden in the schema-aware listing", + ).to.deep.equal([]); + + // (2) Schema-agnostic attester-scoped listing: same — the TAG must not resurrect the file. + const [mixedItems] = await fileView.getDirectoryPageByAddressList(dir, [ownerAddr, aliceAddr], 0, 10); + expect( + mixedItems.map(i => i.uid), + "file stays hidden in the attester-scoped listing", + ).to.deep.equal([]); + + // (3) Resolution parity: getFilesAtPath at the FILE anchor (where the placement PIN lives — calling it + // with `dir` would trivially return empty since no DATA is pinned at the folder, so it would prove + // nothing). It is PIN-gated, so for [owner, alice] owner's whiteout terminates the scan (his + // folder-TAG does NOT un-gate a DATA read) → deleted; the control lens [alice] still resolves it. + const deletedForViewer = await fileView.getFilesAtPath(fileAnchor, [ownerAddr, aliceAddr], dataSchemaUID, "0x", 10); + expect( + deletedForViewer.items.map(i => i.uid), + "getFilesAtPath agrees the file is deleted for [owner, alice]", + ).to.deep.equal([]); + const resolvedForAlice = await fileView.getFilesAtPath(fileAnchor, [aliceAddr], dataSchemaUID, "0x", 10); + expect( + resolvedForAlice.items.length, + "getFilesAtPath still resolves the file for the control lens [alice] (her own PIN)", + ).to.equal(1); + + // Control: a viewer EXCLUDING the whiteout author (alice only) still sees her file via her own PIN. + const alonePage = await fileView.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [aliceAddr], "0x", 10); + expect( + alonePage.items.map(i => i.uid), + "control lens (excludes owner) still sees the file", + ).to.deep.equal([fileAnchor]); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 18 — getFilesAtPath: a MID-STACK whiteout suppresses lenses below it, even with a HIGHER + // visible lens. The old all-or-nothing pre-gate returned "not whited" on the higher positive PIN, and + // the cursor loop then still emitted the LOWER lens's DATA. The negative terminal now lives IN the + // loop: higher positives are kept; the walk STOPS at the first whiteout; lower lenses are suppressed, + // and the cursor is forced terminal so a later page can't resume past it (ADR-0055 lens precedence). + // ════════════════════════════════════════════════════════════════════════════ + it("vector 18: getFilesAtPath stops at a mid-stack whiteout (higher positive kept, lower lens suppressed)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const fileAnchor = await createAnchor("file.txt", dir, dataSchemaUID); + + // Three lenses placing/suppressing at the SAME file anchor: owner (higher) PINs DATA-H, alice (lower) + // PINs DATA-A, bob (middle) whites it out. + const dataOwner = await mintData(owner); + const dataAlice = await mintData(alice); + await createPin(fileAnchor, dataOwner, owner); + await createPin(fileAnchor, dataAlice, alice); + await attestWhiteout(fileAnchor, bob); + + // Viewer stack [owner, bob, alice]: owner's PIN is ABOVE bob's whiteout (kept); bob's whiteout + // suppresses alice (strictly below) → result is owner's DATA ONLY, and the cursor is terminal. + const page = await fileView.getFilesAtPath(fileAnchor, [ownerAddr, bobAddr, aliceAddr], dataSchemaUID, "0x", 10); + expect( + page.items.map(i => i.uid), + "higher positive kept; lower lens suppressed by the mid-stack whiteout", + ).to.deep.equal([dataOwner]); + expect(page.nextCursor, "cursor terminal after the whiteout cutoff (no resume into lower lenses)").to.equal("0x"); + + // Control without the deleter: [owner, alice] surfaces BOTH placements (no whiteout in the stack). + const both = await fileView.getFilesAtPath(fileAnchor, [ownerAddr, aliceAddr], dataSchemaUID, "0x", 10); + expect( + both.items.map(i => i.uid), + "no whiteout in the stack → both lenses' placements surface", + ).to.have.members([dataOwner, dataAlice]); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 19 — getActiveWhiteout exposes the live UID for un-delete (no log replay) + // The un-delete path is `eas.revoke(WHITEOUT_SCHEMA, whiteoutUID)` (specs/04 §8f). A reloaded client + // knows the (parent, attester, child) slot but not the UID; getActiveWhiteout returns it on-chain. + // After a re-whiteout it must return the CURRENT UID, since revoking the stale one is an intentional + // no-op (vector 8). 0 when none is live. + // ════════════════════════════════════════════════════════════════════════════ + it("vector 19: getActiveWhiteout returns the live UID (0 when none; the NEW UID after re-whiteout)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const fileAnchor = await createAnchor("file.txt", dir, dataSchemaUID); + + // None yet → 0 (the slot a reloaded client would query). + expect(await whiteoutResolver.getActiveWhiteout(dir, ownerAddr, fileAnchor)).to.equal(ZERO_BYTES32); + + // Attest → the getter returns the live UID, recoverable from the slot alone (no event-log replay). + const uid1 = await attestWhiteout(fileAnchor, owner); + expect(await whiteoutResolver.getActiveWhiteout(dir, ownerAddr, fileAnchor)).to.equal(uid1); + + // Re-whiteout the SAME slot → the getter returns the NEW UID; the stale uid1 would no-op on revoke. + const uid2 = await attestWhiteout(fileAnchor, owner); + expect(uid2).to.not.equal(uid1); + expect(await whiteoutResolver.getActiveWhiteout(dir, ownerAddr, fileAnchor)).to.equal(uid2); + + // Un-delete with the getter's UID → slot cleared back to 0 (the documented revoke path works). + await revoke(whiteoutSchemaUID, uid2, owner); + expect(await whiteoutResolver.getActiveWhiteout(dir, ownerAddr, fileAnchor)).to.equal(ZERO_BYTES32); + expect(await whiteoutResolver.isWhitedOut(dir, ownerAddr, fileAnchor)).to.equal(false); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 20 — a crafted getFilesAtPath cursor cannot BYPASS a mid-stack whiteout terminal + // The per-lens terminal only fires for lenses at index >= the cursor. A hand-crafted opaque cursor + // that resumes PAST the whiteout lens would otherwise skip the terminal and serve the lower lens's + // DATA (the router, cursorless, would 404). The skipped-prefix guard re-evaluates [0, cursor) and + // returns empty when a skipped lens is a whiteout terminal. + // ════════════════════════════════════════════════════════════════════════════ + it("vector 20: a fabricated cursor cannot resume past a whiteout terminal in getFilesAtPath", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root); + const fileAnchor = await createAnchor("file.txt", dir, dataSchemaUID); + + // Stack [owner(PIN), bob(whiteout), alice(PIN)] at the same file anchor. + const dataOwner = await mintData(owner); + const dataAlice = await mintData(alice); + await createPin(fileAnchor, dataOwner, owner); + await createPin(fileAnchor, dataAlice, alice); + await attestWhiteout(fileAnchor, bob); + const stack = [ownerAddr, bobAddr, aliceAddr]; + + // Baseline (empty cursor): the walk hits bob's terminal at index 1 → owner's DATA only, terminal cursor. + const fresh = await fileView.getFilesAtPath(fileAnchor, stack, dataSchemaUID, "0x", 10); + expect( + fresh.items.map(i => i.uid), + "fresh walk: owner kept, alice suppressed", + ).to.deep.equal([dataOwner]); + expect(fresh.nextCursor, "fresh walk terminates the cursor at the whiteout").to.equal("0x"); + + // Attack: a fabricated cursor `abi.encode(2)` resumes at alice (index 2), skipping bob's terminal at + // index 1. Without the skipped-prefix guard this returns alice's DATA; with it, the guard re-checks + // [0,2), finds bob's whiteout (no own PIN) → terminal empty page, no fall-through. + const craftedCursor = ethers.AbiCoder.defaultAbiCoder().encode(["uint256"], [2]); + const attacked = await fileView.getFilesAtPath(fileAnchor, stack, dataSchemaUID, craftedCursor, 10); + expect( + attacked.items.map(i => i.uid), + "fabricated cursor cannot bypass the whiteout terminal (no lower-lens DATA leaks)", + ).to.deep.equal([]); + expect(attacked.nextCursor, "bypassed-whiteout page is terminal").to.equal("0x"); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 21 — listing a folder that is ITSELF whited out returns an empty page (router parity) + // The per-item filters drop whited CHILDREN, but if the FOLDER BEING LISTED is whited out by a + // higher lens (and not re-added), the whole page must be suppressed — otherwise EFSFileView would + // list lower-lens children while EFSRouter 404s a deep link into the same folder. Applies to all + // three lens-scoped listing entrypoints. + // ════════════════════════════════════════════════════════════════════════════ + it("vector 21: listing a whited-out folder returns empty across all listing entrypoints (router parity)", async function () { + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root, ZERO_BYTES32); // a generic folder under root + const fileAnchor = await createAnchor("child.txt", dir, dataSchemaUID); + + // alice (lower lens) places a child under /dir and tags /dir visible (so it lists when not whited). + const dAlice = await mintData(alice); + await createPin(fileAnchor, dAlice, alice); + await createTag(dataSchemaUID, dir, alice); // alice's folder-visibility TAG on /dir + + // Baseline: viewer [owner, alice] sees /dir's child before any whiteout. + let schemaPage = await fileView.getDirectoryPageBySchemaAndAddressList( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + "0x", + 10, + ); + expect( + schemaPage.items.map(i => i.uid), + "baseline lists the child", + ).to.deep.equal([fileAnchor]); + + // owner (higher lens) whiteouts the FOLDER /dir itself (a child of root). + await attestWhiteout(dir, owner); + expect(await whiteoutResolver.isWhitedOut(root, ownerAddr, dir)).to.equal(true); + + // Now ALL three listing entrypoints return empty for [owner, alice] — the listed folder is whited. + schemaPage = await fileView.getDirectoryPageBySchemaAndAddressList( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + "0x", + 10, + ); + expect( + schemaPage.items.map(i => i.uid), + "schema-aware listing of a whited folder is empty", + ).to.deep.equal([]); + + const [mixedItems] = await fileView.getDirectoryPageByAddressList(dir, [ownerAddr, aliceAddr], 0, 10); + expect( + mixedItems.map(i => i.uid), + "attester-scoped listing of a whited folder is empty", + ).to.deep.equal([]); + + const filtered = await fileView.getDirectoryPageFiltered( + dir, + dataSchemaUID, + [ownerAddr, aliceAddr], + [], + [], + "0x", + 10, + ); + expect( + filtered.items.map(i => i.uid), + "filtered listing of a whited folder is empty", + ).to.deep.equal([]); + + // Control: a viewer EXCLUDING the whiteout author (alice only) still lists the child. + const alonePage = await fileView.getDirectoryPageBySchemaAndAddressList(dir, dataSchemaUID, [aliceAddr], "0x", 10); + expect( + alonePage.items.map(i => i.uid), + "control lens (excludes owner) still lists the child", + ).to.deep.equal([fileAnchor]); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // VECTOR 22 — the parent-folder terminal uses the LISTING schema (non-DATA folder re-add) + // The parent-folder check must use the same visibility-TAG definition as the per-item walker — the + // listing's `anchorSchema` — not a hard-coded dataSchemaUID. Otherwise a folder whited out and then + // re-added under a NON-DATA schema (`TAG(definition=anchorSchema, refUID=folder)`) is still treated as + // whited and the whole page returns empty, contradicting the per-item re-add positive. + // ════════════════════════════════════════════════════════════════════════════ + it("vector 22: a folder re-added under a non-DATA schema is recognized by the parent terminal", async function () { + const customSchema = propertySchemaUID; // a non-DATA schema UID used as the listing's anchorSchema + const root = await createAnchor("root", ZERO_BYTES32); + const dir = await createAnchor("dir", root, ZERO_BYTES32); // the folder being listed + const sub = await createAnchor("sub", dir, ZERO_BYTES32); // a child folder of /dir + // /sub is made visible under the CUSTOM schema, so listing /dir under customSchema is non-empty. + await createTag(customSchema, sub, owner); + + // Baseline: [owner] lists /sub under the custom schema. + let page = await fileView.getDirectoryPageBySchemaAndAddressList(dir, customSchema, [ownerAddr], "0x", 10); + expect( + page.items.map(i => i.uid), + "baseline lists /sub under the custom schema", + ).to.deep.equal([sub]); + + // owner whiteouts the FOLDER /dir, then RE-ADDS it under the SAME custom schema (its own TAG). + await attestWhiteout(dir, owner); + await createTag(customSchema, dir, owner); + + // With the parent terminal threading anchorSchema, owner's custom-schema re-add of /dir is a positive + // (folder re-add) → the page lists /sub. With the old hard-coded dataSchemaUID it would be empty. + page = await fileView.getDirectoryPageBySchemaAndAddressList(dir, customSchema, [ownerAddr], "0x", 10); + expect( + page.items.map(i => i.uid), + "non-DATA folder re-add recognized by the parent terminal (not wrongly suppressed)", + ).to.deep.equal([sub]); + }); +}); diff --git a/packages/nextjs/contracts/deployedContracts.ts b/packages/nextjs/contracts/deployedContracts.ts index 16f0a455..b1abf904 100644 --- a/packages/nextjs/contracts/deployedContracts.ts +++ b/packages/nextjs/contracts/deployedContracts.ts @@ -578,7 +578,7 @@ const deployedContracts = { inheritedFunctions: {}, }, EFSFileView: { - address: "0x857576b079E5521ffEb76DE4f685907AA789f2E8", + address: "0xe30dF55aEB917a0F5E7EB15F049364Cb2Ea5Efad", abi: [ { inputs: [ @@ -592,6 +592,11 @@ const deployedContracts = { name: "_edgeResolver", type: "address", }, + { + internalType: "contract IWhiteoutResolverForFileView", + name: "_whiteoutResolver", + type: "address", + }, ], stateMutability: "nonpayable", type: "constructor", @@ -1316,11 +1321,24 @@ const deployedContracts = { stateMutability: "view", type: "function", }, + { + inputs: [], + name: "whiteoutResolver", + outputs: [ + { + internalType: "contract IWhiteoutResolverForFileView", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, ], inheritedFunctions: {}, }, EFSRouter: { - address: "0xf1a3B23faB73e9Dd294d11fc0d6F7222511400DD", + address: "0x199fAB45908F1ff9f6A0F64C6430FBB4947b6E07", abi: [ { inputs: [ @@ -1354,6 +1372,11 @@ const deployedContracts = { name: "_systemAccount", type: "address", }, + { + internalType: "address", + name: "_whiteoutResolver", + type: "address", + }, ], stateMutability: "nonpayable", type: "constructor", @@ -1531,6 +1554,19 @@ const deployedContracts = { stateMutability: "view", type: "function", }, + { + inputs: [], + name: "whiteoutResolver", + outputs: [ + { + internalType: "contract IWhiteoutResolverForRouter", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, ], inheritedFunctions: {}, }, @@ -5826,7 +5862,7 @@ const deployedContracts = { inheritedFunctions: {}, }, ListReader: { - address: "0x9a6ff90672C00b1cFF81E8493562acd703095cde", + address: "0xBc8f16C2840E7D3FBa1D0FC107b3f4B6447Ed453", abi: [ { inputs: [ @@ -7883,10 +7919,8 @@ const deployedContracts = { ], inheritedFunctions: {}, }, - }, - 11155111: { - AliasResolver: { - address: "0xB07225842d6513239a3519ae052B5bc7EBf18996", + WhiteoutResolver: { + address: "0xAE6Ea58d883473Cd0aD8c6D2ddbB1F9dDa5e2363", abi: [ { inputs: [ @@ -7951,7 +7985,7 @@ const deployedContracts = { }, { inputs: [], - name: "SelfLoop", + name: "OrphanAnchor", type: "error", }, { @@ -7959,21 +7993,6 @@ const deployedContracts = { name: "SourceNotAnchor", type: "error", }, - { - inputs: [], - name: "SourceNotData", - type: "error", - }, - { - inputs: [], - name: "TargetNotAnchorOrData", - type: "error", - }, - { - inputs: [], - name: "TargetNotData", - type: "error", - }, { inputs: [], name: "WrongSchema", @@ -7981,7 +8000,7 @@ const deployedContracts = { }, { inputs: [], - name: "ZeroTarget", + name: "ZeroRef", type: "error", }, { @@ -8003,29 +8022,29 @@ const deployedContracts = { { indexed: true, internalType: "bytes32", - name: "source", + name: "parent", type: "bytes32", }, { indexed: true, internalType: "bytes32", - name: "target", + name: "suppressedChild", type: "bytes32", }, { indexed: false, - internalType: "uint16", - name: "kind", - type: "uint16", + internalType: "address", + name: "attester", + type: "address", }, { indexed: true, internalType: "bytes32", - name: "redirectUID", + name: "whiteoutUID", type: "bytes32", }, ], - name: "RedirectAttested", + name: "WhiteoutAttested", type: "event", }, { @@ -8034,29 +8053,29 @@ const deployedContracts = { { indexed: true, internalType: "bytes32", - name: "source", + name: "parent", type: "bytes32", }, { indexed: true, internalType: "bytes32", - name: "target", + name: "suppressedChild", type: "bytes32", }, { indexed: false, - internalType: "uint16", - name: "kind", - type: "uint16", + internalType: "address", + name: "attester", + type: "address", }, { indexed: true, internalType: "bytes32", - name: "redirectUID", + name: "whiteoutUID", type: "bytes32", }, ], - name: "RedirectRevoked", + name: "WhiteoutRevoked", type: "event", }, { @@ -8144,8 +8163,24 @@ const deployedContracts = { type: "function", }, { - inputs: [], - name: "dataSchemaUID", + inputs: [ + { + internalType: "bytes32", + name: "parent", + type: "bytes32", + }, + { + internalType: "address", + name: "attester", + type: "address", + }, + { + internalType: "bytes32", + name: "child", + type: "bytes32", + }, + ], + name: "getActiveWhiteout", outputs: [ { internalType: "bytes32", @@ -8156,6 +8191,64 @@ const deployedContracts = { stateMutability: "view", type: "function", }, + { + inputs: [ + { + internalType: "bytes32", + name: "parent", + type: "bytes32", + }, + { + internalType: "address", + name: "attester", + type: "address", + }, + { + internalType: "uint256", + name: "start", + type: "uint256", + }, + { + internalType: "uint256", + name: "length", + type: "uint256", + }, + ], + name: "getChildrenWhitedOut", + outputs: [ + { + internalType: "bytes32[]", + name: "", + type: "bytes32[]", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "parent", + type: "bytes32", + }, + { + internalType: "address", + name: "attester", + type: "address", + }, + ], + name: "getChildrenWhitedOutCount", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, { inputs: [], name: "getEAS", @@ -8170,16 +8263,24 @@ const deployedContracts = { type: "function", }, { - inputs: [ + inputs: [], + name: "indexer", + outputs: [ { - internalType: "bytes32", - name: "dataSchemaUID_", - type: "bytes32", + internalType: "address", + name: "", + type: "address", }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ { - internalType: "bytes32", - name: "anchorSchemaUID_", - type: "bytes32", + internalType: "contract IEFSIndexerForWhiteout", + name: "indexer_", + type: "address", }, ], name: "initialize", @@ -8200,6 +8301,35 @@ const deployedContracts = { stateMutability: "pure", type: "function", }, + { + inputs: [ + { + internalType: "bytes32", + name: "parent", + type: "bytes32", + }, + { + internalType: "address", + name: "attester", + type: "address", + }, + { + internalType: "bytes32", + name: "child", + type: "bytes32", + }, + ], + name: "isWhitedOut", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, { inputs: [ { @@ -8352,19 +8482,6 @@ const deployedContracts = { stateMutability: "payable", type: "function", }, - { - inputs: [], - name: "redirectSchemaUID", - outputs: [ - { - internalType: "bytes32", - name: "", - type: "bytes32", - }, - ], - stateMutability: "view", - type: "function", - }, { inputs: [ { @@ -8449,6 +8566,19 @@ const deployedContracts = { stateMutability: "view", type: "function", }, + { + inputs: [], + name: "whiteoutSchemaUID", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, { stateMutability: "payable", type: "receive", @@ -8456,19 +8586,16 @@ const deployedContracts = { ], inheritedFunctions: {}, }, - EFSFileView: { - address: "0x76B10909Ff10b53c54387C66B083b1613E2276d3", + }, + 11155111: { + AliasResolver: { + address: "0xB07225842d6513239a3519ae052B5bc7EBf18996", abi: [ { inputs: [ { - internalType: "contract IEFSIndexer", - name: "_indexer", - type: "address", - }, - { - internalType: "contract IEdgeResolverForFileView", - name: "_edgeResolver", + internalType: "contract IEAS", + name: "eas", type: "address", }, ], @@ -8476,29 +8603,605 @@ const deployedContracts = { type: "constructor", }, { - inputs: [ - { - internalType: "bytes", - name: "data", - type: "bytes", - }, - ], - name: "decodeName", - outputs: [ - { - internalType: "string", - name: "", - type: "string", - }, - ], - stateMutability: "pure", - type: "function", + inputs: [], + name: "AccessDenied", + type: "error", }, { inputs: [], - name: "eas", - outputs: [ - { + name: "BadPayload", + type: "error", + }, + { + inputs: [], + name: "HasExpiration", + type: "error", + }, + { + inputs: [], + name: "InsufficientValue", + type: "error", + }, + { + inputs: [], + name: "InvalidEAS", + type: "error", + }, + { + inputs: [], + name: "InvalidInitialization", + type: "error", + }, + { + inputs: [], + name: "InvalidLength", + type: "error", + }, + { + inputs: [], + name: "NotInitializing", + type: "error", + }, + { + inputs: [], + name: "NotPayable", + type: "error", + }, + { + inputs: [], + name: "NotRevocable", + type: "error", + }, + { + inputs: [], + name: "SelfLoop", + type: "error", + }, + { + inputs: [], + name: "SourceNotAnchor", + type: "error", + }, + { + inputs: [], + name: "SourceNotData", + type: "error", + }, + { + inputs: [], + name: "TargetNotAnchorOrData", + type: "error", + }, + { + inputs: [], + name: "TargetNotData", + type: "error", + }, + { + inputs: [], + name: "WrongSchema", + type: "error", + }, + { + inputs: [], + name: "ZeroTarget", + type: "error", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "uint64", + name: "version", + type: "uint64", + }, + ], + name: "Initialized", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "bytes32", + name: "source", + type: "bytes32", + }, + { + indexed: true, + internalType: "bytes32", + name: "target", + type: "bytes32", + }, + { + indexed: false, + internalType: "uint16", + name: "kind", + type: "uint16", + }, + { + indexed: true, + internalType: "bytes32", + name: "redirectUID", + type: "bytes32", + }, + ], + name: "RedirectAttested", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "bytes32", + name: "source", + type: "bytes32", + }, + { + indexed: true, + internalType: "bytes32", + name: "target", + type: "bytes32", + }, + { + indexed: false, + internalType: "uint16", + name: "kind", + type: "uint16", + }, + { + indexed: true, + internalType: "bytes32", + name: "redirectUID", + type: "bytes32", + }, + ], + name: "RedirectRevoked", + type: "event", + }, + { + inputs: [], + name: "anchorSchemaUID", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + components: [ + { + internalType: "bytes32", + name: "uid", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "schema", + type: "bytes32", + }, + { + internalType: "uint64", + name: "time", + type: "uint64", + }, + { + internalType: "uint64", + name: "expirationTime", + type: "uint64", + }, + { + internalType: "uint64", + name: "revocationTime", + type: "uint64", + }, + { + internalType: "bytes32", + name: "refUID", + type: "bytes32", + }, + { + internalType: "address", + name: "recipient", + type: "address", + }, + { + internalType: "address", + name: "attester", + type: "address", + }, + { + internalType: "bool", + name: "revocable", + type: "bool", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + ], + internalType: "struct Attestation", + name: "attestation", + type: "tuple", + }, + ], + name: "attest", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "payable", + type: "function", + }, + { + inputs: [], + name: "dataSchemaUID", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getEAS", + outputs: [ + { + internalType: "contract IEAS", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "dataSchemaUID_", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "anchorSchemaUID_", + type: "bytes32", + }, + ], + name: "initialize", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "isPayable", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "pure", + type: "function", + }, + { + inputs: [ + { + components: [ + { + internalType: "bytes32", + name: "uid", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "schema", + type: "bytes32", + }, + { + internalType: "uint64", + name: "time", + type: "uint64", + }, + { + internalType: "uint64", + name: "expirationTime", + type: "uint64", + }, + { + internalType: "uint64", + name: "revocationTime", + type: "uint64", + }, + { + internalType: "bytes32", + name: "refUID", + type: "bytes32", + }, + { + internalType: "address", + name: "recipient", + type: "address", + }, + { + internalType: "address", + name: "attester", + type: "address", + }, + { + internalType: "bool", + name: "revocable", + type: "bool", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + ], + internalType: "struct Attestation[]", + name: "attestations", + type: "tuple[]", + }, + { + internalType: "uint256[]", + name: "values", + type: "uint256[]", + }, + ], + name: "multiAttest", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + components: [ + { + internalType: "bytes32", + name: "uid", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "schema", + type: "bytes32", + }, + { + internalType: "uint64", + name: "time", + type: "uint64", + }, + { + internalType: "uint64", + name: "expirationTime", + type: "uint64", + }, + { + internalType: "uint64", + name: "revocationTime", + type: "uint64", + }, + { + internalType: "bytes32", + name: "refUID", + type: "bytes32", + }, + { + internalType: "address", + name: "recipient", + type: "address", + }, + { + internalType: "address", + name: "attester", + type: "address", + }, + { + internalType: "bool", + name: "revocable", + type: "bool", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + ], + internalType: "struct Attestation[]", + name: "attestations", + type: "tuple[]", + }, + { + internalType: "uint256[]", + name: "values", + type: "uint256[]", + }, + ], + name: "multiRevoke", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "payable", + type: "function", + }, + { + inputs: [], + name: "redirectSchemaUID", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + components: [ + { + internalType: "bytes32", + name: "uid", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "schema", + type: "bytes32", + }, + { + internalType: "uint64", + name: "time", + type: "uint64", + }, + { + internalType: "uint64", + name: "expirationTime", + type: "uint64", + }, + { + internalType: "uint64", + name: "revocationTime", + type: "uint64", + }, + { + internalType: "bytes32", + name: "refUID", + type: "bytes32", + }, + { + internalType: "address", + name: "recipient", + type: "address", + }, + { + internalType: "address", + name: "attester", + type: "address", + }, + { + internalType: "bool", + name: "revocable", + type: "bool", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + ], + internalType: "struct Attestation", + name: "attestation", + type: "tuple", + }, + ], + name: "revoke", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "payable", + type: "function", + }, + { + inputs: [], + name: "version", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], + stateMutability: "view", + type: "function", + }, + { + stateMutability: "payable", + type: "receive", + }, + ], + inheritedFunctions: {}, + }, + EFSFileView: { + address: "0x141D9FdbadCd9f6e6928A4842FF00094502CC146", + abi: [ + { + inputs: [ + { + internalType: "contract IEFSIndexer", + name: "_indexer", + type: "address", + }, + { + internalType: "contract IEdgeResolverForFileView", + name: "_edgeResolver", + type: "address", + }, + ], + stateMutability: "nonpayable", + type: "constructor", + }, + { + inputs: [ + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + ], + name: "decodeName", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], + stateMutability: "pure", + type: "function", + }, + { + inputs: [], + name: "eas", + outputs: [ + { internalType: "contract IEAS", name: "", type: "address", @@ -9199,7 +9902,7 @@ const deployedContracts = { inheritedFunctions: {}, }, EFSRouter: { - address: "0x44D5F6803127B442218e9aA0481A9931444dc82c", + address: "0x4EF216e1096237dA8A962157Ed13ea1B3FcC5E17", abi: [ { inputs: [ @@ -9341,9 +10044,9 @@ const deployedContracts = { name: "request", outputs: [ { - internalType: "uint16", + internalType: "uint256", name: "statusCode", - type: "uint16", + type: "uint256", }, { internalType: "bytes", @@ -13705,7 +14408,7 @@ const deployedContracts = { inheritedFunctions: {}, }, ListReader: { - address: "0xCc182611B572b5C162a3D96674E821C61ac658FC", + address: "0x689AA70BF6a8b22BE4E959dcf33A40ea03F85Bd5", abi: [ { inputs: [ @@ -14628,6 +15331,11 @@ const deployedContracts = { name: "InvalidTransport", type: "error", }, + { + inputs: [], + name: "InvalidURIScheme", + type: "error", + }, { inputs: [], name: "NonCanonicalPayload", diff --git a/packages/nextjs/lib/efs/uploadOnchainFile.ts b/packages/nextjs/lib/efs/uploadOnchainFile.ts index aca3c27b..1305d3cc 100644 --- a/packages/nextjs/lib/efs/uploadOnchainFile.ts +++ b/packages/nextjs/lib/efs/uploadOnchainFile.ts @@ -130,7 +130,11 @@ export interface UploadOnchainFileResult { * attestation layers) surfaces as the same graceful-cancel sentinel the UI checks. */ export const UPLOAD_CANCELLED = SUBMIT_CANCELLED; -const MAX_ANCHOR_DEPTH = 32; +// Keep in sync with the contract cap EFSIndexer.MAX_ANCHOR_DEPTH — 256 (ADR-0068; ADR-0065 had set +// 1024, gas-unsafe for a first-time deep placement). Bounds the uploader's ancestor visibility-TAG walk; +// if it stops short of the contract cap, a deep upload's upper ancestor folders go un-tagged and hide +// from root EFSFileView listings even though the path exists. +const MAX_ANCHOR_DEPTH = 256; // MirrorResolver caps the final UTF-8 URI bytes at 8192. Keep a cheap raw-byte // prefilter so large files never pay the base64 cost, then check the exact URI diff --git a/specs/02-Data-Models-and-Schemas.md b/specs/02-Data-Models-and-Schemas.md index 41797272..782d8c5c 100644 --- a/specs/02-Data-Models-and-Schemas.md +++ b/specs/02-Data-Models-and-Schemas.md @@ -340,11 +340,11 @@ See [ADR-0044](../docs/adr/0044-list-and-list-entry-schemas.md) and [Lists and C `refUID` = the **source**: the duplicate DATA for `sameAs`/`supersededBy`; the source path Anchor for `symlink`. -**Kinds taxonomy** (initial — evolvable, not in the UID): -- `0 = sameAs` — strong dedup. Source + target both DATA. Followed at read time. -- `1 = supersededBy` — version replacement. Source + target both DATA. Followed at read time. -- `2 = symlink` — path → target. Source ANCHOR; target ANCHOR or DATA. Followed one hop. -- `3+ = reserved` — recorded but **not type-checked** by the resolver (e.g. `relatedVersion`: a weak discovery hint that is **never** auto-followed). Follow rules for these are decided by the read-time resolution spec, not the resolver. +**Kinds taxonomy** (initial — evolvable, not in the UID). **Follow rules are authoritative in `specs/09-redirect-resolution.md` (ADR-0067, James ratified 2026-06-20): `symlink` is the ONLY auto-followed kind; `sameAs`/`supersededBy`/reserved are non-followed terminals.** +- `0 = sameAs` — strong dedup. Source + target both DATA. **Not auto-followed** — a canonicalization relation only; clients/indexers pick a canonical representative for dedup, but a navigational walk that lands on a DATA does not chase `sameAs` (specs/09 §4.2). +- `1 = supersededBy` — version replacement. Source + target both DATA. **NOT auto-followed** — a discoverable version breadcrumb only. "Latest" is reached the EFS way (the path's placement PIN, which the publisher re-points), NOT by chasing this edge; auto-following it would let a fixed DATA UID silently resolve to a newer version, violating "no silent revision" (specs/09). +- `2 = symlink` — path → target. Source ANCHOR; target ANCHOR or DATA. **The only auto-followed kind** — when path resolution lands on a symlink source, the follower advances to `target` (a `D_MAX`-bounded, cycle-safe walk per specs/09, not a hardcoded single hop). +- `3+ = reserved` — recorded but **not type-checked** by the resolver (e.g. `relatedVersion`: a weak discovery hint that is **never** auto-followed). Non-followed terminal; meaning is assigned by a future taxonomy revision (specs/09), never by silent client guesswork. **Write-time guards enforced by `AliasResolver`** (correctness before any mainnet burn): - `a.schema == redirectSchemaUID` (self-derived in `initialize()` against the proxy address; rejects foreign schemas pointed at the resolver) else `WrongSchema`. @@ -356,13 +356,35 @@ See [ADR-0044](../docs/adr/0044-list-and-list-entry-schemas.md) and [Lists and C - `symlink` (2): source must be an ANCHOR (`SourceNotAnchor`); target must be ANCHOR or DATA (`TargetNotAnchorOrData`). - `kind >= 3`: no typing (reserved); only the `target != 0` / `target != source` guards apply. -**Read-time resolution is client/spec, not the resolver.** The resolver enforces only **write-time** correctness (direct self-loops, typing). **Multi-hop cycle handling** (resolve to the lowest UID in the strongly-connected component — start-independent), **chain following**, **depth caps** (`D_MAX`), **lens precedence** (ADR-0031), and **kind-following rules** all live in the client/router + a later Durable resolution spec (ADR-0050 §"Write-time guards vs read-time resolution"). The resolver cannot afford to walk the graph on each write. +**Read-time resolution is client/spec, not the resolver.** The resolver enforces only **write-time** correctness (direct self-loops, typing). **Multi-hop cycle handling** (for symlink navigation: a bounded visited-set stop returning `CycleStopped` — NOT lowest-UID-in-SCC, which is the separate `sameAs` *dedup-canonicalization* rule; ADR-0067 / specs/09), **chain following**, **depth caps** (`D_MAX`), **lens precedence** (ADR-0031), and **kind-following rules** (only `symlink` is auto-followed) all live in the client/router + the **[specs/09-redirect-resolution.md](./09-redirect-resolution.md)** resolution spec (Accepted, ADR-0067). The resolver cannot afford to walk the graph on each write. **Reverse fan-in** ("what points at me?") is intentionally not indexed on-chain by `AliasResolver` — it is the off-chain indexer's job (a future on-chain advisory index is addable as upgradeable logic; ADR-0050 §4). **Symlink / hardlink mapping**: a *hardlink* (one DATA PINned at many path Anchors) is native and untouched — no follow, no cycle. A *symlink* is `REDIRECT kind=2`. *Canonical/dedup* is `REDIRECT kind=0` (`sameAs`). -See [ADR-0050](../docs/adr/0050-redirect-canonical-symlink-schema.md) for full design rationale. +See [ADR-0050](../docs/adr/0050-redirect-canonical-symlink-schema.md) for full design rationale, [ADR-0067](../docs/adr/0067-redirect-read-time-resolution.md) for the read-time resolution decision, and **[specs/09-redirect-resolution.md](./09-redirect-resolution.md)** for the authoritative follow rules + conformance vectors. + +## Schema 10: WHITEOUT (additive post-freeze, ADR-0055) + +> **Not in the frozen nine.** WHITEOUT is added **additively after the Sepolia freeze** (ADR-0055) — a new schema + resolver orphans nothing, exactly how SORT_INFO or any future primitive is added. It is not part of the nine-schema freeze set. Resolver: `WhiteoutResolver`. + +**Purpose**: the cross-lens **negative mask** — the one filesystem primitive additive-only lenses (ADR-0031) otherwise lack: *"render this path empty in MY view; stop fall-through to lower lenses, WITHOUT substituting my own content."* This is the overlayfs *whiteout*. It powers the lens-local **delete** of inherited content and the other half of a lens-local **rename** (whiteout the old inherited name + place the new one). + +| Schema | Field string | Revocable | refUID | Payload | Resolver | +|---|---|---|---|---|---| +| **WHITEOUT** | `""` (empty — pure-identity negative marker, DATA/ADR-0049 idiom) | yes (revoke == un-hide) | the suppressed **child path ANCHOR** | none (zero-length) | WhiteoutResolver | + +- **Per-name WHITEOUT** (`refUID = leaf/folder Anchor`) hides one entry. Keyed on `(parent, attester, suppressedChildAnchor)` — `parent = indexer.getParent(refUID)`. Whiting out a root-level anchor reverts (`OrphanAnchor`): there is no lens below the parent slot to suppress. + +**Write-time guards** (`WhiteoutResolver`): `a.schema` must be the resolver's own self-derived WHITEOUT UID (`WrongSchema` otherwise — EAS lets anyone register a foreign schema pointing here); `revocable` (`NotRevocable`); no expiry (`HasExpiration`); `refUID != 0` (`ZeroRef`); payload empty (`BadPayload`); refUID is an ANCHOR (`SourceNotAnchor`); refUID is not a root-level anchor (`OrphanAnchor`). The resolver keeps its **own** append-only discovery indices (ADR-0009 shape), NOT co-keyed with EdgeResolver, and writes no kernel state (read-only `getParent` / `ANCHOR_SCHEMA_UID`). + +**Read-time resolution** (`EFSFileView` listings + `EFSRouter` path walk) — *"deleted means gone" across BOTH listing AND resolution*: +- **Negative terminal in first-attester-wins**: walking `L0…system`, the first lens at a path with EITHER a positive placement (file PIN or folder visibility TAG) OR a whiteout terminates the scan — positive ⇒ serve / list it; whiteout ⇒ serve **empty** (404-equivalent) and **stop** (no fall-through, no `system` gap-fill). +- **Lens-scoped & viewer-sovereign**: a whiteout affects only viewers who voluntarily include the authoring lens; never a global delete. Revoke un-hides. Same-lens override: a lens's own newer positive placement beats its own earlier whiteout (in listings the positive can be a visibility TAG — the folder re-add fix; in single-anchor DATA *resolution* the positive is PIN-only, the correct overlayfs lookup gate). Reads stay single-pass (one extra O(1)-class index source per page, never a per-item doubling). + +**Out of scope (explicit, ADR-0055)**: DATA-whiteout (suppressing a DATA UID directly) is **not** a unionfs concept and is not built — whiteout suppresses a *path entry* in a lens, not content identity. Suppressing a PROPERTY / MIRROR / another whiteout is meaningless and rejected at write time. The **opaque-directory variant** (*"show only MY children here"*) is **DEFERRED** (no concrete use case yet; re-adds additively per ADR-0055). + +See [ADR-0055](../docs/adr/0055-whiteout-cross-lens-negative-mask.md) for full design rationale + the rejected encodings (REDIRECT kind, TAG `weight < 0`, sentinel PIN, tombstone DATA). ## Schema Hierarchy To represent a standard filesystem interaction where a file has a name within a folder: diff --git a/specs/03-Onchain-Indexing-Strategy.md b/specs/03-Onchain-Indexing-Strategy.md index fde2ef8e..e9ab256b 100644 --- a/specs/03-Onchain-Indexing-Strategy.md +++ b/specs/03-Onchain-Indexing-Strategy.md @@ -135,7 +135,7 @@ Use `getDirectoryPageBySchemaAndAddressList(folderUID, DATA_SCHEMA_UID, [alice, ### `propagateContains` (Tree Visibility) When a PIN or TAG places content at a structural Anchor, EdgeResolver calls `indexer.propagateContains(definition, attester)`. This walks up the `_parents` chain from the definition Anchor, setting `_containsAttestations[ancestor][attester] = true` at each level. Early-exit on already-flagged ancestors makes repeated contributions amortized O(1). This enables the sidebar tree to show which folders contain content from a given attester without scanning their children. -`MAX_ANCHOR_DEPTH = 32` caps the upward walk to prevent gas griefing via deeply nested Anchor chains. +`MAX_ANCHOR_DEPTH = 256` (ADR-0068; read it from `EFSIndexer.MAX_ANCHOR_DEPTH()`, never hardcode) caps this upward walk to prevent gas griefing via deeply nested Anchor chains. It is sized to the **write-side** budget: the walk does ~4 cold zero→nonzero SSTOREs/level (~90k gas/level), so a first-time deep placement at 256 costs ~23M gas — under the block limit, with headroom for the rest of the tx. (History: 32 in ADR-0021; raised to 1024 in ADR-0065 on a wrong ~2.1k/level estimate, which made deep placement unexecutable; corrected to 256 in ADR-0068.) ### `clearContains` (partial de-propagation) When the last active edge placed at a definition Anchor by an attester is removed (revocation, or PIN supersede that empties the slot), EdgeResolver calls `indexer.clearContains(definition, attester)`. This clears the **immediate folder's** `_containsAttestations` flag only — ancestor flags remain sticky (see ADR-0010). The immediate-folder clear is sufficient because `getDirectoryPageByAddressList` checks the direct child's flag; leaving ancestors flagged is conservative (a folder might stop appearing empty) and avoids the gas cost of reference-counted de-propagation. @@ -315,10 +315,10 @@ The kernel (EFSIndexer) remains the source of truth. The sort overlay is a secon ### Public Index API -EFSIndexer exposes a permissionless indexing API for any EAS attestation whose schema is resolved by a contract other than EFSIndexer: +EFSIndexer exposes a permissionless indexing API for any EAS attestation whose schema is resolved by a contract other than EFSIndexer. **It is DISCOVERY-ONLY (ADR-0066):** it makes any attestation *findable*, but does NOT manufacture schema-blind FOLDER PRESENCE — folder presence is an intentional placement signal owned by real content writes (PIN/TAG via `EdgeResolver.propagateContains`, or anchor creation in `onAttest`), never by a permissionless discovery call (else anyone could `index()` a negative WHITEOUT, or a junk foreign attestation pointed at a popular anchor, to fake "contributor" presence + bloat `_childrenByAttester`). -- **`index(bytes32 uid) → bool wasIndexed`** — reads the attestation from EAS and runs the same global indexing logic as `onAttest` (schema, attester, sent, received, referencing, upward propagation). Idempotent — guarded by `mapping(bytes32 => bool) _indexed`. Returns `false` for EFS-native schemas (already indexed via `onAttest`) and for already-indexed UIDs. Emits `AttestationIndexed`. -- **`indexBatch(bytes32[] uids) → uint256 count`** — batch version; skips already-indexed and EFS-native UIDs without reverting. Returns count of newly indexed UIDs. +- **`index(bytes32 uid) → bool wasIndexed`** — reads the attestation from EAS and runs the **discovery** indexing of `onAttest` (schema, attester, sent, received, referencing, and the schema-SCOPED `_containsSchemaAttestations` fact) — but **NOT** the schema-blind upward propagation of `_containsAttestations` / `_childrenByAttester` (ADR-0066). Idempotent — guarded by `mapping(bytes32 => bool) _indexed`. Returns `false` for EFS-native schemas (already indexed via `onAttest`) and for already-indexed UIDs. Emits `AttestationIndexed`. +- **`indexBatch(bytes32[] uids) → uint256 count`** — batch version; same discovery-only semantics; skips already-indexed and EFS-native UIDs without reverting. Returns count of newly indexed UIDs. - **`indexRevocation(bytes32 uid)`** — mirrors a revocation from EAS into `_isRevoked`. Call after `eas.revoke()` to make `isRevoked()` return true for externally-resolved schemas. Requires the attestation to already be revoked in EAS. Idempotent. Emits `RevocationIndexed`. - **`isIndexed(bytes32 uid) → bool`** — returns true if a UID was indexed via the public API (not via `onAttest`). diff --git a/specs/04-Core-Workflows.md b/specs/04-Core-Workflows.md index 9d416b3d..3459e182 100644 --- a/specs/04-Core-Workflows.md +++ b/specs/04-Core-Workflows.md @@ -4,13 +4,13 @@ This document maps the step-by-step execution for specific developer and user in ### 1. Upload a file to `/memes/cat.jpg` (on-chain) - **Action**: Atomic upload via EAS `multiAttest` — all attestations in a single transaction. -- **Step 1**: Read file bytes → compute `keccak256(bytes)` locally for `contentHash`; measure `size`. These are attester-supplied claims, not DATA fields (ADR-0049). +- **Step 1**: Read file bytes → compute the canonical `contentHash` locally — the file's **sha2-256 digest in multibase-base16 multihash form** (`f1220…`), per `specs/10-file-metadata-encoding.md` / ADR-0064 (sha2-256 so it shares the IPFS CID digest; do **not** mint a bare keccak hex — PROPERTY values are non-revocable, so the form is permanent). Measure `size` (decimal byte count). These are attester-supplied claims, not DATA fields (ADR-0049). - **Step 2**: Query the PROPERTY index for a trusted `contentHash` claim to check for an existing DATA (best-effort client-side dedup, ADR-0049). If a matching DATA UID is found from a trusted attester, skip DATA attestation and hardlink the existing DATA with a new PIN (step 4.1 below). To resolve an existing duplicate to a canonical, use the REDIRECT primitive (ADR-0050). -- **Step 3**: Walk the ancestor chain from `/memes/` up to root exclusive (ADR-0006 revised 2026-04-18, ADR-0038, ADR-0041). For each generic folder the attester hasn't already covered with an active `TAG(definition=DATA_SCHEMA_UID, refUID=folder)`, queue a visibility TAG for that folder. A TAG is active iff it exists and is not EAS-revoked; weight is opaque metadata (ADR-0041 §4). The walk short-circuits the first time an existing active TAG is found — steady-state cost is zero. Bounded by `MAX_ANCHOR_DEPTH = 32` (ADR-0021). +- **Step 3**: Walk the ancestor chain from `/memes/` up to root exclusive (ADR-0006 revised 2026-04-18, ADR-0038, ADR-0041). For each generic folder the attester hasn't already covered with an active `TAG(definition=DATA_SCHEMA_UID, refUID=folder)`, queue a visibility TAG for that folder. A TAG is active iff it exists and is not EAS-revoked; weight is opaque metadata (ADR-0041 §4). The walk short-circuits the first time an existing active TAG is found — steady-state cost is zero. Bounded by the `MAX_ANCHOR_DEPTH` contract constant — **256** (ADR-0068, supersedes ADR-0065/0021); read it from `EFSIndexer.MAX_ANCHOR_DEPTH()` rather than hardcoding, so the client never drifts from the on-chain cap. - **Step 4**: Build `multiAttest` batch: 1. `DATA()` — **empty schema** (pure identity, ADR-0049), standalone, non-revocable, `refUID = 0x0`. No `contentHash` or `size` fields. 2. **contentType binding** (ADR-0041 supersedes ADR-0035): three sub-attestations — (a) `Anchor(refUID=DATA, anchorSchema=PROPERTY_SCHEMA_UID, name="contentType")` (skipped if already exists from a prior upload of this DATA), (b) `PROPERTY(value="image/jpeg")` — free-floating, `refUID = 0x0`, (c) `PIN(definition=contentType keyAnchor, refUID=PROPERTY UID)` — binds the value into the cardinality-1 slot. - 2a. **contentHash + size bindings** (ADR-0049): same three-sub-attestation pattern for `contentHash` (e.g. `keccak256(bytes)`) and `size` (byte count as decimal string) — each is a reserved-key PROPERTY bound to the DATA UID via PIN, lens-scoped per attester. + 2a. **contentHash + size bindings** (ADR-0049): same three-sub-attestation pattern for `contentHash` (canonical sha2-256 multihash `f1220…`, specs/10 / ADR-0064 — not bare keccak) and `size` (byte count as decimal string) — each is a reserved-key PROPERTY bound to the DATA UID via PIN, lens-scoped per attester. 3. `MIRROR(transportDef=/transports/onchain, uri=web3://0xABC)` — `refUID = DATA UID` 4. `PIN(definition=cat.jpg Anchor)` — `refUID = DATA UID` (places DATA at path; cardinality-1 — re-PIN at the same `(attester, definition, targetSchema)` slot supersedes the prior placement in O(1)) 5. One visibility TAG per queued ancestor from Step 3: `TAG(definition=DATA_SCHEMA_UID, refUID=ancestorFolder, weight=1)`. Weight=1 is the conventional default; any existing non-revoked TAG makes the folder appear in the attester's lens listing. @@ -18,7 +18,7 @@ This document maps the step-by-step execution for specific developer and user in ### 2. Paste an IPFS link to `/docs/paper.pdf` - **Action**: Same as upload but MIRROR uses a different transport. -- **Step 1**: Client fetches `ipfs://QmXxx` via gateway to compute `keccak256(bytes)` for `contentHash` (attester's claim, client can verify on display). For large files, user provides hash manually. +- **Step 1**: Client fetches `ipfs://QmXxx` via gateway to compute the canonical `contentHash` (sha2-256 multihash `f1220…`, specs/10 / ADR-0064; attester's claim, client can verify on display). For large files, user provides the hash manually. - **Step 2**: Build `multiAttest` batch: 1. `DATA()` — empty schema (pure identity, ADR-0049); no `contentHash` or `size` fields. 2. contentType binding triplet (Anchor + PROPERTY + PIN — see workflow 1 step 4 for the breakdown) @@ -35,7 +35,7 @@ This document maps the step-by-step execution for specific developer and user in ### 4. Cross-reference the same file at another path - **Action**: Place an existing DATA at a second path (e.g., same cat.jpg at `/animals/cat.jpg`). - **Execution**: Single `PIN(definition=animals/cat.jpg Anchor, refUID=same DATA UID)`. -- **Result**: DATA, PROPERTYs, and MIRRORs are shared. Only one new PIN. Metadata on the canonical DATA is visible from both paths. +- **Result**: DATA, PROPERTYs, and MIRRORs are shared. Only one new PIN. Metadata on the canonical DATA is visible from both paths. **If you pin across an attester boundary** (e.g. Bob pins Alice's DATA), MIRRORs/`contentType` are lens-scoped and won't resolve under Bob — see workflow 8e Caveat A. ### 4a. Browse an address home (`/vitalik.eth/memes/`) - **Action**: Resolve the top-level URL segment as an Ethereum address and walk anchors parented at that address (ADR-0033). @@ -76,6 +76,68 @@ EFS files are modified by issuing new attestations. - **Delete a folder (client-driven cascade)**: Collect every active edge the attester owns in the folder's subtree — the visibility TAG on the **target folder only** (NOT ancestors — revoking an ancestor would hide sibling content the attester still owns elsewhere in that subtree) plus every file-placement PIN on descendant anchors. Batch via EAS `multiRevoke` (chunked 50 per tx — ADR-0026 analog). EdgeResolver's `onRevoke` clears the corresponding `_activeBySlot` (PIN) / `_activeByAAS` (TAG) entries; when an attester's active edge count on a given anchor drops to zero, EdgeResolver also calls `EFSIndexer.clearContains(anchor, attester)` so `_containsAttestations` no longer reports the attester as a contributor to that subtree (refines ADR-0010). The folder anchor itself is non-revocable and persists in the kernel forever; it simply stops appearing in the attester's lens listing because no visibility TAG is active. - **Cross-reference**: `PIN(definition=new path Anchor, refUID=existing DATA)`. Same DATA appears at multiple locations. +### 8a. Move a file (`/docs/a.txt → /archive/a.txt`, own content) + +- **Action**: Re-place an existing DATA at a new path and revoke the old placement. **Atomic** — the whole sequence is one EAS `multiAttest` (the revoke rides along as a `multiRevoke` in the same wallet flow, or a follow-up tx; sequence it so the new placement lands before the old is dropped, never the reverse — a crash mid-flight must never leave the file at neither path). +- **Own vs inherited content**: if the attester owns the DATA placement at the source (their own active PIN), the move revokes it (Step 4). Moving **inherited** content — a file you see only because a *lower* lens placed it — cannot be removed by revoke (you hold no edge at the source); instead the old location is suppressed with a **per-name WHITEOUT on the source Anchor** (ADR-0055; the `delete`-verb mechanism in workflow 8f). This is **lens-local** — it hides the old path only for viewers who include your lens (you can't globally move what you don't own — correct for a union FS). So Step 4 below is **revoke the source PIN (own content) OR attest a WHITEOUT on the source Anchor (inherited content)**; either way the new placement lands first (a crash mid-flight must never leave the file at neither path). +- **Step 1 — Ensure the destination name anchor**: `resolvePath(archiveFolder, "a.txt")`. If zero, queue an `Anchor(refUID=archiveFolder, name="a.txt", anchorSchema=DATA_SCHEMA_UID)` — non-revocable, persists forever (ADR-0002). +- **Step 2 — New placement PIN**: `PIN(definition=destAnchor, refUID=sameDataUID)`. The DATA UID is unchanged — **DATA, MIRRORs, contentType/contentHash/size PROPERTYs are path-independent and fully preserved** (ADR-0049; they hang off the DATA UID, not the path). No content is re-attested. +- **Step 3 — Ancestor-walk visibility TAGs for the new path**: walk `/archive` up to root exclusive (per workflow 1 step 3 / ADR-0038); emit a `TAG(definition=DATA_SCHEMA_UID, refUID=ancestor)` for each ancestor the attester hasn't already actively tagged. Steady-state zero if `/archive` already holds the attester's content. +- **Step 4 — Suppress the old location**. *Own content*: revoke the old placement PIN — `getActivePin(srcAnchor, attester, DATA_SCHEMA_UID)` → `eas.revoke(PIN_SCHEMA_UID, oldPinUID)`; EdgeResolver clears `_activeBySlot[srcAnchor][attester][DATA_SCHEMA]`. *Inherited content* (no PIN of yours to revoke): attest a **per-name WHITEOUT** on the source Anchor — `eas.attest(WHITEOUT_SCHEMA_UID, { refUID: srcAnchor, data: 0x, revocable: true })` (workflow 8f) — a lens-local negative terminal that stops fall-through to the lower lens. Un-move = `revoke` the WHITEOUT. +- **Step 5 — Conditional source-folder visibility-TAG revoke (the step clients get wrong)**: revoking the placement PIN does **not** auto-revoke the attester's visibility TAG on the *source folder* — folder visibility is a separate TAG (ADR-0038). Only revoke `TAG(definition=DATA_SCHEMA_UID, refUID=srcFolder)` if, after Step 4, the attester has **no other active placement PIN or visibility TAG anywhere in that folder's subtree** — i.e. the folder is now empty *for this attester*. If the attester still owns sibling content under `/docs`, leave the source-folder TAG in place; revoking it would hide those siblings. **Do not** blindly revoke it on every move. (Note `clearContains` fires kernel-side only when the attester's `_activeTotalByDefAndAttester` for that folder hits zero and clears the *immediate* folder flag only, never ancestors — see EFSIndexer.sol `clearContains` ~L350 and ADR-0010; the lens-visible self-correction is TAG-driven, the sticky `_containsAttestations` flag is not — see Caveat B.) +- **Result**: `a.txt` resolves at `/archive/` for the attester's lens and no longer at `/docs/`. The source name anchor persists as a harmless husk. Rename (8b) is the same mechanism with `srcFolder == destFolder`. + +### 8b. Rename a file (`a.txt → b.txt`) + +- **Action**: Same mechanism as move (8a) with the **same parent** — new name anchor + re-PIN at the new anchor + revoke the old PIN. One `multiAttest`. +- **Step 1**: Ensure `Anchor(refUID=sameFolder, name="b.txt", DATA_SCHEMA_UID)` (create if absent, non-revocable). +- **Step 2**: `PIN(definition=bAnchor, refUID=sameDataUID)` — DATA/MIRRORs/PROPERTYs preserved (path-independent). +- **Step 3**: No new ancestor-walk needed — the parent folder is already tagged from the original placement. +- **Step 4**: Revoke the old `a.txt` placement PIN. +- **The old name anchor persists forever as a harmless husk** — anchors are non-revocable (ADR-0002). It resolves to nothing (no active PIN) and never reappears in a lens listing. This is expected, not a leak. +- **Not a rename**: changing the `name` *display*-PROPERTY (ADR-0034) is **not** a rename. The path segment and the directory-listing label both come from the **anchor `name` field**, not the display PROPERTY; rebinding the `name` PROPERTY changes a cosmetic label only and leaves the file addressable at the old path segment. A true rename re-PINs onto a new name anchor as above. +- **Inherited content**: same as 8a — renaming an inherited file places `b.txt`, then suppresses the inherited `a.txt` with a **per-name WHITEOUT on the old name Anchor** (ADR-0055 / workflow 8f), lens-local. Own content uses the Step-4 revoke; inherited content substitutes the WHITEOUT. (Without it the file would appear under both names.) + +### 8c. Rename / move a FOLDER + +- **There is no reparent.** A child anchor binds to its parent by `refUID` at attestation time, and ANCHOR is non-revocable and immutable (ADR-0002) — the parent pointer can never be changed. Renaming or moving a folder is therefore **not** an edit to the folder anchor. +- **Today — full subtree rebuild**: re-create the entire subtree under the new parent/name and revoke the old placements. Per descendant: one new name `Anchor` (if absent) + one re-`PIN` of its DATA at the new anchor + one ancestor-walk visibility `TAG` (deduped per folder) + one `revoke` of the old PIN; plus one visibility-TAG revoke per source folder that empties out (the 8a Step-5 conditional, applied bottom-up). Chunk via `multiAttest` / `multiRevoke` at **~50 ops per tx** (ADR-0026 analog; same chunking as the delete cascade, workflow 8 / 8e). Cost scales with descendant count — expensive for deep trees, by design (archival, not commodity). +- **Same inherited-content rule**: descendants served from a lower lens are re-placed under the new folder, then suppressed at the old location with **per-name WHITEOUTs** on the old descendant anchors (ADR-0055 / workflow 8f) — and, when an old folder fully empties for your lens, a WHITEOUT on that folder anchor (the 8f last-visible-child cascade). Lens-local: hidden only for viewers who include your lens. +- **Cheap future path — symlink on the folder anchor**: a single `REDIRECT(refUID=oldFolderAnchor, target=newFolderAnchor, kind=2)` (symlink, ADR-0050) renames/moves a whole subtree with **one** attestation, leaving the children in place. This is **pending the read-time redirect-resolution follower** — the production `EFSRouter` reads only the DATA-pin slot and does **not** follow symlinks yet (ADR-0050 §Symlink/hardlink mapping). The resolution algorithm (lens-scoped follow, depth cap, symlink cycle = bounded visited-set stop → `CycleStopped`; lowest-UID-in-SCC is the *`sameAs` dedup* canonicalization rule, NOT symlink following — ADR-0067) is specified in **specs/09-redirect-resolution.md**; until a client/router implements it, symlink-on-folder does not resolve and the subtree rebuild above is the only working option. Recommend symlink-on-arrival as the default **once the follower ships**; subtree-rebuild is the interim default. (See SPICY note to maintainer.) + +### 8d. Delete a file / folder + +- **Mental model — unreachable, not erased (state this to users)**: revoke **hides**, it never erases. DATA, ANCHOR, and PROPERTY are **non-revocable** and persist on-chain forever (ADR-0049, ADR-0002, ADR-0052); only the **edges** (PIN, TAG, MIRROR) are revocable. A "delete" revokes the attester's *edges* so the content stops appearing in their lens (default reads exclude revoked, ADR-0051) — the bytes, the identity, and the path husk remain permanently and are reconstructable with `includeRevoked`. For an archival system this is a **user-trust / expectation point**: tell users "delete" means "removed from your view," not "wiped." Only the author can hide their own claims; nobody can erase or hide anyone else's (ADR-0051). +- **Delete a file**: revoke the placement PIN — `getActivePin(pathAnchor, attester, DATA_SCHEMA_UID)` → `eas.revoke(PIN_SCHEMA_UID, pinUID)`. EdgeResolver clears `_activeBySlot`. DATA + MIRRORs + PROPERTYs survive (still reachable from any other path the DATA is pinned at). Then apply the 8a Step-5 conditional source-folder TAG revoke if the folder emptied for this attester. **Caveat — shadowed overrides:** if your placement was *overriding* a lower lens's content at this path (e.g. you shadowed `system`'s file), this revoke alone falls through to that lower lens (revert-to-inherited), not a deletion. To make the path stay empty, also attest a WHITEOUT — see §8f `delete(path)`. +- **Delete a folder**: revoke the attester's **visibility TAG on the target folder** + every **descendant placement PIN** in the subtree. Batch `multiRevoke`, ~50/tx (ADR-0026 analog). EdgeResolver's `onRevoke` clears the matching `_activeBySlot`/`_activeByAAS` entries; when an attester's active-edge count on an anchor reaches zero, EdgeResolver calls `EFSIndexer.clearContains(anchor, attester)` (EdgeResolver.sol ~L502). **Revoke the TAG on the target folder only — NOT on ancestors.** Revoking an ancestor's visibility TAG would hide sibling content the attester still owns elsewhere under that ancestor. (This is the same rule as workflow 8's "Delete a folder" bullet — see there for the kernel detail.) +- **Inherited / system content**: revoke removes only *your* edges. To delete inherited or `system`-lens content **from your own view**, you attest an additive **WHITEOUT** on the path Anchor (ADR-0055) — a negative terminal in the lens scan that stops fall-through to lower lenses without substituting your own content. **Now implemented** (see workflow 8f). Un-hide = `revoke` the WHITEOUT. +- **Undelete = re-attest at the same slot.** Re-`PIN(definition=anchor, refUID=dataUID)` restores the placement in O(1) (cardinality-1 supersession, ADR-0041); re-`TAG` restores folder visibility. No special "undelete" primitive — the append-only kernel makes resurrection a normal write. (Revoking is one-directional per UID, but a fresh attestation at the same slot is always legal.) + +### 8e. Caveats (move / rename / delete / cross-reference) + +- **A. Cross-attester hardlink mirror footgun.** If Bob places a PIN onto **Alice's** DATA (hardlinking her file into his lens) but attests no MIRROR/`contentType` of his own, reads scoped to Bob's lens find **no mirror and no contentType under Bob** — mirrors and PROPERTYs are lens-scoped to the winning attester (ADR-0013, ADR-0014). The file resolves to **unreachable bytes / `application/octet-stream`**. Rule: a cross-attester hardlinker **MUST also attest a `MIRROR` (+ a `contentType` PROPERTY) under their own lens**, OR the reader must include the DATA's author in `?lenses=` so the author's mirror/contentType resolve. This applies anywhere a DATA is pinned across an attester boundary (workflow 4 cross-reference, and moves/renames that re-pin someone else's DATA). +- **B. Ghost-folder sticky flag.** `_containsAttestations` is sticky on revoke (ADR-0010): `clearContains` clears only the **immediate** folder flag and only when the attester's active-edge total for it hits zero (EFSIndexer.sol ~L350); **ancestor** flags stay set forever. A schema-blind reader (`getChildrenByAddressList`, `containsSchemaAttestations`) can therefore still report an emptied ancestor folder as "non-empty for this attester." Folder **visibility** in lens listings is TAG-driven (ADR-0038) and self-corrects on TAG revoke, so the directory *listing* is correct — but clients that consult the sticky flag directly should **cross-check for active children** rather than trust it (the ADR-0010 papering-over). Cosmetic, not a correctness bug. + +### 8f. Suppress inherited / lower-lens content — the delete verb (WHITEOUT, ADR-0055) + +Lenses are additive-only: a lens can ADD content or SHADOW a lower lens by placing its OWN content, but cannot say *"render this path empty in my view; stop the fall-through without substituting my own."* The **WHITEOUT** schema (additive post-freeze, specs/02 §10) adds that negative assertion. The client exposes it as a **single `delete` verb** — the user never picks a marker: + +- **`delete(path)` — "this shouldn't be here" (stay GONE).** The client **auto-selects the marker**, overlayfs-style, from what the lens stack resolves at `path` — and crucially checks whether revoking your own edge would **fall through** to a lower lens: + - **Own placement, nothing inherited underneath** → revoke your PIN/TAG (workflow 8d). Resolves to **genuine not-found** (no 0-byte sentinel). + - **Own placement that SHADOWS a lower lens** (you overrode e.g. `system`'s file at this path) → revoking **alone is not delete** — first-attester-wins then falls through and the lower lens's content **reappears** (that is *revert-to-inherited*, below). To keep it gone, **revoke your edge AND attest a WHITEOUT** on the path Anchor. Both are required: the WHITEOUT alone would lose to your own active PIN (same-lens positive-before-whiteout), and the revoke alone reveals the lower lens. + - **Inherited only** (placed by a lower lens / `system`; you hold no edge) → attest a **per-name WHITEOUT** on the path Anchor directly. + + WHITEOUT: `eas.attest(WHITEOUT_SCHEMA_UID, { refUID: pathAnchor, data: 0x, revocable: true })`. Read-time, the first lens in the scan with a whiteout (and no own positive above it) terminates resolution with **empty** (404-equivalent) and stops fall-through. Un-delete = `eas.revoke(WHITEOUT_SCHEMA_UID, whiteoutUID)` (and re-PIN if you also revoked your own placement). A reloaded client recovers `whiteoutUID` on-chain from the slot it already knows via `WhiteoutResolver.getActiveWhiteout(parent, attester, child)` — no event-log replay; after a re-whiteout this returns the CURRENT UID (revoking a stale one no-ops). +- **`revertToInherited(path)` — the sibling of delete.** Distinct user intent: drop *your* override and let the lower lens show through. This is **revoke-only** (no WHITEOUT) — exactly the middle case above without the suppression. The client must not conflate it with `delete`: "remove my version" (revert) vs "this should not exist here at all" (delete + whiteout) are different operations on a shadowing override. +- **Cascade on the last visible child.** When the deleted item was the **last visible child** of an inherited folder, the client also whites out that now-empty folder (and walks up while each parent empties) — overlayfs-style auto-marker management, so an emptied inherited folder also disappears. The user just clicks delete; the client manages the markers. +- **Lens-local for inherited content.** `delete` of inherited content is **lens-local** — you can't globally delete what you don't own (correct for a union / shared FS); the WHITEOUT hides it only for viewers who include your lens. `delete` of your **own** content is a `revoke` **when nothing lies beneath it** (genuine not-found); when your content shadows a lower lens, `delete` is `revoke` **+ WHITEOUT** (see the three cases above) — a bare revoke there reverts to the inherited content, it does not delete. + +**"Deleted means gone" is enforced across BOTH listing AND resolution**: `EFSFileView`'s directory listings drop whited entries, AND `EFSRouter`'s path walk applies the same negative terminal so a deep link into a whited path 404s — a viewer cannot bypass a whiteout by deep-linking past the listing. `EFSFileView.getFilesAtPath` applies the identical terminal for view/router consistency. + +**Folder re-add (the fix).** A lens that whites out a FOLDER and then re-asserts that folder with its OWN visibility TAG sees it again in listings: the listing predicate's positive terminal is a file PIN **or** a folder visibility TAG (Shape B), so the lens's own positive assertion beats its own earlier whiteout (same-lens override). A single-anchor DATA *lookup* (`getFilesAtPath` / router terminal) stays PIN-gated — the correct overlayfs lookup semantics. + +**Out of scope**: **DATA-whiteout** (suppressing a DATA UID directly) is **not** a unionfs concept and is deliberately not built — whiteout suppresses a *path entry within a lens*, not content identity. Suppressing a PROPERTY / MIRROR / another whiteout is rejected at write time. The **opaque-directory variant** (*"show only MY children here"*) is **DEFERRED** (no concrete use case yet; re-adds additively per ADR-0055). + ### 9. Resolve Subjective File Content (Lenses) - **Action**: User wants to load `/pets/best.jpg`, trusting "Vitalik", "LocalDAO", and "Self". - **Execution**: The client calls `edgeResolver.getActivePinTarget(bestJpgAnchor, attester, DATA_SCHEMA_UID)` for each attester in priority order. Returns the DATA UID actively pinned at that path, or `bytes32(0)` if none. diff --git a/specs/09-redirect-resolution.md b/specs/09-redirect-resolution.md new file mode 100644 index 00000000..fde92dfd --- /dev/null +++ b/specs/09-redirect-resolution.md @@ -0,0 +1,226 @@ +# REDIRECT Read-Time Resolution + +**Status:** Accepted (James ratified 2026-06-20 — see `docs/adr/0067-redirect-read-time-resolution.md`). The **rules** here are accepted; the on-chain read-time follower **implementation is DEFERRED** (see "Implementation status" below). +**Governs:** the read-time behavior of the REDIRECT schema (ADR-0050). The on-wire schema and its write-time guards are frozen; this spec is the **Durable** half ADR-0050 §"Write-time guards vs read-time resolution" requires before any durable REDIRECT data is seeded on Sepolia. +**Related:** ADR-0050 (REDIRECT schema), ADR-0055 (WHITEOUT negative terminal — reserved here), ADR-0031 (lens first-attester-wins), ADR-0065 (depth-cap precedent), specs/02 §10 (REDIRECT fields), specs/overview.md (read flow). + +> **Implementation status — DEFERRED (follower not yet built).** No on-chain redirect-follower exists today, by design: it will be built when REDIRECTs are actually used. This document is the **specification that the future follower must satisfy** — the rules are pinned now (Accepted) because they must be fixed *before* any durable REDIRECT data is seeded (see §10), but the read-time code itself is deferred until there is real redirect data to resolve. An earlier draft follower (a `resolveRedirect` view on `EFSFileView` plus an `AliasResolver.getActiveRedirect` reverse-by-source index) was removed pending that need; when the follower is implemented, this spec may be refined in light of what implementation surfaces ("we might find things"). Treat every "a reader MUST …" / "the follower …" sentence below as a **requirement on that future implementation**, not a description of shipped code. The write-time guards referenced as `AliasResolver.sol:NNN` ARE shipped (the frozen REDIRECT resolver); only the *read-time follower* is deferred. + +--- + +## 1. Scope + +This spec defines how a **reader** (router, on-chain view, off-chain indexer, or client) resolves a path or DATA UID *through* REDIRECT attestations. It is normative for the on-chain navigational follower and for any client/indexer that claims conformance. + +What this spec governs: + +- **Following semantics by `kind`** — which redirects are walked, and to what. +- **The hop cap `D_MAX`** — how far a walk may go before stopping. +- **Cycle handling** — bounded-walk cycle-stop (navigational) and the `sameAs` canonicalization rule (dedup). +- **Lens precedence** — which redirects are even visible to follow. +- **Dangling targets** — what a walk returns when a target is missing/revoked/mistyped. +- **The WHITEOUT negative-terminal boundary** — why the suppression terminal (WHITEOUT, now a live separate schema, applied by the router / file view) is **never** produced by this redirect follower, and why no REDIRECT kind may stand in for it (§7). + +What this spec does **not** govern (out of scope, frozen elsewhere): + +- The REDIRECT field string `"bytes32 target, uint16 kind"` (FROZEN, ADR-0050). +- `AliasResolver` write-time guards (no self-loop, per-kind typing). The resolver does **not** follow, detect multi-hop cycles, cap depth, or apply lens precedence — verified `AliasResolver.sol:161-198`. +- WHITEOUT's own schema/resolver/readdir behavior — that is a separate, now-live dedicated schema (ADR-0055; `specs/02` §WHITEOUT, `specs/04` §8f). Only its *boundary with the redirect follower* (§7) is in scope here. + +**Baseline fact this spec changes.** The production reader follows nothing today. `EFSRouter._findDataAtPath` (`EFSRouter.sol:889-895`) reads the active placement PIN target in O(1) and returns it — it never inspects REDIRECT. `EFSIndexer.resolvePath` (`EFSIndexer.sol:531-533`) is a pure `_nameToAnchor` lookup. So REDIRECT-following is **new read-side logic**, additively layered on top of the existing path walk; it is not implicit in any deployed read path. + +--- + +## 2. Following semantics by `kind` + +Resolution is **positive-only and navigational**: a walk follows an edge only when the kind is a *navigational* kind AND the edge is lens-visible (§5) AND the target type-checks at read time (§6). Following yields a new node; the walk continues from it. **Only `symlink` (2) is auto-followed** (James ratified 2026-06-20); every other kind is a **non-followed terminal**. + +**Versioning model: path = newest, UID = exact (James ratified 2026-06-20).** A *path* resolves to the newest content because the publisher re-points the path's placement PIN; a specific *DATA UID* is permanent and resolves to exactly that version. "Give me the latest version" is therefore answered by the **path/placement**, NOT by chasing `supersededBy`. A version chain is a **discoverable on-chain breadcrumb** (read by clients/indexers walking `supersededBy` deliberately), not auto-navigation by the follower. This preserves EFS's **"no silent revision"** property: a fixed UID or link never silently advances to a newer version under a reader's feet. + +| `kind` | Name | Source → Target type | Navigational? | Follow rule | +|---|---|---|---|---| +| 0 | `sameAs` | DATA → DATA | **No** (canonicalization only) | Not followed. Used by clients/indexers to pick a canonical representative for dedup (§4.2). A navigational walk that lands on a DATA does **not** chase `sameAs`. Non-followed terminal. | +| 1 | `supersededBy` | DATA → DATA | **No** (breadcrumb only) | **NOT auto-followed** (James ratified 2026-06-20). An active `supersededBy` means "a newer version of this DATA exists at `target`," but the follower does **not** advance to it — "latest" is reached by the path/placement, not by chasing this edge. The follower **stops at the DATA holding the edge** and returns `Resolved`. Clients/indexers MAY walk the chain deliberately (a discoverable breadcrumb); the navigational follower does not. Non-followed terminal. | +| 2 | `symlink` | ANCHOR → (ANCHOR or DATA) | **Yes** | When path resolution lands on an Anchor that is the **source** of a `symlink`, follow to `target`. If `target` is an ANCHOR, continue path resolution there (remaining path segments, if any, resolve under it). If `target` is a DATA, that DATA is the resolved file. The **only** auto-followed kind. | +| 3 | `relatedVersion` (convention) | (untyped) | **No** | Never auto-followed (ADR-0050). A discovery hint only. Non-followed terminal. | +| ≥ 4 | reserved | (untyped) | **No** | Recorded by the resolver, not type-checked, **not followed** by a conformant navigational walk. Meaning is assigned by a future taxonomy revision, never by silent client guesswork. Non-followed terminal. | + +**Rationale for `symlink`-only following.** `symlink` is a *navigational shortcut* — "this path points at that node" — exactly what navigation means, so it is followed. `supersededBy` is a *directed version chain* whose terminal ("newest") is real but is reached the EFS way — by the **path's placement PIN**, which the publisher re-points to the newest DATA — not by the follower chasing the chain. Auto-following `supersededBy` would mean a fixed DATA UID silently resolves to a different (newer) version, violating "no silent revision." `sameAs` is an *equivalence relation* (dedup) with no inherent direction or terminal; walking it as if directional would teleport navigation arbitrarily, so it is resolved by *canonicalization* (§4.2), not navigation (the SKOS discipline ADR-0050 cites: keep equivalence out of the follow path). The net: the follower auto-follows only `symlink`; `supersededBy`/`sameAs`/reserved are non-followed terminals. + +--- + +## 3. Hop cap `D_MAX` + +- **`D_MAX = 16` hops** is the default navigational ceiling (James ratified 2026-06-20). +- **Hard ceiling = 32 hops.** A reader MUST NOT walk further than 32 hops regardless of configuration. `D_MAX` is a *policy* knob between 1 and 32; 32 is the *structural* ceiling. +- A "hop" is one followed navigational edge — i.e. a `symlink` (the only auto-followed kind, §2). Plain path-segment descent (`resolvePath`) is **not** a hop and is bounded separately by `MAX_ANCHOR_DEPTH = 256` (`EFSIndexer.sol:149`, ADR-0065). The two budgets are independent: a deep path with no redirects costs 0 hops; a shallow path through 17 chained symlinks exceeds `D_MAX`. + +**Return contract on cap exceedance.** When a walk would take a 17th hop (i.e. it has already followed `D_MAX` edges and the current node still has a lens-visible navigational edge out), the walk **stops** and returns status `DepthExceeded`, surfacing the **last node reached** (the node at depth `D_MAX`) as the partial result. It does **not** revert and does **not** silently return the entry node. `DepthExceeded` is a distinct status from `Dangling` — the difference matters for diagnostics (a too-long-but-healthy chain vs. a broken target). + +**Why 16 (and why a 32 ceiling).** ADR-0050 proposed `D_MAX ≈ 8`; James ratified **16** (2026-06-20). Sixteen covers any long real symlink chain with comfortable headroom: a symlink-of-symlink beyond 2-3 levels is already a configuration smell, so 16 truncates nothing legitimate, and the gas cost is trivial — 16 × a per-hop `getAttestation` + lens scan is a tiny constant for a read-only `eth_call`. (`sameAs` and `supersededBy` are non-followed terminals, §2, so they consume **zero** hops; only chained symlinks count toward `D_MAX`.) The hard ceiling 32 borrows the *bounded-walk* discipline of the original `MAX_ANCHOR_DEPTH = 32` (ADR-0021): an on-chain walk must never be unbounded, and 32 is the historically-blessed "deep enough that nothing real reaches it, shallow enough that gas is predictable" number for read-side walks. (the *anchor-depth* cap is 256 (ADR-0068; ADR-0065 had set 1024) because real filesystems nest deeply; redirect *chains* have no such real-world depth pressure, so the small cap stays.) + +--- + +## 4. Cycle handling + +Two distinct concerns live under "cycles." Keep them separate — they belong to different layers. + +### 4.1 Navigational cycle-stop (on-chain follower — required) + +The navigational walk maintains a **visited-set** of node UIDs seen *in this walk*. Before following an edge to `target`, if `target` is already in the visited-set, the walk **stops** and returns status `CycleStopped`, surfacing the node at which the cycle was detected (the last node before the repeat). Because only `symlink` is auto-followed (§2), the cycles the follower can actually encounter are **symlink cycles**. This catches: + +- a **direct cycle** the resolver could not (A `symlink` B authored by one attester, B `symlink` A authored by another — each write is individually loop-free, so `AliasResolver`'s `SelfLoop` guard at `AliasResolver.sol:179` does not fire); +- a **multi-hop cycle** (A → B → C → A). + +The visited-set is bounded by `D_MAX` (the walk stops at `D_MAX` anyway), so it is a small fixed-size array — no unbounded memory. **The on-chain navigational follower needs only this**: bounded walk + visited-set + cycle-stop. It does NOT compute strongly-connected components. + +### 4.2 `sameAs` canonicalization (client/indexer — defined, not on-chain-navigational) + +For **dedup** — "which DATA UID is the canonical representative of this `sameAs` cluster?" — the rule is: + +> The canonical representative of a `sameAs` strongly-connected component (SCC) is the **lowest UID in the SCC**, by unsigned `bytes32` byte-comparison. + +This is **start-independent and deterministic**: every reader, regardless of which member they entered from, lands on the same representative. It is the rule ADR-0050 §"Cycle rule" fixes ("lowest UID in the strongly-connected component… not last safe node, which is entry-dependent and attacker-influenceable"). + +**Layer assignment (be explicit):** + +- The **on-chain navigational follower** (router/view) does the bounded-walk-with-cycle-stop of §4.1. It does **not** run SCC analysis — graph-walking a `sameAs` cluster to its lowest-UID representative is unbounded-in-the-general-case work that does not belong in a per-read on-chain path. When navigation lands on a DATA, it returns that DATA; `sameAs` canonicalization is layered *above* by the caller if dedup is wanted. +- **Clients and off-chain indexers** that present dedup ("you already have this file") compute the `sameAs` SCC (lens-scoped, §5) and resolve to its lowest UID. They MUST use the lowest-UID-in-SCC rule so all conformant clients converge. + +`supersededBy` cycles never arise in the navigational follower, because `supersededBy` is a non-followed terminal (§2) — the follower stops at the first DATA holding such an edge, so it cannot loop through a version chain at all. A client/indexer that deliberately walks a `supersededBy` chain (the discoverable breadcrumb) handles a malformed looping chain with its own bounded walk + visited-set; there is no "canonical version" to elect, unlike a `sameAs` equivalence class. + +--- + +## 5. Lens precedence + +Resolution is **per-lens, first-attester-wins** (ADR-0031). The reader carries an ordered lens set `[L0, L1, …, system]` (≤ `MAX_LENSES = 20`, `EFSRouter.sol:154`). + +- A REDIRECT is **followable only if its attester is a member of the active lens set.** A redirect authored by an attester outside the lens set is **invisible** — it is never followed, never enters the visited-set, never canonicalizes. Foreign redirects cannot reroute a viewer's resolution. This is the structural enforcement of viewer sovereignty (ADR-0031): nobody teleports your reads but an attester you chose to trust. +- A `symlink` resolves **within the same lens scope** as the surrounding path walk. After following a symlink to an Anchor, continued path resolution uses the *same* lens set in the *same* precedence order. The lens scope does not reset or widen at a symlink boundary. +- When more than one lens-visible navigational redirect exists out of a node (e.g. two trusted attesters each assert a different `symlink` for the same Anchor), **first-attester-wins by lens order** selects which to follow: the redirect authored by the earliest lens in the list. Ties within a single attester (a malformed double-assert) break by lowest redirect UID — deterministic and start-independent, the same discipline as §4.2. + +--- + +## 6. Dangling targets + +A target is **dangling** at read time if any of the following hold when the walk attempts to follow to it: + +- `target` does not resolve to an existing attestation; +- the target attestation is **revoked** (REDIRECTs are revocable; default reads exclude revoked, ADR-0051); +- the target **fails the kind's read-time typing** (e.g. a `symlink` whose `target` is no longer an ANCHOR-or-DATA, or a `sameAs`/`supersededBy` target that is not DATA). Write-time typing was enforced at attest, but targets are independent attestations whose validity is not frozen by the redirect; a reader re-checks. + +On encountering a dangling target the walk **stops** and returns status `Dangling`, surfacing the **last good node** (the node holding the dangling edge). It **never reverts**. Because the follower auto-follows only `symlink` (§2), the only `Dangling` it can produce is a **dangling symlink** → "no file at this path" (404-equivalent at the router). A `supersededBy` target that dangles is never reached by the follower (it is a non-followed terminal); a client/indexer that deliberately walks a version chain applies the same "broken pointer absent → last good DATA is the latest reachable version" rule in its own walk. + +--- + +## 7. WHITEOUT is a live, separate schema — never a REDIRECT terminal (binding) + +This spec defines a **positive-only** *redirect* follow order. WHITEOUT — the **negative terminal** ("this path is suppressed/empty in this lens — STOP, serve empty, do not fall through to lower lenses or `system`") — now ships as its **own EAS schema + `WhiteoutResolver`** (ADR-0055, Accepted & **implemented**; `specs/02` §WHITEOUT, `specs/04` §8f). Two consequences for a **redirect** reader: + +- **The negative terminal is applied OUTSIDE this follower.** Suppression is evaluated by the `EFSRouter` path-walk and `EFSFileView` listings against the `WhiteoutResolver` (a per-name whiteout on a path Anchor), **not** by the redirect follower in §8. Path resolution applies the whiteout terminal independently of — and ahead of — any redirect follow: a whited path serves empty and the follower is never consulted for it. So this spec's *redirect* follower still has **no input that returns suppression**: `Suppressed-reserved` (§8) remains a defined-but-never-returned status **for the redirect follower** — now because WHITEOUT lives in a separate schema handled elsewhere, **not** because it is unbuilt. The control-flow slot was kept so the follower never had to change when WHITEOUT shipped. +- **No REDIRECT kind is ever a whiteout — the sentinel encodings stay banned.** The encodings ADR-0055 rejected — a reserved REDIRECT `kind`, a `weight < 0` TAG, a sentinel PIN→PROPERTY, a tombstone DATA — remain **banned for durable seeding** and MUST NOT be interpreted as suppression by any conformant redirect reader. A `kind >= 3` REDIRECT is an **inert, ignored, non-followed terminal** (§2), never a negative terminal. Suppression comes **only** from the dedicated WHITEOUT schema. A redirect reader that sees no whiteout-as-redirect is correct; a reader that synthesizes suppression from a REDIRECT kind is non-conformant. + +The negative terminal is evaluated in the **same lens precedence** as positives (§5): a WHITEOUT by `Lk` suppresses lenses strictly below `Lk` and is transparent to lenses above `Lk` — but, again, that evaluation lives in the router / file view against `WhiteoutResolver`, not in this redirect follower. + +--- + +## 8. Reference algorithm (on-chain navigational follower) + +Pseudocode for the bounded, lens-scoped, cycle-stopping navigational follower. `firstInLensRedirect(node, lenses)` returns the first-attester-wins (§5) active, lens-visible redirect out of `node` (of **any** kind), or ∅ — the follower then decides whether the kind is auto-followed. + +``` +struct Result { + bytes32 uid; // resolvedDataUID or resolvedAnchorUID (the surfaced node) + bool isData; // true => uid is a DATA; false => uid is an ANCHOR + Status status; // Resolved | Dangling | CycleStopped | DepthExceeded | Suppressed-reserved +} + +// Entry: `node` is the Anchor the path walk landed on (symlink case) +// or the DATA just placed by a PIN (version case — note: the follower does +// NOT chase supersededBy; "latest" comes from the path's placement PIN, §2). +function resolve(node, isData, lenses) -> Result { + visited = {} // bounded by D_MAX + hops = 0 + + loop { + // ── negative terminal (WHITEOUT, ADR-0055) — applied OUTSIDE this follower ── + // if negativeTerminal(node, lenses): return Result(0, false, Suppressed-reserved) + // ^ The WHITEOUT schema is LIVE, but suppression is evaluated by the router / + // file view against WhiteoutResolver, NOT by this redirect follower — no + // redirect input produces it. The branch is kept reserved so this follower, + // when built, can terminate "empty, stop, no fall-through" without restructuring. + + // ── pick the first lens-visible redirect out of `node` ─────────── + edge = firstInLensRedirect(node, lenses) + if edge == ∅: + return Result(node, isData, Resolved) // terminal: nothing to follow + + // ── follow ONLY symlink (2); every other kind is a non-followed ── + // terminal (supersededBy/sameAs/reserved). James ratified 2026-06-20. + if edge.kind != symlink: + return Result(node, isData, Resolved) // non-followed terminal + + // ── dangling check (existence, revocation, read-time typing) ───── + if !targetValidForKind(edge.target, edge.kind): + return Result(node, isData, Dangling) // surface last good node + + // ── depth cap (hard ceiling 32; default D_MAX = 16) ────────────── + if hops == D_MAX: + return Result(node, isData, DepthExceeded) // surface last node + + // ── cycle-stop (visited-set within this walk) ──────────────────── + if edge.target ∈ visited: + return Result(node, isData, CycleStopped) // surface node before repeat + visited.add(node) + + // ── advance ────────────────────────────────────────────────────── + node = edge.target + isData = (schemaOf(edge.target) == DATA) // symlink may cross ANCHOR→DATA + hops += 1 + // If symlink landed on an ANCHOR with remaining path segments, the caller + // resumes resolvePath() under `node` in the SAME lens scope (§5), then + // re-enters resolve() if that lands on a new symlink-source. + } +} +``` + +Notes: +- `supersededBy` and `sameAs` are **non-followed terminals** — the loop returns `Resolved` at the node holding either. `supersededBy` is a discoverable version-chain breadcrumb (clients/indexers may walk it deliberately, §2); `sameAs` canonicalization (§4.2) is a separate, caller-layered, off-the-navigational-path computation. Only `symlink` advances the loop. +- The follower returns a node + status; the **router** maps `Resolved`+DATA → serve that DATA's best mirror (existing `_findDataAtPath` + `_getBestMirrorURI` flow), `Dangling`/`DepthExceeded`/`CycleStopped` → 404-equivalent (with the surfaced node available for diagnostics), `Suppressed-reserved` → serve empty without fall-through (never produced by this follower — the live WHITEOUT terminal is applied by the router / file view against `WhiteoutResolver`, §7). +- The follower, once built, is a **stateless redeployable view** — it adds no kernel storage (§ADR-0067 Consequences). It can be re-deployed without touching any frozen schema UID, so the exact landing site (EFSFileView vs EFSRouter vs a dedicated follower) is not frozen by this spec (flagged in ADR-0067 SPICY). Because the implementation is **deferred** (see "Implementation status"), even the reverse-by-source read it needs from `AliasResolver` (the earlier draft's `getActiveRedirect` index) is not present today and will be added — additively, off the frozen REDIRECT schema UID — when the follower is built. + +--- + +## 9. Conformance Vectors + +Every conformant reader (on-chain follower, router, off-chain indexer, client) MUST produce these results. UIDs are symbolic and ordered `A < B < C < …` by `bytes32` byte-comparison. All redirects are authored by a lens-visible attester unless stated otherwise. "Latest"/"resolved node" is the `Result.uid`; status is `Result.status`. + +| # | Name | Setup | Expected result | +|---|---|---|---| +| 1 | Simple symlink | Path `/x` Anchor `Ax` is `symlink` source → DATA `D1`. | `{uid: D1, isData: true, status: Resolved}`. Router serves `D1`'s mirror. 1 hop. | +| 2 | Symlink to anchor + descent | `Ax` (`/x`) `symlink` → Anchor `Ay` (`/y`); requested path `/x/file`; `Ay` has child `file` placed → `D2`. | Resolve `Ax`→`Ay` (1 hop), resume `resolvePath(Ay,"file")` in same lens scope, land on PIN → `{uid: D2, status: Resolved}`. | +| 3 | `supersededBy` is a non-followed terminal | DATA `D1` `supersededBy` `D2`; `D2` `supersededBy` `D3`. Read lands on `D1`. | `{uid: D1, isData: true, status: Resolved}`. **0 hops** — `supersededBy` is NOT auto-followed (James ratified 2026-06-20). The follower stops at `D1`. "Latest" is reached by the path's placement PIN, not by chasing this chain; the chain is a discoverable breadcrumb for clients/indexers (path=newest / UID=exact). | +| 4 | Depth exceeded (symlinks) | Chain of 17 `symlink` hops `A0→A1→…→A17` (all lens-visible, healthy). `D_MAX = 16`. Read lands on `A0`. | After 16 hops the walk is at `A16`, which still has a lens-visible symlink out → `{uid: A16, status: DepthExceeded}`. Not `A17`, not reverted. | +| 5 | Direct cycle (symlinks) | `A1` `symlink` `A2` (attester α); `A2` `symlink` `A1` (attester β); both in lens set. Each write passes `AliasResolver` (no direct self-loop). Read lands on `A1`. | Walk: `A1`→`A2` (1 hop), next edge target `A1` ∈ visited → `{uid: A2, status: CycleStopped}`. No infinite loop, no revert. (Only symlinks can cycle the follower — `supersededBy` cycles never arise, §4.2.) | +| 6 | Multi-hop cycle (symlinks) | `A1`→`A2`→`A3`→`A1` (all `symlink`, lens-visible). Read lands on `A1`. | `A1`→`A2`→`A3` (2 hops), next target `A1` ∈ visited → `{uid: A3, status: CycleStopped}`. | +| 7 | Dangling target (revoked) | `A1` `symlink` `A2`; `A2` exists. `A2` `symlink` `A3`, but `A3` is revoked / does not exist. Read lands on `A1`. | `A1`→`A2` (1 hop), edge out of `A2` is dangling → `{uid: A2, status: Dangling}`. `A2` is the last good node. Router 404s only if `A2` resolves to no file. | +| 8 | Cross-lens not followed | `A1` `symlink` `A2` authored by attester γ. Reader's lens set is `[α, system]`; γ ∉ set. Read lands on `A1`. | γ's redirect is invisible → no edge out of `A1` → `{uid: A1, status: Resolved}`. The foreign redirect does not reroute. 0 hops. | +| 9 | `supersededBy` not followed even with a fork | `D1` `supersededBy` `D2` (attester α, lens index 0); `D1` `supersededBy` `D9` (attester β, lens index 1). Lens set `[α, β]`. Read lands on `D1`. | `{uid: D1, status: Resolved}`, **0 hops** — neither supersession is taken; `supersededBy` is a non-followed terminal regardless of lens precedence. The competing edges are breadcrumbs only. | +| 10 | `sameAs` not navigated | `D1` `sameAs` `D2`. Navigational read lands on `D1` (e.g. a PIN placed `D1`). | `sameAs` is not in the follow loop → `{uid: D1, status: Resolved}`. (Dedup layer *may* separately canonicalize the `{D1,D2}` cluster to lowest UID `D1` — vector 11.) | +| 11 | `sameAs` canonicalization (client/indexer) | `sameAs` cluster SCC `{C, A, B}` (e.g. `A↔B`, `B↔C`, all lens-visible). Dedup query enters from `C`. | Canonical representative = **lowest UID in SCC = `A`**, regardless of entry node. (On-chain navigational follower is not required to compute this; client/indexer layer is.) | +| 12 | WHITEOUT not produced by the redirect follower | A WHITEOUT at path `/x` in lens `Lk`. WHITEOUT is **live and seedable** (ADR-0055), but it is a **separate schema**, not a REDIRECT — and a `kind ≥ 3` REDIRECT must NOT be read as suppression (§7, §10). | The **redirect follower** returns no suppression from any redirect input: `Suppressed-reserved` is never returned here. The live WHITEOUT terminal (`serve empty, STOP, no fall-through`) is applied by the `EFSRouter` path-walk / `EFSFileView` against `WhiteoutResolver`, **outside** this follower. The vector pins that the follower's reserved slot stays never-returned. | + +--- + +## 10. Seeding ban (binding — permanent) + +**Suppression is expressed ONLY by the dedicated WHITEOUT schema (ADR-0055, live).** No durable EFS data may encode whiteout/suppression via any sentinel — **this ban does not expire now that WHITEOUT ships; it is permanent.** Using a *follow* vocabulary (REDIRECT) or a *weight* / *placement* primitive (TAG/PIN/DATA) as a *stop* terminal is a category error whose cost — a forever-fact every conformant reader must honor — is identical before and after WHITEOUT exists; the only correct encoding of "this path is suppressed" is a WHITEOUT attestation. Specifically banned on permanent data, forever: + +- a **reserved/sentinel REDIRECT kind** (e.g. `kind ≥ 4` meaning "void") — `AliasResolver` does not type-check `kind ≥ 3` (`AliasResolver.sol:193`), so such a redirect is *writable* but is an **inert, ignored redirect** to every conformant reader (it is never a navigational kind per §2); it MUST NOT be read as suppression; +- a **`weight < 0` TAG** used as suppression; +- a **sentinel PIN → reserved PROPERTY** "deleted" marker; +- a **tombstone DATA** UID meaning "deleted." + +Any such encoding, once on permanent data, becomes a forever-fact every client must honor — foreclosing the clean WHITEOUT schema and baking in a category error (a *follow* vocabulary used as a *stop* terminal). The generic ban above is the only protection needed; per ADR-0055 §3 (James-ratified) no on-chain guard is added, because a stray reserved-kind REDIRECT is inert and does not foreclose the independent WHITEOUT schema. + +**This is why this spec must be pinned before any durable REDIRECT data is seeded on Sepolia.** A redirect minted before the resolution rules are fixed either carries no defined read behavior or risks an ad-hoc convention hardening into a forever-fact. diff --git a/specs/10-file-metadata-encoding.md b/specs/10-file-metadata-encoding.md new file mode 100644 index 00000000..f18f2d0f --- /dev/null +++ b/specs/10-file-metadata-encoding.md @@ -0,0 +1,352 @@ +# File-Metadata Encoding Convention (`contentHash`, `size`, `cid`) + +> **Status: Accepted** (James ratified 2026-06-20 — see ADR-0064). This spec +> defines the **canonical string format** for the reserved-key file-stat +> PROPERTYs that ADR-0049 moved out of DATA. It changes **no schema**: PROPERTY +> remains `string value` (frozen). It pins the *encoding* of that value, because +> PROPERTY is **non-revocable** (ADR-0052) — a value minted under an ambiguous +> format is permanent. This spec MUST be pinned before any durable data is +> seeded under these keys. + +**Related:** ADR-0049 (DATA empty; `contentHash`/`size`/`cid` as reserved-key +PROPERTYs), ADR-0052 (PROPERTY non-revocable; the PIN is the revocable claim), +ADR-0005 (`contentType` reserved-key precedent), ADR-0034 (`name` reserved key), +ADR-0064 (this convention's decision record), specs/02 §2 (Property Schema), +specs/04 (upload workflow), `EFSIndexer.PropertyCreated` (the `valueHash` topic). + +--- + +## 1. Why an encoding convention is needed + +A file's **identity** in EFS is its DATA UID (ADR-0049). Everything *about* the +file — content type, display name, and the file-stat metadata covered here — +hangs off that UID as a lens-scoped PROPERTY, bound by a cardinality-1 PIN +(specs/02 §2). The PROPERTY schema field is `string value`, frozen and +format-agnostic: it carries any string. That freedom is the problem. The +workflow docs historically implied a **bare keccak256 hex** for `contentHash` +(`specs/04`). A bare digest is: + +- **Algorithm-ambiguous.** A 64-hex-char string is indistinguishable between + keccak-256 (EVM-native) and sha2-256 (IPFS-native). A verifier in 2050 — SDK + or Solidity — cannot tell which function to run against the bytes, so it + cannot verify the claim at all. +- **Permanent.** PROPERTY values are non-revocable interned content (ADR-0052). + Once a bare-hex `contentHash` is seeded and PINned, that exact string is the + permanent canonical value; the only retraction is revoking the *binding* (the + PIN), never the value. An early bad format cannot be cleaned up. + +The fix is a **self-describing** value: the algorithm and digest length travel +inside the string itself. The string answers "which hash function?" without an +out-of-band registry. + +--- + +## 2. Canonical format: multibase-prefixed multihash + +**`contentHash` values are encoded as a [multibase]-prefixed [multihash].** + +A **multihash** is ``, each of the +first two written as an unsigned-varint. For all hash functions EFS uses today +the code and length are single bytes (< 0x80), so a multihash is literally +`0xCC 0x20 <32 digest bytes>` for a 256-bit digest. The **multibase** prefix is +a single leading character naming the base the bytes are then rendered in. + +This makes the value self-describing along two axes — *which function* (the +multihash code) and *which text encoding* (the multibase prefix) — with one +short, registry-free, IPFS/IPLD-native string. + +### 2.1 Multihash function codes used by EFS + +| Hash function | Multicodec name | Code (hex) | Digest length | Use | +|---|---|---|---|---| +| sha2-256 | `sha2-256` | `0x12` | 32 (`0x20`) | **Canonical/default** for `contentHash` (James ratified 2026-06-20); IPFS/IPLD-native; the digest inside a CIDv1 — so a file's EFS `contentHash` and its IPFS CID share one digest | +| keccak-256 | `keccak-256` | `0x1b` | 32 (`0x20`) | Optional alternate the self-describing format CAN carry (EVM-native); NOT the default | + +These two are the **only** registered functions for `contentHash` at genesis. +Both are drawn from the canonical [multicodec table]; EFS does not invent codes. +Additional functions (e.g. `blake3` `0x1e`, `sha3-256` `0x16`) MAY be added by a +later revision of this spec, but a writer MUST NOT emit a function not listed +here without that revision — an unrecognized code is a permanent, unverifiable +value. + +### 2.2 Multibase: `base16` (lowercase hex) is canonical; `base32` is accepted-on-read + +| Base | Multibase prefix | Alphabet | Status | +|---|---|---|---| +| `base16` | `f` | lowercase hex `0-9a-f` | **Canonical** for `contentHash` — emit this | +| `base32` | `b` | RFC 4648 lowercase, no padding | Accepted on read (matches CIDv1 default; see §4) | + +**Writers MUST emit `base16` (prefix `f`) for `contentHash`.** Rationale: +base16 round-trips trivially to/from a Solidity `bytes32` (strip `f`, strip the +2-byte multihash header, parse 64 hex chars), so a Solidity verifier needs only +the non-reverting hex parser already in the codebase (ADR-0019) — no base32 +decoder on-chain. Lowercase is fixed (not uppercase `F`/base16upper) to give +**exactly one** canonical string per digest, mirroring the +one-representation-per-name discipline of anchor names (specs/02 §1). + +**Readers SHOULD accept both `f` (base16) and `b` (base32)** and decode by the +multibase prefix. `base32` appears because a `cid` value (§4) is `base32` by +CIDv1 default; a reader that already decodes multibase handles both for free. + +> **Canonical-string warning.** Because the value is non-revocable and is the +> `keccak256(bytes(value))` preimage behind `PropertyCreated.valueHash`, two +> different encodings of the *same* digest (e.g. `f1b20…` vs `b…`, or lowercase +> vs uppercase hex) are **distinct interned values** with **distinct** +> `valueHash` topics — they will not dedup against each other and both persist +> forever. Emitting the single canonical form (`base16` lowercase) is what keeps +> the interning/dedup story (ADR-0052) and the off-chain value index coherent. + +### 2.3 The exact `contentHash` string + +``` +contentHash = || baseEncode( || || ) +``` + +For a sha2-256 digest `D` (32 bytes) — the **canonical/default** function (James +ratified 2026-06-20) — the canonical value is: + +``` +"f" + lowerhex( 0x12 || 0x20 || D ) +``` + +i.e. the literal string `f1220` followed by the 64 lowercase-hex chars of the +digest. (`f` = base16, `12` = sha2-256, `20` = length 32.) A reader strips `f`, +confirms the `1220` header, and the remaining 64 hex chars are the sha2-256 +digest as a `bytes32` — the **same digest embedded in the file's IPFS CID** (§4), +so EFS's `contentHash` and its CID carry one hash, not two. + +For the optional keccak-256 alternate, the header is `1b20` instead (`f1b20…`, +`1b` = keccak-256); a reader dispatches on the multihash code either way. + +--- + +## 3. `size` + +**`size` is the content length in bytes, encoded as a base-10 ASCII string with +no leading zeros, no sign, no separators, and no unit suffix.** + +- `value = "0"` for empty content; `value = "1024"` for 1 KiB; etc. +- **No leading zeros** (`"007"` is non-canonical) — one canonical string per + length, so two writers agree byte-for-byte and the value interns/dedups. +- **No fixed width.** A fixed-width or zero-padded form was rejected: it adds no + parsing benefit (decimal-string → integer is trivial in JS and Solidity) and + breaks the no-leading-zeros canonical rule. Arbitrary length is fine — + PROPERTY `value` is an unbounded string and there is no anchor-name length cap + (ADR-0065), so even an astronomically large `size` fits. +- Decimal (not hex) is chosen for human readability and because it is the + obvious default a naive SDK author reaches for, minimizing the + ambiguity surface. `size` is **not** self-describing (it needs no algorithm + tag — a byte count is unambiguous), so the simplest unambiguous encoding wins. + +`size` is the byte length of the **content the `contentHash` covers** — the same +preimage. A `size`/`contentHash` pair from one attester describes one byte +string. + +--- + +## 4. `cid` + +**`cid` carries an IPFS [CID] (Content IDentifier) as its own canonical +self-describing string** — the standard `cid.toString()` form, which for CIDv1 +is `base32` (multibase prefix `b`), e.g. `bafkrei…`. + +A CID is *already* a superset of a multihash: ``. +So a `cid` value embeds a sha2-256 (or other) digest **plus** the IPLD content +codec (`raw` `0x55`, `dag-pb` `0x70`, …) that says how the bytes are framed for +IPFS. That extra codec is exactly what a `contentHash` multihash omits. + +### 4.1 `cid` vs `contentHash` — when to use which + +They are **distinct reserved keys with distinct jobs**, and both MAY coexist on +one DATA: + +- **`contentHash`** — a *bare digest of the file bytes*, function-tagged + (canonical/default **sha2-256**, §2). Use it for content-identity / + dedup-prevention lookups and for verification (the base16 form round-trips to a + `bytes32` regardless of function). Because the default sha2-256 digest is the + *same digest a CIDv1 embeds*, a file's `contentHash` and its `cid` carry **one + hash, not two**. `contentHash` says nothing about IPFS framing (no IPLD codec) — + that is what `cid` adds. +- **`cid`** — an *IPFS-addressable identifier*. Use it when the content is (or + will be) retrievable from IPFS, so the value doubles as the key for an + `ipfs://` MIRROR. A `cid` is the right key for the **zero-download remote + pin** (ADR-0049): pinning a 10 GB IPFS file mints empty DATA + an + `ipfs://CID` MIRROR + a `cid` PROPERTY, extracting the sha2-256 digest already + inside the CID with no bytes downloaded. + +### 4.2 Relationship to `ipfs://` MIRROR URIs + +A `cid` PROPERTY and an `ipfs://` MIRROR are **complementary, not +redundant**, and SHOULD agree: + +- The **MIRROR** (`uri = "ipfs://bafkrei…"`) is a *retrieval* claim — "fetch the + bytes here" — consumed by the router's transport layer (specs/02 §3a). +- The **`cid` PROPERTY** (`value = "bafkrei…"`) is a *metadata/identity* claim — + "this content's CID is X" — consumed by content-identity / dedup lookups and + the property index, independent of whether any mirror currently resolves. + +A writer adding an `ipfs://` MIRROR SHOULD also attach the matching `cid` +PROPERTY so the content is discoverable by CID without scanning mirror URIs. The +bare CID (no `ipfs://` scheme prefix) is the canonical `cid` *value*; the scheme +prefix belongs only on the MIRROR `uri`. + +--- + +## 5. Reserved-key PROPERTY registry + +The set of reserved PROPERTY **key-anchor names** is **closed and listed here**. +Each is the `name` of a `forSchema = PROPERTY_SCHEMA_UID` key anchor under a +container, bound to a value by a cardinality-1 PIN (specs/02 §2). A client MUST +NOT overload a reserved key for a different purpose. (Reservations are +per-container conventions, exactly as ADR-0034 framed `name`.) + +| Reserved key | Container | Canonical value format | Defined by | +|---|---|---|---| +| `contentType` | DATA | IANA media type, e.g. `image/png`; fallback `application/octet-stream` | ADR-0005 | +| `name` | any | display string, NFC-normalized; SHOULD be ≤ 64 chars (clients truncate, never reject) | ADR-0034 | +| `contentHash` | DATA | multibase-`base16` multihash; canonical/default `f1220…` (sha2-256); optional alternate `f1b20…` (keccak-256) — **§2** | ADR-0049 + **this spec** | +| `size` | DATA | base-10 ASCII byte count, no leading zeros — **§3** | ADR-0049 + **this spec** | +| `cid` | DATA | IPFS CID string (CIDv1 default `base32`, `bafkrei…`) — **§4** | ADR-0049 + **this spec** | + +Non-reserved but conventional keys (free for client use, NOT governed here): +`previousVersion` (a DATA UID), `description`, `icon` (specs/02 §2). + +### 5.1 Multiple coexisting hashes + +ADR-0049 permits multiple coexisting hash claims. They coexist **by encoding, not +by minting extra keys**: because a `contentHash` value is self-describing, the +*same* `contentHash` key anchor can hold either a sha2-256 (`f1220…`, the +canonical default) or a keccak-256 (`f1b20…`, the optional alternate) value, and a +reader dispatches on the multihash code. The cardinality-1 PIN means one +attester's `contentHash` slot holds **one** value at a time, so: + +- With **sha2-256 canonical** (James ratified 2026-06-20), the common case needs + no dual hash at all: `contentHash` and `cid` share the *same* sha2-256 digest, + so one digest serves both EVM-side identity/dedup and the IPFS CID. An attester + wanting an additional **keccak** claim (e.g. for a contract that wants the + EVM-native function) carries it in the self-describing `contentHash` slot + (`f1b20…`) — or keeps sha2 in `contentHash` and the CID in `cid`. +- This avoids needing a `contentHash:keccak` / `contentHash:sha2` keyspace + explosion or a multi-value (TAG) binding. The self-describing prefix plus the + `contentHash`/`cid` split covers the dual-hash case cleanly. + +> **Resolved (James ratified 2026-06-20, ADR-0064):** keep the **single +> self-describing `contentHash` slot** with **sha2-256 canonical** (sharing the +> CID digest); keccak-256 remains an optional alternate the format CAN carry. No +> algorithm-suffixed keyspace. + +--- + +## 6. Verification semantics + +- **The hash is a lens-scoped *claim*, never authenticated identity.** Per + ADR-0049 the bytes are not on-chain, so the kernel cannot and does not verify + `contentHash`↔bytes. A `contentHash` is what *one attester* asserts about the + content; a reader trusts it exactly insofar as they trust that attester (lens + scoping, ADR-0014). It is **not** the file's identity (the DATA UID is). +- **Who verifies, and when.** Verification is the **consumer's** job, **at read + time**, off-chain (or in a verifying contract that has the bytes): + 1. Fetch the bytes via a MIRROR. + 2. Read the lens-scoped `contentHash` value; decode the multibase + multihash + header to learn the function (`0x1b` ⇒ keccak-256, `0x12` ⇒ sha2-256). + 3. Recompute that function over the fetched bytes; compare digests; compare + `size` to the byte length. + 4. A mismatch means the *attester's claim is wrong* (or the mirror is lying) — + surface it; it is never a kernel-level failure. +- **Self-describing is what makes step 2 possible.** A bare-hex value gives the + verifier no function to run; the multihash code is load-bearing for + verifiability. +- **Retraction is by revoking the PIN binding, never the value** (ADR-0052). To + withdraw a wrong `contentHash`, an attester revokes the PIN that binds it (or + supersedes it with a new PIN at the same slot). The non-revocable value + attestation itself stays interned — possibly shared by other bindings — and is + never the unit of retraction. +- **`valueHash` is not the content hash.** `EFSIndexer.PropertyCreated` emits + `valueHash = keccak256(bytes(value))` — the keccak of the *string* (e.g. of + `"f1b20c5d2…"`), used as the value's interning/dedup content key. It is **not** + `keccak256(file bytes)`. Do not conflate the two; the canonical `contentHash` + string defined here is what gets interned, and *its* keccak is `valueHash`. + +--- + +## 7. Conformance vectors + +For each input byte string, an SDK author and a Solidity verifier MUST produce +**byte-identical** values. Digests verified against Node `crypto`/`viem` +(`sha256`, `keccak256`); CIDs verified against `multiformats` +(`CID.create(1, raw, sha256)`). The **canonical/default** `contentHash` is the +**sha2-256 `base16`** form (`f1220…`, James ratified 2026-06-20) — bolded below; +it shares its digest with the `cid`. The keccak-256 rows are the optional alternate +the format can carry; the `base32` and `cid` columns are accepted-on-read / +IPFS-side forms. + +### Vector 1 — empty content (`""`, 0 bytes) + +| Field | Canonical value | +|---|---| +| `size` | `0` | +| sha2-256 digest | `0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | +| **`contentHash`** (sha2, base16 — canonical/default) | `f1220e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | +| **`cid`** (CIDv1 raw + sha2-256, shares the digest) | `bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku` | +| keccak-256 digest (alternate) | `0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470` | +| `contentHash` (keccak, base16 — alternate) | `f1b20c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470` | +| `contentHash` (keccak, base32) | `bdmqmlusgagdpoiz4sj7h3mw4y4b4bziawzj4varhhn57vwaelwc2i4a` | + +### Vector 2 — `abc` (3 bytes: `0x61 0x62 0x63`) + +| Field | Canonical value | +|---|---| +| `size` | `3` | +| sha2-256 digest | `0xba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad` | +| **`contentHash`** (sha2, base16 — canonical/default) | `f1220ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad` | +| **`cid`** (CIDv1 raw + sha2-256, shares the digest) | `bafkreif2pall7dybz7vecqka3zo24irdwabwdi4wc55jznaq75q7eaavvu` | +| keccak-256 digest (alternate) | `0x4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45` | +| `contentHash` (keccak, base16 — alternate) | `f1b204e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45` | +| `contentHash` (keccak, base32) | `bdmqe4a3fplvelkkpy7khxkbgzdlgpqgr43rtuzfag3wej5mpuewwyri` | + +### Vector 3 — `hello\n` (6 bytes: `hello` + LF `0x0a`) + +| Field | Canonical value | +|---|---| +| `size` | `6` | +| sha2-256 digest | `0x5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03` | +| **`contentHash`** (sha2, base16 — canonical/default) | `f12205891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03` | +| **`cid`** (CIDv1 raw + sha2-256, shares the digest) | `bafkreicysg23kiwv34eg2d7qweipxwosdo2py4ldv42nbauguluen5v6am` | +| keccak-256 digest (alternate) | `0x1d63660020a5b5062fb35d9f82afa81581442281c43343763ab1d340e9861bae` | +| `contentHash` (keccak, base16 — alternate) | `f1b201d63660020a5b5062fb35d9f82afa81581442281c43343763ab1d340e9861bae` | +| `contentHash` (keccak, base32) | `bdmqb2y3gaaqklnigf6zv3h4cv6ublakeeka4im2doy5ldu2a5gdbxlq` | + +### 7.1 Reconstruction recipe (for an implementer) + +To regenerate the canonical `contentHash` from the file bytes: + +1. Compute the **sha2-256** digest `D` of the bytes (canonical/default function). +2. Prepend the multihash header bytes `0x12 0x20` (sha2-256, length 32) to `D`. +3. Lowercase-hex-encode the 34-byte result. +4. Prepend the multibase prefix `f`. → `f1220<64-hex sha256>`. + +(For the optional keccak-256 alternate, use function code `0x1b` in step 2 → +`f1b20…`. A reader dispatches on the multihash code regardless.) + +To regenerate `cid` (CIDv1, raw codec, sha2-256): `CID.create(1, 0x55, +multihash(0x12, sha256(bytes))).toString()`. The leading `bafkrei` is the +CIDv1/base32/raw/sha2-256 signature shared by every vector above. Note the `cid` +and the canonical `contentHash` embed the **same** sha2-256 digest `D`. + +--- + +## 8. Migration of existing prose + +`specs/04` (upload workflow) currently says "compute `keccak256(bytes)` for +`contentHash`" and "byte count as decimal string" for `size`. The `size` prose +is already correct (§3). The `contentHash` prose MUST be updated to compute the +**sha2-256** digest and emit the canonical multibase-multihash form (`f1220…`, +sha2-256 — James ratified 2026-06-20) rather than a bare keccak `0x…` hex, so the +`contentHash` shares the IPFS CID digest. This must land **before any durable data +is seeded** — the gate ADR-0064 records. (That edit is owned by another agent per +the worktree split; this spec is the normative target it points at.) + +[multibase]: https://github.com/multiformats/multibase +[multihash]: https://github.com/multiformats/multihash +[multicodec table]: https://github.com/multiformats/multicodec/blob/master/table.csv +[CID]: https://github.com/multiformats/cid diff --git a/specs/README.md b/specs/README.md index 21f26c73..bd0ac6f7 100644 --- a/specs/README.md +++ b/specs/README.md @@ -13,7 +13,7 @@ Authoritative description of how EFS works today. If specs and code disagree, th - **`06-Lists-and-Collections.md`** — the `LIST` + `LIST_ENTRY` curated-collection primitive (ADR-0044, ADR-0046): write-time shape enforcement, per-attester lenses, order/label as PIN-bound PROPERTYs. Authoritative for list-related work. - **`07-Sort-Overlay-Architecture.md`** — EFSSortOverlay design: per-parent linked lists, `processItems`, `computeHints`, `ISortFunc` comparators. Required for sort overlay work. - **`08-Custom-Lists-Design-Notes.md`** — **historical design notes** (pre-ADR-0044/0046) exploring the rejected positional-anchor list model. Superseded by the `LIST`/`LIST_ENTRY` primitive in `06`; retained for the sorts-vs-curation reasoning only. Not the implemented model. - -## When to update +- **`09-redirect-resolution.md`** — REDIRECT read-time resolution (ADR-0050/0067): symlink-only following (supersededBy is a non-followed terminal; path=newest/UID=exact), the D_MAX hop cap, cycle-stop, lens precedence, the dangling contract, the WHITEOUT negative-terminal reservation, the reference follower algorithm, and conformance vectors. **Accepted** — gates durable REDIRECT seeding. +- **`10-file-metadata-encoding.md`** — canonical string format for the reserved-key file-stat PROPERTYs (`contentHash`, `size`, `cid`): multibase-multihash (sha2-256 canonical, sharing the IPFS CID digest) + CID, the closed reserved-key registry, verification semantics, and conformance vectors (ADR-0049/0064). **Accepted** — gates durable file-metadata seeding. When code changes alter system behavior visible to consumers (contracts, client UIs, off-chain indexers), update the relevant spec **in the same PR**. Specs lagging behind code is the most common failure mode of this kind of doc. diff --git a/specs/overview.md b/specs/overview.md index 6bd69a61..96ba55ba 100644 --- a/specs/overview.md +++ b/specs/overview.md @@ -80,8 +80,9 @@ The Sepolia freeze set is **nine** schemas: ANCHOR, DATA, MIRROR, PIN, TAG, PROP | **LIST** | no | Curated collection declaration. Permanent identity (like DATA). Fields: `bool allowsDuplicates, bool appendOnly, uint8 targetType, bytes32 targetSchema, uint256 maxEntries`. Three modes: ANY (0), ADDR (1), SCHEMA (2). Enforced by ListResolver. ADR-0044, ADR-0047. | | **LIST_ENTRY** | yes | Member entry in a LIST — pure membership identity. Fields: `bytes32 listUID, bytes32 target` (ADR-0046; order + free-text label are PIN-bound PROPERTYs on the stable entry UID, not fields). Per-attester lens storage with wide EntryRecord[] for O(N) on-chain iteration. Enforced by ListEntryResolver. ADR-0044, ADR-0046. | | **REDIRECT** | yes | Trust-scoped "this points at that" — canonical/dedup (`sameAs`), version supersession (`supersededBy`), path symlinks. Fields: `bytes32 target, uint16 kind` (FROZEN). `refUID` = source. Kinds `0=sameAs / 1=supersededBy / 2=symlink / 3+=reserved` (taxonomy is resolver+client logic, not in the UID). Write-time guards only (no self-loop, per-kind typing) by AliasResolver; read-time multi-hop resolution is client/spec. ADR-0050. | +| **WHITEOUT** | yes | **Additive 10th schema — registered post-freeze (ADR-0055), NOT in the frozen nine.** Cross-lens negative mask / overlay delete: an empty-payload pure-identity marker on a path ANCHOR (`refUID` = the suppressed child) that, in a lens scan, is a *negative terminal* — serve empty, stop, no fall-through to lower lenses. Own `WhiteoutResolver`. Applied by `EFSRouter`/`EFSFileView` (listing + path walk + `getFilesAtPath`); a deep link into a whited path 404s. Un-delete = `eas.revoke()`; `WhiteoutResolver.getActiveWhiteout(parent,attester,child)` recovers the live UID. | -Full field definitions and resolver wiring: `02-Data-Models-and-Schemas.md`. +Full field definitions and resolver wiring: `02-Data-Models-and-Schemas.md`. The Sepolia **freeze set is the nine schemas above**; WHITEOUT is added *additively after* the freeze (a new schema + resolver orphans nothing), exactly how SORT_INFO and any future primitive join. **Cardinality lives in the schema UID** (ADR-0041). PIN and TAG share one resolver contract but distinct schema UIDs. Smart-contract readers and subgraph indexers see the schema UID and know whether to call a singular or list-shaped reader — zero EFS-specific decoding. @@ -99,6 +100,7 @@ Full field definitions and resolver wiring: `02-Data-Models-and-Schemas.md`. | ListEntryResolver | LIST_ENTRY schema hook. Wide EntryRecord[] storage, swap-and-pop removal, per-attester lens. | Yes | No — baked into LIST_ENTRY_SCHEMA_UID at registration | | ListReader | Stateless view over ListEntryResolver + EAS. getMode, length, entries, countOf, typed accessors. | No | Yes — address not baked into any schema UID | | AliasResolver | REDIRECT schema hook (ADR-0050). Write-time guards only: no self-loop, per-kind typing (sameAs/supersededBy → DATA↔DATA; symlink → Anchor source). No read-time resolution. | No | No — baked into REDIRECT_SCHEMA_UID at registration | +| WhiteoutResolver | WHITEOUT schema hook (ADR-0055, additive post-freeze). Write-guards the negative marker (source is an ANCHOR, empty payload, revocable); keeps a per-`(parent,attester,child)` active-UID + per-parent discovery index; readers `isWhitedOut`/`getActiveWhiteout`/`getChildrenWhitedOut`. Not `Ownable` (config is `initialize()`-only). | Minimal | No — baked into WHITEOUT_SCHEMA_UID at registration | "Not redeployable" means the contract's address is baked into one or more schema UIDs at registration. Replacing it breaks every attestation under those schemas. @@ -111,7 +113,7 @@ For a new file: - **> 4 KB (SSTORE2):** Content split into ~24KB chunks, each deployed as a raw-bytecode contract. An `EFSBytesStore` chunk-manager contract is deployed that holds the chunk addresses (ADR-0057). It is dual-interface: `chunkCount()`/`chunkAddress(i)` for the router's efficient `extcodecopy` read, plus ERC-5219 (`resolveMode()`/`request()`) so a bare `web3://` also resolves the file in any standard web3:// client. *(Status: `EFSBytesStore` is the contract of record. The `packages/nextjs/` debug uploaders still vendor the prior one-arg chunk-only bytecode; re-vendoring the dual-interface store there + in the SDK is the tracked follow-up — `docs/FUTURE_WORK.md` / issue [#34](https://github.com/efs-project/contracts/issues/34). Stores deployed by the debug UI until then are chunk-only, still router-readable.)* 2. **Attest the DATA** — an empty attestation (pure identity, ADR-0049). To dedup, the client may first query the property index for a trusted `contentHash` claim; if found, skip this step and hardlink the existing DATA via a new PIN (step 6). 3. **Attest a MIRROR** pointing at the DATA with the storage URI — either `data:;base64,…` (inline) or `web3://:` (SSTORE2). Additional MIRRORs (ipfs://, ar://, etc.) may be added for redundancy. -4. **Attest contentType / contentHash / size PROPERTYs** — each is three attestations batched (ADR-0041 supersedes ADR-0035; reserved keys per ADR-0049): `Anchor(refUID=DATA, name="")` (skipped if already exists), a free-floating `PROPERTY(value=…)`, and a `PIN(definition=that anchor, refUID=that property)` that binds the value into the cardinality-1 slot. `contentHash` (e.g. keccak256) and `size` are computed locally; both are lens-scoped attester claims, not authenticated identity. +4. **Attest contentType / contentHash / size PROPERTYs** — each is three attestations batched (ADR-0041 supersedes ADR-0035; reserved keys per ADR-0049): `Anchor(refUID=DATA, name="")` (skipped if already exists), a free-floating `PROPERTY(value=…)`, and a `PIN(definition=that anchor, refUID=that property)` that binds the value into the cardinality-1 slot. `contentHash` (canonical sha2-256 multihash `f1220…`, specs/10 / ADR-0064) and `size` are computed locally; both are lens-scoped attester claims, not authenticated identity. 5. **Attest an ANCHOR** for the filename under the target folder (if the name slot doesn't already exist). 6. **Attest a PIN** linking the DATA to the file Anchor under the uploader's address. Cardinality 1 — re-attesting at the same `(attester, definition, targetSchema)` slot supersedes the prior placement in O(1). 7. **Ancestor-walk visibility TAGs** (ADR-0006 revised, ADR-0038, ADR-0041) — for every generic folder on the path from the immediate parent up to root exclusive, if the uploader has no active `TAG(definition=dataSchemaUID, refUID=folder)` yet, emit one. Weight defaults to 1 by convention; the kernel treats any existing, non-revoked TAG as active regardless of weight (ADR-0041 §4). Ensures the uploader's lens listing shows the folders that contain their content. Steady-state zero cost (walk exits once an existing TAG is found); pays 1 TAG per untagged ancestor on the first upload into a new subtree. @@ -123,7 +125,7 @@ Typical new upload: **~5 transactions** for small (≤ 4 KB) files using the `da 1. Router parses the URL: path segments + `?lenses=`, `?caller=`. 2. **Top-level segment is classified** into one of four container flavors (ADR-0033): Ethereum address, EAS schema UID, EAS attestation UID, or anchor name. Address seeds `currentParent` with `bytes32(uint160(addr))`; anchor names seed `rootAnchorUID`. For schema and attestation UIDs, the router first checks for an **alias anchor** — a root-child anchor whose name is the UID in lowercase 0x-hex — and seeds `currentParent` with the alias if present; otherwise it seeds the raw UID. Alias anchors let schemas and attestations carry EFS-native metadata (human label PROPERTY, sub-anchors, TAGs) without conflating with the raw EAS record. When the container is an address and `?lenses=` wasn't given, the router defaults lenses to `[caller, segmentAddr, system]` — the `system` tail (ADR-0053/0039) matches the default-lens fallback every other flavor gets, so address browsing still surfaces canonical defaults. 3. Walks the remaining path segments using `EFSIndexer.resolvePath` — every flavor reduces to a bytes32 parent, so the walk is the same code path. -4. For each lens attester in order, queries `EdgeResolver` for the active placement PIN at that Anchor → DATA (cardinality-1, O(1) read). First attester with a match wins. Returns the DATA UID plus that attester's address. With no `?lenses=`, the attester order is `[caller, system]` (or just `[system]` if no caller) — `system` = the `SystemAccount` address (ADR-0053), the default-lens tail, replacing the former deployer-EOA fallback. +4. For each lens attester in order, queries `EdgeResolver` for the active placement PIN at that Anchor → DATA (cardinality-1, O(1) read). First attester with a match wins. Returns the DATA UID plus that attester's address. With no `?lenses=`, the attester order is `[caller, system]` (or just `[system]` if no caller) — `system` = the `SystemAccount` address (ADR-0053), the default-lens tail, replacing the former deployer-EOA fallback. **WHITEOUT negative terminal (ADR-0055):** while walking the lens stack, the first lens with an active whiteout on the path (and no own positive PIN above it) terminates with **empty** — no fall-through to lower lenses — so a deletion stays gone; the same per-segment terminal applies to intermediate path segments (and the alias-container seed), so a deep link into a whited path 404s rather than serving lower-lens content. 5. Finds the best MIRROR for the DATA **from the same attester**, by transport priority: `web3:// > ar:// > ipfs:// > magnet: > https://`. Skips revoked mirrors and invalid URIs. Capped at 500 mirror scans per request. 6. Finds the `contentType` PROPERTY **from the same attester** on the DATA. Falls back to `application/octet-stream`. 7. Serves: