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:
- 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).
- 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.
- 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();
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...
}
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
andtime
requirestd
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:
- 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.
- 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,
}
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),
})
}
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!
}
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();
}
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
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)
That's 1729512000000
milliseconds, which equals 1729512000
seconds.
Conversion formula:
let seconds = milliseconds / 1000;
let milliseconds = seconds * 1000;
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,
}
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)
}
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)
}
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)
}
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
}
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],
}
// 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),
});
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),
))
}
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)
}
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,
}
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'
}));
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>
);
}
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();
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),
}
}
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
}
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)))
}
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),
});
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));
}
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);
}
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);
}
Best Practices for Testing Time-Based Logic
- 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)
-
Test boundary conditions:
- Before vesting starts
- Exactly at start time
- Midway through vesting
- Exactly at end time
- After vesting ends
-
Test edge cases:
- Leap years (February 29)
- Year boundaries (December 31 → January 1)
- Month boundaries with different lengths
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);
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
-
Store timestamps as
u64
integers on-chain -
Perform formatting off-chain using full-featured libraries (JavaScript Date, Rust's
chrono
, etc.) - Provide both raw and readable options when appropriate (like the vesting contract demonstrates)
- Use clear comments in tests to make timestamp values understandable during development
-
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
- Substrate Timestamp Pallet Documentation
- ink! Documentation
- Polkadot API(PAPI) for reading blockchain data
- chrono crate for Rust backend conversions
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)