fix: sign through an embedded local account, and survive hosts without dynamic import (release 0.1.5) - #84
Conversation
…t dynamic import
Release 0.1.5. Two fixes reported from downstream use.
A viem WalletClient built over a local account lost the fast path
`createWalletClient({ account: privateKeyToAccount(key), ... })` — what
wagmi and viem hand around when the key lives in process — always
satisfies the JSON-RPC structural guard, since it carries signTypedData,
getAddresses and getChainId. It was therefore adapted as a remote wallet
and every L1 action went through generic typed-data encoding, even
though the key was right there. Measured downstream at 2.17x on every
order, with nothing to indicate it was happening.
Re-routing such a client to the local adapter would have been wrong:
that adapter hardcodes getChainId to "0x1" ("local accounts have no
notion of chain"), and getChainId feeds signatureChainId for
user-signed actions, so the EIP-712 domain would have silently stopped
reporting the chain the signature was produced on. Instead the JSON-RPC
adapter now additionally sources `signDigest` from the embedded
account. Typed data, address, chain ID and every cache keep going
through the client, so this is a pure addition: only L1 digest signing
changes, which is exactly where the cost was.
Detection is deliberately narrow — viem's own `type: "local"` marker
plus a usable raw-digest signer. A client over a remote account keeps
signing through the client.
createFastLocalWallet hard-failed where dynamic import is unavailable
Its docstring promised the tiny-secp256k1-missing path was "never a
hard failure", but the fallback does `await import("viem/accounts")`,
which throws outright in hosts that cannot service a dynamic import —
Jest without --experimental-vm-modules, and some React Native bundlers.
The specifier has to stay dynamic: viem is not a dependency of this
package, so a static import would break every consumer that does not
use viem. Callers in such hosts now pass `options.privateKeyToAccount`
instead, which is used on both viem-dependent paths (the fallback and
the lazy signTypedData delegate). When viem is genuinely needed and can
be neither imported nor supplied, the failure now says so and names the
remedy, preserving the host's original error as `cause`, rather than
surfacing an opaque message from deep in the signing path. The
docstrings no longer promise a fallback that cannot be delivered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The
|
| round | measured first | measured second |
|---|---|---|
| 1 (head, base) | 57.15 µs (head) | 39.01 µs (base) |
| 2 (base, head) | 57.75 µs (base) | 40.58 µs (head) |
| 3 (head, base) | 57.91 µs (head) | 41.49 µs (base) |
Whichever revision runs first in a round costs ~57.5 µs and whichever runs second costs ~40.4 µs — independent of which revision it is. It is a position-in-round effect, not a revision effect. The reported band [-29.7%, +177.2%] spans zero for the same reason: the rounds disagree in sign.
Because the alternation is (head, base), (base, head), (head, base), head occupies the slow first slot in two of three rounds, so the median is systematically biased against head. That means this scenario will flag a false regression on most PRs, not just this one.
subscribe_user_trio was added in #83 and is the scenario most exposed to this: its per-subscription figure is over half fixed transport setup cost, so a cold first process in a round dominates it. warmupSamples: 3 warms within a scenario run but not across the two processes a round spawns.
Not fixing it here — that would edit tests/perf, changing the suite fingerprint and turning the gate red for a different reason, and it does not belong in a signing fix. Worth its own PR (adding warmup that survives process start, or reducing the fixed-cost share of the measurement).
Every other check is green: test, quality, docs. 52 of 53 scenarios unchanged, 0 genuinely faster or slower.
Correction to the analysis aboveI attributed the swing to a cold first process in each round. That is wrong — every other scenario is stable across the same two positions, so there is no general cold-start effect. The instability is specific to Same three rounds, first-measured vs second-measured:
Everything else moves a few percent. That does not change the conclusion, and arguably strengthens it:
The follow-up is to find what the scenario's cost depends on across processes and pin it, rather than to widen the threshold or retry the job. Still not doing that here: it would edit |
An adversarial review of c76e21e confirmed three low-severity issues, all in the new viem-resolution path, plus stale comments the routing change invalidated. - The error thrown when `viem/accounts` cannot be loaded asserted one cause ("this environment cannot service a dynamic import") and gave a remedy — install tiny-secp256k1 — that is impossible on one of its two call sites: the `signTypedData` delegate only exists when tiny-secp256k1 already loaded. It now names which path needed viem and offers both real remedies, since viem simply not being installed is the likelier cause. - An injected `options.privateKeyToAccount` was never checked against the key the wallet signs with. On the WASM path `address` and the raw-digest signer come from tiny-secp256k1 over the private key while `signTypedData` delegates to the injected factory, so a factory closed over a different key — `() => myAccount` type-checks — would sign L1 actions and user-signed actions as two different accounts and report only one. The delegate's address is now checked before it is memoized. - `PrivateKeyToAccount` is the declared type of a public option but was not reachable from `@bloxwap/hyperliquid/signing`; it is now re-exported so consumers can name it. - Four comments stated "JSON-RPC wallets never have signDigest", which the embedded-local-account routing makes false. They now distinguish wallets that can only sign remotely. The review also reproduced a split-identity case: because the embedded account is captured when the adapter is memoized while address and chain ID stay live reads, a wallet object whose `account` changes could sign L1 actions with the old key. Left as-is deliberately — verification downgraded it to low after establishing that viem's own bound actions close over the client at creation, so reassigning `client.account` is already a no-op for viem itself, and no real wallet or library produces the dynamic-`account` shape the other direction needs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bumps to 0.1.5. Two fixes reported from downstream use.
1. A viem
WalletClientover a local account lost the raw-digest fast pathcreateWalletClient({ account: privateKeyToAccount(key), … })— what wagmi and viem hand around when the key lives in process — always satisfies the JSON-RPC structural guard, because it carriessignTypedData,getAddressesandgetChainId. So it was adapted as a remote wallet and every L1 action went through generic typed-data encoding, even though the key was right there. Measured downstream at 2.17× on every order, with no warning and no way to notice short of reading the adapter.Why not just re-route it to the local adapter. That adapter hardcodes
getChainIdto"0x1"("local accounts have no notion of chain"), andgetChainIdfeedssignatureChainIdfor user-signed actions — so the EIP-712 domain would have silently stopped reporting the chain the signature was actually produced on.Instead
adaptViemJsonRpcnow additionally sourcessignDigestfrom the embedded account. Typed data, address, chain ID and every cache still go through the client. It's a pure addition: only L1 digest signing changes, which is precisely where the cost was.Detection is deliberately narrow — viem's own
type: "local"marker plus a usable raw-digest signer. A client over a remote account keeps signing through the client.Verified against a real
WalletClient: L1 signature byte-identical to the bare account,account.signcalled whileclient.signTypedDatais called zero times, chain id still0xa4b1rather than0x1, and atype: "json-rpc"client still routed through the client. Four regression tests.2.
createFastLocalWallethard-failed where dynamic import is unavailableIts docstring promised the
tiny-secp256k1-missing path was "never a hard failure", but the fallback doesawait import("viem/accounts"), which throws outright in hosts that cannot service a dynamic import — Jest without--experimental-vm-modules, and some React Native bundlers. Not theoretical: it's why a downstream mobile build was reverted off the fast wallet, and it put the README's React Native claim in question.The specifier has to stay dynamic. viem is not a dependency of this package (only a devDependency), so a static import would break every consumer that doesn't use viem — which is why a blind static import isn't the fix.
Callers in such hosts now pass
options.privateKeyToAccount, used on both viem-dependent paths (the fallback and the lazysignTypedDatadelegate). When viem is genuinely needed and can be neither imported nor supplied, the error names the cause and the remedy and preserves the host's original error ascause, instead of surfacing an opaque message from deep in the signing path. The docstrings no longer promise a fallback that can't be delivered.Note: with
tiny-secp256k1present and only L1 actions signed, viem is never loaded at all — the exposure is the fallback andsignTypedData.Also
The v0.1.4 release notes gained a Behavioural change section recording that
HyperliquidEventTargetstopped being anEventTargetsubclass in that patch release:addEventListener/removeEventListenersemantics are preserved, butinstanceof EventTargetis nowfalseanddispatchEventis gone.Verification
bun run checkexits 0; 1658 pass, 236 skip, 0 fail (+6 tests). Unlike #83 this PR touches no perf scenarios, so the Performance gate should compare cleanly rather than fail closed on a suite-fingerprint change.🤖 Generated with Claude Code