KeeperHub / KeeperHub/keeperhub
feat: chain-agnostic sign-and-hold primitive for generic EVM chains
- Dominant language
- TypeScript
- Stars
- 24
- Forks
- 93
- Avg merge
- 1d 4h
- Merged PRs (30d)
- 253
Description
### Before filing
- [x] Searched open and closed issues, and open+merged PRs, for this proposal — found nothing (checked commit messages, `specs/`, PR titles/bodies for hold/escrow/chainless/sign_and_hold/held_payments).
- [x] Checked current behaviour on `staging` at commit `e089f84356c5d31322b3a89299fe97a9c76ffd4c`.
- [x] This is one change (see Scope below for why the pieces are coupled).
### Reason: what you cannot do today
`tempo_sign_and_hold` / `tempo_release_hold` / `tempo_cancel_hold` (`lib/mcp/tools.ts`) give an LLM-driven workflow a real "propose now, commit deterministically later" primitive: a transfer is signed and parked, then released (broadcast) or canceled, without ever handing the model direct broadcast power. It works today only because Tempo has a bespoke native transaction type (a scheduled-tx envelope with an on-chain expiry window and a nonce scheme that doesn't require sequential ordering).
On every other EVM chain KeeperHub executes on, `execute_transfer`/`execute_contract_call` sign and broadcast in one atomic step. There is no way to separate "propose a payment" from "commit it" on those chains, so an agent-initiated payment on, say, Arc gets no re-validation window before funds move — exactly the safety property the Tempo primitive exists to provide.
Confirmed Arc specifically has no native scheduling primitive: `docs.arc.io/arc/references/evm-differences` states Arc's only transaction-type deviations from stock Ethereum are supporting EIP-7702 and rejecting EIP-4844 blob transactions. No `validAfter`/`validBefore` window, no hold-and-release construct.
### Reason: what the workaround costs
Two workarounds exist and both are bad defaults:
- **Do nothing** (immediate `execute_transfer`): drops the safety property entirely for every non-Tempo chain — no propose/commit separation for agent-initiated payments.
- **Per-chain escrow contract**: funds leave the sender's wallet at hold-creation time (a real on-chain deposit, real gas, immediately), requires an audited contract deployed on every chain, and turns cancel into a second on-chain transaction. It also does not scale with KeeperHub's pace of onboarding new EVM chains (Arc is being added right now; others will follow).
Neither preserves what makes the Tempo primitive valuable without per-chain custody-contract cost.
### Scope: what this touches, and what it does not
**Touches:**
- New DB table + broker for generic-EVM held payments, separate from `tempoHeldPayments` (which is untouched).
- Signing/holding built on the existing creator-wallet Turnkey stack (`lib/web3/transaction-manager.ts`, `submit-signed.ts`), which already splits `signer.signTransaction` from `provider.broadcastTransaction`.
- A nonce-reservation approach that does not require extending `lib/web3/nonce-manager.ts`'s TTL/heartbeat lock (that lock is capped at ~30 minutes by design and its heartbeat is an in-process `setInterval` that cannot outlive the request that created it — structurally wrong for a hold that may sit for hours). Reservation instead recorded as a durable row in the existing `pendingTransactions` table with a new `status: "held"` value.
- New unprefixed MCP tools (`sign_and_hold`, `release_hold`, `cancel_hold`) parallel to `tempo_*`, plus oauth scopes and catalog entries.
- A scheduled expiry/observability sweep (mirrors `lib/tempo/broadcast-due.ts`'s poller shape).
- Extending the existing `/held-payments` UI (`components/held-payments/*`) to union Tempo + generic-EVM rows via a `protocol` discriminator.
**Does not touch:**
- Tempo's own hold pipeline — stays exactly as-is.
- Solana — structurally separate signer/tx model (`lib/web3/chain-adapter/registry.ts` branches `EvmChainAdapter` vs `SolanaChainAdapter`); out of scope, follow-up issue if wanted.
- Workflow-plugin steps for release/cancel — Tempo itself only has a hold step today (no release/cancel step either), so step parity is a separate, pre-existing gap, not new scope here.
- A unified dispatch layer serving both Tempo and generic-EVM under one tool name — a natural v2 (a `HoldStrategy` per chain-adapter), not proposed here.
**Confirmed one change:** the schema, broker, signing path, MCP tools, and UI union are only correct together — the MCP tools depend on the broker, the broker depends on the schema and the nonce-reservation approach, and the UI needs the view discriminator the broker introduces. None of these ship correctly alone.
### Plan: what you propose
1. **Schema** — new `lib/db/schema-held-payments.ts`: `evmHeldPaymentStatus` enum (same lifecycle as Tempo's: pending/broadcasting/broadcast/confirmed/failed/expired/canceled) and an `evmHeldPayments` table, same shape as `tempoHeldPayments` minus Tempo-only fields (plain integer `nonce` instead of a nonce-lane key, no `feeToken`). Unique index on `(walletAddress, chainId, nonce)`.
2. **Broker** — new `lib/web3/held-payments.ts`, copying the guarded-transition pattern from `lib/tempo/held-payments.ts` (`createHeldPayment`, `claimHeldPayment` atomic claim, `markBroadcast`/`markConfirmed`/`markFailed`, `cancelHeldPayment`, `expireDueHeldPayments`, `selectDueHeldPayments`, `toHeldPaymentView`). Promote `HeldPaymentView` to carry a `protocol: "tempo" | "evm"` discriminator.
3. **Nonce reservation** — at hold-creation, in the same transaction as the `evmHeldPayments` insert, write a `pendingTransactions` row with `status: "held"` and `txHash` set to the precomputed hash (available at sign time). Any later `NonceManager` session on that wallet+chain sees the nonce as occupied. Release flips the row to `pending`; cancel/expire flips it to `dropped`. No lock-lifetime change needed.
4. **Signing** — extend `lib/web3/transaction-manager.ts` with `signTransferForHold(...)`: start/end a short nonce session as today, populate + sign via the existing path, apply a fee headroom multiplier (new, small) on top of `getGasStrategy()`'s output to reduce staleness risk, stop before broadcasting. Reuse existing spend-cap/policy checks (`lib/execute/spend-cap-defaults.ts`), enforced at hold-creation only (documented limitation, not re-checked at release).
5. **Release** — new `lib/web3/release-held-payment.ts`: claim guard → check `validBefore` (app-enforced only, no chain backstop here unlike Tempo) → flip the nonce row to `pending` → broadcast (extract the broadcast+reconcile half of `submitSignedTransactionWithFailover` into its own function so both paths share it) → `markBroadcast`/`markFailed`.
6. **Cancel** — guarded `cancelHeldPayment` + flip nonce row to `dropped`.
7. **Expiry + observability** — new internal route mirroring `app/api/internal/tempo/broadcast-due/route.ts`'s HMAC pattern; a DB-sourced Prometheus gauge (mirroring `keeperhub_web3_pending_transactions_stuck` from PR #2272) for holds approaching `validBefore` unreleased, so a human decides whether to re-hold at a higher fee.
8. **MCP tools** — `sign_and_hold(chainId, to, tokenAddress?, amount, memo?, broadcastAt?)` → `paymentId`; `release_hold(paymentId)`; `cancel_hold(paymentId)`. Copies the existing `tempo_*` tool definitions' shape (org-owner-only, `withToolLogging`).
9. **API + UI** — new `app/api/held-payments/**` mirroring the Tempo routes' auth/org-scoping; union both sources in the existing `/held-payments` page.
No breaking changes to existing callers — purely additive.
### Plan: alternatives you considered
- **Per-chain escrow contract.** Rejected: moves funds at hold-time (not release-time), needs audited deployment per chain, cancel becomes a second on-chain tx. Doesn't scale with the pace of onboarding new EVM chains.
- **Automatic fee-bump/resubmit at release time**, to handle a stale fee cap on a long-held payment. Rejected: this is exactly what PR #2272 (`KEEP-1291`) deliberately removed from `lib/web3/gas-strategy.ts` — a same-nonce fee-escalation implementation — replacing it with a human-facing Prometheus gauge instead of silent automated action, on the stated reasoning that "a stuck transaction remains a human decision." This proposal follows the same precedent: fee headroom at hold-time + expiry + an alert, not an auto-resubmit path.
- **Extending the existing `wallet_locks` TTL/heartbeat lock to span a hold's full lifetime.** Not feasible: the lock is capped at `(MAX_SESSION_TTLS + 1) * lockTtlMs` (~30 minutes) by design, and its heartbeat is an in-process `setInterval` that cannot outlive the HTTP request that started it — structurally the wrong primitive for a hold that may sit for hours or days.
- **Do nothing / immediate execute.** Rejected: drops the propose/commit safety separation entirely for non-Tempo chains.
### Scope: compatibility
- [x] Changes database schema or requires a migration.
- [x] Touches authentication, permissions, validation, or spend limits (new MCP tool scopes; reuses existing spend-cap checks at hold-creation).
- [ ] Changes an existing response shape, status code, CLI flag, or default.
- [ ] Adds, removes, or upgrades a dependency.
- [ ] Changes pricing, plan limits, or anything a user is charged.
Contributor guide
Research direction
Start by reading lib/tempo/held-payments.ts, lib/web3/transaction-manager.ts, submit-signed.ts, and the existing Tempo MCP tools and held-payments routes. Trace how schema, nonce reservation, signing, release, expiry, OAuth scopes, and the UI are connected. Done means the generic-EVM lifecycle works end to end without changing the existing Tempo pipeline, with the planned API, observability, and UI coverage.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, authentication, backend, blockchain, database, frontend, observability
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 25/100