Add membership contract, deployment scripts, and Base network config

This commit is contained in:
Joshua 2026-02-17 20:48:49 -08:00
parent 082b29b205
commit dbac2f0883
12 changed files with 3847 additions and 1 deletions

10
.env.example Normal file
View 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
View File

@ -0,0 +1,7 @@
.DS_Store
*.log
cache/
out/
artifacts/
node_modules/
.env

10
Makefile Normal file
View File

@ -0,0 +1,10 @@
.PHONY: check build test
check:
@npm run check
build:
@npm run build
test:
@npm run test

View File

@ -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.

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

View File

@ -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.

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

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View 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"
}
}

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

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