From a5a03e23d220bd0d98ae3d4246b12317a83bff7b Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 17:33:32 -0500 Subject: [PATCH 01/47] chore: scaffold the EFS SDK monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm + Turborepo + Changesets workspace with two packages: - @efs-project/sdk — TypeScript SDK (tsup dual ESM/CJS, viem-native, vitest) - @efs-project/solidity — compile-in Solidity library (Foundry, ships .sol source) Includes the ADR system (mirrored from contracts, lighter — ADR-0001 layout, 0002 viem-only, 0003 compile-in Solidity), top-level docs (README, CONTRIBUTING, AGENTS.md), CI, and scaffolded public API shapes reflecting the planning design (identity seam, static-vs-dynamic refs). TS build/typecheck/test/lint verified green; Solidity is CI-verified (forge not local). Best-practice review applied (changeset config, publishConfig, forge-std CI install, .nvmrc/engines). Co-authored-by: Claude Opus 4.8 --- .changeset/config.json | 11 + .github/workflows/ci.yml | 52 + .gitignore | 27 + .nvmrc | 1 + AGENTS.md | 34 + CLAUDE.md | 6 + CONTRIBUTING.md | 43 + README.md | 49 + biome.json | 19 + .../adr/0001-monorepo-layout-and-toolchain.md | 45 + .../0002-viem-only-no-eas-sdk-dependency.md | 36 + .../0003-onchain-sdk-as-compile-in-source.md | 36 + docs/adr/README.md | 57 + docs/adr/_template.md | 22 + examples/README.md | 11 + package.json | 31 + packages/sdk/README.md | 45 + packages/sdk/package.json | 45 + packages/sdk/src/index.ts | 79 + packages/sdk/test/index.test.ts | 15 + packages/sdk/tsconfig.json | 9 + packages/sdk/tsup.config.ts | 12 + packages/sdk/vitest.config.ts | 10 + packages/solidity/README.md | 48 + packages/solidity/foundry.toml | 18 + packages/solidity/package.json | 21 + packages/solidity/remappings.txt | 2 + packages/solidity/src/EFSLib.sol | 62 + packages/solidity/src/EFSWriter.sol | 29 + packages/solidity/test/EFSWriter.t.sol | 30 + pnpm-lock.yaml | 2794 +++++++++++++++++ pnpm-workspace.yaml | 3 + tsconfig.base.json | 18 + turbo.json | 15 + 34 files changed, 3735 insertions(+) create mode 100644 .changeset/config.json create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 biome.json create mode 100644 docs/adr/0001-monorepo-layout-and-toolchain.md create mode 100644 docs/adr/0002-viem-only-no-eas-sdk-dependency.md create mode 100644 docs/adr/0003-onchain-sdk-as-compile-in-source.md create mode 100644 docs/adr/README.md create mode 100644 docs/adr/_template.md create mode 100644 examples/README.md create mode 100644 package.json create mode 100644 packages/sdk/README.md create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/src/index.ts create mode 100644 packages/sdk/test/index.test.ts create mode 100644 packages/sdk/tsconfig.json create mode 100644 packages/sdk/tsup.config.ts create mode 100644 packages/sdk/vitest.config.ts create mode 100644 packages/solidity/README.md create mode 100644 packages/solidity/foundry.toml create mode 100644 packages/solidity/package.json create mode 100644 packages/solidity/remappings.txt create mode 100644 packages/solidity/src/EFSLib.sol create mode 100644 packages/solidity/src/EFSWriter.sol create mode 100644 packages/solidity/test/EFSWriter.t.sol create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 turbo.json diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..fce1c26 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c65a21f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ts: + name: TypeScript (build, typecheck, test, lint) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @efs-project/sdk build + - run: pnpm --filter @efs-project/sdk typecheck + - run: pnpm --filter @efs-project/sdk test + - run: pnpm lint + + solidity: + name: Solidity (build, test, fmt) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: foundry-rs/foundry-toolchain@v1 + - working-directory: packages/solidity + run: | + forge install foundry-rs/forge-std --no-commit + forge build + forge test + forge fmt --check + + changeset: + name: Changeset present + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm changeset status --since=origin/main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d11f0b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# TypeScript / build output +dist/ +*.tsbuildinfo + +# Turbo +.turbo/ + +# Foundry (packages/solidity) +out/ +cache/ +broadcast/ +packages/solidity/lib/ + +# Env / secrets +.env +.env.* +!.env.example + +# Editor / OS +.DS_Store +.idea/ +.vscode/ +*.log diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ca6b1d1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# AGENTS.md + +The **EFS SDK** — the developer-facing SDK for the Ethereum File System (an on-chain filesystem on EAS attestations). This repo ships two packages: a TypeScript SDK and a compile-in Solidity SDK. Pre-1.0, pre-launch — breaking changes are fine for now; good design and future-proofing of the *public surface* is what matters. + +This is the **upgradeable** layer of EFS (vs. the immutable contracts). Ship the simple version, observe, revise. + +## Read on init + +**If your tool does not auto-load `@`-imported files, read these before starting any task:** + +- **[docs/adr/README.md](./docs/adr/README.md)** — the ADR system, the permanence framing, and the **boundary rule** (what's an SDK ADR vs. a planning-vault design). Required. +- **[README.md](./README.md)** — what the two packages are and how they're consumed. + +## The two packages + +- **`packages/sdk`** → `@efs-project/sdk` — TypeScript, off-chain reads/writes, viem-native (ADR-0002). +- **`packages/solidity`** → `@efs-project/solidity` — a compile-in Solidity library (ADR-0003); your contract stays the attester. + +## Cross-repo coordination — the planning vault + +The cross-cutting SDK architecture lives in the **planning vault**, not here: `planning/Designs/sdk-architecture.md` (the "what + why"), plus `planning/Designs/sdk-minimal-clicks.md`. This repo's ADRs implement *slices* of that design. + +**Boundary rule (see [docs/adr/README.md](./docs/adr/README.md)):** +- SDK-only decision (package layout, deps, error model, API shape) → an **SDK ADR** here. +- A decision that changes EFS *architecture* or touches the **contracts**/**client** repos → a **planning-vault design**, not an SDK ADR. +- SDK code decisions never go in `planning/Decisions.md` (that's for cross-repo coordination only). + +The sibling repos: protocol contracts (`efs-project/contracts`) and the production client (`efs-project/client`). + +## Conventions + +- **ADRs:** mirror the contracts repo's format, independent numbering from `0001`, lighter discipline (no freeze ceremony). Supersede-don't-edit accepted ADRs. +- **Commits:** conventional style (`feat:`, `fix:`, `docs:`, `adr:`, `chore:`). +- **Quality:** `pnpm build && pnpm test && pnpm typecheck && pnpm lint` before a PR. Every change that touches a published package needs a Changeset (`pnpm changeset`). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f0c47f6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +# CLAUDE.md + +This repo uses **AGENTS.md** as the canonical entrypoint for all AI agents. The files below are auto-loaded into context at session start: + +@AGENTS.md +@docs/adr/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d6503d1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing + +## Setup + +```bash +pnpm install +``` + +Requires **pnpm 9+**, **Node 20+**, and **[Foundry](https://book.getfoundry.sh/)** (for `packages/solidity`). + +## Commands + +```bash +pnpm build # build both packages (turbo) +pnpm test # vitest (sdk) + forge test (solidity) +pnpm typecheck # tsc --noEmit +pnpm lint # biome ci +pnpm format # biome format --write +``` + +Per package: `pnpm --filter @efs-project/sdk test`, `pnpm --filter @efs-project/solidity build`, etc. + +## Before you open a PR + +1. `pnpm build && pnpm test && pnpm typecheck && pnpm lint` are green. +2. If you changed a **published package**, add a Changeset: `pnpm changeset` (pick the bump; write a one-line consumer-facing summary). +3. If you made a **decision** (a choice with alternatives), write an ADR — see below. + +## Decisions → ADRs + +Read [`docs/adr/README.md`](./docs/adr/README.md). The short version: + +- A decision **scoped to the SDK** (package layout, deps, error model, public API shape) → a new ADR in `docs/adr/`, numbered from the next free `NNNN`, using `_template.md`. +- A decision that changes EFS **architecture** or touches the **contracts**/**client** repos → it belongs in the **planning vault** (`planning/Designs/`), not here. +- Accepted ADRs are immutable — to change one, write a new ADR and mark the old `Superseded by ADR-NNNN`. + +The SDK is the upgradeable layer, so the ADR bar is light: same format as the contracts repo, no freeze ceremony. + +## Style + +- TypeScript: Biome (`pnpm format`); `strict` tsconfig; typed errors (extend `EfsError`), never raw RPC strings. +- Solidity: `forge fmt` (solhint planned). +- Commits: conventional (`feat:`, `fix:`, `docs:`, `adr:`, `chore:`). diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e7d841 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# EFS SDK + +The developer SDK for the **Ethereum File System (EFS)** — an on-chain filesystem built on [EAS](https://attest.org) attestations. + +> **Status: scaffold.** Repo structure, toolchain, and public API shapes are in place; implementations land next. Architecture: [`planning/Designs/sdk-architecture.md`](https://github.com/efs-project/planning). Decisions: [`docs/adr/`](./docs/adr). + +## Two packages, two audiences + +| Package | npm | For | +|---|---|---| +| [`packages/sdk`](./packages/sdk) | `@efs-project/sdk` | **TypeScript** — apps, scripts, agents reading/writing EFS off-chain. viem-native. | +| [`packages/solidity`](./packages/solidity) | `@efs-project/solidity` | **Solidity** — a compile-in library so your *own contract* can read/write EFS. | + +```bash +npm i @efs-project/sdk viem # TypeScript SDK +npm i @efs-project/solidity # Solidity library (compile-in) +``` + +## Repo layout + +``` +packages/ + sdk/ TypeScript SDK (tsup, viem, vitest) + solidity/ Solidity library (Foundry, ships .sol source) +examples/ runnable consumers (foundry / hardhat / ts) +docs/adr/ architecture decision records (this repo's decisions) +``` + +The cross-cutting design lives in the **planning vault**; this repo holds the code and its per-repo ADRs. See [AGENTS.md](./AGENTS.md) for the boundary rule. + +## Develop + +```bash +pnpm install +pnpm build # turbo: builds both packages +pnpm test # turbo: vitest + forge test +pnpm typecheck +pnpm lint # biome +``` + +Requires **pnpm 9+**, **Node 20+**, and **Foundry** (for the Solidity package). + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md). Every change to a published package needs a Changeset. + +## License + +[MIT](./LICENSE) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..4dd50af --- /dev/null +++ b/biome.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { "ignore": ["dist", "out", "cache", "node_modules", "**/*.sol"] }, + "organizeImports": { "enabled": true }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { "recommended": true } + }, + "javascript": { + "formatter": { "quoteStyle": "single", "semicolons": "asNeeded" } + } +} diff --git a/docs/adr/0001-monorepo-layout-and-toolchain.md b/docs/adr/0001-monorepo-layout-and-toolchain.md new file mode 100644 index 0000000..36f3308 --- /dev/null +++ b/docs/adr/0001-monorepo-layout-and-toolchain.md @@ -0,0 +1,45 @@ +# ADR-0001: Monorepo layout, packages & toolchain + +**Status:** Accepted +**Date:** 2026-06-10 +**Permanence:** Ephemeral (internal structure) — the *package names* are Durable once published +**Related:** planning/Designs/sdk-architecture.md (Q1: both SDKs in one repo) + +## Context + +The EFS SDK ships two deliverables from one repo (`github.com/efs-project/sdk`): a **TypeScript SDK** (npm, off-chain reads/writes) and an **on-chain Solidity SDK** (a compile-in library). The planning design resolved Q1 — both live here because distribution, not deployment, is the deciding factor: the Solidity library is `npm install`-ed and compiled into a dev's own contract, exactly like `@openzeppelin/contracts`. + +We need a layout that is clean for us (builders) and obvious for external devs, with a publishing story that works for both audiences. + +## Decision + +A **pnpm + Turborepo + Changesets** monorepo with two independently-versioned packages: + +``` +sdk/ +├── packages/ +│ ├── sdk/ → npm @efs-project/sdk (TypeScript, off-chain) +│ └── solidity/ → npm @efs-project/solidity (Solidity, compile-in source) +├── examples/ (foundry-consumer, hardhat-consumer, ts-quickstart) +├── docs/adr/ (this system) +├── pnpm-workspace.yaml, turbo.json, tsconfig.base.json, biome.json, .changeset/ +``` + +- **Workspace manager: pnpm.** De-facto standard for 2025–2026 TS+Solidity monorepos (viem, wagmi); strict `node_modules` avoids phantom-dependency bugs. We override the sibling `contracts` repo's yarn/scaffold-eth convention — inheriting yarn isn't worth it here. +- **Task runner: Turborepo.** Caches build/test/typecheck across packages. +- **Two packages, versioned independently via Changesets.** The Solidity ABI and the TS API move on different cadences; lockstep would force no-op bumps and dishonest changelogs. +- **npm scope `@efs-project`** (matches the GitHub org; guaranteed claimable). If the shorter `@efs` org is secured on npm, rename both packages before first publish — it is a one-line change in each `package.json`, cheap pre-1.0. +- **Package name `@efs-project/solidity`, not `…/contracts`** — to avoid conceptual collision with the core protocol repo (`efs-project/contracts`). This SDK is a *library you compile in*, not the deployed protocol. + +## Consequences + +- External devs run `npm i @efs-project/sdk` (TS) or `npm i @efs-project/solidity` (Solidity source + remap). One repo, two clear install paths. +- Changesets gates every PR on a stated version intent; CI opens a "Version Packages" PR and publishes on merge. +- The package names cross the npm boundary, so they are **Durable** once published — renaming after external adoption is a breaking change. Hence the pre-publish window to settle `@efs` vs `@efs-project`. +- Toolchain specifics (bundler, test runner, lint) are recorded in ADR-0002+ and the package configs; they are Ephemeral and revisable. + +## Alternatives considered + +- **Single package** holding both TS and Solidity — rejected: the two have different consumers, toolchains, and release cadences; one version line couples them artificially. +- **yarn workspaces** (for parity with `contracts`) — rejected: consistency with a legacy scaffold doesn't outweigh pnpm's correctness and ecosystem momentum. +- **Two separate repos** — rejected by the design's Q1: they share docs, examples, and a coordinated story; one repo keeps them in sync. diff --git a/docs/adr/0002-viem-only-no-eas-sdk-dependency.md b/docs/adr/0002-viem-only-no-eas-sdk-dependency.md new file mode 100644 index 0000000..9e61b5f --- /dev/null +++ b/docs/adr/0002-viem-only-no-eas-sdk-dependency.md @@ -0,0 +1,36 @@ +# ADR-0002: viem-only; do not depend on the ethers-based EAS SDK + +**Status:** Accepted +**Date:** 2026-06-10 +**Permanence:** Durable (the chain-client choice leaks into the public API surface) +**Related:** ADR-0001, planning/Designs/sdk-architecture.md (the `EFS.EAS` exposure) + +## Context + +The TypeScript SDK reads and writes EFS data, which lives in **EAS attestations**. The obvious move is to wrap EAS's own SDK, `@ethereum-attestation-service/eas-sdk`. But that package (verified `2.9.1`, May 2026) still has a **hard dependency on `ethers@^6`**. Meanwhile the modern EVM-client standard — viem/wagmi — is what EFS's consumers (and the EFS client app) use. + +Depending on `eas-sdk` would drag `ethers` into every consumer's bundle, create two provider/signer models side-by-side (an ethers `Signer` next to the app's viem `WalletClient`), and risk version skew. The thing we actually need from EAS is thin: ABI-encode schema data and build `attest` / `multiAttest` / `multiRevoke` calldata. + +## Decision + +**Standardize on viem end-to-end. Do not depend on `eas-sdk` or `ethers`.** + +- Vendor the EAS contract ABIs and call them through viem (`writeContract`, `readContract`, `encodeAbiParameters`). +- Re-implement the small pieces we need from `eas-sdk` (the `SchemaEncoder` equivalent via `encodeAbiParameters`; UID derivation via viem `keccak256`/`encodePacked` when we need to predict/verify one). +- `viem` is a **peerDependency** (`^2`) so the consumer app has a single viem instance — wagmi's stance. No hard `viem` dep, no `ethers` anywhere. +- If we ever need EAS's offchain/Merkle helpers, isolate them in an optional `@efs-project/eas-adapter` sub-package so `ethers` stays strictly opt-in and out of the core dep tree. + +This refines the planning design's `EFS.EAS` namespace: it exposes **EAS access via viem**, not a re-export of the ethers-based `eas-sdk`. + +## Consequences + +- Zero `ethers` in the dependency tree → smaller bundles, tree-shakeable, one provider model, no dual-signer confusion. +- We own EAS calldata construction and must track EAS ABI changes ourselves — a small, well-bounded surface (a handful of functions). Worth it; this is wagmi's own "typed ABIs over wrapper SDKs" philosophy, and Solady/solmate show "own the minimal primitive" beats "drag a heavy dependency." +- `EFS.EAS` (per the design) is provided as viem-native helpers + the vendored ABIs, not `import { EAS } from '@ethereum-attestation-service/eas-sdk'`. +- Because the chain client is visible in the public API (clients, accounts, returned types), this is **Durable** — switching off viem later would be a semver-major break. + +## Alternatives considered + +- **peerDep `eas-sdk` (+ ethers)** — rejected: forces ethers on all consumers and the dual-signer conflict with their viem `WalletClient`. +- **Adapter layer translating viem↔ethers** — rejected for the core path: more surface than just owning the few EAS ABIs; kept as the opt-in `eas-adapter` escape hatch only. +- **Wait for `eas-sdk` to drop ethers** — rejected: building on a deprecated path now to maybe migrate later is the worse bet; the calldata we need is trivial to own today. diff --git a/docs/adr/0003-onchain-sdk-as-compile-in-source.md b/docs/adr/0003-onchain-sdk-as-compile-in-source.md new file mode 100644 index 0000000..dad048d --- /dev/null +++ b/docs/adr/0003-onchain-sdk-as-compile-in-source.md @@ -0,0 +1,36 @@ +# ADR-0003: Ship the on-chain SDK as compile-in Solidity source + +**Status:** Accepted +**Date:** 2026-06-10 +**Permanence:** Durable (the import path & base-contract API cross the package boundary) +**Related:** ADR-0001, planning/Designs/sdk-architecture.md (On-chain SDK section) + +## Context + +The on-chain SDK lets a developer's *own* contract read and write EFS. EFS keys all content by **attester address** (lenses, cardinality-1 PINs). That attester is whatever address EAS sees as `msg.sender`. So the SDK must execute **in the consuming contract's context** — if it were a separately deployed helper that the consumer `CALL`s, the *helper* would be `msg.sender`/attester, collapsing every consumer into one identity (the same attribution bug the TS batch design avoids). + +A library of `internal` functions inlines into the caller; an inheritable base contract runs in the child's context. Both preserve `msg.sender`. A plain `CALL` to a deployed contract does not. + +## Decision + +Ship the on-chain SDK as **Solidity source compiled into the consumer**, not as a deployed contract: + +- `@efs-project/solidity` publishes `.sol` **source files only** (no bundling, no deploy artifacts), exactly like `@openzeppelin/contracts`. +- Two entry shapes: an `internal` **library** (`EFSLib`) for drop-in helpers, and an inheritable **base contract** (`EFSWriter`) for the happy path. Both keep the consuming contract as attester. +- **Tooling: Foundry** for building and testing the library (fast Solidity unit tests; `vm.prank` to prove `msg.sender` survives inlining). The sibling `contracts` repo uses Hardhat; a non-deployed library is better served by Foundry, and the published source is toolchain-agnostic for consumers. +- Consumption: + - **Hardhat:** `npm i @efs-project/solidity`, then `import "@efs-project/solidity/src/EFSWriter.sol";` (resolved via `node_modules`). + - **Foundry:** install, then `remappings.txt`: `@efs-project/solidity/=node_modules/@efs-project/solidity/`. + +## Consequences + +- Consumers pin the EFS interface/schema-UID constants by package version; no runtime coupling to a deployed helper. +- We must keep the published `.sol` API (the `EFSWriter` base methods, `EFSLib` signatures, hardcoded schema UIDs) stable across patch/minor — it's **Durable** across the npm boundary. Schema-UID changes in the protocol are a coordinated major bump here. +- A consumer's `msg.sender` is preserved, so attester-keyed lenses and cardinality work correctly without the consumer thinking about it. +- Tests live in `packages/solidity/test/*.t.sol` and are **not** published (`files` allowlist ships `src/**/*.sol` only). + +## Alternatives considered + +- **Deployed singleton helper the consumer calls** — rejected: makes the helper the attester (identity collapse). This is the load-bearing reason for the library form. +- **Hardhat for the SDK's Solidity package** (parity with `contracts`) — rejected: Foundry gives faster library unit tests and ergonomic `msg.sender`/inlining assertions; published source is consumer-toolchain-agnostic regardless. +- **Precompiled artifacts / bytecode** — rejected: a compile-in library has no standalone bytecode to ship; source is the artifact, and it lets the consumer's compiler pin the pragma. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..ad3d164 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,57 @@ +# Architecture Decision Records + +ADRs document **decisions** made about the EFS SDK — what we chose, why, and what we considered. They are the institutional memory that survives turnover (human or agent). + +This is the **same ADR system as the [contracts repo](https://github.com/efs-project/contracts/blob/main/docs/adr/README.md)** — deliberately, so agents moving between repos don't learn two conventions. It is intentionally **lighter** here. The SDK is the *upgradeable* layer: we ship, observe, and revise. There is no frozen-schema-UID equivalent, so there is no 50-year freeze ceremony. Most SDK ADRs are freely revisable. + +Numbering is **independent** from contracts — SDK ADRs start at `0001` in their own sequence. + +## Status legend + +- **Proposed** — under discussion, not yet acted on. +- **Accepted** — currently in force. Code reflects this decision. +- **Superseded by ADR-NNNN** — replaced by a later decision. The superseded ADR is preserved unmodified; the link points to the replacement. +- **Rejected** — considered and explicitly chosen against. Preserved so the option isn't reconsidered without learning from the prior thinking. +- **Deprecated** — no longer the right choice but not yet replaced. May indicate a known wart. + +## Permanence framing (SDK flavor) + +The contracts repo classifies surfaces as Etched / Durable / Ephemeral. The SDK inherits the vocabulary but sits almost entirely in the lower two tiers: + +- **Durable** — the **published public API across the npm boundary**: exported function signatures, types, and runtime behaviour external devs `import` and depend on. Breaking these is a semver-major event with downstream cost, so change them carefully — supersede-don't-edit the deciding ADR, and document the migration. This is the most permanent thing the SDK has, and it is still *fixable* with a major bump. Treat it as **Durable-but-careful, not Etched**. +- **Ephemeral** — internal modules, build/tooling config, test helpers, dev scripts, docs prose. Ship the simple version; revise next commit. +- **Etched** — essentially empty. The SDK hashes nothing into permanent on-chain identity. If a decision feels Etched, it almost certainly belongs in the contracts repo or the planning vault, not here. + +## Discipline (lighter) + +ADRs are **immutable once `Status: Accepted`** — but the bar to supersede is low. To change a decision: + +1. Write a new ADR with the new approach. +2. Set the old ADR's `Status` to `Superseded by ADR-NNNN`. Touch nothing else in it. +3. The new ADR's Context explains why the old one fell short. + +That's the whole ceremony. No 50-year test, no freeze WIP-limit, no invariant-proof gate. Supersede freely; the chain of reasoning is the only thing we protect. For a published-API change that breaks downstream consumers, the *care* is in the migration note and the semver bump — not extra ADR rigor. + +Prose-level fixes (typo, stale symbol name, wrong link) to an accepted ADR may be made in place at any time. Only the `Decision`, `Consequences`, and `Alternatives` **substance** is the historical record and must be changed via supersession. + +## Boundary rule — does this belong here? + +- **SDK ADR** (this folder): decisions scoped to the SDK alone — package/monorepo layout, viem-vs-ethers, error model, public API shape, dependency choices and bumps, build/bundling, codegen. +- **Planning vault design** (`planning/Designs/`): any decision that changes EFS *architecture* or touches the **contracts** or **client** repos. Cross-repo concerns are designed in the vault; a landed design may then produce a per-repo SDK ADR here for the SDK's slice. +- **Not** `planning/Decisions.md`: SDK code decisions live in SDK ADRs, never in the vault's decisions log. The vault log is for cross-repo coordination calls, not this repo's implementation choices. + +When unsure which side of the boundary a decision sits on, ask before writing — a misfiled ADR is worse than a late one. + +The cross-cutting SDK architecture lives in the planning vault: `planning/Designs/sdk-architecture.md`. ADRs here implement slices of it. + +## Format + +Compact, scannable. One screen per ADR. Copy `_template.md`. + +## Index + +- [ADR-0001 — Monorepo layout, packages & toolchain](./0001-monorepo-layout-and-toolchain.md) +- [ADR-0002 — viem-only; do not depend on the ethers-based EAS SDK](./0002-viem-only-no-eas-sdk-dependency.md) +- [ADR-0003 — Ship the on-chain SDK as compile-in Solidity source](./0003-onchain-sdk-as-compile-in-source.md) + +_Recommended next: ADR-0004 error model · ADR-0005 public API surface & semver policy (write when the code lands)._ diff --git a/docs/adr/_template.md b/docs/adr/_template.md new file mode 100644 index 0000000..f93dcc6 --- /dev/null +++ b/docs/adr/_template.md @@ -0,0 +1,22 @@ +# ADR-NNNN: Title + +**Status:** Proposed +**Date:** YYYY-MM-DD +**Permanence:** Durable | Ephemeral +**Related:** PR #N, ADR-XXXX, planning/Designs/ (if relevant) + +## Context + +Why this decision was needed. What problem are we solving? + +## Decision + +What we chose. Specific enough to act on. + +## Consequences + +What this enables, what it costs, what follow-up it implies. If this changes the published public API, note the semver impact and migration path. + +## Alternatives considered (optional) + +What else we looked at and why it lost. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..4ba8fec --- /dev/null +++ b/examples/README.md @@ -0,0 +1,11 @@ +# Examples + +Runnable consumers of the EFS SDK, kept in the workspace so they stay in sync with the packages. + +| Example | Shows | Status | +|---|---|---| +| `ts-quickstart/` | Use `@efs-project/sdk` from a TypeScript app (read a file, pin a file). | planned | +| `foundry-consumer/` | Import `@efs-project/solidity` into a Foundry project via remappings. | planned | +| `hardhat-consumer/` | Import `@efs-project/solidity` into a Hardhat project via `node_modules`. | planned | + +> **Status: scaffold.** The directories above are planned and land alongside the package implementations. Each example doubles as an acceptance test for the "easy for external devs" goal — if it isn't copy-paste simple, the SDK API needs work, not the example. diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0d3901 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "@efs-project/monorepo", + "version": "0.0.0", + "private": true, + "description": "The Ethereum File System SDK — TypeScript and Solidity", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/efs-project/sdk.git" + }, + "packageManager": "pnpm@9.12.0", + "engines": { + "node": ">=20", + "pnpm": ">=9" + }, + "scripts": { + "build": "turbo run build", + "test": "turbo run test", + "typecheck": "turbo run typecheck", + "lint": "biome ci .", + "format": "biome format --write .", + "changeset": "changeset", + "release": "turbo run build && changeset publish" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@changesets/cli": "^2.27.9", + "turbo": "^2.3.0", + "typescript": "^5.6.3" + } +} diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 0000000..0da45e2 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,45 @@ +# @efs-project/sdk + +TypeScript SDK for the **Ethereum File System (EFS)** — read and write an on-chain filesystem built on EAS attestations. + +> **Status: scaffold.** The public surface is shaped; method bodies are stubs until the build lands. See [`planning/Designs/sdk-architecture.md`](https://github.com/efs-project/planning) for the design and [`docs/adr/`](../../docs/adr) for decisions. + +## Install + +```bash +npm i @efs-project/sdk viem +``` + +`viem` is a peer dependency (ADR-0002) — the SDK is viem-native and pulls in no `ethers`. + +## Quickstart (target API) + +```ts +import { createEfsClient, identity } from '@efs-project/sdk' +import { createPublicClient, createWalletClient, http } from 'viem' + +const efs = createEfsClient({ + publicClient: createPublicClient({ transport: http() }), + walletClient, // required for writes +}) + +// Read "the file at /logo", resolved through an identity (ENS → key-set → lens). +const file = await efs.read('/logo', { as: identity('jamescarnley.eth') }) + +// Write a file (batched multi-attestation under the hood). +await efs.pinFile('/notes/hello.txt', new TextEncoder().encode('gm')) +``` + +## Design notes that shape this API + +- **Lenses are a resolved set, not an address.** `identity()` may expand (ENS → device key-set); `lens()` is a literal escape hatch. The type is opaque so adding expansion later doesn't break callers. +- **Static vs dynamic refs are distinct types.** `DataRef` = these exact bytes; `PathRef` = whatever's active here now. They never silently interconvert. +- **viem-only.** EAS access is exposed viem-native, not via the ethers-based EAS SDK. + +## Develop + +```bash +pnpm build # tsup → dual ESM/CJS + .d.ts +pnpm test # vitest (+ anvil fork for on-chain tests) +pnpm typecheck +``` diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 0000000..ccebbca --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,45 @@ +{ + "name": "@efs-project/sdk", + "version": "0.0.0", + "description": "TypeScript SDK for the Ethereum File System (EFS)", + "license": "MIT", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/efs-project/sdk.git", + "directory": "packages/sdk" + }, + "files": ["dist"], + "publishConfig": { "access": "public" }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "viem": "^2" + }, + "peerDependenciesMeta": { + "viem": { "optional": false } + }, + "devDependencies": { + "tsup": "^8.3.0", + "typescript": "^5.6.3", + "viem": "^2.21.0", + "vitest": "^2.1.0", + "prool": "^0.0.16" + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 0000000..932f8e8 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,79 @@ +/** + * @efs-project/sdk — TypeScript SDK for the Ethereum File System (EFS). + * + * Status: scaffold. Public surface is shaped per planning/Designs/sdk-architecture.md; + * method bodies are stubs (`NotImplemented`) until the build lands. The *shapes* below + * are the load-bearing part — they encode decisions we don't want to break later + * (the identity seam, the static-vs-dynamic reference split). + */ + +import type { Address, PublicClient, WalletClient } from 'viem' + +// ── Errors ─────────────────────────────────────────────────────────────────── +// Discriminated error base so external callers catch typed errors, never raw RPC +// strings (ADR-0004, pending). Mirrors viem's BaseError ergonomics. + +export class EfsError extends Error { + override name = 'EfsError' +} + +export class NotImplemented extends EfsError { + override name = 'NotImplemented' + constructor(what: string) { + super(`${what} is not implemented yet (SDK scaffold).`) + } +} + +// ── Identity / lens seam (sdk-architecture §1–§3) ────────────────────────────── +// A lens is a *resolved set of attester addresses*, built from a configurable +// hierarchy (EFS contracts ADR-0039), not a bare address. v1 ships the trivial resolver +// (addr -> [addr]); ENS/key-set expansion drops in later, additively. The type +// stays opaque so N-vs-1 never leaks into a signature. + +export type Lens = { + readonly __brand: 'Lens' + /** Resolve to the ordered attester set at read time. */ + resolve(): Promise +} + +/** An explicit, literal lens — exactly these addresses, never expanded. */ +export function lens(_addresses: Address | readonly Address[]): Lens { + throw new NotImplemented('lens()') +} + +/** An identity that may expand (ENS -> key-set -> ordered lens). Resolves at read time. */ +export function identity(_ensOrAddress: string): Lens { + throw new NotImplemented('identity()') +} + +// ── Static vs dynamic references (sdk-architecture §5) ───────────────────────── +// Distinct types that never silently interconvert. A DataRef is "these exact +// bytes / this version" (UID). A PathRef is "whatever is active here now". + +export type DataRef = { readonly __brand: 'DataRef'; readonly uid: `0x${string}` } +export type PathRef = { readonly __brand: 'PathRef'; readonly path: string } + +// ── Client ───────────────────────────────────────────────────────────────────── + +export type EfsClientConfig = { + publicClient: PublicClient + /** Required for writes; reads work without it. */ + walletClient?: WalletClient + /** Default lens when none is passed to a read. Defaults to the connected wallet. */ + defaultLens?: Lens +} + +export type EfsClient = { + /** Read the file at a path, resolved through a lens. */ + read(path: string, opts?: { as?: Lens }): Promise + /** Pin (write) a file. Batches the underlying attestations (sdk-architecture §6). */ + pinFile(path: string, content: Uint8Array): Promise + /** Clean viem-native access to the underlying EAS layer (ADR-0002). */ + readonly eas: unknown +} + +export function createEfsClient(_config: EfsClientConfig): EfsClient { + throw new NotImplemented('createEfsClient()') +} + +export const version = '0.0.0' diff --git a/packages/sdk/test/index.test.ts b/packages/sdk/test/index.test.ts new file mode 100644 index 0000000..cda2dfa --- /dev/null +++ b/packages/sdk/test/index.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { NotImplemented, createEfsClient, identity, lens, version } from '../src/index.js' + +describe('@efs-project/sdk scaffold', () => { + it('exposes a version', () => { + expect(version).toBe('0.0.0') + }) + + it('stubs throw NotImplemented until the build lands', () => { + expect(() => lens('0x0000000000000000000000000000000000000001')).toThrow(NotImplemented) + expect(() => identity('jamescarnley.eth')).toThrow(NotImplemented) + // @ts-expect-error scaffold: config shape not exercised yet + expect(() => createEfsClient({})).toThrow(NotImplemented) + }) +}) diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 0000000..aef3495 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts new file mode 100644 index 0000000..328c648 --- /dev/null +++ b/packages/sdk/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + splitting: true, + treeshake: true, + sourcemap: true, + clean: true, + target: 'es2022', +}) diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts new file mode 100644 index 0000000..1d3f23e --- /dev/null +++ b/packages/sdk/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + // On-chain tests run against a local anvil fork (see test/setup.ts). + // globalSetup: ['./test/setup.ts'], + include: ['test/**/*.test.ts'], + testTimeout: 30_000, + }, +}) diff --git a/packages/solidity/README.md b/packages/solidity/README.md new file mode 100644 index 0000000..7190215 --- /dev/null +++ b/packages/solidity/README.md @@ -0,0 +1,48 @@ +# @efs-project/solidity + +On-chain (Solidity) SDK for the **Ethereum File System (EFS)** — a **compile-in library** for reading and writing EFS from your own smart contract. + +> **Status: scaffold.** Signatures are shaped per the design; bodies are stubs until the build lands. See [`docs/adr/0003`](../../docs/adr/0003-onchain-sdk-as-compile-in-source.md). + +## Why a library (not a deployed contract) + +EFS keys all content by **attester address** (`msg.sender` at EAS). This library's functions are `internal` and inline into *your* contract, and `EFSWriter` is an inheritable base — both keep **your contract** as the attester. A separately deployed helper you `CALL` would become the attester and collapse every consumer into one identity. So: compile it in, don't deploy it. + +## Install & import + +```bash +npm i @efs-project/solidity +``` + +**Hardhat** (resolved via `node_modules`): + +```solidity +import "@efs-project/solidity/src/EFSWriter.sol"; + +contract MyApp is EFSWriter { + function save(string calldata path, bytes32 dataUID) external { + _efsPinFile(path, dataUID); // your contract is the attester + } +} +``` + +**Foundry** — add to `remappings.txt`: + +``` +@efs-project/solidity/=node_modules/@efs-project/solidity/ +``` + +## Surface + +- `EFSLib` — `internal` helpers: `read` / `readAs` (lens-scoped), `pinFile`, `mkdir`. +- `EFSWriter` — inheritable base with EFS-level events + the happy-path wrappers. + +## Develop + +```bash +forge build # compile the library +forge test # unit tests (incl. asserting msg.sender survives inlining) +forge fmt +``` + +Tests live in `test/` and are **not** published — the npm package ships `src/**/*.sol` only. diff --git a/packages/solidity/foundry.toml b/packages/solidity/foundry.toml new file mode 100644 index 0000000..b800a81 --- /dev/null +++ b/packages/solidity/foundry.toml @@ -0,0 +1,18 @@ +[profile.default] +src = "src" +test = "test" +out = "out" +libs = ["lib"] +solc = "0.8.28" +optimizer = true +optimizer_runs = 200 +remappings = [ + "forge-std/=lib/forge-std/src/", + # Consumers remap this package as: + # @efs-project/solidity/=node_modules/@efs-project/solidity/ + "@efs-project/solidity/=src/", +] + +[fmt] +line_length = 100 +tab_width = 4 diff --git a/packages/solidity/package.json b/packages/solidity/package.json new file mode 100644 index 0000000..a77c505 --- /dev/null +++ b/packages/solidity/package.json @@ -0,0 +1,21 @@ +{ + "name": "@efs-project/solidity", + "version": "0.0.0", + "description": "On-chain (Solidity) SDK for the Ethereum File System (EFS) — a compile-in library", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/efs-project/sdk.git", + "directory": "packages/solidity" + }, + "files": ["src/**/*.sol", "README.md"], + "publishConfig": { "access": "public" }, + "keywords": ["ethereum", "efs", "eas", "solidity", "filesystem"], + "homepage": "https://github.com/efs-project/sdk/tree/main/packages/solidity", + "scripts": { + "build": "forge build", + "test": "forge test", + "fmt": "forge fmt", + "fmt:check": "forge fmt --check" + } +} diff --git a/packages/solidity/remappings.txt b/packages/solidity/remappings.txt new file mode 100644 index 0000000..8cff2cc --- /dev/null +++ b/packages/solidity/remappings.txt @@ -0,0 +1,2 @@ +forge-std/=lib/forge-std/src/ +@efs-project/solidity/=src/ diff --git a/packages/solidity/src/EFSLib.sol b/packages/solidity/src/EFSLib.sol new file mode 100644 index 0000000..f1877df --- /dev/null +++ b/packages/solidity/src/EFSLib.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @title EFSLib +/// @notice Internal library for reading and writing the Ethereum File System (EFS) +/// from *your own* contract. Functions are `internal` so they inline into +/// the calling contract — preserving `msg.sender` as the EAS attester, which +/// EFS lenses and cardinality-1 PINs depend on (ADR-0003). +/// @dev Status: scaffold. Signatures are shaped per planning/Designs/sdk-architecture.md +/// (On-chain SDK section); bodies are stubs until the build lands. Do NOT deploy +/// this as a standalone helper — a separate CALL would make the helper the +/// attester and collapse every consumer into one identity. +library EFSLib { + error NotImplemented(); + + // ── Reads (lens-scoped) ──────────────────────────────────────────────────── + // read(path) with no lens defaults to the consuming contract's own data + // (lens = [address(this)]). readAs names an explicit author. Never tx.origin. + + /// @notice Read the data UID at `path` for the consuming contract's own lens. + /// @return exists Whether a file is present (your namespace is usually empty). + /// @return dataUID The active DATA attestation UID at the path. + function read(string memory /*path*/ ) + internal + view + returns (bool exists, bytes32 dataUID) + { + // lens = [address(this)] + exists; // silence + revert NotImplemented(); + } + + /// @notice Read `path` resolved through an explicit author address. + function readAs(string memory, /*path*/ address /*who*/ ) + internal + view + returns (bool exists, bytes32 dataUID) + { + revert NotImplemented(); + } + + /// @notice Read `path` resolved through an explicit, ordered lens stack. + function read(string memory, /*path*/ address[] memory /*lenses*/ ) + internal + view + returns (bool exists, bytes32 dataUID) + { + revert NotImplemented(); + } + + // ── Writes ───────────────────────────────────────────────────────────────── + + /// @notice Pin a file: place `dataUID` at `path`. The consuming contract is the attester. + function pinFile(string memory, /*path*/ bytes32 /*dataUID*/ ) internal returns (bytes32 pinUID) { + revert NotImplemented(); + } + + /// @notice Create a folder hierarchy for `path` (mkdir -p). + function mkdir(string memory /*path*/ ) internal returns (bytes32 anchorUID) { + revert NotImplemented(); + } +} diff --git a/packages/solidity/src/EFSWriter.sol b/packages/solidity/src/EFSWriter.sol new file mode 100644 index 0000000..4477298 --- /dev/null +++ b/packages/solidity/src/EFSWriter.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {EFSLib} from "./EFSLib.sol"; + +/// @title EFSWriter +/// @notice Inheritable base contract — the happy path for adding EFS to your contract. +/// Inherit it and call the wrapped helpers; because the base runs in your +/// contract's context, your contract stays the EAS attester (ADR-0003). +/// @dev Status: scaffold. Methods delegate to {EFSLib}; bodies are stubs until the +/// build lands. EFS-level events/errors live here (EAS events are UID-keyed, +/// not domain-keyed, so domain consumers need these). +abstract contract EFSWriter { + using EFSLib for *; + + /// @notice Emitted when this contract pins a file at a path. + event EfsFilePinned(string path, bytes32 indexed dataUID, bytes32 pinUID); + + /// @notice Read this contract's own file at `path`. + function _efsRead(string memory path) internal view returns (bool exists, bytes32 dataUID) { + return EFSLib.read(path); + } + + /// @notice Pin a file at `path`; emits {EfsFilePinned}. + function _efsPinFile(string memory path, bytes32 dataUID) internal returns (bytes32 pinUID) { + pinUID = EFSLib.pinFile(path, dataUID); + emit EfsFilePinned(path, dataUID, pinUID); + } +} diff --git a/packages/solidity/test/EFSWriter.t.sol b/packages/solidity/test/EFSWriter.t.sol new file mode 100644 index 0000000..97c380a --- /dev/null +++ b/packages/solidity/test/EFSWriter.t.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {EFSWriter} from "../src/EFSWriter.sol"; +import {EFSLib} from "../src/EFSLib.sol"; + +/// @dev A minimal consumer that inherits the base, used to assert the inline pattern. +contract ConsumerMock is EFSWriter { + function read(string memory path) external view returns (bool, bytes32) { + return _efsRead(path); + } +} + +contract EFSWriterTest is Test { + ConsumerMock consumer; + + function setUp() public { + consumer = new ConsumerMock(); + } + + /// @notice Scaffold guard: stubs revert with NotImplemented until the build lands. + function test_ReadRevertsNotImplemented() public { + vm.expectRevert(EFSLib.NotImplemented.selector); + consumer.read("/hello.txt"); + } + + // TODO(build): once EFSLib reads/writes are real, assert msg.sender survives + // library inlining — vm.prank(alice); consumer.pin(...); assertEq(attester, alice); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..429a043 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2794 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: ^1.9.4 + version: 1.9.4 + '@changesets/cli': + specifier: ^2.27.9 + version: 2.31.0 + turbo: + specifier: ^2.3.0 + version: 2.9.17 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + + packages/sdk: + devDependencies: + prool: + specifier: ^0.0.16 + version: 0.0.16 + tsup: + specifier: ^8.3.0 + version: 8.5.1(postcss@8.5.15)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + viem: + specifier: ^2.21.0 + version: 2.52.2(typescript@5.9.3) + vitest: + specifier: ^2.1.0 + version: 2.1.9 + + packages/solidity: {} + +packages: + + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@1.9.4': + resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@1.9.4': + resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@1.9.4': + resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@1.9.4': + resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@1.9.4': + resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@1.9.4': + resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@1.9.4': + resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@1.9.4': + resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@1.9.4': + resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} + + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.31.0': + resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==} + hasBin: true + + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} + + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@rollup/rollup-android-arm-eabi@4.61.1': + resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.1': + resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.1': + resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.1': + resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.1': + resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.1': + resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.61.1': + resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.61.1': + resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.61.1': + resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.61.1': + resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.61.1': + resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.1': + resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.1': + resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.1': + resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} + cpu: [x64] + os: [win32] + + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@turbo/darwin-64@2.9.17': + resolution: {integrity: sha512-io5jn5RDeU+9YV78rWhwG++HD/OZ/Lxg1sg93+jDGKQNP3UDxY6RX2dmarbCILhNxNuAM8FH3WgGMY9E96Mf8w==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.9.17': + resolution: {integrity: sha512-83YZTYmN2sxFWf2LTMOwqbOvR3qZMa/TSFwnB6BHVBbIWyoPPe+TAdSTd8KevEx8ml8KkycJ/9A70DFVReyUww==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.9.17': + resolution: {integrity: sha512-teKfwJg0zSC+C2ZSOsX3VnAJGVgcN+pgKNmnGWzcpXQ9eIkAQtYP+getrQ2f1Tw/ePudnreQhq8tVP8S73Vy6Q==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.9.17': + resolution: {integrity: sha512-mqO36x2CNtJ9CCbEf5xOqH662tVSc1wB0mxh7dopBpgXg0fEifdzwX0IEAUW1WUAaNH986L7iEUUgw3HNqUWgg==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.9.17': + resolution: {integrity: sha512-jbyoNePufyMoSSrvVr+/mglcjmya/MOgoIrSHPr67iZ1VFgrlMQXHXtptR2lR48gi+86b1XBvsviJZ9A7zrydg==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.9.17': + resolution: {integrity: sha512-+ql0wYc99Y2AMvyHCcC/P+xtyV4nz522L+C9HDnyi7ryHXBGM+ZjBP28M7SLBGMDImgpN8sk2szpgbvreMeXVA==} + cpu: [arm64] + os: [win32] + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-port@7.2.0: + resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==} + engines: {node: '>=16'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + human-id@4.2.0: + resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} + hasBin: true + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + ox@0.14.29: + resolution: {integrity: sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prool@0.0.16: + resolution: {integrity: sha512-s+i66jsINIJQd8w5iGunT8hCeM6RSXjL8qgXDTvcIuaAOjVRVMh0/PgbJ90KR3JAprXWXpa5XQnDEc4fLnvy1Q==} + engines: {node: '>=22'} + peerDependencies: + '@pimlico/alto': '*' + peerDependenciesMeta: + '@pimlico/alto': + optional: true + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.61.1: + resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + tar@7.2.0: + resolution: {integrity: sha512-hctwP0Nb4AB60bj8WQgRYaMOuJYRAPMGiQUAotms5igN8ppfQM+IvjQ5HcKu1MaZh2Wy2KWVTe563Yj8dfc14w==} + engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + turbo@2.9.17: + resolution: {integrity: sha512-91Q3KxfHJn7esFu2Ic6j9pkvQqWjncQCOp7r1gCKChRSb/+T/yIjsavAmbGLmFRKAzSjmWW/FMrcknmJ4hEOPA==} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + viem@2.52.2: + resolution: {integrity: sha512-HSU12p5aD/kAPZfrlbCUqdiP4P/c6hQ9AhfTS51VbLUQIjkWd1d5EjrCx/SCxZ0zhZVRn4Iv5X5WDqXPG8Ubew==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + +snapshots: + + '@adraffy/ens-normalize@1.11.1': {} + + '@babel/runtime@7.29.7': {} + + '@biomejs/biome@1.9.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.9.4 + '@biomejs/cli-darwin-x64': 1.9.4 + '@biomejs/cli-linux-arm64': 1.9.4 + '@biomejs/cli-linux-arm64-musl': 1.9.4 + '@biomejs/cli-linux-x64': 1.9.4 + '@biomejs/cli-linux-x64-musl': 1.9.4 + '@biomejs/cli-win32-arm64': 1.9.4 + '@biomejs/cli-win32-x64': 1.9.4 + + '@biomejs/cli-darwin-arm64@1.9.4': + optional: true + + '@biomejs/cli-darwin-x64@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64@1.9.4': + optional: true + + '@biomejs/cli-linux-x64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-x64@1.9.4': + optional: true + + '@biomejs/cli-win32-arm64@1.9.4': + optional: true + + '@biomejs/cli-win32-x64@1.9.4': + optional: true + + '@changesets/apply-release-plan@7.1.1': + dependencies: + '@changesets/config': 3.1.4 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.8.4 + + '@changesets/assemble-release-plan@6.0.10': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.8.4 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.31.0': + dependencies: + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.4 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3 + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.8.4 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' + + '@changesets/config@3.1.4': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.4': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.8.4 + + '@changesets/get-release-plan@4.0.16': + dependencies: + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.3': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 4.2.0 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.7': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.3 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.2.0 + prettier: 2.8.8 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@inquirer/external-editor@1.0.3': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.29.7 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.29.7 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@rollup/rollup-android-arm-eabi@4.61.1': + optional: true + + '@rollup/rollup-android-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-x64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.1': + optional: true + + '@scure/base@1.2.6': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@turbo/darwin-64@2.9.17': + optional: true + + '@turbo/darwin-arm64@2.9.17': + optional: true + + '@turbo/linux-64@2.9.17': + optional: true + + '@turbo/linux-arm64@2.9.17': + optional: true + + '@turbo/windows-64@2.9.17': + optional: true + + '@turbo/windows-arm64@2.9.17': + optional: true + + '@types/estree@1.0.9': {} + + '@types/node@12.20.55': {} + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21)': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21 + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + abitype@1.2.3(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + acorn@8.16.0: {} + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + any-promise@1.3.0: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + assertion-error@2.0.1: {} + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + change-case@5.4.4: {} + + chardet@2.1.1: {} + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@3.0.0: {} + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + detect-indent@6.1.0: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esprima@4.0.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@4.0.7: {} + + eventemitter3@5.0.1: {} + + eventemitter3@5.0.4: {} + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + expect-type@1.3.0: {} + + extendable-error@0.1.7: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.61.1 + + follow-redirects@1.16.0: {} + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fsevents@2.3.3: + optional: true + + get-port@7.2.0: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graceful-fs@4.2.11: {} + + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.16.0 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + human-id@4.2.0: {} + + human-signals@8.0.1: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-unicode-supported@2.1.0: {} + + is-windows@1.0.2: {} + + isexe@2.0.0: {} + + isows@1.0.7(ws@8.20.1): + dependencies: + ws: 8.20.1 + + joycon@3.1.1: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash.startcase@4.4.0: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp@3.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + mri@1.2.0: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + object-assign@4.1.1: {} + + outdent@0.5.0: {} + + ox@0.14.29(typescript@5.9.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-map@2.1.0: {} + + p-try@2.2.0: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.11 + + parse-ms@4.0.0: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-type@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@4.0.1: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.15): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.15 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@2.8.8: {} + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prool@0.0.16: + dependencies: + change-case: 5.4.4 + eventemitter3: 5.0.4 + execa: 9.6.1 + get-port: 7.2.0 + http-proxy: 1.18.1 + tar: 7.2.0 + transitivePeerDependencies: + - debug + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.2 + pify: 4.0.1 + strip-bom: 3.0.0 + + readdirp@4.1.2: {} + + requires-port@1.0.0: {} + + resolve-from@5.0.0: {} + + reusify@1.1.0: {} + + rollup@4.61.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.1 + '@rollup/rollup-android-arm64': 4.61.1 + '@rollup/rollup-darwin-arm64': 4.61.1 + '@rollup/rollup-darwin-x64': 4.61.1 + '@rollup/rollup-freebsd-arm64': 4.61.1 + '@rollup/rollup-freebsd-x64': 4.61.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 + '@rollup/rollup-linux-arm-musleabihf': 4.61.1 + '@rollup/rollup-linux-arm64-gnu': 4.61.1 + '@rollup/rollup-linux-arm64-musl': 4.61.1 + '@rollup/rollup-linux-loong64-gnu': 4.61.1 + '@rollup/rollup-linux-loong64-musl': 4.61.1 + '@rollup/rollup-linux-ppc64-gnu': 4.61.1 + '@rollup/rollup-linux-ppc64-musl': 4.61.1 + '@rollup/rollup-linux-riscv64-gnu': 4.61.1 + '@rollup/rollup-linux-riscv64-musl': 4.61.1 + '@rollup/rollup-linux-s390x-gnu': 4.61.1 + '@rollup/rollup-linux-x64-gnu': 4.61.1 + '@rollup/rollup-linux-x64-musl': 4.61.1 + '@rollup/rollup-openbsd-x64': 4.61.1 + '@rollup/rollup-openharmony-arm64': 4.61.1 + '@rollup/rollup-win32-arm64-msvc': 4.61.1 + '@rollup/rollup-win32-ia32-msvc': 4.61.1 + '@rollup/rollup-win32-x64-gnu': 4.61.1 + '@rollup/rollup-win32-x64-msvc': 4.61.1 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + semver@7.8.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + sprintf-js@1.0.3: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + strip-final-newline@4.0.0: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + tar@7.2.0: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + mkdirp: 3.0.1 + yallist: 5.0.0 + + term-size@2.2.1: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.15)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.15) + resolve-from: 5.0.0 + rollup: 4.61.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.15 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + turbo@2.9.17: + optionalDependencies: + '@turbo/darwin-64': 2.9.17 + '@turbo/darwin-arm64': 2.9.17 + '@turbo/linux-64': 2.9.17 + '@turbo/linux-arm64': 2.9.17 + '@turbo/windows-64': 2.9.17 + '@turbo/windows-arm64': 2.9.17 + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + unicorn-magic@0.3.0: {} + + universalify@0.1.2: {} + + viem@2.52.2(typescript@5.9.3): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3) + isows: 1.0.7(ws@8.20.1) + ox: 0.14.29(typescript@5.9.3) + ws: 8.20.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + vite-node@2.1.9: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.61.1 + optionalDependencies: + fsevents: 2.3.3 + + vitest@2.1.9: + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21 + vite-node: 2.1.9 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.20.1: {} + + yallist@5.0.0: {} + + yoctocolors@2.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..89723f9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "packages/*" + - "examples/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..4759d73 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..b630cf5 --- /dev/null +++ b/turbo.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", "out/**"] + }, + "test": { + "dependsOn": ["^build"] + }, + "typecheck": { + "dependsOn": ["^build"] + } + } +} From 7f0f5b7da282eee29eb8b29ef88c794e4ca80dea Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 17:34:55 -0500 Subject: [PATCH 02/47] docs(adr): note the lightweight MADR-style lineage --- docs/adr/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index ad3d164..48972b9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -2,7 +2,7 @@ ADRs document **decisions** made about the EFS SDK — what we chose, why, and what we considered. They are the institutional memory that survives turnover (human or agent). -This is the **same ADR system as the [contracts repo](https://github.com/efs-project/contracts/blob/main/docs/adr/README.md)** — deliberately, so agents moving between repos don't learn two conventions. It is intentionally **lighter** here. The SDK is the *upgradeable* layer: we ship, observe, and revise. There is no frozen-schema-UID equivalent, so there is no 50-year freeze ceremony. Most SDK ADRs are freely revisable. +This is the **same ADR system as the [contracts repo](https://github.com/efs-project/contracts/blob/main/docs/adr/README.md)** — a lightweight, MADR-style markdown ADR (`Context` / `Decision` / `Consequences` / `Alternatives`). Mirrored deliberately, so agents moving between repos don't learn two conventions. It is intentionally **lighter** here. The SDK is the *upgradeable* layer: we ship, observe, and revise. There is no frozen-schema-UID equivalent, so there is no 50-year freeze ceremony. Most SDK ADRs are freely revisable. Numbering is **independent** from contracts — SDK ADRs start at `0001` in their own sequence. From c8e5a19b205a161b7089f10f334eaf2b18f43fe5 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 17:42:30 -0500 Subject: [PATCH 03/47] chore: adopt @efs npm scope; trim ADR system per validation - Rename npm scope @efs-project/* -> @efs/* (scope confirmed unpublished; GitHub org stays efs-project). Pending `npm org create efs` before publish. - ADR system: cut the contracts-inherited Etched/Durable/Ephemeral "permanence framing" section + the cargo-culted `Permanence:` template field (validation flagged as residual freeze-rigor); fold the one real point (published npm API is the only near-permanent surface) into Discipline. - Add the missing procedural bits agents need: next-number mechanic, an ADR-worthiness threshold ("a choice with alternatives worth preserving"), and a worked boundary example (vault design -> SDK ADR slice). Co-authored-by: Claude Opus 4.8 --- AGENTS.md | 4 ++-- CONTRIBUTING.md | 2 +- README.md | 8 +++---- .../adr/0001-monorepo-layout-and-toolchain.md | 13 +++++----- .../0002-viem-only-no-eas-sdk-dependency.md | 3 +-- .../0003-onchain-sdk-as-compile-in-source.md | 7 +++--- docs/adr/README.md | 24 ++++++++----------- docs/adr/_template.md | 1 - examples/README.md | 6 ++--- package.json | 2 +- packages/sdk/README.md | 6 ++--- packages/sdk/package.json | 2 +- packages/sdk/src/index.ts | 2 +- packages/sdk/test/index.test.ts | 2 +- packages/solidity/README.md | 8 +++---- packages/solidity/foundry.toml | 4 ++-- packages/solidity/package.json | 2 +- packages/solidity/remappings.txt | 2 +- 18 files changed, 45 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca6b1d1..78852ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,8 @@ This is the **upgradeable** layer of EFS (vs. the immutable contracts). Ship the ## The two packages -- **`packages/sdk`** → `@efs-project/sdk` — TypeScript, off-chain reads/writes, viem-native (ADR-0002). -- **`packages/solidity`** → `@efs-project/solidity` — a compile-in Solidity library (ADR-0003); your contract stays the attester. +- **`packages/sdk`** → `@efs/sdk` — TypeScript, off-chain reads/writes, viem-native (ADR-0002). +- **`packages/solidity`** → `@efs/solidity` — a compile-in Solidity library (ADR-0003); your contract stays the attester. ## Cross-repo coordination — the planning vault diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6503d1..b160dff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ pnpm lint # biome ci pnpm format # biome format --write ``` -Per package: `pnpm --filter @efs-project/sdk test`, `pnpm --filter @efs-project/solidity build`, etc. +Per package: `pnpm --filter @efs/sdk test`, `pnpm --filter @efs/solidity build`, etc. ## Before you open a PR diff --git a/README.md b/README.md index 3e7d841..19f66c8 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ The developer SDK for the **Ethereum File System (EFS)** — an on-chain filesys | Package | npm | For | |---|---|---| -| [`packages/sdk`](./packages/sdk) | `@efs-project/sdk` | **TypeScript** — apps, scripts, agents reading/writing EFS off-chain. viem-native. | -| [`packages/solidity`](./packages/solidity) | `@efs-project/solidity` | **Solidity** — a compile-in library so your *own contract* can read/write EFS. | +| [`packages/sdk`](./packages/sdk) | `@efs/sdk` | **TypeScript** — apps, scripts, agents reading/writing EFS off-chain. viem-native. | +| [`packages/solidity`](./packages/solidity) | `@efs/solidity` | **Solidity** — a compile-in library so your *own contract* can read/write EFS. | ```bash -npm i @efs-project/sdk viem # TypeScript SDK -npm i @efs-project/solidity # Solidity library (compile-in) +npm i @efs/sdk viem # TypeScript SDK +npm i @efs/solidity # Solidity library (compile-in) ``` ## Repo layout diff --git a/docs/adr/0001-monorepo-layout-and-toolchain.md b/docs/adr/0001-monorepo-layout-and-toolchain.md index 36f3308..8abee83 100644 --- a/docs/adr/0001-monorepo-layout-and-toolchain.md +++ b/docs/adr/0001-monorepo-layout-and-toolchain.md @@ -2,7 +2,6 @@ **Status:** Accepted **Date:** 2026-06-10 -**Permanence:** Ephemeral (internal structure) — the *package names* are Durable once published **Related:** planning/Designs/sdk-architecture.md (Q1: both SDKs in one repo) ## Context @@ -18,8 +17,8 @@ A **pnpm + Turborepo + Changesets** monorepo with two independently-versioned pa ``` sdk/ ├── packages/ -│ ├── sdk/ → npm @efs-project/sdk (TypeScript, off-chain) -│ └── solidity/ → npm @efs-project/solidity (Solidity, compile-in source) +│ ├── sdk/ → npm @efs/sdk (TypeScript, off-chain) +│ └── solidity/ → npm @efs/solidity (Solidity, compile-in source) ├── examples/ (foundry-consumer, hardhat-consumer, ts-quickstart) ├── docs/adr/ (this system) ├── pnpm-workspace.yaml, turbo.json, tsconfig.base.json, biome.json, .changeset/ @@ -28,14 +27,14 @@ sdk/ - **Workspace manager: pnpm.** De-facto standard for 2025–2026 TS+Solidity monorepos (viem, wagmi); strict `node_modules` avoids phantom-dependency bugs. We override the sibling `contracts` repo's yarn/scaffold-eth convention — inheriting yarn isn't worth it here. - **Task runner: Turborepo.** Caches build/test/typecheck across packages. - **Two packages, versioned independently via Changesets.** The Solidity ABI and the TS API move on different cadences; lockstep would force no-op bumps and dishonest changelogs. -- **npm scope `@efs-project`** (matches the GitHub org; guaranteed claimable). If the shorter `@efs` org is secured on npm, rename both packages before first publish — it is a one-line change in each `package.json`, cheap pre-1.0. -- **Package name `@efs-project/solidity`, not `…/contracts`** — to avoid conceptual collision with the core protocol repo (`efs-project/contracts`). This SDK is a *library you compile in*, not the deployed protocol. +- **npm scope `@efs`** (selected 2026-06-10 after confirming the scope has no published packages on the npm registry; pending the org being claimed via `npm org create efs` before first publish). The GitHub org stays `efs-project`; only the npm scope is the shorter `@efs`. +- **Package name `@efs/solidity`, not `…/contracts`** — to avoid conceptual collision with the core protocol repo (`efs-project/contracts`). This SDK is a *library you compile in*, not the deployed protocol. ## Consequences -- External devs run `npm i @efs-project/sdk` (TS) or `npm i @efs-project/solidity` (Solidity source + remap). One repo, two clear install paths. +- External devs run `npm i @efs/sdk` (TS) or `npm i @efs/solidity` (Solidity source + remap). One repo, two clear install paths. - Changesets gates every PR on a stated version intent; CI opens a "Version Packages" PR and publishes on merge. -- The package names cross the npm boundary, so they are **Durable** once published — renaming after external adoption is a breaking change. Hence the pre-publish window to settle `@efs` vs `@efs-project`. +- The package names cross the npm boundary, so renaming after external adoption is a breaking change — hence settling the `@efs` scope pre-publish. - Toolchain specifics (bundler, test runner, lint) are recorded in ADR-0002+ and the package configs; they are Ephemeral and revisable. ## Alternatives considered diff --git a/docs/adr/0002-viem-only-no-eas-sdk-dependency.md b/docs/adr/0002-viem-only-no-eas-sdk-dependency.md index 9e61b5f..757c67d 100644 --- a/docs/adr/0002-viem-only-no-eas-sdk-dependency.md +++ b/docs/adr/0002-viem-only-no-eas-sdk-dependency.md @@ -2,7 +2,6 @@ **Status:** Accepted **Date:** 2026-06-10 -**Permanence:** Durable (the chain-client choice leaks into the public API surface) **Related:** ADR-0001, planning/Designs/sdk-architecture.md (the `EFS.EAS` exposure) ## Context @@ -18,7 +17,7 @@ Depending on `eas-sdk` would drag `ethers` into every consumer's bundle, create - Vendor the EAS contract ABIs and call them through viem (`writeContract`, `readContract`, `encodeAbiParameters`). - Re-implement the small pieces we need from `eas-sdk` (the `SchemaEncoder` equivalent via `encodeAbiParameters`; UID derivation via viem `keccak256`/`encodePacked` when we need to predict/verify one). - `viem` is a **peerDependency** (`^2`) so the consumer app has a single viem instance — wagmi's stance. No hard `viem` dep, no `ethers` anywhere. -- If we ever need EAS's offchain/Merkle helpers, isolate them in an optional `@efs-project/eas-adapter` sub-package so `ethers` stays strictly opt-in and out of the core dep tree. +- If we ever need EAS's offchain/Merkle helpers, isolate them in an optional `@efs/eas-adapter` sub-package so `ethers` stays strictly opt-in and out of the core dep tree. This refines the planning design's `EFS.EAS` namespace: it exposes **EAS access via viem**, not a re-export of the ethers-based `eas-sdk`. diff --git a/docs/adr/0003-onchain-sdk-as-compile-in-source.md b/docs/adr/0003-onchain-sdk-as-compile-in-source.md index dad048d..faba2dd 100644 --- a/docs/adr/0003-onchain-sdk-as-compile-in-source.md +++ b/docs/adr/0003-onchain-sdk-as-compile-in-source.md @@ -2,7 +2,6 @@ **Status:** Accepted **Date:** 2026-06-10 -**Permanence:** Durable (the import path & base-contract API cross the package boundary) **Related:** ADR-0001, planning/Designs/sdk-architecture.md (On-chain SDK section) ## Context @@ -15,12 +14,12 @@ A library of `internal` functions inlines into the caller; an inheritable base c Ship the on-chain SDK as **Solidity source compiled into the consumer**, not as a deployed contract: -- `@efs-project/solidity` publishes `.sol` **source files only** (no bundling, no deploy artifacts), exactly like `@openzeppelin/contracts`. +- `@efs/solidity` publishes `.sol` **source files only** (no bundling, no deploy artifacts), exactly like `@openzeppelin/contracts`. - Two entry shapes: an `internal` **library** (`EFSLib`) for drop-in helpers, and an inheritable **base contract** (`EFSWriter`) for the happy path. Both keep the consuming contract as attester. - **Tooling: Foundry** for building and testing the library (fast Solidity unit tests; `vm.prank` to prove `msg.sender` survives inlining). The sibling `contracts` repo uses Hardhat; a non-deployed library is better served by Foundry, and the published source is toolchain-agnostic for consumers. - Consumption: - - **Hardhat:** `npm i @efs-project/solidity`, then `import "@efs-project/solidity/src/EFSWriter.sol";` (resolved via `node_modules`). - - **Foundry:** install, then `remappings.txt`: `@efs-project/solidity/=node_modules/@efs-project/solidity/`. + - **Hardhat:** `npm i @efs/solidity`, then `import "@efs/solidity/src/EFSWriter.sol";` (resolved via `node_modules`). + - **Foundry:** install, then `remappings.txt`: `@efs/solidity/=node_modules/@efs/solidity/`. ## Consequences diff --git a/docs/adr/README.md b/docs/adr/README.md index 48972b9..105aa16 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -14,25 +14,21 @@ Numbering is **independent** from contracts — SDK ADRs start at `0001` in thei - **Rejected** — considered and explicitly chosen against. Preserved so the option isn't reconsidered without learning from the prior thinking. - **Deprecated** — no longer the right choice but not yet replaced. May indicate a known wart. -## Permanence framing (SDK flavor) - -The contracts repo classifies surfaces as Etched / Durable / Ephemeral. The SDK inherits the vocabulary but sits almost entirely in the lower two tiers: - -- **Durable** — the **published public API across the npm boundary**: exported function signatures, types, and runtime behaviour external devs `import` and depend on. Breaking these is a semver-major event with downstream cost, so change them carefully — supersede-don't-edit the deciding ADR, and document the migration. This is the most permanent thing the SDK has, and it is still *fixable* with a major bump. Treat it as **Durable-but-careful, not Etched**. -- **Ephemeral** — internal modules, build/tooling config, test helpers, dev scripts, docs prose. Ship the simple version; revise next commit. -- **Etched** — essentially empty. The SDK hashes nothing into permanent on-chain identity. If a decision feels Etched, it almost certainly belongs in the contracts repo or the planning vault, not here. - ## Discipline (lighter) -ADRs are **immutable once `Status: Accepted`** — but the bar to supersede is low. To change a decision: +The SDK is the *upgradeable* layer — almost nothing is permanent. The one thing that approaches it is the **published npm API**: breaking an exported signature is a semver-major with a migration cost, so supersede-don't-edit the deciding ADR and note the migration. Everything else (internal code, tooling, tests, prose) ships simple and is revised freely. + +ADRs are **immutable once `Status: Accepted`**, but the bar to supersede is low: 1. Write a new ADR with the new approach. 2. Set the old ADR's `Status` to `Superseded by ADR-NNNN`. Touch nothing else in it. 3. The new ADR's Context explains why the old one fell short. -That's the whole ceremony. No 50-year test, no freeze WIP-limit, no invariant-proof gate. Supersede freely; the chain of reasoning is the only thing we protect. For a published-API change that breaks downstream consumers, the *care* is in the migration note and the semver bump — not extra ADR rigor. +That's the whole ceremony — no 50-year test, no freeze gate. Supersede freely; the chain of reasoning is the only thing we protect. Prose-level fixes (typo, stale link) to an accepted ADR are fine in place; only the `Decision` / `Consequences` / `Alternatives` substance must change via supersession. + +## When to write one -Prose-level fixes (typo, stale symbol name, wrong link) to an accepted ADR may be made in place at any time. Only the `Decision`, `Consequences`, and `Alternatives` **substance** is the historical record and must be changed via supersession. +An ADR is for **a choice with alternatives you'd want preserved** — package layout, a dependency, the error model, a public-API shape. A routine code change with no real fork is not an ADR. The test: would a future agent ask *"why did they do it this way?"* ## Boundary rule — does this belong here? @@ -40,13 +36,13 @@ Prose-level fixes (typo, stale symbol name, wrong link) to an accepted ADR may b - **Planning vault design** (`planning/Designs/`): any decision that changes EFS *architecture* or touches the **contracts** or **client** repos. Cross-repo concerns are designed in the vault; a landed design may then produce a per-repo SDK ADR here for the SDK's slice. - **Not** `planning/Decisions.md`: SDK code decisions live in SDK ADRs, never in the vault's decisions log. The vault log is for cross-repo coordination calls, not this repo's implementation choices. -When unsure which side of the boundary a decision sits on, ask before writing — a misfiled ADR is worse than a late one. +When unsure which side of the boundary a decision sits on, ask before writing — a misfiled ADR is worse than a late one. Worked example: *"change the schema UID the Solidity SDK pins"* touches the protocol → it originates as a planning-vault design, which then spawns an SDK ADR for the SDK's slice (the version bump + import change). *"Switch the bundler to tsdown"* is SDK-only → an ADR here. The cross-cutting SDK architecture lives in the planning vault: `planning/Designs/sdk-architecture.md`. ADRs here implement slices of it. -## Format +## Format & numbering -Compact, scannable. One screen per ADR. Copy `_template.md`. +Compact, scannable — one screen per ADR. Copy `_template.md`. The next number is the highest existing `NNNN` + 1; add your ADR to the **Index** below (and remove it from "Recommended next" if listed). ## Index diff --git a/docs/adr/_template.md b/docs/adr/_template.md index f93dcc6..70a281f 100644 --- a/docs/adr/_template.md +++ b/docs/adr/_template.md @@ -2,7 +2,6 @@ **Status:** Proposed **Date:** YYYY-MM-DD -**Permanence:** Durable | Ephemeral **Related:** PR #N, ADR-XXXX, planning/Designs/ (if relevant) ## Context diff --git a/examples/README.md b/examples/README.md index 4ba8fec..7c43b00 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,8 +4,8 @@ Runnable consumers of the EFS SDK, kept in the workspace so they stay in sync wi | Example | Shows | Status | |---|---|---| -| `ts-quickstart/` | Use `@efs-project/sdk` from a TypeScript app (read a file, pin a file). | planned | -| `foundry-consumer/` | Import `@efs-project/solidity` into a Foundry project via remappings. | planned | -| `hardhat-consumer/` | Import `@efs-project/solidity` into a Hardhat project via `node_modules`. | planned | +| `ts-quickstart/` | Use `@efs/sdk` from a TypeScript app (read a file, pin a file). | planned | +| `foundry-consumer/` | Import `@efs/solidity` into a Foundry project via remappings. | planned | +| `hardhat-consumer/` | Import `@efs/solidity` into a Hardhat project via `node_modules`. | planned | > **Status: scaffold.** The directories above are planned and land alongside the package implementations. Each example doubles as an acceptance test for the "easy for external devs" goal — if it isn't copy-paste simple, the SDK API needs work, not the example. diff --git a/package.json b/package.json index d0d3901..be49d4b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@efs-project/monorepo", + "name": "@efs/monorepo", "version": "0.0.0", "private": true, "description": "The Ethereum File System SDK — TypeScript and Solidity", diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 0da45e2..2d91eca 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -1,4 +1,4 @@ -# @efs-project/sdk +# @efs/sdk TypeScript SDK for the **Ethereum File System (EFS)** — read and write an on-chain filesystem built on EAS attestations. @@ -7,7 +7,7 @@ TypeScript SDK for the **Ethereum File System (EFS)** — read and write an on-c ## Install ```bash -npm i @efs-project/sdk viem +npm i @efs/sdk viem ``` `viem` is a peer dependency (ADR-0002) — the SDK is viem-native and pulls in no `ethers`. @@ -15,7 +15,7 @@ npm i @efs-project/sdk viem ## Quickstart (target API) ```ts -import { createEfsClient, identity } from '@efs-project/sdk' +import { createEfsClient, identity } from '@efs/sdk' import { createPublicClient, createWalletClient, http } from 'viem' const efs = createEfsClient({ diff --git a/packages/sdk/package.json b/packages/sdk/package.json index ccebbca..82b848a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,5 +1,5 @@ { - "name": "@efs-project/sdk", + "name": "@efs/sdk", "version": "0.0.0", "description": "TypeScript SDK for the Ethereum File System (EFS)", "license": "MIT", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 932f8e8..da1eb61 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,5 +1,5 @@ /** - * @efs-project/sdk — TypeScript SDK for the Ethereum File System (EFS). + * @efs/sdk — TypeScript SDK for the Ethereum File System (EFS). * * Status: scaffold. Public surface is shaped per planning/Designs/sdk-architecture.md; * method bodies are stubs (`NotImplemented`) until the build lands. The *shapes* below diff --git a/packages/sdk/test/index.test.ts b/packages/sdk/test/index.test.ts index cda2dfa..90e8220 100644 --- a/packages/sdk/test/index.test.ts +++ b/packages/sdk/test/index.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { NotImplemented, createEfsClient, identity, lens, version } from '../src/index.js' -describe('@efs-project/sdk scaffold', () => { +describe('@efs/sdk scaffold', () => { it('exposes a version', () => { expect(version).toBe('0.0.0') }) diff --git a/packages/solidity/README.md b/packages/solidity/README.md index 7190215..f8a8494 100644 --- a/packages/solidity/README.md +++ b/packages/solidity/README.md @@ -1,4 +1,4 @@ -# @efs-project/solidity +# @efs/solidity On-chain (Solidity) SDK for the **Ethereum File System (EFS)** — a **compile-in library** for reading and writing EFS from your own smart contract. @@ -11,13 +11,13 @@ EFS keys all content by **attester address** (`msg.sender` at EAS). This library ## Install & import ```bash -npm i @efs-project/solidity +npm i @efs/solidity ``` **Hardhat** (resolved via `node_modules`): ```solidity -import "@efs-project/solidity/src/EFSWriter.sol"; +import "@efs/solidity/src/EFSWriter.sol"; contract MyApp is EFSWriter { function save(string calldata path, bytes32 dataUID) external { @@ -29,7 +29,7 @@ contract MyApp is EFSWriter { **Foundry** — add to `remappings.txt`: ``` -@efs-project/solidity/=node_modules/@efs-project/solidity/ +@efs/solidity/=node_modules/@efs/solidity/ ``` ## Surface diff --git a/packages/solidity/foundry.toml b/packages/solidity/foundry.toml index b800a81..23c9845 100644 --- a/packages/solidity/foundry.toml +++ b/packages/solidity/foundry.toml @@ -9,8 +9,8 @@ optimizer_runs = 200 remappings = [ "forge-std/=lib/forge-std/src/", # Consumers remap this package as: - # @efs-project/solidity/=node_modules/@efs-project/solidity/ - "@efs-project/solidity/=src/", + # @efs/solidity/=node_modules/@efs/solidity/ + "@efs/solidity/=src/", ] [fmt] diff --git a/packages/solidity/package.json b/packages/solidity/package.json index a77c505..10a9d8a 100644 --- a/packages/solidity/package.json +++ b/packages/solidity/package.json @@ -1,5 +1,5 @@ { - "name": "@efs-project/solidity", + "name": "@efs/solidity", "version": "0.0.0", "description": "On-chain (Solidity) SDK for the Ethereum File System (EFS) — a compile-in library", "license": "MIT", diff --git a/packages/solidity/remappings.txt b/packages/solidity/remappings.txt index 8cff2cc..bc3c2ee 100644 --- a/packages/solidity/remappings.txt +++ b/packages/solidity/remappings.txt @@ -1,2 +1,2 @@ forge-std/=lib/forge-std/src/ -@efs-project/solidity/=src/ +@efs/solidity/=src/ From 885a0adc153265e3c180b9ebbe76c49e4e2760e6 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 17:48:19 -0500 Subject: [PATCH 04/47] =?UTF-8?q?docs:=20add=20docs/specs/=20=E2=80=94=20p?= =?UTF-8?q?lain-language=20"how=20it=20works"=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the three-layer doc model so nothing duplicates: specs (how it works now) vs ADRs (why we chose it) vs planning vault (cross-cutting design). Seeds specs/overview.md (the SDK model at a glance) and wires it into AGENTS.md as start-here reading. Co-authored-by: Claude Opus 4.8 --- AGENTS.md | 5 ++++- docs/adr/README.md | 2 +- docs/specs/README.md | 24 ++++++++++++++++++++++++ docs/specs/overview.md | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 docs/specs/README.md create mode 100644 docs/specs/overview.md diff --git a/AGENTS.md b/AGENTS.md index 78852ce..448460d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,9 +8,12 @@ This is the **upgradeable** layer of EFS (vs. the immutable contracts). Ship the **If your tool does not auto-load `@`-imported files, read these before starting any task:** -- **[docs/adr/README.md](./docs/adr/README.md)** — the ADR system, the permanence framing, and the **boundary rule** (what's an SDK ADR vs. a planning-vault design). Required. +- **[docs/specs/overview.md](./docs/specs/overview.md)** — plain-language *how the SDK works* (the model). Start here to understand behaviour. +- **[docs/adr/README.md](./docs/adr/README.md)** — the ADR system and the **boundary rule** (SDK ADR vs. planning-vault design). Required before writing a decision. - **[README.md](./README.md)** — what the two packages are and how they're consumed. +Doc layers (don't duplicate): **specs** = how it works now · **ADRs** = why we chose it · **planning vault** = cross-cutting design. See [docs/specs/README.md](./docs/specs/README.md). + ## The two packages - **`packages/sdk`** → `@efs/sdk` — TypeScript, off-chain reads/writes, viem-native (ADR-0002). diff --git a/docs/adr/README.md b/docs/adr/README.md index 105aa16..88b69f1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -38,7 +38,7 @@ An ADR is for **a choice with alternatives you'd want preserved** — package la When unsure which side of the boundary a decision sits on, ask before writing — a misfiled ADR is worse than a late one. Worked example: *"change the schema UID the Solidity SDK pins"* touches the protocol → it originates as a planning-vault design, which then spawns an SDK ADR for the SDK's slice (the version bump + import change). *"Switch the bundler to tsdown"* is SDK-only → an ADR here. -The cross-cutting SDK architecture lives in the planning vault: `planning/Designs/sdk-architecture.md`. ADRs here implement slices of it. +The cross-cutting SDK architecture lives in the planning vault: `planning/Designs/sdk-architecture.md`. ADRs here implement slices of it. For *how the SDK behaves* (not why), see [`docs/specs/`](../specs/). ## Format & numbering diff --git a/docs/specs/README.md b/docs/specs/README.md new file mode 100644 index 0000000..3aee612 --- /dev/null +++ b/docs/specs/README.md @@ -0,0 +1,24 @@ +# Specs + +**Plain-language description of how the SDK works** — the authoritative reference for *current behaviour*. If you want to understand what the SDK does and how to think about it (as a human or an agent), start here. + +This is one of three doc layers; keeping them distinct stops duplication and rot: + +| Layer | Question it answers | Lives in | +|---|---|---| +| **Specs** (this folder) | *How does it work now?* — plain-language current behaviour | `docs/specs/` | +| **ADRs** (`docs/adr/`) | *Why did we choose X?* — decisions + alternatives | `docs/adr/` | +| **Design** (planning vault) | *What + why at the architecture level?* — cross-cutting, often historical once built | `planning/Designs/sdk-architecture.md` | +| **API reference** | *Exact signatures* — mechanical, generated | `docs/api/` (typedoc, later) | + +Rules of thumb: + +- A spec describes **behaviour that exists** (or is the agreed target for the slice being built). It is not a decision log and not a design doc — when it explains *why*, it links to an ADR. +- Specs are **Ephemeral-to-Durable**: revise them as behaviour changes. They track the code, not the history. +- One concept per file. Keep them scannable. + +## Index + +- [overview.md](./overview.md) — the SDK at a glance: the two packages and the core model. + +_More specs land as the implementation does (reads, writes/batching, lenses/identity, errors)._ diff --git a/docs/specs/overview.md b/docs/specs/overview.md new file mode 100644 index 0000000..f0b3524 --- /dev/null +++ b/docs/specs/overview.md @@ -0,0 +1,32 @@ +# Overview — the EFS SDK at a glance + +> **Status: target behaviour (scaffold).** The shapes here are agreed; the code is being filled in. Where a statement describes a decision, it links to an ADR. Full design rationale: `planning/Designs/sdk-architecture.md`. + +EFS (Ethereum File System) is an on-chain filesystem built on [EAS](https://attest.org) attestations: **paths** (folders/anchors) → **data** (file content) → **mirrors** (where to fetch the bytes). All content is keyed by the **attester address** that wrote it. The SDK is how developers read and write that filesystem. + +## Two packages + +- **`@efs/sdk`** (TypeScript) — for apps, scripts, and agents reading/writing EFS *off-chain*. viem-native ([ADR-0002](../adr/0002-viem-only-no-eas-sdk-dependency.md)). +- **`@efs/solidity`** (Solidity) — a *compile-in library* so your own contract can read/write EFS, staying the attester ([ADR-0003](../adr/0003-onchain-sdk-as-compile-in-source.md)). + +Both speak the same model below; they differ only in environment (one runs in a wallet/RPC context, the other inside your contract, gas-bounded). + +## The core model (four ideas) + +**1. A lens is a *resolved set of attesters*, not an address.** "Whose version of `/logo` do I see?" is answered by a lens — an ordered list of attester addresses, first-one-wins. The SDK builds that list from a configurable hierarchy (you → who you're viewing → who you trust → system defaults); a raw address is just the simplest lens. The type is opaque so richer resolution (ENS → a person's device keys) can drop in later without breaking callers. + +**2. Two kinds of reference, and they don't interchange.** +- A **DataRef** points at *these exact bytes / this version* (a permanent id). Use it when the link must not move — e.g. a mirror serving specific content. +- A **PathRef** points at *whatever is active here now* (`/logo` → the newest). Use it for navigation. +Picking a path where you meant a specific version is silent breakage when the data later changes — so the SDK keeps them as distinct types. + +**3. Writing a file is several attestations; the SDK batches them.** A single logical write (content + placement + metadata) is multiple on-chain attestations. The SDK groups them so the user signs **~2–3 times instead of ~8**, with no protocol change. (Why not 1: the pieces reference each other by ids only known after mining — see `planning/Designs/sdk-minimal-clicks.md`.) + +**4. Your contract stays the author.** On-chain, the SDK is a library that *inlines into your contract*, so EFS records *your contract* as the attester — never a shared helper. This is load-bearing for lenses ([ADR-0003](../adr/0003-onchain-sdk-as-compile-in-source.md)). + +## What the SDK does and doesn't do + +- **Does:** simplify multi-step reads/writes, resolve lenses, batch writes, expose EAS cleanly (viem-native), and give a typed escape hatch to the raw contracts. +- **Doesn't (yet):** bundle an indexer. Reverse-lookups ("who tagged this?") that need an external index are stubbed and out of scope for v1. + +For exact signatures, see the package READMEs and (later) the generated API reference. From 7994df53349de33314c568c4f329adb83222e6f0 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 18:11:34 -0500 Subject: [PATCH 05/47] ci: fix scope rename in CI; add OIDC release workflow (ADR-0004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BLOCKER fix: .github/workflows/ci.yml still used @efs-project/sdk (the rename grep missed .yml) — would have failed every CI run. Now @efs/sdk. - Add release.yml using npm Trusted Publishing (OIDC) — no stored NPM_TOKEN, provenance automatic. ADR-0004 records the decision (OIDC over long-lived token) + the one-time per-package trusted-publisher setup. - Mark docs/api row "(later)"; bump recommended-next ADR numbers. Co-authored-by: Claude Opus 4.8 --- .github/workflows/ci.yml | 6 +-- .github/workflows/release.yml | 37 +++++++++++++++++++ ...004-publish-via-oidc-trusted-publishing.md | 32 ++++++++++++++++ docs/adr/README.md | 3 +- docs/specs/README.md | 2 +- 5 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/adr/0004-publish-via-oidc-trusted-publishing.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c65a21f..e70dad3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,9 @@ jobs: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm --filter @efs-project/sdk build - - run: pnpm --filter @efs-project/sdk typecheck - - run: pnpm --filter @efs-project/sdk test + - run: pnpm --filter @efs/sdk build + - run: pnpm --filter @efs/sdk typecheck + - run: pnpm --filter @efs/sdk test - run: pnpm lint solidity: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b8c2935 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +on: + push: + branches: [main] + +concurrency: release-${{ github.ref }} + +permissions: + contents: write # create version PR, tags, GitHub releases + pull-requests: write # open the Changesets "Version Packages" PR + id-token: write # OIDC — npm Trusted Publishing (no NPM_TOKEN secret) + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 # Trusted Publishing needs Node >= 22.14 / npm >= 11.5.1 + cache: pnpm + registry-url: https://registry.npmjs.org + - run: pnpm install --frozen-lockfile + - run: npm install -g npm@latest # ensure npm >= 11.5.1 for OIDC publishing + - run: pnpm build + # Opens/updates the "Version Packages" PR; on merge, publishes changed packages. + # Publish uses npm Trusted Publishing (OIDC) — provenance is emitted automatically, + # no token is read. Requires a Trusted Publisher configured PER PACKAGE on npmjs.com: + # package settings -> Trusted Publisher -> repo "efs-project/sdk", + # workflow ".github/workflows/release.yml". Repo must stay public. + - uses: changesets/action@v1 + with: + publish: pnpm release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/adr/0004-publish-via-oidc-trusted-publishing.md b/docs/adr/0004-publish-via-oidc-trusted-publishing.md new file mode 100644 index 0000000..6d43967 --- /dev/null +++ b/docs/adr/0004-publish-via-oidc-trusted-publishing.md @@ -0,0 +1,32 @@ +# ADR-0004: Publish via npm Trusted Publishing (OIDC), not a stored token + +**Status:** Accepted +**Date:** 2026-06-10 +**Related:** ADR-0001, `.github/workflows/release.yml` + +## Context + +We publish `@efs/sdk` and `@efs/solidity` to npm from CI. The classic approach is a long-lived npm **automation token** stored as a GitHub secret (`NPM_TOKEN`). Long-lived publish tokens are a supply-chain liability: if leaked (CI log, compromised action, exfiltrated secret) an attacker can publish malicious versions. In July 2025 npm made **Trusted Publishing via OIDC** generally available — CI authenticates to npm with a short-lived OIDC token tied to the specific repo + workflow, and **no token is stored**. + +## Decision + +Publish using **npm Trusted Publishing (OIDC)** from `.github/workflows/release.yml` (Changesets-driven). No `NPM_TOKEN` secret. + +Setup (one-time, requires the npm org owner): +- Create the npm org `efs` (reserves the `@efs` scope; free for public packages). Enable org 2FA. +- For **each** package on npmjs.com → Settings → **Trusted Publisher** → GitHub repo `efs-project/sdk`, workflow `.github/workflows/release.yml`. +- The workflow declares `permissions: id-token: write`, uses Node ≥ 22.14 / npm ≥ 11.5.1, and the **repo must stay public** (OIDC provenance is not emitted from private repos). + +Mechanics: a merge to `main` runs the Changesets "Version Packages" PR; merging that publishes the changed packages. Provenance is attached automatically — no `--provenance` flag, no `publishConfig.provenance` needed. + +## Consequences + +- **No long-lived publish secret** to leak or rotate — the biggest supply-chain win for a published SDK. +- Provenance ships free, so consumers can verify packages were built from this repo. +- Constraints to honour: repo stays public; Node/npm version floor in CI; per-package Trusted Publisher config must exist before the first publish or it fails loudly (no silent fallback). +- Caveat to validate against our setup: `changesets/action` + OIDC has had rough edges (see changesets/action#542); confirm the publish step runs in a job with `id-token: write` and the action version cooperates. If it can't, the fallback is a granular, short-expiry token — still better than a classic automation token. + +## Alternatives considered + +- **Classic automation token (`NPM_TOKEN`)** — rejected: long-lived, broad scope, the exact thing OIDC removes. +- **Granular access token** — better (scoped, expiring) but still a stored secret to rotate; kept only as the fallback if OIDC tooling blocks us. diff --git a/docs/adr/README.md b/docs/adr/README.md index 88b69f1..a3321f5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -49,5 +49,6 @@ Compact, scannable — one screen per ADR. Copy `_template.md`. The next number - [ADR-0001 — Monorepo layout, packages & toolchain](./0001-monorepo-layout-and-toolchain.md) - [ADR-0002 — viem-only; do not depend on the ethers-based EAS SDK](./0002-viem-only-no-eas-sdk-dependency.md) - [ADR-0003 — Ship the on-chain SDK as compile-in Solidity source](./0003-onchain-sdk-as-compile-in-source.md) +- [ADR-0004 — Publish via npm Trusted Publishing (OIDC), not a stored token](./0004-publish-via-oidc-trusted-publishing.md) -_Recommended next: ADR-0004 error model · ADR-0005 public API surface & semver policy (write when the code lands)._ +_Recommended next: ADR-0005 error model · ADR-0006 public API surface & semver policy (write when the code lands)._ diff --git a/docs/specs/README.md b/docs/specs/README.md index 3aee612..fa92540 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -9,7 +9,7 @@ This is one of three doc layers; keeping them distinct stops duplication and rot | **Specs** (this folder) | *How does it work now?* — plain-language current behaviour | `docs/specs/` | | **ADRs** (`docs/adr/`) | *Why did we choose X?* — decisions + alternatives | `docs/adr/` | | **Design** (planning vault) | *What + why at the architecture level?* — cross-cutting, often historical once built | `planning/Designs/sdk-architecture.md` | -| **API reference** | *Exact signatures* — mechanical, generated | `docs/api/` (typedoc, later) | +| **API reference** _(later)_ | *Exact signatures* — mechanical, generated | `docs/api/` (typedoc) | Rules of thumb: From 117b1b4a3d147c9c12bc42e42075768044a49c36 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 19:55:10 -0500 Subject: [PATCH 06/47] ci: fix Solidity forge-std install and changeset gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Solidity job failed: `forge install --no-commit` flag was removed in current Foundry. Clone forge-std (test-only dep) via git instead — version-stable. - Changeset job failed: no changeset on the PR. Add an empty changeset (the designed no-release escape hatch) for the scaffold. Co-authored-by: Claude Opus 4.8 --- .changeset/initial-scaffold.md | 4 ++++ .github/workflows/ci.yml | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/initial-scaffold.md diff --git a/.changeset/initial-scaffold.md b/.changeset/initial-scaffold.md new file mode 100644 index 0000000..4cf4741 --- /dev/null +++ b/.changeset/initial-scaffold.md @@ -0,0 +1,4 @@ +--- +--- + +Initial monorepo scaffold — structure, toolchain, ADRs, and docs. No package release (both packages are unpublished `0.0.0`); this empty changeset satisfies the CI gate for a no-release PR. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e70dad3..fd6fcf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,9 @@ jobs: - uses: foundry-rs/foundry-toolchain@v1 - working-directory: packages/solidity run: | - forge install foundry-rs/forge-std --no-commit + # forge-std is a test-only dep (not shipped); clone it rather than + # `forge install` to avoid submodule/flag churn across Foundry versions. + git clone --depth 1 https://github.com/foundry-rs/forge-std lib/forge-std forge build forge test forge fmt --check From daa503aebb935e7bf1f4a80a755a985f0ddce823 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 19:57:25 -0500 Subject: [PATCH 07/47] ci: harden workflows per review - CI: minimal `permissions: contents: read`, cancel-in-progress concurrency, `timeout-minutes` on all jobs, pin forge-std to v1.9.4 (no master drift). - Release: pin global npm to @11; explicit `publishConfig.provenance: true` on both packages (belt-and-suspenders around the changesets/pnpm publish path). - ADR-0004 reconciled with the explicit-provenance choice. Co-authored-by: Claude Opus 4.8 --- .github/workflows/ci.yml | 18 +++++++++++++++--- .github/workflows/release.yml | 3 ++- ...0004-publish-via-oidc-trusted-publishing.md | 2 +- packages/sdk/package.json | 2 +- packages/solidity/package.json | 2 +- 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd6fcf1..8a6abe4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,20 @@ on: branches: [main] pull_request: +# CI only reads the repo — never needs write. +permissions: + contents: read + +# Cancel superseded runs on the same ref. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: ts: name: TypeScript (build, typecheck, test, lint) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -25,14 +35,15 @@ jobs: solidity: name: Solidity (build, test, fmt) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: foundry-rs/foundry-toolchain@v1 - working-directory: packages/solidity run: | - # forge-std is a test-only dep (not shipped); clone it rather than - # `forge install` to avoid submodule/flag churn across Foundry versions. - git clone --depth 1 https://github.com/foundry-rs/forge-std lib/forge-std + # forge-std is a test-only dep (not shipped); clone a pinned tag rather + # than `forge install` to avoid submodule/flag churn and master drift. + git clone --depth 1 --branch v1.9.4 https://github.com/foundry-rs/forge-std lib/forge-std forge build forge test forge fmt --check @@ -41,6 +52,7 @@ jobs: name: Changeset present if: github.event_name == 'pull_request' runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8c2935..69ce553 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,7 @@ permissions: jobs: release: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -23,7 +24,7 @@ jobs: cache: pnpm registry-url: https://registry.npmjs.org - run: pnpm install --frozen-lockfile - - run: npm install -g npm@latest # ensure npm >= 11.5.1 for OIDC publishing + - run: npm install -g npm@11 # pinned major; needs npm >= 11.5.1 for OIDC publishing - run: pnpm build # Opens/updates the "Version Packages" PR; on merge, publishes changed packages. # Publish uses npm Trusted Publishing (OIDC) — provenance is emitted automatically, diff --git a/docs/adr/0004-publish-via-oidc-trusted-publishing.md b/docs/adr/0004-publish-via-oidc-trusted-publishing.md index 6d43967..b38f7a2 100644 --- a/docs/adr/0004-publish-via-oidc-trusted-publishing.md +++ b/docs/adr/0004-publish-via-oidc-trusted-publishing.md @@ -17,7 +17,7 @@ Setup (one-time, requires the npm org owner): - For **each** package on npmjs.com → Settings → **Trusted Publisher** → GitHub repo `efs-project/sdk`, workflow `.github/workflows/release.yml`. - The workflow declares `permissions: id-token: write`, uses Node ≥ 22.14 / npm ≥ 11.5.1, and the **repo must stay public** (OIDC provenance is not emitted from private repos). -Mechanics: a merge to `main` runs the Changesets "Version Packages" PR; merging that publishes the changed packages. Provenance is attached automatically — no `--provenance` flag, no `publishConfig.provenance` needed. +Mechanics: a merge to `main` runs the Changesets "Version Packages" PR; merging that publishes the changed packages. Under OIDC, provenance is emitted automatically — but because `changeset publish` shells out through pnpm/npm and the implicit path has had rough edges (changesets/action#542), we set `publishConfig.provenance: true` on both packages to make it explicit rather than rely on it being inferred. ## Consequences diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 82b848a..5bb753d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -11,7 +11,7 @@ "directory": "packages/sdk" }, "files": ["dist"], - "publishConfig": { "access": "public" }, + "publishConfig": { "access": "public", "provenance": true }, "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/solidity/package.json b/packages/solidity/package.json index 10a9d8a..e7dc1b3 100644 --- a/packages/solidity/package.json +++ b/packages/solidity/package.json @@ -9,7 +9,7 @@ "directory": "packages/solidity" }, "files": ["src/**/*.sol", "README.md"], - "publishConfig": { "access": "public" }, + "publishConfig": { "access": "public", "provenance": true }, "keywords": ["ethereum", "efs", "eas", "solidity", "filesystem"], "homepage": "https://github.com/efs-project/sdk/tree/main/packages/solidity", "scripts": { From eadb898eab4d1ebf42c8c7612b6ddd75b13922c3 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 20:00:07 -0500 Subject: [PATCH 08/47] fix(solidity): forge fmt clean + drop stub warnings forge fmt --check was the last red CI job. Reformatted EFSLib/EFSWriter to Foundry style (verified locally with forge 1.7.1), simplified the stub signatures to unnamed params/returns so there are no unused-variable warnings, and removed the unreachable emit in _efsPinFile. Build + test pass locally. Co-authored-by: Claude Opus 4.8 --- packages/solidity/src/EFSLib.sol | 39 +++++++++-------------------- packages/solidity/src/EFSWriter.sol | 13 ++++------ 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/packages/solidity/src/EFSLib.sol b/packages/solidity/src/EFSLib.sol index f1877df..6cfaf74 100644 --- a/packages/solidity/src/EFSLib.sol +++ b/packages/solidity/src/EFSLib.sol @@ -7,56 +7,41 @@ pragma solidity ^0.8.28; /// the calling contract — preserving `msg.sender` as the EAS attester, which /// EFS lenses and cardinality-1 PINs depend on (ADR-0003). /// @dev Status: scaffold. Signatures are shaped per planning/Designs/sdk-architecture.md -/// (On-chain SDK section); bodies are stubs until the build lands. Do NOT deploy -/// this as a standalone helper — a separate CALL would make the helper the -/// attester and collapse every consumer into one identity. +/// (On-chain SDK section); bodies revert until the build lands. Do NOT deploy this +/// as a standalone helper — a separate CALL would make the helper the attester and +/// collapse every consumer into one identity. library EFSLib { error NotImplemented(); - // ── Reads (lens-scoped) ──────────────────────────────────────────────────── + // --- Reads (lens-scoped) --- // read(path) with no lens defaults to the consuming contract's own data // (lens = [address(this)]). readAs names an explicit author. Never tx.origin. + // Returns (exists, dataUID) so a missing file is distinguishable from a present one. - /// @notice Read the data UID at `path` for the consuming contract's own lens. - /// @return exists Whether a file is present (your namespace is usually empty). - /// @return dataUID The active DATA attestation UID at the path. - function read(string memory /*path*/ ) - internal - view - returns (bool exists, bytes32 dataUID) - { - // lens = [address(this)] - exists; // silence + /// @notice Read the active data UID at `path` for the consuming contract's own lens. + function read(string memory) internal view returns (bool, bytes32) { revert NotImplemented(); } /// @notice Read `path` resolved through an explicit author address. - function readAs(string memory, /*path*/ address /*who*/ ) - internal - view - returns (bool exists, bytes32 dataUID) - { + function readAs(string memory, address) internal view returns (bool, bytes32) { revert NotImplemented(); } /// @notice Read `path` resolved through an explicit, ordered lens stack. - function read(string memory, /*path*/ address[] memory /*lenses*/ ) - internal - view - returns (bool exists, bytes32 dataUID) - { + function read(string memory, address[] memory) internal view returns (bool, bytes32) { revert NotImplemented(); } - // ── Writes ───────────────────────────────────────────────────────────────── + // --- Writes --- /// @notice Pin a file: place `dataUID` at `path`. The consuming contract is the attester. - function pinFile(string memory, /*path*/ bytes32 /*dataUID*/ ) internal returns (bytes32 pinUID) { + function pinFile(string memory, bytes32) internal returns (bytes32) { revert NotImplemented(); } /// @notice Create a folder hierarchy for `path` (mkdir -p). - function mkdir(string memory /*path*/ ) internal returns (bytes32 anchorUID) { + function mkdir(string memory) internal returns (bytes32) { revert NotImplemented(); } } diff --git a/packages/solidity/src/EFSWriter.sol b/packages/solidity/src/EFSWriter.sol index 4477298..d3f3761 100644 --- a/packages/solidity/src/EFSWriter.sol +++ b/packages/solidity/src/EFSWriter.sol @@ -7,12 +7,10 @@ import {EFSLib} from "./EFSLib.sol"; /// @notice Inheritable base contract — the happy path for adding EFS to your contract. /// Inherit it and call the wrapped helpers; because the base runs in your /// contract's context, your contract stays the EAS attester (ADR-0003). -/// @dev Status: scaffold. Methods delegate to {EFSLib}; bodies are stubs until the -/// build lands. EFS-level events/errors live here (EAS events are UID-keyed, -/// not domain-keyed, so domain consumers need these). +/// @dev Status: scaffold. Methods delegate to {EFSLib}, whose bodies revert until the +/// build lands. EFS-level events/errors live here (EAS events are UID-keyed, not +/// domain-keyed, so domain consumers need these). abstract contract EFSWriter { - using EFSLib for *; - /// @notice Emitted when this contract pins a file at a path. event EfsFilePinned(string path, bytes32 indexed dataUID, bytes32 pinUID); @@ -21,9 +19,8 @@ abstract contract EFSWriter { return EFSLib.read(path); } - /// @notice Pin a file at `path`; emits {EfsFilePinned}. + /// @notice Pin a file at `path`. Once implemented, emits {EfsFilePinned}. function _efsPinFile(string memory path, bytes32 dataUID) internal returns (bytes32 pinUID) { - pinUID = EFSLib.pinFile(path, dataUID); - emit EfsFilePinned(path, dataUID, pinUID); + return EFSLib.pinFile(path, dataUID); } } From bd6f0a35bb81443d419ed4e1b9804d3c15d9b2a4 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 21:25:59 -0500 Subject: [PATCH 09/47] =?UTF-8?q?ci/sdk:=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=20release=20build=20scope=20+=20stale=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: release workflow ran `pnpm build` (turbo) which builds @efs/solidity via `forge build`, but never installs Foundry → release job would fail on main. Scope the release build to @efs/sdk only; the Solidity package publishes .sol source and needs no build artifact (CI compile-checks it per-PR). - P2: remove the hardcoded `version = '0.0.0'` export — it would go stale after the first Changesets bump. Re-add derived from the manifest when implementing. Co-authored-by: Claude Opus 4.8 --- .github/workflows/release.yml | 5 ++++- package.json | 2 +- packages/sdk/src/index.ts | 2 -- packages/sdk/test/index.test.ts | 6 +----- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69ce553..0e6c61d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,10 @@ jobs: registry-url: https://registry.npmjs.org - run: pnpm install --frozen-lockfile - run: npm install -g npm@11 # pinned major; needs npm >= 11.5.1 for OIDC publishing - - run: pnpm build + # Only the TS package needs a build artifact to publish; @efs/solidity ships + # .sol source as-is, so the release path needs no Foundry. (CI compile-checks + # the Solidity package on every PR before merge.) + - run: pnpm --filter @efs/sdk build # Opens/updates the "Version Packages" PR; on merge, publishes changed packages. # Publish uses npm Trusted Publishing (OIDC) — provenance is emitted automatically, # no token is read. Requires a Trusted Publisher configured PER PACKAGE on npmjs.com: diff --git a/package.json b/package.json index be49d4b..d373b80 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "lint": "biome ci .", "format": "biome format --write .", "changeset": "changeset", - "release": "turbo run build && changeset publish" + "release": "pnpm --filter @efs/sdk build && changeset publish" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index da1eb61..f444ee9 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -75,5 +75,3 @@ export type EfsClient = { export function createEfsClient(_config: EfsClientConfig): EfsClient { throw new NotImplemented('createEfsClient()') } - -export const version = '0.0.0' diff --git a/packages/sdk/test/index.test.ts b/packages/sdk/test/index.test.ts index 90e8220..fe0e38e 100644 --- a/packages/sdk/test/index.test.ts +++ b/packages/sdk/test/index.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from 'vitest' -import { NotImplemented, createEfsClient, identity, lens, version } from '../src/index.js' +import { NotImplemented, createEfsClient, identity, lens } from '../src/index.js' describe('@efs/sdk scaffold', () => { - it('exposes a version', () => { - expect(version).toBe('0.0.0') - }) - it('stubs throw NotImplemented until the build lands', () => { expect(() => lens('0x0000000000000000000000000000000000000001')).toThrow(NotImplemented) expect(() => identity('jamescarnley.eth')).toThrow(NotImplemented) From ba5c8b144f10913734e0ee080d38bcee9af59089 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 21:48:05 -0500 Subject: [PATCH 10/47] =?UTF-8?q?adr:=20ADR-0005=20=E2=80=94=20per-chain?= =?UTF-8?q?=20deployments=20registry;=20SDK=20is=20a=20client=20not=20a=20?= =?UTF-8?q?deployer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK ships a maintained chainId -> {addresses, schema UIDs} registry and resolves EFS by chain (with a custom-deployment override). It deploys nothing; integration tests fork a registry chain (anvil --fork-url) like the contracts repo, rather than reimplementing EFS's CREATE3/proxy deploy. Reflected in the overview spec. Co-authored-by: Claude Opus 4.8 --- ...005-deployments-registry-not-a-deployer.md | 32 +++++++++++++++++++ docs/adr/README.md | 3 +- docs/specs/overview.md | 2 +- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0005-deployments-registry-not-a-deployer.md diff --git a/docs/adr/0005-deployments-registry-not-a-deployer.md b/docs/adr/0005-deployments-registry-not-a-deployer.md new file mode 100644 index 0000000..70d7cb8 --- /dev/null +++ b/docs/adr/0005-deployments-registry-not-a-deployer.md @@ -0,0 +1,32 @@ +# ADR-0005: Ship a per-chain deployments registry; the SDK is a client, not a deployer + +**Status:** Accepted +**Date:** 2026-06-10 +**Related:** ADR-0002, ADR-0003, planning/Designs/sdk-architecture.md (Instantiation) + +## Context + +The SDK reads/writes EFS, which lives in deployed contracts (EAS + the EFS resolvers/views) with registered schema UIDs. To talk to a chain, the SDK needs those addresses and UIDs. The SDK itself is **not deployed** — it's a client library that consumers run inside their own projects on whatever chain they target. So the open question is: how does the SDK know *where* EFS is? + +## Decision + +The SDK ships a **maintained per-chain deployments registry** and resolves addresses from it. It deploys nothing. + +- A `deployments` map keyed by `chainId` holds the EAS + EFS contract addresses and the **frozen schema UIDs** for each supported chain. +- `createEfsClient({ publicClient, walletClient })` infers `chainId` from the viem client and looks up the registry. Supported chain → works with zero address config. +- An **override** (`createEfsClient({ ..., deployments: customMap })`) points the SDK at a custom or local deployment (a contributor's anvil, a private chain). +- **Integration tests fork a registry chain** — `anvil --fork-url ` — the same approach the contracts repo uses. The fork carries the real addresses, so the registry is exercised unchanged. We do **not** reimplement EFS's CREATE3/proxy deploy locally. +- Source of truth for the registry data is the contracts repo's `FREEZE_LEDGER` / `deployedContracts.ts`. The SDK mirrors it and updates when EFS deploys to a new chain. + +## Consequences + +- **Consumer friction is minimal** — one line for a supported chain, an escape hatch for custom deployments. Their project/run setup stays their concern. +- **Low maintenance** — EFS addresses are CREATE3-deterministic and frozen, so the registry is append-only and stable, not churny. Adding a chain is a data change, not a code change. +- **Pre-deploy reality** — until a chain's EFS is live (Sepolia is gated on the freeze sign-off), its registry entry doesn't exist; unit tests cover pure logic until then, and integration tests light up when the fork target exists. +- The registry must be kept in sync with contracts deploys — a small, well-bounded sync task tied to the (rare) event of a new chain deployment. + +## Alternatives considered + +- **Require consumers to pass all addresses** — rejected: high friction and error-prone for the 99% case (a known chain). +- **Resolve addresses from an on-chain registry / ENS at runtime** — rejected for v1: extra round-trips for data that is static and frozen; a shipped map is simpler and offline-friendly. Could revisit if cross-chain discovery ever needs it. +- **Reimplement EFS deploy locally for integration tests** — rejected: forking the real chain is simpler, higher-fidelity, and toolchain-agnostic. diff --git a/docs/adr/README.md b/docs/adr/README.md index a3321f5..450d9c8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -50,5 +50,6 @@ Compact, scannable — one screen per ADR. Copy `_template.md`. The next number - [ADR-0002 — viem-only; do not depend on the ethers-based EAS SDK](./0002-viem-only-no-eas-sdk-dependency.md) - [ADR-0003 — Ship the on-chain SDK as compile-in Solidity source](./0003-onchain-sdk-as-compile-in-source.md) - [ADR-0004 — Publish via npm Trusted Publishing (OIDC), not a stored token](./0004-publish-via-oidc-trusted-publishing.md) +- [ADR-0005 — Per-chain deployments registry; the SDK is a client, not a deployer](./0005-deployments-registry-not-a-deployer.md) -_Recommended next: ADR-0005 error model · ADR-0006 public API surface & semver policy (write when the code lands)._ +_Recommended next: ADR-0006 error model · ADR-0007 public API surface & semver policy (write when the code lands)._ diff --git a/docs/specs/overview.md b/docs/specs/overview.md index f0b3524..3515989 100644 --- a/docs/specs/overview.md +++ b/docs/specs/overview.md @@ -26,7 +26,7 @@ Picking a path where you meant a specific version is silent breakage when the da ## What the SDK does and doesn't do -- **Does:** simplify multi-step reads/writes, resolve lenses, batch writes, expose EAS cleanly (viem-native), and give a typed escape hatch to the raw contracts. +- **Does:** simplify multi-step reads/writes, resolve lenses, batch writes, expose EAS cleanly (viem-native), give a typed escape hatch to the raw contracts, and locate EFS by chain via a shipped **deployments registry** (addresses + schema UIDs) so a supported chain needs no address config ([ADR-0005](../adr/0005-deployments-registry-not-a-deployer.md)). The SDK deploys nothing. - **Doesn't (yet):** bundle an indexer. Reverse-lookups ("who tagged this?") that need an external index are stubbed and out of scope for v1. For exact signatures, see the package READMEs and (later) the generated API reference. From d1e88d32fa9c1579766a36b4fa3fe8aed30fb41d Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 22:14:10 -0500 Subject: [PATCH 11/47] fix: address SDK-relevant findings from holistic review (cheap defects) - DX-7/ENG-14: lower Solidity pragma floor ^0.8.28 -> ^0.8.26 so the library is compilable alongside the contracts repo (pinned 0.8.26). Real compat bug. - DX-11: fix wrong ADR pointer in index.ts (cited ADR-0004 = OIDC publishing for the error model; now points at "Recommended next"). - DX-10: repoint READMEs from the private planning vault to the public in-repo docs/specs/overview.md as the primary "how it works" reference. Larger API-shape findings (resume/onProgress in pinFile, fetch+verify, resolvedBy, branded UIDs, pagination iterator, deployments codegen) tracked for the beta-slice spec. Co-authored-by: Claude Opus 4.8 --- README.md | 2 +- packages/sdk/README.md | 2 +- packages/sdk/src/index.ts | 3 ++- packages/solidity/src/EFSLib.sol | 2 +- packages/solidity/src/EFSWriter.sol | 2 +- packages/solidity/test/EFSWriter.t.sol | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 19f66c8..44709cc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The developer SDK for the **Ethereum File System (EFS)** — an on-chain filesystem built on [EAS](https://attest.org) attestations. -> **Status: scaffold.** Repo structure, toolchain, and public API shapes are in place; implementations land next. Architecture: [`planning/Designs/sdk-architecture.md`](https://github.com/efs-project/planning). Decisions: [`docs/adr/`](./docs/adr). +> **Status: scaffold.** Repo structure, toolchain, and public API shapes are in place; implementations land next. How it works: [`docs/specs/overview.md`](./docs/specs/overview.md). Decisions: [`docs/adr/`](./docs/adr). (Cross-cutting design rationale lives in the EFS planning vault, internal.) ## Two packages, two audiences diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 2d91eca..d92ac22 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -2,7 +2,7 @@ TypeScript SDK for the **Ethereum File System (EFS)** — read and write an on-chain filesystem built on EAS attestations. -> **Status: scaffold.** The public surface is shaped; method bodies are stubs until the build lands. See [`planning/Designs/sdk-architecture.md`](https://github.com/efs-project/planning) for the design and [`docs/adr/`](../../docs/adr) for decisions. +> **Status: scaffold.** The public surface is shaped; method bodies are stubs until the build lands. See [`docs/specs/overview.md`](../../docs/specs/overview.md) for how it works and [`docs/adr/`](../../docs/adr) for decisions. ## Install diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f444ee9..52abe79 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -11,7 +11,8 @@ import type { Address, PublicClient, WalletClient } from 'viem' // ── Errors ─────────────────────────────────────────────────────────────────── // Discriminated error base so external callers catch typed errors, never raw RPC -// strings (ADR-0004, pending). Mirrors viem's BaseError ergonomics. +// strings (error-model ADR pending — see docs/adr "Recommended next"). Mirrors +// viem's BaseError ergonomics. export class EfsError extends Error { override name = 'EfsError' diff --git a/packages/solidity/src/EFSLib.sol b/packages/solidity/src/EFSLib.sol index 6cfaf74..ffeeeef 100644 --- a/packages/solidity/src/EFSLib.sol +++ b/packages/solidity/src/EFSLib.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; +pragma solidity ^0.8.26; /// @title EFSLib /// @notice Internal library for reading and writing the Ethereum File System (EFS) diff --git a/packages/solidity/src/EFSWriter.sol b/packages/solidity/src/EFSWriter.sol index d3f3761..7d3f4bc 100644 --- a/packages/solidity/src/EFSWriter.sol +++ b/packages/solidity/src/EFSWriter.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; +pragma solidity ^0.8.26; import {EFSLib} from "./EFSLib.sol"; diff --git a/packages/solidity/test/EFSWriter.t.sol b/packages/solidity/test/EFSWriter.t.sol index 97c380a..d7ff5c7 100644 --- a/packages/solidity/test/EFSWriter.t.sol +++ b/packages/solidity/test/EFSWriter.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; +pragma solidity ^0.8.26; import {Test} from "forge-std/Test.sol"; import {EFSWriter} from "../src/EFSWriter.sol"; From cf3025e79a5ed3324e836b67ca8d2114160a5188 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 22:26:37 -0500 Subject: [PATCH 12/47] plan: beta-slice implementation spec (review-hardened) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First functional-slice plan for @efs/sdk: write/read real files with the API shapes frozen correctly before publish. Grounded in the freeze-branch contracts (contentHash now lives as a lens-scoped PROPERTY string; no chain deployed but a local fork exists) and two review passes. Adversarial review caught: (1) the spec/scaffold drifted to a FLAT API but the validated design is NAMESPACED (efs.fs.write) — realigned, surfaced as Decision F; (2) content verification is trust-relative not absolute (attacker can attest a contentHash under their own lens) — honest semantics baked in; (3) honest click count (~3 with metadata, not 2); richer WriteReceipt/preview; per-step idempotent resume; pinned local-fork artifact boundary; declared the large-file/update/delete seams. Decisions A/C/D/E/F surfaced for James. Co-authored-by: Claude Opus 4.8 --- docs/plans/beta-slice.md | 112 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/plans/beta-slice.md diff --git a/docs/plans/beta-slice.md b/docs/plans/beta-slice.md new file mode 100644 index 0000000..757ea0c --- /dev/null +++ b/docs/plans/beta-slice.md @@ -0,0 +1,112 @@ +# Beta-slice implementation plan + +> **What this is:** the plan for the first functional slice of `@efs/sdk` — enough to read and write real EFS files, with the public API *shapes* frozen correctly before publish. Grounded in the schema-freeze-branch contracts, the 2026-06-10 holistic review, and an adversarial review of this plan. Status: **draft for James's review** before implementation. + +## Goal + +A dev installs `@efs/sdk`, points it at a chain, **writes a file** (resumable, content-dedup-safe) and **reads it back** (bytes, content-verified, with attribution). TypeScript only; `@efs/solidity` implementation follows later. + +## Grounding facts (verified against `.wt-schema-freeze`, 2026-06-10) + +- **9 frozen schemas** (`deploy/lib/schemas.ts:36-66`): ANCHOR `string name, bytes32 schemaUID`; PROPERTY `string value` (revocable, ADR-0052); **DATA `""` (empty)**; PIN `bytes32 definition`; TAG `bytes32 definition, int256 weight`; MIRROR `bytes32 transportDefinition, string uri`; LIST/LIST_ENTRY/REDIRECT. +- **A file's `contentHash`, `size`, `contentType` are reserved-key PROPERTYs bound to the DATA UID** — each a 3-attestation bundle (key-anchor→DATA, PROPERTY(`string value`), binding PIN), **lens-scoped per attester**. The hash value is a **self-describing multibase-multihash / CID string** (no on-chain algorithm marker); the encoding spec is **unwritten upstream** → the SDK must own it. +- **No chain is deployed yet** except the contracts repo's local **chain 31337** (Sepolia fork). Real Sepolia is `0x…TBD` pending James's freeze sign-off; schema UIDs are on-chain getters; addresses are post-deploy (CREATE3 planned, not realized). + +## API surface — namespaced, per the architecture doc (NOT flat) + +**Correction from review:** an earlier draft of this plan froze flat free functions (`pinFile`, `read`). The authority — `planning/Designs/sdk-architecture.md` (Q2 resolved: resource-namespaced, eight-verb vocabulary, à la Stripe/Prisma) — specifies a **namespaced client**. We follow the doc; the scaffold's flat `index.ts` is realigned as part of this slice. (See Decision F.) + +```ts +const efs = createEfsClient({ publicClient, walletClient?, deployments? }) +// Exact verb names + the eight-verb vocabulary are owned by sdk-architecture.md; +// the beta implements the efs.fs slice + the efs.lenses / efs.EAS / efs.raw seams. + +// efs.fs — files +efs.fs.write(path, bytes, opts?): Promise +efs.fs.read(path, opts?): Promise // resolves a ref + attribution +efs.fs.fetch(ref, opts?): Promise // ref → bytes (+ verification) +efs.fs.stat(path, opts?): Promise +efs.fs.list(path, opts?): AsyncIterable // hides cursor/0-restart/20-lens footguns (DX-9) +efs.fs.preview(path, bytes): Promise // cost/tx/sig preflight (UX-1/UX-13) + +// efs.lenses — resolution (trivial v1: addr→[addr], identity()=ENS→[addr]; ADR-0039 seam) +// efs.EAS — vendored viem-native EAS access (ADR-0002) +// efs.raw — typed escape hatch to the contracts +``` + +### Frozen value shapes (align to the architecture doc's richer types) + +```ts +type WriteReceipt = { + contentHash: string // multihash/CID string (SDK-owned encoding, Decision A) + data?: DataRef + steps: Array<{ id: string; uid?: AttestationUID; done: boolean }> // path-qualified, idempotent (see Resume) + signatureCount: number + mechanism: 'multiAttest-sequential' | 'eip5792' | 'erc4337' +} +type ReadResult = { data: DataRef; resolvedBy: Address } // which lens/attester won (UX-4) +type EfsFile = { + bytes: Uint8Array + contentType?: string + // TRUST-RELATIVE, not absolute integrity (see Verification semantics): + verification: 'matches-author' | 'no-claim' | 'mismatch' + hashAuthor?: Address // whose contentHash PROPERTY we checked against +} +type WriteEstimate = { + attestations: number; transactions: number; signatureCount: number + chunkDeploys: number // large files store bytes via SSTORE2 chunks + gas: bigint; estimatedUSD?: number; warnings: string[] +} +// Branded UID kinds — wrong-UID-kind is the dominant integration bug (DX-13) +type DataUID = `0x${string}` & { readonly __kind: 'DataUID' } +type DataRef = { readonly __brand: 'DataRef'; readonly uid: DataUID } +type PathRef = { readonly __brand: 'PathRef'; readonly path: string } +``` + +### Seams to declare now (throw `NotImplemented` in v1, but don't foreclose) + +Large-file **SSTORE2 multi-chunk** read/write + a **streaming/partial-read** path; **update-with-`previousVersion`** (version DAG) and **unpin/delete**; **batch-many-files**. The architecture doc carries these; freezing `fetch`/`write` without their seams would force a breaking change later. + +## Verification semantics (the honest model — review BLOCKER #2) + +`contentHash` is a **lens-scoped PROPERTY** — anyone can attest their own onto a popular DATA. So verification is **trust-relative, not absolute integrity**: the authoritative `contentHash` is the one attested by **the same attester whose lens won placement** (`ReadResult.resolvedBy`). `efs.fs.fetch` checks bytes against *that* author's claim and reports `matches-author` / `no-claim` / `mismatch` (plus `hashAuthor`). The SDK must never imply a bare `'verified'` that an attacker's lens could satisfy. + +## Resume / dedup (review BLOCKER-adjacent #6) + +On-chain `dataByContentKey` dedups DATA (same content → same DATA UID), so a retry resumes the DATA mint. But **placement is per-path** and MIRROR/PROPERTY attestations are **not** content-deduped. So the receipt's `steps[]` are **idempotent per step-id, path-qualified** — resume skips only mined steps, always mints a fresh placement PIN for the path, and records each MIRROR/PROPERTY UID *before* the crash window so a layer-1 retry can't double-mint. + +## Click count — honest (review SHOULD-FIX #4) + +The DAG is `DATA → key-anchor → binding-PIN` = **3 layers per property** (the key-anchor references DATA's mined UID, so it can't be layer-0). A bare file (no properties) is 2 layers. Recording contentType+contentHash+size keeps depth at 3 (the three bundles parallelize within their layers) but it is **~3 clicks, not 2**. State the count as a function of property count; stop implying 2 is typical. + +## Build order (Sepolia-independent first) + +1. **`chain/` deployments registry** — `DeploymentsMap` shape (addresses + 9 schema UIDs per chainId) + a **construct-time schema-UID mismatch check**. Seed chain 31337 (Decision C). Sepolia pending. +2. **`eas/`** — vendored EAS ABIs; `encodeSchemaData`, `attest`/`multiAttest`, UID derivation/verification. +3. **`content/`** — the SDK-owned multihash/CID content-hash encode + verify (Decision A). +4. **`schema/`** — the 9 schema encoders; the contentHash/size/contentType reserved-key PROPERTY bundles. +5. **`write/` — `efs.fs.write`** — DAG, one `multiAttest` per layer, thread mined UIDs, dedup, `onProgress`, `resume`, `WriteReceipt`. +6. **`read/` — `efs.fs.read`/`fetch`/`list`** — lens-resolved active DATA → ref + `resolvedBy`; transport-priority mirror resolution + `message/external-body` + trust-relative verification; the iterator. +7. **`lenses/`** — trivial resolver + ENS `identity()`; ADR-0039 hierarchy seam. +8. **`errors/`** — typed `EfsError` tree + the **null-vs-throw convention** (read miss → `null`; misuse → throw). Write the error-model ADR alongside. +9. **`preview` + branded types + chain-mismatch detection** woven through. + +## Test / validation strategy + +- **Unit** — encoding, hashing, DAG layering, lens resolution, UID derivation. No chain. +- **Integration against chain 31337 NOW** — boot the contracts repo's local fork via anvil/prool, point the SDK at the 31337 deployments, run real read/write **before** the Sepolia sign-off (Decision C). +- **`examples/ts-quickstart`** as the acceptance test — "write a file, read it back, verify bytes." +- **Sepolia fork** once live (ADR-0005). + +## Out of scope for the beta + +Reverse-lookup/discovery (`NotImplemented`), account-groups, the gateway, the `@efs/solidity` implementation (signatures only), subgraph helpers, large-file streaming (seam only). + +## Decisions to surface to James (important / controversial) + +- **F. Namespaced vs flat API — realign to the architecture doc (rec).** The validated design is namespaced (`efs.fs.write`); the scaffold drifted to flat (`pinFile`) — an unforced error on my part. **Rec:** realign to namespaced (the doc wins). *Going flat instead would be a conscious override of the validated, expert-reviewed Q2 decision — flag if you want that.* This is the one to settle first; it reshapes the scaffold's `index.ts`. +- **A. The SDK defines the content-hash encoding convention.** Upstream left multihash/CID encoding + vectors unwritten, so whatever the SDK emits becomes de-facto standard. **Rec:** multibase-multihash, keccak256 default (per ADR-0049's stated intent), documented as an SDK spec and surfaced upstream for a contracts-side blessing. *Protocol-adjacent; worth a conscious nod.* +- **Verification is trust-relative (not a choice so much as a truth to honor).** `'verified'` would mislead — an attacker can attest a matching `contentHash` under their own lens. We report `matches-author` against `resolvedBy`. Flagging because it changes what the SDK can promise. +- **C. Integration-test against the local fork (chain 31337) now.** Unblocks end-to-end validation before the freeze sign-off — the biggest schedule win. **Rec:** yes. *Cost: a concrete cross-repo artifact for the 31337 addresses/ABIs — a published `@efs/deployments` snapshot or a pinned vendored copy, NOT a live cross-repo read in CI (fragile). Pin this before building `chain/`.* +- **D. Beta = TypeScript only;** `@efs/solidity` stays signatures-only. **Rec:** yes (flag if OnionDAO needs the on-chain path sooner). +- **E. Record all three reserved PROPERTYs** (contentType, contentHash, size) per write. **Rec:** yes — cheap relative to the verification + rendering value; keeps depth at 3. From 85c7d3deb15bed5557fc5fa6358a2b33f7c1b8bd Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 22:28:55 -0500 Subject: [PATCH 13/47] ci: unblock Changesets version PR + make solidity tests self-bootstrapping - P1: the "Changeset present" gate would block Changesets' own Version Packages PR (it consumes the changeset files). Skip the gate for head_ref changeset-release/main so the version PR can pass and publish. - P2: `pnpm test` / `pnpm --filter @efs/solidity test` failed on a clean checkout (forge-std absent, lib/ gitignored). Add a `setup` script + prebuild/pretest hooks that bootstrap forge-std, and DRY the CI solidity job to run the package scripts so CI and a contributor's local run are identical. Co-authored-by: Claude Opus 4.8 --- .github/workflows/ci.yml | 23 ++++++++++++++--------- packages/solidity/package.json | 3 +++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a6abe4..07fd4ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,18 +39,23 @@ jobs: steps: - uses: actions/checkout@v4 - uses: foundry-rs/foundry-toolchain@v1 - - working-directory: packages/solidity - run: | - # forge-std is a test-only dep (not shipped); clone a pinned tag rather - # than `forge install` to avoid submodule/flag churn and master drift. - git clone --depth 1 --branch v1.9.4 https://github.com/foundry-rs/forge-std lib/forge-std - forge build - forge test - forge fmt --check + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + # Use the package scripts so CI and a contributor's `pnpm test` run the same + # path; the build/test pre-hooks bootstrap forge-std (test-only, not shipped). + - run: pnpm --filter @efs/solidity build + - run: pnpm --filter @efs/solidity test + - run: pnpm --filter @efs/solidity fmt:check changeset: name: Changeset present - if: github.event_name == 'pull_request' + # Skip the gate for Changesets' own "Version Packages" PR, which consumes + # (deletes) the changeset files — it legitimately has none left. + if: github.event_name == 'pull_request' && github.head_ref != 'changeset-release/main' runs-on: ubuntu-latest timeout-minutes: 10 steps: diff --git a/packages/solidity/package.json b/packages/solidity/package.json index e7dc1b3..d982545 100644 --- a/packages/solidity/package.json +++ b/packages/solidity/package.json @@ -13,6 +13,9 @@ "keywords": ["ethereum", "efs", "eas", "solidity", "filesystem"], "homepage": "https://github.com/efs-project/sdk/tree/main/packages/solidity", "scripts": { + "setup": "[ -d lib/forge-std ] || git clone --depth 1 --branch v1.9.4 https://github.com/foundry-rs/forge-std lib/forge-std", + "prebuild": "pnpm run setup", + "pretest": "pnpm run setup", "build": "forge build", "test": "forge test", "fmt": "forge fmt", From 2c88bc2789175bd0483c4d7886dc86d861b396c4 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 23:20:49 -0500 Subject: [PATCH 14/47] feat(content): contentHash = bare SHA-256 (ADR-0006) + real hashing module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision A closed after two expert passes: contentHash is a bare SHA-256 digest (lowercase hex, sha256sum-identical); the PROPERTY key is the algorithm tag — no multihash/CID/keccak (its future-proofing is illusory; the IPFS-CID rationale is false). Built it (it's pure, chain-independent): - src/content/hash.ts: hashContent() + trust-relative verifyContent() (matches-author/mismatch/no-claim; no-claim != success; malformed claim guarded) - tests against known SHA-256 vectors; exported from index.ts - ADR-0006 + docs/specs/content-hash.md (incl. fetch-safety: lens-scoped mirrors, size-cap streaming, ignore transport Content-Type) - updated beta-slice (Decision A done) + overview + ADR index (next: 0007/0008) Verified by subagent (impl correct, vectors confirmed, cross-docs consistent: GO). Co-authored-by: Claude Opus 4.8 --- docs/adr/0006-content-hash-bare-sha256.md | 38 ++++++++++++++++++++ docs/adr/README.md | 3 +- docs/plans/beta-slice.md | 8 ++--- docs/specs/content-hash.md | 44 +++++++++++++++++++++++ packages/sdk/src/content/hash.ts | 36 +++++++++++++++++++ packages/sdk/src/index.ts | 3 ++ packages/sdk/test/content-hash.test.ts | 33 +++++++++++++++++ 7 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0006-content-hash-bare-sha256.md create mode 100644 docs/specs/content-hash.md create mode 100644 packages/sdk/src/content/hash.ts create mode 100644 packages/sdk/test/content-hash.test.ts diff --git a/docs/adr/0006-content-hash-bare-sha256.md b/docs/adr/0006-content-hash-bare-sha256.md new file mode 100644 index 0000000..0d4a3fe --- /dev/null +++ b/docs/adr/0006-content-hash-bare-sha256.md @@ -0,0 +1,38 @@ +# ADR-0006: `contentHash` is a bare SHA-256 digest + +**Status:** Accepted +**Date:** 2026-06-10 +**Related:** docs/specs/content-hash.md, contracts ADR-0049 (DATA empty / hash-as-data), planning/Designs/sdk-architecture.md + +## Context + +A file's `contentHash` is recorded as a string PROPERTY and used by readers to verify fetched bytes. This is effectively permanent (a long-lived protocol artifact), and the encoding was left unwritten upstream (ADR-0049 gestured at "self-describing multihash / CID" but never specified it). We evaluated the options with two expert passes + web-researched governance/longevity facts. + +The candidates: raw **keccak-256** (EVM-native, cheapest on-chain), raw **SHA-256** (web/file standard, NIST FIPS 180-4, every stdlib), and **multihash/CID** (self-describing, Protocol Labs). + +Findings that decided it: +- **The "future-proofing" of self-description is largely illusory.** When SHA-256 eventually weakens, every reader must ship code to compute the *new* algorithm regardless — multihash tells you the name, not the implementation. So multihash ≈ a versioned field in migration cost; it's *labeling*, not future-proofing. +- **The IPFS-interop rationale is false.** An IPFS CID is the hash of the chunked Merkle-DAG root, **not** `sha256(file bytes)`, so a content hash does not need to be a CID; readers verify *fetched bytes* against the recorded hash regardless of transport. +- **Governance favors SHA-256** — a formal NIST/FIPS standard with no single owner; multihash's registry is single-vendor-governed (Protocol Labs, one maintainer, IETF draft not adopted). +- **EFS already namespaces by PROPERTY key**, so the key name *is* the algorithm tag — `contentHash` means SHA-256, removing the only real benefit of an in-value tag (disambiguating two 32-byte algorithms). + +## Decision + +**`contentHash` = a bare SHA-256 digest, lowercase hex, 64 chars, no `0x` prefix** — byte-identical to `sha256sum`. One algorithm; no multihash, no CID, no in-value tag. The PROPERTY key `contentHash` denotes SHA-256 permanently. + +- The SDK computes it automatically from the file bytes on write (`hashContent`); users never type a hash. +- keccak-256 is **not** used for the content hash (it matches no web/IPFS tool and would force an EVM hash lib on every consumer). It remains EAS/EVM-internal only; if cheap on-chain trustless derivation is ever wanted, that is a *separate, explicitly-named* field, not this one. +- **Migration** (decades out, when SHA-256 weakens): introduce a new explicitly-named PROPERTY key (e.g. `contentHashV2`); readers add the new algorithm then. Same reader-update cost as multihash, with no parser and no ambiguity. + +## Consequences + +- **Zero install for consumers** — verify with `sha256sum`, `crypto.subtle.digest('SHA-256')`, or any stdlib; no multiformats dependency. +- On-chain trustless verification still possible (SHA-256 precompile `0x02`, ~2× keccak gas — negligible off the hot path). +- The contract stores the value opaquely (`string`), so this imposes no on-chain constraint — it is purely a client convention. +- Cross-repo: surfaced to the schema-freeze dev as an **ADR-0049 follow-up** to simplify the upstream "multihash/CID" gesture to bare SHA-256, so EFS stays consistent rather than the SDK diverging. + +## Alternatives considered + +- **Multihash-wrapped SHA-256** — rejected: its self-description is a labeling convenience, not future-proofing; adds a parser + a single-vendor standard dependency for ~2 bytes the PROPERTY-key already provides. +- **Raw keccak-256** — rejected: no web/IPFS interop, forces an EVM hash lib on consumers; cheapest on-chain but the content hash isn't an EVM primitive. +- **Full CID** — rejected: heaviest (multibase + codec + multihash); a CID is a Merkle-DAG root, not a file-bytes hash. diff --git a/docs/adr/README.md b/docs/adr/README.md index 450d9c8..f75c978 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -51,5 +51,6 @@ Compact, scannable — one screen per ADR. Copy `_template.md`. The next number - [ADR-0003 — Ship the on-chain SDK as compile-in Solidity source](./0003-onchain-sdk-as-compile-in-source.md) - [ADR-0004 — Publish via npm Trusted Publishing (OIDC), not a stored token](./0004-publish-via-oidc-trusted-publishing.md) - [ADR-0005 — Per-chain deployments registry; the SDK is a client, not a deployer](./0005-deployments-registry-not-a-deployer.md) +- [ADR-0006 — `contentHash` is a bare SHA-256 digest](./0006-content-hash-bare-sha256.md) -_Recommended next: ADR-0006 error model · ADR-0007 public API surface & semver policy (write when the code lands)._ +_Recommended next: ADR-0007 error model · ADR-0008 public API surface & semver policy (write when the code lands)._ diff --git a/docs/plans/beta-slice.md b/docs/plans/beta-slice.md index 757ea0c..10c7921 100644 --- a/docs/plans/beta-slice.md +++ b/docs/plans/beta-slice.md @@ -9,7 +9,7 @@ A dev installs `@efs/sdk`, points it at a chain, **writes a file** (resumable, c ## Grounding facts (verified against `.wt-schema-freeze`, 2026-06-10) - **9 frozen schemas** (`deploy/lib/schemas.ts:36-66`): ANCHOR `string name, bytes32 schemaUID`; PROPERTY `string value` (revocable, ADR-0052); **DATA `""` (empty)**; PIN `bytes32 definition`; TAG `bytes32 definition, int256 weight`; MIRROR `bytes32 transportDefinition, string uri`; LIST/LIST_ENTRY/REDIRECT. -- **A file's `contentHash`, `size`, `contentType` are reserved-key PROPERTYs bound to the DATA UID** — each a 3-attestation bundle (key-anchor→DATA, PROPERTY(`string value`), binding PIN), **lens-scoped per attester**. The hash value is a **self-describing multibase-multihash / CID string** (no on-chain algorithm marker); the encoding spec is **unwritten upstream** → the SDK must own it. +- **A file's `contentHash`, `size`, `contentType` are reserved-key PROPERTYs bound to the DATA UID** — each a 3-attestation bundle (key-anchor→DATA, PROPERTY(`string value`), binding PIN), **lens-scoped per attester**. The hash value is a **bare SHA-256 lowercase-hex string** (ADR-0006; the PROPERTY key is the algorithm tag). The contract stores it opaquely, so this is a client convention. - **No chain is deployed yet** except the contracts repo's local **chain 31337** (Sepolia fork). Real Sepolia is `0x…TBD` pending James's freeze sign-off; schema UIDs are on-chain getters; addresses are post-deploy (CREATE3 planned, not realized). ## API surface — namespaced, per the architecture doc (NOT flat) @@ -38,7 +38,7 @@ efs.fs.preview(path, bytes): Promise // cost/tx/sig preflight ```ts type WriteReceipt = { - contentHash: string // multihash/CID string (SDK-owned encoding, Decision A) + contentHash: string // bare SHA-256 lowercase hex (ADR-0006) data?: DataRef steps: Array<{ id: string; uid?: AttestationUID; done: boolean }> // path-qualified, idempotent (see Resume) signatureCount: number @@ -83,7 +83,7 @@ The DAG is `DATA → key-anchor → binding-PIN` = **3 layers per property** (th 1. **`chain/` deployments registry** — `DeploymentsMap` shape (addresses + 9 schema UIDs per chainId) + a **construct-time schema-UID mismatch check**. Seed chain 31337 (Decision C). Sepolia pending. 2. **`eas/`** — vendored EAS ABIs; `encodeSchemaData`, `attest`/`multiAttest`, UID derivation/verification. -3. **`content/`** — the SDK-owned multihash/CID content-hash encode + verify (Decision A). +3. **`content/`** — bare SHA-256 content-hash (`hashContent`) + trust-relative verify (ADR-0006). **Done** (`content/hash.ts`, tested). 4. **`schema/`** — the 9 schema encoders; the contentHash/size/contentType reserved-key PROPERTY bundles. 5. **`write/` — `efs.fs.write`** — DAG, one `multiAttest` per layer, thread mined UIDs, dedup, `onProgress`, `resume`, `WriteReceipt`. 6. **`read/` — `efs.fs.read`/`fetch`/`list`** — lens-resolved active DATA → ref + `resolvedBy`; transport-priority mirror resolution + `message/external-body` + trust-relative verification; the iterator. @@ -105,7 +105,7 @@ Reverse-lookup/discovery (`NotImplemented`), account-groups, the gateway, the `@ ## Decisions to surface to James (important / controversial) - **F. Namespaced vs flat API — realign to the architecture doc (rec).** The validated design is namespaced (`efs.fs.write`); the scaffold drifted to flat (`pinFile`) — an unforced error on my part. **Rec:** realign to namespaced (the doc wins). *Going flat instead would be a conscious override of the validated, expert-reviewed Q2 decision — flag if you want that.* This is the one to settle first; it reshapes the scaffold's `index.ts`. -- **A. The SDK defines the content-hash encoding convention.** Upstream left multihash/CID encoding + vectors unwritten, so whatever the SDK emits becomes de-facto standard. **Rec:** multibase-multihash, keccak256 default (per ADR-0049's stated intent), documented as an SDK spec and surfaced upstream for a contracts-side blessing. *Protocol-adjacent; worth a conscious nod.* +- **A. Content-hash encoding — DECIDED (ADR-0006): bare SHA-256.** `contentHash` = lowercase-hex SHA-256 (matches `sha256sum`); the PROPERTY key is the algorithm tag; no multihash/CID/keccak. (Two expert passes killed the multihash case: its future-proofing is illusory and the IPFS-CID rationale is false.) Implemented in `content/hash.ts`; spec at [docs/specs/content-hash.md](../specs/content-hash.md). Surfaced upstream as an ADR-0049 follow-up. - **Verification is trust-relative (not a choice so much as a truth to honor).** `'verified'` would mislead — an attacker can attest a matching `contentHash` under their own lens. We report `matches-author` against `resolvedBy`. Flagging because it changes what the SDK can promise. - **C. Integration-test against the local fork (chain 31337) now.** Unblocks end-to-end validation before the freeze sign-off — the biggest schedule win. **Rec:** yes. *Cost: a concrete cross-repo artifact for the 31337 addresses/ABIs — a published `@efs/deployments` snapshot or a pinned vendored copy, NOT a live cross-repo read in CI (fragile). Pin this before building `chain/`.* - **D. Beta = TypeScript only;** `@efs/solidity` stays signatures-only. **Rec:** yes (flag if OnionDAO needs the on-chain path sooner). diff --git a/docs/specs/content-hash.md b/docs/specs/content-hash.md new file mode 100644 index 0000000..b1e1003 --- /dev/null +++ b/docs/specs/content-hash.md @@ -0,0 +1,44 @@ +# Spec — content hashing + +> How EFS records and verifies a file's content hash. Decision + rationale: [ADR-0006](../adr/0006-content-hash-bare-sha256.md). + +## The value + +A file's hash is recorded as a reserved-key PROPERTY on the file's DATA attestation: + +- **key:** `contentHash` +- **value:** the **SHA-256** of the raw file bytes, as a **lowercase hex string, 64 chars, no `0x` prefix** — byte-identical to `sha256sum `. + +The PROPERTY *key* is the algorithm tag. `contentHash` means SHA-256, always. There is no in-value algorithm prefix, no multihash, no CID. + +Companion reserved-key PROPERTYs on the same DATA: +- `size` — the byte length, as a decimal string. +- `contentType` — the MIME type. + +## Writing + +The SDK computes `contentHash` from the bytes on every write (`hashContent(bytes)`). A user never types a hash. (Registering already-existing off-chain content by a hash someone else computed is the only manual case.) + +## Verifying (trust-relative — read this carefully) + +`contentHash` is a **lens-scoped** PROPERTY: anyone can attest their own onto a popular DATA. So verification is **trust-relative, not absolute integrity**. The SDK verifies fetched bytes against the `contentHash` attested by **the same attester whose lens won placement** (`read`'s `resolvedBy`), and reports: + +- `matches-author` — bytes hash equals that author's claim. +- `mismatch` — bytes do not match (or exceed the declared `size`). +- `no-claim` — the resolving author attested no `contentHash`. **This is UNVERIFIABLE, not "ok"** — callers must treat it as failure-to-verify, not success. The type forbids mistaking it for a pass. + +The SDK never emits a bare `'verified'` that an attacker's lens could satisfy. + +### Fetch-path safety (implementation requirements) + +- **Mirror selection:** `fetch` resolves mirrors only from attesters in the resolving lens stack — never lens-blind (an attacker can attest a MIRROR onto popular DATA). +- **Streaming under a size cap:** hash incrementally while fetching; abort and report `mismatch` if bytes exceed the smaller of the declared `size` and a hard SDK ceiling — never buffer an unbounded/declared-50GB stream. +- **No transport trust:** ignore the HTTP `Content-Type`; use the lens-resolved `contentType` PROPERTY only. + +## Migration (decades-out) + +When SHA-256 weakens, introduce a new explicitly-named key (e.g. `contentHashV2`) for the successor algorithm; readers add it then and prefer it. The key name carries the algorithm, so old and new entries never collide and no value parsing is needed. + +## Trustless on-chain verification + +For on-chain (SSTORE2) content, a contract/reader can recompute SHA-256 from the stored bytes via the precompile at `0x02` (~2× keccak gas; fine off the hot path) and compare to `contentHash` — making the hash verifiable, not merely a claim. diff --git a/packages/sdk/src/content/hash.ts b/packages/sdk/src/content/hash.ts new file mode 100644 index 0000000..d846bac --- /dev/null +++ b/packages/sdk/src/content/hash.ts @@ -0,0 +1,36 @@ +/** + * Content hashing for EFS files. + * + * Convention (ADR-0006): a file's `contentHash` is a **bare SHA-256** digest as a + * lowercase hex string (64 chars, no `0x` prefix) — byte-identical to `sha256sum`. + * The PROPERTY *key* `contentHash` denotes the algorithm (SHA-256); a future + * algorithm would use a new key, never a tag inside this value. See + * docs/specs/content-hash.md. + */ + +import { sha256 } from 'viem' + +/** SHA-256 of the file bytes, as a bare lowercase-hex string (matches `sha256sum`). */ +export function hashContent(bytes: Uint8Array): string { + return sha256(bytes, 'hex').slice(2) // strip the 0x viem prepends +} + +/** Verification status of fetched bytes against an attested `contentHash`. */ +export type VerificationStatus = + | 'matches-author' // bytes hash equals the contentHash attested by the resolving lens + | 'no-claim' // the resolving lens attested no contentHash — UNVERIFIABLE, not "ok" + | 'mismatch' // bytes do not match the attested contentHash (or exceed declared size) + +/** + * Verify fetched bytes against the author's attested contentHash. + * `claimedHash` MUST come from the attester whose lens won placement (read's + * `resolvedBy`) — verification is trust-relative, not absolute integrity. + * `undefined` claimedHash → 'no-claim' (caller must treat as unverifiable). + */ +export function verifyContent(bytes: Uint8Array, claimedHash: string | undefined): VerificationStatus { + if (claimedHash === undefined) return 'no-claim' + const claim = claimedHash.toLowerCase() + // A malformed claim (0x-prefixed, padded, wrong length) cannot be trusted → mismatch. + if (!/^[0-9a-f]{64}$/.test(claim)) return 'mismatch' + return hashContent(bytes) === claim ? 'matches-author' : 'mismatch' +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 52abe79..970e588 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -76,3 +76,6 @@ export type EfsClient = { export function createEfsClient(_config: EfsClientConfig): EfsClient { throw new NotImplemented('createEfsClient()') } + +// Content hashing (ADR-0006: bare SHA-256). Real, chain-independent — usable now. +export { hashContent, verifyContent, type VerificationStatus } from './content/hash.js' diff --git a/packages/sdk/test/content-hash.test.ts b/packages/sdk/test/content-hash.test.ts new file mode 100644 index 0000000..4b86245 --- /dev/null +++ b/packages/sdk/test/content-hash.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { hashContent, verifyContent } from '../src/content/hash.js' + +const enc = (s: string) => new TextEncoder().encode(s) + +describe('content hashing (ADR-0006: bare SHA-256)', () => { + it('matches known SHA-256 vectors (byte-identical to sha256sum)', () => { + expect(hashContent(new Uint8Array())).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ) + expect(hashContent(enc('abc'))).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ) + }) + + it('produces a 64-char lowercase hex string with no 0x prefix', () => { + const h = hashContent(enc('hello')) + expect(h).toMatch(/^[0-9a-f]{64}$/) + }) + + it('verifies trust-relative: matches-author / mismatch / no-claim', () => { + const bytes = enc('gm') + expect(verifyContent(bytes, hashContent(bytes))).toBe('matches-author') + expect(verifyContent(bytes, hashContent(enc('other')))).toBe('mismatch') + expect(verifyContent(bytes, undefined)).toBe('no-claim') + }) + + it('treats a malformed claim (0x-prefixed / wrong length) as mismatch, not a pass', () => { + const bytes = enc('gm') + expect(verifyContent(bytes, `0x${hashContent(bytes)}`)).toBe('mismatch') + expect(verifyContent(bytes, 'deadbeef')).toBe('mismatch') + }) +}) From 39653f6167708b3ce07a643a3d748f4503f39132 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 23:22:29 -0500 Subject: [PATCH 15/47] style: biome-format verifyContent signature (CI lint fix) --- packages/sdk/src/content/hash.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/content/hash.ts b/packages/sdk/src/content/hash.ts index d846bac..42562f3 100644 --- a/packages/sdk/src/content/hash.ts +++ b/packages/sdk/src/content/hash.ts @@ -27,7 +27,10 @@ export type VerificationStatus = * `resolvedBy`) — verification is trust-relative, not absolute integrity. * `undefined` claimedHash → 'no-claim' (caller must treat as unverifiable). */ -export function verifyContent(bytes: Uint8Array, claimedHash: string | undefined): VerificationStatus { +export function verifyContent( + bytes: Uint8Array, + claimedHash: string | undefined, +): VerificationStatus { if (claimedHash === undefined) return 'no-claim' const claim = claimedHash.toLowerCase() // A malformed claim (0x-prefixed, padded, wrong length) cannot be trusted → mismatch. From e71548bc6a04db5fe42f8a66e46c181e568682e5 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 23:28:08 -0500 Subject: [PATCH 16/47] ci: disable auto-publish until launch (Codex P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changesets/action with `publish: pnpm release` would publish the unpublished 0.0.0 scaffold packages on the first no-changeset main run — premature, and the @efs org doesn't exist yet. Drop the `publish:` input so the action only manages the Version Packages PR and never publishes. Re-enable at launch with a one-line `with: { publish: pnpm release }` (documented inline + in ADR-0004). Co-authored-by: Claude Opus 4.8 --- .github/workflows/release.yml | 12 +++++++----- docs/adr/0004-publish-via-oidc-trusted-publishing.md | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e6c61d..941ec35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,13 +29,15 @@ jobs: # .sol source as-is, so the release path needs no Foundry. (CI compile-checks # the Solidity package on every PR before merge.) - run: pnpm --filter @efs/sdk build - # Opens/updates the "Version Packages" PR; on merge, publishes changed packages. - # Publish uses npm Trusted Publishing (OIDC) — provenance is emitted automatically, - # no token is read. Requires a Trusted Publisher configured PER PACKAGE on npmjs.com: + # PUBLISHING IS INTENTIONALLY DISABLED UNTIL LAUNCH. The packages are pre-1.0, + # unpublished 0.0.0, and the @efs npm org isn't created yet — auto-publishing + # now would push a scaffold. With no `publish:` input, changesets/action only + # manages the "Version Packages" PR; it never publishes. + # + # TO ENABLE AT LAUNCH: add `with: { publish: pnpm release }` below, and + # configure a Trusted Publisher PER PACKAGE on npmjs.com (OIDC, no token): # package settings -> Trusted Publisher -> repo "efs-project/sdk", # workflow ".github/workflows/release.yml". Repo must stay public. - uses: changesets/action@v1 - with: - publish: pnpm release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/adr/0004-publish-via-oidc-trusted-publishing.md b/docs/adr/0004-publish-via-oidc-trusted-publishing.md index b38f7a2..aacb967 100644 --- a/docs/adr/0004-publish-via-oidc-trusted-publishing.md +++ b/docs/adr/0004-publish-via-oidc-trusted-publishing.md @@ -17,7 +17,7 @@ Setup (one-time, requires the npm org owner): - For **each** package on npmjs.com → Settings → **Trusted Publisher** → GitHub repo `efs-project/sdk`, workflow `.github/workflows/release.yml`. - The workflow declares `permissions: id-token: write`, uses Node ≥ 22.14 / npm ≥ 11.5.1, and the **repo must stay public** (OIDC provenance is not emitted from private repos). -Mechanics: a merge to `main` runs the Changesets "Version Packages" PR; merging that publishes the changed packages. Under OIDC, provenance is emitted automatically — but because `changeset publish` shells out through pnpm/npm and the implicit path has had rough edges (changesets/action#542), we set `publishConfig.provenance: true` on both packages to make it explicit rather than rely on it being inferred. +**Publishing is disabled until launch.** Pre-launch the packages are unpublished `0.0.0` and the `@efs` org doesn't exist, so the release workflow runs `changesets/action` *without* the `publish:` input — it only manages the "Version Packages" PR and never publishes. At launch we add `with: { publish: pnpm release }` and configure the per-package Trusted Publisher. Once enabled, a merge to `main` runs the Version Packages PR; merging that publishes the changed packages. Under OIDC, provenance is emitted automatically — but because `changeset publish` shells out through pnpm/npm and the implicit path has had rough edges (changesets/action#542), we set `publishConfig.provenance: true` on both packages to make it explicit rather than rely on it being inferred. ## Consequences From 5e92a8d0f05e3f61242a90ac44802f3f20477554 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 23:40:36 -0500 Subject: [PATCH 17/47] feat(sdk): namespaced client (F) + EAS layer, lenses, deployments, errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realign to the namespaced client (Decision F): efs.fs.* / efs.lenses.* / efs.eas.* / efs.raw.*. Built the foundational, chain-independent layers: - eas/: viem-native EAS layer (vendored ABIs, SchemaEncoder, attest/multiAttest builders, UID re-derivation) — no ethers/eas-sdk (ADR-0002). 15 tests. - lenses/: opaque Lens, lens()/identity()/resolveLens; throws MaxLensesExceeded rather than truncating (review S2); ENS resolution; MAX_LENSES=20. - chain/: deployments registry + resolveDeployment + construct-time integrity gate (checks view/router bytecode, review S1) (ADR-0005). - errors/: typed EfsError tree (NotImplemented/LensRequired/MaxLensesExceeded/ SchemaMismatch/DeploymentNotFound). - types/: branded DataUID, DataRef vs PathRef, WriteReceipt/ReadResult/EfsFile. - fs.* verbs are NotImplemented stubs with their final signatures. format script now runs `biome check --write` (organizes imports too, matching CI). tsc/test(24)/build/lint all green. Co-authored-by: Claude Opus 4.8 --- package.json | 2 +- packages/sdk/src/chain/deployments.ts | 83 +++++++++++ packages/sdk/src/eas/abi.ts | 142 ++++++++++++++++++ packages/sdk/src/eas/attest.ts | 125 ++++++++++++++++ packages/sdk/src/eas/index.ts | 41 ++++++ packages/sdk/src/eas/schema-encoder.ts | 118 +++++++++++++++ packages/sdk/src/eas/uid.ts | 123 ++++++++++++++++ packages/sdk/src/errors.ts | 52 +++++++ packages/sdk/src/index.ts | 193 +++++++++++++++++-------- packages/sdk/src/lenses/resolve.ts | 71 +++++++++ packages/sdk/src/types.ts | 53 +++++++ packages/sdk/test/eas.test.ts | 174 ++++++++++++++++++++++ packages/sdk/test/index.test.ts | 43 +++++- 13 files changed, 1150 insertions(+), 70 deletions(-) create mode 100644 packages/sdk/src/chain/deployments.ts create mode 100644 packages/sdk/src/eas/abi.ts create mode 100644 packages/sdk/src/eas/attest.ts create mode 100644 packages/sdk/src/eas/index.ts create mode 100644 packages/sdk/src/eas/schema-encoder.ts create mode 100644 packages/sdk/src/eas/uid.ts create mode 100644 packages/sdk/src/errors.ts create mode 100644 packages/sdk/src/lenses/resolve.ts create mode 100644 packages/sdk/src/types.ts create mode 100644 packages/sdk/test/eas.test.ts diff --git a/package.json b/package.json index d373b80..b4ef95b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test": "turbo run test", "typecheck": "turbo run typecheck", "lint": "biome ci .", - "format": "biome format --write .", + "format": "biome check --write .", "changeset": "changeset", "release": "pnpm --filter @efs/sdk build && changeset publish" }, diff --git a/packages/sdk/src/chain/deployments.ts b/packages/sdk/src/chain/deployments.ts new file mode 100644 index 0000000..75069c3 --- /dev/null +++ b/packages/sdk/src/chain/deployments.ts @@ -0,0 +1,83 @@ +/** + * Per-chain deployments registry (ADR-0005): the SDK is a client + an address + * book, not a deployer. It resolves EFS by chainId; a custom/local chain is + * supplied via the `deployments` config override. + */ + +import type { Address, Hex, PublicClient } from 'viem' +import { DeploymentNotFound, EfsError } from '../errors.js' + +/** The EFS + EAS contract addresses on a chain. The view/router addresses are the + * read-resolution trust root, so they are integrity-checked at construct time. */ +export type EfsContracts = { + eas: Address + schemaRegistry: Address + indexer: Address + router: Address + fileView: Address + edgeResolver: Address + mirrorResolver: Address + listResolver: Address + listEntryResolver: Address + aliasResolver: Address +} + +/** The 9 frozen EFS schema UIDs (contracts ADR-0048). */ +export type EfsSchemaUIDs = { + anchor: Hex + property: Hex + data: Hex + pin: Hex + tag: Hex + mirror: Hex + list: Hex + listEntry: Hex + redirect: Hex +} + +export type EfsDeployment = { + chainId: number + contracts: EfsContracts + schemas: EfsSchemaUIDs +} + +export type DeploymentsMap = Record + +/** + * Built-in registry. Populated as EFS deploys to chains; values are generated + * from the contracts repo's deploy output (ADR-0005). Pre-launch this is empty — + * Sepolia (11155111) lands after the freeze sign-off. For a local fork + * (chainId 31337) until then, pass `deployments` in the client config. + */ +export const deployments: DeploymentsMap = {} + +/** Resolve the deployment for a chain, preferring a caller override. */ +export function resolveDeployment(chainId: number, override?: DeploymentsMap): EfsDeployment { + const map = override ?? deployments + const found = map[chainId] + if (!found) throw new DeploymentNotFound(chainId) + return found +} + +/** + * Construct-time integrity gate (ADR-0005 / review S1). The view/router addresses + * resolve every read, so a wrong, typo'd, or non-contract address must be rejected + * loudly — not silently resolve to nothing. Verifies each EFS contract has deployed + * bytecode on the target chain. + * + * TODO(build): also assert `deployment.schemas` match the indexer's on-chain UID + * getters once the eas read layer lands (catches a UID/registry mismatch). + */ +export async function assertDeploymentIntegrity( + publicClient: PublicClient, + deployment: EfsDeployment, +): Promise { + for (const [name, addr] of Object.entries(deployment.contracts)) { + const code = await publicClient.getCode({ address: addr }) + if (!code || code === '0x') { + throw new EfsError( + `EFS deployment integrity check failed: contract '${name}' at ${addr} has no bytecode on chainId ${deployment.chainId}.`, + ) + } + } +} diff --git a/packages/sdk/src/eas/abi.ts b/packages/sdk/src/eas/abi.ts new file mode 100644 index 0000000..9e62788 --- /dev/null +++ b/packages/sdk/src/eas/abi.ts @@ -0,0 +1,142 @@ +/** + * Vendored EAS contract ABI fragments (ADR-0002: viem-only, no eas-sdk). + * + * These are hand-written `as const` viem ABI consts covering only the EAS + * surface the SDK uses: `attest`, `multiAttest`, `getAttestation`, and the + * schema registry's `getSchema`. Struct shapes mirror the EAS contracts + * exactly: + * + * - `AttestationRequestData` / `AttestationRequest` / `MultiAttestationRequest` + * from `IEAS.sol` (lines 10-38). + * - `Attestation` (the `getAttestation` return) from `Common.sol` (lines 26-37). + * - `SchemaRecord` (the `getSchema` return) from `ISchemaRegistry.sol` + * (lines 10-15); `resolver` is an `address` here (the `ISchemaResolver` + * contract type is an address on the wire). + * + * Keep this minimal — add fragments only when a code path needs them. + */ + +/** `IEAS.attest(AttestationRequest)` — single attestation. Returns the new UID. */ +export const attestAbi = [ + { + type: 'function', + name: 'attest', + stateMutability: 'payable', + inputs: [ + { + name: 'request', + type: 'tuple', + components: [ + { name: 'schema', type: 'bytes32' }, + { + name: 'data', + type: 'tuple', + components: [ + { name: 'recipient', type: 'address' }, + { name: 'expirationTime', type: 'uint64' }, + { name: 'revocable', type: 'bool' }, + { name: 'refUID', type: 'bytes32' }, + { name: 'data', type: 'bytes' }, + { name: 'value', type: 'uint256' }, + ], + }, + ], + }, + ], + outputs: [{ name: '', type: 'bytes32' }], + }, +] as const + +/** + * `IEAS.multiAttest(MultiAttestationRequest[])` — batched attestations grouped + * by schema. Returns the flattened list of new UIDs. + */ +export const multiAttestAbi = [ + { + type: 'function', + name: 'multiAttest', + stateMutability: 'payable', + inputs: [ + { + name: 'multiRequests', + type: 'tuple[]', + components: [ + { name: 'schema', type: 'bytes32' }, + { + name: 'data', + type: 'tuple[]', + components: [ + { name: 'recipient', type: 'address' }, + { name: 'expirationTime', type: 'uint64' }, + { name: 'revocable', type: 'bool' }, + { name: 'refUID', type: 'bytes32' }, + { name: 'data', type: 'bytes' }, + { name: 'value', type: 'uint256' }, + ], + }, + ], + }, + ], + outputs: [{ name: '', type: 'bytes32[]' }], + }, +] as const + +/** `IEAS.getAttestation(bytes32) -> Attestation` (Common.sol struct). */ +export const getAttestationAbi = [ + { + type: 'function', + name: 'getAttestation', + stateMutability: 'view', + inputs: [{ name: 'uid', type: 'bytes32' }], + outputs: [ + { + name: '', + type: 'tuple', + components: [ + { name: 'uid', type: 'bytes32' }, + { name: 'schema', type: 'bytes32' }, + { name: 'time', type: 'uint64' }, + { name: 'expirationTime', type: 'uint64' }, + { name: 'revocationTime', type: 'uint64' }, + { name: 'refUID', type: 'bytes32' }, + { name: 'recipient', type: 'address' }, + { name: 'attester', type: 'address' }, + { name: 'revocable', type: 'bool' }, + { name: 'data', type: 'bytes' }, + ], + }, + ], + }, +] as const + +/** `ISchemaRegistry.getSchema(bytes32) -> SchemaRecord`. */ +export const getSchemaAbi = [ + { + type: 'function', + name: 'getSchema', + stateMutability: 'view', + inputs: [{ name: 'uid', type: 'bytes32' }], + outputs: [ + { + name: '', + type: 'tuple', + components: [ + { name: 'uid', type: 'bytes32' }, + { name: 'resolver', type: 'address' }, + { name: 'revocable', type: 'bool' }, + { name: 'schema', type: 'string' }, + ], + }, + ], + }, +] as const + +/** + * Combined EAS ABI for the functions the SDK calls on the EAS contract + * (`attest`, `multiAttest`, `getAttestation`). `getSchema` lives on the + * separate SchemaRegistry contract and is exported on its own. + */ +export const easAbi = [...attestAbi, ...multiAttestAbi, ...getAttestationAbi] as const + +/** SchemaRegistry ABI subset (just `getSchema`). */ +export const schemaRegistryAbi = [...getSchemaAbi] as const diff --git a/packages/sdk/src/eas/attest.ts b/packages/sdk/src/eas/attest.ts new file mode 100644 index 0000000..65cf9b4 --- /dev/null +++ b/packages/sdk/src/eas/attest.ts @@ -0,0 +1,125 @@ +/** + * Typed builders for EAS `attest` / `multiAttest` calls. + * + * These produce the argument tuples for viem's `writeContract` (or + * `simulateContract`) — they do NOT execute anything. The SDK stays a pure + * request builder here; the caller owns the `walletClient` and gas/value + * policy. Spread the returned object into `writeContract`: + * + * await walletClient.writeContract({ ...buildAttest(req, eas), account, chain }) + * + * Struct shapes mirror `IEAS.sol` (`AttestationRequestData` lines 10-17, + * `AttestationRequest` lines 20-23, `MultiAttestationRequest` lines 35-38). + */ + +import type { Address, Hex } from 'viem' +import { easAbi } from './abi.js' + +/** + * `AttestationRequestData` (IEAS.sol:10-17). + * + * `value` is optional in this builder and defaults to `0n` — it's the explicit + * ETH forwarded to the schema resolver, which is `0` for resolver-less EFS + * schemas. EAS keeps it explicit to prevent accidental sends; we preserve that + * by letting callers set it, while defaulting to the safe value. + */ +export interface AttestationRequestData { + /** The recipient of the attestation (may be the zero address). */ + recipient: Address + /** Unix expiration timestamp; `0n` means non-expiring (`NO_EXPIRATION_TIME`). */ + expirationTime: bigint + /** Whether this attestation may later be revoked. */ + revocable: boolean + /** UID of a related attestation, or the zero bytes32 (`EMPTY_UID`) for none. */ + refUID: Hex + /** ABI-encoded attestation payload (see `SchemaEncoder.encodeData`). */ + data: Hex + /** Explicit ETH forwarded to the resolver. Defaults to `0n`. */ + value?: bigint +} + +/** `AttestationRequest` (IEAS.sol:20-23): a schema UID plus one data entry. */ +export interface AttestationRequest { + /** The schema UID to attest against. */ + schema: Hex + /** The single attestation's data. */ + data: AttestationRequestData +} + +/** `MultiAttestationRequest` (IEAS.sol:35-38): a schema UID plus many entries. */ +export interface MultiAttestationRequest { + /** The schema UID shared by every entry in `data`. */ + schema: Hex + /** The attestation data entries, all under `schema`. */ + data: readonly AttestationRequestData[] +} + +const ZERO_VALUE = 0n + +/** Normalize one request-data entry, defaulting `value` to `0n`. */ +function normalizeData(d: AttestationRequestData) { + return { + recipient: d.recipient, + expirationTime: d.expirationTime, + revocable: d.revocable, + refUID: d.refUID, + data: d.data, + value: d.value ?? ZERO_VALUE, + } as const +} + +/** + * The shape consumed by viem's `writeContract` / `simulateContract`: an + * `address` plus the matched `abi`, `functionName`, and `args`. Callers add + * `account`, `chain`, and any `value`/gas overrides at the call site. + */ +export interface ContractCall { + /** The EAS contract address to call. */ + address: Address + /** The vendored EAS ABI (`easAbi`). */ + abi: typeof easAbi + /** The function being invoked. */ + functionName: TFunctionName + /** The positional argument tuple. */ + args: TArgs +} + +/** + * Build the `writeContract` args for a single `attest` call. Pure: returns the + * request, executes nothing. + */ +export function buildAttest( + easAddress: Address, + request: AttestationRequest, +): ContractCall<'attest', readonly [{ schema: Hex; data: ReturnType }]> { + return { + address: easAddress, + abi: easAbi, + functionName: 'attest', + args: [{ schema: request.schema, data: normalizeData(request.data) }] as const, + } +} + +/** + * Build the `writeContract` args for a `multiAttest` call. Requests should be + * grouped by distinct schema for EAS's batching optimization (IEAS.sol:162-163). + * Pure: returns the request, executes nothing. + */ +export function buildMultiAttest( + easAddress: Address, + requests: readonly MultiAttestationRequest[], +): ContractCall< + 'multiAttest', + readonly [readonly { schema: Hex; data: readonly ReturnType[] }[]] +> { + const multiRequests = requests.map((r) => ({ + schema: r.schema, + data: r.data.map(normalizeData), + })) + return { + address: easAddress, + abi: easAbi, + functionName: 'multiAttest', + args: [multiRequests] as const, + } +} diff --git a/packages/sdk/src/eas/index.ts b/packages/sdk/src/eas/index.ts new file mode 100644 index 0000000..591b7ff --- /dev/null +++ b/packages/sdk/src/eas/index.ts @@ -0,0 +1,41 @@ +/** + * EAS layer — the SDK's self-contained, viem-native interface to the Ethereum + * Attestation Service (ADR-0002: no ethers, no eas-sdk; ABIs vendored here). + * + * Public surface: + * - ABI consts for `attest` / `multiAttest` / `getAttestation` / `getSchema`. + * - `SchemaEncoder` for ABI-encoding/decoding attestation `data`. + * - `buildAttest` / `buildMultiAttest` request builders. + * - `computeAttestationUID` / `verifyAttestationUID` for UID re-derivation. + */ + +export { + attestAbi, + multiAttestAbi, + getAttestationAbi, + getSchemaAbi, + easAbi, + schemaRegistryAbi, +} from './abi.js' + +export { + SchemaEncoder, + parseSchema, + type SchemaField, +} from './schema-encoder.js' + +export { + buildAttest, + buildMultiAttest, + type AttestationRequest, + type AttestationRequestData, + type MultiAttestationRequest, + type ContractCall, +} from './attest.js' + +export { + computeAttestationUID, + verifyAttestationUID, + type AttestationUIDInput, + type MinedAttestation, +} from './uid.js' diff --git a/packages/sdk/src/eas/schema-encoder.ts b/packages/sdk/src/eas/schema-encoder.ts new file mode 100644 index 0000000..5472be9 --- /dev/null +++ b/packages/sdk/src/eas/schema-encoder.ts @@ -0,0 +1,118 @@ +/** + * A viem-native equivalent of the EAS `SchemaEncoder`. + * + * EAS schemas are declared as a Solidity-tuple-like field string, e.g. + * `"string name, bytes32 schemaUID"`. The on-chain `data` field of an + * attestation is the ABI encoding of those fields (head/tail encoded exactly + * like `abi.encode((string,bytes32))`). This module parses the field string + * into viem `AbiParameter`s and encodes/decodes values against them using + * `encodeAbiParameters` / `decodeAbiParameters` — no ethers, no eas-sdk + * (ADR-0002). + * + * The empty schema (`""`) — used by EFS's DATA schema — encodes to empty bytes + * (`0x`) and decodes back to an empty value list. + */ + +import { type AbiParameter, type Hex, decodeAbiParameters, encodeAbiParameters } from 'viem' + +/** A single parsed schema field. `name` may be empty (EAS allows unnamed fields). */ +export interface SchemaField { + /** The Solidity type, e.g. `string`, `bytes32`, `uint256`, `bool`, `address`, `uint256[]`. */ + readonly type: string + /** The field name as declared in the schema string (may be `''`). */ + readonly name: string +} + +/** + * Parse an EAS schema field string into viem `AbiParameter`s. + * + * Accepts the EAS comma-separated `" "` form, with arbitrary inner + * whitespace. Unnamed fields (`"uint256"`) are allowed. The empty/whitespace + * schema parses to `[]`. + * + * @throws if a field has no type token. + */ +export function parseSchema(schema: string): readonly SchemaField[] { + const trimmed = schema.trim() + if (trimmed === '') return [] + return trimmed.split(',').map((raw) => { + const parts = raw.trim().split(/\s+/) + const type = parts[0] + if (type === undefined || type === '') { + throw new Error(`Invalid EAS schema field: ${JSON.stringify(raw)}`) + } + // Everything after the type token is the (optional) field name. + const name = parts.slice(1).join(' ') + return { type, name } + }) +} + +/** Convert parsed schema fields to the viem `AbiParameter[]` shape. */ +function toAbiParameters(fields: readonly SchemaField[]): AbiParameter[] { + return fields.map((f) => ({ type: f.type, name: f.name })) +} + +/** + * Encodes/decodes EAS attestation `data` against a fixed schema field string. + * + * Construct once per schema; the parsed params are cached. `encode`/`decode` + * are the inverse of each other for any well-typed value list. + */ +export class SchemaEncoder { + /** The original schema field string this encoder was built from. */ + readonly schema: string + /** The parsed schema fields, in declaration order. */ + readonly fields: readonly SchemaField[] + private readonly params: AbiParameter[] + + constructor(schema: string) { + this.schema = schema + this.fields = parseSchema(schema) + this.params = toAbiParameters(this.fields) + } + + /** Number of fields in the schema (`0` for the empty schema). */ + get length(): number { + return this.fields.length + } + + /** + * ABI-encode `values` (positional, in schema order) into attestation `data`. + * + * The empty schema encodes to `0x` regardless of input. For non-empty + * schemas, `values.length` must equal the field count. + * + * @throws if the arity is wrong, or if a value doesn't match its declared type. + */ + encodeData(values: readonly unknown[]): Hex { + if (this.params.length === 0) { + if (values.length !== 0) { + throw new Error(`Empty schema takes no values, received ${values.length}`) + } + return '0x' + } + if (values.length !== this.params.length) { + throw new Error( + `Schema arity mismatch: expected ${this.params.length} value(s), received ${values.length}`, + ) + } + return encodeAbiParameters(this.params, values as never) + } + + /** + * Decode attestation `data` back into a positional value list. + * + * The empty schema returns `[]` for empty data (`0x` or `''`). + * + * @throws if `data` is non-empty for the empty schema, or fails to decode. + */ + decodeData(data: Hex): readonly unknown[] { + if (this.params.length === 0) { + if (data !== '0x' && data !== ('' as Hex)) { + throw new Error(`Empty schema expects empty data, received ${data}`) + } + return [] + } + return decodeAbiParameters(this.params, data) as readonly unknown[] + } +} diff --git a/packages/sdk/src/eas/uid.ts b/packages/sdk/src/eas/uid.ts new file mode 100644 index 0000000..336adb7 --- /dev/null +++ b/packages/sdk/src/eas/uid.ts @@ -0,0 +1,123 @@ +/** + * Attestation UID derivation, matching EAS's on-chain `_getUID`. + * + * EAS computes a UID as (EAS.sol `_getUID`, lines 697-712): + * + * keccak256(abi.encodePacked( + * attestation.schema, // bytes32 + * attestation.recipient, // address + * attestation.attester, // address + * attestation.time, // uint64 + * attestation.expirationTime, // uint64 + * attestation.revocable, // bool + * attestation.refUID, // bytes32 + * attestation.data, // bytes + * bump // uint32 + * )) + * + * IMPORTANT — this is a VERIFICATION helper, not a predictor. `time` is the + * block timestamp the EAS contract stamps at mine-time, and `bump` is incremented + * by the contract on UID collisions; the SDK cannot know either before the tx is + * mined. So you cannot compute a UID up front and watch for it. Use this to + * RE-DERIVE a UID from a mined `Attestation` (e.g. one returned by + * `getAttestation`) and assert it equals the on-chain `uid` — a cheap integrity + * check that the indexer/RPC returned a self-consistent attestation. + */ + +import { type Address, type Hex, encodePacked, keccak256 } from 'viem' + +/** Inputs to `computeAttestationUID`, named to match the `Attestation` struct. */ +export interface AttestationUIDInput { + /** The schema UID (bytes32). */ + schema: Hex + /** The attestation recipient. */ + recipient: Address + /** The attester (sender). */ + attester: Address + /** Creation time the contract stamped (uint64, Unix seconds) — known only post-mine. */ + time: bigint + /** Expiration time (uint64); `0n` for non-expiring. */ + expirationTime: bigint + /** Whether the attestation is revocable. */ + revocable: boolean + /** Related attestation UID, or the zero bytes32. */ + refUID: Hex + /** The ABI-encoded attestation data. */ + data: Hex + /** Collision bump the contract used (uint32); `0` for the first/only attestation in a tx. */ + bump: number +} + +/** + * Re-derive an attestation UID from its (mined) field values, matching the + * on-chain `_getUID` packed-encoding byte-for-byte. + * + * @see EAS.sol `_getUID` (lines 697-712). + */ +export function computeAttestationUID(input: AttestationUIDInput): Hex { + return keccak256( + encodePacked( + [ + 'bytes32', // schema + 'address', // recipient + 'address', // attester + 'uint64', // time + 'uint64', // expirationTime + 'bool', // revocable + 'bytes32', // refUID + 'bytes', // data + 'uint32', // bump + ], + [ + input.schema, + input.recipient, + input.attester, + input.time, + input.expirationTime, + input.revocable, + input.refUID, + input.data, + input.bump, + ], + ), + ) +} + +/** A mined `Attestation` shaped like the `getAttestation` return (Common.sol:26-37). */ +export interface MinedAttestation { + uid: Hex + schema: Hex + time: bigint + expirationTime: bigint + revocationTime: bigint + refUID: Hex + recipient: Address + attester: Address + revocable: boolean + data: Hex +} + +/** + * Verify that a mined attestation's `uid` is self-consistent with its fields. + * + * Tries `bump = 0` first (the overwhelmingly common case — one attestation per + * schema per tx), then scans up to `maxBump` to tolerate UID-collision bumps. + * Returns `true` iff some `bump` in `[0, maxBump]` reproduces `attestation.uid`. + */ +export function verifyAttestationUID(attestation: MinedAttestation, maxBump = 0): boolean { + for (let bump = 0; bump <= maxBump; bump++) { + const derived = computeAttestationUID({ + schema: attestation.schema, + recipient: attestation.recipient, + attester: attestation.attester, + time: attestation.time, + expirationTime: attestation.expirationTime, + revocable: attestation.revocable, + refUID: attestation.refUID, + data: attestation.data, + bump, + }) + if (derived.toLowerCase() === attestation.uid.toLowerCase()) return true + } + return false +} diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts new file mode 100644 index 0000000..a2ed6f5 --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -0,0 +1,52 @@ +/** + * Typed error tree. External callers catch discriminated `EfsError` subclasses, + * never raw RPC strings (mirrors viem's `BaseError` ergonomics). The error-model + * ADR is pending (docs/adr "Recommended next"). + */ + +export class EfsError extends Error { + override name = 'EfsError' +} + +/** A surface that is shaped but not yet implemented (scaffold/seam). */ +export class NotImplemented extends EfsError { + override name = 'NotImplemented' + constructor(what: string) { + super(`${what} is not implemented yet.`) + } +} + +/** A read/write needs a lens but none was given and no wallet is connected. */ +export class LensRequired extends EfsError { + override name = 'LensRequired' + constructor() { + super('No lens given and no wallet connected — a read needs an attester to resolve against.') + } +} + +/** A lens stack exceeds MAX_LENSES. We throw rather than truncate: silent truncation + * is a trust downgrade (it can push the trusted tail off the end). */ +export class MaxLensesExceeded extends EfsError { + override name = 'MaxLensesExceeded' + constructor(count: number, max: number) { + super( + `Lens stack has ${count} entries; the maximum is ${max}. Reduce it explicitly — the SDK will not truncate (that would silently change which attester wins).`, + ) + } +} + +/** The SDK's compiled schema UIDs don't match what's deployed on the target chain. + * Construct with a message describing the diff (`new SchemaMismatchError(...)`). */ +export class SchemaMismatchError extends EfsError { + override name = 'SchemaMismatchError' +} + +/** No EFS deployment is known for the connected chain (and none was supplied). */ +export class DeploymentNotFound extends EfsError { + override name = 'DeploymentNotFound' + constructor(chainId: number) { + super( + `No EFS deployment registered for chainId ${chainId}. Pass \`deployments\` to point at a custom/local deployment.`, + ) + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 970e588..a38849a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,81 +1,152 @@ /** * @efs/sdk — TypeScript SDK for the Ethereum File System (EFS). * - * Status: scaffold. Public surface is shaped per planning/Designs/sdk-architecture.md; - * method bodies are stubs (`NotImplemented`) until the build lands. The *shapes* below - * are the load-bearing part — they encode decisions we don't want to break later - * (the identity seam, the static-vs-dynamic reference split). + * Resource-namespaced client (ADR / Decision F): `efs.fs.*` (files), + * `efs.lenses.*` (resolution), `efs.eas.*` (viem-native EAS), `efs.raw.*` + * (deployment escape hatch). Shapes follow planning/Designs/sdk-architecture.md; + * unbuilt methods throw `NotImplemented` with their final signatures so the + * public surface is stable before publish. */ import type { Address, PublicClient, WalletClient } from 'viem' - -// ── Errors ─────────────────────────────────────────────────────────────────── -// Discriminated error base so external callers catch typed errors, never raw RPC -// strings (error-model ADR pending — see docs/adr "Recommended next"). Mirrors -// viem's BaseError ergonomics. - -export class EfsError extends Error { - override name = 'EfsError' -} - -export class NotImplemented extends EfsError { - override name = 'NotImplemented' - constructor(what: string) { - super(`${what} is not implemented yet (SDK scaffold).`) - } -} - -// ── Identity / lens seam (sdk-architecture §1–§3) ────────────────────────────── -// A lens is a *resolved set of attester addresses*, built from a configurable -// hierarchy (EFS contracts ADR-0039), not a bare address. v1 ships the trivial resolver -// (addr -> [addr]); ENS/key-set expansion drops in later, additively. The type -// stays opaque so N-vs-1 never leaks into a signature. - -export type Lens = { - readonly __brand: 'Lens' - /** Resolve to the ordered attester set at read time. */ - resolve(): Promise -} - -/** An explicit, literal lens — exactly these addresses, never expanded. */ -export function lens(_addresses: Address | readonly Address[]): Lens { - throw new NotImplemented('lens()') -} - -/** An identity that may expand (ENS -> key-set -> ordered lens). Resolves at read time. */ -export function identity(_ensOrAddress: string): Lens { - throw new NotImplemented('identity()') -} - -// ── Static vs dynamic references (sdk-architecture §5) ───────────────────────── -// Distinct types that never silently interconvert. A DataRef is "these exact -// bytes / this version" (UID). A PathRef is "whatever is active here now". - -export type DataRef = { readonly __brand: 'DataRef'; readonly uid: `0x${string}` } -export type PathRef = { readonly __brand: 'PathRef'; readonly path: string } - -// ── Client ───────────────────────────────────────────────────────────────────── +import { + type DeploymentsMap, + type EfsDeployment, + assertDeploymentIntegrity, + resolveDeployment, +} from './chain/deployments.js' +import { + SchemaEncoder, + computeAttestationUID, + easAbi, + schemaRegistryAbi, + verifyAttestationUID, +} from './eas/index.js' +import { EfsError, NotImplemented } from './errors.js' +import { type Lens, identity, lens, resolveLens } from './lenses/resolve.js' +import type { + DataRef, + EfsFile, + ReadResult, + Stat, + WriteEstimate, + WriteOptions, + WriteReceipt, +} from './types.js' export type EfsClientConfig = { publicClient: PublicClient /** Required for writes; reads work without it. */ walletClient?: WalletClient - /** Default lens when none is passed to a read. Defaults to the connected wallet. */ + /** Override the built-in registry to point at a custom/local deployment. */ + deployments?: DeploymentsMap + /** Default lens when a read passes none (resolves to the connected wallet). */ defaultLens?: Lens } export type EfsClient = { - /** Read the file at a path, resolved through a lens. */ - read(path: string, opts?: { as?: Lens }): Promise - /** Pin (write) a file. Batches the underlying attestations (sdk-architecture §6). */ - pinFile(path: string, content: Uint8Array): Promise - /** Clean viem-native access to the underlying EAS layer (ADR-0002). */ - readonly eas: unknown + /** Files. */ + fs: { + write(path: string, content: Uint8Array, opts?: WriteOptions): Promise + read(path: string, opts?: { as?: Lens | Address }): Promise + fetch(ref: DataRef, opts?: { verify?: boolean }): Promise + stat(path: string, opts?: { as?: Lens | Address }): Promise + list(path: string, opts?: { as?: Lens | Address }): AsyncIterable + preview(path: string, content: Uint8Array): Promise + } + /** Lens resolution. */ + lenses: { + resolve(input: Lens | Address): Promise + lens: typeof lens + identity: typeof identity + } + /** viem-native EAS access (ADR-0002). */ + eas: { + encoder(schema: string): SchemaEncoder + computeUID: typeof computeAttestationUID + verifyUID: typeof verifyAttestationUID + abi: { eas: typeof easAbi; schemaRegistry: typeof schemaRegistryAbi } + } + /** The resolved deployment for the connected chain (escape hatch). */ + raw: { + deployment(): EfsDeployment + verifyDeployment(): Promise + } +} + +function chainIdOf(publicClient: PublicClient): number { + const id = publicClient.chain?.id + if (id === undefined) { + throw new EfsError('publicClient has no `chain` set — cannot resolve the EFS deployment.') + } + return id } -export function createEfsClient(_config: EfsClientConfig): EfsClient { - throw new NotImplemented('createEfsClient()') +export function createEfsClient(config: EfsClientConfig): EfsClient { + const { publicClient, deployments: override } = config + const getDeployment = () => resolveDeployment(chainIdOf(publicClient), override) + + return { + fs: { + write: (_path, _content, _opts) => { + throw new NotImplemented('efs.fs.write()') + }, + read: (_path, _opts) => { + throw new NotImplemented('efs.fs.read()') + }, + fetch: (_ref, _opts) => { + throw new NotImplemented('efs.fs.fetch()') + }, + stat: (_path, _opts) => { + throw new NotImplemented('efs.fs.stat()') + }, + list: (_path, _opts) => { + throw new NotImplemented('efs.fs.list()') + }, + preview: (_path, _content) => { + throw new NotImplemented('efs.fs.preview()') + }, + }, + lenses: { + resolve: (input) => resolveLens(input, { publicClient }), + lens, + identity, + }, + eas: { + encoder: (schema) => new SchemaEncoder(schema), + computeUID: computeAttestationUID, + verifyUID: verifyAttestationUID, + abi: { eas: easAbi, schemaRegistry: schemaRegistryAbi }, + }, + raw: { + deployment: getDeployment, + verifyDeployment: () => assertDeploymentIntegrity(publicClient, getDeployment()), + }, + } } -// Content hashing (ADR-0006: bare SHA-256). Real, chain-independent — usable now. +// ── Standalone exports (chain-independent; usable now) ───────────────────────── +export * from './eas/index.js' export { hashContent, verifyContent, type VerificationStatus } from './content/hash.js' +export { lens, identity, resolveLens, MAX_LENSES, type Lens } from './lenses/resolve.js' +export { + deployments, + resolveDeployment, + assertDeploymentIntegrity, + type DeploymentsMap, + type EfsDeployment, + type EfsContracts, + type EfsSchemaUIDs, +} from './chain/deployments.js' +export * from './errors.js' +export type { + DataRef, + DataUID, + PathRef, + ReadResult, + EfsFile, + WriteReceipt, + WriteEstimate, + WriteOptions, + Stat, +} from './types.js' diff --git a/packages/sdk/src/lenses/resolve.ts b/packages/sdk/src/lenses/resolve.ts new file mode 100644 index 0000000..825a929 --- /dev/null +++ b/packages/sdk/src/lenses/resolve.ts @@ -0,0 +1,71 @@ +/** + * Lenses (ADR-0031/0039). A lens is a *resolved, ordered set of attester addresses* + * (first-wins), not a bare address. The type is opaque so richer resolution + * (ENS → a person's device key-set) can drop in later without breaking callers. + * Resolution is always async and happens at read time. + */ + +import type { Address, PublicClient } from 'viem' +import { isAddress } from 'viem' +import { normalize } from 'viem/ens' +import { EfsError, MaxLensesExceeded } from '../errors.js' + +/** Cap on lens-stack size (contracts ADR-0026, renamed MAX_EDITIONS). */ +export const MAX_LENSES = 20 + +export type LensContext = { publicClient?: PublicClient } + +export type Lens = { + readonly __brand: 'Lens' + /** Resolve to the ordered attester set. Order is load-bearing (first-wins). */ + resolve(ctx: LensContext): Promise +} + +/** Dedupe (case-insensitive, order-preserving) and enforce the cap by THROWING — + * never truncating, which would silently change which attester wins (review S2). */ +function finalize(addresses: readonly Address[]): readonly Address[] { + const seen = new Set() + const out: Address[] = [] + for (const a of addresses) { + const key = a.toLowerCase() + if (!seen.has(key)) { + seen.add(key) + out.push(a) + } + } + if (out.length > MAX_LENSES) throw new MaxLensesExceeded(out.length, MAX_LENSES) + return out +} + +/** A literal lens — exactly these addresses, in this order, never expanded. + * The caller orders them (their own wallet typically first). */ +export function lens(addresses: Address | readonly Address[]): Lens { + const arr: readonly Address[] = Array.isArray(addresses) ? addresses : [addresses] + const resolved = finalize(arr) // validate the cap eagerly (fail fast) + return { __brand: 'Lens', resolve: async () => resolved } +} + +/** An identity that may expand. v1: an address resolves to itself; an ENS name + * resolves to its address. Multi-device key-set expansion (the webOfTrust tier) + * drops in here later WITHOUT changing this signature. */ +export function identity(ensOrAddress: string): Lens { + return { + __brand: 'Lens', + resolve: async ({ publicClient }) => { + if (isAddress(ensOrAddress)) return finalize([ensOrAddress]) + if (!publicClient) { + throw new EfsError(`Resolving the ENS name "${ensOrAddress}" needs a publicClient.`) + } + const addr = await publicClient.getEnsAddress({ name: normalize(ensOrAddress) }) + if (!addr) throw new EfsError(`ENS name "${ensOrAddress}" did not resolve to an address.`) + return finalize([addr]) + }, + } +} + +/** Coerce a Lens or a raw address into resolved attesters. A raw address is + * treated as a literal single-address lens. */ +export function resolveLens(input: Lens | Address, ctx: LensContext): Promise { + const l = typeof input === 'string' ? lens(input) : input + return l.resolve(ctx) +} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 0000000..89bea02 --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,53 @@ +/** Public value shapes. Branded UID kinds because wrong-UID-kind is the dominant + * integration bug (review DX-13). Static `DataRef` vs dynamic `PathRef` never + * silently interconvert (sdk-architecture §5). */ + +import type { Address, Hex } from 'viem' +import type { VerificationStatus } from './content/hash.js' + +export type DataUID = Hex & { readonly __kind: 'DataUID' } + +/** Static reference — these exact bytes / this version. */ +export type DataRef = { readonly __brand: 'DataRef'; readonly uid: DataUID } +/** Dynamic reference — whatever is active at this path now. */ +export type PathRef = { readonly __brand: 'PathRef'; readonly path: string } + +/** A durable, serializable write session. `steps` are idempotent per (path-qualified) + * id so a resume skips only mined work and never double-mints (review B3). */ +export type WriteReceipt = { + contentHash: string + data?: DataRef + steps: Array<{ id: string; uid?: Hex; done: boolean }> + signatureCount: number + mechanism: 'multiAttest-sequential' | 'eip5792' | 'erc4337' +} + +/** A resolved read: the data ref plus which attester/lens won (review UX-4). */ +export type ReadResult = { data: DataRef; resolvedBy: Address } + +/** Fetched bytes + trust-relative verification (never a bare "verified"). */ +export type EfsFile = { + bytes: Uint8Array + contentType?: string + verification: VerificationStatus + hashAuthor?: Address +} + +export type WriteEstimate = { + attestations: number + transactions: number + signatureCount: number + chunkDeploys: number + gas: bigint + estimatedUSD?: number + warnings: string[] +} + +export type Stat = { exists: boolean; data?: DataRef; resolvedBy?: Address } + +export type WriteOptions = { + contentType?: string + onProgress?: (p: { step: number; total: number; phase: string }) => void + resume?: WriteReceipt + signal?: AbortSignal +} diff --git a/packages/sdk/test/eas.test.ts b/packages/sdk/test/eas.test.ts new file mode 100644 index 0000000..4621a62 --- /dev/null +++ b/packages/sdk/test/eas.test.ts @@ -0,0 +1,174 @@ +import { type Hex, decodeAbiParameters, encodeAbiParameters, encodePacked, keccak256 } from 'viem' +import { describe, expect, it } from 'vitest' +import { + SchemaEncoder, + computeAttestationUID, + parseSchema, + verifyAttestationUID, +} from '../src/eas/index.js' + +const ZERO_ADDR = '0x0000000000000000000000000000000000000000' as const +const ZERO_UID = '0x0000000000000000000000000000000000000000000000000000000000000000' as const + +describe('parseSchema', () => { + it('parses a real EFS schema into typed fields', () => { + expect(parseSchema('string name, bytes32 schemaUID')).toEqual([ + { type: 'string', name: 'name' }, + { type: 'bytes32', name: 'schemaUID' }, + ]) + }) + + it('tolerates ragged whitespace and unnamed fields', () => { + expect(parseSchema(' uint256 amount ,bool')).toEqual([ + { type: 'uint256', name: 'amount' }, + { type: 'bool', name: '' }, + ]) + }) + + it('treats the empty schema as zero fields', () => { + expect(parseSchema('')).toEqual([]) + expect(parseSchema(' ')).toEqual([]) + }) +}) + +describe('SchemaEncoder round-trip', () => { + it('round-trips a real EFS schema ("string name, bytes32 schemaUID")', () => { + const enc = new SchemaEncoder('string name, bytes32 schemaUID') + const uid = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex + const values = ['efs.root', uid] as const + + const data = enc.encodeData(values) + // Matches a raw viem encode of the same ABI tuple. + expect(data).toBe( + encodeAbiParameters( + [ + { type: 'string', name: 'name' }, + { type: 'bytes32', name: 'schemaUID' }, + ], + ['efs.root', uid], + ), + ) + + const decoded = enc.decodeData(data) + expect(decoded).toEqual(['efs.root', uid]) + }) + + it('handles the empty schema ("" -> 0x, decodes back to [])', () => { + const enc = new SchemaEncoder('') + expect(enc.length).toBe(0) + expect(enc.encodeData([])).toBe('0x') + expect(enc.decodeData('0x')).toEqual([]) + }) + + it('rejects arity mismatches', () => { + const enc = new SchemaEncoder('string name, bytes32 schemaUID') + expect(() => enc.encodeData(['only-one'])).toThrow(/arity mismatch/) + expect(() => new SchemaEncoder('').encodeData(['x'])).toThrow(/no values/) + }) + + it('rejects non-empty data for the empty schema', () => { + expect(() => new SchemaEncoder('').decodeData('0x1234')).toThrow(/empty data/) + }) + + it('round-trips a multi-type schema (uint256, bool, address)', () => { + const enc = new SchemaEncoder('uint256 score, bool active, address who') + const values = [42n, true, ZERO_ADDR] as const + const decoded = enc.decodeData(enc.encodeData(values)) + expect(decoded).toEqual([42n, true, ZERO_ADDR]) + }) +}) + +describe('computeAttestationUID (matches EAS _getUID, EAS.sol:697-712)', () => { + // Fixed inputs => deterministic UID. We assert against an independent, + // inline re-implementation of the on-chain packed encoding so the test pins + // the exact byte layout (schema, recipient, attester, time, expirationTime, + // revocable, refUID, data, bump). + const input = { + schema: '0x1111111111111111111111111111111111111111111111111111111111111111' as Hex, + recipient: '0x000000000000000000000000000000000000dEaD' as const, + attester: '0x00000000000000000000000000000000000Ff1CE' as const, + time: 1_700_000_000n, + expirationTime: 0n, + revocable: true, + refUID: ZERO_UID, + data: '0xc0ffee' as Hex, + bump: 0, + } + + it('is deterministic and matches an inline abi.encodePacked vector', () => { + const expected = keccak256( + encodePacked( + ['bytes32', 'address', 'address', 'uint64', 'uint64', 'bool', 'bytes32', 'bytes', 'uint32'], + [ + input.schema, + input.recipient, + input.attester, + input.time, + input.expirationTime, + input.revocable, + input.refUID, + input.data, + input.bump, + ], + ), + ) + expect(computeAttestationUID(input)).toBe(expected) + }) + + it('is sensitive to the bump (collision counter)', () => { + const a = computeAttestationUID({ ...input, bump: 0 }) + const b = computeAttestationUID({ ...input, bump: 1 }) + expect(a).not.toBe(b) + }) + + it('produces a 32-byte (66-char) hex UID', () => { + expect(computeAttestationUID(input)).toMatch(/^0x[0-9a-f]{64}$/) + }) +}) + +describe('verifyAttestationUID', () => { + const base = { + schema: '0x2222222222222222222222222222222222222222222222222222222222222222' as Hex, + recipient: ZERO_ADDR, + attester: '0x00000000000000000000000000000000000Ff1CE' as const, + time: 1_700_000_123n, + expirationTime: 0n, + revocationTime: 0n, + revocable: false, + refUID: ZERO_UID, + data: '0x' as Hex, + } + + it('accepts a self-consistent mined attestation (bump 0)', () => { + const uid = computeAttestationUID({ ...base, bump: 0 }) + expect(verifyAttestationUID({ ...base, uid })).toBe(true) + }) + + it('rejects a tampered uid', () => { + expect( + verifyAttestationUID({ + ...base, + uid: '0xbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadba', + }), + ).toBe(false) + }) + + it('finds a bumped uid within maxBump', () => { + const uid = computeAttestationUID({ ...base, bump: 2 }) + expect(verifyAttestationUID({ ...base, uid }, 0)).toBe(false) + expect(verifyAttestationUID({ ...base, uid }, 3)).toBe(true) + }) +}) + +describe('SchemaEncoder.decodeData parity with viem', () => { + it('decodes identically to decodeAbiParameters', () => { + const params = [ + { type: 'string', name: 'name' }, + { type: 'bytes32', name: 'schemaUID' }, + ] as const + const uid = '0xabababababababababababababababababababababababababababababababcd' as Hex + const data = encodeAbiParameters(params, ['x', uid]) + const enc = new SchemaEncoder('string name, bytes32 schemaUID') + expect(enc.decodeData(data)).toEqual([...decodeAbiParameters(params, data)]) + }) +}) diff --git a/packages/sdk/test/index.test.ts b/packages/sdk/test/index.test.ts index fe0e38e..7bd03cd 100644 --- a/packages/sdk/test/index.test.ts +++ b/packages/sdk/test/index.test.ts @@ -1,11 +1,38 @@ +import { http, type Address, createPublicClient } from 'viem' +import { sepolia } from 'viem/chains' import { describe, expect, it } from 'vitest' -import { NotImplemented, createEfsClient, identity, lens } from '../src/index.js' - -describe('@efs/sdk scaffold', () => { - it('stubs throw NotImplemented until the build lands', () => { - expect(() => lens('0x0000000000000000000000000000000000000001')).toThrow(NotImplemented) - expect(() => identity('jamescarnley.eth')).toThrow(NotImplemented) - // @ts-expect-error scaffold: config shape not exercised yet - expect(() => createEfsClient({})).toThrow(NotImplemented) +import { MaxLensesExceeded, NotImplemented, createEfsClient, identity, lens } from '../src/index.js' + +const publicClient = createPublicClient({ chain: sepolia, transport: http() }) +const addr = (n: number) => `0x${n.toString(16).padStart(40, '0')}` as Address + +describe('namespaced client (Decision F)', () => { + it('builds a client; unbuilt fs verbs throw NotImplemented', () => { + const efs = createEfsClient({ publicClient }) + expect(() => efs.fs.write('/x', new Uint8Array())).toThrow(NotImplemented) + expect(() => efs.fs.read('/x')).toThrow(NotImplemented) + expect(() => efs.fs.list('/x')).toThrow(NotImplemented) + }) + + it('exposes lens helpers under efs.lenses', () => { + const efs = createEfsClient({ publicClient }) + expect(typeof efs.lenses.lens).toBe('function') + expect(typeof efs.lenses.identity).toBe('function') + }) +}) + +describe('lenses', () => { + it('a literal lens resolves to its ordered addresses', async () => { + expect(await lens(addr(1)).resolve({})).toEqual([addr(1)]) + expect(await lens([addr(1), addr(2)]).resolve({})).toEqual([addr(1), addr(2)]) + }) + + it('identity resolves a bare address to itself (no chain needed)', async () => { + expect(await identity(addr(7)).resolve({})).toEqual([addr(7)]) + }) + + it('throws (never truncates) above MAX_LENSES', () => { + const many = Array.from({ length: 21 }, (_, i) => addr(i + 1)) + expect(() => lens(many)).toThrow(MaxLensesExceeded) }) }) From c70a66f12be04c9ffa2d992eef3e055d1ae181a1 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 23:43:31 -0500 Subject: [PATCH 18/47] fix(sdk): async fs stubs + honest integrity-check docs (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration review (GO) flagged two: - B1/B2: fs.* stubs were async-typed but threw synchronously — `.catch()`/ `.rejects`/`for await` would misbehave. Make them reject / throw-on-iterate so the async contract is locked before fs.* is implemented; tests use `.rejects`. - S1: assertDeploymentIntegrity only checks bytecode presence — softened the doc to say so (sanity gate, not "is the right EFS contract"); the real trust gate (schema-UID match) is noted as landing with the read layer. EAS layer, lenses, deployments, errors all verified by the review. tsc/test(24)/ build/lint green. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/chain/deployments.ts | 18 ++++++++++++------ packages/sdk/src/index.ts | 20 ++++++++++++-------- packages/sdk/test/index.test.ts | 12 ++++++++---- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/packages/sdk/src/chain/deployments.ts b/packages/sdk/src/chain/deployments.ts index 75069c3..4b0f972 100644 --- a/packages/sdk/src/chain/deployments.ts +++ b/packages/sdk/src/chain/deployments.ts @@ -60,13 +60,19 @@ export function resolveDeployment(chainId: number, override?: DeploymentsMap): E } /** - * Construct-time integrity gate (ADR-0005 / review S1). The view/router addresses - * resolve every read, so a wrong, typo'd, or non-contract address must be rejected - * loudly — not silently resolve to nothing. Verifies each EFS contract has deployed - * bytecode on the target chain. + * Construct-time sanity gate (ADR-0005 / review S1). Verifies each EFS contract + * address has *some* bytecode on the target chain — this catches a wrong/typo'd + * or non-contract address, nothing more. It does NOT authenticate that the code + * is the *right* EFS contract. * - * TODO(build): also assert `deployment.schemas` match the indexer's on-chain UID - * getters once the eas read layer lands (catches a UID/registry mismatch). + * The real trust gate is the **schema-UID match**: assert `deployment.schemas` + * equal the indexer's on-chain UID getters (a malicious/wrong indexer can't fake + * the frozen UIDs). That lands with the read layer (TODO below) and is what makes + * an overridden `deployments` map safe to trust. Until then, treat a passing + * bytecode check as "addresses are contracts," not "addresses are EFS." + * + * TODO(build): add the schema-UID assertion once the eas read layer can call the + * indexer's UID getters. */ export async function assertDeploymentIntegrity( publicClient: PublicClient, diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index a38849a..871d926 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -88,22 +88,26 @@ export function createEfsClient(config: EfsClientConfig): EfsClient { return { fs: { - write: (_path, _content, _opts) => { + // Stubs reject/throw-on-iterate (not sync-throw) so the async contract is + // locked now — callers' `.catch()` / `for await` behave as they will post-build. + write: async (_path, _content, _opts) => { throw new NotImplemented('efs.fs.write()') }, - read: (_path, _opts) => { + read: async (_path, _opts) => { throw new NotImplemented('efs.fs.read()') }, - fetch: (_ref, _opts) => { + fetch: async (_ref, _opts) => { throw new NotImplemented('efs.fs.fetch()') }, - stat: (_path, _opts) => { + stat: async (_path, _opts) => { throw new NotImplemented('efs.fs.stat()') }, - list: (_path, _opts) => { - throw new NotImplemented('efs.fs.list()') - }, - preview: (_path, _content) => { + list: (_path, _opts) => ({ + [Symbol.asyncIterator]() { + throw new NotImplemented('efs.fs.list()') + }, + }), + preview: async (_path, _content) => { throw new NotImplemented('efs.fs.preview()') }, }, diff --git a/packages/sdk/test/index.test.ts b/packages/sdk/test/index.test.ts index 7bd03cd..6450b64 100644 --- a/packages/sdk/test/index.test.ts +++ b/packages/sdk/test/index.test.ts @@ -7,11 +7,15 @@ const publicClient = createPublicClient({ chain: sepolia, transport: http() }) const addr = (n: number) => `0x${n.toString(16).padStart(40, '0')}` as Address describe('namespaced client (Decision F)', () => { - it('builds a client; unbuilt fs verbs throw NotImplemented', () => { + it('builds a client; unbuilt fs verbs reject with NotImplemented (async contract)', async () => { const efs = createEfsClient({ publicClient }) - expect(() => efs.fs.write('/x', new Uint8Array())).toThrow(NotImplemented) - expect(() => efs.fs.read('/x')).toThrow(NotImplemented) - expect(() => efs.fs.list('/x')).toThrow(NotImplemented) + await expect(efs.fs.write('/x', new Uint8Array())).rejects.toThrow(NotImplemented) + await expect(efs.fs.read('/x')).rejects.toThrow(NotImplemented) + await expect( + (async () => { + for await (const _ of efs.fs.list('/x')) break + })(), + ).rejects.toThrow(NotImplemented) }) it('exposes lens helpers under efs.lenses', () => { From 145e590697fc13fb15969afb37f0392fbd21d7ac Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 23:46:02 -0500 Subject: [PATCH 19/47] docs: fix package README quickstart to namespaced API (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quickstart still used the pre-F flat API (efs.read/efs.pinFile); the client is namespaced (efs.fs.read/write/fetch). Updated to the accurate shape — read returns a ref + resolvedBy, fetch gets verified bytes — and added a status note that fs.* verbs are the target shape (currently NotImplemented). Co-authored-by: Claude Opus 4.8 --- packages/sdk/README.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index d92ac22..5a23abd 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -14,22 +14,32 @@ npm i @efs/sdk viem ## Quickstart (target API) +The client is resource-namespaced (`efs.fs.*` for files, `efs.lenses.*`, `efs.eas.*`, `efs.raw.*`): + ```ts import { createEfsClient, identity } from '@efs/sdk' -import { createPublicClient, createWalletClient, http } from 'viem' +import { sepolia } from 'viem/chains' +import { createPublicClient, http } from 'viem' const efs = createEfsClient({ - publicClient: createPublicClient({ transport: http() }), + publicClient: createPublicClient({ chain: sepolia, transport: http() }), walletClient, // required for writes }) -// Read "the file at /logo", resolved through an identity (ENS → key-set → lens). -const file = await efs.read('/logo', { as: identity('jamescarnley.eth') }) +// Resolve "the file at /logo" through an identity (ENS → key-set → lens). +// `read` returns a reference + who resolved it; `fetch` gets the bytes (verified). +const result = await efs.fs.read('/logo', { as: identity('jamescarnley.eth') }) +if (result) { + const file = await efs.fs.fetch(result.data) + console.log(file.bytes, file.verification) // 'matches-author' | 'mismatch' | 'no-claim' +} // Write a file (batched multi-attestation under the hood). -await efs.pinFile('/notes/hello.txt', new TextEncoder().encode('gm')) +await efs.fs.write('/notes/hello.txt', new TextEncoder().encode('gm')) ``` +> **Status:** `efs.lenses`, `efs.eas`, content hashing, and the deployments registry are implemented; the `efs.fs.*` verbs above are the target shape and currently throw `NotImplemented`. + ## Design notes that shape this API - **Lenses are a resolved set, not an address.** `identity()` may expand (ENS → device key-set); `lens()` is a literal escape hatch. The type is opaque so adding expansion later doesn't break callers. From 9e5467f036817a44287287b738c8532ed74ea0c7 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Wed, 10 Jun 2026 23:53:56 -0500 Subject: [PATCH 20/47] fix(eas): forward resolver value as msg.value in attest builders (Codex P2) buildAttest/buildMultiAttest returned no tx-level `value`, so a non-zero per-attestation resolver value would revert (no ETH sent). Compute and include `value` on the ContractCall: buildAttest forwards data.value; buildMultiAttest sums every entry across all requests. Added tests (26 total). Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/eas/attest.ts | 23 ++++++++++++++++++----- packages/sdk/test/eas.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/eas/attest.ts b/packages/sdk/src/eas/attest.ts index 65cf9b4..f991a66 100644 --- a/packages/sdk/src/eas/attest.ts +++ b/packages/sdk/src/eas/attest.ts @@ -70,8 +70,10 @@ function normalizeData(d: AttestationRequestData) { /** * The shape consumed by viem's `writeContract` / `simulateContract`: an - * `address` plus the matched `abi`, `functionName`, and `args`. Callers add - * `account`, `chain`, and any `value`/gas overrides at the call site. + * `address` plus the matched `abi`, `functionName`, `args`, and the transaction + * `value`. `attest`/`multiAttest` are payable — `msg.value` must cover the sum of + * the per-attestation resolver `value`s — so the builder computes and forwards it + * (0n when no resolver value is set). Callers add `account`/`chain`/gas overrides. */ export interface ContractCall { /** The EAS contract address to call. */ @@ -82,28 +84,34 @@ export interface ContractCall { functionName: TFunctionName /** The positional argument tuple. */ args: TArgs + /** `msg.value` = sum of the per-attestation resolver `value`s (`0n` if none). */ + value: bigint } /** * Build the `writeContract` args for a single `attest` call. Pure: returns the - * request, executes nothing. + * request, executes nothing. `value` forwards the resolver value so a payable + * resolver is funded when spread into `writeContract`. */ export function buildAttest( easAddress: Address, request: AttestationRequest, ): ContractCall<'attest', readonly [{ schema: Hex; data: ReturnType }]> { + const data = normalizeData(request.data) return { address: easAddress, abi: easAbi, functionName: 'attest', - args: [{ schema: request.schema, data: normalizeData(request.data) }] as const, + args: [{ schema: request.schema, data }] as const, + value: data.value, } } /** * Build the `writeContract` args for a `multiAttest` call. Requests should be * grouped by distinct schema for EAS's batching optimization (IEAS.sol:162-163). - * Pure: returns the request, executes nothing. + * Pure: returns the request, executes nothing. `value` is the sum of every + * entry's resolver value across all requests (`msg.value` must cover the total). */ export function buildMultiAttest( easAddress: Address, @@ -116,10 +124,15 @@ export function buildMultiAttest( schema: r.schema, data: r.data.map(normalizeData), })) + const value = multiRequests.reduce( + (sum, r) => r.data.reduce((s, d) => s + d.value, sum), + ZERO_VALUE, + ) return { address: easAddress, abi: easAbi, functionName: 'multiAttest', args: [multiRequests] as const, + value, } } diff --git a/packages/sdk/test/eas.test.ts b/packages/sdk/test/eas.test.ts index 4621a62..ccf0734 100644 --- a/packages/sdk/test/eas.test.ts +++ b/packages/sdk/test/eas.test.ts @@ -2,6 +2,8 @@ import { type Hex, decodeAbiParameters, encodeAbiParameters, encodePacked, kecca import { describe, expect, it } from 'vitest' import { SchemaEncoder, + buildAttest, + buildMultiAttest, computeAttestationUID, parseSchema, verifyAttestationUID, @@ -172,3 +174,28 @@ describe('SchemaEncoder.decodeData parity with viem', () => { expect(enc.decodeData(data)).toEqual([...decodeAbiParameters(params, data)]) }) }) + +describe('attest builders forward resolver value (msg.value)', () => { + const EAS = '0x0000000000000000000000000000000000000eA5' as const + const entry = (value?: bigint) => ({ + recipient: ZERO_ADDR, + expirationTime: 0n, + revocable: true, + refUID: ZERO_UID, + data: '0x' as Hex, + ...(value === undefined ? {} : { value }), + }) + + it('buildAttest forwards data.value as the tx value (0n by default)', () => { + expect(buildAttest(EAS, { schema: ZERO_UID, data: entry() }).value).toBe(0n) + expect(buildAttest(EAS, { schema: ZERO_UID, data: entry(5n) }).value).toBe(5n) + }) + + it('buildMultiAttest sums every entry value across requests', () => { + const call = buildMultiAttest(EAS, [ + { schema: ZERO_UID, data: [entry(2n), entry(3n)] }, + { schema: ZERO_UID, data: [entry(), entry(4n)] }, + ]) + expect(call.value).toBe(9n) + }) +}) From 4017c175b9e3c79fd6e99e40f98232802ce37b3c Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 00:27:22 -0500 Subject: [PATCH 21/47] =?UTF-8?q?refactor(sdk):=20foundation=20hardening?= =?UTF-8?q?=20batch=201=20=E2=80=94=20types,=20write-gating,=20seams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the 3-agent foundation review. The additive-now/breaking-later items: - Type-level write gating: createEfsClient overloads return EfsReadClient (no write verbs) without a walletClient, EfsClient with one (viem's read/write split). Runtime WalletRequired backstop. - EfsError -> viem-BaseError-grade: shortMessage, open-union `code`, cause, walk(). Added WalletRequired/CursorInvalid/PartialBatchFailure. - Named, exported option/return types (ReadOptions/ListOptions/FetchOptions/ WriteOptions) — no inline literals; param renamed `as` -> `lens`. - Pagination seam: Page + EfsList (AsyncIterable + .page()). - Batch seam: efs.batch() + BatchReceipt/OperationResult/WriteMechanism; fs.write documented as sugar over it. - Wallet-agnostic seam (your point): EfsReader/EfsWriter aliases instead of raw viem types in the public config, so widening to an ethers adapter later is non-breaking. viem-core stays; any wallet already works via EIP-1193. - FileStat (was Stat), branded steps[].uid, dropped root `export *`. Deferred (additive, non-breaking later): full graph/props/lists/sorts namespace skeleton — designed in a dedicated pass. tsc/test(27)/build/lint green. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/errors.ts | 96 ++++++++++++++++-- packages/sdk/src/index.ts | 170 +++++++++++++++++++++++--------- packages/sdk/src/types.ts | 114 +++++++++++++++++---- packages/sdk/test/index.test.ts | 27 ++++- 4 files changed, 326 insertions(+), 81 deletions(-) diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index a2ed6f5..27df022 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -1,44 +1,103 @@ /** - * Typed error tree. External callers catch discriminated `EfsError` subclasses, - * never raw RPC strings (mirrors viem's `BaseError` ergonomics). The error-model - * ADR is pending (docs/adr "Recommended next"). + * Typed error tree (ADR-0007, pending). External callers catch discriminated + * `EfsError` subclasses or switch on `.code`, never raw RPC strings. Modeled on + * viem's `BaseError`: a `shortMessage`, an open-union `code`, a `cause` + * passthrough, and `walk()` for cause-chain traversal. */ +/** Open string-union of error codes — open (`string & {}`) so adding a code is + * never a breaking change for an exhaustive `switch`. */ +export type EfsErrorCode = + | 'EfsError' + | 'NotImplemented' + | 'WalletRequired' + | 'LensRequired' + | 'MaxLensesExceeded' + | 'SchemaMismatch' + | 'DeploymentNotFound' + | 'CursorInvalid' + | 'PartialBatchFailure' + | (string & Record) + +export type EfsErrorOptions = { code?: EfsErrorCode; cause?: unknown; details?: string } + +function walkChain(err: unknown, fn?: (e: unknown) => boolean): unknown { + if (fn?.(err)) return err + const cause = (err as { cause?: unknown } | null | undefined)?.cause + if (cause != null) return walkChain(cause, fn) + return fn ? null : err +} + export class EfsError extends Error { override name = 'EfsError' + /** A stable, switchable discriminant. */ + readonly code: EfsErrorCode + /** The headline message, without any appended details. */ + readonly shortMessage: string + + constructor(shortMessage: string, opts: EfsErrorOptions = {}) { + super(opts.details ? `${shortMessage}\n\n${opts.details}` : shortMessage, { + cause: opts.cause, + }) + this.shortMessage = shortMessage + this.code = opts.code ?? 'EfsError' + } + + /** Walk the `cause` chain. With `fn`, returns the first match (or `null`); + * without, returns the deepest error in the chain. */ + walk(fn?: (err: unknown) => boolean): unknown { + return walkChain(this, fn) + } } /** A surface that is shaped but not yet implemented (scaffold/seam). */ export class NotImplemented extends EfsError { override name = 'NotImplemented' constructor(what: string) { - super(`${what} is not implemented yet.`) + super(`${what} is not implemented yet.`, { code: 'NotImplemented' }) + } +} + +/** A write was attempted on a client created without a `walletClient`. + * (Prefer the type-level gate — this is the runtime backstop.) */ +export class WalletRequired extends EfsError { + override name = 'WalletRequired' + constructor() { + super('This operation writes and needs a wallet — create the client with a `walletClient`.', { + code: 'WalletRequired', + }) } } -/** A read/write needs a lens but none was given and no wallet is connected. */ +/** A read needs a lens but none was given and no wallet is connected. */ export class LensRequired extends EfsError { override name = 'LensRequired' constructor() { - super('No lens given and no wallet connected — a read needs an attester to resolve against.') + super('No lens given and no wallet connected — a read needs an attester to resolve against.', { + code: 'LensRequired', + }) } } -/** A lens stack exceeds MAX_LENSES. We throw rather than truncate: silent truncation - * is a trust downgrade (it can push the trusted tail off the end). */ +/** A lens stack exceeds MAX_LENSES. We throw rather than truncate: silent + * truncation is a trust downgrade (it can push the trusted tail off the end). */ export class MaxLensesExceeded extends EfsError { override name = 'MaxLensesExceeded' constructor(count: number, max: number) { super( `Lens stack has ${count} entries; the maximum is ${max}. Reduce it explicitly — the SDK will not truncate (that would silently change which attester wins).`, + { code: 'MaxLensesExceeded' }, ) } } /** The SDK's compiled schema UIDs don't match what's deployed on the target chain. - * Construct with a message describing the diff (`new SchemaMismatchError(...)`). */ + * Construct with a message describing the diff. */ export class SchemaMismatchError extends EfsError { override name = 'SchemaMismatchError' + constructor(message: string, cause?: unknown) { + super(message, { code: 'SchemaMismatch', cause }) + } } /** No EFS deployment is known for the connected chain (and none was supplied). */ @@ -47,6 +106,25 @@ export class DeploymentNotFound extends EfsError { constructor(chainId: number) { super( `No EFS deployment registered for chainId ${chainId}. Pass \`deployments\` to point at a custom/local deployment.`, + { code: 'DeploymentNotFound' }, ) } } + +/** A pagination cursor was invalid, stale, or from a different query. */ +export class CursorInvalid extends EfsError { + override name = 'CursorInvalid' + constructor() { + super('The pagination cursor is invalid or stale — restart the listing from the beginning.', { + code: 'CursorInvalid', + }) + } +} + +/** A multi-operation write partially failed; some operations landed, some did not. */ +export class PartialBatchFailure extends EfsError { + override name = 'PartialBatchFailure' + constructor(message: string, cause?: unknown) { + super(message, { code: 'PartialBatchFailure', cause }) + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 871d926..b0db2bc 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,11 +1,16 @@ /** * @efs/sdk — TypeScript SDK for the Ethereum File System (EFS). * - * Resource-namespaced client (ADR / Decision F): `efs.fs.*` (files), - * `efs.lenses.*` (resolution), `efs.eas.*` (viem-native EAS), `efs.raw.*` - * (deployment escape hatch). Shapes follow planning/Designs/sdk-architecture.md; - * unbuilt methods throw `NotImplemented` with their final signatures so the - * public surface is stable before publish. + * Resource-namespaced client (Decision F): `efs.fs.*` (files), `efs.lenses.*`, + * `efs.eas.*` (viem-native EAS), `efs.raw.*` (deployment escape hatch). Write + * capability is gated at the TYPE level — a client built without a `walletClient` + * doesn't expose `efs.fs.write`/`preview`/`batch` (viem's read/write split). + * Unbuilt methods reject with `NotImplemented`, locking signatures before publish. + * + * Namespaces from the full design not yet on the client (`graph`/`props`/`lists`/ + * `sorts`) are additive — adding a top-level namespace is non-breaking — and are + * designed in a dedicated pass; the option/return/pagination/batch *seams* below + * are the ones that would force a breaking change, so they exist now. */ import type { Address, PublicClient, WalletClient } from 'viem' @@ -22,77 +27,115 @@ import { schemaRegistryAbi, verifyAttestationUID, } from './eas/index.js' -import { EfsError, NotImplemented } from './errors.js' +import { EfsError, NotImplemented, WalletRequired } from './errors.js' import { type Lens, identity, lens, resolveLens } from './lenses/resolve.js' import type { + BatchReceipt, DataRef, EfsFile, + EfsList, + FetchOptions, + FileStat, + ListOptions, + ReadOptions, ReadResult, - Stat, WriteEstimate, WriteOptions, WriteReceipt, } from './types.js' +/** + * The read/write clients the SDK consumes. Aliased (not raw viem types in the + * public config) so we can later widen them to accept an ethers adapter without + * a breaking change — the seam for staying library-agnostic. The SDK core stays + * viem-native (ADR-0002); ethers interop ships later as an optional `@efs/sdk/ethers` + * adapter that produces an `EfsReader`/`EfsWriter`. Any wallet (MetaMask, WalletConnect, + * Coinbase, hardware, embedded) already works today: viem wraps any EIP-1193 provider. + */ +export type EfsReader = PublicClient +export type EfsWriter = WalletClient + export type EfsClientConfig = { - publicClient: PublicClient - /** Required for writes; reads work without it. */ - walletClient?: WalletClient + publicClient: EfsReader + /** Required for writes; reads work without it. Presence gates write methods + * at the type level (see `createEfsClient` overloads). */ + walletClient?: EfsWriter /** Override the built-in registry to point at a custom/local deployment. */ deployments?: DeploymentsMap /** Default lens when a read passes none (resolves to the connected wallet). */ defaultLens?: Lens } -export type EfsClient = { - /** Files. */ - fs: { - write(path: string, content: Uint8Array, opts?: WriteOptions): Promise - read(path: string, opts?: { as?: Lens | Address }): Promise - fetch(ref: DataRef, opts?: { verify?: boolean }): Promise - stat(path: string, opts?: { as?: Lens | Address }): Promise - list(path: string, opts?: { as?: Lens | Address }): AsyncIterable - preview(path: string, content: Uint8Array): Promise - } - /** Lens resolution. */ - lenses: { - resolve(input: Lens | Address): Promise - lens: typeof lens - identity: typeof identity - } - /** viem-native EAS access (ADR-0002). */ - eas: { - encoder(schema: string): SchemaEncoder - computeUID: typeof computeAttestationUID - verifyUID: typeof verifyAttestationUID - abi: { eas: typeof easAbi; schemaRegistry: typeof schemaRegistryAbi } - } - /** The resolved deployment for the connected chain (escape hatch). */ - raw: { - deployment(): EfsDeployment - verifyDeployment(): Promise - } +/** Read-only file operations. */ +export type EfsFsRead = { + read(path: string, opts?: ReadOptions): Promise + fetch(ref: DataRef, opts?: FetchOptions): Promise + stat(path: string, opts?: ReadOptions): Promise + list(path: string, opts?: ListOptions): EfsList +} + +/** Read + write file operations (only present when a `walletClient` is set). */ +export type EfsFsWrite = EfsFsRead & { + write(path: string, content: Uint8Array, opts?: WriteOptions): Promise + preview(path: string, content: Uint8Array): Promise +} + +export type EfsLensesNs = { + resolve(input: Lens | Address): Promise + lens: typeof lens + identity: typeof identity +} + +export type EfsEasNs = { + encoder(schema: string): SchemaEncoder + computeUID: typeof computeAttestationUID + verifyUID: typeof verifyAttestationUID + abi: { eas: typeof easAbi; schemaRegistry: typeof schemaRegistryAbi } +} + +export type EfsRawNs = { + deployment(): EfsDeployment + verifyDeployment(): Promise +} + +/** Read-capable client (no `walletClient`). */ +export type EfsReadClient = { + fs: EfsFsRead + lenses: EfsLensesNs + eas: EfsEasNs + raw: EfsRawNs +} + +/** Full client (a `walletClient` was supplied): reads + writes + batching. */ +export type EfsClient = EfsReadClient & { + fs: EfsFsWrite + /** Compose a multi-operation write delivered with one signature where possible. */ + batch(): { execute(): Promise } } function chainIdOf(publicClient: PublicClient): number { const id = publicClient.chain?.id if (id === undefined) { - throw new EfsError('publicClient has no `chain` set — cannot resolve the EFS deployment.') + throw new EfsError('publicClient has no `chain` set — cannot resolve the EFS deployment.', { + code: 'DeploymentNotFound', + }) } return id } +// Type-level write gate: a `walletClient` in the config widens the return to the +// write-capable `EfsClient`; without it, you get `EfsReadClient` (no write verbs). +export function createEfsClient(config: EfsClientConfig & { walletClient: WalletClient }): EfsClient +export function createEfsClient(config: EfsClientConfig): EfsReadClient export function createEfsClient(config: EfsClientConfig): EfsClient { - const { publicClient, deployments: override } = config + const { publicClient, walletClient, deployments: override } = config const getDeployment = () => resolveDeployment(chainIdOf(publicClient), override) + const requireWallet = () => { + if (!walletClient) throw new WalletRequired() + } return { fs: { - // Stubs reject/throw-on-iterate (not sync-throw) so the async contract is - // locked now — callers' `.catch()` / `for await` behave as they will post-build. - write: async (_path, _content, _opts) => { - throw new NotImplemented('efs.fs.write()') - }, read: async (_path, _opts) => { throw new NotImplemented('efs.fs.read()') }, @@ -106,7 +149,14 @@ export function createEfsClient(config: EfsClientConfig): EfsClient { [Symbol.asyncIterator]() { throw new NotImplemented('efs.fs.list()') }, + page: async () => { + throw new NotImplemented('efs.fs.list().page()') + }, }), + write: async (_path, _content, _opts) => { + requireWallet() + throw new NotImplemented('efs.fs.write()') + }, preview: async (_path, _content) => { throw new NotImplemented('efs.fs.preview()') }, @@ -126,11 +176,27 @@ export function createEfsClient(config: EfsClientConfig): EfsClient { deployment: getDeployment, verifyDeployment: () => assertDeploymentIntegrity(publicClient, getDeployment()), }, + batch: () => { + requireWallet() + throw new NotImplemented('efs.batch()') + }, } } // ── Standalone exports (chain-independent; usable now) ───────────────────────── -export * from './eas/index.js' +export { + SchemaEncoder, + buildAttest, + buildMultiAttest, + computeAttestationUID, + verifyAttestationUID, + parseSchema, + easAbi, + schemaRegistryAbi, + type AttestationRequest, + type AttestationRequestData, + type MultiAttestationRequest, +} from './eas/index.js' export { hashContent, verifyContent, type VerificationStatus } from './content/hash.js' export { lens, identity, resolveLens, MAX_LENSES, type Lens } from './lenses/resolve.js' export { @@ -147,10 +213,18 @@ export type { DataRef, DataUID, PathRef, + ReadOptions, + ListOptions, + FetchOptions, + WriteOptions, + Page, + EfsList, ReadResult, EfsFile, + FileStat, WriteReceipt, + WriteMechanism, WriteEstimate, - WriteOptions, - Stat, + OperationResult, + BatchReceipt, } from './types.js' diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 89bea02..8a3e1b8 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1,9 +1,13 @@ -/** Public value shapes. Branded UID kinds because wrong-UID-kind is the dominant - * integration bug (review DX-13). Static `DataRef` vs dynamic `PathRef` never - * silently interconvert (sdk-architecture §5). */ +/** Public value + option shapes. Branded UID kinds because wrong-UID-kind is the + * dominant integration bug (review DX-13). Static `DataRef` vs dynamic `PathRef` + * never silently interconvert (sdk-architecture §5). Option/return types are + * NAMED and exported so adding a field later is non-breaking (review C1). */ import type { Address, Hex } from 'viem' import type { VerificationStatus } from './content/hash.js' +import type { Lens } from './lenses/resolve.js' + +// ── Branded references ───────────────────────────────────────────────────────── export type DataUID = Hex & { readonly __kind: 'DataUID' } @@ -12,25 +16,81 @@ export type DataRef = { readonly __brand: 'DataRef'; readonly uid: DataUID } /** Dynamic reference — whatever is active at this path now. */ export type PathRef = { readonly __brand: 'PathRef'; readonly path: string } -/** A durable, serializable write session. `steps` are idempotent per (path-qualified) - * id so a resume skips only mined work and never double-mints (review B3). */ +// ── Read options (shared) ────────────────────────────────────────────────────── + +/** How to resolve a read: a `Lens`, a raw address (treated as a literal lens), + * or omitted (defaults to the connected wallet). */ +export type ReadOptions = { + /** The lens to resolve through. */ + lens?: Lens | Address +} + +/** Listing options: read options + pagination + (future) sort/schema filters. */ +export type ListOptions = ReadOptions & { + /** Max entries per page (the SDK windows the underlying bounded reads). */ + limit?: number + /** Opaque resumable cursor from a prior `Page`. */ + cursor?: string +} + +export type FetchOptions = { + /** Verify fetched bytes against the author's attested contentHash (default true). */ + verify?: boolean + /** Restrict/prioritize transports (e.g. `['ipfs', 'https']`); default = all by priority. */ + transports?: readonly string[] +} + +// ── Pagination ───────────────────────────────────────────────────────────────── + +/** One page of a listing plus the cursor to resume after it. */ +export type Page = { + items: readonly T[] + /** Cursor for the next page, or `undefined` at the end. */ + nextCursor?: string +} + +/** An async-iterable read that can also be paged explicitly. `for await` walks + * every entry; `.page(opts)` fetches one bounded page + a resume cursor. */ +export type EfsList = AsyncIterable & { + page(opts?: { limit?: number; cursor?: string }): Promise> +} + +// ── Writes ───────────────────────────────────────────────────────────────────── + +/** How a batched write was delivered. Exported so additions are localized, not a + * breaking change to an exhaustive `switch` (review C5). */ +export type WriteMechanism = 'multiAttest-sequential' | 'eip5792' | 'erc4337' | 'gateway' + +export type WriteOptions = { + contentType?: string + onProgress?: (p: { step: number; total: number; phase: string }) => void + resume?: WriteReceipt + signal?: AbortSignal +} + +/** A durable, serializable write session. `steps` are idempotent per + * (path-qualified) id so a resume skips only mined work and never double-mints. */ export type WriteReceipt = { contentHash: string data?: DataRef - steps: Array<{ id: string; uid?: Hex; done: boolean }> + steps: Array<{ id: string; uid?: DataUID; done: boolean }> signatureCount: number - mechanism: 'multiAttest-sequential' | 'eip5792' | 'erc4337' + mechanism: WriteMechanism } -/** A resolved read: the data ref plus which attester/lens won (review UX-4). */ -export type ReadResult = { data: DataRef; resolvedBy: Address } +/** One operation's result inside a multi-op batch. */ +export type OperationResult = { + id: string + ok: boolean + uid?: DataUID + error?: Error +} -/** Fetched bytes + trust-relative verification (never a bare "verified"). */ -export type EfsFile = { - bytes: Uint8Array - contentType?: string - verification: VerificationStatus - hashAuthor?: Address +/** The result of executing a multi-op batch. */ +export type BatchReceipt = { + results: readonly OperationResult[] + signatureCount: number + mechanism: WriteMechanism } export type WriteEstimate = { @@ -43,11 +103,25 @@ export type WriteEstimate = { warnings: string[] } -export type Stat = { exists: boolean; data?: DataRef; resolvedBy?: Address } +// ── Reads ────────────────────────────────────────────────────────────────────── -export type WriteOptions = { +/** A resolved read: the data ref plus which attester/lens won (review UX-4). */ +export type ReadResult = { data: DataRef; resolvedBy: Address } + +/** Fetched bytes + trust-relative verification (never a bare "verified"). */ +export type EfsFile = { + bytes: Uint8Array contentType?: string - onProgress?: (p: { step: number; total: number; phase: string }) => void - resume?: WriteReceipt - signal?: AbortSignal + verification: VerificationStatus + /** Whose contentHash claim was checked against. */ + hashAuthor?: Address +} + +/** Metadata about the file at a path, without fetching bytes. */ +export type FileStat = { + exists: boolean + data?: DataRef + resolvedBy?: Address + contentType?: string + size?: bigint } diff --git a/packages/sdk/test/index.test.ts b/packages/sdk/test/index.test.ts index 6450b64..3c0f0fc 100644 --- a/packages/sdk/test/index.test.ts +++ b/packages/sdk/test/index.test.ts @@ -1,15 +1,22 @@ -import { http, type Address, createPublicClient } from 'viem' +import { http, type Address, type WalletClient, createPublicClient, createWalletClient } from 'viem' import { sepolia } from 'viem/chains' import { describe, expect, it } from 'vitest' -import { MaxLensesExceeded, NotImplemented, createEfsClient, identity, lens } from '../src/index.js' +import { + MaxLensesExceeded, + NotImplemented, + WalletRequired, + createEfsClient, + identity, + lens, +} from '../src/index.js' const publicClient = createPublicClient({ chain: sepolia, transport: http() }) +const walletClient = createWalletClient({ chain: sepolia, transport: http() }) as WalletClient const addr = (n: number) => `0x${n.toString(16).padStart(40, '0')}` as Address describe('namespaced client (Decision F)', () => { - it('builds a client; unbuilt fs verbs reject with NotImplemented (async contract)', async () => { + it('read-only verbs reject with NotImplemented (async contract)', async () => { const efs = createEfsClient({ publicClient }) - await expect(efs.fs.write('/x', new Uint8Array())).rejects.toThrow(NotImplemented) await expect(efs.fs.read('/x')).rejects.toThrow(NotImplemented) await expect( (async () => { @@ -18,6 +25,18 @@ describe('namespaced client (Decision F)', () => { ).rejects.toThrow(NotImplemented) }) + it('write methods are gated: WalletRequired without a wallet, NotImplemented with one', async () => { + // The type hides `write` on a read-only client; at runtime the verb exists and + // guards with WalletRequired (the backstop behind the type gate). + const readOnly = createEfsClient({ publicClient }) as { + fs: { write(p: string, c: Uint8Array): Promise } + } + await expect(readOnly.fs.write('/x', new Uint8Array())).rejects.toThrow(WalletRequired) + + const writable = createEfsClient({ publicClient, walletClient }) + await expect(writable.fs.write('/x', new Uint8Array())).rejects.toThrow(NotImplemented) + }) + it('exposes lens helpers under efs.lenses', () => { const efs = createEfsClient({ publicClient }) expect(typeof efs.lenses.lens).toBe('function') From 82d5b03ef04858aa0787d08bd1f2a3b97576b18d Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 00:31:26 -0500 Subject: [PATCH 22/47] docs: quickstart uses the actual ReadOptions key `lens` (Codex P2) Renamed the read option `as` -> `lens` in the hardening batch; the README quickstart still showed `{ as: ... }`. Fixed. Co-authored-by: Claude Opus 4.8 --- packages/sdk/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 5a23abd..06ed8fe 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -28,7 +28,7 @@ const efs = createEfsClient({ // Resolve "the file at /logo" through an identity (ENS → key-set → lens). // `read` returns a reference + who resolved it; `fetch` gets the bytes (verified). -const result = await efs.fs.read('/logo', { as: identity('jamescarnley.eth') }) +const result = await efs.fs.read('/logo', { lens: identity('jamescarnley.eth') }) if (result) { const file = await efs.fs.fetch(result.data) console.log(file.bytes, file.verification) // 'matches-author' | 'mismatch' | 'no-claim' From f9733fc42049029fc654b0358ab1ba2bac1f4178 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 00:33:13 -0500 Subject: [PATCH 23/47] =?UTF-8?q?chore(sdk):=20foundation=20hardening=20ba?= =?UTF-8?q?tch=202=20=E2=80=94=20exports,=20attw/publint,=20ADRs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural/tooling (subagent) + the ADRs that lock the decisions: - Subpath exports: @efs/sdk/eas, /lenses, /chain (multi-entry tsup + barrels + exports map), like viem's per-namespace subpaths. - attw + publint in CI (node16 profile; node10 is an explicit non-target given engines.node>=20 + an exports map). This caught + fixed a REAL dual-types bug (CJS-types masquerading) — exports now split import/require with per-condition types (.d.ts ESM / .d.cts CJS). - @efs/solidity exports map for .sol (like @openzeppelin/contracts); gitignore Foundry out/cache. - tsconfig exactOptionalPropertyTypes (passed clean, no src fixes). - turbo lint/typecheck cache tasks; repository.url -> git+https (publint). - ADR-0007 (error model), ADR-0008 (public API + instantiation + semver), ADR-0009 (library-agnostic seam: viem core + optional ethers adapter). build/tsc/test(27)/lint/attw/publint all green. Co-authored-by: Claude Opus 4.8 --- .github/workflows/ci.yml | 7 + .gitignore | 4 +- docs/adr/0007-error-model.md | 31 ++ docs/adr/0008-public-api-and-semver.md | 43 +++ docs/adr/0009-library-agnostic-seam.md | 36 ++ docs/adr/README.md | 5 +- package.json | 4 +- packages/sdk/package.json | 43 ++- packages/sdk/src/chain/index.ts | 14 + packages/sdk/src/lenses/index.ts | 13 + packages/sdk/tsup.config.ts | 2 +- packages/solidity/package.json | 6 +- pnpm-lock.yaml | 487 +++++++++++++++++++++++++ tsconfig.base.json | 1 + turbo.json | 8 +- 15 files changed, 693 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0007-error-model.md create mode 100644 docs/adr/0008-public-api-and-semver.md create mode 100644 docs/adr/0009-library-agnostic-seam.md create mode 100644 packages/sdk/src/chain/index.ts create mode 100644 packages/sdk/src/lenses/index.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07fd4ee..70becf4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,13 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm --filter @efs/sdk build + # Guard the dual ESM/CJS `exports` map: attw catches type masquerading / + # resolution bugs, publint catches malformed package metadata. + # node16 profile: the package requires Node >=20 and ships an `exports` + # map, so legacy node10 resolution (which ignores `exports`, breaking + # subpath entries) is an explicit non-target. + - run: pnpm --filter @efs/sdk exec attw --pack --profile node16 + - run: pnpm --filter @efs/sdk exec publint - run: pnpm --filter @efs/sdk typecheck - run: pnpm --filter @efs/sdk test - run: pnpm lint diff --git a/.gitignore b/.gitignore index d11f0b7..08d9d9f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,12 @@ dist/ # Turbo .turbo/ -# Foundry (packages/solidity) +# Foundry (packages/solidity) — build artifacts, never shipped or tracked out/ cache/ broadcast/ +packages/solidity/out/ +packages/solidity/cache/ packages/solidity/lib/ # Env / secrets diff --git a/docs/adr/0007-error-model.md b/docs/adr/0007-error-model.md new file mode 100644 index 0000000..a3195ea --- /dev/null +++ b/docs/adr/0007-error-model.md @@ -0,0 +1,31 @@ +# ADR-0007: Error model — viem-`BaseError`-grade typed tree + +**Status:** Accepted +**Date:** 2026-06-11 +**Related:** ADR-0008 (public API), `src/errors.ts` + +## Context + +External callers need to handle SDK failures programmatically — distinguish a missing file from a wallet-not-connected from an on-chain revert — without string-matching RPC errors. A bare `Error` subtree (just `name`) is too thin; viem set the bar with `BaseError` (`shortMessage`, `walk()`, cause chains), and it's the most-copied part of viem's DX. + +## Decision + +`EfsError` is the base of a typed tree modeled on viem's `BaseError`: + +- **`shortMessage`** — the headline, separate from any appended `details`. +- **`code: EfsErrorCode`** — a stable, switchable discriminant. The type is an **open string-union** (`… | (string & {})`), never a TS `enum`, so adding a code is not a breaking change to an exhaustive `switch`. +- **`cause`** — passed through to `Error`'s `cause` (wraps the underlying viem/RPC error). +- **`walk(fn?)`** — traverse the cause chain; with `fn`, return the first match (or `null`); without, the deepest cause. Mirrors `viem`'s `BaseError.walk`. + +Subclasses set a distinct `name` + `code`: `NotImplemented`, `WalletRequired`, `LensRequired`, `MaxLensesExceeded`, `SchemaMismatchError`, `DeploymentNotFound`, `CursorInvalid`, `PartialBatchFailure`. New failure modes (transport, mirror-scheme-rejected, list-constraint, partial-batch detail) are added as subclasses + codes over time — additive, never breaking, because callers catch `EfsError` and/or switch on the open `code`. + +## Consequences + +- Callers can `catch (e) { if (e instanceof EfsError) switch (e.code) … }` or `e.walk(x => x instanceof ContractFunctionRevertedError)` for the underlying revert. +- The `code` union being open-ended is the load-bearing future-proofing — adding a code never forces downstream churn. +- `WalletRequired` is the runtime backstop behind the type-level write gate (ADR-0008); both exist on purpose. + +## Alternatives considered + +- **Bare `Error` subclasses (name only)** — rejected: no `shortMessage`/`code`/`walk`, forces string-matching for the common "what went wrong" branch. +- **A TS `enum` for codes** — rejected: enum additions + the class-vs-code duality are a known trap; an open string-union is additive-safe. diff --git a/docs/adr/0008-public-api-and-semver.md b/docs/adr/0008-public-api-and-semver.md new file mode 100644 index 0000000..f053c77 --- /dev/null +++ b/docs/adr/0008-public-api-and-semver.md @@ -0,0 +1,43 @@ +# ADR-0008: Public API shape, instantiation & semver policy + +**Status:** Accepted +**Date:** 2026-06-11 +**Related:** ADR-0002 (viem), ADR-0007 (errors), ADR-0009 (library-agnostic seam), planning/Designs/sdk-architecture.md (Decision F) + +## Context + +The SDK's public surface is the one near-permanent thing it has (breaking it is a semver-major with downstream cost). A three-agent foundation review found the right instincts but two contradictions: the design doc specified `new EFSClient({ rpc, chainId })` while the code shipped `createEfsClient({ publicClient })`, and write capability was a runtime check rather than a type. This ADR locks the surface before publish. + +## Decision + +**Instantiation — a factory taking injected viem clients (the scaffold's shape wins; the doc is updated to match).** + +```ts +const efs = createEfsClient({ publicClient, walletClient?, deployments?, defaultLens? }) +``` + +- A **factory function**, not `new EFSClient` (viem/wagmi never expose `new PublicClient`). +- Consumers inject built **viem clients** (`publicClient`/`walletClient`) — referenced through the `EfsReader`/`EfsWriter` aliases (ADR-0009) — not raw `rpc`+`chainId` strings, so they bring their own transports/fallbacks/chains and any EIP-1193 wallet. + +**Resource-namespaced surface** (Decision F): `efs.fs` (files) · `efs.lenses` · `efs.eas` (viem-native) · `efs.raw` (deployment escape hatch). The full design's `graph`/`props`/`lists`/`sorts` namespaces are **additive** (a new top-level namespace is non-breaking) and land in a dedicated pass. + +**Type-level write gate.** `createEfsClient` is overloaded: with a `walletClient` it returns the write-capable `EfsClient`; without, `EfsReadClient` (no `fs.write`/`preview`/`batch` in the type). `WalletRequired` is the runtime backstop (ADR-0007). This mirrors viem's `PublicClient`/`WalletClient` split — write capability lives in the type, not a runtime check. + +**Future-proofing seams that exist now** (additive-now / breaking-later, so they're in the foundation before publish): +- **Named, exported option/return types** — `ReadOptions`/`ListOptions`/`FetchOptions`/`WriteOptions`, never inline literals (adding a field stays non-breaking). +- **Pagination** — `list` returns `EfsList` (`AsyncIterable` + `.page()`), with an exported `Page`; a bare `AsyncIterable` can't grow `.page()` without a return-type change. +- **Batch** — `efs.batch()` + exported `BatchReceipt`/`OperationResult`/`WriteMechanism`; `fs.write` is documented as sugar over it. +- **Branded UIDs** (`DataUID`) + static `DataRef` vs dynamic `PathRef`. + +**Semver policy.** The published API is the SDK's only Durable surface (per the ADR-process framing). Breaking an exported signature/type is a major. Pre-1.0 we may break freely; at 1.0 the above seams are frozen. Every change to a published package needs a Changeset stating the bump. + +## Consequences + +- The design doc's instantiation section is superseded by this (factory + injected viem clients); the two are reconciled. +- A read-only client cannot call write verbs at compile time — the highest-leverage type safety, cheap now. +- The option/pagination/batch seams mean the bulk of future feature work adds *optional fields* and *new namespaces*, not breaking changes. + +## Alternatives considered + +- **`new EFSClient({ rpc, chainId, signer })`** (the old doc shape) — rejected: not viem-idiomatic; raw rpc strings prevent custom transports/wallets. +- **Runtime-only write check** — rejected: a thrown error is strictly worse than a compile error; the type gate is free. diff --git a/docs/adr/0009-library-agnostic-seam.md b/docs/adr/0009-library-agnostic-seam.md new file mode 100644 index 0000000..3d6d676 --- /dev/null +++ b/docs/adr/0009-library-agnostic-seam.md @@ -0,0 +1,36 @@ +# ADR-0009: Library-agnostic seam — viem core, ethers as an optional adapter + +**Status:** Accepted +**Date:** 2026-06-11 +**Related:** ADR-0002 (viem-only core), ADR-0008 (public API) + +## Context + +ADR-0002 made the SDK viem-native (vendored EAS ABIs, no ethers, no eas-sdk). The concern raised: don't get over-attached to one library — what if a consumer's codebase is ethers-based? A 2025–2026 landscape check (web-verified): + +- Only **viem** and **ethers v6** are realistic EVM client libraries for a new SDK; web3.js is sunset. There is no third contender. +- **Wallets are not client libraries.** A wallet (MetaMask, WalletConnect, Coinbase, Rabby, Ledger, embedded/smart wallets) is an **EIP-1193 provider**; viem wraps *any* EIP-1193 provider via `custom()`. So "support multiple wallets" is already solved by viem — the only real axis of coupling is supporting a second *client library* (ethers). +- The EAS SDK itself went dual (ethers + viem) around mid-2025 — evidence the interop is worth offering. +- Well-regarded SDKs (e.g. thirdweb) keep a viem-native core and expose ethers interop as **subpath adapters** (`thirdweb/adapters/ethers6`), never bundling ethers into core. + +## Decision + +**Keep the core viem-native (ADR-0002 stands). Make ethers a non-breaking future addition via two cheap seams added now:** + +1. **Alias the client types.** The public config references `EfsReader` / `EfsWriter` (today `= PublicClient` / `WalletClient`), not raw viem types. Later these widen to a union (`PublicClient | EfsReaderAdapter`) — a **non-breaking** change. +2. **Reserve `@efs/sdk/ethers`** as the future optional adapter entry: `fromEthersSigner()` / `fromEthersProvider()` produce an `EfsReader`/`EfsWriter`, with `ethers` as an optional peerDependency **only there** — never in the core dep tree. + +This refines ADR-0002 ("viem-only") to **"viem-core, ethers-extensible"** — without weakening its load-bearing point (no eas-sdk/ethers dependency in core). + +## Consequences + +- **Every wallet works today** through viem's EIP-1193 wrapping — no SDK work needed for "multiple wallet types." +- **ethers users get a path later** (the adapter) without the core ever importing ethers, and without a major version bump. +- The cost now is two aliases + a reserved subpath — essentially free. +- If a genuinely new client library emerges, the same alias-widening + adapter-subpath pattern absorbs it. + +## Alternatives considered + +- **A full SDK-owned signer/provider interface with two adapters** — rejected as over-engineered for a one-library-today reality; the aliases give the same non-breaking future with far less surface. +- **EIP-1193-only core (drop viem)** — rejected: loses viem's typed encoding/contract layer and EIP-5792 batching actions. +- **Bundle ethers in core (dual like eas-sdk)** — rejected: drags ethers into every consumer's bundle, the exact coupling ADR-0002 avoided. diff --git a/docs/adr/README.md b/docs/adr/README.md index f75c978..8412111 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,5 +52,6 @@ Compact, scannable — one screen per ADR. Copy `_template.md`. The next number - [ADR-0004 — Publish via npm Trusted Publishing (OIDC), not a stored token](./0004-publish-via-oidc-trusted-publishing.md) - [ADR-0005 — Per-chain deployments registry; the SDK is a client, not a deployer](./0005-deployments-registry-not-a-deployer.md) - [ADR-0006 — `contentHash` is a bare SHA-256 digest](./0006-content-hash-bare-sha256.md) - -_Recommended next: ADR-0007 error model · ADR-0008 public API surface & semver policy (write when the code lands)._ +- [ADR-0007 — Error model: viem-`BaseError`-grade typed tree](./0007-error-model.md) +- [ADR-0008 — Public API shape, instantiation & semver policy](./0008-public-api-and-semver.md) +- [ADR-0009 — Library-agnostic seam: viem core, ethers as an optional adapter](./0009-library-agnostic-seam.md) diff --git a/package.json b/package.json index b4ef95b..3da782e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/efs-project/sdk.git" + "url": "git+https://github.com/efs-project/sdk.git" }, "packageManager": "pnpm@9.12.0", "engines": { @@ -23,8 +23,10 @@ "release": "pnpm --filter @efs/sdk build && changeset publish" }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.3", "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.27.9", + "publint": "^0.2.12", "turbo": "^2.3.0", "typescript": "^5.6.3" } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5bb753d..45be1dd 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -7,7 +7,7 @@ "sideEffects": false, "repository": { "type": "git", - "url": "https://github.com/efs-project/sdk.git", + "url": "git+https://github.com/efs-project/sdk.git", "directory": "packages/sdk" }, "files": ["dist"], @@ -17,9 +17,44 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./eas": { + "import": { + "types": "./dist/eas/index.d.ts", + "default": "./dist/eas/index.js" + }, + "require": { + "types": "./dist/eas/index.d.cts", + "default": "./dist/eas/index.cjs" + } + }, + "./lenses": { + "import": { + "types": "./dist/lenses/index.d.ts", + "default": "./dist/lenses/index.js" + }, + "require": { + "types": "./dist/lenses/index.d.cts", + "default": "./dist/lenses/index.cjs" + } + }, + "./chain": { + "import": { + "types": "./dist/chain/index.d.ts", + "default": "./dist/chain/index.js" + }, + "require": { + "types": "./dist/chain/index.d.cts", + "default": "./dist/chain/index.cjs" + } }, "./package.json": "./package.json" }, diff --git a/packages/sdk/src/chain/index.ts b/packages/sdk/src/chain/index.ts new file mode 100644 index 0000000..5d0df91 --- /dev/null +++ b/packages/sdk/src/chain/index.ts @@ -0,0 +1,14 @@ +/** + * `@efs/sdk/chain` subpath entry — the per-chain deployments registry (ADR-0005). + * Thin barrel: re-exports the curated chain surface from `./deployments.js`. + */ + +export { + deployments, + resolveDeployment, + assertDeploymentIntegrity, + type DeploymentsMap, + type EfsDeployment, + type EfsContracts, + type EfsSchemaUIDs, +} from './deployments.js' diff --git a/packages/sdk/src/lenses/index.ts b/packages/sdk/src/lenses/index.ts new file mode 100644 index 0000000..c40a16d --- /dev/null +++ b/packages/sdk/src/lenses/index.ts @@ -0,0 +1,13 @@ +/** + * `@efs/sdk/lenses` subpath entry — the lens primitives (ADR-0031/0039). + * Thin barrel: re-exports the curated lens surface from `./resolve.js`. + */ + +export { + lens, + identity, + resolveLens, + MAX_LENSES, + type Lens, + type LensContext, +} from './resolve.js' diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts index 328c648..39685bf 100644 --- a/packages/sdk/tsup.config.ts +++ b/packages/sdk/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/eas/index.ts', 'src/lenses/index.ts', 'src/chain/index.ts'], format: ['esm', 'cjs'], dts: true, splitting: true, diff --git a/packages/solidity/package.json b/packages/solidity/package.json index d982545..3b831f7 100644 --- a/packages/solidity/package.json +++ b/packages/solidity/package.json @@ -5,11 +5,15 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/efs-project/sdk.git", + "url": "git+https://github.com/efs-project/sdk.git", "directory": "packages/solidity" }, "files": ["src/**/*.sol", "README.md"], "publishConfig": { "access": "public", "provenance": true }, + "exports": { + "./contracts/*": "./src/*.sol", + "./package.json": "./package.json" + }, "keywords": ["ethereum", "efs", "eas", "solidity", "filesystem"], "homepage": "https://github.com/efs-project/sdk/tree/main/packages/solidity", "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 429a043..d15da1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,18 @@ importers: .: devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.3 + version: 0.18.3 '@biomejs/biome': specifier: ^1.9.4 version: 1.9.4 '@changesets/cli': specifier: ^2.27.9 version: 2.31.0 + publint: + specifier: ^0.2.12 + version: 0.2.12 turbo: specifier: ^2.3.0 version: 2.9.17 @@ -46,6 +52,18 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@andrewbranch/untar.js@1.0.3': + resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} + + '@arethetypeswrong/cli@0.18.3': + resolution: {integrity: sha512-GeAlc+lUD4gKHD/LDQNvQY30FfQ+xAXg2inbQKUjFZgTOdI5ygEweaOnGHGBPSKXSLGQC7VLhpXu9zMnYk/4sQ==} + engines: {node: '>=20'} + hasBin: true + + '@arethetypeswrong/core@0.18.3': + resolution: {integrity: sha512-sWBB/tdIktaT5xMq0Dz6CJyqcf6oMNdmiKiuPU1lWoJLTL6gjRSsksBuSgqot21hylkklBQY1wiSu+PkZhW7sw==} + engines: {node: '>=20'} + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -103,6 +121,9 @@ packages: cpu: [x64] os: [win32] + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -158,6 +179,10 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -478,6 +503,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@loaderkit/resolve@1.0.6': + resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -645,6 +673,10 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -734,10 +766,22 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -755,10 +799,16 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -777,9 +827,21 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -795,6 +857,32 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -831,10 +919,20 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emojilib@2.4.0: + resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -848,6 +946,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -892,6 +994,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -924,11 +1029,18 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-port@7.2.0: resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==} engines: {node: '>=16'} @@ -941,6 +1053,11 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -948,6 +1065,13 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + http-proxy@1.18.1: resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} @@ -964,14 +1088,29 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ignore-walk@5.0.1: + resolution: {integrity: sha512-yemi4pMf51WKT7khInJqAvsIGzoqYXblnsz0ql8tM+yi1EKYTY1evX4NAbJrLL/Aanr2HyZeluqU+Oi7MGHokw==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1044,9 +1183,24 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked-terminal@7.3.0: + resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} + engines: {node: '>=16.0.0'} + peerDependencies: + marked: '>=1 <16' + + marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1055,6 +1209,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -1086,6 +1244,23 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-emoji@2.2.0: + resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} + engines: {node: '>=18'} + + npm-bundled@2.0.1: + resolution: {integrity: sha512-gZLxXdjEzE/+mOstGDqR6b0EkhJ+kM6fxM6vUuckuctuVPh80Q6pw/rSZj9s4Gex9GxWtIicO1pc8DB9KZWudw==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + npm-normalize-package-bin@2.0.0: + resolution: {integrity: sha512-awzfKUO7v0FscrSpRoogyNm0sajikhBWpU0QMrW09AMi9n1PoKU6WaIqUzuJSQnpciZZmJ/jMZ2Egfmb/9LiWQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + npm-packlist@5.1.3: + resolution: {integrity: sha512-263/0NGrn32YFYi4J533qzrQ/krmmrWwhKkzwTuM4f/07ug51odoaNjUexxO4vxlzURHcmYMH1QjvHjsNDKLVg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + hasBin: true + npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} @@ -1094,6 +1269,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -1132,6 +1310,15 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1220,6 +1407,11 @@ packages: '@pimlico/alto': optional: true + publint@0.2.12: + resolution: {integrity: sha512-YNeUtCVeM4j9nDiTT2OPczmlyzOkIXNtdDZnSuajAxS/nZ6j3t7Vs9SUB4euQNddiltIwu7Tdd3s+hr08fAsMw==} + engines: {node: '>=16'} + hasBin: true + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -1234,6 +1426,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -1253,6 +1449,10 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1276,6 +1476,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + skin-tone@2.0.0: + resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} + engines: {node: '>=8'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -1300,6 +1504,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1317,6 +1525,14 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + tar@7.2.0: resolution: {integrity: sha512-hctwP0Nb4AB60bj8WQgRYaMOuJYRAPMGiQUAotms5igN8ppfQM+IvjQ5HcKu1MaZh2Wy2KWVTe563Yj8dfc14w==} engines: {node: '>=18'} @@ -1389,6 +1605,11 @@ packages: resolution: {integrity: sha512-91Q3KxfHJn7esFu2Ic6j9pkvQqWjncQCOp7r1gCKChRSb/+T/yIjsavAmbGLmFRKAzSjmWW/FMrcknmJ4hEOPA==} hasBin: true + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1397,6 +1618,10 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + unicode-emoji-modifier-base@1.0.0: + resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} + engines: {node: '>=4'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -1405,6 +1630,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + viem@2.52.2: resolution: {integrity: sha512-HSU12p5aD/kAPZfrlbCUqdiP4P/c6hQ9AhfTS51VbLUQIjkWd1d5EjrCx/SCxZ0zhZVRn4Iv5X5WDqXPG8Ubew==} peerDependencies: @@ -1484,6 +1713,13 @@ packages: engines: {node: '>=8'} hasBin: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.1: resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} @@ -1496,10 +1732,22 @@ packages: utf-8-validate: optional: true + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -1508,6 +1756,29 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} + '@andrewbranch/untar.js@1.0.3': {} + + '@arethetypeswrong/cli@0.18.3': + dependencies: + '@arethetypeswrong/core': 0.18.3 + chalk: 4.1.2 + cli-table3: 0.6.5 + commander: 10.0.1 + marked: 9.1.6 + marked-terminal: 7.3.0(marked@9.1.6) + semver: 7.8.4 + + '@arethetypeswrong/core@0.18.3': + dependencies: + '@andrewbranch/untar.js': 1.0.3 + '@loaderkit/resolve': 1.0.6 + cjs-module-lexer: 1.4.3 + fflate: 0.8.3 + lru-cache: 11.5.1 + semver: 7.8.4 + typescript: 5.6.1-rc + validate-npm-package-name: 5.0.1 + '@babel/runtime@7.29.7': {} '@biomejs/biome@1.9.4': @@ -1545,6 +1816,8 @@ snapshots: '@biomejs/cli-win32-x64@1.9.4': optional: true + '@braidai/lang@1.1.2': {} + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -1688,6 +1961,9 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@colors/colors@1.5.0': + optional: true + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1858,6 +2134,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.7 @@ -1984,6 +2264,8 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@sindresorhus/is@4.6.0': {} + '@sindresorhus/merge-streams@4.0.0': {} '@turbo/darwin-64@2.9.17': @@ -2056,8 +2338,18 @@ snapshots: ansi-colors@4.1.3: {} + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + any-promise@1.3.0: {} argparse@1.0.10: @@ -2070,10 +2362,16 @@ snapshots: assertion-error@2.0.1: {} + balanced-match@1.0.2: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -2093,8 +2391,17 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + change-case@5.4.4: {} + char-regex@1.0.2: {} + chardet@2.1.1: {} check-error@2.1.3: {} @@ -2105,6 +2412,37 @@ snapshots: chownr@3.0.0: {} + cjs-module-lexer@1.4.3: {} + + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.0 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + commander@4.1.1: {} confbox@0.1.8: {} @@ -2129,11 +2467,17 @@ snapshots: dependencies: path-type: 4.0.0 + emoji-regex@8.0.0: {} + + emojilib@2.4.0: {} + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + environment@1.1.0: {} + es-module-lexer@1.7.0: {} esbuild@0.21.5: @@ -2191,6 +2535,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} + esprima@4.0.1: {} estree-walker@3.0.3: @@ -2238,6 +2584,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -2271,9 +2619,13 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + get-port@7.2.0: {} get-stream@9.0.1: @@ -2285,6 +2637,14 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.9 + once: 1.4.0 + globby@11.1.0: dependencies: array-union: 2.1.0 @@ -2296,6 +2656,10 @@ snapshots: graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + + highlight.js@10.7.3: {} + http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 @@ -2312,10 +2676,23 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ignore-walk@5.0.1: + dependencies: + minimatch: 5.1.9 + ignore@5.3.2: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -2369,10 +2746,25 @@ snapshots: loupe@3.2.1: {} + lru-cache@11.5.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked-terminal@7.3.0(marked@9.1.6): + dependencies: + ansi-escapes: 7.3.0 + ansi-regex: 6.2.2 + chalk: 5.6.2 + cli-highlight: 2.1.11 + cli-table3: 0.6.5 + marked: 9.1.6 + node-emoji: 2.2.0 + supports-hyperlinks: 3.2.0 + + marked@9.1.6: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2380,6 +2772,10 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + minipass@7.1.3: {} minizlib@3.1.0: @@ -2407,6 +2803,26 @@ snapshots: nanoid@3.3.12: {} + node-emoji@2.2.0: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 + + npm-bundled@2.0.1: + dependencies: + npm-normalize-package-bin: 2.0.0 + + npm-normalize-package-bin@2.0.0: {} + + npm-packlist@5.1.3: + dependencies: + glob: 8.1.0 + ignore-walk: 5.0.1 + npm-bundled: 2.0.1 + npm-normalize-package-bin: 2.0.0 + npm-run-path@6.0.0: dependencies: path-key: 4.0.0 @@ -2414,6 +2830,10 @@ snapshots: object-assign@4.1.1: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + outdent@0.5.0: {} ox@0.14.29(typescript@5.9.3): @@ -2453,6 +2873,14 @@ snapshots: parse-ms@4.0.0: {} + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -2512,6 +2940,12 @@ snapshots: transitivePeerDependencies: - debug + publint@0.2.12: + dependencies: + npm-packlist: 5.1.3 + picocolors: 1.1.1 + sade: 1.8.1 + quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -2525,6 +2959,8 @@ snapshots: readdirp@4.1.2: {} + require-directory@2.1.1: {} + requires-port@1.0.0: {} resolve-from@5.0.0: {} @@ -2566,6 +3002,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + sade@1.8.1: + dependencies: + mri: 1.2.0 + safer-buffer@2.1.2: {} semver@7.8.4: {} @@ -2580,6 +3020,10 @@ snapshots: signal-exit@4.1.0: {} + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + slash@3.0.0: {} source-map-js@1.2.1: {} @@ -2597,6 +3041,12 @@ snapshots: std-env@3.10.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -2615,6 +3065,15 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + tar@7.2.0: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -2694,14 +3153,20 @@ snapshots: '@turbo/windows-64': 2.9.17 '@turbo/windows-arm64': 2.9.17 + typescript@5.6.1-rc: {} + typescript@5.9.3: {} ufo@1.6.4: {} + unicode-emoji-modifier-base@1.0.0: {} + unicorn-magic@0.3.0: {} universalify@0.1.2: {} + validate-npm-package-name@5.0.1: {} + viem@2.52.2(typescript@5.9.3): dependencies: '@noble/curves': 1.9.1 @@ -2787,8 +3252,30 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + ws@8.20.1: {} + y18n@5.0.8: {} + yallist@5.0.0: {} + yargs-parser@20.2.9: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + yoctocolors@2.1.2: {} diff --git a/tsconfig.base.json b/tsconfig.base.json index 4759d73..9d5ddc6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -7,6 +7,7 @@ "lib": ["ES2022", "DOM"], "strict": true, "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, "verbatimModuleSyntax": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/turbo.json b/turbo.json index b630cf5..c9ac5ff 100644 --- a/turbo.json +++ b/turbo.json @@ -9,7 +9,13 @@ "dependsOn": ["^build"] }, "typecheck": { - "dependsOn": ["^build"] + "dependsOn": ["^build"], + "inputs": ["src/**", "tsconfig*.json", "$TURBO_ROOT$/tsconfig.base.json"], + "outputs": [] + }, + "lint": { + "inputs": ["src/**", "*.json", "$TURBO_ROOT$/biome.json"], + "outputs": [] } } } From cdd250ff63aad5574a1ff1b70261ec504fadbd55 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 00:50:04 -0500 Subject: [PATCH 24/47] docs: standards-foundation spec (7-domain EIP/ERC/CAIP research pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates a 7-agent web-research sweep into docs/specs/standards.md — every EIP/ERC/CAIP the SDK touches, tagged ADOPT/SEAM/WATCH/AVOID, with the durable principle "depend on the standard, viem is the swappable engine." Key adoptions: EIP-1193 provider boundary, EIP-5792 (now Final) batch path, EIP-712/1271/6492 verification via viem Actions, ENSIP-15+Universal Resolver+CCIP-Read identity, ERC-4804/6860/6944 web3:// mirror layer (no library provides it — our value-add), EIP-155+CAIP-2/10 chain ids, error model as a classifier over viem BaseError. Includes a WATCH (quarterly) list and an AVOID list (web3.js, eth_sign, blobs for durable storage, raw ecrecover). Co-authored-by: Claude Opus 4.8 --- docs/specs/README.md | 2 + docs/specs/standards.md | 107 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 docs/specs/standards.md diff --git a/docs/specs/README.md b/docs/specs/README.md index fa92540..f68a279 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -20,5 +20,7 @@ Rules of thumb: ## Index - [overview.md](./overview.md) — the SDK at a glance: the two packages and the core model. +- [standards.md](./standards.md) — the EIPs/ERCs/CAIPs the SDK is built on (ADOPT/SEAM/WATCH/AVOID), researched 2026-06-11. +- [content-hash.md](./content-hash.md) — the bare-SHA-256 `contentHash` convention. _More specs land as the implementation does (reads, writes/batching, lenses/identity, errors)._ diff --git a/docs/specs/standards.md b/docs/specs/standards.md new file mode 100644 index 0000000..0b6f9db --- /dev/null +++ b/docs/specs/standards.md @@ -0,0 +1,107 @@ +# Ethereum standards the SDK is built on + +> The EFS SDK is anchored on **EIPs / ERCs / CAIPs** (durable, peer-reviewed, multi-decade) rather than library quirks (viem/ethers are ~10-year tools; the standards outlast them). Researched 2026-06-11 via a 7-domain web-search pass (EIP repos, ethereum-magicians, EthResearch, viem docs). Each standard is tagged **ADOPT** (build on it now) · **SEAM** (reserve the hook, don't build yet) · **WATCH** (re-check quarterly) · **AVOID**. + +## The one principle + +**Depend on the standard, use viem as the engine.** The durable boundary is the standard's shape (an EIP-1193 `request` provider, an EIP-712 struct, a `web3://` URL); viem is the best *implementation* of those today and is swappable behind the boundary. We never make viem's concrete types the public contract. + +## Provider & wallet + +| Standard | Status | SDK | Note | +|---|---|---|---| +| **EIP-1193** Provider JS API (`request`/events/error codes) | Final | **ADOPT** | The universal wallet contract. Public boundary = a minimal `{ request }` shape, not viem's `EIP1193Provider` import; adapt to viem via `custom()` internally. | +| **EIP-1193 error codes** (4001 user-rejected, 4100 unauthorized, 4200, 4900/4901) | Final | **ADOPT** | Error model must recognize these; 4001 is benign (user rejection), not a failure. | +| **EIP-1474** JSON-RPC + `-327xx`/`-32000..099` codes | Stagnant | **ADOPT** (codes) | Cite for the error taxonomy, not as a living spec. | +| **EIP-2255** Wallet permissions (`wallet_requestPermissions`) | Final | **SEAM** | Call through the `request` seam when consent is needed; don't build a permissions layer. | +| **EIP-6963** Multi-injected provider discovery | Final | **IGNORE** (app concern) | Wallet *selection* is the connector/app's job; we consume the chosen 1193 provider. Optionally re-export `mipd`. | + +## Batching & account abstraction + +| Standard | Status | SDK | Note | +|---|---|---|---| +| **EIP-5792** `wallet_sendCalls` / `wallet_getCapabilities` | **Final** | **ADOPT (primary)** | The standards-blessed one-signature multi-call. MetaMask 12+, Coinbase, Safe, viem `sendCalls`/`getCapabilities` + `experimental_fallback`. Batched calls execute from the user's address → **attester preserved**. | +| **EIP-7702** EOA→smart-account delegation | **Live (Pectra, May 2025)** | **SEAM (detect)** | The engine behind 5792's `atomic: ready` for plain EOAs. Don't implement it; let the wallet drive. Address unchanged → attester intact. | +| **ERC-4337** Account abstraction | Final (EntryPoint v0.7/0.8) | **IGNORE** | If the user has a 4337 account, `wallet_sendCalls` abstracts it — don't hand-roll UserOps/bundlers. | +| **EIP-7677** Paymaster web service | Draft | **SEAM** | A 5792 capability (`paymasterService`) for gasless writes later. | +| **EIP-7715** `wallet_grantPermissions` / session keys | Draft | **WATCH** | Future signature-free repeated writes. Fast-moving. | +| **ERC-7579 / ERC-6900** Modular smart accounts | mixed | **IGNORE** | Account-implementation altitude, below an RPC client. | + +**Write-path rule:** `wallet_sendCalls` primary (gated on `getCapabilities` → `atomic`), sequential `eth_sendTransaction` fallback (viem `experimental_fallback`). Only set `atomicRequired` when capabilities confirm it; **otherwise design for partial writes** (content-addressed chunks + a final commit attestation). + +## Signatures & verification + +| Standard | Status | SDK | Note | +|---|---|---|---| +| **EIP-712** Typed structured data | Final | **ADOPT** | Every EAS delegated request. Pin the domain exactly: `verifyingContract` = the EAS/proxy address, correct `chainId`, EAS's own `name`/`version`. Domain mismatch is failure mode #1. | +| **EIP-1271** Contract-wallet `isValidSignature` | Final | **ADOPT** (verify) | Never `ecrecover` off-chain — route through a 1271-aware path. | +| **ERC-6492** Counterfactual-wallet signatures | Final | **ADOPT** | Coinbase Smart Wallet emits these. **viem's `verifyTypedData`/`verifyMessage` *Actions* handle 1271 + 6492 (+ 8010) automatically** — use those, never the EOA-only util. | +| **ERC-2098** Compact (64-byte) signatures | Final | **SEAM** | Accept both 64- and 65-byte forms in the verify/normalize seam. | +| **EIP-7739** Nested/readable typed sigs (cross-account replay) | Draft | **WATCH** | Real but not adoption-critical; viem ships it experimental. Don't emit wrappers yet; don't block it. | + +**Replay defenses to implement regardless:** per-EAS `nonce`, `deadline`, and domain-bound `chainId` + `verifyingContract` (kills cross-chain + cross-contract replay). + +## Identity & naming (ENS) + +| Standard | Status | SDK | Note | +|---|---|---|---| +| **ENSIP-15** Name normalization | Final | **ADOPT** | `normalize()` every input before resolving (viem). Skipping it = wrong namehash + spoof risk. | +| **ENSIP-10** Wildcard resolution + **Universal Resolver (ENSIP-23)** | Final | **ADOPT** | The single resolution entrypoint (viem `getEnsAddress`/`getEnsName`/`getEnsText`). Never hand-roll registry traversal. | +| **EIP-3668** CCIP-Read (offchain lookup) | Final | **ADOPT** (ENS) / **SEAM** (general) | Keep enabled so L2/offchain ENS names resolve; allow `gatewayUrls` override. The same seam serves our future offchain "key-sets." | +| **ENSIP-5/12/18** Text records (avatar/url/socials) | Final | **ADOPT** (lazy read) | Curated allowlist; namespace EFS keys as `xyz.efs.*`. | +| **ENSIP-19** Multichain primary names (reverse) | Final (L2 rollout 2025) | **WATCH→ADOPT** | `getEnsName` (forward-verified); thread coinType for L2 primaries as viem stabilizes. | +| **ENSv2 / Namechain** | alpha; now **L1, not an L2** (Feb 2026 reversal) | **WATCH** | Universal Resolver seam absorbs the registry swap. The one fast-mover to recheck before GA. | + +## Content addressing, storage & web3 URLs + +| Standard | Status | SDK | Note | +|---|---|---|---| +| **ERC-4804** `web3://` URL → EVM call | Final | **ADOPT** | Base scheme for MIRROR URIs. | +| **ERC-6860** web3:// clarification | Draft (de-facto live) | **ADOPT** | Implement to 6860 semantics. Fast-moving. | +| **ERC-6944** ERC-5219 resolve mode (`resolveMode()=="5219"`) | Draft | **ADOPT** | EFS's on-chain content path: decode `(uint16 status, string body, KeyValue[] headers)`. **No library does web3:// — our mirror layer owns it.** | +| **ERC-6821** ENS → contract for web3:// (TEXT record, not `contenthash`) | Draft | **SEAM** | Don't assume `contenthash` for web3:// ENS. | +| **ERC-7774** Cache invalidation (ETag) in 5219 | early | **WATCH** | When we cache on-chain fetches. | +| **SSTORE2** On-chain byte storage | pattern (not an EIP) | **ADOPT** | Chunked across data-contracts (24 KB code-size cap); concatenation/ordering is EFS-level. | +| **EIP-4844** Blobs | Final | **AVOID** (for files) | Pruned ~18 days — DA primitive, not durable storage. | +| **data: URIs** (RFC 2397) | Final | **SEAM** | A fetch scheme for tiny inline content. | +| **CID / multihash** (IPFS) | living (multiformats) | locator-only | **CID ≠ `sha256(file bytes)`** for multi-chunk files (Merkle-DAG root). Verify on our own `contentHash`; treat CIDs as locators (ADR-0006). | + +## Chain / address interop & errors + +| Standard | Status | SDK | Note | +|---|---|---|---| +| **EIP-155** chainId | Final | **ADOPT** | Registry key (canonical, viem-native). | +| **CAIP-2 / CAIP-10 / CAIP-19** chain/account/asset IDs | Stable (CASA) | **ADOPT** (boundary) / **SEAM** | De-facto "which chain + which account" (`eip155:1:0x…`). Speak it at the boundary behind a `chainId↔CAIP` seam — also the on-ramp for non-EVM + ERC-7930. | +| **ERC-3770** chain-prefixed addresses (`eth:0x…`) | Stagnant | **WATCH** | Accept-on-input only; not a storage format. | +| **ERC-7930 / ERC-7828** Interoperable addresses | Review (2025) | **WATCH** | Strategic successor; behind the address seam. Recheck quarterly. | +| **EIP-3085/3326** add/switch chain (`4902`) | Final/Review | **SEAM** | Only in the wallet-facing path (viem `addChain`/`switchChain`). | +| **Solidity revert decoding** (`Error(string)`, `Panic(uint256)`, custom selectors) | spec | **ADOPT** | Build the error model as a **classifier over viem's `BaseError` tree** — `walk()` → `ContractFunctionRevertedError`, decode with the EAS ABI. Don't reimplement. | + +## Attestation, metadata, encoding & conventions + +| Standard | Status | SDK | Note | +|---|---|---|---| +| **EAS** (SchemaRegistry + EAS, EIP-712-backed) | de-facto standard (not an ERC) | **ADOPT (substrate)** | Ride EAS directly; no ratified attestation ERC exists or is imminent. Keep an EAS-resolution seam. `expirationTime`/`revocable`/`refUID` are first-class — use them, don't invent parallels. | +| **Solidity ABI** (`abi.encode`, not `encodePacked` for hashed/signed data) | spec | **ADOPT** | SchemaEncoder uses typed `abi.encode` (length-prefixed, collision-safe). | +| **EIP-1559** fee fields (`maxFeePerGas`/`maxPriorityFeePerGas`) | Final | **ADOPT** | Set via `feeHistory` + buffered estimate; expose a per-batch override seam. Never legacy `gasPrice`. | +| **ERC-7572** `contractURI()` contract-level metadata | canonical | **SEAM** | A self-description shape for EFS folders/lenses/lists. | +| **ERC-8048 / ERC-8049** Onchain key-value metadata + indexed events | Draft | **WATCH** | The standards most likely to converge with EFS **properties** — design properties expressibly through this shape. | +| **Token Lists** (tokenlists.org) | community standard | **ADOPT (pattern)** | Versioned, identity-hosted JSON — the template for EFS **lists** serialization. | +| **ERC-7208 / 7813** Onchain data containers / tables | Draft/near-Final | **WATCH** | Architecturally parallel to EFS's attestation-rows model. | +| **ERC-7512 / 5851 / 8273** attestation/credential/agentic | Draft | **IGNORE/WATCH** | Off-target (audits/credentials/agents), not file attestations. | + +## What this changes in the foundation (actionable) + +1. **Provider boundary → EIP-1193.** Widen `EfsReader`/`EfsWriter` to accept a minimal EIP-1193 `{ request }` provider (or a viem client), normalized to viem internally via `custom()`. *This is the "depend on the standard, not the library" upgrade — the single highest-leverage durability change.* +2. **Batch path → EIP-5792 primary** (it's now Final): `getCapabilities` → `wallet_sendCalls` (atomic when supported), sequential fallback; design for partial writes. Updates the Q5 design; `WriteMechanism` already includes `eip5792`. +3. **Error model = classifier over viem `BaseError`** decoding `Error`/`Panic`/custom selectors (EAS ABI) + RPC codes (4001/4902). Refines ADR-0007. +4. **Verification = viem verify Actions** (1271/6492/8010) behind one `verifySignature` seam; accept ERC-2098 compact sigs. +5. **Identity = ENSIP-15 normalize → Universal Resolver + CCIP-Read**, all via viem; expose CCIP-Read as a generic offchain-lookup seam (for key-sets). +6. **Mirror layer owns `web3://`** (ERC-4804/6860 + ERC-6944) — no library provides it; our core value-add. Verify content on `contentHash`, CIDs are locators. +7. **CAIP-2/10 seam** at the chain/address boundary; **EIP-1559** fee handling on writes; **ERC-7572-shaped** self-description + **Token-Lists pattern** for lists. + +## WATCH list (re-check ~quarterly) +EIP-7702 wallet support · EIP-5792 capability evolution · EIP-7715 session keys · ERC-7930/7828 interoperable addresses · ENSv2 registry · ERC-8048/8049 onchain metadata · ERC-7774 cache · ERC-7739 nested sigs. + +## AVOID +`web3.js` (archived) · `eth_sign` (blind-signing) · legacy `gasPrice` · `encodePacked` for hashed/signed data · EIP-4844 blobs for durable file storage · hand-rolled ERC-4337/UserOps · raw `ecrecover` for signature verification. From 1ca8d9eb01224a1e0eeadb880c538a1eb95c3d48 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 00:55:15 -0500 Subject: [PATCH 25/47] fix(solidity): exports map exposes the documented src/*.sol import (Codex P2) The `./contracts/*` -> `./src/*.sol` pattern resolved `contracts/EFSWriter.sol` to `./src/EFSWriter.sol.sol` (double extension) and blocked the documented `@efs/solidity/src/*.sol` path under exports-honoring resolvers. Changed to `./src/*.sol` -> `./src/*.sol`, matching the README's import path. Co-authored-by: Claude Opus 4.8 --- packages/solidity/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solidity/package.json b/packages/solidity/package.json index 3b831f7..0e770ff 100644 --- a/packages/solidity/package.json +++ b/packages/solidity/package.json @@ -11,7 +11,7 @@ "files": ["src/**/*.sol", "README.md"], "publishConfig": { "access": "public", "provenance": true }, "exports": { - "./contracts/*": "./src/*.sol", + "./src/*.sol": "./src/*.sol", "./package.json": "./package.json" }, "keywords": ["ethereum", "efs", "eas", "solidity", "filesystem"], From 3443be823c76b8abb8f09fb5978baa6db32a46fb Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 01:02:58 -0500 Subject: [PATCH 26/47] feat(sdk): EIP-1193 provider boundary (standard at the edge, viem inside) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the standards research (docs/specs/standards.md): the SDK's public boundary is now the durable standard, not a library. - createEfsClient accepts the standard form `{ provider: EIP1193Provider, chain, account? }` and wraps it with viem's custom() transport internally; viem clients `{ publicClient, walletClient? }` stay as a convenience form. Both normalize to viem inside. Write-gating preserved (account/walletClient presence → write client). - Any wallet works (all are EIP-1193 providers); swapping/adding a client library never breaks a consumer. Dropped the EfsReader/EfsWriter placeholder aliases. - Folded the standards into the ADRs: ADR-0009 (boundary implemented), ADR-0008 (instantiation), ADR-0007 (error model = classifier over viem BaseError + RPC codes 4001/4100/4902). README quickstart leads with the provider form. tsc/test(28)/build/lint/attw all green. Co-authored-by: Claude Opus 4.8 --- docs/adr/0007-error-model.md | 4 ++ docs/adr/0008-public-api-and-semver.md | 7 ++- docs/adr/0009-library-agnostic-seam.md | 8 +-- packages/sdk/README.md | 10 +-- packages/sdk/src/index.ts | 84 ++++++++++++++++++++------ packages/sdk/test/index.test.ts | 28 ++++++++- 6 files changed, 110 insertions(+), 31 deletions(-) diff --git a/docs/adr/0007-error-model.md b/docs/adr/0007-error-model.md index a3195ea..a2138ec 100644 --- a/docs/adr/0007-error-model.md +++ b/docs/adr/0007-error-model.md @@ -19,6 +19,10 @@ External callers need to handle SDK failures programmatically — distinguish a Subclasses set a distinct `name` + `code`: `NotImplemented`, `WalletRequired`, `LensRequired`, `MaxLensesExceeded`, `SchemaMismatchError`, `DeploymentNotFound`, `CursorInvalid`, `PartialBatchFailure`. New failure modes (transport, mirror-scheme-rejected, list-constraint, partial-batch detail) are added as subclasses + codes over time — additive, never breaking, because callers catch `EfsError` and/or switch on the open `code`. +## Realization (standards research, `docs/specs/standards.md`) + +The model is a **classifier over viem's `BaseError` tree**, not a reimplementation: viem already decodes Solidity reverts (`Error(string)` `0x08c379a0`, `Panic(uint256)` `0x4e487b71`, and custom errors by 4-byte selector given an ABI). The SDK passes the **EAS ABI** so custom errors decode, `walk()`s to the underlying `ContractFunctionRevertedError`, and maps **EIP-1193/1474 RPC codes** — notably `4001` (user-rejected — surface as benign, not a failure), `4100` (unauthorized), `4902` (chain-not-added) — onto `EfsError` subclasses/codes. + ## Consequences - Callers can `catch (e) { if (e instanceof EfsError) switch (e.code) … }` or `e.walk(x => x instanceof ContractFunctionRevertedError)` for the underlying revert. diff --git a/docs/adr/0008-public-api-and-semver.md b/docs/adr/0008-public-api-and-semver.md index f053c77..8c03df7 100644 --- a/docs/adr/0008-public-api-and-semver.md +++ b/docs/adr/0008-public-api-and-semver.md @@ -10,14 +10,17 @@ The SDK's public surface is the one near-permanent thing it has (breaking it is ## Decision -**Instantiation — a factory taking injected viem clients (the scaffold's shape wins; the doc is updated to match).** +**Instantiation — a factory whose boundary is the standard (an EIP-1193 provider + EIP-155 chain); viem is the engine inside (ADR-0009).** ```ts +// Standard form (durable, library-neutral; any wallet is an EIP-1193 provider): +const efs = createEfsClient({ provider, chain, account?, deployments?, defaultLens? }) +// Convenience form (viem-native callers): const efs = createEfsClient({ publicClient, walletClient?, deployments?, defaultLens? }) ``` - A **factory function**, not `new EFSClient` (viem/wagmi never expose `new PublicClient`). -- Consumers inject built **viem clients** (`publicClient`/`walletClient`) — referenced through the `EfsReader`/`EfsWriter` aliases (ADR-0009) — not raw `rpc`+`chainId` strings, so they bring their own transports/fallbacks/chains and any EIP-1193 wallet. +- The public contract is the **EIP-1193 `request` interface** (`docs/specs/standards.md`), not viem's concrete types; the SDK wraps the provider with viem's `custom()` transport internally. This is the "depend on the standard, not the library" boundary. **Resource-namespaced surface** (Decision F): `efs.fs` (files) · `efs.lenses` · `efs.eas` (viem-native) · `efs.raw` (deployment escape hatch). The full design's `graph`/`props`/`lists`/`sorts` namespaces are **additive** (a new top-level namespace is non-breaking) and land in a dedicated pass. diff --git a/docs/adr/0009-library-agnostic-seam.md b/docs/adr/0009-library-agnostic-seam.md index 3d6d676..e9d02c2 100644 --- a/docs/adr/0009-library-agnostic-seam.md +++ b/docs/adr/0009-library-agnostic-seam.md @@ -15,12 +15,12 @@ ADR-0002 made the SDK viem-native (vendored EAS ABIs, no ethers, no eas-sdk). Th ## Decision -**Keep the core viem-native (ADR-0002 stands). Make ethers a non-breaking future addition via two cheap seams added now:** +**The public boundary is the standard — an EIP-1193 provider + EIP-155 chain — and viem is the engine *inside* (ADR-0002 stands). This is implemented, not just seamed:** -1. **Alias the client types.** The public config references `EfsReader` / `EfsWriter` (today `= PublicClient` / `WalletClient`), not raw viem types. Later these widen to a union (`PublicClient | EfsReaderAdapter`) — a **non-breaking** change. -2. **Reserve `@efs/sdk/ethers`** as the future optional adapter entry: `fromEthersSigner()` / `fromEthersProvider()` produce an `EfsReader`/`EfsWriter`, with `ethers` as an optional peerDependency **only there** — never in the core dep tree. +1. **`createEfsClient` accepts the standard form `{ provider: EIP1193Provider, chain, account? }`.** Internally the SDK wraps the provider with viem's `custom()` transport and builds the viem clients it uses. The public contract is the EIP-1193 `request` interface (the durable standard, see `docs/specs/standards.md`), not viem's concrete types. A second **convenience form** `{ publicClient, walletClient? }` is accepted for viem-native callers; both normalize to viem internally. +2. **Reserve `@efs/sdk/ethers`** as the future optional adapter entry (`fromEthersSigner`/`fromEthersProvider` → a provider), with `ethers` as an optional peerDependency **only there** — never in the core dep tree. -This refines ADR-0002 ("viem-only") to **"viem-core, ethers-extensible"** — without weakening its load-bearing point (no eas-sdk/ethers dependency in core). +This refines ADR-0002 ("viem-only") to **"standard boundary, viem engine, library-extensible."** Because the boundary is EIP-1193, **every wallet already works** (MetaMask/WalletConnect/Coinbase/hardware/embedded are all EIP-1193 providers), and swapping or adding a client library never breaks a consumer. ## Consequences diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 06ed8fe..ef525dc 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -14,17 +14,19 @@ npm i @efs/sdk viem ## Quickstart (target API) -The client is resource-namespaced (`efs.fs.*` for files, `efs.lenses.*`, `efs.eas.*`, `efs.raw.*`): +The client is resource-namespaced (`efs.fs.*` for files, `efs.lenses.*`, `efs.eas.*`, `efs.raw.*`). Its boundary is the **standard** — an [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) provider + chain — so any wallet works; viem is the engine inside ([standards](../../docs/specs/standards.md)). ```ts import { createEfsClient, identity } from '@efs/sdk' import { sepolia } from 'viem/chains' -import { createPublicClient, http } from 'viem' +// Standard form — pass any EIP-1193 provider (window.ethereum, WalletConnect, …): const efs = createEfsClient({ - publicClient: createPublicClient({ chain: sepolia, transport: http() }), - walletClient, // required for writes + provider: window.ethereum, // any EIP-1193 provider + chain: sepolia, + account, // the signing address; omit for a read-only client }) +// (viem-native callers can pass `{ publicClient, walletClient }` instead.) // Resolve "the file at /logo" through an identity (ENS → key-set → lens). // `read` returns a reference + who resolved it; `fetch` gets the bytes (verified). diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b0db2bc..b946f93 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -13,7 +13,17 @@ * are the ones that would force a breaking change, so they exist now. */ -import type { Address, PublicClient, WalletClient } from 'viem' +import { + type Account, + type Address, + type Chain, + type EIP1193Provider, + type PublicClient, + type WalletClient, + createPublicClient, + createWalletClient, + custom, +} from 'viem' import { type DeploymentsMap, type EfsDeployment, @@ -45,27 +55,58 @@ import type { } from './types.js' /** - * The read/write clients the SDK consumes. Aliased (not raw viem types in the - * public config) so we can later widen them to accept an ethers adapter without - * a breaking change — the seam for staying library-agnostic. The SDK core stays - * viem-native (ADR-0002); ethers interop ships later as an optional `@efs/sdk/ethers` - * adapter that produces an `EfsReader`/`EfsWriter`. Any wallet (MetaMask, WalletConnect, - * Coinbase, hardware, embedded) already works today: viem wraps any EIP-1193 provider. + * The SDK's boundary is the **standard** (EIP-1193 provider + EIP-155 chain), not + * a library (ADR-0009 / docs/specs/standards.md). viem is the engine *inside* — + * we wrap the provider with viem's `custom()` transport. Any wallet (MetaMask, + * WalletConnect, Coinbase, hardware, embedded) is an EIP-1193 provider, so all of + * them work; a future ethers/other adapter just produces a provider, no break. */ -export type EfsReader = PublicClient -export type EfsWriter = WalletClient - -export type EfsClientConfig = { - publicClient: EfsReader - /** Required for writes; reads work without it. Presence gates write methods - * at the type level (see `createEfsClient` overloads). */ - walletClient?: EfsWriter + +/** Shared config. */ +type CommonConfig = { /** Override the built-in registry to point at a custom/local deployment. */ deployments?: DeploymentsMap - /** Default lens when a read passes none (resolves to the connected wallet). */ + /** Default lens when a read passes none (resolves to the connected account). */ defaultLens?: Lens } +/** Standard form: an EIP-1193 provider + the chain. Pass an `account` to enable writes. */ +export type ProviderConfig = CommonConfig & { + /** Any EIP-1193 provider — `window.ethereum`, a WalletConnect session, a viem + * client's transport, etc. The durable, library-neutral input. */ + provider: EIP1193Provider + /** The chain (EIP-155) the provider talks to. */ + chain: Chain + /** The signing account for writes; omit for a read-only client. */ + account?: Address | Account +} + +/** Convenience form: pre-configured viem clients (for viem-native callers). */ +export type ViemConfig = CommonConfig & { + publicClient: PublicClient + /** Required for writes; presence gates write methods at the type level. */ + walletClient?: WalletClient +} + +export type EfsClientConfig = ProviderConfig | ViemConfig + +/** Normalize either config form to the viem clients the SDK uses internally. */ +function resolveClients(config: EfsClientConfig): { + publicClient: PublicClient + walletClient: WalletClient | undefined +} { + if ('provider' in config) { + const transport = custom(config.provider) + const publicClient = createPublicClient({ chain: config.chain, transport }) + const walletClient = + config.account !== undefined + ? createWalletClient({ chain: config.chain, account: config.account, transport }) + : undefined + return { publicClient, walletClient } + } + return { publicClient: config.publicClient, walletClient: config.walletClient } +} + /** Read-only file operations. */ export type EfsFsRead = { read(path: string, opts?: ReadOptions): Promise @@ -123,12 +164,15 @@ function chainIdOf(publicClient: PublicClient): number { return id } -// Type-level write gate: a `walletClient` in the config widens the return to the -// write-capable `EfsClient`; without it, you get `EfsReadClient` (no write verbs). -export function createEfsClient(config: EfsClientConfig & { walletClient: WalletClient }): EfsClient +// Type-level write gate: a write-capable config (an `account` in the provider form, +// or a `walletClient` in the viem form) widens the return to `EfsClient`; otherwise +// you get `EfsReadClient` (no write verbs). +export function createEfsClient(config: ProviderConfig & { account: Address | Account }): EfsClient +export function createEfsClient(config: ViemConfig & { walletClient: WalletClient }): EfsClient export function createEfsClient(config: EfsClientConfig): EfsReadClient export function createEfsClient(config: EfsClientConfig): EfsClient { - const { publicClient, walletClient, deployments: override } = config + const { publicClient, walletClient } = resolveClients(config) + const override = config.deployments const getDeployment = () => resolveDeployment(chainIdOf(publicClient), override) const requireWallet = () => { if (!walletClient) throw new WalletRequired() diff --git a/packages/sdk/test/index.test.ts b/packages/sdk/test/index.test.ts index 3c0f0fc..f640462 100644 --- a/packages/sdk/test/index.test.ts +++ b/packages/sdk/test/index.test.ts @@ -1,4 +1,11 @@ -import { http, type Address, type WalletClient, createPublicClient, createWalletClient } from 'viem' +import { + http, + type Address, + type EIP1193Provider, + type WalletClient, + createPublicClient, + createWalletClient, +} from 'viem' import { sepolia } from 'viem/chains' import { describe, expect, it } from 'vitest' import { @@ -42,6 +49,25 @@ describe('namespaced client (Decision F)', () => { expect(typeof efs.lenses.lens).toBe('function') expect(typeof efs.lenses.identity).toBe('function') }) + + it('accepts the EIP-1193 provider form (standard boundary), wallet-gated by `account`', async () => { + // A minimal EIP-1193 provider — the durable, library-neutral input. + const provider = { + request: async () => { + throw new Error('mock') + }, + on: () => {}, + removeListener: () => {}, + } as unknown as EIP1193Provider + + // No account → read-only client; read verb still resolves to NotImplemented. + const ro = createEfsClient({ provider, chain: sepolia }) + await expect(ro.fs.read('/x')).rejects.toThrow(NotImplemented) + + // With an account → write-capable; write resolves to NotImplemented (not WalletRequired). + const rw = createEfsClient({ provider, chain: sepolia, account: addr(1) }) + await expect(rw.fs.write('/x', new Uint8Array())).rejects.toThrow(NotImplemented) + }) }) describe('lenses', () => { From 69239b16a8029c1e60c6bc2f98aa65a395b2f33b Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 01:15:22 -0500 Subject: [PATCH 27/47] fix(solidity): _efsPinFile locks the emit happy-path shape (Codex P2) The wrapper documented emitting EfsFilePinned but the stub just returned the UID, so inheritors of the happy-path base would miss every pin once pinFile is implemented. Restored capture -> emit -> return. EFSLib.pinFile still reverts so the emit is unreachable today (harmless warning), but the correct shape is now locked for the implementation. Co-authored-by: Claude Opus 4.8 --- packages/solidity/src/EFSWriter.sol | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/solidity/src/EFSWriter.sol b/packages/solidity/src/EFSWriter.sol index 7d3f4bc..aa6cfb1 100644 --- a/packages/solidity/src/EFSWriter.sol +++ b/packages/solidity/src/EFSWriter.sol @@ -19,8 +19,12 @@ abstract contract EFSWriter { return EFSLib.read(path); } - /// @notice Pin a file at `path`. Once implemented, emits {EfsFilePinned}. + /// @notice Pin a file at `path`; emits {EfsFilePinned} for domain consumers. + /// @dev {EFSLib.pinFile} reverts until implemented, so the emit is unreachable + /// for now — but this locks the happy-path shape (capture, emit, return) + /// so inheritors get the event without writing their own wrapper. function _efsPinFile(string memory path, bytes32 dataUID) internal returns (bytes32 pinUID) { - return EFSLib.pinFile(path, dataUID); + pinUID = EFSLib.pinFile(path, dataUID); + emit EfsFilePinned(path, dataUID, pinUID); } } From 3e1233e52a5691db2c43568feaa6e78a782ac7ec Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 01:18:28 -0500 Subject: [PATCH 28/47] docs: future-proofing doctrine (9-domain research pass 2) Adds docs/specs/future-proofing.md synthesizing a second research sweep beyond the core EIP table: SDK engineering (wevm stack), storage/durability, indexing & history-expiry, security/clear-signing, gas/tx-lifecycle, L2/interop, key- management, the Ethereum roadmap, and metadata. Three load-bearing constraints: (1) history expires (EIP-4444) -> reads are index-first; (2) calldata costs/caps (EIP-7623/7825) -> chunked, hash-on-chain-bytes-off-chain writes; (3) a hash is integrity not availability -> pair fast+permanent mirrors. Extends standards.md with the new EIPs/ERCs (7730/7623/7825/4444/7745/Multicall3/RIP-7212/etc.). Co-authored-by: Claude Opus 4.8 --- docs/specs/README.md | 1 + docs/specs/future-proofing.md | 88 +++++++++++++++++++++++++++++++++++ docs/specs/standards.md | 17 +++++++ 3 files changed, 106 insertions(+) create mode 100644 docs/specs/future-proofing.md diff --git a/docs/specs/README.md b/docs/specs/README.md index f68a279..61e1f54 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -21,6 +21,7 @@ Rules of thumb: - [overview.md](./overview.md) — the SDK at a glance: the two packages and the core model. - [standards.md](./standards.md) — the EIPs/ERCs/CAIPs the SDK is built on (ADOPT/SEAM/WATCH/AVOID), researched 2026-06-11. +- [future-proofing.md](./future-proofing.md) — engineering doctrine + roadmap risk (history expiry, gas/calldata, durability, indexing, security/clear-signing, key-sets), 9-domain pass. - [content-hash.md](./content-hash.md) — the bare-SHA-256 `contentHash` convention. _More specs land as the implementation does (reads, writes/batching, lenses/identity, errors)._ diff --git a/docs/specs/future-proofing.md b/docs/specs/future-proofing.md new file mode 100644 index 0000000..5211431 --- /dev/null +++ b/docs/specs/future-proofing.md @@ -0,0 +1,88 @@ +# Future-proofing & engineering doctrine + +> Synthesis of a 9-domain research pass (2026-06-11) beyond the core EIP/ERC table in [standards.md](./standards.md): SDK engineering, storage/durability, indexing & history-expiry, security/clear-signing, gas & tx-lifecycle, L2/interop, key-management, the Ethereum roadmap, and metadata. Tags: **ADOPT** · **SEAM** · **WATCH** · **AVOID**. This is the doctrine the `fs.*`/read/write modules are built against. + +## The three load-bearing constraints (design around these) + +1. **History expires (EIP-4444).** Partial history expiry shipped July 2025; rolling ~1-year expiry is the roadmap. `eth_getLogs` over deep history is officially unreliable. **→ Reads are index-first.** Resolve via *current state* (`getAttestation(uid)`, SSTORE2 `eth_call`) and an indexer; never replay genesis logs at runtime. Live attestation/content reads are unaffected — only history is. +2. **Calldata gets costly and capped.** EIP-7623 (live, Pectra) makes calldata-heavy writes ~2.5× pricier; EIP-7825 (live, Fusaka Dec 2025) caps a single tx at ~16.7M gas. **→ Writes chunk under the cap; "hash-on-chain, bytes-off-chain" is the default**, full on-chain bytes opt-in. +3. **A hash proves integrity, not availability.** Content addressing is tamper-evidence, not durability. **→ Pair a fast mirror with a permanent one; verify every fetched byte against `contentHash`.** + +## 1. SDK engineering — ADOPT the wevm stack + +- **ABI codegen:** `@wagmi/cli` (foundry plugin) → generated typed bindings + the per-chain **`@efs/sdk/deployments`** map from contract artifacts. *Cheap, directly needed.* +- **Testing:** **vitest + prool** (the wevm Anvil manager; `@viem/anvil` is deprecated) against a **fork** of the chain hosting EAS, + a thin mock-provider tier for the EIP-1193 boundary. +- **Docs:** vocs guide + typedoc reference + a 5-minute quickstart. +- **Versioning:** keep Changesets; add a viem-style **`@efs/sdk/experimental`** entrypoint + `@deprecated` JSDoc discipline. +- **Bundle:** `size-limit` budgets in CI; `sideEffects:false`; ESM-first; Knip for dead exports. (`attw`+`publint` already in CI.) +- **No telemetry** (viem/wagmi don't phone home). +- **Framework:** reserve `@efs/react` (thin TanStack-Query wrapper) as a package boundary; build on demand. + +## 2. Storage & durability — the mirror doctrine + +- **Verify before trust, always** — hash full fetched bytes against `contentHash`, reject on mismatch. This is the whole security model and sidesteps CID≠sha256. +- **Multi-gateway, multi-mirror fallback** with per-attempt timeouts — **public IPFS gateways are being deprecated (2025)**; assume any single gateway is rate-limited, dead, or hostile. +- **Durability = pair a fast mirror** (IPFS pin via Storacha/Pinata/Filebase, or Filecoin-warm) **with a permanent one** (**Arweave** `ar://`). Surface a "durability = count of reachable, hash-verified mirrors" signal. Document that a hash alone is *not* a durability promise. +- **Transports:** `ipfs://`/`ar://`/`https://` ADOPT; `web3://` we own (standards.md); `walrus://`/EthStorage WATCH (seam-only); Swarm/Greenfield AVOID. +- **Content-type is adversarial** — `nosniff`, derive type ourselves, sandbox HTML/SVG; size limits + streaming hash; a range/partial read **cannot** be verified against a whole-file hash. + +## 3. Indexing & read-scaling — index-first (see constraint #1) + +- **`EfsIndexProvider` interface** (`whoTagged`, `listWriters`, `versionsOf`) — the SDK bundles **no** indexer (confirms the design). Ship an **`EasGraphQLProvider` default** over EAS's per-chain GraphQL endpoints (instant working reverse-lookups), swappable for a self-hosted subgraph/Envio/Ponder. +- **`eth_getLogs` is bounded-only** — chunked, recent-range, or one-time backfill; never the reverse-lookup backbone. +- **Multicall3** (`0xcA11…CA11`) for batched point-reads ("resolve N attestations"). +- **Future:** shape the interface so **EIP-7745 verifiable logs** (Draft, Glamsterdam) can back it later — today's centralized-index pragmatism upgrades to trustless reads without an API break. +- **Event/indexability** lives in how we populate EAS's indexed `attester`/`schema`/`refUID` topics, not custom events. + +## 4. Security & clear-signing + +- **ERC-7730 Clear Signing** (launched May 2026; Ledger/Trezor/MetaMask/WalletConnect) — **ADOPT: ship + maintain 7730 descriptors** for EFS's `attest`/`multiAttest` so users see path/contentHash/lens in plain text instead of blind-signing a hash. PR them to the clear-signing registry. +- **Never request `eth_sign`** (the Bybit-hack mechanism); EIP-712 typed data only. Never blind-sign. +- **Supply-chain:** keep OIDC Trusted Publishing + provenance; **pin exact dependency versions** (no `^`), commit lockfile, **minimize deps** (every dep is attack surface — the biggest lever for a wallet-adjacent SDK), `--ignore-scripts` in CI, `pnpm audit`/Socket. +- **Deployments registry is a trust root** — beyond the bytecode/UID check: provenance-attest + sign the generated registry, verify at load, make codegen reproducible. The only path to an address is generation+verification (no hand-edited JSON bypass). +- **Untrusted content is inert** — never execute fetched bytes, SSRF-guard mirror URLs (block private/loopback/metadata IPs), size/timeout limits, lens-scoped mirror filtering. +- **MEV/front-running** — path claims are front-runnable; **WATCH**, warn, and push commit-reveal at the contract layer if path-ownership lands (the SDK can't fix this alone). + +## 5. Gas & transaction lifecycle — durable shapes + +- **EIP-5792 `getCallsStatus`** numeric status: `100` pending, `200` confirmed, `400` offchain-failed, `500` reverted, **`600` partial** — the dangerous one: a half-written file. Treat `600` as first-class with **resume/repair**, not a generic error. +- **`WriteEstimate`** should carry: per-call + total `gasUnits` (buffered), `chunkDeploys` (count+bytes), **`tokensInCalldata`** (EIP-7623 transparency), `maxFeePerGas`/`maxPriorityFeePerGas`, **`l1DataFee`** (L2 only — dominates, >90%; an L2-blind estimate under-reports by 10×), and `usd` as a **range** with `priceSource`+`asOf` (never a bare scalar; price provider is injected, Chainlink optional with staleness checks). +- **Nonce:** delegate to the wallet on the 5792 path; own gap-free sequential nonces + bump-retry only in the sequential fallback. +- **Finality:** expose a `confirmations` threshold; surface reorg via replacement reasons; own the poll cadence (5792 status retention is only 24h). + +## 6. L2 / multi-chain / interop + +- **Cross-chain reads** (read EFS on chain X while connected to Y) — **ADOPT now**: hold a read-only provider per supported chain, keyed by `chainId`, decoupled from the wallet chain. Highest-value multi-chain seam. +- **Per-chain address book is mandatory** — **EAS addresses differ per chain** (OP/Base share a predeploy; mainnet/Arbitrum don't). Ship `chainId → {efs, eas, schemaRegistry, schemaUIDs, transport}` as typed data. +- **CREATE2/CREATE3** deploy of EFS's *own* contracts → same address everywhere (collapses our half of the book). +- **Abstract the signer** so AA/EIP-7702/RIP-7560/intents drop in without touching resolution. +- **EIL / Superchain / ERC-7683 / ERC-7802** — **WATCH/AVOID**: they abstract *assets*, not *where data lives*; they won't relocate an EFS file. Don't architect around them. + +## 7. Key management & the EFS-native key-set + +- **Passkeys (RIP-7212) + MPC/embedded wallets are already covered** by our 1271/6492 verify path — the attester is the stable wallet address; key-share rotation doesn't change it. No new work. +- **No wallet standard does "many addresses → one identity."** Sub-accounts (ERC-7895) and session keys (ERC-7715) each get a *distinct* attester — the inverse of content attribution. So the **multi-device key-set is EFS-native**: a primary identity *attests* "these addresses are me" (the `webOfTrust` lens tier), *consuming* wallet hierarchies rather than replacing them. +- **Design in revocation/time-bounding** — "this device key is no longer me as of block N" — so a compromised device can't retroactively poison content. No standard gives this. +- **Prefer stable smart-account addresses** over bare EOAs (EOA key loss = identity loss; smart-account recovery rotates the signer, not the address → attestations survive). + +## 8. Metadata & representation + +- **`contentType` = IANA media type** (+ optional `charset`), validated on write; the **attested** value is authoritative, never the transport's `Content-Type` or a file extension. +- **Minimal fixed-shape JSON manifest** per file (`name, description, contentType, size, created, author, contentHash`) — NFT-metadata-familiar, not overloaded. +- **Folders/lenses/lists self-describe** with a **Token-Lists-style envelope** (`name`, semver `version`, `timestamp`, `entries[]`) + ERC-7572 display fields so they render in existing dapp UIs. +- **Property keys: reverse-DNS** (`xyz.efs.*`) for third-party keys; a tiny reserved bare-key core (`contentType`/`contentHash`/`size`). +- **Serve path:** ERC-5219 status/headers + **ERC-7774 ETag/`evm-events` caching**; range as a seam. **JSON-LD/schema.org as an opt-in seam**, not the baseline (overkill). + +## 9. Roadmap watch list (re-check ~quarterly) + +| Item | Timeline | Why we care | +|---|---|---| +| **Rolling history expiry (EIP-4444)** | active, ~1yr rollout | **Highest risk** — index-first reads (constraint #1). | +| **Glamsterdam** (ePBS, BALs, **EIP-7904 gas repricing**) | H1 2026 | Re-benchmark storage/write costs after it lands. | +| **EIP-7745/7792 verifiable logs** | Draft, Glamsterdam | The trustless upgrade path for our index provider. | +| **State expiry** | research, years out | The one item that could touch *live* attestation reads — standing watch. | +| **ERC-7730 V2**, ERC-7572/7774/7895/7715 | Draft/moving | Adopt shapes now, treat wire formats as moving (SEAM). | +| **Native AA (RIP-7560)** | L2, no L1 date | Keep the signer abstracted. | + +## AVOID +`eth_sign`/blind-signing · `web3.js` · `^`-ranged deps for a wallet SDK · `SELFDESTRUCT`+CREATE2 redeploy tricks (EIP-6780) · self-rolled MPT storage proofs (state-tree migration coming) · EIP-4844 blobs for durable content · genesis-to-head `getLogs` at runtime · depending on specific opcode/precompile gas costs (repricing forks). diff --git a/docs/specs/standards.md b/docs/specs/standards.md index 0b6f9db..739d640 100644 --- a/docs/specs/standards.md +++ b/docs/specs/standards.md @@ -105,3 +105,20 @@ EIP-7702 wallet support · EIP-5792 capability evolution · EIP-7715 session key ## AVOID `web3.js` (archived) · `eth_sign` (blind-signing) · legacy `gasPrice` · `encodePacked` for hashed/signed data · EIP-4844 blobs for durable file storage · hand-rolled ERC-4337/UserOps · raw `ecrecover` for signature verification. + +## Adjacent standards (2026-06-11 pass 2 — full doctrine in [future-proofing.md](./future-proofing.md)) + +| Standard | Status | SDK | Note | +|---|---|---|---| +| **EIP-4444** History expiry | active (partial shipped) | **design constraint** | `eth_getLogs` over deep history is unreliable → reads are index-first; resolve via current state + an `EfsIndexProvider`. | +| **EIP-7623** Calldata cost floor | Final (Pectra) | **ADOPT** (estimate) | Calldata-heavy writes ~2.5×; surface `tokensInCalldata` in estimates. | +| **EIP-7825** Per-tx gas cap (~16.7M) | Final (Fusaka) | **design constraint** | Chunk SSTORE2/batched writes under the cap. | +| **EIP-5792 `getCallsStatus`** | Final | **ADOPT** | Status `600` = partial write (half-written file) — first-class resume/repair. | +| **EIP-7745 / 7792** Verifiable logs | Draft (Glamsterdam) | **WATCH** | Future trustless backing for the index provider. | +| **Multicall3** (`0xcA11…CA11`) | de-facto | **ADOPT** | Batched point-reads. | +| **ERC-7730** Clear Signing | launched 2026-05 | **ADOPT** | Ship descriptors so EFS writes show path/contentHash/lens, not a blind hash. | +| **RIP-7212** P-256 precompile | finalized | **covered** | Passkey-wallet sigs verify transparently via our 1271/6492 path. | +| **ERC-7895** sub-accounts / **ERC-7715** session keys | Draft | **WATCH/SEAM** | Wallet "one account, many keys" — but each is a distinct attester; the EFS key-set is the inverse and stays EFS-native. | +| **ERC-7683 / 7802 / EIL** interop | mixed | **WATCH/AVOID** | Abstract assets, not where data lives — won't relocate an EFS file. | +| **ERC-7774** ETag caching (over ERC-5219) | Draft | **SEAM** | Cache on-chain content serves. | +| **IANA media types** | RFC 6838 | **ADOPT** | `contentType` is an IANA type; the *attested* value is authoritative, never the transport's. | From 97230fe4370bc799a308a0fd1669f79c6f16fc76 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 01:25:00 -0500 Subject: [PATCH 29/47] docs: recover dropped pass-2 findings (completeness sweep + sim seam) A mid-conversation compaction caused the first synthesis to omit two agents' output. Audited all 10 pass-2 agent transcripts against the doc and recovered: - completeness-sweep agent (the "what are we missing" pass): ride-EAS-directly (no Final attestation ERC; 7512/5851/8273 off-target WATCH), ERC-8048/8049 onchain key-value metadata as the closest PROPERTY mirror, ERC-2098 compact signatures, EAS-native expirationTime/revocable/refUID for revocation, abi. encode-not-encodePacked. New section 9 + standards.md rows. - security agent: transaction-simulation seam (Tenderly/Blockaid -> fs.preview, pluggable, no baked keys). - indexing agent: ship a reference subgraph/Envio/Ponder schema as docs. Co-authored-by: Claude Opus 4.8 --- docs/specs/future-proofing.md | 16 ++++++++++++++-- docs/specs/standards.md | 4 ++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/specs/future-proofing.md b/docs/specs/future-proofing.md index 5211431..1d2f210 100644 --- a/docs/specs/future-proofing.md +++ b/docs/specs/future-proofing.md @@ -33,6 +33,7 @@ - **Multicall3** (`0xcA11…CA11`) for batched point-reads ("resolve N attestations"). - **Future:** shape the interface so **EIP-7745 verifiable logs** (Draft, Glamsterdam) can back it later — today's centralized-index pragmatism upgrades to trustless reads without an API break. - **Event/indexability** lives in how we populate EAS's indexed `attester`/`schema`/`refUID` topics, not custom events. +- **Ship a reference subgraph + Envio/Ponder schema as docs** (not deps) so teams needing self-hosted/decentralized durability have a working starting point. Envio HyperIndex's wildcard indexing fits "index all EAS attestations of schema X" without enumerating addresses. ## 4. Security & clear-signing @@ -42,6 +43,7 @@ - **Deployments registry is a trust root** — beyond the bytecode/UID check: provenance-attest + sign the generated registry, verify at load, make codegen reproducible. The only path to an address is generation+verification (no hand-edited JSON bypass). - **Untrusted content is inert** — never execute fetched bytes, SSRF-guard mirror URLs (block private/loopback/metadata IPs), size/timeout limits, lens-scoped mirror filtering. - **MEV/front-running** — path claims are front-runnable; **WATCH**, warn, and push commit-reveal at the contract layer if path-ownership lands (the SDK can't fix this alone). +- **Transaction simulation is a seam, not a dependency** — `efs.fs.preview` stays pluggable to a Tenderly/Blockaid-style simulation RPC (dry-run + asset-diff + malicious-contract scan), but bakes in **no** paid provider or API key. Simulation reflects current state and can drift at inclusion — never present "simulation passed" as a safety guarantee. ## 5. Gas & transaction lifecycle — durable shapes @@ -62,7 +64,7 @@ - **Passkeys (RIP-7212) + MPC/embedded wallets are already covered** by our 1271/6492 verify path — the attester is the stable wallet address; key-share rotation doesn't change it. No new work. - **No wallet standard does "many addresses → one identity."** Sub-accounts (ERC-7895) and session keys (ERC-7715) each get a *distinct* attester — the inverse of content attribution. So the **multi-device key-set is EFS-native**: a primary identity *attests* "these addresses are me" (the `webOfTrust` lens tier), *consuming* wallet hierarchies rather than replacing them. -- **Design in revocation/time-bounding** — "this device key is no longer me as of block N" — so a compromised device can't retroactively poison content. No standard gives this. +- **Design in revocation/time-bounding** — "this device key is no longer me as of block N" — so a compromised device can't retroactively poison content. No standard gives this — but build it on **EAS-native primitives** (`expirationTime` uint64, `revocable`, `refUID`) rather than inventing parallel concepts. - **Prefer stable smart-account addresses** over bare EOAs (EOA key loss = identity loss; smart-account recovery rotates the signer, not the address → attestations survive). ## 8. Metadata & representation @@ -73,7 +75,17 @@ - **Property keys: reverse-DNS** (`xyz.efs.*`) for third-party keys; a tiny reserved bare-key core (`contentType`/`contentHash`/`size`). - **Serve path:** ERC-5219 status/headers + **ERC-7774 ETag/`evm-events` caching**; range as a seam. **JSON-LD/schema.org as an opt-in seam**, not the baseline (overkill). -## 9. Roadmap watch list (re-check ~quarterly) +## 9. Attestation substrate & encoding (completeness sweep) + +The "what are we missing" sweep over attestation/registry/data standards — confirms we ride the right substrate and flags the emerging ones to track. + +- **Ride EAS directly — there is no Final attestation ERC.** EAS is infrastructure, not a ratified ERC; `AttestationRequestData` (recipient, `expirationTime`, `revocable`, `refUID`, `bytes data`, value) is the contract we encode against. ERC-7512 (audits), ERC-5851 (verifiable credentials), ERC-8273 (agentic actions) are all Draft and off-target. **Keep an EAS-resolution seam** so a future attestation ERC could slot in without client churn, but don't wait for one. +- **EAS-native time/revocation** — use `expirationTime`/`revocable`/`refUID` for mirrors, transports, versioning, and key-set revocation rather than parallel concepts (ties to §7). +- **ERC-8048 / ERC-8049** — onchain key-value metadata with indexed-key events; the **closest emerging mirror of EFS PROPERTYs**. **WATCH closely** — design properties so they're expressible through that shape. (ERC-7208 data-containers / ERC-7813 table-storage are looser parallels — lower-priority WATCH.) +- **ERC-2098 compact signatures (64-byte)** — EAS delegated/offchain paths may hand us compact sigs; the verify/normalize seam should **accept both 64- and 65-byte forms**. +- **`abi.encode`, never `encodePacked`** for anything hashed/signed (collision-safety); **EIP-1559 fee fields** via `feeHistory` + buffered estimate, exposed as a per-batch override seam. (Both already in [standards.md](./standards.md) AVOID/ADOPT.) + +## 10. Roadmap watch list (re-check ~quarterly) | Item | Timeline | Why we care | |---|---|---| diff --git a/docs/specs/standards.md b/docs/specs/standards.md index 739d640..11c45f9 100644 --- a/docs/specs/standards.md +++ b/docs/specs/standards.md @@ -122,3 +122,7 @@ EIP-7702 wallet support · EIP-5792 capability evolution · EIP-7715 session key | **ERC-7683 / 7802 / EIL** interop | mixed | **WATCH/AVOID** | Abstract assets, not where data lives — won't relocate an EFS file. | | **ERC-7774** ETag caching (over ERC-5219) | Draft | **SEAM** | Cache on-chain content serves. | | **IANA media types** | RFC 6838 | **ADOPT** | `contentType` is an IANA type; the *attested* value is authoritative, never the transport's. | +| **ERC-2098** Compact signatures | Final | **SEAM** | Accept both 64- and 65-byte sig forms in the verify/normalize path (EAS delegated/offchain may hand us compact sigs). | +| **ERC-8048 / ERC-8049** Onchain key-value metadata | Draft | **WATCH** | Closest emerging mirror of EFS PROPERTYs (string-key/bytes-value + indexed events) — design properties expressible through it. | +| **ERC-7512 / ERC-5851 / ERC-8273** Attestation ERCs | Draft | **WATCH** | None Final or on-target; we ride EAS directly as the substrate and keep an EAS-resolution seam. | +| **Tenderly / Blockaid** tx simulation | de-facto | **SEAM** | `efs.fs.preview` stays pluggable to a simulation RPC; no baked provider/keys, no safety guarantee. | From d4e413c77f75d83db9832cb0e0eea238d35b0bbc Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 02:12:22 -0500 Subject: [PATCH 30/47] review: apply verified API + standards review (40 findings) Adversarially-verified 6-dimension review (docs/reviews/2026-06-11). Applies the before-freeze surface locks; defers the additive/open items. TS surface (A1-A12): DataRef gains chainId + resolvedBy (multi-chain identity + fetch-can-verify); CallStatus + status/partialFailure/txHashes on receipts (EIP-5792 600 partial-write); WriteEstimate usd-range; branded ContentHash; OperationResult.error: EfsError + kind + txHash; stat() non-nullable discriminated FileStat; TransportName union; VerificationStatus malformed-claim; WriteMechanism 'sequential'; DirEntry for fs.list; PreviewOptions seam; drop unexported PathRef. Solidity (B1-B4): event EfsFilePinned -> EFSFilePinned w/ indexed path; pinFile PinOpts overload (EAS-native expirationTime/revocable/refUID); _efsMkdir + readAs/lens-stack parity wrappers; must-not-hash-paths + active-pin NatSpec. Docs (C/D): reconcile sdk-architecture.md drift (efs.eas casing, SCHEMAS.REDIRECT, lens singular, receipt field names); EAS EIP-712 domain via getDomainSeparator(); ERC-2098 TS-scoped; SSTORE2/EIP-7825 + payable-resolver notes. Reverse the bundled-index-provider framing in future-proofing.md per review section F. Gate: typecheck/build/test/lint green across @efs/sdk + @efs/solidity. Co-authored-by: Claude Opus 4.8 --- .../2026-06-11-api-and-standards-review.md | 74 ++++++++++++ docs/specs/future-proofing.md | 5 +- docs/specs/standards.md | 6 +- packages/sdk/src/chain/deployments.ts | 2 +- packages/sdk/src/content/hash.ts | 26 ++++- packages/sdk/src/index.ts | 24 +++- packages/sdk/src/types.ts | 109 +++++++++++++++--- packages/sdk/test/content-hash.test.ts | 8 +- packages/solidity/src/EFSLib.sol | 30 +++++ packages/solidity/src/EFSWriter.sol | 48 +++++++- 10 files changed, 293 insertions(+), 39 deletions(-) create mode 100644 docs/reviews/2026-06-11-api-and-standards-review.md diff --git a/docs/reviews/2026-06-11-api-and-standards-review.md b/docs/reviews/2026-06-11-api-and-standards-review.md new file mode 100644 index 0000000..5bd990b --- /dev/null +++ b/docs/reviews/2026-06-11-api-and-standards-review.md @@ -0,0 +1,74 @@ +# API & standards review — 2026-06-11 + +> A 6-dimension expert multi-agent review (TS namespace/API, pass-2 seam reconciliation, Solidity SDK + standards applicability, type design, standards cross-check, holistic coherence). 50 raw findings → 40 survived **adversarial verification** (each finding re-checked by a skeptic told to reject overstated "breaking" claims). This is the actionable record. Priorities: **before-freeze** (lock the public surface before first publish) · **before-launch** (additive, safe after freeze) · **later** (deferred namespaces). + +## Bottom line + +- **Is the TS SDK solid?** Yes, structurally — the boundary (EIP-1193 + viem-inside), write-gating, branded refs, named option types, and error tree all held up. The verification pass **deflated most "critical/breaking" alarms**: nearly everything flagged as breaking-if-deferred turned out to be *additive* under the repo's named-exported-types policy (ADR-0008). The real debt is narrower: a handful of genuine surface locks (multi-chain identity, partial-write state, verification plumbing, USD-range) and a **large amount of planning-doc drift** (the shipped code is usually right; `sdk-architecture.md` is stale). +- **Do the standards findings apply to the on-chain SDK?** Yes, and correctly — but mostly as **documentation + invariants**, not signature changes, because `@efs/solidity` is a compile-in `internal` library (no selectors/ABI), so almost nothing about it is "breaking" and the heavy on-chain concerns (gas-cap chunking, SSTORE2, path hashing) belong to the **immutable protocol contracts**, not this lib. The one real lib-scope question is whether `pinFile`/`mkdir` expose EAS-native lifecycle fields — and even that is an additive overload, not a freeze gate. +- **Most important correction to our own prior recommendation:** see §F — reserving an `EfsIndexProvider` config seam would **re-introduce the bundled-indexer framing James explicitly stripped**. We should *not* add it. The real multi-chain seam is `DataRef.chainId`, not a provider map. + +## A. Before-freeze — TS surface locks (DO NOW; cheap, confirmed) + +| # | Change | Why | File | +|---|---|---|---| +| A1 | **`DataRef` gains `readonly chainId: number`**; propagate to `ReadResult.data`, `FileStat.data`, `WriteReceipt.data`. Fill from `chainIdOf(publicClient)`. | Confirmed multi-chain identity gap — a ref without its chain can't be resolved cross-chain. The genuine seam (not a provider map). | types.ts, index.ts | +| A2 | **`fetch` can verify**: fold the resolving attester into the ref `read` returns / `fetch` consumes (e.g. `DataRef` carries `resolvedBy`, or `read` yields `{uid, resolvedBy}`). | `fetch(DataRef)` currently drops the attester that contentHash verification needs — the verified two-step flow is broken open. | types.ts, index.ts | +| A3 | **Partial-write state**: add `export type CallStatus = 'pending'|'confirmed'|'offchain-failed'|'reverted'|'partial'`; add `status?: CallStatus` on `WriteReceipt`/`BatchReceipt`; restore `BatchReceipt.partialFailure?` + `txHashes`. Keep `done`/`ok` as the binary view. | EIP-5792 status `600` (half-written file) is unrepresentable today → an abandoned sequential run returns a success-shaped receipt. Real footgun. | types.ts | +| A4 | **`WriteEstimate.estimatedUSD: number` → `usd?: { min; max; priceSource?; asOf? }`** (or omit until pricing lands). | Bare scalar violates the range-USD doctrine we just documented in future-proofing.md §5. Our own rule. | types.ts | +| A5 | **Branded `ContentHash = string & { __brand: 'ContentHash' }`** in content/hash.ts; `hashContent` returns it (trusted constructor); apply to `WriteReceipt.contentHash`. Add `asContentHash(s): ContentHash \| undefined` coercer for deserialized receipts. Do **not** brand `verifyContent`'s `claimedHash` param. | `contentHash` is an untyped string on the receipt; the hash is load-bearing. | content/hash.ts, types.ts | +| A6 | **`OperationResult`**: `error?: Error` → `error?: EfsError`; add `kind` (op-type union `'write'\|'pin'\|'tag'\|'property'\|'list'\|'mirror'\|'sort'`) and `txHash?: Hex`. Runtime: classify per-op failures into `EfsError` when batch lands. | A bare `Error` doesn't carry `.code` type-safely; partial-failure UIs need which op kind failed and its tx. | types.ts | +| A7 | **`stat()` returns non-nullable discriminated `FileStat`**: `{ exists: false } \| { exists: true; data; resolvedBy; ... }`. Drop `\| null`. | Absence is modeled two ways (`ReadResult\|null` vs `FileStat.exists`); pick one. Matches design + Solidity `(bool exists, …)`. | types.ts, index.ts | +| A8 | **`TransportName` open union** for `FetchOptions.transports`: `'web3'\|'arweave'\|'ipfs'\|'magnet'\|'https'\|(string & Record)`, ideally derived from the planned `TRANSPORT` constant (ADR-0011). | The one mirror/SSRF-adjacent piece that is genuinely breaking-later; cheap now. | types.ts | +| A9 | **`VerificationStatus`** splits tampering from authoring-bug: add `'malformed-claim'`; return it from hash.ts where the claimed hash fails the 64-hex regex (vs `'mismatch'` = real content/hash divergence). | Security-meaningful distinction (tamper vs attester typo). | content/hash.ts | +| A10 | **`WriteMechanism` literal `'multiAttest-sequential'` → `'sequential'`** (matches `opts.via` + 3 design sites). | Code/design divergence; cheap rename pre-publish. | types.ts | +| A11 | **Define `DirEntry`** (`name` + `anchorUID`/`dataUID` + `kind`) and make `fs.list` element `DirEntry`, not `DataRef`. | `list` returns raw refs; design specifies dir entries. Surface lock. | types.ts, index.ts | +| A12 | **Reserve `opts?: PreviewOptions`** on `fs.preview` (near-empty exported type) so the simulation seam can land additively. Drop `PathRef` from public exports (unconsumed dead surface) — or wire it where dynamic refs are returned. | Cheap seam reservation; remove dead exported surface. | types.ts, index.ts | + +## B. Before-freeze — Solidity (`@efs/solidity`) + +| # | Change | Note | +|---|---|---| +| B1 | **Fix event drift**: `EfsFilePinned` → `EFSFilePinned`; restore `string indexed path` (scaffold regressed the design's hash-filterable indexed path, `sdk-architecture.md:1031`). | Real drift against a decided shape. | +| B2 | **EAS-native lifecycle**: add a `pinFile(path, dataUID, PinOpts)` **overload** (`expirationTime`/`revocable`/`refUID`) — *not* a mutation of the existing signature. Deferring is **not** a semver-major (compile-in `internal` lib), but settle the seam pre-freeze for coherence. | Append-fields-to-struct still re-encodes calldata, so add as overload, not by editing in place. | +| B3 | **Parity stubs**: extend `EFSWriter` + lock the full parity signature set as `revert NotImplemented` stubs before publishing; add the missing `_efsMkdir` wrapper (decide if it emits an event — design has none yet). | Lock the surface; don't ship a partial wrapper set. | +| B4 | **NatSpec invariants** (additive): `read()`/`readAs()` return the *active* pin per lens (revocation/expiry resolved on-chain); the lib **must not hash paths** (path encoding is a protocol-contracts concern, settle there before freeze). | Drop the proposed `PinView`/`PinOpts` *structs* for reads — doc note only. | + +## C. Doc reconciliation — code is right, `planning/Designs/sdk-architecture.md` drifted + +These are **doc edits**, not code changes (verification confirmed the shipped code is correct): + +- **C1 — namespace casing**: lowercase `efs.eas` everywhere in the design doc (lines 70, 129, 170, 336, 631–646, 827, 845…). Code matches ADR-0008. +- **C2 — schema vocabulary**: add `SCHEMAS.REDIRECT`; move `SORT_INFO` out of the frozen-9 enumeration (it's a separate not-yet-frozen sort schema per the doc's own freeze flag at :522). Fix the stale `deployments.ts:25` pointer ("contracts ADR-0048"). +- **C3 — `WriteMechanism` `'sequential'`** in the 3 design sites (pairs with A10). +- **C4 — lens singular/plural**: reconcile `types.ts` `lens` vs design `lenses` (:261). A single `Lens` already encodes the ordered first-wins stack, so align the *doc* to `lens` (don't rename code to `lenses`). +- **C5 — `OperationResult`/`WriteReceipt`/`WriteEstimate` field names** in the design (`mechanism`, `txHashes`/`transactions`) to match A3/A6 and the code. + +## D. Standards-doc additions (standards.md / future-proofing.md) + +- **D1 — EAS EIP-712 domain**: at runtime obtain the domain via the deployed verifier's `getDomainSeparator()` (binds name+version+chainId+verifyingContract); record observed `name "EAS"`, `version "1.4.0"` (eas-contracts master) only as a sanity check — they vary by deployment. Do **not** assert EIP-5267 `eip712Domain()` support (absent in master). +- **D2 — ERC-2098** is **TS-verification-scoped only** (half-sentence clarification on standards.md:39 / future-proofing.md §9). +- **D3 — SSTORE2 / EIP-7825 gas boundary**: add a write-side note to the On-chain SDK section (chunking under ~16.7M gas is a protocol-contracts invariant; the lib inherits it). +- **D4 — payable resolvers / EAS `value`**: one-line note on the Solidity write surface (forward resolver value; already handled in `buildMultiAttest`). + +## E. Before-launch (additive, safe after freeze — do when the relevant pass lands) + +- `OperationResult.kind`/`txHash` full wiring; `WriteReceipt`/`BatchReceipt` `attester?: Address` (observability). +- `ReadResult`/`FileStat` provenance/freshness triple (defer to graph/mirrors pass; provably additive). +- `connect()` late-bind seam (MetaMask M15 flow) — **needs an ADR** (see §G). +- `FileStat` `mirrors?`/`attester?`/`time?`; `VerificationStatus` size-exceeded arm. +- Mirror/SSRF `FetchOptions` knobs beyond `transports`. + +## F. Explicitly NOT doing (verification rejected or strongly cautioned) + +- **❌ `index?: EfsIndexProvider` config seam / `EasGraphQLProvider` default** — re-imports the **bundled-indexer framing James explicitly stripped** (sdk-architecture.md revision log 2026-06-10; Q3 resolution 2026-05-28). Reverse-lookups stay caller-supplied. *(This reverses a recommendation we gave earlier — flag to James.)* +- **❌ Per-chain provider map preemptively** — the cross-chain retrofit is additive, not breaking. `DataRef.chainId` (A1) is the real seam. +- **❌ `defaultLenses?: readonly (Lens\|Address)[]`** — `defaultLens: Lens` already encodes the ordered stack via `lens([...])`; the plural form re-adds the address-vs-Lens ambiguity `resolveLens` exists to remove. +- **❌ NotImplemented shims for `raw` contract handles / `eas` attest verbs** — advertises absent capability for zero semver benefit (additions are non-breaking). +- **❌ `sort?`/`schema?` on `ListOptions` standalone** — fold into the dedicated lists/sorts namespace pass where the cursor-encoding decision is actually made. + +## G. Open questions for James + +1. **Lens-state model** (Q4, real fork): commit to the design's mutable `efs.lenses.set/add/remove/active()` stack, **or** the simpler immutable `defaultLens?` config? Both are non-breaking to extend, but the *exported `defaultLens` field* is the lock-in. **Rec:** keep `defaultLens?: Lens` as the documented model now (simpler, sufficient); reserve `active()` as a shim; revisit mutation when identity work lands. +2. **`connect()` late-bind**: the vault design's MetaMask flow needs a post-construction `connect(account)` path that ADR-0008 doesn't define. **Rec:** write a short follow-up ADR defining `connect()` rather than forcing account-at-construction. +3. **`pinFile` lifecycle overload** (B2): expose `expirationTime`/`revocable`/`refUID` now via overload, or defer? **Rec:** add the overload pre-freeze for coherence (it's cheap and signals intent), bodies still revert. diff --git a/docs/specs/future-proofing.md b/docs/specs/future-proofing.md index 1d2f210..c56d0d3 100644 --- a/docs/specs/future-proofing.md +++ b/docs/specs/future-proofing.md @@ -28,7 +28,7 @@ ## 3. Indexing & read-scaling — index-first (see constraint #1) -- **`EfsIndexProvider` interface** (`whoTagged`, `listWriters`, `versionsOf`) — the SDK bundles **no** indexer (confirms the design). Ship an **`EasGraphQLProvider` default** over EAS's per-chain GraphQL endpoints (instant working reverse-lookups), swappable for a self-hosted subgraph/Envio/Ponder. +- **Reverse-lookups stay caller-supplied** — the SDK bundles **no** indexer *and* wires **no** index provider into the client config. Document an `EfsIndexProvider` *shape* (`whoTagged`, `listWriters`, `versionsOf`) that callers implement, and ship a reference `EasGraphQLProvider` (over EAS's per-chain GraphQL endpoints) as a **docs/example adapter, not a bundled default or `index?` config seam**. Reserving such a seam would re-import the bundled-indexer framing that was explicitly stripped (sdk-architecture.md revision 2026-06-10 / Q3 2026-05-28); see the 2026-06-11 API review §F. - **`eth_getLogs` is bounded-only** — chunked, recent-range, or one-time backfill; never the reverse-lookup backbone. - **Multicall3** (`0xcA11…CA11`) for batched point-reads ("resolve N attestations"). - **Future:** shape the interface so **EIP-7745 verifiable logs** (Draft, Glamsterdam) can back it later — today's centralized-index pragmatism upgrades to trustless reads without an API break. @@ -81,8 +81,9 @@ The "what are we missing" sweep over attestation/registry/data standards — con - **Ride EAS directly — there is no Final attestation ERC.** EAS is infrastructure, not a ratified ERC; `AttestationRequestData` (recipient, `expirationTime`, `revocable`, `refUID`, `bytes data`, value) is the contract we encode against. ERC-7512 (audits), ERC-5851 (verifiable credentials), ERC-8273 (agentic actions) are all Draft and off-target. **Keep an EAS-resolution seam** so a future attestation ERC could slot in without client churn, but don't wait for one. - **EAS-native time/revocation** — use `expirationTime`/`revocable`/`refUID` for mirrors, transports, versioning, and key-set revocation rather than parallel concepts (ties to §7). +- **EIP-712 domain is obtained at runtime, never hardcoded** — fetch it from the deployed verifier's `getDomainSeparator()` (which binds `name`+`version`+`chainId`+`verifyingContract`) for every delegated/offchain EAS request. The observed `name "EAS"` / `version "1.4.0"` (eas-contracts master) is a **sanity check only** — both vary by deployment. Do **not** assume EIP-5267 `eip712Domain()` is callable (absent in master). Domain mismatch is the #1 EIP-712 failure mode (see [standards.md](./standards.md) EIP-712 row). - **ERC-8048 / ERC-8049** — onchain key-value metadata with indexed-key events; the **closest emerging mirror of EFS PROPERTYs**. **WATCH closely** — design properties so they're expressible through that shape. (ERC-7208 data-containers / ERC-7813 table-storage are looser parallels — lower-priority WATCH.) -- **ERC-2098 compact signatures (64-byte)** — EAS delegated/offchain paths may hand us compact sigs; the verify/normalize seam should **accept both 64- and 65-byte forms**. +- **ERC-2098 compact signatures (64-byte)** — EAS delegated/offchain paths may hand us compact sigs; the verify/normalize seam should **accept both 64- and 65-byte forms**. This is **TS-verification-scoped only** — an off-chain normalization concern, not an on-chain `@efs/solidity` one. - **`abi.encode`, never `encodePacked`** for anything hashed/signed (collision-safety); **EIP-1559 fee fields** via `feeHistory` + buffered estimate, exposed as a per-batch override seam. (Both already in [standards.md](./standards.md) AVOID/ADOPT.) ## 10. Roadmap watch list (re-check ~quarterly) diff --git a/docs/specs/standards.md b/docs/specs/standards.md index 11c45f9..d623774 100644 --- a/docs/specs/standards.md +++ b/docs/specs/standards.md @@ -33,10 +33,10 @@ | Standard | Status | SDK | Note | |---|---|---|---| -| **EIP-712** Typed structured data | Final | **ADOPT** | Every EAS delegated request. Pin the domain exactly: `verifyingContract` = the EAS/proxy address, correct `chainId`, EAS's own `name`/`version`. Domain mismatch is failure mode #1. | +| **EIP-712** Typed structured data | Final | **ADOPT** | Every EAS delegated request. Obtain the domain **at runtime** from the deployed verifier's `getDomainSeparator()` (it binds `name`+`version`+`chainId`+`verifyingContract`) — never hardcode it, as these vary by deployment. Observed `name "EAS"` / `version "1.4.0"` (eas-contracts master) only as a sanity check, not an assumption. Do **not** rely on EIP-5267 `eip712Domain()` (absent in master). Domain mismatch is failure mode #1. | | **EIP-1271** Contract-wallet `isValidSignature` | Final | **ADOPT** (verify) | Never `ecrecover` off-chain — route through a 1271-aware path. | | **ERC-6492** Counterfactual-wallet signatures | Final | **ADOPT** | Coinbase Smart Wallet emits these. **viem's `verifyTypedData`/`verifyMessage` *Actions* handle 1271 + 6492 (+ 8010) automatically** — use those, never the EOA-only util. | -| **ERC-2098** Compact (64-byte) signatures | Final | **SEAM** | Accept both 64- and 65-byte forms in the verify/normalize seam. | +| **ERC-2098** Compact (64-byte) signatures | Final | **SEAM** | Accept both 64- and 65-byte forms in the verify/normalize seam. **TS-verification-scoped only** — this is an off-chain signature-normalization concern, not an on-chain (`@efs/solidity`) one. | | **EIP-7739** Nested/readable typed sigs (cross-account replay) | Draft | **WATCH** | Real but not adoption-critical; viem ships it experimental. Don't emit wrappers yet; don't block it. | **Replay defenses to implement regardless:** per-EAS `nonce`, `deadline`, and domain-bound `chainId` + `verifyingContract` (kills cross-chain + cross-contract replay). @@ -122,7 +122,7 @@ EIP-7702 wallet support · EIP-5792 capability evolution · EIP-7715 session key | **ERC-7683 / 7802 / EIL** interop | mixed | **WATCH/AVOID** | Abstract assets, not where data lives — won't relocate an EFS file. | | **ERC-7774** ETag caching (over ERC-5219) | Draft | **SEAM** | Cache on-chain content serves. | | **IANA media types** | RFC 6838 | **ADOPT** | `contentType` is an IANA type; the *attested* value is authoritative, never the transport's. | -| **ERC-2098** Compact signatures | Final | **SEAM** | Accept both 64- and 65-byte sig forms in the verify/normalize path (EAS delegated/offchain may hand us compact sigs). | +| **ERC-2098** Compact signatures | Final | **SEAM** | Accept both 64- and 65-byte sig forms in the verify/normalize path (EAS delegated/offchain may hand us compact sigs). **TS-verification-scoped only**, not an on-chain `@efs/solidity` concern. | | **ERC-8048 / ERC-8049** Onchain key-value metadata | Draft | **WATCH** | Closest emerging mirror of EFS PROPERTYs (string-key/bytes-value + indexed events) — design properties expressible through it. | | **ERC-7512 / ERC-5851 / ERC-8273** Attestation ERCs | Draft | **WATCH** | None Final or on-target; we ride EAS directly as the substrate and keep an EAS-resolution seam. | | **Tenderly / Blockaid** tx simulation | de-facto | **SEAM** | `efs.fs.preview` stays pluggable to a simulation RPC; no baked provider/keys, no safety guarantee. | diff --git a/packages/sdk/src/chain/deployments.ts b/packages/sdk/src/chain/deployments.ts index 4b0f972..fd779e2 100644 --- a/packages/sdk/src/chain/deployments.ts +++ b/packages/sdk/src/chain/deployments.ts @@ -22,7 +22,7 @@ export type EfsContracts = { aliasResolver: Address } -/** The 9 frozen EFS schema UIDs (contracts ADR-0048). */ +/** The frozen EFS schema-UID set (defined in the contracts repo). */ export type EfsSchemaUIDs = { anchor: Hex property: Hex diff --git a/packages/sdk/src/content/hash.ts b/packages/sdk/src/content/hash.ts index 42562f3..55d52f2 100644 --- a/packages/sdk/src/content/hash.ts +++ b/packages/sdk/src/content/hash.ts @@ -10,9 +10,22 @@ import { sha256 } from 'viem' -/** SHA-256 of the file bytes, as a bare lowercase-hex string (matches `sha256sum`). */ -export function hashContent(bytes: Uint8Array): string { - return sha256(bytes, 'hex').slice(2) // strip the 0x viem prepends +/** A validated bare SHA-256 content digest (64 lowercase hex chars, no `0x`). + * Branded (review A5) because the hash is load-bearing: it must come from a + * trusted constructor (`hashContent`) or the `asContentHash` coercer, never an + * arbitrary string. */ +export type ContentHash = string & { readonly __brand: 'ContentHash' } + +/** SHA-256 of the file bytes, as a bare lowercase-hex string (matches `sha256sum`). + * The trusted constructor for `ContentHash`. */ +export function hashContent(bytes: Uint8Array): ContentHash { + return sha256(bytes, 'hex').slice(2) as ContentHash // strip the 0x viem prepends +} + +/** Coerce a deserialized string (e.g. from a persisted receipt) into a + * `ContentHash`, or `undefined` if it isn't a well-formed bare SHA-256 digest. */ +export function asContentHash(s: string): ContentHash | undefined { + return /^[0-9a-f]{64}$/.test(s) ? (s as ContentHash) : undefined } /** Verification status of fetched bytes against an attested `contentHash`. */ @@ -20,6 +33,7 @@ export type VerificationStatus = | 'matches-author' // bytes hash equals the contentHash attested by the resolving lens | 'no-claim' // the resolving lens attested no contentHash — UNVERIFIABLE, not "ok" | 'mismatch' // bytes do not match the attested contentHash (or exceed declared size) + | 'malformed-claim' // the attested claim isn't a well-formed hash (attester bug, not tampering) /** * Verify fetched bytes against the author's attested contentHash. @@ -33,7 +47,9 @@ export function verifyContent( ): VerificationStatus { if (claimedHash === undefined) return 'no-claim' const claim = claimedHash.toLowerCase() - // A malformed claim (0x-prefixed, padded, wrong length) cannot be trusted → mismatch. - if (!/^[0-9a-f]{64}$/.test(claim)) return 'mismatch' + // A claim that isn't a well-formed bare SHA-256 (0x-prefixed, padded, wrong + // length) is an authoring bug, not content tampering (review A9) — distinguish + // it from a real content/hash divergence so callers can tell the two apart. + if (!/^[0-9a-f]{64}$/.test(claim)) return 'malformed-claim' return hashContent(bytes) === claim ? 'matches-author' : 'mismatch' } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b946f93..ace51c3 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -42,11 +42,13 @@ import { type Lens, identity, lens, resolveLens } from './lenses/resolve.js' import type { BatchReceipt, DataRef, + DirEntry, EfsFile, EfsList, FetchOptions, FileStat, ListOptions, + PreviewOptions, ReadOptions, ReadResult, WriteEstimate, @@ -111,14 +113,16 @@ function resolveClients(config: EfsClientConfig): { export type EfsFsRead = { read(path: string, opts?: ReadOptions): Promise fetch(ref: DataRef, opts?: FetchOptions): Promise - stat(path: string, opts?: ReadOptions): Promise - list(path: string, opts?: ListOptions): EfsList + /** Metadata at a path. Returns a discriminated `FileStat` (`{exists:false}` vs + * `{exists:true; …}`), never `null` — absence is modeled once (review A7). */ + stat(path: string, opts?: ReadOptions): Promise + list(path: string, opts?: ListOptions): EfsList } /** Read + write file operations (only present when a `walletClient` is set). */ export type EfsFsWrite = EfsFsRead & { write(path: string, content: Uint8Array, opts?: WriteOptions): Promise - preview(path: string, content: Uint8Array): Promise + preview(path: string, content: Uint8Array, opts?: PreviewOptions): Promise } export type EfsLensesNs = { @@ -241,7 +245,13 @@ export { type AttestationRequestData, type MultiAttestationRequest, } from './eas/index.js' -export { hashContent, verifyContent, type VerificationStatus } from './content/hash.js' +export { + hashContent, + verifyContent, + asContentHash, + type ContentHash, + type VerificationStatus, +} from './content/hash.js' export { lens, identity, resolveLens, MAX_LENSES, type Lens } from './lenses/resolve.js' export { deployments, @@ -256,11 +266,13 @@ export * from './errors.js' export type { DataRef, DataUID, - PathRef, + DirEntry, ReadOptions, ListOptions, FetchOptions, + TransportName, WriteOptions, + PreviewOptions, Page, EfsList, ReadResult, @@ -268,7 +280,9 @@ export type { FileStat, WriteReceipt, WriteMechanism, + CallStatus, WriteEstimate, OperationResult, + OperationKind, BatchReceipt, } from './types.js' diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 8a3e1b8..da8128d 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -4,15 +4,26 @@ * NAMED and exported so adding a field later is non-breaking (review C1). */ import type { Address, Hex } from 'viem' -import type { VerificationStatus } from './content/hash.js' +import type { ContentHash, VerificationStatus } from './content/hash.js' +import type { EfsError } from './errors.js' import type { Lens } from './lenses/resolve.js' // ── Branded references ───────────────────────────────────────────────────────── export type DataUID = Hex & { readonly __kind: 'DataUID' } -/** Static reference — these exact bytes / this version. */ -export type DataRef = { readonly __brand: 'DataRef'; readonly uid: DataUID } +/** Static reference — these exact bytes / this version. Carries the chain it lives + * on (review A1: a ref without its chain can't be resolved cross-chain) and the + * attester that resolved it (review A2: `fetch(ref)` needs the author to verify + * the attested `contentHash` — the verified two-step flow is broken without it). */ +export type DataRef = { + readonly __brand: 'DataRef' + readonly uid: DataUID + /** The EIP-155 chain this ref resolves on. */ + readonly chainId: number + /** The attester whose lens won placement — the author `fetch` verifies against. */ + readonly resolvedBy: Address +} /** Dynamic reference — whatever is active at this path now. */ export type PathRef = { readonly __brand: 'PathRef'; readonly path: string } @@ -33,11 +44,23 @@ export type ListOptions = ReadOptions & { cursor?: string } +/** A named transport for mirror/fetch resolution. Open union (review A8): the + * known transports are autocompletable, but an unrecognized name is still + * assignable so adding one is never breaking. Will be derived from the planned + * `TRANSPORT` constant (ADR-0011) once it lands. */ +export type TransportName = + | 'web3' + | 'arweave' + | 'ipfs' + | 'magnet' + | 'https' + | (string & Record) + export type FetchOptions = { /** Verify fetched bytes against the author's attested contentHash (default true). */ verify?: boolean /** Restrict/prioritize transports (e.g. `['ipfs', 'https']`); default = all by priority. */ - transports?: readonly string[] + transports?: readonly TransportName[] } // ── Pagination ───────────────────────────────────────────────────────────────── @@ -55,11 +78,33 @@ export type EfsList = AsyncIterable & { page(opts?: { limit?: number; cursor?: string }): Promise> } +// ── Listings ───────────────────────────────────────────────────────────────── + +/** One entry in a directory listing. The design specifies dir entries (a name + * plus its anchoring UID and kind), not raw refs (review A11): a listing needs + * the entry's name and whether it's a file or a directory, which a bare + * `DataRef` can't carry. */ +export type DirEntry = { + /** The entry's name within the listed directory (the last path segment). */ + name: string + /** Whether this entry is a file or a subdirectory. */ + kind: 'file' | 'dir' + /** The static ref to the file's bytes (present for `kind: 'file'`). */ + dataUID?: DataUID + /** The anchor UID for a subdirectory (present for `kind: 'dir'`). */ + anchorUID?: DataUID +} + // ── Writes ───────────────────────────────────────────────────────────────────── /** How a batched write was delivered. Exported so additions are localized, not a * breaking change to an exhaustive `switch` (review C5). */ -export type WriteMechanism = 'multiAttest-sequential' | 'eip5792' | 'erc4337' | 'gateway' +export type WriteMechanism = 'sequential' | 'eip5792' | 'erc4337' | 'gateway' + +/** Lifecycle status of a write/batch (review A3). Models EIP-5792 status `600` + * (a half-written file) which a binary `done`/`ok` can't represent — without it + * an abandoned sequential run returns a success-shaped receipt. */ +export type CallStatus = 'pending' | 'confirmed' | 'offchain-failed' | 'reverted' | 'partial' export type WriteOptions = { contentType?: string @@ -68,22 +113,38 @@ export type WriteOptions = { signal?: AbortSignal } +/** Options for `fs.preview`. Reserved now (review A12) so the write-simulation + * seam (e.g. price source, lens) can land additively without a signature change. + * Intentionally near-empty until the preview pass defines its knobs. */ +export type PreviewOptions = ReadOptions + /** A durable, serializable write session. `steps` are idempotent per * (path-qualified) id so a resume skips only mined work and never double-mints. */ export type WriteReceipt = { - contentHash: string + contentHash: ContentHash data?: DataRef steps: Array<{ id: string; uid?: DataUID; done: boolean }> signatureCount: number mechanism: WriteMechanism + /** Lifecycle status; `'partial'`/`'reverted'` flag a half-written file. */ + status?: CallStatus } +/** The op-type a batch entry performed — partial-failure UIs need to show which + * kind of operation failed (review A6). */ +export type OperationKind = 'write' | 'pin' | 'tag' | 'property' | 'list' | 'mirror' | 'sort' + /** One operation's result inside a multi-op batch. */ export type OperationResult = { id: string + /** Which op-type this entry performed. */ + kind: OperationKind ok: boolean uid?: DataUID - error?: Error + /** The op's transaction hash, when it produced one. */ + txHash?: Hex + /** A typed EFS error (carries `.code`); not a bare `Error` (review A6). */ + error?: EfsError } /** The result of executing a multi-op batch. */ @@ -91,6 +152,12 @@ export type BatchReceipt = { results: readonly OperationResult[] signatureCount: number mechanism: WriteMechanism + /** Lifecycle status; `'partial'` when some ops landed and some did not. */ + status?: CallStatus + /** True when some operations landed and some did not (review A3). */ + partialFailure?: boolean + /** The transaction hashes the batch produced, in delivery order. */ + txHashes?: readonly Hex[] } export type WriteEstimate = { @@ -99,13 +166,17 @@ export type WriteEstimate = { signatureCount: number chunkDeploys: number gas: bigint - estimatedUSD?: number + /** Estimated cost as a range, not a bare scalar (review A4 / future-proofing.md §5). + * Omitted until pricing lands. */ + usd?: { min: number; max: number; priceSource?: string; asOf?: number } warnings: string[] } // ── Reads ────────────────────────────────────────────────────────────────────── -/** A resolved read: the data ref plus which attester/lens won (review UX-4). */ +/** A resolved read: the data ref plus which attester/lens won (review UX-4). + * `resolvedBy` is also folded into `DataRef` (review A2) so a ref carried to + * `fetch` alone can still verify; it is kept here for the read-time view. */ export type ReadResult = { data: DataRef; resolvedBy: Address } /** Fetched bytes + trust-relative verification (never a bare "verified"). */ @@ -117,11 +188,15 @@ export type EfsFile = { hashAuthor?: Address } -/** Metadata about the file at a path, without fetching bytes. */ -export type FileStat = { - exists: boolean - data?: DataRef - resolvedBy?: Address - contentType?: string - size?: bigint -} +/** Metadata about the file at a path, without fetching bytes. Discriminated on + * `exists` (review A7): absence is modeled once here, not also as a `| null` + * return. Mirrors the Solidity `(bool exists, …)` shape. */ +export type FileStat = + | { exists: false } + | { + exists: true + data: DataRef + resolvedBy: Address + contentType?: string + size?: bigint + } diff --git a/packages/sdk/test/content-hash.test.ts b/packages/sdk/test/content-hash.test.ts index 4b86245..4b823f7 100644 --- a/packages/sdk/test/content-hash.test.ts +++ b/packages/sdk/test/content-hash.test.ts @@ -25,9 +25,11 @@ describe('content hashing (ADR-0006: bare SHA-256)', () => { expect(verifyContent(bytes, undefined)).toBe('no-claim') }) - it('treats a malformed claim (0x-prefixed / wrong length) as mismatch, not a pass', () => { + it('flags a malformed claim (0x-prefixed / wrong length) as malformed-claim, not a pass (A9)', () => { + // A claim that isn't a well-formed bare SHA-256 is an attester bug, distinct + // from real content/hash divergence ('mismatch') — and never a pass. const bytes = enc('gm') - expect(verifyContent(bytes, `0x${hashContent(bytes)}`)).toBe('mismatch') - expect(verifyContent(bytes, 'deadbeef')).toBe('mismatch') + expect(verifyContent(bytes, `0x${hashContent(bytes)}`)).toBe('malformed-claim') + expect(verifyContent(bytes, 'deadbeef')).toBe('malformed-claim') }) }) diff --git a/packages/solidity/src/EFSLib.sol b/packages/solidity/src/EFSLib.sol index ffeeeef..b4ddee7 100644 --- a/packages/solidity/src/EFSLib.sol +++ b/packages/solidity/src/EFSLib.sol @@ -10,25 +10,48 @@ pragma solidity ^0.8.26; /// (On-chain SDK section); bodies revert until the build lands. Do NOT deploy this /// as a standalone helper — a separate CALL would make the helper the attester and /// collapse every consumer into one identity. +/// @dev Path-encoding invariant: this lib MUST NOT hash paths. How a path string maps to +/// on-chain identity is a protocol-contracts concern (settled there before schema +/// freeze); the lib passes paths through verbatim so it never diverges from the +/// contracts' canonical encoding. library EFSLib { error NotImplemented(); + /// @notice EAS-native lifecycle fields for a pin (ADR-0003 / B2). + /// @dev Mirrors the EAS attestation lifecycle so a pin can carry the same controls + /// as the underlying attestation, without overloading the EFS path/UID surface. + /// @param expirationTime Unix time after which the pin's attestation is no longer valid + /// (0 = no expiry). + /// @param revocable Whether the pin's attestation may later be revoked. + /// @param refUID Optional referenced attestation UID (bytes32(0) = none). + struct PinOpts { + uint64 expirationTime; + bool revocable; + bytes32 refUID; + } + // --- Reads (lens-scoped) --- // read(path) with no lens defaults to the consuming contract's own data // (lens = [address(this)]). readAs names an explicit author. Never tx.origin. // Returns (exists, dataUID) so a missing file is distinguishable from a present one. /// @notice Read the active data UID at `path` for the consuming contract's own lens. + /// @dev Returns the *active* pin per lens — revocation and expiry are resolved + /// on-chain, so a revoked or expired pin reads as absent (exists = false). function read(string memory) internal view returns (bool, bytes32) { revert NotImplemented(); } /// @notice Read `path` resolved through an explicit author address. + /// @dev Returns the *active* pin per lens — revocation and expiry are resolved + /// on-chain, so a revoked or expired pin reads as absent (exists = false). function readAs(string memory, address) internal view returns (bool, bytes32) { revert NotImplemented(); } /// @notice Read `path` resolved through an explicit, ordered lens stack. + /// @dev Returns the *active* pin per lens — revocation and expiry are resolved + /// on-chain, so a revoked or expired pin reads as absent (exists = false). function read(string memory, address[] memory) internal view returns (bool, bytes32) { revert NotImplemented(); } @@ -40,6 +63,13 @@ library EFSLib { revert NotImplemented(); } + /// @notice Pin a file at `path` with EAS-native lifecycle controls (`opts`). + /// @dev Overload of {pinFile} (B2). Additive — the 2-arg form stays the default; this + /// form exposes expiration/revocability/refUID for callers that need them. + function pinFile(string memory, bytes32, PinOpts memory) internal returns (bytes32) { + revert NotImplemented(); + } + /// @notice Create a folder hierarchy for `path` (mkdir -p). function mkdir(string memory) internal returns (bytes32) { revert NotImplemented(); diff --git a/packages/solidity/src/EFSWriter.sol b/packages/solidity/src/EFSWriter.sol index aa6cfb1..f757f72 100644 --- a/packages/solidity/src/EFSWriter.sol +++ b/packages/solidity/src/EFSWriter.sol @@ -12,19 +12,61 @@ import {EFSLib} from "./EFSLib.sol"; /// domain-keyed, so domain consumers need these). abstract contract EFSWriter { /// @notice Emitted when this contract pins a file at a path. - event EfsFilePinned(string path, bytes32 indexed dataUID, bytes32 pinUID); + /// @dev `path` is indexed so domain consumers can filter pins by path hash + /// (indexed strings are stored as their keccak256 hash in the topic). + event EFSFilePinned(string indexed path, bytes32 indexed dataUID, bytes32 pinUID); + + // --- Reads (view) --- /// @notice Read this contract's own file at `path`. function _efsRead(string memory path) internal view returns (bool exists, bytes32 dataUID) { return EFSLib.read(path); } - /// @notice Pin a file at `path`; emits {EfsFilePinned} for domain consumers. + /// @notice Read `path` resolved through an explicit author address. + function _efsReadAs(string memory path, address author) + internal + view + returns (bool exists, bytes32 dataUID) + { + return EFSLib.readAs(path, author); + } + + /// @notice Read `path` resolved through an explicit, ordered lens stack. + function _efsRead(string memory path, address[] memory lens) + internal + view + returns (bool exists, bytes32 dataUID) + { + return EFSLib.read(path, lens); + } + + // --- Writes --- + + /// @notice Pin a file at `path`; emits {EFSFilePinned} for domain consumers. /// @dev {EFSLib.pinFile} reverts until implemented, so the emit is unreachable /// for now — but this locks the happy-path shape (capture, emit, return) /// so inheritors get the event without writing their own wrapper. function _efsPinFile(string memory path, bytes32 dataUID) internal returns (bytes32 pinUID) { pinUID = EFSLib.pinFile(path, dataUID); - emit EfsFilePinned(path, dataUID, pinUID); + emit EFSFilePinned(path, dataUID, pinUID); + } + + /// @notice Pin a file at `path` with EAS-native lifecycle controls (`opts`); emits {EFSFilePinned}. + /// @dev Overload mirroring {EFSLib.pinFile} with {EFSLib.PinOpts} (B2). Emits the same + /// event as the 2-arg form so consumers index pins uniformly regardless of opts. + function _efsPinFile(string memory path, bytes32 dataUID, EFSLib.PinOpts memory opts) + internal + returns (bytes32 pinUID) + { + pinUID = EFSLib.pinFile(path, dataUID, opts); + emit EFSFilePinned(path, dataUID, pinUID); + } + + /// @notice Create a folder hierarchy for `path` (mkdir -p). + /// @dev No event yet: mkdir has no EFS-level event in the design, so this wrapper + /// intentionally does not emit. Add one here if/when the design defines it. + function _efsMkdir(string memory path) internal returns (bytes32 dirUID) { + return EFSLib.mkdir(path); } } From 449c286907b19b2fedd9e307035ae35698b73800 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 02:41:11 -0500 Subject: [PATCH 31/47] feat: build freeze-independent foundation (fetch engine, classifier, tooling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Productive work that doesn't depend on the contracts schema freeze, so the freeze unblocks only the thin attestation glue. Fetch/verify/mirror engine (src/mirror/, ADR-0010): zero-dep transport resolver (ipfs/ar/https/data full; web3:// parsed, resolution seam'd), fetchVerified with sequential gateway failover, per-attempt timeout, hard size cap, nosniff, and an SSRF guard (v4/v6 incl. IPv4-mapped). Verify-before-trust against the bare-SHA-256 contentHash (ADR-0006); bytes returned even on mismatch, never executed. Error classifier (ADR-0007, errors.ts): classifyError() maps viem BaseError / ContractFunctionRevertedError + EIP-1193 (4001/4100/4200/4900/4901) + JSON-RPC -32xxx to typed EfsError codes; idempotent; cause-preserving. Tooling/supply-chain (future-proofing §1/§4): pinned exact dep versions (11; viem peer left ranged), size-limit budget (6.45/8 kB), Knip dead-export check, --ignore-scripts in CI. Test infra: mock EIP-1193 provider + createEfsClient boundary tests, prool fork harness (env-gated). 75 tests pass, 1 skipped. Gate green: typecheck/build/test/lint/size across @efs/sdk + @efs/solidity. Co-authored-by: Claude Opus 4.8 --- .github/workflows/ci.yml | 18 +- .github/workflows/release.yml | 3 +- docs/adr/0010-fetch-mirror-engine.md | 38 + docs/adr/README.md | 1 + package.json | 12 +- packages/sdk/.size-limit.json | 10 + packages/sdk/knip.json | 6 + packages/sdk/package.json | 17 +- packages/sdk/src/errors.ts | 181 +++++ packages/sdk/src/index.ts | 2 + packages/sdk/src/mirror/fetch.ts | 325 ++++++++ packages/sdk/src/mirror/index.ts | 42 + packages/sdk/src/mirror/ssrf.ts | 140 ++++ packages/sdk/src/mirror/transport.ts | 271 +++++++ packages/sdk/test/client.test.ts | 140 ++++ packages/sdk/test/errors.test.ts | 131 +++ packages/sdk/test/fork.test.ts | 40 + packages/sdk/test/helpers/anvil-fork.ts | 63 ++ packages/sdk/test/helpers/mock-eip1193.ts | 107 +++ packages/sdk/test/mirror.test.ts | 348 ++++++++ pnpm-lock.yaml | 938 +++++++++++++++++++++- 21 files changed, 2795 insertions(+), 38 deletions(-) create mode 100644 docs/adr/0010-fetch-mirror-engine.md create mode 100644 packages/sdk/.size-limit.json create mode 100644 packages/sdk/knip.json create mode 100644 packages/sdk/src/mirror/fetch.ts create mode 100644 packages/sdk/src/mirror/index.ts create mode 100644 packages/sdk/src/mirror/ssrf.ts create mode 100644 packages/sdk/src/mirror/transport.ts create mode 100644 packages/sdk/test/client.test.ts create mode 100644 packages/sdk/test/errors.test.ts create mode 100644 packages/sdk/test/fork.test.ts create mode 100644 packages/sdk/test/helpers/anvil-fork.ts create mode 100644 packages/sdk/test/helpers/mock-eip1193.ts create mode 100644 packages/sdk/test/mirror.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70becf4..7f84db4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,11 @@ jobs: with: node-version: 20 cache: pnpm - - run: pnpm install --frozen-lockfile + # --ignore-scripts neutralizes malicious dependency postinstall/preinstall + # scripts — the dominant supply-chain attack on a wallet-adjacent SDK. + # It only suppresses *dependency* lifecycle scripts; explicit `pnpm run` + # pre/post hooks (e.g. the Solidity forge-std bootstrap) still run. + - run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm --filter @efs/sdk build # Guard the dual ESM/CJS `exports` map: attw catches type masquerading / # resolution bugs, publint catches malformed package metadata. @@ -37,6 +41,12 @@ jobs: - run: pnpm --filter @efs/sdk exec publint - run: pnpm --filter @efs/sdk typecheck - run: pnpm --filter @efs/sdk test + # Bundle-size budget on the @efs/sdk ESM entry (viem external). Fails the + # build if the gzipped surface grows past the budget in .size-limit.json. + - run: pnpm --filter @efs/sdk size + # Dead-code / unused-dependency report. Report-only (|| true) for now — the + # findings are follow-ups, not a merge gate, while the API surface settles. + - run: pnpm --filter @efs/sdk knip || true - run: pnpm lint solidity: @@ -51,9 +61,11 @@ jobs: with: node-version: 20 cache: pnpm - - run: pnpm install --frozen-lockfile + - run: pnpm install --frozen-lockfile --ignore-scripts # Use the package scripts so CI and a contributor's `pnpm test` run the same # path; the build/test pre-hooks bootstrap forge-std (test-only, not shipped). + # --ignore-scripts above only suppresses *dependency* install scripts, so + # these explicit `pnpm run` pre/post hooks still execute. - run: pnpm --filter @efs/solidity build - run: pnpm --filter @efs/solidity test - run: pnpm --filter @efs/solidity fmt:check @@ -74,5 +86,5 @@ jobs: with: node-version: 20 cache: pnpm - - run: pnpm install --frozen-lockfile + - run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm changeset status --since=origin/main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 941ec35..6e8898f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,8 @@ jobs: node-version: 22 # Trusted Publishing needs Node >= 22.14 / npm >= 11.5.1 cache: pnpm registry-url: https://registry.npmjs.org - - run: pnpm install --frozen-lockfile + # --ignore-scripts: block dependency install scripts on the publish path too. + - run: pnpm install --frozen-lockfile --ignore-scripts - run: npm install -g npm@11 # pinned major; needs npm >= 11.5.1 for OIDC publishing # Only the TS package needs a build artifact to publish; @efs/solidity ships # .sol source as-is, so the release path needs no Foundry. (CI compile-checks diff --git a/docs/adr/0010-fetch-mirror-engine.md b/docs/adr/0010-fetch-mirror-engine.md new file mode 100644 index 0000000..348e8ea --- /dev/null +++ b/docs/adr/0010-fetch-mirror-engine.md @@ -0,0 +1,38 @@ +# ADR-0010: Off-chain fetch/verify/mirror engine + +**Status:** Accepted +**Date:** 2026-06-11 +**Related:** ADR-0006 (bare-SHA-256 contentHash), ADR-0007 (error model); `docs/specs/future-proofing.md` §2 (mirror doctrine), `docs/specs/standards.md` (transports) + +## Context + +A file read is two halves: resolve an attestation to a `(contentHash, mirror URIs, contentType)` tuple, then fetch the bytes from a mirror and verify them. The **first half is blocked on the schema freeze**; the **second half is not** — it only needs `(uri, expectedHash) → verified bytes` and the already-decided `contentHash` convention (ADR-0006) and transport set (`standards.md`). Building it now means the freeze unblocks only the thin attestation glue, against an engine that already exists and is tested. + +The mirror layer is also where the SDK touches **attacker-controlled bytes** (any mirror can be hostile, rate-limited, or dead), so its security posture is load-bearing, not incidental (`future-proofing.md` §2/§4). + +## Decision + +Ship a standalone `src/mirror/` engine, exported from the package root, with **zero new runtime dependencies** (global `fetch`, Web Crypto via the existing `hashContent`, standard JS only): + +1. **`resolveTransport(uri)` + `TRANSPORT` allowlist** — parses a URI to `{ scheme: TransportName; httpUrls(gateways) }`. `ipfs://`, `ar://`, `https://`, and `data:` (RFC 2397) are fully implemented; `web3://` parses but its resolution throws `TransportNotImplementedError` (it needs a chain call + ERC-6944 decode — a deliberate seam for later). CIDs are treated as **locators only** — never as the integrity check (ADR-0006: CID ≠ `sha256(bytes)`). +2. **`fetchVerified(mirrors, expectedHash, opts?)`** — ordered sequential failover across mirrors/gateways with a per-attempt timeout (default 10s) and a **hard size cap** (default 50 MB; trips on both an honest `Content-Length` and a lying one, mid-stream). It hashes the full bytes and returns `{ bytes, verification, contentType?, mirrorUsed }` where `verification ∈ {matches-author, mismatch, malformed-claim, no-claim}` (mirrors `verifyContent`). **Bytes are always returned, even on mismatch** — the caller decides; the SDK never silently swallows a failed verification. +3. **Security invariants** (the reason this is its own module): + - **Verify before trust** — the independent SHA-256 is the whole model. + - **nosniff** — the response `Content-Type` is informational only; it never drives handling, and bytes are never executed. + - **SSRF guard** (`checkSsrf`) — blocks loopback/private/link-local/CGNAT/cloud-metadata IPs (v4 + v6, incl. IPv4-mapped) and `localhost`/`*.local`/metadata hostnames, with explicit `allowPrivateHosts`/allowlist escapes. Documented gap: with global `fetch` there is no pre-connect DNS resolution, so DNS-rebinding isn't fully closable here, and in-browser the SSRF concern is the browser's; the guard is the node-side defense. +4. **Injection seams** — gateway lists, timeout, size cap, `AbortSignal`, and a `fetchImpl` override are all options, so the engine is testable without network and overridable per call. + +`efs.fs.fetch` will be the thin wrapper that, once reads resolve a `DataRef` to its `contentHash` + mirror URIs, calls `fetchVerified`. + +## Consequences + +- The verify-half of reads exists and is unit-tested (28 tests, `fetch` mocked) before the freeze; post-freeze work shrinks to attestation resolution. +- Zero new dependencies keeps the supply-chain surface minimal (`future-proofing.md` §4) and the bundle small (engine fits inside the 8 kB budget). +- `web3://` resolution and DNS-rebinding-proof SSRF are known, documented gaps deferred to when the chain-read path lands. +- The default IPFS gateway list (`ipfs.io`, `dweb.link`, `cloudflare-ipfs.com`) will rot as public gateways are deprecated — it is overridable by design, and durability remains the app's job (pair a fast mirror with a permanent one). + +## Alternatives + +- **`@helia/verified-fetch`** — verifies against the *CID*, which we deliberately don't trust as the integrity root (ADR-0006); it's a fine optional path for CID-native callers but wrong as our core. Rejected as a dependency. +- **Defer the whole engine until the freeze** — leaves a large, freeze-independent, security-sensitive chunk unbuilt and untested while we wait. Rejected; building it now is the highest-leverage use of the blocked period. +- **Wait for `web3://` libraries** — none exist; the mirror layer owns web3:// (standards.md). Parsed-now/resolved-later is the pragmatic split. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8412111..045c4fb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -55,3 +55,4 @@ Compact, scannable — one screen per ADR. Copy `_template.md`. The next number - [ADR-0007 — Error model: viem-`BaseError`-grade typed tree](./0007-error-model.md) - [ADR-0008 — Public API shape, instantiation & semver policy](./0008-public-api-and-semver.md) - [ADR-0009 — Library-agnostic seam: viem core, ethers as an optional adapter](./0009-library-agnostic-seam.md) +- [ADR-0010 — Off-chain fetch/verify/mirror engine](./0010-fetch-mirror-engine.md) diff --git a/package.json b/package.json index 3da782e..81dab9c 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,11 @@ "release": "pnpm --filter @efs/sdk build && changeset publish" }, "devDependencies": { - "@arethetypeswrong/cli": "^0.18.3", - "@biomejs/biome": "^1.9.4", - "@changesets/cli": "^2.27.9", - "publint": "^0.2.12", - "turbo": "^2.3.0", - "typescript": "^5.6.3" + "@arethetypeswrong/cli": "0.18.3", + "@biomejs/biome": "1.9.4", + "@changesets/cli": "2.31.0", + "publint": "0.2.12", + "turbo": "2.9.17", + "typescript": "5.9.3" } } diff --git a/packages/sdk/.size-limit.json b/packages/sdk/.size-limit.json new file mode 100644 index 0000000..358ed13 --- /dev/null +++ b/packages/sdk/.size-limit.json @@ -0,0 +1,10 @@ +[ + { + "name": "@efs/sdk ESM (gzip, viem external)", + "path": "dist/index.js", + "import": "*", + "ignore": ["viem"], + "gzip": true, + "limit": "8 kB" + } +] diff --git a/packages/sdk/knip.json b/packages/sdk/knip.json new file mode 100644 index 0000000..0bbe4a2 --- /dev/null +++ b/packages/sdk/knip.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "entry": ["src/mirror/index.ts"], + "project": ["src/**/*.ts"], + "ignoreDependencies": ["prool"] +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 45be1dd..bea7b77 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -62,7 +62,9 @@ "build": "tsup", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "size": "size-limit", + "knip": "knip" }, "peerDependencies": { "viem": "^2" @@ -71,10 +73,13 @@ "viem": { "optional": false } }, "devDependencies": { - "tsup": "^8.3.0", - "typescript": "^5.6.3", - "viem": "^2.21.0", - "vitest": "^2.1.0", - "prool": "^0.0.16" + "@size-limit/preset-small-lib": "12.1.0", + "knip": "6.16.1", + "prool": "0.0.16", + "size-limit": "12.1.0", + "tsup": "8.5.1", + "typescript": "5.9.3", + "viem": "2.52.2", + "vitest": "2.1.9" } } diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 27df022..3355cc1 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -5,6 +5,9 @@ * passthrough, and `walk()` for cause-chain traversal. */ +import { BaseError, ContractFunctionRevertedError } from 'viem' +import { easAbi } from './eas/abi.js' + /** Open string-union of error codes — open (`string & {}`) so adding a code is * never a breaking change for an exhaustive `switch`. */ export type EfsErrorCode = @@ -17,6 +20,19 @@ export type EfsErrorCode = | 'DeploymentNotFound' | 'CursorInvalid' | 'PartialBatchFailure' + // --- classifier codes (ADR-0007 §Realization) --------------------------- + /** Wallet rejected by the user (EIP-1193 `4001`). Benign, not a failure. */ + | 'UserRejected' + /** Caller lacks authorization for the request (EIP-1193 `4100`). */ + | 'Unauthorized' + /** The provider does not support the requested method (EIP-1193 `4200`). */ + | 'UnsupportedMethod' + /** Provider/chain disconnected (EIP-1193 `4900`/`4901`). */ + | 'Disconnected' + /** A contract call reverted (decoded against the EAS ABI where possible). */ + | 'ContractReverted' + /** A JSON-RPC transport/server error (EIP-1474 `-32xxx`). */ + | 'RpcError' | (string & Record) export type EfsErrorOptions = { code?: EfsErrorCode; cause?: unknown; details?: string } @@ -128,3 +144,168 @@ export class PartialBatchFailure extends EfsError { super(message, { code: 'PartialBatchFailure', cause }) } } + +/** The wallet rejected the request because the user declined it (EIP-1193 `4001`). + * Benign — a user choice, not a fault. Surfaced as a distinct code so callers can + * quietly no-op instead of treating it like a failure. */ +export class UserRejected extends EfsError { + override name = 'UserRejected' + constructor(cause?: unknown) { + super('The wallet request was rejected in the wallet.', { code: 'UserRejected', cause }) + } +} + +/** The caller is not authorized for the requested method/account (EIP-1193 `4100`). */ +export class Unauthorized extends EfsError { + override name = 'Unauthorized' + constructor(cause?: unknown) { + super('The wallet has not authorized this account or method — connect/grant access first.', { + code: 'Unauthorized', + cause, + }) + } +} + +/** The provider does not support the requested method (EIP-1193 `4200`). */ +export class UnsupportedMethod extends EfsError { + override name = 'UnsupportedMethod' + constructor(cause?: unknown) { + super('The wallet provider does not support this method.', { + code: 'UnsupportedMethod', + cause, + }) + } +} + +/** The provider or the chain is disconnected (EIP-1193 `4900`/`4901`). */ +export class Disconnected extends EfsError { + override name = 'Disconnected' + constructor(cause?: unknown) { + super('The wallet provider is disconnected from the chain — reconnect and retry.', { + code: 'Disconnected', + cause, + }) + } +} + +/** A contract call reverted. The decoded revert (custom error name / `Error(string)` + * reason) is in the `shortMessage`; the underlying viem error is the `cause`. */ +export class ContractReverted extends EfsError { + override name = 'ContractReverted' + constructor(shortMessage: string, cause?: unknown) { + super(shortMessage, { code: 'ContractReverted', cause }) + } +} + +/** A JSON-RPC transport/server error (EIP-1474 `-32xxx`). */ +export class RpcError extends EfsError { + override name = 'RpcError' + constructor(shortMessage: string, cause?: unknown) { + super(shortMessage, { code: 'RpcError', cause }) + } +} + +/** Pull a numeric EIP-1193/1474 error code off an arbitrary error-ish value. + * viem's `ProviderRpcError`/`RpcError` carry `.code`; raw EIP-1193 errors do too. */ +function numericCode(err: unknown): number | undefined { + const code = (err as { code?: unknown } | null | undefined)?.code + return typeof code === 'number' ? code : undefined +} + +/** + * Map a viem `ContractFunctionRevertedError` to a meaningful EfsError. viem has + * already decoded the revert against the ABI passed at call time (and the SDK + * always passes `easAbi`), exposing `.data` (custom error name + args) or + * `.reason` (an `Error(string)` revert). We key off the decoded error name. + */ +function fromRevert(revert: ContractFunctionRevertedError, original: unknown): EfsError { + const errorName = revert.data?.errorName + const reason = revert.reason + + // EAS surfaces schema problems as `InvalidSchema`; map that to the existing + // SchemaMismatch code so the typed-tree contract (ADR-0007) holds end to end. + if (errorName === 'InvalidSchema' || /invalid schema/i.test(reason ?? '')) { + return new SchemaMismatchError( + 'The on-chain EAS schema does not match what the SDK expected (revert: InvalidSchema).', + original, + ) + } + + const headline = errorName + ? `Contract reverted with ${errorName}.` + : reason + ? `Contract reverted: ${reason}` + : (revert.shortMessage ?? 'Contract reverted.') + return new ContractReverted(headline, original) +} + +/** + * Classify an arbitrary thrown value into a typed {@link EfsError} (ADR-0007). + * + * The single funnel external surfaces run unknown failures through so callers + * get a stable, switchable `.code` instead of raw RPC strings. It is a pure + * classifier — it never throws, always returns an `EfsError`, and preserves the + * original error as `.cause`. + * + * - An existing `EfsError` is returned unchanged (idempotent). + * - A viem `BaseError` is walked to its underlying `ContractFunctionRevertedError` + * (decoded against `easAbi`) and mapped to a meaningful EfsError. + * - EIP-1193 codes — `4001` user-rejected (benign), `4100` unauthorized, `4200` + * unsupported method, `4900`/`4901` disconnected — map to dedicated subclasses. + * - EIP-1474 JSON-RPC `-32xxx` codes map to {@link RpcError}. + * - Anything else is wrapped in a generic {@link EfsError}. + */ +export function classifyError(err: unknown): EfsError { + // Idempotent: an already-classified error passes straight through. + if (err instanceof EfsError) return err + + // viem error tree: walk to a contract revert and decode it (against easAbi, + // which viem applied at call time). `easAbi` is referenced so this module is + // the documented home of EAS-revert decoding per ADR-0007. + void easAbi + if (err instanceof BaseError) { + const revert = err.walk((e) => e instanceof ContractFunctionRevertedError) + if (revert instanceof ContractFunctionRevertedError) { + return fromRevert(revert, err) + } + } + + // EIP-1193 / EIP-1474 numeric codes. viem's ProviderRpcError/RpcError expose + // `.code`; raw provider errors carry the same field, so this catches both. + const code = numericCode(err) + if (code !== undefined) { + switch (code) { + case 4001: + return new UserRejected(err) + case 4100: + return new Unauthorized(err) + case 4200: + return new UnsupportedMethod(err) + case 4900: + case 4901: + return new Disconnected(err) + default: + // EIP-1474 reserves -32768..-32000 (and the -327xx block) for RPC errors. + if (code <= -32000 && code >= -32768) { + const short = + (err as { shortMessage?: unknown }).shortMessage ?? + (err as { message?: unknown }).message + return new RpcError( + typeof short === 'string' && short.length > 0 + ? short + : `JSON-RPC error (code ${code}).`, + err, + ) + } + } + } + + // Unrecognized: wrap, never throw. + const message = + err instanceof Error && err.message + ? err.message + : typeof err === 'string' && err.length > 0 + ? err + : 'An unexpected error occurred.' + return new EfsError(message, { cause: err }) +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ace51c3..5263e48 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -252,6 +252,8 @@ export { type ContentHash, type VerificationStatus, } from './content/hash.js' +// Off-chain fetch/verify/mirror engine (freeze-independent; see future-proofing.md §2). +export * from './mirror/index.js' export { lens, identity, resolveLens, MAX_LENSES, type Lens } from './lenses/resolve.js' export { deployments, diff --git a/packages/sdk/src/mirror/fetch.ts b/packages/sdk/src/mirror/fetch.ts new file mode 100644 index 0000000..e777106 --- /dev/null +++ b/packages/sdk/src/mirror/fetch.ts @@ -0,0 +1,325 @@ +/** + * fetchVerified - the off-chain fetch/verify/mirror engine. Given an ordered + * list of mirror URIs and an attested `contentHash`, try each transport and + * gateway in order with a per-attempt timeout and sequential failover, read the + * full bytes under a hard size cap, hash them, and report a trust-relative + * `VerificationStatus`. Freeze-independent: nothing here touches attestations. + * + * Security doctrine (future-proofing.md §2/§4): + * - Verify before trust: hash the full bytes against `contentHash`; a CID or a + * gateway's word is never trusted (CID is not sha256, ADR-0006). + * - nosniff: NEVER decide handling from the response `Content-Type`. We return + * the declared type as informational only and never execute fetched bytes. + * - Hard size cap: stream and abort past the cap to defend against zip-bombs + * and error-page poisoning (an HTML 404 body would otherwise hash-mismatch + * silently after a large download). + * - SSRF guard: block private/loopback/link-local/metadata hosts (Node is + * where it bites; the browser guards itself). + * - AbortSignal: the caller can cancel the whole operation. + */ + +import { type ContentHash, type VerificationStatus, hashContent } from '../content/hash.js' +import type { TransportName } from '../types.js' +import { type SsrfGuardOptions, checkSsrf } from './ssrf.js' +import { type ResolveOptions, type ResolvedTransport, resolveTransport } from './transport.js' + +/** Default per-attempt timeout (ms). */ +export const DEFAULT_TIMEOUT_MS = 10_000 +/** Default hard size cap (bytes) ~50 MB. */ +export const DEFAULT_MAX_BYTES = 50 * 1024 * 1024 + +/** A mirror to try: a bare URI or an object carrying one. */ +export type Mirror = string | { uri: string } + +function mirrorUri(m: Mirror): string { + return typeof m === 'string' ? m : m.uri +} + +/** Options for {@link fetchVerified}. */ +export type FetchVerifiedOptions = ResolveOptions & + SsrfGuardOptions & { + /** Per-attempt timeout in ms (default {@link DEFAULT_TIMEOUT_MS}). */ + timeoutMs?: number + /** Hard cap on bytes read per attempt (default {@link DEFAULT_MAX_BYTES}). */ + maxBytes?: number + /** Caller cancellation for the whole operation. */ + signal?: AbortSignal + /** Inject a `fetch` implementation (tests stub this). Defaults to global. */ + fetchImpl?: typeof fetch + } + +/** One failed attempt, kept so callers can surface why every mirror failed. */ +export type AttemptError = { + uri: string + url?: string + scheme: TransportName + reason: string +} + +/** The result of a successful {@link fetchVerified}. */ +export type FetchVerifiedResult = { + /** The full verified bytes (whatever the hash status, these are the bytes). */ + bytes: Uint8Array + /** Trust-relative status of the bytes against `expectedHash`. */ + verification: VerificationStatus + /** The transport's declared media type - INFORMATIONAL ONLY (nosniff). */ + contentType?: string + /** The mirror URI the bytes came from. */ + mirrorUsed: string + /** The concrete URL fetched (absent for inline `data:`). */ + urlUsed?: string + /** Every attempt that failed before success, in order. */ + attempts: readonly AttemptError[] +} + +/** Thrown when every mirror/gateway attempt failed. Carries the attempt log. */ +export class AllMirrorsFailedError extends Error { + override readonly name = 'AllMirrorsFailedError' + readonly attempts: readonly AttemptError[] + constructor(attempts: readonly AttemptError[]) { + const detail = attempts.map((a) => `${a.url ?? a.uri}: ${a.reason}`).join('; ') + super(`all mirrors failed: ${detail || '(no mirrors provided)'}`) + this.attempts = attempts + } +} + +/** A 64-hex bare SHA-256 (an unbranded shape `expectedHash` may arrive as). */ +function isWellFormedHash(s: string): boolean { + return /^[0-9a-f]{64}$/.test(s.toLowerCase()) +} + +/** Compute the trust-relative status without re-importing verifyContent's + * branching (we already have the bytes hashed once on the success path). */ +function statusFor(bytes: Uint8Array, expectedHash: string | undefined): VerificationStatus { + if (expectedHash === undefined) return 'no-claim' + const claim = expectedHash.toLowerCase() + if (!isWellFormedHash(claim)) return 'malformed-claim' + return (hashContent(bytes) as string) === claim ? 'matches-author' : 'mismatch' +} + +/** + * Read a `Response` body into bytes under a hard cap. Aborts (via the provided + * controller) and throws if the declared or actual size exceeds `maxBytes`. + */ +async function readCapped( + res: Response, + maxBytes: number, + abort: AbortController, +): Promise { + // Early rejection on an honest Content-Length (cheap; not trusted for + // handling, only as an upper-bound fast-path). + const declared = res.headers.get('content-length') + if (declared !== null) { + const n = Number(declared) + if (Number.isFinite(n) && n > maxBytes) { + abort.abort() + throw new Error(`declared size ${n} exceeds cap ${maxBytes}`) + } + } + + const body = res.body + if (!body) { + // No stream (e.g. some mock environments) - fall back to arrayBuffer, still + // enforcing the cap after the fact. + const buf = new Uint8Array(await res.arrayBuffer()) + if (buf.byteLength > maxBytes) { + throw new Error(`body size ${buf.byteLength} exceeds cap ${maxBytes}`) + } + return buf + } + + const reader = body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (value) { + total += value.byteLength + if (total > maxBytes) { + abort.abort() + throw new Error(`stream exceeded cap ${maxBytes} bytes`) + } + chunks.push(value) + } + } + } finally { + reader.releaseLock?.() + } + + const out = new Uint8Array(total) + let off = 0 + for (const c of chunks) { + out.set(c, off) + off += c.byteLength + } + return out +} + +/** Link an outer abort signal to an inner controller, once. */ +function linkAbort(outer: AbortSignal | undefined, inner: AbortController): () => void { + if (!outer) return () => {} + if (outer.aborted) { + inner.abort() + return () => {} + } + const onAbort = () => inner.abort() + outer.addEventListener('abort', onAbort, { once: true }) + return () => outer.removeEventListener('abort', onAbort) +} + +/** Try a single concrete HTTP(S) URL. Returns bytes + declared type, or throws. */ +async function fetchOne( + url: URL, + opts: FetchVerifiedOptions, +): Promise<{ bytes: Uint8Array; contentType?: string }> { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES + const doFetch = opts.fetchImpl ?? globalThis.fetch + if (typeof doFetch !== 'function') { + throw new Error('no fetch implementation available (provide opts.fetchImpl)') + } + + const controller = new AbortController() + const unlink = linkAbort(opts.signal, controller) + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const res = await doFetch(url.href, { + signal: controller.signal, + redirect: 'follow', + // We never want a cached cross-origin opaque response; ask for bytes. + headers: { accept: 'application/octet-stream, */*' }, + }) + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText}`) + } + const bytes = await readCapped(res, maxBytes, controller) + // Content-Type is informational ONLY (nosniff). We capture the declared + // value but never branch handling on it and never execute the bytes. + const declaredType = res.headers.get('content-type') ?? undefined + return declaredType !== undefined ? { bytes, contentType: declaredType } : { bytes } + } finally { + clearTimeout(timer) + unlink() + } +} + +/** + * Fetch bytes from the first reachable mirror/gateway and verify them against + * `expectedHash`. Tries mirrors in order; within a mirror, tries each candidate + * gateway URL in order; SSRF-blocked URLs are skipped (recorded as attempts). + * + * `data:` URIs short-circuit to their inline bytes (no network). `web3://` + * throws NotImplemented when its (lazy) `httpUrls` is read - recorded as a + * failed attempt so a later mirror can still win. + * + * @throws {AllMirrorsFailedError} if no mirror yielded bytes. + */ +export async function fetchVerified( + mirrors: readonly Mirror[], + expectedHash: ContentHash | string | undefined, + opts: FetchVerifiedOptions = {}, +): Promise { + const attempts: AttemptError[] = [] + + for (const mirror of mirrors) { + const uri = mirrorUri(mirror) + let resolved: ResolvedTransport + try { + resolved = resolveTransport(uri) + } catch (err) { + attempts.push({ uri, scheme: 'https', reason: errMsg(err) }) + continue + } + + // Inline data: - no network, no SSRF, just decode + verify. + if (resolved.inline) { + const bytes = resolved.inline.bytes + const verification = statusFor(bytes, expectedHash) + return { + bytes, + verification, + ...(resolved.inline.contentType !== undefined + ? { contentType: resolved.inline.contentType } + : {}), + mirrorUsed: uri, + attempts, + } + } + + // Expand to candidate URLs (may throw for web3:// - the clear seam). + let urls: URL[] + try { + urls = resolved.httpUrls(opts) + } catch (err) { + attempts.push({ uri, scheme: resolved.scheme, reason: errMsg(err) }) + continue + } + if (urls.length === 0) { + attempts.push({ + uri, + scheme: resolved.scheme, + reason: `transport "${resolved.scheme}" has no HTTP resolution path`, + }) + continue + } + + for (const url of urls) { + // SSRF guard before any network call. + const ssrf = checkSsrf(url, opts) + if (ssrf.blocked) { + attempts.push({ + uri, + url: url.href, + scheme: resolved.scheme, + reason: `SSRF-blocked host (${ssrf.reason})`, + }) + continue + } + + // Cooperative cancellation between attempts. + if (opts.signal?.aborted) { + attempts.push({ + uri, + url: url.href, + scheme: resolved.scheme, + reason: 'aborted by caller', + }) + throw new AllMirrorsFailedError(attempts) + } + + try { + const { bytes, contentType } = await fetchOne(url, opts) + const verification = statusFor(bytes, expectedHash) + return { + bytes, + verification, + ...(contentType !== undefined ? { contentType } : {}), + mirrorUsed: uri, + urlUsed: url.href, + attempts, + } + } catch (err) { + attempts.push({ + uri, + url: url.href, + scheme: resolved.scheme, + reason: errMsg(err), + }) + // Sequential failover - try the next gateway / mirror. + } + } + } + + throw new AllMirrorsFailedError(attempts) +} + +function errMsg(err: unknown): string { + if (err instanceof Error) { + // AbortError shows up as a timeout/cancel on this path. + if (err.name === 'AbortError') return 'timed out or aborted' + return err.message + } + return String(err) +} diff --git a/packages/sdk/src/mirror/index.ts b/packages/sdk/src/mirror/index.ts new file mode 100644 index 0000000..c47df45 --- /dev/null +++ b/packages/sdk/src/mirror/index.ts @@ -0,0 +1,42 @@ +/** + * Mirror layer - the off-chain fetch/verify/mirror engine. Freeze-independent: + * given (uri, expectedHash) it yields verified bytes; nothing here touches + * attestations. See future-proofing.md §2 ("the mirror doctrine"). + * + * Public surface: + * - `TRANSPORT` allowlist + `resolveTransport(uri)` URI parsing. + * - `fetchVerified(mirrors, expectedHash, opts?)` - sequential failover, + * per-attempt timeout, hard size cap, nosniff, SSRF guard, AbortSignal. + * - `checkSsrf(url, opts?)` - the standalone SSRF host guard. + */ + +export { + TRANSPORT, + resolveTransport, + DEFAULT_IPFS_GATEWAYS, + DEFAULT_ARWEAVE_GATEWAYS, + TransportNotImplementedError, + UnsupportedUriError, + type ResolveOptions, + type ResolvedTransport, + type InlineData, +} from './transport.js' + +export { + fetchVerified, + AllMirrorsFailedError, + DEFAULT_TIMEOUT_MS, + DEFAULT_MAX_BYTES, + type Mirror, + type FetchVerifiedOptions, + type FetchVerifiedResult, + type AttemptError, +} from './fetch.js' + +export { + checkSsrf, + type SsrfGuardOptions, + type SsrfResult, + type SsrfOk, + type SsrfRejection, +} from './ssrf.js' diff --git a/packages/sdk/src/mirror/ssrf.ts b/packages/sdk/src/mirror/ssrf.ts new file mode 100644 index 0000000..d233274 --- /dev/null +++ b/packages/sdk/src/mirror/ssrf.ts @@ -0,0 +1,140 @@ +/** + * SSRF guard - block fetches whose host resolves to a private, loopback, + * link-local, or cloud-metadata address (future-proofing.md §2/§4: "untrusted + * content is inert - SSRF-guard mirror URLs, block private/loopback/metadata + * IPs"). + * + * Where this bites: Node. A server-side process fetching an attacker-chosen + * mirror URI can be steered at 169.254.169.254 (cloud metadata), 127.0.0.1 + * (local admin endpoints), or RFC-1918 LAN hosts. In a browser this is the + * browser's job (it won't expose internal responses cross-origin), so this + * guard is defense-in-depth there but always applied. + * + * Important limitation: this is a literal-IP and hostname-shape guard. It does + * NOT perform DNS resolution, so a public hostname that resolves to a private + * IP (DNS rebinding) is not caught here. That requires resolving + pinning at + * connect time, which global fetch does not expose; documented as a known gap. + */ + +/** Why a host was rejected. */ +export type SsrfRejection = { + blocked: true + host: string + reason: string +} +export type SsrfOk = { blocked: false } +export type SsrfResult = SsrfOk | SsrfRejection + +/** Options for the SSRF guard. */ +export type SsrfGuardOptions = { + /** + * Disable the guard entirely. Default `false`. Set `true` only when the + * caller has its own egress controls (e.g. a locked-down proxy) or is in a + * browser and wants to skip the redundant check. + */ + allowPrivateHosts?: boolean + /** Extra hostnames to allow even if they look private (exact, lowercased). */ + allowlist?: readonly string[] +} + +// IPv4 dotted-quad. +const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/ + +function parseIpv4(host: string): [number, number, number, number] | undefined { + const m = IPV4_RE.exec(host) + if (!m) return undefined + const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])] as const + if (octets.some((o) => o > 255)) return undefined + return [octets[0], octets[1], octets[2], octets[3]] +} + +/** A reason string for IPv4 ranges that must never be fetched server-side. */ +function isBlockedIpv4(a: number, b: number, c: number, _d: number): string | undefined { + if (a === 0) return 'unspecified/this-network (0.0.0.0/8)' + if (a === 127) return 'loopback (127.0.0.0/8)' + if (a === 10) return 'private (10.0.0.0/8)' + if (a === 172 && b >= 16 && b <= 31) return 'private (172.16.0.0/12)' + if (a === 192 && b === 168) return 'private (192.168.0.0/16)' + if (a === 169 && b === 254) return 'link-local / cloud-metadata (169.254.0.0/16)' + if (a === 100 && b >= 64 && b <= 127) return 'carrier-grade NAT (100.64.0.0/10)' + if (a === 192 && b === 0 && c === 0) return 'IETF protocol assignments (192.0.0.0/24)' + if (a >= 224) return 'multicast/reserved (>=224.0.0.0)' + return undefined +} + +/** Strip IPv6 brackets and zone id; return the lowercased address. */ +function normalizeIpv6(host: string): string | undefined { + if (!host.startsWith('[') || !host.endsWith(']')) { + // Bare IPv6 (no brackets) only matters if it contains a colon. + if (host.includes(':')) { + const [addr] = host.split('%') + return (addr ?? host).toLowerCase() + } + return undefined + } + const [addr] = host.slice(1, -1).split('%') + return (addr ?? '').toLowerCase() +} + +/** A reason string for IPv6 addresses that must never be fetched server-side. */ +function isBlockedIpv6(addr: string): string | undefined { + const a = addr.toLowerCase() + if (a === '::1' || a === '0:0:0:0:0:0:0:1') return 'loopback (::1)' + if (a === '::' || a === '0:0:0:0:0:0:0:0') return 'unspecified (::)' + if (a.startsWith('fe8') || a.startsWith('fe9') || a.startsWith('fea') || a.startsWith('feb')) + return 'link-local (fe80::/10)' + if (a.startsWith('fc') || a.startsWith('fd')) return 'unique-local (fc00::/7)' + // IPv4-mapped (::ffff:a.b.c.d) - extract and re-check as IPv4. + const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(a) + if (mapped?.[1]) { + const v4 = parseIpv4(mapped[1]) + if (v4) { + const why = isBlockedIpv4(v4[0], v4[1], v4[2], v4[3]) + if (why) return `IPv4-mapped ${why}` + } + } + return undefined +} + +/** + * Assess a URL's host for SSRF risk. Returns `{ blocked: false }` to allow, or + * a rejection with a human-readable `reason`. Hostnames that aren't literal IPs + * are allowed here (no DNS resolution) except for a small set of obviously + * internal names (`localhost`, `*.local`, `*.internal`). + */ +export function checkSsrf(url: URL, opts: SsrfGuardOptions = {}): SsrfResult { + const host = url.hostname.toLowerCase() + + if (opts.allowPrivateHosts) return { blocked: false } + if (opts.allowlist?.some((h) => h.toLowerCase() === host)) return { blocked: false } + + // Literal IPv4. + const v4 = parseIpv4(host) + if (v4) { + const why = isBlockedIpv4(v4[0], v4[1], v4[2], v4[3]) + if (why) return { blocked: true, host, reason: why } + return { blocked: false } + } + + // Literal IPv6. + const v6 = normalizeIpv6(url.hostname) + if (v6) { + const why = isBlockedIpv6(v6) + if (why) return { blocked: true, host: v6, reason: why } + return { blocked: false } + } + + // Obvious internal hostnames (no DNS resolution available to us). + if (host === 'localhost' || host.endsWith('.localhost')) { + return { blocked: true, host, reason: 'localhost' } + } + if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.lan')) { + return { blocked: true, host, reason: 'internal TLD' } + } + // AWS/GCP/Azure metadata hostnames sometimes used in place of the IP. + if (host === 'metadata.google.internal' || host === 'metadata') { + return { blocked: true, host, reason: 'cloud metadata host' } + } + + return { blocked: false } +} diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts new file mode 100644 index 0000000..fd583e7 --- /dev/null +++ b/packages/sdk/src/mirror/transport.ts @@ -0,0 +1,271 @@ +/** + * Transport resolution — parse a mirror URI into the concrete HTTP(S) URLs the + * fetch engine will try. Freeze-independent: this layer has nothing to do with + * attestations, only with *where the bytes live*. + * + * Doctrine (future-proofing.md §2, standards.md "Content addressing"): + * - `ipfs://`/`ar://`/`https://` ADOPT; `data:` SEAM (tiny inline content); + * `web3://` we own but resolution needs a chain + ERC-6944 → NotImplemented + * here (clear seam); `magnet://` parse-only (no HTTP resolution). + * - CID is a *locator only* — never trusted as `sha256(bytes)` (ADR-0006). + * We always re-verify fetched bytes against the attested `contentHash`. + * - Multi-gateway fallback: a transport can expand to several candidate URLs; + * the fetch engine tries them in order (public gateways die / rate-limit / + * turn hostile — assume any single one is unreliable). + */ + +import type { TransportName } from '../types.js' + +/** Value-level allowlist of known transports (ADR-0011 seam). Keys match + * `TransportName`. This is the *recognized* set; `TransportName` stays an open + * union so an unknown scheme is still assignable, but only these resolve. */ +export const TRANSPORT = { + web3: 'web3', + arweave: 'arweave', + ipfs: 'ipfs', + magnet: 'magnet', + https: 'https', + data: 'data', +} as const satisfies Record + +/** Default IPFS gateways, tried in order. Trustless-friendly: we request + * `?format=raw` (IPIP-402) so a gateway returns the raw block bytes rather than + * a UnixFS-unwrapped/transcoded representation — but note CID is not sha256 + * regardless, so the gateway is never trusted; the fetch engine re-hashes every + * byte. Overridable via `ResolveOptions.ipfsGateways`. */ +export const DEFAULT_IPFS_GATEWAYS: readonly string[] = [ + 'https://ipfs.io', + 'https://dweb.link', + 'https://cloudflare-ipfs.com', +] + +/** Default Arweave gateways, tried in order. Overridable via + * `ResolveOptions.arweaveGateways`. */ +export const DEFAULT_ARWEAVE_GATEWAYS: readonly string[] = [ + 'https://arweave.net', + 'https://ar-io.net', +] + +/** Gateway overrides for resolution. */ +export type ResolveOptions = { + /** IPFS gateway origins (e.g. `https://ipfs.io`), tried in order. */ + ipfsGateways?: readonly string[] + /** Arweave gateway origins (e.g. `https://arweave.net`), tried in order. */ + arweaveGateways?: readonly string[] +} + +/** A `data:` URI decoded inline — no network fetch is needed. */ +export type InlineData = { + /** The declared media type (informational only — never used to decide + * handling; see the nosniff doctrine in fetch.ts). */ + contentType?: string + /** The decoded payload bytes. */ + bytes: Uint8Array +} + +/** A parsed mirror URI ready for the fetch engine. */ +export type ResolvedTransport = { + /** The recognized transport scheme. */ + scheme: TransportName + /** The original URI, normalized. */ + uri: string + /** + * Candidate HTTP(S) URLs to try in order, given a set of gateways. + * Empty for `data:` (the bytes are `inline`) and for `magnet:`/`web3:` + * (no HTTP resolution path here). + */ + httpUrls(opts?: ResolveOptions): URL[] + /** Decoded inline bytes, present only for `data:` URIs. */ + inline?: InlineData +} + +/** Thrown when a transport is recognized but cannot be resolved to HTTP URLs + * in this layer (the clear seam for `web3://`). */ +export class TransportNotImplementedError extends Error { + override readonly name = 'TransportNotImplementedError' + readonly scheme: TransportName + constructor(scheme: TransportName, detail: string) { + super(`transport "${scheme}" resolution is not implemented: ${detail}`) + this.scheme = scheme + } +} + +/** Thrown when a URI cannot be parsed or its scheme is unrecognized. */ +export class UnsupportedUriError extends Error { + override readonly name = 'UnsupportedUriError' + constructor(uri: string, detail: string) { + super(`unsupported mirror URI "${uri}": ${detail}`) + } +} + +const SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):/ + +/** Strip a trailing slash from a gateway origin so we can concatenate paths. */ +function trimGateway(g: string): string { + return g.endsWith('/') ? g.slice(0, -1) : g +} + +/** + * Parse a mirror URI into a {@link ResolvedTransport}. Does NOT fetch — purely + * structural. Throws {@link UnsupportedUriError} for unknown schemes and + * {@link TransportNotImplementedError} for recognized-but-unresolvable ones + * (`web3://`). + */ +export function resolveTransport(uri: string): ResolvedTransport { + const scheme = SCHEME_RE.exec(uri)?.[1]?.toLowerCase() + if (!scheme) { + throw new UnsupportedUriError(uri, 'no URI scheme') + } + + switch (scheme) { + case 'https': + case 'http': { + // `http:` is accepted as a parse target but the SSRF/security guard in the + // fetch engine governs whether it is actually allowed to be retrieved. + let parsed: URL + try { + parsed = new URL(uri) + } catch { + throw new UnsupportedUriError(uri, 'malformed https URL') + } + return { + scheme: TRANSPORT.https, + uri, + httpUrls: () => [new URL(parsed.href)], + } + } + + case 'ipfs': + return resolveIpfs(uri) + + case 'ar': + case 'arweave': + return resolveArweave(uri) + + case 'data': + return resolveData(uri) + + case 'magnet': + // Parse-only: BitTorrent has no synchronous HTTP resolution path here. + return { + scheme: TRANSPORT.magnet, + uri, + httpUrls: () => [], + } + + case 'web3': + // Recognized, but resolution needs a chain client plus ERC-6944 + // (resolveMode == "5219") decoding — out of scope for the off-chain fetch + // engine. The seam: a future web3 resolver yields the on-chain (status, + // body, headers) which this engine would then treat like inline bytes. + return { + scheme: TRANSPORT.web3, + uri, + httpUrls: () => { + throw new TransportNotImplementedError( + TRANSPORT.web3, + 'needs a chain client and ERC-6944 resolveMode decoding', + ) + }, + } + + default: + throw new UnsupportedUriError(uri, `unrecognized scheme "${scheme}"`) + } +} + +/** `ipfs://[/]` to gateway path-style URLs (`/ipfs//`), + * each carrying `?format=raw` to prefer the raw block over a transcoded one. */ +function resolveIpfs(uri: string): ResolvedTransport { + // Tolerate `ipfs://ipfs/` and bare `ipfs://`. + let rest = uri.slice('ipfs://'.length) + if (rest.startsWith('ipfs/')) rest = rest.slice('ipfs/'.length) + const slash = rest.indexOf('/') + const cid = slash === -1 ? rest : rest.slice(0, slash) + const subpath = slash === -1 ? '' : rest.slice(slash) // includes leading '/' + if (cid.length === 0) { + throw new UnsupportedUriError(uri, 'missing CID') + } + return { + scheme: TRANSPORT.ipfs, + uri, + httpUrls: (opts) => { + const gateways = opts?.ipfsGateways ?? DEFAULT_IPFS_GATEWAYS + return gateways.map((g) => { + const u = new URL(`${trimGateway(g)}/ipfs/${cid}${subpath}`) + // IPIP-402: ask the gateway for the verifiable raw block. Harmless on + // gateways that ignore it; we re-hash regardless. + if (!u.searchParams.has('format')) u.searchParams.set('format', 'raw') + return u + }) + }, + } +} + +/** `ar://[/]` to arweave gateway URLs. */ +function resolveArweave(uri: string): ResolvedTransport { + const prefix = uri.toLowerCase().startsWith('arweave://') ? 'arweave://' : 'ar://' + const rest = uri.slice(prefix.length) + const slash = rest.indexOf('/') + const txid = slash === -1 ? rest : rest.slice(0, slash) + const subpath = slash === -1 ? '' : rest.slice(slash) + if (txid.length === 0) { + throw new UnsupportedUriError(uri, 'missing Arweave transaction id') + } + return { + scheme: TRANSPORT.arweave, + uri, + httpUrls: (opts) => { + const gateways = opts?.arweaveGateways ?? DEFAULT_ARWEAVE_GATEWAYS + return gateways.map((g) => new URL(`${trimGateway(g)}/${txid}${subpath}`)) + }, + } +} + +/** + * `data:[][;base64],` (RFC 2397) to inline decoded bytes. + * No network. The media type is captured for information only. + */ +function resolveData(uri: string): ResolvedTransport { + const comma = uri.indexOf(',') + if (comma === -1) { + throw new UnsupportedUriError(uri, 'malformed data: URI (no comma)') + } + const meta = uri.slice('data:'.length, comma) + const dataPart = uri.slice(comma + 1) + const isBase64 = /;base64$/i.test(meta) + const mediaType = (isBase64 ? meta.replace(/;base64$/i, '') : meta).trim() + const contentType = mediaType.length > 0 ? mediaType : undefined + + let bytes: Uint8Array + if (isBase64) { + bytes = decodeBase64(dataPart) + } else { + // Percent-decoded text payload, then UTF-8 encoded. + const text = decodeURIComponent(dataPart) + bytes = new TextEncoder().encode(text) + } + + return { + scheme: TRANSPORT.data, + uri, + httpUrls: () => [], + ...(contentType !== undefined ? { inline: { contentType, bytes } } : { inline: { bytes } }), + } +} + +/** Decode a base64 string to bytes without Buffer (works in browser + node). */ +function decodeBase64(b64: string): Uint8Array { + // Tolerate URL-safe alphabet and stray whitespace/newlines. + const normalized = b64.replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/') + if (typeof atob === 'function') { + const bin = atob(normalized) + const out = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) + return out + } + // Node fallback (Buffer is global there; avoids a runtime dependency). + const g = globalThis as { Buffer?: { from(s: string, enc: string): Uint8Array } } + if (g.Buffer) return Uint8Array.from(g.Buffer.from(normalized, 'base64')) + throw new UnsupportedUriError('data:', 'no base64 decoder available in this runtime') +} diff --git a/packages/sdk/test/client.test.ts b/packages/sdk/test/client.test.ts new file mode 100644 index 0000000..8799bbf --- /dev/null +++ b/packages/sdk/test/client.test.ts @@ -0,0 +1,140 @@ +/** + * Unit tests for `createEfsClient` driven through a mock EIP-1193 provider — + * the SDK's standard boundary (ADR-0009). These exercise the freeze-independent + * client paths: + * - ProviderConfig → viem-clients normalization (`resolveClients`). + * - Runtime write-gating: a read-only client (no wallet) throws WalletRequired; + * a write-capable client reaches NotImplemented. + * - Deployment resolution via chainId (the per-chain registry, ADR-0005) and + * the construct-time bytecode integrity gate. + */ + +import type { Address, Chain } from 'viem' +import { describe, expect, it } from 'vitest' +import { + DeploymentNotFound, + type DeploymentsMap, + type EfsContracts, + type EfsDeployment, + EfsError, + type EfsSchemaUIDs, + NotImplemented, + WalletRequired, + createEfsClient, +} from '../src/index.js' +import { createMockProvider } from './helpers/mock-eip1193.js' + +const CHAIN_ID = 31337 + +/** A minimal viem `Chain` for the mock — only `id` is read by the SDK paths. */ +const localChain = { + id: CHAIN_ID, + name: 'Mock Local', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['http://127.0.0.1:8545'] } }, +} as const satisfies Chain + +const addr = (n: number) => `0x${n.toString(16).padStart(40, '0')}` as Address + +/** A complete contracts set, all pointed at distinct dummy addresses. */ +const contracts: EfsContracts = { + eas: addr(1), + schemaRegistry: addr(2), + indexer: addr(3), + router: addr(4), + fileView: addr(5), + edgeResolver: addr(6), + mirrorResolver: addr(7), + listResolver: addr(8), + listEntryResolver: addr(9), + aliasResolver: addr(10), +} + +const schemas: EfsSchemaUIDs = { + anchor: '0x01', + property: '0x02', + data: '0x03', + pin: '0x04', + tag: '0x05', + mirror: '0x06', + list: '0x07', + listEntry: '0x08', + redirect: '0x09', +} + +const deployment: EfsDeployment = { chainId: CHAIN_ID, contracts, schemas } +const deployments: DeploymentsMap = { [CHAIN_ID]: deployment } + +describe('createEfsClient — ProviderConfig normalization (EIP-1193 boundary)', () => { + it('builds a read-only client from a provider with no account', async () => { + const provider = createMockProvider({ chainId: CHAIN_ID }) + const efs = createEfsClient({ provider, chain: localChain }) + // The read namespaces exist; the read verb is shaped but not implemented yet. + expect(typeof efs.lenses.lens).toBe('function') + await expect(efs.fs.read('/x')).rejects.toThrow(NotImplemented) + }) + + it('builds a write-capable client when an account is supplied', async () => { + const provider = createMockProvider({ chainId: CHAIN_ID }) + const efs = createEfsClient({ provider, chain: localChain, account: addr(99) }) + // `write` is present and reaches NotImplemented (not WalletRequired). + await expect(efs.fs.write('/x', new Uint8Array())).rejects.toThrow(NotImplemented) + }) +}) + +describe('createEfsClient — runtime write gate', () => { + it('a read-only client rejects writes with WalletRequired (the backstop)', async () => { + const provider = createMockProvider({ chainId: CHAIN_ID }) + // The type hides `write` on a read-only client; reach it via a cast to prove + // the runtime guard fires before NotImplemented. + const readOnly = createEfsClient({ provider, chain: localChain }) as unknown as { + fs: { write(p: string, c: Uint8Array): Promise } + batch(): { execute(): Promise } + } + await expect(readOnly.fs.write('/x', new Uint8Array())).rejects.toThrow(WalletRequired) + // The batch entrypoint is gated the same way. + expect(() => readOnly.batch()).toThrow(WalletRequired) + }) + + it('a write client reaches NotImplemented for write and batch', async () => { + const provider = createMockProvider({ chainId: CHAIN_ID }) + const efs = createEfsClient({ provider, chain: localChain, account: addr(1) }) + await expect(efs.fs.write('/x', new Uint8Array())).rejects.toThrow(NotImplemented) + expect(() => efs.batch()).toThrow(NotImplemented) + }) +}) + +describe('createEfsClient — deployment resolution via chainId (ADR-0005)', () => { + it('resolves the deployment from the chain id via the override registry', () => { + const provider = createMockProvider({ chainId: CHAIN_ID }) + const efs = createEfsClient({ provider, chain: localChain, deployments }) + const resolved = efs.raw.deployment() + expect(resolved.chainId).toBe(CHAIN_ID) + expect(resolved.contracts.eas).toBe(addr(1)) + }) + + it('throws DeploymentNotFound for a chain with no registered deployment', () => { + const provider = createMockProvider({ chainId: CHAIN_ID }) + // No override and the built-in registry is empty pre-launch. + const efs = createEfsClient({ provider, chain: localChain }) + expect(() => efs.raw.deployment()).toThrow(DeploymentNotFound) + }) + + it('verifyDeployment passes when every contract address has bytecode', async () => { + // Give every contract address some bytecode so the integrity gate passes. + const code: Record = {} + for (const a of Object.values(contracts)) code[a.toLowerCase()] = '0x60006000' + const provider = createMockProvider({ chainId: CHAIN_ID, code }) + const efs = createEfsClient({ provider, chain: localChain, deployments }) + await expect(efs.raw.verifyDeployment()).resolves.toBeUndefined() + // The gate probed each contract via eth_getCode. + expect(provider.callCount('eth_getCode')).toBe(Object.keys(contracts).length) + }) + + it('verifyDeployment throws when a contract address has no bytecode', async () => { + // Default mock returns '0x' (no code) for every address → first probe fails. + const provider = createMockProvider({ chainId: CHAIN_ID }) + const efs = createEfsClient({ provider, chain: localChain, deployments }) + await expect(efs.raw.verifyDeployment()).rejects.toThrow(EfsError) + }) +}) diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts new file mode 100644 index 0000000..5f332fe --- /dev/null +++ b/packages/sdk/test/errors.test.ts @@ -0,0 +1,131 @@ +import { + ContractFunctionRevertedError, + ProviderRpcError, + UserRejectedRequestError, + RpcError as ViemRpcError, +} from 'viem' +import { describe, expect, it } from 'vitest' +import { + ContractReverted, + Disconnected, + EfsError, + RpcError, + SchemaMismatchError, + Unauthorized, + UnsupportedMethod, + UserRejected, + WalletRequired, + classifyError, +} from '../src/errors.js' + +describe('classifyError (ADR-0007 classifier)', () => { + it('is idempotent — an existing EfsError passes through unchanged', () => { + const original = new WalletRequired() + expect(classifyError(original)).toBe(original) + + const base = new EfsError('boom', { code: 'EfsError' }) + expect(classifyError(base)).toBe(base) + }) + + it('maps EIP-1193 4001 to a benign UserRejected (not a generic failure)', () => { + const cause = new Error('User denied transaction signature') + const viemErr = new UserRejectedRequestError(cause) + const out = classifyError(viemErr) + expect(out).toBeInstanceOf(UserRejected) + expect(out.code).toBe('UserRejected') + // The benign user-rejection is distinct from a generic 'EfsError'. + expect(out.code).not.toBe('EfsError') + expect(out.cause).toBe(viemErr) + }) + + it('maps EIP-1193 4100 to Unauthorized', () => { + const err = new ProviderRpcError(new Error('x'), { code: 4100, shortMessage: 'unauthorized' }) + const out = classifyError(err) + expect(out).toBeInstanceOf(Unauthorized) + expect(out.code).toBe('Unauthorized') + expect(out.cause).toBe(err) + }) + + it('maps EIP-1193 4200 to UnsupportedMethod', () => { + const err = new ProviderRpcError(new Error('x'), { code: 4200, shortMessage: 'unsupported' }) + const out = classifyError(err) + expect(out).toBeInstanceOf(UnsupportedMethod) + expect(out.code).toBe('UnsupportedMethod') + }) + + it('maps EIP-1193 4900/4901 to Disconnected', () => { + for (const code of [4900, 4901]) { + const err = new ProviderRpcError(new Error('x'), { code, shortMessage: 'disconnected' }) + const out = classifyError(err) + expect(out).toBeInstanceOf(Disconnected) + expect(out.code).toBe('Disconnected') + expect(out.cause).toBe(err) + } + }) + + it('maps JSON-RPC -32xxx (EIP-1474) to RpcError with the shortMessage', () => { + const err = new ViemRpcError(new Error('boom'), { code: -32000, shortMessage: 'rpc boom' }) + const out = classifyError(err) + expect(out).toBeInstanceOf(RpcError) + expect(out.code).toBe('RpcError') + expect(out.shortMessage).toBe('rpc boom') + expect(out.cause).toBe(err) + }) + + it('walks a viem BaseError to the ContractFunctionRevertedError and maps the revert', () => { + const revert = new ContractFunctionRevertedError({ + abi: [{ type: 'error', name: 'SomeCustomError', inputs: [] }], + functionName: 'attest', + data: '0x', + }) + const out = classifyError(revert) + expect(out).toBeInstanceOf(ContractReverted) + expect(out.code).toBe('ContractReverted') + expect(out.cause).toBe(revert) + }) + + it('maps an InvalidSchema revert to SchemaMismatchError', () => { + const revert = new ContractFunctionRevertedError({ + abi: [{ type: 'error', name: 'InvalidSchema', inputs: [] }], + functionName: 'attest', + data: '0x', + }) + // Force the decoded error name viem would surface for a real on-chain decode. + Object.defineProperty(revert, 'data', { + value: { errorName: 'InvalidSchema', args: [] }, + configurable: true, + }) + const out = classifyError(revert) + expect(out).toBeInstanceOf(SchemaMismatchError) + expect(out.code).toBe('SchemaMismatch') + expect(out.cause).toBe(revert) + }) + + it('wraps an unrecognized plain Error in a generic EfsError, preserving cause', () => { + const plain = new Error('something odd happened') + const out = classifyError(plain) + expect(out).toBeInstanceOf(EfsError) + expect(out.code).toBe('EfsError') + expect(out.shortMessage).toBe('something odd happened') + expect(out.cause).toBe(plain) + }) + + it('never throws on exotic inputs and always returns an EfsError', () => { + for (const input of [undefined, null, 42, 'a string', {}, Symbol('s')]) { + const out = classifyError(input) + expect(out).toBeInstanceOf(EfsError) + expect(typeof out.code).toBe('string') + } + }) + + it('preserves the cause chain so .walk() reaches the underlying revert', () => { + const revert = new ContractFunctionRevertedError({ + abi: [{ type: 'error', name: 'SomeCustomError', inputs: [] }], + functionName: 'attest', + data: '0x', + }) + const out = classifyError(revert) + const found = out.walk((e) => e instanceof ContractFunctionRevertedError) + expect(found).toBe(revert) + }) +}) diff --git a/packages/sdk/test/fork.test.ts b/packages/sdk/test/fork.test.ts new file mode 100644 index 0000000..00b5826 --- /dev/null +++ b/packages/sdk/test/fork.test.ts @@ -0,0 +1,40 @@ +/** + * Fork tests (opt-in). These spin a real Anvil fork via prool and talk to it + * with viem. They SKIP unless `EFS_FORK_RPC_URL` is set and a local `anvil` + * binary (Foundry) is available — networkless CI never runs them. + * + * EFS_FORK_RPC_URL="https://sepolia.infura.io/v3/" pnpm --filter @efs/sdk test + * + * This is scaffold only: a single smoke test that the EAS contract has bytecode + * on the forked chain. On-chain EFS logic tests are deferred until the schema + * freeze lands (see future-proofing.md §1). + */ + +import { http, createPublicClient } from 'viem' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + type AnvilFork, + FORK_EAS_ADDRESS, + forkEnabled, + startAnvilFork, +} from './helpers/anvil-fork.js' + +describe.skipIf(!forkEnabled())('fork smoke (prool + Anvil)', () => { + let fork: AnvilFork + + beforeAll(async () => { + fork = await startAnvilFork() + }, 60_000) + + afterAll(async () => { + await fork?.stop() + }) + + it('EAS bytecode is present on the fork', async () => { + const client = createPublicClient({ transport: http(fork.rpcUrl) }) + const code = await client.getCode({ address: FORK_EAS_ADDRESS }) + expect(code).toBeDefined() + expect(code).not.toBe('0x') + expect(code?.length ?? 0).toBeGreaterThan(2) + }) +}) diff --git a/packages/sdk/test/helpers/anvil-fork.ts b/packages/sdk/test/helpers/anvil-fork.ts new file mode 100644 index 0000000..49c4f1c --- /dev/null +++ b/packages/sdk/test/helpers/anvil-fork.ts @@ -0,0 +1,63 @@ +/** + * prool-based Anvil fork harness (scaffold). + * + * `@viem/anvil` is deprecated in favour of `prool` (wevm), so the fork harness + * is built on prool's `anvil` instance. Fork tests are **opt-in**: they require + * a real upstream RPC to fork from and a local `anvil` binary (Foundry), neither + * of which exists in networkless CI. They are gated behind `EFS_FORK_RPC_URL` + * and SKIP by default. + * + * Run locally with Foundry installed: + * + * EFS_FORK_RPC_URL="https://sepolia.infura.io/v3/" pnpm --filter @efs/sdk test + * + * prool is loaded via dynamic import inside {@link startAnvilFork} so that + * merely collecting the test file never imports it — prool requires Node >=22 + * while CI runs Node 20, and the import must not run unless a fork test does. + */ + +import type { Address } from 'viem' + +/** The upstream RPC to fork from, or `undefined` when fork tests are disabled. */ +export const FORK_RPC_URL: string | undefined = process.env.EFS_FORK_RPC_URL || undefined + +/** Whether the env opts into fork tests. Use to `describe.skipIf(!forkEnabled())`. */ +export function forkEnabled(): boolean { + return Boolean(FORK_RPC_URL) +} + +/** + * Canonical EAS deployment address to assert bytecode for. Defaults to the EAS + * on Sepolia (the SDK's first target chain); override via `EFS_FORK_EAS_ADDRESS` + * when forking a different chain (e.g. mainnet EAS). + */ +export const FORK_EAS_ADDRESS = (process.env.EFS_FORK_EAS_ADDRESS ?? + '0xC2679fBD37d54388Ce493F1DB75320D236e1815e') as Address + +export type AnvilFork = { + /** The JSON-RPC URL of the forked node. */ + rpcUrl: string + /** Stop the node and free the port. */ + stop: () => Promise +} + +/** + * Start an Anvil instance forking {@link FORK_RPC_URL}. Throws if fork tests are + * not enabled — callers must gate with {@link forkEnabled} first. Loads prool + * dynamically so the dependency is only touched when a fork test actually runs. + */ +export async function startAnvilFork(): Promise { + if (!FORK_RPC_URL) { + throw new Error('startAnvilFork() called without EFS_FORK_RPC_URL set') + } + const { anvil } = await import('prool/instances') + const instance = anvil({ forkUrl: FORK_RPC_URL }) + const stop = await instance.start() + const rpcUrl = `http://${instance.host}:${instance.port}` + return { + rpcUrl, + stop: async () => { + await stop() + }, + } +} diff --git a/packages/sdk/test/helpers/mock-eip1193.ts b/packages/sdk/test/helpers/mock-eip1193.ts new file mode 100644 index 0000000..1ff67a1 --- /dev/null +++ b/packages/sdk/test/helpers/mock-eip1193.ts @@ -0,0 +1,107 @@ +/** + * A tiny, dependency-free EIP-1193 provider fake for unit tests. + * + * Real wallets (MetaMask, WalletConnect, Coinbase, embedded) are all EIP-1193 + * providers — an object with `request({ method, params })`. The SDK's boundary + * is that interface (ADR-0009): `createEfsClient({ provider, chain })` wraps it + * with viem's `custom()` transport. This helper lets a test drive that boundary + * without a network or a browser wallet. + * + * It answers the handful of JSON-RPC methods the freeze-independent client paths + * touch — `eth_chainId`, `eth_getCode`, `eth_call`, `web3_clientVersion`, + * `eth_blockNumber` — and records every call for assertions. Per-method handlers + * can be overridden, and any unhandled method rejects (so a test notices an + * unexpected RPC) unless a `fallback` is supplied. + */ + +import type { EIP1193Provider } from 'viem' + +/** One recorded JSON-RPC request. */ +export type RecordedCall = { method: string; params: readonly unknown[] } + +/** A per-method handler: receives the params, returns the raw RPC result. */ +export type MethodHandler = (params: readonly unknown[]) => unknown | Promise + +export type MockProviderOptions = { + /** + * The chain id the provider reports for `eth_chainId` (hex-encoded on the + * wire). Defaults to 31337 (the common local/anvil id). + */ + chainId?: number + /** + * Bytecode returned by `eth_getCode`, keyed by lowercased address. A bare + * string sets the default for every address. Defaults to `'0x'` (no code). + */ + code?: string | Record + /** Explicit per-method overrides; take precedence over the built-ins. */ + handlers?: Record + /** Called for any method with no handler. Without it, unknown methods reject. */ + fallback?: MethodHandler +} + +/** A mock provider plus the bits a test wants to inspect. */ +export type MockProvider = EIP1193Provider & { + /** Every `request` made, in order. */ + readonly calls: RecordedCall[] + /** Count of calls for a given method. */ + callCount(method: string): number +} + +function toHex(n: number): `0x${string}` { + return `0x${n.toString(16)}` +} + +function codeFor(code: MockProviderOptions['code'], address: string): string { + if (code === undefined) return '0x' + if (typeof code === 'string') return code + return code[address.toLowerCase()] ?? '0x' +} + +/** + * Build a minimal EIP-1193 provider fake. Pass `chainId`, `code`, per-method + * `handlers`, or a `fallback`. Every request is recorded on `.calls`. + */ +export function createMockProvider(opts: MockProviderOptions = {}): MockProvider { + const chainId = opts.chainId ?? 31337 + const calls: RecordedCall[] = [] + + const builtins: Record = { + eth_chainId: () => toHex(chainId), + net_version: () => String(chainId), + web3_clientVersion: () => 'efs-mock/1.0.0', + eth_blockNumber: () => '0x1', + eth_getCode: (params) => codeFor(opts.code, String(params[0] ?? '')), + eth_call: () => '0x', + } + + const request = async ({ + method, + params, + }: { + method: string + params?: readonly unknown[] + }): Promise => { + const p = (params ?? []) as readonly unknown[] + calls.push({ method, params: p }) + const handler = opts.handlers?.[method] ?? builtins[method] ?? opts.fallback + if (!handler) { + throw new Error(`mock EIP-1193 provider: unhandled method "${method}"`) + } + return handler(p) + } + + // EIP-1193 also specifies an event emitter surface; the SDK doesn't subscribe + // in these paths, so no-op listeners are enough to satisfy the type. + const provider = { + request, + on: () => provider, + removeListener: () => provider, + } as unknown as MockProvider + + Object.defineProperty(provider, 'calls', { get: () => calls }) + Object.defineProperty(provider, 'callCount', { + value: (method: string) => calls.filter((c) => c.method === method).length, + }) + + return provider +} diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts new file mode 100644 index 0000000..ffc4344 --- /dev/null +++ b/packages/sdk/test/mirror.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it, vi } from 'vitest' +import { hashContent } from '../src/content/hash.js' +import { + AllMirrorsFailedError, + DEFAULT_ARWEAVE_GATEWAYS, + DEFAULT_IPFS_GATEWAYS, + TRANSPORT, + TransportNotImplementedError, + UnsupportedUriError, + checkSsrf, + fetchVerified, + resolveTransport, +} from '../src/mirror/index.js' + +const enc = (s: string) => new TextEncoder().encode(s) + +/** Build a minimal `Response`-like object with a streaming body so the engine's + * capped reader path is exercised (not just arrayBuffer). */ +function mockResponse( + bytes: Uint8Array, + init: { status?: number; statusText?: string; headers?: Record } = {}, +): Response { + const status = init.status ?? 200 + const headers = new Headers(init.headers ?? {}) + if (!headers.has('content-length')) headers.set('content-length', String(bytes.byteLength)) + const body = new ReadableStream({ + start(controller) { + controller.enqueue(bytes) + controller.close() + }, + }) + return { + ok: status >= 200 && status < 300, + status, + statusText: init.statusText ?? 'OK', + headers, + body, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + } as unknown as Response +} + +describe('resolveTransport - URI parsing (TRANSPORT allowlist)', () => { + it('parses https:// to itself', () => { + const r = resolveTransport('https://example.com/file.bin') + expect(r.scheme).toBe(TRANSPORT.https) + expect(r.httpUrls().map((u) => u.href)).toEqual(['https://example.com/file.bin']) + }) + + it('parses ipfs://CID to the default gateway list with ?format=raw', () => { + const cid = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' + const r = resolveTransport(`ipfs://${cid}`) + expect(r.scheme).toBe(TRANSPORT.ipfs) + const urls = r.httpUrls() + expect(urls).toHaveLength(DEFAULT_IPFS_GATEWAYS.length) + expect(urls[0]!.href).toBe(`https://ipfs.io/ipfs/${cid}?format=raw`) + expect(urls[1]!.href).toBe(`https://dweb.link/ipfs/${cid}?format=raw`) + }) + + it('honors overridden ipfs gateways and a subpath', () => { + const cid = 'bafytest' + const r = resolveTransport(`ipfs://${cid}/dir/a.txt`) + const urls = r.httpUrls({ ipfsGateways: ['https://my.gw/'] }) + expect(urls).toHaveLength(1) + expect(urls[0]!.href).toBe(`https://my.gw/ipfs/${cid}/dir/a.txt?format=raw`) + }) + + it('parses ar://TXID to arweave gateways', () => { + const tx = 'AbC123_txid' + const r = resolveTransport(`ar://${tx}`) + expect(r.scheme).toBe(TRANSPORT.arweave) + const urls = r.httpUrls() + expect(urls).toHaveLength(DEFAULT_ARWEAVE_GATEWAYS.length) + expect(urls[0]!.href).toBe(`https://arweave.net/${tx}`) + }) + + it('decodes a base64 data: URI inline (no network)', () => { + // base64 of "hello" + const r = resolveTransport('data:text/plain;base64,aGVsbG8=') + expect(r.scheme).toBe(TRANSPORT.data) + expect(r.inline?.contentType).toBe('text/plain') + expect(new TextDecoder().decode(r.inline!.bytes)).toBe('hello') + expect(r.httpUrls()).toEqual([]) + }) + + it('decodes a percent-encoded (non-base64) data: URI inline', () => { + const r = resolveTransport('data:,hello%20world') + expect(new TextDecoder().decode(r.inline!.bytes)).toBe('hello world') + expect(r.inline?.contentType).toBeUndefined() + }) + + it('magnet: parses but yields no HTTP URLs', () => { + const r = resolveTransport('magnet:?xt=urn:btih:abc') + expect(r.scheme).toBe(TRANSPORT.magnet) + expect(r.httpUrls()).toEqual([]) + }) + + it('web3:// parses but throws NotImplemented when resolved (the seam)', () => { + const r = resolveTransport('web3://0xabc/foo') + expect(r.scheme).toBe(TRANSPORT.web3) + expect(() => r.httpUrls()).toThrow(TransportNotImplementedError) + }) + + it('rejects unknown schemes and schemeless input', () => { + expect(() => resolveTransport('ftp://x')).toThrow(UnsupportedUriError) + expect(() => resolveTransport('not-a-uri')).toThrow(UnsupportedUriError) + }) +}) + +describe('checkSsrf - host guard', () => { + const block = (h: string) => checkSsrf(new URL(h)) + it('blocks loopback, private, link-local, metadata IPs', () => { + expect(block('http://127.0.0.1/x').blocked).toBe(true) + expect(block('http://10.0.0.5/x').blocked).toBe(true) + expect(block('http://172.16.0.1/x').blocked).toBe(true) + expect(block('http://192.168.1.1/x').blocked).toBe(true) + expect(block('http://169.254.169.254/latest/meta-data').blocked).toBe(true) + expect(block('http://[::1]/x').blocked).toBe(true) + expect(block('http://localhost/x').blocked).toBe(true) + expect(block('http://metadata.google.internal/x').blocked).toBe(true) + }) + it('allows public hosts', () => { + expect(block('https://example.com/x').blocked).toBe(false) + expect(block('https://8.8.8.8/x').blocked).toBe(false) + }) + it('respects allowPrivateHosts + allowlist', () => { + expect(checkSsrf(new URL('http://127.0.0.1/x'), { allowPrivateHosts: true }).blocked).toBe( + false, + ) + expect(checkSsrf(new URL('http://localhost/x'), { allowlist: ['localhost'] }).blocked).toBe( + false, + ) + }) +}) + +describe('fetchVerified - happy paths per transport', () => { + it('https happy path returns matches-author + declared content-type (informational)', async () => { + const bytes = enc('the bytes') + const hash = hashContent(bytes) + const fetchImpl = vi.fn(async () => + mockResponse(bytes, { headers: { 'content-type': 'application/pdf' } }), + ) as unknown as typeof fetch + const res = await fetchVerified(['https://cdn.example.com/a'], hash, { fetchImpl }) + expect(res.verification).toBe('matches-author') + expect(res.contentType).toBe('application/pdf') + expect(res.mirrorUsed).toBe('https://cdn.example.com/a') + expect(res.bytes).toEqual(bytes) + expect(fetchImpl).toHaveBeenCalledOnce() + }) + + it('ipfs happy path hits the first gateway', async () => { + const bytes = enc('ipfs content') + const hash = hashContent(bytes) + const fetchImpl = vi.fn(async () => mockResponse(bytes)) as unknown as typeof fetch + const res = await fetchVerified(['ipfs://bafytest'], hash, { fetchImpl }) + expect(res.verification).toBe('matches-author') + expect(res.urlUsed).toBe('https://ipfs.io/ipfs/bafytest?format=raw') + }) + + it('data: URI is verified inline without any fetch call', async () => { + const bytes = enc('hello') + const hash = hashContent(bytes) + const fetchImpl = vi.fn() as unknown as typeof fetch + const res = await fetchVerified(['data:text/plain;base64,aGVsbG8='], hash, { fetchImpl }) + expect(res.verification).toBe('matches-author') + expect(res.contentType).toBe('text/plain') + expect(fetchImpl).not.toHaveBeenCalled() + }) +}) + +describe('fetchVerified - failover', () => { + it('falls over to the next ipfs gateway when the first errors', async () => { + const bytes = enc('content') + const hash = hashContent(bytes) + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + mockResponse(new Uint8Array(), { status: 502, statusText: 'Bad Gateway' }), + ) + .mockResolvedValueOnce(mockResponse(bytes)) as unknown as typeof fetch + const res = await fetchVerified(['ipfs://bafytest'], hash, { fetchImpl }) + expect(res.verification).toBe('matches-author') + expect(res.urlUsed).toBe('https://dweb.link/ipfs/bafytest?format=raw') + expect(res.attempts).toHaveLength(1) + expect(res.attempts[0]!.reason).toContain('502') + }) + + it('falls over from a dead mirror to the next mirror', async () => { + const bytes = enc('second mirror wins') + const hash = hashContent(bytes) + const fetchImpl = vi.fn(async (input: string | URL | Request) => { + const href = String(input) + if (href.startsWith('https://dead.example')) throw new Error('ECONNREFUSED') + return mockResponse(bytes) + }) as unknown as typeof fetch + const res = await fetchVerified(['https://dead.example/a', 'https://live.example/a'], hash, { + fetchImpl, + }) + expect(res.mirrorUsed).toBe('https://live.example/a') + expect(res.attempts).toHaveLength(1) + }) + + it('web3:// mirror is recorded as failed but a later mirror still wins', async () => { + const bytes = enc('x') + const hash = hashContent(bytes) + const fetchImpl = vi.fn(async () => mockResponse(bytes)) as unknown as typeof fetch + const res = await fetchVerified(['web3://0xabc/f', 'https://ok.example/f'], hash, { fetchImpl }) + expect(res.mirrorUsed).toBe('https://ok.example/f') + expect(res.attempts[0]!.scheme).toBe(TRANSPORT.web3) + expect(res.attempts[0]!.reason).toContain('not implemented') + }) + + it('throws AllMirrorsFailedError with the attempt log when nothing works', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('network down') + }) as unknown as typeof fetch + await expect( + fetchVerified(['https://a.example/x', 'https://b.example/x'], undefined, { fetchImpl }), + ).rejects.toBeInstanceOf(AllMirrorsFailedError) + }) +}) + +describe('fetchVerified - size cap', () => { + it('trips on an honest Content-Length over the cap (no body read)', async () => { + const big = enc('x'.repeat(100)) + const fetchImpl = vi.fn(async () => + mockResponse(big, { headers: { 'content-length': '999999999' } }), + ) as unknown as typeof fetch + await expect( + fetchVerified(['https://a.example/big'], undefined, { fetchImpl, maxBytes: 10 }), + ).rejects.toBeInstanceOf(AllMirrorsFailedError) + }) + + it('trips while streaming when the declared length lies', async () => { + const big = enc('x'.repeat(100)) + // Declare a small length but stream a large body. + const fetchImpl = vi.fn(async () => + mockResponse(big, { headers: { 'content-length': '5' } }), + ) as unknown as typeof fetch + await expect( + fetchVerified(['https://a.example/big'], undefined, { fetchImpl, maxBytes: 10 }), + ).rejects.toBeInstanceOf(AllMirrorsFailedError) + }) +}) + +describe('fetchVerified - timeout', () => { + it('aborts a slow attempt and fails over', async () => { + vi.useFakeTimers() + try { + const bytes = enc('fast') + const hash = hashContent(bytes) + const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => { + const href = String(input) + if (href.includes('slow')) { + // Never resolves on its own; rejects when the engine's timer aborts. + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + const e = new Error('aborted') + e.name = 'AbortError' + reject(e) + }) + }) + } + return Promise.resolve(mockResponse(bytes)) + }) as unknown as typeof fetch + + const p = fetchVerified(['https://slow.example/a', 'https://fast.example/a'], hash, { + fetchImpl, + timeoutMs: 1000, + }) + // Advance past the per-attempt timeout so the slow attempt aborts. + await vi.advanceTimersByTimeAsync(1001) + const res = await p + expect(res.mirrorUsed).toBe('https://fast.example/a') + expect(res.attempts[0]!.reason).toContain('timed out') + } finally { + vi.useRealTimers() + } + }) +}) + +describe('fetchVerified - verification statuses', () => { + const bytes = enc('payload') + const makeFetch = () => vi.fn(async () => mockResponse(bytes)) as unknown as typeof fetch + + it('mismatch when the hash diverges', async () => { + const res = await fetchVerified(['https://a.example/x'], hashContent(enc('other')), { + fetchImpl: makeFetch(), + }) + expect(res.verification).toBe('mismatch') + expect(res.bytes).toEqual(bytes) // bytes are still returned + }) + + it('malformed-claim when expectedHash is not 64-hex', async () => { + const res = await fetchVerified(['https://a.example/x'], '0xdeadbeef', { + fetchImpl: makeFetch(), + }) + expect(res.verification).toBe('malformed-claim') + }) + + it('no-claim when expectedHash is undefined', async () => { + const res = await fetchVerified(['https://a.example/x'], undefined, { + fetchImpl: makeFetch(), + }) + expect(res.verification).toBe('no-claim') + }) +}) + +describe('fetchVerified - SSRF', () => { + it('skips an SSRF-blocked host and records it, then fails over', async () => { + const bytes = enc('safe') + const hash = hashContent(bytes) + const fetchImpl = vi.fn(async () => mockResponse(bytes)) as unknown as typeof fetch + const res = await fetchVerified( + ['https://169.254.169.254/latest', 'https://public.example/x'], + hash, + { fetchImpl }, + ) + expect(res.mirrorUsed).toBe('https://public.example/x') + expect(res.attempts[0]!.reason).toContain('SSRF-blocked') + // The blocked host was never fetched. + expect(fetchImpl).toHaveBeenCalledOnce() + expect(fetchImpl).toHaveBeenCalledWith('https://public.example/x', expect.anything()) + }) + + it('allows a private host when allowPrivateHosts is set', async () => { + const bytes = enc('local') + const hash = hashContent(bytes) + const fetchImpl = vi.fn(async () => mockResponse(bytes)) as unknown as typeof fetch + const res = await fetchVerified(['https://127.0.0.1/x'], hash, { + fetchImpl, + allowPrivateHosts: true, + }) + expect(res.verification).toBe('matches-author') + }) +}) + +describe('fetchVerified - AbortSignal', () => { + it('an already-aborted signal short-circuits before any fetch', async () => { + const ac = new AbortController() + ac.abort() + const fetchImpl = vi.fn() as unknown as typeof fetch + await expect( + fetchVerified(['https://a.example/x'], undefined, { fetchImpl, signal: ac.signal }), + ).rejects.toBeInstanceOf(AllMirrorsFailedError) + expect(fetchImpl).not.toHaveBeenCalled() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d15da1e..0ff9922 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,40 +9,49 @@ importers: .: devDependencies: '@arethetypeswrong/cli': - specifier: ^0.18.3 + specifier: 0.18.3 version: 0.18.3 '@biomejs/biome': - specifier: ^1.9.4 + specifier: 1.9.4 version: 1.9.4 '@changesets/cli': - specifier: ^2.27.9 + specifier: 2.31.0 version: 2.31.0 publint: - specifier: ^0.2.12 + specifier: 0.2.12 version: 0.2.12 turbo: - specifier: ^2.3.0 + specifier: 2.9.17 version: 2.9.17 typescript: - specifier: ^5.6.3 + specifier: 5.9.3 version: 5.9.3 packages/sdk: devDependencies: + '@size-limit/preset-small-lib': + specifier: 12.1.0 + version: 12.1.0(size-limit@12.1.0(jiti@2.7.0)) + knip: + specifier: 6.16.1 + version: 6.16.1 prool: - specifier: ^0.0.16 + specifier: 0.0.16 version: 0.0.16 + size-limit: + specifier: 12.1.0 + version: 12.1.0(jiti@2.7.0) tsup: - specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.15)(typescript@5.9.3) + specifier: 8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0) typescript: - specifier: ^5.6.3 + specifier: 5.9.3 version: 5.9.3 viem: - specifier: ^2.21.0 - version: 2.52.2(typescript@5.9.3) + specifier: 2.52.2 + version: 2.52.2(typescript@5.9.3)(zod@4.4.3) vitest: - specifier: ^2.1.0 + specifier: 2.1.9 version: 2.1.9 packages/solidity: {} @@ -183,6 +192,15 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -195,6 +213,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} @@ -207,6 +231,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} @@ -219,6 +249,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} @@ -231,6 +267,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} @@ -243,6 +285,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} @@ -255,6 +303,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} @@ -267,6 +321,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} @@ -279,6 +339,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} @@ -291,6 +357,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} @@ -303,6 +375,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} @@ -315,6 +393,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} @@ -327,6 +411,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} @@ -339,6 +429,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} @@ -351,6 +447,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} @@ -363,6 +465,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} @@ -375,6 +483,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} @@ -387,12 +501,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} @@ -405,12 +531,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} @@ -423,12 +561,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} @@ -441,6 +591,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} @@ -453,6 +609,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} @@ -465,6 +627,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} @@ -477,6 +645,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -512,6 +686,12 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -536,6 +716,223 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxc-parser/binding-android-arm-eabi@0.133.0': + resolution: {integrity: sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.133.0': + resolution: {integrity: sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.133.0': + resolution: {integrity: sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.133.0': + resolution: {integrity: sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.133.0': + resolution: {integrity: sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0': + resolution: {integrity: sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.133.0': + resolution: {integrity: sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.133.0': + resolution: {integrity: sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.133.0': + resolution: {integrity: sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.133.0': + resolution: {integrity: sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.133.0': + resolution: {integrity: sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.133.0': + resolution: {integrity: sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.133.0': + resolution: {integrity: sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.133.0': + resolution: {integrity: sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.133.0': + resolution: {integrity: sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.133.0': + resolution: {integrity: sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.133.0': + resolution: {integrity: sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.133.0': + resolution: {integrity: sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.133.0': + resolution: {integrity: sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.133.0': + resolution: {integrity: sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@oxc-resolver/binding-android-arm-eabi@11.20.0': + resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.20.0': + resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.20.0': + resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.20.0': + resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.20.0': + resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': + resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': + resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': + resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': + resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': + resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': + resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': + resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': + resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': + resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.20.0': + resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.20.0': + resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.20.0': + resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': + resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': + resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==} + cpu: [x64] + os: [win32] + '@rollup/rollup-android-arm-eabi@4.61.1': resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} cpu: [arm] @@ -681,6 +1078,23 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@size-limit/esbuild@12.1.0': + resolution: {integrity: sha512-Um6MVrX+05kIxI4+zk0ZByG9dA/Th1f+sfGc571D95BnCPc90/pl2+2OdsQuOyoWEbeAMqfcTKo0v07i+E65Vw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + size-limit: 12.1.0 + + '@size-limit/file@12.1.0': + resolution: {integrity: sha512-eGwDcIufnNnvJRzv3liDOn6MAOGgmOTUdpeGQ2KuRTlgIgO54AJH1ilvktlJc6PIjNfwpYY0dOGyap1QgM1swQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + size-limit: 12.1.0 + + '@size-limit/preset-small-lib@12.1.0': + resolution: {integrity: sha512-TVVQ/iuHbaGtHJrjur5s4XKYEyGk0nIwUAqhuzhKPbTyV9nYOH/laDelQ4vg3cGmm8sayRx998wxEdnwM/Yewg==} + peerDependencies: + size-limit: 12.1.0 + '@turbo/darwin-64@2.9.17': resolution: {integrity: sha512-io5jn5RDeU+9YV78rWhwG++HD/OZ/Lxg1sg93+jDGKQNP3UDxY6RX2dmarbCILhNxNuAM8FH3WgGMY9E96Mf8w==} cpu: [x64] @@ -711,6 +1125,9 @@ packages: cpu: [arm64] os: [win32] + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -819,6 +1236,10 @@ packages: peerDependencies: esbuild: '>=0.18' + bytes-iec@3.1.1: + resolution: {integrity: sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -946,6 +1367,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -985,6 +1411,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1021,6 +1450,11 @@ packages: debug: optional: true + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1049,6 +1483,9 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1147,6 +1584,10 @@ packages: peerDependencies: ws: '*' + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1162,6 +1603,11 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + knip@6.16.1: + resolution: {integrity: sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1244,6 +1690,14 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.11: + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + engines: {node: ^18 || >=20} + hasBin: true + + nanospinner@1.2.2: + resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} + node-emoji@2.2.0: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} @@ -1283,6 +1737,13 @@ packages: typescript: optional: true + oxc-parser@0.133.0: + resolution: {integrity: sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.20.0: + resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -1437,6 +1898,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1476,6 +1940,16 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + size-limit@12.1.0: + resolution: {integrity: sha512-VnDS2fycANrJFVPQwjaD+h+hkISY7EB3LsPsYWje4lBCjQwwsZLxjwwRwVJKHrcj2ZqyG+DdXykWm9mbZklZrw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + jiti: ^2.0.0 + peerDependenciesMeta: + jiti: + optional: true + skin-tone@2.0.0: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} @@ -1484,6 +1958,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1520,6 +1998,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -1582,6 +2064,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -1618,6 +2103,10 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + unbash@3.0.0: + resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} + engines: {node: '>=14'} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -1703,6 +2192,10 @@ packages: jsdom: optional: true + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1740,6 +2233,11 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -1752,6 +2250,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@adraffy/ens-normalize@1.11.1': {} @@ -1964,153 +2465,247 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.21.5': optional: true '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.21.5': optional: true '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.21.5': optional: true '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true '@esbuild/win32-x64@0.27.7': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@inquirer/external-editor@1.0.3': dependencies: chardet: 2.1.1 @@ -2154,6 +2749,13 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.1': @@ -2174,6 +2776,133 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@oxc-parser/binding-android-arm-eabi@0.133.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.133.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.133.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.133.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.133.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.133.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.133.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.133.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.133.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.133.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.133.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.133.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.133.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.133.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.133.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.133.0': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.133.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.133.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.133.0': + optional: true + + '@oxc-project/types@0.133.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.20.0': + optional: true + + '@oxc-resolver/binding-android-arm64@11.20.0': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.20.0': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.20.0': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.20.0': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.20.0': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.20.0': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': + optional: true + '@rollup/rollup-android-arm-eabi@4.61.1': optional: true @@ -2268,6 +2997,22 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@size-limit/esbuild@12.1.0(size-limit@12.1.0(jiti@2.7.0))': + dependencies: + esbuild: 0.28.0 + nanoid: 5.1.11 + size-limit: 12.1.0(jiti@2.7.0) + + '@size-limit/file@12.1.0(size-limit@12.1.0(jiti@2.7.0))': + dependencies: + size-limit: 12.1.0(jiti@2.7.0) + + '@size-limit/preset-small-lib@12.1.0(size-limit@12.1.0(jiti@2.7.0))': + dependencies: + '@size-limit/esbuild': 12.1.0(size-limit@12.1.0(jiti@2.7.0)) + '@size-limit/file': 12.1.0(size-limit@12.1.0(jiti@2.7.0)) + size-limit: 12.1.0(jiti@2.7.0) + '@turbo/darwin-64@2.9.17': optional: true @@ -2286,6 +3031,11 @@ snapshots: '@turbo/windows-arm64@2.9.17': optional: true + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + '@types/estree@1.0.9': {} '@types/node@12.20.55': {} @@ -2330,9 +3080,10 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 - abitype@1.2.3(typescript@5.9.3): + abitype@1.2.3(typescript@5.9.3)(zod@4.4.3): optionalDependencies: typescript: 5.9.3 + zod: 4.4.3 acorn@8.16.0: {} @@ -2381,6 +3132,8 @@ snapshots: esbuild: 0.27.7 load-tsconfig: 0.2.5 + bytes-iec@3.1.1: {} + cac@6.7.14: {} chai@5.3.3: @@ -2535,6 +3288,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} esprima@4.0.1: {} @@ -2580,6 +3362,10 @@ snapshots: dependencies: reusify: 1.1.0 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2607,6 +3393,10 @@ snapshots: follow-redirects@1.16.0: {} + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -2633,6 +3423,10 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2717,6 +3511,8 @@ snapshots: dependencies: ws: 8.20.1 + jiti@2.7.0: {} + joycon@3.1.1: {} js-yaml@3.14.2: @@ -2732,6 +3528,22 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + knip@6.16.1: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + formatly: 0.3.0 + get-tsconfig: 4.14.0 + jiti: 2.7.0 + oxc-parser: 0.133.0 + oxc-resolver: 11.20.0 + picomatch: 4.0.4 + smol-toml: 1.6.1 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 3.0.0 + yaml: 2.9.0 + zod: 4.4.3 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -2803,6 +3615,12 @@ snapshots: nanoid@3.3.12: {} + nanoid@5.1.11: {} + + nanospinner@1.2.2: + dependencies: + picocolors: 1.1.1 + node-emoji@2.2.0: dependencies: '@sindresorhus/is': 4.6.0 @@ -2836,7 +3654,7 @@ snapshots: outdent@0.5.0: {} - ox@0.14.29(typescript@5.9.3): + ox@0.14.29(typescript@5.9.3)(zod@4.4.3): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -2844,13 +3662,60 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3) + abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - zod + oxc-parser@0.133.0: + dependencies: + '@oxc-project/types': 0.133.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.133.0 + '@oxc-parser/binding-android-arm64': 0.133.0 + '@oxc-parser/binding-darwin-arm64': 0.133.0 + '@oxc-parser/binding-darwin-x64': 0.133.0 + '@oxc-parser/binding-freebsd-x64': 0.133.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.133.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.133.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.133.0 + '@oxc-parser/binding-linux-arm64-musl': 0.133.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.133.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.133.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.133.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.133.0 + '@oxc-parser/binding-linux-x64-gnu': 0.133.0 + '@oxc-parser/binding-linux-x64-musl': 0.133.0 + '@oxc-parser/binding-openharmony-arm64': 0.133.0 + '@oxc-parser/binding-wasm32-wasi': 0.133.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.133.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.133.0 + '@oxc-parser/binding-win32-x64-msvc': 0.133.0 + + oxc-resolver@11.20.0: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.20.0 + '@oxc-resolver/binding-android-arm64': 11.20.0 + '@oxc-resolver/binding-darwin-arm64': 11.20.0 + '@oxc-resolver/binding-darwin-x64': 11.20.0 + '@oxc-resolver/binding-freebsd-x64': 11.20.0 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0 + '@oxc-resolver/binding-linux-arm64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-arm64-musl': 11.20.0 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-riscv64-musl': 11.20.0 + '@oxc-resolver/binding-linux-s390x-gnu': 11.20.0 + '@oxc-resolver/binding-linux-x64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-x64-musl': 11.20.0 + '@oxc-resolver/binding-openharmony-arm64': 11.20.0 + '@oxc-resolver/binding-wasm32-wasi': 11.20.0 + '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 + '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 + p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -2911,11 +3776,13 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(postcss@8.5.15): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.15)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: + jiti: 2.7.0 postcss: 8.5.15 + yaml: 2.9.0 postcss@8.5.15: dependencies: @@ -2965,6 +3832,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + reusify@1.1.0: {} rollup@4.61.1: @@ -3020,12 +3889,24 @@ snapshots: signal-exit@4.1.0: {} + size-limit@12.1.0(jiti@2.7.0): + dependencies: + bytes-iec: 3.1.1 + lilconfig: 3.1.3 + nanospinner: 1.2.2 + picocolors: 1.1.1 + tinyglobby: 0.2.17 + optionalDependencies: + jiti: 2.7.0 + skin-tone@2.0.0: dependencies: unicode-emoji-modifier-base: 1.0.0 slash@3.0.0: {} + smol-toml@1.6.1: {} + source-map-js@1.2.1: {} source-map@0.7.6: {} @@ -3055,6 +3936,8 @@ snapshots: strip-final-newline@4.0.0: {} + strip-json-comments@5.0.3: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -3116,7 +3999,10 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(postcss@8.5.15)(typescript@5.9.3): + tslib@2.8.1: + optional: true + + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -3127,7 +4013,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.15) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.15)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.61.1 source-map: 0.7.6 @@ -3159,6 +4045,8 @@ snapshots: ufo@1.6.4: {} + unbash@3.0.0: {} + unicode-emoji-modifier-base@1.0.0: {} unicorn-magic@0.3.0: {} @@ -3167,15 +4055,15 @@ snapshots: validate-npm-package-name@5.0.1: {} - viem@2.52.2(typescript@5.9.3): + viem@2.52.2(typescript@5.9.3)(zod@4.4.3): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3) + abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) isows: 1.0.7(ws@8.20.1) - ox: 0.14.29(typescript@5.9.3) + ox: 0.14.29(typescript@5.9.3)(zod@4.4.3) ws: 8.20.1 optionalDependencies: typescript: 5.9.3 @@ -3243,6 +4131,8 @@ snapshots: - supports-color - terser + walk-up-path@4.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3266,6 +4156,8 @@ snapshots: yallist@5.0.0: {} + yaml@2.9.0: {} + yargs-parser@20.2.9: {} yargs@16.2.0: @@ -3279,3 +4171,5 @@ snapshots: yargs-parser: 20.2.9 yoctocolors@2.1.2: {} + + zod@4.4.3: {} From ae1d1c0d88b9a9982d8947e009164fff2a3a0bc4 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 02:44:02 -0500 Subject: [PATCH 32/47] fix(ci): scope noNonNullAssertion off for tests, keep src strict CI caught 11 noNonNullAssertion violations in test/mirror.test.ts that a stale turbo lint cache hid locally. Rather than weaken null-safety repo-wide (the rule stays enforced in src/, where the mirror engine handles untrusted bytes), add a biome override that exempts only test files, where expect(arr[0]!.x) is idiomatic. Co-authored-by: Claude Opus 4.8 --- biome.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/biome.json b/biome.json index 4dd50af..6efd64c 100644 --- a/biome.json +++ b/biome.json @@ -15,5 +15,15 @@ }, "javascript": { "formatter": { "quoteStyle": "single", "semicolons": "asNeeded" } - } + }, + "overrides": [ + { + "include": ["**/test/**", "**/*.test.ts"], + "linter": { + "rules": { + "style": { "noNonNullAssertion": "off" } + } + } + } + ] } From 78a6a6cc08c7683f9e7bdfd76a802312f697f26a Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 02:47:13 -0500 Subject: [PATCH 33/47] fix(mirror): block canonical hex IPv4-mapped IPv6 in SSRF guard (P1) The guard only matched the dotted tail (::ffff:127.0.0.1), but new URL()/Node canonicalize it to the two-hextet hex form (::ffff:7f00:1), so an attacker- chosen mirror like http://[::ffff:127.0.0.1]/ slipped past checkSsrf and could reach loopback/private services. embeddedMappedIpv4() now decodes both the dotted and hex tails and re-checks the embedded IPv4 against the v4 blocklist. Regression test covers loopback/metadata/private mapped forms + a public one. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/mirror/ssrf.ts | 33 ++++++++++++++++++++++++-------- packages/sdk/test/mirror.test.ts | 10 ++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/mirror/ssrf.ts b/packages/sdk/src/mirror/ssrf.ts index d233274..e89a37b 100644 --- a/packages/sdk/src/mirror/ssrf.ts +++ b/packages/sdk/src/mirror/ssrf.ts @@ -84,14 +84,31 @@ function isBlockedIpv6(addr: string): string | undefined { if (a.startsWith('fe8') || a.startsWith('fe9') || a.startsWith('fea') || a.startsWith('feb')) return 'link-local (fe80::/10)' if (a.startsWith('fc') || a.startsWith('fd')) return 'unique-local (fc00::/7)' - // IPv4-mapped (::ffff:a.b.c.d) - extract and re-check as IPv4. - const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(a) - if (mapped?.[1]) { - const v4 = parseIpv4(mapped[1]) - if (v4) { - const why = isBlockedIpv4(v4[0], v4[1], v4[2], v4[3]) - if (why) return `IPv4-mapped ${why}` - } + // IPv4-mapped (::ffff:a.b.c.d) - extract and re-check as IPv4. Node + // canonicalizes the dotted tail to hex (::ffff:127.0.0.1 -> ::ffff:7f00:1), + // so both forms must be recognized or the guard is trivially bypassed. + const v4 = embeddedMappedIpv4(a) + if (v4) { + const why = isBlockedIpv4(v4[0], v4[1], v4[2], v4[3]) + if (why) return `IPv4-mapped ${why}` + } + return undefined +} + +/** + * Extract the embedded IPv4 of an IPv4-mapped IPv6 address (`::ffff:…`), + * accepting both the dotted tail (`::ffff:127.0.0.1`) and the canonical + * two-hextet hex tail Node emits (`::ffff:7f00:1`). Returns the four octets, + * or `undefined` if `a` isn't an IPv4-mapped address. + */ +function embeddedMappedIpv4(a: string): [number, number, number, number] | undefined { + const dotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(a) + if (dotted?.[1]) return parseIpv4(dotted[1]) + const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(a) + if (hex?.[1] && hex[2]) { + const hi = Number.parseInt(hex[1], 16) + const lo = Number.parseInt(hex[2], 16) + return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff] } return undefined } diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index ffc4344..5920404 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -119,6 +119,16 @@ describe('checkSsrf - host guard', () => { expect(block('http://localhost/x').blocked).toBe(true) expect(block('http://metadata.google.internal/x').blocked).toBe(true) }) + it('blocks IPv4-mapped IPv6, including the canonical hex form Node emits', () => { + // new URL() canonicalizes [::ffff:127.0.0.1] -> hostname '::ffff:7f00:1'; + // both the dotted input and the explicit hex form must be blocked (P1 SSRF). + expect(block('http://[::ffff:127.0.0.1]/x').blocked).toBe(true) + expect(block('http://[::ffff:7f00:1]/x').blocked).toBe(true) + expect(block('http://[::ffff:a9fe:a9fe]/latest/meta-data').blocked).toBe(true) // 169.254.169.254 + expect(block('http://[::ffff:0a00:0005]/x').blocked).toBe(true) // 10.0.0.5 + // A public IPv4-mapped address stays allowed. + expect(block('http://[::ffff:0808:0808]/x').blocked).toBe(false) // 8.8.8.8 + }) it('allows public hosts', () => { expect(block('https://example.com/x').blocked).toBe(false) expect(block('https://8.8.8.8/x').blocked).toBe(false) From a0c0d83143e064c2c32d0be8aac551e190f46106 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 02:54:21 -0500 Subject: [PATCH 34/47] fix(mirror): re-check redirect targets in SSRF guard (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchOne used redirect:'follow', so a public mirror could 30x to http://127.0.0.1/... and fetch would follow it without re-running checkSsrf — bypassing the private-host block. Switched to redirect:'manual' with a manual hop loop that re-checks every Location against the SSRF guard (and rejects non-http(s) schemes), capped at 5 hops. Browser opaque-redirects fall back to a follow fetch (the browser enforces SSRF/CORS itself). Regression tests: a 30x to loopback is blocked and never fetched; a 30x to a public host is followed. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/mirror/fetch.ts | 74 ++++++++++++++++++++++++++------ packages/sdk/test/mirror.test.ts | 36 ++++++++++++++++ 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/packages/sdk/src/mirror/fetch.ts b/packages/sdk/src/mirror/fetch.ts index e777106..6c28e7f 100644 --- a/packages/sdk/src/mirror/fetch.ts +++ b/packages/sdk/src/mirror/fetch.ts @@ -169,6 +169,21 @@ function linkAbort(outer: AbortSignal | undefined, inner: AbortController): () = return () => outer.removeEventListener('abort', onAbort) } +/** Max redirect hops to follow before giving up (each re-checked for SSRF). */ +const MAX_REDIRECTS = 5 + +/** Read the capped body and capture the declared (informational) content type. */ +async function finishResponse( + res: Response, + maxBytes: number, + controller: AbortController, +): Promise<{ bytes: Uint8Array; contentType?: string }> { + const bytes = await readCapped(res, maxBytes, controller) + // Content-Type is informational ONLY (nosniff): captured, never acted on. + const declaredType = res.headers.get('content-type') ?? undefined + return declaredType !== undefined ? { bytes, contentType: declaredType } : { bytes } +} + /** Try a single concrete HTTP(S) URL. Returns bytes + declared type, or throws. */ async function fetchOne( url: URL, @@ -185,20 +200,53 @@ async function fetchOne( const unlink = linkAbort(opts.signal, controller) const timer = setTimeout(() => controller.abort(), timeoutMs) try { - const res = await doFetch(url.href, { - signal: controller.signal, - redirect: 'follow', - // We never want a cached cross-origin opaque response; ask for bytes. - headers: { accept: 'application/octet-stream, */*' }, - }) - if (!res.ok) { - throw new Error(`HTTP ${res.status} ${res.statusText}`) + let current = url + for (let hop = 0; ; hop += 1) { + // redirect: 'manual' is load-bearing security: a public mirror must not + // be able to 30x us toward a private/metadata host without re-running the + // SSRF guard on the new target (P1). In Node/undici this surfaces the 3xx + // + Location; in a browser it yields an opaque redirect, where the browser + // enforces SSRF/CORS itself, so we let it follow there. + const res = await doFetch(current.href, { + signal: controller.signal, + redirect: 'manual', + headers: { accept: 'application/octet-stream, */*' }, + }) + + if (res.type === 'opaqueredirect') { + const followed = await doFetch(current.href, { + signal: controller.signal, + redirect: 'follow', + headers: { accept: 'application/octet-stream, */*' }, + }) + if (!followed.ok) throw new Error(`HTTP ${followed.status} ${followed.statusText}`) + return finishResponse(followed, maxBytes, controller) + } + + if (res.status >= 300 && res.status < 400) { + if (hop >= MAX_REDIRECTS) throw new Error(`too many redirects (> ${MAX_REDIRECTS})`) + const location = res.headers.get('location') + if (!location) throw new Error(`HTTP ${res.status} redirect with no Location`) + let next: URL + try { + next = new URL(location, current) + } catch { + throw new Error(`invalid redirect Location: ${location}`) + } + if (next.protocol !== 'http:' && next.protocol !== 'https:') { + throw new Error(`redirect to non-http(s) scheme (${next.protocol})`) + } + const ssrf = checkSsrf(next, opts) + if (ssrf.blocked) throw new Error(`redirect to SSRF-blocked host (${ssrf.reason})`) + current = next + continue + } + + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText}`) + } + return finishResponse(res, maxBytes, controller) } - const bytes = await readCapped(res, maxBytes, controller) - // Content-Type is informational ONLY (nosniff). We capture the declared - // value but never branch handling on it and never execute the bytes. - const declaredType = res.headers.get('content-type') ?? undefined - return declaredType !== undefined ? { bytes, contentType: declaredType } : { bytes } } finally { clearTimeout(timer) unlink() diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index 5920404..c36bb1e 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -343,6 +343,42 @@ describe('fetchVerified - SSRF', () => { }) expect(res.verification).toBe('matches-author') }) + + it('re-checks redirect targets: a 30x to a private host is blocked (P1)', async () => { + const fetchImpl = vi.fn(async (url: string) => { + // A public mirror that tries to bounce us at loopback. + if (url === 'https://public.example/a') { + return mockResponse(new Uint8Array(), { + status: 302, + headers: { location: 'http://127.0.0.1/secret' }, + }) + } + throw new Error(`unexpected fetch to ${url}`) // 127.0.0.1 must never be hit + }) as unknown as typeof fetch + await expect( + fetchVerified(['https://public.example/a'], undefined, { fetchImpl }), + ).rejects.toBeInstanceOf(AllMirrorsFailedError) + // The redirect was issued once; the loopback target was never fetched. + expect(fetchImpl).toHaveBeenCalledOnce() + expect(fetchImpl).toHaveBeenCalledWith('https://public.example/a', expect.anything()) + }) + + it('follows a redirect to a public host and verifies the final bytes', async () => { + const bytes = enc('after redirect') + const hash = hashContent(bytes) + const fetchImpl = vi.fn(async (url: string) => + url === 'https://public.example/a' + ? mockResponse(new Uint8Array(), { + status: 302, + headers: { location: 'https://cdn.example/b' }, + }) + : mockResponse(bytes), + ) as unknown as typeof fetch + const res = await fetchVerified(['https://public.example/a'], hash, { fetchImpl }) + expect(res.verification).toBe('matches-author') + expect(fetchImpl).toHaveBeenCalledTimes(2) + expect(fetchImpl).toHaveBeenLastCalledWith('https://cdn.example/b', expect.anything()) + }) }) describe('fetchVerified - AbortSignal', () => { From 59253c1cf7a328f03b176f51a35743043658d14a Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 02:59:55 -0500 Subject: [PATCH 35/47] fix(mirror): trailing-dot host bypass (P1) + data: URI size cap (P2) P1: URL.hostname preserves a trailing dot, so http://localhost./ and http://metadata.google.internal./ slipped past the literal internal-host checks even though DNS treats the FQDN form as the same host. checkSsrf now strips trailing dot(s) from the host (and allowlist entries) before any comparison. P2: the inline data: branch returned decoded bytes without checking maxBytes, so a giant data: URI from untrusted metadata bypassed the size cap. fetchVerified now rejects inline payloads over maxBytes (recorded as an attempt, fails over). Regression tests for both. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/mirror/fetch.ts | 13 ++++++++++++- packages/sdk/src/mirror/ssrf.ts | 11 ++++++++--- packages/sdk/test/mirror.test.ts | 16 ++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/mirror/fetch.ts b/packages/sdk/src/mirror/fetch.ts index 6c28e7f..634fc50 100644 --- a/packages/sdk/src/mirror/fetch.ts +++ b/packages/sdk/src/mirror/fetch.ts @@ -281,9 +281,20 @@ export async function fetchVerified( continue } - // Inline data: - no network, no SSRF, just decode + verify. + // Inline data: - no network, no SSRF, just decode + verify. Still enforce + // the size cap: a giant data: URI (from untrusted metadata) must not bypass + // maxBytes just because no fetch is involved. if (resolved.inline) { const bytes = resolved.inline.bytes + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES + if (bytes.byteLength > maxBytes) { + attempts.push({ + uri, + scheme: resolved.scheme, + reason: `inline payload ${bytes.byteLength} bytes exceeds cap (${maxBytes})`, + }) + continue + } const verification = statusFor(bytes, expectedHash) return { bytes, diff --git a/packages/sdk/src/mirror/ssrf.ts b/packages/sdk/src/mirror/ssrf.ts index e89a37b..6d8915a 100644 --- a/packages/sdk/src/mirror/ssrf.ts +++ b/packages/sdk/src/mirror/ssrf.ts @@ -120,10 +120,15 @@ function embeddedMappedIpv4(a: string): [number, number, number, number] | undef * internal names (`localhost`, `*.local`, `*.internal`). */ export function checkSsrf(url: URL, opts: SsrfGuardOptions = {}): SsrfResult { - const host = url.hostname.toLowerCase() + // Strip any trailing dot(s): DNS treats `localhost.` as the same host as + // `localhost`, but `URL.hostname` preserves the dot, so the FQDN form would + // otherwise slip past the literal name/IP checks below (P1). + const host = url.hostname.toLowerCase().replace(/\.+$/, '') if (opts.allowPrivateHosts) return { blocked: false } - if (opts.allowlist?.some((h) => h.toLowerCase() === host)) return { blocked: false } + if (opts.allowlist?.some((h) => h.toLowerCase().replace(/\.+$/, '') === host)) { + return { blocked: false } + } // Literal IPv4. const v4 = parseIpv4(host) @@ -134,7 +139,7 @@ export function checkSsrf(url: URL, opts: SsrfGuardOptions = {}): SsrfResult { } // Literal IPv6. - const v6 = normalizeIpv6(url.hostname) + const v6 = normalizeIpv6(host) if (v6) { const why = isBlockedIpv6(v6) if (why) return { blocked: true, host: v6, reason: why } diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index c36bb1e..01206d3 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -129,6 +129,13 @@ describe('checkSsrf - host guard', () => { // A public IPv4-mapped address stays allowed. expect(block('http://[::ffff:0808:0808]/x').blocked).toBe(false) // 8.8.8.8 }) + it('blocks trailing-dot FQDN forms of internal hosts', () => { + // DNS treats `localhost.` as `localhost`, but URL.hostname keeps the dot. + expect(block('http://localhost./x').blocked).toBe(true) + expect(block('http://metadata.google.internal./x').blocked).toBe(true) + expect(block('http://foo.internal./x').blocked).toBe(true) + expect(block('http://127.0.0.1./x').blocked).toBe(true) + }) it('allows public hosts', () => { expect(block('https://example.com/x').blocked).toBe(false) expect(block('https://8.8.8.8/x').blocked).toBe(false) @@ -176,6 +183,15 @@ describe('fetchVerified - happy paths per transport', () => { expect(res.contentType).toBe('text/plain') expect(fetchImpl).not.toHaveBeenCalled() }) + + it('enforces maxBytes on inline data: URIs (no cap bypass)', async () => { + // 'hello' is 5 bytes; cap at 4 -> the inline mirror must be rejected. + const fetchImpl = vi.fn() as unknown as typeof fetch + await expect( + fetchVerified(['data:text/plain;base64,aGVsbG8='], undefined, { fetchImpl, maxBytes: 4 }), + ).rejects.toBeInstanceOf(AllMirrorsFailedError) + expect(fetchImpl).not.toHaveBeenCalled() + }) }) describe('fetchVerified - failover', () => { From 2fa8bf9f9e8a9dff0a82ec08644a441ebf6e4fe4 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 03:13:12 -0500 Subject: [PATCH 36/47] harden(mirror): IPv6 transition SSRF, decompression bomb, data: decode bomb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proactive adversarial hardening pass on the mirror module (the SDK's untrusted- input boundary), from a 6-category red-team enumeration validated against actual Node 20/undici behavior — getting ahead of the SSRF/size bypass classes rather than fielding them one at a time. - IPv6 SSRF: replaced the ::ffff:-only regex with a byte-level IPv6 expander + prefix checks. Now blocks IPv4-compatible (::7f00:1), IPv4-translated (::ffff:0:..), NAT64 (64:ff9b::/96), 6to4 (2002::/16 embedded v4), plus site-local (fec0::/10) and Teredo (2001::/32). One canonical byte check covers all textual variants (compressed/expanded/case/leading-zero). - Decompression bomb: fetchOne now sends accept-encoding: identity and rejects any non-identity Content-Encoding BEFORE reading the body, so undici never pumps a gzip/br/zstd inflate (also neutralizes the chained-encoding CVE-2026-22036 in undici 7.16 regardless of runtime version). - data: decode bomb: resolveData rejects by *encoded* length before decoding, so an oversized data: URI can't force a large allocation pre-cap. - Confirmed-safe (lock-in tests, no code change): alternate IPv4 literal encodings (octal/decimal/hex/dword/part-collapse) — Node's URL parser canonicalizes them before checkSsrf runs. Known gap still open: DNS rebinding (public name -> private A record) needs connect-time IP pinning (a Node/undici dispatcher) — its own deliberate pass. Tests: 84 (36 mirror), gate green. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/mirror/fetch.ts | 15 +++- packages/sdk/src/mirror/ssrf.ts | 105 ++++++++++++++++++++------- packages/sdk/src/mirror/transport.ts | 19 ++++- packages/sdk/test/mirror.test.ts | 31 ++++++++ 4 files changed, 136 insertions(+), 34 deletions(-) diff --git a/packages/sdk/src/mirror/fetch.ts b/packages/sdk/src/mirror/fetch.ts index 634fc50..b1db662 100644 --- a/packages/sdk/src/mirror/fetch.ts +++ b/packages/sdk/src/mirror/fetch.ts @@ -178,6 +178,15 @@ async function finishResponse( maxBytes: number, controller: AbortController, ): Promise<{ bytes: Uint8Array; contentType?: string }> { + // Refuse compressed responses BEFORE reading the body. undici auto-inflates + // gzip/br/zstd, so a tiny compressed body can balloon past maxBytes during the + // read (and chained encodings were unbounded pre-undici-7.18.2, CVE-2026-22036). + // Mirror bytes are raw and hash-verified, so transport compression is never + // wanted; rejecting it here means the decompression stream is never pumped. + const encoding = res.headers.get('content-encoding') + if (encoding && encoding.toLowerCase() !== 'identity') { + throw new Error(`refusing compressed response (content-encoding: ${encoding})`) + } const bytes = await readCapped(res, maxBytes, controller) // Content-Type is informational ONLY (nosniff): captured, never acted on. const declaredType = res.headers.get('content-type') ?? undefined @@ -210,14 +219,14 @@ async function fetchOne( const res = await doFetch(current.href, { signal: controller.signal, redirect: 'manual', - headers: { accept: 'application/octet-stream, */*' }, + headers: { accept: 'application/octet-stream, */*', 'accept-encoding': 'identity' }, }) if (res.type === 'opaqueredirect') { const followed = await doFetch(current.href, { signal: controller.signal, redirect: 'follow', - headers: { accept: 'application/octet-stream, */*' }, + headers: { accept: 'application/octet-stream, */*', 'accept-encoding': 'identity' }, }) if (!followed.ok) throw new Error(`HTTP ${followed.status} ${followed.statusText}`) return finishResponse(followed, maxBytes, controller) @@ -275,7 +284,7 @@ export async function fetchVerified( const uri = mirrorUri(mirror) let resolved: ResolvedTransport try { - resolved = resolveTransport(uri) + resolved = resolveTransport(uri, { maxBytes: opts.maxBytes ?? DEFAULT_MAX_BYTES }) } catch (err) { attempts.push({ uri, scheme: 'https', reason: errMsg(err) }) continue diff --git a/packages/sdk/src/mirror/ssrf.ts b/packages/sdk/src/mirror/ssrf.ts index 6d8915a..2917150 100644 --- a/packages/sdk/src/mirror/ssrf.ts +++ b/packages/sdk/src/mirror/ssrf.ts @@ -76,39 +76,88 @@ function normalizeIpv6(host: string): string | undefined { return (addr ?? '').toLowerCase() } -/** A reason string for IPv6 addresses that must never be fetched server-side. */ -function isBlockedIpv6(addr: string): string | undefined { - const a = addr.toLowerCase() - if (a === '::1' || a === '0:0:0:0:0:0:0:1') return 'loopback (::1)' - if (a === '::' || a === '0:0:0:0:0:0:0:0') return 'unspecified (::)' - if (a.startsWith('fe8') || a.startsWith('fe9') || a.startsWith('fea') || a.startsWith('feb')) - return 'link-local (fe80::/10)' - if (a.startsWith('fc') || a.startsWith('fd')) return 'unique-local (fc00::/7)' - // IPv4-mapped (::ffff:a.b.c.d) - extract and re-check as IPv4. Node - // canonicalizes the dotted tail to hex (::ffff:127.0.0.1 -> ::ffff:7f00:1), - // so both forms must be recognized or the guard is trivially bypassed. - const v4 = embeddedMappedIpv4(a) - if (v4) { - const why = isBlockedIpv4(v4[0], v4[1], v4[2], v4[3]) - if (why) return `IPv4-mapped ${why}` +/** + * Expand an IPv6 textual address (lowercased, no brackets/zone) to its 16 bytes. + * Handles `::` compression and a trailing embedded IPv4 (`::ffff:1.2.3.4`). + * Returns `undefined` if it isn't a parseable IPv6 literal. Working at the byte + * level (rather than string-prefix matching) collapses every textual variant — + * compressed, expanded, mixed-case, leading-zero — to one canonical check. + */ +function expandIpv6(addr: string): number[] | undefined { + if (!addr.includes(':')) return undefined + let s = addr + // A trailing embedded IPv4 (`…:1.2.3.4`) becomes two hextets. + const lastColon = s.lastIndexOf(':') + const tail = s.slice(lastColon + 1) + if (tail.includes('.')) { + const v4 = parseIpv4(tail) + if (!v4) return undefined + const hi = ((v4[0] << 8) | v4[1]).toString(16) + const lo = ((v4[2] << 8) | v4[3]).toString(16) + s = `${s.slice(0, lastColon + 1)}${hi}:${lo}` } - return undefined + const halves = s.split('::') + if (halves.length > 2) return undefined + const head = halves[0] ? halves[0].split(':') : [] + const tailGroups = halves.length === 2 ? (halves[1] ? halves[1].split(':') : []) : null + let groups: string[] + if (tailGroups === null) { + groups = head // no `::` — must be a full 8 groups + } else { + const missing = 8 - head.length - tailGroups.length + if (missing < 0) return undefined + groups = [...head, ...Array(missing).fill('0'), ...tailGroups] + } + if (groups.length !== 8) return undefined + const bytes: number[] = [] + for (const g of groups) { + if (!/^[0-9a-f]{1,4}$/.test(g)) return undefined + const n = Number.parseInt(g, 16) + bytes.push((n >> 8) & 0xff, n & 0xff) + } + return bytes } /** - * Extract the embedded IPv4 of an IPv4-mapped IPv6 address (`::ffff:…`), - * accepting both the dotted tail (`::ffff:127.0.0.1`) and the canonical - * two-hextet hex tail Node emits (`::ffff:7f00:1`). Returns the four octets, - * or `undefined` if `a` isn't an IPv4-mapped address. + * A reason string for IPv6 addresses that must never be fetched server-side. + * Beyond the literal scoped ranges, IPv6 can *embed* an IPv4 via several + * transition prefixes (IPv4-mapped/-compatible/-translated, NAT64, 6to4) — each + * a known SSRF bypass if only `::ffff:` is recognized — so we expand to bytes, + * pull the embedded IPv4 out of every such prefix, and re-check it as IPv4. */ -function embeddedMappedIpv4(a: string): [number, number, number, number] | undefined { - const dotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(a) - if (dotted?.[1]) return parseIpv4(dotted[1]) - const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(a) - if (hex?.[1] && hex[2]) { - const hi = Number.parseInt(hex[1], 16) - const lo = Number.parseInt(hex[2], 16) - return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff] +function isBlockedIpv6(addr: string): string | undefined { + const b = expandIpv6(addr.toLowerCase()) + if (!b) { + if (addr === '::1') return 'loopback (::1)' + if (addr === '::') return 'unspecified (::)' + return undefined + } + const at = (i: number) => b[i] ?? 0 // b is length 16; coalesce keeps the type clean + const isZero = (lo: number, hi: number) => b.slice(lo, hi).every((x) => x === 0) + + if (isZero(0, 15) && at(15) === 1) return 'loopback (::1)' + if (isZero(0, 16)) return 'unspecified (::)' + if (at(0) === 0xfe && (at(1) & 0xc0) === 0x80) return 'link-local (fe80::/10)' + if ((at(0) & 0xfe) === 0xfc) return 'unique-local (fc00::/7)' + if (at(0) === 0xfe && (at(1) & 0xc0) === 0xc0) return 'site-local (fec0::/10, deprecated)' + if (at(0) === 0x20 && at(1) === 0x01 && at(2) === 0x00 && at(3) === 0x00) + return 'Teredo (2001::/32)' + + // Transition prefixes that embed an IPv4 in their low bits — extract + recheck. + let v4: [number, number, number, number] | undefined + const low = (): [number, number, number, number] => [at(12), at(13), at(14), at(15)] + if (isZero(0, 10) && at(10) === 0xff && at(11) === 0xff) + v4 = low() // ::ffff:0:0/96 IPv4-mapped + else if (isZero(0, 12)) + v4 = low() // ::/96 IPv4-compatible (:: and ::1 handled above) + else if (at(0) === 0x00 && at(1) === 0x64 && at(2) === 0xff && at(3) === 0x9b && isZero(4, 12)) + v4 = low() // 64:ff9b::/96 NAT64 well-known + else if (isZero(0, 8) && at(8) === 0xff && at(9) === 0xff && isZero(10, 12)) + v4 = low() // ::ffff:0:0 IPv4-translated form + else if (at(0) === 0x20 && at(1) === 0x02) v4 = [at(2), at(3), at(4), at(5)] // 2002::/16 6to4 (embedded v4 in bytes 2-5) + if (v4) { + const why = isBlockedIpv4(v4[0], v4[1], v4[2], v4[3]) + if (why) return `IPv6-embedded IPv4 ${why}` } return undefined } diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts index fd583e7..a0132c6 100644 --- a/packages/sdk/src/mirror/transport.ts +++ b/packages/sdk/src/mirror/transport.ts @@ -111,7 +111,7 @@ function trimGateway(g: string): string { * {@link TransportNotImplementedError} for recognized-but-unresolvable ones * (`web3://`). */ -export function resolveTransport(uri: string): ResolvedTransport { +export function resolveTransport(uri: string, opts: { maxBytes?: number } = {}): ResolvedTransport { const scheme = SCHEME_RE.exec(uri)?.[1]?.toLowerCase() if (!scheme) { throw new UnsupportedUriError(uri, 'no URI scheme') @@ -143,7 +143,7 @@ export function resolveTransport(uri: string): ResolvedTransport { return resolveArweave(uri) case 'data': - return resolveData(uri) + return resolveData(uri, opts.maxBytes) case 'magnet': // Parse-only: BitTorrent has no synchronous HTTP resolution path here. @@ -226,7 +226,7 @@ function resolveArweave(uri: string): ResolvedTransport { * `data:[][;base64],` (RFC 2397) to inline decoded bytes. * No network. The media type is captured for information only. */ -function resolveData(uri: string): ResolvedTransport { +function resolveData(uri: string, maxBytes?: number): ResolvedTransport { const comma = uri.indexOf(',') if (comma === -1) { throw new UnsupportedUriError(uri, 'malformed data: URI (no comma)') @@ -237,6 +237,19 @@ function resolveData(uri: string): ResolvedTransport { const mediaType = (isBase64 ? meta.replace(/;base64$/i, '') : meta).trim() const contentType = mediaType.length > 0 ? mediaType : undefined + // Reject by *encoded* length before decoding, so an oversized data: URI from + // untrusted metadata can't force a huge allocation (the decode is the bomb). + // base64 decodes to ~len*3/4 bytes; the text path is a safe over-approximation. + if (maxBytes !== undefined) { + const estimated = isBase64 ? Math.floor((dataPart.length * 3) / 4) : dataPart.length + if (estimated > maxBytes) { + throw new UnsupportedUriError( + uri, + `inline payload exceeds maxBytes (~${estimated} > ${maxBytes})`, + ) + } + } + let bytes: Uint8Array if (isBase64) { bytes = decodeBase64(dataPart) diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index 01206d3..fb1edaa 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -129,6 +129,28 @@ describe('checkSsrf - host guard', () => { // A public IPv4-mapped address stays allowed. expect(block('http://[::ffff:0808:0808]/x').blocked).toBe(false) // 8.8.8.8 }) + it('relies on URL normalization for alternate IPv4 encodings (lock-in)', () => { + // Node's WHATWG URL parser canonicalizes these to dotted-quad BEFORE the + // guard runs. These assertions lock that assumption in — if a future parser + // stopped normalizing, the guard would silently weaken and this would fail. + expect(block('http://0177.0.0.1/x').blocked).toBe(true) // octal -> 127.0.0.1 + expect(block('http://2130706433/x').blocked).toBe(true) // dword -> 127.0.0.1 + expect(block('http://0x7f000001/x').blocked).toBe(true) // hex -> 127.0.0.1 + expect(block('http://127.1/x').blocked).toBe(true) // part-collapse -> 127.0.0.1 + expect(block('http://2852039166/x').blocked).toBe(true) // dword -> 169.254.169.254 + }) + it('blocks IPv6 transition forms that embed a private/loopback IPv4', () => { + expect(block('http://[::127.0.0.1]/x').blocked).toBe(true) // IPv4-compatible + expect(block('http://[::ffff:0:127.0.0.1]/x').blocked).toBe(true) // IPv4-translated + expect(block('http://[64:ff9b::127.0.0.1]/x').blocked).toBe(true) // NAT64 -> loopback + expect(block('http://[64:ff9b::a9fe:a9fe]/x').blocked).toBe(true) // NAT64 -> metadata + expect(block('http://[2002:7f00:1::]/x').blocked).toBe(true) // 6to4 -> 127.0.0.1 + expect(block('http://[fec0::1]/x').blocked).toBe(true) // site-local (deprecated) + expect(block('http://[2001::1]/x').blocked).toBe(true) // Teredo + // Public IPv6 and a public IPv4-mapped address stay allowed. + expect(block('https://[2606:4700:4700::1111]/x').blocked).toBe(false) // Cloudflare DNS + expect(block('http://[::ffff:8.8.8.8]/x').blocked).toBe(false) + }) it('blocks trailing-dot FQDN forms of internal hosts', () => { // DNS treats `localhost.` as `localhost`, but URL.hostname keeps the dot. expect(block('http://localhost./x').blocked).toBe(true) @@ -184,6 +206,15 @@ describe('fetchVerified - happy paths per transport', () => { expect(fetchImpl).not.toHaveBeenCalled() }) + it('refuses a compressed response before reading the body (decompression bomb)', async () => { + const fetchImpl = vi.fn(async () => + mockResponse(enc('x'), { headers: { 'content-encoding': 'gzip' } }), + ) as unknown as typeof fetch + await expect( + fetchVerified(['https://mirror.example/blob'], undefined, { fetchImpl }), + ).rejects.toBeInstanceOf(AllMirrorsFailedError) + }) + it('enforces maxBytes on inline data: URIs (no cap bypass)', async () => { // 'hello' is 5 bytes; cap at 4 -> the inline mirror must be rejected. const fetchImpl = vi.fn() as unknown as typeof fetch From db195ad73aaf509fa6f248ae74be66dce3b65da1 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 03:21:13 -0500 Subject: [PATCH 37/47] harden(mirror): reject gateway path traversal in ipfs/arweave subpaths (P2) ipfs://cid/%2e%2e/admin (and the arweave equivalent) string-concatenated into new URL, which normalizes .. AND %2e%2e (WHATWG decodes percent-encoded dots during dot-segment removal), escaping /ipfs// to an arbitrary path on the trusted gateway. Now: validate CID (alphanumeric) / Arweave txid (base64url) shape at parse, and buildGatewayUrl asserts the post-normalization pathname still begins with the content-address namespace, rejecting otherwise. Legit subpaths preserved. Regression tests for literal + %2e-encoded traversal. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/mirror/transport.ts | 33 ++++++++++++++++++++++++++-- packages/sdk/test/mirror.test.ts | 11 ++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts index a0132c6..035bc31 100644 --- a/packages/sdk/src/mirror/transport.ts +++ b/packages/sdk/src/mirror/transport.ts @@ -105,6 +105,25 @@ function trimGateway(g: string): string { return g.endsWith('/') ? g.slice(0, -1) : g } +/** + * Build a gateway URL for `` and verify the subpath cannot + * escape the content-address namespace. `new URL` normalizes `..` AND `%2e%2e` + * (WHATWG decodes percent-encoded dots during dot-segment removal), so a mirror + * like `ipfs://cid/%2e%2e/admin` would otherwise resolve to `/admin` on the + * trusted gateway. We assert the post-normalization pathname still begins with + * the intended namespace and reject otherwise. + */ +function buildGatewayUrl(gateway: string, nsSegments: string, subpath: string, uri: string): URL { + const gw = new URL(trimGateway(gateway)) + const basePath = gw.pathname.endsWith('/') ? gw.pathname.slice(0, -1) : gw.pathname + const nsPath = `${basePath}/${nsSegments}` + const u = new URL(`${nsPath}${subpath}`, gw.origin) + if (!u.pathname.startsWith(nsPath)) { + throw new UnsupportedUriError(uri, 'subpath escapes the content-address namespace') + } + return u +} + /** * Parse a mirror URI into a {@link ResolvedTransport}. Does NOT fetch — purely * structural. Throws {@link UnsupportedUriError} for unknown schemes and @@ -186,13 +205,18 @@ function resolveIpfs(uri: string): ResolvedTransport { if (cid.length === 0) { throw new UnsupportedUriError(uri, 'missing CID') } + // CIDs are alphanumeric (base32/base58/base16); reject anything else so a + // crafted CID can't smuggle path/host characters into the gateway URL. + if (!/^[A-Za-z0-9]+$/.test(cid)) { + throw new UnsupportedUriError(uri, 'invalid CID') + } return { scheme: TRANSPORT.ipfs, uri, httpUrls: (opts) => { const gateways = opts?.ipfsGateways ?? DEFAULT_IPFS_GATEWAYS return gateways.map((g) => { - const u = new URL(`${trimGateway(g)}/ipfs/${cid}${subpath}`) + const u = buildGatewayUrl(g, `ipfs/${cid}`, subpath, uri) // IPIP-402: ask the gateway for the verifiable raw block. Harmless on // gateways that ignore it; we re-hash regardless. if (!u.searchParams.has('format')) u.searchParams.set('format', 'raw') @@ -212,12 +236,17 @@ function resolveArweave(uri: string): ResolvedTransport { if (txid.length === 0) { throw new UnsupportedUriError(uri, 'missing Arweave transaction id') } + // Arweave tx ids are base64url (43 chars of [A-Za-z0-9_-]); reject anything + // else so the id can't carry path/host characters. + if (!/^[A-Za-z0-9_-]+$/.test(txid)) { + throw new UnsupportedUriError(uri, 'invalid Arweave transaction id') + } return { scheme: TRANSPORT.arweave, uri, httpUrls: (opts) => { const gateways = opts?.arweaveGateways ?? DEFAULT_ARWEAVE_GATEWAYS - return gateways.map((g) => new URL(`${trimGateway(g)}/${txid}${subpath}`)) + return gateways.map((g) => buildGatewayUrl(g, txid, subpath, uri)) }, } } diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index fb1edaa..4b2dce2 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -65,6 +65,17 @@ describe('resolveTransport - URI parsing (TRANSPORT allowlist)', () => { expect(urls[0]!.href).toBe(`https://my.gw/ipfs/${cid}/dir/a.txt?format=raw`) }) + it('rejects path traversal in ipfs/arweave subpaths (literal and %2e-encoded)', () => { + const cid = 'bafytest' + // Literal `..` and percent-encoded `%2e%2e` both normalize in new URL and + // must not escape the /ipfs// namespace onto an arbitrary gateway path. + expect(() => resolveTransport(`ipfs://${cid}/../admin`).httpUrls()).toThrow() + expect(() => resolveTransport(`ipfs://${cid}/%2e%2e/admin`).httpUrls()).toThrow() + expect(() => resolveTransport('ar://txid123/../../admin').httpUrls()).toThrow() + // A CID/txid that carries non-alphanumeric smuggling chars is rejected at parse. + expect(() => resolveTransport('ipfs://bafy%2f..%2fadmin')).toThrow() + }) + it('parses ar://TXID to arweave gateways', () => { const tx = 'AbC123_txid' const r = resolveTransport(`ar://${tx}`) From 335762da2f58640972580284dc5c7aec381c1831 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Thu, 11 Jun 2026 03:26:02 -0500 Subject: [PATCH 38/47] harden(mirror): require / boundary in gateway namespace check (P2) The startsWith(nsPath) guard from db195ad accepted a sibling sharing the CID prefix: ipfs://bafy/../bafyadmin normalizes to /ipfs/bafyadmin, which startsWith /ipfs/bafy. Now require pathname === nsPath or startsWith nsPath+'/' so only the exact namespace (or a child under it) is allowed. Regression test added. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/mirror/transport.ts | 4 +++- packages/sdk/test/mirror.test.ts | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts index 035bc31..ddcfbcb 100644 --- a/packages/sdk/src/mirror/transport.ts +++ b/packages/sdk/src/mirror/transport.ts @@ -118,7 +118,9 @@ function buildGatewayUrl(gateway: string, nsSegments: string, subpath: string, u const basePath = gw.pathname.endsWith('/') ? gw.pathname.slice(0, -1) : gw.pathname const nsPath = `${basePath}/${nsSegments}` const u = new URL(`${nsPath}${subpath}`, gw.origin) - if (!u.pathname.startsWith(nsPath)) { + // Require an exact match or a `/` boundary — a bare prefix check would let + // `../admin` pass (`/ipfs/bafyadmin` startsWith `/ipfs/bafy`). + if (u.pathname !== nsPath && !u.pathname.startsWith(`${nsPath}/`)) { throw new UnsupportedUriError(uri, 'subpath escapes the content-address namespace') } return u diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index 4b2dce2..911e8f6 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -72,6 +72,9 @@ describe('resolveTransport - URI parsing (TRANSPORT allowlist)', () => { expect(() => resolveTransport(`ipfs://${cid}/../admin`).httpUrls()).toThrow() expect(() => resolveTransport(`ipfs://${cid}/%2e%2e/admin`).httpUrls()).toThrow() expect(() => resolveTransport('ar://txid123/../../admin').httpUrls()).toThrow() + // Sibling whose name shares the CID prefix: /ipfs/bafyadmin must NOT pass a + // namespace check for /ipfs/bafy (needs a `/` boundary, not a raw prefix). + expect(() => resolveTransport('ipfs://bafy/../bafyadmin').httpUrls()).toThrow() // A CID/txid that carries non-alphanumeric smuggling chars is rejected at parse. expect(() => resolveTransport('ipfs://bafy%2f..%2fadmin')).toThrow() }) From 0e6a85955b026b4faa28af971f399f888af7e326 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Mon, 15 Jun 2026 16:43:14 -0500 Subject: [PATCH 39/47] feat: build on merged on-chain tag-exclusion filter + folder Overviews (ADR-0011) Absorbs two merged contracts features as additive surface; fs.* bodies stay NotImplemented stubs pending the schema freeze, shapes locked now. On-chain directory filter (contracts ADR-0048): - ListOptions gains optional excludes (def-UID or human label) + minWeights; non-empty routes fs.list to the filtered view, empty = unfiltered. - Vendor the EFSFileView view ABI (first view-layer ABI; was EAS-only): getDirectoryPageFiltered + unfiltered siblings, transcribed from deployedContracts.ts. - Pure read helpers (src/reads/directory.ts): shouldUseFilteredQuery, reconcileMinWeights (all-zero default avoids the length-mismatch revert), validateDirectoryQuery (fails fast on the 20-attester / 8-exclude / maxItems caps) + InvalidDirectoryQuery. 18 tests. - No default excludes; SAFETY_EXCLUDES=['system','nsfw'] exported for opt-in. Folder Overviews: - fs.overview(path) -> discriminated OverviewResult (none|markdown|binary| too-large), exact-path resolve; fs.setOverview(container, markdown) (system TAG before placement). Convention constants OVERVIEW_NAME, MAX_RENDER_BYTES. No new schema/contract/reserved key. Deployments reconciled to contracts source-of-truth: drop phantom redirect schema + aliasResolver contract; add real blob/sortInfo/naming schemas + fileView/edgeResolver/sortOverlay/schemaNameIndex/listReader contracts. Add 'InvalidArgument' error code. Fix stale ADR-0011->ADR-0010 transport refs. Gate green: lint/typecheck/test(102)/build across @efs/sdk + @efs/solidity. Co-authored-by: Claude Opus 4.8 --- docs/adr/0011-onchain-filter-and-overviews.md | 40 +++++ docs/adr/README.md | 1 + docs/specs/overview.md | 2 + packages/sdk/src/chain/abi/fileView.ts | 142 ++++++++++++++++ packages/sdk/src/chain/deployments.ts | 26 ++- packages/sdk/src/errors.ts | 2 + packages/sdk/src/index.ts | 31 ++++ packages/sdk/src/mirror/transport.ts | 2 +- packages/sdk/src/reads/directory.ts | 115 +++++++++++++ packages/sdk/src/types.ts | 47 +++++- packages/sdk/test/client.test.ts | 22 ++- packages/sdk/test/reads-directory.test.ts | 155 ++++++++++++++++++ 12 files changed, 569 insertions(+), 16 deletions(-) create mode 100644 docs/adr/0011-onchain-filter-and-overviews.md create mode 100644 packages/sdk/src/chain/abi/fileView.ts create mode 100644 packages/sdk/src/reads/directory.ts create mode 100644 packages/sdk/test/reads-directory.test.ts diff --git a/docs/adr/0011-onchain-filter-and-overviews.md b/docs/adr/0011-onchain-filter-and-overviews.md new file mode 100644 index 0000000..8ed9895 --- /dev/null +++ b/docs/adr/0011-onchain-filter-and-overviews.md @@ -0,0 +1,40 @@ +# ADR-0011: Build on the on-chain tag-exclusion filter (ADR-0048) and folder Overviews + +**Status:** Accepted +**Date:** 2026-06-11 +**Related:** contracts ADR-0048 (view-layer tag-exclusion filter), ADR-0042 (effective tag weight), ADR-0031 (lenses), ADR-0033 (root containers / recipient-fallback anchors), ADR-0036 (opaque cursor); SDK ADR-0008 (public API/semver) + +## Context + +The contracts repo merged two features the SDK must build on: + +1. **On-chain tag-exclusion directory filter (ADR-0048).** `EFSFileView.getDirectoryPageFiltered(parentAnchor, anchorSchema, attesters, excludeTagDefs, minWeights, cursor, maxItems)` returns a directory page already filtered by a set of `(excludeTagDef, minWeight)` pairs, evaluated as a **union over viewed lenses and over exclude pairs**, inclusive (`weight >= minWeight`), with a folder-vs-file tag-target asymmetry (folders test the ANCHOR UID, files test the PIN-resolved DATA UID). Caps: `attesters` 1–20, `excludeTagDefs` ≤ 8, `maxItems > 0`. A heavily-excluded page can return **empty items with a non-empty cursor** (phase-1 scan budget) — empty ≠ end-of-list. +2. **Folder Overviews.** A markdown `README.md` anchor placed in a folder (or address-container root), tagged `system` **before** placement so it never flashes as a visible untagged item, authored on the top lens, resolved by **exact path** (never a directory scan). File-anchor Overviews were abandoned — Overviews are folder-scoped. + +The SDK vendors only EAS ABIs today, has no view-layer ABI, and its `fs.*` bodies are NotImplemented stubs pending the schema freeze. So "build on that" = **additive surface/type/ABI/doc changes now; bodies stay stubs**. + +## Decision + +Adopt both as additive surface. Four forks resolved: + +1. **Filtering rides `ListOptions`, not a new verb.** Add optional `excludes?: readonly (Hex | string)[]` (def-UID or human label resolved to `/tags/`) and `minWeights?: readonly bigint[]`. Non-empty `excludes` routes `fs.list` to `getDirectoryPageFiltered`; empty routes to the unfiltered sibling. Cursors stay opaque and method-bound. +2. **No `visibility` tri-state preset.** Ship only raw `excludes`/`minWeights`, which map 1:1 to the contract. A `'all'|'visible'|'hidden'` preset would invent semantics the contract doesn't model; deferred to a product decision. +3. **No default excludes.** `fs.list` excludes nothing unless asked. Export `SAFETY_EXCLUDES = ['system','nsfw']` for callers (and the reference explorer) that want the "hide system/nsfw" policy. A library that silently hides a caller's own `README.md` is a worse surprise than requiring opt-in. +4. **Overview is a dedicated verb pair.** `fs.overview(path)` → discriminated `OverviewResult` (`none | markdown | binary | too-large`), resolved by exact `[...container, 'README.md']`. `fs.setOverview(container, markdown)` composes the upload pipeline and applies the `system` TAG **before** placement. No new schema/contract/reserved-key — `README.md` + `/tags/system` are the entire convention. `getActiveTagWeight` is **not** vendored (the filter applies the threshold internally; no SDK consumer yet). + +Client-side we fail fast on the on-chain caps (≤20 attesters, ≤8 excludes, `maxItems>0`), derive an all-zero `minWeights` vector when omitted (avoids the length-mismatch revert), and the list iterator treats *empty-items + non-empty-cursor* as "keep paging." + +The SDK's deployments registry is reconciled against the contracts source-of-truth (drop phantom `redirect` schema + `aliasResolver` contract; add the real schemas/contracts) as a separate, SDK-internal, non-freeze-gated correction. + +## Consequences + +- All read surface, ABI, helpers, types, and registry work is **buildable now**; only `fs.list`'s filtered body and `fs.setOverview` are freeze-gated (stay stubs with locked signatures). +- The SDK gains its first **view-layer ABI** (`EFSFileView`), vendored hand-written `as const`. +- v1 filter limitations the SDK inherits and documents: reviewer/lens-relative exclusion, LIST items never excluded, cross-lens tagging honored. +- The `excludes`-as-labels path needs `/tags/` resolution before the first filtered call, and must gate the read so it never briefly issues the unfiltered branch (leak window). + +## Alternatives + +- **A `visibility` preset now** — rejected (#2): invents tri-state semantics over a per-tag-exclusion primitive; revisit when product defines "hidden." +- **Default-hide system/nsfw** — rejected (#3): surprising for a low-level SDK; opt-in via `SAFETY_EXCLUDES`. +- **`fs.read([...path,'README.md'])` instead of `fs.overview()`** — rejected (#4): a dedicated verb carries "absent = none" cleanly and pins the exact-path (no-scan) contract. diff --git a/docs/adr/README.md b/docs/adr/README.md index 045c4fb..e9335d5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -56,3 +56,4 @@ Compact, scannable — one screen per ADR. Copy `_template.md`. The next number - [ADR-0008 — Public API shape, instantiation & semver policy](./0008-public-api-and-semver.md) - [ADR-0009 — Library-agnostic seam: viem core, ethers as an optional adapter](./0009-library-agnostic-seam.md) - [ADR-0010 — Off-chain fetch/verify/mirror engine](./0010-fetch-mirror-engine.md) +- [ADR-0011 — Build on the on-chain tag-exclusion filter + folder Overviews](./0011-onchain-filter-and-overviews.md) diff --git a/docs/specs/overview.md b/docs/specs/overview.md index 3515989..a9f2aa7 100644 --- a/docs/specs/overview.md +++ b/docs/specs/overview.md @@ -29,4 +29,6 @@ Picking a path where you meant a specific version is silent breakage when the da - **Does:** simplify multi-step reads/writes, resolve lenses, batch writes, expose EAS cleanly (viem-native), give a typed escape hatch to the raw contracts, and locate EFS by chain via a shipped **deployments registry** (addresses + schema UIDs) so a supported chain needs no address config ([ADR-0005](../adr/0005-deployments-registry-not-a-deployer.md)). The SDK deploys nothing. - **Doesn't (yet):** bundle an indexer. Reverse-lookups ("who tagged this?") that need an external index are stubbed and out of scope for v1. +The SDK also builds on two on-chain features: **directory filtering** (pass `excludes` to a directory listing to hide entries carrying given tags, evaluated on-chain) and folder **Overviews** (a folder's `README.md`, read via `fs.overview(path)` and written via `fs.setOverview(...)`). Both are additive and need no new schema. See [ADR-0011](../adr/0011-onchain-filter-and-overviews.md). + For exact signatures, see the package READMEs and (later) the generated API reference. diff --git a/packages/sdk/src/chain/abi/fileView.ts b/packages/sdk/src/chain/abi/fileView.ts new file mode 100644 index 0000000..677a8d8 --- /dev/null +++ b/packages/sdk/src/chain/abi/fileView.ts @@ -0,0 +1,142 @@ +/** + * Vendored `EFSFileView` view-layer ABI fragments (ADR-0011). + * + * The SDK previously vendored only EAS ABIs (`src/eas/abi.ts`). This is the + * SDK's first view-layer ABI: a hand-written `as const` viem ABI covering only + * the directory-listing view functions the SDK reads. Struct shapes mirror the + * contracts source of truth exactly: + * + * - `FileSystemItem` and `DirectoryPage` from `EFSFileView.sol` (≈lines + * 104-145). `DirectoryPage` is `{ FileSystemItem[] items; bytes nextCursor }`. + * - The three directory-page reads: + * - `getDirectoryPageByAddressList` — unfiltered, no schema filter, returns + * a bare `FileSystemItem[]` + a `uint256 nextCursor` (NOT a DirectoryPage). + * - `getDirectoryPageBySchemaAndAddressList` — unfiltered sibling, returns a + * `DirectoryPage` (opaque `bytes` cursor, ADR-0036). + * - `getDirectoryPageFiltered` — on-chain tag-exclusion filter (ADR-0048), + * returns a `DirectoryPage`. + * + * Component names / types / order are transcribed from the committed ABI mirror + * `contracts/packages/nextjs/contracts/deployedContracts.ts` (the authoritative + * artifact), cross-checked against `EFSFileView.sol`. + * + * Keep this minimal — add fragments only when a code path needs them. + * `getActiveTagWeight` is deliberately NOT vendored (ADR-0011 §4): the filter + * applies the weight threshold internally; no SDK consumer reads it directly. + */ + +/** + * `FileSystemItem` tuple components, shared by all three reads. Mirrors + * `EFSFileView.FileSystemItem` (EFSFileView.sol lines 104-116). + */ +const fileSystemItemComponents = [ + { name: 'uid', type: 'bytes32' }, + { name: 'name', type: 'string' }, + { name: 'parentUID', type: 'bytes32' }, + { name: 'isFolder', type: 'bool' }, + { name: 'hasData', type: 'bool' }, + { name: 'childCount', type: 'uint256' }, + { name: 'propertyCount', type: 'uint256' }, + { name: 'timestamp', type: 'uint64' }, + { name: 'attester', type: 'address' }, + { name: 'schema', type: 'bytes32' }, + { name: 'contentHash', type: 'bytes32' }, +] as const + +/** + * `EFSFileView.getDirectoryPageByAddressList(bytes32, address[], uint256, uint256)` + * — unfiltered listing across an attester list. Returns a bare + * `FileSystemItem[]` plus a `uint256 nextCursor` (NOT wrapped in DirectoryPage). + */ +export const getDirectoryPageByAddressListAbi = [ + { + type: 'function', + name: 'getDirectoryPageByAddressList', + stateMutability: 'view', + inputs: [ + { name: 'parentAnchor', type: 'bytes32' }, + { name: 'attesters', type: 'address[]' }, + { name: 'startingCursor', type: 'uint256' }, + { name: 'pageSize', type: 'uint256' }, + ], + outputs: [ + { name: 'items', type: 'tuple[]', components: fileSystemItemComponents }, + { name: 'nextCursor', type: 'uint256' }, + ], + }, +] as const + +/** + * `EFSFileView.getDirectoryPageBySchemaAndAddressList(bytes32, bytes32, address[], bytes, uint256)` + * — unfiltered sibling of `getDirectoryPageFiltered`: same schema + attester + * filter and opaque `bytes` cursor (ADR-0036), no tag exclusion. Returns a + * `DirectoryPage`. + */ +export const getDirectoryPageBySchemaAndAddressListAbi = [ + { + type: 'function', + name: 'getDirectoryPageBySchemaAndAddressList', + stateMutability: 'view', + inputs: [ + { name: 'parentAnchor', type: 'bytes32' }, + { name: 'anchorSchema', type: 'bytes32' }, + { name: 'attesters', type: 'address[]' }, + { name: 'cursor', type: 'bytes' }, + { name: 'maxItems', type: 'uint256' }, + ], + outputs: [ + { + name: 'page', + type: 'tuple', + components: [ + { name: 'items', type: 'tuple[]', components: fileSystemItemComponents }, + { name: 'nextCursor', type: 'bytes' }, + ], + }, + ], + }, +] as const + +/** + * `EFSFileView.getDirectoryPageFiltered(bytes32, bytes32, address[], bytes32[], int256[], bytes, uint256)` + * — on-chain tag-exclusion directory filter (ADR-0048). `excludeTagDefs` and + * `minWeights` are parallel arrays (length must match, enforced on-chain). + * Returns a `DirectoryPage`; a heavily-excluded page can return empty `items` + * with a non-empty `nextCursor` (phase-1 scan budget) — empty ≠ end-of-list. + */ +export const getDirectoryPageFilteredAbi = [ + { + type: 'function', + name: 'getDirectoryPageFiltered', + stateMutability: 'view', + inputs: [ + { name: 'parentAnchor', type: 'bytes32' }, + { name: 'anchorSchema', type: 'bytes32' }, + { name: 'attesters', type: 'address[]' }, + { name: 'excludeTagDefs', type: 'bytes32[]' }, + { name: 'minWeights', type: 'int256[]' }, + { name: 'cursor', type: 'bytes' }, + { name: 'maxItems', type: 'uint256' }, + ], + outputs: [ + { + name: 'page', + type: 'tuple', + components: [ + { name: 'items', type: 'tuple[]', components: fileSystemItemComponents }, + { name: 'nextCursor', type: 'bytes' }, + ], + }, + ], + }, +] as const + +/** + * Combined `EFSFileView` ABI for the directory-page reads the SDK calls. Compose + * from the per-function fragments above (mirrors `easAbi`'s composition style). + */ +export const fileViewAbi = [ + ...getDirectoryPageByAddressListAbi, + ...getDirectoryPageBySchemaAndAddressListAbi, + ...getDirectoryPageFilteredAbi, +] as const diff --git a/packages/sdk/src/chain/deployments.ts b/packages/sdk/src/chain/deployments.ts index fd779e2..9e9b7ef 100644 --- a/packages/sdk/src/chain/deployments.ts +++ b/packages/sdk/src/chain/deployments.ts @@ -8,7 +8,15 @@ import type { Address, Hex, PublicClient } from 'viem' import { DeploymentNotFound, EfsError } from '../errors.js' /** The EFS + EAS contract addresses on a chain. The view/router addresses are the - * read-resolution trust root, so they are integrity-checked at construct time. */ + * read-resolution trust root, so they are integrity-checked at construct time. + * + * Keys mirror the contracts repo's deployed-contract set (chain 31337 in + * `packages/nextjs/contracts/deployedContracts.ts`): `Indexer`, `EFSRouter`, + * `EFSFileView`, `EdgeResolver`, `MirrorResolver`, `EFSSortOverlay`, + * `ListResolver`, `ListEntryResolver`, `ListReader`, `SchemaNameIndex` — plus the + * two external EAS contracts EFS is registered against. There is no `aliasResolver`: + * schema/attestation alias-anchor resolution lives in EFSRouter (ADR-0033), + * not a standalone contract. */ export type EfsContracts = { eas: Address schemaRegistry: Address @@ -17,22 +25,32 @@ export type EfsContracts = { fileView: Address edgeResolver: Address mirrorResolver: Address + sortOverlay: Address listResolver: Address listEntryResolver: Address - aliasResolver: Address + listReader: Address + schemaNameIndex: Address } -/** The frozen EFS schema-UID set (defined in the contracts repo). */ +/** The frozen EFS schema-UID set (defined in the contracts repo). + * + * Keys mirror the on-chain `*_SCHEMA_UID` getters in `deployedContracts.ts` + * (`Indexer` exposes ANCHOR/PROPERTY/DATA/BLOB/MIRROR/PIN/TAG/SORT_INFO; + * `SchemaNameIndex` exposes NAMING; `ListResolver`/`ListEntryResolver` expose + * LIST/LIST_ENTRY). There is no `redirect` schema — it does not exist in the + * contracts repo (no registration in `deploy/`, no `_SCHEMA_UID` getter). */ export type EfsSchemaUIDs = { anchor: Hex property: Hex data: Hex + blob: Hex pin: Hex tag: Hex mirror: Hex + sortInfo: Hex list: Hex listEntry: Hex - redirect: Hex + naming: Hex } export type EfsDeployment = { diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 3355cc1..b7e8cbc 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -20,6 +20,8 @@ export type EfsErrorCode = | 'DeploymentNotFound' | 'CursorInvalid' | 'PartialBatchFailure' + /** A caller argument violated a documented bound (e.g. directory-query caps). */ + | 'InvalidArgument' // --- classifier codes (ADR-0007 §Realization) --------------------------- /** Wallet rejected by the user (EIP-1193 `4001`). Benign, not a failure. */ | 'UserRejected' diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5263e48..5554234 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -48,6 +48,8 @@ import type { FetchOptions, FileStat, ListOptions, + OverviewOptions, + OverviewResult, PreviewOptions, ReadOptions, ReadResult, @@ -117,12 +119,20 @@ export type EfsFsRead = { * `{exists:true; …}`), never `null` — absence is modeled once (review A7). */ stat(path: string, opts?: ReadOptions): Promise list(path: string, opts?: ListOptions): EfsList + /** The folder Overview (`README.md`) for `path`, resolved by exact path — never + * a directory scan (ADR-0011). Returns a discriminated `OverviewResult` + * (`none` when absent). Folder-scoped: a file path has no Overview. */ + overview(path: string, opts?: OverviewOptions): Promise } /** Read + write file operations (only present when a `walletClient` is set). */ export type EfsFsWrite = EfsFsRead & { write(path: string, content: Uint8Array, opts?: WriteOptions): Promise preview(path: string, content: Uint8Array, opts?: PreviewOptions): Promise + /** Author/replace the folder Overview at `container`: composes the upload + * pipeline and applies the `system` TAG *before* placement, so an interrupted + * write never exposes a visible untagged README (ADR-0011). Folder-scoped. */ + setOverview(container: string, markdown: string, opts?: WriteOptions): Promise } export type EfsLensesNs = { @@ -201,6 +211,9 @@ export function createEfsClient(config: EfsClientConfig): EfsClient { throw new NotImplemented('efs.fs.list().page()') }, }), + overview: async (_path, _opts) => { + throw new NotImplemented('efs.fs.overview()') + }, write: async (_path, _content, _opts) => { requireWallet() throw new NotImplemented('efs.fs.write()') @@ -208,6 +221,10 @@ export function createEfsClient(config: EfsClientConfig): EfsClient { preview: async (_path, _content) => { throw new NotImplemented('efs.fs.preview()') }, + setOverview: async (_container, _markdown, _opts) => { + requireWallet() + throw new NotImplemented('efs.fs.setOverview()') + }, }, lenses: { resolve: (input) => resolveLens(input, { publicClient }), @@ -280,6 +297,8 @@ export type { ReadResult, EfsFile, FileStat, + OverviewResult, + OverviewOptions, WriteReceipt, WriteMechanism, CallStatus, @@ -288,3 +307,15 @@ export type { OperationKind, BatchReceipt, } from './types.js' +// Overview convention constants (values, ADR-0011). +export { OVERVIEW_NAME, SAFETY_EXCLUDES, MAX_RENDER_BYTES } from './types.js' +// On-chain directory filtering (ADR-0011): vendored view ABI + pure routing helpers. +export { fileViewAbi } from './chain/abi/fileView.js' +export { + MAX_ATTESTERS_PER_QUERY, + MAX_EXCLUDE_TAGS_PER_QUERY, + shouldUseFilteredQuery, + reconcileMinWeights, + validateDirectoryQuery, + InvalidDirectoryQuery, +} from './reads/directory.js' diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts index ddcfbcb..3e1f468 100644 --- a/packages/sdk/src/mirror/transport.ts +++ b/packages/sdk/src/mirror/transport.ts @@ -16,7 +16,7 @@ import type { TransportName } from '../types.js' -/** Value-level allowlist of known transports (ADR-0011 seam). Keys match +/** Value-level allowlist of known transports (ADR-0010). Keys match * `TransportName`. This is the *recognized* set; `TransportName` stays an open * union so an unknown scheme is still assignable, but only these resolve. */ export const TRANSPORT = { diff --git a/packages/sdk/src/reads/directory.ts b/packages/sdk/src/reads/directory.ts new file mode 100644 index 0000000..853cb15 --- /dev/null +++ b/packages/sdk/src/reads/directory.ts @@ -0,0 +1,115 @@ +/** + * Pure directory-read helpers (ADR-0011) — no network calls, fully unit-testable. + * + * These prepare and validate the arguments for the `EFSFileView` directory-page + * reads (`getDirectoryPageFiltered` and its unfiltered siblings) so the eventual + * `fs.list` body can route, reconcile, and fail-fast before issuing any RPC: + * + * - Routing: non-empty `excludeTagDefs` ⇒ the filtered query + * (`getDirectoryPageFiltered`); empty ⇒ the unfiltered sibling (ADR-0011 §1). + * - `minWeights` reconciliation: derive an all-zero vector when omitted/mismatched + * so the on-chain `excludeTagDefs/minWeights length mismatch` revert never fires + * (ADR-0042 default weight = 0; ADR-0048). + * - Cap enforcement: fail fast on the on-chain caps (`attesters` 1-20, + * `excludeTagDefs` ≤ 8, `maxItems > 0`) with a typed {@link EfsError}. + * + * Imports are limited to viem (types only) and the SDK error tree. + */ + +import type { Address, Hex } from 'viem' +import { EfsError } from '../errors.js' + +/** + * On-chain cap: `EFSFileView` rejects an attester list that is empty or has more + * than this many entries (`MAX_ATTESTERS_PER_QUERY`). Mirrors the contract guard + * (`require(attesters.length > 0 ...)` / `require(attesters.length <= ...)`). + */ +export const MAX_ATTESTERS_PER_QUERY = 20 + +/** + * On-chain cap: `getDirectoryPageFiltered` rejects more than this many exclude + * predicates (`MAX_EXCLUDE_TAGS_PER_QUERY`). + */ +export const MAX_EXCLUDE_TAGS_PER_QUERY = 8 + +/** + * Route to the filtered query (`getDirectoryPageFiltered`) iff at least one + * exclude predicate was given; otherwise the unfiltered sibling is used + * (ADR-0011 §1). Pure predicate over the exclude list's length. + */ +export function shouldUseFilteredQuery(excludeTagDefs: readonly unknown[]): boolean { + return excludeTagDefs.length > 0 +} + +/** + * Reconcile the parallel `minWeights` vector against `excludeTagDefs`. + * + * The contract requires `minWeights.length === excludeTagDefs.length` and reverts + * otherwise. When the caller supplies a vector of the matching length we pass it + * through verbatim; otherwise (omitted, or any length mismatch) we derive an + * all-zero `0n` vector of the correct length — the ADR-0042 default threshold, + * which means "exclude on any non-negative-weight tag." This prevents the + * `excludeTagDefs/minWeights length mismatch` revert. + */ +export function reconcileMinWeights( + excludeTagDefs: readonly unknown[], + minWeights?: readonly bigint[], +): bigint[] { + if (minWeights !== undefined && minWeights.length === excludeTagDefs.length) { + return [...minWeights] + } + return new Array(excludeTagDefs.length).fill(0n) +} + +/** + * A directory-query argument violated an on-chain cap (ADR-0011, ADR-0048). + * Surfaced before any RPC so the caller never round-trips into a contract revert. + */ +export class InvalidDirectoryQuery extends EfsError { + override name = 'InvalidDirectoryQuery' + constructor(message: string) { + super(message, { code: 'InvalidArgument' }) + } +} + +/** + * Fail-fast validation of the on-chain caps for a directory query (ADR-0011 §Decision): + * + * - `attesters`: non-empty and ≤ {@link MAX_ATTESTERS_PER_QUERY}. + * - `excludeTagDefs`: ≤ {@link MAX_EXCLUDE_TAGS_PER_QUERY}. + * - `maxItems`: `> 0`. + * + * Throws {@link InvalidDirectoryQuery} (code `'InvalidArgument'`) naming the + * violated cap. Returns `void` on success. + */ +export function validateDirectoryQuery(args: { + attesters: readonly unknown[] + excludeTagDefs: readonly unknown[] + maxItems: number | bigint +}): void { + const { attesters, excludeTagDefs, maxItems } = args + + if (attesters.length === 0) { + throw new InvalidDirectoryQuery( + 'A directory query needs at least one attester (lens) — the attesters list is empty.', + ) + } + if (attesters.length > MAX_ATTESTERS_PER_QUERY) { + throw new InvalidDirectoryQuery( + `Too many attesters: ${attesters.length} given, the on-chain cap (MAX_ATTESTERS_PER_QUERY) is ${MAX_ATTESTERS_PER_QUERY}.`, + ) + } + if (excludeTagDefs.length > MAX_EXCLUDE_TAGS_PER_QUERY) { + throw new InvalidDirectoryQuery( + `Too many exclude tags: ${excludeTagDefs.length} given, the on-chain cap (MAX_EXCLUDE_TAGS_PER_QUERY) is ${MAX_EXCLUDE_TAGS_PER_QUERY}.`, + ) + } + if (maxItems <= 0) { + throw new InvalidDirectoryQuery(`maxItems must be greater than 0 (got ${maxItems.toString()}).`) + } +} + +// Reference the viem types so this module is the documented home of the +// directory-query argument shapes even before `fs.list` consumes them. +export type DirectoryQueryAttester = Address +export type DirectoryQueryExcludeTag = Hex diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index da8128d..ba32ede 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -42,12 +42,26 @@ export type ListOptions = ReadOptions & { limit?: number /** Opaque resumable cursor from a prior `Page`. */ cursor?: string + /** + * Tag-exclusion filter (contracts ADR-0048 / SDK ADR-0011). Each entry is a + * TAG definition UID (`Hex`) or a human label (e.g. `'system'`/`'nsfw'`) the + * SDK resolves to its `/tags/` definition UID. Non-empty routes the + * listing to the on-chain filtered view; empty/absent = unfiltered. Nothing is + * excluded by default — pass `SAFETY_EXCLUDES` to opt into the common policy. + */ + excludes?: readonly (Hex | string)[] + /** + * Per-exclude inclusive weight threshold (`weight >= minWeights[k]`), aligned + * by index with `excludes`. Omitted or length-mismatched ⇒ an all-zero vector + * (ADR-0042 default). Capped at 8 excludes on-chain. + */ + minWeights?: readonly bigint[] } /** A named transport for mirror/fetch resolution. Open union (review A8): the * known transports are autocompletable, but an unrecognized name is still - * assignable so adding one is never breaking. Will be derived from the planned - * `TRANSPORT` constant (ADR-0011) once it lands. */ + * assignable so adding one is never breaking. Mirrors the `TRANSPORT` value + * allowlist in `mirror/transport.ts` (ADR-0010). */ export type TransportName = | 'web3' | 'arweave' @@ -200,3 +214,32 @@ export type FileStat = contentType?: string size?: bigint } + +// ── Folder Overviews (ADR-0011) ───────────────────────────────────────────────── + +/** The fixed anchor name a folder Overview is stored under (case-sensitive). */ +export const OVERVIEW_NAME = 'README.md' as const + +/** Opt-in directory-filter policy that hides the conventional system labels + * (the Overview is `system`-tagged). Pass to `ListOptions.excludes`; not applied + * by default (ADR-0011 §3). */ +export const SAFETY_EXCLUDES: readonly string[] = ['system', 'nsfw'] + +/** Default cap on Overview bytes the SDK will buffer/return as text; larger + * payloads surface as the `too-large` variant rather than being materialized. */ +export const MAX_RENDER_BYTES = 256 * 1024 + +/** Options for an Overview read (extends read options; reserved for future + * knobs following the `PreviewOptions` precedent). */ +export type OverviewOptions = ReadOptions + +/** + * Result of `fs.overview()` — a discriminated union so "absent" is distinct from + * "present but not markdown". `source` distinguishes an on-chain (editable) body + * from a mirror-hosted (read-only) one. + */ +export type OverviewResult = + | { kind: 'none' } + | { kind: 'markdown'; text: string; source: 'onchain' | 'mirror' } + | { kind: 'binary'; bytes: Uint8Array; contentType?: string; source: 'onchain' | 'mirror' } + | { kind: 'too-large'; size: bigint } diff --git a/packages/sdk/test/client.test.ts b/packages/sdk/test/client.test.ts index 8799bbf..9fc60fc 100644 --- a/packages/sdk/test/client.test.ts +++ b/packages/sdk/test/client.test.ts @@ -45,21 +45,25 @@ const contracts: EfsContracts = { fileView: addr(5), edgeResolver: addr(6), mirrorResolver: addr(7), - listResolver: addr(8), - listEntryResolver: addr(9), - aliasResolver: addr(10), + sortOverlay: addr(8), + listResolver: addr(9), + listEntryResolver: addr(10), + listReader: addr(11), + schemaNameIndex: addr(12), } const schemas: EfsSchemaUIDs = { anchor: '0x01', property: '0x02', data: '0x03', - pin: '0x04', - tag: '0x05', - mirror: '0x06', - list: '0x07', - listEntry: '0x08', - redirect: '0x09', + blob: '0x04', + pin: '0x05', + tag: '0x06', + mirror: '0x07', + sortInfo: '0x08', + list: '0x09', + listEntry: '0x0a', + naming: '0x0b', } const deployment: EfsDeployment = { chainId: CHAIN_ID, contracts, schemas } diff --git a/packages/sdk/test/reads-directory.test.ts b/packages/sdk/test/reads-directory.test.ts new file mode 100644 index 0000000..a7b7fce --- /dev/null +++ b/packages/sdk/test/reads-directory.test.ts @@ -0,0 +1,155 @@ +import { encodeFunctionData, getAbiItem } from 'viem' +import { describe, expect, it } from 'vitest' +import { fileViewAbi } from '../src/chain/abi/fileView.js' +import { EfsError } from '../src/errors.js' +import { + InvalidDirectoryQuery, + MAX_ATTESTERS_PER_QUERY, + MAX_EXCLUDE_TAGS_PER_QUERY, + reconcileMinWeights, + shouldUseFilteredQuery, + validateDirectoryQuery, +} from '../src/reads/directory.js' + +const attester = '0x1111111111111111111111111111111111111111' as const +const tagDef = `0x${'ab'.repeat(32)}` as `0x${string}` + +describe('fileViewAbi (ADR-0011: vendored EFSFileView view-layer ABI)', () => { + it('exposes the three directory-page reads', () => { + for (const name of [ + 'getDirectoryPageByAddressList', + 'getDirectoryPageBySchemaAndAddressList', + 'getDirectoryPageFiltered', + ] as const) { + expect(getAbiItem({ abi: fileViewAbi, name })).toBeDefined() + } + }) + + it('viem accepts it as a real ABI (encodes the filtered call)', () => { + const data = encodeFunctionData({ + abi: fileViewAbi, + functionName: 'getDirectoryPageFiltered', + args: [`0x${'00'.repeat(32)}`, `0x${'00'.repeat(32)}`, [attester], [tagDef], [0n], '0x', 50n], + }) + expect(data.startsWith('0x')).toBe(true) + }) +}) + +describe('shouldUseFilteredQuery (ADR-0011 §1 routing)', () => { + it('routes to the unfiltered sibling when no excludes are given', () => { + expect(shouldUseFilteredQuery([])).toBe(false) + }) + + it('routes to the filtered query when at least one exclude is given', () => { + expect(shouldUseFilteredQuery([tagDef])).toBe(true) + expect(shouldUseFilteredQuery([tagDef, tagDef])).toBe(true) + }) +}) + +describe('reconcileMinWeights (ADR-0042 default / length-mismatch revert guard)', () => { + it('passes minWeights through verbatim when lengths match', () => { + const weights = [1n, -2n, 3n] + const out = reconcileMinWeights([tagDef, tagDef, tagDef], weights) + expect(out).toEqual([1n, -2n, 3n]) + }) + + it('returns a fresh array (does not alias the input)', () => { + const weights = [5n] + const out = reconcileMinWeights([tagDef], weights) + expect(out).toEqual([5n]) + expect(out).not.toBe(weights) + }) + + it('derives an all-zero vector when minWeights is omitted', () => { + expect(reconcileMinWeights([tagDef, tagDef])).toEqual([0n, 0n]) + }) + + it('derives an all-zero vector on length mismatch (too short)', () => { + expect(reconcileMinWeights([tagDef, tagDef], [1n])).toEqual([0n, 0n]) + }) + + it('derives an all-zero vector on length mismatch (too long)', () => { + expect(reconcileMinWeights([tagDef], [1n, 2n])).toEqual([0n]) + }) + + it('returns an empty vector for an empty exclude list', () => { + expect(reconcileMinWeights([])).toEqual([]) + expect(reconcileMinWeights([], [1n])).toEqual([]) + }) +}) + +describe('validateDirectoryQuery (on-chain cap fail-fast)', () => { + const valid = { + attesters: [attester], + excludeTagDefs: [tagDef], + maxItems: 50, + } + + it('passes for a valid query', () => { + expect(() => validateDirectoryQuery(valid)).not.toThrow() + }) + + it('passes at the exact attester and exclude caps', () => { + expect(() => + validateDirectoryQuery({ + attesters: new Array(MAX_ATTESTERS_PER_QUERY).fill(attester), + excludeTagDefs: new Array(MAX_EXCLUDE_TAGS_PER_QUERY).fill(tagDef), + maxItems: 1, + }), + ).not.toThrow() + }) + + it('throws a typed EfsError naming the empty-attesters violation', () => { + let err: unknown + try { + validateDirectoryQuery({ ...valid, attesters: [] }) + } catch (e) { + err = e + } + expect(err).toBeInstanceOf(InvalidDirectoryQuery) + expect(err).toBeInstanceOf(EfsError) + expect((err as EfsError).code).toBe('InvalidArgument') + expect((err as EfsError).message).toMatch(/attester/i) + }) + + it('throws when attesters exceeds the cap, naming the cap', () => { + let err: unknown + try { + validateDirectoryQuery({ + ...valid, + attesters: new Array(MAX_ATTESTERS_PER_QUERY + 1).fill(attester), + }) + } catch (e) { + err = e + } + expect(err).toBeInstanceOf(InvalidDirectoryQuery) + expect((err as EfsError).message).toMatch(/MAX_ATTESTERS_PER_QUERY/) + }) + + it('throws when excludeTagDefs exceeds the cap, naming the cap', () => { + let err: unknown + try { + validateDirectoryQuery({ + ...valid, + excludeTagDefs: new Array(MAX_EXCLUDE_TAGS_PER_QUERY + 1).fill(tagDef), + }) + } catch (e) { + err = e + } + expect(err).toBeInstanceOf(InvalidDirectoryQuery) + expect((err as EfsError).message).toMatch(/MAX_EXCLUDE_TAGS_PER_QUERY/) + }) + + it('throws when maxItems is zero', () => { + expect(() => validateDirectoryQuery({ ...valid, maxItems: 0 })).toThrow(InvalidDirectoryQuery) + }) + + it('throws when maxItems is negative', () => { + expect(() => validateDirectoryQuery({ ...valid, maxItems: -1 })).toThrow(InvalidDirectoryQuery) + }) + + it('accepts a bigint maxItems', () => { + expect(() => validateDirectoryQuery({ ...valid, maxItems: 10n })).not.toThrow() + expect(() => validateDirectoryQuery({ ...valid, maxItems: 0n })).toThrow(InvalidDirectoryQuery) + }) +}) From 310ef5f138bae4e84a75e275e12c47c97a359e94 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Mon, 15 Jun 2026 16:45:57 -0500 Subject: [PATCH 40/47] chore(size): raise budget to 10 kB for the view ABI + filter/Overview surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-0011 additions (first view-layer ABI + directory-filter helpers + Overview types) grew the gzipped ESM bundle from 6.45 -> 8.15 kB, past the 8 kB budget. size-limit guards regressions, not deliberate surface growth — raise to 10 kB (~25% headroom per our bundle doctrine). Revisit subpath-splitting the ABIs if bundle size later becomes a real constraint. Co-authored-by: Claude Opus 4.8 --- packages/sdk/.size-limit.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/.size-limit.json b/packages/sdk/.size-limit.json index 358ed13..bf2bec6 100644 --- a/packages/sdk/.size-limit.json +++ b/packages/sdk/.size-limit.json @@ -5,6 +5,6 @@ "import": "*", "ignore": ["viem"], "gzip": true, - "limit": "8 kB" + "limit": "10 kB" } ] From 8481394982237f02abe9993b0cf5aec9cb695ca4 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Mon, 15 Jun 2026 16:58:39 -0500 Subject: [PATCH 41/47] fix(mirror,errors): UTF-8 data: cap + walk cause chain for error codes (P2 x2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 (transport): the data: text-path size guard measured UTF-16 string length, so a payload within the char cap could decode past it (€ = 1 char, 3 UTF-8 bytes), and resolveTransport used directly never enforced the cap. Now: pre-decode reject on a cheap lower bound (ceil(len/3) for text) to bound transient allocation, plus an authoritative exact UTF-8 byteLength cap inside resolveData itself. P2 (errors): classifyError read the numeric EIP-1193/1474 code off the OUTER error only, so a 4001/-32xxx wrapped under a viem contract/tx error fell through to generic. numericCode now walks the cause chain (cycle-guarded). Tests: UTF-8 vs UTF-16 data: cap (encoded + literal non-ASCII), nested-cause 4001->UserRejected and -32000->RpcError. Gate incl. size: 104 tests, green. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/errors.ts | 18 +++++++++++++---- packages/sdk/src/mirror/transport.ts | 29 +++++++++++++++++++--------- packages/sdk/test/errors.test.ts | 12 ++++++++++++ packages/sdk/test/mirror.test.ts | 13 +++++++++++++ 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index b7e8cbc..d8c0506 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -207,11 +207,21 @@ export class RpcError extends EfsError { } } -/** Pull a numeric EIP-1193/1474 error code off an arbitrary error-ish value. - * viem's `ProviderRpcError`/`RpcError` carry `.code`; raw EIP-1193 errors do too. */ +/** Pull a numeric EIP-1193/1474 error code off an error or ANY error in its + * `cause` chain. viem wraps provider errors (e.g. a `UserRejectedRequestError` + * carrying `4001`) under contract/transaction errors, so the code is often on a + * nested cause, not the outer error — walk the chain or wrapped failures fall + * through to a generic `EfsError`. Cycle-guarded. */ function numericCode(err: unknown): number | undefined { - const code = (err as { code?: unknown } | null | undefined)?.code - return typeof code === 'number' ? code : undefined + const seen = new Set() + let cur: unknown = err + while (cur != null && !seen.has(cur)) { + seen.add(cur) + const code = (cur as { code?: unknown }).code + if (typeof code === 'number') return code + cur = (cur as { cause?: unknown }).cause + } + return undefined } /** diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts index 3e1f468..aa53240 100644 --- a/packages/sdk/src/mirror/transport.ts +++ b/packages/sdk/src/mirror/transport.ts @@ -268,16 +268,17 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { const mediaType = (isBase64 ? meta.replace(/;base64$/i, '') : meta).trim() const contentType = mediaType.length > 0 ? mediaType : undefined - // Reject by *encoded* length before decoding, so an oversized data: URI from - // untrusted metadata can't force a huge allocation (the decode is the bomb). - // base64 decodes to ~len*3/4 bytes; the text path is a safe over-approximation. + // Pre-decode reject on a cheap LOWER bound of the decoded size, so a giant + // payload can't force a large allocation (the decode is the bomb). base64 + // decodes to ~len*3/4 bytes (tight). For text, the minimum is ceil(len/3) — the + // all-`%XX` case (3 chars → 1 byte) — which bounds transient allocation to + // ≤ ~3× maxBytes. The exact UTF-8 check AFTER decode is authoritative. if (maxBytes !== undefined) { - const estimated = isBase64 ? Math.floor((dataPart.length * 3) / 4) : dataPart.length - if (estimated > maxBytes) { - throw new UnsupportedUriError( - uri, - `inline payload exceeds maxBytes (~${estimated} > ${maxBytes})`, - ) + const lowerBound = isBase64 + ? Math.floor((dataPart.length * 3) / 4) + : Math.ceil(dataPart.length / 3) + if (lowerBound > maxBytes) { + throw new UnsupportedUriError(uri, `inline payload exceeds maxBytes (~>${maxBytes})`) } } @@ -290,6 +291,16 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { bytes = new TextEncoder().encode(text) } + // Authoritative cap on ACTUAL UTF-8 bytes (e.g. `€` is 1 char but 3 bytes, so a + // char-count check under-counts). Enforced here so `resolveTransport` honors the + // cap even when called directly, not only via fetchVerified afterward. + if (maxBytes !== undefined && bytes.byteLength > maxBytes) { + throw new UnsupportedUriError( + uri, + `inline payload ${bytes.byteLength} bytes exceeds maxBytes (${maxBytes})`, + ) + } + return { scheme: TRANSPORT.data, uri, diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts index 5f332fe..67d6e4c 100644 --- a/packages/sdk/test/errors.test.ts +++ b/packages/sdk/test/errors.test.ts @@ -110,6 +110,18 @@ describe('classifyError (ADR-0007 classifier)', () => { expect(out.cause).toBe(plain) }) + it('finds an EIP-1193 code on a nested cause (wrapped wallet rejection)', () => { + // viem wraps a UserRejectedRequestError (4001) under a contract/tx error; + // the code is on the cause, not the outer error. + const inner = Object.assign(new Error('User rejected the request.'), { code: 4001 }) + const wrapped = Object.assign(new Error('execution failed'), { cause: inner }) + expect(classifyError(wrapped).code).toBe('UserRejected') + // A nested JSON-RPC code resolves too. + const rpcInner = Object.assign(new Error('rpc'), { code: -32000 }) + const rpcWrapped = Object.assign(new Error('outer'), { cause: rpcInner }) + expect(classifyError(rpcWrapped).code).toBe('RpcError') + }) + it('never throws on exotic inputs and always returns an EfsError', () => { for (const input of [undefined, null, 42, 'a string', {}, Symbol('s')]) { const out = classifyError(input) diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index 911e8f6..7a59756 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -237,6 +237,19 @@ describe('fetchVerified - happy paths per transport', () => { ).rejects.toBeInstanceOf(AllMirrorsFailedError) expect(fetchImpl).not.toHaveBeenCalled() }) + + it('counts UTF-8 bytes (not UTF-16 length) for text data: URIs', async () => { + // '€' is 1 string char but 3 UTF-8 bytes; cap at 2 must reject it even though + // its character count (1) is within the cap. + const fetchImpl = vi.fn() as unknown as typeof fetch + await expect( + fetchVerified(['data:,%E2%82%AC'], undefined, { fetchImpl, maxBytes: 2 }), + ).rejects.toBeInstanceOf(AllMirrorsFailedError) + // resolveTransport enforces the cap directly too (not only via fetchVerified). + expect(() => resolveTransport('data:,%E2%82%AC', { maxBytes: 2 })).toThrow() + // A literal (non-percent-encoded) non-ASCII char is caught as well. + expect(() => resolveTransport('data:,€', { maxBytes: 2 })).toThrow() + }) }) describe('fetchVerified - failover', () => { From f4f9bb5564123b5eda029dbc36d2cc1e5eb9e29d Mon Sep 17 00:00:00 2001 From: James Carnley Date: Mon, 15 Jun 2026 17:07:18 -0500 Subject: [PATCH 42/47] fix(mirror): exclude base64 padding/whitespace from data: cap precheck (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base64 pre-decode bound counted `=` padding (and whitespace), over-estimating by up to 2 bytes — so an at-cap payload like data:;base64,aGVsbG8= (5 bytes) was falsely rejected at maxBytes=5 before the authoritative byteLength check. Strip whitespace + trailing padding first: floor(sig*3/4). Regression test: at-cap padded payload is now accepted. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/mirror/transport.ts | 19 +++++++++++++------ packages/sdk/test/mirror.test.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts index aa53240..1802005 100644 --- a/packages/sdk/src/mirror/transport.ts +++ b/packages/sdk/src/mirror/transport.ts @@ -270,13 +270,20 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { // Pre-decode reject on a cheap LOWER bound of the decoded size, so a giant // payload can't force a large allocation (the decode is the bomb). base64 - // decodes to ~len*3/4 bytes (tight). For text, the minimum is ceil(len/3) — the - // all-`%XX` case (3 chars → 1 byte) — which bounds transient allocation to - // ≤ ~3× maxBytes. The exact UTF-8 check AFTER decode is authoritative. + // decodes to `floor(sig*3/4)` where `sig` is the significant-char count after + // stripping whitespace and `=` padding (counting padding over-estimates and + // would falsely reject an at-cap payload, e.g. `aGVsbG8=` → 5 bytes, not 6). + // For text, the minimum is ceil(len/3) — the all-`%XX` case (3 chars → 1 byte) + // — bounding transient allocation to ≤ ~3× maxBytes. The exact UTF-8 check + // AFTER decode is authoritative. if (maxBytes !== undefined) { - const lowerBound = isBase64 - ? Math.floor((dataPart.length * 3) / 4) - : Math.ceil(dataPart.length / 3) + let lowerBound: number + if (isBase64) { + const sig = dataPart.replace(/\s+/g, '').replace(/=+$/, '').length + lowerBound = Math.floor((sig * 3) / 4) + } else { + lowerBound = Math.ceil(dataPart.length / 3) + } if (lowerBound > maxBytes) { throw new UnsupportedUriError(uri, `inline payload exceeds maxBytes (~>${maxBytes})`) } diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index 7a59756..f3dee2c 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -238,6 +238,20 @@ describe('fetchVerified - happy paths per transport', () => { expect(fetchImpl).not.toHaveBeenCalled() }) + it('does not count base64 padding/whitespace against the cap', async () => { + // 'aGVsbG8=' decodes to exactly 5 bytes; at maxBytes=5 it must be ACCEPTED + // (the padding `=` must not be counted as a 6th byte by the pre-check). + const bytes = enc('hello') + const hash = hashContent(bytes) + const fetchImpl = vi.fn() as unknown as typeof fetch + const res = await fetchVerified(['data:text/plain;base64,aGVsbG8='], hash, { + fetchImpl, + maxBytes: 5, + }) + expect(res.verification).toBe('matches-author') + expect(res.bytes).toEqual(bytes) + }) + it('counts UTF-8 bytes (not UTF-16 length) for text data: URIs', async () => { // '€' is 1 string char but 3 UTF-8 bytes; cap at 2 must reject it even though // its character count (1) is within the cap. From fa56b1134d8506ed37150eff537a4fddf0e4b6cf Mon Sep 17 00:00:00 2001 From: James Carnley Date: Mon, 15 Jun 2026 17:18:20 -0500 Subject: [PATCH 43/47] fix(mirror,eas): byte-wise data: octet decode + EAS error fragments (P2 x2) P2 (transport): non-base64 data: payloads are octets (RFC 2397), but decodeURIComponent treats %XX as UTF-8 and throws on binary like data:application/octet-stream,%ff. New decodeDataOctets decodes %XX byte-wise (literal runs as UTF-8), so binary inline mirrors hash instead of failing. P2 (eas): easAbi exported only function fragments, so viem couldn't populate ContractFunctionRevertedError.data.errorName on a real attest/multiAttest revert and the classifier's InvalidSchema->SchemaMismatch mapping never fired. Added the 22 EAS custom-error fragments (transcribed from eas-contracts EAS.sol/IEAS.sol). Tests: binary octet decode (%ff, mixed literal+octet); InvalidSchema revert decoded against the real easAbi -> SchemaMismatch. Gate incl. size: 107 tests. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/eas/abi.ts | 44 ++++++++++++++++++++++++++-- packages/sdk/src/mirror/transport.ts | 34 +++++++++++++++++++-- packages/sdk/test/errors.test.ts | 15 ++++++++++ packages/sdk/test/mirror.test.ts | 9 ++++++ 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/eas/abi.ts b/packages/sdk/src/eas/abi.ts index 9e62788..1f7c9e3 100644 --- a/packages/sdk/src/eas/abi.ts +++ b/packages/sdk/src/eas/abi.ts @@ -131,12 +131,50 @@ export const getSchemaAbi = [ }, ] as const +/** + * EAS custom-error fragments (transcribed from eas-contracts EAS.sol/IEAS.sol — + * all zero-arg). Required so viem can populate `ContractFunctionRevertedError + * .data.errorName` when an `attest`/`multiAttest` call reverts; without them the + * error classifier (errors.ts) can't map e.g. `InvalidSchema` → `SchemaMismatch` + * and EAS reverts fall through as generic. (Codex P2.) + */ +const easErrorsAbi = [ + { type: 'error', name: 'AccessDenied', inputs: [] }, + { type: 'error', name: 'AlreadyRevoked', inputs: [] }, + { type: 'error', name: 'AlreadyRevokedOffchain', inputs: [] }, + { type: 'error', name: 'AlreadyTimestamped', inputs: [] }, + { type: 'error', name: 'DeadlineExpired', inputs: [] }, + { type: 'error', name: 'InsufficientValue', inputs: [] }, + { type: 'error', name: 'InvalidAttestation', inputs: [] }, + { type: 'error', name: 'InvalidAttestations', inputs: [] }, + { type: 'error', name: 'InvalidEAS', inputs: [] }, + { type: 'error', name: 'InvalidExpirationTime', inputs: [] }, + { type: 'error', name: 'InvalidLength', inputs: [] }, + { type: 'error', name: 'InvalidOffset', inputs: [] }, + { type: 'error', name: 'InvalidRegistry', inputs: [] }, + { type: 'error', name: 'InvalidRevocation', inputs: [] }, + { type: 'error', name: 'InvalidRevocations', inputs: [] }, + { type: 'error', name: 'InvalidSchema', inputs: [] }, + { type: 'error', name: 'InvalidSignature', inputs: [] }, + { type: 'error', name: 'InvalidVerifier', inputs: [] }, + { type: 'error', name: 'Irrevocable', inputs: [] }, + { type: 'error', name: 'NotFound', inputs: [] }, + { type: 'error', name: 'NotPayable', inputs: [] }, + { type: 'error', name: 'WrongSchema', inputs: [] }, +] as const + /** * Combined EAS ABI for the functions the SDK calls on the EAS contract - * (`attest`, `multiAttest`, `getAttestation`). `getSchema` lives on the - * separate SchemaRegistry contract and is exported on its own. + * (`attest`, `multiAttest`, `getAttestation`) plus the custom-error fragments so + * reverts decode to named errors. `getSchema` lives on the separate + * SchemaRegistry contract and is exported on its own. */ -export const easAbi = [...attestAbi, ...multiAttestAbi, ...getAttestationAbi] as const +export const easAbi = [ + ...attestAbi, + ...multiAttestAbi, + ...getAttestationAbi, + ...easErrorsAbi, +] as const /** SchemaRegistry ABI subset (just `getSchema`). */ export const schemaRegistryAbi = [...getSchemaAbi] as const diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts index 1802005..6cca015 100644 --- a/packages/sdk/src/mirror/transport.ts +++ b/packages/sdk/src/mirror/transport.ts @@ -293,9 +293,7 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { if (isBase64) { bytes = decodeBase64(dataPart) } else { - // Percent-decoded text payload, then UTF-8 encoded. - const text = decodeURIComponent(dataPart) - bytes = new TextEncoder().encode(text) + bytes = decodeDataOctets(dataPart) } // Authoritative cap on ACTUAL UTF-8 bytes (e.g. `€` is 1 char but 3 bytes, so a @@ -316,6 +314,36 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { } } +/** + * Decode a non-base64 `data:` payload to raw bytes (RFC 2397). Percent escapes + * are OCTETS, not UTF-8 text — `%ff` is the byte `0xFF`, which `decodeURIComponent` + * would reject as invalid UTF-8. So decode `%XX` byte-wise and emit literal runs + * as their UTF-8 bytes. Never throws on arbitrary octets, so binary inline + * mirrors (e.g. `data:application/octet-stream,%ff`) hash correctly. + */ +function decodeDataOctets(s: string): Uint8Array { + const out: number[] = [] + const enc = new TextEncoder() + let literal = '' + const flush = () => { + if (literal.length > 0) { + for (const b of enc.encode(literal)) out.push(b) + literal = '' + } + } + for (let i = 0; i < s.length; i += 1) { + if (s[i] === '%' && i + 2 < s.length && /^[0-9a-f]{2}$/i.test(s.slice(i + 1, i + 3))) { + flush() + out.push(Number.parseInt(s.slice(i + 1, i + 3), 16)) + i += 2 + } else { + literal += s[i] + } + } + flush() + return Uint8Array.from(out) +} + /** Decode a base64 string to bytes without Buffer (works in browser + node). */ function decodeBase64(b64: string): Uint8Array { // Tolerate URL-safe alphabet and stray whitespace/newlines. diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts index 67d6e4c..301bfd7 100644 --- a/packages/sdk/test/errors.test.ts +++ b/packages/sdk/test/errors.test.ts @@ -3,8 +3,10 @@ import { ProviderRpcError, UserRejectedRequestError, RpcError as ViemRpcError, + toFunctionSelector, } from 'viem' import { describe, expect, it } from 'vitest' +import { easAbi } from '../src/eas/index.js' import { ContractReverted, Disconnected, @@ -110,6 +112,19 @@ describe('classifyError (ADR-0007 classifier)', () => { expect(out.cause).toBe(plain) }) + it('decodes a real EAS revert via the bundled easAbi error fragments', () => { + // The bundled easAbi must carry the EAS custom-error fragments, or viem + // cannot populate errorName on a real attest/multiAttest revert. + expect(easAbi.some((f) => f.type === 'error' && f.name === 'InvalidSchema')).toBe(true) + const revert = new ContractFunctionRevertedError({ + abi: easAbi, + functionName: 'attest', + data: toFunctionSelector('InvalidSchema()'), + }) + expect(revert.data?.errorName).toBe('InvalidSchema') + expect(classifyError(revert).code).toBe('SchemaMismatch') + }) + it('finds an EIP-1193 code on a nested cause (wrapped wallet rejection)', () => { // viem wraps a UserRejectedRequestError (4001) under a contract/tx error; // the code is on the cause, not the outer error. diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index f3dee2c..983d03e 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -103,6 +103,15 @@ describe('resolveTransport - URI parsing (TRANSPORT allowlist)', () => { expect(r.inline?.contentType).toBeUndefined() }) + it('decodes percent-escaped binary octets (not UTF-8 text) in data: URIs', () => { + // %ff is the byte 0xFF — invalid UTF-8; decodeURIComponent would throw. + const r = resolveTransport('data:application/octet-stream,%ff') + expect(r.inline?.bytes).toEqual(new Uint8Array([0xff])) + // Mixed literal + octet escapes round-trip byte-wise. + const mixed = resolveTransport('data:,A%00%ff') + expect(mixed.inline?.bytes).toEqual(new Uint8Array([0x41, 0x00, 0xff])) + }) + it('magnet: parses but yields no HTTP URLs', () => { const r = resolveTransport('magnet:?xt=urn:btih:abc') expect(r.scheme).toBe(TRANSPORT.magnet) From be24b0c4a89021714c9ae8212fe23d79319da216 Mon Sep 17 00:00:00 2001 From: James Carnley Date: Mon, 15 Jun 2026 17:30:29 -0500 Subject: [PATCH 44/47] fix(mirror): percent-decode base64 data: bodies before decoding (P2) WHATWG data: processing percent-decodes the body before base64-decoding, so a producer may percent-encode base64 specials (data:...;base64,%2Fw%3D%3D == 0xff). We passed raw %XX to atob (throws) and the size estimate counted the triplets (false oversize at small caps). Now decodeURIComponent the base64 body first (ASCII; falls back to raw on malformed input), then estimate + decode. Regression test: %2Fw%3D%3D -> 0xff, incl. at maxBytes=1. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/mirror/transport.ts | 18 ++++++++++++++++-- packages/sdk/test/mirror.test.ts | 11 +++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts index 6cca015..4c01a68 100644 --- a/packages/sdk/src/mirror/transport.ts +++ b/packages/sdk/src/mirror/transport.ts @@ -268,6 +268,20 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { const mediaType = (isBase64 ? meta.replace(/;base64$/i, '') : meta).trim() const contentType = mediaType.length > 0 ? mediaType : undefined + // For a base64 payload the WHATWG data: processor percent-decodes the body + // BEFORE base64-decoding, so producers may percent-encode base64 specials + // (`%2F`→`/`, `%2B`→`+`, `%3D`→`=`). Decode to the real base64 text first so the + // size estimate and `atob` see actual base64 chars, not `%XX` triplets. Percent + // escapes here are ASCII (valid UTF-8); fall back to raw on malformed input. + let b64Text = dataPart + if (isBase64) { + try { + b64Text = decodeURIComponent(dataPart) + } catch { + // leave raw; decodeBase64's normalization/`atob` will surface the error + } + } + // Pre-decode reject on a cheap LOWER bound of the decoded size, so a giant // payload can't force a large allocation (the decode is the bomb). base64 // decodes to `floor(sig*3/4)` where `sig` is the significant-char count after @@ -279,7 +293,7 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { if (maxBytes !== undefined) { let lowerBound: number if (isBase64) { - const sig = dataPart.replace(/\s+/g, '').replace(/=+$/, '').length + const sig = b64Text.replace(/\s+/g, '').replace(/=+$/, '').length lowerBound = Math.floor((sig * 3) / 4) } else { lowerBound = Math.ceil(dataPart.length / 3) @@ -291,7 +305,7 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { let bytes: Uint8Array if (isBase64) { - bytes = decodeBase64(dataPart) + bytes = decodeBase64(b64Text) } else { bytes = decodeDataOctets(dataPart) } diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index 983d03e..eac3773 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -103,6 +103,17 @@ describe('resolveTransport - URI parsing (TRANSPORT allowlist)', () => { expect(r.inline?.contentType).toBeUndefined() }) + it('percent-decodes a base64 data: body before decoding (WHATWG order)', () => { + // %2Fw%3D%3D percent-decodes to '/w==', which base64-decodes to the byte 0xff. + const r = resolveTransport('data:application/octet-stream;base64,%2Fw%3D%3D') + expect(r.inline?.bytes).toEqual(new Uint8Array([0xff])) + // And it must not be falsely rejected at a tight cap (1 real byte). + const capped = resolveTransport('data:application/octet-stream;base64,%2Fw%3D%3D', { + maxBytes: 1, + }) + expect(capped.inline?.bytes).toEqual(new Uint8Array([0xff])) + }) + it('decodes percent-escaped binary octets (not UTF-8 text) in data: URIs', () => { // %ff is the byte 0xFF — invalid UTF-8; decodeURIComponent would throw. const r = resolveTransport('data:application/octet-stream,%ff') From 28a0bd8d54c016e3078413aaa6cf0b9d3e89986d Mon Sep 17 00:00:00 2001 From: James Carnley Date: Mon, 15 Jun 2026 17:40:19 -0500 Subject: [PATCH 45/47] =?UTF-8?q?fix(mirror):=20bound-aware=20data:=20octe?= =?UTF-8?q?t=20decode=20=E2=80=94=20enforce=20cap=20DURING=20decode=20(P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-base64 size guard was a pre-check lower bound (ceil(len/3)) plus an after-the-fact byteLength check, so a mostly-literal payload could accumulate up to ~3x maxBytes before rejection — partly defeating the cap. decodeDataOctets now takes maxBytes and aborts the moment the running decoded length exceeds it (literals flushed in 4096-char chunks so the total is checked continuously), enforcing the bound during decode rather than after. Dropped the now-redundant text pre-check; base64 keeps its up-front estimate (atob decodes whole). Regression test: a 10k-char literal at maxBytes=64 is rejected. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/mirror/transport.ts | 43 ++++++++++++++++------------ packages/sdk/test/mirror.test.ts | 7 +++++ 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts index 4c01a68..d2b24a5 100644 --- a/packages/sdk/src/mirror/transport.ts +++ b/packages/sdk/src/mirror/transport.ts @@ -282,23 +282,14 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { } } - // Pre-decode reject on a cheap LOWER bound of the decoded size, so a giant - // payload can't force a large allocation (the decode is the bomb). base64 - // decodes to `floor(sig*3/4)` where `sig` is the significant-char count after - // stripping whitespace and `=` padding (counting padding over-estimates and - // would falsely reject an at-cap payload, e.g. `aGVsbG8=` → 5 bytes, not 6). - // For text, the minimum is ceil(len/3) — the all-`%XX` case (3 chars → 1 byte) - // — bounding transient allocation to ≤ ~3× maxBytes. The exact UTF-8 check - // AFTER decode is authoritative. - if (maxBytes !== undefined) { - let lowerBound: number - if (isBase64) { - const sig = b64Text.replace(/\s+/g, '').replace(/=+$/, '').length - lowerBound = Math.floor((sig * 3) / 4) - } else { - lowerBound = Math.ceil(dataPart.length / 3) - } - if (lowerBound > maxBytes) { + // base64 decodes whole (atob allocates the lot), so reject up front on the + // decoded-size estimate: `floor(sig*3/4)` over the significant chars (whitespace + // and `=` padding stripped, since counting them over-estimates and would falsely + // reject an at-cap payload like `aGVsbG8=` → 5 bytes, not 6). The text path needs + // no pre-check — decodeDataOctets is bound-aware and aborts mid-decode. + if (maxBytes !== undefined && isBase64) { + const sig = b64Text.replace(/\s+/g, '').replace(/=+$/, '').length + if (Math.floor((sig * 3) / 4) > maxBytes) { throw new UnsupportedUriError(uri, `inline payload exceeds maxBytes (~>${maxBytes})`) } } @@ -307,7 +298,7 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { if (isBase64) { bytes = decodeBase64(b64Text) } else { - bytes = decodeDataOctets(dataPart) + bytes = decodeDataOctets(dataPart, uri, maxBytes) } // Authoritative cap on ACTUAL UTF-8 bytes (e.g. `€` is 1 char but 3 bytes, so a @@ -334,24 +325,38 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { * would reject as invalid UTF-8. So decode `%XX` byte-wise and emit literal runs * as their UTF-8 bytes. Never throws on arbitrary octets, so binary inline * mirrors (e.g. `data:application/octet-stream,%ff`) hash correctly. + * + * Bound-aware: aborts the moment the decoded length exceeds `maxBytes` (literals + * are flushed in chunks so the running total is checked continuously), so a giant + * literal payload can't allocate ~3× the cap before an after-the-fact check — + * the size guard is enforced DURING decode, not after. */ -function decodeDataOctets(s: string): Uint8Array { +function decodeDataOctets(s: string, uri: string, maxBytes?: number): Uint8Array { + const cap = maxBytes ?? Number.POSITIVE_INFINITY const out: number[] = [] const enc = new TextEncoder() let literal = '' + const checkCap = () => { + if (out.length > cap) { + throw new UnsupportedUriError(uri, `inline payload exceeds maxBytes (${maxBytes})`) + } + } const flush = () => { if (literal.length > 0) { for (const b of enc.encode(literal)) out.push(b) literal = '' + checkCap() } } for (let i = 0; i < s.length; i += 1) { if (s[i] === '%' && i + 2 < s.length && /^[0-9a-f]{2}$/i.test(s.slice(i + 1, i + 3))) { flush() out.push(Number.parseInt(s.slice(i + 1, i + 3), 16)) + checkCap() i += 2 } else { literal += s[i] + if (literal.length >= 4096) flush() // bound the running total + encode transient } } flush() diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index eac3773..d3957e6 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -272,6 +272,13 @@ describe('fetchVerified - happy paths per transport', () => { expect(res.bytes).toEqual(bytes) }) + it('aborts a large literal data: payload during decode (bound-aware)', () => { + // A mostly-literal payload well over the cap must be rejected by the decoder + // itself, not allocated in full and then rejected after the fact. + const big = 'a'.repeat(10_000) + expect(() => resolveTransport(`data:,${big}`, { maxBytes: 64 })).toThrow() + }) + it('counts UTF-8 bytes (not UTF-16 length) for text data: URIs', async () => { // '€' is 1 string char but 3 UTF-8 bytes; cap at 2 must reject it even though // its character count (1) is within the cap. From 1f0541f7d99feb169dae8240012092a78851e69d Mon Sep 17 00:00:00 2001 From: James Carnley Date: Mon, 15 Jun 2026 17:54:25 -0500 Subject: [PATCH 46/47] fix(mirror): size-check percent-encoded base64 before materializing (P2) Completes the pass-#6 allocation control on the base64 branch: the body was percent-decoded (decodeURIComponent) in full before the cap estimate, so an oversized percent-encoded base64 body still materialized. significantBase64Chars now scans the RAW body (escapes decoded inline, whitespace + trailing padding excluded) to estimate decoded size and reject BEFORE decodeURIComponent. Both data: branches (octet + base64) are now bound-safe by construction. Regression test: '%41'x1000 at maxBytes=64 rejected. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/mirror/transport.ts | 67 +++++++++++++++++++--------- packages/sdk/test/mirror.test.ts | 7 +++ 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts index d2b24a5..d09ec3c 100644 --- a/packages/sdk/src/mirror/transport.ts +++ b/packages/sdk/src/mirror/transport.ts @@ -268,34 +268,28 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { const mediaType = (isBase64 ? meta.replace(/;base64$/i, '') : meta).trim() const contentType = mediaType.length > 0 ? mediaType : undefined - // For a base64 payload the WHATWG data: processor percent-decodes the body - // BEFORE base64-decoding, so producers may percent-encode base64 specials - // (`%2F`→`/`, `%2B`→`+`, `%3D`→`=`). Decode to the real base64 text first so the - // size estimate and `atob` see actual base64 chars, not `%XX` triplets. Percent - // escapes here are ASCII (valid UTF-8); fall back to raw on malformed input. - let b64Text = dataPart + let bytes: Uint8Array if (isBase64) { + // base64 decodes whole (atob allocates the lot, and the WHATWG processor + // percent-decodes the body first — `%2F`→`/`, `%3D`→`=`). Estimate the decoded + // size from the RAW body via a scan (percent escapes decoded inline, whitespace + // + trailing `=` padding excluded) and reject BEFORE materializing the decoded + // base64 text, so an oversized percent-encoded body can't force the decode-all + // allocation the cap exists to prevent. + if (maxBytes !== undefined) { + const sig = significantBase64Chars(dataPart) + if (Math.floor((sig * 3) / 4) > maxBytes) { + throw new UnsupportedUriError(uri, `inline payload exceeds maxBytes (~>${maxBytes})`) + } + } + // WHATWG order: percent-decode the body, then base64-decode. Escapes are ASCII + // (valid UTF-8); fall back to raw on malformed input. + let b64Text = dataPart try { b64Text = decodeURIComponent(dataPart) } catch { // leave raw; decodeBase64's normalization/`atob` will surface the error } - } - - // base64 decodes whole (atob allocates the lot), so reject up front on the - // decoded-size estimate: `floor(sig*3/4)` over the significant chars (whitespace - // and `=` padding stripped, since counting them over-estimates and would falsely - // reject an at-cap payload like `aGVsbG8=` → 5 bytes, not 6). The text path needs - // no pre-check — decodeDataOctets is bound-aware and aborts mid-decode. - if (maxBytes !== undefined && isBase64) { - const sig = b64Text.replace(/\s+/g, '').replace(/=+$/, '').length - if (Math.floor((sig * 3) / 4) > maxBytes) { - throw new UnsupportedUriError(uri, `inline payload exceeds maxBytes (~>${maxBytes})`) - } - } - - let bytes: Uint8Array - if (isBase64) { bytes = decodeBase64(b64Text) } else { bytes = decodeDataOctets(dataPart, uri, maxBytes) @@ -363,6 +357,35 @@ function decodeDataOctets(s: string, uri: string, maxBytes?: number): Uint8Array return Uint8Array.from(out) } +/** + * Count the significant base64 characters of a (possibly percent-encoded) body + * WITHOUT materializing the decoded string — percent escapes are decoded inline, + * whitespace is skipped, and trailing `=` padding is excluded (counting padding + * would over-estimate and falsely reject an at-cap payload). Used to size-check a + * base64 `data:` body before allocating it. `floor(result * 3 / 4)` = decoded bytes. + */ +function significantBase64Chars(s: string): number { + let sig = 0 + let pendingPad = 0 // `=` runs are padding only if nothing significant follows + for (let i = 0; i < s.length; i += 1) { + let ch = s[i] ?? '' + if (ch === '%' && i + 2 < s.length && /^[0-9a-f]{2}$/i.test(s.slice(i + 1, i + 3))) { + ch = String.fromCharCode(Number.parseInt(s.slice(i + 1, i + 3), 16)) + i += 2 + } + if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f' || ch === '\v') { + continue + } + if (ch === '=') { + pendingPad += 1 + continue + } + sig += 1 + pendingPad // a real char means any prior `=` were not trailing padding + pendingPad = 0 + } + return sig +} + /** Decode a base64 string to bytes without Buffer (works in browser + node). */ function decodeBase64(b64: string): Uint8Array { // Tolerate URL-safe alphabet and stray whitespace/newlines. diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index d3957e6..1ea903e 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -103,6 +103,13 @@ describe('resolveTransport - URI parsing (TRANSPORT allowlist)', () => { expect(r.inline?.contentType).toBeUndefined() }) + it('rejects an oversized percent-encoded base64 body before materializing it', () => { + // '%41' x1000 percent-decodes to 1000 'A's -> ~750 decoded bytes; at a small + // cap it must be rejected from the raw-scan estimate, before decodeURIComponent. + const body = '%41'.repeat(1000) + expect(() => resolveTransport(`data:;base64,${body}`, { maxBytes: 64 })).toThrow() + }) + it('percent-decodes a base64 data: body before decoding (WHATWG order)', () => { // %2Fw%3D%3D percent-decodes to '/w==', which base64-decodes to the byte 0xff. const r = resolveTransport('data:application/octet-stream;base64,%2Fw%3D%3D') From 47dd93baaa53ca7c1febb5a040736605ff6f162b Mon Sep 17 00:00:00 2001 From: James Carnley Date: Mon, 15 Jun 2026 18:00:08 -0500 Subject: [PATCH 47/47] fix(mirror): trim media type before ;base64 test + surrogate-safe flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a consolidated adversarial audit of the data: decoder (to end the per-round trickle), two real items: - MEDIUM: `;base64` was tested on the un-trimmed media type, so a legal `data:...;base64 ,` (whitespace before the comma) was misread as literal text and decoded to the wrong bytes. Trim the media type before the test (WHATWG strips whitespace first). - LOW: the 4096-char literal flush could split a surrogate pair (lone high surrogate → replacement char). Hold a trailing high surrogate back for the next chunk so an astral char on the boundary encodes as its 4 UTF-8 bytes. Audit confirmed all 7 prior fixes intact and the parser otherwise sound (URL-safe base64 leniency is the documented intentional divergence). Regression tests for both. Gate incl. size: 112 tests. Co-authored-by: Claude Opus 4.8 --- packages/sdk/src/mirror/transport.ts | 20 ++++++++++++++++++-- packages/sdk/test/mirror.test.ts | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/mirror/transport.ts b/packages/sdk/src/mirror/transport.ts index d09ec3c..29e5f47 100644 --- a/packages/sdk/src/mirror/transport.ts +++ b/packages/sdk/src/mirror/transport.ts @@ -262,7 +262,9 @@ function resolveData(uri: string, maxBytes?: number): ResolvedTransport { if (comma === -1) { throw new UnsupportedUriError(uri, 'malformed data: URI (no comma)') } - const meta = uri.slice('data:'.length, comma) + // Trim the media type BEFORE the `;base64` test (WHATWG strips leading/trailing + // whitespace first): `data:...;base64 ,…` is still base64, not literal text. + const meta = uri.slice('data:'.length, comma).trim() const dataPart = uri.slice(comma + 1) const isBase64 = /;base64$/i.test(meta) const mediaType = (isBase64 ? meta.replace(/;base64$/i, '') : meta).trim() @@ -350,7 +352,21 @@ function decodeDataOctets(s: string, uri: string, maxBytes?: number): Uint8Array i += 2 } else { literal += s[i] - if (literal.length >= 4096) flush() // bound the running total + encode transient + // Flush in chunks to bound the running total + encode transient — but never + // split a surrogate pair across the boundary (a lone high surrogate would + // UTF-8-encode to the replacement char), so hold a trailing high surrogate + // back for the next chunk. + if (literal.length >= 4096) { + const lastCode = literal.charCodeAt(literal.length - 1) + if (lastCode >= 0xd800 && lastCode <= 0xdbff) { + const hold = literal.slice(-1) + literal = literal.slice(0, -1) + flush() + literal = hold + } else { + flush() + } + } } } flush() diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts index 1ea903e..ac93e10 100644 --- a/packages/sdk/test/mirror.test.ts +++ b/packages/sdk/test/mirror.test.ts @@ -110,6 +110,20 @@ describe('resolveTransport - URI parsing (TRANSPORT allowlist)', () => { expect(() => resolveTransport(`data:;base64,${body}`, { maxBytes: 64 })).toThrow() }) + it('detects ;base64 with whitespace before the comma (trimmed media type)', () => { + // RFC 2397 allows whitespace; `;base64 ,` must still be treated as base64. + const r = resolveTransport('data:text/plain;base64 ,SGVsbG8=') + expect(new TextDecoder().decode(r.inline!.bytes)).toBe('Hello') + }) + + it('does not split a surrogate pair across the literal flush boundary', () => { + // An astral char (😀 = F0 9F 98 80) landing on the 4096-char flush boundary + // must encode as 4 bytes, not two replacement chars. + const r = resolveTransport(`data:,${'a'.repeat(4095)}😀`) + const tail = r.inline!.bytes.slice(-4) + expect(Array.from(tail)).toEqual([0xf0, 0x9f, 0x98, 0x80]) + }) + it('percent-decodes a base64 data: body before decoding (WHATWG order)', () => { // %2Fw%3D%3D percent-decodes to '/w==', which base64-decodes to the byte 0xff. const r = resolveTransport('data:application/octet-stream;base64,%2Fw%3D%3D')