diff --git a/DEMO.md b/DEMO.md index 8915641..f8cd329 100644 --- a/DEMO.md +++ b/DEMO.md @@ -144,9 +144,11 @@ one run. disagree at small n. At n = 30 a p95 is close to the second-largest sample — a weak tail estimate. Treat 151 ms as "the tail we have observed", not a service-level guarantee. -**The drift sample is a population, not a sample.** All 30 renewals this deployment has ever -armed are in it, because the contract emits `RenewalScheduled(agent, schedule, firesAt)` and -every one of those pairs to a `scheduled=true` CONTRACTCALL. Nothing was selected out. +**The drift sample is a population, not a sample.** All 30 renewals this deployment had armed as +of the run above are in it, because the contract emits `RenewalScheduled(agent, schedule, firesAt)` +and every one of those pairs to a `scheduled=true` CONTRACTCALL. Nothing was selected out. The +deployment keeps renewing, so a later `yarn bench` reports a larger n — the table is the run it is +dated with, not a running total. **Disclosed seeding.** That traffic is operator-generated: every renewal came from subscriptions we funded ourselves on testnet, at a demo period of 90 seconds. There are no external users in diff --git a/JUDGE.md b/JUDGE.md index 7a63564..0e0c950 100644 --- a/JUDGE.md +++ b/JUDGE.md @@ -163,9 +163,11 @@ Four real ones. None of them is fixed here. balance; neither schedules anything. Opening a subscription again is the only way back — `subscribe()`, or the `subscribeFor()` the server calls when the agent pays the next 402. So the unattended part runs exactly as far as the money does: until the subscriber's balance or - the seller's gas reserve runs dry, and then someone outside has to send a transaction. At the - demo settings — 90-second periods, 2 ℏ held back per armed renewal — that is minutes, not - months. + the seller's gas reserve runs dry, and then someone outside has to send a transaction. With + 2 ℏ held back per armed renewal, the period length decides how long that takes: at the + 90-second periods the demo ran until 2026-09-09, minutes; at the hourly periods it runs now, + days. Either way it is the reserve that ends it, and `periodSeconds` in `/api/retainer/status` + says which setting is live. Also true: not audited, testnet only, and `RENEWAL_COST_ESTIMATE` is an explicit estimate — a contract cannot know a future network fee. diff --git a/README.md b/README.md index 74959bf..95bc82e 100644 --- a/README.md +++ b/README.md @@ -565,8 +565,13 @@ runs exactly as far as the money does: until the subscriber's balance or the sel reserve runs dry, and no further. Nothing on-chain is holding a wake-up call after a lapse, so the restart has to come from outside — the agent paying again, or the seller topping up `fundGasReserve()`, which nothing in the contract does on its own. That boundary is easy to -miss at the demo settings on the current deployment: 90-second periods against a reserve that -holds back 2 ℏ per armed renewal, so a funded Retainer burns down in minutes rather than months. +miss, because it is the gas reserve and not the subscription that ends up being the clock: a +renewal that re-arms costs about 1.6 ℏ of gas to collect 1 ℏ of revenue, and 2 ℏ is held back per +armed renewal while it is pending. The period length turns that into a duration. The demo ran +90-second periods until 2026-09-09 and burned down in minutes; it runs hourly periods now and the +same reserve lasts days. `packages/hardhat/scripts/retermAndRestart.ts` derives both the period and +the reserve from the date the demo has to survive to, rather than from a number someone picked, and +`periodSeconds` in `/api/retainer/status` is the live answer for whatever it is set to today. ## 📐 Reproduce the numbers diff --git a/RUNBOOK.md b/RUNBOOK.md index 39ca63e..e709703 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -119,7 +119,7 @@ typings generated into `packages/hardhat/typechain-types`. yarn hardhat:test ``` -Expect **40 passing**. The suite takes roughly two minutes; each test redeploys the contract +Expect **64 passing**. The suite takes roughly two minutes; each test redeploys the contract and reinstalls the mock scheduler. The tests run against a forked Hedera environment (`HEDERA_FORKING=true`) with a diff --git a/docs/proof.md b/docs/proof.md index f9cf526..1b4e1a2 100644 --- a/docs/proof.md +++ b/docs/proof.md @@ -56,7 +56,8 @@ current source concrete: source (`subscribe()` is `0x8f449a05`, `subscribeFor(address)` is `0x6da6c39c`). - The run used a **60-second period**. The current source sets `MIN_PERIOD_SECONDS = 61`, a bound added after this run so the anyone-callable `renew()` window stays a strict minority - of every period; the current deployment runs 90-second periods. + of every period; the current deployment ran 90-second periods when this run was transcribed and + hourly ones since 2026-09-09, `periodSeconds` in `/api/retainer/status` being the live answer. Neither affects what the run demonstrates: the Schedule Service really does call back into a contract, on time, unattended, and re-arm itself — and what that costs. diff --git a/e2e/landing.spec.ts b/e2e/landing.spec.ts index cfc8734..dbffb1d 100644 --- a/e2e/landing.spec.ts +++ b/e2e/landing.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { type Page, expect, test } from "@playwright/test"; import { version } from "../package.json"; /** @@ -13,6 +13,50 @@ import { version } from "../package.json"; const CLAIM = "renews itself"; +/** + * The status the page reads on arrival, stubbed. + * + * The landing page asks the chain once, unprompted, whether the demo agent's window is open, and + * behaves differently depending on the answer. Left unstubbed that makes these tests a function of + * what testnet happens to be doing at the minute they run — which is exactly how a green suite went + * red the moment the demo was restarted, with no code change between the two runs. So the answer is + * supplied here, both ways round, and the chain gets to decide nothing. + */ +const statusBody = (open: boolean) => ({ + agent: "0xD14CA86A1483e9b2147a7B86fB74D437d3d2Cc66", + contract: "0x433050c9bd203FBdd49FAB6b5E20eD3E1FB2a931", + hasAccess: open, + expiresAt: open ? Math.floor(Date.now() / 1000) + 1800 : 0, + secondsRemaining: open ? 1800 : 0, + periodSeconds: open ? 3600 : 0, + pricePerPeriodTinybar: "100000000", + balanceTinybar: open ? "17600000000" : "0", + periodsFunded: open ? 176 : 0, + active: open, + nextRenewalSchedule: open + ? "0x00000000000000000000000000000000009F56b5" + : "0x0000000000000000000000000000000000000000", + renewalsReserveCanArm: open ? 176 : 0, + usage: { used: 0, allowance: 5, remaining: open ? 5 : 0 }, + unavailable: [], + now: Math.floor(Date.now() / 1000), +}); + +/** Answer every status read with `open`, after `delayMs`. Resolves the count of reads served. */ +async function stubStatus(page: Page, open: boolean, delayMs = 0) { + const served = { count: 0 }; + await page.route("**/api/retainer/status**", async route => { + if (delayMs) await new Promise(r => setTimeout(r, delayMs)); + served.count += 1; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(statusBody(open)), + }); + }); + return served; +} + test.describe("/ — the landing page a cold visitor gets", () => { test("carries exactly one h1 with the claim, and the instrument above the fold", async ({ page, @@ -39,6 +83,9 @@ test.describe("/ — the landing page a cold visitor gets", () => { }) => { await page.goto("/"); const instrument = page.getByTestId("instrument"); + // Ask for the recorded run rather than assuming it is what a cold visitor gets: when the demo + // agent's window is open on chain, the page legitimately opens on the live tab instead. + await instrument.getByRole("tab", { name: "Recorded run" }).click(); await expect(instrument).toHaveAttribute("data-mode", "replay"); await expect( instrument.getByText(/REPLAY · 2026-09-07 · 0\.0\.10406083/), @@ -79,6 +126,7 @@ test.describe("/ — the landing page a cold visitor gets", () => { }) => { const context = await browser.newContext({ reducedMotion: "reduce" }); const page = await context.newPage(); + await stubStatus(page, false); await page.goto("/"); const play = page .getByTestId("instrument") @@ -102,6 +150,43 @@ test.describe("/ — the landing page a cold visitor gets", () => { ).toBeLessThanOrEqual(1); }); + test("opens on the live chain when the demo agent's window is open and nobody has touched it", async ({ + page, + }) => { + await stubStatus(page, true); + await page.goto("/"); + const instrument = page.getByTestId("instrument"); + await expect(instrument).toHaveAttribute("data-mode", "live"); + }); + + /** + * The regression this file exists to prevent from recurring. + * + * The arrival read lands a few hundred milliseconds after paint. While the demo agent was lapsed + * it answered "closed" and changed nothing, so nothing here ever ran against the other branch. + * Once the demo was restarted the same read started switching the instrument to live *after* a + * visitor had begun working the replay — the controls unmounted under a click already in flight + * (`element is not stable`). The suite caught it, on a branch that touched no UI code at all. + */ + test("a late arrival read never pulls the mode away from a visitor already working the replay", async ({ + page, + }) => { + const served = await stubStatus(page, true, 1200); + await page.goto("/"); + const instrument = page.getByTestId("instrument"); + + // Take hold of the instrument before the read can land. + await instrument.getByRole("tab", { name: "Recorded run" }).click(); + await expect(instrument).toHaveAttribute("data-mode", "replay"); + + // Now let it land — and keep landing, because the live window polls. + await expect.poll(() => served.count, { timeout: 15_000 }).toBeGreaterThan(0); + await expect(instrument).toHaveAttribute("data-mode", "replay"); + await expect( + instrument.getByRole("button", { name: /Pause|Play/ }), + ).toBeVisible(); + }); + test("has the landmarks and the version stamp a real page has", async ({ page, }) => { diff --git a/packages/nextjs/app/layout.tsx b/packages/nextjs/app/layout.tsx index 02d1ab9..5ee0042 100644 --- a/packages/nextjs/app/layout.tsx +++ b/packages/nextjs/app/layout.tsx @@ -31,7 +31,7 @@ export const metadata = getMetadata({ "An x402-gated service on Hedera whose access renews itself on-chain via the Hedera Schedule Service. An agent pays once; the network keeps it alive.", // 101 chars — the card copy has to survive a mobile feed, which clips around 125. cardDescription: - "An agent pays once, then Hedera renews its access on-chain by itself — 39 renewals, no user, no cron.", + "An agent pays once, then Hedera renews its access on-chain by itself — on schedule, no user, no cron.", }); const ScaffoldHbarApp = ({ children }: { children: React.ReactNode }) => { diff --git a/packages/nextjs/app/page.tsx b/packages/nextjs/app/page.tsx index 0a6c5da..0876381 100644 --- a/packages/nextjs/app/page.tsx +++ b/packages/nextjs/app/page.tsx @@ -38,6 +38,9 @@ const Home: NextPage = () => { const live = useLiveWindow(); useReveal([live.valid, live.status !== null]); const checkedDemo = useRef(false); + // The address the arrival read put there on nobody's behalf. Anything the visitor asks for + // clears it, so the instrument can tell "the chain happened to be open" from "show me the chain". + const [arrivalAgent, setArrivalAgent] = useState(null); // One read on arrival: if the demo agent's window is open on chain right now, the real thing // beats a replay, so the instrument starts live. If it is closed, the recorded run stays. @@ -48,15 +51,23 @@ const Home: NextPage = () => { fetch(`/api/retainer/status?agent=${DEMO_AGENT}`, { cache: "no-store", signal: ctrl.signal }) .then(r => (r.ok ? r.json() : null)) .then(body => { - if (body?.hasAccess && !live.agent) live.setAgent(DEMO_AGENT); + if (body?.hasAccess && !live.agent) { + live.setAgent(DEMO_AGENT); + setArrivalAgent(DEMO_AGENT); + } }) .catch(() => {}); return () => ctrl.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const askForLive = (agent: string) => { + setArrivalAgent(null); + live.setAgent(agent); + }; + const watchDemo = () => { - live.setAgent(DEMO_AGENT); + askForLive(DEMO_AGENT); if (window.innerWidth < 1024) { document.getElementById("instrument")?.scrollIntoView({ behavior: "smooth", block: "start" }); } @@ -91,7 +102,7 @@ const Home: NextPage = () => {
- +
@@ -176,7 +187,7 @@ const Home: NextPage = () => {

{!live.valid && ( - )} diff --git a/packages/nextjs/components/landing/Instrument.tsx b/packages/nextjs/components/landing/Instrument.tsx index 48d3502..3f54d51 100644 --- a/packages/nextjs/components/landing/Instrument.tsx +++ b/packages/nextjs/components/landing/Instrument.tsx @@ -51,7 +51,16 @@ function phaseForKey(k: Key): number { return k.i === RECORDED_RUN.length - 1 ? PHASES.length - 1 : (k.i + 1) * 3; // next fill, or closed } -export function Instrument({ live, initialMode = "replay" }: { live: LiveWindow; initialMode?: Mode }) { +export function Instrument({ + live, + initialMode = "replay", + fromArrival = false, +}: { + live: LiveWindow; + initialMode?: Mode; + /** True while the only reason live data exists is the page's own arrival read — nobody asked. */ + fromArrival?: boolean; +}) { const [mode, setMode] = useState(initialMode); const [reduced, setReduced] = useState(false); const [playing, setPlaying] = useState(false); @@ -68,6 +77,8 @@ export function Instrument({ live, initialMode = "replay" }: { live: LiveWindow; const onScreen = useRef(true); const lastAnnounced = useRef(-1); const stepFireTimer = useRef | null>(null); + /** Set the moment the visitor works a control. From then on the mode is theirs, not ours. */ + const engaged = useRef(false); // Reduced motion decides autoplay once, at mount. useEffect(() => { @@ -83,9 +94,18 @@ export function Instrument({ live, initialMode = "replay" }: { live: LiveWindow; }, []); // An address, the demo agent or a wallet means the visitor wants the chain. + // + // With one exception, and it is the whole reason this is not a one-liner: the page also reads + // the demo agent on arrival, unasked, and that read lands a few hundred milliseconds late. If + // the window happens to be open it used to switch the mode out from under whoever was already + // working the replay — the controls unmount mid-click. Invisible for as long as the demo agent + // was lapsed, and a broken hero the day it was not. An unasked-for read never wins against a + // visitor who has taken hold of the instrument; anything they asked for still does. useEffect(() => { - if (live.valid) setMode("live"); - }, [live.valid]); + if (!live.valid) return; + if (fromArrival && engaged.current) return; + setMode("live"); + }, [live.valid, fromArrival]); // Pause when the instrument is off screen or the tab is hidden — the replay is not a CPU tax. useEffect(() => { @@ -192,10 +212,12 @@ export function Instrument({ live, initialMode = "replay" }: { live: LiveWindow; }, []); const step = () => { + engaged.current = true; setPlaying(false); showKey((stepIdx + 1) % KEYS.length); }; const togglePlay = () => { + engaged.current = true; if (playing) { setPlaying(false); // Land on the nearest key so Step continues from here. @@ -210,6 +232,7 @@ export function Instrument({ live, initialMode = "replay" }: { live: LiveWindow; }; const switchMode = (m: Mode) => { + engaged.current = true; setMode(m); setAnnounce(m === "replay" ? "Showing the recorded run." : "Showing the live chain."); if (m === "live" && !live.valid) setTimeout(() => inputRef.current?.focus(), 0); @@ -460,7 +483,10 @@ export function Instrument({ live, initialMode = "replay" }: { live: LiveWindow; className="rt-input" placeholder="0x… agent address" value={live.agent} - onChange={e => live.setAgent(e.target.value)} + onChange={e => { + engaged.current = true; + live.setAgent(e.target.value); + }} spellCheck={false} autoComplete="off" inputMode="text" @@ -468,7 +494,10 @@ export function Instrument({ live, initialMode = "replay" }: { live: LiveWindow; diff --git a/packages/nextjs/public/og-card.png b/packages/nextjs/public/og-card.png index 6d2a3ce..d4b6243 100644 Binary files a/packages/nextjs/public/og-card.png and b/packages/nextjs/public/og-card.png differ diff --git a/packages/nextjs/utils/scaffold-hbar/getMetadata.ts b/packages/nextjs/utils/scaffold-hbar/getMetadata.ts index 48e9e73..7223564 100644 --- a/packages/nextjs/utils/scaffold-hbar/getMetadata.ts +++ b/packages/nextjs/utils/scaffold-hbar/getMetadata.ts @@ -18,9 +18,9 @@ const titleTemplate = "%s | Retainer"; // // BUMP THE ?v= WHENEVER THE IMAGE CHANGES. Discord, X and Slack cache the card by URL and offer // no purge; a regenerated file at an unchanged URL is invisible to everyone who has already seen it. -const SOCIAL_CARD = "/og-card.png?v=2"; +const SOCIAL_CARD = "/og-card.png?v=3"; const SOCIAL_CARD_ALT = - "Retainer social card: one signature buys access, then 39 unattended renewals executed by the network, with the countdown ring caught refilling to 60:00"; + "Retainer social card: one signature buys access, then 46+ unattended renewals executed by the network, with the countdown ring caught refilling to 60:00"; export const getMetadata = ({ title,