Standards/ARC-21

ARC-21: Scheduled Execution and Subscriptions.

A protocol-level cron registry for AsentumChain, and an ARC-20-compatible subscriptions standard built on top of it. No keeper bots, no third-party executors, no missed schedules.

NumberARC-21
TitleScheduled Execution and Subscriptions
StatusDraft
TypeStandards Track
CategoryARC (Application)
Created2026-05-17
RequiresARC-20 (for token denomination)

Abstract

ARC-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 interfacefor ARC-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.

ARC-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 address0x0000000000000000000000000000000000000006 at chain genesis. Its source is part of the chain release artifact and is not deployed via a user transaction.

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, emitJobExhausted 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, updatenextRunAt = previousNextRunAt + intervalSec, decrementrunsLeft, 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.
  6. Proceed to normal mempool processing for the rest of the block's gas.

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)

Any contract that wants to accept recurring ARC-20 payments SHOULD implement this interface. It's not a system contract; merchants deploy their own instances.

approveSubscription(token, merchant, amount, intervalSec, maxCharges) -> id

Customer authorises a recurring charge. Token must be an ARC-20. Customer must have called token.approve(...) for the merchant contract for at least amount * maxCharges. Schedules the first charge via the cron registry. Returns the subscription id.

chargeSubscription(id)

Called by the cron registry (and only by the cron registry) on the configured schedule. Transfers one charge's worth from customer to merchant. On failure (insufficient allowance, insufficient balance), emits SubscriptionFailed but does not cancel the subscription.

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).

Events

JobScheduledfrom cron

{ id, owner, target, nextRunAt }

JobExecutedfrom cron

{ id, success, gasUsed }

JobCancelledfrom cron

{ id, owner, refunded }

JobToppedUpfrom cron

{ id, amount, totalEscrow }

JobExhaustedfrom cron

{ id, reason }

SubscriptionApprovedfrom subscription contract

{ id, customer, merchant }

SubscriptionChargedfrom subscription contract

{ id, amount, chargesLeft }

SubscriptionFailedfrom subscription contract

{ id, reason }

SubscriptionCancelledfrom subscription contract

{ id }

Rationale

Why pre-deploy as a system contract

The cron registry has to exist before any user contract can reference it, and its address has to be stable so dapps can hardcode it. Putting it at0x...06 at genesis solves both. It also means the registry can't be redeployed by an attacker who front-runs a regular deploy.

Why a gas escrow per job

Block-builder-driven execution still costs gas. Someone has to pay it. A per-job prepaid escrow keeps the accounting local: the job runs as long as its escrow can fund a run, then emits JobExhausted and stops. This avoids both spam (a job with no escrow does nothing) and unbounded liability (the chain never runs a job for free).

Why a per-block cron gas budget

Without a cap, a popular schedule (e.g. a million subscriptions all due at midnight UTC) would crowd out regular transactions in that block. Capping cron at 15M gas (50 percent of the 30M block gas limit at v1) leaves room for normal use, and jobs that don't fit just roll to the next block.

Why a minimum interval of 60 seconds

Shorter intervals don't add real economic value but they multiply scheduling overhead. 60 seconds is below any reasonable subscription cadence and far above what would be useful as a high-frequency execution mechanism (which is a different problem with different solutions).

Why the subscriptions interface is separate from cron

Cron is general purpose: schedule any contract call. Subscriptions are specifically about ARC-20 transfer authorisations on a schedule. Keeping them separate means wallets can present a clean "manage subscriptions" view without needing to introspect every cron job to see which are subscription charges.

Reference implementations

Cron registry system contract

// Cron registry: protocol system contract at the reserved address
// 0x0000000000000000000000000000000000000006. Pre-deployed at genesis.
//
// Storage layout:
//   job:<id>                → JSON { id, owner, target, method, args, nextRunAt, intervalSec, maxRuns, runsLeft, gasLimit, gasEscrow }
//   owner:<addr>:<id>       → "1"   (index for "list my jobs")
//   nextJobId               → uint counter
//   due:<unixSec>:<id>      → "1"   (block builder walks this prefix)

({
  // Schedule a recurring or one-shot call.
  // intervalSec = 0 means "fire once at nextRunAt and stop".
  // maxRuns = 0 means "run forever (until cancelled or escrow runs out)".
  schedule(target, method, args, nextRunAt, intervalSec, maxRuns, gasLimit) {
    assert(typeof target === 'string' && /^0x[0-9a-fA-F]{40}$/.test(target), 'invalid target');
    assert(typeof method === 'string' && method.length > 0, 'method required');
    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');
    // msg.value funds the gas escrow. Each run debits gas at the
    // prevailing base fee.
    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 a scheduled job. Refunds the remaining escrow to the owner.
  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;
  },

  // Add more escrow to an existing job (extend its runway).
  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;
  },

  // Read a job.
  getJob(id) {
    const raw = storage.get('job:' + id);
    return raw ? JSON.parse(raw) : null;
  },
})

Subscription merchant contract

// ARC-21 subscriptions reference implementation.
// A merchant deploys this once. Customers approve subscriptions.
// The chain's cron layer charges them on schedule.

const CRON = '0x0000000000000000000000000000000000000006';

({
  // Customer approves a recurring charge.
  //   token        ARC-20 contract address (e.g. aUSD)
  //   merchant     who gets paid
  //   amount       per-charge amount (decimal string)
  //   intervalSec  charge frequency, e.g. 2592000 = 30 days
  //   maxCharges   0 = unlimited, otherwise stops after N
  approveSubscription(token, merchant, amount, intervalSec, maxCharges) {
    const sub = {
      customer: String(msg.sender).toLowerCase(),
      merchant: String(merchant).toLowerCase(),
      token: String(token).toLowerCase(),
      amount: String(amount),
      intervalSec: String(intervalSec),
      chargesLeft: String(maxCharges),
      lastChargeAt: '0',
    };
    const id = String(BigInt(storage.get('nextSubId') || '0') + 1n);
    storage.set('nextSubId', id);
    storage.set('sub:' + id, JSON.stringify(sub));

    // The customer must have already called token.approve(thisContract, ...)
    // for the full amount they're authorising. We don't enforce that here
    // so the user can extend their allowance later if they want.

    // Schedule the first charge via the protocol cron registry.
    const firstChargeAt = BigInt(chain.timestamp) + BigInt(intervalSec);
    E(CRON).schedule(
      chain.thisAddress,
      'chargeSubscription',
      [id],
      firstChargeAt.toString(),
      String(intervalSec),
      String(maxCharges),
      '100000',  // gasLimit per charge
    );

    emit('SubscriptionApproved', { id, customer: sub.customer, merchant: sub.merchant });
    return id;
  },

  // Called by the cron registry. Transfers one charge from customer to merchant.
  chargeSubscription(id) {
    assert(String(msg.sender).toLowerCase() === CRON, 'caller must be cron registry');
    const raw = storage.get('sub:' + id);
    assert(raw, 'no such subscription');
    const sub = JSON.parse(raw);
    if (BigInt(sub.chargesLeft) === 0n) return false;

    // transferFrom the customer to the merchant via the ARC-20 token contract.
    const ok = E(sub.token).transferFrom(sub.customer, sub.merchant, sub.amount);
    if (!ok) {
      emit('SubscriptionFailed', { id, reason: 'transferFrom returned false' });
      return false;
    }

    sub.chargesLeft = (BigInt(sub.chargesLeft) - 1n).toString();
    sub.lastChargeAt = String(chain.timestamp);
    storage.set('sub:' + id, JSON.stringify(sub));

    emit('SubscriptionCharged', { id, amount: sub.amount, chargesLeft: sub.chargesLeft });
    return true;
  },

  cancelSubscription(id) {
    const raw = storage.get('sub:' + id);
    assert(raw, 'no such subscription');
    const sub = JSON.parse(raw);
    assert(sub.customer === String(msg.sender).toLowerCase(), 'not the customer');
    storage.delete('sub:' + id);
    emit('SubscriptionCancelled', { id });
    return true;
  },
})

Test cases

A compliant cron registry MUST pass at minimum the following behavioural tests:

  1. schedule(...) with nextRunAt in the past MUST throw.
  2. schedule(...) with insufficient msg.value for at least one run MUST throw.
  3. A one-shot job (intervalSec = 0) fires exactly once at the scheduled time, then is removed from due:.
  4. A recurring job with maxRuns = 3 fires three times then emits JobExhausted.
  5. A recurring job with maxRuns = 0 fires every intervalSec until its escrow runs out, then emits JobExhausted.
  6. cancel(id) by the owner refunds the remaining escrow and removes the job.
  7. cancel(id) by anyone other than the owner MUST throw.
  8. 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 is malicious and schedules a new job at the same block, that new job is NOT executed in the same block (it's added to the registry; it'll fire 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. If the block builder's cron pass runs first, the cancel goes through but the job has already executed once. Implementations SHOULD document this. Users who want a hard stop SHOULD cancel at least one full block before the next scheduled run.

Allowance erosion for subscriptions

The subscriptions interface uses ARC-20 transferFrom, which means a subscription effectively spends down the customer's pre-approved allowance. If the allowance runs out, chargeSubscription emits SubscriptionFailed and the subscription continues to schedule (it doesn't auto-cancel) so the customer can top up the allowance and resume.

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 simply roll forward. The spammer also pays for every run via the escrow mechanism, so the attack is self-limiting.

ARC-21 is in Draft. Ships with the next chain reset.