Compare commits
10 Commits
cfb4770e7e
...
d758c3e41f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d758c3e41f | ||
|
|
c174215057 | ||
|
|
9ab2805139 | ||
|
|
4dc53255cf | ||
|
|
bdbc931dcd | ||
|
|
7300612b71 | ||
|
|
16a1c5836c | ||
|
|
6d9130c9db | ||
|
|
4f7977c3fd | ||
|
|
0718198924 |
15
.env.example
15
.env.example
@ -4,9 +4,18 @@ BASE_MAINNET_RPC_URL=https://mainnet.base.org
|
||||
|
||||
TREASURY_WALLET=0x0000000000000000000000000000000000000000
|
||||
|
||||
# Use zero address for native ETH pricing.
|
||||
MINT_CURRENCY_ADDRESS=0x0000000000000000000000000000000000000000
|
||||
MINT_AMOUNT_ATOMIC=5000000000000000
|
||||
# Base Sepolia USDC by default (override per network as needed).
|
||||
MINT_CURRENCY_ADDRESS=0x036cbd53842c5426634e7929541ec2318f3dcf7e
|
||||
MINT_AMOUNT_ATOMIC=100000000
|
||||
|
||||
# Optional: write deployment metadata JSON (address, tx hash, params).
|
||||
# DEPLOY_OUTPUT_PATH=./deploy/membership-deploy.sepolia.json
|
||||
|
||||
# Entitlement registry deployment defaults (fixed 1000 USDC SKU pricing).
|
||||
ENTITLEMENT_TREASURY_WALLET=0x0000000000000000000000000000000000000000
|
||||
MEMBERSHIP_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000
|
||||
PAYMENT_TOKEN_ADDRESS=0x036cbd53842c5426634e7929541ec2318f3dcf7e
|
||||
OFFER_PRICE_ATOMIC=1000000000
|
||||
|
||||
# Optional: write entitlement deployment metadata JSON.
|
||||
# ENTITLEMENT_DEPLOY_OUTPUT_PATH=./deploy/entitlement-deploy.sepolia.json
|
||||
|
||||
45
README.md
45
README.md
@ -5,9 +5,8 @@ On-chain contracts and deployment artifacts for membership and entitlements.
|
||||
## Scope
|
||||
|
||||
1. Human membership contract (soulbound governance identity).
|
||||
2. Offer registry contract interfaces.
|
||||
3. Entitlement contract interfaces.
|
||||
4. ABI and deployment artifact publication.
|
||||
2. Offer + entitlement settlement contract for fixed-SKU purchases.
|
||||
3. ABI and deployment artifact publication.
|
||||
|
||||
## Current Contract
|
||||
|
||||
@ -17,10 +16,20 @@ Features:
|
||||
|
||||
1. One-time soulbound human token mint.
|
||||
2. Sponsor mint support (`mintMembership(recipient)` can be paid by inviter/company wallet).
|
||||
3. Owner-configurable flat mint price (`updateMintPrice`), launch default can stay flat `$5` equivalent.
|
||||
3. Owner-configurable flat mint price (`updateMintPrice`), launch default is fixed `100 USDC` (6 decimals).
|
||||
4. Membership status lifecycle (`ACTIVE/SUSPENDED/REVOKED`) for runtime gates.
|
||||
5. Treasury address control for settlement routing.
|
||||
|
||||
`contracts/EdutOfferEntitlement.sol`
|
||||
|
||||
Features:
|
||||
|
||||
1. Membership-gated entitlement purchases.
|
||||
2. Owner-configurable offer registry (`upsertOffer`).
|
||||
3. Fixed USDC settlement support (ETH optional if payment token is zero address).
|
||||
4. Deterministic entitlement id sequence with state lifecycle (`ACTIVE/SUSPENDED/REVOKED`).
|
||||
5. Emits offer + entitlement events for backend reconciliation.
|
||||
|
||||
## Local Commands
|
||||
|
||||
Use a Hardhat-supported Node runtime (`20.x` recommended).
|
||||
@ -30,6 +39,10 @@ Use a Hardhat-supported Node runtime (`20.x` recommended).
|
||||
3. `npm run test`
|
||||
4. `npm run deploy:sepolia`
|
||||
5. `npm run deploy:mainnet`
|
||||
6. `npm run deploy:entitlement:sepolia`
|
||||
7. `npm run deploy:entitlement:mainnet`
|
||||
8. `npm run update:membership:price:sepolia`
|
||||
9. `npm run update:membership:price:mainnet`
|
||||
|
||||
`make check` wraps build + tests.
|
||||
|
||||
@ -40,9 +53,31 @@ Copy `.env.example` values into your shell/session before deploy:
|
||||
1. `DEPLOYER_PRIVATE_KEY`
|
||||
2. `BASE_SEPOLIA_RPC_URL` / `BASE_MAINNET_RPC_URL`
|
||||
3. `TREASURY_WALLET`
|
||||
4. `MINT_CURRENCY_ADDRESS` (`0x000...000` for ETH)
|
||||
4. `MINT_CURRENCY_ADDRESS` (USDC token contract on target chain)
|
||||
5. `MINT_AMOUNT_ATOMIC`
|
||||
6. `DEPLOY_OUTPUT_PATH` (optional)
|
||||
7. `ENTITLEMENT_TREASURY_WALLET`
|
||||
8. `MEMBERSHIP_CONTRACT_ADDRESS`
|
||||
9. `PAYMENT_TOKEN_ADDRESS`
|
||||
10. `OFFER_PRICE_ATOMIC`
|
||||
11. `ENTITLEMENT_DEPLOY_OUTPUT_PATH` (optional)
|
||||
12. `OFFERS_JSON` (optional path to per-offer seed config JSON)
|
||||
13. `OFFERS_INLINE_JSON` (optional inline JSON array alternative to `OFFERS_JSON`)
|
||||
|
||||
`update:membership:price:*` requires:
|
||||
|
||||
1. `MEMBERSHIP_CONTRACT_ADDRESS`
|
||||
2. `MINT_CURRENCY_ADDRESS`
|
||||
3. `MINT_AMOUNT_ATOMIC`
|
||||
|
||||
If no offer override JSON is provided, deploy script seeds default offers at `OFFER_PRICE_ATOMIC`.
|
||||
Use `deploy/offers.template.json` to define per-offer prices and policy flags.
|
||||
|
||||
Smoke flow optional vars:
|
||||
|
||||
1. `E2E_IDENTITY_ASSURANCE_LEVEL`
|
||||
2. `E2E_IDENTITY_ATTESTED_BY`
|
||||
3. `E2E_IDENTITY_ATTESTATION_ID`
|
||||
|
||||
Example (Sepolia):
|
||||
|
||||
|
||||
237
contracts/EdutOfferEntitlement.sol
Normal file
237
contracts/EdutOfferEntitlement.sol
Normal file
@ -0,0 +1,237 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.24;
|
||||
|
||||
interface IERC20Settlement {
|
||||
function transferFrom(address from, address to, uint256 value) external returns (bool);
|
||||
}
|
||||
|
||||
interface IEdutMembershipStatus {
|
||||
function hasMembership(address wallet) external view returns (bool);
|
||||
}
|
||||
|
||||
/// @title EDUT Offer Entitlements
|
||||
/// @notice Membership-gated purchase registry for solo/workspace offer SKUs.
|
||||
contract EdutOfferEntitlement {
|
||||
uint8 public constant ENTITLEMENT_NONE = 0;
|
||||
uint8 public constant ENTITLEMENT_ACTIVE = 1;
|
||||
uint8 public constant ENTITLEMENT_SUSPENDED = 2;
|
||||
uint8 public constant ENTITLEMENT_REVOKED = 3;
|
||||
|
||||
error NotOwner();
|
||||
error InvalidAddress();
|
||||
error InvalidState();
|
||||
error UnknownOffer();
|
||||
error OfferInactive();
|
||||
error OfferIDRequired();
|
||||
error MembershipRequired();
|
||||
error UnknownEntitlement();
|
||||
error IncorrectEthValue();
|
||||
error UnexpectedEthValue();
|
||||
error PaymentTransferFailed();
|
||||
|
||||
event OfferUpserted(
|
||||
string offerId,
|
||||
bytes32 indexed offerKey,
|
||||
uint256 priceAtomic,
|
||||
bool active,
|
||||
bool membershipRequired
|
||||
);
|
||||
event EntitlementMinted(
|
||||
uint256 indexed entitlementId,
|
||||
string offerId,
|
||||
bytes32 indexed offerKey,
|
||||
address indexed ownerWallet,
|
||||
address payerWallet,
|
||||
bytes32 orgRootKey,
|
||||
bytes32 workspaceKey,
|
||||
uint256 amountPaidAtomic,
|
||||
address currency
|
||||
);
|
||||
event EntitlementStateChanged(uint256 indexed entitlementId, uint8 state);
|
||||
event TreasuryUpdated(address indexed treasury);
|
||||
event PaymentTokenUpdated(address indexed paymentToken);
|
||||
event MembershipContractUpdated(address indexed membershipContract);
|
||||
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
|
||||
|
||||
struct OfferConfig {
|
||||
uint256 priceAtomic;
|
||||
bool active;
|
||||
bool membershipRequired;
|
||||
bool exists;
|
||||
}
|
||||
|
||||
struct Entitlement {
|
||||
address ownerWallet;
|
||||
address payerWallet;
|
||||
bytes32 offerKey;
|
||||
bytes32 orgRootKey;
|
||||
bytes32 workspaceKey;
|
||||
uint8 state;
|
||||
uint64 issuedAt;
|
||||
}
|
||||
|
||||
address public owner;
|
||||
address public treasury;
|
||||
address public paymentToken;
|
||||
address public membershipContract;
|
||||
uint256 public nextEntitlementId = 1;
|
||||
|
||||
mapping(bytes32 => OfferConfig) private _offers;
|
||||
mapping(bytes32 => string) private _offerIdByKey;
|
||||
mapping(uint256 => Entitlement) private _entitlements;
|
||||
|
||||
modifier onlyOwner() {
|
||||
if (msg.sender != owner) revert NotOwner();
|
||||
_;
|
||||
}
|
||||
|
||||
constructor(address treasury_, address paymentToken_, address membershipContract_) {
|
||||
if (treasury_ == address(0)) revert InvalidAddress();
|
||||
owner = msg.sender;
|
||||
treasury = treasury_;
|
||||
paymentToken = paymentToken_;
|
||||
membershipContract = membershipContract_;
|
||||
emit OwnershipTransferred(address(0), msg.sender);
|
||||
emit TreasuryUpdated(treasury_);
|
||||
emit PaymentTokenUpdated(paymentToken_);
|
||||
emit MembershipContractUpdated(membershipContract_);
|
||||
}
|
||||
|
||||
function offerKeyFor(string memory offerId) public pure returns (bytes32) {
|
||||
return keccak256(bytes(offerId));
|
||||
}
|
||||
|
||||
function offerConfig(string calldata offerId)
|
||||
external
|
||||
view
|
||||
returns (uint256 priceAtomic, bool active, bool membershipRequired, bool exists)
|
||||
{
|
||||
OfferConfig memory cfg = _offers[offerKeyFor(offerId)];
|
||||
return (cfg.priceAtomic, cfg.active, cfg.membershipRequired, cfg.exists);
|
||||
}
|
||||
|
||||
function offerIDByKey(bytes32 offerKey) external view returns (string memory offerId) {
|
||||
return _offerIdByKey[offerKey];
|
||||
}
|
||||
|
||||
function entitlementById(uint256 entitlementId) external view returns (Entitlement memory) {
|
||||
Entitlement memory ent = _entitlements[entitlementId];
|
||||
if (ent.ownerWallet == address(0)) revert UnknownEntitlement();
|
||||
return ent;
|
||||
}
|
||||
|
||||
function upsertOffer(
|
||||
string calldata offerId,
|
||||
uint256 priceAtomic,
|
||||
bool active,
|
||||
bool membershipRequired
|
||||
) external onlyOwner {
|
||||
if (bytes(offerId).length == 0) revert OfferIDRequired();
|
||||
bytes32 key = offerKeyFor(offerId);
|
||||
_offers[key] = OfferConfig({
|
||||
priceAtomic: priceAtomic,
|
||||
active: active,
|
||||
membershipRequired: membershipRequired,
|
||||
exists: true
|
||||
});
|
||||
_offerIdByKey[key] = offerId;
|
||||
emit OfferUpserted(offerId, key, priceAtomic, active, membershipRequired);
|
||||
}
|
||||
|
||||
function purchaseEntitlement(
|
||||
string calldata offerId,
|
||||
address ownerWallet,
|
||||
bytes32 orgRootKey,
|
||||
bytes32 workspaceKey
|
||||
) external payable returns (uint256 entitlementId) {
|
||||
if (bytes(offerId).length == 0) revert OfferIDRequired();
|
||||
if (ownerWallet == address(0)) revert InvalidAddress();
|
||||
|
||||
bytes32 key = offerKeyFor(offerId);
|
||||
OfferConfig memory cfg = _offers[key];
|
||||
if (!cfg.exists) revert UnknownOffer();
|
||||
if (!cfg.active) revert OfferInactive();
|
||||
if (cfg.membershipRequired) {
|
||||
address membership = membershipContract;
|
||||
if (membership == address(0) || !IEdutMembershipStatus(membership).hasMembership(ownerWallet)) {
|
||||
revert MembershipRequired();
|
||||
}
|
||||
}
|
||||
|
||||
_collectPayment(msg.sender, cfg.priceAtomic);
|
||||
|
||||
entitlementId = nextEntitlementId;
|
||||
nextEntitlementId += 1;
|
||||
_entitlements[entitlementId] = Entitlement({
|
||||
ownerWallet: ownerWallet,
|
||||
payerWallet: msg.sender,
|
||||
offerKey: key,
|
||||
orgRootKey: orgRootKey,
|
||||
workspaceKey: workspaceKey,
|
||||
state: ENTITLEMENT_ACTIVE,
|
||||
issuedAt: uint64(block.timestamp)
|
||||
});
|
||||
|
||||
emit EntitlementMinted(
|
||||
entitlementId,
|
||||
offerId,
|
||||
key,
|
||||
ownerWallet,
|
||||
msg.sender,
|
||||
orgRootKey,
|
||||
workspaceKey,
|
||||
cfg.priceAtomic,
|
||||
paymentToken
|
||||
);
|
||||
emit EntitlementStateChanged(entitlementId, ENTITLEMENT_ACTIVE);
|
||||
}
|
||||
|
||||
function setEntitlementState(uint256 entitlementId, uint8 state) external onlyOwner {
|
||||
if (state > ENTITLEMENT_REVOKED) revert InvalidState();
|
||||
Entitlement storage ent = _entitlements[entitlementId];
|
||||
if (ent.ownerWallet == address(0)) revert UnknownEntitlement();
|
||||
ent.state = state;
|
||||
emit EntitlementStateChanged(entitlementId, state);
|
||||
}
|
||||
|
||||
function setTreasury(address nextTreasury) external onlyOwner {
|
||||
if (nextTreasury == address(0)) revert InvalidAddress();
|
||||
treasury = nextTreasury;
|
||||
emit TreasuryUpdated(nextTreasury);
|
||||
}
|
||||
|
||||
function setPaymentToken(address nextPaymentToken) external onlyOwner {
|
||||
paymentToken = nextPaymentToken;
|
||||
emit PaymentTokenUpdated(nextPaymentToken);
|
||||
}
|
||||
|
||||
function setMembershipContract(address nextMembershipContract) external onlyOwner {
|
||||
membershipContract = nextMembershipContract;
|
||||
emit MembershipContractUpdated(nextMembershipContract);
|
||||
}
|
||||
|
||||
function transferOwnership(address nextOwner) external onlyOwner {
|
||||
if (nextOwner == address(0)) revert InvalidAddress();
|
||||
address previous = owner;
|
||||
owner = nextOwner;
|
||||
emit OwnershipTransferred(previous, nextOwner);
|
||||
}
|
||||
|
||||
function _collectPayment(address payer, uint256 amountAtomic) private {
|
||||
if (amountAtomic == 0) {
|
||||
if (msg.value != 0) revert UnexpectedEthValue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (paymentToken == address(0)) {
|
||||
if (msg.value != amountAtomic) revert IncorrectEthValue();
|
||||
(bool sent, ) = treasury.call{value: amountAtomic}("");
|
||||
if (!sent) revert PaymentTransferFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.value != 0) revert UnexpectedEthValue();
|
||||
bool transferred = IERC20Settlement(paymentToken).transferFrom(payer, treasury, amountAtomic);
|
||||
if (!transferred) revert PaymentTransferFailed();
|
||||
}
|
||||
}
|
||||
47
contracts/MockERC20.sol
Normal file
47
contracts/MockERC20.sol
Normal file
@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.24;
|
||||
|
||||
contract MockERC20 {
|
||||
string public name;
|
||||
string public symbol;
|
||||
uint8 public immutable decimals;
|
||||
|
||||
mapping(address => uint256) public balanceOf;
|
||||
mapping(address => mapping(address => uint256)) public allowance;
|
||||
|
||||
event Transfer(address indexed from, address indexed to, uint256 value);
|
||||
event Approval(address indexed owner, address indexed spender, uint256 value);
|
||||
|
||||
constructor(string memory name_, string memory symbol_, uint8 decimals_) {
|
||||
name = name_;
|
||||
symbol = symbol_;
|
||||
decimals = decimals_;
|
||||
}
|
||||
|
||||
function mint(address to, uint256 value) external {
|
||||
balanceOf[to] += value;
|
||||
emit Transfer(address(0), to, value);
|
||||
}
|
||||
|
||||
function approve(address spender, uint256 value) external returns (bool) {
|
||||
allowance[msg.sender][spender] = value;
|
||||
emit Approval(msg.sender, spender, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
function transferFrom(address from, address to, uint256 value) external returns (bool) {
|
||||
uint256 allowed = allowance[from][msg.sender];
|
||||
if (allowed < value) {
|
||||
return false;
|
||||
}
|
||||
uint256 bal = balanceOf[from];
|
||||
if (bal < value) {
|
||||
return false;
|
||||
}
|
||||
allowance[from][msg.sender] = allowed - value;
|
||||
balanceOf[from] = bal - value;
|
||||
balanceOf[to] += value;
|
||||
emit Transfer(from, to, value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
14
contracts/MockMembershipStatus.sol
Normal file
14
contracts/MockMembershipStatus.sol
Normal file
@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.24;
|
||||
|
||||
contract MockMembershipStatus {
|
||||
mapping(address => bool) private _active;
|
||||
|
||||
function setMembership(address wallet, bool active) external {
|
||||
_active[wallet] = active;
|
||||
}
|
||||
|
||||
function hasMembership(address wallet) external view returns (bool) {
|
||||
return _active[wallet];
|
||||
}
|
||||
}
|
||||
@ -11,9 +11,14 @@ Place versioned deployment manifests here:
|
||||
Template:
|
||||
|
||||
- `membership-deploy.template.json`
|
||||
- `entitlement-deploy.template.json`
|
||||
- `offers.template.json`
|
||||
|
||||
Recommended process:
|
||||
|
||||
1. Run `npm run deploy:sepolia` or `npm run deploy:mainnet`.
|
||||
2. Copy template to a dated file (for example `membership-base-sepolia-2026-02-18.json`).
|
||||
1. Run `npm run deploy:sepolia` / `npm run deploy:mainnet` for membership or
|
||||
`npm run deploy:entitlement:sepolia` / `npm run deploy:entitlement:mainnet` for offer entitlements.
|
||||
2. Copy the matching template to a dated file (for example `membership-base-sepolia-2026-02-18.json`).
|
||||
3. Offer override files may include non-contract metadata (for example `execution_profile`) for downstream catalog parity; deploy script ignores unknown keys and only applies on-chain fields.
|
||||
3. Fill all deployment fields from script output and explorer links.
|
||||
4. If you need per-offer pricing, copy `offers.template.json`, edit values, and pass it via `OFFERS_JSON=/path/to/file.json`.
|
||||
|
||||
18
deploy/entitlement-deploy.template.json
Normal file
18
deploy/entitlement-deploy.template.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"network": "baseSepolia",
|
||||
"chain_id": 84532,
|
||||
"deployer": "0x0000000000000000000000000000000000000000",
|
||||
"treasury": "0x0000000000000000000000000000000000000000",
|
||||
"payment_token": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
|
||||
"membership_contract": "0x0000000000000000000000000000000000000000",
|
||||
"offer_price_atomic": "1000000000",
|
||||
"entitlement_contract": "0x0000000000000000000000000000000000000000",
|
||||
"tx_hash": "0x",
|
||||
"seeded_offers": [
|
||||
{ "offer_id": "edut.solo.core", "tx_hash": "0x" },
|
||||
{ "offer_id": "edut.workspace.core", "tx_hash": "0x" },
|
||||
{ "offer_id": "edut.workspace.ai", "tx_hash": "0x" },
|
||||
{ "offer_id": "edut.workspace.lane24", "tx_hash": "0x" },
|
||||
{ "offer_id": "edut.workspace.sovereign", "tx_hash": "0x" }
|
||||
]
|
||||
}
|
||||
@ -3,8 +3,9 @@
|
||||
"chainId": 84532,
|
||||
"deployer": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7",
|
||||
"treasury": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7",
|
||||
"mintCurrency": "0x0000000000000000000000000000000000000000",
|
||||
"mintAmountAtomic": "5000000000000000",
|
||||
"mintCurrency": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
||||
"mintAmountAtomic": "100000000",
|
||||
"membershipContract": "0x51C3c35532f395B1555CF5b9d9850909365d0a4f",
|
||||
"txHash": "0x048ef7fd5444b3fb66a063aa24702c6fbbaea8e3469d618971c51a0ceb36af86"
|
||||
}
|
||||
"txHash": "0x048ef7fd5444b3fb66a063aa24702c6fbbaea8e3469d618971c51a0ceb36af86",
|
||||
"mintPriceUpdatedTxHash": "0x5df32570f674e6e46d9a2907e26b70205c01ad1d68899ddd75102bfc9764f69c"
|
||||
}
|
||||
|
||||
@ -3,11 +3,10 @@
|
||||
"chain_id": 84532,
|
||||
"deployer": "0x0000000000000000000000000000000000000000",
|
||||
"treasury_wallet": "0x0000000000000000000000000000000000000000",
|
||||
"mint_currency_address": "0x0000000000000000000000000000000000000000",
|
||||
"mint_amount_atomic": "5000000000000000",
|
||||
"mint_currency_address": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
|
||||
"mint_amount_atomic": "100000000",
|
||||
"membership_contract": "0x0000000000000000000000000000000000000000",
|
||||
"deployment_tx_hash": "0x",
|
||||
"deployed_at": "2026-02-18T00:00:00Z",
|
||||
"notes": "Fill from deployment output and keep under change control."
|
||||
}
|
||||
|
||||
|
||||
55
deploy/offers.template.json
Normal file
55
deploy/offers.template.json
Normal file
@ -0,0 +1,55 @@
|
||||
[
|
||||
{
|
||||
"offer_id": "edut.solo.core",
|
||||
"price_atomic": "1000000000",
|
||||
"active": true,
|
||||
"membership_required": true,
|
||||
"execution_profile": {
|
||||
"connector_surface": "hybrid",
|
||||
"pacing_tier": "governed_human_pace",
|
||||
"human_pace_floor_ms": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.workspace.core",
|
||||
"price_atomic": "1000000000",
|
||||
"active": true,
|
||||
"membership_required": true,
|
||||
"execution_profile": {
|
||||
"connector_surface": "hybrid",
|
||||
"pacing_tier": "governed_human_pace",
|
||||
"human_pace_floor_ms": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.workspace.ai",
|
||||
"price_atomic": "1000000000",
|
||||
"active": true,
|
||||
"membership_required": true,
|
||||
"execution_profile": {
|
||||
"connector_surface": "hybrid",
|
||||
"pacing_tier": "governed_human_pace",
|
||||
"human_pace_floor_ms": 1200
|
||||
}
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.workspace.lane24",
|
||||
"price_atomic": "1000000000",
|
||||
"active": true,
|
||||
"membership_required": true,
|
||||
"execution_profile": {
|
||||
"connector_surface": "edut_native",
|
||||
"pacing_tier": "local_hardware_speed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.workspace.sovereign",
|
||||
"price_atomic": "1000000000",
|
||||
"active": true,
|
||||
"membership_required": true,
|
||||
"execution_profile": {
|
||||
"connector_surface": "edut_native",
|
||||
"pacing_tier": "local_hardware_speed"
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
1. `C-001` Membership mint creates non-transferable token.
|
||||
2. `C-002` Membership status transitions emit corresponding events.
|
||||
3. `C-003` Entitlement mint emits deterministic entitlement id/event payload.
|
||||
4. `C-004` Entitlement state transitions cannot bypass allowed state machine.
|
||||
5. `C-005` Contract deployment artifacts map to verified on-chain bytecode.
|
||||
3. `C-003` Entitlement purchase enforces membership requirement when offer policy requires it.
|
||||
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.
|
||||
8. `C-008` USDC settlement path transfers exact atomic amount to treasury and rejects missing allowance.
|
||||
|
||||
@ -3,12 +3,11 @@
|
||||
## Scope
|
||||
|
||||
1. Membership contract (soulbound).
|
||||
2. Offer registry contract.
|
||||
3. Entitlement contract.
|
||||
2. Offer + entitlement settlement contract.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. One ownership wallet can hold membership + multiple entitlements.
|
||||
2. Membership contract supports deterministic pricing policy inputs.
|
||||
3. Entitlement minting records recipient ownership wallet.
|
||||
3. Entitlement minting records recipient ownership wallet and payer wallet.
|
||||
4. Contract events are sufficient for backend reconciliation.
|
||||
|
||||
@ -3,8 +3,7 @@
|
||||
## Core Contracts
|
||||
|
||||
1. Membership contract (soulbound utility access).
|
||||
2. Offer registry contract.
|
||||
3. Entitlement contract.
|
||||
2. Offer + entitlement settlement contract (`EdutOfferEntitlement`).
|
||||
|
||||
## Required Events
|
||||
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"check": "npm run build && npm run test",
|
||||
"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",
|
||||
"smoke:e2e:sepolia": "node scripts/e2e-membership-flow.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
184
scripts/deploy-entitlement.cjs
Normal file
184
scripts/deploy-entitlement.cjs
Normal file
@ -0,0 +1,184 @@
|
||||
const hre = require("hardhat");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const DEFAULT_OFFERS = [
|
||||
"edut.solo.core",
|
||||
"edut.workspace.core",
|
||||
"edut.workspace.ai",
|
||||
"edut.workspace.lane24",
|
||||
"edut.workspace.sovereign",
|
||||
];
|
||||
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
|
||||
function readOfferOverrides() {
|
||||
const inlineRaw = (process.env.OFFERS_INLINE_JSON || "").trim();
|
||||
if (inlineRaw) {
|
||||
try {
|
||||
return JSON.parse(inlineRaw);
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid OFFERS_INLINE_JSON (${err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = (process.env.OFFERS_JSON || "").trim();
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
const absolute = path.resolve(filePath);
|
||||
const raw = fs.readFileSync(absolute, "utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid OFFERS_JSON at ${absolute} (${err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildOfferPlan(defaultPriceAtomic) {
|
||||
const overrides = readOfferOverrides();
|
||||
if (!overrides) {
|
||||
return DEFAULT_OFFERS.map((offerID) => ({
|
||||
offerID,
|
||||
priceAtomic: defaultPriceAtomic,
|
||||
active: true,
|
||||
membershipRequired: true,
|
||||
}));
|
||||
}
|
||||
if (!Array.isArray(overrides) || overrides.length === 0) {
|
||||
throw new Error("Offer override list must be a non-empty JSON array");
|
||||
}
|
||||
|
||||
return overrides.map((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
throw new Error(`Offer override at index ${index} must be an object`);
|
||||
}
|
||||
const offerID = String(entry.offer_id || "").trim();
|
||||
if (!offerID) {
|
||||
throw new Error(`Offer override at index ${index} missing offer_id`);
|
||||
}
|
||||
const rawPrice = String(
|
||||
entry.price_atomic !== undefined ? entry.price_atomic : defaultPriceAtomic.toString()
|
||||
).trim();
|
||||
const priceAtomic = parseUint(`offer[${offerID}].price_atomic`, rawPrice);
|
||||
const active = entry.active !== undefined ? Boolean(entry.active) : true;
|
||||
const membershipRequired =
|
||||
entry.membership_required !== undefined ? Boolean(entry.membership_required) : true;
|
||||
return { offerID, priceAtomic, active, membershipRequired };
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const treasury = requireAddress(
|
||||
"ENTITLEMENT_TREASURY_WALLET",
|
||||
requiredEnv("ENTITLEMENT_TREASURY_WALLET"),
|
||||
);
|
||||
const paymentToken = requireAddress(
|
||||
"PAYMENT_TOKEN_ADDRESS",
|
||||
(process.env.PAYMENT_TOKEN_ADDRESS || hre.ethers.constants.AddressZero).trim(),
|
||||
{ allowZero: true },
|
||||
);
|
||||
const membershipContract = requireAddress(
|
||||
"MEMBERSHIP_CONTRACT_ADDRESS",
|
||||
requiredEnv("MEMBERSHIP_CONTRACT_ADDRESS"),
|
||||
);
|
||||
const offerPriceAtomic = parseUint(
|
||||
"OFFER_PRICE_ATOMIC",
|
||||
(process.env.OFFER_PRICE_ATOMIC || "1000000000").trim(),
|
||||
);
|
||||
|
||||
const [deployer] = await hre.ethers.getSigners();
|
||||
console.log("deployer:", deployer.address);
|
||||
console.log("network:", hre.network.name);
|
||||
console.log("treasury:", treasury);
|
||||
console.log("payment_token:", paymentToken);
|
||||
console.log("membership_contract:", membershipContract);
|
||||
console.log("offer_price_atomic:", offerPriceAtomic.toString());
|
||||
|
||||
const factory = await hre.ethers.getContractFactory("EdutOfferEntitlement");
|
||||
const contract = await factory.deploy(treasury, paymentToken, membershipContract);
|
||||
await contract.deployed();
|
||||
console.log("entitlement_contract:", contract.address);
|
||||
|
||||
const offerPlan = buildOfferPlan(offerPriceAtomic);
|
||||
const seededOffers = [];
|
||||
for (const offer of offerPlan) {
|
||||
const tx = await contract.upsertOffer(
|
||||
offer.offerID,
|
||||
offer.priceAtomic,
|
||||
offer.active,
|
||||
offer.membershipRequired
|
||||
);
|
||||
const receipt = await tx.wait();
|
||||
seededOffers.push({
|
||||
offerID: offer.offerID,
|
||||
priceAtomic: offer.priceAtomic.toString(),
|
||||
active: offer.active,
|
||||
membershipRequired: offer.membershipRequired,
|
||||
txHash: receipt.transactionHash,
|
||||
});
|
||||
console.log(
|
||||
"offer_seeded:",
|
||||
offer.offerID,
|
||||
offer.priceAtomic.toString(),
|
||||
offer.active,
|
||||
offer.membershipRequired,
|
||||
receipt.transactionHash
|
||||
);
|
||||
}
|
||||
|
||||
const output = {
|
||||
network: hre.network.name,
|
||||
chainId: hre.network.config.chainId || null,
|
||||
deployer: deployer.address,
|
||||
treasury,
|
||||
paymentToken,
|
||||
membershipContract,
|
||||
offerPriceAtomic: offerPriceAtomic.toString(),
|
||||
defaultOfferPriceAtomic: offerPriceAtomic.toString(),
|
||||
entitlementContract: contract.address,
|
||||
txHash: contract.deployTransaction.hash,
|
||||
seededOffers,
|
||||
};
|
||||
|
||||
const outputPath = (process.env.ENTITLEMENT_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;
|
||||
});
|
||||
@ -7,6 +7,9 @@ const CHAIN_ID = Number((process.env.SECRET_API_CHAIN_ID || "84532").trim());
|
||||
const PRIVATE_KEY = (process.env.DEPLOYER_PRIVATE_KEY || "").trim();
|
||||
const RPC_URL = (process.env.BASE_SEPOLIA_RPC_URL || "").trim();
|
||||
const GAS_PRICE_WEI = (process.env.E2E_GAS_PRICE_WEI || "").trim();
|
||||
const IDENTITY_ASSURANCE = (process.env.E2E_IDENTITY_ASSURANCE_LEVEL || "").trim();
|
||||
const IDENTITY_ATTESTED_BY = (process.env.E2E_IDENTITY_ATTESTED_BY || "").trim();
|
||||
const IDENTITY_ATTESTATION_ID = (process.env.E2E_IDENTITY_ATTESTATION_ID || "").trim();
|
||||
|
||||
function required(name, value) {
|
||||
if (!value) {
|
||||
@ -116,10 +119,34 @@ async function main() {
|
||||
data: quote.calldata,
|
||||
value: quote.value,
|
||||
};
|
||||
|
||||
const value = ethers.BigNumber.from(txReq.value || "0x0");
|
||||
const feeData = await provider.getFeeData();
|
||||
const configuredGasPrice = GAS_PRICE_WEI ? ethers.BigNumber.from(GAS_PRICE_WEI) : null;
|
||||
const effectiveGasPrice =
|
||||
configuredGasPrice || feeData.gasPrice || feeData.maxFeePerGas || ethers.BigNumber.from("0");
|
||||
const gasUnitsReserve = ethers.BigNumber.from("250000");
|
||||
const estimatedGasCost = effectiveGasPrice.mul(gasUnitsReserve);
|
||||
const totalNeeded = value.add(estimatedGasCost);
|
||||
const balance = await provider.getBalance(address);
|
||||
if (balance.lt(totalNeeded)) {
|
||||
const deficit = totalNeeded.sub(balance);
|
||||
throw new Error(
|
||||
[
|
||||
"insufficient funded balance for membership mint preflight",
|
||||
`balance_wei=${balance.toString()}`,
|
||||
`required_min_wei=${totalNeeded.toString()}`,
|
||||
`deficit_wei=${deficit.toString()}`,
|
||||
`mint_value_wei=${value.toString()}`,
|
||||
`gas_reserve_wei=${estimatedGasCost.toString()}`,
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
const txRequest = {
|
||||
to: txReq.to,
|
||||
data: txReq.data,
|
||||
value: txReq.value,
|
||||
value: value,
|
||||
};
|
||||
if (GAS_PRICE_WEI) {
|
||||
txRequest.gasPrice = ethers.BigNumber.from(GAS_PRICE_WEI);
|
||||
@ -129,19 +156,36 @@ async function main() {
|
||||
await tx.wait(1);
|
||||
console.log("mined_tx:", tx.hash);
|
||||
|
||||
const confirm = await postJSON("/secret/membership/confirm", {
|
||||
const confirmPayload = {
|
||||
designation_code: intent.designation_code,
|
||||
quote_id: quote.quote_id,
|
||||
tx_hash: tx.hash,
|
||||
address,
|
||||
chain_id: CHAIN_ID,
|
||||
});
|
||||
};
|
||||
if (IDENTITY_ASSURANCE) {
|
||||
confirmPayload.identity_assurance_level = IDENTITY_ASSURANCE;
|
||||
}
|
||||
if (IDENTITY_ATTESTED_BY) {
|
||||
confirmPayload.identity_attested_by = IDENTITY_ATTESTED_BY;
|
||||
}
|
||||
if (IDENTITY_ATTESTATION_ID) {
|
||||
confirmPayload.identity_attestation_id = IDENTITY_ATTESTATION_ID;
|
||||
}
|
||||
|
||||
const confirm = await postJSON("/secret/membership/confirm", confirmPayload);
|
||||
console.log("confirm_status:", confirm.status);
|
||||
if (confirm.identity_assurance_level) {
|
||||
console.log("identity_assurance:", confirm.identity_assurance_level);
|
||||
}
|
||||
|
||||
const status = await getJSON(
|
||||
`/secret/membership/status?wallet=${encodeURIComponent(address)}`
|
||||
);
|
||||
console.log("membership_status:", status.status);
|
||||
if (status.identity_assurance_level) {
|
||||
console.log("membership_assurance:", status.identity_assurance_level);
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
@ -152,6 +196,7 @@ async function main() {
|
||||
tx_hash: tx.hash,
|
||||
confirm_status: confirm.status,
|
||||
membership_status: status.status,
|
||||
identity_assurance_level: status.identity_assurance_level || null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
82
scripts/update-membership-price.cjs
Normal file
82
scripts/update-membership-price.cjs
Normal file
@ -0,0 +1,82 @@
|
||||
const hre = require("hardhat");
|
||||
|
||||
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 membershipContract = requireAddress(
|
||||
"MEMBERSHIP_CONTRACT_ADDRESS",
|
||||
requiredEnv("MEMBERSHIP_CONTRACT_ADDRESS"),
|
||||
);
|
||||
const targetCurrency = requireAddress(
|
||||
"MINT_CURRENCY_ADDRESS",
|
||||
(process.env.MINT_CURRENCY_ADDRESS || hre.ethers.constants.AddressZero).trim(),
|
||||
{ allowZero: true },
|
||||
);
|
||||
const targetAmount = 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("membership_contract:", membershipContract);
|
||||
console.log("target_mint_currency:", targetCurrency);
|
||||
console.log("target_mint_amount_atomic:", targetAmount.toString());
|
||||
|
||||
const abi = [
|
||||
"function currentMintPrice() view returns (address currency, uint256 amountAtomic)",
|
||||
"function updateMintPrice(address currency, uint256 amountAtomic)",
|
||||
];
|
||||
const contract = new hre.ethers.Contract(membershipContract, abi, deployer);
|
||||
|
||||
const [currentCurrency, currentAmount] = await contract.currentMintPrice();
|
||||
console.log("current_mint_currency:", currentCurrency);
|
||||
console.log("current_mint_amount_atomic:", currentAmount.toString());
|
||||
|
||||
const sameCurrency = currentCurrency.toLowerCase() === targetCurrency.toLowerCase();
|
||||
const sameAmount = currentAmount.eq(targetAmount);
|
||||
if (sameCurrency && sameAmount) {
|
||||
console.log("status: no_change_required");
|
||||
return;
|
||||
}
|
||||
|
||||
const tx = await contract.updateMintPrice(targetCurrency, targetAmount);
|
||||
console.log("submitted_tx:", tx.hash);
|
||||
await tx.wait(1);
|
||||
console.log("mined_tx:", tx.hash);
|
||||
|
||||
const [nextCurrency, nextAmount] = await contract.currentMintPrice();
|
||||
console.log("updated_mint_currency:", nextCurrency);
|
||||
console.log("updated_mint_amount_atomic:", nextAmount.toString());
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@ -78,4 +78,44 @@ describe("EdutHumanMembership", function () {
|
||||
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
137
test/EdutOfferEntitlement.test.js
Normal file
137
test/EdutOfferEntitlement.test.js
Normal file
@ -0,0 +1,137 @@
|
||||
const { expect } = require("chai");
|
||||
const { ethers } = require("hardhat");
|
||||
|
||||
describe("EdutOfferEntitlement", function () {
|
||||
const PRICE = ethers.BigNumber.from("1000000000"); // 1000 USDC (6 decimals)
|
||||
const OFFER_WORKSPACE_CORE = "edut.workspace.core";
|
||||
const OFFER_WORKSPACE_LANE24 = "edut.workspace.lane24";
|
||||
|
||||
async function expectRevertWithCustomError(promise, errorName) {
|
||||
try {
|
||||
await promise;
|
||||
expect.fail(`expected revert ${errorName}`);
|
||||
} catch (err) {
|
||||
expect(String(err)).to.contain(errorName);
|
||||
}
|
||||
}
|
||||
|
||||
async function deployFixture() {
|
||||
const [deployer, treasury, ownerWallet, payer, other] = await ethers.getSigners();
|
||||
|
||||
const tokenFactory = await ethers.getContractFactory("MockERC20");
|
||||
const usdc = await tokenFactory.connect(deployer).deploy("Mock USDC", "USDC", 6);
|
||||
await usdc.deployed();
|
||||
|
||||
const membershipFactory = await ethers.getContractFactory("MockMembershipStatus");
|
||||
const membership = await membershipFactory.connect(deployer).deploy();
|
||||
await membership.deployed();
|
||||
|
||||
const registryFactory = await ethers.getContractFactory("EdutOfferEntitlement");
|
||||
const registry = await registryFactory
|
||||
.connect(deployer)
|
||||
.deploy(treasury.address, usdc.address, membership.address);
|
||||
await registry.deployed();
|
||||
|
||||
return { deployer, treasury, ownerWallet, payer, other, usdc, membership, registry };
|
||||
}
|
||||
|
||||
it("requires owner role to configure offers", async function () {
|
||||
const { registry, other } = await deployFixture();
|
||||
await expectRevertWithCustomError(
|
||||
registry.connect(other).upsertOffer(OFFER_WORKSPACE_CORE, PRICE, true, true),
|
||||
"NotOwner",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks purchase when membership-required offer has no active membership", async function () {
|
||||
const { registry, ownerWallet, payer, usdc } = await deployFixture();
|
||||
await (await registry.upsertOffer(OFFER_WORKSPACE_CORE, PRICE, true, true)).wait();
|
||||
await (await usdc.mint(payer.address, PRICE)).wait();
|
||||
await (await usdc.connect(payer).approve(registry.address, PRICE)).wait();
|
||||
|
||||
await expectRevertWithCustomError(
|
||||
registry.connect(payer).purchaseEntitlement(
|
||||
OFFER_WORKSPACE_CORE,
|
||||
ownerWallet.address,
|
||||
ethers.utils.formatBytes32String("org_root_a"),
|
||||
ethers.utils.formatBytes32String("workspace_a"),
|
||||
),
|
||||
"MembershipRequired",
|
||||
);
|
||||
});
|
||||
|
||||
it("mints entitlement and settles USDC to treasury", async function () {
|
||||
const { registry, membership, ownerWallet, payer, treasury, usdc } = await deployFixture();
|
||||
await (await registry.upsertOffer(OFFER_WORKSPACE_CORE, PRICE, true, true)).wait();
|
||||
await (await membership.setMembership(ownerWallet.address, true)).wait();
|
||||
await (await usdc.mint(payer.address, PRICE)).wait();
|
||||
await (await usdc.connect(payer).approve(registry.address, PRICE)).wait();
|
||||
|
||||
const tx = await registry.connect(payer).purchaseEntitlement(
|
||||
OFFER_WORKSPACE_CORE,
|
||||
ownerWallet.address,
|
||||
ethers.utils.formatBytes32String("org_root_a"),
|
||||
ethers.utils.formatBytes32String("workspace_a"),
|
||||
);
|
||||
await tx.wait();
|
||||
|
||||
expect((await usdc.balanceOf(treasury.address)).eq(PRICE)).to.equal(true);
|
||||
const entitlement = await registry.entitlementById(1);
|
||||
expect(entitlement.ownerWallet).to.equal(ownerWallet.address);
|
||||
expect(entitlement.payerWallet).to.equal(payer.address);
|
||||
expect(Number(entitlement.state)).to.equal(1);
|
||||
});
|
||||
|
||||
it("supports repeated lane purchases with deterministic incremental ids", async function () {
|
||||
const { registry, membership, ownerWallet, payer, usdc } = await deployFixture();
|
||||
await (await registry.upsertOffer(OFFER_WORKSPACE_LANE24, PRICE, true, true)).wait();
|
||||
await (await membership.setMembership(ownerWallet.address, true)).wait();
|
||||
|
||||
const total = PRICE.mul(2);
|
||||
await (await usdc.mint(payer.address, total)).wait();
|
||||
await (await usdc.connect(payer).approve(registry.address, total)).wait();
|
||||
|
||||
await (
|
||||
await registry.connect(payer).purchaseEntitlement(
|
||||
OFFER_WORKSPACE_LANE24,
|
||||
ownerWallet.address,
|
||||
ethers.utils.formatBytes32String("org_root_a"),
|
||||
ethers.utils.formatBytes32String("workspace_a"),
|
||||
)
|
||||
).wait();
|
||||
await (
|
||||
await registry.connect(payer).purchaseEntitlement(
|
||||
OFFER_WORKSPACE_LANE24,
|
||||
ownerWallet.address,
|
||||
ethers.utils.formatBytes32String("org_root_a"),
|
||||
ethers.utils.formatBytes32String("workspace_a"),
|
||||
)
|
||||
).wait();
|
||||
|
||||
const first = await registry.entitlementById(1);
|
||||
const second = await registry.entitlementById(2);
|
||||
expect(Number(first.state)).to.equal(1);
|
||||
expect(Number(second.state)).to.equal(1);
|
||||
expect((await registry.nextEntitlementId()).eq(3)).to.equal(true);
|
||||
});
|
||||
|
||||
it("allows owner to update entitlement state", async function () {
|
||||
const { registry, membership, ownerWallet, payer, usdc } = await deployFixture();
|
||||
await (await registry.upsertOffer(OFFER_WORKSPACE_CORE, PRICE, true, true)).wait();
|
||||
await (await membership.setMembership(ownerWallet.address, true)).wait();
|
||||
await (await usdc.mint(payer.address, PRICE)).wait();
|
||||
await (await usdc.connect(payer).approve(registry.address, PRICE)).wait();
|
||||
await (
|
||||
await registry.connect(payer).purchaseEntitlement(
|
||||
OFFER_WORKSPACE_CORE,
|
||||
ownerWallet.address,
|
||||
ethers.utils.formatBytes32String("org_root_a"),
|
||||
ethers.utils.formatBytes32String("workspace_a"),
|
||||
)
|
||||
).wait();
|
||||
|
||||
await (await registry.setEntitlementState(1, 2)).wait();
|
||||
const entitlement = await registry.entitlementById(1);
|
||||
expect(Number(entitlement.state)).to.equal(2);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user