Skip to content

release: to prod#1311

Merged
suisuss merged 29 commits into
prodfrom
staging
May 20, 2026
Merged

release: to prod#1311
suisuss merged 29 commits into
prodfrom
staging

Conversation

@eskp

@eskp eskp commented May 20, 2026

Copy link
Copy Markdown

Summary

Promote the following merged PRs from staging to prod:

Post-deploy verification

  • deploy-keeperhub workflow finishes green
  • curl -fsS https://app.keeperhub.com/api/health returns 200
  • curl -fsS https://app.keeperhub.com/api/mcp/schemas | jq '.actions|keys|map(select(startswith("hyperliquid/")))|length' returns 8 (Hyperliquid plugin live)
  • Hub > Protocols shows the Hyperliquid card with logo + "View Actions" (8 actions in the detail modal)
  • Spot-check executor retry behavior (fix(executor): wait for late-landing step success before nullifying on spurious max-retries #1310) — confirm no spurious nullified step results under fan-in
  • Watch Sentry / logs for ~10 minutes after the rollout

suisuss and others added 25 commits May 19, 2026 14:10
…ain tests

Add tests/integration/_shared/build-calldata.ts exporting buildCalldata
and its Calldata return type. The implementation matches what is
already duplicated across the 4 protocol on-chain integration tests
(wrapped, aave-v4, rocket-pool, uniswap), with two API tweaks:

- chainId is required (passed via an options object) instead of
  captured from module scope, so the helper cannot silently pick the
  wrong chain when a protocol is deployed on multiple.
- toOverride is opt-in via the same options object, used by contracts
  marked userSpecifiedAddress (e.g. Uniswap V3 pools whose address is
  computed per pair).

The next commit converts the 4 call sites to use this module and drops
their local copies. The two-commit split keeps the API addition
reviewable in isolation from the mechanical replacement.
…alldata

Drop the local buildCalldata function from each of the 4 protocol
on-chain integration tests; import from
tests/integration/_shared/build-calldata.ts instead. Each call site
now passes { chainId: CHAIN_ID } as the options object. Uniswap's
toOverride hook is preserved through the shared API for future
userSpecifiedAddress protocols.

Per-protocol assertion models are unchanged. The
expectCallAcceptedByBytecode helper (used by aave-v4 and rocket-pool)
stays local to each file; extracting that is out of scope here.

Verified against real RPC endpoints:
- wrapped:    3/3 pass against Sepolia
- aave-v4:    6/6 pass against mainnet
- rocket-pool: 6/6 pass against mainnet
- uniswap:    11/11 pass via the public-RPC fallback path

Total: 26/26 tests pass; pnpm type-check clean; biome clean on the
buildCalldata-related changes (the 6 remaining biome warnings in
aave-v4 and rocket-pool are pre-existing, all in their local
expectCallAcceptedByBytecode helper which this PR does not touch).
…data

Drop the local buildCalldata function from protocol-chainlink-onchain.test.ts
and import from tests/integration/_shared/build-calldata.ts. Each of the 6
call sites passes { chainId: CHAIN_ID }; the 3 sites that target
userSpecifiedAddress contracts (CCIP-BnM token) additionally pass
toOverride: CCIP_BNM_SEPOLIA.

This is the conversion the original buildCalldata extraction missed.
toOverride was carried through the shared helper's options object but
had no caller in the converted set; chainlink is the only file that
actually exercises it (CCIP token contracts whose runtime address is
supplied per call). With this commit, every parameter of the shared
helper's API has a real caller.

Verified: 6/6 chainlink tests pass against public Sepolia RPC; 32/32
tests pass across all 5 converted protocol on-chain test files
(wrapped, aave-v4, rocket-pool, uniswap, chainlink).
…in test

Superfluid's local buildCalldata diverged from the original 4 in one
load-bearing way: after reshapeArgsForAbi, it ran coerceArgsForAbi to
coerce stringly-typed leaves before encoding. The file's comment was
explicit: 'Reproduce the production pipeline ... same order as
plugins/web3/steps/write-contract-core.ts'. This matters because
ethers.encodeFunctionData treats any non-empty string as truthy, so
the literal 'false' encodes as true without coerce.

Add coerceArgs?: boolean to BuildCalldataOptions, default false. The
default preserves the behavior of the 4 protocols converted previously,
which were verified passing without the coerce step. Superfluid opts
in by passing coerceArgs: true through its callAndDecode and
estimateGasError wrappers.

Why not always coerce: changing the encoding path under the 4 already-
verified protocols would be a behavior change worth its own
verification pass, out of scope here. The latent question -- whether
test/prod encoding divergence in those 4 hides bugs -- is left as a
follow-up.

Verified:
- superfluid: 24/24 pass against public Sepolia RPC (includes
  create-pool, which passes 'false' for two bool inputs and would
  silently encode as true if coerce did not run).
- wrapped/aave-v4/rocket-pool/uniswap: 26/26 pass unchanged
  (coerceArgs defaults off; behavior preserved).
Read-only wrappers over the Hyperliquid Info REST API
(api.hyperliquid.xyz/info) for vault operator reporting and account-state
queries. 8 actions:

- clearinghouse-state  perp account positions, margin, value
- vault-details        vault metadata, leader, portfolio, APR
- validator-summaries  all validators: stake, jailed, uptime
- funding-history      historical funding rates for a coin
- spot-deploy-state    HIP-1 token deployment auction state
- referral             referral status and rewards
- sub-accounts         master user's subaccount list
- active-asset-data    per-coin leverage, sizes, mark price

No credentials -- Info API is public POST JSON. SSRF-guarded via
safeFetch. Shared HTTP helper at steps/info-request-core.ts keeps the 8
step files DRY without exporting from "use step" files.

Deviation from spec: ticket named the 8th endpoint "activeAssetCtx" but
that's a WebSocket subscription type only, not an Info POST endpoint.
The Info POST equivalent (same user+coin context: leverage, max trade
sizes, available balance, markPx) is "activeAssetData" -- shipped as
that.

Smoke-tested: all 8 endpoints return shaped JSON against
api.hyperliquid.xyz.
Review-team fixes for PR #1294 (KEEP-572):

- info-request-core: explicit JSON content-type + parse-error branch.
  Previously a 2xx HTML/empty response would surface as a misleading
  "Network error" via the outer catch; now logs as VALIDATION and
  returns a clear "Invalid JSON response" or "non-JSON content-type"
  message.
- Add shared isEvmAddress(value) helper; replace `if (!input.user)`
  in all six user/vault-address steps with a regex-checked guard.
  Catches "undefined"/"null" template substitutions and malformed
  hex before round-tripping to Hyperliquid.
- funding-history: require startTime/endTime to be positive integers
  (rejects 0, negatives, decimals) and enforce endTime >= startTime.
- active-asset-data: in-code comment explaining the activeAssetCtx
  (WebSocket only) -> activeAssetData (Info POST equivalent) swap.
- test.ts: dedupe HYPERLIQUID_INFO_URL constant by importing from
  info-request-core.
8 tests cover the postInfo HTTP wrapper and isEvmAddress helper:
- 200 + JSON returns {success:true, data}
- non-2xx returns 'HTTP <status>: <body>' + logs EXTERNAL_SERVICE
- 200 + non-JSON content-type rejected + logs VALIDATION
- 200 + malformed JSON body rejected + logs VALIDATION
- thrown error returns getErrorMessage envelope + logs EXTERNAL_SERVICE
- isEvmAddress accepts checksummed and uppercase hex, rejects
  too-short / too-long / missing-prefix / non-hex / non-string inputs
Auto-generated by `pnpm discover-plugins` — was missed in the initial
commit because the explicit staging list omitted this barrel file.
Caught by the `Plugin Discovery Integrity` CI check.
The lazy import in index.ts (`getTestFunction: async () => await import("./test")`)
is meant to keep test.ts off the client bundle, but Turbopack still
statically analyzes the file. When test.ts imports safeFetch
(transitively pulls in node:async_hooks) and info-request-core.ts
(declared "server-only"), the production build errors with:
  the chunking context (unknown) does not support external modules
  (request: node:async_hooks)

Mirror the webhook/discord pattern: use plain `fetch()` for the
connection smoke test, inline the URL constant. The test only ever
runs server-side (called via the dynamic-imported getTestFunction)
and the URL is hardcoded to a known host, so SSRF surface remains
zero. Reverts the I2 review nit that introduced the cross-module
import.
Adds a placeholder `protocols/hyperliquid.ts` so the Hub > Protocols
tab surfaces Hyperliquid alongside Safe, Aave, etc. The plugin in
`plugins/hyperliquid/` still owns the runtime actions (Info REST API);
this entry is metadata-only.

Implementation: a naive `defineProtocol` with the same slug as the
plugin would have the protocol-to-integration shim overwrite the
plugin's 8 actions in the integrationRegistry. To preserve them while
still appearing in Hub:

- Add `hubOnly?: boolean` to ProtocolDefinition. When true:
  - `contracts`, `actions`, `events` must all be empty
  - `discover-plugins` codegen skips
    `registerIntegration(protocolToPlugin(...))`
  - The protocol registers in protocolRegistry (Hub) only

When real HyperEVM on-chain actions land, drop the `hubOnly` flag and
add real contracts/actions here.

Verified:
- /api/protocols returns hyperliquid (Hub card renders)
- /api/mcp/schemas still returns all 8 hyperliquid actions
- Total action count steady at 420; integration count 31
Stylized H + waveform mark in Hyperliquid's mint-teal (#97FCE4) on
dark green, rounded-corner 256x256 PNG. Matches the existing protocol
icon convention at public/protocols/{slug}.png.

Replace with the official Hyperliquid wordmark when brand-asset
clearance is available.
Previous commit shipped a hand-rendered H+waveform placeholder. This
is the real brand mark sourced from
https://app.hyperliquid.xyz/apple-touch-icon.png (180x180 RGBA),
resized to 256x256 to match the other protocol icons.
Hyperliquid is a hubOnly protocol (no on-chain actions yet) whose 8
runtime actions live in the `hyperliquid` plugin. The Hub card showed
"Coming soon" and the detail modal "Actions (0)" because both only
read protocol-registry actions.

- _protocols-tab.tsx: `withPluginActions` enriches hubOnly protocols
  server-side by reading `getIntegration(slug)` and mapping the
  plugin's actions into the protocol's `actions` array (slug, label,
  description, type "read", required inputs derived from configFields).
  The synthesized slugs match the plugin action ids, so "Use in
  Workflow" resolves to e.g. hyperliquid/clearinghouse-state.
- protocol-card-v2.tsx: drop the border-t divider above the action
  row for a cleaner card.

Result: Hyperliquid card reads "View Actions"; detail modal shows
"Actions (8)" with each action's READ badge, description, and inputs
(e.g. "user (address)"), and "No inputs required" only for
validator-summaries.
Superfluid's original local buildCalldata appended 'and no override
given' to the missing-address error so a contributor debugging a
userSpecifiedAddress contract on a chain that lacks a default address
got an immediate hint to pass toOverride. The shared helper dropped
that suffix during extraction. Restore it.
The helper has branching (coerceArgs gating, toOverride fallback) and
error paths that were only exercised transitively through 6 integration
tests requiring a live RPC. With most of those integration suites
gating on RPC env vars not exposed to CI, the helper's logic had no
direct coverage in the test-unit job.

Add 12 unit tests covering:
- toOverride takes precedence over addresses[chainId]
- toOverride supplies the address for userSpecifiedAddress contracts
- calldata encoding matches a direct ethers.Interface encode
- returned action and contract match what callers assert on
- coerceArgs: false documents the 'false' -> true trap that motivates the option
- coerceArgs: true preserves boolean 'false' and 'true' values
- error: unknown action slug
- error: contract with no ABI
- error: no address and no toOverride (includes the restored hint suffix)

Tests pass an explicit coerceArgs value rather than relying on the
default, so they survive a future default flip without rework.
…n spurious max-retries (KEEP-576)

Under fan-in the Workflow DevKit loses a step's completion event and
re-fires it (despite httpRequestStep.maxRetries=0), throwing
`Step "...httpRequestStep" exceeded max retries (1 retry)`. The catch
handler's spurious-retry reconciliation reads getCompletedStepOutput once,
but the real step body's success row is committed ~0.3-0.5s LATER than the
throw. The one-shot read misses, recovery is skipped, the node is nullified,
and convergence is unblocked early -- so a downstream combine resolves
{{@bungee:Bungee}} to null and aborts with "Node X produced no data".

Confirmed on prod: ~2/6 runs of the forked Across+Bungee bridge optimizer
reproduced it after the earlier in-memory-preserve change shipped; bungee
logged status=success ~0.5s AFTER combine errored.

Add a bounded poll: on a spurious-retries miss, await the recorded success
for up to 3s (250ms interval) inside a step boundary (DB-backed, replay-safe
via memoization) before falling back to nullify. Extracts the pure poll loop
(pollForCompletedOutput) into its own dependency-free module so it imports
cleanly into the workflow bundle and is deterministically unit-testable with
an injected clock.

Tests: poll-for-completed-output.test.ts covers first-hit (no sleep),
recovery after several misses (the late-landing case), timeout->null, and
one-shot (timeoutMs=0). Full unit suite green.
…sing function

Previously JSON.parse(contract.abi) returned any and the .find predicate
was the only thing giving the result a shape. That hid a real bug:
.find() returns undefined when the action.function doesn't exist in the
ABI, and the undefined fell through to reshapeArgsForAbi /
coerceArgsForAbi with no clear error.

Export FunctionAbiEntry from lib/abi/struct-args.ts and use it as the
basis for an AbiEntry shape that JSON.parse is cast to. TypeScript now
surfaces the undefined case, so add an explicit throw with a clear
contract+function reference for the caller.

Add a unit test exercising the new throw path.
Previously, sampleInputs[inp.name] silently substituted '' for any
typo'd key because the helper iterated only over declared action.inputs.
A test like { tokenID: '1' } (capital D) instead of { tokenId: '1' }
would encode tokenId as empty string and either revert at the contract
or encode bogus zero-padded data, with no hint that the test fixture
was the bug.

Validate the sampleInputs object up front. If any key is not in
action.inputs, throw with a clear message listing the declared input
names. Place the check after the action lookup so the error
references a real action; before the ABI check so an unrelated ABI
problem does not mask an input typo.

Verified: 56/56 integration tests still pass (no existing call site
passes undeclared keys); 2 new unit tests cover the throw paths.
The original opt-in default was 'safer' only in the sense of preserving
the verified-passing state of the 4 first-converted suites. But 'passing'
and 'encoding the same calldata production would' are different things:
none of those 4 currently has a bool fixture input, so coerce vs no-coerce
is observationally indistinguishable for them today. The day someone
adds an action with a bool input and writes a fixture like
{ ..., autoCompound: 'false' }, the test will silently encode true (the
foot-gun coerceArgs exists to prevent) and pass against a chain that
does not care.

Default-off codified that latent foot-gun. Flip to default-on so tests
match the production write pipeline by default; pass coerceArgs: false
only to deliberately exercise the un-coerced shape (e.g. a regression
test pinning the trap).

Mechanically: change the gate from 'options.coerceArgs ? coerce : reshape'
to 'options.coerceArgs === false ? reshape : coerce' (undefined now
means coerce).

Drop the now-redundant coerceArgs: true from superfluid's wrappers;
the helper's doc comment carries the intent.

Verified: 56/56 integration tests still pass across all 6 converted
protocols (wrapped, aave-v4, rocket-pool, uniswap, chainlink, superfluid)
under the new default. None of the 4 first-converted protocols has bool
inputs in its current fixtures, so the flip is observationally a no-op
for them today; the point is to keep it that way as new fixtures land.
…plugin

feat: add Hyperliquid Info plugin (KEEP-572)
The previous signature was buildCalldata(protocol, slug, sampleInputs,
options) - three positional args plus an options object. The first
three positions were easy to misorder, since the (slug, sampleInputs)
pair is both vaguely 'the action description' and TypeScript's only
guardrail was the slug being a string vs sampleInputs being a record.

Move to a single params object: buildCalldata({ protocol, actionSlug,
sampleInputs, chainId, toOverride?, coerceArgs? }). Trade-off: call
sites are wider; benefit: every argument is named and labelled at the
call site, and adding future options (e.g. function-name override,
custom encoder) does not change positional shape.

Renamed BuildCalldataOptions -> BuildCalldataParams to reflect that the
type now includes required fields.

Verified: 16/16 unit tests pass; 56/56 integration tests pass across
all 6 converted protocols.
…y-race

fix(executor): wait for late-landing step success before nullifying on spurious max-retries
Three of the four protocol on-chain tests this PR touched still gated
on INTEGRATION_TEST_RPC_URL / INTEGRATION_TEST_MAINNET_RPC_URL via
`describe.skipIf(!RPC_URL)`. Neither secret is exposed to CI's
test-integration job (only CHAIN_RPC_CONFIG is), so all three skipped
silently -- meaning CI never actually exercised the shared
buildCalldata helper for wrapped, aave-v4, or rocket-pool.

Switch to the same 3-tier resolution the uniswap test already uses:
CHAIN_RPC_CONFIG -> individual CHAIN_*_RPC env vars -> public RPC
default. Drop the skipIf and the `if (!RPC_URL) return;` guard in
beforeAll. Tests now always run.

For mainnet (aave-v4, rocket-pool) the public default is
PUBLIC_RPCS.ETH_MAINNET / ETH_MAINNET_FALLBACK so the tests don't
require any env vars at all to function -- CI's CHAIN_RPC_CONFIG just
swaps in the paid staging endpoints transparently.

Verified locally with no env vars set: 15/15 pass (3 wrapped + 6
aave-v4 + 6 rocket-pool) in ~34s against public RPCs only.
Previous patterns only matched two specific filenames and one wildcard.
Tests with new output filenames (like test-onchain-output.txt) slipped
past. Replace with two broader globs covering anything in .claude/ that
ends in -output.txt or starts with test-*-output.txt.
Same fix as 0ab1d84 applied to two more files that gated on
INTEGRATION_TEST_RPC_URL: switch to the parseRpcConfig /
createRpcUrlResolver pattern, drop describe.skipIf and the
`if (!RPC_URL) return;` guard, fall back to PUBLIC_RPCS.SEPOLIA when
no env vars are set. Tests now always run.

For protocol-superfluid-onchain.test.ts, only the outer "Superfluid
on-chain integration" describe was gated. The second describe in the
same file ("DISPATCH_FAILURE_RE shape") was already ungated; that
behavior is unchanged.

Not included (and won't be by this same pattern):

- tests/integration/protocol-coverage/aave-v3/sepolia/coverage.test.ts
- tests/integration/protocol-coverage/superfluid/sepolia/coverage.test.ts

These don't use the RPC URL directly. They gate on SEPOLIA_RPC_URL as
a proxy for "the full E2E env is up" -- the tests themselves hit
PROTOCOL_E2E_BASE_URL (default http://localhost:3000) and wait on
real workflow executions (180s timeouts). Ungating them via
CHAIN_RPC_CONFIG would not make them runnable on CI; it would just
shift the failure mode from "skipped" to "could not reach localhost".
Standing CI up for those requires starting the keeperhub server in
the test-integration job, which is a separate change.

- tests/integration/eip7702-spike.test.ts

Three describes gate on PARA_API_KEY / PIMLICO_API_KEY -- third-party
service credentials, not RPC URLs. Out of scope for the
CHAIN_RPC_CONFIG migration.

Verified locally: 32/32 pass (8 web3-write + 24 superfluid) in 15.47s
against public RPCs only.
…alldata

refactor: KEEP-579 extract buildCalldata from protocol on-chain integration tests
…-tests

test: KEEP-579 unblock onchain integration tests on CI
…1307)

protocol-frax-ether-v2-onchain.test.ts was added by KEEP-578 after the
KEEP-579 PRs were already in review. It used the same two anti-patterns
KEEP-579 was eliminating: a local copy of buildCalldata, and
describe.skipIf(!RPC_URL) gating on INTEGRATION_TEST_MAINNET_RPC_URL.

Net effect: the file was skipping silently on every CI integration run
because the test-integration job does not expose INTEGRATION_TEST_*_RPC_URL
secrets - only CHAIN_RPC_CONFIG. The CI green checkmark hid 4 unrun
encoding assertions.

Apply the same mechanical conversion KEEP-579 did for the other 6
protocol on-chain tests:
- drop the local buildCalldata, import from tests/integration/_shared
- pass { protocol, actionSlug, sampleInputs, chainId } to the shared
  helper (which now also runs coerceArgs by default for production parity)
- replace getRpcUrlByChainId with the parseRpcConfig + createRpcUrlResolver
  pipeline, falling back to PUBLIC_RPCS.ETH_MAINNET when no env vars are set
- drop describe.skipIf(!RPC_URL) and the matching beforeAll guard

Verified locally with no env vars set: 4/4 pass in 5.39s against the
public Ethereum mainnet RPC fallback. type-check clean.
…chain

test: unblock frax-ether-v2 onchain test on CI
@suisuss suisuss merged commit aebbed7 into prod May 20, 2026
43 of 44 checks passed
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.

2 participants