contracts: add last light controller and gas-only identity mint
Some checks are pending
check / contracts (push) Waiting to run
Some checks are pending
check / contracts (push) Waiting to run
This commit is contained in:
parent
64a6f0154e
commit
32141a89f4
23
.env.example
23
.env.example
@ -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
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
336
contracts/LastLightController.sol
Normal file
336
contracts/LastLightController.sol
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
deploy/lastlight-deploy.template.json
Normal file
16
deploy/lastlight-deploy.template.json
Normal 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."
|
||||||
|
}
|
||||||
@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
97
scripts/deploy-lastlight.cjs
Normal file
97
scripts/deploy-lastlight.cjs
Normal 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;
|
||||||
|
});
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
142
scripts/lastlight-eip712-flow.cjs
Normal file
142
scripts/lastlight-eip712-flow.cjs
Normal 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);
|
||||||
|
});
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
229
test/LastLightController.test.js
Normal file
229
test/LastLightController.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user