From 6d9130c9dbe194d0f0f61bf0491292b67a6590b3 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 18 Feb 2026 13:25:09 -0800 Subject: [PATCH] Add membership-gated offer entitlement contract and deploy script --- .env.example | 9 + README.md | 22 ++- contracts/EdutOfferEntitlement.sol | 237 ++++++++++++++++++++++++ contracts/MockMembershipStatus.sol | 14 ++ deploy/README.md | 6 +- deploy/entitlement-deploy.template.json | 18 ++ docs/conformance-vectors.md | 7 +- docs/contract-roadmap.md | 5 +- docs/interfaces.md | 3 +- package.json | 2 + scripts/deploy-entitlement.cjs | 108 +++++++++++ test/EdutOfferEntitlement.test.js | 137 ++++++++++++++ 12 files changed, 555 insertions(+), 13 deletions(-) create mode 100644 contracts/EdutOfferEntitlement.sol create mode 100644 contracts/MockMembershipStatus.sol create mode 100644 deploy/entitlement-deploy.template.json create mode 100644 scripts/deploy-entitlement.cjs create mode 100644 test/EdutOfferEntitlement.test.js diff --git a/.env.example b/.env.example index bcbe8d6..6404b23 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,12 @@ MINT_AMOUNT_ATOMIC=100000000 # Optional: write deployment metadata JSON (address, tx hash, params). # 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 diff --git a/README.md b/README.md index bf1311f..d0b2fad 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,8 @@ On-chain contracts and deployment artifacts for membership and entitlements. ## Scope 1. Human membership contract (soulbound governance identity). -2. Offer registry contract interfaces. -3. Entitlement contract interfaces. -4. ABI and deployment artifact publication. +2. Offer + entitlement settlement contract for fixed-SKU purchases. +3. ABI and deployment artifact publication. ## Current Contract @@ -21,6 +20,16 @@ Features: 4. Membership status lifecycle (`ACTIVE/SUSPENDED/REVOKED`) for runtime gates. 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 Use a Hardhat-supported Node runtime (`20.x` recommended). @@ -30,6 +39,8 @@ Use a Hardhat-supported Node runtime (`20.x` recommended). 3. `npm run test` 4. `npm run deploy:sepolia` 5. `npm run deploy:mainnet` +6. `npm run deploy:entitlement:sepolia` +7. `npm run deploy:entitlement:mainnet` `make check` wraps build + tests. @@ -43,6 +54,11 @@ Copy `.env.example` values into your shell/session before deploy: 4. `MINT_CURRENCY_ADDRESS` (USDC token contract on target chain) 5. `MINT_AMOUNT_ATOMIC` 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) Example (Sepolia): diff --git a/contracts/EdutOfferEntitlement.sol b/contracts/EdutOfferEntitlement.sol new file mode 100644 index 0000000..ceb9db1 --- /dev/null +++ b/contracts/EdutOfferEntitlement.sol @@ -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(); + } +} diff --git a/contracts/MockMembershipStatus.sol b/contracts/MockMembershipStatus.sol new file mode 100644 index 0000000..9abf101 --- /dev/null +++ b/contracts/MockMembershipStatus.sol @@ -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]; + } +} diff --git a/deploy/README.md b/deploy/README.md index 5253d2e..37cc907 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -11,9 +11,11 @@ Place versioned deployment manifests here: Template: - `membership-deploy.template.json` +- `entitlement-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`). +1. Run `npm run deploy:sepolia` / `npm run deploy:mainnet` for membership or + `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. Fill all deployment fields from script output and explorer links. diff --git a/deploy/entitlement-deploy.template.json b/deploy/entitlement-deploy.template.json new file mode 100644 index 0000000..848d5cc --- /dev/null +++ b/deploy/entitlement-deploy.template.json @@ -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" } + ] +} diff --git a/docs/conformance-vectors.md b/docs/conformance-vectors.md index a1ad9bf..c2fe086 100644 --- a/docs/conformance-vectors.md +++ b/docs/conformance-vectors.md @@ -2,6 +2,7 @@ 1. `C-001` Membership mint creates non-transferable token. 2. `C-002` Membership status transitions emit corresponding events. -3. `C-003` Entitlement mint emits deterministic entitlement id/event payload. -4. `C-004` Entitlement state transitions cannot bypass allowed state machine. -5. `C-005` Contract deployment artifacts map to verified on-chain bytecode. +3. `C-003` Entitlement purchase enforces membership requirement when offer policy requires it. +4. `C-004` Entitlement mint emits deterministic entitlement id/event payload. +5. `C-005` Entitlement state transitions cannot bypass allowed state machine. +6. `C-006` Contract deployment artifacts map to verified on-chain bytecode. diff --git a/docs/contract-roadmap.md b/docs/contract-roadmap.md index 7d23b00..010e2bf 100644 --- a/docs/contract-roadmap.md +++ b/docs/contract-roadmap.md @@ -3,12 +3,11 @@ ## Scope 1. Membership contract (soulbound). -2. Offer registry contract. -3. Entitlement contract. +2. Offer + entitlement settlement contract. ## Requirements 1. One ownership wallet can hold membership + multiple entitlements. 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. diff --git a/docs/interfaces.md b/docs/interfaces.md index ead2b0c..6f208ba 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -3,8 +3,7 @@ ## Core Contracts 1. Membership contract (soulbound utility access). -2. Offer registry contract. -3. Entitlement contract. +2. Offer + entitlement settlement contract (`EdutOfferEntitlement`). ## Required Events diff --git a/package.json b/package.json index 8cde4af..0b9916c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "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", + "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" }, "devDependencies": { diff --git a/scripts/deploy-entitlement.cjs b/scripts/deploy-entitlement.cjs new file mode 100644 index 0000000..9b9d58a --- /dev/null +++ b/scripts/deploy-entitlement.cjs @@ -0,0 +1,108 @@ +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})`); + } +} + +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 seededOffers = []; + for (const offerID of DEFAULT_OFFERS) { + const tx = await contract.upsertOffer(offerID, offerPriceAtomic, true, true); + const receipt = await tx.wait(); + seededOffers.push({ offerID, txHash: receipt.transactionHash }); + console.log("offer_seeded:", offerID, receipt.transactionHash); + } + + const output = { + network: hre.network.name, + chainId: hre.network.config.chainId || null, + deployer: deployer.address, + treasury, + paymentToken, + membershipContract, + offerPriceAtomic: 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; +}); diff --git a/test/EdutOfferEntitlement.test.js b/test/EdutOfferEntitlement.test.js new file mode 100644 index 0000000..890e2a1 --- /dev/null +++ b/test/EdutOfferEntitlement.test.js @@ -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); + }); +});