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 ## Scope
1. Membership contract (soulbound utility access). 1. Human membership contract (soulbound governance identity).
2. Offer registry contract interfaces. 2. Offer registry contract interfaces.
3. Entitlement contract interfaces. 3. Entitlement contract interfaces.
4. ABI and deployment artifact publication. 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 ## Boundary
Contracts are settlement primitives. Runtime execution remains off-chain and fail-closed by entitlement state. 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 3. deployment tx hashes
4. verifier links 4. verifier links
5. policy hash snapshot 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);
});
});