KeeperHub / KeeperHub/keeperhub
An Event trigger the event tracker refuses is indistinguishable from one that is waiting
- Dominant language
- TypeScript
- Stars
- 24
- Forks
- 93
- Avg merge
- 1d 4h
- Merged PRs (30d)
- 253
Description
### Before filing
- [x] I searched open and closed issues for this proposal.
- [x] I checked the docs and the current behaviour on `staging`.
- [x] This is one change, not several.
## Reason: what you cannot do today
There is no way to find out whether an Event trigger will ever fire, short of enabling the workflow and waiting.
That matters because the event pipeline declines a malformed Event trigger silently. `buildRegistration` (`keeperhub-events/event-tracker/src/listener/workflow-mapper.ts`) returns `null`, and the caller skips the workflow, at nine separate points: no `config.network`, a non-numeric chainId, a chainId absent from the chains table, a chain whose `defaultPrimaryWss` is null or not a WebSocket URL, a
missing `contractAddress`, a missing `eventName`, a missing `contractABI`, a `contractABI` that is not valid JSON, one that is not an array, and one that contains no event fragments. `EventListener.start` (`.../listener/event-listener.ts:154-169`) throws at a tenth: an `eventName`
the ABI does not declare.
Every one of those emits a `logger.warn` inside the event-tracker pod. None of them reaches the user. The workflow reports Enabled and never runs, which is exactly what a correctly configured trigger waiting for a rare event also looks like.
The `defaultPrimaryWss` case is the one I would single out: it is a property of the chain, not of anything the user typed, so no amount of re-reading the trigger config reveals it. `chains.default_primary_wss` is nullable (`lib/db/schema.ts:1216`), and an Event trigger on such a chain cannot fire at all.
Nothing existing covers this. `runWorkflowSimulation` (`lib/workflow/run-simulation.ts`) only simulates `web3/transfer-funds`, `web3/transfer-token` and `web3/write-contract`, and skips every node that is not one of those three, triggers included.
There is a second, opposite failure with the same root cause: because there is no way to see a trigger's match rate before enabling it, an Event trigger pointed at a busy event (an ERC-20 `Transfer` on a major token) starts a billed workflow execution per matching log. The first signal today is the bill.
## Reason: what the workaround costs
For the "never fires" case there is no workaround inside the product. The diagnosis is done outside it: take the contract address and ABI to a block explorer or an `eth_getLogs` call and check by hand whether the event exists, whether the address holds a contract on that network, and whether it has been emitted recently. The `defaultPrimaryWss` case cannot be diagnosed that way at all, because the chains table is not user-visible.
For the volume case, the workaround is to enable the workflow, watch the Runs panel, and disable it if the rate is alarming. That is a live experiment paid for in executions.
I have not measured either cost, and I am not claiming a time saving. What I am claiming is that both failures are currently silent, and silence is the part worth fixing.
## Scope: what this touches, and what it does not
One read-only preflight, in two halves that share a result:
- the static half reproduces the refusal conditions listed above and reports each with the config field at fault;
- the dynamic half scans a bounded window of recent blocks with `eth_getLogs`, counts matches, decodes a few samples and derives a fires-per-day rate from the two block timestamps.
It adds one route, one lib module and its tests, and a documentation section. It reuses the existing authentication, workflow access check and rate limiter.
It does not change the event tracker, the trigger config shape, the workflow schema or any existing response. It adds no dependency and no migration. It signs nothing, executes nothing and records no execution. Every finding is advisory: it never blocks enabling or running a workflow.
Out of scope, each a reasonable follow-up and none of them a blocker: Solana event triggers (no `eth_getLogs` equivalent), `stateThreshold` triggers, the Schedule and Webhook trigger types, an editor surface for the result, and any change to how the tracker reports its own refusals.
This is one change. The static half and the dynamic half are not independently shippable in any useful sense: a verdict that reports "your ABI is fine" while declining to say whether anything matched is the ambiguity this is meant to remove, and the dynamic half cannot run at all until the static half has resolved the chain, address and event.
## Plan: what you propose
`POST /api/workflows/{workflowId}/trigger-preview`, modelled on `POST /api/workflows/{workflowId}/simulate`: same `getDualAuthContext`, `checkRateLimit` and `getWorkflowAccess` chain, and reads the saved workflow rather than accepting trigger config in the body. Optional `{"lookbackBlocks": n}`, default 5000, ceiling 50000, with an out-of-range value rejected rather than silently defaulted.
Returns a `verdict` of `ok`, `high-volume`, `no-recent-matches`, `will-never-fire` or `unknown`, a one-sentence `summary`, and `findings[]` with a stable `code`, a `severity`, a `message` and the `fieldKey` at fault.
Three things I would call out as design decisions rather than details:
1. **`will-never-fire` is only claimed from a fact, never from an absence.** An empty window is `no-recent-matches`, because a rare event and a broken one are indistinguishable by absence. The verdict is claimed from a static refusal or from `eth_getCode` returning `0x` at the configured address, which is the common "right address, wrong network" mistake.
2. **A zero-match window is disambiguated by a second probe**, one `eth_getLogs` over the same range with no topic filter, stopping at the first log found. If the contract emitted anything, the wiring reaches a live contract and the event is merely rare; if it emitted nothing, that is worth saying differently.
3. **The rate is measured, not assumed.** Fires per day comes from the two block timestamps bounding the scanned range, so it needs no per-chain block time table and stays correct on a chain whose block time changes. When a timestamp cannot be read the rate is `null` rather than estimated.
The preview reproduces the tracker's match semantics rather than approximating them: filter on `topic0`, re-check the decoded event name (as `EventListener.onLog` does, so a topic collision with another event in the same ABI is not a match), then apply the recipient and memo filters the payment trigger carries.
One thing I want to flag rather than hide: the refusal list lives in `keeperhub-events/`, which the root tsconfig excludes, so it cannot be
imported and has to be reproduced. I would pin it with a test that asserts every refusal code is covered, so a refusal added to the tracker and not to the preview fails a test rather than going quiet. If maintainers would rather move the rule into a shared module that both consume, that is a better end state and I am happy to build it that way instead; it is a larger change and I did not want to assume it.
## Plan: alternatives you considered
1. **Do nothing.** The failure stays silent and diagnosis stays outside the product.
2. **Have the event tracker write its refusals back to the workflow** as a status field. Better in principle, because it reports the real registration outcome rather than a reproduction of the rule. Rejected as a first step: it needs a schema change and a write path from the tracker into the app's database, and it still cannot answer the volume question. Worth doing after, and the two compose.
3. **Extend `runWorkflowSimulation` to cover triggers.** Rejected because that function is per-node advisory preflight for write actions, and its result shape (`warnings`, `simulatedNodeCount`, `skippedNodeCount`) has nowhere to put a match count, a scanned window or a rate.
4. **Static checks only, no chain scan.** Cheaper and catches most of the silent-skip cases, but leaves both the "is it wired to a live contract" question and the volume question unanswered, which is half the value.
I am happy to take a smaller scope if triage prefers one; option 4 is the natural reduction.
## Scope: compatibility
- [ ] Changes an existing response shape, status code, CLI flag, or default.
- [ ] Adds, removes, or upgrades a dependency.
- [ ] Changes database schema or requires a migration.
- [x] Touches authentication, permissions, validation, or spend limits.
- [ ] Changes pricing, plan limits, or anything a user is charged.
The authentication box is ticked because the route adds a new authenticated surface. It introduces no new auth path: it reuses `getDualAuthContext`, `getWorkflowAccess` and `checkRateLimit` exactly as the simulate route does, and requires full access to the workflow.
Contributor guide
Research direction
Start by reading the existing POST /api/workflows/{workflowId}/simulate flow, including lib/workflow/run-simulation.ts, and the event-tracker entry points workflow-mapper.ts and event-listener.ts. Check lib/db/schema.ts for chains.default_primary_wss and review the stated authentication and rate-limit helpers. Done means the new preview route returns the specified verdict and findings for static and dynamic checks, with tests covering every refusal code.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend-api-design, blockchain
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100