Skip to content

fix(airdrop): canonicalize Base wallet case so one wallet cannot double-claim#8010

Open
Vyacheslav-Tomashevskiy wants to merge 1 commit into
Scottcjn:mainfrom
Vyacheslav-Tomashevskiy:fix/airdrop-wallet-case-dedup
Open

fix(airdrop): canonicalize Base wallet case so one wallet cannot double-claim#8010
Vyacheslav-Tomashevskiy wants to merge 1 commit into
Scottcjn:mainfrom
Vyacheslav-Tomashevskiy:fix/airdrop-wallet-case-dedup

Conversation

@Vyacheslav-Tomashevskiy

Copy link
Copy Markdown
Contributor

Bug: same Base (EVM) wallet can claim the airdrop multiple times by varying hex case

node/airdrop_v2.py enforces one airdrop per GitHub account and per wallet_has_claimed() and UNIQUE(github_username, wallet_address, chain). Only the GitHub half is canonicalized (_normalize_github_username casefolds it); wallet_address is compared as a raw string everywhere.

Base is EVM, where addresses are case-insensitive on-chain: 0xAbCd…, 0xabcd… and 0xABCD… are the same account and eth_getBalance (_check_base_wallet) returns the identical balance for every casing — so every casing passes the anti-Sybil check. But _has_claimed and the UNIQUE index treat each casing as a different wallet, so the wallet-dedup leg is void for the entire Base chain.

Impact

N aged, PR-merged GitHub accounts can all funnel the airdrop to one physical Base wallet by only changing hex case, and the global Base allocation is debited each time. Even a single honest user who claims with an EIP-55 checksummed address and re-submits the lowercased form gets a second allocation.

Repro (deterministic, no network — skip_antisybil=True)

a = AirdropV2(":memory:")
a.claim_airdrop("alice", "0xAbCdEf...01", "base", "contributor", skip_antisybil=True)  # ok
a.claim_airdrop("bob",   "0xabcdef...01", "base", "contributor", skip_antisybil=True)  # main: ALSO ok (bug)
# main -> base claimed_wrtc == 100 (same wallet paid twice)

Fix

Add _normalize_wallet(address, chain) and canonicalize wallet_address at the entry of both check_eligibility and claim_airdrop (lowercase for base; Solana base58 left untouched — it is genuinely case-sensitive), so dedup, the UNIQUE index, the generated claim id and the RPC balance check all use one canonical form.

Tests

  • New node/test_airdrop_wallet_case_dedup.py: fails on main (Base wallet double-claims, 100 wRTC), passes with fix (50 wRTC, one claim); Solana distinct-case wallets stay independently claimable.
  • Existing node/test_airdrop_v2.py31 passed, no regressions.

Claiming under rustchain-bounties#71. Payout RTC: RTCd1554f0f35576faf01d386a6be1c947f560dd0b7

…le-claim

Base (EVM) addresses are case-insensitive on-chain — 0xAbCd..., 0xabcd...
and 0xABCD... are the same account and eth_getBalance returns the same
balance for every casing. But the one-airdrop-per-wallet invariant
(_has_claimed + UNIQUE(github_username, wallet_address, chain)) compared
the wallet as a raw string, so the same physical Base wallet could collect
the airdrop once per GitHub identity just by varying hex case, draining the
Base allocation.

Add _normalize_wallet() and canonicalize wallet_address (lowercase for
base; base58/Solana left untouched, it is case-sensitive) at the entry of
both check_eligibility and claim_airdrop, so dedup, the UNIQUE index, the
generated claim id and the RPC balance check all use one canonical form.

Regression test fails on main (same Base wallet double-claims -> 100 wRTC
charged) and passes with the fix (50 wRTC, one claim); Solana distinct-case
wallets stay independently claimable.
@github-actions github-actions Bot added BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) BCOS-L2 Beacon Certified Open Source tier BCOS-L2 (required for non-doc PRs) node Node server related size/M PR: 51-200 lines labels Jul 18, 2026
@Vyacheslav-Tomashevskiy

Copy link
Copy Markdown
Contributor Author

/claim rustchain-bounties#71 — verified bug: same Base (EVM) wallet double-claims the airdrop by varying hex case (wallet-dedup invariant void for the whole Base chain). Deterministic regression test included; existing suite 31/31 green. Payout RTC: RTCd1554f0f35576faf01d386a6be1c947f560dd0b7

@jjb9707 jjb9707 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.

Substantive review (Bounty #73 candidate)

Verified scope: node/airdrop_v2.py adds chain-aware wallet canonicalization and node/test_airdrop_wallet_case_dedup.py adds three regression paths. I compared the patch with the actual schema, _has_claimed, eligibility, claim-record, and allocation-update paths at head eb2641e64624fbbdf4eb07053c6f4410997822d5.

Strengths

  1. The threat model is correct and narrowly scoped: Base/EVM addresses are case-insensitive, while Solana base58 addresses are case-sensitive. Lowercasing only chain == "base" avoids corrupting Solana identities.
  2. Canonicalization occurs before both _has_claimed() call sites and before the ClaimRecord/claim ID are built. Consequently the lookup, stored wallet, RPC validation input, generated ID, and future comparisons all share one representation rather than merely normalizing one query.
  3. The regression suite covers the two externally visible paths (check_eligibility and claim_airdrop) and explicitly protects the Solana non-regression. The allocation assertion also proves rejected case variants do not debit the pool.

Substantive findings

  1. The existing SQL uniqueness constraint does not independently enforce one-wallet/one-account semantics. UNIQUE(github_username, wallet_address, chain) only rejects the same pair; _has_claimed() is what enforces the OR rule. This patch correctly closes the reported sequential case-variant exploit, but two concurrent requests for different GitHub users targeting the same canonical Base wallet can still both pass _has_claimed() before either insert, and both distinct tuples can commit. That is a pre-existing race, not introduced here. A follow-up migration should add separate unique indexes for active claims (or serialize the check/insert with an immediate transaction) so the database owns the invariant.
  2. Legacy mixed-case rows are not migrated. New writes are canonical, and new checks catch an exact stored casing, but a database that already contains 0xAbCd... will not match a new canonical lowercase query. Before rollout, normalize existing Base rows and resolve any collisions, or temporarily compare lower(wallet_address) for Base until the migration is complete. This matters because the patch changes application semantics without rewriting historical state.
  3. Validation ordering is acceptable but worth preserving deliberately. _normalize_wallet() runs after the supported-chain guard in check_eligibility, while claim_airdrop() normalizes immediately after chain.lower(). Since unsupported chains are not lowercased by the helper, there is no cross-chain corruption; however, any future direct storage path must call the same helper or the invariant can regress. Centralizing canonicalization at a typed wallet boundary would make that harder.

Verification

  • All 12 reported checks on the reviewed head completed successfully, including test, BCOS Tier Gate, BCOS v2 Engine Scan, and the review-tier gate.
  • Read-through confirms the canonical value is passed into _check_wallet, stored in airdrop_claims, included in claim_id, and used by _has_claimed.
  • The new tests are deterministic (:memory: + skip_antisybil=True) and assert both rejection behavior and allocation accounting.

Verdict: APPROVE — the patch fixes the demonstrated sequential Base-address casing bypass without changing Solana semantics. The legacy-row migration and database-level concurrency invariant should be tracked as follow-ups; neither invalidates the focused fix for fresh canonical writes.

Reviewer: @jjb9707

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

Labels

BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) BCOS-L2 Beacon Certified Open Source tier BCOS-L2 (required for non-doc PRs) node Node server related size/M PR: 51-200 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants