DEV Community

Tosh
Tosh

Posted on

Time and Deadlines in Compact: Block Time, Counters, and Practical Workarounds

Time and Deadlines in Compact: Block Time, Counters, and Practical Workarounds

Midnight's blockchain doesn't have a clock. There's no block.timestamp like Solidity or now() you can call anywhere. What it does have is block time — a monotonically increasing value the consensus layer tracks — and a set of predicates in Compact that let you enforce timing constraints directly inside your ZK circuits.

This article covers the four block-time functions, why Uint<16> counters top out at 65,535 increments, and the patterns people reach for when they need deadline or timestamp logic that survives the ZK proof.


Block Time in Compact

The Compact standard library provides four predicates for comparing the current block timestamp against a threshold you supply. All four take a Uint<64> representing seconds since Unix epoch:

blockTimeLt(deadline: Uint<64>): Boolean       // current < deadline
blockTimeGte(deadline: Uint<64>): Boolean      // current >= deadline
blockTimeGt(deadline: Uint<64>): Boolean       // current > deadline
blockTimeBetween(start: Uint<64>, end: Uint<64>): Boolean  // start <= current < end
Enter fullscreen mode Exit fullscreen mode

These are circuit predicates, not arguments you pass in. They read actual consensus state. The verifier enforces that the current block time is what you claim — a caller can't fabricate a timestamp to bypass a check.

Here's a basic escrow that releases after a deadline:

import CompactStandardLibrary;

export ledger escrow_amount: Map<Bytes<32>, Uint<64>>;
export ledger escrow_deadline: Map<Bytes<32>, Uint<64>>;
export ledger escrow_recipient: Map<Bytes<32>, Bytes<32>>;

export circuit deposit(
  escrow_id: Bytes<32>,
  recipient: Bytes<32>,
  deadline_unix: Uint<64>
): [] {
  escrow_recipient[escrow_id] = recipient;
  escrow_deadline[escrow_id] = deadline_unix;
  // amount handling via coin witness
}

export circuit release(escrow_id: Bytes<32>): [] {
  const deadline = escrow_deadline[escrow_id];
  assert blockTimeGte(deadline) "Escrow still locked";
  const amount = escrow_amount[escrow_id];
  escrow_amount[escrow_id] = 0 as Uint<64>;
  // transfer amount to escrow_recipient[escrow_id]
}
Enter fullscreen mode Exit fullscreen mode

The assertion fires if the block time hasn't reached the deadline. No passing "current time" as an argument that a caller could manipulate. The circuit handles it.

When to Use Each Function

blockTimeLt(deadline) — use for validity windows. "This action is only valid before a certain time." Token presales, early-bird pricing, cutoff periods.

export circuit early_bird_purchase(buyer: Bytes<32>): [] {
  assert blockTimeLt(presale_cutoff) "Presale has ended";
  // apply discount pricing
}
Enter fullscreen mode Exit fullscreen mode

blockTimeGte(deadline) — the mirror case. "This action is only valid at or after a certain time." Vesting releases, escrow expiry, governance vote counting.

export circuit release_vested(beneficiary: Bytes<32>): [] {
  const vest_date = vesting_schedule[beneficiary];
  assert blockTimeGte(vest_date) "Tokens not yet vested";
  // release tokens
}
Enter fullscreen mode Exit fullscreen mode

blockTimeGt(deadline) — strict greater-than. Use when you need to exclude the exact boundary second. Rarely the default, but occasionally the strict inequality matters for fairness — for example, ensuring that an action at exactly the deadline is treated as after, not before.

blockTimeBetween(start, end) — use for participation windows. "This action is valid only during a specific interval." Voting periods, auction windows, scheduled claim periods.

export ledger vote_start: Uint<64>;
export ledger vote_end: Uint<64>;
export ledger votes_for: Counter;
export ledger votes_against: Counter;

export circuit vote(in_favor: Boolean): [] {
  assert blockTimeBetween(vote_start, vote_end) "Voting window not active";
  if (in_favor) {
    votes_for += 1;
  } else {
    votes_against += 1;
  }
}
Enter fullscreen mode Exit fullscreen mode

Important: blockTimeBetween(start, end) is inclusive on start, exclusive on end. A vote submitted exactly at vote_end gets rejected. Document this in your frontend — users will be surprised if they submit right at the cutoff and it fails.


Counters and the Uint<16> Ceiling

Compact's Counter type is designed for concurrent-safe incrementing. When you declare a ledger field as a counter, the runtime allows multiple transactions in the same block to increment it without conflicting:

export ledger total_claims: Counter;

export circuit claim(): [] {
  // multiple users can call this in the same block
  total_claims += 1;
}
Enter fullscreen mode Exit fullscreen mode

The reason counters are safe to increment concurrently: they don't read their current value. Each transaction just requests an increment, and the ledger applies them all. Compare this to a normal Uint<64> field — two transactions both reading value 5 and writing 6 would conflict; one would get rejected.

The tradeoff for this concurrency property: counters use Uint<16>, which caps at 65,535. Hit that ceiling and your transaction panics.

// Perfectly fine for the first 65,535 calls
total_claims += 1;

// Transaction #65,536 panics: counter overflow
Enter fullscreen mode Exit fullscreen mode

For most use cases — daily claim counts, epoch totals, rate limit tracking — 65,535 is more than enough. For unbounded totals, you need to work around it.

Working Around the 65,535 Cap

Option 1: Multiple Counters with Epoch Rotation

Use one counter per epoch, tracked by an epoch index:

export ledger current_epoch: Uint<32>;
export ledger epoch_start: Uint<64>;
export ledger epoch_claim_count: Counter;

const EPOCH_DURATION: Uint<64> = 86400;  // 24 hours

export circuit claim(): [] {
  assert blockTimeLt(epoch_start + EPOCH_DURATION) "Use new epoch";
  epoch_claim_count += 1;
}

export circuit advance_epoch(): [] {
  assert blockTimeGte(epoch_start + EPOCH_DURATION) "Epoch still active";
  epoch_start = epoch_start + EPOCH_DURATION;
  current_epoch = current_epoch + 1;
  // epoch_claim_count effectively resets for the new epoch context
  // track by epoch_number off-chain
}
Enter fullscreen mode Exit fullscreen mode

The counter resets semantically when you advance to a new epoch — you stop caring about epoch_claim_count from the previous epoch and start a fresh one.

Option 2: High/Low Split for Cumulative Totals

When you need to track a true cumulative count beyond 65,535, pair a Counter with a regular Uint<64> field:

export ledger total_hi: Uint<32>;        // each unit = 65,535
export ledger total_lo: Counter;          // counts within 0..65,535

export circuit record_event(): [] {
  total_lo += 1;
  // Note: checking total_lo == 65,535 isn't possible inside circuit
  // Use a witness to carry in the current count for rollover detection
}

export circuit rollover_counter(current_count: Uint<16>): [] {
  // Caller provides current_count as witness; contract verifies via state proof
  assert current_count == 65535 as Uint<16> "Not at ceiling";
  total_hi = total_hi + 1;
  // total_lo is now 65,535 — next increment will panic unless you handle it
}
Enter fullscreen mode Exit fullscreen mode

The limitation: you can't read a counter's value inside the circuit to check for rollover automatically. You need an off-chain component to watch the count and submit the rollover transaction before the cap is hit.


Timestamps Without a Clock: The Hours-Since-Epoch Pattern

Sometimes you need to store a timestamp in ledger state — not just compare against a deadline, but record when something happened so you can compute elapsed time later.

Storing full Unix timestamps as Uint<64> works, but it's larger than necessary. A cleaner approach: store hours since Unix epoch as Uint<32>.

Unix epoch seconds / 3600 = hours since epoch. Uint<32> gives you coverage through year 2106. More than enough.

In your TypeScript service layer:

function currentHoursSinceEpoch(): number {
  return Math.floor(Date.now() / 1000 / 3600);
}

function hoursSinceEpochToDate(hours: number): Date {
  return new Date(hours * 3600 * 1000);
}
Enter fullscreen mode Exit fullscreen mode

In your contract:

export ledger last_action_hour: Uint<32>;

export circuit record_action(hour: Uint<32>): [] {
  // Verify hour is plausible (within last 60 minutes of block time)
  const hour_as_seconds: Uint<64> = (hour as Uint<64>) * 3600;
  assert blockTimeGte(hour_as_seconds) "Hour is in the future";
  assert blockTimeLt(hour_as_seconds + 3600) "Hour is too old";

  last_action_hour = hour;
}

export circuit elapsed_hours(current_hour: Uint<32>): Uint<32> {
  return current_hour - last_action_hour;
}
Enter fullscreen mode Exit fullscreen mode

The assertion brackets ensure the passed-in hour value corresponds to the actual current block time — close enough for coarse timestamps, not forgeable.

The deadline_hi + deadline_lo Pattern

When you need to store a 64-bit deadline in ledger state but want to work in smaller integer types, split it:

export ledger deadline_hi: Uint<32>;
export ledger deadline_lo: Uint<32>;

export circuit set_deadline(deadline_unix: Uint<64>): [] {
  deadline_hi = (deadline_unix >> 32) as Uint<32>;
  deadline_lo = (deadline_unix & 0xFFFFFFFF) as Uint<32>;
}

export circuit check_deadline(): Boolean {
  const full_deadline: Uint<64> =
    ((deadline_hi as Uint<64>) << 32) | (deadline_lo as Uint<64>);
  return blockTimeGte(full_deadline);
}
Enter fullscreen mode Exit fullscreen mode

The circuit compiler handles the bitwise operations correctly. This pattern shows up in contracts that store many deadline values in a map — keeping each value as two Uint<32> fields saves circuit size compared to Uint<64> fields.

In TypeScript, assembling the two halves before passing them to the contract:

function splitDeadline(unixSeconds: bigint): { hi: bigint; lo: bigint } {
  return {
    hi: unixSeconds >> 32n,
    lo: unixSeconds & 0xFFFFFFFFn,
  };
}

function joinDeadline(hi: bigint, lo: bigint): bigint {
  return (hi << 32n) | lo;
}
Enter fullscreen mode Exit fullscreen mode

Querying Block Height from the Indexer

Your service layer often needs to compute deadline timestamps relative to the current block. The Midnight Indexer exposes a GraphQL API for this.

Install a GraphQL client:

yarn add graphql-request graphql
Enter fullscreen mode Exit fullscreen mode

Set up the indexer client:

import { GraphQLClient, gql } from 'graphql-request';

const INDEXER_URL = process.env.MIDNIGHT_INDEXER_URL
  ?? 'https://indexer.testnet-02.midnight.network/api/v1/graphql';

const indexerClient = new GraphQLClient(INDEXER_URL);
Enter fullscreen mode Exit fullscreen mode

Query the current block:

const CURRENT_BLOCK_QUERY = gql`
  query CurrentBlock {
    block {
      height
      timestamp
    }
  }
`;

interface CurrentBlockResponse {
  block: {
    height: number;
    timestamp: string;  // ISO 8601
  };
}

export async function getCurrentBlock(): Promise<{
  height: number;
  timestamp: Date;
  unixSeconds: bigint;
}> {
  const data = await indexerClient.request<CurrentBlockResponse>(CURRENT_BLOCK_QUERY);
  const timestamp = new Date(data.block.timestamp);
  return {
    height: data.block.height,
    timestamp,
    unixSeconds: BigInt(Math.floor(timestamp.getTime() / 1000)),
  };
}
Enter fullscreen mode Exit fullscreen mode

Computing a deadline relative to now:

export async function computeDeadlineAt(hoursFromNow: number): Promise<bigint> {
  const { unixSeconds } = await getCurrentBlock();
  return unixSeconds + BigInt(hoursFromNow * 3600);
}
Enter fullscreen mode Exit fullscreen mode

For real-time updates — for example, showing the user a countdown to a deadline — subscribe to new blocks over WebSocket:

import { createClient } from 'graphql-ws';

const wsClient = createClient({
  url: INDEXER_URL.replace('https://', 'wss://').replace('http://', 'ws://'),
});

export function subscribeToBlockHeight(
  onBlock: (height: number, timestamp: Date) => void
): () => void {
  const unsubscribe = wsClient.subscribe(
    {
      query: `subscription {
        blocks {
          height
          timestamp
        }
      }`,
    },
    {
      next: ({ data }) => {
        if (data?.blocks) {
          onBlock(data.blocks.height, new Date(data.blocks.timestamp));
        }
      },
      error: console.error,
      complete: () => {},
    }
  );
  return unsubscribe;
}
Enter fullscreen mode Exit fullscreen mode

In a React hook:

import { useState, useEffect } from 'react';
import { subscribeToBlockHeight } from '../lib/indexer';

export function useBlockHeight(): number | null {
  const [height, setHeight] = useState<number | null>(null);

  useEffect(() => {
    const unsubscribe = subscribeToBlockHeight((h) => setHeight(h));
    return unsubscribe;
  }, []);

  return height;
}
Enter fullscreen mode Exit fullscreen mode

A Complete Example: Time-Locked Vault

Here's a contract that uses block time for a time-locked vault with counter-based withdrawal limits:

import CompactStandardLibrary;

export ledger vault_amount: Uint<64>;
export ledger unlock_time: Uint<64>;
export ledger withdrawals_this_day: Counter;
export ledger day_start: Uint<64>;

const MAX_WITHDRAWALS_PER_DAY: Uint<16> = 10;
const ONE_DAY: Uint<64> = 86400;

export circuit initialize(unlock_unix: Uint<64>): [] {
  unlock_time = unlock_unix;
  day_start = unlock_unix;
}

export circuit withdraw(amount: Uint<64>, current_day_withdrawals: Uint<16>): [] {
  // Must be past unlock time
  assert blockTimeGte(unlock_time) "Vault still locked";

  // Handle day reset (caller provides current count as witness)
  if (blockTimeGte(day_start + ONE_DAY)) {
    day_start = day_start + ONE_DAY;
    // withdrawals_this_day starts fresh for the new day
  } else {
    // Enforce daily limit — caller provides count, circuit validates
    assert current_day_withdrawals < MAX_WITHDRAWALS_PER_DAY "Daily limit reached";
    withdrawals_this_day += 1;
  }

  assert vault_amount >= amount "Insufficient balance";
  vault_amount = vault_amount - amount;
}
Enter fullscreen mode Exit fullscreen mode

And the TypeScript service that calls it:

import { getCurrentBlock, computeDeadlineAt } from './indexer';
import { VaultContract } from './generated/vault';

async function withdrawFromVault(
  contract: VaultContract,
  amount: bigint,
  currentDayWithdrawals: number
): Promise<void> {
  const { unixSeconds } = await getCurrentBlock();

  await contract.callTx.withdraw(amount, BigInt(currentDayWithdrawals));
}

async function initializeVault(
  contract: VaultContract,
  lockForHours: number
): Promise<void> {
  const unlockTime = await computeDeadlineAt(lockForHours);
  await contract.callTx.initialize(unlockTime);
  console.log(`Vault locked until Unix timestamp ${unlockTime}`);
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

Milliseconds vs. seconds: JavaScript Date.now() returns milliseconds. The Midnight block-time functions expect seconds. Always divide by 1000 before passing to a contract.

// Wrong
const deadline = BigInt(Date.now() + 3600000);  // milliseconds

// Right
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);  // seconds
Enter fullscreen mode Exit fullscreen mode

Trying to read counter values in circuits: Counters can't be compared inside a circuit. If you need conditional logic based on count, track it separately with a normal Uint<64> field and increment both.

Off-by-one on blockTimeBetween: The end is exclusive. If a window closes at noon, the call at exactly noon fails. Add a buffer or communicate the exact semantics in your UI.

Counter underflow: counter -= 1 panics when the counter is at 0. Guard all decrements.

Stale block time estimates: When computing deadlines on the client, fetch the current block time immediately before building the transaction, not at app load. Block time drifts.

Block time in Midnight gives you the primitives you need for deadline enforcement. The constraints are that timestamps are coarse (block-level, not sub-second) and that counter values can't be read inside circuits. Design around these constraints from the start and they stop feeling like limitations.

Top comments (0)