Token Expiration
Credits don’t last forever. Promotional tokens, trial balances, and time-limited top-ups all need expiration dates. Murai handles this with a FIFO bucket-based expiration system that integrates seamlessly with the append-only ledger.
How it works
Buckets
Every call to topUp creates a bucket — a record of how many tokens were
added, how many remain, and when they expire. Each bucket tracks:
| Field | Description |
|---|---|
userId | Owner of the bucket |
amount | Original credit amount |
remaining | Unspent tokens in this bucket |
expiresAt | When the bucket expires (null = never expires) |
createdAt | When the bucket was created |
// Non-expiring top-up (no expiresAt)await wallet.topUp('user_123', 5_000, 'purchase-001');
// Expiring top-up (30 days)const thirtyDays = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);await wallet.topUp('user_123', 10_000, 'promo-002', { expiresAt: thirtyDays,});FIFO spend ordering
When a user spends tokens, the system consumes from buckets in order:
- Earliest-expiring buckets first — tokens that expire soonest are used first
- Non-expiring buckets last — buckets with
expiresAt: nullare used only after all expiring buckets are exhausted (NULLS LASTordering) - Within the same expiration date — buckets are consumed in creation order
This ensures that expiring tokens are always used before permanent ones, minimizing waste for the user.
Bucket A: 3,000 remaining, expires Jan 15 <- consumed firstBucket B: 5,000 remaining, expires Feb 20 <- consumed secondBucket C: 2,000 remaining, never expires <- consumed lastExpiring tokens
The expireTokens function
Expired buckets are not automatically removed from the balance. Instead, you
call expireTokens(userId) to atomically debit any remaining tokens from
expired buckets.
const result = await wallet.expireTokens('user_123');// result.expiredCount — number of buckets expired// result.expiredAmount — total tokens debitedThis function:
- Finds all buckets where
expiresAt < nowandremaining > 0 - Creates a debit ledger entry for each bucket’s remaining balance
- Sets
remaining = 0on each expired bucket - Returns an
ExpireResultwith the totals
All steps happen inside a single transaction with SELECT FOR UPDATE locking,
so it is safe to call concurrently.
Balance semantics
getBalance() returns the materialized balance — the sum of all ledger
entries. Expired-but-not-yet-processed buckets are still counted in the balance
until expireTokens runs and creates the corresponding debit entries.
Running via cron
In production, run expireTokens on a schedule for all users with expiring
buckets. Here is a minimal example:
// cron-expire.ts — run via cron (e.g., daily at midnight)import { createDrizzleStorage, createWallet } from '@murai-wallet/murai';
const storage = createDrizzleStorage(db);const wallet = createWallet({ storage });
// Get all user IDs with expiring buckets (your own query)const userIds = await getUsersWithExpiringBuckets();
for (const userId of userIds) { const result = await wallet.expireTokens(userId); if (result.expiredAmount > 0) { console.log( `${userId}: expired ${result.expiredCount} buckets,` + ` ${result.expiredAmount} tokens` ); }}Full example
import { createDrizzleStorage, createWallet } from '@murai-wallet/murai';import { drizzle } from 'drizzle-orm/postgres-js';import postgres from 'postgres';
// biome-ignore lint/style/noNonNullAssertion: env vars validated at startupconst sql = postgres(process.env.DATABASE_URL!);const storage = createDrizzleStorage(drizzle(sql));const wallet = createWallet({ storage });
// Top up with 30-day expirationconst thirtyDays = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);await wallet.topUp('user_123', 10_000, 'purchase-abc', { expiresAt: thirtyDays,});
// Spend consumes earliest-expiring bucket firstawait wallet.spend('user_123', 2_000, 'usage-xyz');
// Run this via cron (e.g., daily) to expire old creditsconst result = await wallet.expireTokens('user_123');// biome-ignore lint/suspicious/noConsole: example scriptconsole.log(`Expired ${result.expiredCount} buckets, ${result.expiredAmount} tokens`);What’s next?
- Types reference — see
ExpireResultandTopUpOptions - Architecture — how the append-only ledger and locking work
- Usage Reporting — track provider costs alongside spends