Skip to content

[Bug]: record_spend/set_spend_limit take a free-form asset_code string, completely decoupled from the case-insensitive asset registry issue #29 built #88

Description

@ndii-dev

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

  • set_spend_limit/record_spend validate asset_code against the caller's UserAssets, using the same canonicalization add_asset uses
  • A canonicalized code is used for the actual storage key (or the lookup canonicalizes before hitting storage) so "USDC"/"usdc" land in the same bucket
  • Test proving a case-variant of a registered asset's code hits the same spend-limit bucket, not an independent one
  • Test proving record_spend/set_spend_limit reject an asset_code that doesn't correspond to any registered asset
  • cargo test --workspace output pasted

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignbugSomething isn't workingvery hardDifficulty: very hard

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions