VRC-21: Scheduled Execution
and Subscriptions.Draft
A protocol-level cron registry for ValdiumChain, and a VRC-20-compatible subscriptions standard built on top of it. No keeper bots, no third-party executors, no missed schedules.
Abstract
VRC-21 defines two things. First, a cron registry system contract pre-deployed at 0x...06, which lets any contract schedule a future call. The chain's block builder executes due jobs before mempool processing, in the same block they are due, without relying on an external keeper. Second, a subscriptions interface for VRC-20 tokens, so any merchant can accept recurring payments without redeploying its own scheduling infrastructure.
Motivation
Recurring on-chain payments, vesting schedules, treasury rebalancing, auto-compounding yield, and time-locked operations are all "scheduled execution" problems. Every other major chain solves this with an off-chain keeper network (Gelato, Chainlink Automation, Keeper Network) that polls the chain and triggers contracts when conditions are met. That introduces a third party you have to trust: a keeper can be sanctioned, sued, or just forget to run.
VRC-21 puts scheduling inside the chain. The block builder is responsible for executing due jobs, deterministically, as part of consensus. There is no separate operator network to bribe or break. A payroll cron set up today will fire on schedule for as long as the chain runs and as long as its gas escrow holds.
The subscriptions interface gives merchants a uniform way to accept recurring payments without writing the scheduling logic themselves. Any wallet, exchange, or dapp can present a "manage subscriptions" view that speaks one shape.
Specification
Cron registry: pre-deployed system contract
The cron registry is a system contract deployed at the reserved address 0x0000000000000000000000000000000000000006 at chain genesis.
Block builder behaviour
On each new block at timestamp T, the block builder MUST:
- Walk the cron registry storage keys
due:<ts>:<id>for everyts ≤ T, in ascending order. - For each due job, deduct
job.gasLimit * baseFeefrom the job's escrow. If escrow is insufficient, emitJobExhaustedand remove the job. - Otherwise execute
target.method(args)with the configured gas. EmitJobExecutedwith the result. - If the job is recurring and has runs left, update
nextRunAt, decrementrunsLeft, re-insert the due key. - Cap total cron gas per block at
CRON_BLOCK_GAS_BUDGET(15M gas at v1). Jobs that don't fit roll to the next block.
This sequence is part of consensus. All validators MUST produce identical cron execution results given identical state, or block apply will fail with a state root mismatch.
Subscriptions interface (built on cron)
Rationale
Why pre-deploy as a system contract. The cron registry must exist before any user contract can reference it, and its address must be stable so dapps can hardcode it. Putting it at 0x...06 at genesis solves both, and prevents front-running by an attacker deploying first.
Why a gas escrow per job. Block-builder-driven execution still costs gas. A per-job prepaid escrow keeps the accounting local: the job runs as long as its escrow can fund a run, then emits JobExhausted. This avoids spam and unbounded liability.
Why a per-block cron gas budget. A million subscriptions all due at midnight UTC would crowd out regular transactions in that block. Capping cron at 15M gas (50% of the 30M block limit) leaves room for normal use. Jobs that don't fit roll forward.
Why a minimum interval of 60 seconds. Shorter intervals don't add real economic value but multiply scheduling overhead. 60 seconds is below any reasonable subscription cadence.
Reference implementations
Cron registry system contract
// Cron registry: protocol system contract at 0x0000000000000000000000000000000000000006
({
schedule(target, method, args, nextRunAt, intervalSec, maxRuns, gasLimit) {
assert(typeof target === 'string' && /^0x[0-9a-fA-F]{40}$/.test(target), 'invalid target');
const next = BigInt(nextRunAt);
assert(next > BigInt(chain.timestamp), 'nextRunAt must be in the future');
const interval = BigInt(intervalSec);
assert(interval === 0n || interval >= 60n, 'interval must be 0 or >= 60 seconds');
const gas = BigInt(gasLimit);
assert(gas >= 21000n && gas <= 5_000_000n, 'gasLimit out of range');
const escrow = BigInt(msg.value);
assert(escrow >= gas * BigInt(chain.baseFee), 'escrow must cover at least one run');
const id = String(BigInt(storage.get('nextJobId') || '0') + 1n);
storage.set('nextJobId', id);
const job = {
id, owner: String(msg.sender).toLowerCase(),
target: target.toLowerCase(), method, args: args || [],
nextRunAt: next.toString(), intervalSec: interval.toString(),
maxRuns: BigInt(maxRuns).toString(), runsLeft: BigInt(maxRuns).toString(),
gasLimit: gas.toString(), gasEscrow: escrow.toString(),
};
storage.set('job:' + id, JSON.stringify(job));
storage.set('owner:' + job.owner + ':' + id, '1');
storage.set('due:' + job.nextRunAt + ':' + id, '1');
emit('JobScheduled', { id, owner: job.owner, target: job.target, nextRunAt: job.nextRunAt });
return id;
},
cancel(id) {
const raw = storage.get('job:' + id);
assert(raw, 'no such job');
const job = JSON.parse(raw);
assert(job.owner === String(msg.sender).toLowerCase(), 'not the owner');
storage.delete('job:' + id);
storage.delete('owner:' + job.owner + ':' + id);
storage.delete('due:' + job.nextRunAt + ':' + id);
transfer(msg.sender, BigInt(job.gasEscrow));
emit('JobCancelled', { id, owner: job.owner, refunded: job.gasEscrow });
return true;
},
topUp(id) {
const raw = storage.get('job:' + id);
assert(raw, 'no such job');
const job = JSON.parse(raw);
job.gasEscrow = (BigInt(job.gasEscrow) + BigInt(msg.value)).toString();
storage.set('job:' + id, JSON.stringify(job));
emit('JobToppedUp', { id, amount: String(msg.value), totalEscrow: job.gasEscrow });
return true;
},
getJob(id) {
const raw = storage.get('job:' + id);
return raw ? JSON.parse(raw) : null;
},
})
Test cases
A compliant cron registry MUST pass at minimum:
schedule(...)withnextRunAtin the past MUST throw.schedule(...)with insufficientmsg.valuefor at least one run MUST throw.- A one-shot job (
intervalSec = 0) fires exactly once, then is removed fromdue:. - A recurring job with
maxRuns = 3fires three times then emitsJobExhausted. - A recurring job with
maxRuns = 0fires everyintervalSecuntil escrow runs out. cancel(id)by the owner refunds the remaining escrow and removes the job.cancel(id)by anyone other than the owner MUST throw.- When the cron block-gas budget is exhausted, remaining jobs roll to the next block in the same due order.
Security considerations
Reentrancy via scheduled execution. A job calls target.method(args) at block-build time. If the target schedules a new job in the same block, that new job is NOT executed in the same block — it fires on its own next-run-at. The cron loop walks a snapshot of due jobs at block start.
Front-running cancellation. A user who calls cancel(id) the same block their job is due will race the block builder. Users who want a hard stop SHOULD cancel at least one full block before the next scheduled run.
Block-gas griefing. A spammer who creates many low-gas jobs all due at the same second cannot DoS the chain: the per-block cron gas budget caps total execution at 15M gas. Excess jobs roll forward. The spammer also pays for every run via the escrow mechanism, so the attack is self-limiting.
VRC-21 is in Draft. Ships with the next chain reset. · All standards