diff --git a/.env.example b/.env.example index df149c6..864e71d 100644 --- a/.env.example +++ b/.env.example @@ -2,11 +2,9 @@ DEPLOYER_PRIVATE_KEY=0x... BASE_SEPOLIA_RPC_URL=https://sepolia.base.org BASE_MAINNET_RPC_URL=https://mainnet.base.org -TREASURY_WALLET=0x0000000000000000000000000000000000000000 - -# Base Sepolia USDC by default (override per network as needed). -MINT_CURRENCY_ADDRESS=0x036cbd53842c5426634e7929541ec2318f3dcf7e -MINT_AMOUNT_ATOMIC=100000000 +# EDUT ID mint is gas-only: no treasury transfer and no token payment. +MINT_CURRENCY_ADDRESS=0x0000000000000000000000000000000000000000 +MINT_AMOUNT_ATOMIC=0 # Optional: write deployment metadata JSON (address, tx hash, params). # DEPLOY_OUTPUT_PATH=./deploy/membership-deploy.sepolia.json @@ -19,3 +17,18 @@ OFFER_PRICE_ATOMIC=1000000000 # Optional: write entitlement deployment metadata JSON. # ENTITLEMENT_DEPLOY_OUTPUT_PATH=./deploy/entitlement-deploy.sepolia.json + +# Last Light deployment (comma-separated guardian addresses + threshold). +LASTLIGHT_GUARDIANS=0x0000000000000000000000000000000000000000 +LASTLIGHT_THRESHOLD=3 +# LASTLIGHT_DEPLOY_OUTPUT_PATH=./deploy/lastlight-deploy.sepolia.json + +# Last Light EIP-712 flow (arm/cancel/execute). +LASTLIGHT_ACTION=arm +LASTLIGHT_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000 +LASTLIGHT_RELEASE_ID=last-light-release +# LASTLIGHT_GUARDIAN_PRIVATE_KEYS=0x...,0x...,0x... +LASTLIGHT_DEADLINE_SECONDS=3600 +# LASTLIGHT_REASON_HASH=owner-incapacity +# LASTLIGHT_DECRYPTION_KEY=decryption-key +# LASTLIGHT_MANIFEST_REF_HASH=manifest-ref diff --git a/contracts/EdutHumanMembership.sol b/contracts/EdutHumanMembership.sol index bc70872..f31e67b 100644 --- a/contracts/EdutHumanMembership.sol +++ b/contracts/EdutHumanMembership.sol @@ -1,15 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -interface IERC20 { - function transferFrom(address from, address to, uint256 value) external returns (bool); -} - -/// @title EDUT Human Membership (Soulbound) -/// @notice One-time human governance token with configurable mint price and sponsor mint support. +/// @title EDUT ID (Soulbound) +/// @notice One-time human governance identity token. Gas-only mint; no platform fee path. contract EdutHumanMembership { - string public constant name = "EDUT Human Membership"; - string public constant symbol = "EDUTHUM"; + string public constant name = "EDUT ID"; + string public constant symbol = "EID"; uint8 public constant MEMBERSHIP_NONE = 0; uint8 public constant MEMBERSHIP_ACTIVE = 1; @@ -22,22 +18,15 @@ contract EdutHumanMembership { error UnknownToken(); error InvalidStatus(); error SoulboundNonTransferable(); - error IncorrectEthValue(); error UnexpectedEthValue(); - error PaymentTransferFailed(); event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); event MembershipMinted(address indexed wallet, uint256 indexed tokenId, uint256 amountPaid, address currency); - event MintPriceUpdated(address indexed currency, uint256 amountAtomic); + event EdutIDMinted(address indexed wallet, uint256 indexed tokenId); event MembershipStatusUpdated(address indexed wallet, uint8 status); - event TreasuryUpdated(address indexed treasury); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); address public owner; - address public treasury; - - address private _mintCurrency; - uint256 private _mintAmountAtomic; uint256 private _nextTokenId = 1; mapping(uint256 => address) private _tokenOwner; @@ -50,15 +39,9 @@ contract EdutHumanMembership { _; } - constructor(address treasury_, address currency_, uint256 amountAtomic_) { - if (treasury_ == address(0)) revert InvalidAddress(); + constructor() { owner = msg.sender; - treasury = treasury_; - _mintCurrency = currency_; - _mintAmountAtomic = amountAtomic_; emit OwnershipTransferred(address(0), msg.sender); - emit TreasuryUpdated(treasury_); - emit MintPriceUpdated(currency_, amountAtomic_); } function balanceOf(address wallet) external view returns (uint256) { @@ -84,16 +67,15 @@ contract EdutHumanMembership { return _membershipStatus[wallet]; } - function currentMintPrice() external view returns (address currency, uint256 amountAtomic) { - return (_mintCurrency, _mintAmountAtomic); + function currentMintPrice() external pure returns (address currency, uint256 amountAtomic) { + return (address(0), 0); } - /// @notice Mint one human membership token to recipient. Caller may sponsor payment. + /// @notice Mint one EDUT ID token to recipient. Caller may sponsor gas only. function mintMembership(address recipient) external payable returns (uint256 tokenId) { if (recipient == address(0)) revert InvalidAddress(); if (_walletToken[recipient] != 0) revert AlreadyMinted(); - - _collectMintPayment(msg.sender); + if (msg.value != 0) revert UnexpectedEthValue(); tokenId = _nextTokenId; _nextTokenId += 1; @@ -104,22 +86,11 @@ contract EdutHumanMembership { _membershipStatus[recipient] = MEMBERSHIP_ACTIVE; emit Transfer(address(0), recipient, tokenId); - emit MembershipMinted(recipient, tokenId, _mintAmountAtomic, _mintCurrency); + emit MembershipMinted(recipient, tokenId, 0, address(0)); + emit EdutIDMinted(recipient, tokenId); emit MembershipStatusUpdated(recipient, MEMBERSHIP_ACTIVE); } - function updateMintPrice(address currency, uint256 amountAtomic) external onlyOwner { - _mintCurrency = currency; - _mintAmountAtomic = amountAtomic; - emit MintPriceUpdated(currency, amountAtomic); - } - - function setTreasury(address nextTreasury) external onlyOwner { - if (nextTreasury == address(0)) revert InvalidAddress(); - treasury = nextTreasury; - emit TreasuryUpdated(nextTreasury); - } - function setMembershipStatus(address wallet, uint8 status) external onlyOwner { uint256 tokenId = _walletToken[wallet]; if (tokenId == 0) revert UnknownToken(); @@ -163,23 +134,4 @@ contract EdutHumanMembership { function safeTransferFrom(address, address, uint256, bytes calldata) external pure { revert SoulboundNonTransferable(); } - - function _collectMintPayment(address payer) private { - uint256 amount = _mintAmountAtomic; - if (amount == 0) { - if (msg.value != 0) revert UnexpectedEthValue(); - return; - } - - if (_mintCurrency == address(0)) { - if (msg.value != amount) revert IncorrectEthValue(); - (bool ok, ) = treasury.call{value: amount}(""); - if (!ok) revert PaymentTransferFailed(); - return; - } - - if (msg.value != 0) revert UnexpectedEthValue(); - bool transferred = IERC20(_mintCurrency).transferFrom(payer, treasury, amount); - if (!transferred) revert PaymentTransferFailed(); - } } diff --git a/contracts/LastLightController.sol b/contracts/LastLightController.sol new file mode 100644 index 0000000..0dd54cb --- /dev/null +++ b/contracts/LastLightController.sol @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title EDUT Project Last Light Controller +/// @notice Guardian-threshold continuity release controller with EIP-712 quorum signatures. +contract LastLightController { + error NotOwner(); + error InvalidAddress(); + error InvalidThreshold(); + error InvalidGuardianSet(); + error InvalidReleaseId(); + error InvalidReleaseStatus(); + error GuardianSetChanged(); + error GuardianNotFound(); + error DuplicateGuardian(); + error SignatureExpired(); + error InvalidGuardianQuorum(); + error InvalidSignature(); + error TimelockRequired(); + error ExecutionTimelockActive(); + error InvalidDecryptionKey(); + + enum ReleaseStatus { + None, + Staged, + Armed, + Executed, + Canceled + } + + struct ReleaseRecord { + bytes32 payloadManifestHash; + bytes32 encryptedPayloadRootHash; + bytes32 decryptionKeyHash; + bytes32 guardianSetHash; + bytes32 metadataRefHash; + uint64 timelockSeconds; + uint64 stagedAt; + uint64 armedAt; + uint64 executeNotBefore; + uint64 armEpoch; + uint8 threshold; + ReleaseStatus status; + } + + bytes32 private constant DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 private constant ARM_TYPEHASH = + keccak256("ArmIntent(bytes32 releaseId,bytes32 reasonHash,uint64 deadline,uint64 armEpoch)"); + bytes32 private constant CANCEL_TYPEHASH = + keccak256("CancelIntent(bytes32 releaseId,bytes32 reasonHash,uint64 deadline,uint64 armEpoch)"); + bytes32 private constant EXECUTE_TYPEHASH = + keccak256("ExecuteIntent(bytes32 releaseId,bytes32 decryptionKey,bytes32 manifestRefHash,uint64 deadline,uint64 armEpoch)"); + bytes32 private constant NAME_HASH = keccak256("EDUT Last Light"); + bytes32 private constant VERSION_HASH = keccak256("1"); + uint256 private constant SECP256K1_HALF_N = + 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0; + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event GuardianSetUpdated(bytes32 indexed guardianSetHash, uint8 threshold, uint256 guardianCount); + event LastLightReleaseStaged( + bytes32 indexed releaseId, + bytes32 payloadManifestHash, + bytes32 encryptedPayloadRootHash, + bytes32 decryptionKeyHash, + bytes32 guardianSetHash, + uint8 threshold, + uint64 timelockSeconds + ); + event LastLightReleaseArmed(bytes32 indexed releaseId, bytes32 reasonHash, uint64 executeNotBefore, uint64 armEpoch); + event LastLightReleaseCanceled(bytes32 indexed releaseId, bytes32 reasonHash, uint64 armEpoch); + event LastLightReleaseExecuted(bytes32 indexed releaseId, bytes32 decryptionKey, bytes32 manifestRefHash, uint64 armEpoch); + + address public owner; + address[] private _guardians; + mapping(address => bool) private _isGuardian; + bytes32 public guardianSetHash; + uint8 public threshold; + + mapping(bytes32 => ReleaseRecord) private _releases; + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + constructor(address[] memory guardians_, uint8 threshold_) { + owner = msg.sender; + emit OwnershipTransferred(address(0), msg.sender); + _setGuardians(guardians_, threshold_); + } + + function transferOwnership(address nextOwner) external onlyOwner { + if (nextOwner == address(0)) revert InvalidAddress(); + address previous = owner; + owner = nextOwner; + emit OwnershipTransferred(previous, nextOwner); + } + + function setGuardians(address[] calldata guardians_, uint8 threshold_) external onlyOwner { + _setGuardians(guardians_, threshold_); + } + + function guardians() external view returns (address[] memory) { + return _guardians; + } + + function isGuardian(address account) external view returns (bool) { + return _isGuardian[account]; + } + + function releaseRecord(bytes32 releaseId) external view returns (ReleaseRecord memory) { + return _releases[releaseId]; + } + + function stageRelease( + bytes32 releaseId, + bytes32 payloadManifestHash, + bytes32 encryptedPayloadRootHash, + bytes32 decryptionKeyHash, + bytes32 metadataRefHash, + uint64 timelockSeconds + ) external onlyOwner { + if (releaseId == bytes32(0)) revert InvalidReleaseId(); + if (payloadManifestHash == bytes32(0) || encryptedPayloadRootHash == bytes32(0) || decryptionKeyHash == bytes32(0)) { + revert InvalidReleaseId(); + } + if (timelockSeconds == 0) revert TimelockRequired(); + + ReleaseRecord storage rec = _releases[releaseId]; + if (rec.status == ReleaseStatus.Staged || rec.status == ReleaseStatus.Armed) revert InvalidReleaseStatus(); + + uint64 nowTs = uint64(block.timestamp); + rec.payloadManifestHash = payloadManifestHash; + rec.encryptedPayloadRootHash = encryptedPayloadRootHash; + rec.decryptionKeyHash = decryptionKeyHash; + rec.guardianSetHash = guardianSetHash; + rec.metadataRefHash = metadataRefHash; + rec.timelockSeconds = timelockSeconds; + rec.stagedAt = nowTs; + rec.armedAt = 0; + rec.executeNotBefore = 0; + rec.armEpoch += 1; + rec.threshold = threshold; + rec.status = ReleaseStatus.Staged; + + emit LastLightReleaseStaged( + releaseId, + payloadManifestHash, + encryptedPayloadRootHash, + decryptionKeyHash, + guardianSetHash, + threshold, + timelockSeconds + ); + } + + function armReleaseWithSignatures( + bytes32 releaseId, + bytes32 reasonHash, + uint64 deadline, + bytes[] calldata signatures + ) external { + ReleaseRecord storage rec = _requireStagedRelease(releaseId); + _requireCurrentGuardianSet(rec); + if (deadline < block.timestamp) revert SignatureExpired(); + + bytes32 structHash = keccak256( + abi.encode( + ARM_TYPEHASH, + releaseId, + reasonHash, + deadline, + rec.armEpoch + ) + ); + _enforceGuardianQuorum(structHash, signatures, rec.threshold); + + rec.status = ReleaseStatus.Armed; + rec.armedAt = uint64(block.timestamp); + rec.executeNotBefore = rec.armedAt + rec.timelockSeconds; + + emit LastLightReleaseArmed(releaseId, reasonHash, rec.executeNotBefore, rec.armEpoch); + } + + function cancelReleaseWithSignatures( + bytes32 releaseId, + bytes32 reasonHash, + uint64 deadline, + bytes[] calldata signatures + ) external { + ReleaseRecord storage rec = _releases[releaseId]; + if (rec.status != ReleaseStatus.Staged && rec.status != ReleaseStatus.Armed) revert InvalidReleaseStatus(); + _requireCurrentGuardianSet(rec); + if (deadline < block.timestamp) revert SignatureExpired(); + + bytes32 structHash = keccak256( + abi.encode( + CANCEL_TYPEHASH, + releaseId, + reasonHash, + deadline, + rec.armEpoch + ) + ); + _enforceGuardianQuorum(structHash, signatures, rec.threshold); + + rec.status = ReleaseStatus.Canceled; + rec.armedAt = 0; + rec.executeNotBefore = 0; + + emit LastLightReleaseCanceled(releaseId, reasonHash, rec.armEpoch); + } + + function executeReleaseWithSignatures( + bytes32 releaseId, + bytes32 decryptionKey, + bytes32 manifestRefHash, + uint64 deadline, + bytes[] calldata signatures + ) external { + ReleaseRecord storage rec = _releases[releaseId]; + if (rec.status != ReleaseStatus.Armed) revert InvalidReleaseStatus(); + _requireCurrentGuardianSet(rec); + if (deadline < block.timestamp) revert SignatureExpired(); + if (block.timestamp < rec.executeNotBefore) revert ExecutionTimelockActive(); + if (keccak256(abi.encodePacked(decryptionKey)) != rec.decryptionKeyHash) revert InvalidDecryptionKey(); + + bytes32 structHash = keccak256( + abi.encode( + EXECUTE_TYPEHASH, + releaseId, + decryptionKey, + manifestRefHash, + deadline, + rec.armEpoch + ) + ); + _enforceGuardianQuorum(structHash, signatures, rec.threshold); + + rec.status = ReleaseStatus.Executed; + + emit LastLightReleaseExecuted(releaseId, decryptionKey, manifestRefHash, rec.armEpoch); + } + + function _setGuardians(address[] memory guardians_, uint8 threshold_) private { + uint256 count = guardians_.length; + if (count == 0) revert InvalidGuardianSet(); + if (threshold_ == 0 || threshold_ > count) revert InvalidThreshold(); + + for (uint256 i = 0; i < _guardians.length; i++) { + _isGuardian[_guardians[i]] = false; + } + delete _guardians; + + bytes32 rolling; + for (uint256 i = 0; i < count; i++) { + address guardian = guardians_[i]; + if (guardian == address(0)) revert InvalidAddress(); + if (_isGuardian[guardian]) revert DuplicateGuardian(); + _isGuardian[guardian] = true; + _guardians.push(guardian); + rolling = keccak256(abi.encodePacked(rolling, guardian)); + } + + guardianSetHash = rolling; + threshold = threshold_; + emit GuardianSetUpdated(guardianSetHash, threshold_, count); + } + + function _requireStagedRelease(bytes32 releaseId) private view returns (ReleaseRecord storage rec) { + rec = _releases[releaseId]; + if (rec.status != ReleaseStatus.Staged) revert InvalidReleaseStatus(); + } + + function _requireCurrentGuardianSet(ReleaseRecord storage rec) private view { + if (rec.guardianSetHash != guardianSetHash || rec.threshold != threshold) revert GuardianSetChanged(); + } + + function _enforceGuardianQuorum(bytes32 structHash, bytes[] calldata signatures, uint8 threshold_) private view { + bytes32 digest = _hashTypedDataV4(structHash); + uint256 unique = 0; + address[] memory seen = new address[](signatures.length); + + for (uint256 i = 0; i < signatures.length; i++) { + address signer = _recover(digest, signatures[i]); + if (!_isGuardian[signer]) revert GuardianNotFound(); + bool duplicate; + for (uint256 j = 0; j < unique; j++) { + if (seen[j] == signer) { + duplicate = true; + break; + } + } + if (duplicate) continue; + seen[unique] = signer; + unique += 1; + } + + if (unique < threshold_) revert InvalidGuardianQuorum(); + } + + function _hashTypedDataV4(bytes32 structHash) private view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", _domainSeparatorV4(), structHash)); + } + + function _domainSeparatorV4() private view returns (bytes32) { + return keccak256( + abi.encode( + DOMAIN_TYPEHASH, + NAME_HASH, + VERSION_HASH, + block.chainid, + address(this) + ) + ); + } + + function _recover(bytes32 digest, bytes calldata signature) private pure returns (address signer) { + if (signature.length != 65) revert InvalidSignature(); + + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := calldataload(signature.offset) + s := calldataload(add(signature.offset, 32)) + v := byte(0, calldataload(add(signature.offset, 64))) + } + + if (uint256(s) > SECP256K1_HALF_N) revert InvalidSignature(); + if (v != 27 && v != 28) revert InvalidSignature(); + signer = ecrecover(digest, v, r, s); + if (signer == address(0)) revert InvalidSignature(); + } +} diff --git a/deploy/lastlight-deploy.template.json b/deploy/lastlight-deploy.template.json new file mode 100644 index 0000000..df60db7 --- /dev/null +++ b/deploy/lastlight-deploy.template.json @@ -0,0 +1,16 @@ +{ + "network": "baseSepolia", + "chain_id": 84532, + "deployer": "0x0000000000000000000000000000000000000000", + "lastlight_contract": "0x0000000000000000000000000000000000000000", + "guardians": [ + "0x0000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002" + ], + "guardian_count": 5, + "threshold": 3, + "deployment_tx_hash": "0x", + "deployed_at": "2026-02-20T00:00:00Z", + "notes": "Fill from deployment output and keep under change control." +} diff --git a/deploy/membership-deploy.template.json b/deploy/membership-deploy.template.json index cb4e72f..23ab66c 100644 --- a/deploy/membership-deploy.template.json +++ b/deploy/membership-deploy.template.json @@ -2,11 +2,10 @@ "network": "base-sepolia", "chain_id": 84532, "deployer": "0x0000000000000000000000000000000000000000", - "treasury_wallet": "0x0000000000000000000000000000000000000000", - "mint_currency_address": "0x036cbd53842c5426634e7929541ec2318f3dcf7e", - "mint_amount_atomic": "100000000", + "mint_currency_address": "0x0000000000000000000000000000000000000000", + "mint_amount_atomic": "0", "membership_contract": "0x0000000000000000000000000000000000000000", "deployment_tx_hash": "0x", "deployed_at": "2026-02-18T00:00:00Z", - "notes": "Fill from deployment output and keep under change control." + "notes": "Gas-only EDUT ID mint (no platform fee). Fill from deployment output and keep under change control." } diff --git a/docs/conformance-vectors.md b/docs/conformance-vectors.md index 268e5f2..47e500f 100644 --- a/docs/conformance-vectors.md +++ b/docs/conformance-vectors.md @@ -6,5 +6,5 @@ 4. `C-004` Entitlement mint emits deterministic entitlement id/event payload. 5. `C-005` Entitlement state transitions cannot bypass allowed state machine. 6. `C-006` Contract deployment artifacts map to verified on-chain bytecode. -7. `C-007` Sponsor/company payer can mint membership for recipient without transferring soulbound ownership. +7. `C-007` Sponsor/company payer can mint membership for recipient without transferring soulbound ownership and without token settlement. 8. `C-008` USDC settlement path transfers exact atomic amount to treasury and rejects missing allowance. diff --git a/docs/contract-roadmap.md b/docs/contract-roadmap.md index 010e2bf..68884c6 100644 --- a/docs/contract-roadmap.md +++ b/docs/contract-roadmap.md @@ -2,12 +2,14 @@ ## Scope -1. Membership contract (soulbound). +1. EDUT ID contract (soulbound, gas-only mint). 2. Offer + entitlement settlement contract. +3. Last Light continuity release controller. ## Requirements -1. One ownership wallet can hold membership + multiple entitlements. -2. Membership contract supports deterministic pricing policy inputs. +1. One ownership wallet can hold EDUT ID + multiple entitlements. +2. EDUT ID mint path remains non-custodial and gas-only (no platform fee transfer). 3. Entitlement minting records recipient ownership wallet and payer wallet. 4. Contract events are sufficient for backend reconciliation. +5. Continuity release requires threshold guardian signatures (EIP-712) and timelocked execute path. diff --git a/docs/interfaces.md b/docs/interfaces.md index 6f208ba..c686240 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -2,17 +2,24 @@ ## Core Contracts -1. Membership contract (soulbound utility access). +1. EDUT ID contract (soulbound identity access). 2. Offer + entitlement settlement contract (`EdutOfferEntitlement`). +3. Continuity release controller (`LastLightController`). ## Required Events 1. `MembershipMinted` -2. `MembershipStatusUpdated` -3. `OfferUpserted` -4. `EntitlementMinted` -5. `EntitlementStateChanged` +2. `EdutIDMinted` +3. `MembershipStatusUpdated` +4. `OfferUpserted` +5. `EntitlementMinted` +6. `EntitlementStateChanged` +7. `LastLightReleaseStaged` +8. `LastLightReleaseArmed` +9. `LastLightReleaseCanceled` +10. `LastLightReleaseExecuted` ## Backend Dependency -Backend must reconcile on-chain events into deterministic membership/entitlement status for runtime gates. +Backend must reconcile on-chain events into deterministic EDUT ID + entitlement status for runtime gates. +Continuity watchers consume `LastLightReleaseExecuted` events for propagation triggers. diff --git a/package.json b/package.json index c85fbf1..a7e8e08 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,13 @@ "check:addresses": "node scripts/validate-runtime-addresses.cjs", "deploy:sepolia": "hardhat run scripts/deploy-membership.cjs --network baseSepolia", "deploy:mainnet": "hardhat run scripts/deploy-membership.cjs --network base", - "update:membership:price:sepolia": "hardhat run scripts/update-membership-price.cjs --network baseSepolia", - "update:membership:price:mainnet": "hardhat run scripts/update-membership-price.cjs --network base", "deploy:entitlement:sepolia": "hardhat run scripts/deploy-entitlement.cjs --network baseSepolia", "deploy:entitlement:mainnet": "hardhat run scripts/deploy-entitlement.cjs --network base", + "deploy:lastlight:sepolia": "hardhat run scripts/deploy-lastlight.cjs --network baseSepolia", + "deploy:lastlight:mainnet": "hardhat run scripts/deploy-lastlight.cjs --network base", "smoke:e2e:sepolia": "node scripts/e2e-membership-flow.cjs", "smoke:e2e:controlplane:sepolia": "node scripts/e2e-control-plane-flow.cjs", + "lastlight:eip712:sepolia": "node scripts/lastlight-eip712-flow.cjs", "verify:offers:sepolia": "node scripts/verify-offer-config-readback.cjs", "smoke:funding:sepolia": "node scripts/report-smoke-funding-threshold.cjs" }, diff --git a/scripts/deploy-lastlight.cjs b/scripts/deploy-lastlight.cjs new file mode 100644 index 0000000..3d54fdb --- /dev/null +++ b/scripts/deploy-lastlight.cjs @@ -0,0 +1,97 @@ +const hre = require("hardhat"); +const fs = require("fs"); +const path = require("path"); + +function requiredEnv(name) { + const value = process.env[name]; + if (!value || !value.trim()) { + throw new Error(`Missing required env: ${name}`); + } + return value.trim(); +} + +function requireAddress(name, value) { + if (!hre.ethers.utils.isAddress(value)) { + throw new Error(`Invalid address for ${name}: ${value}`); + } + if (value === hre.ethers.constants.AddressZero) { + throw new Error(`${name} cannot be zero address`); + } + return value; +} + +function parseUint(name, value, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) { + const parsed = Number.parseInt(String(value).trim(), 10); + if (!Number.isFinite(parsed) || parsed < min || parsed > max) { + throw new Error(`Invalid integer for ${name}: ${value}`); + } + return parsed; +} + +function parseGuardianAddresses(raw) { + const values = raw + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + if (values.length === 0) { + throw new Error("LASTLIGHT_GUARDIANS must include at least one address"); + } + + const dedupe = new Set(); + const out = []; + for (const value of values) { + const checksum = requireAddress("LASTLIGHT_GUARDIANS", value); + const key = checksum.toLowerCase(); + if (dedupe.has(key)) { + throw new Error(`Duplicate guardian address: ${checksum}`); + } + dedupe.add(key); + out.push(checksum); + } + return out; +} + +async function main() { + const guardians = parseGuardianAddresses(requiredEnv("LASTLIGHT_GUARDIANS")); + const threshold = parseUint( + "LASTLIGHT_THRESHOLD", + process.env.LASTLIGHT_THRESHOLD || "3", + { min: 1, max: guardians.length }, + ); + + const [deployer] = await hre.ethers.getSigners(); + console.log("deployer:", deployer.address); + console.log("network:", hre.network.name); + console.log("guardian_count:", guardians.length); + console.log("threshold:", threshold); + + const factory = await hre.ethers.getContractFactory("LastLightController"); + const contract = await factory.deploy(guardians, threshold); + await contract.deployed(); + + console.log("lastlight_contract:", contract.address); + + const output = { + network: hre.network.name, + chain_id: hre.network.config.chainId || null, + deployer: deployer.address, + lastlight_contract: contract.address, + guardian_count: guardians.length, + threshold, + deployment_tx_hash: contract.deployTransaction.hash, + guardians, + }; + + const outputPath = (process.env.LASTLIGHT_DEPLOY_OUTPUT_PATH || "").trim(); + if (outputPath) { + const absolute = path.resolve(outputPath); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, JSON.stringify(output, null, 2)); + console.log("deployment_output:", absolute); + } +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/scripts/deploy-membership.cjs b/scripts/deploy-membership.cjs index c02cc4f..e694b11 100644 --- a/scripts/deploy-membership.cjs +++ b/scripts/deploy-membership.cjs @@ -2,54 +2,14 @@ const hre = require("hardhat"); const fs = require("fs"); const path = require("path"); -function requiredEnv(name) { - const value = process.env[name]; - if (!value || !value.trim()) { - throw new Error(`Missing required env: ${name}`); - } - return value.trim(); -} - -function requireAddress(name, value, { allowZero = false } = {}) { - if (!hre.ethers.utils.isAddress(value)) { - throw new Error(`Invalid address for ${name}: ${value}`); - } - if (!allowZero && value === hre.ethers.constants.AddressZero) { - throw new Error(`${name} cannot be zero address`); - } - return value; -} - -function parseUint(name, value) { - try { - const parsed = hre.ethers.BigNumber.from(value); - if (parsed.lt(0)) { - throw new Error("must be >= 0"); - } - return parsed; - } catch (err) { - throw new Error(`Invalid uint for ${name}: ${value} (${err.message})`); - } -} - async function main() { - const treasury = requireAddress("TREASURY_WALLET", requiredEnv("TREASURY_WALLET")); - const mintCurrency = requireAddress( - "MINT_CURRENCY_ADDRESS", - (process.env.MINT_CURRENCY_ADDRESS || hre.ethers.constants.AddressZero).trim(), - { allowZero: true }, - ); - const mintAmountAtomic = parseUint("MINT_AMOUNT_ATOMIC", requiredEnv("MINT_AMOUNT_ATOMIC")); - const [deployer] = await hre.ethers.getSigners(); console.log("deployer:", deployer.address); console.log("network:", hre.network.name); - console.log("treasury:", treasury); - console.log("mint_currency:", mintCurrency); - console.log("mint_amount_atomic:", mintAmountAtomic); + console.log("mint_mode:", "gas_only"); const factory = await hre.ethers.getContractFactory("EdutHumanMembership"); - const contract = await factory.deploy(treasury, mintCurrency, mintAmountAtomic); + const contract = await factory.deploy(); await contract.deployed(); console.log("membership_contract:", contract.address); @@ -58,9 +18,8 @@ async function main() { network: hre.network.name, chainId: hre.network.config.chainId || null, deployer: deployer.address, - treasury, - mintCurrency, - mintAmountAtomic: mintAmountAtomic.toString(), + mintCurrency: hre.ethers.constants.AddressZero, + mintAmountAtomic: "0", membershipContract: contract.address, txHash: contract.deployTransaction.hash, }; diff --git a/scripts/lastlight-eip712-flow.cjs b/scripts/lastlight-eip712-flow.cjs new file mode 100644 index 0000000..99e3772 --- /dev/null +++ b/scripts/lastlight-eip712-flow.cjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +const { ethers } = require("ethers"); + +const ABI = [ + "function releaseRecord(bytes32 releaseId) view returns (bytes32 payloadManifestHash, bytes32 encryptedPayloadRootHash, bytes32 decryptionKeyHash, bytes32 guardianSetHash, bytes32 metadataRefHash, uint64 timelockSeconds, uint64 stagedAt, uint64 armedAt, uint64 executeNotBefore, uint64 armEpoch, uint8 threshold, uint8 status)", + "function armReleaseWithSignatures(bytes32 releaseId, bytes32 reasonHash, uint64 deadline, bytes[] signatures)", + "function cancelReleaseWithSignatures(bytes32 releaseId, bytes32 reasonHash, uint64 deadline, bytes[] signatures)", + "function executeReleaseWithSignatures(bytes32 releaseId, bytes32 decryptionKey, bytes32 manifestRefHash, uint64 deadline, bytes[] signatures)" +]; + +function required(name) { + const value = process.env[name]; + if (!value || !String(value).trim()) { + throw new Error(`missing required env ${name}`); + } + return String(value).trim(); +} + +function normalizeBytes32(value, fallbackLabel) { + if (!value || !String(value).trim()) { + if (!fallbackLabel) { + throw new Error("missing bytes32 input"); + } + return ethers.utils.id(fallbackLabel); + } + const trimmed = String(value).trim(); + if (ethers.utils.isHexString(trimmed, 32)) { + return trimmed; + } + return ethers.utils.id(trimmed); +} + +async function main() { + const action = String(process.env.LASTLIGHT_ACTION || "arm").trim().toLowerCase(); + const rpc = required("BASE_SEPOLIA_RPC_URL"); + const contractAddress = required("LASTLIGHT_CONTRACT_ADDRESS"); + const releaseId = normalizeBytes32(process.env.LASTLIGHT_RELEASE_ID, "last-light-release"); + const deadlineSeconds = Number(process.env.LASTLIGHT_DEADLINE_SECONDS || "3600"); + if (!Number.isFinite(deadlineSeconds) || deadlineSeconds <= 0) { + throw new Error(`invalid LASTLIGHT_DEADLINE_SECONDS=${process.env.LASTLIGHT_DEADLINE_SECONDS}`); + } + + const provider = new ethers.providers.JsonRpcProvider(rpc); + const relayer = new ethers.Wallet(required("DEPLOYER_PRIVATE_KEY"), provider); + const contract = new ethers.Contract(contractAddress, ABI, relayer); + const network = await provider.getNetwork(); + const latestBlock = await provider.getBlock("latest"); + const deadline = Number(latestBlock.timestamp) + deadlineSeconds; + + const record = await contract.releaseRecord(releaseId); + const armEpoch = Number(record.armEpoch); + const status = Number(record.status); + if (status !== 1 && action === "arm") { + throw new Error(`release must be staged before arm, current status=${status}`); + } + if (status !== 1 && status !== 2 && action === "cancel") { + throw new Error(`release must be staged/armed before cancel, current status=${status}`); + } + if (status !== 2 && action === "execute") { + throw new Error(`release must be armed before execute, current status=${status}`); + } + + const guardianKeysRaw = required("LASTLIGHT_GUARDIAN_PRIVATE_KEYS"); + const guardianKeys = guardianKeysRaw + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + if (guardianKeys.length === 0) { + throw new Error("no guardian keys provided"); + } + const guardians = guardianKeys.map((k) => new ethers.Wallet(k, provider)); + + const domain = { + name: "EDUT Last Light", + version: "1", + chainId: network.chainId, + verifyingContract: contractAddress + }; + + let tx; + if (action === "arm") { + const reasonHash = normalizeBytes32(process.env.LASTLIGHT_REASON_HASH, "owner-incapacity"); + const types = { + ArmIntent: [ + { name: "releaseId", type: "bytes32" }, + { name: "reasonHash", type: "bytes32" }, + { name: "deadline", type: "uint64" }, + { name: "armEpoch", type: "uint64" } + ] + }; + const value = { releaseId, reasonHash, deadline, armEpoch }; + const signatures = await Promise.all(guardians.map((g) => g._signTypedData(domain, types, value))); + tx = await contract.armReleaseWithSignatures(releaseId, reasonHash, deadline, signatures); + } else if (action === "cancel") { + const reasonHash = normalizeBytes32(process.env.LASTLIGHT_REASON_HASH, "cancelled-by-guardians"); + const types = { + CancelIntent: [ + { name: "releaseId", type: "bytes32" }, + { name: "reasonHash", type: "bytes32" }, + { name: "deadline", type: "uint64" }, + { name: "armEpoch", type: "uint64" } + ] + }; + const value = { releaseId, reasonHash, deadline, armEpoch }; + const signatures = await Promise.all(guardians.map((g) => g._signTypedData(domain, types, value))); + tx = await contract.cancelReleaseWithSignatures(releaseId, reasonHash, deadline, signatures); + } else if (action === "execute") { + const decryptionKey = normalizeBytes32(process.env.LASTLIGHT_DECRYPTION_KEY, ""); + const manifestRefHash = normalizeBytes32(process.env.LASTLIGHT_MANIFEST_REF_HASH, "manifest-ref"); + const types = { + ExecuteIntent: [ + { name: "releaseId", type: "bytes32" }, + { name: "decryptionKey", type: "bytes32" }, + { name: "manifestRefHash", type: "bytes32" }, + { name: "deadline", type: "uint64" }, + { name: "armEpoch", type: "uint64" } + ] + }; + const value = { releaseId, decryptionKey, manifestRefHash, deadline, armEpoch }; + const signatures = await Promise.all(guardians.map((g) => g._signTypedData(domain, types, value))); + tx = await contract.executeReleaseWithSignatures(releaseId, decryptionKey, manifestRefHash, deadline, signatures); + } else { + throw new Error(`unsupported LASTLIGHT_ACTION=${action}`); + } + + const receipt = await tx.wait(); + console.log(JSON.stringify({ + ok: true, + action, + chain_id: network.chainId, + contract: contractAddress, + release_id: releaseId, + arm_epoch: armEpoch, + tx_hash: tx.hash, + block_number: receipt.blockNumber + }, null, 2)); +} + +main().catch((err) => { + console.error(JSON.stringify({ ok: false, error: err.message }, null, 2)); + process.exit(1); +}); diff --git a/scripts/validate-runtime-addresses.cjs b/scripts/validate-runtime-addresses.cjs index 52539cd..b656b11 100644 --- a/scripts/validate-runtime-addresses.cjs +++ b/scripts/validate-runtime-addresses.cjs @@ -62,7 +62,6 @@ function main() { assert(Number(membership.chainId) === 84532, `membership chainId mismatch: ${membership.chainId}`); requireAddress("membership.membershipContract", membership.membershipContract); - requireAddress("membership.treasury", membership.treasury); requireUintString("membership.mintAmountAtomic", membership.mintAmountAtomic); assert(Number(entitlement.chainId) === 84532, `entitlement chainId mismatch: ${entitlement.chainId}`); @@ -83,10 +82,6 @@ function main() { normalizedAddress(runtime.entitlement_contract) === normalizedAddress(entitlement.entitlementContract), "runtime entitlement_contract mismatch with entitlement deploy artifact" ); - assert( - normalizedAddress(runtime.treasury_wallet) === normalizedAddress(membership.treasury), - "runtime treasury_wallet mismatch with membership deploy artifact" - ); assert( normalizedAddress(runtime.treasury_wallet) === normalizedAddress(entitlement.treasury), "runtime treasury_wallet mismatch with entitlement deploy artifact" diff --git a/test/EdutHumanMembership.test.js b/test/EdutHumanMembership.test.js index f9c687c..b0b40a6 100644 --- a/test/EdutHumanMembership.test.js +++ b/test/EdutHumanMembership.test.js @@ -12,23 +12,20 @@ describe("EdutHumanMembership", function () { } async function deployFixture() { - const [deployer, treasury, sponsor, recipient, other] = await ethers.getSigners(); + const [deployer, sponsor, recipient, other] = await ethers.getSigners(); const factory = await ethers.getContractFactory("EdutHumanMembership", deployer); - const price = ethers.utils.parseEther("0.002"); - const contract = await factory.deploy(treasury.address, ethers.constants.AddressZero, price); + const contract = await factory.deploy(); await contract.deployed(); - return { contract, deployer, treasury, sponsor, recipient, other, price }; + return { contract, deployer, sponsor, recipient, other }; } - it("mints to recipient with sponsor payment", async function () { - const { contract, treasury, sponsor, recipient, price } = await deployFixture(); - const before = await ethers.provider.getBalance(treasury.address); - - const tx = await contract.connect(sponsor).mintMembership(recipient.address, { value: price }); + it("mints to recipient with gas-only sponsored call", async function () { + const { contract, sponsor, recipient } = await deployFixture(); + const tx = await contract.connect(sponsor).mintMembership(recipient.address); await tx.wait(); - const after = await ethers.provider.getBalance(treasury.address); - expect(after.sub(before).eq(price)).to.equal(true); + expect(await contract.name()).to.equal("EDUT ID"); + expect(await contract.symbol()).to.equal("EID"); expect(await contract.ownerOf(1)).to.equal(recipient.address); const token = await contract.tokenOf(recipient.address); @@ -38,32 +35,33 @@ describe("EdutHumanMembership", function () { }); it("rejects duplicate mint for same human wallet", async function () { - const { contract, sponsor, recipient, price } = await deployFixture(); - await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait(); + const { contract, sponsor, recipient } = await deployFixture(); + await (await contract.connect(sponsor).mintMembership(recipient.address)).wait(); await expectRevertWithCustomError( - contract.connect(sponsor).mintMembership(recipient.address, { value: price }), + contract.connect(sponsor).mintMembership(recipient.address), "AlreadyMinted" ); }); - it("supports owner-configurable price and owner-only admin", async function () { - const { contract, deployer, other } = await deployFixture(); - const nextPrice = ethers.utils.parseEther("0.003"); - - await expectRevertWithCustomError( - contract.connect(other).updateMintPrice(ethers.constants.AddressZero, nextPrice), - "NotOwner" - ); - - await (await contract.connect(deployer).updateMintPrice(ethers.constants.AddressZero, nextPrice)).wait(); + it("always reports zero mint price", async function () { + const { contract } = await deployFixture(); const price = await contract.currentMintPrice(); - expect(price.amountAtomic.eq(nextPrice)).to.equal(true); + expect(price.currency).to.equal(ethers.constants.AddressZero); + expect(price.amountAtomic.toNumber()).to.equal(0); + }); + + it("rejects accidental ETH value", async function () { + const { contract, sponsor, recipient } = await deployFixture(); + await expectRevertWithCustomError( + contract.connect(sponsor).mintMembership(recipient.address, { value: 1 }), + "UnexpectedEthValue" + ); }); it("blocks transfer paths (soulbound)", async function () { - const { contract, sponsor, recipient, other, price } = await deployFixture(); - await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait(); + const { contract, sponsor, recipient, other } = await deployFixture(); + await (await contract.connect(sponsor).mintMembership(recipient.address)).wait(); await expectRevertWithCustomError( contract.connect(recipient).transferFrom(recipient.address, other.address, 1), @@ -72,50 +70,10 @@ describe("EdutHumanMembership", function () { }); it("allows owner to update status", async function () { - const { contract, deployer, sponsor, recipient, price } = await deployFixture(); - await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait(); + const { contract, deployer, sponsor, recipient } = await deployFixture(); + await (await contract.connect(sponsor).mintMembership(recipient.address)).wait(); await (await contract.connect(deployer).setMembershipStatus(recipient.address, 2)).wait(); expect(await contract.membershipStatus(recipient.address)).to.equal(2); }); - - it("supports ERC20 settlement path for USDC-style pricing", async function () { - const [deployer, treasury, sponsor, recipient] = await ethers.getSigners(); - const tokenFactory = await ethers.getContractFactory("MockERC20", deployer); - const usdc = await tokenFactory.deploy("Mock USDC", "USDC", 6); - await usdc.deployed(); - - const membershipFactory = await ethers.getContractFactory("EdutHumanMembership", deployer); - const usdcPrice = ethers.BigNumber.from("100000000"); // 100 USDC with 6 decimals - const contract = await membershipFactory.deploy(treasury.address, usdc.address, usdcPrice); - await contract.deployed(); - - await (await usdc.mint(sponsor.address, usdcPrice)).wait(); - await (await usdc.connect(sponsor).approve(contract.address, usdcPrice)).wait(); - - const before = await usdc.balanceOf(treasury.address); - await (await contract.connect(sponsor).mintMembership(recipient.address)).wait(); - const after = await usdc.balanceOf(treasury.address); - - expect(after.sub(before).eq(usdcPrice)).to.equal(true); - expect(await contract.hasMembership(recipient.address)).to.equal(true); - }); - - it("fails ERC20 mint without allowance", async function () { - const [deployer, treasury, sponsor, recipient] = await ethers.getSigners(); - const tokenFactory = await ethers.getContractFactory("MockERC20", deployer); - const usdc = await tokenFactory.deploy("Mock USDC", "USDC", 6); - await usdc.deployed(); - - const membershipFactory = await ethers.getContractFactory("EdutHumanMembership", deployer); - const usdcPrice = ethers.BigNumber.from("100000000"); - const contract = await membershipFactory.deploy(treasury.address, usdc.address, usdcPrice); - await contract.deployed(); - - await (await usdc.mint(sponsor.address, usdcPrice)).wait(); - await expectRevertWithCustomError( - contract.connect(sponsor).mintMembership(recipient.address), - "PaymentTransferFailed" - ); - }); }); diff --git a/test/LastLightController.test.js b/test/LastLightController.test.js new file mode 100644 index 0000000..f275052 --- /dev/null +++ b/test/LastLightController.test.js @@ -0,0 +1,229 @@ +const { expect } = require("chai"); +const { ethers, network } = require("hardhat"); + +describe("LastLightController", function () { + async function futureDeadline(seconds) { + const block = await ethers.provider.getBlock("latest"); + return block.timestamp + seconds; + } + + async function expectRevertWithCustomError(promise, errorName) { + try { + await promise; + expect.fail(`expected revert ${errorName}`); + } catch (err) { + expect(String(err)).to.contain(errorName); + } + } + + async function signArm(contract, signer, releaseId, reasonHash, deadline, armEpoch) { + const domain = { + name: "EDUT Last Light", + version: "1", + chainId: await signer.getChainId(), + verifyingContract: contract.address + }; + const types = { + ArmIntent: [ + { name: "releaseId", type: "bytes32" }, + { name: "reasonHash", type: "bytes32" }, + { name: "deadline", type: "uint64" }, + { name: "armEpoch", type: "uint64" } + ] + }; + const value = { releaseId, reasonHash, deadline, armEpoch }; + return signer._signTypedData(domain, types, value); + } + + async function signCancel(contract, signer, releaseId, reasonHash, deadline, armEpoch) { + const domain = { + name: "EDUT Last Light", + version: "1", + chainId: await signer.getChainId(), + verifyingContract: contract.address + }; + const types = { + CancelIntent: [ + { name: "releaseId", type: "bytes32" }, + { name: "reasonHash", type: "bytes32" }, + { name: "deadline", type: "uint64" }, + { name: "armEpoch", type: "uint64" } + ] + }; + const value = { releaseId, reasonHash, deadline, armEpoch }; + return signer._signTypedData(domain, types, value); + } + + async function signExecute(contract, signer, releaseId, decryptionKey, manifestRefHash, deadline, armEpoch) { + const domain = { + name: "EDUT Last Light", + version: "1", + chainId: await signer.getChainId(), + verifyingContract: contract.address + }; + const types = { + ExecuteIntent: [ + { name: "releaseId", type: "bytes32" }, + { name: "decryptionKey", type: "bytes32" }, + { name: "manifestRefHash", type: "bytes32" }, + { name: "deadline", type: "uint64" }, + { name: "armEpoch", type: "uint64" } + ] + }; + const value = { releaseId, decryptionKey, manifestRefHash, deadline, armEpoch }; + return signer._signTypedData(domain, types, value); + } + + async function deployFixture() { + const [owner, g1, g2, g3, g4, g5, relayer, other] = await ethers.getSigners(); + const factory = await ethers.getContractFactory("LastLightController", owner); + const contract = await factory.deploy([g1.address, g2.address, g3.address, g4.address, g5.address], 3); + await contract.deployed(); + return { contract, owner, g1, g2, g3, g4, g5, relayer, other }; + } + + async function stageDefault(contract, owner) { + const releaseId = ethers.utils.id("last-light-release-001"); + const payloadManifestHash = ethers.utils.id("payload-manifest"); + const encryptedPayloadRootHash = ethers.utils.id("encrypted-root"); + const decryptionKey = ethers.utils.id("decryption-key"); + const decryptionKeyHash = ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(["bytes32"], [decryptionKey])); + const metadataRefHash = ethers.utils.id("metadata-ref"); + const timelock = 3600; + await (await contract.connect(owner).stageRelease( + releaseId, + payloadManifestHash, + encryptedPayloadRootHash, + decryptionKeyHash, + metadataRefHash, + timelock + )).wait(); + const record = await contract.releaseRecord(releaseId); + return { releaseId, decryptionKey, armEpoch: Number(record.armEpoch), timelock }; + } + + it("requires quorum to arm and execute", async function () { + const { contract, owner, g1, g2, g3, relayer } = await deployFixture(); + const { releaseId, decryptionKey, armEpoch, timelock } = await stageDefault(contract, owner); + const reasonHash = ethers.utils.id("owner-incapacity"); + const manifestRefHash = ethers.utils.id("manifest-ref-hash"); + const deadline = await futureDeadline(24 * 3600); + + const armSig1 = await signArm(contract, g1, releaseId, reasonHash, deadline, armEpoch); + await expectRevertWithCustomError( + contract.connect(relayer).armReleaseWithSignatures(releaseId, reasonHash, deadline, [armSig1]), + "InvalidGuardianQuorum" + ); + + const armSig2 = await signArm(contract, g2, releaseId, reasonHash, deadline, armEpoch); + const armSig3 = await signArm(contract, g3, releaseId, reasonHash, deadline, armEpoch); + await (await contract.connect(relayer).armReleaseWithSignatures(releaseId, reasonHash, deadline, [armSig1, armSig2, armSig3])).wait(); + + const executeSig1 = await signExecute(contract, g1, releaseId, decryptionKey, manifestRefHash, deadline, armEpoch); + const executeSig2 = await signExecute(contract, g2, releaseId, decryptionKey, manifestRefHash, deadline, armEpoch); + const executeSig3 = await signExecute(contract, g3, releaseId, decryptionKey, manifestRefHash, deadline, armEpoch); + await expectRevertWithCustomError( + contract.connect(relayer).executeReleaseWithSignatures( + releaseId, + decryptionKey, + manifestRefHash, + deadline, + [executeSig1, executeSig2, executeSig3] + ), + "ExecutionTimelockActive" + ); + + await network.provider.send("evm_increaseTime", [timelock + 1]); + await network.provider.send("evm_mine"); + + await (await contract.connect(relayer).executeReleaseWithSignatures( + releaseId, + decryptionKey, + manifestRefHash, + deadline, + [executeSig1, executeSig2, executeSig3] + )).wait(); + const record = await contract.releaseRecord(releaseId); + expect(record.status).to.equal(3); // Executed + }); + + it("requires quorum to cancel; single guardian cannot cancel", async function () { + const { contract, owner, g1, g2, g3, relayer } = await deployFixture(); + const { releaseId, armEpoch } = await stageDefault(contract, owner); + const reasonHash = ethers.utils.id("owner-incapacity"); + const cancelReasonHash = ethers.utils.id("false-alarm"); + const deadline = await futureDeadline(24 * 3600); + + const armSig1 = await signArm(contract, g1, releaseId, reasonHash, deadline, armEpoch); + const armSig2 = await signArm(contract, g2, releaseId, reasonHash, deadline, armEpoch); + const armSig3 = await signArm(contract, g3, releaseId, reasonHash, deadline, armEpoch); + await (await contract.connect(relayer).armReleaseWithSignatures(releaseId, reasonHash, deadline, [armSig1, armSig2, armSig3])).wait(); + + const cancelSig1 = await signCancel(contract, g1, releaseId, cancelReasonHash, deadline, armEpoch); + await expectRevertWithCustomError( + contract.connect(relayer).cancelReleaseWithSignatures(releaseId, cancelReasonHash, deadline, [cancelSig1]), + "InvalidGuardianQuorum" + ); + + const cancelSig2 = await signCancel(contract, g2, releaseId, cancelReasonHash, deadline, armEpoch); + const cancelSig3 = await signCancel(contract, g3, releaseId, cancelReasonHash, deadline, armEpoch); + await (await contract.connect(relayer).cancelReleaseWithSignatures( + releaseId, + cancelReasonHash, + deadline, + [cancelSig1, cancelSig2, cancelSig3] + )).wait(); + const record = await contract.releaseRecord(releaseId); + expect(record.status).to.equal(4); // Canceled + }); + + it("rejects execute when decryption key hash does not match staged commitment", async function () { + const { contract, owner, g1, g2, g3, relayer } = await deployFixture(); + const { releaseId, armEpoch, timelock } = await stageDefault(contract, owner); + const reasonHash = ethers.utils.id("owner-incapacity"); + const deadline = await futureDeadline(24 * 3600); + const armSig1 = await signArm(contract, g1, releaseId, reasonHash, deadline, armEpoch); + const armSig2 = await signArm(contract, g2, releaseId, reasonHash, deadline, armEpoch); + const armSig3 = await signArm(contract, g3, releaseId, reasonHash, deadline, armEpoch); + await (await contract.connect(relayer).armReleaseWithSignatures(releaseId, reasonHash, deadline, [armSig1, armSig2, armSig3])).wait(); + + await network.provider.send("evm_increaseTime", [timelock + 1]); + await network.provider.send("evm_mine"); + + const wrongKey = ethers.utils.id("wrong-key"); + const manifestRefHash = ethers.utils.id("manifest-ref"); + const executeSig1 = await signExecute(contract, g1, releaseId, wrongKey, manifestRefHash, deadline, armEpoch); + const executeSig2 = await signExecute(contract, g2, releaseId, wrongKey, manifestRefHash, deadline, armEpoch); + const executeSig3 = await signExecute(contract, g3, releaseId, wrongKey, manifestRefHash, deadline, armEpoch); + await expectRevertWithCustomError( + contract.connect(relayer).executeReleaseWithSignatures( + releaseId, + wrongKey, + manifestRefHash, + deadline, + [executeSig1, executeSig2, executeSig3] + ), + "InvalidDecryptionKey" + ); + }); + + it("invalidates staged release if guardian set changes", async function () { + const { contract, owner, g1, g2, g3, g4, g5, other, relayer } = await deployFixture(); + const { releaseId, armEpoch } = await stageDefault(contract, owner); + + await (await contract.connect(owner).setGuardians([g1.address, g2.address, g3.address, g4.address, other.address], 3)).wait(); + + const reasonHash = ethers.utils.id("owner-incapacity"); + const deadline = await futureDeadline(24 * 3600); + const armSig1 = await signArm(contract, g1, releaseId, reasonHash, deadline, armEpoch); + const armSig2 = await signArm(contract, g2, releaseId, reasonHash, deadline, armEpoch); + const armSig3 = await signArm(contract, g3, releaseId, reasonHash, deadline, armEpoch); + await expectRevertWithCustomError( + contract.connect(relayer).armReleaseWithSignatures(releaseId, reasonHash, deadline, [armSig1, armSig2, armSig3]), + "GuardianSetChanged" + ); + + // ensure old guardian is no longer active in current set + expect(await contract.isGuardian(g5.address)).to.equal(false); + }); +});