Compare commits
No commits in common. "d758c3e41f47b991a1747868604f99132842d312" and "cfb4770e7ef823851fb0cb6947e52728393e005e" have entirely different histories.
d758c3e41f
...
cfb4770e7e
15
.env.example
15
.env.example
@ -4,18 +4,9 @@ BASE_MAINNET_RPC_URL=https://mainnet.base.org
|
|||||||
|
|
||||||
TREASURY_WALLET=0x0000000000000000000000000000000000000000
|
TREASURY_WALLET=0x0000000000000000000000000000000000000000
|
||||||
|
|
||||||
# Base Sepolia USDC by default (override per network as needed).
|
# Use zero address for native ETH pricing.
|
||||||
MINT_CURRENCY_ADDRESS=0x036cbd53842c5426634e7929541ec2318f3dcf7e
|
MINT_CURRENCY_ADDRESS=0x0000000000000000000000000000000000000000
|
||||||
MINT_AMOUNT_ATOMIC=100000000
|
MINT_AMOUNT_ATOMIC=5000000000000000
|
||||||
|
|
||||||
# Optional: write deployment metadata JSON (address, tx hash, params).
|
# Optional: write deployment metadata JSON (address, tx hash, params).
|
||||||
# DEPLOY_OUTPUT_PATH=./deploy/membership-deploy.sepolia.json
|
# DEPLOY_OUTPUT_PATH=./deploy/membership-deploy.sepolia.json
|
||||||
|
|
||||||
# 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,8 +5,9 @@ On-chain contracts and deployment artifacts for membership and entitlements.
|
|||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
1. Human membership contract (soulbound governance identity).
|
1. Human membership contract (soulbound governance identity).
|
||||||
2. Offer + entitlement settlement contract for fixed-SKU purchases.
|
2. Offer registry contract interfaces.
|
||||||
3. ABI and deployment artifact publication.
|
3. Entitlement contract interfaces.
|
||||||
|
4. ABI and deployment artifact publication.
|
||||||
|
|
||||||
## Current Contract
|
## Current Contract
|
||||||
|
|
||||||
@ -16,20 +17,10 @@ Features:
|
|||||||
|
|
||||||
1. One-time soulbound human token mint.
|
1. One-time soulbound human token mint.
|
||||||
2. Sponsor mint support (`mintMembership(recipient)` can be paid by inviter/company wallet).
|
2. Sponsor mint support (`mintMembership(recipient)` can be paid by inviter/company wallet).
|
||||||
3. Owner-configurable flat mint price (`updateMintPrice`), launch default is fixed `100 USDC` (6 decimals).
|
3. Owner-configurable flat mint price (`updateMintPrice`), launch default can stay flat `$5` equivalent.
|
||||||
4. Membership status lifecycle (`ACTIVE/SUSPENDED/REVOKED`) for runtime gates.
|
4. Membership status lifecycle (`ACTIVE/SUSPENDED/REVOKED`) for runtime gates.
|
||||||
5. Treasury address control for settlement routing.
|
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
|
## Local Commands
|
||||||
|
|
||||||
Use a Hardhat-supported Node runtime (`20.x` recommended).
|
Use a Hardhat-supported Node runtime (`20.x` recommended).
|
||||||
@ -39,10 +30,6 @@ Use a Hardhat-supported Node runtime (`20.x` recommended).
|
|||||||
3. `npm run test`
|
3. `npm run test`
|
||||||
4. `npm run deploy:sepolia`
|
4. `npm run deploy:sepolia`
|
||||||
5. `npm run deploy:mainnet`
|
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.
|
`make check` wraps build + tests.
|
||||||
|
|
||||||
@ -53,31 +40,9 @@ Copy `.env.example` values into your shell/session before deploy:
|
|||||||
1. `DEPLOYER_PRIVATE_KEY`
|
1. `DEPLOYER_PRIVATE_KEY`
|
||||||
2. `BASE_SEPOLIA_RPC_URL` / `BASE_MAINNET_RPC_URL`
|
2. `BASE_SEPOLIA_RPC_URL` / `BASE_MAINNET_RPC_URL`
|
||||||
3. `TREASURY_WALLET`
|
3. `TREASURY_WALLET`
|
||||||
4. `MINT_CURRENCY_ADDRESS` (USDC token contract on target chain)
|
4. `MINT_CURRENCY_ADDRESS` (`0x000...000` for ETH)
|
||||||
5. `MINT_AMOUNT_ATOMIC`
|
5. `MINT_AMOUNT_ATOMIC`
|
||||||
6. `DEPLOY_OUTPUT_PATH` (optional)
|
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):
|
Example (Sepolia):
|
||||||
|
|
||||||
|
|||||||
@ -1,237 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
// 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,14 +11,9 @@ Place versioned deployment manifests here:
|
|||||||
Template:
|
Template:
|
||||||
|
|
||||||
- `membership-deploy.template.json`
|
- `membership-deploy.template.json`
|
||||||
- `entitlement-deploy.template.json`
|
|
||||||
- `offers.template.json`
|
|
||||||
|
|
||||||
Recommended process:
|
Recommended process:
|
||||||
|
|
||||||
1. Run `npm run deploy:sepolia` / `npm run deploy:mainnet` for membership or
|
1. Run `npm run deploy:sepolia` or `npm run deploy:mainnet`.
|
||||||
`npm run deploy:entitlement:sepolia` / `npm run deploy:entitlement:mainnet` for offer entitlements.
|
2. Copy template to a dated file (for example `membership-base-sepolia-2026-02-18.json`).
|
||||||
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.
|
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`.
|
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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,9 +3,8 @@
|
|||||||
"chainId": 84532,
|
"chainId": 84532,
|
||||||
"deployer": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7",
|
"deployer": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7",
|
||||||
"treasury": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7",
|
"treasury": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7",
|
||||||
"mintCurrency": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
"mintCurrency": "0x0000000000000000000000000000000000000000",
|
||||||
"mintAmountAtomic": "100000000",
|
"mintAmountAtomic": "5000000000000000",
|
||||||
"membershipContract": "0x51C3c35532f395B1555CF5b9d9850909365d0a4f",
|
"membershipContract": "0x51C3c35532f395B1555CF5b9d9850909365d0a4f",
|
||||||
"txHash": "0x048ef7fd5444b3fb66a063aa24702c6fbbaea8e3469d618971c51a0ceb36af86",
|
"txHash": "0x048ef7fd5444b3fb66a063aa24702c6fbbaea8e3469d618971c51a0ceb36af86"
|
||||||
"mintPriceUpdatedTxHash": "0x5df32570f674e6e46d9a2907e26b70205c01ad1d68899ddd75102bfc9764f69c"
|
|
||||||
}
|
}
|
||||||
@ -3,10 +3,11 @@
|
|||||||
"chain_id": 84532,
|
"chain_id": 84532,
|
||||||
"deployer": "0x0000000000000000000000000000000000000000",
|
"deployer": "0x0000000000000000000000000000000000000000",
|
||||||
"treasury_wallet": "0x0000000000000000000000000000000000000000",
|
"treasury_wallet": "0x0000000000000000000000000000000000000000",
|
||||||
"mint_currency_address": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
|
"mint_currency_address": "0x0000000000000000000000000000000000000000",
|
||||||
"mint_amount_atomic": "100000000",
|
"mint_amount_atomic": "5000000000000000",
|
||||||
"membership_contract": "0x0000000000000000000000000000000000000000",
|
"membership_contract": "0x0000000000000000000000000000000000000000",
|
||||||
"deployment_tx_hash": "0x",
|
"deployment_tx_hash": "0x",
|
||||||
"deployed_at": "2026-02-18T00:00:00Z",
|
"deployed_at": "2026-02-18T00:00:00Z",
|
||||||
"notes": "Fill from deployment output and keep under change control."
|
"notes": "Fill from deployment output and keep under change control."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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,9 +2,6 @@
|
|||||||
|
|
||||||
1. `C-001` Membership mint creates non-transferable token.
|
1. `C-001` Membership mint creates non-transferable token.
|
||||||
2. `C-002` Membership status transitions emit corresponding events.
|
2. `C-002` Membership status transitions emit corresponding events.
|
||||||
3. `C-003` Entitlement purchase enforces membership requirement when offer policy requires it.
|
3. `C-003` Entitlement mint emits deterministic entitlement id/event payload.
|
||||||
4. `C-004` Entitlement mint emits deterministic entitlement id/event payload.
|
4. `C-004` Entitlement state transitions cannot bypass allowed state machine.
|
||||||
5. `C-005` Entitlement state transitions cannot bypass allowed state machine.
|
5. `C-005` Contract deployment artifacts map to verified on-chain bytecode.
|
||||||
6. `C-006` Contract deployment artifacts map to verified on-chain bytecode.
|
|
||||||
7. `C-007` Sponsor/company payer can mint membership for recipient without transferring soulbound ownership.
|
|
||||||
8. `C-008` USDC settlement path transfers exact atomic amount to treasury and rejects missing allowance.
|
|
||||||
|
|||||||
@ -3,11 +3,12 @@
|
|||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
1. Membership contract (soulbound).
|
1. Membership contract (soulbound).
|
||||||
2. Offer + entitlement settlement contract.
|
2. Offer registry contract.
|
||||||
|
3. Entitlement contract.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
1. One ownership wallet can hold membership + multiple entitlements.
|
1. One ownership wallet can hold membership + multiple entitlements.
|
||||||
2. Membership contract supports deterministic pricing policy inputs.
|
2. Membership contract supports deterministic pricing policy inputs.
|
||||||
3. Entitlement minting records recipient ownership wallet and payer wallet.
|
3. Entitlement minting records recipient ownership wallet.
|
||||||
4. Contract events are sufficient for backend reconciliation.
|
4. Contract events are sufficient for backend reconciliation.
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
## Core Contracts
|
## Core Contracts
|
||||||
|
|
||||||
1. Membership contract (soulbound utility access).
|
1. Membership contract (soulbound utility access).
|
||||||
2. Offer + entitlement settlement contract (`EdutOfferEntitlement`).
|
2. Offer registry contract.
|
||||||
|
3. Entitlement contract.
|
||||||
|
|
||||||
## Required Events
|
## Required Events
|
||||||
|
|
||||||
|
|||||||
@ -9,10 +9,6 @@
|
|||||||
"check": "npm run build && npm run test",
|
"check": "npm run build && npm run test",
|
||||||
"deploy:sepolia": "hardhat run scripts/deploy-membership.cjs --network baseSepolia",
|
"deploy:sepolia": "hardhat run scripts/deploy-membership.cjs --network baseSepolia",
|
||||||
"deploy:mainnet": "hardhat run scripts/deploy-membership.cjs --network base",
|
"deploy:mainnet": "hardhat run scripts/deploy-membership.cjs --network base",
|
||||||
"update:membership:price:sepolia": "hardhat run scripts/update-membership-price.cjs --network baseSepolia",
|
|
||||||
"update:membership:price:mainnet": "hardhat run scripts/update-membership-price.cjs --network base",
|
|
||||||
"deploy:entitlement:sepolia": "hardhat run scripts/deploy-entitlement.cjs --network baseSepolia",
|
|
||||||
"deploy:entitlement:mainnet": "hardhat run scripts/deploy-entitlement.cjs --network base",
|
|
||||||
"smoke:e2e:sepolia": "node scripts/e2e-membership-flow.cjs"
|
"smoke:e2e:sepolia": "node scripts/e2e-membership-flow.cjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -1,184 +0,0 @@
|
|||||||
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,9 +7,6 @@ const CHAIN_ID = Number((process.env.SECRET_API_CHAIN_ID || "84532").trim());
|
|||||||
const PRIVATE_KEY = (process.env.DEPLOYER_PRIVATE_KEY || "").trim();
|
const PRIVATE_KEY = (process.env.DEPLOYER_PRIVATE_KEY || "").trim();
|
||||||
const RPC_URL = (process.env.BASE_SEPOLIA_RPC_URL || "").trim();
|
const RPC_URL = (process.env.BASE_SEPOLIA_RPC_URL || "").trim();
|
||||||
const GAS_PRICE_WEI = (process.env.E2E_GAS_PRICE_WEI || "").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) {
|
function required(name, value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@ -119,34 +116,10 @@ async function main() {
|
|||||||
data: quote.calldata,
|
data: quote.calldata,
|
||||||
value: quote.value,
|
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 = {
|
const txRequest = {
|
||||||
to: txReq.to,
|
to: txReq.to,
|
||||||
data: txReq.data,
|
data: txReq.data,
|
||||||
value: value,
|
value: txReq.value,
|
||||||
};
|
};
|
||||||
if (GAS_PRICE_WEI) {
|
if (GAS_PRICE_WEI) {
|
||||||
txRequest.gasPrice = ethers.BigNumber.from(GAS_PRICE_WEI);
|
txRequest.gasPrice = ethers.BigNumber.from(GAS_PRICE_WEI);
|
||||||
@ -156,36 +129,19 @@ async function main() {
|
|||||||
await tx.wait(1);
|
await tx.wait(1);
|
||||||
console.log("mined_tx:", tx.hash);
|
console.log("mined_tx:", tx.hash);
|
||||||
|
|
||||||
const confirmPayload = {
|
const confirm = await postJSON("/secret/membership/confirm", {
|
||||||
designation_code: intent.designation_code,
|
designation_code: intent.designation_code,
|
||||||
quote_id: quote.quote_id,
|
quote_id: quote.quote_id,
|
||||||
tx_hash: tx.hash,
|
tx_hash: tx.hash,
|
||||||
address,
|
address,
|
||||||
chain_id: CHAIN_ID,
|
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);
|
console.log("confirm_status:", confirm.status);
|
||||||
if (confirm.identity_assurance_level) {
|
|
||||||
console.log("identity_assurance:", confirm.identity_assurance_level);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await getJSON(
|
const status = await getJSON(
|
||||||
`/secret/membership/status?wallet=${encodeURIComponent(address)}`
|
`/secret/membership/status?wallet=${encodeURIComponent(address)}`
|
||||||
);
|
);
|
||||||
console.log("membership_status:", status.status);
|
console.log("membership_status:", status.status);
|
||||||
if (status.identity_assurance_level) {
|
|
||||||
console.log("membership_assurance:", status.identity_assurance_level);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@ -196,7 +152,6 @@ async function main() {
|
|||||||
tx_hash: tx.hash,
|
tx_hash: tx.hash,
|
||||||
confirm_status: confirm.status,
|
confirm_status: confirm.status,
|
||||||
membership_status: status.status,
|
membership_status: status.status,
|
||||||
identity_assurance_level: status.identity_assurance_level || null,
|
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
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,44 +78,4 @@ describe("EdutHumanMembership", function () {
|
|||||||
|
|
||||||
expect(await contract.membershipStatus(recipient.address)).to.equal(2);
|
expect(await contract.membershipStatus(recipient.address)).to.equal(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports ERC20 settlement path for USDC-style pricing", async function () {
|
|
||||||
const [deployer, treasury, sponsor, recipient] = await ethers.getSigners();
|
|
||||||
const tokenFactory = await ethers.getContractFactory("MockERC20", deployer);
|
|
||||||
const usdc = await tokenFactory.deploy("Mock USDC", "USDC", 6);
|
|
||||||
await usdc.deployed();
|
|
||||||
|
|
||||||
const membershipFactory = await ethers.getContractFactory("EdutHumanMembership", deployer);
|
|
||||||
const usdcPrice = ethers.BigNumber.from("100000000"); // 100 USDC with 6 decimals
|
|
||||||
const contract = await membershipFactory.deploy(treasury.address, usdc.address, usdcPrice);
|
|
||||||
await contract.deployed();
|
|
||||||
|
|
||||||
await (await usdc.mint(sponsor.address, usdcPrice)).wait();
|
|
||||||
await (await usdc.connect(sponsor).approve(contract.address, usdcPrice)).wait();
|
|
||||||
|
|
||||||
const before = await usdc.balanceOf(treasury.address);
|
|
||||||
await (await contract.connect(sponsor).mintMembership(recipient.address)).wait();
|
|
||||||
const after = await usdc.balanceOf(treasury.address);
|
|
||||||
|
|
||||||
expect(after.sub(before).eq(usdcPrice)).to.equal(true);
|
|
||||||
expect(await contract.hasMembership(recipient.address)).to.equal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fails ERC20 mint without allowance", async function () {
|
|
||||||
const [deployer, treasury, sponsor, recipient] = await ethers.getSigners();
|
|
||||||
const tokenFactory = await ethers.getContractFactory("MockERC20", deployer);
|
|
||||||
const usdc = await tokenFactory.deploy("Mock USDC", "USDC", 6);
|
|
||||||
await usdc.deployed();
|
|
||||||
|
|
||||||
const membershipFactory = await ethers.getContractFactory("EdutHumanMembership", deployer);
|
|
||||||
const usdcPrice = ethers.BigNumber.from("100000000");
|
|
||||||
const contract = await membershipFactory.deploy(treasury.address, usdc.address, usdcPrice);
|
|
||||||
await contract.deployed();
|
|
||||||
|
|
||||||
await (await usdc.mint(sponsor.address, usdcPrice)).wait();
|
|
||||||
await expectRevertWithCustomError(
|
|
||||||
contract.connect(sponsor).mintMembership(recipient.address),
|
|
||||||
"PaymentTransferFailed"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,137 +0,0 @@
|
|||||||
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