Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions DEMO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions JUDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/proof.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
87 changes: 86 additions & 1 deletion e2e/landing.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { type Page, expect, test } from "@playwright/test";
import { version } from "../package.json";

/**
Expand All @@ -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,
Expand All @@ -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/),
Expand Down Expand Up @@ -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")
Expand All @@ -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,
}) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
19 changes: 15 additions & 4 deletions packages/nextjs/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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.
Expand All @@ -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" });
}
Expand Down Expand Up @@ -91,7 +102,7 @@ const Home: NextPage = () => {
</div>
</div>
<div className="order-3 lg:order-none mt-1 lg:mt-0 lg:col-span-6 xl:col-span-7 min-w-0 rt-enter rt-enter--2">
<Instrument live={live} />
<Instrument live={live} fromArrival={!!arrivalAgent && live.agent === arrivalAgent} />
</div>
</div>
</section>
Expand Down Expand Up @@ -176,7 +187,7 @@ const Home: NextPage = () => {
</p>
</div>
{!live.valid && (
<button type="button" className="rt-btn rt-btn--ghost" onClick={() => live.setAgent(DEMO_AGENT)}>
<button type="button" className="rt-btn rt-btn--ghost" onClick={() => askForLive(DEMO_AGENT)}>
Watch the demo agent
</button>
)}
Expand Down
39 changes: 34 additions & 5 deletions packages/nextjs/components/landing/Instrument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mode>(initialMode);
const [reduced, setReduced] = useState(false);
const [playing, setPlaying] = useState(false);
Expand All @@ -68,6 +77,8 @@ export function Instrument({ live, initialMode = "replay" }: { live: LiveWindow;
const onScreen = useRef(true);
const lastAnnounced = useRef<number>(-1);
const stepFireTimer = useRef<ReturnType<typeof setTimeout> | 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(() => {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -460,15 +483,21 @@ 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"
/>
<button
type="button"
className="rt-btn rt-btn--ghost whitespace-nowrap"
onClick={() => live.setAgent(DEMO_AGENT)}
onClick={() => {
engaged.current = true;
live.setAgent(DEMO_AGENT);
}}
>
Watch the demo agent
</button>
Expand Down
Binary file modified packages/nextjs/public/og-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions packages/nextjs/utils/scaffold-hbar/getMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading