1
// SPDX-License-Identifier: MIT
2
pragma solidity ^0.8.20;
3
4
/**
5
* @title LOCKITLaunchVault
6
* @author LockItIn DAO
7
* @notice Trust-minimized token sale contract for LOCKIT token
8
* @dev Solidity 0.8.20, optimizer ON, runs = 777
9
*
10
* VERSION: 2.2 (USDC Distribution Alignment)
11
* CHANGES FROM v2.1:
12
* - FIXED: Removed incorrect DAO_USDC_ALLOCATION ($125k that shouldn't exist)
13
* - FIXED: Renamed legalWallet → complianceWallet with $250k allocation
14
* - FIXED: USDC distribution now matches Services Agreement Section 6.2:
15
* * $50,000 → LP (burned)
16
* * $250,000 → Compliance Budget
17
* * $125,000 → Development Services
18
* * $75,000 → Infrastructure Budget
19
* - Note: daoTreasury still used for TOKEN distribution and transfer fallbacks
20
*
21
* VERSION: 2.1 (LP Allocation Update)
22
* CHANGES FROM v2.0:
23
* - Updated: LP allocation increased from 4% to 5% (40M → 50M tokens)
24
* - Updated: DAO vesting reduced from 36% to 35% (360M → 350M tokens)
25
* - Note: LP USDC remains $50k, resulting in cleaner $0.001/token LP price
26
*
27
* VERSION: 2.0 (Final)
28
* CHANGES FROM v1:
29
* - Fixed: Contributions now permanently blocked once goal reached (monotonic lifecycle)
30
* - Fixed: createPair() race condition handled via re-check after failed create
31
* - Fixed: _isSaleReady() semantics cleaner for post-finalization state
32
* - Fixed: Distinct PairTokenMismatch error instead of reusing LPNotBurned
33
*
34
* Architecture:
35
* - Pre-mint 1B LOCKIT tokens to this contract before sale opens
36
* - Contributors send USDC, receive proportional LOCKIT after TGE
37
* - When goal is met, anyone can call finalize()
38
* - finalize() creates permanent Uniswap V2 liquidity + distributes to all fixed recipients
39
* - Contributors can claim() their tokens after finalization (pull-based)
40
* - distribute() can also push tokens to contributors in batches (anyone can call)
41
* - After deadline, sale closes; contributors can refund if goal not met
42
*
43
* Trust Model:
44
* - All allocation percentages are immutable constants
45
* - All recipient addresses are immutable (set in constructor)
46
* - No admin functions that can redirect funds
47
* - LP tokens are burned (sent to 0xdead) making liquidity permanent
48
* - Only escape hatch: refund() if goal not met by deadline
49
*
50
* CRITICAL OPERATIONAL INVARIANT:
51
* - The ENTIRE 1B LOCKIT supply must be transferred to this vault before ANY
52
* tokens are distributed elsewhere
53
* - If ANY LOCKIT exists outside this vault before finalization, an attacker
54
* could initialize the Uniswap pair and grief the automated LP creation
55
* - The sale cannot start until the full 1B is deposited (enforced in code)
56
* - Deploy → immediately transfer all tokens → then announce sale
57
*
58
* LP Creation Strategy (Griefing-Resistant + Atomic):
59
* - For uninitialized pairs (totalSupply == 0), we bypass the router entirely
60
* - We transfer tokens directly to the pair and call mint()
61
* - CRITICAL: All LP operations (transfer + mint + burn) are wrapped in an
62
* external self-call so they revert atomically if any step fails
63
* - This prevents the "tokens stranded in pair" brick scenario
64
* - This makes sync() griefing attacks economically irrelevant:
65
* * Attacker's dust gets absorbed into our first mint
66
* * Our 50M LOCKIT + $50k USDC dominates any dust amounts
67
* * First mint uses sqrt(amount0 * amount1) where amounts = balance - reserve
68
* - For already-initialized pairs, we fail safely into refund path
69
* - createPair() front-running is handled by re-checking getPair() after failure
70
*
71
* Failed Sale Behavior:
72
* - If deadline passes and goal is not met, contributors can refund()
73
* - The 1B LOCKIT tokens remain locked in this contract forever (effective burn)
74
* - This is intentional: no admin can recover tokens from a failed sale
75
* - A new sale would require minting a new token
76
*
77
* Emergency Refund (Finalize Stuck):
78
* - If goal IS met but finalize() keeps failing (LP creation blocked somehow)
79
* - After goalReachedAt + 180 days, emergency refunds become available
80
* - This is INDEPENDENT of the deadline - emergency refunds use goalReachedAt timing
81
* - Contributors can get their USDC back even though goal was met
82
* - LOCKIT tokens remain locked forever (effective burn)
83
* - IMPORTANT: Emergency refunds are BLOCKED if manual override was executed
84
* - This prevents partial refunds (vault would lack full USDC after override)
85
*
86
* Manual Override System (LP Creation Fails):
87
* - If finalize() fails 5 times (100+ blocks between each attempt)
88
* - manualOverrideEnabled becomes true
89
* - ONLY lpOpsWallet can call executeManualOverride() (prevents griefing)
90
* - executeManualOverride() sends LP allocation (50M LOCKIT + $50k) to lpOpsWallet
91
* - lpOpsWallet manually creates LP on Uniswap (fast, no governance delay)
92
* - completeFinalization() verifies LP was burned (minimum threshold check)
93
* - This allows launch to proceed even if automated LP creation fails
94
* - Once override executed, emergency refunds are blocked (must complete finalization)
95
* - Override requires full balances intact (prevents post-refund execution)
96
* - ESCAPE HATCH: If override executed but not completed after 90 days,
97
* contributors can claim prorated refunds via overrideEscapeRefund()
98
* - IMPORTANT: Once any escape refund is claimed, completeFinalization() is permanently
99
* blocked to prevent mixed states (some refunded, some claiming tokens)
100
*
101
* Blacklist-Safe USDC Transfers:
102
* - All USDC payouts to operational wallets use _safeUSDCTransferOrFallback()
103
* - If a recipient is USDC-blacklisted, funds go to daoTreasury instead
104
* - Emits PaymentFailed event for transparency
105
* - NOTE: If daoTreasury itself is blacklisted, finalization will revert
106
* - This is acceptable: if the DAO is sanctioned, we don't want to proceed
107
*
108
* Pre-Sale Safety:
109
* - Contributions are rejected until TOTAL_SUPPLY tokens are deposited
110
* - Prevents "bricked sale" if deployment is botched
111
* - Use isSaleReady() to verify before announcing sale
112
*
113
* Deadline Behavior:
114
* - Deadline is set at deployment time (block.timestamp + saleDurationDays)
115
* - IMPORTANT: Deposit 1B tokens immediately after deployment
116
* - The sale duration counts from deployment, not from token deposit time
117
*
118
* Extra USDC Handling:
119
* - USDC sent directly to the contract (not via contribute()) is NOT tracked
120
* - Such USDC becomes permanently locked (by design - maintains trust model)
121
* - Only use contribute() or contributeWithPermit() to participate
122
*
123
* Near-Goal Contribution Handling:
124
* - Minimum contribution is $10, but if remaining capacity is less than $10,
125
* contributors can contribute exactly the remaining amount to complete the sale
126
* - This prevents the sale from getting stuck near the goal
127
*
128
* Monotonic Sale Lifecycle (v2 fix):
129
* - Once goal is reached (goalReachedAt != 0), no new contributions are ever accepted
130
* - This prevents "re-opening" the sale after refunds reduce totalRaised
131
* - Sale states: Open → Goal Reached → Finalize/Override/Refund (one-way only)
132
*
133
* Token Allocation (1,000,000,000 LOCKIT):
134
* - 50.0% (500M) → Token sale buyers (claimed after finalize)
135
* - 5.0% (50M) → Uniswap V2 LP (burned, permanent liquidity)
136
* - 1.0% (10M) → Founder immediate
137
* - 4.0% (40M) → Founder vesting (7y7m7d linear)
138
* - 5.0% (50M) → DAO treasury immediate
139
* - 35.0% (350M) → DAO vesting (11y11m11d linear)
140
*
141
* USDC Allocation ($500,000 raise) - Per Services Agreement Section 6.2:
142
* - $50,000 → Uniswap V2 LP (paired with 50M tokens)
143
* - $250,000 → Compliance Budget (managed by Labs)
144
* - $125,000 → Development Services (Labs compensation)
145
* - $75,000 → Infrastructure Budget (managed by Labs)
146
*/
147
148
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
149
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
150
import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
151
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
152
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
153
154
interface IUniswapV2Factory {
155
function getPair(address tokenA, address tokenB) external view returns (address pair);
156
function createPair(address tokenA, address tokenB) external returns (address pair);
157
}
158
159
interface IUniswapV2Pair {
160
function totalSupply() external view returns (uint256);
161
function balanceOf(address owner) external view returns (uint256);
162
function mint(address to) external returns (uint256 liquidity);
163
function token0() external view returns (address);
164
function token1() external view returns (address);
165
}
166
167
contract LOCKITLaunchVault is ReentrancyGuard {
168
using SafeERC20 for IERC20;
169
170
// ═══════════════════════════════════════════════════════════════════════════
171
// ERRORS
172
// ═══════════════════════════════════════════════════════════════════════════
173
174
error SaleNotOpen();
175
error SaleEnded();
176
error GoalAlreadyMet();
177
error BelowMinContribution();
178
error ExceedsMaxContribution();
179
error ExceedsGoal();
180
error GoalNotMet();
181
error NotRefundable();
182
error AlreadyFinalized();
183
error NotFinalized();
184
error NoContribution();
185
error AlreadyClaimed();
186
error DistributionComplete();
187
error InvalidAddress();
188
error InsufficientTokenBalance();
189
error InsufficientUSDCBalance();
190
error PermitDeadlineExpired();
191
error CannotRescueLOCKIT();
192
error CannotRescueUSDC();
193
error NoTokensToRescue();
194
error TooSoonToRetry();
195
error ManualOverrideNotEnabled();
196
error ManualOverrideAlreadyExecuted();
197
error ManualOverrideNotExecuted();
198
error UseCompleteFinalization();
199
error EmergencyWindowOpen();
200
error OverrideEscapeNotAvailable();
201
error OverrideEscapeActivated();
202
error TokenAddressesMustDiffer();
203
error ZeroDurationNotAllowed();
204
error LPNotBurned();
205
error InsufficientLPBurned();
206
error PairTokenMismatch();
207
error OnlySelf();
208
error ZeroLiquidityMinted();
209
error OnlyLpOpsWallet();
210
211
// ═══════════════════════════════════════════════════════════════════════════
212
// EVENTS
213
// ═══════════════════════════════════════════════════════════════════════════
214
215
event ContributionReceived(address indexed contributor, uint256 amount, uint256 totalContributed);
216
event SaleFinalized(uint256 totalRaised, uint256 contributorCount, uint256 lpTokensBurned);
217
event TokensClaimed(address indexed contributor, uint256 amount);
218
event TokensDistributed(address indexed contributor, uint256 amount);
219
event BatchDistributed(uint256 startIndex, uint256 endIndex, uint256 totalDistributed);
220
event RefundClaimed(address indexed contributor, uint256 amount);
221
event OverrideEscapeRefundClaimed(address indexed contributor, uint256 amount);
222
event LiquidityCreated(address indexed pair, uint256 liquidity);
223
event LPTokensBurned(address indexed pair, uint256 amount);
224
event TokensRescued(address indexed token, uint256 amount, address indexed recipient);
225
event PaymentFailed(address indexed recipient, uint256 amount, address indexed fallbackRecipient);
226
event GoalReached(uint256 timestamp, uint256 totalContributors);
227
228
// Manual override events
229
event FinalizeAttemptFailed(uint256 attemptNumber, uint256 blockNumber, string reason);
230
event ManualOverrideEnabled(uint256 blockNumber, uint256 totalAttempts);
231
event ManualOverrideExecuted(uint256 tokenAmount, uint256 usdcAmount, uint256 timestamp);
232
event FinalizationCompleted(uint256 totalRaised, uint256 contributorCount);
233
234
// ═══════════════════════════════════════════════════════════════════════════
235
// CONSTANTS - Token Allocation (immutable percentages)
236
// ═══════════════════════════════════════════════════════════════════════════
237
238
uint256 public constant TOTAL_SUPPLY = 1_000_000_000e18; // 1 billion tokens
239
240
uint256 public constant SALE_ALLOCATION = 500_000_000e18; // 50% - Token sale
241
uint256 public constant LP_TOKEN_ALLOCATION = 50_000_000e18; // 5% - Uniswap LP (burned)
242
uint256 public constant FOUNDER_IMMEDIATE = 10_000_000e18; // 1% - Founder at TGE
243
uint256 public constant FOUNDER_VESTING = 40_000_000e18; // 4% - Founder vesting
244
uint256 public constant DAO_IMMEDIATE = 50_000_000e18; // 5% - DAO treasury at TGE
245
uint256 public constant DAO_VESTING = 350_000_000e18; // 35% - DAO vesting
246
247
// ═══════════════════════════════════════════════════════════════════════════
248
// CONSTANTS - Sale Parameters
249
// ═══════════════════════════════════════════════════════════════════════════
250
251
uint256 public constant GOAL = 500_000e6; // $500,000 USDC (6 decimals)
252
uint256 public constant MIN_CONTRIBUTION = 10e6; // $10 USDC
253
uint256 public constant MAX_CONTRIBUTION = 25_000e6; // $25,000 USDC
254
255
// USDC allocation constants - Per Services Agreement Section 6.2
256
// v2.2 FIX: Removed DAO_USDC_ALLOCATION, renamed LEGAL to COMPLIANCE with correct amount
257
uint256 public constant LP_USDC_ALLOCATION = 50_000e6; // $50,000 for LP
258
uint256 public constant COMPLIANCE_USDC_ALLOCATION = 250_000e6; // $250,000 to Compliance Budget
259
uint256 public constant DEV_USDC_ALLOCATION = 125_000e6; // $125,000 to Development Services
260
uint256 public constant INFRA_USDC_ALLOCATION = 75_000e6; // $75,000 to Infrastructure Budget
261
262
// Token price: $0.001 per token
263
// 500,000,000 tokens / $500,000 = 1000 tokens per dollar
264
// Or: 1 USDC (1e6) buys 1000e18 tokens
265
uint256 public constant TOKENS_PER_USDC = 1000e18; // 1000 tokens per $1 (in wei per USDC unit)
266
267
address public constant BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD;
268
269
// Uniswap V2 minimum liquidity (burned to address(0) on first mint)
270
uint256 public constant UNISWAP_MINIMUM_LIQUIDITY = 1000;
271
272
// Manual override parameters (if LP creation repeatedly fails)
273
uint256 public constant MAX_FAILED_ATTEMPTS = 5;
274
uint256 public constant MIN_BLOCKS_BETWEEN_ATTEMPTS = 100;
275
276
// Emergency refund delay (absolute last resort - 180 days after goal reached)
277
uint256 public constant EMERGENCY_REFUND_DELAY = 180 days;
278
279
// Override escape delay (if override executed but not completed - 90 days)
280
uint256 public constant OVERRIDE_ESCAPE_DELAY = 90 days;
281
282
// ═══════════════════════════════════════════════════════════════════════════
283
// IMMUTABLE STATE (set in constructor, never changes)
284
// ═══════════════════════════════════════════════════════════════════════════
285
286
IERC20 public immutable lockitToken;
287
IERC20 public immutable usdc;
288
address public immutable uniswapFactory;
289
290
uint256 public immutable deadline;
291
292
// Recipient addresses (all verified before contributing)
293
address public immutable founderWallet;
294
address public immutable founderVestingContract;
295
address public immutable daoTreasury; // Receives TOKENS + serves as USDC fallback for failed transfers
296
address public immutable daoVestingContract;
297
address public immutable complianceWallet; // $250k USDC - Compliance Budget (was "legalWallet" in v2.1)
298
address public immutable devWallet; // $125k USDC - Development Services
299
address public immutable infraWallet; // $75k USDC - Infrastructure Budget
300
address public immutable lpOpsWallet; // Receives LP allocation if manual override needed
301
302
// ═══════════════════════════════════════════════════════════════════════════
303
// MUTABLE STATE
304
// ═══════════════════════════════════════════════════════════════════════════
305
306
// Contribution tracking
307
mapping(address => uint256) public contributions;
308
address[] public contributors;
309
mapping(address => bool) public hasContributed;
310
311
uint256 public totalRaised;
312
uint256 public goalReachedAt; // Timestamp when goal was first reached (0 if not yet)
313
314
// Sale state
315
bool public finalized;
316
317
// Distribution tracking (for batched token push and pull-based claims)
318
uint256 public distributionIndex;
319
mapping(address => bool) public tokensReceived;
320
321
// Manual override tracking (if LP creation fails)
322
uint256 public failedAttempts;
323
uint256 public lastAttemptBlock;
324
bool public manualOverrideEnabled;
325
bool public manualOverrideExecuted;
326
uint256 public manualOverrideExecutedAt; // Timestamp for escape hatch timing
327
328
// Override escape state - once activated, completeFinalization() is permanently blocked
329
bool public overrideEscapeActivated;
330
331
// LP pair address (set during finalization for verification)
332
address public lpPair;
333
334
// ═══════════════════════════════════════════════════════════════════════════
335
// CONSTRUCTOR
336
// ═══════════════════════════════════════════════════════════════════════════
337
338
/**
339
* @notice Deploy the LaunchVault
340
* @dev All recipient addresses must be non-zero and verified before deployment.
341
* IMPORTANT: Transfer all 1B LOCKIT tokens immediately after deployment.
342
* The deadline starts at deployment time, not at token deposit time.
343
*
344
* v2.2 CHANGE: Renamed _legalWallet → _complianceWallet
345
* v2.2 CHANGE: daoTreasury no longer receives USDC (only tokens + fallback)
346
*
347
* @param _lockitToken LOCKIT token address (must hold 1B tokens)
348
* @param _usdc USDC token address
349
* @param _uniswapFactory Uniswap V2 Factory address (Base: 0x8909Dc15e40173Ff4699343b6eB8132c65e18eC6)
350
* @param _saleDurationDays Number of days the sale runs (must be > 0)
351
* @param _founderWallet Founder EOA for 1% immediate tokens
352
* @param _founderVesting Founder vesting contract for 4% tokens
353
* @param _daoTreasury DAO treasury for 5% tokens (also USDC fallback for failed transfers)
354
* @param _daoVesting DAO vesting contract for 35% tokens
355
* @param _complianceWallet Compliance Budget wallet for $250k USDC
356
* @param _devWallet Development wallet for $125k USDC
357
* @param _infraWallet Infrastructure wallet for $75k USDC
358
* @param _lpOpsWallet LP operations wallet for manual override (ONLY address that can trigger override)
359
*/
360
constructor(
361
address _lockitToken,
362
address _usdc,
363
address _uniswapFactory,
364
uint256 _saleDurationDays,
365
address _founderWallet,
366
address _founderVesting,
367
address _daoTreasury,
368
address _daoVesting,
369
address _complianceWallet,
370
address _devWallet,
371
address _infraWallet,
372
address _lpOpsWallet
373
) {
374
// Validate all addresses
375
if (_lockitToken == address(0)) revert InvalidAddress();
376
if (_usdc == address(0)) revert InvalidAddress();
377
if (_uniswapFactory == address(0)) revert InvalidAddress();
378
if (_founderWallet == address(0)) revert InvalidAddress();
379
if (_founderVesting == address(0)) revert InvalidAddress();
380
if (_daoTreasury == address(0)) revert InvalidAddress();
381
if (_daoVesting == address(0)) revert InvalidAddress();
382
if (_complianceWallet == address(0)) revert InvalidAddress();
383
if (_devWallet == address(0)) revert InvalidAddress();
384
if (_infraWallet == address(0)) revert InvalidAddress();
385
if (_lpOpsWallet == address(0)) revert InvalidAddress();
386
387
// Validate token addresses are different
388
if (_lockitToken == _usdc) revert TokenAddressesMustDiffer();
389
390
// Validate sale duration
391
if (_saleDurationDays == 0) revert ZeroDurationNotAllowed();
392
393
lockitToken = IERC20(_lockitToken);
394
usdc = IERC20(_usdc);
395
uniswapFactory = _uniswapFactory;
396
397
deadline = block.timestamp + (_saleDurationDays * 1 days);
398
399
founderWallet = _founderWallet;
400
founderVestingContract = _founderVesting;
401
daoTreasury = _daoTreasury;
402
daoVestingContract = _daoVesting;
403
complianceWallet = _complianceWallet;
404
devWallet = _devWallet;
405
infraWallet = _infraWallet;
406
lpOpsWallet = _lpOpsWallet;
407
408
// Sanity check: token allocations must equal total supply
409
assert(
410
SALE_ALLOCATION +
411
LP_TOKEN_ALLOCATION +
412
FOUNDER_IMMEDIATE +
413
FOUNDER_VESTING +
414
DAO_IMMEDIATE +
415
DAO_VESTING == TOTAL_SUPPLY
416
);
417
418
// Sanity check: USDC allocations must equal goal
419
// v2.2 FIX: Removed DAO_USDC_ALLOCATION from this sum
420
assert(
421
LP_USDC_ALLOCATION +
422
COMPLIANCE_USDC_ALLOCATION +
423
DEV_USDC_ALLOCATION +
424
INFRA_USDC_ALLOCATION == GOAL
425
);
426
}
427
428
// ═══════════════════════════════════════════════════════════════════════════
429
// INTERNAL HELPERS
430
// ═══════════════════════════════════════════════════════════════════════════
431
432
/**
433
* @dev Check if the sale is ready to accept contributions
434
* v2: Returns false after finalization (cleaner semantics for UIs)
435
*/
436
function _isSaleReady() internal view returns (bool) {
437
// After finalization, the vault no longer holds TOTAL_SUPPLY
438
// Return true for "was ready, now complete" to avoid confusing UIs
439
if (finalized) return true;
440
return lockitToken.balanceOf(address(this)) >= TOTAL_SUPPLY;
441
}
442
443
/**
444
* @dev Check if the sale can currently accept contributions
445
* This is the actual "is open for business" check
446
*/
447
function _canAcceptContributions() internal view returns (bool) {
448
if (finalized) return false;
449
if (block.timestamp >= deadline) return false;
450
if (goalReachedAt != 0) return false; // v2: Goal reached = permanently closed
451
if (manualOverrideExecuted) return false; // v2: Override path = permanently closed
452
if (lockitToken.balanceOf(address(this)) < TOTAL_SUPPLY) return false;
453
return true;
454
}
455
456
/**
457
* @dev Blacklist-safe USDC transfer: attempts transfer, falls back to daoTreasury on failure
458
* @param _recipient Intended recipient
459
* @param _amount Amount to transfer
460
* @return actualRecipient The address that actually received the funds
461
*/
462
function _safeUSDCTransferOrFallback(address _recipient, uint256 _amount) internal returns (address actualRecipient) {
463
if (_recipient == address(0) || _recipient == daoTreasury) {
464
usdc.safeTransfer(daoTreasury, _amount);
465
return daoTreasury;
466
}
467
468
// Try low-level call to handle blacklist reverts
469
(bool success, bytes memory data) = address(usdc).call(
470
abi.encodeWithSelector(IERC20.transfer.selector, _recipient, _amount)
471
);
472
473
bool transferOk = _evaluateTransferResult(success, data);
474
475
if (transferOk) {
476
return _recipient;
477
} else {
478
// Fallback to treasury
479
usdc.safeTransfer(daoTreasury, _amount);
480
emit PaymentFailed(_recipient, _amount, daoTreasury);
481
return daoTreasury;
482
}
483
}
484
485
/**
486
* @dev Evaluate transfer result from low-level call
487
*/
488
function _evaluateTransferResult(bool success, bytes memory data) internal pure returns (bool) {
489
if (!success) return false;
490
if (data.length == 0) return true; // No return data = success (some tokens)
491
if (data.length == 32) return abi.decode(data, (bool)); // Standard ERC20 return
492
return false;
493
}
494
495
// ═══════════════════════════════════════════════════════════════════════════
496
// CONTRIBUTION FUNCTIONS
497
// ═══════════════════════════════════════════════════════════════════════════
498
499
/**
500
* @notice Contribute USDC to the token sale
501
* @param amount Amount of USDC to contribute (6 decimals)
502
*/
503
function contribute(uint256 amount) external nonReentrant {
504
_contribute(msg.sender, amount);
505
}
506
507
/**
508
* @notice Contribute with EIP-2612 permit (single transaction)
509
* @param amount Amount of USDC to contribute
510
* @param permitDeadline Permit signature deadline
511
* @param v Signature v
512
* @param r Signature r
513
* @param s Signature s
514
*/
515
function contributeWithPermit(
516
uint256 amount,
517
uint256 permitDeadline,
518
uint8 v,
519
bytes32 r,
520
bytes32 s
521
) external nonReentrant {
522
if (block.timestamp > permitDeadline) revert PermitDeadlineExpired();
523
524
IERC20Permit(address(usdc)).permit(
525
msg.sender,
526
address(this),
527
amount,
528
permitDeadline,
529
v,
530
r,
531
s
532
);
533
534
_contribute(msg.sender, amount);
535
}
536
537
/**
538
* @notice Internal contribution logic
539
* @dev v2 CHANGES:
540
* - Once goal has EVER been reached (goalReachedAt != 0), no new contributions
541
* - Once manual override is executed, no new contributions
542
* - This ensures monotonic sale lifecycle: Open → Goal Reached → Finalize/Refund
543
* - Prevents "re-opening" after refunds reduce totalRaised below GOAL
544
*/
545
function _contribute(address contributor, uint256 amount) internal {
546
// Verify tokens are deposited (prevents bricked sale if deployment botched)
547
if (lockitToken.balanceOf(address(this)) < TOTAL_SUPPLY) revert SaleNotOpen();
548
549
// Check sale is open - block contributions after deadline
550
if (finalized) revert SaleEnded();
551
if (block.timestamp >= deadline) revert SaleEnded();
552
553
// v2 FIX: Once goal has EVER been reached, no new contributions
554
// This prevents the "sale re-opens after refunds" bug
555
if (goalReachedAt != 0) revert GoalAlreadyMet();
556
557
// v2 FIX: Block contributions if override path is active (defense in depth)
558
if (manualOverrideExecuted) revert SaleEnded();
559
560
uint256 remaining = GOAL - totalRaised;
561
562
// Allow contribution below MIN if it exactly fills remaining capacity
563
// This prevents the "brick near goal" scenario where remaining < MIN_CONTRIBUTION
564
if (amount < MIN_CONTRIBUTION && amount != remaining) revert BelowMinContribution();
565
566
uint256 newTotal = contributions[contributor] + amount;
567
if (newTotal > MAX_CONTRIBUTION) revert ExceedsMaxContribution();
568
569
// Don't exceed goal
570
if (amount > remaining) revert ExceedsGoal();
571
572
// Transfer USDC from contributor
573
usdc.safeTransferFrom(contributor, address(this), amount);
574
575
// Track contribution
576
if (!hasContributed[contributor]) {
577
hasContributed[contributor] = true;
578
contributors.push(contributor);
579
}
580
contributions[contributor] = newTotal;
581
totalRaised += amount;
582
583
emit ContributionReceived(contributor, amount, newTotal);
584
585
// Record when goal was first reached (for emergency refund timing)
586
// This is the ONLY place goalReachedAt gets set, making it monotonic
587
if (totalRaised >= GOAL && goalReachedAt == 0) {
588
goalReachedAt = block.timestamp;
589
emit GoalReached(block.timestamp, contributors.length);
590
}
591
}
592
593
// ═══════════════════════════════════════════════════════════════════════════
594
// FINALIZATION
595
// ═══════════════════════════════════════════════════════════════════════════
596
597
/**
598
* @notice Finalize the sale when goal is met
599
* @dev Anyone can call this once goal is reached
600
* If LP creation fails, records attempt and may enable manual override
601
* After 5 failed attempts (100 blocks apart), manual override becomes available
602
* Uses blacklist-safe transfers for all USDC payouts
603
*
604
* v2.2 CHANGE: Removed USDC transfer to daoTreasury (only tokens now)
605
*/
606
function finalize() external nonReentrant {
607
if (finalized) revert AlreadyFinalized();
608
if (manualOverrideExecuted) revert UseCompleteFinalization();
609
if (totalRaised < GOAL) revert GoalNotMet();
610
611
// Check attempt spacing (100 blocks minimum between attempts)
612
if (failedAttempts > 0 && block.number < lastAttemptBlock + MIN_BLOCKS_BETWEEN_ATTEMPTS) {
613
revert TooSoonToRetry();
614
}
615
616
// Verify we have the tokens
617
uint256 tokenBalance = lockitToken.balanceOf(address(this));
618
if (tokenBalance < TOTAL_SUPPLY) revert InsufficientTokenBalance();
619
620
// Verify we have the USDC
621
uint256 usdcBalance = usdc.balanceOf(address(this));
622
if (usdcBalance < GOAL) revert InsufficientUSDCBalance();
623
624
// ─────────────────────────────────────────────────────────────────────
625
// STEP 1: Try to create permanent Uniswap V2 liquidity
626
// ─────────────────────────────────────────────────────────────────────
627
628
(bool lpSuccess, uint256 lpTokensBurned, string memory failReason) = _tryCreateAndBurnLP();
629
630
if (!lpSuccess) {
631
// Record failed attempt
632
lastAttemptBlock = block.number;
633
failedAttempts++;
634
635
emit FinalizeAttemptFailed(failedAttempts, block.number, failReason);
636
637
// Check if manual override should be enabled
638
if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
639
manualOverrideEnabled = true;
640
emit ManualOverrideEnabled(block.number, failedAttempts);
641
}
642
643
return; // Exit without reverting - attempt recorded
644
}
645
646
// LP succeeded - complete finalization
647
finalized = true;
648
649
// ─────────────────────────────────────────────────────────────────────
650
// STEP 2: Distribute TOKENS to fixed recipients
651
// ─────────────────────────────────────────────────────────────────────
652
653
// Founder allocations
654
lockitToken.safeTransfer(founderWallet, FOUNDER_IMMEDIATE);
655
lockitToken.safeTransfer(founderVestingContract, FOUNDER_VESTING);
656
657
// DAO allocations (TOKENS only - no USDC to DAO per Services Agreement)
658
lockitToken.safeTransfer(daoTreasury, DAO_IMMEDIATE);
659
lockitToken.safeTransfer(daoVestingContract, DAO_VESTING);
660
661
// ─────────────────────────────────────────────────────────────────────
662
// STEP 3: Distribute USDC to operational wallets (blacklist-safe)
663
// v2.2 FIX: Removed daoTreasury USDC transfer - only 3 recipients now
664
// ─────────────────────────────────────────────────────────────────────
665
666
_safeUSDCTransferOrFallback(complianceWallet, COMPLIANCE_USDC_ALLOCATION);
667
_safeUSDCTransferOrFallback(devWallet, DEV_USDC_ALLOCATION);
668
_safeUSDCTransferOrFallback(infraWallet, INFRA_USDC_ALLOCATION);
669
670
emit SaleFinalized(totalRaised, contributors.length, lpTokensBurned);
671
}
672
673
/**
674
* @notice Execute manual override after LP creation has failed multiple times
675
* @dev ONLY lpOpsWallet can call this (prevents griefing attacks)
676
* Sends LP allocation to lpOpsWallet for manual LP creation
677
* Requires full balances intact and blocks if emergency window open
678
*/
679
function executeManualOverride() external nonReentrant {
680
// CRITICAL: Only lpOpsWallet can execute override
681
// This prevents third parties from forcing the sale into 90% refund mode
682
if (msg.sender != lpOpsWallet) revert OnlyLpOpsWallet();
683
684
if (!manualOverrideEnabled) revert ManualOverrideNotEnabled();
685
if (manualOverrideExecuted) revert ManualOverrideAlreadyExecuted();
686
if (finalized) revert AlreadyFinalized();
687
688
// Prevent execution if emergency refund window is open
689
// This prevents the "partial refunds then override" trap
690
if (goalReachedAt != 0 && block.timestamp >= goalReachedAt + EMERGENCY_REFUND_DELAY) {
691
revert EmergencyWindowOpen();
692
}
693
694
// Require full balances (ensures no refunds have occurred)
695
if (usdc.balanceOf(address(this)) < GOAL) revert InsufficientUSDCBalance();
696
if (lockitToken.balanceOf(address(this)) < TOTAL_SUPPLY) revert InsufficientTokenBalance();
697
698
manualOverrideExecuted = true;
699
manualOverrideExecutedAt = block.timestamp;
700
701
// Send LP allocation to lpOpsWallet for manual LP creation
702
// This allows fast action without waiting for governance
703
lockitToken.safeTransfer(lpOpsWallet, LP_TOKEN_ALLOCATION);
704
usdc.safeTransfer(lpOpsWallet, LP_USDC_ALLOCATION);
705
706
emit ManualOverrideExecuted(LP_TOKEN_ALLOCATION, LP_USDC_ALLOCATION, block.timestamp);
707
}
708
709
/**
710
* @notice Complete finalization after manual override
711
* @dev Distributes remaining tokens and USDC after LP was manually created
712
* Anyone can call once override has been executed
713
* REQUIRES: LP tokens have been burned to BURN_ADDRESS (minimum threshold enforced)
714
* Uses blacklist-safe transfers for all USDC payouts
715
* BLOCKED if any override escape refunds have been claimed
716
*
717
* v2.2 CHANGE: Removed USDC transfer to daoTreasury
718
*/
719
function completeFinalization() external nonReentrant {
720
if (!manualOverrideExecuted) revert ManualOverrideNotExecuted();
721
if (finalized) revert AlreadyFinalized();
722
723
// Block if escape refunds have started - prevents mixed state
724
if (overrideEscapeActivated) revert OverrideEscapeActivated();
725
726
// ─────────────────────────────────────────────────────────────────────
727
// VERIFY LP WAS CREATED AND PROPERLY BURNED
728
// ─────────────────────────────────────────────────────────────────────
729
address pair = IUniswapV2Factory(uniswapFactory).getPair(address(lockitToken), address(usdc));
730
if (pair == address(0)) revert LPNotBurned();
731
732
// v2: Use distinct error for token mismatch
733
address t0 = IUniswapV2Pair(pair).token0();
734
address t1 = IUniswapV2Pair(pair).token1();
735
bool tokensMatch = (t0 == address(lockitToken) && t1 == address(usdc)) ||
736
(t0 == address(usdc) && t1 == address(lockitToken));
737
if (!tokensMatch) revert PairTokenMismatch();
738
739
// Verify LP tokens exist and substantial amount is burned
740
// Prevents "burn 1 wei and keep the rest" attack
741
uint256 burnedLP = IUniswapV2Pair(pair).balanceOf(BURN_ADDRESS);
742
uint256 minExpectedLP = _minExpectedInitialLiquidity();
743
if (burnedLP < minExpectedLP) revert InsufficientLPBurned();
744
745
// Store pair address for reference
746
lpPair = pair;
747
748
// ─────────────────────────────────────────────────────────────────────
749
// VERIFY REMAINING BALANCES
750
// v2.2 FIX: Updated required USDC calculation (no DAO allocation)
751
// ─────────────────────────────────────────────────────────────────────
752
uint256 requiredUSDC = GOAL - LP_USDC_ALLOCATION; // $450k
753
if (usdc.balanceOf(address(this)) < requiredUSDC) revert InsufficientUSDCBalance();
754
755
uint256 requiredLOCKIT = TOTAL_SUPPLY - LP_TOKEN_ALLOCATION; // 950M
756
if (lockitToken.balanceOf(address(this)) < requiredLOCKIT) revert InsufficientTokenBalance();
757
758
finalized = true;
759
760
// Distribute TOKENS to fixed recipients
761
lockitToken.safeTransfer(founderWallet, FOUNDER_IMMEDIATE);
762
lockitToken.safeTransfer(founderVestingContract, FOUNDER_VESTING);
763
lockitToken.safeTransfer(daoTreasury, DAO_IMMEDIATE);
764
lockitToken.safeTransfer(daoVestingContract, DAO_VESTING);
765
766
// Distribute remaining USDC (LP portion already sent in override) - blacklist-safe
767
// v2.2 FIX: Removed daoTreasury USDC transfer
768
_safeUSDCTransferOrFallback(complianceWallet, COMPLIANCE_USDC_ALLOCATION);
769
_safeUSDCTransferOrFallback(devWallet, DEV_USDC_ALLOCATION);
770
_safeUSDCTransferOrFallback(infraWallet, INFRA_USDC_ALLOCATION);
771
772
emit FinalizationCompleted(totalRaised, contributors.length);
773
}
774
775
/**
776
* @notice Calculate minimum expected LP tokens from a full first mint
777
* @dev Uniswap V2 first mint: liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY
778
* This is used to verify manual override burned sufficient LP
779
* @return Minimum expected LP tokens
780
*/
781
function _minExpectedInitialLiquidity() internal pure returns (uint256) {
782
// sqrt(50M tokens * 50k USDC) - 1000
783
// Note: different decimals but Math.sqrt handles the product correctly
784
uint256 root = Math.sqrt(LP_TOKEN_ALLOCATION * LP_USDC_ALLOCATION);
785
return root - UNISWAP_MINIMUM_LIQUIDITY;
786
}
787
788
/**
789
* @notice Try to create Uniswap V2 LP and burn the LP tokens
790
* @dev ATOMIC: All operations (transfer + mint + burn) are wrapped in an external
791
* self-call so they revert together if any step fails.
792
* This prevents the "tokens stranded in pair" brick scenario.
793
*
794
* For uninitialized pairs (totalSupply == 0):
795
* - Transfer tokens directly to pair, call mint()
796
* - Uniswap V2 first mint uses sqrt(amount0 * amount1) where amount = balance - reserve
797
* - Even if attacker sync'd dust, our amounts (50M + $50k) dominate
798
* - Attacker's dust gets absorbed, our ratio is preserved
799
*
800
* For initialized pairs (totalSupply > 0):
801
* - Someone already minted LP - this is a fundamental problem
802
* - Fail safely into refund/override path
803
*
804
* v2 FIX: If createPair() fails, re-check getPair() to handle front-running
805
*
806
* @return success Whether LP creation succeeded
807
* @return lpTokensBurned Amount of LP tokens burned (0 if failed)
808
* @return failReason Human-readable failure reason
809
*/
810
function _tryCreateAndBurnLP() internal returns (bool success, uint256 lpTokensBurned, string memory failReason) {
811
// ─────────────────────────────────────────────────────────────────────
812
// STEP 1: Get or create pair
813
// v2 FIX: Handle createPair() front-running by re-checking getPair()
814
// ─────────────────────────────────────────────────────────────────────
815
address pair = IUniswapV2Factory(uniswapFactory).getPair(address(lockitToken), address(usdc));
816
817
if (pair == address(0)) {
818
// Try to create the pair
819
try IUniswapV2Factory(uniswapFactory).createPair(address(lockitToken), address(usdc)) returns (address newPair) {
820
pair = newPair;
821
} catch {
822
// v2 FIX: createPair failed - maybe someone front-ran us
823
// Re-check getPair() to see if pair now exists
824
pair = IUniswapV2Factory(uniswapFactory).getPair(address(lockitToken), address(usdc));
825
if (pair == address(0)) {
826
// Pair truly doesn't exist and we can't create it
827
return (false, 0, "Pair creation failed");
828
}
829
// Pair exists now (someone else created it) - continue with it
830
}
831
}
832
833
// Store pair address
834
lpPair = pair;
835
836
// ─────────────────────────────────────────────────────────────────────
837
// STEP 2: Verify pair contains expected tokens
838
// ─────────────────────────────────────────────────────────────────────
839
address t0 = IUniswapV2Pair(pair).token0();
840
address t1 = IUniswapV2Pair(pair).token1();
841
bool tokensMatch = (t0 == address(lockitToken) && t1 == address(usdc)) ||
842
(t0 == address(usdc) && t1 == address(lockitToken));
843
if (!tokensMatch) {
844
return (false, 0, "Pair token mismatch");
845
}
846
847
// ─────────────────────────────────────────────────────────────────────
848
// STEP 3: Check if pair is already initialized
849
// ─────────────────────────────────────────────────────────────────────
850
uint256 existingSupply = IUniswapV2Pair(pair).totalSupply();
851
852
if (existingSupply > 0) {
853
// Pair already has LP tokens - someone already minted
854
// This is a fundamental issue - fail into refund/override path
855
return (false, 0, "Pair already has LP tokens");
856
}
857
858
// ─────────────────────────────────────────────────────────────────────
859
// STEP 4: Atomic mint and burn via self-call
860
// CRITICAL: This ensures if mint() fails, the transfers revert too
861
// ─────────────────────────────────────────────────────────────────────
862
try this.atomicMintAndBurnLP(pair) returns (uint256 liquidity) {
863
return (true, liquidity, "");
864
} catch Error(string memory reason) {
865
return (false, 0, reason);
866
} catch {
867
return (false, 0, "Mint/burn failed");
868
}
869
}
870
871
/**
872
* @notice Atomically transfer tokens to pair, mint LP, and burn LP
873
* @dev EXTERNAL so try/catch works - only callable by this contract itself.
874
* If any step fails, the entire call reverts, protecting funds.
875
* @param pair The Uniswap V2 pair address
876
* @return liquidity Amount of LP tokens minted and burned
877
*/
878
function atomicMintAndBurnLP(address pair) external returns (uint256 liquidity) {
879
// Only callable by this contract
880
if (msg.sender != address(this)) revert OnlySelf();
881
882
// Transfer both tokens to pair
883
lockitToken.safeTransfer(pair, LP_TOKEN_ALLOCATION);
884
usdc.safeTransfer(pair, LP_USDC_ALLOCATION);
885
886
// Mint LP to this vault
887
liquidity = IUniswapV2Pair(pair).mint(address(this));
888
if (liquidity == 0) revert ZeroLiquidityMinted();
889
890
emit LiquidityCreated(pair, liquidity);
891
892
// Burn LP by sending to dead address
893
IERC20(pair).safeTransfer(BURN_ADDRESS, liquidity);
894
895
emit LPTokensBurned(pair, liquidity);
896
}
897
898
// ═══════════════════════════════════════════════════════════════════════════
899
// TOKEN DISTRIBUTION (pull-based claim + batched push)
900
// ═══════════════════════════════════════════════════════════════════════════
901
902
/**
903
* @notice Claim your LOCKIT tokens after finalization (pull-based)
904
* @dev Contributors can call this directly without waiting for batch distribution
905
*/
906
function claim() external nonReentrant {
907
if (!finalized) revert NotFinalized();
908
if (tokensReceived[msg.sender]) revert AlreadyClaimed();
909
910
uint256 contribution = contributions[msg.sender];
911
if (contribution == 0) revert NoContribution();
912
913
tokensReceived[msg.sender] = true;
914
uint256 tokenAmount = _calculateTokens(contribution);
915
lockitToken.safeTransfer(msg.sender, tokenAmount);
916
917
emit TokensClaimed(msg.sender, tokenAmount);
918
}
919
920
/**
921
* @notice Distribute tokens to contributors in batches (push-based)
922
* @dev Anyone can call this to push tokens. Caller pays gas.
923
* @param count Number of contributors to process in this batch
924
*/
925
function distribute(uint256 count) external nonReentrant {
926
if (!finalized) revert NotFinalized();
927
if (distributionIndex >= contributors.length) revert DistributionComplete();
928
929
_distributeInternal(count);
930
}
931
932
/**
933
* @notice Internal distribution logic
934
*/
935
function _distributeInternal(uint256 count) internal {
936
uint256 startIndex = distributionIndex;
937
uint256 endIndex = startIndex + count;
938
if (endIndex > contributors.length) {
939
endIndex = contributors.length;
940
}
941
942
uint256 totalDistributed = 0;
943
944
for (uint256 i = startIndex; i < endIndex; i++) {
945
address contributor = contributors[i];
946
947
// Skip if already received (via claim() or previous distribution)
948
if (tokensReceived[contributor]) continue;
949
950
uint256 contribution = contributions[contributor];
951
952
// Skip zero contributions (from refunded contributors)
953
if (contribution == 0) continue;
954
955
uint256 tokenAmount = _calculateTokens(contribution);
956
957
tokensReceived[contributor] = true;
958
lockitToken.safeTransfer(contributor, tokenAmount);
959
960
totalDistributed += tokenAmount;
961
emit TokensDistributed(contributor, tokenAmount);
962
}
963
964
distributionIndex = endIndex;
965
emit BatchDistributed(startIndex, endIndex, totalDistributed);
966
}
967
968
/**
969
* @notice Distribute tokens to all remaining contributors
970
* @dev Convenience function - may run out of gas with many contributors
971
* If this reverts due to gas, use distribute(count) in batches
972
*/
973
function distributeAll() external nonReentrant {
974
if (!finalized) revert NotFinalized();
975
if (distributionIndex >= contributors.length) revert DistributionComplete();
976
977
_distributeInternal(contributors.length - distributionIndex);
978
}
979
980
/**
981
* @notice Calculate tokens for a given USDC contribution
982
* @param usdcAmount USDC contribution (6 decimals)
983
* @return Token amount (18 decimals)
984
*/
985
function _calculateTokens(uint256 usdcAmount) internal pure returns (uint256) {
986
// 1000 tokens per $1 USDC
987
// usdcAmount is in 1e6, tokens are in 1e18
988
// tokens = usdcAmount * 1000e18 / 1e6 = usdcAmount * 1000e12
989
return usdcAmount * TOKENS_PER_USDC / 1e6;
990
}
991
992
// ═══════════════════════════════════════════════════════════════════════════
993
// REFUNDS (if goal not met or emergency situations)
994
// ═══════════════════════════════════════════════════════════════════════════
995
996
/**
997
* @notice Claim refund if sale failed or emergency conditions are met
998
* @dev Two refund paths:
999
* 1. Normal: deadline passed AND goal not met
1000
* 2. Emergency: goal met but finalize stuck for 180 days after goalReachedAt
1001
* (INDEPENDENT of deadline - uses goalReachedAt timing)
1002
* Emergency refunds blocked if manual override executed (use overrideEscapeRefund instead)
1003
*/
1004
function refund() external nonReentrant {
1005
if (finalized) revert SaleEnded();
1006
1007
// Block if manual override was executed - must use overrideEscapeRefund instead
1008
if (manualOverrideExecuted) revert ManualOverrideAlreadyExecuted();
1009
1010
// Determine refund eligibility
1011
// Normal refund: deadline passed AND goal not met
1012
bool normalRefund = block.timestamp >= deadline && totalRaised < GOAL;
1013
1014
// Emergency refund: goal met but finalize stuck for 180 days
1015
// INDEPENDENT of deadline - this is the fix for the timing bug
1016
bool emergencyRefund = goalReachedAt != 0 &&
1017
block.timestamp >= goalReachedAt + EMERGENCY_REFUND_DELAY;
1018
1019
// Must qualify for either normal or emergency refund
1020
if (!normalRefund && !emergencyRefund) revert NotRefundable();
1021
1022
uint256 contribution = contributions[msg.sender];
1023
if (contribution == 0) revert NoContribution();
1024
1025
// Clear contribution state to prevent any future claims
1026
contributions[msg.sender] = 0;
1027
totalRaised -= contribution;
1028
1029
usdc.safeTransfer(msg.sender, contribution);
1030
1031
emit RefundClaimed(msg.sender, contribution);
1032
}
1033
1034
/**
1035
* @notice Emergency escape refund after manual override was executed but not completed
1036
* @dev If manual override was executed but completeFinalization() hasn't happened
1037
* after 90 days, contributors can claim prorated refunds from remaining USDC.
1038
* This prevents permanent stuck state if completeFinalization() fails.
1039
*
1040
* Prorated amount = contribution * (remaining USDC / original USDC before override)
1041
* Since LP allocation ($50k) was sent out, remaining is $450k of original $500k = 90%
1042
*
1043
* IMPORTANT: Once any escape refund is claimed, this activates overrideEscapeActivated
1044
* which permanently blocks completeFinalization() to prevent mixed states.
1045
* Contributors are fully exited (contribution zeroed, tokensReceived marked).
1046
*/
1047
function overrideEscapeRefund() external nonReentrant {
1048
// Must be in override-executed-but-not-completed state
1049
if (!manualOverrideExecuted) revert ManualOverrideNotExecuted();
1050
if (finalized) revert SaleEnded();
1051
1052
// Must wait 90 days after override was executed
1053
if (block.timestamp < manualOverrideExecutedAt + OVERRIDE_ESCAPE_DELAY) {
1054
revert OverrideEscapeNotAvailable();
1055
}
1056
1057
// Check contribution
1058
uint256 contribution = contributions[msg.sender];
1059
if (contribution == 0) revert NoContribution();
1060
1061
// Prevent double claim (check tokensReceived as well for defense in depth)
1062
if (tokensReceived[msg.sender]) revert AlreadyClaimed();
1063
1064
// Calculate prorated refund (90% since LP allocation was sent out)
1065
// This is (contribution * 450k / 500k) = contribution * 9 / 10
1066
uint256 proRataRefund = (contribution * (GOAL - LP_USDC_ALLOCATION)) / GOAL;
1067
1068
// Require sufficient balance - revert if vault doesn't have enough
1069
// This prevents the "partial refund then marked as claimed" fairness issue
1070
uint256 available = usdc.balanceOf(address(this));
1071
if (available < proRataRefund) revert InsufficientUSDCBalance();
1072
1073
// Activate escape mode on first escape refund - blocks completeFinalization() forever
1074
if (!overrideEscapeActivated) {
1075
overrideEscapeActivated = true;
1076
}
1077
1078
// Fully exit the contributor from the sale
1079
// This prevents double-dipping (refund now + claim tokens later)
1080
contributions[msg.sender] = 0;
1081
tokensReceived[msg.sender] = true; // Defense in depth - blocks claim() too
1082
totalRaised -= contribution; // Keep accounting clean
1083
1084
usdc.safeTransfer(msg.sender, proRataRefund);
1085
1086
emit OverrideEscapeRefundClaimed(msg.sender, proRataRefund);
1087
}
1088
1089
// ═══════════════════════════════════════════════════════════════════════════
1090
// TOKEN RESCUE (for accidentally sent tokens - NOT LOCKIT or USDC)
1091
// ═══════════════════════════════════════════════════════════════════════════
1092
1093
/**
1094
* @notice Rescue accidentally sent tokens (NOT LOCKIT or USDC)
1095
* @dev Anyone can call - tokens go to DAO treasury
1096
* LOCKIT and USDC are intentionally non-rescuable to maintain trust model
1097
* @param _token Address of token to rescue
1098
*/
1099
function rescueTokens(address _token) external nonReentrant {
1100
if (_token == address(lockitToken)) revert CannotRescueLOCKIT();
1101
if (_token == address(usdc)) revert CannotRescueUSDC();
1102
1103
IERC20 token = IERC20(_token);
1104
uint256 balance = token.balanceOf(address(this));
1105
if (balance == 0) revert NoTokensToRescue();
1106
1107
token.safeTransfer(daoTreasury, balance);
1108
1109
emit TokensRescued(_token, balance, daoTreasury);
1110
}
1111
1112
// ═══════════════════════════════════════════════════════════════════════════
1113
// VIEW FUNCTIONS
1114
// ═══════════════════════════════════════════════════════════════════════════
1115
1116
/**
1117
* @notice Get the number of contributors
1118
*/
1119
function getContributorCount() external view returns (uint256) {
1120
return contributors.length;
1121
}
1122
1123
/**
1124
* @notice Check if sale is ready (tokens deposited)
1125
* @dev v2: Returns true after finalization (was ready, now complete)
1126
*/
1127
function isSaleReady() external view returns (bool ready, uint256 currentBalance, uint256 required) {
1128
uint256 balance = lockitToken.balanceOf(address(this));
1129
bool isReady = _isSaleReady();
1130
return (isReady, balance, TOTAL_SUPPLY);
1131
}
1132
1133
/**
1134
* @notice Get contribution for an address
1135
*/
1136
function getContribution(address contributor) external view returns (uint256) {
1137
return contributions[contributor];
1138
}
1139
1140
/**
1141
* @notice Calculate tokens that will be received for a contribution amount
1142
*/
1143
function calculateTokensForContribution(uint256 usdcAmount) external pure returns (uint256) {
1144
return _calculateTokens(usdcAmount);
1145
}
1146
1147
/**
1148
* @notice Get remaining capacity before goal is met
1149
*/
1150
function getRemainingCapacity() external view returns (uint256) {
1151
if (totalRaised >= GOAL) return 0;
1152
return GOAL - totalRaised;
1153
}
1154
1155
/**
1156
* @notice Check if an address can still contribute and how much
1157
* @dev v2: Returns 0 if goalReachedAt != 0 (goal ever reached = closed forever)
1158
*/
1159
function getContributionRoom(address contributor) external view returns (uint256) {
1160
// Use the comprehensive check
1161
if (!_canAcceptContributions()) return 0;
1162
1163
uint256 currentContribution = contributions[contributor];
1164
uint256 walletRoom = MAX_CONTRIBUTION - currentContribution;
1165
uint256 saleRoom = GOAL - totalRaised;
1166
1167
return walletRoom < saleRoom ? walletRoom : saleRoom;
1168
}
1169
1170
/**
1171
* @notice Get the minimum contribution amount for current state
1172
* @dev Returns the smaller of MIN_CONTRIBUTION or remaining capacity
1173
* v2: Returns 0 if goal ever reached
1174
*/
1175
function getEffectiveMinContribution() external view returns (uint256) {
1176
if (!_canAcceptContributions()) return 0;
1177
1178
uint256 remaining = GOAL - totalRaised;
1179
return remaining < MIN_CONTRIBUTION ? remaining : MIN_CONTRIBUTION;
1180
}
1181
1182
/**
1183
* @notice Get claimable token amount for an address
1184
* @dev Returns 0 if not finalized, already claimed, or no contribution
1185
*/
1186
function getClaimableAmount(address contributor) external view returns (uint256) {
1187
if (!finalized) return 0;
1188
if (tokensReceived[contributor]) return 0;
1189
1190
uint256 contribution = contributions[contributor];
1191
if (contribution == 0) return 0;
1192
1193
return _calculateTokens(contribution);
1194
}
1195
1196
/**
1197
* @notice Get distribution progress
1198
*/
1199
function getDistributionProgress() external view returns (
1200
uint256 distributed,
1201
uint256 total,
1202
bool complete
1203
) {
1204
return (
1205
distributionIndex,
1206
contributors.length,
1207
distributionIndex >= contributors.length
1208
);
1209
}
1210
1211
/**
1212
* @notice Check if a contributor has received their tokens
1213
*/
1214
function hasReceivedTokens(address contributor) external view returns (bool) {
1215
return tokensReceived[contributor];
1216
}
1217
1218
/**
1219
* @notice Get time remaining until deadline
1220
*/
1221
function getTimeRemaining() external view returns (uint256) {
1222
if (block.timestamp >= deadline) return 0;
1223
return deadline - block.timestamp;
1224
}
1225
1226
/**
1227
* @notice Check refund eligibility for an address
1228
* @dev Emergency refunds available 180 days after goal reached (INDEPENDENT of deadline)
1229
* Emergency refunds blocked if manual override was executed (use getOverrideEscapeStatus)
1230
* @return canRefund Whether the address can currently claim a refund via refund()
1231
* @return isEmergency Whether this would be an emergency refund
1232
* @return contribution Amount that would be refunded
1233
* @return timeUntilRefundable Seconds until some refund becomes available (0 if available now)
1234
*/
1235
function getRefundStatus(address contributor) external view returns (
1236
bool canRefund,
1237
bool isEmergency,
1238
uint256 contribution,
1239
uint256 timeUntilRefundable
1240
) {
1241
contribution = contributions[contributor];
1242
1243
// Can't refund if finalized, no contribution, or manual override executed
1244
if (finalized || contribution == 0 || manualOverrideExecuted) {
1245
return (false, false, contribution, 0);
1246
}
1247
1248
// Normal refund: deadline passed AND goal not met
1249
if (block.timestamp >= deadline && totalRaised < GOAL) {
1250
return (true, false, contribution, 0);
1251
}
1252
1253
// Emergency refund: goal met but finalize stuck for 180 days (independent of deadline)
1254
uint256 emergencyTime = goalReachedAt != 0 ? goalReachedAt + EMERGENCY_REFUND_DELAY : 0;
1255
if (emergencyTime != 0 && block.timestamp >= emergencyTime) {
1256
return (true, true, contribution, 0);
1257
}
1258
1259
// Calculate time until some refund is available
1260
if (totalRaised < GOAL) {
1261
// Waiting for deadline (normal refund path)
1262
timeUntilRefundable = block.timestamp < deadline ? deadline - block.timestamp : 0;
1263
} else if (emergencyTime != 0) {
1264
// Waiting for emergency window
1265
timeUntilRefundable = block.timestamp < emergencyTime ? emergencyTime - block.timestamp : 0;
1266
}
1267
1268
return (false, false, contribution, timeUntilRefundable);
1269
}
1270
1271
/**
1272
* @notice Check override escape refund eligibility
1273
* @return canClaim Whether override escape refund is available
1274
* @return proRataAmount Refund amount (90% of contribution)
1275
* @return timeUntilAvailable Seconds until escape becomes available (0 if available now)
1276
*/
1277
function getOverrideEscapeStatus(address contributor) external view returns (
1278
bool canClaim,
1279
uint256 proRataAmount,
1280
uint256 timeUntilAvailable
1281
) {
1282
uint256 contribution = contributions[contributor];
1283
1284
// Calculate prorated amount
1285
proRataAmount = (contribution * (GOAL - LP_USDC_ALLOCATION)) / GOAL;
1286
1287
// Check conditions - tokensReceived also blocks (defense in depth)
1288
if (!manualOverrideExecuted || finalized || contribution == 0 || tokensReceived[contributor]) {
1289
return (false, proRataAmount, 0);
1290
}
1291
1292
uint256 escapeTime = manualOverrideExecutedAt + OVERRIDE_ESCAPE_DELAY;
1293
if (block.timestamp >= escapeTime) {
1294
return (true, proRataAmount, 0);
1295
}
1296
1297
return (false, proRataAmount, escapeTime - block.timestamp);
1298
}
1299
1300
/**
1301
* @notice Get finalization and manual override status
1302
* @return attempts Number of failed finalize attempts
1303
* @return lastBlock Block number of last attempt
1304
* @return blocksUntilRetry Blocks remaining before next attempt allowed (0 if ready)
1305
* @return overrideEnabled Whether manual override is available
1306
* @return overrideExecuted Whether manual override has been used
1307
* @return overrideExecutedAt_ Timestamp when override was executed (0 if not)
1308
* @return escapeActivated Whether override escape refunds have started (blocks completeFinalization)
1309
* @return isFinalized Whether sale has been finalized
1310
*/
1311
function getFinalizeStatus() external view returns (
1312
uint256 attempts,
1313
uint256 lastBlock,
1314
uint256 blocksUntilRetry,
1315
bool overrideEnabled,
1316
bool overrideExecuted,
1317
uint256 overrideExecutedAt_,
1318
bool escapeActivated,
1319
bool isFinalized
1320
) {
1321
attempts = failedAttempts;
1322
lastBlock = lastAttemptBlock;
1323
1324
if (failedAttempts > 0 && block.number < lastAttemptBlock + MIN_BLOCKS_BETWEEN_ATTEMPTS) {
1325
blocksUntilRetry = (lastAttemptBlock + MIN_BLOCKS_BETWEEN_ATTEMPTS) - block.number;
1326
} else {
1327
blocksUntilRetry = 0;
1328
}
1329
1330
overrideEnabled = manualOverrideEnabled;
1331
overrideExecuted = manualOverrideExecuted;
1332
overrideExecutedAt_ = manualOverrideExecutedAt;
1333
escapeActivated = overrideEscapeActivated;
1334
isFinalized = finalized;
1335
}
1336
1337
/**
1338
* @notice Get sale status
1339
* @dev v2: isOpen properly reflects monotonic lifecycle (closed once goal reached)
1340
*/
1341
function getSaleStatus() external view returns (
1342
bool isReady,
1343
bool isOpen,
1344
bool isFinalized,
1345
bool goalMet,
1346
bool deadlinePassed,
1347
uint256 raised,
1348
uint256 goal,
1349
uint256 contributorCount
1350
) {
1351
bool _isReady = _isSaleReady();
1352
bool _goalMet = goalReachedAt != 0; // v2: Use goalReachedAt for definitive "goal ever met"
1353
bool _deadlinePassed = block.timestamp >= deadline;
1354
bool _isOpen = _canAcceptContributions(); // v2: Use comprehensive check
1355
1356
return (
1357
_isReady,
1358
_isOpen,
1359
finalized,
1360
_goalMet,
1361
_deadlinePassed,
1362
totalRaised,
1363
GOAL,
1364
contributors.length
1365
);
1366
}
1367
1368
/**
1369
* @notice Get all recipient addresses for verification
1370
* @dev v2.2: Renamed legalWallet → complianceWallet
1371
*/
1372
function getRecipients() external view returns (
1373
address founder,
1374
address founderVesting,
1375
address dao,
1376
address daoVesting,
1377
address compliance,
1378
address dev,
1379
address infra,
1380
address lpOps
1381
) {
1382
return (
1383
founderWallet,
1384
founderVestingContract,
1385
daoTreasury,
1386
daoVestingContract,
1387
complianceWallet,
1388
devWallet,
1389
infraWallet,
1390
lpOpsWallet
1391
);
1392
}
1393
1394
/**
1395
* @notice Get token allocation breakdown
1396
* @dev Useful for UI verification
1397
*/
1398
function getTokenAllocations() external pure returns (
1399
uint256 sale,
1400
uint256 lp,
1401
uint256 founderImmediate,
1402
uint256 founderVesting,
1403
uint256 daoImmediate,
1404
uint256 daoVesting
1405
) {
1406
return (
1407
SALE_ALLOCATION,
1408
LP_TOKEN_ALLOCATION,
1409
FOUNDER_IMMEDIATE,
1410
FOUNDER_VESTING,
1411
DAO_IMMEDIATE,
1412
DAO_VESTING
1413
);
1414
}
1415
1416
/**
1417
* @notice Get USDC allocation breakdown
1418
* @dev v2.2 FIX: Removed DAO allocation, renamed legal → compliance
1419
*/
1420
function getUSDCAllocations() external pure returns (
1421
uint256 lp,
1422
uint256 compliance,
1423
uint256 dev,
1424
uint256 infra
1425
) {
1426
return (
1427
LP_USDC_ALLOCATION,
1428
COMPLIANCE_USDC_ALLOCATION,
1429
DEV_USDC_ALLOCATION,
1430
INFRA_USDC_ALLOCATION
1431
);
1432
}
1433
1434
/**
1435
* @notice Get goal reached timestamp
1436
* @dev Returns 0 if goal not yet reached
1437
*/
1438
function getGoalReachedAt() external view returns (uint256) {
1439
return goalReachedAt;
1440
}
1441
1442
/**
1443
* @notice Get LP pair address
1444
* @dev Returns address(0) if LP not yet created
1445
*/
1446
function getLPPair() external view returns (address) {
1447
return lpPair;
1448
}
1449
1450
/**
1451
* @notice Get minimum expected LP tokens from first mint
1452
* @dev Used for verifying manual override burned sufficient LP
1453
*/
1454
function getMinExpectedLP() external pure returns (uint256) {
1455
return _minExpectedInitialLiquidity();
1456
}
1457
1458
/**
1459
* @notice Get token price in USDC
1460
* @dev Returns the price per token in USDC with 6 decimals
1461
* Price = $0.001 per token = 1000 USDC units (1e3) per 1e18 tokens
1462
*/
1463
function getTokenPrice() external pure returns (uint256 pricePerToken, string memory priceString) {
1464
// 1 token (1e18) costs 1e6 / 1000 = 1000 USDC units = $0.001
1465
return (1e6 / 1000, "$0.001");
1466
}
1467
}