Add deploy artifact validators and offer readback verification
Some checks are pending
check / contracts (push) Waiting to run

This commit is contained in:
Joshua 2026-02-19 12:59:08 -08:00
parent 4818b2a0a5
commit 60d7cbc5e1
5 changed files with 284 additions and 8 deletions

View File

@ -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/<key>"
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.

View File

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

View File

@ -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",

View File

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

View File

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