Add membership-gated offer entitlement contract and deploy script

This commit is contained in:
Joshua 2026-02-18 13:25:09 -08:00
parent 4f7977c3fd
commit 6d9130c9db
12 changed files with 555 additions and 13 deletions

View File

@ -10,3 +10,12 @@ 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
@ -21,6 +20,16 @@ Features:
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,8 @@ 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`
`make check` wraps build + tests. `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) 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)
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();
}
}

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,11 @@ Place versioned deployment manifests here:
Template: Template:
- `membership-deploy.template.json` - `membership-deploy.template.json`
- `entitlement-deploy.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. Fill all deployment fields from script output and explorer links. 3. Fill all deployment fields from script output and explorer links.

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

@ -2,6 +2,7 @@
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.

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,8 @@
"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",
"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,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;
});

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