From 07737023b6f8bf3f44df0dad0d70b607ea6d644e Mon Sep 17 00:00:00 2001 From: Vyacheslav-Tomashevskiy Date: Wed, 15 Jul 2026 00:54:33 +0200 Subject: [PATCH] fix(airdrop): canonicalize Base wallet case so one wallet cannot claim twice _has_claimed normalized the GitHub username but compared the wallet byte-exactly. EVM addresses are case-insensitive (mixed case is only an EIP-55 display checksum over the same 20 bytes), so the same Base wallet in two casings read as two different wallets and the UNIQUE(github_username, wallet_address, chain) index did not catch it either. N GitHub accounts could route N x 200 wRTC into one Base wallet, defeating RIP-305's one-claim-per-wallet rule. Add a chain-aware _normalize_wallet_address() applied in check_eligibility, claim_airdrop and _has_claimed. Solana addresses are base58 and genuinely case-sensitive, so they stay byte-exact. --- node/airdrop_v2.py | 24 +++++ ...st_airdrop_evm_wallet_case_double_claim.py | 99 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 node/tests/test_airdrop_evm_wallet_case_double_claim.py diff --git a/node/airdrop_v2.py b/node/airdrop_v2.py index 5052a8376..fe6639cca 100644 --- a/node/airdrop_v2.py +++ b/node/airdrop_v2.py @@ -66,6 +66,11 @@ MIN_WALLET_AGE_DAYS = 7 MIN_GITHUB_AGE_DAYS = 30 GITHUB_USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$") +# EVM addresses are case-insensitive: mixed case is only an EIP-55 display +# checksum over the same 20 bytes. Solana addresses are base58 and ARE +# case-sensitive, so they must never be folded. +EVM_ADDRESS_RE = re.compile(r"0x[0-9a-fA-F]{40}") +EVM_CHAINS = frozenset({"base"}) MAX_BRIDGE_ADDRESS_LENGTH = 128 MAX_BRIDGE_TX_LENGTH = 256 @@ -322,6 +327,22 @@ def _normalize_github_username(github_username: str) -> str: def _is_valid_github_username(github_username: str) -> bool: return bool(GITHUB_USERNAME_RE.fullmatch(github_username)) + @staticmethod + def _normalize_wallet_address(wallet_address: str, chain: str) -> str: + """Canonicalize a wallet address for uniqueness comparison. + + EVM (Base) addresses are case-insensitive — the mixed-case form is + only an EIP-55 checksum over the same 20 bytes — so they are folded + to lowercase. Without this, the same Base wallet can claim the + airdrop once per casing variant, defeating the "one claim per + GitHub/wallet" anti-Sybil rule. Solana addresses are base58 and are + case-sensitive, so they are left byte-exact. + """ + address = (wallet_address or "").strip() + if chain in EVM_CHAINS and EVM_ADDRESS_RE.fullmatch(address): + return address.lower() + return address + def check_eligibility( self, github_username: str, @@ -355,6 +376,7 @@ def check_eligibility( eligible=False, reason=f"Unsupported chain: {chain}. Must be 'solana' or 'base'", ) + wallet_address = self._normalize_wallet_address(wallet_address, chain_lower) checks = {} @@ -695,6 +717,7 @@ def _has_claimed( ) -> bool: """Check if a GitHub account or wallet already claimed an airdrop.""" github_username = self._normalize_github_username(github_username) + wallet_address = self._normalize_wallet_address(wallet_address, chain) conn = self._get_conn() cursor = conn.cursor() cursor.execute( @@ -794,6 +817,7 @@ def claim_airdrop( if not self._is_valid_github_username(github_username): return False, "Invalid GitHub username", None chain_lower = chain.lower() + wallet_address = self._normalize_wallet_address(wallet_address, chain_lower) if self._has_claimed(github_username, wallet_address, chain_lower): return False, "Claim already exists for this GitHub account or wallet", None diff --git a/node/tests/test_airdrop_evm_wallet_case_double_claim.py b/node/tests/test_airdrop_evm_wallet_case_double_claim.py new file mode 100644 index 000000000..b9aef6ef1 --- /dev/null +++ b/node/tests/test_airdrop_evm_wallet_case_double_claim.py @@ -0,0 +1,99 @@ +"""Regression: the same Base (EVM) wallet must not claim the airdrop twice +just by varying the address' EIP-55 checksum casing. + +EVM addresses are case-insensitive: the mixed-case form is only an EIP-55 +display checksum over the same 20 bytes. `_has_claimed` compares +`wallet_address = ?` byte-exactly, so `0x5683...9c6` and `0x5683...9c6` +lowercased are treated as two different wallets, defeating the +"One claim per GitHub/wallet" anti-Sybil rule (RIP-305). + +Solana addresses are base58 and ARE case-sensitive, so they must keep +byte-exact comparison. +""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from airdrop_v2 import AirdropV2 # noqa: E402 + +# Real EIP-55 checksummed address (the project's own wRTC contract on Base). +EVM_CHECKSUMMED = "0x5683C10596AaA09AD7F4eF13CAB94b9b74A669c6" +EVM_LOWERCASE = EVM_CHECKSUMMED.lower() +EVM_UPPERCASE = "0x" + EVM_CHECKSUMMED[2:].upper() + + +@pytest.fixture +def airdrop(): + return AirdropV2(":memory:") + + +def _claimed_uwrtc(airdrop, chain): + conn = airdrop._get_conn() + row = conn.execute( + "SELECT claimed_uwrtc FROM airdrop_allocation WHERE chain = ?", (chain,) + ).fetchone() + return row["claimed_uwrtc"] + + +def test_same_base_wallet_different_checksum_case_rejected(airdrop): + """Same EVM wallet, different casing, different GitHub -> must be rejected.""" + ok, msg, _ = airdrop.claim_airdrop( + github_username="alice", + wallet_address=EVM_CHECKSUMMED, + chain="base", + tier="core", + skip_antisybil=True, + ) + assert ok, msg + + ok2, msg2, _ = airdrop.claim_airdrop( + github_username="mallory", + wallet_address=EVM_LOWERCASE, + chain="base", + tier="core", + skip_antisybil=True, + ) + assert not ok2, ( + "same Base wallet claimed twice via checksum-case variation: " + f"{EVM_CHECKSUMMED} then {EVM_LOWERCASE}" + ) + + # 200 wRTC (core tier), not 400. + assert _claimed_uwrtc(airdrop, "base") == 200 * 1_000_000 + + +def test_same_base_wallet_uppercase_variant_rejected(airdrop): + ok, _, _ = airdrop.claim_airdrop( + "alice", EVM_LOWERCASE, "base", "core", skip_antisybil=True + ) + assert ok + ok2, _, _ = airdrop.claim_airdrop( + "mallory", EVM_UPPERCASE, "base", "core", skip_antisybil=True + ) + assert not ok2 + + +def test_has_claimed_matches_base_wallet_in_any_case(airdrop): + ok, _, _ = airdrop.claim_airdrop( + "alice", EVM_CHECKSUMMED, "base", "core", skip_antisybil=True + ) + assert ok + assert airdrop._has_claimed("mallory", EVM_LOWERCASE, "base") + assert airdrop._has_claimed("mallory", EVM_UPPERCASE, "base") + + +def test_solana_addresses_remain_case_sensitive(airdrop): + """Base58 Solana addresses are case-sensitive - must NOT be folded.""" + addr_a = "7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK" + addr_b = "7eqqdeulxwcravx3mxkfjc84lhckmgzckrudpvcmwjek" # different wallet + ok, _, _ = airdrop.claim_airdrop( + "alice", addr_a, "solana", "core", skip_antisybil=True + ) + assert ok + ok2, msg2, _ = airdrop.claim_airdrop( + "mallory", addr_b, "solana", "core", skip_antisybil=True + ) + assert ok2, f"distinct Solana wallet wrongly rejected: {msg2}"