A minting bot for the Circularity NFT, running as a Cloudflare Worker on a Cron Trigger. It's managed with the Cloudflare CLI (cf) and configured in cloudflare.config.ts. It previously ran on Vercel and sent transactions through Syndicate's Transaction Cloud.
On the first of every month at 1 AM UTC it:
- Calls
burn()on Circularity, which burns the prior month's NFT and mints the current month's NFT to its recorded owner. - Calls
mint(amount)on Circularity - Burn Ceremony, minting every Burn Ceremony NFT that is owed to the artist.
It runs every day at 1 AM UTC, so if the run on the 1st fails, the next day's run retries it. Runs on other days find nothing due and send nothing. 1 AM is used instead of midnight to give humans an hour on the 1st to burn and mint themselves for on-chain provenance. Contract source: WillPapper/circularity.
- Idempotent. Each run reads on-chain state first and sends only the transactions that are due. Re-running after a success, or after a human already burned and minted, sends nothing.
- Retries daily. A failed run is retried the next day. This matters for Circularity:
burn()can only mint the current month's NFT, so a month that's never burned is skipped for good. - Catches up.
mintusesamountEligibleToMint()rather than a fixed 1, so Burn Ceremony NFTs for missed months are all minted on the next successful run. - No wasted gas on reverts. Every call is simulated before it is sent. Nothing is sent while an earlier transaction from the bot wallet is still pending (for example, stuck during a gas spike), since a duplicate behind it would be rejected. To make getting stuck unlikely, the max fee is set to 2x the current base fee instead of viem's default 1.2x; transactions still pay only the actual fee.
- Independent steps. A burn that fails or reverts doesn't stop the mint, or the reverse. The exception is a pending transaction: if one is waiting to be mined, including one sent earlier in the same run, both steps wait for the next run. If either step fails, the invocation is marked as failed in the Worker's Cron Events.
Both contract functions are permissionless, so any wallet with ETH for gas can run this. The wallet never holds the NFTs.
| Route | Description |
|---|---|
GET / |
Read-only status: the current Circularity month, Burn Ceremony NFTs owed, and the minter wallet's address, balance, and pending transactions. |
POST /run |
Runs the same job as the cron immediately. Requires Authorization: Bearer <RUN_TOKEN>. It waits for receipts, so keep the connection open until it responds (usually under a minute). Avoid calling it while the 1 AM UTC run is in progress: two overlapping runs can each send a transaction, and the contract rejects the second, wasting a little gas. |
Install dependencies and sign in to Cloudflare:
npm install
npx cf auth loginIf your login can access more than one Cloudflare account, choose one with a gitignored .env file in the project root:
echo "CLOUDFLARE_ACCOUNT_ID=<account id>" > .envCreate a dedicated wallet for the bot and fund it with a small amount of ETH. A normal month uses about 145k to 165k gas: 77k to 97k for the burn, plus 68k for minting one Burn Ceremony NFT.
cf can't set secrets on a Worker that doesn't exist yet, so the first deploy uploads them with the code:
npm run deploy:secretsIt prompts, with hidden input, for:
PRIVATE_KEY: the bot wallet's 0x-prefixed private keyRPC_URL: an Ethereum mainnet JSON-RPC URL, for example from Alchemy or InfuraRUN_TOKEN: any long random string, which you'll need forPOST /run
The values are piped straight to cf deploy --secrets-file, so they never touch disk or shell history. Run it again any time to rotate secrets.
If the deploy runs somewhere you can't type into (for example, a remote agent's machine), set the secrets yourself first:
-
Create a Worker named
circularity-minter, with any placeholder code (for example, the dashboard's Hello World template). -
Add the secrets to it, either in the dashboard under Settings > Variables and Secrets (type Secret), or with
cfon your own machine. This zsh loop prompts for each value with hidden input. In bash, useread -rsp "$name: " valueinstead:for name in PRIVATE_KEY RPC_URL RUN_TOKEN; do read -rs "value?$name: " && echo && cf workers secrets update "$name" --worker circularity-minter --type secret_text --text "$value"; done; unset value
-
Run
npm run deploy. It replaces the placeholder code and keeps the secrets. If it reports a configuration conflict with the placeholder, review the diff and rerun with--force.
npm run deployExisting secrets carry over to new versions.
To catch up immediately instead of waiting for the next cron:
curl -X POST -H "Authorization: Bearer $RUN_TOKEN" https://circularity-minter.<your-subdomain>.workers.dev/runThe Workers Paid plan is recommended. Deriving the signing key takes about 9ms of CPU on a cold start, and the Free plan allows 10ms per invocation. Free plan subrequest limits (50 per invocation) are fine: RPC reads are batched, and receipts are polled once per block.
- Each run's report is logged as JSON. View it in the dashboard under the Worker's Observability tab, or stream live logs with
wrangler tail circularity-minter.cfcan't tail logs yet, so this is the one task that falls back to Wrangler (installed globally, not as a project dependency). It reads the same.envaccount. - Failed runs appear under the Worker's Settings > Triggers > Cron Events. A failed run on the 1st is retried each day at 1 AM UTC until it succeeds.
GET /reportspendingTransactionsfor the bot wallet. If it stays above 0, a transaction is stuck and runs won't send until it's mined or dropped. It'snullif the RPC doesn't report pending transactions, in which case runs send normally and the contracts reject any duplicate.GET /shows the minter wallet's balance, so you can top it up before it runs dry.
Copy .dev.vars.example to .dev.vars and fill it in. .dev.vars is gitignored. Then:
npm run dev
curl "http://localhost:5173/cdn-cgi/local/scheduled?cron=0+1+1+*+*&format=json"The second command triggers the scheduled handler. Local runs send real transactions to whatever RPC_URL points at, so use the fork test below to try changes without spending ETH.
npm run typecheck regenerates the binding types in .cloudflare/types and type-checks the project.
npm run test:forkThis runs an end-to-end test against a local mainnet fork in about 15 seconds. It starts a Hardhat fork of Ethereum mainnet, runs the Worker with cf dev against it, and moves the fork's clock through each scenario:
- A scheduled run that catches up every missed month
- Re-runs, by cron and
POST /run, and a mid-month daily run, all sending nothing POST /runrejecting missing and wrong tokens- Humans burning and minting before the 1 AM run, which then sends nothing
- Missed months up to December, then a year rollover to January
- Exactly 12 missed months, where
burn()only updates the year - A transaction stuck pending blocking new sends, then the next day's run proceeding once it's mined
- An empty wallet failing loudly without changing anything, then the next day's run catching up once it's funded
- The RPC URL's key never appearing in responses or logs, including while the RPC is down
Expectations are computed from the contracts' state at the fork, so the test works whichever month mainnet is in.
The test never touches mainnet or your .dev.vars. It writes its own .dev.vars.forktest holding Hardhat's public test keys, removes it afterwards, and aborts unless the Worker is signing with the test key on the fork.
| Variable | Default | Description |
|---|---|---|
FORK_RPC_URL |
A public mainnet RPC | The RPC to fork. Use your own for reliability. |
FORK_BLOCK |
Latest block | Pins the fork to a block. Requires an archive RPC. |