Compare commits

...

10 Commits

19 changed files with 941 additions and 28 deletions

View File

@ -4,9 +4,18 @@ BASE_MAINNET_RPC_URL=https://mainnet.base.org
TREASURY_WALLET=0x0000000000000000000000000000000000000000 TREASURY_WALLET=0x0000000000000000000000000000000000000000
# Use zero address for native ETH pricing. # Base Sepolia USDC by default (override per network as needed).
MINT_CURRENCY_ADDRESS=0x0000000000000000000000000000000000000000 MINT_CURRENCY_ADDRESS=0x036cbd53842c5426634e7929541ec2318f3dcf7e
MINT_AMOUNT_ATOMIC=5000000000000000 MINT_AMOUNT_ATOMIC=100000000
# Optional: write deployment metadata JSON (address, tx hash, params). # Optional: write deployment metadata JSON (address, tx hash, params).
# DEPLOY_OUTPUT_PATH=./deploy/membership-deploy.sepolia.json # DEPLOY_OUTPUT_PATH=./deploy/membership-deploy.sepolia.json
# 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

View File

@ -5,9 +5,8 @@ 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 registry contract interfaces. 2. Offer + entitlement settlement contract for fixed-SKU purchases.
3. Entitlement contract interfaces. 3. ABI and deployment artifact publication.
4. ABI and deployment artifact publication.
## Current Contract ## Current Contract
@ -17,10 +16,20 @@ 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 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. 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).
@ -30,6 +39,10 @@ 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.
@ -40,9 +53,31 @@ 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` (`0x000...000` for ETH) 4. `MINT_CURRENCY_ADDRESS` (USDC token contract on target chain)
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):

View 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
View 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;
}
}

View 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];
}
}

View File

@ -11,9 +11,14 @@ 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` or `npm run deploy:mainnet`. 1. Run `npm run deploy:sepolia` / `npm run deploy:mainnet` for membership or
2. Copy template to a dated file (for example `membership-base-sepolia-2026-02-18.json`). `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. 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`.

View 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" }
]
}

View File

@ -3,8 +3,9 @@
"chainId": 84532, "chainId": 84532,
"deployer": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7", "deployer": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7",
"treasury": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7", "treasury": "0xD148d4dFA882007e5226C90287622b3Af6eB56D7",
"mintCurrency": "0x0000000000000000000000000000000000000000", "mintCurrency": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"mintAmountAtomic": "5000000000000000", "mintAmountAtomic": "100000000",
"membershipContract": "0x51C3c35532f395B1555CF5b9d9850909365d0a4f", "membershipContract": "0x51C3c35532f395B1555CF5b9d9850909365d0a4f",
"txHash": "0x048ef7fd5444b3fb66a063aa24702c6fbbaea8e3469d618971c51a0ceb36af86" "txHash": "0x048ef7fd5444b3fb66a063aa24702c6fbbaea8e3469d618971c51a0ceb36af86",
"mintPriceUpdatedTxHash": "0x5df32570f674e6e46d9a2907e26b70205c01ad1d68899ddd75102bfc9764f69c"
} }

View File

@ -3,11 +3,10 @@
"chain_id": 84532, "chain_id": 84532,
"deployer": "0x0000000000000000000000000000000000000000", "deployer": "0x0000000000000000000000000000000000000000",
"treasury_wallet": "0x0000000000000000000000000000000000000000", "treasury_wallet": "0x0000000000000000000000000000000000000000",
"mint_currency_address": "0x0000000000000000000000000000000000000000", "mint_currency_address": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
"mint_amount_atomic": "5000000000000000", "mint_amount_atomic": "100000000",
"membership_contract": "0x0000000000000000000000000000000000000000", "membership_contract": "0x0000000000000000000000000000000000000000",
"deployment_tx_hash": "0x", "deployment_tx_hash": "0x",
"deployed_at": "2026-02-18T00:00:00Z", "deployed_at": "2026-02-18T00:00:00Z",
"notes": "Fill from deployment output and keep under change control." "notes": "Fill from deployment output and keep under change control."
} }

View 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"
}
}
]

View File

@ -2,6 +2,9 @@
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 mint emits deterministic entitlement id/event payload. 3. `C-003` Entitlement purchase enforces membership requirement when offer policy requires it.
4. `C-004` Entitlement state transitions cannot bypass allowed state machine. 4. `C-004` Entitlement mint emits deterministic entitlement id/event payload.
5. `C-005` Contract deployment artifacts map to verified on-chain bytecode. 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.

View File

@ -3,12 +3,11 @@
## Scope ## Scope
1. Membership contract (soulbound). 1. Membership contract (soulbound).
2. Offer registry contract. 2. Offer + entitlement settlement 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. 3. Entitlement minting records recipient ownership wallet and payer wallet.
4. Contract events are sufficient for backend reconciliation. 4. Contract events are sufficient for backend reconciliation.

View File

@ -3,8 +3,7 @@
## Core Contracts ## Core Contracts
1. Membership contract (soulbound utility access). 1. Membership contract (soulbound utility access).
2. Offer registry contract. 2. Offer + entitlement settlement contract (`EdutOfferEntitlement`).
3. Entitlement contract.
## Required Events ## Required Events

View File

@ -9,6 +9,10 @@
"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": {

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

View File

@ -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 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) {
@ -116,10 +119,34 @@ 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: txReq.value, value: 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);
@ -129,19 +156,36 @@ async function main() {
await tx.wait(1); await tx.wait(1);
console.log("mined_tx:", tx.hash); console.log("mined_tx:", tx.hash);
const confirm = await postJSON("/secret/membership/confirm", { const confirmPayload = {
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(
@ -152,6 +196,7 @@ 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

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

View File

@ -78,4 +78,44 @@ 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"
);
});
}); });

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