KeeperHub / KeeperHub/keeperhub
feat: add a cross-chain message status action, starting with LayerZero
- 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. (Several means several issues.)
### Reason: what you cannot do today
A workflow can start a cross-chain transfer, through CCIP `ccip-send` today and through LayerZero OFT `send` once that lands, but it cannot find out whether the message arrived. There is no action that answers "is it delivered yet, and what is the destination transaction?"
That is the step every cross-chain automation needs before it can act on the destination chain: rebalance once funds land, alert when a message is stuck, reconcile source against destination. Delivery is not instant. A USDT0 transfer from Ethereum to Polygon sent at block 25979145 was delivered 974 seconds later. A second transfer, observed live, was still `INFLIGHT` more than two and a half minutes after its source transaction.
### Reason: what the workaround costs
The workaround is an HTTP Request node hand-built against a bridge explorer API, with the response parsed in a Code node. It works until it silently does not. Querying LayerZero Scan for the all-zero hash returns HTTP 200 with a *different* message, from an unrelated application on Base to Arbitrum, while a random unknown hash correctly returns 404 `{"message":"Message not found for tx ...","code":4040}`. A hand-built workflow that reads `data[0].status` can therefore report someone else's transfer as delivered. Every user who builds this has to discover that quirk alone.
### Scope: what this touches, and what it does not
Touches:
- A new read-only plugin, following the Hyperliquid pattern: public API, `egress: "fixed-host"`, no credentials, `safeFetch` only. It has one action, "Get Cross-Chain Message Status", with a protocol select that offers LayerZero in this change.
- Its step, a small provider interface so later protocols are one file each, unit tests from recorded responses, and a docs page.
Does not touch:
- **Other protocols.** CCIP, Hyperlane and Wormhole each get their own provider in a follow-up; this change only establishes the interface.
- **Triggers.** A "message arrived" trigger already works with the Event trigger on the destination contract; this is an action.
- **Waiting.** The action returns the current status. Waiting is a Schedule trigger plus a Condition, which keeps a single run from holding a worker for fifteen minutes.
- **LayerZero OFT send.** Separate issue; this action works for any LayerZero transaction, including ones sent outside KeeperHub.
One change: nothing here ships usefully without the one provider it defines.
### Plan: what you propose
Inputs:
- `protocol` (LayerZero).
- `sourceTxHash`, typically a reference to a previous send step's transaction hash.
- An optional `guid` that selects one message when a transaction carries several. The Scan response is a `data` array, and batched sends produce more than one entry.
Outputs:
- `found` (boolean);
- `status`, normalised to `inflight` / `delivered` / `failed` / `blocked`, plus the provider's raw name in `rawStatus`;
- `guid`, `srcEid`, `dstEid`;
- `sourceTxHash`, `destinationTxHash`, `destinationBlockNumber`;
- `sourceTimestamp`, `deliveredTimestamp`, `latencySeconds`;
- `messageCount`.
Rules for the LayerZero provider (`GET https://scan.layerzero-api.com/v1/messages/tx/{hash}`):
- Keep only entries whose `source.tx.txHash` equals the queried hash, case-insensitively. This is the guard against the zero-hash response above.
- A 404 with code 4040, or no matching entry, returns `found: false` with no error. A message that the indexer has not seen yet is a normal early state, not a failure.
- Other non-2xx responses and timeouts fail the step with the provider's message, so a Condition cannot mistake an outage for "not delivered".
Evidence gathered on 2026-09-15, read-only:
- **In flight:** `0x23b8fd4b6be3f30c3784eaaf01bfc038e81e54df142c1dbee28f4c6c03446f6d` (Ethereum to Polygon, USDT0) returned `status.name: "INFLIGHT"`, `destination.status: "WAITING"`, `pathway: {srcEid: 30101, dstEid: 30109}`, and a `guid` equal to the `OFTSent` event's guid.
- **Delivered:** `0x65d24dc0174ee490e433e50f1feb6c1cd18d1cef76967f37f8d15aec96f09c86` returned `status.name: "DELIVERED"`, `source.status` and `destination.status` both `"SUCCEEDED"`, and destination tx `0x93d8f840f9737ec2ba1100c3aa46655aebe17c6832291d19d36436337c9ec743` at Polygon block 93817632. The source and destination block timestamps are 974 s apart.
- **Unknown hashes:** a random hash returns HTTP 404 with code 4040; the zero hash returns HTTP 200 with an unrelated message, as described above.
Questions for triage:
1. **A third-party API as a workflow dependency.** LayerZero Scan needs no key, but I found no published rate limit. Is a public explorer API acceptable here, with rate-limit handling stated in the step, or would you rather this read destination-chain state (for example the endpoint's inbound nonce)? That is keyless and first-party, but needs the destination OApp and cannot give a destination transaction hash.
2. **Naming.** A new `crosschain` plugin with a protocol select, or a `layerzero-scan` plugin now with later protocols as siblings?
3. **Testnet.** Should the LayerZero provider also cover the testnet Scan host? I have not verified that host yet and will before the PR.
### Plan: alternatives you considered
- **A polling action that waits until delivery.** Rejected: it holds a run for as long as the bridge takes, and long waits belong to the scheduler.
- **Decoding destination-chain events directly.** Rejected for this change: it needs the destination contract per OApp and cannot tell "not yet" from "never".
- **One plugin per protocol from the start.** Possible; the shared status vocabulary is what makes one action with a protocol select more useful to a workflow author.
### 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.
- [ ] Touches authentication, permissions, validation, or spend limits.
- [ ] Changes pricing, plan limits, or anything a user is charged.
Contributor guide
Research direction
Start by reading the existing Hyperliquid plugin pattern, then inspect the plugin action entry points and the proposed provider interface. Use the recorded LayerZero responses as test fixtures, covering matching and unrelated entries, 404 code 4040, status normalization, and provider failures; done means the action exposes the specified inputs and outputs with the agreed plugin naming and testnet scope.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, blockchain
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100