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