299 lines
9.0 KiB
JavaScript
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;
|
|
});
|