Add deploy artifact validators and offer readback verification
Some checks are pending
check / contracts (push) Waiting to run
Some checks are pending
check / contracts (push) Waiting to run
This commit is contained in:
parent
4818b2a0a5
commit
60d7cbc5e1
22
README.md
22
README.md
@ -37,12 +37,13 @@ Use a Hardhat-supported Node runtime (`20.x` recommended).
|
|||||||
1. `npm install`
|
1. `npm install`
|
||||||
2. `npm run build`
|
2. `npm run build`
|
||||||
3. `npm run test`
|
3. `npm run test`
|
||||||
4. `npm run deploy:sepolia`
|
4. `npm run check:addresses`
|
||||||
5. `npm run deploy:mainnet`
|
5. `npm run deploy:sepolia`
|
||||||
6. `npm run deploy:entitlement:sepolia`
|
6. `npm run deploy:mainnet`
|
||||||
7. `npm run deploy:entitlement:mainnet`
|
7. `npm run deploy:entitlement:sepolia`
|
||||||
8. `npm run update:membership:price:sepolia`
|
8. `npm run deploy:entitlement:mainnet`
|
||||||
9. `npm run update:membership:price:mainnet`
|
9. `npm run update:membership:price:sepolia`
|
||||||
|
10. `npm run update:membership:price:mainnet`
|
||||||
|
|
||||||
`make check` wraps build + tests.
|
`make check` wraps build + tests.
|
||||||
|
|
||||||
@ -107,6 +108,15 @@ export $(grep -v '^#' .env | xargs)
|
|||||||
npm run smoke:e2e:controlplane:sepolia
|
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
|
## 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.
|
||||||
|
|||||||
@ -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`.
|
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.
|
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.
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "hardhat compile",
|
"build": "hardhat compile",
|
||||||
"test": "hardhat test",
|
"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: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",
|
||||||
"update:membership:price:sepolia": "hardhat run scripts/update-membership-price.cjs --network baseSepolia",
|
"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:sepolia": "hardhat run scripts/deploy-entitlement.cjs --network baseSepolia",
|
||||||
"deploy:entitlement:mainnet": "hardhat run scripts/deploy-entitlement.cjs --network base",
|
"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",
|
||||||
"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": {
|
"devDependencies": {
|
||||||
"@nomiclabs/hardhat-ethers": "^2.2.3",
|
"@nomiclabs/hardhat-ethers": "^2.2.3",
|
||||||
|
|||||||
117
scripts/validate-runtime-addresses.cjs
Normal file
117
scripts/validate-runtime-addresses.cjs
Normal 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);
|
||||||
|
}
|
||||||
146
scripts/verify-offer-config-readback.cjs
Normal file
146
scripts/verify-offer-config-readback.cjs
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user