feat(apis/vara-eth): 0.5.0-rc.0 — helpers, streams, typed errors - #2483
feat(apis/vara-eth): 0.5.0-rc.0 — helpers, streams, typed errors#2483ukint-vs wants to merge 12 commits into
Conversation
Land the upstream-library half of the Vara.eth wallet plan:
Phase 0
- scripts/poc-wallet.ts — end-to-end Phase 0 gate script.
Run with `yarn poc:ethexe --eth ... --ws ... --router ... --key ... --wasm ...`
against a local `ethexe run --dev` devnet. Performs:
upload -> create -> send-injected -> wait promise -> print reply.
Not CI-gated (no Anvil/ethexe in CI); `yarn typecheck:poc` covers
type-level drift against the lib surface.
- src/util/viem-fork.ts — `assertViemFork()` runtime probe invoked at
the entry of `RouterClient.requestCodeValidation*` paths (and
`prepareAndSignRequestCodeValidationPermitData`). Read-only consumers
never trigger the check. Throws `ViemForkRequiredError` with a clear
remediation message when the installed viem isn't the
`@vara-eth/viem` fork.
- test/unit/injected-signing.fixture.test.ts — P0c golden fixture.
Pins preimage byte layout, keccak256 hash, blake2b messageId, and the
deterministic ECDSA signature against known inputs (Anvil #0 key,
zeroed value, fixed reference block + salt). Locks `InjectedTx` byte
layout against drift from the Rust verifier in
`ethexe/common/src/injected.rs`.
Phase 1 (upstream helpers, narrow per CEO/eng review)
- src/api/programs/{deploy,send-and-wait,index}.ts — `api.programs.deploy`
and `api.programs.sendAndWait`. The deploy helper covers WVARA permit
signing, requestCodeValidation, CodeGotValidated wait, and the right
createProgram* variant. sendAndWait supports both eth and injected
rails behind `options.via`, returning a uniform shape with parsed
`ReplyCode` regardless of path.
- src/api/fees/{estimate,index}.ts — `api.fees.estimate(op)` for
sendMessage / sendReply / claimValue / uploadCode / createProgram.
- src/errors/vara-eth-error.ts — typed-error taxonomy at PUBLIC API
boundaries. Wallet-only errors stay in vara-wallet by design.
- src/signer/adapters/local.ts — `LocalSigner` + `privateKeyToLocalSigner`.
Thin viem WalletClient wrapper, ~50 LOC. Suitable for scripts and
CLI/agent flows; browser dApps keep using walletClientToSigner.
- src/programs/idl/extract.ts — Sails IDL extractor.
Pure WASM custom-section parser; accepts both `sails_idl` and
`sails-idl` naming. Returns null on missing or malformed bytes;
`extractSailsIdlOrThrow` for strict callers.
- src/eth/ethereumClient.ts — no API change (existing `signer` getter
is what helpers use to construct ad-hoc MirrorClients).
Bug-fix-in-passing
- Narrow `feeHistory.baseFeePerBlobGas` access in router.contract.ts
via unknown cast. The @vara-eth/viem fork populates the field but
upstream viem types don't declare it. Was emitting a TS warning on
every build.
Tests: 30/30 unit pass.
Build: ESM + CJS clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sweep after the three review agents: - **Tautological golden fixture (BUG)**: the P0c test computed EXPECTED_HASH from `preimageBytes()` then asserted `bytesToHex(keccak_256(preimageBytes())) === EXPECTED_HASH` — a self-consistency check, not a golden fixture. Any change to `preimageBytes()` would update both sides simultaneously and the test would still pass. Replaced with hardcoded literals for hash / messageId / signature / address. This is now an actual cross-impl drift gate. - **Lying error fallback (BUG)**: `PromiseSignatureInvalidError` accepted `'0x0' as Address` when the validator address was unknown — a fake value masquerading as a real one. Made `recoveredAddress: Address | undefined` and pass through; error message branches on presence. - **Reuse**: route injected path in `sendAndWaitForReply` through `api.createInjectedTransaction` (the single official factory) instead of `new InjectedTx(...)`. Pass a `CreateInjectedTransaction` callback into `ProgramsNamespace` to avoid a back-ref cycle to `VaraEthApi`. - **Reuse**: import `ZERO_ADDRESS` from `util/constants.ts` and add `ZERO_BYTES32` there too. Drop the duplicates in `fees/estimate.ts`. - **Dead code**: drop `MessageRevertedError` — declared, exported, never thrown anywhere. The pre-existing `decodeContractError` already returns a plain `Error` with the decoded revert reason. Re-add when we wire it up. - **Auto-set error name**: `VaraEthError` base now sets `this.name = new.target.name` instead of 10 hand-written `this.name = …` overrides in each subclass. Removed all 9 overrides. - **Typed error codes**: extracted `VaraEthErrorCode` `as const` map + derived union type. `VaraEthError.code` is now typed against the union, so callers' `switch (err.code)` is compile-time checked. - **Efficiency**: in `deployProgram`, overlap the executable-balance permit signing with the `CodeGotValidated` wait via `Promise.all`. The two are independent (permit only needs amount + deadline; wait dominates with chain latency), so the signing round-trip happens for free. - **Efficiency**: in `estimateFee`, parallelize `pc.getBlock` with `pc.estimateGas` (state-changing branches) and with the WVARA fee reads (uploadCode branch). Saves one RPC round-trip per call. - **DRY**: `deployProgram` now uses a `signPermit(amount)` local closure instead of repeating `wvara.prepareAndSignPermitData(router.address, ..., deadline)` at two call sites. - **Cleanup**: drop the four unrelated re-exports (`Address`, `Hash`, `Hex`, `TransactionRequest`) from `signer/adapters/local.ts`. Consumers should import these from `viem`. - **Cleanup**: extract `makeNoEthClientProxy(namespace)` from `api/api.ts`'s inline `new Proxy(...)` for `programs`/`fees` stubs. - **Comments**: drop the `// --- Step N:` narrative comments from `deploy.ts`. The function docblock already enumerates the steps. Tests: 30/30 unit pass. Build: ESM + CJS clean. Typecheck: poc-wallet.ts clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add typed `api.stream` namespace wrapping viem's watchContractEvent and watchBlocks. Three methods: - programEvents(mirror, handlers, opts?) — emits all 14 Mirror events as a discriminated union (Message, Reply, StateChanged, ValueClaimed, etc.). - routerEvents(handlers, opts?) — emits all 13 Router events. - blocks(handlers, opts?) — emits StreamedBlockHeader on new L1 blocks. Each event carries EventMeta (blockNumber, blockHash, txHash, txIndex, logIndex). Re-connection and polling fallback are handled by viem internally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three pre-existing packaging defects surfaced when wiring @vara-eth/api into
vara-wallet (CommonJS module mode). All three are necessary for CJS consumers
to require('@vara-eth/api') successfully.
1. Add main + types fields. Node's node10 resolution (the default for
moduleResolution: "node") doesn't honour the `exports` field; without
`main`, it falls back to looking for `index.js` at the package root and
fails.
2. Write a {"type":"commonjs"} package.json into lib/cjs/ during build. The
root package.json declares "type":"module", so Node treats every .js in
the package as ESM by default — including the require()-based files in
lib/cjs/. The nested package.json scopes the CJS subtree back.
3. Repoint subpath exports' `require` field to the lib/cjs/*.js files that
actually exist. The previous entries pointed at lib/<subpath>/index.cjs
files that the build doesn't produce.
Also re-export the signer subpath from the root so LocalSigner is reachable
via `import { LocalSigner } from '@vara-eth/api'` (matches existing pattern
where contracts/errors/programs are root-exported alongside their subpaths).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract buildMeta into a shared buildEventMeta in stream/types.ts. The Mirror and Router decoders were carrying byte-identical copies. - Merge WatchProgramEventsOptions and WatchRouterEventsOptions into a single WatchEventsOptions; both interfaces only declared `fromBlock?: bigint`. - Drop the documented-but-unwired `onReconnect` handler from StreamHandlers — viem's watchContractEvent manages its own reconnection internally; the field was a leaky abstraction that promised behaviour the runtime never delivered. - Check `eventName` before allocating `meta` so unknown events don't pay the cost of building a 5-field object. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request updates the @vara-eth/api library to version 0.5.0-rc.0, introducing several major features including the api.stream namespace for typed event subscriptions, high-level program deployment and message handling via api.programs, and fee estimation through api.fees. It also adds a LocalSigner for private key operations, a Sails IDL extractor for WASM binaries, and a robust typed error taxonomy. Review feedback highlights the need for better resource cleanup in timeout-wrapped promises to prevent leaks and suggests adjustments to the block streaming logic to correctly support pending blocks by allowing nullable block numbers and hashes.
| async function withTimeout<T>(promise: Promise<T>, ms: number, txHash: Hex): Promise<T> { | ||
| let timer: ReturnType<typeof setTimeout> | undefined; | ||
| try { | ||
| return await Promise.race([ | ||
| promise, | ||
| new Promise<T>((_, reject) => { | ||
| timer = setTimeout(() => reject(new PromiseTimeoutError(txHash, ms)), ms); | ||
| }), | ||
| ]); | ||
| } finally { | ||
| if (timer) clearTimeout(timer); | ||
| } | ||
| } |
There was a problem hiding this comment.
The withTimeout helper uses Promise.race, which does not cancel the underlying promise if a timeout occurs. In sendViaEth and sendViaInjected, the promises returned by waitForReply() and sendAndWaitForPromise() likely involve active event listeners or RPC subscriptions. If a timeout happens, these listeners may remain active in the background, leading to resource leaks over time. Consider implementing a cancellation mechanism using AbortController or ensuring the TxManager provides a way to clean up listeners when the wait is abandoned.
| return publicClient.watchBlocks({ | ||
| blockTag: options.includePending ? 'pending' : 'latest', | ||
| onBlock: (block) => { | ||
| if (block.number === null || block.hash === null) return; // pending blocks lack canonical IDs |
There was a problem hiding this comment.
This filtering logic prevents the includePending option from working as intended. Pending blocks typically have a null block number (and sometimes a null hash). By returning early here, the stream effectively suppresses all pending blocks even when the user explicitly requests them. This check should be adjusted to allow null values when options.includePending is enabled.
| if (block.number === null || block.hash === null) return; // pending blocks lack canonical IDs | |
| if (!options.includePending && (block.number === null || block.hash === null)) return; |
| number: bigint; | ||
| hash: Hex; |
There was a problem hiding this comment.
Three functional regressions surfaced by `/codex review` (every helper threw
on first call) and three deploy-ceremony reliability bugs from internal eng
review. All six now fenced by unit tests in test/unit/.
Codex P1s (broken on first call):
- 1.1 sendAndWait via=eth now calls tx.send() before tx.setupReplyListener().
Was throwing "No transaction hash available. Call send() first." every time.
- 1.2 LocalSigner routes through viem's custom({ request: publicClient.request })
instead of casting the constructed transport. Constructor no longer crashes
with "parameters.transport is not a function".
- 1.3 viem-fork.ts dropped the top-level `import { createRequire } from
'node:module'` that broke browser bundles. Probe now gates on
process.versions.node and accesses require via opaque indirection
(Function('return require')) so bundlers don't try to resolve node:module.
Deploy-ceremony fixes:
- 2.1 deployProgram now bounds the CodeGotValidated wait with
codeValidationTimeoutMs (default 120s). On expiry throws
CodeValidationTimeoutError carrying { codeId, txHash, timeoutMs } so callers
can resume by feeding codeId into createProgramBuilder out-of-band.
- 2.2 executable-balance permit is signed AFTER CodeGotValidated resolves
with a fresh now-based deadline. The previous parallel-signing optimization
shared one 5-min deadline across both permits and caused
createProgramWithExecutableBalance to revert on long validator waits,
burning code-validation fees.
- 2.3 InjectedTx.setReferenceBlock(suppliedHash) pre-checks the supplied hash
against the chain head and throws InjectedTxStaleError if outside the
32-block validity window. Transient RPC failure falls through to signing.
Tests: 50 passing (40 prior + 10 fence). Each fix has a unit test that fails
before the fix and passes after.
CHANGELOG: reconciled to drop the never-implemented MessageRevertedError and
StreamHandlers.onReconnect entries; added CodeValidationTimeoutError and the
new codeValidationTimeoutMs option.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix-up pushed — addresses 6 blockers from
|
…mmit Dedup the `Promise.race`-with-timeout pattern duplicated across `send-and-wait.ts` and `deploy.ts` into a single generic `util/promise.ts#withTimeout` helper. Callers pass an error factory so the thrown error keeps call-site-specific context (txHash, codeId). Parallelize the staleness pre-check in `InjectedTx.setReferenceBlock` — supplied-block lookup and head-number lookup now race via `Promise.all`, saving one RTT on the in-window success path. Narrow the surrounding catch so `TypeError` / `RangeError` aren't swallowed (programmer bugs shouldn't masquerade as transient RPC failures). Trim the `CodeValidationTimeoutError` message — the recovery procedure moves to the JSDoc where it belongs; the message stays scannable. Fix the misleading `permitDeadline` JSDoc on `DeployProgramOptions`: the override applies to BOTH permits (not just the code-fee one), each signed independently, sharing one absolute deadline only safe when the validator wait is short. Drop unused `log` parameter from the send-and-wait test mock factory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-verified PR #2483 claims against the actual ethexe sources in the gear repo. The cryptography and protocol primitives (preimage layout, keccak/blake2b hashing, EIP-191 path, 32-block validity window, RPC method names, server timeout, EIP-7594 sidecar use) all matched. Three user-facing semantics were off; fixing each: 1) `deployProgram` over-permitted by `extraFee`. The direct `Router.requestCodeValidation` variant charges only `baseFee` (Router.sol:558). `extraFee` is for `requestCodeValidationOnBehalf` (Router.sol:644). The helper signed the permit for `baseFee + extraFee`, leaking an unused allowance to the router. Now signs for `baseFee` only. Same correction in `api.fees.estimate({ type: 'uploadCode' })` and in the JSDoc on `RouterClient.requestCodeValidation`. 2) `sendAndWait({ via: 'injected', value: > 0n })` would hit an opaque bad-request RPC error from ethexe-rpc relay (relay.rs:47-56), which refuses non-zero value on the injected path. Pre-validate client-side and throw with a clear message before signing. 3) `SendAndWaitOptions.recipient` documented zero-address as "broadcast / any validator picks up." Actual server behavior at relay.rs:58-60 + :97-113 is "auto-route to the next-slot producer" via `calculate_next_producer`. Fixed the JSDoc; the runtime behavior was already correct. Also clarified `PermitExpiredError` JSDoc: `Router.requestCodeValidation` wraps `WVARA.permit()` in `try {} catch {}` (Router.sol:559), so an expired permit doesn't revert at the permit step when the user already has allowance — `transferFrom` failure is the real signal. Added fence test for the value-reject path. 51/51 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three concrete refactors from the parallel /simplify review pass over main..HEAD. The other findings were triaged out: pre-existing code outside this branch's scope, intentional design choices fenced by tests, or reviewer misreads of Fix 2.2 (parallelizing the executable-balance permit with the validator wait would re-introduce the bug it fixes). 1) deploy.ts: extract `makePermitDeadline()` local so the `options.permitDeadline ?? now+5min` expression isn't written twice. Names the freshness intent — each permit gets `now` again at signing time, never a stale shared deadline. Stale class docstring updated. 2) stream/types.ts: extract `decodeEventHeader(log)` shared by `decodeProgramLog` and `decodeRouterLog`. Reorders the checks so `eventName` short-circuits BEFORE `buildEventMeta` runs — unknown logs no longer pay the metadata assembly cost. 3) injected/tx.ts: drop the third RPC `getBlock(headNumber)` in `setReferenceBlock`'s stale-path error construction. The error message now carries the head BLOCK NUMBER instead of the head block HASH — same diagnostic value, one fewer round-trip on the error path. `InjectedTxStaleError` constructor signature changed accordingly (was `currentBlock: Hex`, now `currentBlockNumber: bigint`). 51/51 unit tests pass. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MirrorClient.sendMessage and sendReply now catch viem's ContractFunctionRevertedError at simulation time, decode the revert selector against the Mirror + Router ABIs, and re-throw MessageRevertedError carrying the decoded ErrorName(args) reason plus a functionName discriminator. Bumps to 0.5.0-rc.1. Closes follow-up #8 in vara-wallet/docs/vara-eth-followups.md — wallet consumers can now branch on err.code === 'MESSAGE_REVERTED' instead of regex on .message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps
@vara-eth/api0.4.0 → 0.5.0-rc.0. Lands the lib-side primitives a full-featured wallet — dApp or CLI — needs, without breaking the adapter-shaped contract. The lib still doesn't hold keys; consumers pass anITransactionSignerand pick their rail.What this gives consumers
Two signer rails, one API
The lib never sees a private key on the dApp rail; on the CLI rail the key custody choice lives entirely in the consumer (keystore, mnemonic, Ledger — all out of scope here by design).
Helpers that collapse multi-step ceremonies
api.programs.deploy(code, opts)— WVARA permit →requestCodeValidation→ boundedCodeGotValidatedwait → fresh executable-balance permit (if requested) →createProgram*variant pick → result.api.programs.sendAndWait(mirror, payload, opts)— on-chainMirror.sendMessage(defaultvia: 'eth') or off-chain injected viainjected_sendTransactionAndWatch. Uniform{messageId, reply, txHash, validator?}result.api.fees.estimate(op)— viem-backed gas + WVARA fee preview before submit.api.stream.{programEvents,routerEvents,blocks}(handlers, opts?)— typed discriminated-union event streams over viem'swatchContractEvent/watchBlocks.extractSailsIdl{,OrThrow}— pure WASM custom-section parser, tolerates bothsails_idlandsails-idlnames.Typed errors at public boundaries
VaraEthErrorbase + named subclasses for everything callers might want to branch on:ViemForkRequiredError,InjectedTxStaleError,PromiseTimeoutError,PromiseSignatureInvalidError,PermitExpiredError,BlobUnderpricedError,CodeValidationTimeoutError,NoSailsIdlError,RpcConnectionError,ChainIdMismatchError. Internal throw sites untouched — wallet-side errors (WalletLockedError, …) stay in the CLI by design.What changed under the hood
Phase 0 — gates + packaging
scripts/poc-wallet.tsruns the full ceremony againstethexe run --dev(yarn poc:ethexe).yarn typecheck:pocruns in CI on every PR so the script can't silently rot when lib types change.assertViemFork()runtime check atRouterClient.requestCodeValidation*entry points only. EIP-7594 blob upload requires@vara-eth/viem@2.48.11; upstream viem tripsViemForkRequiredErrorwith a remediation message. Read-only consumers never hit the check.lib/cjs/now ships{"type":"commonjs"}so Node doesn't read itsrequire()-based files as ESM. Subpathrequireexports point at real built files. Top-levelmain+typesadded.Phase 1 — helpers + typed errors + Sails IDL
See "What this gives consumers" above.
Phase 2 — event streams
Thin discriminated-union wrappers over viem watchers — reconnection delegated to viem.
Review history
Two independent reviews on the first push (
d76776ae) — Claude eng-review +/codex review— found 6 disjoint blockers. ~10% agreement rate, because there were almost zero unit tests for the new helpers and each reviewer caught different classes of bug.3 P1 regressions (broke on first call, found by codex):
sendAndWait(via:'eth')was missingtx.send()betweensendMessageandsetupReplyListener— the listener throws immediately without it.LocalSignercastpublicClient.transporttoTransport, but viem treatstransportas a factory. Replaced withcustom({ request: publicClient.request }).viem-fork.tsdid a top-levelimport { createRequire } from 'node:module', which poisons browser bundles since the module is reachable from the root@vara-eth/apiindex. Moved the import behind a Node-runtime guard withFunction('return typeof require !== "undefined" ? require : null')()for opaque indirection.3 deploy-ceremony reliability bugs (eng-review):
deployProgramhad no timeout onwaitForCodeGotValidated()— would hang forever if validators stalled. Now bounded bycodeValidationTimeoutMs(default 120 s); on expiry throwsCodeValidationTimeoutErrorcarryingcodeId+txHashso the caller can resume the second half out-of-band.createProgramWithExecutableBalancewould silently revert. Now signed AFTERCodeGotValidatedresolves, with a freshnow-based deadline.InjectedTxStaleErrorwas declared but never thrown. Now thrown fromInjectedTx.setReferenceBlock(supplied)when the supplied block is outside the 32-block validity window. Transient RPC failures fall through silently; programmer errors (TypeError/RangeError) are re-thrown.Each bug now has a fence test in
test/unit/. CHANGELOG reconciled (removed staleMessageRevertedErrormention, addedCodeValidationTimeoutError+codeValidationTimeoutMs). Post-review/simplifypass dedup'd thePromise.race-with-timeout pattern intosrc/util/promise.ts#withTimeoutand parallelized the staleness pre-check.Test plan
yarn workspace @vara-eth/api typecheck:pocpassesyarn workspace @vara-eth/api test:unit— 50 tests pass (40 pre-existing + 6 fence tests for the blockers + 4 across IDL/local-signer fixtures)yarn workspace @vara-eth/api build— both ESM and CJS outputs producedyarn workspace @vara-eth/api poc:ethexeagainstethexe run --devNotes for reviewers
LocalSigner,privateKeyToLocalSigner,WalletClientAdapter,walletClientToSigner,ProgramsNamespace,FeesNamespace,StreamNamespace, all typed errors,extractSailsIdl{,OrThrow},watchProgramEvents/watchRouterEvents/watchBlocks,buildEventMeta.sendAndWaitdefaults to broadcast (zero-address recipient); pool round-robin is opt-in only — slot-based selection is heuristic and worse than broadcast under clock skew / reorgs / validator-set transitions.🤖 Generated with Claude Code