diff --git a/agent/flow-trace/02_TOKENS_AND_ACTIVATION.md b/agent/flow-trace/02_TOKENS_AND_ACTIVATION.md index f2b057c0d..00315123b 100644 --- a/agent/flow-trace/02_TOKENS_AND_ACTIVATION.md +++ b/agent/flow-trace/02_TOKENS_AND_ACTIVATION.md @@ -16,11 +16,60 @@ Before a node can register, it must stake two types of collateral: ┌───────────────────────────────────────────────────────────┐ │ InterfoldToken (INTF) │ │ ERC20 + ERC20Permit + ERC20Votes + AccessControl │ +│ + Ownable2Step │ │ │ │ MAX_SUPPLY: 1,200,000,000 (1.2B with 18 decimals) │ -│ Roles: MINTER_ROLE can mint via mintAllocation() │ -│ Transfer restrictions: when transfersRestricted=true, │ -│ only whitelisted addresses can transfer │ +│ Immutables: CCA_START, CCA_END, CLAIM_SOURCE, │ +│ BONDING_REGISTRY (set at construction) │ +│ │ +│ Lifecycle phases (derived from CCA window + TGE): │ +│ Virtual → PublicSale → Cooldown → Live │ +│ - Virtual: mint() + mintAllocations() allowed │ +│ - PublicSale: CCA bidding window │ +│ - Cooldown: CCA ended, TGE not yet called │ +│ - Live: TGE fired (permissionless after cooldown) │ +│ │ +│ Minting (Virtual phase only): │ +│ - mint(recipient, amount, label) │ +│ DEFAULT_ADMIN_ROLE — unlocked tokens │ +│ - mintAllocations(MintAllocation[]) │ +│ MINTER_ROLE — tokens locked under a policy │ +│ │ +│ Pre-TGE transfer gate (phase-based, automatic): │ +│ Allowed: bonding registry, claim source, whitelisted │ +│ Blocked: all other transfers │ +│ Once TGE fires, all transfers unrestricted │ +│ │ +│ Lock system (wallet-level pooled enforcement): │ +│ - createLockPolicy(id, LockPolicy) → write-once │ +│ LOCK_MANAGER_ROLE │ +│ - linkClaim(account, amount, policyId) │ +│ LOCK_MANAGER_ROLE │ +│ - LockPolicy: { holdUntil, Curve { anchor, start, │ +│ cliffDuration, vestDuration } } │ +│ - Anchor: Absolute (fixed start) | Tge (tgeTimestamp) │ +│ - PENDING_LOCK_POLICY_ID for unclassified claims │ +│ - Queued locks consumed by later claims (linkClaim) │ +│ │ +│ Lock invariant for transfers: │ +│ transferable = balance - max(0, lockedBalance - │ +│ BONDING_REGISTRY.totalBonded(account)) │ +│ Transfer reverts with InsufficientUnlockedBalance │ +│ if value > transferable │ +│ │ +│ Lock sunset (NO_MORE_LOCKS, immutable): │ +│ - Absolute timestamp set at deployment │ +│ - createLockPolicy rejects any policy that could │ +│ outlast the sunset (curves and holdUntil) │ +│ - From NO_MORE_LOCKS on, _update skips all lock │ +│ accounting (vanilla ERC20); PENDING locks die too │ +│ │ +│ Whitelisting: │ +│ - setTransferWhitelisted(addr, bool) │ +│ WHITELIST_ROLE — pre-TGE transfer gate │ +│ - setLockWhitelisted(addr, bool) │ +│ LOCK_MANAGER_ROLE — exempt from claim-source locks │ +│ │ │ Used as: LICENSE BOND token │ └───────────────────────────────────────────────────────────┘ @@ -65,13 +114,16 @@ User runs: interfold ciphernode license bond --amount 50000 │ │ │ │ │ │ │ bondLicense(uint256 amount) { │ │ │ │ 1. require(amount > 0) │ -│ │ │ 2. licenseToken.safeTransferFrom( │ +│ │ │ 2. operators[msg.sender].licenseBond += amount │ +│ │ │ → totalBonded(msg.sender) now includes amount │ +│ │ │ 3. licenseToken.safeTransferFrom( │ │ │ │ msg.sender, // from operator │ │ │ │ address(this), // to BondingRegistry │ │ │ │ amount │ │ │ │ ) │ +│ │ │ → INTF _update can see the pre-recorded bond │ +│ │ │ and enforce locked-floor accounting │ │ │ │ → INTF tokens move from operator → contract │ -│ │ │ 3. operators[msg.sender].licenseBond += amount │ │ │ │ 4. _updateOperatorStatus(msg.sender) │ │ │ │ → May activate if all conditions now met │ │ │ │ 5. Emit LicenseBondUpdated(msg.sender, newBond) │ @@ -81,6 +133,14 @@ User runs: interfold ciphernode license bond --amount 50000 └─ OUTPUT: "Transaction hash: 0x..." ``` +### Locked INTF bonding + +`BondingRegistry.totalBonded(account)` returns active INTF license bond plus pending INTF exits that +remain slashable/not returned. `InterfoldToken` uses this view for pooled wallet-level locks, so +locked INTF can be self-bonded by the same account without becoming transferable. Delegated +source-aware bonding is not part of the pooled-lock model; license bonds are credited to +`msg.sender` through `bondLicense(amount)`. + ### Activation check after bonding: ``` @@ -190,17 +250,8 @@ User runs: interfold ciphernode license unbond --amount 10000 │ │ │ 4. _exits.queueLicensesForExit( │ │ │ │ msg.sender, exitDelay, amount │ │ │ │ ) │ -│ │ │ │ │ -│ │ │ │ ┌─ ExitQueueLib ─────────────────────────┐ │ -│ │ │ │ │ Creates ExitTranche { │ │ -│ │ │ │ │ unlockTimestamp: now + exitDelay, │ │ -│ │ │ │ │ ticketAmount: 0, │ │ -│ │ │ │ │ licenseAmount: 10000 │ │ -│ │ │ │ │ } │ │ -│ │ │ │ │ Merges into last tranche if same │ │ -│ │ │ │ │ unlock time, else appends new tranche │ │ -│ │ │ │ │ Updates pendingTotals │ │ -│ │ │ │ └────────────────────────────────────────┘ │ +│ │ │ → Pending INTF still counts in totalBonded() │ +│ │ │ until claimed or slashed │ │ │ │ 5. _updateOperatorStatus(msg.sender) │ │ │ │ → May DEACTIVATE if bond drops below threshold │ │ │ │ 6. Emit LicenseBondUpdated(msg.sender, newBond) │ @@ -268,9 +319,9 @@ User runs: interfold ciphernode license claim [--max-ticket 50] [--max-license 1 │ │ ┌─── ON-CHAIN ─────────────────────────────────────────┐ │ │ │ │ │ │ │ claimExits(maxTicket, maxLicense) { │ -│ │ │ 1. (ticketAmount, licenseAmount) = │ +│ │ │ 1. (ticketAmount, _) = │ │ │ │ _exits.claimAssets( │ -│ │ │ msg.sender, maxTicket, maxLicense │ +│ │ │ msg.sender, maxTicket, 0 │ │ │ │ ) │ │ │ │ │ │ │ │ │ │ ┌─ ExitQueueLib.claimAssets() ───────────┐ │ @@ -278,7 +329,7 @@ User runs: interfold ciphernode license claim [--max-ticket 50] [--max-license 1 │ │ │ │ │ for each tranche where │ │ │ │ │ │ │ block.timestamp >= unlockTimestamp: │ │ │ │ │ │ │ take min(wanted, available) │ │ -│ │ │ │ │ from ticketAmount & licenseAmount │ │ +│ │ │ │ │ from ticketAmount │ │ │ │ │ │ │ Skip locked tranches (future unlock) │ │ │ │ │ │ │ Clean up empty tranches │ │ │ │ │ │ │ Update pendingTotals │ │ @@ -294,15 +345,18 @@ User runs: interfold ciphernode license claim [--max-ticket 50] [--max-license 1 │ │ │ │ │ underlying.safeTransfer(to, amount) │ │ │ │ │ │ └────────────────────────────────────────┘ │ │ │ │ │ -│ │ │ 3. if licenseAmount > 0: │ -│ │ │ licenseToken.safeTransfer( │ -│ │ │ msg.sender, licenseAmount │ +│ │ │ 3. licenseAmount = _claimLicenseExits( │ +│ │ │ msg.sender, maxLicense │ │ │ │ ) │ -│ │ │ → INTF tokens returned to operator │ +│ │ │ → Each INTF source pays its withdrawalAddress │ +│ │ │ → Receiver callback gets (operator, amount, │ +│ │ │ sourceId) when supported │ +│ │ │ → Pending INTF is removed from totalBonded() │ +│ │ │ as returned INTF reaches the wallet │ │ │ │ } │ │ │ └───────────────────────────────────────────────────────┘ │ -└─ Operator receives back their USDC and/or INTF tokens +└─ Operator receives back USDC; INTF goes to each source's withdrawal address ``` --- @@ -346,7 +400,7 @@ active = registered CLAIM EXITS ─────────── After exitDelay seconds: - INTF → returned from ExitQueue + INTF → returned to source withdrawal address USDC → paid out from ITK.payableBalance ``` @@ -380,20 +434,37 @@ The token contracts were hardened against the following audit findings. All chan - **M-29 — EIP-6372 timestamp clock.** `clock() = uint48(block.timestamp)`, `CLOCK_MODE() = "mode=timestamp"`. -### InterfoldToken (INTF) - -- **H-15 — WHITELIST_ROLE separation + one-way disable.** New `WHITELIST_ROLE` gates - `toggleTransferWhitelist` and `whitelistContracts`, decoupling whitelist edits from `MINTER_ROLE`. - `disableTransferRestrictions` is `DEFAULT_ADMIN_ROLE` only and idempotent (silent no-op when - already disabled) so deployment/setup scripts can call it unconditionally. -- **M-21 — per-epoch mint cap.** New rolling cap configured via - `setMintCap(epochLength, capPerEpoch)` (`ZeroEpochLength` on zero length). Both `mintAllocation` - and `batchMintAllocations` route through `_accountForMintAgainstCap`, which rolls the epoch - (`MintEpochRolled(newStart)`) and reverts `ExceedsMintCap` on overflow. Constructor defaults to a - 30-day epoch with `cap = MAX_SUPPLY` so bootstrap deployments keep working; governance is expected - to tighten this before broad distribution. -- **M-29 — EIP-6372 timestamp clock.** Same timestamp clock as ITK, aligning INTF voting checkpoints - with timepoints used elsewhere. +### InterfoldToken (INTF) — Complete Rewrite + +The INTF token was rewritten to implement a CCA-auction-aligned lifecycle with wallet-level lock +enforcement based on immutable policy curves. Key changes: + +- **Phase-based lifecycle.** The token derives its phase from immutable `CCA_START` / `CCA_END` and + the one-way `tge()` call: Virtual → PublicSale → Cooldown → Live. Minting is gated to Virtual + phase only; TGE is permissionless after `CCA_END + TGE_COOLDOWN` (45 days). The pre-TGE transfer + gate automatically lifts at TGE — no `disableTransferRestrictions` / `transfersRestricted` flag. +- **Pre-TGE transfer gate.** Before TGE, only bonding-registry transfers, claim-source + distributions, and whitelisted addresses can transfer. Bonding is always allowed so operators can + stake during Virtual phase. +- **Immutable constructor parameters.** `CCA_START`, `CCA_END`, `CLAIM_SOURCE`, and + `BONDING_REGISTRY` are set at construction and cannot change. The BondingRegistry must be deployed + first (or a placeholder used and fixed via `setLicenseToken`). +- **Lock policy system.** `createLockPolicy(id, LockPolicy)` creates write-once policies with + `Curve { anchor (Absolute|Tge), start, cliffDuration, vestDuration }` and optional `holdUntil`. + `linkClaim(account, amount, policyId)` classifies pending claim-source tokens under a real policy. + `PENDING_LOCK_POLICY_ID` holds unclassified claim tokens until linked. +- **Pooled wallet enforcement.** `lockedBalanceOf(account)` sums active locks (including PENDING). + `transferableBalanceOf(account) = balance - max(0, locked - BONDING_REGISTRY.totalBonded(account))`. + Transfers that exceed the transferable balance revert with `InsufficientUnlockedBalance`. +- **Claim-source auto-lock.** Tokens arriving from `CLAIM_SOURCE` are automatically locked as + PENDING unless the recipient is in `lockWhitelist`. `linkClaim` moves PENDING to a real policy and + queues unfilled amounts for future claims. +- **EIP-6372 timestamp clock.** `clock()` returns `block.timestamp`, `CLOCK_MODE()` is + `"mode=timestamp"`. +- **Minting.** `mint(recipient, amount, label)` (DEFAULT_ADMIN_ROLE, unlocked) and + `mintAllocations(MintAllocation[])` (MINTER_ROLE, locked to a policy) are both Virtual-only. +- **Ownership.** `renounceOwnership()` is disabled. Two-step ownership transfer via Ownable2Step + syncs all AccessControl roles atomically. ### Registry coordination diff --git a/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md b/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md index a629618b5..ebdfbcd23 100644 --- a/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md +++ b/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md @@ -661,15 +661,19 @@ _executeSlash(proposalId): │ │ │ │ ┌─── BondingRegistry.slashLicenseBond() ───────────────┐ │ │ │ │ -│ │ │ 1. Slash from ACTIVE bond first: │ -│ │ │ slashFromActive = min(amount, licenseBond) │ -│ │ │ operators[op].licenseBond -= slashFromActive │ +│ │ │ 1. Compute active + pending INTF source total │ │ │ │ │ -│ │ │ 2. Remaining from EXIT QUEUE: │ -│ │ │ _exits.slashPendingAssets( │ -│ │ │ operator, 0, remaining, │ -│ │ │ includeLockedAssets=true │ -│ │ │ ) │ +│ │ │ 2. _slashLicenseSourcesLifo(operator, amount): │ +│ │ │ Compare newest active source sequence with │ +│ │ │ newest pending-exit source sequence │ +│ │ │ Slash the newest source first │ +│ │ │ → Active slash decrements operators[op].licenseBond│ +│ │ │ → Pending slash decrements pending license totals │ +│ │ │ → totalBonded(op) drops immediately; if op has │ +│ │ │ token-level locks, same-wallet INTF may become │ +│ │ │ encumbered until the locked floor decays/top-up │ +│ │ │ → Receiver callback gets (operator, amount, │ +│ │ │ sourceId) when supported │ │ │ │ │ │ │ │ 3. slashedLicenseBond += totalSlashed │ │ │ │ 4. _updateOperatorStatus(operator) │ diff --git a/agent/flow-trace/06_DEACTIVATION_AND_COMPLETION.md b/agent/flow-trace/06_DEACTIVATION_AND_COMPLETION.md index 3cab9725e..066ecf78f 100644 --- a/agent/flow-trace/06_DEACTIVATION_AND_COMPLETION.md +++ b/agent/flow-trace/06_DEACTIVATION_AND_COMPLETION.md @@ -54,6 +54,8 @@ User runs: interfold ciphernode deactivate --license 20000 │ │ 1. require(amount != 0, sufficient bonded INTF) │ │ │ 2. operators[op].licenseBond -= 20000 │ │ │ 3. _exits.queueLicensesForExit(op, exitDelay, 20000)│ + │ │ → Pending INTF remains in totalBonded(op) for │ + │ │ token-level locked-floor accounting │ │ │ 4. _updateOperatorStatus(operator) │ │ │ → If licenseBond < │ │ │ (licenseRequiredBond * licenseActiveBps / 10000)│ @@ -72,8 +74,8 @@ User runs: interfold ciphernode deactivate --tickets 50 --license 20000 │ ├─ Calls removeTicketBalance(50) first └─ Then calls unbondLicense(20000) - → Both queued in ExitQueue with same exitDelay - → May merge into single tranche if same unlock time + → Tickets are queued in ExitQueueLib + → INTF is queued in ExitQueueLib pending license exits and remains counted in totalBonded() ``` --- @@ -109,8 +111,9 @@ User runs: interfold ciphernode deregister │ │ _exits.queueAssetsForExit( │ │ │ op, exitDelay, │ │ │ fullTicketBalance, // tickets │ - │ │ licenseBondAmount // license │ + │ │ 0 // license handled below │ │ │ ) │ + │ │ _queueLicenseExitFromSources(op, licenseBondAmount)│ │ │ │ │ │ 8. Remove from Merkle tree: │ │ │ registry.removeCiphernode(msg.sender) │ @@ -277,7 +280,8 @@ Time ───────────────────────── │ │ (configured) │ │ │ Assets queued │ │ Assets claimable │ │ ITK burned │ Cannot cancel │ USDC returned │ -│ INTF locked │ Can be slashed! │ INTF returned │ +│ INTF locked │ Can be slashed! │ INTF returned to │ +│ │ │ withdrawal addr │ │ │ │ │ IMPORTANT: Even during the exit delay, slashing can still diff --git a/crates/init/src/lib.rs b/crates/init/src/lib.rs index 9d4b4051d..cb5563e15 100644 --- a/crates/init/src/lib.rs +++ b/crates/init/src/lib.rs @@ -306,13 +306,12 @@ pub async fn execute( ) -> Result<()> { println!( r" - ░██████████ ░██ - ░██ ░██ - ░██ ░████████ ░███████ ░██ ░██████ ░██ ░██ ░███████ - ░█████████ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ - ░██ ░██ ░██ ░██ ░██ ░███████ ░██ ░██ ░█████████ - ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██░██ ░██ - ░██████████ ░██ ░██ ░███████ ░██ ░█████░██ ░███ ░███████ + ██╗███╗ ██╗████████╗███████╗██████╗ ███████╗ ██████╗ ██╗ ██████╗ + ██║████╗ ██║╚══██╔══╝██╔════╝██╔══██╗██╔════╝██╔═══██╗██║ ██╔══██╗ + ██║██╔██╗ ██║ ██║ █████╗ ██████╔╝█████╗ ██║ ██║██║ ██║ ██║ + ██║██║╚██╗██║ ██║ ██╔══╝ ██╔══██╗██╔══╝ ██║ ██║██║ ██║ ██║ + ██║██║ ╚████║ ██║ ███████╗██║ ██║██║ ╚██████╔╝███████╗██████╔╝ + ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═════╝ ╚══════╝╚═════╝ " ); diff --git a/docs/pages/noir-circuits.mdx b/docs/pages/noir-circuits.mdx index 57c8a4b01..c92d062de 100644 --- a/docs/pages/noir-circuits.mdx +++ b/docs/pages/noir-circuits.mdx @@ -90,9 +90,9 @@ pnpm check:committee # verify active.nr, utils.ts, p **Preset** (`insecure-512` | `secure-8192`) selects the BFV parameter set; **committee** (`minimum` | `micro` | `small`) selects `(N, T, H)` for secret sharing. Always switch committee via `pnpm build:circuits --committee ` — see -[`scripts/README.md`](https://github.com/gnosisguild/enclave/blob/main/scripts/README.md#circuit-builder) +[`scripts/README.md`](https://github.com/gnosisguild/interfold/blob/main/scripts/README.md#circuit-builder) and -[`circuits/benchmarks/README.md`](https://github.com/gnosisguild/enclave/blob/main/circuits/benchmarks/README.md). +[`circuits/benchmarks/README.md`](https://github.com/gnosisguild/interfold/blob/main/circuits/benchmarks/README.md). Generate per-circuit witness/config artifacts with `zk_cli` (must pass matching `--committee`): diff --git a/examples/CRISP/interfold.config.yaml b/examples/CRISP/interfold.config.yaml index 2b1eb47aa..3a11c747e 100644 --- a/examples/CRISP/interfold.config.yaml +++ b/examples/CRISP/interfold.config.yaml @@ -11,6 +11,9 @@ chains: address: "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" deploy_block: 14 ciphernode_registry: + address: "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707" + deploy_block: 8 + bonding_registry: address: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853" deploy_block: 9 bonding_registry: diff --git a/examples/CRISP/packages/crisp-contracts/deployed_contracts.json b/examples/CRISP/packages/crisp-contracts/deployed_contracts.json index fdfc8b084..37f3dc87a 100644 --- a/examples/CRISP/packages/crisp-contracts/deployed_contracts.json +++ b/examples/CRISP/packages/crisp-contracts/deployed_contracts.json @@ -1,154 +1,7 @@ { - "sepolia": { - "PoseidonT3": { - "blockNumber": 10939899, - "address": "0x3333333C0A88F9BE4fd23ed0536F9B6c427e3B93" - }, - "MockUSDC": { - "constructorArgs": { - "initialSupply": "1000000" - }, - "blockNumber": 10939865, - "address": "0x2721Cdf281d40744aD567cBf3e7100F60bcbAE79" - }, - "InterfoldToken": { - "constructorArgs": { - "owner": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B" - }, - "blockNumber": 10939866, - "address": "0xbAb3220FD04a193a192F07879673597Cd695Cb03" - }, - "InterfoldTicketToken": { - "constructorArgs": { - "baseToken": "0x2721Cdf281d40744aD567cBf3e7100F60bcbAE79", - "registry": "0x0000000000000000000000000000000000000001", - "owner": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B" - }, - "blockNumber": 10939867, - "address": "0x2446f2AC9632f17af96053e48dEDff44b50711Ea" - }, - "SlashingManager": { - "constructorArgs": { - "initialDelay": "172800", - "admin": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B" - }, - "blockNumber": 10939868, - "address": "0x0553387EE0992Fe339579728B6c777164fD1de40" - }, - "CiphernodeRegistryOwnable": { - "constructorArgs": { - "owner": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B", - "submissionWindow": "10" - }, - "proxyRecords": { - "initData": "0xcd6dc6870000000000000000000000008837e47c4bb520ade83aab761c3b60679443af1b000000000000000000000000000000000000000000000000000000000000000a", - "initialOwner": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B", - "proxyAddress": "0x4D707127F72a216EA116AF0B4262dD7382F84259", - "proxyAdminAddress": "0x080DD900b6471f5850105E9023AEcbF5857B6bCB", - "implementationAddress": "0xE5E77dE44e71a535D8376dB650447ab9AB3ADE92" - }, - "blockNumber": 10939869, - "address": "0x4D707127F72a216EA116AF0B4262dD7382F84259" - }, - "BondingRegistry": { - "constructorArgs": { - "owner": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B", - "ticketToken": "0x2446f2AC9632f17af96053e48dEDff44b50711Ea", - "licenseToken": "0xbAb3220FD04a193a192F07879673597Cd695Cb03", - "registry": "0x4D707127F72a216EA116AF0B4262dD7382F84259", - "slashedFundsTreasury": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B", - "ticketPrice": "10000000", - "licenseRequiredBond": "100000000000000000000", - "minTicketBalance": "1", - "exitDelay": "604800" - }, - "proxyRecords": { - "initData": "0x7333fa820000000000000000000000008837e47c4bb520ade83aab761c3b60679443af1b0000000000000000000000002446f2ac9632f17af96053e48dedff44b50711ea000000000000000000000000bab3220fd04a193a192f07879673597cd695cb030000000000000000000000004d707127f72a216ea116af0b4262dd7382f842590000000000000000000000008837e47c4bb520ade83aab761c3b60679443af1b00000000000000000000000000000000000000000000000000000000009896800000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000093a80", - "initialOwner": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B", - "proxyAddress": "0xb9b64c5e0a30f38ed33760f299613087aAe87283", - "proxyAdminAddress": "0x2db58f52cA92516d079042Bc14D3C55c9839794e", - "implementationAddress": "0x871E4390995301A0918b1f559b0f97782063D682" - }, - "blockNumber": 10939870, - "address": "0xb9b64c5e0a30f38ed33760f299613087aAe87283" - }, - "Interfold": { - "constructorArgs": { - "owner": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B", - "registry": "0x4D707127F72a216EA116AF0B4262dD7382F84259", - "bondingRegistry": "0xb9b64c5e0a30f38ed33760f299613087aAe87283", - "e3RefundManager": "0x0000000000000000000000000000000000000001", - "feeToken": "0x2721Cdf281d40744aD567cBf3e7100F60bcbAE79", - "maxDuration": "2592000", - "timeoutConfig": "{\"dkgWindow\":7200,\"computeWindow\":86400,\"decryptionWindow\":3600}" - }, - "proxyRecords": { - "initData": "0x4d600e5d0000000000000000000000008837e47c4bb520ade83aab761c3b60679443af1b0000000000000000000000004d707127f72a216ea116af0b4262dd7382f84259000000000000000000000000b9b64c5e0a30f38ed33760f299613087aae8728300000000000000000000000000000000000000000000000000000000000000010000000000000000000000002721cdf281d40744ad567cbf3e7100f60bcbae790000000000000000000000000000000000000000000000000000000000278d000000000000000000000000000000000000000000000000000000000000001c2000000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000000000000000000e10", - "initialOwner": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B", - "proxyAddress": "0x670eFE043d1D340148037b4b76c4F9dfED294309", - "proxyAdminAddress": "0xA2C502EaDeEa534A6a772656a339570381fCB9b5", - "implementationAddress": "0x2cb325A86A543A39752405588c9D59c23c0ea7B8" - }, - "blockNumber": 10939874, - "address": "0x670eFE043d1D340148037b4b76c4F9dfED294309" - }, - "E3RefundManager": { - "constructorArgs": { - "owner": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B", - "interfold": "0x670eFE043d1D340148037b4b76c4F9dfED294309", - "treasury": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B" - }, - "proxyRecords": { - "initData": "0xc0c53b8b0000000000000000000000008837e47c4bb520ade83aab761c3b60679443af1b000000000000000000000000670efe043d1d340148037b4b76c4f9dfed2943090000000000000000000000008837e47c4bb520ade83aab761c3b60679443af1b", - "initialOwner": "0x8837e47c4Bb520ADE83AAB761C3B60679443af1B", - "proxyAddress": "0xD327ec50C7a8909f90DE15fe53872e88B4948Ee0", - "proxyAdminAddress": "0x4CfC7BCA035bFA28be27fB5aeBc87bDA86E7e240", - "implementationAddress": "0xcfD77dB6899Df39969Db2B3f69eA755cFefAf68d" - }, - "blockNumber": 10939876, - "address": "0xD327ec50C7a8909f90DE15fe53872e88B4948Ee0" - }, - "MockComputeProvider": { - "blockNumber": 10939891, - "address": "0x1A5BFB6b2d26E2EBeA6fd92B29Ab392Ab961b04F" - }, - "MockDecryptionVerifier": { - "blockNumber": 10939892, - "address": "0x52E2e2d903d68d9611599d05bA9E856805bA563F" - }, - "MockPkVerifier": { - "blockNumber": 10939893, - "address": "0x3d9979A65A36B1A339f213f0Ae545555AebD16DD" - }, - "MockE3Program": { - "blockNumber": 10939894, - "address": "0xB37D0DFc2967423786466489A5E44fe6b3b0c116" - }, - "MockRISC0Verifier": { - "address": "0x05c415E39AF18B2cc118fFC98258afe38636EAb0", - "blockNumber": 10939931 - }, - "HonkVerifier": { - "address": "0x70AA319AA8a305Eb65a78b0791b2526BE7193Cfa", - "blockNumber": 10939932 - }, - "CRISPProgram": { - "address": "0xbCc418F4dd1266Cc6070b1e2AC728ef56De946e7", - "blockNumber": 10939932, - "constructorArgs": { - "interfold": "0x670eFE043d1D340148037b4b76c4F9dfED294309", - "verifierAddress": "0x05c415E39AF18B2cc118fFC98258afe38636EAb0", - "honkVerifierAddress": "0x70AA319AA8a305Eb65a78b0791b2526BE7193Cfa", - "imageId": "0x23734b77b0f76e85623a88d7a82f24c34c94834f2501964ea123b7a2027013a2" - } - }, - "MockVotingToken": { - "address": "0x4Ef697B1bE877384b0f205205a8EDA49E09b63ae", - "blockNumber": 10939934 - } - }, "localhost": { "PoseidonT3": { + "blockNumber": 3, "blockNumber": 3, "address": "0x3333333C0A88F9BE4fd23ed0536F9B6c427e3B93" }, @@ -157,6 +10,7 @@ "initialSupply": "1000000" }, "blockNumber": 4, + "blockNumber": 4, "address": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" }, "InterfoldToken": { @@ -164,6 +18,7 @@ "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }, "blockNumber": 5, + "blockNumber": 5, "address": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0" }, "InterfoldTicketToken": { @@ -173,6 +28,7 @@ "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }, "blockNumber": 7, + "blockNumber": 7, "address": "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9" }, "SlashingManager": { @@ -181,6 +37,7 @@ "admin": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }, "blockNumber": 8, + "blockNumber": 8, "address": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707" }, "CiphernodeRegistryOwnable": { @@ -191,19 +48,20 @@ "proxyRecords": { "initData": "0xcd6dc687000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000000000000000000000000000000000000000000a", "initialOwner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "proxyAddress": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", - "proxyAdminAddress": "0x9bd03768a7DCc129555dE410FF8E85528A4F88b5", - "implementationAddress": "0x0165878A594ca255338adfa4d48449f69242Eb8F" + "proxyAddress": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707", + "proxyAdminAddress": "0x61c36a8d610163660E21a8b7359e1Cac0C9133e1", + "implementationAddress": "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9" }, "blockNumber": 9, + "blockNumber": 9, "address": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853" }, "BondingRegistry": { "constructorArgs": { "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "ticketToken": "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", - "licenseToken": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0", - "registry": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", + "ticketToken": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0", + "licenseToken": "0x0000000000000000000000000000000000000000", + "registry": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707", "slashedFundsTreasury": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "ticketPrice": "10000000", "licenseRequiredBond": "100000000000000000000", @@ -211,66 +69,88 @@ "exitDelay": "604800" }, "proxyRecords": { - "initData": "0x7333fa82000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000dc64a140aa3e981100a9beca4e685f962f0cf6c90000000000000000000000009fe46736679d2d9a65f0992f2272de9f3c7fa6e0000000000000000000000000a513e6e4b8f2a923d98304ec87f64353c4d5c853000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000009896800000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000093a80", + "initData": "0x7333fa82000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000009fe46736679d2d9a65f0992f2272de9f3c7fa6e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005fc8d32690cc91d4c39d9d3abcbd16989f875707000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000009896800000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000093a80", "initialOwner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "proxyAddress": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", - "proxyAdminAddress": "0x8aCd85898458400f7Db866d53FCFF6f0D49741FF", - "implementationAddress": "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6" + "proxyAddress": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", + "proxyAdminAddress": "0x9bd03768a7DCc129555dE410FF8E85528A4F88b5", + "implementationAddress": "0x0165878A594ca255338adfa4d48449f69242Eb8F" + }, + "blockNumber": 9, + "address": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853" + }, + "InterfoldToken": { + "constructorArgs": { + "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "ccaStart": "1781296729", + "ccaEnd": "1781901529", + "claimSource": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "bondingRegistry": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853" }, "blockNumber": 10, + "blockNumber": 10, "address": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318" }, "Interfold": { "constructorArgs": { "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "registry": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", - "bondingRegistry": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + "registry": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707", + "bondingRegistry": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", "e3RefundManager": "0x0000000000000000000000000000000000000001", "feeToken": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", "maxDuration": "2592000", "timeoutConfig": "{\"dkgWindow\":7200,\"computeWindow\":86400,\"decryptionWindow\":3600}" }, "proxyRecords": { - "initData": "0x4d600e5d000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000a513e6e4b8f2a923d98304ec87f64353c4d5c8530000000000000000000000008a791620dd6260079bf849dc5567adc3f2fdc3180000000000000000000000000000000000000000000000000000000000000001000000000000000000000000e7f1725e7734ce288f8367e1bb143e90bb3f05120000000000000000000000000000000000000000000000000000000000278d000000000000000000000000000000000000000000000000000000000000001c2000000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000000000000000000e10", + "initData": "0x4d600e5d000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000005fc8d32690cc91d4c39d9d3abcbd16989f875707000000000000000000000000a513e6e4b8f2a923d98304ec87f64353c4d5c8530000000000000000000000000000000000000000000000000000000000000001000000000000000000000000e7f1725e7734ce288f8367e1bb143e90bb3f05120000000000000000000000000000000000000000000000000000000000278d000000000000000000000000000000000000000000000000000000000000001c2000000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000000000000000000e10", "initialOwner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "proxyAddress": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", - "proxyAdminAddress": "0x1F708C24a0D3A740cD47cC0444E9480899f3dA7D", - "implementationAddress": "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e" + "proxyAddress": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", + "proxyAdminAddress": "0x32467b43BFa67273FC7dDda0999Ee9A12F2AaA08", + "implementationAddress": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" }, "blockNumber": 14, + "blockNumber": 14, "address": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" }, "E3RefundManager": { "constructorArgs": { "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "interfold": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", + "interfold": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", "treasury": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }, "proxyRecords": { - "initData": "0xc0c53b8b000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000a51c1fc2f0d1a1b8494ed1fe312d7c3a78ed91c0000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "initData": "0xc0c53b8b000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000dcd1bf9a1b36ce34237eeafef220932846bcd82000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266", "initialOwner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "proxyAddress": "0x9A676e781A523b5d0C0e43731313A708CB607508", - "proxyAdminAddress": "0x8e80FFe6Dc044F4A766Afd6e5a8732Fe0977A493", - "implementationAddress": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" + "proxyAddress": "0x0B306BF915C4d645ff596e518fAf3F9669b97016", + "proxyAdminAddress": "0x524F04724632eED237cbA3c37272e018b3A7967e", + "implementationAddress": "0x9A676e781A523b5d0C0e43731313A708CB607508" }, "blockNumber": 16, + "blockNumber": 16, "address": "0x9A676e781A523b5d0C0e43731313A708CB607508" }, "MockComputeProvider": { "blockNumber": 30, "address": "0x36C02dA8a0983159322a80FFE9F24b1acfF8B570" + "blockNumber": 30, + "address": "0x36C02dA8a0983159322a80FFE9F24b1acfF8B570" }, "MockDecryptionVerifier": { "blockNumber": 31, "address": "0x809d550fca64d94Bd9F66E60752A544199cfAC3D" + "blockNumber": 31, + "address": "0x809d550fca64d94Bd9F66E60752A544199cfAC3D" }, "MockPkVerifier": { "blockNumber": 32, "address": "0x4c5859f0F772848b2D91F1D83E2Fe57935348029" + "blockNumber": 32, + "address": "0x4c5859f0F772848b2D91F1D83E2Fe57935348029" }, "MockE3Program": { "blockNumber": 33, "address": "0x1291Be112d480055DaFd8a610b7d1e203891C274" + "blockNumber": 33, + "address": "0x1291Be112d480055DaFd8a610b7d1e203891C274" }, "MockRISC0Verifier": { "address": "0x82e01223d51Eb87e16A03E24687EDF0F294da6f1", @@ -280,19 +160,29 @@ "address": "0x7969c5eD335650692Bc04293B07F5BF2e7A673C0", "blockNumber": 38 }, + "HonkVerifier": { + "address": "0x7bc06c482DEAd17c0e297aFbC32f6e63d3846650", + "blockNumber": 40 + }, "CRISPProgram": { + "address": "0x7bc06c482DEAd17c0e297aFbC32f6e63d3846650", + "blockNumber": 38, "address": "0x7bc06c482DEAd17c0e297aFbC32f6e63d3846650", "blockNumber": 38, "constructorArgs": { "interfold": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", "verifierAddress": "0x82e01223d51Eb87e16A03E24687EDF0F294da6f1", "honkVerifierAddress": "0x7969c5eD335650692Bc04293B07F5BF2e7A673C0", + "verifierAddress": "0x82e01223d51Eb87e16A03E24687EDF0F294da6f1", + "honkVerifierAddress": "0x7969c5eD335650692Bc04293B07F5BF2e7A673C0", "imageId": "0x0ad904cfaec1eeefa9b89a11020086ec51454423db1fee3b1ab614fff97368d6" } }, "MockVotingToken": { "address": "0xFD471836031dc5108809D173A067e8486B9047A3", "blockNumber": 40 + "address": "0xFD471836031dc5108809D173A067e8486B9047A3", + "blockNumber": 40 } } } \ No newline at end of file diff --git a/examples/CRISP/server/.env.example b/examples/CRISP/server/.env.example index 9f1006157..8c800f6df 100644 --- a/examples/CRISP/server/.env.example +++ b/examples/CRISP/server/.env.example @@ -15,10 +15,10 @@ CRON_API_KEY=1234567890 # Interfold stack: updated automatically on each `pnpm dev:up` deploy (do not edit by hand unless debugging). # Stale E3_PROGRAM_ADDRESS causes requestE3 to revert with empty data `0x`. -INTERFOLD_ADDRESS=0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 +INTERFOLD_ADDRESS=0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 FEE_TOKEN_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 -E3_PROGRAM_ADDRESS=0x162A433068F51e18b7d13932F27e66a3f99E6890 -CIPHERNODE_REGISTRY_ADDRESS=0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 +E3_PROGRAM_ADDRESS=0xc351628EB244ec633d5f21fBD6621e1a683B1181 +CIPHERNODE_REGISTRY_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 # CRISP voting eligibility token (MockVotingToken) — NOT the fee token above CRISP_VOTING_TOKEN=0x5081a39b8A5f0E35a8D959395a630b68B74Dd30f diff --git a/packages/interfold-contracts/README.md b/packages/interfold-contracts/README.md index 74dccb696..e3e6b3611 100644 --- a/packages/interfold-contracts/README.md +++ b/packages/interfold-contracts/README.md @@ -56,6 +56,11 @@ directory, as well as to the `deployed_contracts.json` file. Be sure to configure your desired network in `hardhat.config.ts` before deploying. +For non-local networks, set `INTERFOLD_TGE_TIMESTAMP` to the agreed INTF TGE +Unix timestamp before deploying. The deployment script configures this on +`InterfoldToken` for token-level lock schedules. Local mock deployments default +this timestamp to the latest local block timestamp. + ## Localhost deployment If you are running Interfold locally, you can first start a local hardhat (or diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json b/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json index 6b909a814..df44e9161 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json @@ -1024,6 +1024,25 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "totalBonded", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -1062,5 +1081,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/IBondingRegistry.sol", - "buildInfoId": "solc-0_8_28-bc3ca5094033f5b4b0cf838691e05da99cc1561b" + "buildInfoId": "solc-0_8_28-131befb7b6efbc9af996a8f87e4762d0d84ba904" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json b/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json index a1cfe8c6e..4d5a4c982 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json @@ -1332,5 +1332,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ICiphernodeRegistry.sol", - "buildInfoId": "solc-0_8_28-bc3ca5094033f5b4b0cf838691e05da99cc1561b" + "buildInfoId": "solc-0_8_28-131befb7b6efbc9af996a8f87e4762d0d84ba904" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json b/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json index 46d394020..6dc1dc71e 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json @@ -2427,5 +2427,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/IInterfold.sol", - "buildInfoId": "solc-0_8_28-bc3ca5094033f5b4b0cf838691e05da99cc1561b" + "buildInfoId": "solc-0_8_28-131befb7b6efbc9af996a8f87e4762d0d84ba904" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json b/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json index d5552587a..24271bce1 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json @@ -1233,5 +1233,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ISlashingManager.sol", - "buildInfoId": "solc-0_8_28-bc3ca5094033f5b4b0cf838691e05da99cc1561b" + "buildInfoId": "solc-0_8_28-131befb7b6efbc9af996a8f87e4762d0d84ba904" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json b/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json index 5a7fc43f1..6111a835f 100644 --- a/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json +++ b/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json @@ -1486,5 +1486,5 @@ ] }, "inputSourceName": "project/contracts/token/InterfoldTicketToken.sol", - "buildInfoId": "solc-0_8_28-bc3ca5094033f5b4b0cf838691e05da99cc1561b" + "buildInfoId": "solc-0_8_28-131befb7b6efbc9af996a8f87e4762d0d84ba904" } \ No newline at end of file diff --git a/packages/interfold-contracts/contracts/interfaces/IBondingRegistry.sol b/packages/interfold-contracts/contracts/interfaces/IBondingRegistry.sol index c83dc4900..0b9cbec71 100644 --- a/packages/interfold-contracts/contracts/interfaces/IBondingRegistry.sol +++ b/packages/interfold-contracts/contracts/interfaces/IBondingRegistry.sol @@ -213,6 +213,14 @@ interface IBondingRegistry { */ function getLicenseBond(address operator) external view returns (uint256); + /** + * @notice Get INTF that still counts toward an account's locked-floor collateral. + * @dev Includes active license bond plus pending INTF exits that remain slashable/not returned. + * @param account Account/operator whose INTF bond credit is queried + * @return Active plus pending license-bond amount + */ + function totalBonded(address account) external view returns (uint256); + /** * @notice Get current ticket price * @return Price per ticket in collateral token units diff --git a/packages/interfold-contracts/contracts/registry/BondingRegistry.sol b/packages/interfold-contracts/contracts/registry/BondingRegistry.sol index 995f9ffad..277672b15 100644 --- a/packages/interfold-contracts/contracts/registry/BondingRegistry.sol +++ b/packages/interfold-contracts/contracts/registry/BondingRegistry.sol @@ -248,6 +248,12 @@ contract BondingRegistry is return operators[operator].licenseBond; } + /// @inheritdoc IBondingRegistry + function totalBonded(address account) external view returns (uint256) { + (, uint256 pendingLicense) = _exits.getPendingAmounts(account); + return operators[account].licenseBond + pendingLicense; + } + /// @inheritdoc IBondingRegistry function availableTickets( address operator @@ -276,7 +282,7 @@ contract BondingRegistry is function pendingExits( address operator ) external view returns (uint256 ticket, uint256 license) { - return _exits.getPendingAmounts(operator); + (ticket, license) = _exits.getPendingAmounts(operator); } /// @notice Preview how much an operator can currently claim @@ -286,7 +292,7 @@ contract BondingRegistry is function previewClaimable( address operator ) external view returns (uint256 ticket, uint256 license) { - return _exits.previewClaimableAmounts(operator); + (ticket, license) = _exits.previewClaimableAmounts(operator); } /// @inheritdoc IBondingRegistry @@ -444,29 +450,13 @@ contract BondingRegistry is function bondLicense( uint256 amount ) external nonReentrant noExitInProgress(msg.sender) { - require(amount != 0, ZeroAmount()); - - uint256 balanceBefore = licenseToken.balanceOf(address(this)); - licenseToken.safeTransferFrom(msg.sender, address(this), amount); - uint256 actualReceived = licenseToken.balanceOf(address(this)) - - balanceBefore; - - operators[msg.sender].licenseBond += actualReceived; - - emit LicenseBondUpdated( - msg.sender, - int256(actualReceived), - operators[msg.sender].licenseBond, - REASON_BOND - ); - - _updateOperatorStatus(msg.sender); + _bondLicense(msg.sender, amount); } /// @inheritdoc IBondingRegistry function unbondLicense( uint256 amount - ) external noExitInProgress(msg.sender) { + ) external nonReentrant noExitInProgress(msg.sender) { require(amount != 0, ZeroAmount()); require( operators[msg.sender].licenseBond >= amount, @@ -494,7 +484,7 @@ contract BondingRegistry is function claimExits( uint256 maxTicketAmount, uint256 maxLicenseAmount - ) external { + ) external nonReentrant { (uint256 ticketClaim, uint256 licenseClaim) = _exits.claimAssets( msg.sender, maxTicketAmount, @@ -571,7 +561,7 @@ contract BondingRegistry is address operator, uint256 requestedSlashAmount, bytes32 slashReason - ) external onlySlashingManager { + ) external onlySlashingManager nonReentrant { require(requestedSlashAmount != 0, ZeroAmount()); Operator storage operatorData = operators[operator]; @@ -585,24 +575,26 @@ contract BondingRegistry is if (actualSlashAmount == 0) return; - // Slash from active balance first - uint256 slashedFromActiveBalance = Math.min( + uint256 activeSlashAmount = Math.min( actualSlashAmount, operatorData.licenseBond ); - if (slashedFromActiveBalance > 0) { - operatorData.licenseBond -= slashedFromActiveBalance; + if (activeSlashAmount != 0) { + operatorData.licenseBond -= activeSlashAmount; } - // Slash remaining amount from pending queue - uint256 remainingToSlash = actualSlashAmount - slashedFromActiveBalance; - if (remainingToSlash > 0) { - _exits.slashPendingAssets( + uint256 remainingSlashAmount = actualSlashAmount - activeSlashAmount; + if (remainingSlashAmount != 0) { + (, uint256 pendingSlashed) = _exits.slashPendingAssets( operator, - 0, // ticketAmount - remainingToSlash, + 0, + remainingSlashAmount, true ); + require( + pendingSlashed == remainingSlashAmount, + InsufficientBalance() + ); } slashedLicenseBond += actualSlashAmount; @@ -827,6 +819,28 @@ contract BondingRegistry is // Internal Functions // ====================== + function _bondLicense(address operator, uint256 amount) internal { + require(operator != address(0), ZeroAddress()); + require(amount != 0, ZeroAmount()); + + operators[operator].licenseBond += amount; + + uint256 balanceBefore = licenseToken.balanceOf(address(this)); + licenseToken.safeTransferFrom(operator, address(this), amount); + uint256 actualReceived = licenseToken.balanceOf(address(this)) - + balanceBefore; + require(actualReceived == amount, InvalidAmount()); + + emit LicenseBondUpdated( + operator, + int256(amount), + operators[operator].licenseBond, + REASON_BOND + ); + + _updateOperatorStatus(operator); + } + /// @dev Updates operator's active status based on current conditions /// @dev Operator is active if: registered, has minimum license bond, and has minimum tickets /// @param operator Address of the operator to update diff --git a/packages/interfold-contracts/contracts/test/MockBondingRegistry.sol b/packages/interfold-contracts/contracts/test/MockBondingRegistry.sol new file mode 100644 index 000000000..c491804d5 --- /dev/null +++ b/packages/interfold-contracts/contracts/test/MockBondingRegistry.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. +pragma solidity 0.8.28; + +/// @notice Minimal mock BondingRegistry for standalone InterfoldToken tests. +/// Returns 0 for totalBonded so locked-balance enforcement works +/// without a full system deployment. +contract MockBondingRegistry { + function totalBonded( + address /* account */ + ) external pure returns (uint256) { + return 0; + } +} diff --git a/packages/interfold-contracts/contracts/token/InterfoldToken.sol b/packages/interfold-contracts/contracts/token/InterfoldToken.sol index 2f124dc66..67c222b3e 100644 --- a/packages/interfold-contracts/contracts/token/InterfoldToken.sol +++ b/packages/interfold-contracts/contracts/token/InterfoldToken.sol @@ -20,22 +20,14 @@ import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol"; +import { IBondingRegistry } from "../interfaces/IBondingRegistry.sol"; + /** * @title InterfoldToken - * @notice The governance and utility token for the Interfold protocol - * @dev ERC20 token with voting capabilities, permit functionality, and controlled minting. - * - * Roles: - * - DEFAULT_ADMIN_ROLE manages role assignments and can {disableTransferRestrictions}. - * - MINTER_ROLE can call {mintAllocation} / {batchMintAllocations} up to MAX_SUPPLY. - * - WHITELIST_ROLE can manage the transfer whitelist independently from minting so - * the same account is not required to control both surfaces. + * @notice The governance and utility token for the Interfold protocol, with + * wallet-level lock enforcement designed around the Uniswap CCA + * distribution flow. * - * Transfer restrictions are a one-way switch: once {disableTransferRestrictions} is called - * they cannot be re-enabled. - * - * Voting uses {block.timestamp} (EIP-6372 "mode=timestamp") so timepoints align with other - * Interfold contracts. */ contract InterfoldToken is ERC20, @@ -44,256 +36,979 @@ contract InterfoldToken is Ownable2Step, AccessControl { - /// @notice Thrown when {renounceOwnership} is called. Ownership is - /// critical for protocol governance; renouncing would permanently - /// freeze admin functions and is disallowed. - error RenounceOwnershipDisabled(); - // Custom errors + // ───────────────────────────────────────────────────────────────────────── + // Types + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Global token lifecycle phase, derived from the immutable CCA + /// window and the TGE: Virtual (pre-sale), CCA (CCA + /// bidding window), Cooldown (sale ended, TGE not yet fired), + /// Live (TGE fired). + enum Phase { + Virtual, + CCA, + Cooldown, + Live + } + + enum Anchor { + Absolute, + Tge + } + + /// @param anchor How the curve's start resolves (Absolute or Tge). + /// @param start Anchor timestamp when {anchor} is Absolute; must be zero + /// when {anchor} is Tge. + /// @param cliffDuration Seconds after the anchor before anything releases. + /// @param vestDuration Total linear release duration measured from the + /// anchor; zero means everything releases at the cliff. + struct Curve { + Anchor anchor; + uint64 start; + uint64 cliffDuration; + uint64 vestDuration; + } + + /// @notice Token-level lock policy with a single unlock curve. + /// @dev This token enforces token unlock schedules only. Service vesting + /// schedules that continue after TGE may live in a separate vesting + /// contract. + /// @param holdUntil Optional absolute timestamp before which nothing is + /// transferable, whatever the unlock curve has accrued; + /// @param unlock Unlock curve. + struct LockPolicy { + uint64 holdUntil; + Curve unlock; + } + + struct Lock { + bytes32 policyId; + uint256 amount; + } - /// @notice Thrown when a zero address is provided where a valid address is required + struct MintAllocation { + address recipient; + uint256 amount; + bytes32 policyId; + bytes32 label; + } + + // ───────────────────────────────────────────────────────────────────────── + // Errors + // ───────────────────────────────────────────────────────────────────────── + + /// @notice A zero address was provided where a valid address is required. error ZeroAddress(); - /// @notice Thrown when attempting to mint zero tokens + /// @notice A zero amount or zero timestamp was provided where a non-zero + /// value is required. error ZeroAmount(); - /// @notice Thrown when minting would exceed the maximum token supply - error ExceedsTotalSupply(); + /// @notice Minting would exceed {MAX_SUPPLY}. + error MaxSupplyExceeded(); + + /// @notice The transfer is not one of the movements allowed pre-TGE: + /// bonding (the registry on either side) or a CCA distribution + /// ({CLAIM_SOURCE} sending, any phase). + error TransferRestricted(address from, address to); + + /// @notice {mint} or {mintAllocations} was called after the Virtual phase; the + /// full supply is distributed before {CCA_START}. + error MintingClosed(); + + /// @notice {tge} was called but the token is already live. + error AlreadyLive(); + + /// @notice {tge} was called before {CCA_END} + {TGE_COOLDOWN}. + error TgeTooEarly(uint64 current, uint64 notBefore); + + /// @notice The CCA window is empty, inverted, or does not start in the + /// future. + error InvalidCcaWindow(uint64 ccaStart, uint64 ccaEnd); + + /// @notice Policy parameters are internally inconsistent, or the policy + /// could keep tokens locked past the lock sunset. + error InvalidPolicy(); + + /// @notice The lock sunset timestamp must be after the earliest TGE. + error InvalidNoMoreLocks(uint256 noMoreLocks, uint256 minimum); + + /// @notice The requested relink amount exceeds the amount available under + /// the source policy. + error RelinkAmountExceeded(); - /// @notice Thrown when array parameters have mismatched lengths - error ArrayLengthMismatch(); + /// @notice An account has reached the maximum number of active lock + /// policy entries. + error TooManyLocks(); - /// @notice Thrown when a transfer is attempted while restrictions are active and neither party is whitelisted - error TransferNotAllowed(); + /// @notice An account has reached the maximum number of queued lock + /// policy entries. + error TooManyQueuedLocks(); + + /// @notice The policy id is already defined; policies are write-once. + error PolicyAlreadyDefined(bytes32 policyId); + + /// @notice The referenced policy id has not been defined. + error PolicyNotDefined(bytes32 policyId); + + /// @notice A transfer of `value` exceeds the sender's spendable balance + /// (balance + bonded − locked balance). + error InsufficientUnlockedBalance( + address account, + uint256 spendable, + uint256 value + ); + + /// @notice The bonding registry address has no deployed code. + error InvalidBondingRegistry(address registry); + + /// @notice Thrown when {renounceOwnership} is called. Ownership is + /// critical for protocol governance; renouncing would permanently + /// freeze admin functions and is disallowed. + error RenounceOwnershipDisabled(); + + // ───────────────────────────────────────────────────────────────────────── + // Constants and immutables + // ───────────────────────────────────────────────────────────────────────── - /// @notice Maximum supply of the token: 1.2 billion tokens with 18 decimals - /// @dev Hard cap on total token supply that cannot be exceeded through minting uint256 public constant MAX_SUPPLY = 1_200_000_000e18; - /// @notice Role identifier for accounts authorized to mint new tokens - /// @dev Keccak256 hash of "MINTER_ROLE" used in AccessControl + /// @notice Role authorized to mint allocations, while Virtual. bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); - /// @notice Role identifier for accounts authorized to manage the transfer whitelist. - /// @dev Separated from MINTER_ROLE so mint authority does not also control transferability. + /// @notice Role authorized to manage the pre-TGE transfer whitelist. bytes32 public constant WHITELIST_ROLE = keccak256("WHITELIST_ROLE"); - /// @notice Tracks the cumulative amount of tokens minted since deployment - uint256 public totalMinted; + /// @notice Role authorized to create lock policies and manage claim-lock + /// exemptions. + bytes32 public constant LOCK_MANAGER_ROLE = keccak256("LOCK_MANAGER_ROLE"); + + /// @notice Minimum time between {CCA_END} and {tge}. + uint64 public constant TGE_COOLDOWN = 45 days; + + bytes32 public constant PENDING_LOCK_POLICY_ID = "PENDING"; - /// @notice Mapping of addresses permitted to transfer tokens when restrictions are active - /// @dev When transfersRestricted is true, only whitelisted addresses can send or receive tokens - mapping(address account => bool allowed) public transferWhitelisted; + /// @notice Maximum number of distinct active lock policies an account may + /// hold. Protects against unbounded gas costs in {_update}. + uint256 public constant MAX_LOCKS_PER_ACCOUNT = 8; - /// @notice Indicates whether token transfers are currently restricted - /// @dev When true, only whitelisted addresses can transfer tokens - bool public transfersRestricted; + /// @notice Maximum number of distinct queued lock policies an account may + /// hold. + uint256 public constant MAX_QUEUED_LOCKS_PER_ACCOUNT = 8; - /// @notice Emitted when tokens are minted as part of a named allocation - /// @param recipient Address receiving the minted tokens - /// @param amount Number of tokens minted (18 decimals) - /// @param allocation Description of the allocation for tracking purposes + /// @notice Start of the CCA auction window, fixed at deployment. + uint64 public immutable CCA_START; + + /// @notice End of the CCA auction window, fixed at deployment + uint64 public immutable CCA_END; + + /// @notice Absolute timestamp after which token locks no longer apply. + uint64 public immutable NO_MORE_LOCKS; + + /// @notice The CCA auction contract + address public immutable CLAIM_SOURCE; + + /// @notice Registry whose bonded INTF counts toward locked balances. + IBondingRegistry public immutable BONDING_REGISTRY; + + // ───────────────────────────────────────────────────────────────────────── + // Storage + // ───────────────────────────────────────────────────────────────────────── + + /// @notice TGE timestamp; zero until {tge} is called, then immutable. + uint64 public tgeTimestamp; + + /// @notice Addresses allowed to transfer before TGE. + mapping(address account => bool whitelisted) public transferWhitelist; + + /// @notice Addresses exempt from automatic claim-source lock creation. + mapping(address account => bool exempt) public claimLockExempt; + + /// @notice Write-once lock policies by id. + mapping(bytes32 policyId => LockPolicy policy) internal lockPolicies; + + /// @notice Active locks by account. + mapping(address account => Lock[] entries) public locks; + + /// @notice Policy buckets for links that arrived before enough claim + /// balance existed to classify them. + mapping(address account => Lock[] entries) public queuedLocks; + + // ───────────────────────────────────────────────────────────────────────── + // Events + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Emitted for every mint instruction. event AllocationMinted( address indexed recipient, uint256 amount, - string allocation + bytes32 indexed policyId, + bytes32 indexed label ); - /// @notice Emitted when the transfer restriction setting is changed - /// @param restricted New state of transfer restrictions (true = restricted, false = unrestricted) - event TransferRestrictionUpdated(bool restricted); + /// @notice Emitted when a lock policy is defined (write-once). + event PolicyDefined(bytes32 indexed policyId, LockPolicy policy); - /// @notice Emitted when an address is added to or removed from the transfer whitelist - /// @param account Address whose whitelist status changed - /// @param whitelisted New whitelist status (true = whitelisted, false = not whitelisted) + /// @notice Emitted when an account's transfer whitelist status changes. event TransferWhitelistUpdated(address indexed account, bool whitelisted); + /// @notice Emitted when an account's claim-lock exemption status changes. + event ClaimLockExemptUpdated(address indexed account, bool exempt); + + /// @notice Emitted whenever an active lock amount changes; `amount` is + /// the new total under `policyId`. + event ActiveLockUpdated( + address indexed account, + bytes32 indexed policyId, + uint256 amount + ); + + /// @notice Emitted whenever a queued lock amount changes; `amount` is + /// the new remaining queued total under `policyId`. + event QueuedLockUpdated( + address indexed account, + bytes32 indexed policyId, + uint256 amount + ); + + /// @notice Emitted when an active lock is moved from one policy to + /// another via {relinkActiveLock}. + event ActiveLockRelinked( + address indexed account, + bytes32 indexed fromPolicyId, + bytes32 indexed toPolicyId, + uint256 amount + ); + + /// @notice Emitted once, when {tge} fires. + event TgeTriggered(uint64 timestamp); + + // ───────────────────────────────────────────────────────────────────────── + // Constructor + // ───────────────────────────────────────────────────────────────────────── + /** - * @notice Initializes the Interfold token with name "Interfold" and symbol "INTF" - * @dev Sets up the token with voting and permit functionality. Grants admin, minter, and - * whitelist roles to the owner; enables transfer restrictions; whitelists the owner. - * @param initialOwner_ Address that will own the contract and receive admin, minter and whitelist roles. + * @notice Deploys INTF with no TGE set. + * @dev The initial owner receives every role via the {_transferOwnership} + * sync. Operational roles can additionally be granted to dedicated + * keys post-deployment. + * @param initialOwner_ Initial owner; receives all roles. + * @param ccaStart_ CCA auction window start; + * @param ccaEnd_ CCA auction window end; after `ccaStart_`. + * @param noMoreLocks_ Absolute timestamp after which token locks no longer apply. + * @param claimSource_ The CCA auction contract + * @param bondingRegistry_ Registry whose bonded INTF */ constructor( - address initialOwner_ + address initialOwner_, + uint64 ccaStart_, + uint64 ccaEnd_, + uint64 noMoreLocks_, + address claimSource_, + IBondingRegistry bondingRegistry_ ) ERC20("Interfold", "INTF") ERC20Permit("Interfold") Ownable(initialOwner_) { - // Grant the deployer all admin roles. - _grantRole(DEFAULT_ADMIN_ROLE, initialOwner_); - _grantRole(MINTER_ROLE, initialOwner_); - _grantRole(WHITELIST_ROLE, initialOwner_); - - // Initialise state variables. - transfersRestricted = true; - transferWhitelisted[initialOwner_] = true; - - emit TransferRestrictionUpdated(true); - emit TransferWhitelistUpdated(initialOwner_, true); + if (ccaStart_ <= block.timestamp) { + revert InvalidCcaWindow(ccaStart_, ccaEnd_); + } + if (ccaEnd_ <= ccaStart_) revert InvalidCcaWindow(ccaStart_, ccaEnd_); + if (claimSource_ == address(0)) revert ZeroAddress(); + address registry = address(bondingRegistry_); + if (registry == address(0)) revert ZeroAddress(); + if (registry.code.length == 0) { + revert InvalidBondingRegistry(registry); + } + if (noMoreLocks_ == 0) revert ZeroAmount(); + uint256 earliestTge = uint256(ccaEnd_) + TGE_COOLDOWN; + if (noMoreLocks_ <= earliestTge) { + revert InvalidNoMoreLocks(noMoreLocks_, earliestTge + 1); + } + CCA_START = ccaStart_; + CCA_END = ccaEnd_; + NO_MORE_LOCKS = noMoreLocks_; + CLAIM_SOURCE = claimSource_; + BONDING_REGISTRY = bondingRegistry_; } - /** - * @notice Mints a named allocation of tokens to a specified recipient - * @dev Only callable by accounts with MINTER_ROLE. Reverts if recipient is zero address, - * amount is zero, or minting would exceed MAX_SUPPLY. - * @param recipient Address to receive the minted tokens (cannot be zero address) - * @param amount Number of tokens to mint in wei (18 decimals, must be greater than zero) - * @param allocation Human-readable description of this allocation for tracking and auditing purposes - */ - function mintAllocation( + // ───────────────────────────────────────────────────────────────────────── + // Minting + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Plain vanilla admin mint: INTF with no lock attached. Only + /// allowed during the Virtual phase. + function mint( address recipient, uint256 amount, - string memory allocation - ) external onlyRole(MINTER_ROLE) { - if (recipient == address(0)) revert ZeroAddress(); - if (amount == 0) revert ZeroAmount(); - // Ensure we do not exceed the total supply. - if (totalMinted + amount > MAX_SUPPLY) revert ExceedsTotalSupply(); + bytes32 label + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (phase() != Phase.Virtual) revert MintingClosed(); + _mintTokens(recipient, amount); + emit AllocationMinted(recipient, amount, bytes32(0), label); + } - _mint(recipient, amount); - totalMinted += amount; - emit AllocationMinted(recipient, amount, allocation); + /// @notice Mints allocations locked under their policies; the path the + /// minter role uses to distribute vested supply. Only allowed + /// during the Virtual phase. + /// @dev Team / GG "vested as of TGE" amounts must be calculated + /// off-chain using the expected TGE date, since {tgeTimestamp} is + /// not known when Virtual minting closes at {CCA_START}. + function mintAllocations( + MintAllocation[] calldata allocations + ) external onlyRole(MINTER_ROLE) { + if (phase() != Phase.Virtual) revert MintingClosed(); + uint256 len = allocations.length; + for (uint256 i = 0; i < len; i++) { + _mintAllocation(allocations[i]); + } } + // ───────────────────────────────────────────────────────────────────────── + // Launch lifecycle + // ───────────────────────────────────────────────────────────────────────── + /** - * @notice Mints multiple named allocations to different recipients in a single transaction - * @dev Only callable by accounts with MINTER_ROLE. All arrays must have the same length. - * Reverts if any amount is zero, or if cumulative minting would exceed MAX_SUPPLY. - * @param recipients Array of addresses to receive minted tokens - * @param amounts Array of token amounts to mint (18 decimals, must match recipients length) - * @param allocations Array of allocation descriptions (must match recipients length) + * @notice Sets {tgeTimestamp} to the current block timestamp, exactly once. + * @dev Permissionless: anyone may trigger the TGE once {CCA_END} + + * {TGE_COOLDOWN} has passed, so launch cannot be stalled by an idle + * operator. */ - function batchMintAllocations( - address[] calldata recipients, - uint256[] calldata amounts, - string[] calldata allocations - ) external onlyRole(MINTER_ROLE) { - uint256 len = recipients.length; - if (amounts.length != len || allocations.length != len) { - revert ArrayLengthMismatch(); - } + function tge() external { + if (tgeTimestamp != 0) revert AlreadyLive(); + uint64 current = uint64(block.timestamp); + uint64 earliest = CCA_END + TGE_COOLDOWN; + if (current < earliest) revert TgeTooEarly(current, earliest); + tgeTimestamp = current; + emit TgeTriggered(current); + } - uint256 minted = totalMinted; + /// @notice Current lifecycle phase. Live is event-driven ({tge}); the + /// earlier phases derive from the immutable CCA window. + function phase() public view returns (Phase) { + if (tgeTimestamp != 0) return Phase.Live; - for (uint256 i = 0; i < len; i++) { - address recipient = recipients[i]; - uint256 amount = amounts[i]; - if (recipient == address(0)) revert ZeroAddress(); - if (amount == 0) revert ZeroAmount(); + uint64 current = uint64(block.timestamp); + if (current < CCA_START) return Phase.Virtual; + if (current < CCA_END) return Phase.CCA; + return Phase.Cooldown; + } - if (amount > MAX_SUPPLY - minted) revert ExceedsTotalSupply(); - minted += amount; + // ───────────────────────────────────────────────────────────────────────── + // Whitelisting + // ───────────────────────────────────────────────────────────────────────── - _mint(recipient, amount); - emit AllocationMinted(recipient, amount, allocations[i]); - } + function setTransferWhitelisted( + address account, + bool whitelisted + ) external onlyRole(WHITELIST_ROLE) { + if (account == address(0)) revert ZeroAddress(); + transferWhitelist[account] = whitelisted; + emit TransferWhitelistUpdated(account, whitelisted); + } - totalMinted = minted; + /// @notice Sets whether `account` is exempt from automatic claim-lock + /// creation when receiving tokens from {CLAIM_SOURCE}. + function setClaimLockExempt( + address account, + bool exempt + ) external onlyRole(LOCK_MANAGER_ROLE) { + if (account == address(0)) revert ZeroAddress(); + claimLockExempt[account] = exempt; + emit ClaimLockExemptUpdated(account, exempt); } + // ───────────────────────────────────────────────────────────────────────── + // Lock configuration + // ───────────────────────────────────────────────────────────────────────── + /** - * @notice Permanently disables transfer restrictions. - * @dev Once disabled, restrictions cannot be re-enabled (one-way switch). - * Only callable by DEFAULT_ADMIN_ROLE. Idempotent: a no-op when already disabled - * so deployment/setup scripts can call it unconditionally. + * @notice Creates a lock policy. Policies are write-once: once created, + * the terms backing existing locks can never be changed, by anyone. + * @param policyId Non-zero identifier, e.g. bytes32("CCA_REG_S"). + * @param policy Lock terms. The unlock curve is required, must lock + * something, and must be consistent with its anchor mode; + * `holdUntil` is optional (zero = none). */ - function disableTransferRestrictions() - external - onlyRole(DEFAULT_ADMIN_ROLE) - { - if (!transfersRestricted) return; - transfersRestricted = false; - emit TransferRestrictionUpdated(false); + function createLockPolicy( + bytes32 policyId, + LockPolicy calldata policy + ) external onlyRole(LOCK_MANAGER_ROLE) { + if (policyId == bytes32(0) || policyId == PENDING_LOCK_POLICY_ID) { + revert InvalidPolicy(); + } + + if (_policyDefined(policyId)) { + revert PolicyAlreadyDefined(policyId); + } + _validateCurve(policy.unlock); + _validatePolicyMaturity(policy); + + lockPolicies[policyId] = policy; + emit PolicyDefined(policyId, policy); } /** - * @notice Toggles an account's transfer whitelist status between enabled and disabled - * @dev Only callable by accounts holding WHITELIST_ROLE. Flips the current whitelist - * state for the given account. Whitelisted accounts can send and receive tokens even - * when transfer restrictions are active. - * @param account Address whose whitelist status will be toggled + * @notice Links `amount` of `account`'s claims to `policyId` — the + * Predicate/KYC bucket import. + * + * Claims from the CCA can come before or after the operator's + * linkClaim. {_claim} and {_linkClaim} manipulate {locks} and + * {queuedLocks} to link balances to policies in a resilient + * way: it doesn't matter who calls what first. + * + * Each wallet is expected to have at most one CCA policy bucket. + * If a wallet has multiple queued CCA policies, claim matching + * is not business-order aware and should be treated as + * undefined. The importer must not create multiple queued CCA + * buckets for the same wallet. */ - function toggleTransferWhitelist( - address account - ) external onlyRole(WHITELIST_ROLE) { - bool newStatus = !transferWhitelisted[account]; - transferWhitelisted[account] = newStatus; - emit TransferWhitelistUpdated(account, newStatus); + function linkClaim( + address account, + uint256 amount, + bytes32 policyId + ) external onlyRole(LOCK_MANAGER_ROLE) { + if (account == address(0)) revert ZeroAddress(); + if (amount == 0) revert ZeroAmount(); + if (!_policyDefined(policyId)) revert PolicyNotDefined(policyId); + + _linkClaim(account, amount, policyId); } /** - * @notice Whitelists key protocol contracts to allow them to transfer tokens during restricted periods - * @dev Only callable by accounts holding WHITELIST_ROLE. Zero addresses are safely ignored. - * @param bondingManager Address of the BondingManager contract (zero address skipped) - * @param vestingEscrow Address of the VestingEscrow contract (zero address skipped) + * @notice Corrects an active lock that was incorrectly linked to the + * wrong policy. Only allowed before TGE (Live phase). + * @dev This is a safety hatch for admin mistakes during lock import; + * it is not intended for routine use. The {PENDING_LOCK_POLICY_ID} + * policy cannot be used as source or target. */ - function whitelistContracts( - address bondingManager, - address vestingEscrow - ) external onlyRole(WHITELIST_ROLE) { - if (bondingManager != address(0)) { - transferWhitelisted[bondingManager] = true; - emit TransferWhitelistUpdated(bondingManager, true); + function relinkActiveLock( + address account, + bytes32 fromPolicyId, + bytes32 toPolicyId, + uint256 amount + ) external onlyRole(LOCK_MANAGER_ROLE) { + if (tgeTimestamp != 0) revert AlreadyLive(); + _validateRelinkParams(account, fromPolicyId, toPolicyId, amount); + if (_activeLockAmount(account, fromPolicyId) < amount) { + revert RelinkAmountExceeded(); } - if (vestingEscrow != address(0)) { - transferWhitelisted[vestingEscrow] = true; - emit TransferWhitelistUpdated(vestingEscrow, true); + + (uint256 consumed, ) = _consumeLock( + account, + locks[account], + fromPolicyId, + amount, + true + ); + + _addOrIncrementLock( + account, + locks[account], + toPolicyId, + consumed, + true + ); + + emit ActiveLockRelinked(account, fromPolicyId, toPolicyId, consumed); + } + + // ───────────────────────────────────────────────────────────────────────── + // Lock views + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Current locked balance of `account`: the amount that must remain + /// controlled (wallet balance + bonded) by the account. + function lockedBalanceOf(address account) public view returns (uint256) { + return lockedBalanceAt(account, uint64(block.timestamp)); + } + + /// @notice Locked balance of `account` at `timestamp`, evaluated against the + /// current configuration (an unset TGE keeps {Anchor.Tge} policies + /// fully locked for any timestamp). Always zero from the lock + /// sunset onwards. + function lockedBalanceAt( + address account, + uint64 timestamp + ) public view returns (uint256 lockedBalance) { + if (timestamp >= NO_MORE_LOCKS) { + return 0; } + + Lock[] storage accountLocks = locks[account]; + for (uint256 i = 0; i < accountLocks.length; i++) { + Lock storage accountLock = accountLocks[i]; + bytes32 policyId = accountLock.policyId; + uint256 amount = accountLock.amount; + if (policyId == PENDING_LOCK_POLICY_ID) { + // Unclassified claims are fully locked, immune to time. + lockedBalance += amount; + } else { + lockedBalance += _lockedAmount( + lockPolicies[policyId], + amount, + timestamp + ); + } + } + } + + /// @notice Wallet balance `account` can transfer right now: the wallet + /// must retain whatever part of its locked balance its bond does + /// not already cover. + /// @dev Never consults the registry for accounts with nothing locked. + function transferableBalanceOf( + address account + ) public view returns (uint256) { + uint256 balance = balanceOf(account); + uint256 lockedBalance = lockedBalanceOf(account); + if (lockedBalance == 0) return balance; + + uint256 bondedBalance = BONDING_REGISTRY.totalBonded(account); + uint256 mustRetain = lockedBalance > bondedBalance + ? lockedBalance - bondedBalance + : 0; + return balance > mustRetain ? balance - mustRetain : 0; + } + + /// @notice Returns the full lock policy for `policyId`, or an empty + /// struct if the policy has not been defined. + function lockPolicyOf( + bytes32 policyId + ) external view returns (LockPolicy memory) { + return lockPolicies[policyId]; } + /// @notice Number of distinct lock policy entries for `account`. + function lockCount(address account) external view returns (uint256) { + return locks[account].length; + } + + /// @notice Number of distinct queued lock policy entries for `account`. + function queuedLockCount(address account) external view returns (uint256) { + return queuedLocks[account].length; + } + + // ───────────────────────────────────────────────────────────────────────── + // Transfer hook + // ───────────────────────────────────────────────────────────────────────── + /** - * @notice Internal hook that enforces transfer restrictions and updates voting power - * @dev Overrides ERC20 and ERC20Votes to add transfer restriction logic. Reverts if transfers - * are restricted and neither sender nor receiver is whitelisted. Minting (from == 0) and - * burning (to == 0) are always allowed regardless of restrictions. - * - * @param from Address sending tokens (zero address for minting) - * @param to Address receiving tokens (zero address for burning) - * @param value Amount of tokens being transferred + * @dev Applies, in order: + * 1. The lock sunset short-circuit. + * 2. The pre-TGE transfer gate (_isTransferRestricted) + * 3. The lock check, the sender can move at most its transferable + * balance + * 4. The transfer itself, via parent contract + * 5. Claim-lock creation, unless the recipient is claim-lock exempt. */ function _update( address from, address to, uint256 value ) internal override(ERC20, ERC20Votes) { - // When transfers are restricted, only whitelisted addresses can send or receive. - if (from != address(0) && to != address(0) && transfersRestricted) { - if (!transferWhitelisted[from] && !transferWhitelisted[to]) { - revert TransferNotAllowed(); + if (block.timestamp >= NO_MORE_LOCKS) { + super._update(from, to, value); + return; + } + + bool isMint = from == address(0); + bool isBurn = to == address(0); + + if (_isTransferRestricted(from, to)) { + revert TransferRestricted(from, to); + } + + if (!isMint) { + uint256 transferable = transferableBalanceOf(from); + if (value > transferable) { + revert InsufficientUnlockedBalance(from, transferable, value); } } + super._update(from, to, value); + + // from == CLAIM_SOURCE implies neither mint nor an unset claim source. + if ( + !isBurn && + value != 0 && + from == CLAIM_SOURCE && + !claimLockExempt[to] + ) { + _claim(to, value); + } } - /** - * @notice Checks if this contract implements a given interface - * @dev Implements ERC165 interface detection via AccessControl - * @param interfaceId The interface identifier to check, as specified in ERC-165 - * @return bool True if the contract implements the interface, false otherwise - */ - function supportsInterface( - bytes4 interfaceId - ) public view override(AccessControl) returns (bool) { - return super.supportsInterface(interfaceId); + /// @dev Whether a transfer from `from` to `to` is blocked by the + /// pre-TGE gate. Always false once {tge} fires; mints and burns + /// are never gated. The locked-balance check + /// ({transferableBalanceOf}) applies independently. + function _isTransferRestricted( + address from, + address to + ) internal view returns (bool) { + if (tgeTimestamp != 0) return false; + if (from == address(0) || to == address(0)) return false; + + address registry = address(BONDING_REGISTRY); + bool isBonding = from == registry || to == registry; + // The claim source is trusted in any phase: the CCA enforces its own + // (block-based) claim timing. Every distribution still lands in a + // lock via {_claim}. + bool isCcaDistribution = from == CLAIM_SOURCE; + bool isWhitelisted = transferWhitelist[from] || transferWhitelist[to]; + return !isBonding && !isCcaDistribution && !isWhitelisted; } - /** - * @notice Returns the current nonce for an address, used for permit signatures - * @dev Resolves the override conflict between ERC20Permit and Nonces by calling the parent - * implementation. Nonces are incremented with each permit to prevent replay attacks. - * @param owner Address to query the nonce for - * @return uint256 The current nonce value for the given address - */ + // ───────────────────────────────────────────────────────────────────────── + // Internals + // ───────────────────────────────────────────────────────────────────────── + + function _mintAllocation(MintAllocation calldata allocation) internal { + // Every batch allocation is locked under a policy; unlocked supply + // goes through {mint}. + if (allocation.policyId == bytes32(0)) { + revert InvalidPolicy(); + } + + if (!_policyDefined(allocation.policyId)) { + revert PolicyNotDefined(allocation.policyId); + } + + _mintTokens(allocation.recipient, allocation.amount); + _addOrIncrementLock( + allocation.recipient, + locks[allocation.recipient], + allocation.policyId, + allocation.amount, + true + ); + emit AllocationMinted( + allocation.recipient, + allocation.amount, + allocation.policyId, + allocation.label + ); + } + + /// @dev Shared mint path: amount and supply-cap validation plus the + /// ERC20 mint itself. + function _mintTokens(address recipient, uint256 amount) internal { + if (amount == 0) revert ZeroAmount(); + if (totalSupply() + amount > MAX_SUPPLY) { + revert MaxSupplyExceeded(); + } + _mint(recipient, amount); + } + + /// @dev Claim behavior: + /// + /// - When a claim arrives, greedily fill queued policy buckets until + /// the claim amount is exhausted. + /// - Each consumed portion is moved into active locks under the + /// bucket's policyId. A claim may fully consume a bucket or + /// partially consume the current bucket. + /// - Any claim amount left after all queued buckets are consumed + /// lands as { PENDING_LOCK_POLICY_ID }. + /// + /// Note: no order guarantees on claims and links. + function _claim(address account, uint256 amount) private { + uint256 remaining = amount; + while (remaining != 0) { + (uint256 consumed, bytes32 policyId) = _consumeLock( + account, + queuedLocks[account], + bytes32(0), + remaining, + false + ); + + if (consumed == 0) { + policyId = PENDING_LOCK_POLICY_ID; + consumed = remaining; + } + + _addOrIncrementLock( + account, + locks[account], + policyId, + consumed, + true + ); + remaining -= consumed; + } + } + + /// @dev Link behavior: + /// + /// - When linking a claim amount to a real policyId, consume as much + /// active pending balance as possible. + /// - The consumed portion moves from {PENDING_LOCK_POLICY_ID} into + /// an active lock under {policyId}. + /// - Any unfilled remainder increments that policy's queued bucket + /// for future claims to consume. + /// + /// Note: no order guarantees on claims and links. + function _linkClaim( + address account, + uint256 amount, + bytes32 policyId + ) private { + (uint256 consumed, ) = _consumeLock( + account, + locks[account], + PENDING_LOCK_POLICY_ID, + amount, + true + ); + uint256 remaining = amount - consumed; + + // if we consumed from PENDING policy, add the real thing + if (consumed != 0) { + _addOrIncrementLock( + account, + locks[account], + policyId, + consumed, + true + ); + } + + // Whatever is left queues under the target policy. + if (remaining != 0) { + _addOrIncrementLock( + account, + queuedLocks[account], + policyId, + remaining, + false + ); + } + } + + /// @dev Finds the index of `filterPolicyId` in `entries`. Returns + /// (0, false) when not found, or when `filterPolicyId` is zero + /// and `entries` is empty. + function _findLockIndex( + Lock[] storage entries, + bytes32 filterPolicyId + ) internal view returns (uint256 index, bool found) { + uint256 len = entries.length; + if (filterPolicyId != bytes32(0)) { + for (uint256 i = 0; i < len; i++) { + if (entries[i].policyId == filterPolicyId) { + return (i, true); + } + } + return (0, false); + } + return (0, len > 0); + } + + function _consumeLock( + address account, + Lock[] storage entries, + bytes32 filterPolicyId, + uint256 amount, + bool isActive + ) internal returns (uint256 consumed, bytes32 consumedPolicyId) { + (uint256 i, bool found) = _findLockIndex(entries, filterPolicyId); + if (!found) return (0, bytes32(0)); + + consumedPolicyId = entries[i].policyId; + consumed = entries[i].amount; + assert(consumed > 0); + if (consumed > amount) { + consumed = amount; + } + + uint256 remaining = entries[i].amount - consumed; + if (remaining == 0) { + _removeLockAt(entries, i); + } else { + entries[i].amount = remaining; + } + + if (isActive) { + emit ActiveLockUpdated(account, consumedPolicyId, remaining); + } else { + emit QueuedLockUpdated(account, consumedPolicyId, remaining); + } + } + + function _addOrIncrementLock( + address account, + Lock[] storage entries, + bytes32 policyId, + uint256 amount, + bool isActive + ) internal returns (uint256 newAmount) { + uint256 len = entries.length; + for (uint256 i = 0; i < len; i++) { + if (entries[i].policyId == policyId) { + entries[i].amount += amount; + newAmount = entries[i].amount; + if (isActive) { + emit ActiveLockUpdated(account, policyId, newAmount); + } else { + emit QueuedLockUpdated(account, policyId, newAmount); + } + return newAmount; + } + } + if (isActive && len >= MAX_LOCKS_PER_ACCOUNT) revert TooManyLocks(); + if (!isActive && len >= MAX_QUEUED_LOCKS_PER_ACCOUNT) { + revert TooManyQueuedLocks(); + } + entries.push(Lock(policyId, amount)); + if (isActive) { + emit ActiveLockUpdated(account, policyId, amount); + } else { + emit QueuedLockUpdated(account, policyId, amount); + } + return amount; + } + + function _removeLockAt(Lock[] storage entries, uint256 index) internal { + entries[index] = entries[entries.length - 1]; + entries.pop(); + } + + function _policyDefined(bytes32 policyId) internal view returns (bool) { + Curve storage unlock = lockPolicies[policyId].unlock; + return unlock.cliffDuration != 0 || unlock.vestDuration != 0; + } + + /// @dev Validates {relinkActiveLock} parameters. Extracted to keep the + /// external function below the solhint cyclomatic-complexity cap. + function _validateRelinkParams( + address account, + bytes32 fromPolicyId, + bytes32 toPolicyId, + uint256 amount + ) internal view { + if (account == address(0)) revert ZeroAddress(); + if (amount == 0) revert ZeroAmount(); + if (fromPolicyId == bytes32(0) || toPolicyId == bytes32(0)) { + revert InvalidPolicy(); + } + if (fromPolicyId == toPolicyId) revert InvalidPolicy(); + if ( + fromPolicyId == PENDING_LOCK_POLICY_ID || + toPolicyId == PENDING_LOCK_POLICY_ID + ) revert InvalidPolicy(); + if (!_policyDefined(fromPolicyId)) { + revert PolicyNotDefined(fromPolicyId); + } + if (!_policyDefined(toPolicyId)) { + revert PolicyNotDefined(toPolicyId); + } + } + + /// @dev Returns the active lock amount for `account` under `policyId`, + /// or zero if no such lock exists. + function _activeLockAmount( + address account, + bytes32 policyId + ) internal view returns (uint256) { + Lock[] storage accountLocks = locks[account]; + for (uint256 i = 0; i < accountLocks.length; i++) { + if (accountLocks[i].policyId == policyId) { + return accountLocks[i].amount; + } + } + return 0; + } + + /// @dev Ensures the policy cannot keep anything locked past the lock + /// sunset. Ending exactly at the sunset is allowed: the curve has + /// fully released and the hold has lapsed at that moment, which is + /// also when the sunset takes effect. + function _validatePolicyMaturity(LockPolicy calldata policy) internal view { + Curve calldata curve = policy.unlock; + // The curve validation guarantees cliff <= vest when vest != 0, so + // the curve fully releases at cliff (vest == 0) or at vest end. + uint256 curveEnd = curve.vestDuration == 0 + ? curve.cliffDuration + : curve.vestDuration; + + uint256 policyEnd = curve.anchor == Anchor.Tge + ? uint256(CCA_END) + TGE_COOLDOWN + curveEnd + : curve.start + curveEnd; + + if (policyEnd > NO_MORE_LOCKS) { + revert InvalidPolicy(); + } + if (policy.holdUntil > NO_MORE_LOCKS) { + revert InvalidPolicy(); + } + } + + /// @dev Validates the unlock curve: it must lock something and be + /// consistent with its anchor mode. + function _validateCurve(Curve calldata curve) internal pure { + if (curve.cliffDuration == 0 && curve.vestDuration == 0) { + revert InvalidPolicy(); + } + if (curve.anchor == Anchor.Absolute && curve.start == 0) { + revert InvalidPolicy(); + } + if (curve.anchor == Anchor.Tge && curve.start != 0) { + revert InvalidPolicy(); + } + // A cliff past the vest end would be a disguised step function. + if ( + curve.vestDuration != 0 && curve.cliffDuration > curve.vestDuration + ) { + revert InvalidPolicy(); + } + } + + /// @dev Still-locked amount under `policy` at `timestamp`: everything + /// before {LockPolicy.holdUntil}; the unlock curve's remainder + /// after (the curve accrues through the hold, so the accrued + /// portion catches up the moment the hold lapses). Fails closed: + /// A TGE-anchored curve releases nothing while TGE is unset. + function _lockedAmount( + LockPolicy storage policy, + uint256 amount, + uint64 timestamp + ) internal view returns (uint256) { + if (timestamp < policy.holdUntil) return amount; + + Curve storage curve = policy.unlock; + uint256 anchor; + if (curve.anchor == Anchor.Tge) { + anchor = tgeTimestamp; + } else { + anchor = curve.start; + } + + if (anchor == 0 || timestamp < anchor + curve.cliffDuration) { + return amount; + } + + uint256 vestDuration = curve.vestDuration; + if (vestDuration == 0 || timestamp >= anchor + vestDuration) { + return 0; + } + return amount - (amount * (timestamp - anchor)) / vestDuration; + } + + // ───────────────────────────────────────────────────────────────────────── + // Required overrides + // ───────────────────────────────────────────────────────────────────────── + + /// @inheritdoc ERC20Permit function nonces( address owner ) public view override(ERC20Permit, Nonces) returns (uint256) { return super.nonces(owner); } - // ── EIP-6372 clock (timestamp mode) ─────────────────────────────────────── - - /// @notice EIP-6372 clock — uses {block.timestamp}. + /// @notice EIP-6372 clock — block.timestamp, aligned with other Interfold + /// contracts. function clock() public view override returns (uint48) { return uint48(block.timestamp); } @@ -312,13 +1027,6 @@ contract InterfoldToken is /** * @notice Synchronises AccessControl roles whenever Ownable2Step completes a * transfer (i.e. when {acceptOwnership} is called by the pending owner). - * @dev Without this override, the new `owner()` would have no roles: the previous - * owner would silently retain DEFAULT_ADMIN_ROLE, MINTER_ROLE, and WHITELIST_ROLE. - * Called internally by {Ownable._transferOwnership}; never call directly. - * - * Roles are also granted during construction (previousOwner == address(0)), - * but the constructor body already calls `_grantRole` explicitly, so the - * grant here is idempotent for the deployment case and adds no overhead. */ function _transferOwnership(address newOwner) internal override { address previousOwner = owner(); @@ -327,11 +1035,13 @@ contract InterfoldToken is _revokeRole(DEFAULT_ADMIN_ROLE, previousOwner); _revokeRole(MINTER_ROLE, previousOwner); _revokeRole(WHITELIST_ROLE, previousOwner); + _revokeRole(LOCK_MANAGER_ROLE, previousOwner); } if (newOwner != address(0)) { _grantRole(DEFAULT_ADMIN_ROLE, newOwner); _grantRole(MINTER_ROLE, newOwner); _grantRole(WHITELIST_ROLE, newOwner); + _grantRole(LOCK_MANAGER_ROLE, newOwner); } } } diff --git a/packages/interfold-contracts/ignition/modules/interfoldToken.ts b/packages/interfold-contracts/ignition/modules/interfoldToken.ts index 0c17e6353..9c43b7ef1 100644 --- a/packages/interfold-contracts/ignition/modules/interfoldToken.ts +++ b/packages/interfold-contracts/ignition/modules/interfoldToken.ts @@ -7,8 +7,20 @@ import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; export default buildModule("InterfoldToken", (m) => { const owner = m.getParameter("owner"); + const ccaStart = m.getParameter("ccaStart"); + const ccaEnd = m.getParameter("ccaEnd"); + const claimSource = m.getParameter("claimSource"); + const bondingRegistry = m.getParameter("bondingRegistry"); + const noMoreLocks = m.getParameter("noMoreLocks"); - const interfoldToken = m.contract("InterfoldToken", [owner]); + const interfoldToken = m.contract("InterfoldToken", [ + owner, + ccaStart, + ccaEnd, + noMoreLocks, + claimSource, + bondingRegistry, + ]); return { interfoldToken }; }) as any; diff --git a/packages/interfold-contracts/scripts/deployAndSave/interfoldToken.ts b/packages/interfold-contracts/scripts/deployAndSave/interfoldToken.ts index 896a9138e..d6fb104e8 100644 --- a/packages/interfold-contracts/scripts/deployAndSave/interfoldToken.ts +++ b/packages/interfold-contracts/scripts/deployAndSave/interfoldToken.ts @@ -20,6 +20,11 @@ import { */ export interface InterfoldTokenArgs { owner?: string; + ccaStart?: bigint; + ccaEnd?: bigint; + claimSource?: string; + bondingRegistry?: string; + noMoreLocks?: bigint; hre: HardhatRuntimeEnvironment; } @@ -36,15 +41,13 @@ async function disableTransferRestrictionsForLocal( console.log("Disabling transfer restrictions for chain", chain); console.log("Contract address", await contract.getAddress()); - try { - const isRestricted = await contract.transfersRestricted(); - if (isRestricted) { - const tx = await contract.disableTransferRestrictions(); - await tx.wait(); - console.log("Transfer restrictions disabled for local development"); - } - } catch (error) { - console.warn("Failed to disable transfer restrictions:", error); + const tgeTs = await contract.tgeTimestamp(); + if (tgeTs === 0n) { + console.warn( + "TGE not yet fired — call tge() after advancing time past CCA_END + 45 days.", + ); + } else { + console.log("Token is already Live (TGE timestamp:", tgeTs.toString(), ")"); } } @@ -55,6 +58,11 @@ async function disableTransferRestrictionsForLocal( */ export const deployAndSaveInterfoldToken = async ({ owner, + ccaStart, + ccaEnd, + claimSource, + bondingRegistry, + noMoreLocks, hre, }: InterfoldTokenArgs): Promise<{ interfoldToken: InterfoldToken; @@ -65,7 +73,15 @@ export const deployAndSaveInterfoldToken = async ({ const preDeployedArgs = readDeploymentArgs("InterfoldToken", chain); - if (!owner || preDeployedArgs?.constructorArgs?.owner === owner) { + if ( + !owner || + ccaStart === undefined || + ccaEnd === undefined || + !claimSource || + !bondingRegistry || + noMoreLocks === undefined || + preDeployedArgs?.constructorArgs?.owner === owner + ) { if (!preDeployedArgs?.address) { throw new Error( "InterfoldToken address not found, it must be deployed first", @@ -83,7 +99,14 @@ export const deployAndSaveInterfoldToken = async ({ const interfoldTokenFactory = await ethers.getContractFactory("InterfoldToken"); - const interfoldToken = await interfoldTokenFactory.deploy(owner); + const interfoldToken = await interfoldTokenFactory.deploy( + owner, + ccaStart, + ccaEnd, + noMoreLocks, + claimSource, + bondingRegistry, + ); await interfoldToken.waitForDeployment(); @@ -95,6 +118,11 @@ export const deployAndSaveInterfoldToken = async ({ { constructorArgs: { owner, + ccaStart: ccaStart.toString(), + ccaEnd: ccaEnd.toString(), + claimSource, + bondingRegistry, + noMoreLocks: noMoreLocks.toString(), }, blockNumber, address: interfoldTokenAddress, diff --git a/packages/interfold-contracts/scripts/deployInterfold.ts b/packages/interfold-contracts/scripts/deployInterfold.ts index 05eb05617..6666ee28e 100644 --- a/packages/interfold-contracts/scripts/deployInterfold.ts +++ b/packages/interfold-contracts/scripts/deployInterfold.ts @@ -22,6 +22,7 @@ import { deployAndSavePoseidonT3 } from "./deployAndSave/poseidonT3"; import { deployAndSaveSlashingManager } from "./deployAndSave/slashingManager"; import { deployAndSaveAllVerifiers } from "./deployAndSave/verifiers"; import { deployMocks } from "./deployMocks"; +import { isLocalDeploymentChain } from "./utils"; // BFV parameter presets — hardcoded from crates/fhe-params/src/constants.rs // to avoid a cyclic dependency on @interfold/sdk. @@ -71,6 +72,18 @@ const DEFAULT_TIMEOUT_CONFIG = { decryptionWindow: 3600, }; +function parseRequiredUint64(value: string, label: string): bigint { + if (!/^\d+$/.test(value)) { + throw new Error(`${label} must be a base-10 unix timestamp`); + } + const parsed = BigInt(value); + const maxUint64 = (1n << 64n) - 1n; + if (parsed > maxUint64) { + throw new Error(`${label} must fit in uint64`); + } + return parsed; +} + /** Circuit names required for BFV ZK verification in this script */ const DKG_AGGREGATOR_VERIFIER = "DkgAggregatorVerifier"; const DECRYPTION_AGGREGATOR_VERIFIER = "DecryptionAggregatorVerifier"; @@ -91,11 +104,18 @@ export const deployInterfold = async ( const [owner] = await ethers.getSigners(); const ownerAddress = await owner.getAddress(); + const latestBlock = await ethers.provider.getBlock("latest"); + if (!latestBlock) { + throw new Error("Could not read latest block for local TGE timestamp"); + } const encodedInsecure = encodeBfvParams(BFV_PARAMS.insecure512); const encodedSecure = encodeBfvParams(BFV_PARAMS.secure8192); const THIRTY_DAYS_IN_SECONDS = 60 * 60 * 24 * 30; + const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7; + const TGE_COOLDOWN_SECONDS = 60 * 60 * 24 * 45; + const FOUR_YEARS_IN_SECONDS = 60 * 60 * 24 * 365 * 4; const SORTITION_SUBMISSION_WINDOW = 10; const addressOne = "0x0000000000000000000000000000000000000001"; @@ -146,21 +166,25 @@ export const deployInterfold = async ( ); } - console.log("Deploying INTF token..."); - const { interfoldToken } = await deployAndSaveInterfoldToken({ - owner: ownerAddress, - hre, - }); - const interfoldTokenAddress = await interfoldToken.getAddress(); - console.log("InterfoldToken deployed to:", interfoldTokenAddress); - - if (interfoldTokenAddress.toLowerCase() === feeTokenAddress.toLowerCase()) { + // ── CCA window ────────────────────────────────────────────────────────── + // For local dev the CCA window starts soon after deployment and runs for + // 7 days. Production deployments must set INTERFOLD_CCA_START. + const ccaStartEnv = process.env.INTERFOLD_CCA_START; + let ccaStart: bigint; + if (ccaStartEnv?.trim()) { + ccaStart = parseRequiredUint64(ccaStartEnv.trim(), "INTERFOLD_CCA_START"); + } else if (isLocalDeploymentChain(networkName)) { + const now = BigInt(latestBlock.timestamp); + ccaStart = now + 3600n; // 1 hour from now + console.warn( + `[WARN] INTERFOLD_CCA_START not set; using ${ccaStart} (block.timestamp + 1h) for local deployment.`, + ); + } else { throw new Error( - "MockUSDC and InterfoldToken resolved to the same address. " + - "Start a fresh Anvil on http://127.0.0.1:8545 (e.g. `anvil --chain-id 31337`) " + - "and rerun deploy so token nonces advance separately.", + "INTERFOLD_CCA_START must be set for non-local token-lock deployment", ); } + const ccaEnd = ccaStart + BigInt(SEVEN_DAYS_IN_SECONDS); console.log("Deploying InterfoldTicketToken..."); const { interfoldTicketToken } = await deployAndSaveInterfoldTicketToken({ @@ -190,11 +214,14 @@ export const deployInterfold = async ( const ciphernodeRegistryAddress = await ciphernodeRegistry.getAddress(); console.log("CiphernodeRegistry deployed to:", ciphernodeRegistryAddress); + // BondingRegistry is deployed before INTF so its address can be passed to + // the token constructor. The license token is set to address(0) temporarily + // and fixed after INTF is deployed via setLicenseToken(). console.log("Deploying BondingRegistry..."); const { bondingRegistry } = await deployAndSaveBondingRegistry({ owner: ownerAddress, ticketToken: interfoldTicketTokenAddress, - licenseToken: interfoldTokenAddress, + licenseToken: ethers.ZeroAddress, registry: ciphernodeRegistryAddress, slashedFundsTreasury: ownerAddress, ticketPrice: ethers.parseUnits("10", 6).toString(), @@ -206,6 +233,41 @@ export const deployInterfold = async ( const bondingRegistryAddress = await bondingRegistry.getAddress(); console.log("BondingRegistry deployed to:", bondingRegistryAddress); + // INTF is deployed with BondingRegistry's real address. claimSource uses + // the deployer as a placeholder; the actual Interfold protocol contract is + // deployed later (its address is not known at this point). CLAIM_SOURCE is + // immutable, so local deployments use the deployer as the claim source. + console.log("Deploying INTF token..."); + const { interfoldToken } = await deployAndSaveInterfoldToken({ + owner: ownerAddress, + ccaStart, + ccaEnd, + claimSource: ownerAddress, + bondingRegistry: bondingRegistryAddress, + noMoreLocks: ccaEnd + BigInt(TGE_COOLDOWN_SECONDS + FOUR_YEARS_IN_SECONDS), + hre, + }); + const interfoldTokenAddress = await interfoldToken.getAddress(); + console.log("InterfoldToken deployed to:", interfoldTokenAddress); + + // Fix up BondingRegistry's license token now that INTF exists. + console.log("Setting license token in BondingRegistry..."); + await (await bondingRegistry.setLicenseToken(interfoldTokenAddress)).wait(); + + if (interfoldTokenAddress.toLowerCase() === feeTokenAddress.toLowerCase()) { + throw new Error( + "MockUSDC and InterfoldToken resolved to the same address. " + + "Start a fresh Anvil on http://127.0.0.1:8545 (e.g. `anvil --chain-id 31337`) " + + "and rerun deploy so token nonces advance separately.", + ); + } + + // Whitelist BondingRegistry so bonded transfers work pre-TGE. + console.log("Whitelisting BondingRegistry in INTF..."); + await ( + await interfoldToken.setTransferWhitelisted(bondingRegistryAddress, true) + ).wait(); + console.log("Deploying Interfold..."); const { interfold } = await deployAndSaveInterfold({ owner: ownerAddress, diff --git a/packages/interfold-contracts/tasks/ciphernode.ts b/packages/interfold-contracts/tasks/ciphernode.ts index ae4efed36..393b6ae70 100644 --- a/packages/interfold-contracts/tasks/ciphernode.ts +++ b/packages/interfold-contracts/tasks/ciphernode.ts @@ -276,10 +276,10 @@ export const ciphernodeMintTokens = task( console.log(`Minting tokens for: ${ciphernodeAddress}`); console.log(`Minting ${intfAmount} INTF...`); - const intfTx = await interfoldTokenContract.mintAllocation( + const intfTx = await interfoldTokenContract.mint( ciphernodeAddress, ethers.parseEther(intfAmount), - "Ciphernode allocation", + ethers.encodeBytes32String("cn-alloc"), ); await intfTx.wait(); console.log(`${intfAmount} INTF minted`); @@ -300,14 +300,18 @@ export const ciphernodeMintTokens = task( console.log(`INTF: ${ethers.formatEther(intfBalance)}`); console.log(`USDC: ${ethers.formatUnits(usdcBalance, 6)}`); - const transfersRestricted = - await interfoldTokenContract.transfersRestricted(); - if (transfersRestricted) { - console.log("Allowing InterfoldToken to be transferrable..."); - const transferEnabledTx = - await interfoldTokenContract.disableTransferRestrictions(); - await transferEnabledTx.wait(); - console.log("InterfoldToken transfers are now enabled"); + const tgeTs = await interfoldTokenContract.tgeTimestamp(); + if (tgeTs === 0n) { + console.log("Firing TGE to enable transfers..."); + try { + await (await interfoldTokenContract.tge()).wait(); + console.log("InterfoldToken TGE fired, transfers enabled"); + } catch (e: any) { + console.warn( + "TGE not yet available (CCA cooldown may not have passed):", + e.reason ?? e.message ?? e, + ); + } } } catch (error) { console.error("Token minting failed:", error); @@ -415,10 +419,10 @@ export const ciphernodeAdminAdd = task( console.log("Step 1: Minting and transferring INTF to ciphernode..."); - const intfTx = await interfoldTokenConnected.mintAllocation( + const intfTx = await interfoldTokenConnected.mint( adminWallet.address, licenseBondWei, - "Admin allocation for ciphernode registration", + ethers.encodeBytes32String("admin-cn-reg"), ); await intfTx.wait(); diff --git a/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts b/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts index 31177c192..19a50dfd5 100644 --- a/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts +++ b/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts @@ -146,11 +146,10 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const ticketTokenAddress = await bondingRegistry.ticketToken(); const ticketAmount = ethers.parseUnits("100", 6); - await intfToken.disableTransferRestrictions(); - await intfToken.mintAllocation( + await intfToken.mint( operatorAddress, ethers.parseEther("10000"), - "Test allocation", + ethers.encodeBytes32String("Test allocation"), ); await usdcToken.mint(operatorAddress, ethers.parseUnits("100000", 6)); diff --git a/packages/interfold-contracts/test/E3Lifecycle/Sortition.spec.ts b/packages/interfold-contracts/test/E3Lifecycle/Sortition.spec.ts index b6db2b6ab..bd4775f3b 100644 --- a/packages/interfold-contracts/test/E3Lifecycle/Sortition.spec.ts +++ b/packages/interfold-contracts/test/E3Lifecycle/Sortition.spec.ts @@ -33,10 +33,10 @@ async function fundOperator( ticketAmount: bigint, ) { const operatorAddress = await operator.getAddress(); - await licenseToken.mintAllocation( + await licenseToken.mint( operatorAddress, ethers.parseEther("10000"), - "Test allocation", + ethers.encodeBytes32String("Test allocation"), ); await feeToken.mint(operatorAddress, ethers.parseUnits("1000000", 6)); await licenseToken diff --git a/packages/interfold-contracts/test/Registry/BondingRegistry.spec.ts b/packages/interfold-contracts/test/Registry/BondingRegistry.spec.ts index 1cd3bc7b3..fb1e2b083 100644 --- a/packages/interfold-contracts/test/Registry/BondingRegistry.spec.ts +++ b/packages/interfold-contracts/test/Registry/BondingRegistry.spec.ts @@ -56,10 +56,10 @@ describe("BondingRegistry", function () { for (const address of [ownerAddress, operator1Address, operator2Address]) { await usdcToken.mint(address, USDC_AMOUNT); - await licenseToken.mintAllocation( + await licenseToken.mint( address, LICENSE_AMOUNT, - "Test allocation", + ethers.encodeBytes32String("Test allocation"), ); } @@ -129,6 +129,9 @@ describe("BondingRegistry", function () { expect( await bondingRegistry.getLicenseBond(await operator1.getAddress()), ).to.equal(bondAmount); + expect( + await bondingRegistry.totalBonded(await operator1.getAddress()), + ).to.equal(bondAmount); }); it("reverts if amount is zero", async function () { @@ -249,6 +252,43 @@ describe("BondingRegistry", function () { await operator1.getAddress(), ); expect(licensePending).to.equal(unbondAmount); + expect( + await bondingRegistry.totalBonded(await operator1.getAddress()), + ).to.equal(bondAmount); + }); + + it("slashes active and pending license bond from totalBonded", async function () { + const { bondingRegistry, licenseToken, operator1, notTheOwner } = + await loadFixture(setup); + const operatorAddress = await operator1.getAddress(); + const slashReason = ethers.encodeBytes32String("TEST_SLASH"); + + const bondAmount = ethers.parseEther("1000"); + const unbondAmount = ethers.parseEther("300"); + const slashAmount = ethers.parseEther("800"); + + await bondingRegistry.setSlashingManager(await notTheOwner.getAddress()); + await licenseToken + .connect(operator1) + .approve(await bondingRegistry.getAddress(), bondAmount); + await bondingRegistry.connect(operator1).bondLicense(bondAmount); + await bondingRegistry.connect(operator1).unbondLicense(unbondAmount); + + await expect( + bondingRegistry + .connect(notTheOwner) + .slashLicenseBond(operatorAddress, slashAmount, slashReason), + ) + .to.emit(bondingRegistry, "LicenseBondUpdated") + .withArgs(operatorAddress, -slashAmount, 0, slashReason); + + const [, pendingLicense] = + await bondingRegistry.pendingExits(operatorAddress); + expect(pendingLicense).to.equal(bondAmount - slashAmount); + expect(await bondingRegistry.totalBonded(operatorAddress)).to.equal( + bondAmount - slashAmount, + ); + expect(await bondingRegistry.slashedLicenseBond()).to.equal(slashAmount); }); }); @@ -1179,28 +1219,40 @@ describe("BondingRegistry", function () { * `_takeAssetsFromQueue` during a slash. */ it("H-21: queueAssetsForExit reverts after MAX_ACTIVE_TRANCHES live tranches", async function () { - const { bondingRegistry, licenseToken, operator1 } = - await loadFixture(setup); + const { + bondingRegistry, + licenseToken, + ticketToken, + usdcToken, + operator1, + } = await loadFixture(setup); - // Bond a large enough amount to do many tiny unbonds. - const big = ethers.parseEther("10000"); + // Register and fund tickets so the generic ExitQueueLib ticket path is + // exercised directly alongside INTF exits. + const bondAmount = LICENSE_REQUIRED_BOND; await licenseToken .connect(operator1) - .approve(await bondingRegistry.getAddress(), big); - await bondingRegistry.connect(operator1).bondLicense(big); + .approve(await bondingRegistry.getAddress(), bondAmount); + await bondingRegistry.connect(operator1).bondLicense(bondAmount); await bondingRegistry.connect(operator1).registerOperator(); + const ticketAmount = ethers.parseUnits("10000", 6); + await usdcToken + .connect(operator1) + .approve(await ticketToken.getAddress(), ticketAmount); + await bondingRegistry.connect(operator1).addTicketBalance(ticketAmount); + // Fill the queue with 64 distinct-timestamp tranches. - const step = ethers.parseEther("1"); + const step = ethers.parseUnits("1", 6); for (let i = 0; i < 64; i++) { - await bondingRegistry.connect(operator1).unbondLicense(step); + await bondingRegistry.connect(operator1).removeTicketBalance(step); // Ensure next unlock timestamp differs (no merge). await time.increase(1); } // The 65th must revert. await expect( - bondingRegistry.connect(operator1).unbondLicense(step), + bondingRegistry.connect(operator1).removeTicketBalance(step), ).to.be.revertedWithCustomError(bondingRegistry, "TooManyTranches"); }); diff --git a/packages/interfold-contracts/test/Slashing/CommitteeExpulsion.spec.ts b/packages/interfold-contracts/test/Slashing/CommitteeExpulsion.spec.ts index c8f10135a..e61f844e7 100644 --- a/packages/interfold-contracts/test/Slashing/CommitteeExpulsion.spec.ts +++ b/packages/interfold-contracts/test/Slashing/CommitteeExpulsion.spec.ts @@ -107,10 +107,10 @@ describe("Committee Expulsion & Fault Tolerance", function () { async function setupOperator(operator: Signer) { const operatorAddress = await operator.getAddress(); - await intfToken.mintAllocation( + await intfToken.mint( operatorAddress, ethers.parseEther("10000"), - "Test allocation", + ethers.encodeBytes32String("Test allocation"), ); await usdcToken.mint(operatorAddress, ethers.parseUnits("100000", 6)); diff --git a/packages/interfold-contracts/test/Slashing/SlashingLanes.spec.ts b/packages/interfold-contracts/test/Slashing/SlashingLanes.spec.ts index 4d289a06a..650dd7d60 100644 --- a/packages/interfold-contracts/test/Slashing/SlashingLanes.spec.ts +++ b/packages/interfold-contracts/test/Slashing/SlashingLanes.spec.ts @@ -111,10 +111,10 @@ describe("SlashingManager — lanes, roles, EIP-712 & admin handover", function } = sys; const mockCiphernodeRegistry = mockCiphernodeRegistryOpt!; - await interfoldToken.mintAllocation( + await interfoldToken.mint( operatorAddress, ethers.parseEther("2000"), - "Test allocation", + ethers.encodeBytes32String("Test allocation"), ); await slashingManager.addSlasher(await slasher.getAddress()); await slashingManager.setCiphernodeRegistry( diff --git a/packages/interfold-contracts/test/Slashing/SlashingManager.spec.ts b/packages/interfold-contracts/test/Slashing/SlashingManager.spec.ts index ad9fd9722..145e861a0 100644 --- a/packages/interfold-contracts/test/Slashing/SlashingManager.spec.ts +++ b/packages/interfold-contracts/test/Slashing/SlashingManager.spec.ts @@ -139,10 +139,10 @@ describe("SlashingManager", function () { const mockCiphernodeRegistryAddress = await mockCiphernodeRegistry.getAddress(); - await interfoldToken.mintAllocation( + await interfoldToken.mint( operatorAddress, ethers.parseEther("2000"), - "Test allocation", + ethers.encodeBytes32String("Test allocation"), ); await slashingManager.addSlasher(await slasher.getAddress()); await slashingManager.setCiphernodeRegistry(mockCiphernodeRegistryAddress); diff --git a/packages/interfold-contracts/test/Token/InterfoldToken.spec.ts b/packages/interfold-contracts/test/Token/InterfoldToken.spec.ts index d72d1cde4..94893703d 100644 --- a/packages/interfold-contracts/test/Token/InterfoldToken.spec.ts +++ b/packages/interfold-contracts/test/Token/InterfoldToken.spec.ts @@ -4,92 +4,2758 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. import { expect } from "chai"; -import { network } from "hardhat"; -import { InterfoldToken__factory as InterfoldTokenFactory } from "../../types"; +import { + InterfoldToken__factory as InterfoldTokenFactory, + MockBondingRegistry__factory as MockBondingRegistryFactory, +} from "../../types"; +import { deployInterfoldSystem, ethers, networkHelpers } from "../fixtures"; -const { ethers, networkHelpers } = await network.connect(); const { loadFixture, time } = networkHelpers; +const DAY = 24n * 60n * 60n; +const YEAR = 365n * DAY; +const NO_MORE_LOCKS_DELAY = 4n * YEAR; +const TGE_COOLDOWN = 45n * DAY; + +function noMoreLocksFor(ccaEnd: bigint) { + return ccaEnd + TGE_COOLDOWN + NO_MORE_LOCKS_DELAY; +} + describe("InterfoldToken", function () { + // ── Helpers ───────────────────────────────────────────────────────────── + + /// Deploy a minimal MockBondingRegistry + InterfoldToken for standalone tests. + /// CCA window starts far in the future so tests control the phase via + /// `time.increaseTo` / `time.increase`. async function deploy() { - const [deployer, admin, minter, whitelister, alice, bob] = - await ethers.getSigners(); + const [ + deployer, + admin, + minter, + whitelister, + lockManager, + alice, + bob, + claimSource, + ] = await ethers.getSigners(); + + // Deploy a minimal mock BondingRegistry that returns 0 for totalBonded. + const mockRegistry = await new MockBondingRegistryFactory( + deployer, + ).deploy(); + await mockRegistry.waitForDeployment(); + + const now = BigInt(await time.latest()); + const ccaStart = now + 10n * DAY; // far future — Virtual phase + const ccaEnd = ccaStart + 7n * DAY; + const noMoreLocks = noMoreLocksFor(ccaEnd); + const token = await new InterfoldTokenFactory(deployer).deploy( await admin.getAddress(), + ccaStart, + ccaEnd, + noMoreLocks, + await claimSource.getAddress(), + await mockRegistry.getAddress(), + ); + + return { + deployer, + admin, + minter, + whitelister, + lockManager, + alice, + bob, + claimSource, + token, + mockRegistry, + ccaStart, + ccaEnd, + noMoreLocks, + }; + } + + /// Deploy, create a policy, mint locked tokens, THEN fire TGE. + /// Returns everything needed for transfer-enforcement tests. + async function deployWithLockAndTge( + opts: { + policyName?: string; + mintAmount?: bigint; + vestDuration?: bigint; + holdUntil?: bigint; + recipient?: "alice" | "claimSource"; + } = {}, + ) { + const fixture = await loadFixture(deploy); + const { token, admin, alice, claimSource, ccaEnd } = fixture; + const recipient = opts.recipient === "claimSource" ? claimSource : alice; + const recipientAddress = await recipient.getAddress(); + const policyId = await createLinearPolicy( + token, + admin, + opts.policyName ?? "TEST_LOCK", + { + vestDuration: opts.vestDuration ?? 2n * YEAR, + holdUntil: opts.holdUntil, + }, ); - return { deployer, admin, minter, whitelister, alice, bob, token }; + const amount = opts.mintAmount ?? ethers.parseEther("1000"); + + // Mint during Virtual phase. + await token.connect(admin).mintAllocations([ + { + recipient: recipientAddress, + amount, + policyId, + label: ethers.encodeBytes32String("test"), + }, + ]); + + // Fire TGE. + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + const tgeTx = await token.tge(); + const receipt = await tgeTx.wait(); + const tgeBlock = await ethers.provider.getBlock(receipt!.blockNumber); + const tgeTimestamp = BigInt(tgeBlock!.timestamp); + + return { ...fixture, policyId, amount, tgeTimestamp, recipientAddress }; + } + + /// Deploy, mint unlocked tokens to alice, THEN fire TGE. + async function deployWithUnlockedAndTge(mintAmount?: bigint) { + const fixture = await loadFixture(deploy); + const { token, admin, alice, ccaEnd } = fixture; + const amount = mintAmount ?? ethers.parseEther("500"); + + await token + .connect(admin) + .mint(await alice.getAddress(), amount, ethers.ZeroHash); + + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + await token.tge(); + + return { ...fixture, amount }; + } + + // ── Helpers for lock policies ─────────────────────────────────────────── + + /// Create a standard linear lock policy and return its id. + async function createLinearPolicy( + token: Awaited>["token"], + admin: Awaited>["admin"], + policyId: string, + opts: { + anchor?: number; // 0 = Absolute, 1 = Tge + start?: bigint; + cliffDuration?: bigint; + vestDuration?: bigint; + holdUntil?: bigint; + } = {}, + ) { + const id = ethers.encodeBytes32String(policyId); + const anchor = opts.anchor ?? 1; // default Tge-anchored + const start = opts.start ?? 0n; + const cliffDuration = opts.cliffDuration ?? 0n; + const vestDuration = opts.vestDuration ?? 2n * YEAR; + await token.connect(admin).createLockPolicy(id, { + holdUntil: opts.holdUntil ?? 0n, + unlock: { anchor, start, cliffDuration, vestDuration }, + }); + return id; } - // ── H-15 ────────────────────────────────────────────────────────────────── - describe("H-15 — WHITELIST_ROLE separation + one-way disable", function () { - it("admin starts with DEFAULT_ADMIN_ROLE, MINTER_ROLE, and WHITELIST_ROLE", async function () { + // ═════════════════════════════════════════════════════════════════════════ + // Deployment & Constructor + // ═════════════════════════════════════════════════════════════════════════ + + describe("constructor", function () { + it("reverts when claimSource is zero address", async function () { + const [deployer] = await ethers.getSigners(); + const mockRegistry = await new MockBondingRegistryFactory( + deployer, + ).deploy(); + await mockRegistry.waitForDeployment(); + const now = BigInt(await time.latest()); + const ccaStart = now + DAY; + const ccaEnd = ccaStart + 7n * DAY; + const noMoreLocks = noMoreLocksFor(ccaEnd); + + await expect( + new InterfoldTokenFactory(deployer).deploy( + await deployer.getAddress(), + ccaStart, + ccaEnd, + noMoreLocks, + ethers.ZeroAddress, + await mockRegistry.getAddress(), + ), + ).to.be.revertedWithCustomError( + { interface: InterfoldTokenFactory.createInterface() }, + "ZeroAddress", + ); + }); + + it("reverts when bondingRegistry is zero address", async function () { + const [deployer] = await ethers.getSigners(); + const now = BigInt(await time.latest()); + const ccaStart = now + DAY; + const ccaEnd = ccaStart + 7n * DAY; + const noMoreLocks = noMoreLocksFor(ccaEnd); + + await expect( + new InterfoldTokenFactory(deployer).deploy( + await deployer.getAddress(), + ccaStart, + ccaEnd, + noMoreLocks, + await deployer.getAddress(), + ethers.ZeroAddress, + ), + ).to.be.revertedWithCustomError( + { interface: InterfoldTokenFactory.createInterface() }, + "ZeroAddress", + ); + }); + + it("reverts when bondingRegistry has no code (EOA)", async function () { + const [deployer, admin] = await ethers.getSigners(); + const now = BigInt(await time.latest()); + const ccaStart = now + DAY; + const ccaEnd = ccaStart + 7n * DAY; + const noMoreLocks = noMoreLocksFor(ccaEnd); + + await expect( + new InterfoldTokenFactory(deployer).deploy( + await admin.getAddress(), + ccaStart, + ccaEnd, + noMoreLocks, + await deployer.getAddress(), + await admin.getAddress(), // EOA, not a contract + ), + ).to.be.revertedWithCustomError( + { interface: InterfoldTokenFactory.createInterface() }, + "InvalidBondingRegistry", + ); + }); + + it("reverts when CCA start is in the past", async function () { + const [deployer] = await ethers.getSigners(); + const mockRegistry = await new MockBondingRegistryFactory( + deployer, + ).deploy(); + await mockRegistry.waitForDeployment(); + const now = BigInt(await time.latest()); + + await expect( + new InterfoldTokenFactory(deployer).deploy( + await deployer.getAddress(), + now, // in the past (or now) + now + 7n * DAY, + noMoreLocksFor(now + 7n * DAY), + await deployer.getAddress(), + await mockRegistry.getAddress(), + ), + ).to.be.revertedWithCustomError( + { interface: InterfoldTokenFactory.createInterface() }, + "InvalidCcaWindow", + ); + }); + + it("reverts when CCA end is not after start", async function () { + const [deployer] = await ethers.getSigners(); + const mockRegistry = await new MockBondingRegistryFactory( + deployer, + ).deploy(); + await mockRegistry.waitForDeployment(); + const now = BigInt(await time.latest()); + const ccaStart = now + DAY; + const ccaEnd = ccaStart; // equal, not greater + const noMoreLocks = noMoreLocksFor(ccaEnd); + + await expect( + new InterfoldTokenFactory(deployer).deploy( + await deployer.getAddress(), + ccaStart, + ccaEnd, + noMoreLocks, + await deployer.getAddress(), + await mockRegistry.getAddress(), + ), + ).to.be.revertedWithCustomError( + { interface: InterfoldTokenFactory.createInterface() }, + "InvalidCcaWindow", + ); + }); + + it("reverts when noMoreLocks is zero", async function () { + const [deployer] = await ethers.getSigners(); + const mockRegistry = await new MockBondingRegistryFactory( + deployer, + ).deploy(); + await mockRegistry.waitForDeployment(); + const now = BigInt(await time.latest()); + const ccaStart = now + DAY; + const ccaEnd = ccaStart + 7n * DAY; + + await expect( + new InterfoldTokenFactory(deployer).deploy( + await deployer.getAddress(), + ccaStart, + ccaEnd, + 0n, + await deployer.getAddress(), + await mockRegistry.getAddress(), + ), + ).to.be.revertedWithCustomError( + { interface: InterfoldTokenFactory.createInterface() }, + "ZeroAmount", + ); + }); + + it("reverts when noMoreLocks is not after the earliest TGE", async function () { + const [deployer] = await ethers.getSigners(); + const mockRegistry = await new MockBondingRegistryFactory( + deployer, + ).deploy(); + await mockRegistry.waitForDeployment(); + const now = BigInt(await time.latest()); + const ccaStart = now + DAY; + const ccaEnd = ccaStart + 7n * DAY; + const earliestTge = ccaEnd + TGE_COOLDOWN; + + await expect( + new InterfoldTokenFactory(deployer).deploy( + await deployer.getAddress(), + ccaStart, + ccaEnd, + earliestTge, // must be strictly after + await deployer.getAddress(), + await mockRegistry.getAddress(), + ), + ).to.be.revertedWithCustomError( + { interface: InterfoldTokenFactory.createInterface() }, + "InvalidNoMoreLocks", + ); + }); + + it("initial owner receives all roles", async function () { const { token, admin } = await loadFixture(deploy); - const DEFAULT_ADMIN_ROLE = await token.DEFAULT_ADMIN_ROLE(); - const MINTER_ROLE = await token.MINTER_ROLE(); - const WHITELIST_ROLE = await token.WHITELIST_ROLE(); - expect( - await token.hasRole(DEFAULT_ADMIN_ROLE, await admin.getAddress()), - ).to.equal(true); + const adminAddress = await admin.getAddress(); expect( - await token.hasRole(MINTER_ROLE, await admin.getAddress()), - ).to.equal(true); - expect( - await token.hasRole(WHITELIST_ROLE, await admin.getAddress()), - ).to.equal(true); + await token.hasRole(await token.DEFAULT_ADMIN_ROLE(), adminAddress), + ).to.be.true; + expect(await token.hasRole(await token.MINTER_ROLE(), adminAddress)).to.be + .true; + expect(await token.hasRole(await token.WHITELIST_ROLE(), adminAddress)).to + .be.true; + expect(await token.hasRole(await token.LOCK_MANAGER_ROLE(), adminAddress)) + .to.be.true; + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // Phase lifecycle + // ═════════════════════════════════════════════════════════════════════════ + + describe("phase()", function () { + it("starts in Virtual phase", async function () { + const { token } = await loadFixture(deploy); + expect(await token.phase()).to.equal(0); // Phase.Virtual + }); + + it("enters CCA during CCA window", async function () { + const { token, ccaStart } = await loadFixture(deploy); + await time.increaseTo(ccaStart); + expect(await token.phase()).to.equal(1); // Phase.CCA + }); + + it("enters Cooldown after CCA_END before TGE", async function () { + const { token, ccaEnd } = await loadFixture(deploy); + await time.increaseTo(ccaEnd); + expect(await token.phase()).to.equal(2); // Phase.Cooldown + }); + + it("enters Live phase after TGE", async function () { + const { token, ccaEnd } = await loadFixture(deploy); + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + await token.tge(); + expect(await token.phase()).to.equal(3); // Phase.Live + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // Minting + // ═════════════════════════════════════════════════════════════════════════ + + describe("mint", function () { + it("DEFAULT_ADMIN_ROLE can mint unlocked tokens during Virtual", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const amount = ethers.parseEther("100"); + await expect( + token + .connect(admin) + .mint( + await alice.getAddress(), + amount, + ethers.encodeBytes32String("test"), + ), + ) + .to.emit(token, "AllocationMinted") + .withArgs( + await alice.getAddress(), + amount, + ethers.ZeroHash, + ethers.encodeBytes32String("test"), + ); + expect(await token.balanceOf(await alice.getAddress())).to.equal(amount); + }); + + it("mint reverts after Virtual phase", async function () { + const { token, admin, alice, ccaStart } = await loadFixture(deploy); + await time.increaseTo(ccaStart); + await expect( + token + .connect(admin) + .mint( + await alice.getAddress(), + ethers.parseEther("1"), + ethers.encodeBytes32String("test"), + ), + ).to.be.revertedWithCustomError(token, "MintingClosed"); + }); + + it("reverts with zero amount", async function () { + const { token, admin, alice } = await loadFixture(deploy); + await expect( + token + .connect(admin) + .mint( + await alice.getAddress(), + 0n, + ethers.encodeBytes32String("test"), + ), + ).to.be.revertedWithCustomError(token, "ZeroAmount"); + }); + + it("reverts when MAX_SUPPLY would be exceeded", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const maxSupply = await token.MAX_SUPPLY(); + await expect( + token + .connect(admin) + .mint(await alice.getAddress(), maxSupply + 1n, ethers.ZeroHash), + ).to.be.revertedWithCustomError(token, "MaxSupplyExceeded"); + }); + }); + + describe("mintAllocations", function () { + it("MINTER_ROLE can mint locked allocations during Virtual", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const policyId = await createLinearPolicy(token, admin, "TEST_POLICY"); + const amount = ethers.parseEther("1000"); + + await expect( + token.connect(admin).mintAllocations([ + { + recipient: await alice.getAddress(), + amount, + policyId, + label: ethers.encodeBytes32String("test"), + }, + ]), + ) + .to.emit(token, "AllocationMinted") + .withArgs( + await alice.getAddress(), + amount, + policyId, + ethers.encodeBytes32String("test"), + ); + + // Tokens are locked — lockedBalanceOf should be > 0. + expect(await token.lockedBalanceOf(await alice.getAddress())).to.equal( + amount, + ); + expect(await token.balanceOf(await alice.getAddress())).to.equal(amount); + }); + + it("reverts with zero policyId", async function () { + const { token, admin, alice } = await loadFixture(deploy); + await expect( + token.connect(admin).mintAllocations([ + { + recipient: await alice.getAddress(), + amount: ethers.parseEther("1"), + policyId: ethers.ZeroHash, + label: ethers.encodeBytes32String("test"), + }, + ]), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("reverts with undefined policy", async function () { + const { token, admin, alice } = await loadFixture(deploy); + await expect( + token.connect(admin).mintAllocations([ + { + recipient: await alice.getAddress(), + amount: ethers.parseEther("1"), + policyId: ethers.encodeBytes32String("UNDEFINED"), + label: ethers.encodeBytes32String("test"), + }, + ]), + ).to.be.revertedWithCustomError(token, "PolicyNotDefined"); + }); + + it("reverts after Virtual phase", async function () { + const { token, admin, alice, ccaStart } = await loadFixture(deploy); + const policyId = await createLinearPolicy(token, admin, "TEST_POLICY"); + await time.increaseTo(ccaStart); + await expect( + token.connect(admin).mintAllocations([ + { + recipient: await alice.getAddress(), + amount: ethers.parseEther("1"), + policyId, + label: ethers.ZeroHash, + }, + ]), + ).to.be.revertedWithCustomError(token, "MintingClosed"); + }); + + it("handles mixed recipients and mixed policies in one batch", async function () { + const { token, admin, alice, bob } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + const bobAddress = await bob.getAddress(); + + const saftPolicy = await createLinearPolicy(token, admin, "SAFT_BATCH", { + vestDuration: 2n * YEAR, + }); + const teamPolicy = await createLinearPolicy(token, admin, "TEAM_BATCH", { + vestDuration: 3n * YEAR, + }); + const ccaPolicy = await createLinearPolicy(token, admin, "CCA_BATCH", { + vestDuration: 1n * YEAR, + }); + + // One batch: Alice gets SAFT + TEAM, Bob gets CCA. + const saftAmount = ethers.parseEther("1000"); + const teamAmount = ethers.parseEther("500"); + const ccaAmount = ethers.parseEther("700"); + + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount: saftAmount, + policyId: saftPolicy, + label: ethers.encodeBytes32String("saft"), + }, + { + recipient: aliceAddress, + amount: teamAmount, + policyId: teamPolicy, + label: ethers.encodeBytes32String("team"), + }, + { + recipient: bobAddress, + amount: ccaAmount, + policyId: ccaPolicy, + label: ethers.encodeBytes32String("cca"), + }, + ]); + + // Alice: 2 locks, 1500 total. + expect(await token.lockCount(aliceAddress)).to.equal(2n); + expect(await token.lockedBalanceOf(aliceAddress)).to.equal( + saftAmount + teamAmount, + ); + expect(await token.balanceOf(aliceAddress)).to.equal( + saftAmount + teamAmount, + ); + + // Bob: 1 lock, 700 total. + expect(await token.lockCount(bobAddress)).to.equal(1n); + expect(await token.lockedBalanceOf(bobAddress)).to.equal(ccaAmount); + expect(await token.balanceOf(bobAddress)).to.equal(ccaAmount); + + // Verify Alice's locks have the correct policies. + const aliceLock0 = await token.locks(aliceAddress, 0); + const aliceLock1 = await token.locks(aliceAddress, 1); + const alicePolicies = new Set([aliceLock0.policyId, aliceLock1.policyId]); + expect(alicePolicies.has(saftPolicy)).to.be.true; + expect(alicePolicies.has(teamPolicy)).to.be.true; + + // Bob's lock is CCA. + const bobLock = await token.locks(bobAddress, 0); + expect(bobLock.policyId).to.equal(ccaPolicy); + expect(bobLock.amount).to.equal(ccaAmount); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // TGE + // ═════════════════════════════════════════════════════════════════════════ + + describe("tge()", function () { + it("reverts before CCA_END + TGE_COOLDOWN", async function () { + const { token, ccaEnd } = await loadFixture(deploy); + await time.increaseTo(ccaEnd); // Cooldown phase but not enough + await expect(token.tge()).to.be.revertedWithCustomError( + token, + "TgeTooEarly", + ); + }); + + it("anyone can trigger TGE after cooldown", async function () { + const { token, ccaEnd, alice } = await loadFixture(deploy); + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + await expect(token.connect(alice).tge()).to.emit(token, "TgeTriggered"); + expect(await token.tgeTimestamp()).to.be.gt(0); + expect(await token.phase()).to.equal(3); // Live + }); + + it("reverts if already live", async function () { + const { token, ccaEnd } = await loadFixture(deploy); + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + await token.tge(); + await expect(token.tge()).to.be.revertedWithCustomError( + token, + "AlreadyLive", + ); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // Whitelisting + // ═════════════════════════════════════════════════════════════════════════ + + describe("setTransferWhitelisted", function () { + it("WHITELIST_ROLE can whitelist an address", async function () { + const { token, admin, alice } = await loadFixture(deploy); + await expect( + token + .connect(admin) + .setTransferWhitelisted(await alice.getAddress(), true), + ) + .to.emit(token, "TransferWhitelistUpdated") + .withArgs(await alice.getAddress(), true); + expect(await token.transferWhitelist(await alice.getAddress())).to.be + .true; }); - it("non-WHITELIST_ROLE cannot call toggleTransferWhitelist", async function () { + it("non-WHITELIST_ROLE cannot whitelist", async function () { const { token, alice } = await loadFixture(deploy); await expect( - token.connect(alice).toggleTransferWhitelist(await alice.getAddress()), + token + .connect(alice) + .setTransferWhitelisted(await alice.getAddress(), true), ).to.be.revertedWithCustomError( token, "AccessControlUnauthorizedAccount", ); }); - it("WHITELIST_ROLE without MINTER_ROLE can whitelist", async function () { - const { token, admin, whitelister, alice } = await loadFixture(deploy); - const WHITELIST_ROLE = await token.WHITELIST_ROLE(); - await token - .connect(admin) - .grantRole(WHITELIST_ROLE, await whitelister.getAddress()); + it("reverts with zero address", async function () { + const { token, admin } = await loadFixture(deploy); await expect( - token - .connect(whitelister) - .toggleTransferWhitelist(await alice.getAddress()), + token.connect(admin).setTransferWhitelisted(ethers.ZeroAddress, true), + ).to.be.revertedWithCustomError(token, "ZeroAddress"); + }); + }); + + describe("setClaimLockExempt", function () { + it("LOCK_MANAGER_ROLE can manage claim-lock exemption", async function () { + const { token, admin, alice } = await loadFixture(deploy); + await expect( + token.connect(admin).setClaimLockExempt(await alice.getAddress(), true), ) - .to.emit(token, "TransferWhitelistUpdated") + .to.emit(token, "ClaimLockExemptUpdated") .withArgs(await alice.getAddress(), true); }); - it("non-admin cannot disableTransferRestrictions", async function () { + it("non-LOCK_MANAGER_ROLE cannot manage claim-lock exemption", async function () { + const { token, alice } = await loadFixture(deploy); + await expect( + token.connect(alice).setClaimLockExempt(await alice.getAddress(), true), + ).to.be.revertedWithCustomError( + token, + "AccessControlUnauthorizedAccount", + ); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // Lock Policies + // ═════════════════════════════════════════════════════════════════════════ + + describe("createLockPolicy", function () { + it("LOCK_MANAGER_ROLE can create a policy", async function () { + const { token, admin } = await loadFixture(deploy); + const policyId = ethers.encodeBytes32String("MY_POLICY"); + await expect( + token.connect(admin).createLockPolicy(policyId, { + holdUntil: 0n, + unlock: { + anchor: 1, // Tge + start: 0n, + cliffDuration: 0n, + vestDuration: 2n * YEAR, + }, + }), + ).to.emit(token, "PolicyDefined"); + }); + + it("reverts on duplicate policy id (write-once)", async function () { + const { token, admin } = await loadFixture(deploy); + const policyId = ethers.encodeBytes32String("MY_POLICY"); + await token.connect(admin).createLockPolicy(policyId, { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: YEAR, + }, + }); + await expect( + token.connect(admin).createLockPolicy(policyId, { + holdUntil: 0n, + unlock: { + anchor: 0, + start: 1n, + cliffDuration: 1n, + vestDuration: 0n, + }, + }), + ).to.be.revertedWithCustomError(token, "PolicyAlreadyDefined"); + }); + + it("reverts with zero policyId", async function () { + const { token, admin } = await loadFixture(deploy); + await expect( + token.connect(admin).createLockPolicy(ethers.ZeroHash, { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: YEAR, + }, + }), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("reverts with PENDING policyId", async function () { + const { token, admin } = await loadFixture(deploy); + await expect( + token + .connect(admin) + .createLockPolicy(ethers.encodeBytes32String("PENDING"), { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: YEAR, + }, + }), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("reverts when both cliff and vest are zero", async function () { + const { token, admin } = await loadFixture(deploy); + await expect( + token + .connect(admin) + .createLockPolicy(ethers.encodeBytes32String("BAD"), { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: 0n, + }, + }), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("reverts when Absolute anchor has zero start", async function () { + const { token, admin } = await loadFixture(deploy); + await expect( + token + .connect(admin) + .createLockPolicy(ethers.encodeBytes32String("BAD"), { + holdUntil: 0n, + unlock: { + anchor: 0, + start: 0n, + cliffDuration: 1n, + vestDuration: 0n, + }, + }), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("reverts when Tge anchor has non-zero start", async function () { + const { token, admin } = await loadFixture(deploy); + await expect( + token + .connect(admin) + .createLockPolicy(ethers.encodeBytes32String("BAD"), { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 1n, + cliffDuration: 1n, + vestDuration: 0n, + }, + }), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("reverts when cliff exceeds vest duration", async function () { + const { token, admin } = await loadFixture(deploy); + await expect( + token + .connect(admin) + .createLockPolicy(ethers.encodeBytes32String("BAD"), { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 2n * YEAR, + vestDuration: YEAR, + }, + }), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("non-LOCK_MANAGER_ROLE cannot create a policy", async function () { const { token, alice } = await loadFixture(deploy); await expect( - token.connect(alice).disableTransferRestrictions(), + token + .connect(alice) + .createLockPolicy(ethers.encodeBytes32String("MY_POLICY"), { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: YEAR, + }, + }), ).to.be.revertedWithCustomError( token, "AccessControlUnauthorizedAccount", ); }); - it("disableTransferRestrictions is one-way (idempotent no-op on second call)", async function () { + it("reverts when Tge-anchored vest outlasts noMoreLocks", async function () { + const { token, admin } = await loadFixture(deploy); + await expect( + token + .connect(admin) + .createLockPolicy(ethers.encodeBytes32String("TOO_LONG"), { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: NO_MORE_LOCKS_DELAY + 1n, + }, + }), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("reverts when Tge-anchored cliff-only release outlasts noMoreLocks", async function () { + const { token, admin } = await loadFixture(deploy); + await expect( + token + .connect(admin) + .createLockPolicy(ethers.encodeBytes32String("TOO_LONG"), { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: NO_MORE_LOCKS_DELAY + 1n, + vestDuration: 0n, + }, + }), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("accepts Tge-anchored vest of exactly noMoreLocks", async function () { const { token, admin } = await loadFixture(deploy); - await expect(token.connect(admin).disableTransferRestrictions()) - .to.emit(token, "TransferRestrictionUpdated") - .withArgs(false); - expect(await token.transfersRestricted()).to.equal(false); - // Second call: idempotent no-op (does not revert, does not emit). await expect( - token.connect(admin).disableTransferRestrictions(), - ).to.not.emit(token, "TransferRestrictionUpdated"); - expect(await token.transfersRestricted()).to.equal(false); + token + .connect(admin) + .createLockPolicy(ethers.encodeBytes32String("FULL_TAIL"), { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: NO_MORE_LOCKS_DELAY, + }, + }), + ).to.emit(token, "PolicyDefined"); + }); + + it("reverts when Absolute curve ends past the earliest sunset", async function () { + const { token, admin, ccaEnd } = await loadFixture(deploy); + const earliestMaturity = noMoreLocksFor(ccaEnd); + await expect( + token + .connect(admin) + .createLockPolicy(ethers.encodeBytes32String("TOO_LONG"), { + holdUntil: 0n, + unlock: { + anchor: 0, + start: earliestMaturity - YEAR, + cliffDuration: 0n, + vestDuration: YEAR + 1n, + }, + }), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("reverts when holdUntil is past the earliest sunset", async function () { + const { token, admin, ccaEnd } = await loadFixture(deploy); + const earliestMaturity = noMoreLocksFor(ccaEnd); + await expect( + token + .connect(admin) + .createLockPolicy(ethers.encodeBytes32String("TOO_LONG"), { + holdUntil: earliestMaturity + 1n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: YEAR, + }, + }), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); }); }); - // ── M-29 ────────────────────────────────────────────────────────────────── - describe("M-29 — EIP-6372 timestamp clock", function () { + // ═════════════════════════════════════════════════════════════════════════ + // Lock enforcement + // ═════════════════════════════════════════════════════════════════════════ + + describe("lockedBalanceOf / lockedBalanceAt / transferableBalanceOf", function () { + it("lockedBalanceOf returns 0 for accounts with no locks", async function () { + const { token, alice } = await loadFixture(deploy); + expect(await token.lockedBalanceOf(await alice.getAddress())).to.equal( + 0n, + ); + }); + + it("mintAllocation creates a lock tracked by lockedBalanceOf", async function () { + const { token, alice, amount } = await deployWithLockAndTge({ + mintAmount: ethers.parseEther("2400"), + }); + expect(await token.lockedBalanceOf(await alice.getAddress())).to.equal( + amount, + ); + }); + + it("TGE-anchored policy releases nothing before TGE timestamp", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const policyId = await createLinearPolicy(token, admin, "TEST_POLICY", { + vestDuration: 2n * YEAR, + }); + const amount = ethers.parseEther("2400"); + + await token.connect(admin).mintAllocations([ + { + recipient: await alice.getAddress(), + amount, + policyId, + label: ethers.encodeBytes32String("test"), + }, + ]); + + // TGE not fired yet, Tge-anchored curve should keep everything locked. + expect(await token.lockedBalanceOf(await alice.getAddress())).to.equal( + amount, + ); + }); + + it("linear unlock over time after TGE", async function () { + const { token, admin, alice, ccaEnd } = await loadFixture(deploy); + const policyId = await createLinearPolicy(token, admin, "TEST_POLICY", { + vestDuration: 2n * YEAR, + }); + const amount = ethers.parseEther("2400"); + + await token.connect(admin).mintAllocations([ + { + recipient: await alice.getAddress(), + amount, + policyId, + label: ethers.encodeBytes32String("test"), + }, + ]); + + // Fire TGE. + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + const tgeTx = await token.tge(); + const receipt = await tgeTx.wait(); + const tgeBlock = await ethers.provider.getBlock(receipt!.blockNumber); + const tgeTimestamp = BigInt(tgeBlock!.timestamp); + + // Right at TGE: everything still locked (cliffDuration = 0 so it starts + // vesting immediately — but at timestamp == anchor, nothing has accrued). + expect(await token.lockedBalanceOf(await alice.getAddress())).to.equal( + amount, + ); + + // Halfway through vesting: half unlocked. + await time.increaseTo(tgeTimestamp + YEAR); + expect(await token.lockedBalanceOf(await alice.getAddress())).to.equal( + amount / 2n, + ); + + // Past vest end: fully unlocked. + await time.increaseTo(tgeTimestamp + 2n * YEAR); + expect(await token.lockedBalanceOf(await alice.getAddress())).to.equal( + 0n, + ); + }); + + it("lockedBalanceAt follows the TGE-linear curve over time", async function () { + // Use deployWithLockAndTge which creates a Tge-anchored lock with holdUntil=0. + // Then verify lockedBalanceAt at various timestamps. + const { token, alice, amount, tgeTimestamp } = await deployWithLockAndTge( + { mintAmount: ethers.parseEther("1000") }, + ); + const aliceAddress = await alice.getAddress(); + + // At tgeTimestamp, lock is fully locked (no time elapsed). + expect(await token.lockedBalanceAt(aliceAddress, tgeTimestamp)).to.equal( + amount, + ); + + // At tgeTimestamp + YEAR, half is unlocked (linear vest over 2Y). + expect( + await token.lockedBalanceAt(aliceAddress, tgeTimestamp + YEAR), + ).to.equal(amount / 2n); + + // At tgeTimestamp + 2*YEAR, fully unlocked. + expect( + await token.lockedBalanceAt(aliceAddress, tgeTimestamp + 2n * YEAR), + ).to.equal(0n); + }); + + it("sums multiple active locks with different curves correctly over time", async function () { + const { token, admin, alice, ccaEnd } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + + // Alice receives three locks with different vesting curves. + const policy24m = await createLinearPolicy(token, admin, "VEST_24M", { + vestDuration: 2n * YEAR, + }); + const policy12m = await createLinearPolicy(token, admin, "VEST_12M", { + vestDuration: 1n * YEAR, + }); + // Absolute policy: unlocks at a specific timestamp (ccaEnd + 180 days). + // Use cliffDuration=1 (1 second) — zero cliff+vest together is invalid. + const policyAbs = await createLinearPolicy(token, admin, "VEST_ABS", { + anchor: 0, + start: ccaEnd + 180n * DAY, + cliffDuration: 1n, + vestDuration: 0n, + }); + + const amount24m = ethers.parseEther("1000"); + const amount12m = ethers.parseEther("600"); + const amountAbs = ethers.parseEther("400"); + const total = amount24m + amount12m + amountAbs; + + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount: amount24m, + policyId: policy24m, + label: ethers.encodeBytes32String("v24"), + }, + { + recipient: aliceAddress, + amount: amount12m, + policyId: policy12m, + label: ethers.encodeBytes32String("v12"), + }, + { + recipient: aliceAddress, + amount: amountAbs, + policyId: policyAbs, + label: ethers.encodeBytes32String("abs"), + }, + ]); + + // Fire TGE. + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + const tgeTx = await token.tge(); + const receipt = await tgeTx.wait(); + const tgeBlock = await ethers.provider.getBlock(receipt!.blockNumber); + const tgeTimestamp = BigInt(tgeBlock!.timestamp); + + // At TGE: everything fully locked (all Tge-anchored + absolute cliff not yet). + expect(await token.lockedBalanceOf(aliceAddress)).to.equal(total); + + // TGE + 6 months: + // 24m policy: 6/24 = 25% unlocked → 750 locked + // 12m policy: 6/12 = 50% unlocked → 300 locked + // Absolute: start=ccaEnd+180d, TGE+6m past that → 0 locked + // Total locked ≈ 750 + 300 + 0 = 1050 + await time.increaseTo(tgeTimestamp + YEAR / 2n); + let locked = await token.lockedBalanceOf(aliceAddress); + expect(locked).to.be.closeTo( + ethers.parseEther("1050"), + ethers.parseEther("0.02"), + ); + + // TGE + 12 months: + // 24m policy: 12/24 = 50% unlocked → 500 locked + // 12m policy: 100% unlocked → 0 locked + // Absolute: unlocked → 0 locked + // Total locked ≈ 500 + await time.increaseTo(tgeTimestamp + YEAR); + locked = await token.lockedBalanceOf(aliceAddress); + expect(locked).to.be.closeTo( + ethers.parseEther("500"), + ethers.parseEther("0.02"), + ); + }); + + it("transferableBalanceOf returns full balance when nothing locked", async function () { + const { token, alice, amount } = await deployWithUnlockedAndTge( + ethers.parseEther("100"), + ); + expect( + await token.transferableBalanceOf(await alice.getAddress()), + ).to.equal(amount); + }); + + it("transferableBalanceOf = 0 when fully locked and no bond", async function () { + const { token, alice } = await deployWithLockAndTge({ + mintAmount: ethers.parseEther("1000"), + }); + expect( + await token.transferableBalanceOf(await alice.getAddress()), + ).to.equal(0n); + }); + + it("holdUntil keeps everything locked regardless of curve", async function () { + const { token, admin, alice, ccaEnd } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + + // Compute the intended TGE timestamp before firing it. + const TGE_COOLDOWN = 45n * DAY; + const intendedTge = ccaEnd + TGE_COOLDOWN + 1n; + + // Policy: 1-year linear vest, holdUntil = intended TGE + 2 years. + const policyId = await createLinearPolicy(token, admin, "HOLD_TEST", { + vestDuration: 1n * YEAR, + holdUntil: intendedTge + 2n * YEAR, + }); + + // Mint locked allocation DURING Virtual phase. + const amount = ethers.parseEther("1000"); + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount, + policyId, + label: ethers.encodeBytes32String("hold"), + }, + ]); + + // Fire TGE. + await time.increaseTo(intendedTge); + const tgeTx = await token.tge(); + const receipt = await tgeTx.wait(); + const tgeBlock = await ethers.provider.getBlock(receipt!.blockNumber); + const tgeTimestamp = BigInt(tgeBlock!.timestamp); + + // At TGE + 1.5 years: curve says fully unlocked (vestDuration = 1Y), + // but holdUntil = TGE + 2Y keeps everything locked. + await time.increaseTo(tgeTimestamp + (1n * YEAR + YEAR / 2n)); + expect(await token.lockedBalanceOf(aliceAddress)).to.equal(amount); + + // At TGE + 2 years (holdUntil): hold lifts, curve already fully vested. + await time.increaseTo(tgeTimestamp + 2n * YEAR); + expect(await token.lockedBalanceOf(aliceAddress)).to.equal(0n); + }); + + it("MAX_LOCKS_PER_ACCOUNT: 9th active policy reverts", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + const maxLocks = Number(await token.MAX_LOCKS_PER_ACCOUNT()); + + // Create 8 distinct policies and mint 1 wei under each. + for (let i = 0; i < maxLocks; i++) { + const policyId = ethers.encodeBytes32String(`CAP_${i}`); + await createLinearPolicy(token, admin, `CAP_${i}`, { + vestDuration: 1n * YEAR, + }); + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount: 1n, + policyId, + label: ethers.encodeBytes32String(`cap${i}`), + }, + ]); + } + expect(await token.lockCount(aliceAddress)).to.equal(BigInt(maxLocks)); + + // 9th policy should revert. + const ninthId = ethers.encodeBytes32String("CAP_9"); + await createLinearPolicy(token, admin, "CAP_9", { + vestDuration: 1n * YEAR, + }); + await expect( + token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount: 1n, + policyId: ninthId, + label: ethers.encodeBytes32String("toomany"), + }, + ]), + ).to.be.revertedWithCustomError(token, "TooManyLocks"); + }); + + it("MAX_QUEUED_LOCKS_PER_ACCOUNT: 9th queued policy reverts", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + const maxQueued = Number(await token.MAX_QUEUED_LOCKS_PER_ACCOUNT()); + + // Create 8 distinct policies and queue links. + for (let i = 0; i < maxQueued; i++) { + const policyId = ethers.encodeBytes32String(`QCAP_${i}`); + await createLinearPolicy(token, admin, `QCAP_${i}`, { + vestDuration: 1n * YEAR, + }); + await token.connect(admin).linkClaim(aliceAddress, 1n, policyId); + } + expect(await token.queuedLockCount(aliceAddress)).to.equal( + BigInt(maxQueued), + ); + + // 9th queued link should revert. + const ninthId = ethers.encodeBytes32String("QCAP_9"); + await createLinearPolicy(token, admin, "QCAP_9", { + vestDuration: 1n * YEAR, + }); + await expect( + token.connect(admin).linkClaim(aliceAddress, 1n, ninthId), + ).to.be.revertedWithCustomError(token, "TooManyQueuedLocks"); + }); + + it("incrementing an existing policy does not count as a new entry", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + const policyId = await createLinearPolicy(token, admin, "INCREMENT", { + vestDuration: 1n * YEAR, + }); + + // One allocation under the policy. + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount: ethers.parseEther("100"), + policyId, + label: ethers.encodeBytes32String("first"), + }, + ]); + expect(await token.lockCount(aliceAddress)).to.equal(1n); + + // Second allocation under the SAME policy -- increments amount, not a new entry. + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount: ethers.parseEther("100"), + policyId, + label: ethers.encodeBytes32String("second"), + }, + ]); + expect(await token.lockCount(aliceAddress)).to.equal(1n); + + const lock = await token.locks(aliceAddress, 0); + expect(lock.amount).to.equal(ethers.parseEther("200")); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // Transfer enforcement + // ═════════════════════════════════════════════════════════════════════════ + + describe("transfer enforcement", function () { + it("blocks transfer that would drop below locked balance", async function () { + const { token, alice, bob, amount } = await deployWithLockAndTge({ + mintAmount: ethers.parseEther("1000"), + }); + // After TGE, a tiny fraction may have unlocked (1-2 seconds of vesting). + // transferableBalance should be far less than the full amount. + const transferable = await token.transferableBalanceOf( + await alice.getAddress(), + ); + expect(transferable).to.be.lt(amount / 2n); + // Attempting to transfer the full amount should revert. + await expect( + token.connect(alice).transfer(await bob.getAddress(), amount), + ).to.be.revertedWithCustomError(token, "InsufficientUnlockedBalance"); + }); + + it("allows transfer of unlocked portion", async function () { + const { token, alice, bob, amount, tgeTimestamp } = + await deployWithLockAndTge({ + mintAmount: ethers.parseEther("1000"), + vestDuration: 2n * YEAR, + }); + + await time.increaseTo(tgeTimestamp + YEAR); + + // Half unlocked. + const half = amount / 2n; + expect( + await token.transferableBalanceOf(await alice.getAddress()), + ).to.equal(half); + + await token.connect(alice).transfer(await bob.getAddress(), half); + }); + + it("pre-TGE: bonding registry transfers are allowed", async function () { + const { token, admin, alice, mockRegistry } = await loadFixture(deploy); + const amount = ethers.parseEther("100"); + const registryAddress = await mockRegistry.getAddress(); + + await token + .connect(admin) + .mint(await alice.getAddress(), amount, ethers.ZeroHash); + + // Transfer TO bonding registry — should work pre-TGE. + await token.connect(alice).transfer(registryAddress, amount); + }); + + it("pre-TGE: whitelisted addresses can transfer", async function () { + const { token, admin, alice, bob } = await loadFixture(deploy); + const amount = ethers.parseEther("100"); + + await token + .connect(admin) + .mint(await alice.getAddress(), amount, ethers.ZeroHash); + await token + .connect(admin) + .setTransferWhitelisted(await alice.getAddress(), true); + + await token.connect(alice).transfer(await bob.getAddress(), amount); + }); + + it("pre-TGE: claim source transfers are allowed", async function () { + const { token, admin, alice, claimSource } = await loadFixture(deploy); + const amount = ethers.parseEther("100"); + + await token + .connect(admin) + .mint(await claimSource.getAddress(), amount, ethers.ZeroHash); + + await token + .connect(claimSource) + .transfer(await alice.getAddress(), amount); + }); + + it("pre-TGE: regular transfers are blocked", async function () { + const { token, admin, alice, bob } = await loadFixture(deploy); + const amount = ethers.parseEther("100"); + + await token + .connect(admin) + .mint(await alice.getAddress(), amount, ethers.ZeroHash); + + await expect( + token.connect(alice).transfer(await bob.getAddress(), amount), + ).to.be.revertedWithCustomError(token, "TransferRestricted"); + }); + + it("pre-TGE: whitelist does NOT bypass locked-balance invariant", async function () { + const { token, admin, alice, bob } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + const policyId = await createLinearPolicy( + token, + admin, + "WHITELIST_LOCK", + { + vestDuration: 2n * YEAR, + }, + ); + const amount = ethers.parseEther("1000"); + + // Mint locked allocation to Alice. + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount, + policyId, + label: ethers.encodeBytes32String("locked"), + }, + ]); + + // Whitelist Alice. + await token.connect(admin).setTransferWhitelisted(aliceAddress, true); + + // Pre-TGE, whitelist bypasses the transfer gate but NOT the lock invariant. + // Alice has all tokens locked -> transferableBalance is 0 -> transfer reverts. + await expect( + token.connect(alice).transfer(await bob.getAddress(), amount), + ).to.be.revertedWithCustomError(token, "InsufficientUnlockedBalance"); + }); + + it("pre-TGE: CLAIM_SOURCE transfer creates PENDING lock on recipient", async function () { + const { token, admin, alice, claimSource } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + const amount = ethers.parseEther("500"); + + // Mint unlocked tokens to claimSource during Virtual phase. + await token + .connect(admin) + .mint(await claimSource.getAddress(), amount, ethers.ZeroHash); + + // Pre-TGE claim-source transfer to Alice. + await token.connect(claimSource).transfer(aliceAddress, amount); + + // Alice received a PENDING lock (pre-TGE, no bond, so fully locked). + expect(await token.lockedBalanceOf(aliceAddress)).to.equal(amount); + expect(await token.lockCount(aliceAddress)).to.equal(1n); + + const lock = await token.locks(aliceAddress, 0); + expect(lock.policyId).to.equal(ethers.encodeBytes32String("PENDING")); + expect(lock.amount).to.equal(amount); + + // Pre-TGE, Alice cannot transfer it onward at all (transfer gate). + // The locked-balance invariant would also block it, but the pre-TGE + // gate catches it first. Both protections are correct. + await expect( + token.connect(alice).transfer(await claimSource.getAddress(), amount), + ).to.be.revertedWithCustomError(token, "TransferRestricted"); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // Lock sunset + // ═════════════════════════════════════════════════════════════════════════ + + describe("lock sunset", function () { + it("NO_MORE_LOCKS is fixed at deployment", async function () { + const { token, noMoreLocks } = await loadFixture(deploy); + expect(await token.NO_MORE_LOCKS()).to.equal(noMoreLocks); + }); + + it("locked balance becomes fully transferable at the sunset", async function () { + const { token, alice, bob, amount, noMoreLocks } = + await deployWithLockAndTge({ mintAmount: ethers.parseEther("1000") }); + const aliceAddress = await alice.getAddress(); + + await time.increaseTo(noMoreLocks); + + expect(await token.lockedBalanceOf(aliceAddress)).to.equal(0n); + expect(await token.transferableBalanceOf(aliceAddress)).to.equal(amount); + await token.connect(alice).transfer(await bob.getAddress(), amount); + }); + + it("unlinked PENDING locks sunset too", async function () { + const { token, alice, bob, claimSource, amount } = + await deployWithUnlockedAndTge(ethers.parseEther("500")); + const aliceAddress = await alice.getAddress(); + + await token + .connect(alice) + .transfer(await claimSource.getAddress(), amount); + await token.connect(claimSource).transfer(aliceAddress, amount); + expect(await token.lockedBalanceOf(aliceAddress)).to.equal(amount); + + await time.increaseTo(await token.NO_MORE_LOCKS()); + + expect(await token.lockedBalanceOf(aliceAddress)).to.equal(0n); + await token.connect(alice).transfer(await bob.getAddress(), amount); + }); + + it("lockedBalanceAt reports 0 from the sunset onwards", async function () { + const { token, alice, claimSource, amount } = + await deployWithUnlockedAndTge(ethers.parseEther("500")); + const aliceAddress = await alice.getAddress(); + + await token + .connect(alice) + .transfer(await claimSource.getAddress(), amount); + await token.connect(claimSource).transfer(aliceAddress, amount); + + const maturity = await token.NO_MORE_LOCKS(); + expect(await token.lockedBalanceAt(aliceAddress, maturity - 1n)).to.equal( + amount, + ); + expect(await token.lockedBalanceAt(aliceAddress, maturity)).to.equal(0n); + }); + + it("CLAIM_SOURCE transfers past the sunset create no locks", async function () { + const { token, alice, claimSource, amount } = + await deployWithUnlockedAndTge(ethers.parseEther("500")); + const aliceAddress = await alice.getAddress(); + + await token + .connect(alice) + .transfer(await claimSource.getAddress(), amount); + + await time.increaseTo(await token.NO_MORE_LOCKS()); + + await token.connect(claimSource).transfer(aliceAddress, amount); + expect(await token.lockCount(aliceAddress)).to.equal(0n); + expect(await token.lockedBalanceOf(aliceAddress)).to.equal(0n); + }); + + it("late TGE: max-length policy tail is truncated at NO_MORE_LOCKS", async function () { + const { token, admin, alice, ccaEnd } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + + // Create a policy whose natural end lands exactly at NO_MORE_LOCKS + // (using the earliest possible TGE: ccaEnd + TGE_COOLDOWN). + const earliestTge = ccaEnd + TGE_COOLDOWN; + // The tail after earliestTge is NO_MORE_LOCKS_DELAY = 4 years. + const policyId = await createLinearPolicy(token, admin, "MAX_TAIL", { + vestDuration: NO_MORE_LOCKS_DELAY, + }); + const amount = ethers.parseEther("1000"); + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount, + policyId, + label: ethers.encodeBytes32String("tail"), + }, + ]); + + // Call TGE slightly late -- 1 day past the earliest possible TGE. + await time.increaseTo(earliestTge + 1n * DAY); + await token.tge(); + + // Advance to NO_MORE_LOCKS. The natural unlock tail would extend 1 day + // past NO_MORE_LOCKS (since TGE was 1 day late), but NO_MORE_LOCKS + // overrides -- locked balance MUST be 0. + await time.increaseTo(await token.NO_MORE_LOCKS()); + expect(await token.lockedBalanceOf(aliceAddress)).to.equal(0n); + }); + + it("absolute sunset without TGE: transfers succeed and no locks are created", async function () { + const { token, admin, alice, bob, claimSource, noMoreLocks } = + await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + const claimSourceAddress = await claimSource.getAddress(); + const amount = ethers.parseEther("500"); + + // Mint unlocked tokens during Virtual phase (before time advance). + await token.connect(admin).mint(aliceAddress, amount, ethers.ZeroHash); + await token + .connect(admin) + .mint(claimSourceAddress, amount, ethers.ZeroHash); + + // Do NOT call tge(). Advance straight to NO_MORE_LOCKS. + await time.increaseTo(noMoreLocks); + + // Regular transfer succeeds. + await token.connect(alice).transfer(await bob.getAddress(), amount); + expect(await token.balanceOf(aliceAddress)).to.equal(0n); + + // lockedBalanceOf returns 0. + expect(await token.lockedBalanceOf(aliceAddress)).to.equal(0n); + + // CLAIM_SOURCE transfer creates no lock. + await token.connect(claimSource).transfer(aliceAddress, amount); + expect(await token.lockedBalanceOf(aliceAddress)).to.equal(0n); + expect(await token.lockCount(aliceAddress)).to.equal(0n); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // Claim-source auto-lock & linkClaim + // ═════════════════════════════════════════════════════════════════════════ + + describe("claim-source auto-lock & linkClaim", function () { + it("CLAIM_SOURCE transfers create PENDING locks", async function () { + const { token, alice, claimSource, amount } = + await deployWithUnlockedAndTge(ethers.parseEther("500")); + + // Transfer from alice to claimSource first so claimSource has tokens. + await token + .connect(alice) + .transfer(await claimSource.getAddress(), amount); + + await token + .connect(claimSource) + .transfer(await alice.getAddress(), amount); + + // Pending lock should be created. + expect(await token.lockedBalanceOf(await alice.getAddress())).to.equal( + amount, + ); + }); + + it("claimLockExempt exempts from auto-lock on claim-source transfer", async function () { + const { token, admin, alice, claimSource, amount } = + await deployWithUnlockedAndTge(ethers.parseEther("500")); + + await token + .connect(admin) + .setClaimLockExempt(await alice.getAddress(), true); + + // Transfer tokens from alice to claimSource so claimSource can send. + await token + .connect(alice) + .transfer(await claimSource.getAddress(), amount); + + await token + .connect(claimSource) + .transfer(await alice.getAddress(), amount); + + // No lock created because recipient is claim-lock exempt. + expect(await token.lockedBalanceOf(await alice.getAddress())).to.equal( + 0n, + ); + expect(await token.balanceOf(await alice.getAddress())).to.equal(amount); + }); + + it("linkClaim moves PENDING to a real policy", async function () { + const { token, admin, alice, claimSource, amount } = + await deployWithUnlockedAndTge(ethers.parseEther("500")); + const policyId = await createLinearPolicy(token, admin, "REAL_POLICY", { + vestDuration: 2n * YEAR, + }); + + // Transfer from alice to claimSource so claimSource can send. + await token + .connect(alice) + .transfer(await claimSource.getAddress(), amount); + await token + .connect(claimSource) + .transfer(await alice.getAddress(), amount); + + // Now link the claim to the real policy. + await token + .connect(admin) + .linkClaim(await alice.getAddress(), amount, policyId); + + // Lock should still exist but now under the real policy (allow tiny + // rounding from vesting elapsed seconds). + const lb = await token.lockedBalanceOf(await alice.getAddress()); + expect(lb).to.be.closeTo(amount, ethers.parseEther("0.01")); + }); + + it("linkClaim queues unfilled amounts for future claims", async function () { + const fixture = await loadFixture(deploy); + const { token, admin, alice, claimSource, ccaEnd } = fixture; + const policyId = await createLinearPolicy(token, admin, "FUTURE_POLICY", { + vestDuration: 2n * YEAR, + }); + const linkAmount = ethers.parseEther("500"); + + // Link before any claim arrives — should queue. + await token + .connect(admin) + .linkClaim(await alice.getAddress(), linkAmount, policyId); + + // No balance yet so no active lock. + expect(await token.lockedBalanceOf(await alice.getAddress())).to.equal( + 0n, + ); + + // Mint tokens to claimSource during Virtual phase. + await token + .connect(admin) + .mint(await claimSource.getAddress(), linkAmount, ethers.ZeroHash); + + // Fire TGE so transfers are unrestricted. + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + await token.tge(); + + // Now send a claim — it should consume the queued lock. + await token + .connect(claimSource) + .transfer(await alice.getAddress(), linkAmount); + + // Queued lock should be consumed and active lock created (allow tiny + // rounding from vesting elapsed seconds). + const lb2 = await token.lockedBalanceOf(await alice.getAddress()); + expect(lb2).to.be.closeTo(linkAmount, ethers.parseEther("0.01")); + }); + + it("linkClaim partly consumes PENDING and queues the remainder", async function () { + const { token, admin, alice, claimSource, amount } = + await deployWithUnlockedAndTge(ethers.parseEther("300")); + const aliceAddress = await alice.getAddress(); + const policyId = await createLinearPolicy(token, admin, "PART_QUEUE", { + vestDuration: 2n * YEAR, + }); + + await token + .connect(alice) + .transfer(await claimSource.getAddress(), amount); + + expect(await token.lockCount(aliceAddress)).to.equal(0n); + expect(await token.queuedLockCount(aliceAddress)).to.equal(0n); + + await token.connect(claimSource).transfer(aliceAddress, amount); + + const linkAmount = ethers.parseEther("1000"); + await token.connect(admin).linkClaim(aliceAddress, linkAmount, policyId); + + expect(await token.lockCount(aliceAddress)).to.equal(1n); + expect(await token.queuedLockCount(aliceAddress)).to.equal(1n); + + const activeLock = await token.locks(aliceAddress, 0); + expect(activeLock.policyId).to.equal(policyId); + expect(activeLock.amount).to.equal(amount); + + const queuedLock = await token.queuedLocks(aliceAddress, 0); + expect(queuedLock.policyId).to.equal(policyId); + expect(queuedLock.amount).to.equal(linkAmount - amount); + }); + + it("claim after link consumes the queued link", async function () { + const fixture = await loadFixture(deploy); + const { token, admin, alice, claimSource, ccaEnd } = fixture; + const aliceAddress = await alice.getAddress(); + const policyId = await createLinearPolicy(token, admin, "CLAIM_LINK", { + vestDuration: 2n * YEAR, + }); + const linkAmount = ethers.parseEther("500"); + + await token.connect(admin).linkClaim(aliceAddress, linkAmount, policyId); + expect(await token.queuedLockCount(aliceAddress)).to.equal(1n); + + await token + .connect(admin) + .mint(await claimSource.getAddress(), linkAmount, ethers.ZeroHash); + + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + await token.tge(); + + await token.connect(claimSource).transfer(aliceAddress, linkAmount); + + expect(await token.queuedLockCount(aliceAddress)).to.equal(0n); + expect(await token.lockCount(aliceAddress)).to.equal(1n); + + const activeLock = await token.locks(aliceAddress, 0); + expect(activeLock.policyId).to.equal(policyId); + expect(activeLock.amount).to.equal(linkAmount); + }); + + it("claim after link fully consumes queued link and adds excess as PENDING", async function () { + const fixture = await loadFixture(deploy); + const { token, admin, alice, claimSource, ccaEnd } = fixture; + const aliceAddress = await alice.getAddress(); + const policyId = await createLinearPolicy(token, admin, "LINK_PENDING", { + vestDuration: 2n * YEAR, + }); + const linkAmount = ethers.parseEther("500"); + const claimAmount = ethers.parseEther("700"); + const pendingPolicyId = await token.PENDING_LOCK_POLICY_ID(); + + await token.connect(admin).linkClaim(aliceAddress, linkAmount, policyId); + await token + .connect(admin) + .mint(await claimSource.getAddress(), claimAmount, ethers.ZeroHash); + + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + await token.tge(); + + await token.connect(claimSource).transfer(aliceAddress, claimAmount); + + expect(await token.queuedLockCount(aliceAddress)).to.equal(0n); + expect(await token.lockCount(aliceAddress)).to.equal(2n); + + const firstLock = await token.locks(aliceAddress, 0); + const secondLock = await token.locks(aliceAddress, 1); + const locksByPolicy = new Map([ + [firstLock.policyId, firstLock.amount], + [secondLock.policyId, secondLock.amount], + ]); + + expect(locksByPolicy.get(policyId)).to.equal(linkAmount); + expect(locksByPolicy.get(pendingPolicyId)).to.equal( + claimAmount - linkAmount, + ); + }); + + it("linkClaim reverts with undefined policy", async function () { + const { token, admin, alice } = await loadFixture(deploy); + await expect( + token + .connect(admin) + .linkClaim( + await alice.getAddress(), + ethers.parseEther("1"), + ethers.encodeBytes32String("UNDEFINED"), + ), + ).to.be.revertedWithCustomError(token, "PolicyNotDefined"); + }); + + it("linkClaim reverts with zero amount", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const policyId = await createLinearPolicy(token, admin, "REAL_POLICY"); + await expect( + token.connect(admin).linkClaim(await alice.getAddress(), 0n, policyId), + ).to.be.revertedWithCustomError(token, "ZeroAmount"); + }); + + it("non-LOCK_MANAGER_ROLE cannot linkClaim", async function () { + const { token, alice } = await loadFixture(deploy); + await expect( + token + .connect(alice) + .linkClaim( + await alice.getAddress(), + ethers.parseEther("1"), + ethers.encodeBytes32String("ANY"), + ), + ).to.be.revertedWithCustomError( + token, + "AccessControlUnauthorizedAccount", + ); + }); + + it("queued locks survive multiple partial claims", async function () { + const fixture = await loadFixture(deploy); + const { token, admin, alice, claimSource, ccaEnd } = fixture; + const policyId = await createLinearPolicy(token, admin, "PARTIAL", { + vestDuration: 2n * YEAR, + }); + const linkAmount = ethers.parseEther("1000"); + + // Queue a large amount. + await token + .connect(admin) + .linkClaim(await alice.getAddress(), linkAmount, policyId); + + // Mint all claim tokens during Virtual phase. + const totalClaim = ethers.parseEther("700"); + await token + .connect(admin) + .mint(await claimSource.getAddress(), totalClaim, ethers.ZeroHash); + + // Fire TGE. + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + await token.tge(); + + // Send a partial claim. + const partialAmount = ethers.parseEther("400"); + await token + .connect(claimSource) + .transfer(await alice.getAddress(), partialAmount); + + let lb3 = await token.lockedBalanceOf(await alice.getAddress()); + expect(lb3).to.be.closeTo(partialAmount, ethers.parseEther("0.01")); + + // Send another claim. + const anotherAmount = ethers.parseEther("300"); + await token + .connect(claimSource) + .transfer(await alice.getAddress(), anotherAmount); + + lb3 = await token.lockedBalanceOf(await alice.getAddress()); + expect(lb3).to.be.closeTo( + partialAmount + anotherAmount, + ethers.parseEther("0.01"), + ); + }); + + it("links CCA claim without disturbing existing non-CCA locks", async function () { + const fixture = await loadFixture(deploy); + const { token, admin, alice, claimSource, ccaEnd } = fixture; + const aliceAddress = await alice.getAddress(); + + // Alice already has a Legion/SAFT lock — mint BEFORE TGE. + const legionPolicy = await createLinearPolicy(token, admin, "LEGION", { + vestDuration: 2n * YEAR, + }); + const legionAmount = ethers.parseEther("1000"); + + // Mint extra unlocked tokens to fund the claim transfer. + const claimAmount = ethers.parseEther("500"); + await token + .connect(admin) + .mint(aliceAddress, claimAmount, ethers.ZeroHash); + + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount: legionAmount, + policyId: legionPolicy, + label: ethers.encodeBytes32String("legion"), + }, + ]); + + // Fire TGE. + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + await token.tge(); + + // Now CLAIM_SOURCE sends tokens — becomes PENDING. + await token + .connect(alice) + .transfer(await claimSource.getAddress(), claimAmount); + await token.connect(claimSource).transfer(aliceAddress, claimAmount); + + // Lock before link: LEGION active + PENDING. + expect(await token.lockCount(aliceAddress)).to.equal(2n); + + // linkClaim the PENDING to CCA_POLICY. + const ccaPolicy = await createLinearPolicy(token, admin, "CCA", { + vestDuration: 1n * YEAR, + }); + await token + .connect(admin) + .linkClaim(aliceAddress, claimAmount, ccaPolicy); + + // LEGION still has 1,000; CCA now has 500; no PENDING remains. + expect(await token.lockCount(aliceAddress)).to.equal(2n); + const locks = [ + await token.locks(aliceAddress, 0), + await token.locks(aliceAddress, 1), + ]; + const byPolicy = new Map( + locks.map((l: { policyId: string; amount: bigint }) => [ + l.policyId, + l.amount, + ]), + ); + expect(byPolicy.get(legionPolicy)).to.equal(legionAmount); + expect(byPolicy.get(ccaPolicy)).to.equal(claimAmount); + + // lockedBalanceOf equals sum of both (allow tiny vesting rounding). + const lb = await token.lockedBalanceOf(aliceAddress); + expect(lb).to.be.closeTo( + legionAmount + claimAmount, + ethers.parseEther("0.02"), + ); + }); + + it("supports mixed allocation types for one wallet: unlocked grant + vested allocation + CCA claim", async function () { + const fixture = await loadFixture(deploy); + const { token, admin, alice, claimSource, ccaEnd } = fixture; + const aliceAddress = await alice.getAddress(); + + // 1. Unlocked grant. + const grantAmount = ethers.parseEther("100"); + await token + .connect(admin) + .mint(aliceAddress, grantAmount, ethers.ZeroHash); + + // 2. Legion/SAFT vested allocation. + const legionPolicy = await createLinearPolicy(token, admin, "SAFT", { + vestDuration: 2n * YEAR, + }); + const legionAmount = ethers.parseEther("1000"); + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount: legionAmount, + policyId: legionPolicy, + label: ethers.encodeBytes32String("saft"), + }, + ]); + + // 3. CCA claim tokens: mint to claimSource, then later transfer back. + const ccaAmount = ethers.parseEther("500"); + await token + .connect(admin) + .mint(await claimSource.getAddress(), ccaAmount, ethers.ZeroHash); + + // Fire TGE. + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + await token.tge(); + + // Now CLAIM_SOURCE sends tokens — becomes PENDING. + await token.connect(claimSource).transfer(aliceAddress, ccaAmount); + + // Link CCA PENDING to a CCA policy. + const ccaPolicy = await createLinearPolicy(token, admin, "CCA_VEST", { + vestDuration: 1n * YEAR, + }); + await token.connect(admin).linkClaim(aliceAddress, ccaAmount, ccaPolicy); + + // Assertions after all allocations are in place. + const totalBalance = grantAmount + legionAmount + ccaAmount; + expect(await token.balanceOf(aliceAddress)).to.equal(totalBalance); + expect(await token.lockCount(aliceAddress)).to.equal(2n); + + // Sum of locked: SAFT locked + CCA locked (grant is unlocked). + const lb = await token.lockedBalanceOf(aliceAddress); + expect(lb).to.be.closeTo( + legionAmount + ccaAmount, + ethers.parseEther("0.02"), + ); + + // Grant portion (100) is unlocked and transferable subject to floor. + // With no bond, floor = lockedBalance ≈ 1500. Wallet = 1600. + // transferable ≈ 1600 - 1500 = 100 (the grant portion). + const tb = await token.transferableBalanceOf(aliceAddress); + expect(tb).to.be.closeTo(grantAmount, ethers.parseEther("0.02")); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // relinkActiveLock + // ═════════════════════════════════════════════════════════════════════════ + + describe("relinkActiveLock", function () { + it("relink works before TGE", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + + const fromPolicy = await createLinearPolicy(token, admin, "FROM_POL", { + vestDuration: 1n * YEAR, + }); + const toPolicy = await createLinearPolicy(token, admin, "TO_POL", { + vestDuration: 2n * YEAR, + }); + const amount = ethers.parseEther("1000"); + + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount, + policyId: fromPolicy, + label: ethers.encodeBytes32String("src"), + }, + ]); + + // Relink 400 from FROM_POL to TO_POL. + const relinkAmount = ethers.parseEther("400"); + await expect( + token + .connect(admin) + .relinkActiveLock(aliceAddress, fromPolicy, toPolicy, relinkAmount), + ) + .to.emit(token, "ActiveLockRelinked") + .withArgs(aliceAddress, fromPolicy, toPolicy, relinkAmount); + + // FROM_POL should now have 600, TO_POL should have 400. + const locks = [ + await token.locks(aliceAddress, 0), + await token.locks(aliceAddress, 1), + ]; + const byPolicy = new Map( + locks.map((l: { policyId: string; amount: bigint }) => [ + l.policyId, + l.amount, + ]), + ); + expect(byPolicy.get(fromPolicy)).to.equal(ethers.parseEther("600")); + expect(byPolicy.get(toPolicy)).to.equal(relinkAmount); + }); + + it("relink reverts after TGE", async function () { + const { token, admin, alice, ccaEnd } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + + const fromPolicy = await createLinearPolicy(token, admin, "FROM_AFTER", { + vestDuration: 1n * YEAR, + }); + const toPolicy = await createLinearPolicy(token, admin, "TO_AFTER", { + vestDuration: 2n * YEAR, + }); + const amount = ethers.parseEther("1000"); + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount, + policyId: fromPolicy, + label: ethers.encodeBytes32String("src"), + }, + ]); + + // Fire TGE. + const TGE_COOLDOWN = 45n * DAY; + await time.increaseTo(ccaEnd + TGE_COOLDOWN + 1n); + await token.tge(); + + await expect( + token + .connect(admin) + .relinkActiveLock( + aliceAddress, + fromPolicy, + toPolicy, + ethers.parseEther("100"), + ), + ).to.be.revertedWithCustomError(token, "AlreadyLive"); + }); + + it("relink reverts when amount exceeds source lock", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + + const fromPolicy = await createLinearPolicy(token, admin, "SRC_SMALL", { + vestDuration: 1n * YEAR, + }); + const toPolicy = await createLinearPolicy(token, admin, "DST_BIG", { + vestDuration: 2n * YEAR, + }); + const amount = ethers.parseEther("100"); + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount, + policyId: fromPolicy, + label: ethers.encodeBytes32String("small"), + }, + ]); + + await expect( + token + .connect(admin) + .relinkActiveLock( + aliceAddress, + fromPolicy, + toPolicy, + ethers.parseEther("200"), + ), + ).to.be.revertedWithCustomError(token, "RelinkAmountExceeded"); + }); + + it("relink from PENDING reverts", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + const pendingId = ethers.encodeBytes32String("PENDING"); + const toPolicy = await createLinearPolicy(token, admin, "TO_REAL", { + vestDuration: 2n * YEAR, + }); + + await expect( + token + .connect(admin) + .relinkActiveLock( + aliceAddress, + pendingId, + toPolicy, + ethers.parseEther("1"), + ), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("relink to PENDING reverts", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + const pendingId = ethers.encodeBytes32String("PENDING"); + const fromPolicy = await createLinearPolicy(token, admin, "FROM_REAL", { + vestDuration: 1n * YEAR, + }); + const amount = ethers.parseEther("100"); + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount, + policyId: fromPolicy, + label: ethers.encodeBytes32String("real"), + }, + ]); + + await expect( + token + .connect(admin) + .relinkActiveLock( + aliceAddress, + fromPolicy, + pendingId, + ethers.parseEther("50"), + ), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("relink source == target reverts", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const aliceAddress = await alice.getAddress(); + const policyId = await createLinearPolicy(token, admin, "SAME_POL", { + vestDuration: 1n * YEAR, + }); + const amount = ethers.parseEther("100"); + await token.connect(admin).mintAllocations([ + { + recipient: aliceAddress, + amount, + policyId, + label: ethers.encodeBytes32String("same"), + }, + ]); + + await expect( + token + .connect(admin) + .relinkActiveLock( + aliceAddress, + policyId, + policyId, + ethers.parseEther("50"), + ), + ).to.be.revertedWithCustomError(token, "InvalidPolicy"); + }); + + it("non-LOCK_MANAGER_ROLE cannot relink", async function () { + const { token, alice } = await loadFixture(deploy); + await expect( + token + .connect(alice) + .relinkActiveLock( + await alice.getAddress(), + ethers.encodeBytes32String("A"), + ethers.encodeBytes32String("B"), + ethers.parseEther("1"), + ), + ).to.be.revertedWithCustomError( + token, + "AccessControlUnauthorizedAccount", + ); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // BondingRegistry integration (uses deployInterfoldSystem) + // ═════════════════════════════════════════════════════════════════════════ + + describe("BondingRegistry integration", function () { + it("bonded balance covers aggregate mixed locks without affecting grant accounting", async function () { + const signers = await ethers.getSigners(); + const [, beneficiary, slasher] = signers; + const beneficiaryAddress = await beneficiary.getAddress(); + const slasherAddress = await slasher.getAddress(); + const sys = await deployInterfoldSystem({ + useMockCiphernodeRegistry: true, + setupOperators: 0, + wireSlashingManager: false, + mintUsdcTo: [], + }); + const { bondingRegistry, licenseToken } = sys; + const bondingRegistryAddress = await bondingRegistry.getAddress(); + + await bondingRegistry.setSlashingManager(slasherAddress); + + // Alice has a mixed allocation: 200 unlocked grant + 1000 SAFT + 500 CCA. + const grantAmount = ethers.parseEther("200"); + const saftAmount = ethers.parseEther("1000"); + const ccaAmount = ethers.parseEther("500"); + const totalTokens = grantAmount + saftAmount + ccaAmount; + + // Mint unlocked grant (NOT locked). + await licenseToken.mint( + beneficiaryAddress, + grantAmount, + ethers.encodeBytes32String("grant"), + ); + + // SAFT vested allocation: mintAllocations creates AND locks tokens. + const saftPolicy = ethers.encodeBytes32String("SAFT_MIX"); + await licenseToken.createLockPolicy(saftPolicy, { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: 2n * YEAR, + }, + }); + await licenseToken.mintAllocations([ + { + recipient: beneficiaryAddress, + amount: saftAmount, + policyId: saftPolicy, + label: ethers.encodeBytes32String("saft"), + }, + ]); + + // CCA vested allocation. + const ccaPolicy = ethers.encodeBytes32String("CCA_MIX"); + await licenseToken.createLockPolicy(ccaPolicy, { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: 1n * YEAR, + }, + }); + await licenseToken.mintAllocations([ + { + recipient: beneficiaryAddress, + amount: ccaAmount, + policyId: ccaPolicy, + label: ethers.encodeBytes32String("cca"), + }, + ]); + + // Total tokens: grant(200) + SAFT(1000) + CCA(500) = 1700. + + // Bond 1000. + const bondAmount = ethers.parseEther("1000"); + await licenseToken + .connect(beneficiary) + .approve(bondingRegistryAddress, bondAmount); + await bondingRegistry.connect(beneficiary).bondLicense(bondAmount); + + // Wallet = totalTokens - bondAmount = 1700 - 1000 = 700. + expect(await licenseToken.balanceOf(beneficiaryAddress)).to.equal( + totalTokens - bondAmount, + ); + + // Locked ≈ SAFT locked + CCA locked ≈ 1500 (Tge-anchored, no time passed). + const locked = await licenseToken.lockedBalanceOf(beneficiaryAddress); + expect(locked).to.be.closeTo( + saftAmount + ccaAmount, + ethers.parseEther("0.01"), + ); + + // Bonded = 1000 covers 1000 / 1500 of the lock floor. + // mustRetain = max(0, locked - bonded) ≈ 500. + // transferable = max(0, wallet - mustRetain) = 700 - 500 = 200. + // The grant portion (200) is transferable. + const tb = await licenseToken.transferableBalanceOf(beneficiaryAddress); + expect(tb).to.be.closeTo(grantAmount, ethers.parseEther("0.02")); + }); + + it("transferableBalanceOf counts bonded INTF toward the locked floor", async function () { + const signers = await ethers.getSigners(); + const [, beneficiary, slasher] = signers; + const beneficiaryAddress = await beneficiary.getAddress(); + const slasherAddress = await slasher.getAddress(); + const sys = await deployInterfoldSystem({ + useMockCiphernodeRegistry: true, + setupOperators: 0, + wireSlashingManager: false, + mintUsdcTo: [], + }); + const { bondingRegistry, licenseToken } = sys; + const bondingRegistryAddress = await bondingRegistry.getAddress(); + const totalAmount = ethers.parseEther("1000"); + const bondAmount = ethers.parseEther("800"); + + await bondingRegistry.setSlashingManager(slasherAddress); + + // Mint unlocked tokens and bond some. + await licenseToken.mint( + beneficiaryAddress, + totalAmount, + ethers.encodeBytes32String("test"), + ); + await licenseToken + .connect(beneficiary) + .approve(bondingRegistryAddress, bondAmount); + await bondingRegistry.connect(beneficiary).bondLicense(bondAmount); + + // Wallet balance is totalAmount - bondAmount, bonded = bondAmount. + // No locks so everything is transferable. + expect(await licenseToken.balanceOf(beneficiaryAddress)).to.equal( + totalAmount - bondAmount, + ); + expect( + await licenseToken.transferableBalanceOf(beneficiaryAddress), + ).to.equal(totalAmount - bondAmount); + + // Now create a lock policy and mint a locked allocation. + const policyId = ethers.encodeBytes32String("BOND_TEST"); + await licenseToken.createLockPolicy(policyId, { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: 2n * YEAR, + }, + }); + const lockAmount = ethers.parseEther("400"); + // Mint extra unlocked tokens to fund the lock. + await licenseToken.mint( + beneficiaryAddress, + lockAmount, + ethers.encodeBytes32String("extra"), + ); + await licenseToken.mintAllocations([ + { + recipient: beneficiaryAddress, + amount: lockAmount, + policyId, + label: ethers.encodeBytes32String("locked"), + }, + ]); + + // Locked balance ≈ lockAmount (400) — Tge-anchored with tiny vesting. + // Bonded balance = bondAmount (800). + // Since bonded > locked, the bond covers all locks. + // Wallet = totalAmount - bondAmount + lockAmount + lockAmount + // = 1000 - 800 + 400 + 400 = 1000. + // transferable = balance - max(0, locked - bonded) ≈ 1000 - 0 = 1000. + const tb = await licenseToken.transferableBalanceOf(beneficiaryAddress); + expect(tb).to.be.closeTo( + totalAmount - bondAmount + lockAmount + lockAmount, + ethers.parseEther("0.01"), + ); + }); + + it("bonding registry transfers are allowed pre-TGE", async function () { + const sys = await deployInterfoldSystem({ + useMockCiphernodeRegistry: true, + setupOperators: 0, + mintUsdcTo: [], + }); + const { bondingRegistry, licenseToken, owner } = sys; + const bondingRegistryAddress = await bondingRegistry.getAddress(); + const bondAmount = ethers.parseEther("100"); + + await licenseToken.mint( + await owner.getAddress(), + bondAmount, + ethers.encodeBytes32String("test"), + ); + await licenseToken + .connect(owner) + .approve(bondingRegistryAddress, bondAmount); + // Bonding transfer should succeed. + await bondingRegistry.connect(owner).bondLicense(bondAmount); + }); + + it("locked tokens can be bonded (pre-credit visible to token)", async function () { + const signers = await ethers.getSigners(); + const [, beneficiary, slasher] = signers; + const beneficiaryAddress = await beneficiary.getAddress(); + const slasherAddress = await slasher.getAddress(); + const sys = await deployInterfoldSystem({ + useMockCiphernodeRegistry: true, + setupOperators: 0, + wireSlashingManager: false, + mintUsdcTo: [], + }); + const { bondingRegistry, licenseToken } = sys; + const bondingRegistryAddress = await bondingRegistry.getAddress(); + + await bondingRegistry.setSlashingManager(slasherAddress); + + // Create a lock policy and mint locked tokens. + const policyId = ethers.encodeBytes32String("LOCKED_BOND"); + await licenseToken.createLockPolicy(policyId, { + holdUntil: 0n, + unlock: { + anchor: 1, // Tge-anchored + start: 0n, + cliffDuration: 0n, + vestDuration: 2n * YEAR, + }, + }); + const lockAmount = ethers.parseEther("1000"); + // Mint locked allocation directly (balance = locked). + await licenseToken.mintAllocations([ + { + recipient: beneficiaryAddress, + amount: lockAmount, + policyId, + label: ethers.encodeBytes32String("locked"), + }, + ]); + + // Before bonding: balance = 1000, locked ≈ 1000, bonded = 0. + // transferable ≈ 0 (Tge-anchored, no time has passed). + const tbBefore = + await licenseToken.transferableBalanceOf(beneficiaryAddress); + expect(tbBefore).to.be.lt(ethers.parseEther("0.01")); + + // Bond all locked tokens. Should succeed because BondingRegistry + // pre-credits `operators[beneficiary].licenseBond` before calling + // `safeTransferFrom`, so the token sees bonded = lockAmount during + // `_update()`. + await licenseToken + .connect(beneficiary) + .approve(bondingRegistryAddress, lockAmount); + await bondingRegistry.connect(beneficiary).bondLicense(lockAmount); + + // After bonding: wallet = 0, locked ≈ 1000, bonded = 1000. + // Bond covers lock, so no mustRetain. + expect(await licenseToken.balanceOf(beneficiaryAddress)).to.equal(0n); + expect(await bondingRegistry.totalBonded(beneficiaryAddress)).to.equal( + lockAmount, + ); + }); + + it("after bonding locked tokens, cannot transfer below locked floor", async function () { + const signers = await ethers.getSigners(); + const [, beneficiary, slasher] = signers; + const beneficiaryAddress = await beneficiary.getAddress(); + const slasherAddress = await slasher.getAddress(); + const sys = await deployInterfoldSystem({ + useMockCiphernodeRegistry: true, + setupOperators: 0, + wireSlashingManager: false, + mintUsdcTo: [], + }); + const { bondingRegistry, licenseToken } = sys; + const bondingRegistryAddress = await bondingRegistry.getAddress(); + + await bondingRegistry.setSlashingManager(slasherAddress); + + const policyId = ethers.encodeBytes32String("LOCKED_FLOOR"); + await licenseToken.createLockPolicy(policyId, { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: 2n * YEAR, + }, + }); + const lockAmount = ethers.parseEther("1000"); + await licenseToken.mintAllocations([ + { + recipient: beneficiaryAddress, + amount: lockAmount, + policyId, + label: ethers.encodeBytes32String("locked"), + }, + ]); + + // Bond 600 out of 1000 locked. + const bondAmount = ethers.parseEther("600"); + await licenseToken + .connect(beneficiary) + .approve(bondingRegistryAddress, bondAmount); + await bondingRegistry.connect(beneficiary).bondLicense(bondAmount); + + // Wallet = 400, locked ≈ 1000, bonded = 600. + // mustRetain = max(0, 1000 - 600) = 400. + // transferable = max(0, 400 - 400) = 0. + const tb = await licenseToken.transferableBalanceOf(beneficiaryAddress); + expect(tb).to.equal(0n); + }); + + it("slashing does not reduce locked balance", async function () { + const signers = await ethers.getSigners(); + const [, beneficiary, slasher] = signers; + const beneficiaryAddress = await beneficiary.getAddress(); + const slasherAddress = await slasher.getAddress(); + const sys = await deployInterfoldSystem({ + useMockCiphernodeRegistry: true, + setupOperators: 0, + wireSlashingManager: false, + mintUsdcTo: [], + }); + const { bondingRegistry, licenseToken } = sys; + const bondingRegistryAddress = await bondingRegistry.getAddress(); + + await bondingRegistry.setSlashingManager(slasherAddress); + + const policyId = ethers.encodeBytes32String("SLASH_LOCK"); + await licenseToken.createLockPolicy(policyId, { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: 2n * YEAR, + }, + }); + const lockAmount = ethers.parseEther("1000"); + await licenseToken.mintAllocations([ + { + recipient: beneficiaryAddress, + amount: lockAmount, + policyId, + label: ethers.encodeBytes32String("locked"), + }, + ]); + + // Bond everything. + await licenseToken + .connect(beneficiary) + .approve(bondingRegistryAddress, lockAmount); + await bondingRegistry.connect(beneficiary).bondLicense(lockAmount); + + const lockedBefore = + await licenseToken.lockedBalanceOf(beneficiaryAddress); + expect(lockedBefore).to.be.closeTo(lockAmount, ethers.parseEther("0.01")); + + // Slash 500 license bond. + const slashAmount = ethers.parseEther("500"); + await bondingRegistry + .connect(slasher) + .slashLicenseBond( + beneficiaryAddress, + slashAmount, + ethers.encodeBytes32String("SLASH"), + ); + + // Bonded is now 500. + expect(await bondingRegistry.totalBonded(beneficiaryAddress)).to.equal( + lockAmount - slashAmount, + ); + + // Locked balance must NOT change due to slashing. + const lockedAfter = + await licenseToken.lockedBalanceOf(beneficiaryAddress); + expect(lockedAfter).to.equal(lockedBefore); + }); + + it("after slashing, incoming tokens are retained by lock-floor invariant", async function () { + const signers = await ethers.getSigners(); + const [, beneficiary, slasher] = signers; + const beneficiaryAddress = await beneficiary.getAddress(); + const slasherAddress = await slasher.getAddress(); + const sys = await deployInterfoldSystem({ + useMockCiphernodeRegistry: true, + setupOperators: 0, + wireSlashingManager: false, + mintUsdcTo: [], + }); + const { bondingRegistry, licenseToken, owner } = sys; + const bondingRegistryAddress = await bondingRegistry.getAddress(); + + await bondingRegistry.setSlashingManager(slasherAddress); + + const policyId = ethers.encodeBytes32String("SLASH_FLOOR"); + await licenseToken.createLockPolicy(policyId, { + holdUntil: 0n, + unlock: { + anchor: 1, + start: 0n, + cliffDuration: 0n, + vestDuration: 2n * YEAR, + }, + }); + const lockAmount = ethers.parseEther("1000"); + await licenseToken.mintAllocations([ + { + recipient: beneficiaryAddress, + amount: lockAmount, + policyId, + label: ethers.encodeBytes32String("locked"), + }, + ]); + + // Bond everything, then slash half. + await licenseToken + .connect(beneficiary) + .approve(bondingRegistryAddress, lockAmount); + await bondingRegistry.connect(beneficiary).bondLicense(lockAmount); + const slashAmount = ethers.parseEther("500"); + await bondingRegistry + .connect(slasher) + .slashLicenseBond( + beneficiaryAddress, + slashAmount, + ethers.encodeBytes32String("SLASH"), + ); + + // Now: wallet = 0, locked ≈ 1000, bonded = 500. + // mustRetain = 1000 - 500 = 500. Wallet is 500 below floor. + expect( + await licenseToken.transferableBalanceOf(beneficiaryAddress), + ).to.equal(0n); + + // Send 200 unlocked tokens to beneficiary. They should be retained + // (non-transferable) because wallet is still below floor. + await licenseToken + .connect(owner) + .mint(beneficiaryAddress, ethers.parseEther("200"), ethers.ZeroHash); + expect(await licenseToken.balanceOf(beneficiaryAddress)).to.equal( + ethers.parseEther("200"), + ); + expect( + await licenseToken.transferableBalanceOf(beneficiaryAddress), + ).to.equal(0n); + + // Send enough to fill the floor gap (300 more = 500 total). + await licenseToken + .connect(owner) + .mint(beneficiaryAddress, ethers.parseEther("300"), ethers.ZeroHash); + expect(await licenseToken.balanceOf(beneficiaryAddress)).to.equal( + ethers.parseEther("500"), + ); + // Now wallet = 500, locked ≈ 1000, bonded = 500 → transferable = 0. + expect( + await licenseToken.transferableBalanceOf(beneficiaryAddress), + ).to.equal(0n); + + // Send one more wei above the floor. + await licenseToken + .connect(owner) + .mint(beneficiaryAddress, 1n, ethers.ZeroHash); + // Now wallet = 500 + 1, mustRetain = 500 → transferable = 1. + expect( + await licenseToken.transferableBalanceOf(beneficiaryAddress), + ).to.equal(1n); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // Ownership + // ═════════════════════════════════════════════════════════════════════════ + + describe("ownership", function () { + it("renounceOwnership is disabled", async function () { + const { token, admin } = await loadFixture(deploy); + await expect( + token.connect(admin).renounceOwnership(), + ).to.be.revertedWithCustomError(token, "RenounceOwnershipDisabled"); + }); + + it("ownership transfer syncs AccessControl roles", async function () { + const { token, admin, alice } = await loadFixture(deploy); + const adminAddress = await admin.getAddress(); + const aliceAddress = await alice.getAddress(); + + // Transfer ownership to alice via 2-step. + await token.connect(admin).transferOwnership(aliceAddress); + await token.connect(alice).acceptOwnership(); + + // Old owner loses all roles. + expect( + await token.hasRole(await token.DEFAULT_ADMIN_ROLE(), adminAddress), + ).to.be.false; + expect(await token.hasRole(await token.MINTER_ROLE(), adminAddress)).to.be + .false; + expect(await token.hasRole(await token.WHITELIST_ROLE(), adminAddress)).to + .be.false; + expect(await token.hasRole(await token.LOCK_MANAGER_ROLE(), adminAddress)) + .to.be.false; + + // New owner gains all roles. + expect( + await token.hasRole(await token.DEFAULT_ADMIN_ROLE(), aliceAddress), + ).to.be.true; + expect(await token.hasRole(await token.MINTER_ROLE(), aliceAddress)).to.be + .true; + expect(await token.hasRole(await token.WHITELIST_ROLE(), aliceAddress)).to + .be.true; + expect(await token.hasRole(await token.LOCK_MANAGER_ROLE(), aliceAddress)) + .to.be.true; + }); + }); + + // ═════════════════════════════════════════════════════════════════════════ + // EIP-6372 + // ═════════════════════════════════════════════════════════════════════════ + + describe("EIP-6372", function () { it("clock() returns block.timestamp and CLOCK_MODE() is mode=timestamp", async function () { const { token } = await loadFixture(deploy); expect(await token.clock()).to.equal(await time.latest()); diff --git a/packages/interfold-contracts/test/fixtures/operators.ts b/packages/interfold-contracts/test/fixtures/operators.ts index f3e31b6fd..3ed612d32 100644 --- a/packages/interfold-contracts/test/fixtures/operators.ts +++ b/packages/interfold-contracts/test/fixtures/operators.ts @@ -22,10 +22,10 @@ export async function setupOperatorForSortition( ): Promise { const operatorAddress = await operator.getAddress(); - await licenseToken.mintAllocation( + await licenseToken.mint( operatorAddress, ethers.parseEther("10000"), - "Test allocation", + ethers.encodeBytes32String("Test allocation"), ); await usdcToken.mint(operatorAddress, ethers.parseUnits("100000", 6)); diff --git a/packages/interfold-contracts/test/fixtures/system.ts b/packages/interfold-contracts/test/fixtures/system.ts index 25a856670..66a0c2593 100644 --- a/packages/interfold-contracts/test/fixtures/system.ts +++ b/packages/interfold-contracts/test/fixtures/system.ts @@ -269,13 +269,8 @@ export async function deployInterfoldSystem( usdcToken = MockUSDCFactory.connect(await mockUSDC.getAddress(), owner); } - const { interfoldToken } = await ignition.deploy(InterfoldTokenModule, { - parameters: { InterfoldToken: { owner: ownerAddress } }, - }); - const licenseToken = InterfoldTokenFactory.connect( - await interfoldToken.getAddress(), - owner, - ); + // Deferred: InterfoldToken is deployed after BondingRegistry so the + // immutable BONDING_REGISTRY reference can be set. See below. const { interfoldTicketToken } = await ignition.deploy( InterfoldTicketTokenModule, @@ -338,6 +333,7 @@ export async function deployInterfoldSystem( effectiveRegistryAddress = mockRegAddress; } + // ── BondingRegistry (deployed before token; uses ADDRESS_ONE placeholder) ── const { bondingRegistry: _bondingRegistry } = await ignition.deploy( BondingRegistryModule, { @@ -345,7 +341,7 @@ export async function deployInterfoldSystem( BondingRegistry: { owner: ownerAddress, ticketToken: await ticketToken.getAddress(), - licenseToken: await licenseToken.getAddress(), + licenseToken: ADDRESS_ONE, // placeholder — fixed below registry: effectiveRegistryAddress, slashedFundsTreasury: slashedFundsTreasuryAddress, ticketPrice: TICKET_PRICE, @@ -360,6 +356,34 @@ export async function deployInterfoldSystem( await _bondingRegistry.getAddress(), owner, ); + const bondingRegistryAddress = await bondingRegistry.getAddress(); + + // ── InterfoldToken (deployed after BondingRegistry for immutable ref) ── + const deployTime = BigInt(await time.latest()); + const ccaStart = deployTime + 1000n; // keep Virtual phase during setup + const ccaEnd = ccaStart + 7n * 24n * 60n * 60n; // 7-day CCA window + const claimSource = ownerAddress; // owner as placeholder claim source + const noMoreLocks = + ccaEnd + 45n * 24n * 60n * 60n + 4n * 365n * 24n * 60n * 60n; + const { interfoldToken } = await ignition.deploy(InterfoldTokenModule, { + parameters: { + InterfoldToken: { + owner: ownerAddress, + ccaStart, + ccaEnd, + claimSource, + bondingRegistry: bondingRegistryAddress, + noMoreLocks, + }, + }, + }); + const licenseToken = InterfoldTokenFactory.connect( + await interfoldToken.getAddress(), + owner, + ); + + // Fix the BondingRegistry licenseToken placeholder. + await bondingRegistry.setLicenseToken(await licenseToken.getAddress()); // ── Interfold ──────────────────────────────────────────────────────────────── const { interfold: _interfold } = await ignition.deploy(InterfoldModule, { @@ -481,8 +505,7 @@ export async function deployInterfoldSystem( await interfold.setCommitteeThresholds(size, [m, n]); } - // ── Operators ───────────────────────────────────────────────────────────── - await licenseToken.disableTransferRestrictions(); + // ── Operators (token stays in Virtual phase — bonding allowed pre-TGE) ──── if (operators.length > 0) { for (const operator of operators) { await setupOperatorForSortition( diff --git a/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts b/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts index 366eab2f6..e0ae7a744 100644 --- a/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts +++ b/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts @@ -14,25 +14,26 @@ import { SDKError } from '../utils' export const SDK_CIRCUIT_COMMITTEE = 'minimum' function findActivePath(): string | null { - // Walk up from the bundle file (depth varies: dist/ vs dist/crypto/ etc.) - // until we find the package root (directory containing package.json). + if (!import.meta.url) return null + let dir = dirname(fileURLToPath(import.meta.url)) + while (true) { if (existsSync(resolve(dir, 'package.json'))) { - // Bundled preset shipped inside the package takes priority (future use). const bundled = resolve(dir, '.active-preset.json') if (existsSync(bundled)) return bundled - // When installed under node_modules the monorepo root is not available; - // skip the check rather than resolving into an unrelated project tree. if (dir.includes('node_modules')) return null return resolve(dir, '../../circuits/bin/.active-preset.json') } + const parent = dirname(dir) if (parent === dir) break + dir = parent } + throw new SDKError('Could not locate SDK package root', 'SDK_CIRCUIT_STAMP_MISSING') } @@ -47,9 +48,11 @@ let checked = false */ export function assertSdkMinimumCircuits(): void { if (checked) return - checked = true - if (ACTIVE_PRESET_PATH === null) return + if (ACTIVE_PRESET_PATH === null) { + checked = true + return + } let raw: string try { @@ -79,4 +82,6 @@ export function assertSdkMinimumCircuits(): void { 'SDK_CIRCUIT_COMMITTEE_MISMATCH', ) } + + checked = true } diff --git a/templates/default/deployed_contracts.json b/templates/default/deployed_contracts.json index 166267be6..0898fbfef 100644 --- a/templates/default/deployed_contracts.json +++ b/templates/default/deployed_contracts.json @@ -11,29 +11,22 @@ "blockNumber": 10, "address": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" }, - "InterfoldToken": { - "constructorArgs": { - "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" - }, - "blockNumber": 12, - "address": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0" - }, "InterfoldTicketToken": { "constructorArgs": { "baseToken": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", "registry": "0x0000000000000000000000000000000000000001", "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }, - "blockNumber": 14, - "address": "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9" + "blockNumber": 12, + "address": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0" }, "SlashingManager": { "constructorArgs": { "initialDelay": "172800", "admin": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }, - "blockNumber": 16, - "address": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707" + "blockNumber": 14, + "address": "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9" }, "CiphernodeRegistryOwnable": { "constructorArgs": { @@ -43,19 +36,19 @@ "proxyRecords": { "initData": "0xcd6dc687000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000000000000000000000000000000000000000000a", "initialOwner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "proxyAddress": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", - "proxyAdminAddress": "0x9bd03768a7DCc129555dE410FF8E85528A4F88b5", - "implementationAddress": "0x0165878A594ca255338adfa4d48449f69242Eb8F" + "proxyAddress": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707", + "proxyAdminAddress": "0x61c36a8d610163660E21a8b7359e1Cac0C9133e1", + "implementationAddress": "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9" }, - "blockNumber": 18, - "address": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853" + "blockNumber": 16, + "address": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707" }, "BondingRegistry": { "constructorArgs": { "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "ticketToken": "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", - "licenseToken": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0", - "registry": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", + "ticketToken": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0", + "licenseToken": "0x0000000000000000000000000000000000000000", + "registry": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707", "slashedFundsTreasury": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "ticketPrice": "10000000", "licenseRequiredBond": "100000000000000000000", @@ -63,74 +56,85 @@ "exitDelay": "604800" }, "proxyRecords": { - "initData": "0x7333fa82000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000dc64a140aa3e981100a9beca4e685f962f0cf6c90000000000000000000000009fe46736679d2d9a65f0992f2272de9f3c7fa6e0000000000000000000000000a513e6e4b8f2a923d98304ec87f64353c4d5c853000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000009896800000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000093a80", + "initData": "0x7333fa82000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000009fe46736679d2d9a65f0992f2272de9f3c7fa6e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005fc8d32690cc91d4c39d9d3abcbd16989f875707000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000009896800000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000093a80", "initialOwner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "proxyAddress": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", - "proxyAdminAddress": "0x8aCd85898458400f7Db866d53FCFF6f0D49741FF", - "implementationAddress": "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6" + "proxyAddress": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", + "proxyAdminAddress": "0x9bd03768a7DCc129555dE410FF8E85528A4F88b5", + "implementationAddress": "0x0165878A594ca255338adfa4d48449f69242Eb8F" + }, + "blockNumber": 17, + "address": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853" + }, + "InterfoldToken": { + "constructorArgs": { + "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "ccaStart": "1781303982", + "ccaEnd": "1781908782", + "claimSource": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "bondingRegistry": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853" }, - "blockNumber": 19, - "address": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318" + "blockNumber": 20, + "address": "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6" }, "Interfold": { "constructorArgs": { "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "registry": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", - "bondingRegistry": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + "registry": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707", + "bondingRegistry": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", "e3RefundManager": "0x0000000000000000000000000000000000000001", "feeToken": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", "maxDuration": "2592000", "timeoutConfig": "{\"dkgWindow\":7200,\"computeWindow\":86400,\"decryptionWindow\":3600}" }, "proxyRecords": { - "initData": "0x4d600e5d000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000a513e6e4b8f2a923d98304ec87f64353c4d5c8530000000000000000000000008a791620dd6260079bf849dc5567adc3f2fdc3180000000000000000000000000000000000000000000000000000000000000001000000000000000000000000e7f1725e7734ce288f8367e1bb143e90bb3f05120000000000000000000000000000000000000000000000000000000000278d000000000000000000000000000000000000000000000000000000000000001c2000000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000000000000000000e10", + "initData": "0x4d600e5d000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000005fc8d32690cc91d4c39d9d3abcbd16989f875707000000000000000000000000a513e6e4b8f2a923d98304ec87f64353c4d5c8530000000000000000000000000000000000000000000000000000000000000001000000000000000000000000e7f1725e7734ce288f8367e1bb143e90bb3f05120000000000000000000000000000000000000000000000000000000000278d000000000000000000000000000000000000000000000000000000000000001c2000000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000000000000000000e10", "initialOwner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "proxyAddress": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", - "proxyAdminAddress": "0x1F708C24a0D3A740cD47cC0444E9480899f3dA7D", - "implementationAddress": "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e" + "proxyAddress": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", + "proxyAdminAddress": "0x32467b43BFa67273FC7dDda0999Ee9A12F2AaA08", + "implementationAddress": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" }, - "blockNumber": 23, - "address": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" + "blockNumber": 25, + "address": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" }, "E3RefundManager": { "constructorArgs": { "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "interfold": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", + "interfold": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", "treasury": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }, "proxyRecords": { - "initData": "0xc0c53b8b000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000a51c1fc2f0d1a1b8494ed1fe312d7c3a78ed91c0000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "initData": "0xc0c53b8b000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000dcd1bf9a1b36ce34237eeafef220932846bcd82000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266", "initialOwner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "proxyAddress": "0x9A676e781A523b5d0C0e43731313A708CB607508", - "proxyAdminAddress": "0x8e80FFe6Dc044F4A766Afd6e5a8732Fe0977A493", - "implementationAddress": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" + "proxyAddress": "0x0B306BF915C4d645ff596e518fAf3F9669b97016", + "proxyAdminAddress": "0x524F04724632eED237cbA3c37272e018b3A7967e", + "implementationAddress": "0x9A676e781A523b5d0C0e43731313A708CB607508" }, - "blockNumber": 26, - "address": "0x9A676e781A523b5d0C0e43731313A708CB607508" + "blockNumber": 28, + "address": "0x0B306BF915C4d645ff596e518fAf3F9669b97016" }, "MockComputeProvider": { - "blockNumber": 40, - "address": "0x5eb3Bc0a489C5A8288765d2336659EbCA68FCd00" + "blockNumber": 42, + "address": "0x809d550fca64d94Bd9F66E60752A544199cfAC3D" }, "MockDecryptionVerifier": { - "blockNumber": 42, - "address": "0x36C02dA8a0983159322a80FFE9F24b1acfF8B570" + "blockNumber": 43, + "address": "0x4c5859f0F772848b2D91F1D83E2Fe57935348029" }, "MockPkVerifier": { - "blockNumber": 43, - "address": "0x809d550fca64d94Bd9F66E60752A544199cfAC3D" + "blockNumber": 44, + "address": "0x1291Be112d480055DaFd8a610b7d1e203891C274" }, "MockE3Program": { - "blockNumber": 44, - "address": "0x4c5859f0F772848b2D91F1D83E2Fe57935348029" + "blockNumber": 45, + "address": "0x5f3f1dBD7B74C6B46e8c44f98792A1dAf8d69154" }, "ImageID": { - "address": "0x82e01223d51Eb87e16A03E24687EDF0F294da6f1", - "blockNumber": 49 + "address": "0x7969c5eD335650692Bc04293B07F5BF2e7A673C0", + "blockNumber": 50 }, "MyProgram": { - "address": "0x2bdCC0de6bE1f7D2ee689a0342D76F52E8EFABa3", - "blockNumber": 51 + "address": "0x7bc06c482DEAd17c0e297aFbC32f6e63d3846650", + "blockNumber": 52 } } } \ No newline at end of file diff --git a/templates/default/interfold.config.yaml b/templates/default/interfold.config.yaml index c7dae4e57..0575ecfd6 100644 --- a/templates/default/interfold.config.yaml +++ b/templates/default/interfold.config.yaml @@ -3,19 +3,19 @@ chains: rpc_url: 'ws://localhost:8545' contracts: e3_program: - address: "0x2bdCC0de6bE1f7D2ee689a0342D76F52E8EFABa3" - deploy_block: 51 + address: "0x7bc06c482DEAd17c0e297aFbC32f6e63d3846650" + deploy_block: 52 interfold: - address: "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" - deploy_block: 23 + address: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82" + deploy_block: 25 ciphernode_registry: - address: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853" - deploy_block: 18 + address: "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707" + deploy_block: 16 bonding_registry: - address: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318" - deploy_block: 19 + address: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853" + deploy_block: 17 slashing_manager: - address: '0x5FC8d32690cc91D4c39d9d3abcBD16989F875707' + address: '0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9' deploy_block: 11 fee_token: address: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" diff --git a/templates/default/package.json b/templates/default/package.json index 868d1fd8b..8bf76c42a 100644 --- a/templates/default/package.json +++ b/templates/default/package.json @@ -9,7 +9,7 @@ "lint": "eslint .", "clean:deployments": "hardhat utils:clean-deployments", "deploy": "pnpm clean:deployments && hardhat run scripts/deploy-local.ts --network localhost", - "deploy:dev": "hardhat run scripts/deploy-local.ts", + "deploy:dev": "hardhat run scripts/deploy-local.ts --network localhost", "dev:all": "./scripts/dev_all.sh", "dev:ciphernodes": "./scripts/dev_ciphernodes.sh", "dev:setup": "bash ./scripts/setup.sh",