Add membership contract, deployment scripts, and Base network config
This commit is contained in:
parent
082b29b205
commit
dbac2f0883
10
.env.example
Normal file
10
.env.example
Normal file
@ -0,0 +1,10 @@
|
||||
DEPLOYER_PRIVATE_KEY=0x...
|
||||
BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
|
||||
BASE_MAINNET_RPC_URL=https://mainnet.base.org
|
||||
|
||||
TREASURY_WALLET=0x0000000000000000000000000000000000000000
|
||||
|
||||
# Use zero address for native ETH pricing.
|
||||
MINT_CURRENCY_ADDRESS=0x0000000000000000000000000000000000000000
|
||||
MINT_AMOUNT_ATOMIC=5000000000000000
|
||||
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
cache/
|
||||
out/
|
||||
artifacts/
|
||||
node_modules/
|
||||
.env
|
||||
10
Makefile
Normal file
10
Makefile
Normal file
@ -0,0 +1,10 @@
|
||||
.PHONY: check build test
|
||||
|
||||
check:
|
||||
@npm run check
|
||||
|
||||
build:
|
||||
@npm run build
|
||||
|
||||
test:
|
||||
@npm run test
|
||||
42
README.md
42
README.md
@ -4,11 +4,51 @@ On-chain contracts and deployment artifacts for membership and entitlements.
|
||||
|
||||
## Scope
|
||||
|
||||
1. Membership contract (soulbound utility access).
|
||||
1. Human membership contract (soulbound governance identity).
|
||||
2. Offer registry contract interfaces.
|
||||
3. Entitlement contract interfaces.
|
||||
4. ABI and deployment artifact publication.
|
||||
|
||||
## Current Contract
|
||||
|
||||
`contracts/EdutHumanMembership.sol`
|
||||
|
||||
Features:
|
||||
|
||||
1. One-time soulbound human token mint.
|
||||
2. Sponsor mint support (`mintMembership(recipient)` can be paid by inviter/company wallet).
|
||||
3. Owner-configurable flat mint price (`updateMintPrice`), launch default can stay flat `$5` equivalent.
|
||||
4. Membership status lifecycle (`ACTIVE/SUSPENDED/REVOKED`) for runtime gates.
|
||||
5. Treasury address control for settlement routing.
|
||||
|
||||
## Local Commands
|
||||
|
||||
1. `npm install`
|
||||
2. `npm run build`
|
||||
3. `npm run test`
|
||||
4. `npm run deploy:sepolia`
|
||||
5. `npm run deploy:mainnet`
|
||||
|
||||
`make check` wraps build + tests.
|
||||
|
||||
## Deployment Environment
|
||||
|
||||
Copy `.env.example` values into your shell/session before deploy:
|
||||
|
||||
1. `DEPLOYER_PRIVATE_KEY`
|
||||
2. `BASE_SEPOLIA_RPC_URL` / `BASE_MAINNET_RPC_URL`
|
||||
3. `TREASURY_WALLET`
|
||||
4. `MINT_CURRENCY_ADDRESS` (`0x000...000` for ETH)
|
||||
5. `MINT_AMOUNT_ATOMIC`
|
||||
|
||||
Example (Sepolia):
|
||||
|
||||
```bash
|
||||
cd /Users/vsg/Documents/VSG\ Codex/contracts
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
npm run deploy:sepolia
|
||||
```
|
||||
|
||||
## Boundary
|
||||
|
||||
Contracts are settlement primitives. Runtime execution remains off-chain and fail-closed by entitlement state.
|
||||
|
||||
185
contracts/EdutHumanMembership.sol
Normal file
185
contracts/EdutHumanMembership.sol
Normal file
@ -0,0 +1,185 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.24;
|
||||
|
||||
interface IERC20 {
|
||||
function transferFrom(address from, address to, uint256 value) external returns (bool);
|
||||
}
|
||||
|
||||
/// @title EDUT Human Membership (Soulbound)
|
||||
/// @notice One-time human governance token with configurable mint price and sponsor mint support.
|
||||
contract EdutHumanMembership {
|
||||
string public constant name = "EDUT Human Membership";
|
||||
string public constant symbol = "EDUTHUM";
|
||||
|
||||
uint8 public constant MEMBERSHIP_NONE = 0;
|
||||
uint8 public constant MEMBERSHIP_ACTIVE = 1;
|
||||
uint8 public constant MEMBERSHIP_SUSPENDED = 2;
|
||||
uint8 public constant MEMBERSHIP_REVOKED = 3;
|
||||
|
||||
error NotOwner();
|
||||
error InvalidAddress();
|
||||
error AlreadyMinted();
|
||||
error UnknownToken();
|
||||
error InvalidStatus();
|
||||
error SoulboundNonTransferable();
|
||||
error IncorrectEthValue();
|
||||
error UnexpectedEthValue();
|
||||
error PaymentTransferFailed();
|
||||
|
||||
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
|
||||
event MembershipMinted(address indexed wallet, uint256 indexed tokenId, uint256 amountPaid, address currency);
|
||||
event MintPriceUpdated(address indexed currency, uint256 amountAtomic);
|
||||
event MembershipStatusUpdated(address indexed wallet, uint8 status);
|
||||
event TreasuryUpdated(address indexed treasury);
|
||||
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
|
||||
|
||||
address public owner;
|
||||
address public treasury;
|
||||
|
||||
address private _mintCurrency;
|
||||
uint256 private _mintAmountAtomic;
|
||||
uint256 private _nextTokenId = 1;
|
||||
|
||||
mapping(uint256 => address) private _tokenOwner;
|
||||
mapping(address => uint256) private _walletToken;
|
||||
mapping(address => uint256) private _walletBalance;
|
||||
mapping(address => uint8) private _membershipStatus;
|
||||
|
||||
modifier onlyOwner() {
|
||||
if (msg.sender != owner) revert NotOwner();
|
||||
_;
|
||||
}
|
||||
|
||||
constructor(address treasury_, address currency_, uint256 amountAtomic_) {
|
||||
if (treasury_ == address(0)) revert InvalidAddress();
|
||||
owner = msg.sender;
|
||||
treasury = treasury_;
|
||||
_mintCurrency = currency_;
|
||||
_mintAmountAtomic = amountAtomic_;
|
||||
emit OwnershipTransferred(address(0), msg.sender);
|
||||
emit TreasuryUpdated(treasury_);
|
||||
emit MintPriceUpdated(currency_, amountAtomic_);
|
||||
}
|
||||
|
||||
function balanceOf(address wallet) external view returns (uint256) {
|
||||
if (wallet == address(0)) revert InvalidAddress();
|
||||
return _walletBalance[wallet];
|
||||
}
|
||||
|
||||
function ownerOf(uint256 tokenId) public view returns (address) {
|
||||
address tokenOwner = _tokenOwner[tokenId];
|
||||
if (tokenOwner == address(0)) revert UnknownToken();
|
||||
return tokenOwner;
|
||||
}
|
||||
|
||||
function tokenOf(address wallet) external view returns (uint256 tokenId, uint8 status) {
|
||||
return (_walletToken[wallet], _membershipStatus[wallet]);
|
||||
}
|
||||
|
||||
function hasMembership(address wallet) external view returns (bool) {
|
||||
return _membershipStatus[wallet] == MEMBERSHIP_ACTIVE;
|
||||
}
|
||||
|
||||
function membershipStatus(address wallet) external view returns (uint8 status) {
|
||||
return _membershipStatus[wallet];
|
||||
}
|
||||
|
||||
function currentMintPrice() external view returns (address currency, uint256 amountAtomic) {
|
||||
return (_mintCurrency, _mintAmountAtomic);
|
||||
}
|
||||
|
||||
/// @notice Mint one human membership token to recipient. Caller may sponsor payment.
|
||||
function mintMembership(address recipient) external payable returns (uint256 tokenId) {
|
||||
if (recipient == address(0)) revert InvalidAddress();
|
||||
if (_walletToken[recipient] != 0) revert AlreadyMinted();
|
||||
|
||||
_collectMintPayment(msg.sender);
|
||||
|
||||
tokenId = _nextTokenId;
|
||||
_nextTokenId += 1;
|
||||
|
||||
_walletToken[recipient] = tokenId;
|
||||
_tokenOwner[tokenId] = recipient;
|
||||
_walletBalance[recipient] = 1;
|
||||
_membershipStatus[recipient] = MEMBERSHIP_ACTIVE;
|
||||
|
||||
emit Transfer(address(0), recipient, tokenId);
|
||||
emit MembershipMinted(recipient, tokenId, _mintAmountAtomic, _mintCurrency);
|
||||
emit MembershipStatusUpdated(recipient, MEMBERSHIP_ACTIVE);
|
||||
}
|
||||
|
||||
function updateMintPrice(address currency, uint256 amountAtomic) external onlyOwner {
|
||||
_mintCurrency = currency;
|
||||
_mintAmountAtomic = amountAtomic;
|
||||
emit MintPriceUpdated(currency, amountAtomic);
|
||||
}
|
||||
|
||||
function setTreasury(address nextTreasury) external onlyOwner {
|
||||
if (nextTreasury == address(0)) revert InvalidAddress();
|
||||
treasury = nextTreasury;
|
||||
emit TreasuryUpdated(nextTreasury);
|
||||
}
|
||||
|
||||
function setMembershipStatus(address wallet, uint8 status) external onlyOwner {
|
||||
uint256 tokenId = _walletToken[wallet];
|
||||
if (tokenId == 0) revert UnknownToken();
|
||||
if (status > MEMBERSHIP_REVOKED) revert InvalidStatus();
|
||||
_membershipStatus[wallet] = status;
|
||||
emit MembershipStatusUpdated(wallet, status);
|
||||
}
|
||||
|
||||
function transferOwnership(address nextOwner) external onlyOwner {
|
||||
if (nextOwner == address(0)) revert InvalidAddress();
|
||||
address previous = owner;
|
||||
owner = nextOwner;
|
||||
emit OwnershipTransferred(previous, nextOwner);
|
||||
}
|
||||
|
||||
// Soulbound: all transfer/approval paths are disabled.
|
||||
function approve(address, uint256) external pure {
|
||||
revert SoulboundNonTransferable();
|
||||
}
|
||||
|
||||
function setApprovalForAll(address, bool) external pure {
|
||||
revert SoulboundNonTransferable();
|
||||
}
|
||||
|
||||
function getApproved(uint256) external pure returns (address) {
|
||||
return address(0);
|
||||
}
|
||||
|
||||
function isApprovedForAll(address, address) external pure returns (bool) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function transferFrom(address, address, uint256) external pure {
|
||||
revert SoulboundNonTransferable();
|
||||
}
|
||||
|
||||
function safeTransferFrom(address, address, uint256) external pure {
|
||||
revert SoulboundNonTransferable();
|
||||
}
|
||||
|
||||
function safeTransferFrom(address, address, uint256, bytes calldata) external pure {
|
||||
revert SoulboundNonTransferable();
|
||||
}
|
||||
|
||||
function _collectMintPayment(address payer) private {
|
||||
uint256 amount = _mintAmountAtomic;
|
||||
if (amount == 0) {
|
||||
if (msg.value != 0) revert UnexpectedEthValue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_mintCurrency == address(0)) {
|
||||
if (msg.value != amount) revert IncorrectEthValue();
|
||||
(bool ok, ) = treasury.call{value: amount}("");
|
||||
if (!ok) revert PaymentTransferFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.value != 0) revert UnexpectedEthValue();
|
||||
bool transferred = IERC20(_mintCurrency).transferFrom(payer, treasury, amount);
|
||||
if (!transferred) revert PaymentTransferFailed();
|
||||
}
|
||||
}
|
||||
@ -7,3 +7,13 @@ Place versioned deployment manifests here:
|
||||
3. deployment tx hashes
|
||||
4. verifier links
|
||||
5. policy hash snapshot
|
||||
|
||||
Template:
|
||||
|
||||
- `membership-deploy.template.json`
|
||||
|
||||
Recommended process:
|
||||
|
||||
1. Run `npm run deploy:sepolia` or `npm run deploy:mainnet`.
|
||||
2. Copy template to a dated file (for example `membership-base-sepolia-2026-02-18.json`).
|
||||
3. Fill all deployment fields from script output and explorer links.
|
||||
|
||||
13
deploy/membership-deploy.template.json
Normal file
13
deploy/membership-deploy.template.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"network": "base-sepolia",
|
||||
"chain_id": 84532,
|
||||
"deployer": "0x0000000000000000000000000000000000000000",
|
||||
"treasury_wallet": "0x0000000000000000000000000000000000000000",
|
||||
"mint_currency_address": "0x0000000000000000000000000000000000000000",
|
||||
"mint_amount_atomic": "5000000000000000",
|
||||
"membership_contract": "0x0000000000000000000000000000000000000000",
|
||||
"deployment_tx_hash": "0x",
|
||||
"deployed_at": "2026-02-18T00:00:00Z",
|
||||
"notes": "Fill from deployment output and keep under change control."
|
||||
}
|
||||
|
||||
29
hardhat.config.cjs
Normal file
29
hardhat.config.cjs
Normal file
@ -0,0 +1,29 @@
|
||||
require("@nomiclabs/hardhat-ethers");
|
||||
|
||||
const accounts = process.env.DEPLOYER_PRIVATE_KEY
|
||||
? [process.env.DEPLOYER_PRIVATE_KEY]
|
||||
: [];
|
||||
|
||||
module.exports = {
|
||||
solidity: {
|
||||
version: "0.8.24",
|
||||
settings: {
|
||||
optimizer: {
|
||||
enabled: true,
|
||||
runs: 200
|
||||
}
|
||||
}
|
||||
},
|
||||
networks: {
|
||||
baseSepolia: {
|
||||
url: process.env.BASE_SEPOLIA_RPC_URL || "",
|
||||
chainId: 84532,
|
||||
accounts
|
||||
},
|
||||
base: {
|
||||
url: process.env.BASE_MAINNET_RPC_URL || "",
|
||||
chainId: 8453,
|
||||
accounts
|
||||
}
|
||||
}
|
||||
};
|
||||
3408
package-lock.json
generated
Normal file
3408
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@edut/contracts",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "EDUT membership and entitlement contracts",
|
||||
"scripts": {
|
||||
"build": "hardhat compile",
|
||||
"test": "hardhat test",
|
||||
"check": "npm run build && npm run test",
|
||||
"deploy:sepolia": "hardhat run scripts/deploy-membership.cjs --network baseSepolia",
|
||||
"deploy:mainnet": "hardhat run scripts/deploy-membership.cjs --network base"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nomiclabs/hardhat-ethers": "^2.2.3",
|
||||
"chai": "^4.5.0",
|
||||
"ethers": "^5.7.2",
|
||||
"hardhat": "^2.22.18"
|
||||
}
|
||||
}
|
||||
34
scripts/deploy-membership.cjs
Normal file
34
scripts/deploy-membership.cjs
Normal file
@ -0,0 +1,34 @@
|
||||
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();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const treasury = requiredEnv("TREASURY_WALLET");
|
||||
const mintCurrency = (process.env.MINT_CURRENCY_ADDRESS || hre.ethers.constants.AddressZero).trim();
|
||||
const mintAmountAtomic = requiredEnv("MINT_AMOUNT_ATOMIC");
|
||||
|
||||
const [deployer] = await hre.ethers.getSigners();
|
||||
console.log("deployer:", deployer.address);
|
||||
console.log("network:", hre.network.name);
|
||||
console.log("treasury:", treasury);
|
||||
console.log("mint_currency:", mintCurrency);
|
||||
console.log("mint_amount_atomic:", mintAmountAtomic);
|
||||
|
||||
const factory = await hre.ethers.getContractFactory("EdutHumanMembership");
|
||||
const contract = await factory.deploy(treasury, mintCurrency, mintAmountAtomic);
|
||||
await contract.deployed();
|
||||
|
||||
console.log("membership_contract:", contract.address);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
81
test/EdutHumanMembership.test.js
Normal file
81
test/EdutHumanMembership.test.js
Normal file
@ -0,0 +1,81 @@
|
||||
const { expect } = require("chai");
|
||||
const { ethers } = require("hardhat");
|
||||
|
||||
describe("EdutHumanMembership", function () {
|
||||
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, sponsor, recipient, other] = await ethers.getSigners();
|
||||
const factory = await ethers.getContractFactory("EdutHumanMembership", deployer);
|
||||
const price = ethers.utils.parseEther("0.002");
|
||||
const contract = await factory.deploy(treasury.address, ethers.constants.AddressZero, price);
|
||||
await contract.deployed();
|
||||
return { contract, deployer, treasury, sponsor, recipient, other, price };
|
||||
}
|
||||
|
||||
it("mints to recipient with sponsor payment", async function () {
|
||||
const { contract, treasury, sponsor, recipient, price } = await deployFixture();
|
||||
const before = await ethers.provider.getBalance(treasury.address);
|
||||
|
||||
const tx = await contract.connect(sponsor).mintMembership(recipient.address, { value: price });
|
||||
await tx.wait();
|
||||
|
||||
const after = await ethers.provider.getBalance(treasury.address);
|
||||
expect(after.sub(before).eq(price)).to.equal(true);
|
||||
expect(await contract.ownerOf(1)).to.equal(recipient.address);
|
||||
|
||||
const token = await contract.tokenOf(recipient.address);
|
||||
expect(token.tokenId.toNumber()).to.equal(1);
|
||||
expect(token.status).to.equal(1);
|
||||
expect(await contract.hasMembership(recipient.address)).to.equal(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate mint for same human wallet", async function () {
|
||||
const { contract, sponsor, recipient, price } = await deployFixture();
|
||||
await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait();
|
||||
|
||||
await expectRevertWithCustomError(
|
||||
contract.connect(sponsor).mintMembership(recipient.address, { value: price }),
|
||||
"AlreadyMinted"
|
||||
);
|
||||
});
|
||||
|
||||
it("supports owner-configurable price and owner-only admin", async function () {
|
||||
const { contract, deployer, other } = await deployFixture();
|
||||
const nextPrice = ethers.utils.parseEther("0.003");
|
||||
|
||||
await expectRevertWithCustomError(
|
||||
contract.connect(other).updateMintPrice(ethers.constants.AddressZero, nextPrice),
|
||||
"NotOwner"
|
||||
);
|
||||
|
||||
await (await contract.connect(deployer).updateMintPrice(ethers.constants.AddressZero, nextPrice)).wait();
|
||||
const price = await contract.currentMintPrice();
|
||||
expect(price.amountAtomic.eq(nextPrice)).to.equal(true);
|
||||
});
|
||||
|
||||
it("blocks transfer paths (soulbound)", async function () {
|
||||
const { contract, sponsor, recipient, other, price } = await deployFixture();
|
||||
await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait();
|
||||
|
||||
await expectRevertWithCustomError(
|
||||
contract.connect(recipient).transferFrom(recipient.address, other.address, 1),
|
||||
"SoulboundNonTransferable"
|
||||
);
|
||||
});
|
||||
|
||||
it("allows owner to update status", async function () {
|
||||
const { contract, deployer, sponsor, recipient, price } = await deployFixture();
|
||||
await (await contract.connect(sponsor).mintMembership(recipient.address, { value: price })).wait();
|
||||
await (await contract.connect(deployer).setMembershipStatus(recipient.address, 2)).wait();
|
||||
|
||||
expect(await contract.membershipStatus(recipient.address)).to.equal(2);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user