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.

NumberVRC-21
TitleScheduled Execution and Subscriptions
StatusDraft
TypeStandards Track
CategoryVRC (Application)
Created2026-05-17
RequiresVRC-20 (for token denomination)

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.

schedule(target, method, args, nextRunAt, intervalSec, maxRuns, gasLimit) → string
Register a scheduled call. nextRunAt is an absolute unix timestamp in seconds. intervalSec is 0 for a one-shot, or >= 60 for recurring. maxRuns is 0 for "until escrow runs out" or N for "exactly N runs". msg.value funds the gas escrow. Returns the job id.
cancel(id) → bool
Cancel a job. Only callable by the job's owner. Remaining escrow is refunded to the owner via transfer. Emits JobCancelled.
topUp(id)
Add msg.value to a job's escrow. Anyone can top up any job (good for sponsored execution). Emits JobToppedUp.
getJob(id) → object
Read view. Returns the full job record or null. Used by UIs to display upcoming runs.

Block builder behaviour

On each new block at timestamp T, the block builder MUST:

  1. Walk the cron registry storage keys due:<ts>:<id> for every ts ≤ T, in ascending order.
  2. For each due job, deduct job.gasLimit * baseFee from the job's escrow. If escrow is insufficient, emit JobExhausted and remove the job.
  3. Otherwise execute target.method(args) with the configured gas. Emit JobExecuted with the result.
  4. If the job is recurring and has runs left, update nextRunAt, decrement runsLeft, re-insert the due key.
  5. 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)

approveSubscription(token, merchant, amount, intervalSec, maxCharges) → id
Customer authorises a recurring charge. Token must be a VRC-20. Schedules the first charge via the cron registry. Returns the subscription id.
chargeSubscription(id)
Called by the cron registry on the configured schedule. Transfers one charge's worth from customer to merchant. On failure, emits SubscriptionFailed but does not cancel.
cancelSubscription(id)
Customer cancels. Cron job stays scheduled but chargeSubscription becomes a no-op. The cron escrow can be reclaimed by calling cron.cancel(jobId).

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:

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