From c4baf1d2924f4e6864b2b8b7ab91a235995afeec Mon Sep 17 00:00:00 2001 From: Edy Cu Date: Wed, 9 Sep 2026 22:52:27 +0700 Subject: [PATCH 1/2] feat(ops): re-term and restart the demo in the order that works The demo subscription cannot survive a judging window at the shipped terms. A renewal that re-arms costs ~1.55 HBAR of gas to collect 1 HBAR, so at a 90-second period the reserve drains in minutes -- measured at 3 renewals remaining, which is 4.5 minutes of life. Covering 72 hours at 90s would need ~4,464 HBAR. A longer period is the only affordable fix: 72 renewals an hour apart instead of 2,880 at ninety seconds. This script does the three calls that get there. Order is load-bearing and the script enforces it. setTerms snapshots into each Subscription at subscribe() time, so a subscription opened before the re-term keeps the old period for its whole life -- restart first and you have re-armed the same trap. The non-obvious half: raising the price does not fix the drain. The three pots are separate. A subscription payment lands in refundable/revenue, while scheduled calls are paid out of gasReserve, which only fundGasReserve() and syncReserve() feed. Topping up the reserve is the actual fix; the longer period is what makes the top-up last. Refuses to run if SELLER_PRIVATE_KEY is not the immutable beneficiary, or if the requested period is under MIN_PERIOD_SECONDS, and DRY_RUN=1 prints the arithmetic without sending. It warns when the funded reserve does not reach the judging window. Dry-run against the live contract reads: 1 HBAR per 90s, reserve arms 3 = 4.5 minutes; 3600s with +120 HBAR = ~75 hours. --- packages/hardhat/scripts/retermAndRestart.ts | 141 +++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 packages/hardhat/scripts/retermAndRestart.ts diff --git a/packages/hardhat/scripts/retermAndRestart.ts b/packages/hardhat/scripts/retermAndRestart.ts new file mode 100644 index 0000000..9fd9a7e --- /dev/null +++ b/packages/hardhat/scripts/retermAndRestart.ts @@ -0,0 +1,141 @@ +/** + * Re-terms the live contract and restarts the demo subscription, in the one order that works. + * + * The demo dies because the economics are upside down at the default terms: a renewal that + * re-arms costs ~1.55 HBAR of gas to collect 1 HBAR, so at a 90s period the gas reserve drains + * in minutes. Covering a multi-day judging window at 90s would need thousands of HBAR. Making + * the period longer is the only affordable fix — 72 renewals an hour apart instead of 2,880 at + * 90 seconds. + * + * ORDER IS LOAD-BEARING. `setTerms` snapshots into each Subscription at subscribe() time, so a + * subscription opened before the re-term keeps the OLD period for its whole life. Re-term first, + * subscribe second. This script refuses to run them the wrong way round. + * + * Raising the price does NOT fix the drain. The three pots are separate: a subscription payment + * lands in refundable/revenue, while scheduled calls are paid out of gasReserve, which is only + * fed by fundGasReserve() or syncReserve(). Topping the reserve up is the actual fix; the longer + * period is what makes the top-up last. + * + * yarn hardhat run scripts/retermAndRestart.ts --network hederaTestnet + * + * Env (all optional, shown with defaults): + * PERIOD_SECONDS=3600 new access window. Contract floor is MIN_PERIOD_SECONDS = 61. + * PRICE_HBAR=1 price per period. Unchanged by default. + * CALLS_PER_PERIOD= metered calls per window. Read from the contract if unset. + * RESERVE_HBAR=120 HBAR to add to the gas reserve. + * PERIODS=24 periods to fund the demo subscription for. + * AGENT=0xD14C... subscription to restart. Defaults to the demo agent. + * DRY_RUN=1 print the plan and the arithmetic, send nothing. + * + * SELLER_PRIVATE_KEY signs setTerms (onlyBeneficiary). BUYER_PRIVATE_KEY funds and subscribes. + */ +import { ethers } from "ethers"; +import * as fs from "fs"; +import * as path from "path"; + +const RPC = process.env.HEDERA_RPC_URL || "https://testnet.hashio.io/api"; +const DEMO_AGENT = "0xD14CA86A1483e9b2147a7B86fB74D437d3d2Cc66"; + +// Measured on this deployment, 2026-09-09: a renewal that re-arms costs 1.53-1.60 HBAR, one that +// lapses costs 0.05-0.06. Sizing the reserve off the re-arm cost is the conservative direction. +const REARM_COST_HBAR = 1.6; + +function cred(k: string): string { + const f = path.join(process.env.HOME!, ".config/retainer/hedera.env"); + const m = fs.readFileSync(f, "utf8").match(new RegExp(`^${k}=(.*)$`, "m")); + if (!m) throw new Error(`missing ${k} in ~/.config/retainer/hedera.env`); + return m[1].trim(); +} + +const WEIBAR_PER_TINYBAR = 10n ** 10n; +const hbar = (tinybar: bigint | number) => `${Number(tinybar) / 1e8} HBAR`; +const toTinybar = (h: number) => BigInt(Math.round(h * 1e8)); + +async function main() { + const dryRun = process.env.DRY_RUN === "1"; + const periodSeconds = Number(process.env.PERIOD_SECONDS ?? 3600); + const reserveHbar = Number(process.env.RESERVE_HBAR ?? 120); + const periods = BigInt(process.env.PERIODS ?? 24); + const agent = process.env.AGENT ?? DEMO_AGENT; + + const dep = JSON.parse(fs.readFileSync("deployments/hederaTestnet/RetainerAccess.json", "utf8")); + const provider = new ethers.JsonRpcProvider(RPC); + const seller = new ethers.Wallet(cred("SELLER_PRIVATE_KEY"), provider); + const buyer = new ethers.Wallet(cred("BUYER_PRIVATE_KEY"), provider); + + const asSeller = new ethers.Contract(dep.address, dep.abi, seller); + const asBuyer = new ethers.Contract(dep.address, dep.abi, buyer); + + const beneficiary: string = await asSeller.beneficiary(); + const priceNow: bigint = await asSeller.pricePerPeriod(); + const periodNow = Number(await asSeller.periodSeconds()); + const callsNow = Number(await asSeller.callsPerPeriod()); + const minPeriod = Number(await asSeller.MIN_PERIOD_SECONDS()); + const armableNow: bigint = await asSeller.renewalsRemaining(); + + const priceHbar = Number(process.env.PRICE_HBAR ?? Number(priceNow) / 1e8); + const callsPerPeriod = Number(process.env.CALLS_PER_PERIOD ?? callsNow); + const price = toTinybar(priceHbar); + + console.log(`contract ${dep.address} (${dep.hederaContractId})`); + console.log(`beneficiary ${beneficiary}`); + console.log(`seller ${seller.address}`); + console.log(`buyer ${buyer.address}`); + console.log(`agent ${agent}\n`); + + if (beneficiary.toLowerCase() !== seller.address.toLowerCase()) + throw new Error( + `SELLER_PRIVATE_KEY is ${seller.address} but beneficiary is ${beneficiary}. ` + + `setTerms is onlyBeneficiary and beneficiary is immutable — this key cannot re-term.`, + ); + if (periodSeconds < minPeriod) + throw new Error(`PERIOD_SECONDS=${periodSeconds} is below the contract floor of ${minPeriod}`); + + console.log(`now: ${hbar(priceNow)} per ${periodNow}s · ${callsNow} calls · reserve arms ${armableNow}`); + console.log(` = ${((Number(armableNow) * periodNow) / 60).toFixed(1)} minutes of life\n`); + console.log(`next: ${hbar(price)} per ${periodSeconds}s · ${callsPerPeriod} calls`); + + const renewalsFunded = Math.floor(reserveHbar / REARM_COST_HBAR); + const hours = (renewalsFunded * periodSeconds) / 3600; + console.log( + ` +${reserveHbar} HBAR reserve ≈ ${renewalsFunded} re-arming renewals at ~${REARM_COST_HBAR} HBAR each`, + ); + console.log(` ≈ ${hours.toFixed(1)} hours of unattended life\n`); + + if (hours < 72) + console.log(` NOTE: under 72h. Judging starts 2026-09-14T16:00Z; raise RESERVE_HBAR or PERIOD_SECONDS.\n`); + + if (dryRun) return void console.log("DRY_RUN=1 — nothing sent."); + + // 1. Re-term FIRST. Terms snapshot at subscribe() time, so a subscription opened before this + // would keep the old period for its entire life. + console.log(`1/3 setTerms(${price}, ${periodSeconds}, ${callsPerPeriod}) as beneficiary`); + let rc = await (await asSeller.setTerms(price, periodSeconds, callsPerPeriod, { gasLimit: 200_000 })).wait(); + console.log(` tx ${rc!.hash}`); + + // 2. Top up the reserve. This is the actual fix — payments never reach gasReserve. + // value on the wire is WEIBAR; the relay converts to TINYBAR before the EVM sees it. + console.log(`2/3 fundGasReserve() +${reserveHbar} HBAR`); + rc = await ( + await asBuyer.fundGasReserve({ value: toTinybar(reserveHbar) * WEIBAR_PER_TINYBAR, gasLimit: 200_000 }) + ).wait(); + console.log(` tx ${rc!.hash}`); + + // 3. Restart the demo subscription, now under the new terms. + console.log(`3/3 subscribeFor(${agent}) funding ${periods} periods`); + rc = await ( + await asBuyer.subscribeFor(agent, { value: price * periods * WEIBAR_PER_TINYBAR, gasLimit: 2_000_000 }) + ).wait(); + console.log(` tx ${rc!.hash}`); + + const armable: bigint = await asSeller.renewalsRemaining(); + console.log( + `\ndone. reserve arms ${armable} renewals ≈ ${((Number(armable) * periodSeconds) / 3600).toFixed(1)} hours`, + ); + console.log(`verify: curl -s "https://retainer.edycu.dev/api/retainer/status?agent=${agent}"`); +} + +main().catch(e => { + console.error(e.message ?? e); + process.exit(1); +}); From 0e212197f845897884a9b8c4d8af4c6a5164b6de Mon Sep 17 00:00:00 2001 From: Edy Cu Date: Wed, 9 Sep 2026 22:59:54 +0700 Subject: [PATCH 2/2] fix(ops): derive the funding from the date, not from a guess The first version took RESERVE_HBAR as a number with a default of 120. At a one-hour period that is 75 renewals -- 75 hours -- which lapses 2026-09-12, two days BEFORE judging starts on 09-14. A plausible-looking default that quietly misses the only date that matters is worse than no default. Funding is now derived from UNTIL (default 2026-09-16T23:59Z, the end of judging): renewals = hours / period, reserve = renewals x re-arm cost, and the subscription is funded for the same horizon. Both can still be overridden. It also now prints the SUBSCRIPTION cost, which the first version omitted entirely. The two pots are funded separately and either can run out first, so the warnings name which one is short rather than saying "short". Measured through the end of judging, from the live contract: 3600s 282 gas + 177 subs = 459 HBAR 7200s 141 gas + 89 subs = 230 HBAR 14400s 71 gas + 45 subs = 116 HBAR --- packages/hardhat/scripts/retermAndRestart.ts | 45 +++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/hardhat/scripts/retermAndRestart.ts b/packages/hardhat/scripts/retermAndRestart.ts index 9fd9a7e..1ed94ab 100644 --- a/packages/hardhat/scripts/retermAndRestart.ts +++ b/packages/hardhat/scripts/retermAndRestart.ts @@ -20,10 +20,13 @@ * * Env (all optional, shown with defaults): * PERIOD_SECONDS=3600 new access window. Contract floor is MIN_PERIOD_SECONDS = 61. + * UNTIL=2026-09-16T23:59Z how long it must stay alive. Reserve and periods are DERIVED from + * this, so the demo cannot be funded to a date that quietly precedes + * judging -- the mistake this default exists to prevent. * PRICE_HBAR=1 price per period. Unchanged by default. * CALLS_PER_PERIOD= metered calls per window. Read from the contract if unset. - * RESERVE_HBAR=120 HBAR to add to the gas reserve. - * PERIODS=24 periods to fund the demo subscription for. + * RESERVE_HBAR= override the derived gas reserve. + * PERIODS= override the derived subscription funding. * AGENT=0xD14C... subscription to restart. Defaults to the demo agent. * DRY_RUN=1 print the plan and the arithmetic, send nothing. * @@ -54,10 +57,17 @@ const toTinybar = (h: number) => BigInt(Math.round(h * 1e8)); async function main() { const dryRun = process.env.DRY_RUN === "1"; const periodSeconds = Number(process.env.PERIOD_SECONDS ?? 3600); - const reserveHbar = Number(process.env.RESERVE_HBAR ?? 120); - const periods = BigInt(process.env.PERIODS ?? 24); const agent = process.env.AGENT ?? DEMO_AGENT; + // Derive funding from the date it must survive to, not from a number someone guessed. A + // hand-picked reserve is how you fund the demo to a date that quietly precedes judging. + const until = new Date(process.env.UNTIL ?? "2026-09-16T23:59Z"); + const hours = (until.getTime() - Date.now()) / 3_600_000; + if (!(hours > 0)) throw new Error(`UNTIL=${until.toISOString()} is in the past`); + const renewalsNeeded = Math.ceil((hours * 3600) / periodSeconds); + const reserveHbar = Number(process.env.RESERVE_HBAR ?? Math.ceil(renewalsNeeded * REARM_COST_HBAR)); + const periods = BigInt(process.env.PERIODS ?? renewalsNeeded + 1); + const dep = JSON.parse(fs.readFileSync("deployments/hederaTestnet/RetainerAccess.json", "utf8")); const provider = new ethers.JsonRpcProvider(RPC); const seller = new ethers.Wallet(cred("SELLER_PRIVATE_KEY"), provider); @@ -96,14 +106,29 @@ async function main() { console.log(`next: ${hbar(price)} per ${periodSeconds}s · ${callsPerPeriod} calls`); const renewalsFunded = Math.floor(reserveHbar / REARM_COST_HBAR); - const hours = (renewalsFunded * periodSeconds) / 3600; + const livesHours = (renewalsFunded * periodSeconds) / 3600; + const lapsesAt = new Date(Date.now() + livesHours * 3_600_000); + const subscriptionHbar = (Number(price) / 1e8) * Number(periods); + console.log( - ` +${reserveHbar} HBAR reserve ≈ ${renewalsFunded} re-arming renewals at ~${REARM_COST_HBAR} HBAR each`, + ` target alive until ${until.toISOString().slice(0, 16)}Z (${hours.toFixed(0)}h) = ${renewalsNeeded} renewals`, ); - console.log(` ≈ ${hours.toFixed(1)} hours of unattended life\n`); - - if (hours < 72) - console.log(` NOTE: under 72h. Judging starts 2026-09-14T16:00Z; raise RESERVE_HBAR or PERIOD_SECONDS.\n`); + console.log( + ` gas +${reserveHbar} HBAR reserve -> ${renewalsFunded} re-arms at ~${REARM_COST_HBAR} HBAR each`, + ); + console.log(` subs ${subscriptionHbar} HBAR for ${periods} periods`); + console.log(` TOTAL ${(reserveHbar + subscriptionHbar).toFixed(0)} HBAR from the buyer account`); + console.log(` lapses ${lapsesAt.toISOString().slice(0, 16)}Z (${livesHours.toFixed(0)}h of life)\n`); + + // Two separate pots, two separate ways to fall short. Say WHICH one is short, not just "short". + const JUDGING_STARTS = new Date("2026-09-14T16:00Z"); + if (lapsesAt < JUDGING_STARTS) + console.log(` WARNING: gas lapses BEFORE judging starts ${JUDGING_STARTS.toISOString().slice(0, 16)}Z.`); + else if (lapsesAt < until) + console.log(` WARNING: gas lapses before the ${until.toISOString().slice(0, 10)} target.`); + if (Number(periods) < renewalsNeeded) + console.log(` WARNING: subscription funds ${periods} periods but ${renewalsNeeded} are needed.`); + console.log(); if (dryRun) return void console.log("DRY_RUN=1 — nothing sent.");