Context
GlobeWallet::set_spend_limit / record_spend (keyed by asset_code: String) vs. GlobeWallet::add_asset (keyed by the same conceptual identity, but canonicalized case-insensitively since issue #29).
Problem
Issue #29 taught add_asset that "USDC" and "usdc" are the same asset, via codes_match_case_insensitive. That canonicalization exists in exactly one place: add_asset's duplicate-detection loop. set_spend_limit and record_spend never call it, and never consult UserAssets at all — they take a bare asset_code: String and use it directly as (part of) a storage key:
pub fn set_spend_limit(env: Env, user: Address, asset_code: String, limit: i128) -> Result<(), WalletError> {
user.require_auth();
...
let key = DataKey::SpendLimit(user.clone(), asset_code.clone()); // raw string, no relation to UserAssets
...
}
pub fn record_spend(env: Env, user: Address, asset_code: String, amount: i128) -> Result<(), WalletError> {
user.require_auth();
...
let limit = Self::get_spend_limit(env.clone(), user.clone(), asset_code.clone()); // same raw string
...
}
Nothing requires asset_code to match anything in UserAssets(user), and nothing case-normalizes it. A limit configured against "USDC" and a limit configured against "usdc" are two entirely independent buckets, even though add_asset would treat them as the same asset for registration purposes.
Reproduction steps
#[test]
fn test_case_variant_asset_code_bypasses_configured_limit() {
let (env, _cid, admin, client) = setup();
let user = Address::generate(&env);
client.add_asset(&user, &usdc(&env)); // registers "USDC"
client.set_spend_limit(&user, &String::from_str(&env, "USDC"), &100);
client.record_spend(&user, &String::from_str(&env, "USDC"), &100); // at the cap
// A different casing of the same real-world asset's code hits a
// completely separate, unconfigured bucket -- unlimited by default.
client.record_spend(&user, &String::from_str(&env, "usdc"), &1_000_000);
// Succeeds today. Whether this is exploitable end-to-end depends on
// whatever integration passes asset_code into record_spend (see
// docs/design/architecture.md) -- but the contract itself provides
// zero enforcement that the string it's given is canonicalized, or
// even a real, user-registered asset at all.
}
Impact
This is the same root cause as the already-open issue on SpendLimit/DailySpent colliding across different issuers for the same code — a asset_code: String key with no relationship to the actual AssetInfo identity it's supposed to represent. Here the failure mode is the mirror image: instead of two different assets sharing one budget, the same asset can be addressed by multiple strings that each get their own, unconfigured budget. Any fix to the issuer-collision issue should solve this in the same pass, since both stem from record_spend/set_spend_limit never validating their asset_code input against the registry at all.
Suggested fix
Require set_spend_limit/record_spend/get_spend_limit to take (or internally resolve to) the canonicalized form of an asset already present in UserAssets(user) — reject calls whose asset_code doesn't case-insensitively match a registered asset. This closes the bypass and, as a side effect, stops spend-limit bookkeeping from being creatable for assets the user never actually registered.
Definition of done
Context
GlobeWallet::set_spend_limit/record_spend(keyed byasset_code: String) vs.GlobeWallet::add_asset(keyed by the same conceptual identity, but canonicalized case-insensitively since issue #29).Problem
Issue #29 taught
add_assetthat"USDC"and"usdc"are the same asset, viacodes_match_case_insensitive. That canonicalization exists in exactly one place:add_asset's duplicate-detection loop.set_spend_limitandrecord_spendnever call it, and never consultUserAssetsat all — they take a bareasset_code: Stringand use it directly as (part of) a storage key:Nothing requires
asset_codeto match anything inUserAssets(user), and nothing case-normalizes it. A limit configured against"USDC"and a limit configured against"usdc"are two entirely independent buckets, even thoughadd_assetwould treat them as the same asset for registration purposes.Reproduction steps
Impact
This is the same root cause as the already-open issue on
SpendLimit/DailySpentcolliding across different issuers for the same code — aasset_code: Stringkey with no relationship to the actualAssetInfoidentity it's supposed to represent. Here the failure mode is the mirror image: instead of two different assets sharing one budget, the same asset can be addressed by multiple strings that each get their own, unconfigured budget. Any fix to the issuer-collision issue should solve this in the same pass, since both stem fromrecord_spend/set_spend_limitnever validating theirasset_codeinput against the registry at all.Suggested fix
Require
set_spend_limit/record_spend/get_spend_limitto take (or internally resolve to) the canonicalized form of an asset already present inUserAssets(user)— reject calls whoseasset_codedoesn't case-insensitively match a registered asset. This closes the bypass and, as a side effect, stops spend-limit bookkeeping from being creatable for assets the user never actually registered.Definition of done
set_spend_limit/record_spendvalidateasset_codeagainst the caller'sUserAssets, using the same canonicalizationadd_assetuses"USDC"/"usdc"land in the same bucketrecord_spend/set_spend_limitreject anasset_codethat doesn't correspond to any registered assetcargo test --workspaceoutput pasted