Skip to content

Latest commit

 

History

History
103 lines (68 loc) · 12.2 KB

task_050826_9.md

File metadata and controls

103 lines (68 loc) · 12.2 KB

Task: Fair-Launch Token Distributor — Contract Slice (Issue #75)

ID: task_050826_9 Assigned to: Robbert Abimbola (@robertocarlous) Created: 2026-08-05 Status: Open Repo: https://github.com/InfiniteZeroFoundation/DevNet Base branch: develop HEAD → new branch feat/fair-launch-distributor Roadmap ref: P4 (fair-launch distribution — no ROADMAP.md row yet, tracked via BL-18); the eligibility-computation slice (see "Explicitly out of scope" below) depends on P4-IDX1/P4-IDX2, also yours Dates: Testnet dry-run — move fast, no external audit gate at this stage (see Context). Propose your own estimate once scoped; flag if anything below needs more than a few days. Tracking: Issue #75 (long-running, overall effort) · Discussion #76 (progress updates, questions, PR links)


Context

Abraham's fair-launch vision (relayed by Umer, Aug 5 2026): no ICO, no pre-sale, no VC allocation. DIN goes exclusively to validators/active network participants, distributed programmatically as an airdrop. This is a testnet dry-run — Abraham's own words: "move fast, mistakes are fixable (redeploy, adjust), no real money at stake" — deployed following the normal path: local anvil → devnet → testnet → mainnet. This is not the same mechanism as the existing per-GI validator reward emission (decay schedule, inflation guard) — that's an ongoing, separate flow. This is a one-time (or periodic-snapshot) grant.

This resolves an open fork already tracked in this repo: Developer/design/MECHANISM_DESIGN.md §9, item 14 ("ICO / token-sale mechanics — not resolved") named two options for retiring the depositAndMint faucet at mainnet — a proper on-ramp/ICO, or a validator-airdrop program. Abraham's answer picks airdrop. Full backlog entry: BL-18.

Governance note that bounds the admin design below: per DD-3 (resolved 2026-08-04), there is no on-chain multisig for protocol roles at this stage. Any multisig that exists is a plain Gnosis Safe scoped to treasury/fund custody only — not governance.

(Internal references below are for background only — you won't have access to these, everything you need is inlined in this task. Flagged as Umer-reference-only.)


Scope for this task: the distributor contract only

This task is contract-only. It does not include eligibility computation (who qualifies, and for how much) — see "Explicitly out of scope" below for why, and what comes next.

You do not need to pull in any contracts from the 8 open P3 PRs (fee router, reward engine, staking governance, slashing taxonomy, etc.) — this contract only needs DinToken, which is already on develop. Build clean off current develop HEAD. ( pulling in other-PR contracts is fine if needed — flagging that it isn't needed here, so you don't go looking for a dependency that doesn't exist.)

1. New contract: foundry/src/DinFairLaunchDistributor.sol

Transparent Proxy pattern, matching the existing platform contracts exactly — same shape as DinValidatorStake.sol/DinToken.sol (Initializable, OwnableUpgradeable, initialize() instead of constructor args) and the same deploy pattern as foundry/script/DeployPlatform.s.sol (Upgrades.deployTransparentProxy(...)).

Important implementation note on vesting: the earlier design conversation described this as "OZ VestingWallet-style." Don't actually deploy OZ's VestingWallet/VestingWalletCliff — those are single-beneficiary contracts (one instance per recipient), which would mean deploying N contracts for N validators. What's wanted is one distributor contract holding a mapping(address => VestingPosition) internally, reusing OZ's linear-vesting math (cliff, then linear release to cliff + duration) but not the OZ contract class itself. lib/openzeppelin-contracts/contracts/utils/cryptography/MerkleProof.sol is available and should be used as-is for proof verification.

State & functions (design intent — adjust as needed, but flag any deviation in your PR description):

  • initialize(address dinToken_, address treasuryAdmin_, uint64 cliffDuration_, uint64 vestingDuration_) — dinToken_/treasuryAdmin_ addresses, cliff/vesting durations in seconds. treasuryAdmin_ becomes owner(). No Gnosis Safe exists yet for testnet — pass an EOA (Umer's or a test wallet) as treasuryAdmin_ for now; document in NatSpec that this should point at a Gnosis Safe once one exists, consistent with DD-3's "multisig = treasury only" model. Don't block on a Safe existing.
  • deposit(uint256 amount) — onlyOwner, pulls amount via transferFrom(msg.sender, address(this), amount) (treasury pre-approves), increments totalFunded. This is the "funded once from treasury" step — not per-user minting, DinToken.mint stays onlyCoordinator and is untouched.
  • setRoot(bytes32 root_) — onlyOwner, settable only before openClaims() has been called (so a snapshot can be corrected pre-launch); revert with a custom error after claims open, so a root can't be swapped out from under people who already hold valid proofs.
  • openClaims() — onlyOwner, one-shot (revert if already open), freezes the current root, records vestingStart = block.timestamp (the vesting clock starts here, not at admin's leisure), sets claimsOpen = true.
  • claim(uint256 index, uint256 totalAmount, bytes32[] calldata proof) — nonReentrant (reuse ReentrancyGuardTransient, same as DinCoordinator/DinValidatorStake). Requires claimsOpen. Leaf shape: keccak256(bytes.concat(keccak256(abi.encode(index, msg.sender, totalAmount)))) (standard double-hashed leaf, avoids second-preimage issues) verified against the frozen root. One-time per address (revert on repeat). Creates the caller's VestingPosition{total: totalAmount, claimed: 0} and immediately releases whatever's already vested as of block.timestamp (0 if still pre-cliff). Increments totalClaimedPrincipal by totalAmount (the full allocation, not just what's released yet — needed for the sweep invariant below).
  • release() — nonReentrant, callable by anyone for their own position, any number of times. No proof needed (position already exists post-claim). Releases vestedAmount(msg.sender) - position.claimed.
  • vestedAmount(address account) public view returns (uint256) — standard cliff+linear formula: 0 before vestingStart + cliffDuration; total at/after vestingStart + vestingDuration; linear interpolation between.
  • sweepUnclaimed(address to) — onlyOwner, callable only after a deadline (vestingStart + vestingDuration + sweepGracePeriod — pick a reasonable grace period, e.g. 30 days, and make it a constant or initializer param, your call). Sweeps totalFunded - totalClaimedPrincipal (i.e., allocations nobody ever claimed at all — not unreleased-but-claimed vesting, which stays claimable by its owner indefinitely) back to to. Addresses Q7's griefing concern (unclaimed allocations shouldn't sit stranded forever).
  • Custom errors throughout, not require(cond, "string") — matches the gas-optimization convention already being applied elsewhere in foundry/src (see P3-6.3a in ROADMAP.md).
  • Full NatSpec (@notice/@param/@return) on every public/external function — matches P3-6.3b convention.

2. Deploy script

New foundry/script/DeployFairLaunchDistributor.s.sol, following DeployPlatform.s.sol's exact pattern (Upgrades.deployTransparentProxy, console.log the proxy address). Reads the existing DinToken proxy address the same way DeployPlatform.s.sol's later steps do. Don't wire this into dincli import-deployments — that's Python/dincli tooling, outside your lane; just get the contract deployable and log the address clearly enough that someone else can wire it up later.

3. Tests (foundry/test/)

Foundry tests using a hand-built test Merkle tree (4–8 addresses is enough) — this is what makes the contract testable end-to-end on local anvil without waiting on the real eligibility pipeline (see below). Cover at minimum:

  • Happy path: deposit → setRoot → openClaims → claim (0 vested pre-cliff) → warp past cliff → release() releases the right linear fraction → warp past full duration → release() drains remainder.
  • Invalid proof rejected; double claim() rejected; setRoot after openClaims() rejected; openClaims() called twice rejected.
  • sweepUnclaimed before deadline rejected; after deadline, sweeps exactly the never-claimed portion, leaves already-claimed-but-unvested positions untouched.
  • Reentrancy guard present on claim/release.
  • Funding invariant: can't claim more in total than totalFunded.

Explicitly out of scope for this task (and why)

Eligibility computation — deciding who qualifies and how much they get, and turning that into the (address, amount) list that generates the Merkle root — is not part of this task. Two reasons:

  1. It's off-chain scripting/aggregation, not contract work — outside your Solidity/Foundry lane.
  2. Umer's call (Aug 5 2026): fair-launch distribution should sit in P4, and the eligibility snapshot should be computed off the on-chain indexer (P4-IDX1/P4-IDX2 — also your work, Developer/ROADMAP.md) rather than a raw RPC-log script, once that indexer exists. The snapshot script itself would then be run by "authority" (i.e., executed by whoever holds the treasuryAdmin_ key/Safe — an operational script, not on-chain logic) to compute the list and call setRoot().

That means this task's tests use a hand-built test tree specifically so the contract can be built and verified now, without waiting on the indexer. The real indexer-backed eligibility script is the next slice in this line of work — flag it as such (don't start it) once this task is done, same convention as prior deferred-slice tasks in this repo.


Open questions — ask rather than guess

Per Abraham/Umer: if anything below is ambiguous once you're in the code, stop and ask rather than picking a default silently.

  1. Cliff/vesting duration values. No concrete numbers have been set (e.g., "3-month cliff + 9-month linear" is just an example, not a decision). Take them as initialize() params (not hardcoded) so this isn't a blocker — but flag in your PR that the actual testnet values need to come from Umer/Abraham before openClaims() is ever called for real.
  2. Sweep grace period. Pick a reasonable default (suggest 30 days after full vesting) but call it out explicitly as a placeholder in NatSpec.
  3. Contract naming. DinFairLaunchDistributor is a suggestion, not a mandate — check it doesn't collide with anything and use your judgment on the exact name.

Deliverables

  • foundry/src/DinFairLaunchDistributor.sol — Merkle-claim + cliff/linear vesting, Transparent Proxy, custom errors, full NatSpec
  • foundry/script/DeployFairLaunchDistributor.s.sol — deploy script following DeployPlatform.s.sol's pattern
  • foundry/test/DinFairLaunchDistributor.t.sol (or similar) — tests per section 3, hand-built Merkle fixture
  • forge build && forge test green (plain build is fine for iteration; confirm against via_ir = true profile before opening the PR since that's the real deploy config)
  • PR opened from feat/fair-launch-distributor against develop, referencing this task file and BL-18
  • PR description flags: the three open questions above (with your chosen defaults called out), and explicitly notes the eligibility-computation slice is deferred pending P4-IDX1/IDX2

Notes

  • This is a fresh line of work, not a continuation — no prior branch/PR to build on.
  • Base branch is develop HEAD as of whenever you start; re-pull right before opening the PR in case it's moved.
  • If it turns out you genuinely do need a contract from one of the open P3 PRs (fee router, reward engine, etc.) once you're in the code, that's fine to pull in — Abraham's explicit go-ahead — just call out exactly which PR/commit in your PR description so review knows what's borrowed vs. new.