Context
GlobeWallet::set_spend_limit and GlobeWallet::record_spend, contracts/globe-wallet/src/lib.rs.
record_spend's doc comment is unusually explicit about its threat model: "require_auth() on user authenticates the caller, not the value — in the compromised-key threat model this function exists to defend against, the attacker can already produce valid user signatures, so require_auth() alone provides no protection here." That analysis is correct, and it's why record_spend rejects amount <= 0 (to stop an attacker resetting spent_today before a legitimate-looking large spend).
Problem
set_spend_limit was never given the same analysis. It is public, requires only user.require_auth(), and has no upper bound, no rate limit, no timelock, and no second-party (guardian) involvement on increases:
pub fn set_spend_limit(env: Env, user: Address, asset_code: String, limit: i128) -> Result<(), WalletError> {
user.require_auth();
if limit < 0 { return Err(WalletError::InvalidSpendLimit); }
// "retroactive enforcement" below only blocks LOWERING the limit below
// today's already-recorded spend -- it does nothing to slow down RAISING it.
...
}
Under the exact threat model record_spend's own comment describes, the attacker holding the compromised user key doesn't need to fight record_spend's defenses at all. They call set_spend_limit(user, "USDC", i128::MAX) first, then spend freely. The entire feature — whose module-level doc comment states its purpose is "to limit loss on key compromise" — provides zero protection against the one attacker it exists to defend against, because the sole authority over the limit is the same single key being defended.
Reproduction steps
#[test]
fn test_spend_limit_is_self_escalatable_by_the_key_it_defends_against() {
let (env, _cid, admin, client) = setup();
let user = Address::generate(&env);
client.add_asset(&user, &usdc(&env));
// Owner sets a conservative daily cap.
client.set_spend_limit(&user, &String::from_str(&env, "USDC"), &100);
assert_eq!(client.get_spend_limit(&user, &String::from_str(&env, "USDC")), 100);
// Attacker has `user`'s compromised key -- same signature capability
// record_spend's own doc comment assumes. Nothing stops them from
// raising the limit to whatever they want before spending.
client.set_spend_limit(&user, &String::from_str(&env, "USDC"), &i128::MAX);
assert_eq!(client.get_spend_limit(&user, &String::from_str(&env, "USDC")), i128::MAX);
// The "protection" is now meaningless.
client.record_spend(&user, &String::from_str(&env, "USDC"), &1_000_000_000);
// Expected once fixed: raising a limit should require something the
// attacker doesn't already have -- e.g. a guardian-approved change, or
// a timelock long enough for the legitimate owner to notice and cancel,
// the same pattern already used for admin recovery.
}
Impact
The core security promise of this whole subsystem is false as implemented. Anyone relying on "I set a $50/day cap, so a compromised key can only cost me $50" is wrong — a compromised key costs them everything, in one transaction, with zero additional friction. This is worse than having no spend-limit feature at all, because it creates false confidence.
Suggested fix
Split "lowering/removing a limit" (safe to do instantly — it can only ever restrict the attacker further) from "raising a limit" (must be slow and/or require a second factor). A minimal fix: raising a limit goes through the same propose/timelock pattern already built for admin transfer and recovery — propose_spend_limit_increase + a delay + either the same key confirming again after the delay, or (stronger) guardian co-signature, mirroring the existing RecoveryConfig/guardian infrastructure that already exists in this contract for exactly this class of problem.
Definition of done
Context
GlobeWallet::set_spend_limitandGlobeWallet::record_spend,contracts/globe-wallet/src/lib.rs.record_spend's doc comment is unusually explicit about its threat model: "require_auth()onuserauthenticates the caller, not the value — in the compromised-key threat model this function exists to defend against, the attacker can already produce validusersignatures, sorequire_auth()alone provides no protection here." That analysis is correct, and it's whyrecord_spendrejectsamount <= 0(to stop an attacker resettingspent_todaybefore a legitimate-looking large spend).Problem
set_spend_limitwas never given the same analysis. It is public, requires onlyuser.require_auth(), and has no upper bound, no rate limit, no timelock, and no second-party (guardian) involvement on increases:Under the exact threat model
record_spend's own comment describes, the attacker holding the compromiseduserkey doesn't need to fightrecord_spend's defenses at all. They callset_spend_limit(user, "USDC", i128::MAX)first, then spend freely. The entire feature — whose module-level doc comment states its purpose is "to limit loss on key compromise" — provides zero protection against the one attacker it exists to defend against, because the sole authority over the limit is the same single key being defended.Reproduction steps
Impact
The core security promise of this whole subsystem is false as implemented. Anyone relying on "I set a $50/day cap, so a compromised key can only cost me $50" is wrong — a compromised key costs them everything, in one transaction, with zero additional friction. This is worse than having no spend-limit feature at all, because it creates false confidence.
Suggested fix
Split "lowering/removing a limit" (safe to do instantly — it can only ever restrict the attacker further) from "raising a limit" (must be slow and/or require a second factor). A minimal fix: raising a limit goes through the same propose/timelock pattern already built for admin transfer and recovery —
propose_spend_limit_increase+ a delay + either the same key confirming again after the delay, or (stronger) guardian co-signature, mirroring the existingRecoveryConfig/guardian infrastructure that already exists in this contract for exactly this class of problem.Definition of done
set_spend_limitneeds asymmetric handling for increases vs. decreases, referencingrecord_spend's existing threat-model commentcargo test --workspaceoutput pasted showing red→green for the new test(s)