contracts/scripts/deploy-entitlement.cjs
Joshua 4818b2a0a5
Some checks are pending
check / contracts (push) Waiting to run
Add resilient entitlement seeding and control-plane e2e smoke
2026-02-19 12:45:51 -08:00

299 lines
9.0 KiB
JavaScript

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 envInt(name, fallback) {
const raw = (process.env[name] || "").trim();
if (!raw) {
return fallback;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(`Invalid integer for ${name}: ${raw}`);
}
return parsed;
}
function envBool(name, fallback = false) {
const raw = (process.env[name] || "").trim().toLowerCase();
if (!raw) {
return fallback;
}
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
}
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})`);
}
}
function readOfferOverrides() {
const inlineRaw = (process.env.OFFERS_INLINE_JSON || "").trim();
if (inlineRaw) {
try {
return JSON.parse(inlineRaw);
} catch (err) {
throw new Error(`Invalid OFFERS_INLINE_JSON (${err.message})`);
}
}
const filePath = (process.env.OFFERS_JSON || "").trim();
if (!filePath) {
return null;
}
const absolute = path.resolve(filePath);
const raw = fs.readFileSync(absolute, "utf8");
try {
return JSON.parse(raw);
} catch (err) {
throw new Error(`Invalid OFFERS_JSON at ${absolute} (${err.message})`);
}
}
function buildOfferPlan(defaultPriceAtomic) {
const overrides = readOfferOverrides();
if (!overrides) {
return DEFAULT_OFFERS.map((offerID) => ({
offerID,
priceAtomic: defaultPriceAtomic,
active: true,
membershipRequired: true,
}));
}
if (!Array.isArray(overrides) || overrides.length === 0) {
throw new Error("Offer override list must be a non-empty JSON array");
}
return overrides.map((entry, index) => {
if (!entry || typeof entry !== "object") {
throw new Error(`Offer override at index ${index} must be an object`);
}
const offerID = String(entry.offer_id || "").trim();
if (!offerID) {
throw new Error(`Offer override at index ${index} missing offer_id`);
}
const rawPrice = String(
entry.price_atomic !== undefined ? entry.price_atomic : defaultPriceAtomic.toString()
).trim();
const priceAtomic = parseUint(`offer[${offerID}].price_atomic`, rawPrice);
const active = entry.active !== undefined ? Boolean(entry.active) : true;
const membershipRequired =
entry.membership_required !== undefined ? Boolean(entry.membership_required) : true;
return { offerID, priceAtomic, active, membershipRequired };
});
}
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeOfferConfig(configResult) {
return {
priceAtomic: configResult.priceAtomic.toString(),
active: Boolean(configResult.active),
membershipRequired: Boolean(configResult.membershipRequired),
exists: Boolean(configResult.exists),
};
}
function offerConfigMatches(current, expected) {
return (
current.exists &&
current.priceAtomic === expected.priceAtomic.toString() &&
current.active === expected.active &&
current.membershipRequired === expected.membershipRequired
);
}
async function upsertOfferWithRetry(contract, offer, retries, retryDelayMs) {
let lastError = null;
for (let attempt = 1; attempt <= retries + 1; attempt += 1) {
try {
const tx = await contract.upsertOffer(
offer.offerID,
offer.priceAtomic,
offer.active,
offer.membershipRequired
);
const receipt = await tx.wait();
const cfg = normalizeOfferConfig(await contract.offerConfig(offer.offerID));
if (!offerConfigMatches(cfg, offer)) {
throw new Error(
`post-upsert verification mismatch for ${offer.offerID}: got=${JSON.stringify(cfg)}`
);
}
return {
status: "seeded",
attempts: attempt,
txHash: receipt.transactionHash,
};
} catch (err) {
lastError = err;
if (attempt > retries) {
break;
}
const delay = retryDelayMs * attempt;
console.warn(
`offer_seed_retry: ${offer.offerID} attempt=${attempt} delay_ms=${delay} err=${err.message}`
);
await sleep(delay);
}
}
throw new Error(`failed to seed ${offer.offerID}: ${lastError?.message || "unknown error"}`);
}
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 seedRetries = envInt("SEED_RETRIES", 2);
const seedRetryDelayMs = envInt("SEED_RETRY_DELAY_MS", 1200);
const seedOnly = envBool("SEED_ONLY", false);
const existingEntitlementContract = (process.env.ENTITLEMENT_CONTRACT_ADDRESS || "").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");
let contract;
let deployTxHash = null;
if (seedOnly) {
if (!existingEntitlementContract) {
throw new Error("SEED_ONLY=true requires ENTITLEMENT_CONTRACT_ADDRESS");
}
requireAddress("ENTITLEMENT_CONTRACT_ADDRESS", existingEntitlementContract);
contract = factory.attach(existingEntitlementContract);
console.log("entitlement_contract_attached:", contract.address);
} else {
contract = await factory.deploy(treasury, paymentToken, membershipContract);
await contract.deployed();
deployTxHash = contract.deployTransaction.hash;
console.log("entitlement_contract:", contract.address);
}
const offerPlan = buildOfferPlan(offerPriceAtomic);
const seededOffers = [];
for (const offer of offerPlan) {
const current = normalizeOfferConfig(await contract.offerConfig(offer.offerID));
if (offerConfigMatches(current, offer)) {
seededOffers.push({
offerID: offer.offerID,
priceAtomic: offer.priceAtomic.toString(),
active: offer.active,
membershipRequired: offer.membershipRequired,
status: "already_aligned",
attempts: 0,
});
console.log(
"offer_already_aligned:",
offer.offerID,
offer.priceAtomic.toString(),
offer.active,
offer.membershipRequired
);
continue;
}
const seeded = await upsertOfferWithRetry(contract, offer, seedRetries, seedRetryDelayMs);
seededOffers.push({
offerID: offer.offerID,
priceAtomic: offer.priceAtomic.toString(),
active: offer.active,
membershipRequired: offer.membershipRequired,
status: seeded.status,
attempts: seeded.attempts,
txHash: seeded.txHash,
});
console.log(
"offer_seeded:",
offer.offerID,
offer.priceAtomic.toString(),
offer.active,
offer.membershipRequired,
seeded.txHash,
`attempts=${seeded.attempts}`
);
}
const output = {
network: hre.network.name,
chainId: hre.network.config.chainId || null,
deployer: deployer.address,
treasury,
paymentToken,
membershipContract,
offerPriceAtomic: offerPriceAtomic.toString(),
defaultOfferPriceAtomic: offerPriceAtomic.toString(),
entitlementContract: contract.address,
txHash: deployTxHash,
seedOnly,
seedRetries,
seedRetryDelayMs,
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;
});