Skip to content

[T2.7] Add claim deadline + sweepUnclaimed to MerkleClaim contract (T6.5 prerequisite) #1267

Description

@realproject7

Epic: #1229 · Spec: §5.3, §6.2 · Type: CODE (Solidity) · Estimate: 0.7 day · Depends on: T2.1

🚨 Blocker

Current contracts/MerkleClaim.sol (deployed name PLOTAirdrop) has NO owner, NO deadline, NO sweep/withdraw path. If T6.3 transfers released_pool PLOT into that contract and users don't claim, those tokens are stuck forever — T6.5 cannot execute.

RE1 round-6 caught this. Must be deployed BEFORE T6.2 settlement contract goes live.

Scope

Modify contracts/MerkleClaim.sol + bootstrap Foundry test infrastructure (no framework exists yet — contracts/ only contains the source file, no foundry.toml).


Part A — Contract changes

(a) Rename contract PLOTAirdropMerkleClaim

The filename is already MerkleClaim.sol and the spec consistently uses "MerkleClaim" terminology. Rename the contract declaration to match. Update doc comment block (@title MerkleClaim). Eliminates downstream naming ambiguity.

(b) Add owner / admin

  • address public immutable owner set in constructor (deployer = owner)
  • onlyOwner modifier

(c) Add claim deadline as DIRECT constructor argument

  • uint256 public immutable claimDeadline set in constructor
  • Operator computes at T6.2 deploy time as (intended claim-open timestamp) + getClaimWindowSeconds(AIRDROP_CONFIG) — the helper from T2.1 ([T2.1] Update lib/airdrop/config.ts (PROD + TEST configs) #1236) resolves PROD's CLAIM_WINDOW_DAYS × 86400 OR TEST modes' CLAIM_WINDOW_SECONDS to a single integer-seconds value. Contract itself is config-agnostic; it just accepts a unix timestamp.
  • Modify claim() to revert if block.timestamp > claimDeadline
  • NO internal CAMPAIGN_END + 30d computation (RE1 round-7)

(d) Add sweep function

function sweepUnclaimed(address to) external onlyOwner {
    require(block.timestamp > claimDeadline, "Claim window still open");
    uint256 remaining = PLOT.balanceOf(address(this));
    require(PLOT.transfer(to, remaining), "Sweep failed");
    emit Swept(to, remaining);
}

(e) Add event

event Swept(address indexed to, uint256 amount);

Constructor signature

constructor(
    address _plot,
    bytes32 _merkleRoot,
    uint256 _claimDeadline
)

Part B — Bootstrap Foundry test framework (RE1 round-12)

contracts/ currently has no test infrastructure. Bootstrap it:

  1. Install Foundry (via foundryup if not installed)

  2. forge init --no-commit at repo root OR create the structure manually:

    contracts/
    ├── MerkleClaim.sol
    ├── test/
    │   └── MerkleClaim.t.sol      ← Foundry test
    ├── script/
    │   └── DeployMerkleClaim.s.sol ← deploy script (used at T6.2)
    foundry.toml                     ← config
    remappings.txt                   ← if needed
    
  3. foundry.toml minimal config (RE1 round-14: scope all Foundry paths under contracts/ to avoid colliding with the existing TypeScript lib/ directory which has 39 entries of app code):

    [profile.default]
    src = "contracts"
    test = "contracts/test"
    script = "contracts/script"
    out = "contracts/out"
    cache_path = "contracts/cache"
    libs = ["contracts/lib"]    # NOT "lib" — that is the TypeScript app dir
    solc = "0.8.20"

    forge install writes dependencies to contracts/lib/ rather than polluting the app source tree.

  4. .gitignore additions:

    # Foundry
    contracts/out/
    contracts/cache/
    contracts/lib/          # RE1 round-14: vendored Foundry deps, regenerable via `forge install`
    broadcast/
    .env
    .env.local
    

    If team prefers committing vendored Foundry deps for reproducibility, remove contracts/lib/ from gitignore. Default: gitignored + setup script runs forge install.

  5. .env.example — repo already has /Users/cho/Projects/plotlink/.env.example (Supabase + Filebase + other app vars, ~3.7KB). DO NOT overwrite. Two acceptable options (pick at PR time, document choice):

    Option (a) — Append a Foundry/deploy section to existing .env.example (recommended for single-source):

    # -----------------------------------------------------------------------------
    # Foundry / Contract Deploy (§T2.7 MerkleClaim)
    # -----------------------------------------------------------------------------
    BASE_RPC_URL=https://mainnet.base.org
    DEPLOYER_PRIVATE_KEY=                # NEVER commit real key — set in shell only
    PLOT_TOKEN_ADDRESS=0x4F567DACBF9D15A6acBe4A47FC2Ade0719Fb63C4
    

    Option (b) — Create contracts/.env.example (recommended if contract ops are isolated from app):

    • Same content as above, scoped under contracts/ folder
    • forge commands run from contracts/ directory pick up local .env

    Either way: confirm git diff does NOT touch existing .env.example Supabase/Filebase keys. If accidentally clobbered, restore from git checkout -- .env.example before commit.

  6. Setup script (RE1 round-23 — required if contracts/lib/ stays gitignored): create contracts/setup.sh (or add to root Makefile / package.json script) with the exact forge install commands so a fresh clone can build:

    #!/bin/bash
    # contracts/setup.sh
    set -e
    cd "$(dirname "$0")"
    forge install OpenZeppelin/openzeppelin-contracts@v5.0.0 --no-commit
    forge install foundry-rs/forge-std --no-commit
    forge build
    echo "✓ Foundry contracts ready"

    Document in repo README or docs/airdrop-contracts.md: "Run bash contracts/setup.sh once after fresh clone before testing/deploying."

Security: never commit .env, never hardcode DEPLOYER_PRIVATE_KEY. Anvil-generated test addresses (deterministic accounts) for tests — no real funds. broadcast/ folder contains tx data → gitignored.


Part C — Unit tests in contracts/test/MerkleClaim.t.sol

Required:

  • test_claim_BeforeDeadline_Succeeds — eligible address claims within window
  • test_claim_AfterDeadline_Reverts — claim attempt after claimDeadline reverts
  • test_claim_InvalidProof_Reverts — bad Merkle proof reverts
  • test_claim_DoubleClaim_Reverts — already-claimed address can't claim again
  • test_sweep_BeforeDeadline_Reverts — sweep before deadline reverts with "Claim window still open"
  • test_sweep_AfterDeadline_BySomeone_Reverts — non-owner sweep reverts
  • test_sweep_AfterDeadline_ByOwner_Succeeds — owner sweep after deadline transfers full balance
  • test_sweep_DoubleSweep_TransfersZero — second sweep after first sends 0 (or reverts; document choice)

Run via forge test -vv. Gas snapshot via forge snapshot → claim path within +5% of v1.


What NOT to add (per spec intent + R21)

  • ❌ No pause functionality
  • ❌ No upgrade keys
  • ❌ No setMerkleRoot
  • ❌ No partial sweep before deadline
  • No internal CAMPAIGN_END + 30d computation

Migration of T6.5

After this lands, T6.5 simplifies: operator calls sweepUnclaimed(deadAddress) once. Already reflected in #1266.

Acceptance

  • Contract renamed PLOTAirdropMerkleClaim
  • Owner + claimDeadline (constructor arg) + sweepUnclaimed implemented
  • Claim reverts after deadline
  • Sweep reverts before deadline + reverts for non-owner
  • Foundry bootstrapped (foundry.toml + contracts/test/ exists)
  • foundry.toml libs = ["contracts/lib"] (NOT ["lib"]) — no collision with TypeScript app lib/ dir
  • forge install (if invoked) writes to contracts/lib/, not lib/ — verify via git status shows no untracked files in repo lib/
  • contracts/setup.sh (or equivalent script entry) exists with forge install commands so fresh clone can build
  • README or docs/airdrop-contracts.md documents the setup step
  • .gitignore includes contracts/out/, contracts/cache/, contracts/lib/, broadcast/, .env, .env.local
  • .env.example documents required env vars; no real keys committed
  • All 8 unit tests pass (forge test -vv)
  • Gas snapshot acceptable (claim path within +5% of v1)
  • Documentation comment block updated

Dependencies

T2.1 (#1236 defines claim window config + getClaimWindowSeconds(AIRDROP_CONFIG) helper — operator uses the helper for deadline computation at T6.2; contract itself is config-agnostic)

Blocks

T6.2 (#1263), T6.3 (#1264 closed - merged), T6.5 (#1266) — without this, settlement is one-way and tokens get stranded.

Metadata

Metadata

Assignees

No one assigned

    Labels

    airdropPLOT 10x Airdrop Campaign

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions