Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions node/airdrop_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions node/tests/test_airdrop_evm_wallet_case_double_claim.py
Original file line number Diff line number Diff line change
@@ -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}"
Loading