Skip to main content

NestJS Module for Implementing a Token in Redis

· 6 min read
Max Kaido
Architect

Overview

We will create a NestJS module called TokenModule that implements a token system using Redis as the data store. The module will handle token balances, transfers, and burning of tokens. We will leverage Redis's advanced features such as atomic operations, Pub/Sub, and data structures to maximize performance and reliability.

Token Specifications

  • Name: e.g., "Tarot Token"
  • Symbol: e.g., "TARO"
  • Decimals: e.g., 6 (common for tokens to allow fractional amounts)
  • Total Supply: e.g., 100,000,000 (fixed supply)
  • Burnable: Tokens can be burned (destroyed), reducing the circulating supply.

Data Storage in Redis

1. Token Information

  • Key: token:info
  • Type: Hash
  • Fields:
    • name
    • symbol
    • decimals
    • totalSupply
    • circulatingSupply (updates when tokens are burned)

2. User Balances

  • Key Pattern: balance:{userId}
  • Type: String (stores the balance as a string representing a number)
  • Alternative: Use a Hash balances with userId as the field and balance as the value.

3. Transaction Logs (Optional)

  • Key: transactions
  • Type: Stream
  • Purpose: Logs all transfers and burn events for auditing and history.

4. Pub/Sub Channels

  • Transfer Events: token:transfers
  • Burn Events: token:burns
  • Purpose: Notify subscribed services or clients about token events in real-time.

RedisService Assumption

We assume a RedisService is available in the application context that provides a Redis client instance connected to the Redis server.

@Injectable()
export class RedisService {
getClient(): Redis.RedisClient {
// Returns a Redis client instance
}
}

TokenModule Structure

  • Services:

    • TokenService: Core service handling all token operations.
  • Providers:

    • RedisService: Provides Redis client instances.
  • Exports:

    • TokenService: So it can be used by other modules if needed.

TokenService Interface and Methods

@Injectable()
export class TokenService {
constructor(private readonly redisService: RedisService) {}

async initializeToken(): Promise<void> {
// Initializes token information in Redis
}

async getTokenInfo(): Promise<TokenInfo> {
// Retrieves token information
}

async getBalance(userId: string): Promise<string> {
// Retrieves the balance of a user
}

async transfer(
fromUserId: string,
toUserId: string,
amount: string,
): Promise<void> {
// Transfers tokens from one user to another
}

async burn(userId: string, amount: string): Promise<void> {
// Burns tokens from a user's balance
}

async getTotalSupply(): Promise<string> {
// Retrieves the total supply of the token
}

async getCirculatingSupply(): Promise<string> {
// Retrieves the circulating supply of the token
}

async getTopHolders(
limit: number,
): Promise<{ userId: string; balance: string }[]> {
// Retrieves top token holders
}
}

Detailed Method Implementations

1. initializeToken()

  • Purpose: Sets up the token information and initial balances in Redis.
  • Process:
    • Check if token:info already exists.
    • If not, set the token information in token:info.
    • Distribute the total supply to an initial account or accounts.
    • Use MULTI and EXEC for transaction-like behavior.

2. getTokenInfo()

  • Purpose: Retrieves token metadata.
  • Process:
    • Use HGETALL on token:info.
    • Return the token information as a TokenInfo object.

3. getBalance(userId: string)

  • Purpose: Gets the balance of a specific user.
  • Process:
    • Use GET on balance:{userId}.
    • If the key doesn't exist, return 0.

4. transfer(fromUserId: string, toUserId: string, amount: string)

  • Purpose: Transfers tokens between users.
  • Process:
    • Use Lua scripting to ensure atomicity of multiple operations:
      • Check if fromUserId has sufficient balance.
      • Decrease fromUserId balance.
      • Increase toUserId balance.
    • Publish a transfer event via Pub/Sub.
  • Redis Features Used:
    • Atomic operations with Lua scripts.
    • Pub/Sub for event notifications.

5. burn(userId: string, amount: string)

  • Purpose: Burns tokens from a user's balance.
  • Process:
    • Check if the user has sufficient balance.
    • Decrease the user's balance.
    • Decrease circulatingSupply in token:info using HINCRBY.
    • Publish a burn event via Pub/Sub.
  • Redis Features Used:
    • Atomic operations.
    • Pub/Sub.

6. getTotalSupply()

  • Purpose: Retrieves the total token supply.
  • Process:
    • Use HGET on token:info for totalSupply.

7. getCirculatingSupply()

  • Purpose: Retrieves the circulating supply.
  • Process:
    • Use HGET on token:info for circulatingSupply.

8. getTopHolders(limit: number)

  • Purpose: Retrieves the top token holders.
  • Process:
    • Use a Sorted Set balances:zset where:
      • Key: balances:zset
      • Member: userId
      • Score: Balance
    • Use ZREVRANGE to get users with the highest balances.
  • Redis Features Used:
    • Sorted Sets for ranking.

Implementing Redis Data Structures and Commands

1. Token Information (token:info)

HMSET token:info name "NovaToken" symbol "NVT" decimals "18" totalSupply "1000000000" circulatingSupply "1000000000"

2. User Balances (balance:{userId})

  • Set Balance:

    SET balance:{userId} {balance}
  • Increment/Decrement Balance Atomically:

    INCRBY balance:{userId} {amount}

3. Using Lua Scripts for Atomic Transfers

-- Lua script for atomic transfer
local fromKey = "balance:" .. KEYS[1]
local toKey = "balance:" .. KEYS[2]
local amount = tonumber(ARGV[1])

local fromBalance = tonumber(redis.call("GET", fromKey) or "0")
if fromBalance < amount then
return redis.error_reply("Insufficient balance")
end

redis.call("DECRBY", fromKey, amount)
redis.call("INCRBY", toKey, amount)

-- Update sorted set for rankings
redis.call("ZADD", "balances:zset", fromBalance - amount, KEYS[1])
local toBalance = tonumber(redis.call("GET", toKey))
redis.call("ZADD", "balances:zset", toBalance, KEYS[2])

return "OK"

4. Pub/Sub for Event Notifications

  • Publishing an Event:

    PUBLISH token:transfers "{ 'from': 'userId1', 'to': 'userId2', 'amount': '100' }"
  • Subscribing to Events:

    Services or clients can subscribe to token:transfers and token:burns channels to receive real-time updates.

Utilizing Redis Features Maximally

1. Atomic Operations

  • Balances Updates:
    • Use INCRBY and DECRBY for atomic balance modifications.
    • Lua scripts ensure multiple operations are executed atomically.

2. Pub/Sub Mechanism

  • Real-Time Notifications:
    • Notify other services or microservices about transfers and burns.
    • Enables reactive programming and event-driven architectures.

3. Sorted Sets

  • Ranking Users:
    • Keep track of users' balances in a Sorted Set for leaderboards or top holders.
    • Efficient retrieval of top N users.

4. Streams (Optional)

  • Transaction Logs:
    • Use Redis Streams to log all transactions for auditing.
    • Can be processed asynchronously for analytics.

Example Interface Definitions

TokenInfo Interface

interface TokenInfo {
name: string;
symbol: string;
decimals: number;
totalSupply: string; // Use string to handle big numbers
circulatingSupply: string;
}

Considerations for Big Numbers

Handling Large Numbers

  • Token amounts can be very large (especially with high decimals).
  • Use strings to represent numbers in code to prevent precision loss.
  • Utilize libraries like BigNumber.js for arithmetic operations.

Sample Method Implementations

transfer(fromUserId: string, toUserId: string, amount: string)

async transfer(fromUserId: string, toUserId: string, amount: string): Promise<void> {
const redisClient = this.redisService.getClient();

const luaScript = `
local fromKey = "balance:" .. KEYS[1]
local toKey = "balance:" .. KEYS[2]
local amount = tonumber(ARGV[1])

local fromBalance = tonumber(redis.call("GET", fromKey) or "0")
if fromBalance < amount then
return redis.error_reply("Insufficient balance")
end

redis.call("DECRBY", fromKey, amount)
redis.call("INCRBY", toKey, amount)

-- Update sorted set for rankings
redis.call("ZADD", "balances:zset", fromBalance - amount, KEYS[1])
local toBalance = tonumber(redis.call("GET", toKey))
redis.call("ZADD", "balances:zset", toBalance, KEYS[2])

return "OK"
`;

try {
await redisClient.eval(luaScript, 2, fromUserId, toUserId, amount);
// Publish transfer event
const event = {
from: fromUserId,
to: toUserId,
amount,
timestamp: new Date().toISOString(),
};
redisClient.publish('token:transfers', JSON.stringify(event));
} catch (error) {
throw new Error(error.message);
}
}

Security and Validation

Input Validation

  • Ensure userId and amount are validated before processing.
  • Prevent injection attacks or malformed data.

Error Handling

  • Handle exceptions gracefully.
  • Return meaningful error messages.

Authentication and Authorization

  • Integrate with NestJS guards or middleware to authenticate users.
  • Ensure that only authorized users can perform transfers or burns.

Module Registration in NestJS

@Module({
providers: [TokenService, RedisService],
exports: [TokenService],
})
export class TokenModule {}

Usage Example

@Controller('token')
export class TokenController {
constructor(private readonly tokenService: TokenService) {}

@Get('balance/:userId')
async getBalance(
@Param('userId') userId: string,
): Promise<{ balance: string }> {
const balance = await this.tokenService.getBalance(userId);
return { balance };
}

@Post('transfer')
async transfer(@Body() transferDto: TransferDto): Promise<void> {
await this.tokenService.transfer(
transferDto.fromUserId,
transferDto.toUserId,
transferDto.amount,
);
}

// Additional endpoints as needed
}

Maximizing Redis Features

Performance

  • High-speed in-memory operations for low latency.
  • Efficient data structures for quick data retrieval.

Atomicity

  • Ensuring data consistency with atomic operations and Lua scripting.

Scalability

  • Redis can be scaled horizontally using clustering.
  • Suitable for applications with a large number of users and transactions.

Real-Time Capabilities

  • Pub/Sub enables instant notifications and real-time updates.

Potential Limitations and Mitigations

Data Persistence

  • Ensure Redis is configured with persistence options (RDB snapshots and/or AOF logs) to prevent data loss.
  • Consider periodic backups.

Memory Usage

  • Monitor Redis memory usage.
  • Use efficient data structures and optimize key sizes.

Numeric Precision

  • Be cautious with floating-point arithmetic.
  • Use string representations or libraries that handle big numbers.

Concurrency

  • Redis's single-threaded nature ensures operations are processed sequentially, but high concurrency might require scaling Redis instances.

Conclusion

By leveraging Redis's powerful features within a NestJS module, we can create an efficient and scalable token system that benefits from high-speed operations and real-time capabilities. This module can serve as the backbone for applications requiring quick and reliable token transactions, such as gaming platforms, reward systems, or financial services.

Note: The provided code snippets are for illustrative purposes. In a production environment, ensure to handle exceptions, edge cases, and security concerns appropriately. Testing and code reviews are essential to maintain the integrity and reliability of the token system.