contracts: add last light controller and gas-only identity mint
Some checks are pending
check / contracts (push) Waiting to run

This commit is contained in:
Edut LLC 2026-02-20 15:43:16 -08:00
parent 64a6f0154e
commit 32141a89f4
15 changed files with 906 additions and 200 deletions

View File

@ -2,11 +2,9 @@ DEPLOYER_PRIVATE_KEY=0x...
BASE_SEPOLIA_RPC_URL=https://sepolia.base.org BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
BASE_MAINNET_RPC_URL=https://mainnet.base.org BASE_MAINNET_RPC_URL=https://mainnet.base.org
TREASURY_WALLET=0x0000000000000000000000000000000000000000 # EDUT ID mint is gas-only: no treasury transfer and no token payment.
MINT_CURRENCY_ADDRESS=0x0000000000000000000000000000000000000000
# Base Sepolia USDC by default (override per network as needed). MINT_AMOUNT_ATOMIC=0
MINT_CURRENCY_ADDRESS=0x036cbd53842c5426634e7929541ec2318f3dcf7e
MINT_AMOUNT_ATOMIC=100000000
# Optional: write deployment metadata JSON (address, tx hash, params). # Optional: write deployment metadata JSON (address, tx hash, params).
# DEPLOY_OUTPUT_PATH=./deploy/membership-deploy.sepolia.json # DEPLOY_OUTPUT_PATH=./deploy/membership-deploy.sepolia.json
@ -19,3 +17,18 @@ OFFER_PRICE_ATOMIC=1000000000
# Optional: write entitlement deployment metadata JSON. # Optional: write entitlement deployment metadata JSON.
# ENTITLEMENT_DEPLOY_OUTPUT_PATH=./deploy/entitlement-deploy.sepolia.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

View File

@ -1,15 +1,11 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
pragma solidity ^0.8.24; pragma solidity ^0.8.24;
interface IERC20 { /// @title EDUT ID (Soulbound)
function transferFrom(address from, address to, uint256 value) external returns (bool); /// @notice One-time human governance identity token. Gas-only mint; no platform fee path.
}
/// @title EDUT Human Membership (Soulbound)
/// @notice One-time human governance token with configurable mint price and sponsor mint support.
contract EdutHumanMembership { contract EdutHumanMembership {
string public constant name = "EDUT Human Membership"; string public constant name = "EDUT ID";
string public constant symbol = "EDUTHUM"; string public constant symbol = "EID";
uint8 public constant MEMBERSHIP_NONE = 0; uint8 public constant MEMBERSHIP_NONE = 0;
uint8 public constant MEMBERSHIP_ACTIVE = 1; uint8 public constant MEMBERSHIP_ACTIVE = 1;
@ -22,22 +18,15 @@ contract EdutHumanMembership {
error UnknownToken(); error UnknownToken();
error InvalidStatus(); error InvalidStatus();
error SoulboundNonTransferable(); error SoulboundNonTransferable();
error IncorrectEthValue();
error UnexpectedEthValue(); error UnexpectedEthValue();
error PaymentTransferFailed();
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event MembershipMinted(address indexed wallet, uint256 indexed tokenId, uint256 amountPaid, address currency); 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 MembershipStatusUpdated(address indexed wallet, uint8 status);
event TreasuryUpdated(address indexed treasury);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
address public owner; address public owner;
address public treasury;
address private _mintCurrency;
uint256 private _mintAmountAtomic;
uint256 private _nextTokenId = 1; uint256 private _nextTokenId = 1;
mapping(uint256 => address) private _tokenOwner; mapping(uint256 => address) private _tokenOwner;
@ -50,15 +39,9 @@ contract EdutHumanMembership {
_; _;
} }
constructor(address treasury_, address currency_, uint256 amountAtomic_) { constructor() {
if (treasury_ == address(0)) revert InvalidAddress();
owner = msg.sender; owner = msg.sender;
treasury = treasury_;
_mintCurrency = currency_;
_mintAmountAtomic = amountAtomic_;
emit OwnershipTransferred(address(0), msg.sender); emit OwnershipTransferred(address(0), msg.sender);
emit TreasuryUpdated(treasury_);
emit MintPriceUpdated(currency_, amountAtomic_);
} }
function balanceOf(address wallet) external view returns (uint256) { function balanceOf(address wallet) external view returns (uint256) {
@ -84,16 +67,15 @@ contract EdutHumanMembership {
return _membershipStatus[wallet]; return _membershipStatus[wallet];
} }
function currentMintPrice() external view returns (address currency, uint256 amountAtomic) { function currentMintPrice() external pure returns (address currency, uint256 amountAtomic) {
return (_mintCurrency, _mintAmountAtomic); 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) { function mintMembership(address recipient) external payable returns (uint256 tokenId) {
if (recipient == address(0)) revert InvalidAddress(); if (recipient == address(0)) revert InvalidAddress();
if (_walletToken[recipient] != 0) revert AlreadyMinted(); if (_walletToken[recipient] != 0) revert AlreadyMinted();
if (msg.value != 0) revert UnexpectedEthValue();
_collectMintPayment(msg.sender);
tokenId = _nextTokenId; tokenId = _nextTokenId;
_nextTokenId += 1; _nextTokenId += 1;
@ -104,22 +86,11 @@ contract EdutHumanMembership {
_membershipStatus[recipient] = MEMBERSHIP_ACTIVE; _membershipStatus[recipient] = MEMBERSHIP_ACTIVE;
emit Transfer(address(0), recipient, tokenId); 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); 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 { function setMembershipStatus(address wallet, uint8 status) external onlyOwner {
uint256 tokenId = _walletToken[wallet]; uint256 tokenId = _walletToken[wallet];
if (tokenId == 0) revert UnknownToken(); if (tokenId == 0) revert UnknownToken();
@ -163,23 +134,4 @@ contract EdutHumanMembership {
function safeTransferFrom(address, address, uint256, bytes calldata) external pure { function safeTransferFrom(address, address, uint256, bytes calldata) external pure {
revert SoulboundNonTransferable(); 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();
}
} }

View File

@ -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();
}
}

View File

@ -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."
}

View File

@ -2,11 +2,10 @@
"network": "base-sepolia", "network": "base-sepolia",
"chain_id": 84532, "chain_id": 84532,
"deployer": "0x0000000000000000000000000000000000000000", "deployer": "0x0000000000000000000000000000000000000000",
"treasury_wallet": "0x0000000000000000000000000000000000000000", "mint_currency_address": "0x0000000000000000000000000000000000000000",
"mint_currency_address": "0x036cbd53842c5426634e7929541ec2318f3dcf7e", "mint_amount_atomic": "0",
"mint_amount_atomic": "100000000",
"membership_contract": "0x0000000000000000000000000000000000000000", "membership_contract": "0x0000000000000000000000000000000000000000",
"deployment_tx_hash": "0x", "deployment_tx_hash": "0x",
"deployed_at": "2026-02-18T00:00:00Z", "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."
} }

View File

@ -6,5 +6,5 @@
4. `C-004` Entitlement mint emits deterministic entitlement id/event payload. 4. `C-004` Entitlement mint emits deterministic entitlement id/event payload.
5. `C-005` Entitlement state transitions cannot bypass allowed state machine. 5. `C-005` Entitlement state transitions cannot bypass allowed state machine.
6. `C-006` Contract deployment artifacts map to verified on-chain bytecode. 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. 8. `C-008` USDC settlement path transfers exact atomic amount to treasury and rejects missing allowance.

View File

@ -2,12 +2,14 @@
## Scope ## Scope
1. Membership contract (soulbound). 1. EDUT ID contract (soulbound, gas-only mint).
2. Offer + entitlement settlement contract. 2. Offer + entitlement settlement contract.
3. Last Light continuity release controller.
## Requirements ## Requirements
1. One ownership wallet can hold membership + multiple entitlements. 1. One ownership wallet can hold EDUT ID + multiple entitlements.
2. Membership contract supports deterministic pricing policy inputs. 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. 3. Entitlement minting records recipient ownership wallet and payer wallet.
4. Contract events are sufficient for backend reconciliation. 4. Contract events are sufficient for backend reconciliation.
5. Continuity release requires threshold guardian signatures (EIP-712) and timelocked execute path.

View File

@ -2,17 +2,24 @@
## Core Contracts ## Core Contracts
1. Membership contract (soulbound utility access). 1. EDUT ID contract (soulbound identity access).
2. Offer + entitlement settlement contract (`EdutOfferEntitlement`). 2. Offer + entitlement settlement contract (`EdutOfferEntitlement`).
3. Continuity release controller (`LastLightController`).
## Required Events ## Required Events
1. `MembershipMinted` 1. `MembershipMinted`
2. `MembershipStatusUpdated` 2. `EdutIDMinted`
3. `OfferUpserted` 3. `MembershipStatusUpdated`
4. `EntitlementMinted` 4. `OfferUpserted`
5. `EntitlementStateChanged` 5. `EntitlementMinted`
6. `EntitlementStateChanged`
7. `LastLightReleaseStaged`
8. `LastLightReleaseArmed`
9. `LastLightReleaseCanceled`
10. `LastLightReleaseExecuted`
## Backend Dependency ## 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.

View File

@ -10,12 +10,13 @@
"check:addresses": "node scripts/validate-runtime-addresses.cjs", "check:addresses": "node scripts/validate-runtime-addresses.cjs",
"deploy:sepolia": "hardhat run scripts/deploy-membership.cjs --network baseSepolia", "deploy:sepolia": "hardhat run scripts/deploy-membership.cjs --network baseSepolia",
"deploy:mainnet": "hardhat run scripts/deploy-membership.cjs --network base", "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:sepolia": "hardhat run scripts/deploy-entitlement.cjs --network baseSepolia",
"deploy:entitlement:mainnet": "hardhat run scripts/deploy-entitlement.cjs --network base", "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:sepolia": "node scripts/e2e-membership-flow.cjs",
"smoke:e2e:controlplane:sepolia": "node scripts/e2e-control-plane-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", "verify:offers:sepolia": "node scripts/verify-offer-config-readback.cjs",
"smoke:funding:sepolia": "node scripts/report-smoke-funding-threshold.cjs" "smoke:funding:sepolia": "node scripts/report-smoke-funding-threshold.cjs"
}, },

View File

@ -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;
});

View File

@ -2,54 +2,14 @@ const hre = require("hardhat");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); 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() { 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(); const [deployer] = await hre.ethers.getSigners();
console.log("deployer:", deployer.address); console.log("deployer:", deployer.address);
console.log("network:", hre.network.name); console.log("network:", hre.network.name);
console.log("treasury:", treasury); console.log("mint_mode:", "gas_only");
console.log("mint_currency:", mintCurrency);
console.log("mint_amount_atomic:", mintAmountAtomic);
const factory = await hre.ethers.getContractFactory("EdutHumanMembership"); const factory = await hre.ethers.getContractFactory("EdutHumanMembership");
const contract = await factory.deploy(treasury, mintCurrency, mintAmountAtomic); const contract = await factory.deploy();
await contract.deployed(); await contract.deployed();
console.log("membership_contract:", contract.address); console.log("membership_contract:", contract.address);
@ -58,9 +18,8 @@ async function main() {
network: hre.network.name, network: hre.network.name,
chainId: hre.network.config.chainId || null, chainId: hre.network.config.chainId || null,
deployer: deployer.address, deployer: deployer.address,
treasury, mintCurrency: hre.ethers.constants.AddressZero,
mintCurrency, mintAmountAtomic: "0",
mintAmountAtomic: mintAmountAtomic.toString(),
membershipContract: contract.address, membershipContract: contract.address,
txHash: contract.deployTransaction.hash, txHash: contract.deployTransaction.hash,
}; };

View File

@ -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);
});

View File

@ -62,7 +62,6 @@ function main() {
assert(Number(membership.chainId) === 84532, `membership chainId mismatch: ${membership.chainId}`); assert(Number(membership.chainId) === 84532, `membership chainId mismatch: ${membership.chainId}`);
requireAddress("membership.membershipContract", membership.membershipContract); requireAddress("membership.membershipContract", membership.membershipContract);
requireAddress("membership.treasury", membership.treasury);
requireUintString("membership.mintAmountAtomic", membership.mintAmountAtomic); requireUintString("membership.mintAmountAtomic", membership.mintAmountAtomic);
assert(Number(entitlement.chainId) === 84532, `entitlement chainId mismatch: ${entitlement.chainId}`); assert(Number(entitlement.chainId) === 84532, `entitlement chainId mismatch: ${entitlement.chainId}`);
@ -83,10 +82,6 @@ function main() {
normalizedAddress(runtime.entitlement_contract) === normalizedAddress(entitlement.entitlementContract), normalizedAddress(runtime.entitlement_contract) === normalizedAddress(entitlement.entitlementContract),
"runtime entitlement_contract mismatch with entitlement deploy artifact" "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( assert(
normalizedAddress(runtime.treasury_wallet) === normalizedAddress(entitlement.treasury), normalizedAddress(runtime.treasury_wallet) === normalizedAddress(entitlement.treasury),
"runtime treasury_wallet mismatch with entitlement deploy artifact" "runtime treasury_wallet mismatch with entitlement deploy artifact"

View File

@ -12,23 +12,20 @@ describe("EdutHumanMembership", function () {
} }
async function deployFixture() { 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 factory = await ethers.getContractFactory("EdutHumanMembership", deployer);
const price = ethers.utils.parseEther("0.002"); const contract = await factory.deploy();
const contract = await factory.deploy(treasury.address, ethers.constants.AddressZero, price);
await contract.deployed(); 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 () { it("mints to recipient with gas-only sponsored call", async function () {
const { contract, treasury, sponsor, recipient, price } = await deployFixture(); const { contract, sponsor, recipient } = await deployFixture();
const before = await ethers.provider.getBalance(treasury.address); const tx = await contract.connect(sponsor).mintMembership(recipient.address);
const tx = await contract.connect(sponsor).mintMembership(recipient.address, { value: price });
await tx.wait(); await tx.wait();
const after = await ethers.provider.getBalance(treasury.address); expect(await contract.name()).to.equal("EDUT ID");
expect(after.sub(before).eq(price)).to.equal(true); expect(await contract.symbol()).to.equal("EID");
expect(await contract.ownerOf(1)).to.equal(recipient.address); expect(await contract.ownerOf(1)).to.equal(recipient.address);
const token = await contract.tokenOf(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 () { it("rejects duplicate mint for same human wallet", async function () {
const { contract, sponsor, recipient, price } = await deployFixture(); const { contract, sponsor, recipient } = await deployFixture();
await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait(); await (await contract.connect(sponsor).mintMembership(recipient.address)).wait();
await expectRevertWithCustomError( await expectRevertWithCustomError(
contract.connect(sponsor).mintMembership(recipient.address, { value: price }), contract.connect(sponsor).mintMembership(recipient.address),
"AlreadyMinted" "AlreadyMinted"
); );
}); });
it("supports owner-configurable price and owner-only admin", async function () { it("always reports zero mint price", async function () {
const { contract, deployer, other } = await deployFixture(); const { contract } = 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();
const price = await contract.currentMintPrice(); 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 () { it("blocks transfer paths (soulbound)", async function () {
const { contract, sponsor, recipient, other, price } = await deployFixture(); const { contract, sponsor, recipient, other } = await deployFixture();
await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait(); await (await contract.connect(sponsor).mintMembership(recipient.address)).wait();
await expectRevertWithCustomError( await expectRevertWithCustomError(
contract.connect(recipient).transferFrom(recipient.address, other.address, 1), contract.connect(recipient).transferFrom(recipient.address, other.address, 1),
@ -72,50 +70,10 @@ describe("EdutHumanMembership", function () {
}); });
it("allows owner to update status", async function () { it("allows owner to update status", async function () {
const { contract, deployer, sponsor, recipient, price } = await deployFixture(); const { contract, deployer, sponsor, recipient } = await deployFixture();
await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait(); await (await contract.connect(sponsor).mintMembership(recipient.address)).wait();
await (await contract.connect(deployer).setMembershipStatus(recipient.address, 2)).wait(); await (await contract.connect(deployer).setMembershipStatus(recipient.address, 2)).wait();
expect(await contract.membershipStatus(recipient.address)).to.equal(2); 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"
);
});
}); });

View File

@ -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);
});
});