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_MAINNET_RPC_URL=https://mainnet.base.org
|
||||
|
||||
TREASURY_WALLET=0x0000000000000000000000000000000000000000
|
||||
|
||||
# Base Sepolia USDC by default (override per network as needed).
|
||||
MINT_CURRENCY_ADDRESS=0x036cbd53842c5426634e7929541ec2318f3dcf7e
|
||||
MINT_AMOUNT_ATOMIC=100000000
|
||||
# EDUT ID mint is gas-only: no treasury transfer and no token payment.
|
||||
MINT_CURRENCY_ADDRESS=0x0000000000000000000000000000000000000000
|
||||
MINT_AMOUNT_ATOMIC=0
|
||||
|
||||
# Optional: write deployment metadata JSON (address, tx hash, params).
|
||||
# DEPLOY_OUTPUT_PATH=./deploy/membership-deploy.sepolia.json
|
||||
@ -19,3 +17,18 @@ OFFER_PRICE_ATOMIC=1000000000
|
||||
|
||||
# Optional: write entitlement deployment metadata JSON.
|
||||
# ENTITLEMENT_DEPLOY_OUTPUT_PATH=./deploy/entitlement-deploy.sepolia.json
|
||||
|
||||
# Last Light deployment (comma-separated guardian addresses + threshold).
|
||||
LASTLIGHT_GUARDIANS=0x0000000000000000000000000000000000000000
|
||||
LASTLIGHT_THRESHOLD=3
|
||||
# LASTLIGHT_DEPLOY_OUTPUT_PATH=./deploy/lastlight-deploy.sepolia.json
|
||||
|
||||
# Last Light EIP-712 flow (arm/cancel/execute).
|
||||
LASTLIGHT_ACTION=arm
|
||||
LASTLIGHT_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000
|
||||
LASTLIGHT_RELEASE_ID=last-light-release
|
||||
# LASTLIGHT_GUARDIAN_PRIVATE_KEYS=0x...,0x...,0x...
|
||||
LASTLIGHT_DEADLINE_SECONDS=3600
|
||||
# LASTLIGHT_REASON_HASH=owner-incapacity
|
||||
# LASTLIGHT_DECRYPTION_KEY=decryption-key
|
||||
# LASTLIGHT_MANIFEST_REF_HASH=manifest-ref
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.24;
|
||||
|
||||
interface IERC20 {
|
||||
function transferFrom(address from, address to, uint256 value) external returns (bool);
|
||||
}
|
||||
|
||||
/// @title EDUT Human Membership (Soulbound)
|
||||
/// @notice One-time human governance token with configurable mint price and sponsor mint support.
|
||||
/// @title EDUT ID (Soulbound)
|
||||
/// @notice One-time human governance identity token. Gas-only mint; no platform fee path.
|
||||
contract EdutHumanMembership {
|
||||
string public constant name = "EDUT Human Membership";
|
||||
string public constant symbol = "EDUTHUM";
|
||||
string public constant name = "EDUT ID";
|
||||
string public constant symbol = "EID";
|
||||
|
||||
uint8 public constant MEMBERSHIP_NONE = 0;
|
||||
uint8 public constant MEMBERSHIP_ACTIVE = 1;
|
||||
@ -22,22 +18,15 @@ contract EdutHumanMembership {
|
||||
error UnknownToken();
|
||||
error InvalidStatus();
|
||||
error SoulboundNonTransferable();
|
||||
error IncorrectEthValue();
|
||||
error UnexpectedEthValue();
|
||||
error PaymentTransferFailed();
|
||||
|
||||
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
|
||||
event MembershipMinted(address indexed wallet, uint256 indexed tokenId, uint256 amountPaid, address currency);
|
||||
event MintPriceUpdated(address indexed currency, uint256 amountAtomic);
|
||||
event EdutIDMinted(address indexed wallet, uint256 indexed tokenId);
|
||||
event MembershipStatusUpdated(address indexed wallet, uint8 status);
|
||||
event TreasuryUpdated(address indexed treasury);
|
||||
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
|
||||
|
||||
address public owner;
|
||||
address public treasury;
|
||||
|
||||
address private _mintCurrency;
|
||||
uint256 private _mintAmountAtomic;
|
||||
uint256 private _nextTokenId = 1;
|
||||
|
||||
mapping(uint256 => address) private _tokenOwner;
|
||||
@ -50,15 +39,9 @@ contract EdutHumanMembership {
|
||||
_;
|
||||
}
|
||||
|
||||
constructor(address treasury_, address currency_, uint256 amountAtomic_) {
|
||||
if (treasury_ == address(0)) revert InvalidAddress();
|
||||
constructor() {
|
||||
owner = msg.sender;
|
||||
treasury = treasury_;
|
||||
_mintCurrency = currency_;
|
||||
_mintAmountAtomic = amountAtomic_;
|
||||
emit OwnershipTransferred(address(0), msg.sender);
|
||||
emit TreasuryUpdated(treasury_);
|
||||
emit MintPriceUpdated(currency_, amountAtomic_);
|
||||
}
|
||||
|
||||
function balanceOf(address wallet) external view returns (uint256) {
|
||||
@ -84,16 +67,15 @@ contract EdutHumanMembership {
|
||||
return _membershipStatus[wallet];
|
||||
}
|
||||
|
||||
function currentMintPrice() external view returns (address currency, uint256 amountAtomic) {
|
||||
return (_mintCurrency, _mintAmountAtomic);
|
||||
function currentMintPrice() external pure returns (address currency, uint256 amountAtomic) {
|
||||
return (address(0), 0);
|
||||
}
|
||||
|
||||
/// @notice Mint one human membership token to recipient. Caller may sponsor payment.
|
||||
/// @notice Mint one EDUT ID token to recipient. Caller may sponsor gas only.
|
||||
function mintMembership(address recipient) external payable returns (uint256 tokenId) {
|
||||
if (recipient == address(0)) revert InvalidAddress();
|
||||
if (_walletToken[recipient] != 0) revert AlreadyMinted();
|
||||
|
||||
_collectMintPayment(msg.sender);
|
||||
if (msg.value != 0) revert UnexpectedEthValue();
|
||||
|
||||
tokenId = _nextTokenId;
|
||||
_nextTokenId += 1;
|
||||
@ -104,22 +86,11 @@ contract EdutHumanMembership {
|
||||
_membershipStatus[recipient] = MEMBERSHIP_ACTIVE;
|
||||
|
||||
emit Transfer(address(0), recipient, tokenId);
|
||||
emit MembershipMinted(recipient, tokenId, _mintAmountAtomic, _mintCurrency);
|
||||
emit MembershipMinted(recipient, tokenId, 0, address(0));
|
||||
emit EdutIDMinted(recipient, tokenId);
|
||||
emit MembershipStatusUpdated(recipient, MEMBERSHIP_ACTIVE);
|
||||
}
|
||||
|
||||
function updateMintPrice(address currency, uint256 amountAtomic) external onlyOwner {
|
||||
_mintCurrency = currency;
|
||||
_mintAmountAtomic = amountAtomic;
|
||||
emit MintPriceUpdated(currency, amountAtomic);
|
||||
}
|
||||
|
||||
function setTreasury(address nextTreasury) external onlyOwner {
|
||||
if (nextTreasury == address(0)) revert InvalidAddress();
|
||||
treasury = nextTreasury;
|
||||
emit TreasuryUpdated(nextTreasury);
|
||||
}
|
||||
|
||||
function setMembershipStatus(address wallet, uint8 status) external onlyOwner {
|
||||
uint256 tokenId = _walletToken[wallet];
|
||||
if (tokenId == 0) revert UnknownToken();
|
||||
@ -163,23 +134,4 @@ contract EdutHumanMembership {
|
||||
function safeTransferFrom(address, address, uint256, bytes calldata) external pure {
|
||||
revert SoulboundNonTransferable();
|
||||
}
|
||||
|
||||
function _collectMintPayment(address payer) private {
|
||||
uint256 amount = _mintAmountAtomic;
|
||||
if (amount == 0) {
|
||||
if (msg.value != 0) revert UnexpectedEthValue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_mintCurrency == address(0)) {
|
||||
if (msg.value != amount) revert IncorrectEthValue();
|
||||
(bool ok, ) = treasury.call{value: amount}("");
|
||||
if (!ok) revert PaymentTransferFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.value != 0) revert UnexpectedEthValue();
|
||||
bool transferred = IERC20(_mintCurrency).transferFrom(payer, treasury, amount);
|
||||
if (!transferred) revert PaymentTransferFailed();
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"chain_id": 84532,
|
||||
"deployer": "0x0000000000000000000000000000000000000000",
|
||||
"treasury_wallet": "0x0000000000000000000000000000000000000000",
|
||||
"mint_currency_address": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
|
||||
"mint_amount_atomic": "100000000",
|
||||
"mint_currency_address": "0x0000000000000000000000000000000000000000",
|
||||
"mint_amount_atomic": "0",
|
||||
"membership_contract": "0x0000000000000000000000000000000000000000",
|
||||
"deployment_tx_hash": "0x",
|
||||
"deployed_at": "2026-02-18T00:00:00Z",
|
||||
"notes": "Fill from deployment output and keep under change control."
|
||||
"notes": "Gas-only EDUT ID mint (no platform fee). Fill from deployment output and keep under change control."
|
||||
}
|
||||
|
||||
@ -6,5 +6,5 @@
|
||||
4. `C-004` Entitlement mint emits deterministic entitlement id/event payload.
|
||||
5. `C-005` Entitlement state transitions cannot bypass allowed state machine.
|
||||
6. `C-006` Contract deployment artifacts map to verified on-chain bytecode.
|
||||
7. `C-007` Sponsor/company payer can mint membership for recipient without transferring soulbound ownership.
|
||||
7. `C-007` Sponsor/company payer can mint membership for recipient without transferring soulbound ownership and without token settlement.
|
||||
8. `C-008` USDC settlement path transfers exact atomic amount to treasury and rejects missing allowance.
|
||||
|
||||
@ -2,12 +2,14 @@
|
||||
|
||||
## Scope
|
||||
|
||||
1. Membership contract (soulbound).
|
||||
1. EDUT ID contract (soulbound, gas-only mint).
|
||||
2. Offer + entitlement settlement contract.
|
||||
3. Last Light continuity release controller.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. One ownership wallet can hold membership + multiple entitlements.
|
||||
2. Membership contract supports deterministic pricing policy inputs.
|
||||
1. One ownership wallet can hold EDUT ID + multiple entitlements.
|
||||
2. EDUT ID mint path remains non-custodial and gas-only (no platform fee transfer).
|
||||
3. Entitlement minting records recipient ownership wallet and payer wallet.
|
||||
4. Contract events are sufficient for backend reconciliation.
|
||||
5. Continuity release requires threshold guardian signatures (EIP-712) and timelocked execute path.
|
||||
|
||||
@ -2,17 +2,24 @@
|
||||
|
||||
## Core Contracts
|
||||
|
||||
1. Membership contract (soulbound utility access).
|
||||
1. EDUT ID contract (soulbound identity access).
|
||||
2. Offer + entitlement settlement contract (`EdutOfferEntitlement`).
|
||||
3. Continuity release controller (`LastLightController`).
|
||||
|
||||
## Required Events
|
||||
|
||||
1. `MembershipMinted`
|
||||
2. `MembershipStatusUpdated`
|
||||
3. `OfferUpserted`
|
||||
4. `EntitlementMinted`
|
||||
5. `EntitlementStateChanged`
|
||||
2. `EdutIDMinted`
|
||||
3. `MembershipStatusUpdated`
|
||||
4. `OfferUpserted`
|
||||
5. `EntitlementMinted`
|
||||
6. `EntitlementStateChanged`
|
||||
7. `LastLightReleaseStaged`
|
||||
8. `LastLightReleaseArmed`
|
||||
9. `LastLightReleaseCanceled`
|
||||
10. `LastLightReleaseExecuted`
|
||||
|
||||
## Backend Dependency
|
||||
|
||||
Backend must reconcile on-chain events into deterministic membership/entitlement status for runtime gates.
|
||||
Backend must reconcile on-chain events into deterministic EDUT ID + entitlement status for runtime gates.
|
||||
Continuity watchers consume `LastLightReleaseExecuted` events for propagation triggers.
|
||||
|
||||
@ -10,12 +10,13 @@
|
||||
"check:addresses": "node scripts/validate-runtime-addresses.cjs",
|
||||
"deploy:sepolia": "hardhat run scripts/deploy-membership.cjs --network baseSepolia",
|
||||
"deploy:mainnet": "hardhat run scripts/deploy-membership.cjs --network base",
|
||||
"update:membership:price:sepolia": "hardhat run scripts/update-membership-price.cjs --network baseSepolia",
|
||||
"update:membership:price:mainnet": "hardhat run scripts/update-membership-price.cjs --network base",
|
||||
"deploy:entitlement:sepolia": "hardhat run scripts/deploy-entitlement.cjs --network baseSepolia",
|
||||
"deploy:entitlement:mainnet": "hardhat run scripts/deploy-entitlement.cjs --network base",
|
||||
"deploy:lastlight:sepolia": "hardhat run scripts/deploy-lastlight.cjs --network baseSepolia",
|
||||
"deploy:lastlight:mainnet": "hardhat run scripts/deploy-lastlight.cjs --network base",
|
||||
"smoke:e2e:sepolia": "node scripts/e2e-membership-flow.cjs",
|
||||
"smoke:e2e:controlplane:sepolia": "node scripts/e2e-control-plane-flow.cjs",
|
||||
"lastlight:eip712:sepolia": "node scripts/lastlight-eip712-flow.cjs",
|
||||
"verify:offers:sepolia": "node scripts/verify-offer-config-readback.cjs",
|
||||
"smoke:funding:sepolia": "node scripts/report-smoke-funding-threshold.cjs"
|
||||
},
|
||||
|
||||
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 path = require("path");
|
||||
|
||||
function requiredEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error(`Missing required env: ${name}`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requireAddress(name, value, { allowZero = false } = {}) {
|
||||
if (!hre.ethers.utils.isAddress(value)) {
|
||||
throw new Error(`Invalid address for ${name}: ${value}`);
|
||||
}
|
||||
if (!allowZero && value === hre.ethers.constants.AddressZero) {
|
||||
throw new Error(`${name} cannot be zero address`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseUint(name, value) {
|
||||
try {
|
||||
const parsed = hre.ethers.BigNumber.from(value);
|
||||
if (parsed.lt(0)) {
|
||||
throw new Error("must be >= 0");
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid uint for ${name}: ${value} (${err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const treasury = requireAddress("TREASURY_WALLET", requiredEnv("TREASURY_WALLET"));
|
||||
const mintCurrency = requireAddress(
|
||||
"MINT_CURRENCY_ADDRESS",
|
||||
(process.env.MINT_CURRENCY_ADDRESS || hre.ethers.constants.AddressZero).trim(),
|
||||
{ allowZero: true },
|
||||
);
|
||||
const mintAmountAtomic = parseUint("MINT_AMOUNT_ATOMIC", requiredEnv("MINT_AMOUNT_ATOMIC"));
|
||||
|
||||
const [deployer] = await hre.ethers.getSigners();
|
||||
console.log("deployer:", deployer.address);
|
||||
console.log("network:", hre.network.name);
|
||||
console.log("treasury:", treasury);
|
||||
console.log("mint_currency:", mintCurrency);
|
||||
console.log("mint_amount_atomic:", mintAmountAtomic);
|
||||
console.log("mint_mode:", "gas_only");
|
||||
|
||||
const factory = await hre.ethers.getContractFactory("EdutHumanMembership");
|
||||
const contract = await factory.deploy(treasury, mintCurrency, mintAmountAtomic);
|
||||
const contract = await factory.deploy();
|
||||
await contract.deployed();
|
||||
|
||||
console.log("membership_contract:", contract.address);
|
||||
@ -58,9 +18,8 @@ async function main() {
|
||||
network: hre.network.name,
|
||||
chainId: hre.network.config.chainId || null,
|
||||
deployer: deployer.address,
|
||||
treasury,
|
||||
mintCurrency,
|
||||
mintAmountAtomic: mintAmountAtomic.toString(),
|
||||
mintCurrency: hre.ethers.constants.AddressZero,
|
||||
mintAmountAtomic: "0",
|
||||
membershipContract: contract.address,
|
||||
txHash: contract.deployTransaction.hash,
|
||||
};
|
||||
|
||||
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}`);
|
||||
requireAddress("membership.membershipContract", membership.membershipContract);
|
||||
requireAddress("membership.treasury", membership.treasury);
|
||||
requireUintString("membership.mintAmountAtomic", membership.mintAmountAtomic);
|
||||
|
||||
assert(Number(entitlement.chainId) === 84532, `entitlement chainId mismatch: ${entitlement.chainId}`);
|
||||
@ -83,10 +82,6 @@ function main() {
|
||||
normalizedAddress(runtime.entitlement_contract) === normalizedAddress(entitlement.entitlementContract),
|
||||
"runtime entitlement_contract mismatch with entitlement deploy artifact"
|
||||
);
|
||||
assert(
|
||||
normalizedAddress(runtime.treasury_wallet) === normalizedAddress(membership.treasury),
|
||||
"runtime treasury_wallet mismatch with membership deploy artifact"
|
||||
);
|
||||
assert(
|
||||
normalizedAddress(runtime.treasury_wallet) === normalizedAddress(entitlement.treasury),
|
||||
"runtime treasury_wallet mismatch with entitlement deploy artifact"
|
||||
|
||||
@ -12,23 +12,20 @@ describe("EdutHumanMembership", function () {
|
||||
}
|
||||
|
||||
async function deployFixture() {
|
||||
const [deployer, treasury, sponsor, recipient, other] = await ethers.getSigners();
|
||||
const [deployer, sponsor, recipient, other] = await ethers.getSigners();
|
||||
const factory = await ethers.getContractFactory("EdutHumanMembership", deployer);
|
||||
const price = ethers.utils.parseEther("0.002");
|
||||
const contract = await factory.deploy(treasury.address, ethers.constants.AddressZero, price);
|
||||
const contract = await factory.deploy();
|
||||
await contract.deployed();
|
||||
return { contract, deployer, treasury, sponsor, recipient, other, price };
|
||||
return { contract, deployer, sponsor, recipient, other };
|
||||
}
|
||||
|
||||
it("mints to recipient with sponsor payment", async function () {
|
||||
const { contract, treasury, sponsor, recipient, price } = await deployFixture();
|
||||
const before = await ethers.provider.getBalance(treasury.address);
|
||||
|
||||
const tx = await contract.connect(sponsor).mintMembership(recipient.address, { value: price });
|
||||
it("mints to recipient with gas-only sponsored call", async function () {
|
||||
const { contract, sponsor, recipient } = await deployFixture();
|
||||
const tx = await contract.connect(sponsor).mintMembership(recipient.address);
|
||||
await tx.wait();
|
||||
|
||||
const after = await ethers.provider.getBalance(treasury.address);
|
||||
expect(after.sub(before).eq(price)).to.equal(true);
|
||||
expect(await contract.name()).to.equal("EDUT ID");
|
||||
expect(await contract.symbol()).to.equal("EID");
|
||||
expect(await contract.ownerOf(1)).to.equal(recipient.address);
|
||||
|
||||
const token = await contract.tokenOf(recipient.address);
|
||||
@ -38,32 +35,33 @@ describe("EdutHumanMembership", function () {
|
||||
});
|
||||
|
||||
it("rejects duplicate mint for same human wallet", async function () {
|
||||
const { contract, sponsor, recipient, price } = await deployFixture();
|
||||
await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait();
|
||||
const { contract, sponsor, recipient } = await deployFixture();
|
||||
await (await contract.connect(sponsor).mintMembership(recipient.address)).wait();
|
||||
|
||||
await expectRevertWithCustomError(
|
||||
contract.connect(sponsor).mintMembership(recipient.address, { value: price }),
|
||||
contract.connect(sponsor).mintMembership(recipient.address),
|
||||
"AlreadyMinted"
|
||||
);
|
||||
});
|
||||
|
||||
it("supports owner-configurable price and owner-only admin", async function () {
|
||||
const { contract, deployer, other } = await deployFixture();
|
||||
const nextPrice = ethers.utils.parseEther("0.003");
|
||||
|
||||
await expectRevertWithCustomError(
|
||||
contract.connect(other).updateMintPrice(ethers.constants.AddressZero, nextPrice),
|
||||
"NotOwner"
|
||||
);
|
||||
|
||||
await (await contract.connect(deployer).updateMintPrice(ethers.constants.AddressZero, nextPrice)).wait();
|
||||
it("always reports zero mint price", async function () {
|
||||
const { contract } = await deployFixture();
|
||||
const price = await contract.currentMintPrice();
|
||||
expect(price.amountAtomic.eq(nextPrice)).to.equal(true);
|
||||
expect(price.currency).to.equal(ethers.constants.AddressZero);
|
||||
expect(price.amountAtomic.toNumber()).to.equal(0);
|
||||
});
|
||||
|
||||
it("rejects accidental ETH value", async function () {
|
||||
const { contract, sponsor, recipient } = await deployFixture();
|
||||
await expectRevertWithCustomError(
|
||||
contract.connect(sponsor).mintMembership(recipient.address, { value: 1 }),
|
||||
"UnexpectedEthValue"
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks transfer paths (soulbound)", async function () {
|
||||
const { contract, sponsor, recipient, other, price } = await deployFixture();
|
||||
await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait();
|
||||
const { contract, sponsor, recipient, other } = await deployFixture();
|
||||
await (await contract.connect(sponsor).mintMembership(recipient.address)).wait();
|
||||
|
||||
await expectRevertWithCustomError(
|
||||
contract.connect(recipient).transferFrom(recipient.address, other.address, 1),
|
||||
@ -72,50 +70,10 @@ describe("EdutHumanMembership", function () {
|
||||
});
|
||||
|
||||
it("allows owner to update status", async function () {
|
||||
const { contract, deployer, sponsor, recipient, price } = await deployFixture();
|
||||
await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait();
|
||||
const { contract, deployer, sponsor, recipient } = await deployFixture();
|
||||
await (await contract.connect(sponsor).mintMembership(recipient.address)).wait();
|
||||
await (await contract.connect(deployer).setMembershipStatus(recipient.address, 2)).wait();
|
||||
|
||||
expect(await contract.membershipStatus(recipient.address)).to.equal(2);
|
||||
});
|
||||
|
||||
it("supports ERC20 settlement path for USDC-style pricing", async function () {
|
||||
const [deployer, treasury, sponsor, recipient] = await ethers.getSigners();
|
||||
const tokenFactory = await ethers.getContractFactory("MockERC20", deployer);
|
||||
const usdc = await tokenFactory.deploy("Mock USDC", "USDC", 6);
|
||||
await usdc.deployed();
|
||||
|
||||
const membershipFactory = await ethers.getContractFactory("EdutHumanMembership", deployer);
|
||||
const usdcPrice = ethers.BigNumber.from("100000000"); // 100 USDC with 6 decimals
|
||||
const contract = await membershipFactory.deploy(treasury.address, usdc.address, usdcPrice);
|
||||
await contract.deployed();
|
||||
|
||||
await (await usdc.mint(sponsor.address, usdcPrice)).wait();
|
||||
await (await usdc.connect(sponsor).approve(contract.address, usdcPrice)).wait();
|
||||
|
||||
const before = await usdc.balanceOf(treasury.address);
|
||||
await (await contract.connect(sponsor).mintMembership(recipient.address)).wait();
|
||||
const after = await usdc.balanceOf(treasury.address);
|
||||
|
||||
expect(after.sub(before).eq(usdcPrice)).to.equal(true);
|
||||
expect(await contract.hasMembership(recipient.address)).to.equal(true);
|
||||
});
|
||||
|
||||
it("fails ERC20 mint without allowance", async function () {
|
||||
const [deployer, treasury, sponsor, recipient] = await ethers.getSigners();
|
||||
const tokenFactory = await ethers.getContractFactory("MockERC20", deployer);
|
||||
const usdc = await tokenFactory.deploy("Mock USDC", "USDC", 6);
|
||||
await usdc.deployed();
|
||||
|
||||
const membershipFactory = await ethers.getContractFactory("EdutHumanMembership", deployer);
|
||||
const usdcPrice = ethers.BigNumber.from("100000000");
|
||||
const contract = await membershipFactory.deploy(treasury.address, usdc.address, usdcPrice);
|
||||
await contract.deployed();
|
||||
|
||||
await (await usdc.mint(sponsor.address, usdcPrice)).wait();
|
||||
await expectRevertWithCustomError(
|
||||
contract.connect(sponsor).mintMembership(recipient.address),
|
||||
"PaymentTransferFailed"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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