Skip to content

feat(evm-wallet-experiment): Use object capability sheaves#947

Draft
grypez wants to merge 51 commits intogrypez/bringing-in-the-sheavesfrom
grypez/evm-wallet-use-sheaves
Draft

feat(evm-wallet-experiment): Use object capability sheaves#947
grypez wants to merge 51 commits intogrypez/bringing-in-the-sheavesfrom
grypez/evm-wallet-use-sheaves

Conversation

@grypez
Copy link
Copy Markdown
Contributor

@grypez grypez commented Apr 27, 2026

draft

Refs: #870

grypez and others added 30 commits April 27, 2026 10:52
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ESCRIPTION

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion

`getSection({ guard, lift })` now requires an explicit interface guard,
mirroring how `makeExo` always requires one. `getGlobalSection({ lift })`
is the new convenience wrapper that computes the full union guard from
all presheaf sections, analogous to `makeDefaultExo`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… dispatch

Replace string-log side-channels and return-value inference with explicit
vi.fn() spy assertions. Each section's handler is a named mock; tests call
expect(spy).toHaveBeenCalledWith(...) and .not.toHaveBeenCalled() to verify
routing directly rather than inferring it from coincident return values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rg sections

`getGuardAt` was returning `undefined` for positions beyond a section's
fixed argument range, even when a `restArgGuard` was present. This caused
rest-arg sections to be absent from optional-position unions, producing
a false negative: e.g. `M.call().rest(M.string())` would not cover
position 0 in the union, so a call `['hello']` would fail the collected
guard even though the section accepts it.

Fix: fall through to `payload.restArgGuard` after exhausting the optional
array, so rest-arg sections contribute to every optional position in the
union.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add MetaDataSpec<M> discriminated union (constant | source | callable) so
that sheaf metadata can vary with call arguments rather than being static.

- constant(v)   — static value, evaluated once
- source(s)     — JS source string compiled via Compartment at sheafify
                  construction time, called at dispatch time
- callable(fn)  — live function called at dispatch time

PresheafSection.metadata changes from M to MetaDataSpec<M> (breaking).
A new EvaluatedSection<M> type carries post-evaluation metadata and is
what Lift receives as its germs array.  EvaluatedSection is distinct from
PresheafSection because the "germ" in the sheaf-theoretic sense only
exists after quotienting by the metadata-equivalence relation (the
collapseEquivalent step); EvaluatedSection describes the pre-collapse
stage where the spec has been applied to the invocation args.

getStalk is generalised to <T extends { exo: Section }> so it works over
ResolvedSection (the internal post-resolution type) without a cast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- getStalk: accept readonly T[] to allow frozen section arrays
- evaluatedStalk map: omit metadata when undefined via ifDefined for
  exactOptionalPropertyTypes (metadata?: M ≠ metadata: M | undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Made-with: Cursor
- evaluateMetadata returns a plain object; missing spec and nullish raw → {}
- reject primitives, arrays, and non-plain objects; hint { value: myValue }
- require EvaluatedSection.metadata; MetaData extends Record<string, unknown>
- simplify metadataKey and decomposeMetadata; drop ifDefined in dispatch

Made-with: Cursor
Replace the one-shot `Lift<M> = (...) => Promise<number>` with an
AsyncGenerator coroutine protocol. The lift receives a snapshot of the
accumulated error array on each `gen.next(errors)` call, yields
candidates one at a time, and can stop early or fall through based on
the error history.

Add `drive.ts` with `driveLift` to encapsulate the retry loop used by
`sheafify.ts`. Add `compose.ts` with `proxyLift`, `withFilter`,
`withRanking`, and `fallthrough` as composition primitives. Export all
four from `index.ts`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cover proxyLift, withFilter, withRanking, fallthrough, and composed
combinations in compose.test.ts. Includes driveToExhaustion and
driveWithSuccessOn test helpers that pass error snapshots (not mutable
references) to gen.next, so inner generators can safely store the
received arrays.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On exhaustion, throw a new Error with the full errors array as `cause`
rather than re-throwing the last error. This preserves the complete
failure history for diagnostics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a Sheaf Module section covering sheafify, metadata kinds, lift
authoring, composition helpers, and error handling on exhaustion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eaf sections

Adds `makeRemoteSection(name, remoteRef, metadata?)` which asynchronously
fetches the interface guard from a CapTP remote ref via E()[GET_INTERFACE_GUARD]()
and returns a PresheafSection with a local forwarding exo — eliminating the
boilerplate of building per-method handlers by hand when wrapping remote caps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion methods

Adds getDiscoverableSection and getDiscoverableGlobalSection to the Sheaf API
so callers can attach a MethodSchema (for __getDescription__) to the
caller-facing dispatch section rather than inside individual capability wrappers.
Marks getGlobalSection and getDiscoverableGlobalSection as @deprecated —
callers should supply an explicit InterfaceGuard via getSection/getDiscoverableSection
instead of relying on the auto-computed union.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…terfaces

The async interface guard synthesized for a sheaf section must admit implicit
exo methods like __getDescription__ that @endo/exo adds to every discoverable
exo. Without passable default guards, those methods are rejected at dispatch
time, preventing sheafs from being sent across a CapTP connection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Detaching a method via destructuring or assignment strips the CapTP receiver
binding and the remote rejects the call as an "Unexpected receiver". Invoke
each method through a fresh E(remote)[method] access so the receiver is
preserved on every dispatch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…n makeRemoteSection

The E() proxy index lookup returns T|undefined under noUncheckedIndexedAccess,
but method is always present — it comes from Object.keys(methodGuards).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
revokePoint, revokeAll, and getExported are unused in application code —
the evm-wallet prototype rebuilds the sheaf wholesale when the grant set
changes rather than revoking individual sections. Remove the implementation,
the Grant type, the revoked flag in buildSection, and all associated tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove stale revokePoint/revokeAll/getExported references from README.md
  and USAGE.md (the revocation API was removed in the preceding commit but
  the docs were not updated)
- Fix withRanking/withFilter signatures in USAGE.md: the combinators are
  curried — withRanking(comparator)(inner), not withRanking(comparator, inner)
- Correct the fallthrough doc comment: liftB does receive accumulated errors
  from liftA's attempts via yield* after its own failures (the test at
  compose.test.ts:337 confirms this); liftB is only unaware of them at its
  prime call
- Document the germ identity invariant in LIFT.md: the lift must yield
  elements from its input germs array, not reconstructed objects, because the
  sheaf resolves the dispatch target by object identity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion

asyncifyMethodGuards (sheafify.ts) and collectSheafGuard (guard.ts) both
contained an identical 4-way if/else for assembling a MethodGuard from its
components. The chain order required by @endo/patterns (callWhen → optional →
rest → returns) makes each branch non-obvious — a bad candidate for
duplication. Extract buildMethodGuard into guard.ts and use it in both sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sheafify returned a plain mutable object and used Object.freeze (shallow)
for frozenSections. Replace both with harden() for deep transitive
immutability under SES lockdown, consistent with the convention applied
to constant/source/callable in metadata.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…holder lift

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n cast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ncifyMethodGuards to guard.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tocol violation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…se only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lic exports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
grypez and others added 21 commits April 27, 2026 10:52
"Metadata" is one compound word; the mid-word capital was inconsistent
with the surrounding identifiers and prose docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The alias added a second public name for PresheafSection<M>[] with no
external consumers. Callers write the array type directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The guard is passed dynamically at call time so TypeScript cannot
propagate the method signatures through Sheaf<M>. The comment prevents
future contributors from chasing a phantom improvement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ndler failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LIFT.md: fix exhaustion description to match actual error shape
- README.md: remove stale "registry" and "tracks" claims post-revocation-removal
- types.ts: remove "revocable" from Sheaf method docs; clarify when to use
  global section variants vs explicit-guard variants
- USAGE.md: use makeSection (public API) in single-provider example; clarify
  proxyLift vs yield* for lift composition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…dataKey conflation bugs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gen.next(errors) was passing the same live mutable array reference on
every resume. A lift that stores the received value from one yield and
inspects it after a later yield would see mutations from subsequent
failures. Pass [...errors] snapshots so each yield receives an
independent copy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… conflation

JSON.stringify maps undefined, NaN, Infinity, and -Infinity all to null,
so sections with e.g. { cost: Infinity } and { cost: null } produced
identical keys and were incorrectly collapsed into one germ. Replace the
plain JSON.stringify(entries) with encodeMetadataEntry, which includes a
typeof tag in each tuple so all of these distinct values produce distinct
keys. BigInt metadata values no longer throw at serialization time either.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sheaf is a large, self-contained subsystem. Keeping it under its own
subpath import reduces coupling on consumers who don't need it, and
keeps the main index focused on general utilities.

- Add @metamask/kernel-utils/sheaf entry point (src/sheaf/index.ts)
- Remove sheaf re-exports from the main index
- Add ./sheaf export to package.json alongside the other subpaths
- Remove sheaf overview from README (belongs in sheaf/README.md)
- Update CHANGELOG: use subpath import, drop internal exports
  (collectSheafGuard, getStalk, guardCoversPoint), add makeSection and
  noopLift, fix MetadataSpec capitalisation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…decomposeMetadata

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
=== fails for NaN (NaN !== NaN), so a NaN value shared by all germs was
never promoted to a constraint — it remained in each germ's distinguishing
metadata instead. Object.is correctly treats NaN === NaN and is consistent
with the type-tagged encoding already used in collapseEquivalent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…aKey

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
JSON.stringify(-0) produces "0", so -0 and +0 were serialised to the
same metadataKey and incorrectly collapsed into one germ by
collapseEquivalent. Object.is(0, -0) is false, so decomposeMetadata
already treated them as distinct — making the two functions inconsistent.
Add -0 as an explicit special case alongside NaN, +Infinity, -Infinity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The delegation twin's interface guard uses M.eq(lowercaseToken) to match the
token address, so dispatch through the sheaf must pass the token in canonical
lowercase form. Callers that supply a mixed-case address were being silently
filtered out of the twin stalk and falling through to the home section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…emantics

Once a matched stalk section throws, the sheaf continues dispatch through the
remaining sections. The e2e scenario now asserts that an over-budget spend
falls through from the delegation twin to the call-home section and that
transfer still succeeds via the home coordinator (using a direct EOA tx, so
no UserOp polling). Also extend dockerExec with an optional timeoutMs so the
two-spend scenario fits within a longer window on bundler-hybrid.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… subpath

The rebase onto grypez/bringing-in-the-sheaves introduced a semantic
conflict: d3b397d moved sheaf exports from the main kernel-utils index
to the ./sheaf subpath, but the evm-wallet commits still imported from
the old path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tead of section.token

The old DelegationSection type exposed a .token property that reflected
the normalized address. PresheafSection does not — the lowercased token
is now embedded in the transferFungible method guard (M.eq(token)) rather
than carried on the return value.

Use getInterfaceGuardPayload/getMethodGuardPayload from @endo/patterns to
extract the first arg guard and verify it accepts the lowercase form but
rejects the original checksummed address.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 71.6%
⬆️ +0.65%
8362 / 11678
🔵 Statements 71.43%
⬆️ +0.64%
8502 / 11901
🔵 Functions 72.55%
⬆️ +0.65%
2033 / 2802
🔵 Branches 65.21%
⬆️ +0.63%
3378 / 5180
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/evm-wallet-experiment/src/lib/delegation-twin.ts 92.68%
🟰 ±0%
84.61%
🟰 ±0%
100%
🟰 ±0%
92.68%
🟰 ±0%
66-68, 84-85
packages/evm-wallet-experiment/src/vats/away-coordinator.ts 0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
57-2017
Generated in workflow #4392 for commit 637f95e by the Vitest Coverage Report Action

@grypez grypez force-pushed the grypez/bringing-in-the-sheaves branch 3 times, most recently from e0cfc2f to 5c536ca Compare May 7, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant