DEV Community

Cover image for Converting Unix Timestamps to Readable Dates in ink! Smart Contracts
Mulandi Cecilia
Mulandi Cecilia

Posted on

Converting Unix Timestamps to Readable Dates in ink! Smart Contracts

Introduction

Imagine you're building a token vesting smart contract on a Substrate blockchain. Your contract stores critical dates like when vesting begins, when it ends, when tokens can be claimed. You deploy your contract, everything works perfectly, and then a user asks: "When exactly can I claim my tokens?"

You check the contract storage and see: start_time: 1729512000000.

What does that mean? Is that October? November? 2024 or 2025? Without context, this number is completely opaque; not just to your users, but potentially to you as the developer trying to debug your contract or verify that the vesting schedule is correct.

This is the fundamental challenge of working with time in blockchain smart contracts: blockchains store time as Unix timestamps; raw integers representing milliseconds (or seconds) since January 1, 1970 UTC. This format is perfect for blockchains because it's:

  • Deterministic: Every node agrees on what the number means.
  • Compact: Just a single u64 integer.
  • Easy to compare: Simple arithmetic for "before" and "after" logic.

But it's terrible for humans. The number 1729512000000 doesn't tell you at a glance that it represents October 21, 2024 at 10:00:00 UTC.

The Problem

In traditional backend development, you'd reach for libraries like chrono (Rust), or built-in date utilities to convert timestamps into readable formats like 2024-10-21 10:00:00 UTC or October 21, 2024 at 10:00 AM.

But ink! smart contracts run in a no_std environment; a minimal Rust environment without access to the standard library. This means most popular time formatting libraries simply aren't available. You can't just use chrono::* and call it a day.

The Goal

This article will show you practical approaches for converting Unix timestamps to human-readable formats when building ink! smart contracts. We'll explore:

  • How timestamps work in Substrate and ink! smart contracts
  • When and why you need readable timestamps
  • Arithmetic-based conversion directly in your contract (with trade-offs)
  • Off-chain conversion in your frontend or indexer (the recommended approach)
  • Testing strategies to ensure your time-based logic works correctly

By the end, you'll understand how to handle timestamps effectively in your ink! contracts;keeping your on-chain logic lean and deterministic while still providing excellent user experiences with readable dates.

Let's start by understanding how time actually works on Substrate-based blockchains.

Background: How Time Works on Substrate and ink!

The Timestamp Pallet

Substrate-based blockchains handle time through the Timestamp pallet, a core runtime module that provides a single, authoritative source of time for the entire blockchain. Here's how it works:

  1. Block producers set the time: When a validator or collator produces a new block, they include a timestamp representing the current time (in milliseconds since the Unix epoch).
  2. Consensus validation: Other nodes verify that this timestamp is reasonable;it must be greater than the previous block's timestamp and close to the actual current time.
  3. On-chain reference: Once the block is finalized, that timestamp becomes the canonical "now" for all smart contracts and pallets executing in that block.

This means that time only advances when new blocks are produced. If you query the timestamp multiple times within the same transaction, you'll always get the same value. Time is deterministic and consistent across all nodes.

Accessing Time in ink! Smart Contracts

In your ink! smart contract, you access the current blockchain time using the environment API:

let current_time = self.env().block_timestamp();
Enter fullscreen mode Exit fullscreen mode

This returns a u64 representing milliseconds since January 1, 1970 00:00:00 UTC (the Unix epoch).

NOTE: Substrate's timestamp pallet uses milliseconds, not seconds. This is different from traditional Unix timestamps which are often expressed in seconds. When you see a timestamp like 1729512000000, that's milliseconds, which equals 1729512000 seconds.

Here is how it would appear in a typical vesting contract:

#[ink(message)]
pub fn claim_vested(&mut self) -> Result<Balance> {
    let caller = self.env().caller();
    let current_time = self.env().block_timestamp(); // milliseconds since epoch

    // Retrieve the vesting schedule
    let mut schedule = self.schedules.get(caller).ok_or(Error::NoVestingSchedule)?;

    // Confirm that vesting has started
    if current_time < schedule.start_time {
        return Err(Error::VestingNotStarted);
    }

    // vesting logic...
}
Enter fullscreen mode Exit fullscreen mode

The no_std Constraint

ink! v6 smart contracts compile to PolkaVM and run in a no_std environment. This means:

  • No standard library access: Features like file I/O, networking, and most date/time utilities aren't available
  • Limited dependencies: Only crates that support no_std can be used
  • Popular libraries unavailable: Common Rust time libraries like chrono and time require std by default

This constraint exists for good reasons:

  • Determinism: Standard library features often rely on system calls that could produce different results on different nodes
  • Security: Limiting capabilities reduces the attack surface

What You Can and Can't Do

You CAN:

  • Store timestamps as u64 integers
  • Compare timestamps (<, >, ==)
  • Perform arithmetic on timestamps (add/subtract durations)
  • Pass timestamps in messages and events
  • Use timestamps for time-based contract logic

You CAN'T (easily):

  • Format timestamps as human-readable strings in-contract
  • Parse date strings into timestamps
  • Handle timezone conversions
  • Use calendar-aware operations (e.g. "add 1 month")

Conversion Approaches

Given these constraints, there are two main approaches for converting timestamps to readable formats:

  1. Lightweight, no_std-compatible date conversions: Manually implement the date/time arithmetic within your contract. This keeps everything on-chain but adds code complexity and gas costs.
  2. Off-chain rendering: Store and manipulate timestamps as integers on-chain, then convert them to readable formats in your frontend, indexer, or API layer. This is the recommended approach for most use cases.

The rest of this article will explore both approaches, helping you choose the right one for your needs.

Why You Might Need Readable Timestamps

Before diving into conversion techniques, let's understand the scenarios where readable timestamps actually matter. Not every contract needs human-readable dates; many can work perfectly well with raw Unix timestamps. However, there are several compelling use cases where conversion becomes valuable.

1. Event Logs and User-Facing Data

Events are one of the primary ways smart contracts communicate with the outside world. When you emit an event with a timestamp, you want it to be useful for monitoring, debugging, and user interfaces.

Consider this event from a vesting contract:

#[ink(event)]
pub struct TokensClaimed {
    #[ink(topic)]
    beneficiary: H160,
    amount: Balance,
  // Raw timestamp
    claimed_at: u64,  
}
Enter fullscreen mode Exit fullscreen mode

When indexers, block explorers, or frontends consume this event, they need to display when the claim happened. While you could convert the timestamp off-chain (and often should), there are cases where having a readable format in the event itself can be helpful:

  • Debugging during development: Quickly seeing "Oct 21, 2024 10:00 UTC" is easier than mentally converting timestamps
  • Direct event monitoring: Tools that listen to blockchain events can display human-readable dates without additional processing
  • Cross-chain or multi-system integration: Systems that consume your events might not have sophisticated date conversion capabilities

2. Contract State Queries and View Functions

Users often want to check time-related information from your contract:

  • "When does my vesting period end?"
  • "What's the deadline for this governance proposal?"
  • "When can I claim my rewards?"

While you can return raw timestamps and convert them in your UI, providing a view function that returns formatted dates can improve developer experience:

#[ink(message)]
pub fn get_vesting_info(&self, beneficiary: H160) -> Option<VestingInfo> {
    let schedule = self.schedules.get(beneficiary)?;
    Some(VestingInfo {
        total_amount: schedule.total_amount,
        start_time: schedule.start_time,
        end_time: schedule.end_time,
// Example: "2024-01-15"
        start_date_readable: self.format_timestamp(schedule.start_time), 
 // Example: "2025-01-15"
        end_date_readable: self.format_timestamp(schedule.end_time),    
    })
}
Enter fullscreen mode Exit fullscreen mode

3. Testing and Debugging

During development, raw timestamps make testing and debugging painful. When you're writing tests, you must use Unix timestamps, but these numbers are impossible to understand at a glance.

Consider this test that's failing:

#[ink::test]
fn test_vesting_claim() {
    let mut contract = VestingScheduler::new();
    let beneficiary = H160::from([1u8; 20]);

    let start = 1729512000000;
    let end = 1737374400000;

    contract.create_vesting_schedule(beneficiary, 1_000_000, start, end).unwrap();

    // Test fails here - but why?
    // Is the vesting period correct?
    // Are these dates even realistic?
    // You can't tell just by looking at the numbers!
}
Enter fullscreen mode Exit fullscreen mode

When this test fails, you face several problems:

  • You can't tell if start is in October or December without converting it
  • You don't know if the vesting period is 30 days or 90 days
  • You can't verify if the dates make logical sense for your use case
  • You need to paste the numbers into a converter tool just to debug

Solution: Add comments to make tests readable

#[ink::test]
fn test_vesting_claim() {
    let mut contract = VestingScheduler::new();
    let beneficiary = H160::from([1u8; 20]);

    // Now it's immediately clear what's being tested
    let start = 1729512000000; // Oct 21, 2024, 10:00 AM UTC
    let end = 1737374400000;   // Jan 20, 2025, 10:00 AM UTC (3-month vesting period)

    contract.create_vesting_schedule(beneficiary, 1_000_000, start, end).unwrap();
}
Enter fullscreen mode Exit fullscreen mode

The ability to quickly understand what timestamps represent;without reaching for conversion tools; dramatically speeds up development and debugging. This is especially valuable when:

  • Debugging edge cases around vesting boundaries
  • Verifying that test scenarios match business requirements

4. Cross-Chain and Integration Scenarios

If your contract interacts with off-chain systems, oracles, or cross-chain bridges, having standardized, readable date formats can simplify integration:

  • API responses that include blockchain event data
  • Indexers that aggregate data from multiple chains
  • Analytics dashboards that need to correlate on-chain events with real-world timelines
  • Compliance and audit trails that require human-readable timestamps

That said, for most of these scenarios, off-chain conversion is still the better choice. But understanding when and why you might need readable timestamps helps you make informed architectural decisions.

The Unix Timestamp Refresher

Before we dive into conversion techniques, let's ensure we're all on the same page about Unix timestamps.

What Is a Unix Timestamp?

A Unix timestamp is the number of seconds (or milliseconds) that have elapsed since the Unix epoch: January 1, 1970 at 00:00:00 UTC. This moment was arbitrarily chosen as the "zero point" for computer time systems.

Key Concepts:

  • Epoch: The starting point (1970-01-01 00:00:00 UTC)
  • UTC: Coordinated Universal Time; no timezone offsets or daylight saving adjustments
  • Monotonic: Time only moves forward (ignoring leap seconds for simplicity)

Conversion Example

Let's break down the timestamp from our vesting contract: 1729512000000

First, remember that Substrate uses milliseconds, so we need to convert to seconds:

1729512000000 milliseconds ÷ 1000 = 1729512000 seconds

1729512000 seconds since epoch
÷ 60 = 28,825,200 minutes
÷ 60 = 480,420 hours  
÷ 24 = 20,017.5 days
÷ 365.25 ≈ 54.8 years

1970 + 54.8 years ≈ October 2024
Enter fullscreen mode Exit fullscreen mode

More precisely: 1729512000000 ms = October 21, 2024 at 10:00:00 UTC

This is the exact timestamp used in our contract's tests and examples throughout this article.

Substrate's Millisecond Timestamps

NOTE: While traditional Unix timestamps use seconds, Substrate's timestamp pallet uses milliseconds. So in your ink! contract when you see:

let current_time = self.env().block_timestamp();
// Returns: 1729512000000 (note the extra three zeros)
Enter fullscreen mode Exit fullscreen mode

That's 1729512000000 milliseconds, which equals 1729512000 seconds.

Conversion formula:

let seconds = milliseconds / 1000;
let milliseconds = seconds * 1000;
Enter fullscreen mode Exit fullscreen mode

Why Milliseconds?

Substrate uses milliseconds for greater precision. While blocks are typically produced every few seconds (6 seconds on Polkadot, 12 seconds on many parachains), millisecond precision:

  • Allows for sub-second timing in high-frequency applications
  • Provides headroom for future optimizations
  • Maintains consistency across different chain configurations

Now that we understand the foundation, let's explore how to convert these timestamps into readable formats.

Approaches to Conversion

There are two primary strategies for converting Unix timestamps to human-readable dates in ink! contracts: in-contract arithmetic conversion and off-chain conversion. Each has distinct trade-offs.

Approach 1: Arithmetic-Only Conversion (In-Contract)

You can implement date conversion logic directly in your smart contract using pure arithmetic. This keeps everything on-chain but adds complexity and gas costs. Our vesting contract demonstrates this approach.

The DateTime Structure

First, we need a structure to represent a human-readable date:

#[ink::scale_derive(Encode, Decode, TypeInfo)]
#[cfg_attr(feature = "std", derive(ink::storage::traits::StorageLayout))]
pub struct DateTime {
    pub year: u32,
    pub month: u8,
    pub day: u8,
    pub hour: u8,
    pub minute: u8,
    pub second: u8,
}
Enter fullscreen mode Exit fullscreen mode

This simple struct holds the components of a date and time, all in UTC.

Core Conversion Function

The main conversion function breaks down a timestamp into its components:

fn timestamp_to_datetime(&self, timestamp_ms: u64) -> DateTime {
    // Convert milliseconds to seconds
    let timestamp = timestamp_ms / 1000;

    // Calculate seconds, minutes, hours
    let second = (timestamp % 60) as u8;
    let minutes_total = timestamp / 60;
    let minute = (minutes_total % 60) as u8;
    let hours_total = minutes_total / 60;
    let hour = (hours_total % 24) as u8;
    let days_total = hours_total / 24;

    // Calculate year (accounting for leap years)
    let mut year = 1970u32;
    let mut remaining_days = days_total;

    // Keep subtracting full years until we have less than 365 days left
    while remaining_days >= 365 {
        let days_in_year = if Self::is_leap_year(year) { 366 } else { 365 };
        remaining_days -= days_in_year;
        year += 1;
    }

    // Calculate month and day
    let (month, day) = Self::days_to_month_day(remaining_days as u32, year);

    DateTime {
        year,
        month,
        day,
        hour,
        minute,
        second,
    }
}

fn is_leap_year(year: u32) -> bool {
    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
Enter fullscreen mode Exit fullscreen mode

This function handles:

  • Milliseconds to seconds conversion
  • Basic time components (hours, minutes, seconds)
  • Year calculation with leap year handling
  • Month and day calculation

Leap Year Handling

Leap years complicate date calculations. The contract includes proper leap year logic:

fn is_leap_year(year: u32) -> bool {
    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
Enter fullscreen mode Exit fullscreen mode

This follows the standard rules:

  • Years divisible by 4 are leap years
  • EXCEPT years divisible by 100 are not leap years
  • EXCEPT years divisible by 400 ARE leap years

So: 2024 is a leap year, 2100 is not, 2000 was.

Month and Day Calculation

Converting a day-of-year to month and day requires accounting for varying month lengths:

fn days_to_month_day(day_of_year: u32, year: u32) -> (u8, u8) {
    let is_leap = Self::is_leap_year(year);
    let days_in_months = if is_leap {
        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    } else {
        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    };

    let mut remaining = day_of_year;
    for (i, &days) in days_in_months.iter().enumerate() {
        if remaining < days {
            return ((i + 1) as u8, (remaining + 1) as u8);
        }
        remaining = remaining.saturating_sub(days);
    }

    // Fallback (shouldn't reach here with valid input)
    (12, 31)
}
Enter fullscreen mode Exit fullscreen mode

This iterates through months, subtracting days until we find which month the day falls in.

Formatting to String

For events and view functions, we need to format the DateTime as a readable string:

fn format_datetime(&self, dt: DateTime) -> [u8; 19] {
    let mut result = [b'0'; 19];

    // Format: YYYY-MM-DD HH:MM:SS
    // Year (4 digits)
    Self::write_u32(&mut result[0..4], dt.year);
    result[4] = b'-';
    // Month (2 digits)
    Self::write_u8(&mut result[5..7], dt.month);
    result[7] = b'-';
    // Day (2 digits)
    Self::write_u8(&mut result[8..10], dt.day);
    result[10] = b' ';
    // Hour (2 digits)
    Self::write_u8(&mut result[11..13], dt.hour);
    result[13] = b':';
    // Minute (2 digits)
    Self::write_u8(&mut result[14..16], dt.minute);
    result[16] = b':';
    // Second (2 digits)
    Self::write_u8(&mut result[17..19], dt.second);

    result
}
Enter fullscreen mode Exit fullscreen mode

Note: We use a fixed-size byte array [u8; 19] because String operations are cumbersome in no_std. This gives us a compact, deterministic format.

Using the Conversion in Your Contract

The contract demonstrates two ways to use these conversion functions:

1. In Events with Readable Timestamps

#[ink(event)]
pub struct TokensClaimedReadable {
    #[ink(topic)]
    beneficiary: H160,
    amount: Balance,
    claimed_at: u64,
    /// Readable format: [Y,Y,Y,Y,-,M,M,-,D,D, ,H,H,:,M,M,:,S,S]
    claimed_at_readable: [u8; 19],
}
Enter fullscreen mode Exit fullscreen mode
// In the claim_vested function
let dt = self.timestamp_to_datetime(current_time);
self.env().emit_event(TokensClaimedReadable {
    beneficiary: caller,
    amount: claimable,
    claimed_at: current_time,
    claimed_at_readable: self.format_datetime(dt),
});
Enter fullscreen mode Exit fullscreen mode

This event includes both the raw timestamp (for precise calculations) and a readable version (for quick human interpretation).

2. In View Functions

/// View function to get vesting schedule with readable dates
#[ink(message)]
pub fn get_vesting_schedule_readable(
    &self,
    beneficiary: H160,
) -> Option<(VestingSchedule, [u8; 19], [u8; 19])> {
    let schedule = self.schedules.get(beneficiary)?;

    let start_dt = self.timestamp_to_datetime(schedule.start_time);
    let end_dt = self.timestamp_to_datetime(schedule.end_time);

    Some((
        schedule,
        self.format_datetime(start_dt),
        self.format_datetime(end_dt),
    ))
}
Enter fullscreen mode Exit fullscreen mode

This allows users to query the contract and immediately see when vesting starts and ends in a readable format.

Trade-offs of In-Contract Conversion

Pros:

  • Complete on-chain transparency; anyone can read the date from contract state or events
  • No dependency on off-chain infrastructure
  • Useful for contracts that need to store formatted dates permanently
  • Can be queried directly from block explorers or contract UIs

Cons:

  • Increased gas costs: Every conversion requires additional computation
  • Code complexity: You're maintaining calendar logic in your contract (leap years, month lengths, etc.)
  • Limited formatting: String manipulation in no_std is cumbersome; you're limited to simple formats
  • Contract size: Additional code increases your contract's storage footprint
  • Timezone limitations: This only handles UTC; timezone conversions would add even more complexity

Recommendation: Only use in-contract conversion when:

  • You absolutely need the readable format stored on-chain
  • Gas costs are not a primary concern
  • The format can be simple (YYYY-MM-DD HH:MM:SS)
  • You want events or state queries to be immediately human-readable

For most applications, off-chain conversion is more practical.

Approach 2: Off-Chain Conversion (Recommended)

The best practice is to store timestamps as u64 integers on-chain and perform all formatting off-chain; in your frontend, API, or indexer. This keeps your contract lean, deterministic, and gas-efficient.

Our vesting contract supports this approach as well, emitting standard events with raw timestamps:

#[ink(event)]
pub struct TokensClaimed {
    #[ink(topic)]
    beneficiary: H160,
    amount: Balance,
    claimed_at: u64,  // Just the raw timestamp
}

#[ink(message)]
pub fn get_vesting_schedule(&self, beneficiary: H160) -> Option<VestingSchedule> {
    self.schedules.get(beneficiary)
}
Enter fullscreen mode Exit fullscreen mode

The VestingSchedule struct contains raw timestamps:

pub struct VestingSchedule {
    pub total_amount: Balance,
    pub claimed_amount: Balance,
    // Raw milliseconds
    pub start_time: u64,      
    pub end_time: u64,        
}
Enter fullscreen mode Exit fullscreen mode

Frontend Conversion (TypeScript)

In your dApp frontend, converting timestamps is straightforward. Here's how you'd display information from our vesting contract using Polkadot API (PAPI):

import { createClient } from "polkadot-api";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import { createInkSdk } from "@polkadot-api/sdk-ink";
import contractMetadata from "./vesting_contract.json"; // Your contract's metadata

// Setup PAPI client
const client = createClient(getWsProvider("wss://your-parachain-endpoint"));
const api = client.getTypedApi(yourChainDescriptor);

// Create ink! SDK instance
const vestingSdk = createInkSdk(api, contractMetadata);

// Get contract instance
const vestingContract = vestingSdk.getContract(contractAddress);

// Query the contract
const getVestingSchedule = async (beneficiary) => {
  // Use the SDK's query method
  const result = await vestingContract.query.get_vesting_schedule({
    origin: beneficiary,
    data: [beneficiary]
  });

  if (result.success) {
    return result.value;
  }
  throw new Error("Query failed");
};

// Use the function
const schedule = await getVestingSchedule(beneficiaryAddress);

// Convert the timestamps (remember: Substrate uses milliseconds)
const startDate = new Date(schedule.start_time);
const endDate = new Date(schedule.end_time);

console.log(startDate.toISOString());
// Output: "2024-10-21T10:00:00.000Z"

console.log(endDate.toLocaleDateString('en-US', { 
    year: 'numeric',
    month: 'long', 
    day: 'numeric',
    timeZone: 'UTC'
}));
Enter fullscreen mode Exit fullscreen mode

Note: The @polkadot-api/sdk-ink package provides a clean abstraction for interacting with ink! smart contracts using PAPI, handling encoding/decoding automatically.

React Component Example

Here's a practical example of displaying vesting information from our contract:

import { useState, useEffect } from 'react';
import { createClient } from "polkadot-api";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import contractMetadata from "./vesting_contract.json"; // Your contract's ABI

interface VestingSchedule {
  total_amount: bigint;
  claimed_amount: bigint;
  start_time: number;
  end_time: number;
}

function VestingDisplay({ 
  contractAddress, 
  beneficiary, 
  callerAddress,
  chainDescriptor 
}: Props) {
  const [schedule, setSchedule] = useState<VestingSchedule | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchSchedule() {
      try {
        // Initialize PAPI client
        const client = createClient(getWsProvider("wss://your-parachain-endpoint"));
        const api = client.getTypedApi(chainDescriptor);

        // Encode the message for get_vesting_schedule
        const messageData = encodeMessage(
          contractMetadata, 
          "get_vesting_schedule", 
          [beneficiary]
        );

        // Perform dry-run call
        const result = await api.apis.ContractsApi.call(
          callerAddress,
          contractAddress,
          0, // value
          null, // gasLimit
          null, // storageDepositLimit
          messageData
        );

        // Check if call was successful
        if (result.result.isOk) {
          const decoded = decodeMessage(
            contractMetadata,
            "get_vesting_schedule",
            result.data
          );
          setSchedule(decoded);
        } else {
          console.error("Contract call failed:", result.result);
        }

        setLoading(false);
      } catch (error) {
        console.error("Failed to fetch vesting schedule:", error);
        setLoading(false);
      }
    }

    fetchSchedule();
  }, [contractAddress, beneficiary, callerAddress, chainDescriptor]);

  if (loading) return <div>Loading...</div>;
  if (!schedule) return <div>No vesting schedule found</div>;

  // Convert timestamps off-chain
  const startDate = new Date(schedule.start_time);
  const endDate = new Date(schedule.end_time);

  const formatDate = (date: Date) => {
    return date.toLocaleString('en-US', {
      year: 'numeric',
      month: 'long',
      day: 'numeric',
      hour: '2-digit',
      minute: '2-digit',
      timeZone: 'UTC',
      timeZoneName: 'short'
    });
  };

  // Calculate progress
  const now = Date.now();
  const progress = Math.min(100, Math.max(0, 
    ((now - schedule.start_time) / (schedule.end_time - schedule.start_time)) * 100
  ));

  return (
    <div className="vesting-card">
      <h3>Vesting Schedule</h3>
      <div className="dates">
        <p>Start: {formatDate(startDate)}</p>
        <p>End: {formatDate(endDate)}</p>
      </div>
      <div className="amounts">
        <p>Total: {schedule.total_amount.toString()} tokens</p>
        <p>Claimed: {schedule.claimed_amount.toString()} tokens</p>
      </div>
      <div className="progress-bar">
        <div className="fill" style={{ width: `${progress}%` }} />
        <span>{progress.toFixed(1)}% vested</span>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This component:

  • Queries raw timestamps from the contract
  • Formats them for display with full locale support
  • Calculates vesting progress client-side
  • Provides a rich user experience with zero on-chain gas costs

Handling Events Off-Chain

When listening to events from our contract, you can convert timestamps as they arrive:

mport { createClient } from "polkadot-api";
import { getWsProvider } from "polkadot-api/ws-provider/web";

// Setup PAPI client
const client = createClient(getWsProvider("wss://your-parachain-endpoint"));
const api = client.getTypedApi(chainDescriptor);

// Subscribe to contract events
const unsubscribe = api.event.Contracts.ContractEmitted.watch((event) => {
  // Filter for your specific contract
  if (event.contract === contractAddress) {
    // Decode the event data
    const decoded = decodeContractEvent(event.data);

    if (decoded.eventName === 'TokensClaimed') {
      const { beneficiary, amount, claimed_at } = decoded.data;

      // Convert the timestamp
      const claimDate = new Date(claimed_at);

      // Display notification
      showNotification({
        title: 'Tokens Claimed',
        message: `${amount} tokens claimed on ${claimDate.toLocaleDateString()}`,
        beneficiary: beneficiary
      });

      // Log to analytics with readable timestamp
      analytics.track('tokens_claimed', {
        beneficiary,
        amount,
        timestamp: claimed_at,
        timestamp_readable: claimDate.toISOString(),
        date: claimDate.toLocaleDateString(),
        time: claimDate.toLocaleTimeString()
      });
    }
  }
});

// Clean up subscription when done
// unsubscribe();
Enter fullscreen mode Exit fullscreen mode

Backend API Conversion (Rust)

If you have a Rust backend service that queries contract state, you can use full std libraries like chrono:

use chrono::{DateTime, Utc, TimeZone};
use serde::Serialize;

#[derive(Serialize)]
struct VestingResponse {
    total_amount: u128,
    claimed_amount: u128,
    start_time: i64,
    end_time: i64,
    start_time_readable: String,
    end_time_readable: String,
    days_remaining: i64,
}

fn format_timestamp(timestamp_ms: i64) -> String {
    let timestamp_seconds = timestamp_ms / 1000;
    let datetime = Utc.timestamp_opt(timestamp_seconds, 0)
        .single()
        .expect("Invalid timestamp");

    datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
}

fn get_vesting_info(beneficiary: &str) -> VestingResponse {
    // Query the contract using get_vesting_schedule
    let schedule = query_contract_state(beneficiary);

    let now = Utc::now().timestamp_millis();
    let days_remaining = (schedule.end_time as i64 - now) / (1000 * 60 * 60 * 24);

    VestingResponse {
        total_amount: schedule.total_amount,
        claimed_amount: schedule.claimed_amount,
        start_time: schedule.start_time as i64,
        end_time: schedule.end_time as i64,
        start_time_readable: format_timestamp(schedule.start_time as i64),
        end_time_readable: format_timestamp(schedule.end_time as i64),
        days_remaining: days_remaining.max(0),
    }
}
Enter fullscreen mode Exit fullscreen mode

Your API can then return rich, formatted data:

{
  "total_amount": "1000000",
  "claimed_amount": "500000",
  "start_time": 1729512000000,
  "end_time": 1738152000000,
  "start_time_readable": "2024-10-21 10:00:00 UTC",
  "end_time_readable": "2025-01-29 10:00:00 UTC",
  "days_remaining": 42
}
Enter fullscreen mode Exit fullscreen mode

Why Off-Chain Conversion Is Better

Pros:

  • Zero gas costs: Conversion happens outside the blockchain
  • Rich formatting: Use full-featured libraries with timezone support, localization, relative time ("2 days ago"), calendar operations, etc.
  • Flexibility: Change date formats without upgrading your contract
  • Contract simplicity: Keep your smart contract focused on business logic, not presentation
  • Better UX: Display dates in user's local timezone, preferred format, and language
  • Powerful computations: Calculate durations, add/subtract time, handle complex calendar operations

Cons:

  • Requires off-chain infrastructure (frontend, API, or indexer)
  • Readable dates aren't directly visible in raw contract state (though most explorers convert automatically)
  • Need to ensure conversion logic is consistent across different parts of your system

Recommendation: Use off-chain conversion for 95% of use cases. Our vesting contract provides both approaches; standard functions that return raw timestamps (recommended) and _readable variants that do on-chain conversion (for special cases).

Hybrid Approach: Best of Both Worlds

Notice how our vesting contract provides both approaches:

// This returns raw timestamps (recommended for most use cases)
#[ink(message)]
pub fn get_vesting_schedule(&self, beneficiary: H160) -> Option<VestingSchedule> {
    self.schedules.get(beneficiary)
}

// This returns a readable version—includes formatted dates
#[ink(message)]
pub fn get_vesting_schedule_readable(
    &self,
    beneficiary: H160,
) -> Option<(VestingSchedule, [u8; 19], [u8; 19])> {
    let schedule = self.schedules.get(beneficiary)?;
    let start_dt = self.timestamp_to_datetime(schedule.start_time);
    let end_dt = self.timestamp_to_datetime(schedule.end_time);
    Some((schedule, self.format_datetime(start_dt), self.format_datetime(end_dt)))
}
Enter fullscreen mode Exit fullscreen mode

This gives users choices:

  • Use get_vesting_schedule() for gas-efficient queries with off-chain conversion
  • Use get_vesting_schedule_readable() when you need on-chain formatting

Similarly with events:

// Standard event with minimal gas cost
self.env().emit_event(TokensClaimed {
    beneficiary: caller,
    amount: claimable,
    claimed_at: current_time,
});

// More readable event which includes formatted timestamp
let dt = self.timestamp_to_datetime(current_time);
self.env().emit_event(TokensClaimedReadable {
    beneficiary: caller,
    amount: claimable,
    claimed_at: current_time,
    claimed_at_readable: self.format_datetime(dt),
});
Enter fullscreen mode Exit fullscreen mode

Testing and Debugging

When working with timestamps in ink! smart contracts, thorough testing is essential. Our vesting contract includes comprehensive tests that demonstrate best practices.

Testing with Mock Timestamps

ink!'s testing framework allows you to set mock block timestamps, which is crucial for testing time-based logic:

#[ink::test]
fn test_vesting_lifecycle() {
    let accounts = ink::env::test::default_accounts();
    let owner: H160 = accounts.alice.into();
    let beneficiary: H160 = H160::from([1u8; 20]);

    // Set caller to owner BEFORE creating contract
    ink::env::test::set_caller(owner);
    let mut contract = VestingScheduler::new();

    // Set initial block timestamp: Oct 21, 2024, 10:00:00 UTC
    let start_time = 1729512000000u64;
    ink::env::test::set_block_timestamp::<ink::env::DefaultEnvironment>(start_time);

    // Create vesting schedule: 1M tokens over 100 days
    let total_amount = 1_000_000;
    let end_time = start_time + (100 * 24 * 60 * 60 * 1000); // 100 days later

    let result = contract.create_vesting_schedule(
        beneficiary, 
        total_amount, 
        start_time, 
        end_time
    );
    assert!(result.is_ok(), "create_vesting_schedule failed: {:?}", result);

    // Switch caller to beneficiary to claim
    ink::env::test::set_caller(beneficiary);

    // Advance time by 50 days
    let fifty_days_later = start_time + (50 * 24 * 60 * 60 * 1000);
    ink::env::test::set_block_timestamp::<ink::env::DefaultEnvironment>(fifty_days_later);

    // Should be able to claim 50% of tokens
    let claimed = contract.claim_vested().unwrap();
    assert_eq!(claimed, 500_000);

    // Advance to after vesting ends
    let after_end = end_time + 1000;
    ink::env::test::set_block_timestamp::<ink::env::DefaultEnvironment>(after_end);

    // Should be able to claim remaining 50%
    let remaining = contract.claim_vested().unwrap();
    assert_eq!(remaining, 500_000);

    // No more tokens to claim
    let result = contract.claim_vested();
    assert_eq!(result, Err(Error::NoTokensAvailable));
}
Enter fullscreen mode Exit fullscreen mode

This test demonstrates:

  • Setting an initial timestamp
  • Creating a vesting schedule
  • Advancing time to test different stages
  • Verifying correct vesting calculations at each stage

Testing Timestamp Conversion Accuracy

The contract includes dedicated tests for the conversion logic:

#[ink::test]
fn test_timestamp_conversion() {
    let contract = VestingScheduler::new();

    // Test known timestamp: Oct 21, 2024, 10:00:00 UTC
    let timestamp = 1729512000000u64;
    let dt = contract.timestamp_to_datetime(timestamp);

    assert_eq!(dt.year, 2024);
    assert_eq!(dt.month, 10);
    assert_eq!(dt.day, 21);
    assert_eq!(dt.hour, 10);
    assert_eq!(dt.minute, 0);
    assert_eq!(dt.second, 0);

    // Test formatting
    let formatted = contract.format_datetime(dt);
    let expected = b"2024-10-21 10:00:00";
    assert_eq!(&formatted[..], expected);
}
Enter fullscreen mode Exit fullscreen mode

This ensures that:

  • The timestamp used in documentation matches the implementation
  • Conversion produces correct year, month, day, hour, minute, second
  • Formatting produces the expected string format

Testing Leap Years

One critical edge case worth testing is leap year handling:

#[ink::test]
fn test_leap_year() {
    let contract = VestingScheduler::new();

    // Test leap year: Mar 1, 2024
    let leap_day = 1709251200000u64; // 2024-03-01 00:00:00 UTC
    let dt = contract.timestamp_to_datetime(leap_day);

    assert_eq!(dt.year, 2024);
    assert_eq!(dt.month, 3);
    assert_eq!(dt.day, 1);
}
Enter fullscreen mode Exit fullscreen mode

Best Practices for Testing Time-Based Logic

  1. Use clear, commented timestamps:
let start = 1729512000000; // Oct 21, 2024, 10:00 AM UTC
let end = 1737374400000;   // Jan 20, 2025, 10:00 AM UTC (3-month vesting)
Enter fullscreen mode Exit fullscreen mode
  1. Test boundary conditions:

    • Before vesting starts
    • Exactly at start time
    • Midway through vesting
    • Exactly at end time
    • After vesting ends
  2. Test edge cases:

    • Leap years (February 29)
    • Year boundaries (December 31 → January 1)
    • Month boundaries with different lengths
  3. Use helper functions for readability:

fn days_to_ms(days: u64) -> u64 {
    days * 24 * 60 * 60 * 1000
}

let start = 1729512000000;
let end = start + days_to_ms(100);
Enter fullscreen mode Exit fullscreen mode

Summary

Working with timestamps in ink! smart contracts requires balancing on-chain determinism with off-chain usability. Let's recap the key takeaways:

Key Points

  • Substrate uses millisecond timestamps: Remember to divide by 1000 when converting to standard Unix seconds
  • Two conversion approaches: In-contract arithmetic conversion or off-chain rendering
  • Off-chain is recommended: Keep your contracts lean, deterministic, and gas-efficient by doing conversion in your frontend, API, or indexer
  • In-contract conversion has its place: Use it sparingly for critical event logs or when on-chain readability is essential

Best Practices

  1. Store timestamps as u64 integers on-chain
  2. Perform formatting off-chain using full-featured libraries (JavaScript Date, Rust's chrono, etc.)
  3. Provide both raw and readable options when appropriate (like the vesting contract demonstrates)
  4. Use clear comments in tests to make timestamp values understandable during development
  5. Test time-based logic thoroughly using ink!'s set_block_timestamp for deterministic testing

When to Use Each Approach

Approach Best For Avoid When
In-contract conversion Critical event logs, on-chain auditability, debugging Gas costs matter, complex formatting needed, timezone support required
Off-chain conversion UI display, analytics, timezone support, localization, flexible formatting You need on-chain proof of readable dates

Final Recommendations

The vesting contract in this article demonstrates both approaches, giving you a complete reference implementation. Whether you choose in-contract conversion, off-chain rendering, or a hybrid approach depends on your specific use case; but now you have the tools to make an informed decision.

For most production dApps, off-chain conversion is the way to go. It keeps your smart contracts focused on business logic while providing users with rich, flexible date formatting in their preferred timezone and locale.

Remember: Timestamps are just numbers on the blockchain. The magic happens when you convert them into meaningful information for your users, and that magic is best performed off-chain where you have access to powerful libraries, flexible formatting options, and zero gas costs.

Additional Resources


About the Example Contract: The complete vesting contract code used throughout this article is available here vesting_schedule_smart_contract as a reference implementation demonstrating both on-chain and off-chain timestamp conversion approaches. It includes comprehensive tests and can serve as a starting point for your own time-based smart contracts.

Top comments (0)