@moesi/settle-zerodev
v0.12.0
Published
ZeroDev session-key settlement adapter for Moesi — implements the abstract Settlement interface over @zerodev/sdk + @zerodev/permissions.
Downloads
872
Maintainers
Readme
@moesi/settle-zerodev
The first-party ZeroDev preset for Moesi. It runs Kernel session-key authorization and execution behind one reusable client.
One owner approval per action-bearing chain can enable a bounded ECDSA or WebAuthn modular operator. The adapter packs ordered deploy and post-deploy actions into bounded ERC-4337 UserOperations as chain state and execution gas limits allow.
Operating-model boundary
The client-level session is a container for chain-local authority. Each chain has its own Kernel derivation/version, permission ID, ENABLE and DEFAULT nonce state, sponsorship/funding route, receipt evidence, revocation transaction, and finality. A repeated address or one detached approval envelope does not make those lifecycles atomic or shared.
The Kernel root owner/admin remains supreme: it approves, administers, and
revokes permissions and is not constrained by an operator grant. Ordinary
authorize() creates an in-memory ECDSA operator; linkless flows can restore a
matching ECDSA or WebAuthn/passkey modular signer. Direct EOA settlement remains
outside Kernel policy enforcement.
Returned action receipts are claims for independent verification, not durable
completion by themselves. Bind them to the moesi.action-id/v3 hash of the
domain, manifest hash, chain key, exact action step, target, calldata, native
value, and stable occurrence; check contractName, selector, and
predictedAddress separately. Then verify externally reviewed action and
execution context, canonical settlement and finality, account/permission
attribution, runtime, and postconditions before re-observing the chain. Preserve
confirmed prefixes and unresolved hashes rather than resubmitting them on
another route.
Sponsored, organization-funded Kernel, owner-submitted EntryPoint, and native/direct EOA routes have different gas payers. Sponsorship exists only when the selected provider and policy accept the operation. Ordinary Kernel grants do not enforce a gas-price ceiling, total gas budget, or mandatory paymaster. Several active grants expose the union of their independently allowed calls and remaining counters.
exact-action supplies reviewed identity. Target, selector, supported
arguments, value, recipient, time, operation-count/rate, and configured
aggregate constraints are on-chain only when the compiler reports them that
way; full-calldata identity and purpose/project/memo remain advisory or
off-chain. ERC-7579 core module types are 1–4. Policy type 5 is the draft
ERC-7780 extension, not a core portability claim.
Install
npm install moesi @moesi/settle-zerodev viemLocal fork harness
The supported programmatic entry for Moesi's loopback-only development bundler is:
import { startForkBundler } from "@moesi/settle-zerodev/fork-bundler";
const bundler = await startForkBundler({
rpcUrl: "http://127.0.0.1:8545",
chainId: 31337,
executorPrivateKey: process.env.ANVIL_PRIVATE_KEY as `0x${string}`,
});
try {
console.log(bundler.url);
} finally {
await bundler.close();
}It is a deterministic local test harness, not a production ERC-4337 bundler.
The separately installed moesi-fork-bundler executable remains part of
@moesi/cli, which has no supported JavaScript import surface.
Quick start
import { createMoesi } from "moesi";
import {
defineZeroDevAuthorizationPolicies,
zerodevPreset,
} from "@moesi/settle-zerodev";
const moesi = createMoesi(zerodevPreset({ zerodevProjectId }));
const policies = defineZeroDevAuthorizationPolicies({
policiesByChain,
resolveActions,
});
const session = await moesi.authorize({
owner,
chains,
policies,
});
const review = await moesi.reviewActions({ chain, session });
if (review.coverage.recommendation === "reauthorize") {
showAuthorizationReview(review);
}
const result = await moesi.executeActions({
chain,
session,
options: {
onWarning: warning => showWarning(warning.userMessage),
onEvent: event => auditTimeline.add(event),
},
});
if (!result.ok) showDiagnostic(result.diagnostic);That is the application transaction API on default, Tempo, HyperEVM, and MegaETH chains. The client derives the reviewed owner, Kernel, policy, route, lane, bootstrap, journal, and canonical resolver from composition and the session. It checks adapter receipt mapping and re-resolves after confirmed alternate prefixes internally; callers still independently verify returned receipt claims before counting durable coverage. The caller does not repeat those composition inputs or branch on chain names.
moesi.dryRun(...) uses the same chain-policy estimate loop as execution.
Missing direct-owner and Kernel-sender estimates are read from chain.rpcUrl
automatically, and RPC-backed policy preflights use that same public reader.
Applications may still pass previously reviewed estimates, but do not need to
construct an RPC adapter or a temporary signer. Required sponsorship without a
usable bundler/paymaster pair returns the same provider-neutral failure before
dry-run or authorization; optional and self-funded entries may review an owner
route.
reviewActions({ chain, session }) is the reusable no-sign boundary for
independently resolved phases. Omit actions to invoke the resolver retained
by the session, or pass the newly resolved canonical subset explicitly. The
structured coverage reason distinguishes scope-mismatch, expired,
revoked, owner-changed, account-changed, chain-changed, and disposed;
its recommendation distinguishes reauthorization from restoring wallet
context or resolving another blocker.
Before a session exists, review the owner/session decision with the same API:
const review = await moesi.reviewActions({
chain,
ownerAddress,
kernelAddress,
actions,
options: { requested: "auto", ownerAvailable: true },
});The result contains the exact ordered pack boundaries and JSON-safe decimal
execution, pre-verification, and total gas evidence used by the settlement
packer. Auto mode selects owner only when the complete plan is proven to fit
one UserOperation; multiple packs select session, and an already covered
session wins without another enable signature. Missing simulation or required
sponsorship-stub evidence is explicitly inconclusive. Review never signs,
allocates a nonce, touches the alternate-payload journal, requests
sponsorship, or submits; executeActions() rechecks all invariants before
side effects.
policies is the reviewed account-authority bundle. Resolver-backed
applications create it once with
defineZeroDevAuthorizationPolicies({ policiesByChain, resolveActions }); the
returned session retains that canonical resolver for later
re-resolution. See the moesi composition-root guide for the
expanded setup.
Linkless operator sessions
The composed client also supports an owner and operator that never share a session secret. The owner derives the exact permission identity from the operator's public material and signs a detached enable approval without submitting a UserOperation:
import {
createWebAuthnSessionSigner,
WebAuthnSignerVersion,
} from "@moesi/settle-zerodev";
const publicSigner = {
kind: "webauthn",
pubX: webAuthnKey.pubX,
pubY: webAuthnKey.pubY,
// This credential-id hash is public and is part of ZeroDev's signer data.
authenticatorIdHash: webAuthnKey.authenticatorIdHash,
webAuthnSignerVersion: WebAuthnSignerVersion.V0_0_4_PATCHED,
} as const;
const permissionId = await moesi.recomputePermissionId({
chainKey: chain.key,
kernelVersion: "0.3.1",
policies: policies.policiesByChain.get(chain.key)!,
publicSigner,
});
const approval = await moesi.buildDetachedEnableApproval({
owner,
kernelAddress,
accountIndex, // exact CREATE2 salt used to create this Kernel
chains: [chain],
policies,
publicSigner,
kernelVersion: "0.3.1",
});
await approvalStore.save({ permissionId, approval });The serialized approval contains the owner-signed enable data, public signer identity, Kernel binding, and policy-derived permission ID. It does not contain an operator private key and does not spend gas.
Every detached approval uses the single
moesi.zerodev-detached-approval/v1 schema. The required kind field is the
model discriminator:
kind: "per-chain"containschainsand nogrant.kind: "replayable"contains one compactgrantand nochains.
The v1 codec is exact: both models at once, neither model, an absent/unknown
kind, or unknown envelope/record fields are rejected before any chain read or
authority restore. There is no v2 approval and no compatibility reader for
undiscriminated or extended payloads; create a new approval instead.
Inspect and diagnose stored approvals
Treat a detached approval as opaque application data. Use the root-package inspector when a database row must be classified or displayed without exposing the owner signature or serialized account:
import { inspectDetachedApproval } from "@moesi/settle-zerodev";
const inspection = inspectDetachedApproval(storedApproval);
if (inspection.status === "reapproval-required") {
showOwnerReapprovalFlow();
} else if (inspection.status === "invalid") {
quarantineStoredValue();
} else {
showApprovalBinding(inspection.binding);
}inspectDetachedApproval() always returns the immutable
moesi.zerodev-detached-approval-inspection/v1 result. Branch on status,
kind, and replayableEnable; reason is diagnostic prose. A supported
per-chain approval has one binding only when every chain record agrees on the
owner, Kernel, version, index, permission, and signer. useMetaFactory is
null for per-chain records because that serialized-account model does not
encode the factory choice.
The tooling subpath can additionally reproduce a current compact replayable WebAuthn ENABLE and compare it with one selected chain. The package owns signer, policy, permission, typed-data, signature, Kernel factory, nonce, and root-owner reconstruction. The caller supplies the public expected binding and a chain-bound viem client:
import {
createDetachedApprovalReadinessReader,
diagnoseDetachedApprovalReadiness,
} from "@moesi/settle-zerodev/tooling";
const reader = createDetachedApprovalReadinessReader({ client: publicClient });
const readiness = await diagnoseDetachedApprovalReadiness({
approval: storedApproval,
chainId: chain.chain.id,
expectedOwnerAddress,
expectedKernelAddress,
expectedAccountIndex: accountIndex.toString(),
expectedPermissionId: permissionId,
passkey: {
pubX: webAuthnKey.pubX.toString(),
pubY: webAuthnKey.pubY.toString(),
authenticatorIdHash: webAuthnKey.authenticatorIdHash,
},
scope: {
targets: [{ target: factory, selector: deploySelector }],
expiresAt,
dailyDeployLimit,
},
reader,
});
if (readiness.issue) {
routeReadinessAction(readiness.issue.code, readiness.issue.action);
}Readiness results use
moesi.zerodev-detached-approval-readiness/v1. Machine decisions consume the
closed issue.code, issue.action, and issue.retryable fields, never
issue.message or provider error text. An absent TimestampPolicy alone is the
retryable SESSION_KEY_TIMESTAMP_POLICY_INSTALL_REQUIRED; unreadable RPC state,
other missing modules, unavailable factory routes, an expired scope, a changed
proxy implementation, an exact consumed permission ENABLE nonce, changed
validator nonce, and changed root authority remain distinct categories. The
reader first rejects a mismatched client chain, then opens and closes one exact
canonical block hash/number while binding every deployment, outer EntryPoint
factory, underlying KernelFactory approval, sender, implementation, exact
permission nonce, and authority read to that hash with EIP-1898
requireCanonical. This API diagnoses only: it does not submit, install,
restore, revoke, or retry an approval.
| Stored detached generation | Inspection | Readiness diagnosis |
| --- | --- | --- |
| Current exact kind: "replayable" compact grant | supported | Supported for the patched WebAuthn v0.0.4 signer and the documented target/selector, optional expiry, and optional with-reset daily-rate scope |
| Current exact kind: "per-chain" records | supported | Not supported; continue to the ordinary per-chain restore path |
| Pre-discriminator v1 (replayable or per-chain) | reapproval-required | Reapprove with the current builder |
| Per-chain record carrying the retired replayable marker | reapproval-required | Reapprove with the current builder |
| Replayable record carrying a retired serialized account | reapproval-required | Reapprove with the current builder |
| Malformed, ambiguous, unknown-version, or extended input | invalid | Reapprove or quarantine; no chain read is attempted |
Later, the operator restores that approval with its live signer. The restored
authority uses the same reviewed resolver and executeActions() path as an
ordinary session. Its route is fixed to the captured bundler transport, so it
cannot fall back to an unavailable owner signer:
const signer = await createWebAuthnSessionSigner({
chain,
webAuthnKey,
webAuthnSignerVersion: WebAuthnSignerVersion.V0_0_4_PATCHED,
});
const session = await moesi.restoreSessionFromApproval({
approval,
signer,
accountIndex,
chains: [chain],
policies,
});
const result = await moesi.executeActions({ chain, session });createWebAuthnSessionSigner() performs no chain read and prompts the passkey
only when execution signs a UserOperation. The first operation uses ENABLE
mode with the stored owner approval; later operations rebuild the same
permission in regular mode. For a replayable grant, the adapter presents the
replayable signature marker on both the ENABLE gas-estimation stub and the
completed submitted signature. It derives that mode from the validated grant;
application transports do not inspect the nonce or rewrite JSON-RPC requests.
DEFAULT-mode estimation and submission remain chain-bound and unmodified.
recomputePermissionId() is pure and
Kernel-version independent, while still accepting kernelVersion to make the
account configuration explicit. accountIndex must be the same non-negative
bigint used when the Kernel was first derived; omitting it preserves the
0n default.
When the one-time detached approval has already been retired, restore only from canonical installed state. This path recomputes the permission ID from the exact policies and live signer, reads the exact signer material from its on-chain module, and reconstructs a steady-state Kernel plugin without an enable signature:
const session = await moesi.restoreInstalledSession({
signer,
owner, // independently derives the Kernel and supports a later session.revoke(...)
kernelAddress,
permissionId,
accountIndex,
chains: [chain],
policies,
});Before reading signer storage, restoration independently derives the Kernel
from the owner, Kernel version, and account index through the canonical factory
route; the caller-supplied address cannot short-circuit that proof. Missing
signer storage, a permission or account binding mismatch, reconstruction
failure, a non-default steady-state nonce, and validation or observation
failure all throw InstalledPermissionRestoreError. Branch on its closed
reason category rather than diagnostic prose. This API does not infer that a
pending installation succeeded and does not replay a retired approval.
Experimental raw P-256 signer
P256PermissionSigner and createRawP256SessionSigner() are explicitly
experimental. They have not received an independent contract/security review,
do not have a canonical deployment registry, and are not a complete OAuth or
persistent-device-key integration. Keep production use disabled until those
separate reviews and integration proofs exist.
The application deploying the module owns its deployment on every chain. Until
the upstream deployment catalog work tracked by
#228 assigns a durable owner,
record the chain ID, immutable source revision, compiler settings, runtime
bytecode hash, deployment transaction, deployer/operations owner, and
independent review beside the explicit signerContractAddress. The library
deliberately has no ambient default address.
Before retaining a live signer, createRawP256SessionSigner() performs three
read-only checks against the supplied chain: exact RPC chain ID, non-empty
code at the explicit signer address, and a known-valid RIP-7212/EIP-7951 vector
at precompile address 0x100. It then asks signPayload to sign a fresh random
challenge under the moesi.raw-p256-key-consistency/v1 local domain and
verifies that proof against the exact pubX/pubY. A wrong HSM/browser key or
a replayed fixed proof therefore fails before an approval is restored or a
UserOperation is assembled. Use
probeRawP256Capability() directly when presenting readiness diagnostics.
Signer construction performs one extra signPayload call for this local proof
and discards its signature; it does not install or expand authority.
const signer = await createRawP256SessionSigner({
chain,
pubX,
pubY,
signerContractAddress, // explicit reviewed deployment; no default
signPayload: payload =>
crypto.subtle
.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, payload)
.then(bytes => new Uint8Array(bytes)),
});The experimental wire contract is moesi.raw-p256-kernel/v1:
| Field | Normative value |
| --- | --- |
| Curve | NIST P-256 / secp256r1 |
| Input H | Kernel's exact 32-byte validation hash |
| Domain | The upstream Kernel/EntryPoint UserOperation hash or ERC-1271 caller hash; Moesi adds no EIP-191, WebAuthn, or custom prefix |
| Verification digest | SHA-256(H) because WebCrypto ECDSA hashes the supplied H once |
| Signature | Exactly 64 bytes: unsigned big-endian IEEE-P1363 r[32] || s[32] |
| Canonicality | 0 < r < n and 0 < s <= n/2; high-s is normalized client-side and rejected on-chain |
| Rejected encodings | ASN.1 DER, variable-width integers, recovery bytes, WebAuthn assertion envelopes |
The public cross-runtime vector in
test-vectors/raw-p256-kernel-v1.json
pins H, SHA-256(H), affine public coordinates, and the accepted low-s
r || s bytes. TypeScript/WebCrypto and Foundry tests both consume or mirror
that vector. Changing the hash layering, domain, coordinate encoding, or
signature encoding requires a new scheme/vector version and a separately
reviewed module deployment.
Registry grant records currently retain only a permission ID, so the default
Kernel observer conservatively checks the ordinary ECDSA signer-storage path.
Consumers that retain the signer kind can use readSignerMaterial() or
isSignerInstalled() from @moesi/settle-zerodev/tooling for an explicit
ECDSA, WebAuthn, or raw-P256 storage check. WebAuthn/raw-P256 registry
reconciliation remains inconclusive when that signer identity is unavailable.
For owner-side revocation, create a public-only operator identity and supply the
live owner while restoring. The resulting session retains owner authority only
for its existing session.revoke(...) boundary:
import { createPublicSessionSigner } from "@moesi/settle-zerodev";
const revocable = await moesi.restoreSessionFromApproval({
approval,
signer: createPublicSessionSigner(publicSigner),
owner,
accountIndex,
chains: [chain],
policies,
});
await revocable.revoke({ chainKey: chain.key, action, actionIndex });Conclusive detached revocation evidence
Submission and inclusion are not proof that the intended permission is gone. After the owner-authorized revocation yields a transaction hash, verify the opaque approval against the authenticated chain reader before persisting a revoked state:
import {
verifyDetachedApprovalRevocation,
type DetachedGrantRevocationEvidence,
} from "@moesi/settle-zerodev";
const evidence: DetachedGrantRevocationEvidence =
await verifyDetachedApprovalRevocation({
client: publicClient,
approval,
chainId: chain.chain.id,
transactionHash,
});
await auditStore.put(JSON.stringify(evidence));The verifier consumes the package-owned approval inspection/codec, checks the
RPC chain and canonical EntryPoint transaction, and recomputes the hash-covered
UserOperation. It requires exactly one successful operation on the supported
version-specific DEFAULT/SUDO ECDSA root nonce lane, whose
uninstallValidation(bytes21,bytes,bytes) input carries the approval's exact
bytes4 permission and canonical policy/signer data. It then requires the exact
ordered Kernel ModuleUninstallResult(address,bool) and
ModuleUninstalled(uint256,address) pairs for every policy and signer module.
Compatibility note: Kernel 0.3.1–0.3.3 ABIs contain
PermissionUninstalled(bytes4), but the deployed supported runtimes do not
emit it. That ABI-only declaration is not accepted as evidence. The verifier
instead binds the permission through canonical calldata and the successful
EntryPoint event, then binds the actual modules through runtime-emitted events.
Finally it pins the number and hash of a fresh block at or after inclusion,
reads the approval's ECDSA, WebAuthn, or raw-P256 signer store at that explicit
block number, and requires the same number/hash to remain canonical after the
read. The evidence retains both observation fields. Unreadable storage, any
nonzero material (including different or reinstalled material), a reorged
receipt or observation block, another validator lane, or missing/mismatched
operation or module evidence fails with
DetachedApprovalRevocationVerificationError.reason; consumers never branch on
diagnostic prose.
Evidence uses decimal strings for block numbers and contains no approval,
enable signature, signer identity, or reusable authority. It supports detached
approval-v1 and Kernel 0.3.1–0.3.3. authorityScope is derived from the approval:
recorded-chains for per-chain approvals and
any-chain-where-account-exists for replayable approvals. The returned artifact
proves absence on exactly the reported chain and observation block; it does not
turn finite checks into global revocation. RPC authentication, confirmation or
finality policy, durable retention, fleet reconciliation, and recovery remain
caller responsibilities.
Detached approvals currently support the built-in Call, Timestamp, and Rate Limit policy serialization used by linkless grants. Custom aggregate-outflow policies remain on the ordinary in-memory authorization path.
Durable alternate-lane recovery
The preset configures restart-durable recovery automatically for every
built-in alternate lane. Ordinary UserOperations do not need an
alternate-payload journal. In Node the preset uses
FileAlternatePayloadJournal at
.moesi/alternate-payloads, resolved from process.cwd(). Node applications
using that default should add /.moesi/ to their .gitignore; this repository
already does. In browsers the preset uses origin-scoped localStorage guarded
by Web Locks. Native bootstrap, native-owner, EntryPoint self-bundle, and
UserOperation execution therefore use the same application API without a
journal argument.
Pass a journal override only to select a custom storage backend, directory, or
retention policy:
import { FileAlternatePayloadJournal } from "moesi/node";
const moesi = createMoesi(zerodevPreset({ zerodevProjectId }), {
journal: new FileAlternatePayloadJournal({
directory: "/var/lib/my-app/moesi-alternate-payloads",
}),
});The later authorize({ owner, chains, policies, account? }) and
executeActions({ chain, session, options }) calls do not
change. The automatic browser backend fails closed if localStorage or Web
Locks is unavailable; other runtimes must supply a durable journal override.
An in-memory map cannot quarantine uncertain submissions across reloads or
process restarts.
Low-level adapter APIs
New applications should compose through createMoesi(zerodevPreset(...)).
The root package exposes that preset, reviewed policy bundles, and the
high-level linkless signer/approval helpers described above.
The earlier prepareAccounts() / authorizeSession() application workflow,
the catch-all advanced entry, and the positional executeActions() overload
are deleted. Transaction call sites use the client object form shown above.
Low-level policy reviewers and adapter authors can explicitly import
executeZeroDevWithChainPolicy() and prepareZeroDevNativeBootstrap() from
@moesi/settle-zerodev/tooling. Owner, threshold, signer, session-policy, and
policy-compilation operations live under
@moesi/settle-zerodev/administration. Those primitives intentionally stay out
of root-package autocomplete and are not application transaction APIs.
computePermissionId() on the administration subpath derives the canonical
ZeroDev permission ID from reviewed policies and public signer material without
a chain client or permission plugin. The tooling subpath exposes canonical
ECDSA/WebAuthn signer-storage read-back plus bounded, confirmation-aware
listWebAuthnOperatorRegistrations() historical log enumeration. Registration
history is discovery evidence only; use signer-storage read-back to establish
whether a permission is currently installed. Historical enumeration is
all-or-nothing: if the head read or any bounded getLogs page/range fails, the
whole promise rejects and no partial registration list is returned. Callers may
reduce chunk and retry the complete requested range; a progress/partial-result
surface is intentionally omitted so incomplete discovery cannot look complete.
import { computePermissionId } from "@moesi/settle-zerodev/administration";
import { readSignerMaterial } from "@moesi/settle-zerodev/tooling";
const permissionId = computePermissionId(policies, publicSigner, { chainKey });
const installedSigner = await readSignerMaterial({
client: publicClient,
permissionId,
kernel,
kind: "ecdsa",
});The permission-ID implementation mirrors the exact
toPermissionValidator().getIdentifier() algorithm resolved in this lockfile:
@zerodev/[email protected] running with @zerodev/[email protected]. It ABI-encodes
toPolicyId(policies), PolicyFlags.FOR_ALL_VALIDATION, and
toSignerId(signer), hashes the result, and takes the first four bytes. The
direct upstream parity test and the fixed packed-consumer result fail when that
algorithm changes.
This is temporary compatibility ownership tracked by #228, not a permanent fork of ZeroDev protocol knowledge. Replace these helpers with upstream APIs and remove the local permission-ID/signer-storage ABI mirror once a supported ZeroDev release exposes both (1) chain-client-free permission-ID derivation from the same public inputs and (2) typed ECDSA/WebAuthn installation evidence that preserves unreadable/unsupported state. The replacement must pass the existing fleet fixtures and direct parity tests before callers migrate.
The adoption smoke builds the matching consolidated moesi runtime, packs it
and this package, installs both tarballs in a temporary npm consumer with the
exact ZeroDev versions above, rejects workspace symlinks/imports, and executes
both documented subpaths with a mocked read-only signer contract call:
pnpm --filter @moesi/settle-zerodev build
pnpm --filter @moesi/settle-zerodev smoke:packed-consumergasBudget: { domain: "execution", maximum } bounds the internal session-key
packing budget while intentionally excluding preVerificationGas. Select
domain: "total" when the ceiling includes it. Packing diagnostics remain
internal and are not part of the ordinary object-form result.
The result attributes every action to its selected lane and confirmed receipt.
For a fresh permission, initialVerificationGasLimit sets a floor on the
initial ENABLE/bootstrap UserOperation before sponsorship or signing. The
adapter verifies that middleware did not lower the prepared value and never
copies the floor to later DEFAULT-mode operations. On chains where even a
large EntryPoint verification allowance cannot cover Kernel code deposit,
bootstrapOwnerKernelAccount() derives the same owner-sudo factory call,
sends it as a normal owner transaction, waits for its native receipt, and
requires fresh RPC reads to observe code at the deterministic Kernel address.
prepareOwnerKernelFactoryDeployment() exposes the unsigned factory target
and calldata for policy orchestrators that submit native transactions through
their own wallet adapter.
Automatic chain-policy execution
executeActions({ chain, session, options }) is the normal
transaction API everywhere. The adapter selects the default, Tempo, HyperEVM,
or MegaETH policy from the ChainEntry; derives owner, Kernel, version, and
account index from the retained session; and prepares any exact MetaFactory
bootstrap internally. Applications do not import policies or branch on chain
names at the send callsite.
Signer and sender routing remain automatic when omitted. Developers may hard-pin
either dimension independently through options.signMethod ("owner" or
"session-key") and options.transactionSender ("bundler" or "owner").
They are independent selection axes, but not every pair is supported:
signMethod: "session-key" with transactionSender: "owner" fails closed
because the owner-submitted EntryPoint path cannot safely submit a
session-signed operation. A hard pin is either honored exactly or rejected; the
preset never silently changes it.
Application onEvent callbacks receive the versioned
moesi.settlement-event/v1 contract. Its variants and fields are additive-only
within v1; consumers should branch on schemaVersion before kind and retain
the version with persisted audit events. Adapter-facing unversioned inputs are
sanitized, frozen, and versioned by createMoesi() before delivery.
The high-level authorize({ owner, chains, policies, account? }) path snapshots
the owner, optional account binding, canonical resolver, and bound journal
methods before authorization awaits. Initial and post-receipt plans therefore
use one resolver and one durable quarantine for the lifetime of the authority.
resolveActions belongs inside
defineZeroDevAuthorizationPolicies({ policiesByChain, resolveActions }); it is
not a top-level authorization field. Every returned action must match the
requested chain. An empty initial resolution is an explicit no-op and performs
no wallet, bootstrap, or journal operation.
Existing Kernels created with a nonzero salt can bind the original CREATE2 index and recorded address directly on the composition root:
const session = await moesi.authorize({
owner,
chains,
policies,
account: {
index: BigInt(kernelSalt),
expectedAddress: recordedKernelAddress,
},
});The preset derives from owner + kernelVersion + index without trusting the
address pin. If the result differs from expectedAddress, authorization fails
before a session key is generated or the wallet signs. Omitting account
preserves the historical index 0n behavior.
session.getReviewedChain(chain.key) returns only frozen, credential-free
review evidence: chain key/id/name, Kernel address and account index, permission
and nonce evidence, policy constraints, sponsorship posture, and coarse
transport configuration. It never returns RPC, bundler, paymaster, or fallback
URLs; session private keys; serialized accounts; opaque native policy objects;
or a direct transaction method. Exact reviewed routes and executable authority
remain enforced inside a non-reflectable package capability and are erased when
the session is disposed.
For native bootstrap, the adapter commits the owner, chain, Kernel, factory, exact calldata/value, Kernel version, account index, and MetaFactory route to the canonical settlement identity. It submits those same prepared bytes, waits for an attributed successful receipt, and verifies fresh Kernel code. The deterministic SDK factory recipe remains available after the Kernel is already deployed, so repeated executions reach the existing code check and continue without resubmitting the bootstrap.
For mixed plans, direct owner estimates are produced only for explicitly
sender-independent deployments. Sender-bound calls are encoded through the
exact owner-sudo Kernel account and estimated with EntryPoint as caller.
Native-owner transactions are receipt- and transaction-attributed to the
reviewed sender, chain, target, calldata, value, gas, and fee envelope. Guarded
Kernel actions are grouped into one unsponsored owner-signed EntryPoint v0.7
UserOperation with explicit gas fields. The adapter reads EntryPoint
balanceOf, confirms a depositTo(kernel) shortfall transaction when needed,
and signs the exact encoded calls. Automatic submission sends that same
owner-signed UserOperation to the bundler first; direct owner-sent handleOps
is eligible only after a conclusive pre-acceptance rejection. Success is
accepted only after the unique matching successful UserOperationEvent is
verified.
Every alternate payload is reserved before submission. Ambiguous native,
deposit, or handleOps outcomes remain pending (and are enriched through
recordUncertain when the journal supports it), so automatic retry cannot
duplicate a transaction. After each confirmed alternate receipt the caller's
resolver runs against canonical state, while the same session is
retained for the final session continuation. Any failed pack, failed actions
inside one pack, explicit unattempted suffix, duplicate mapping, or missing
action result rejects. Successful results expose stable action identity,
occurrence, alternate payload identity, lane, and canonical receipt mapping.
Low-level policy integrators can use the explicit
@moesi/settle-zerodev/tooling subpath. Those orchestration primitives are
intentionally absent from the root package autocomplete.
Settlement preflight
preflightZeroDevSettlement() publishes moesi.settlement-preflight/v1 with
ready | blocked | inconclusive aggregate and per-check states. Every check
records whether it is required, its reason, evidence source, provider,
observation/expiry time, and sanitized values. The preflight reuses the same
route, sponsorship, UltraRelay, gas-domain, and provider-capacity vocabulary as
execution.
Profiles are explicit: read-only (default) issues only JSON-RPC reads,
plan-simulation accepts a caller-supplied callback whose result is labeled
caller-attested (the package cannot prove what that callback did), and
active-canary requires both a literal confirmation and callback. Passive
bundler reachability never proves relay inclusion, a paymaster stub never
promises sponsorship, and UltraRelay EntryPoint support never proves project
entitlement. Unknown capacity or gas domains remain inconclusive. A positive
self-funded balance is likewise inconclusive until simulation supplies a full
requiredPrefund; known shortfalls block and covered prefund is ready. Preflight
accepts the same explicit gasBudget: { domain, maximum } contract as
execution. Any blocked required check blocks; otherwise any inconclusive
required check keeps the report inconclusive.
Failure diagnostics
Every ZeroDev { ok: false } result carries a structured, frozen
diagnostic, and every ZeroDev failed event mirrors it while retaining the
existing reason string. ZeroDev diagnostics carry the prepare/submit/receipt
stage, safe call shapes, sender deployment evidence, factory/paymaster-data
presence, nonce mode, permission identifier, redacted route, sponsorship mode,
and a UserOperation hash once submission is known. phase remains unknown
unless an EntryPoint response or mined receipt proves validation or execution.
Prepared detached/session UserOperation failures also carry
diagnostic.submission. Applications may issue another execution only when
that closed disposition positively says retrySafe: true (not-started or
signature-requested). A completed signature is already conservative and
non-retry-safe and preserves the locally computed operation hash before
formatting, cancellation checks, or dispatch. Entering eth_sendUserOperation produces
submission-attempted with the deterministic hash even when the provider
returns a structured JSON-RPC or HTTP rejection; accepted operations advance
to submitted, and a canonical receipt advances to receipt-observed with
both hashes. Missing or rejected lifecycle evidence is inconclusive, never
permission to retry. Owner-signed alternate self-bundle execution has a
separate lifecycle contract and does not gain this field.
After a submitted receipt timeout, observe the exact hash without entering a
send path:
const observation = await moesi.observeUserOperation({
chain, // must retain the exact caller-owned bundlerTransport used to submit
userOpHash: result.diagnostic.submission.userOpHash,
accountAddress: expectedKernelAddress,
nonce: submittedNonce, // optional when the exact submitted nonce was retained
});The method performs one eth_getUserOperationReceipt request, verifies its
transaction receipt and containing canonical block through the chain read
route, and returns frozen pending, included, finalized, or unreadable
evidence. Included and finalized results distinguish an inner success from
failed; the fixed EntryPoint event must bind the requested hash and Kernel,
plus the nonce when supplied. Finalization carries the canonical finalized
block number and hash.
Null receipts stay pending, while malformed evidence and read failures retain
the operation hash as unreadable rather than claiming it was dropped. It never
submits, waits, mutates the local nonce cursor, or treats observation alone as
permission to reuse a quarantined nonce.
Diagnostic output never includes calldata/request bodies, signatures, session or serialized-account material, factory calldata, paymaster blobs, credentials, authorization/cookies, or raw nested SDK errors. Redaction is recursive across nested causes. Empty errors still include operation context and an honest unreadable-error cause. Moesi does not replay individual inner calls: that would not reproduce EntryPoint, Kernel, counterfactual-deployment, or paymaster validation and cannot replace the original provider response.
Sponsorship intent
Every authorization carries explicit required | optional | self-funded
sponsorship intent. Required mode fails before UserOperation preparation or
submission when the selected configured route lacks a usable paymaster.
Self-funded mode disables UltraRelay and never installs paymaster middleware,
even if paymaster fields are present. Optional mode prefers
the configured sponsor, but may retry preparation without it only before any
submission; the adapter then reads the Kernel balance and proves it covers the
prepared operation's complete prefund before sending. An insufficient balance
blocks, and a failed balance read is inconclusive rather than risking AA21.
Every chain entry must declare sponsorshipMode; URLs never imply sponsorship
or paymaster intent. normalizeSponsorshipIntent validates the explicit mode
and selected paymaster capability.
UltraRelay and configured fallback
ultraRelay: "auto" is the default. For a ZeroDev RPC, the adapter probes the
provider=ULTRA_RELAY route for EntryPoint 0.7 and uses it when available; it
otherwise retains the configured bundler and paymaster path. UltraRelay
combines those services, so the adapter omits separate paymaster middleware on
that route. Because the reachability probe cannot prove project entitlement,
"auto" also retries one explicit availability or sponsorship failure through
the original configured bundler/paymaster only when it occurs before signing
or entering the send request. Every entered send is quarantined and never
falls back, including structured provider rejections. Use "never" to pin the
configured path or "require" to fail without fallback if UltraRelay is
unavailable. Availability probes are
coalesced and refreshed; a transient miss falls back safely and is probed again
on a later execution. Non-ZeroDev/BYO bundler URLs are never rewritten.
Chain-state read pools
Settlement chain-state reads use the same ChainEntry read-pool contract as
the resolver. Optional rpcUrls, rpcStrategy, rpcCooldownMs, and
rpcTimeoutMs settings are carried through authorization and used for Kernel
preparation, permission/nonces, account reconstruction, revocation state, EOA
balances, and receipt reads. Transport failures and HTTP 408/429/5xx responses
may fail over; successful JSON-RPC application errors do not. Concrete block
parameters are replayed unchanged, and endpoint credentials are redacted from
transport diagnostics.
Bundler, paymaster, UltraRelay, wallet transaction, raw transaction, and UserOperation submission routes are not added to this chain-state pool. Their selection and retry boundaries remain independent.
Nonce sequencing and stale account state
Authorization reads the permission nonce once and snapshots the separate
Kernel DEFAULT-mode counter before the first send. executeActions then owns
that logical sequence locally and serializes concurrent callers that share the
same chain, EntryPoint, account, and permission key. EntryPoint consumes one
nonce per packed UserOperation, regardless of how many actions are inside it.
Different sequence keys can still execute in parallel.
Fresh permissions start with one ENABLE-mode operation and then move to the snapshotted DEFAULT key. Later operations are rebuilt with the permission marked pre-installed and omit factory/authorization data. Account-state reads stay on the public read pool while bundler and paymaster routing remains independent. If stale state produces AA10 or AA25 during the ENABLE-to-deployed transition, the adapter reads the current DEFAULT counter from EntryPoint and rebuilds once without factory data; the exact stale payload is never blindly resubmitted. ENABLE-mode AA23 remains the original terminal permission error and cannot advance the cursor. Steady-state AA23/AA25 propagation failures receive bounded byte-identical retries.
An accepted UserOperation whose receipt remains unresolved—or any send whose
transport outcome is ambiguous—makes the local nonce cursor uncertain and
blocks automatic reuse. The adapter signs and submits once; it never
fallbacks, repacks, or resubmits after a returned hash or ambiguous transport
outcome. It computes the canonical hash locally even when the bundler never
returns one. Reconcile that hash first, then pass an explicit nonceSeed only
when the canonical EntryPoint counter is known. receiptTimeoutMs is one
absolute wall-clock deadline (default 120 seconds), not a per-retry allowance;
timeouts include the hash, redacted route, nonce key, submission time, and
deadline. Public grant revocation uses the same prepare-once, sign-once,
submit-once, local-hash, prefund, absolute-deadline, and quarantine primitives;
required revocation never silently self-funds and optional revocation cannot
send unsponsored without a complete prefund/balance proof. Ambiguous and
accepted/no-receipt revocation failures expose the canonical userOpHash on
the failed result, event, and error. revokeGrant(..., { nonceSeed }) is a
deliberate reconciliation override: use it only after querying that exact hash
to prove whether it was included and then reading the canonical EntryPoint
owner-sudo nonce. A timeout alone is not evidence that the operation failed;
an incorrect seed can duplicate or skip a revocation. Omission never clears the
process-local quarantine, and a seed for another logical nonce key is rejected.
Kernel version pinning
The adapter supports Kernel 0.3.1, 0.3.2, and 0.3.3; 0.3.3 remains the
default. Kernel version participates in account address derivation, so existing
fleets must pass the version they were created with everywhere it is accepted:
const kernelVersion = "0.3.2" as const;
const moesi = createMoesi(zerodevPreset({ kernelVersion }));
const session = await moesi.authorize({ owner, chains, policies });The returned session carries the selected version so execution deserializes against the matching account.
Policy-native authorization
authorizePolicies accepts provider-neutral exact-action and
erc20-transfer artifacts from moesi/settlement. Here, exact-action
identifies the reviewed action; the per-constraint result—not the name—states
which parts are enforced on-chain. The adapter compiles target, selector,
supported argument rules, value, expiry, and configured transaction/rate limits
into Kernel policies. The EOA compiler fails closed when a policy requires
on-chain enforcement.
Ordinary CallPolicy sessions still report full calldata hashing as advisory.
The scoped kernel-threshold-2-of-2 proposal and
compileKernelThresholdExactAction can bind the Kernel sender, calldata,
nonce, and final UserOperation digest through two proposal-specific
signatures. encodeKernelThresholdSignatures emits the patched validator's
required approval-then-UserOperation wire order. Either signature alone, a
stale proposal, or a changed bound field fails. This is not standing multisig
governance for the Kernel.
The composed authorization retains one reviewed authority in memory for a sequence of application phases. A session can review either a zero-value target/selector superset or full policy-native constraints, then execute only subsets of that snapshot without another enable signature. Policy sessions preserve argument, value, recipient, timestamp, rate/transaction, and aggregate-outflow guards; aggregate accounting remains enforced by the installed on-chain policies.
Sessions fail closed after expiry, revocation, Kernel/chain binding drift, or
an owner-account change. Bare EIP-1193 providers are watched with
eth_accounts automatically. Their connected chain must remain among the
chains authorized by the session, while switching between reviewed chains is
allowed because session execution itself uses chain RPCs rather than the owner
wallet. Call revoke() for on-chain uninstall and dispose() to release the
in-memory session authority. Session revocation requires the action's explicit
permissionId and verifies that its uninstallValidation calldata encodes the
same validation ID before any RPC, preventing one session from uninstalling a
different grant.
This also supports an explicit authorize-now/execute-later flow. The owner
signs ENABLE once; the retained session then reuses the same session key for
actions that pass the send boundary's policy checks. matchesReviewedAuthority
compares only chain, owner, and Kernel identity; it never claims that proposed
actions are covered or that live on-chain authority remains installed:
const session = await moesi.authorize({
owner,
chains,
policies,
});
const retained = session.getReviewedChain(chains[0].key);
if (
!retained ||
!session.matchesReviewedAuthority({
chain: chains[0],
ownerAddress,
accountAddress: retained.accountAddress,
})
) {
throw new Error("The retained session belongs to another reviewed authority");
}
// No additional owner signature: the enabled session key signs these UserOps.
const first = await moesi.executeActions({ chain: chains[0], session });
const second = await moesi.executeActions({ chain: chains[1], session });selectUserOperationExecutionMethod() exposes the automatic choice before
execution. It is chain-neutral: callers report signer and transport
availability instead of the selector branching on a chain ID. Signer and
transaction sender are deliberately separate:
const method = selectUserOperationExecutionMethod({
signMethod: "auto", // "owner" | "session-key"
transactionSender: "auto", // "bundler" | "owner"
hasActiveSession: session.status === "active",
planFitsSingleUserOperation,
ownerSignerAvailable: true,
bundlerAvailable: true,
ownerTransactionSenderAvailable: true,
});Automatic signer selection first reuses an already-enabled session when the
selected sender can carry a session-signed UserOperation. If the caller pins
the owner transaction sender, or auto mode finds no bundler, the adapter
selects an available owner signer and emits the reviewed
sign-method-fallback warning before signing. Without an active session, auto
selects the owner when the complete prepared action set fits one UserOperation;
a multi-UserOperation plan selects a session key to avoid repeated owner
prompts when a bundler is available. Automatic transaction submission is
bundler-first. It may fall back to owner-sent EntryPoint.handleOps only after
a conclusive rejection proving the bundler did not accept an owner-signed
operation. A session-signed operation is never rerouted through the owner.
Ambiguous transport failures and post-acceptance receipt failures are
quarantined for reconciliation and never resubmitted through another sender.
ownerSignerAvailable and ownerTransactionSenderAvailable are separate
capabilities: the presence of an owner signer does not claim that a direct
transaction transport exists on the selected chain, and an owner transport does
not make a session signature eligible for owner submission.
Use non-auto values to pin either axis independently, not to request an
arbitrary pair. signMethod: "session-key" plus
transactionSender: "owner" fails closed because the owner-submitted
EntryPoint path cannot safely submit a session-signed operation. A pinned
bundler never falls back to the owner, a pinned owner sender skips the bundler,
and neither hard pin is silently changed. The selection result contains
distinct signer and sender reasons so UIs and audit logs do not confuse “owner
signed the UserOperation” with “owner sent the transaction.”
compileZeroDevPolicies supports recipientMatch: "one-of" | "equal", an
explicit any-recipient mode (recipients: []), and
rateLimit.variant: "default" | "with-reset". These switches exist for
permission-ID parity with already installed grants; inspect the compiled
constraint evidence rather than inferring enforcement from the policy name.
Remote owner signers
The signer bridge subpath supplies concrete, dependency-free adapters for AWS KMS and Turnkey. Authentication, SDK clients, key identifiers, and credentials stay in the host application; Moesi receives only a narrow exact-digest callback and verifies every returned signature against the configured Ethereum address before use.
For AWS KMS, use an ECC_SECG_P256K1 key. The adapter fixes
MessageType: "DIGEST" and SigningAlgorithm: "ECDSA_SHA_256", strictly parses
the returned DER signature, normalizes low-s, derives recovery parity, and
verifies the address:
import { SignCommand } from "@aws-sdk/client-kms";
import {
awsKmsSignerToLocalAccount,
} from "@moesi/settle-zerodev/administration";
const owner = awsKmsSignerToLocalAccount({
address: kmsEthereumAddress,
keyId,
sign: request => kms.send(new SignCommand(request)),
});For Turnkey, the adapter requests hexadecimal
ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2 semantics with
HASH_FUNCTION_NO_OP, validates r/s/v, normalizes low-s, and verifies the
same address:
import {
turnkeySignerToLocalAccount,
} from "@moesi/settle-zerodev/administration";
const owner = turnkeySignerToLocalAccount({
address: turnkeyEthereumAddress,
signWith: turnkeyPrivateKeyId,
signRawPayload: request =>
turnkey.apiClient().signRawPayload(request),
});Other HSMs and remote wallets can implement the smaller
EthereumDigestSigner contract and use digestSignerToLocalAccount(). That
generic callback must return a 65-byte recoverable Ethereum signature for the
exact 32-byte digest; it is still recovered and address-checked locally.
Owner administration and activity
executeActionsAsOwner() is the high-level owner-sudo path. It keeps the same
ordered packing, route selection, sponsorship, receipt, retry, event, and
nonce-safety behavior while creating no session permission. The returned
strategy and onStrategySelected callback make the authority choice visible
before any signature. selectExecutionStrategy() remains the signer-only
compatibility helper. New code should use
selectUserOperationExecutionMethod() so signer and transaction sender remain
separate. An active reviewed session wins in auto mode; otherwise a complete
plan that fits one UserOperation may use the owner, while multi-operation plans
that require multiple UserOperations prefer the reusable session path.
createOwnerKernelClient is the lower-level client over caller-provided
ERC-4337 bundler and optional ERC-7677 paymaster transports. Configured fallback
routes remain provider-neutral; ZeroDev routes retain their sponsorship method,
and UltraRelay retains zero-fee behavior. Use it for custom owner administration
when credentials stay behind an application proxy. It is not a bounded
session-key grant. The same Kernel version pin and account index must match the
existing account.
reconstructActivity scans confirmed block ranges and reconstructs ERC-20 and
native outflows, attributing permission-validated UserOperations through their
nonce-encoded permission ID. Pass the trusted ERC-20 contracts in tokens so
the RPC query and the local decoder reject lookalike Transfer logs from
untrusted contracts. Omitting tokens intentionally retains topic-only
discovery, which can be spoofed by any contract; an empty list disables ERC-20
reconstruction. ERC-20 events expose their source logIndex, while native
calldata-derived events use null.
Pair the scan with findDeployBlock from moesi/resolver; a null deploy
block is inconclusive when archive history is unavailable, so applications
need a visible lookback fallback.
Bundler errors
Owner-signed clients automatically convert failures from
sendUserOperation and waitForUserOperationReceipt into structured
MoesiError instances. For custom UserOperation flows, use the same public
decoder directly:
import { explainBundlerError } from "@moesi/settle-zerodev/tooling";
try {
await client.sendUserOperation(request);
} catch (error) {
const { kind, problem, cause, fix } = explainBundlerError(error);
if (kind === "unknown") {
renderApplicationFallback(cause);
return;
}
console.error({ kind, problem, cause, fix });
}Pass { target, data } as the optional second argument when inner-call
context is available. kind: "unknown" is the stable, documented fallback
contract when no supported bundler signal was classified; branch on kind
rather than matching the human-readable problem. Its default remediation is
context-neutral so CLIs and web applications can supply their own workflow.
AA_ERROR_TABLE and flattenErrorMessages are also public for consumers that
need custom presentation. Diagnostics stay redacted by default. If the failing
route is your own trusted backend, recover its raw REST response before
redaction at that explicit trust boundary:
import { extractHttpErrorBody } from "@moesi/settle-zerodev/tooling";
const response = extractHttpErrorBody(error);
// { status: 409, body: '{"error":"actionable first-party message"}' }response.body is intentionally unredacted; do not log or publish it for
third-party bundler/paymaster transports.
Custom-chain settlement
Custom entries may supply a BYO ERC-4337 bundler, sponsorship mode, and an
independent paymaster. Use self-funded for a funded Kernel, required when a
missing sponsor must block, or optional only when balance/prefund proof may
permit fallback. Tier B can submit
an already signed UserOperation directly through EntryPoint handleOps, so the
operator EOA fronts gas without receiving policy authority.
submitUserOperationViaEntryPoint() reports success only when the canonical
transaction receipt contains exactly one matching, successful EntryPoint v0.7
UserOperationEvent for the computed hash, sender, and nonce. Outer reverts,
inner failures, and missing, duplicate, or mismatched event evidence reject.
getReferenceKernelBootstrapManifest() and bootstrapKernelStack() implement
the Tier-C virgin-chain path. They deploy and byte-attest Nick's deterministic
deployment proxy, CreateX, EntryPoint 0.7 and its SenderCreator, and the
supported Kernel v3 factories, validators, policies, signers, hooks, and
helpers, assembled from the self-contained per-contract manifests in
moesi/registry. EntryPoint 0.6 and Kernel v2-era variants are
deliberately omitted. Moesi's patched WeightedValidator is retained as an
explicit extension. The preflight examines every supported canonical address
before the first transaction; an unexpected predecessor fails closed, while a
predecessor matching the reviewed runtime evidence can be retained. EIP-712
runtime hashes are derived for the target chain ID rather than copied from
Ethereum. For CREATE2
components, manifest validation binds each deploy payload to its canonical
address before RPC. Arbitrary constructor execution means the resulting
runtime cannot be established statically, so each transaction is awaited and
its runtime is byte-attested before bootstrap continues or reports success.
License
Apache-2.0
