Skip to content

fix: apply a default organization spending cap instead of treating an absent cap as unlimited - #2031

Merged
eskp merged 7 commits into
stagingfrom
fix/spend-cap-fail-open
Aug 26, 2026
Merged

fix: apply a default organization spending cap instead of treating an absent cap as unlimited#2031
eskp merged 7 commits into
stagingfrom
fix/spend-cap-fail-open

Conversation

@eskp

@eskp eskp commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

What this changes

organization_spend_caps is read on every direct execution. When no row existed, or the column for that chain family was null, spending was unlimited. The only production writer was the admin settings route, and afterCreateOrganization seeded only the subscription row, so an organization was unlimited until an admin opted in.

Separately, the cap only counted native value. app/api/execute/transfer/route.ts reserved "0" for token transfers because token value is not priced into the cap, and an ERC-20 contract call reserves value, which is zero for token calldata. Token movement was therefore invisible to the cap.

This makes an absent cap resolve to a platform default rather than to unlimited, and adds a per-transaction stablecoin ceiling that applies on every write path.

Defaults introduced

Control Value Override
Daily native value (EVM) 0.02 ETH EXECUTE_DEFAULT_DAILY_VALUE_CAP_WEI
Daily native value (Solana) 0.5 SOL EXECUTE_DEFAULT_DAILY_SOLANA_VALUE_CAP_LAMPORTS
Per-transaction stablecoin 100 USD EXECUTE_DEFAULT_STABLECOIN_CAP_MICRO_USD

All three are chosen against the one existing policy figure in the codebase, DEFAULT_DAILY_CAP_MICROS = 200 USD/day from lib/agentic-wallet/daily-spend.ts, and each sits deliberately under it rather than at it. The two native figures are denominated in the native asset, so their USD worth drifts upward with the market with nothing alerting; they are picked so drift cannot carry them past the anchor until ETH clears roughly 10,000 USD or SOL roughly 400 USD. The stablecoin figure is half the anchor because it is per transaction with no aggregate behind it. These values need ratifying before merge.

The override column is currently aspirational. None of the three keys appear in deploy/keeperhub-stack/prod/values.yaml or the staging equivalent, both of which enumerate their environment explicitly. Until they are wired in, widening a cap requires a code change and a release, so the "merge, watch, then widen" sequence below is not executable as written.

When the default is applied because no row existed, it emits spend_cap_default_applied with organization, surface, chain family, reason, default and reserved amounts, so the blast radius is observable in Loki before it bites.

Verification

  • pnpm check 0 errors, pnpm type-check clean
  • Targeted run across 10 suites: 176 passed. Full affected sweep green.

Blast radius

This is the widest change of the four. Read it carefully.

  • There is no longer an unlimited spending cap. Every existing organization is affected on deploy. Any integrator moving more than 0.02 ETH or 0.5 SOL of native value per day starts getting refused.
  • The same default applies to workflow and protocol runs through reserveOrgValue / reserveOrgSolanaValue, so scheduled workflows moving native value draw on the same daily budget.
  • Clearing a cap in the admin UI no longer means "no cap"; it reverts that chain family to the platform default. UI copy, schema comment, route jsdoc and public docs all say so now.
  • A stablecoin outflow above 100 USD is refused on every EVM write path, not just /api/execute/transfer: contract-call, protocol actions, check-and-execute, node execution, the web3 workflow steps and Tempo TIP-20 sends. Solana SPL transfers are NOT covered: loadStablecoin matches hex addresses only, so an SPL mint never resolves and the Solana daily cap counts native lamports alone.
  • The stablecoin refusal is not a 403. It surfaces from the core as a normal pre-broadcast failure, 202 with status failed and an error string, the same shape as an invalid token address. Callers that special-case 403 for cap denials will not match it.

Recommended sequence: merge, watch spend_cap_default_applied for a day, count distinct orgs with exceeded=true, then widen the env defaults before enforcement bites anyone real.

Open decisions

  1. Ratify the three default values above. They are the load-bearing choice in this PR.
  2. approve is deliberately not capped. approve(attacker, MAX_UINT) is a complete drain path for a leaked key, since the attacker then calls transferFrom off-platform where we cannot see it. It is left uncapped because max-uint approve before a swap is a legitimate and common pattern, and capping it would break real workflows. Worth a separate decision.
  3. The stablecoin ceiling is per-transaction, not daily. At 60 rpm that is roughly 12,000 USD/min of headroom. What it genuinely stops is a single catastrophic drain, not sustained exfiltration. A daily stablecoin aggregate needs a schema column.
  4. The ceiling is platform-wide and not admin-configurable. Only the env override exists. An org that legitimately moves large stablecoin amounts has no self-service way to raise it.
  5. "Deliberately unlimited" is no longer expressible. An explicit null resolves to the default, identically to a missing row, because any other choice leaves the fail-open reachable by clearing the field. An org that genuinely needs no cap requires a new representation.

@eskp eskp added the no-issue-required PR exempt from the issue-first gate label Aug 12, 2026
@eskp
eskp marked this pull request as draft August 12, 2026 05:21
@suisuss suisuss removed the no-issue-required PR exempt from the issue-first gate label Aug 12, 2026
@eskp
eskp force-pushed the fix/spend-cap-fail-open branch from 2a35d16 to 10232a5 Compare August 24, 2026 09:24
@eskp
eskp requested review from joelorzet and suisuss August 25, 2026 00:05
@eskp
eskp force-pushed the fix/spend-cap-fail-open branch from 0a084d0 to 7213ee0 Compare August 25, 2026 00:13
@eskp eskp added the no-issue-required PR exempt from the issue-first gate label Aug 25, 2026

@joelorzet joelorzet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closing the unlimited default and locking the cap row so orgs without one still serialize are both right.

Blocking: the Tempo path checks each call instead of the transaction, approve staying uncapped makes the rest of the ceiling advisory, and the three EXECUTE_DEFAULT_* overrides are not wired into either values.yaml.

Also worth folding in: the two cap denials on the same endpoint return different shapes, the two cores classify the same denial differently, and simulate skips the check. Details inline.

@joelorzet joelorzet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All addressed. Env vars are in both values files, and covering the executor block too is the part I missed: reserveOrgValue runs there for workflow steps, so app-only would have meant an operator widening a cap only widened it for direct executions.

created now comes from returning(), so the telemetry stops attributing a lost insert race to "no_cap_row".

The zero-value short circuit is a better catch than the one I raised. I flagged it as wasted lock contention; you found that total + reserved > dailyCap collapses to total > dailyCap at zero, so once an org went over for the day every later zero-value request was refused too, including off-chain node executions that cannot move anything. That was reachable only because absent stopped meaning unlimited, so it arrived with this change and leaves with it.

The stablecoin items moved to #2133 and are answered there. Since that branch contains this one and both target staging, this should merge first, then merge staging into #2133.

eskp added 7 commits August 26, 2026 09:59
The daily value cap treated a missing organization_spend_caps row, or a
null column for one chain family, as unlimited. Only the admin spend-cap
route ever wrote that table and afterCreateOrganization seeded the
subscription row alone, so every organization was uncapped until someone
opted in and a leaked API key was bounded only by the wallet balance.

Both reservation paths now fall back to a platform default instead:
checkAndReserveExecution for the direct execution API, and
reserveOrgValue / reserveOrgSolanaValue for workflow and protocol runs.
Capping only the direct API would have left the workflow entrance to the
same wallet wide open. The defaults live in lib/execute/spend-cap-defaults.ts,
anchored on the 200 USD/day ceiling the agentic wallet already applies,
with an env override per unit so ops can widen them without a deploy.
Applying a default emits a spend_cap_default_applied security event
carrying the org, chain family, reason and whether the request was denied,
so the blast radius is measurable before it starts refusing traffic.

Those defaults are enforced atomically for the organizations they apply
to. SELECT ... FOR UPDATE locks nothing when the row is absent, which is
every organization this change newly covers, so concurrent callers would
each have read a zero day-total and each reserved the full default.
lockOrgSpendCapRow creates the row on first use, with both cap columns
left NULL so it is a lock anchor and not a frozen copy of the default,
and every reservation path takes it.

Token transfers reserved "0" because ERC-20 calldata carries no native
value, so a leaked key could move unbounded USDC against a cap reading
zero. Known stablecoins are now bounded per transaction with no oracle:
supported_tokens records their decimals and the peg is ~1:1, so the
amount rescales to micro-USD. The check sits in the shared cores --
transferTokenCore, writeContractCore and signTempoTx -- not in a route,
so the transfer API, contract-call API, protocol actions,
check-and-execute, node execution, the workflow steps and Tempo's TIP-20
sends all reach it; a ceiling on one route would only have said which
door to use. transfer, transferFrom and their TIP-20 memo variants are
refused above the ceiling; a large approve is reported but allowed,
because refusing max-uint approvals would break ordinary protocol
integrations. Unrecognised tokens still pass through; general ERC-20
pricing needs a price feed in the pre-broadcast path.

The cap is now reported as well as enforced. GET /api/analytics/spend-cap
returns the effective figure and a flag saying whether it is the platform
default, so the dashboard gauge and the get_spending_limits MCP tool stop
telling an agent there is no cap while requests are being refused. The
admin UI copy, the schema comment and the public API docs say that
clearing a cap hands the chain family back to the default rather than
removing it.

The two native defaults are denominated in ETH and SOL at a stated
reference price, so their USD worth drifts with the market. A Chainlink
feed does exist in this codebase, but the reservation runs inside a row
lock and reserveOrgValue carries no chain id, so pricing there would mean
holding the lock across an RPC call on a path with nothing to price.
The three EXECUTE_DEFAULT_* overrides introduced with the platform spend-cap
defaults were read from process.env but never listed in .env.example, so the
Environment Variable Sync check warned on them and an operator widening a cap
mid-incident had no documented name to set.
Lowers the three defaults introduced with the fail-closed spend cap:

  daily EVM native      0.05 ETH -> 0.02 ETH
  daily Solana native   1 SOL    -> 0.5 SOL
  per-tx stablecoin     200 USD  -> 100 USD

The reference ceiling is unchanged: the 200 USD/day figure the agentic
wallet already applies. Each default now sits under it rather than at it.

Two reasons. Every default is env-overridable without a deploy, so a
default that binds too tightly costs a config change while one that binds
too loosely is an unbounded outflow nobody notices; widening on evidence
from spend_cap_default_applied is the recoverable direction. And the two
native figures are denominated in the native asset, so their USD worth
drifts upward with the market with nothing alerting -- they are now picked
so drift cannot carry them past the anchor until ETH clears ~10,000 USD or
SOL ~400 USD.

The stablecoin ceiling goes to half the anchor because it is per
transaction with no aggregate: the per-key rate limit is all that bounds
the daily total, so the figure sizes the single worst transaction a leaked
key can push through.

Also regenerates specs/api-coverage.json, whose recorded line numbers
shifted when this branch edited docs/api/direct-execution.md.
matchOutflowFunction compared the raw declared input types from a
caller-supplied ABI against the literal string "uint256". Solidity treats
uint as an alias of uint256 and ethers canonicalises before computing the
selector, so an ABI declaring transfer(address,uint) encodes 0xa9059cbb --
byte-identical to a real ERC-20 transfer -- while the comparison failed,
matchOutflowFunction returned null, and checkStablecoinContractCall
short-circuited to allowed. loadStablecoin was never reached and no amount
was ever compared, so one word in an attacker-supplied ABI defeated the
whole ceiling.

Declared types now go through ethers ParamType, which applies the same
normalisation used to build the selector, so the two agree. A type ethers
cannot parse passes through unchanged: it will not match a shape, but it
also cannot be encoded into a transaction.

Only checkStablecoinContractCall was affected. The decodeErc20Outflow path
reads parsed.fragment.inputs, which ethers has already normalised.

The regression test drives both spellings and asserts the token lookup
actually ran, so it cannot pass by allowing for an unrelated reason;
reverting the canonicalisation fails the uint case alone.
This branch conflated two things. Closing the fail-open default on the
organization spending cap is a small fix to an existing control: an absent
row meant unlimited, and it now resolves to a platform default. The
stablecoin per-transaction ceiling is a new control over a value class the
cap never covered at all, and it was 854 of the 1816 added lines.

They raise different questions. The native fix is bounded and its
behaviour is settled. The ceiling still needs decisions on whether approve
stays uncapped, whether Solana SPL transfers are in scope, and how the
per-transaction shape relates to a daily aggregate. Keeping them together
meant the settled half waited on the unsettled one.

Removed here and carried to fix/stablecoin-transfer-ceiling, unchanged
apart from the split itself:

  lib/execute/stablecoin-cap.ts and its suite
  the four call sites that invoke it (tempo-tx-core, transfer-token-core,
    write-contract-core, and the transfer route comment)
  the stablecoin default and its env key
  the docs section describing the ceiling

What stays is the native cap: the default resolution, the cap-row lock
that serialises organizations which never configured one, the analytics
and UI surfaces that render an effective cap, and their tests.
Two problems introduced by making an absent cap resolve to a default.

The comparison is `total + reserved > dailyCap`, which at reserved = 0 is
`total > dailyCap`. Once an organization went over for the day, every
later zero-value request was refused with it -- off-chain node executions
and reads that cannot move anything. Before the default existed an
unconfigured org returned before the comparison ran, so this only became
reachable when absent stopped meaning unlimited. A request that moves no
value cannot push a total over anything, so it now returns before the cap
is consulted. withValueCap already does this on the ledger side.

The same early return keeps the cap-row insert and its FOR UPDATE lock off
the request path for traffic that is mostly zero-value. The row exists
only as a lock anchor, and a request with nothing to reserve is not racing
anyone for budget.

Separately, lockOrgSpendCapRow reported created: true whenever it reached
the insert, including when onConflictDoNothing did nothing because a
concurrent transaction won the race. That attributed the race to
"no_cap_row" in spend_cap_default_applied, which reads as "this org has
never configured a cap" when the truth is that two requests arrived
together. onConflictDoNothing returns a row only when the insert actually
happened, so returning() distinguishes the two.

Reverting either fix fails its own cases and nothing else.
The module claimed each default "can be overridden without a deploy", and
.env.example and the public docs documented the keys, but EXECUTE_DEFAULT
appeared in zero files under deploy/. Both values files enumerate their
environment explicitly, so nothing set them and widening a cap that was
binding a live integrator meant editing a constant and shipping code --
which is what the rollout in the description tells an operator not to have
to do.

Sets both native keys for the app and the executor in prod and staging.
Both are needed: direct executions reserve in the app, workflow steps
reserve in the executor through withValueCap, and the executor keeps its
own env block rather than merging shared_env.

The values match the compiled-in defaults, so this changes no behaviour on
deploy. It only makes the escape hatch real.

The claim itself is corrected rather than left overstated: type kv means a
new figure ships with a helm upgrade, not instantly. parameterStore plus a
pod restart is what "without a deploy" would actually take, and that is a
choice worth making deliberately rather than implying.

Also notes that AGENTIC_WALLET_DAILY_CAP_MICROS, cited in the original
comment as the convention being followed, makes the same claim and is
wired into neither environment.
@eskp
eskp force-pushed the fix/spend-cap-fail-open branch from 78be8bc to e4449bc Compare August 26, 2026 00:08
@eskp
eskp merged commit e82bb0e into staging Aug 26, 2026
61 checks passed
@eskp
eskp deleted the fix/spend-cap-fail-open branch August 26, 2026 00:33
@github-actions

Copy link
Copy Markdown
Contributor

🧹 PR Environment Cleaned Up

The PR environment has been successfully deleted.

Deleted Resources:

  • Namespace: pr-2031
  • All Helm releases (Keeperhub, Scheduler, Event services)
  • PostgreSQL Database (including data)
  • LocalStack, Redis
  • All associated secrets and configs

All resources have been cleaned up and will no longer incur costs.

@github-actions

Copy link
Copy Markdown
Contributor

ℹ️ No PR Environment to Clean Up

No PR environment was found for this PR. This is expected if:

  • The PR never had the deploy-pr-environment label
  • The environment was already cleaned up
  • The deployment never completed successfully

hicksonhaziel pushed a commit to hicksonhaziel/keeperhub that referenced this pull request Aug 27, 2026
PR KeeperHub#2031 made an absent organization_spend_caps row resolve to a platform
default of 0.02 ETH per day instead of unlimited. Every protocol-coverage
suite executes as the one persistent org e2e-test-org, so the native value
they move accumulates against a single daily ledger per shard.

Shard 4 sends 0.04 ETH: 0.01 for wrapped/wrap, plus 0.01 for each of the
three frax-ether-v2 mints. wrapped runs first, so frax/mint lands exactly on
the 0.02 cap and passes, and mint-and-give and mint-and-stake are denied.

Seed the org an explicit daily_value_cap_wei of 1 ETH. CI then keeps the same
platform default as staging and prod, which both pin 0.02 ETH, and this org
carries its own ceiling instead. The figure is 25 times the current worst
case, so a new payable fixture does not break the suite again.

The write is an upsert, not an insert-if-absent: the reservation path creates
a row with both cap columns NULL on the org's first value-moving request, and
such a row still resolves to the platform default.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-issue-required PR exempt from the issue-first gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants