diff --git a/README.md b/README.md index 9b505bf..3027df0 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,13 @@ Use a Hardhat-supported Node runtime (`20.x` recommended). 1. `npm install` 2. `npm run build` 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` -8. `npm run update:membership:price:sepolia` -9. `npm run update:membership:price:mainnet` +4. `npm run check:addresses` +5. `npm run deploy:sepolia` +6. `npm run deploy:mainnet` +7. `npm run deploy:entitlement:sepolia` +8. `npm run deploy:entitlement:mainnet` +9. `npm run update:membership:price:sepolia` +10. `npm run update:membership:price:mainnet` `make check` wraps build + tests. @@ -107,6 +108,15 @@ export $(grep -v '^#' .env | xargs) npm run smoke:e2e:controlplane:sepolia ``` +Offer readback verification against deployed entitlement contract: + +```bash +cd /Users/vsg/Documents/VSG\ Codex/contracts +export BASE_SEPOLIA_RPC_URL="https://base-sepolia.g.alchemy.com/v2/" +export ENTITLEMENT_CONTRACT_ADDRESS="0x..." +npm run verify:offers:sepolia +``` + ## Boundary Contracts are settlement primitives. Runtime execution remains off-chain and fail-closed by entitlement state. diff --git a/deploy/README.md b/deploy/README.md index e61beb5..9573da3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -28,3 +28,4 @@ Address parity rule: 1. Keep `runtime-addresses.base-sepolia.json` synchronized with `/Users/vsg/Documents/VSG Codex/web/docs/deployment/contract-addresses.base-sepolia.json`. 2. Any runtime address change must update both repos in the same change set. +3. Run `npm run check:addresses` after edits to verify deploy artifact parity invariants. diff --git a/package.json b/package.json index aed9d79..cf29bc0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "hardhat compile", "test": "hardhat test", - "check": "npm run build && npm run test", + "check": "npm run build && npm run test && npm run check:addresses", + "check:addresses": "node scripts/validate-runtime-addresses.cjs", "deploy:sepolia": "hardhat run scripts/deploy-membership.cjs --network baseSepolia", "deploy:mainnet": "hardhat run scripts/deploy-membership.cjs --network base", "update:membership:price:sepolia": "hardhat run scripts/update-membership-price.cjs --network baseSepolia", @@ -14,7 +15,8 @@ "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:controlplane:sepolia": "node scripts/e2e-control-plane-flow.cjs" + "smoke:e2e:controlplane:sepolia": "node scripts/e2e-control-plane-flow.cjs", + "verify:offers:sepolia": "node scripts/verify-offer-config-readback.cjs" }, "devDependencies": { "@nomiclabs/hardhat-ethers": "^2.2.3", diff --git a/scripts/validate-runtime-addresses.cjs b/scripts/validate-runtime-addresses.cjs new file mode 100644 index 0000000..52539cd --- /dev/null +++ b/scripts/validate-runtime-addresses.cjs @@ -0,0 +1,117 @@ +const fs = require("fs"); +const path = require("path"); +const { ethers } = require("ethers"); + +const ROOT = path.resolve(__dirname, ".."); +const runtimePath = path.resolve(ROOT, "deploy/runtime-addresses.base-sepolia.json"); +const membershipPath = path.resolve(ROOT, "deploy/membership-deploy.sepolia.json"); +const entitlementPath = path.resolve(ROOT, "deploy/entitlement-deploy.sepolia.json"); + +function readJSON(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`missing file: ${filePath}`); + } + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function assert(cond, message) { + if (!cond) { + throw new Error(message); + } +} + +function normalizedAddress(value) { + return String(value || "").trim().toLowerCase(); +} + +function requireAddress(label, value, { allowZero = false } = {}) { + assert(ethers.utils.isAddress(value), `invalid ${label}: ${value}`); + if (!allowZero) { + assert( + normalizedAddress(value) !== ethers.constants.AddressZero.toLowerCase(), + `zero address not allowed for ${label}` + ); + } +} + +function requireUintString(label, value) { + const raw = String(value || "").trim(); + assert(/^[0-9]+$/.test(raw), `invalid uint string for ${label}: ${value}`); +} + +function main() { + const runtime = readJSON(runtimePath); + const membership = readJSON(membershipPath); + const entitlement = readJSON(entitlementPath); + + assert(String(runtime.network) === "base-sepolia", `runtime network mismatch: ${runtime.network}`); + assert(Number(runtime.chain_id) === 84532, `runtime chain_id mismatch: ${runtime.chain_id}`); + requireAddress("runtime.membership_contract", runtime.membership_contract); + requireAddress("runtime.entitlement_contract", runtime.entitlement_contract); + requireAddress("runtime.offer_registry_contract", runtime.offer_registry_contract); + requireAddress("runtime.treasury_wallet", runtime.treasury_wallet); + assert( + normalizedAddress(runtime.entitlement_contract) === normalizedAddress(runtime.offer_registry_contract), + "runtime entitlement_contract must equal offer_registry_contract" + ); + assert( + runtime.mint_currency_mode === "ETH_TEST" || runtime.mint_currency_mode === "USDC", + `runtime mint_currency_mode invalid: ${runtime.mint_currency_mode}` + ); + requireUintString("runtime.mint_amount_atomic", runtime.mint_amount_atomic); + + assert(Number(membership.chainId) === 84532, `membership chainId mismatch: ${membership.chainId}`); + requireAddress("membership.membershipContract", membership.membershipContract); + requireAddress("membership.treasury", membership.treasury); + requireUintString("membership.mintAmountAtomic", membership.mintAmountAtomic); + + assert(Number(entitlement.chainId) === 84532, `entitlement chainId mismatch: ${entitlement.chainId}`); + requireAddress("entitlement.entitlementContract", entitlement.entitlementContract); + requireAddress("entitlement.membershipContract", entitlement.membershipContract); + requireAddress("entitlement.treasury", entitlement.treasury); + requireUintString("entitlement.offerPriceAtomic", entitlement.offerPriceAtomic); + + assert( + normalizedAddress(runtime.membership_contract) === normalizedAddress(membership.membershipContract), + "runtime membership_contract mismatch with membership deploy artifact" + ); + assert( + normalizedAddress(runtime.membership_contract) === normalizedAddress(entitlement.membershipContract), + "runtime membership_contract mismatch with entitlement deploy artifact" + ); + assert( + normalizedAddress(runtime.entitlement_contract) === normalizedAddress(entitlement.entitlementContract), + "runtime entitlement_contract mismatch with entitlement deploy artifact" + ); + assert( + normalizedAddress(runtime.treasury_wallet) === normalizedAddress(membership.treasury), + "runtime treasury_wallet mismatch with membership deploy artifact" + ); + assert( + normalizedAddress(runtime.treasury_wallet) === normalizedAddress(entitlement.treasury), + "runtime treasury_wallet mismatch with entitlement deploy artifact" + ); + + if (runtime.mint_currency_mode === "ETH_TEST") { + assert( + normalizedAddress(membership.mintCurrency) === ethers.constants.AddressZero.toLowerCase(), + `ETH_TEST mode requires membership mintCurrency zero address, got ${membership.mintCurrency}` + ); + assert( + normalizedAddress(entitlement.paymentToken) === ethers.constants.AddressZero.toLowerCase(), + `ETH_TEST mode requires entitlement paymentToken zero address, got ${entitlement.paymentToken}` + ); + } + + console.log("PASS: runtime address artifacts are structurally valid and internally aligned"); + console.log(` membership_contract=${runtime.membership_contract}`); + console.log(` entitlement_contract=${runtime.entitlement_contract}`); + console.log(` treasury_wallet=${runtime.treasury_wallet}`); +} + +try { + main(); +} catch (err) { + console.error(`FAIL: ${err.message || err}`); + process.exit(1); +} diff --git a/scripts/verify-offer-config-readback.cjs b/scripts/verify-offer-config-readback.cjs new file mode 100644 index 0000000..6def7c1 --- /dev/null +++ b/scripts/verify-offer-config-readback.cjs @@ -0,0 +1,146 @@ +const fs = require("fs"); +const path = require("path"); +const { ethers } = require("ethers"); + +const DEFAULT_OFFERS_PATH = path.resolve(__dirname, "../deploy/offers.template.json"); +const DEFAULT_RUNTIME_PATH = path.resolve(__dirname, "../deploy/runtime-addresses.base-sepolia.json"); +const DEFAULT_ENTITLEMENT_PATH = path.resolve(__dirname, "../deploy/entitlement-deploy.sepolia.json"); + +const OFFER_ABI = [ + "function offerConfig(string offerId) view returns (uint256 priceAtomic, bool active, bool membershipRequired, bool exists)", +]; + +function required(name, value) { + if (!String(value || "").trim()) { + throw new Error(`missing required env: ${name}`); + } + return String(value).trim(); +} + +function readJSON(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function resolveEntitlementAddress() { + const envAddress = String(process.env.ENTITLEMENT_CONTRACT_ADDRESS || "").trim(); + if (envAddress) { + return envAddress; + } + if (fs.existsSync(DEFAULT_RUNTIME_PATH)) { + const runtime = readJSON(DEFAULT_RUNTIME_PATH); + if (runtime.entitlement_contract) { + return String(runtime.entitlement_contract).trim(); + } + } + if (fs.existsSync(DEFAULT_ENTITLEMENT_PATH)) { + const deploy = readJSON(DEFAULT_ENTITLEMENT_PATH); + if (deploy.entitlementContract) { + return String(deploy.entitlementContract).trim(); + } + } + return ""; +} + +function loadExpectedOffers() { + const offersPath = String(process.env.OFFERS_JSON || "").trim() || DEFAULT_OFFERS_PATH; + if (!fs.existsSync(offersPath)) { + throw new Error(`offers file missing: ${offersPath}`); + } + const raw = readJSON(offersPath); + if (!Array.isArray(raw) || raw.length === 0) { + throw new Error("offers json must be a non-empty array"); + } + return raw.map((entry, idx) => { + if (!entry || typeof entry !== "object") { + throw new Error(`offers[${idx}] must be an object`); + } + const offerId = String(entry.offer_id || "").trim(); + if (!offerId) { + throw new Error(`offers[${idx}] missing offer_id`); + } + const priceAtomic = String(entry.price_atomic || "").trim(); + if (!/^[0-9]+$/.test(priceAtomic)) { + throw new Error(`offers[${offerId}] invalid price_atomic`); + } + return { + offerId, + priceAtomic, + active: entry.active === undefined ? true : Boolean(entry.active), + membershipRequired: + entry.membership_required === undefined ? true : Boolean(entry.membership_required), + }; + }); +} + +async function main() { + const rpcURL = required("BASE_SEPOLIA_RPC_URL", process.env.BASE_SEPOLIA_RPC_URL); + const entitlementAddress = resolveEntitlementAddress(); + if (!ethers.utils.isAddress(entitlementAddress)) { + throw new Error(`invalid/missing entitlement contract address: ${entitlementAddress}`); + } + const expectedOffers = loadExpectedOffers(); + + const provider = new ethers.providers.JsonRpcProvider(rpcURL); + const contract = new ethers.Contract(entitlementAddress, OFFER_ABI, provider); + + const mismatches = []; + for (const offer of expectedOffers) { + const cfg = await contract.offerConfig(offer.offerId); + const got = { + exists: Boolean(cfg.exists), + priceAtomic: cfg.priceAtomic.toString(), + active: Boolean(cfg.active), + membershipRequired: Boolean(cfg.membershipRequired), + }; + const expected = { + exists: true, + priceAtomic: offer.priceAtomic, + active: offer.active, + membershipRequired: offer.membershipRequired, + }; + const match = + got.exists === expected.exists && + got.priceAtomic === expected.priceAtomic && + got.active === expected.active && + got.membershipRequired === expected.membershipRequired; + if (!match) { + mismatches.push({ + offer_id: offer.offerId, + expected, + got, + }); + } + } + + if (mismatches.length > 0) { + console.error( + JSON.stringify( + { + status: "FAIL", + entitlement_contract: entitlementAddress, + mismatches, + }, + null, + 2 + ) + ); + process.exit(1); + } + + console.log( + JSON.stringify( + { + status: "PASS", + entitlement_contract: entitlementAddress, + offers_checked: expectedOffers.length, + }, + null, + 2 + ) + ); +} + +main().catch((err) => { + console.error(`FAIL: ${err.message || err}`); + process.exit(1); +});