KeeperHub / KeeperHub/keeperhub

feat: chain-agnostic sign-and-hold primitive for generic EVM chains

Open
#2,479 1 comment 0 reactions 0 assignees View on GitHub
enhancement needs-discussion
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.