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/.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 new file mode 100644 index 0000000..7f84db4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI + +on: + push: + 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 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + # --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. + # 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 + # 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: + name: Solidity (build, test, fmt) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: foundry-rs/foundry-toolchain@v1 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - 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 + + changeset: + name: Changeset present + # 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: + - 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 --ignore-scripts + - run: pnpm changeset status --since=origin/main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6e8898f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +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 + timeout-minutes: 15 + 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 + # --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 + # the Solidity package on every PR before merge.) + - run: pnpm --filter @efs/sdk build + # 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 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08d9d9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# TypeScript / build output +dist/ +*.tsbuildinfo + +# Turbo +.turbo/ + +# Foundry (packages/solidity) — build artifacts, never shipped or tracked +out/ +cache/ +broadcast/ +packages/solidity/out/ +packages/solidity/cache/ +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..448460d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# 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/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). +- **`packages/solidity`** → `@efs/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..b160dff --- /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/sdk test`, `pnpm --filter @efs/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..44709cc --- /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. 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 + +| Package | npm | For | +|---|---|---| +| [`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/sdk viem # TypeScript SDK +npm i @efs/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..6efd64c --- /dev/null +++ b/biome.json @@ -0,0 +1,29 @@ +{ + "$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" } + }, + "overrides": [ + { + "include": ["**/test/**", "**/*.test.ts"], + "linter": { + "rules": { + "style": { "noNonNullAssertion": "off" } + } + } + } + ] +} 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..8abee83 --- /dev/null +++ b/docs/adr/0001-monorepo-layout-and-toolchain.md @@ -0,0 +1,44 @@ +# ADR-0001: Monorepo layout, packages & toolchain + +**Status:** Accepted +**Date:** 2026-06-10 +**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/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/ +``` + +- **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`** (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/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 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 + +- **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..757c67d --- /dev/null +++ b/docs/adr/0002-viem-only-no-eas-sdk-dependency.md @@ -0,0 +1,35 @@ +# ADR-0002: viem-only; do not depend on the ethers-based EAS SDK + +**Status:** Accepted +**Date:** 2026-06-10 +**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/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..faba2dd --- /dev/null +++ b/docs/adr/0003-onchain-sdk-as-compile-in-source.md @@ -0,0 +1,35 @@ +# ADR-0003: Ship the on-chain SDK as compile-in Solidity source + +**Status:** Accepted +**Date:** 2026-06-10 +**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/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/solidity`, then `import "@efs/solidity/src/EFSWriter.sol";` (resolved via `node_modules`). + - **Foundry:** install, then `remappings.txt`: `@efs/solidity/=node_modules/@efs/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/0004-publish-via-oidc-trusted-publishing.md b/docs/adr/0004-publish-via-oidc-trusted-publishing.md new file mode 100644 index 0000000..aacb967 --- /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). + +**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 + +- **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/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/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/0007-error-model.md b/docs/adr/0007-error-model.md new file mode 100644 index 0000000..a2138ec --- /dev/null +++ b/docs/adr/0007-error-model.md @@ -0,0 +1,35 @@ +# 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`. + +## 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. +- 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..8c03df7 --- /dev/null +++ b/docs/adr/0008-public-api-and-semver.md @@ -0,0 +1,46 @@ +# 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 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`). +- 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. + +**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..e9d02c2 --- /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 + +**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. **`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 **"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 + +- **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/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/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 new file mode 100644 index 0000000..e9335d5 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,59 @@ +# 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)** — 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. + +## 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. + +## Discipline (lighter) + +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 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 + +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? + +- **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. 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. For *how the SDK behaves* (not why), see [`docs/specs/`](../specs/). + +## Format & numbering + +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 + +- [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) +- [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) +- [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) +- [ADR-0011 — Build on the on-chain tag-exclusion filter + folder Overviews](./0011-onchain-filter-and-overviews.md) diff --git a/docs/adr/_template.md b/docs/adr/_template.md new file mode 100644 index 0000000..70a281f --- /dev/null +++ b/docs/adr/_template.md @@ -0,0 +1,21 @@ +# ADR-NNNN: Title + +**Status:** Proposed +**Date:** YYYY-MM-DD +**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/docs/plans/beta-slice.md b/docs/plans/beta-slice.md new file mode 100644 index 0000000..10c7921 --- /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 **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) + +**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 // bare SHA-256 lowercase hex (ADR-0006) + 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/`** — 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. +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. 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). +- **E. Record all three reserved PROPERTYs** (contentType, contentHash, size) per write. **Rec:** yes — cheap relative to the verification + rendering value; keeps depth at 3. 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/README.md b/docs/specs/README.md new file mode 100644 index 0000000..61e1f54 --- /dev/null +++ b/docs/specs/README.md @@ -0,0 +1,27 @@ +# 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** _(later)_ | *Exact signatures* — mechanical, generated | `docs/api/` (typedoc) | + +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. +- [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/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/docs/specs/future-proofing.md b/docs/specs/future-proofing.md new file mode 100644 index 0000000..c56d0d3 --- /dev/null +++ b/docs/specs/future-proofing.md @@ -0,0 +1,101 @@ +# 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) + +- **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. +- **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 + +- **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). +- **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 + +- **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 — 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 + +- **`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. 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). +- **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**. 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) + +| 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/overview.md b/docs/specs/overview.md new file mode 100644 index 0000000..a9f2aa7 --- /dev/null +++ b/docs/specs/overview.md @@ -0,0 +1,34 @@ +# 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), 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/docs/specs/standards.md b/docs/specs/standards.md new file mode 100644 index 0000000..d623774 --- /dev/null +++ b/docs/specs/standards.md @@ -0,0 +1,128 @@ +# 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. 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. **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). + +## 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. + +## 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. | +| **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/examples/README.md b/examples/README.md new file mode 100644 index 0000000..7c43b00 --- /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/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 new file mode 100644 index 0000000..81dab9c --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "@efs/monorepo", + "version": "0.0.0", + "private": true, + "description": "The Ethereum File System SDK — TypeScript and Solidity", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+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 check --write .", + "changeset": "changeset", + "release": "pnpm --filter @efs/sdk build && changeset publish" + }, + "devDependencies": { + "@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..bf2bec6 --- /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": "10 kB" + } +] diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 0000000..ef525dc --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,57 @@ +# @efs/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 [`docs/specs/overview.md`](../../docs/specs/overview.md) for how it works and [`docs/adr/`](../../docs/adr) for decisions. + +## Install + +```bash +npm i @efs/sdk viem +``` + +`viem` is a peer dependency (ADR-0002) — the SDK is viem-native and pulls in no `ethers`. + +## Quickstart (target API) + +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' + +// Standard form — pass any EIP-1193 provider (window.ethereum, WalletConnect, …): +const efs = createEfsClient({ + 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). +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' +} + +// Write a file (batched multi-attestation under the hood). +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. +- **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/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 new file mode 100644 index 0000000..bea7b77 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,85 @@ +{ + "name": "@efs/sdk", + "version": "0.0.0", + "description": "TypeScript SDK for the Ethereum File System (EFS)", + "license": "MIT", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/efs-project/sdk.git", + "directory": "packages/sdk" + }, + "files": ["dist"], + "publishConfig": { "access": "public", "provenance": true }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "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" + }, + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "size": "size-limit", + "knip": "knip" + }, + "peerDependencies": { + "viem": "^2" + }, + "peerDependenciesMeta": { + "viem": { "optional": false } + }, + "devDependencies": { + "@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/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 new file mode 100644 index 0000000..9e9b7ef --- /dev/null +++ b/packages/sdk/src/chain/deployments.ts @@ -0,0 +1,107 @@ +/** + * 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. + * + * 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 + indexer: Address + router: Address + fileView: Address + edgeResolver: Address + mirrorResolver: Address + sortOverlay: Address + listResolver: Address + listEntryResolver: Address + listReader: Address + schemaNameIndex: Address +} + +/** 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 + naming: 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 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. + * + * 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, + 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/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/content/hash.ts b/packages/sdk/src/content/hash.ts new file mode 100644 index 0000000..55d52f2 --- /dev/null +++ b/packages/sdk/src/content/hash.ts @@ -0,0 +1,55 @@ +/** + * 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' + +/** 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`. */ +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. + * `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 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/eas/abi.ts b/packages/sdk/src/eas/abi.ts new file mode 100644 index 0000000..1f7c9e3 --- /dev/null +++ b/packages/sdk/src/eas/abi.ts @@ -0,0 +1,180 @@ +/** + * 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 + +/** + * 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`) 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, + ...easErrorsAbi, +] 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..f991a66 --- /dev/null +++ b/packages/sdk/src/eas/attest.ts @@ -0,0 +1,138 @@ +/** + * 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`, `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. */ + address: Address + /** The vendored EAS ABI (`easAbi`). */ + abi: typeof easAbi + /** The function being invoked. */ + 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. `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 }] 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. `value` is the sum of every + * entry's resolver value across all requests (`msg.value` must cover the total). + */ +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), + })) + 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/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..d8c0506 --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -0,0 +1,323 @@ +/** + * 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. + */ + +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 = + | 'EfsError' + | 'NotImplemented' + | 'WalletRequired' + | 'LensRequired' + | 'MaxLensesExceeded' + | 'SchemaMismatch' + | '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' + /** 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 } + +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.`, { 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 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.', { + 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). */ +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. */ +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). */ +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.`, + { 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 }) + } +} + +/** 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 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 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 +} + +/** + * 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 new file mode 100644 index 0000000..5554234 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,321 @@ +/** + * @efs/sdk — TypeScript SDK for the Ethereum File System (EFS). + * + * 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 Account, + type Address, + type Chain, + type EIP1193Provider, + type PublicClient, + type WalletClient, + createPublicClient, + createWalletClient, + custom, +} from 'viem' +import { + type DeploymentsMap, + type EfsDeployment, + assertDeploymentIntegrity, + resolveDeployment, +} from './chain/deployments.js' +import { + SchemaEncoder, + computeAttestationUID, + easAbi, + schemaRegistryAbi, + verifyAttestationUID, +} from './eas/index.js' +import { EfsError, NotImplemented, WalletRequired } from './errors.js' +import { type Lens, identity, lens, resolveLens } from './lenses/resolve.js' +import type { + BatchReceipt, + DataRef, + DirEntry, + EfsFile, + EfsList, + FetchOptions, + FileStat, + ListOptions, + OverviewOptions, + OverviewResult, + PreviewOptions, + ReadOptions, + ReadResult, + WriteEstimate, + WriteOptions, + WriteReceipt, +} from './types.js' + +/** + * 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. + */ + +/** 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 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 + fetch(ref: DataRef, opts?: FetchOptions): Promise + /** 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 + /** 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 = { + 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.', { + code: 'DeploymentNotFound', + }) + } + return id +} + +// 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 } = resolveClients(config) + const override = config.deployments + const getDeployment = () => resolveDeployment(chainIdOf(publicClient), override) + const requireWallet = () => { + if (!walletClient) throw new WalletRequired() + } + + return { + fs: { + read: async (_path, _opts) => { + throw new NotImplemented('efs.fs.read()') + }, + fetch: async (_ref, _opts) => { + throw new NotImplemented('efs.fs.fetch()') + }, + stat: async (_path, _opts) => { + throw new NotImplemented('efs.fs.stat()') + }, + list: (_path, _opts) => ({ + [Symbol.asyncIterator]() { + throw new NotImplemented('efs.fs.list()') + }, + page: async () => { + 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()') + }, + 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 }), + lens, + identity, + }, + eas: { + encoder: (schema) => new SchemaEncoder(schema), + computeUID: computeAttestationUID, + verifyUID: verifyAttestationUID, + abi: { eas: easAbi, schemaRegistry: schemaRegistryAbi }, + }, + raw: { + deployment: getDeployment, + verifyDeployment: () => assertDeploymentIntegrity(publicClient, getDeployment()), + }, + batch: () => { + requireWallet() + throw new NotImplemented('efs.batch()') + }, + } +} + +// ── Standalone exports (chain-independent; usable now) ───────────────────────── +export { + SchemaEncoder, + buildAttest, + buildMultiAttest, + computeAttestationUID, + verifyAttestationUID, + parseSchema, + easAbi, + schemaRegistryAbi, + type AttestationRequest, + type AttestationRequestData, + type MultiAttestationRequest, +} from './eas/index.js' +export { + hashContent, + verifyContent, + asContentHash, + 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, + resolveDeployment, + assertDeploymentIntegrity, + type DeploymentsMap, + type EfsDeployment, + type EfsContracts, + type EfsSchemaUIDs, +} from './chain/deployments.js' +export * from './errors.js' +export type { + DataRef, + DataUID, + DirEntry, + ReadOptions, + ListOptions, + FetchOptions, + TransportName, + WriteOptions, + PreviewOptions, + Page, + EfsList, + ReadResult, + EfsFile, + FileStat, + OverviewResult, + OverviewOptions, + WriteReceipt, + WriteMechanism, + CallStatus, + WriteEstimate, + OperationResult, + 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/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/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/mirror/fetch.ts b/packages/sdk/src/mirror/fetch.ts new file mode 100644 index 0000000..b1db662 --- /dev/null +++ b/packages/sdk/src/mirror/fetch.ts @@ -0,0 +1,393 @@ +/** + * 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) +} + +/** 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 }> { + // 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 + 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, + 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 { + 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, */*', 'accept-encoding': 'identity' }, + }) + + if (res.type === 'opaqueredirect') { + const followed = await doFetch(current.href, { + signal: controller.signal, + redirect: 'follow', + headers: { accept: 'application/octet-stream, */*', 'accept-encoding': 'identity' }, + }) + 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) + } + } 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, { maxBytes: opts.maxBytes ?? DEFAULT_MAX_BYTES }) + } catch (err) { + attempts.push({ uri, scheme: 'https', reason: errMsg(err) }) + continue + } + + // 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, + 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..2917150 --- /dev/null +++ b/packages/sdk/src/mirror/ssrf.ts @@ -0,0 +1,211 @@ +/** + * 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() +} + +/** + * 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}` + } + 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 +} + +/** + * 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 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 +} + +/** + * 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 { + // 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().replace(/\.+$/, '') === 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(host) + 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..29e5f47 --- /dev/null +++ b/packages/sdk/src/mirror/transport.ts @@ -0,0 +1,419 @@ +/** + * 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-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 = { + 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 +} + +/** + * 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) + // 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 +} + +/** + * 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, opts: { maxBytes?: number } = {}): 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, opts.maxBytes) + + 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') + } + // 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 = 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') + 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') + } + // 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) => buildGatewayUrl(g, txid, subpath, uri)) + }, + } +} + +/** + * `data:[][;base64],` (RFC 2397) to inline decoded bytes. + * No network. The media type is captured for information only. + */ +function resolveData(uri: string, maxBytes?: number): ResolvedTransport { + const comma = uri.indexOf(',') + if (comma === -1) { + throw new UnsupportedUriError(uri, 'malformed data: URI (no 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() + const contentType = mediaType.length > 0 ? mediaType : undefined + + 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 + } + bytes = decodeBase64(b64Text) + } else { + bytes = decodeDataOctets(dataPart, uri, maxBytes) + } + + // 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, + httpUrls: () => [], + ...(contentType !== undefined ? { inline: { contentType, bytes } } : { inline: { bytes } }), + } +} + +/** + * 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. + * + * 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, 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] + // 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() + 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. + 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/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 new file mode 100644 index 0000000..ba32ede --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,245 @@ +/** 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 { 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. 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 } + +// ── 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 + /** + * 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. Mirrors the `TRANSPORT` value + * allowlist in `mirror/transport.ts` (ADR-0010). */ +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 TransportName[] +} + +// ── 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> +} + +// ── 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 = '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 + onProgress?: (p: { step: number; total: number; phase: string }) => void + resume?: WriteReceipt + 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: 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 + /** 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. */ +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 = { + attestations: number + transactions: number + signatureCount: number + chunkDeploys: number + gas: bigint + /** 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). + * `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"). */ +export type EfsFile = { + bytes: Uint8Array + contentType?: string + verification: VerificationStatus + /** Whose contentHash claim was checked against. */ + hashAuthor?: Address +} + +/** 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 + } + +// ── 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 new file mode 100644 index 0000000..9fc60fc --- /dev/null +++ b/packages/sdk/test/client.test.ts @@ -0,0 +1,144 @@ +/** + * 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), + sortOverlay: addr(8), + listResolver: addr(9), + listEntryResolver: addr(10), + listReader: addr(11), + schemaNameIndex: addr(12), +} + +const schemas: EfsSchemaUIDs = { + anchor: '0x01', + property: '0x02', + data: '0x03', + blob: '0x04', + pin: '0x05', + tag: '0x06', + mirror: '0x07', + sortInfo: '0x08', + list: '0x09', + listEntry: '0x0a', + naming: '0x0b', +} + +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/content-hash.test.ts b/packages/sdk/test/content-hash.test.ts new file mode 100644 index 0000000..4b823f7 --- /dev/null +++ b/packages/sdk/test/content-hash.test.ts @@ -0,0 +1,35 @@ +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('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('malformed-claim') + expect(verifyContent(bytes, 'deadbeef')).toBe('malformed-claim') + }) +}) diff --git a/packages/sdk/test/eas.test.ts b/packages/sdk/test/eas.test.ts new file mode 100644 index 0000000..ccf0734 --- /dev/null +++ b/packages/sdk/test/eas.test.ts @@ -0,0 +1,201 @@ +import { type Hex, decodeAbiParameters, encodeAbiParameters, encodePacked, keccak256 } from 'viem' +import { describe, expect, it } from 'vitest' +import { + SchemaEncoder, + buildAttest, + buildMultiAttest, + 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)]) + }) +}) + +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) + }) +}) diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts new file mode 100644 index 0000000..301bfd7 --- /dev/null +++ b/packages/sdk/test/errors.test.ts @@ -0,0 +1,158 @@ +import { + ContractFunctionRevertedError, + ProviderRpcError, + UserRejectedRequestError, + RpcError as ViemRpcError, + toFunctionSelector, +} from 'viem' +import { describe, expect, it } from 'vitest' +import { easAbi } from '../src/eas/index.js' +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('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. + 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) + 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/index.test.ts b/packages/sdk/test/index.test.ts new file mode 100644 index 0000000..f640462 --- /dev/null +++ b/packages/sdk/test/index.test.ts @@ -0,0 +1,87 @@ +import { + http, + type Address, + type EIP1193Provider, + type WalletClient, + createPublicClient, + createWalletClient, +} from 'viem' +import { sepolia } from 'viem/chains' +import { describe, expect, it } from 'vitest' +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('read-only verbs reject with NotImplemented (async contract)', async () => { + const efs = createEfsClient({ publicClient }) + 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('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') + 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', () => { + 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) + }) +}) diff --git a/packages/sdk/test/mirror.test.ts b/packages/sdk/test/mirror.test.ts new file mode 100644 index 0000000..ac93e10 --- /dev/null +++ b/packages/sdk/test/mirror.test.ts @@ -0,0 +1,530 @@ +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('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() + // 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() + }) + + 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('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('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') + 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') + 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) + 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('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('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) + 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) + }) + 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() + }) + + 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 + await expect( + fetchVerified(['data:text/plain;base64,aGVsbG8='], undefined, { fetchImpl, maxBytes: 4 }), + ).rejects.toBeInstanceOf(AllMirrorsFailedError) + 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('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. + 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', () => { + 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') + }) + + 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', () => { + 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/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) + }) +}) 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..39685bf --- /dev/null +++ b/packages/sdk/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts', 'src/eas/index.ts', 'src/lenses/index.ts', 'src/chain/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..f8a8494 --- /dev/null +++ b/packages/solidity/README.md @@ -0,0 +1,48 @@ +# @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. + +> **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/solidity +``` + +**Hardhat** (resolved via `node_modules`): + +```solidity +import "@efs/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/solidity/=node_modules/@efs/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..23c9845 --- /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/solidity/=node_modules/@efs/solidity/ + "@efs/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..0e770ff --- /dev/null +++ b/packages/solidity/package.json @@ -0,0 +1,28 @@ +{ + "name": "@efs/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": "git+https://github.com/efs-project/sdk.git", + "directory": "packages/solidity" + }, + "files": ["src/**/*.sol", "README.md"], + "publishConfig": { "access": "public", "provenance": true }, + "exports": { + "./src/*.sol": "./src/*.sol", + "./package.json": "./package.json" + }, + "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", + "fmt:check": "forge fmt --check" + } +} diff --git a/packages/solidity/remappings.txt b/packages/solidity/remappings.txt new file mode 100644 index 0000000..bc3c2ee --- /dev/null +++ b/packages/solidity/remappings.txt @@ -0,0 +1,2 @@ +forge-std/=lib/forge-std/src/ +@efs/solidity/=src/ diff --git a/packages/solidity/src/EFSLib.sol b/packages/solidity/src/EFSLib.sol new file mode 100644 index 0000000..b4ddee7 --- /dev/null +++ b/packages/solidity/src/EFSLib.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +/// @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 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(); + } + + // --- Writes --- + + /// @notice Pin a file: place `dataUID` at `path`. The consuming contract is the attester. + function pinFile(string memory, bytes32) internal returns (bytes32) { + 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 new file mode 100644 index 0000000..f757f72 --- /dev/null +++ b/packages/solidity/src/EFSWriter.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +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}, 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 { + /// @notice Emitted when this contract pins a file at a path. + /// @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 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); + } + + /// @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); + } +} diff --git a/packages/solidity/test/EFSWriter.t.sol b/packages/solidity/test/EFSWriter.t.sol new file mode 100644 index 0000000..d7ff5c7 --- /dev/null +++ b/packages/solidity/test/EFSWriter.t.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +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..0ff9922 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4175 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +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.31.0 + version: 2.31.0 + publint: + specifier: 0.2.12 + version: 0.2.12 + turbo: + specifier: 2.9.17 + version: 2.9.17 + typescript: + 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 + version: 0.0.16 + size-limit: + specifier: 12.1.0 + version: 12.1.0(jiti@2.7.0) + tsup: + 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.9.3 + version: 5.9.3 + viem: + specifier: 2.52.2 + version: 2.52.2(typescript@5.9.3)(zod@4.4.3) + vitest: + specifier: 2.1.9 + version: 2.1.9 + + packages/solidity: {} + +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'} + + '@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] + + '@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==} + + '@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==} + + '@colors/colors@1.5.0': + 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'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + 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'} + 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-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'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + 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'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + 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'} + 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-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'} + 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/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'} + 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-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'} + 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/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'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + 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'} + 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-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'} + 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-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'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + 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'} + 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-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'} + 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-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'} + 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-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'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + 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'} + 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/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'} + 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/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'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + 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'} + 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/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'} + 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-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'} + 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-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'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + 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'} + 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==} + + '@loaderkit/resolve@1.0.6': + resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + + '@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==} + + '@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} + + '@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'} + + '@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] + 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/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'} + + '@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] + 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] + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@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-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==} + + 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'} + + 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'} + + 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' + + 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'} + + chai@5.3.3: + 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==} + + 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'} + + 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'} + + 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'} + + 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==} + + 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 + + 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'} + + 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==} + + 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'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + 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'} + + 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 + + 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'} + + fs-extra@8.1.0: + 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'} + + get-stream@9.0.1: + 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'} + + 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'} + + 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'} + + 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-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'} + + 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: '*' + + 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'} + + 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==} + + 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'} + + 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==} + + 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'} + + micromatch@4.0.8: + 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'} + + 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 + + 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'} + + 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'} + + object-assign@4.1.1: + 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==} + + ox@0.14.29: + resolution: {integrity: sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + 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'} + + 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'} + + 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'} + + 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 + + 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==} + + 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'} + + 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==} + + resolve-from@5.0.0: + 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'} + + 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==} + + 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==} + + 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'} + + 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'} + + slash@3.0.0: + 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'} + + 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==} + + 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'} + + 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'} + + 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'} + 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'} + 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==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + 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.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'} + hasBin: true + + 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'} + + 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'} + + 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: + 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 + + 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'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + 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'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + 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'} + + 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'} + + 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'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +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': + 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 + + '@braidai/lang@1.1.2': {} + + '@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 + + '@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 + 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 + + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + + '@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 + + '@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': + 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 + + '@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 + + '@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/is@4.6.0': {} + + '@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 + + '@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 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + 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)(zod@4.4.3): + optionalDependencies: + typescript: 5.9.3 + zod: 4.4.3 + + acorn@8.16.0: {} + + 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: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + 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 + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + bytes-iec@3.1.1: {} + + 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 + + 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: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + 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: {} + + 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 + + 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: + 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 + + 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: {} + + 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 + + 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 + + fflate@0.8.3: {} + + 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: {} + + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.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 + + 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: + dependencies: + '@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 + + 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 + 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: {} + + has-flag@4.0.0: {} + + highlight.js@10.7.3: {} + + 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-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 + + 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 + + jiti@2.7.0: {} + + 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 + + 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: {} + + 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: {} + + 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: + dependencies: + 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: + 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: {} + + nanoid@5.1.11: {} + + nanospinner@1.2.2: + dependencies: + picocolors: 1.1.1 + + 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 + unicorn-magic: 0.3.0 + + 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)(zod@4.4.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)(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 + + 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: {} + + 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: {} + + 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(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: + 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 + + 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: {} + + 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: {} + + require-directory@2.1.1: {} + + requires-port@1.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.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 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + 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: {} + + 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: {} + + 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: {} + + 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 + + strip-bom@3.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-json-comments@5.0.3: {} + + 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 + + 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 + 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: {} + + 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 + 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(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 + 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.6.1-rc: {} + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + unbash@3.0.0: {} + + 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)(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)(zod@4.4.3) + isows: 1.0.7(ws@8.20.1) + ox: 0.14.29(typescript@5.9.3)(zod@4.4.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 + + walk-up-path@4.0.0: {} + + 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 + + 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: {} + + yaml@2.9.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: {} + + zod@4.4.3: {} 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..9d5ddc6 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": 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..c9ac5ff --- /dev/null +++ b/turbo.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", "out/**"] + }, + "test": { + "dependsOn": ["^build"] + }, + "typecheck": { + "dependsOn": ["^build"], + "inputs": ["src/**", "tsconfig*.json", "$TURBO_ROOT$/tsconfig.base.json"], + "outputs": [] + }, + "lint": { + "inputs": ["src/**", "*.json", "$TURBO_ROOT$/biome.json"], + "outputs": [] + } + } +}