Stablecoin remittances via Telegram bots offer a powerful solution for cross-border transfers without the volatility issues of traditional cryptocurrencies. With Dynamic Server Wallets, you can build a secure, user-friendly stablecoin remittance service accessible directly through Telegram’s familiar interface.

Dynamic Server Wallets manage the cryptographic signing and blockchain interactions on your backend, allowing users to send stablecoins to friends and family using just Telegram usernames—no need to handle wallet addresses or seed phrases.

This guide builds on the basic Telegram Bot tutorial to create an enhanced remittance bot with these features:

  • Token Support: Send and receive both ETH and ERC20 tokens (USDC)
  • User-to-User Transfers: Send crypto to other Telegram users using their usernames
  • Balance Checking: Check your wallet’s current balance

This guide extends our basic Telegram bot implementation. If you haven’t gone through that guide, consider starting there for a simpler introduction.

If you want to take a quick look at the final code, check out the GitHub repository.

Overview

This enhanced Telegram bot provides a complete crypto remittance solution with:

  • EVM-compatible wallet creation and management
  • ETH and USDC transfers to addresses or Telegram users
  • Balance checking for multiple tokens
  • Message signing capabilities

The implementation combines:

  • Dynamic’s server wallets for secure transaction signing
  • Supabase for user-wallet association and username lookups
  • Grammy for Telegram bot interaction framework
  • Viem for blockchain interactions and token transfers

Setup

First, follow the setup instructions in the basic Telegram Bot tutorial to set up:

  • Your Dynamic account and environment
  • A Telegram bot using BotFather
  • A Supabase project
  • Node.js or Bun development environment

Once you’ve completed those steps, you’re ready to enhance the bot with stablecoin remittance capabilities.

Start with the Starter Project

For this guide, we’ll use a starter project that already has the basic structure set up:

git clone https://github.com/dynamic-labs/tg-bot-starter.git
cd tg-bot-starter

After cloning, install dependencies using your preferred package manager (npm, yarn, pnpm, or bun). Configure your environment variables as explained in the basic tutorial.

The database schema is the same as in the basic tutorial. We’ll be extending the bot’s functionality without changing the database structure.

Implementation

Now let’s enhance our basic bot by adding stablecoin support and user-to-user transfers. We’ll build on top of the starter repository by adding new features to the existing structure.

1. Add Token Support

First, create a tokens.ts file in the utils folder to handle token operations. This file defines the supported tokens (ETH and USDC), their contract addresses, and helper functions for formatting amounts and checking balances:

utils/tokens.ts
import { formatUnits, parseUnits } from "viem";
import type { PublicClient } from "viem";

export interface Token {
  symbol: string;
  name: string;
  contractAddress: `0x${string}`;
  decimals: number;
}

export const TOKENS: Record<string, Token> = {
  ETH: {
    symbol: "ETH",
    name: "Ether",
    contractAddress:
      "0x0000000000000000000000000000000000000000" as `0x${string}`,
    decimals: 18,
  },
  USDC: {
    symbol: "USDC",
    name: "USD Coin",
    contractAddress:
      "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`,
    decimals: 6,
  },
};

export const ERC20_ABI = [
  {
    constant: true,
    inputs: [{ name: "_owner", type: "address" }],
    name: "balanceOf",
    outputs: [{ name: "balance", type: "uint256" }],
    type: "function",
  },
  {
    constant: false,
    inputs: [
      { name: "_to", type: "address" },
      { name: "_value", type: "uint256" },
    ],
    name: "transfer",
    outputs: [{ name: "success", type: "bool" }],
    type: "function",
  },
  {
    constant: true,
    inputs: [],
    name: "decimals",
    outputs: [{ name: "", type: "uint8" }],
    type: "function",
  },
] as const;

// Get token balance for an address
export const getTokenBalance = async (
  client: PublicClient,
  tokenSymbol: string,
  walletAddress: `0x${string}`
): Promise<{ amount: string; formatted: string }> => {
  const token = TOKENS[tokenSymbol];
  if (!token) throw new Error(`Unsupported token: ${tokenSymbol}`);

  if (tokenSymbol === "ETH") {
    const balanceWei = await client.getBalance({
      address: walletAddress,
    });

    return {
      amount: balanceWei.toString(),
      formatted: formatUnits(balanceWei, token.decimals),
    };
  }

  const balance = (await client.readContract({
    address: token.contractAddress,
    abi: ERC20_ABI,
    functionName: "balanceOf",
    args: [walletAddress],
  })) as bigint;

  return {
    amount: balance.toString(),
    formatted: formatUnits(balance, token.decimals),
  };
};

export const parseTokenAmount = (
  amount: string,
  tokenSymbol: string
): bigint => {
  const token = TOKENS[tokenSymbol];
  if (!token) throw new Error(`Unsupported token: ${tokenSymbol}`);
  return parseUnits(amount, token.decimals);
};

export const formatTokenAmount = (
  amount: bigint,
  tokenSymbol: string
): string => {
  const token = TOKENS[tokenSymbol];
  if (!token) throw new Error(`Unsupported token: ${tokenSymbol}`);
  return formatUnits(amount, token.decimals);
};

export const isValidToken = (tokenSymbol: string): boolean =>
  Object.keys(TOKENS).includes(tokenSymbol);

The code above defines token specifications and utility functions for interacting with ETH and USDC on the Base Sepolia testnet. It includes functions for retrieving balances, parsing amounts between human-readable and blockchain formats, and validating tokens.

2. Add Error Handling Utilities

Create an errorHandling.ts file in the utils folder to handle various error scenarios. This file provides a structured approach to error handling with typed errors, user-friendly error messages, and logging functionality:

utils/errorHandling.ts
import { Context } from "grammy";

export enum ErrorType {
  USER_NOT_FOUND = "USER_NOT_FOUND",
  WALLET_NOT_FOUND = "WALLET_NOT_FOUND",
  WALLET_CREATION_FAILED = "WALLET_CREATION_FAILED",
  INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS",
  TRANSACTION_FAILED = "TRANSACTION_FAILED",
  NETWORK_ERROR = "NETWORK_ERROR",
  INVALID_AMOUNT = "INVALID_AMOUNT",
  INTERNAL_ERROR = "INTERNAL_ERROR",
  AUTH_ERROR = "AUTH_ERROR",
  API_ERROR = "API_ERROR",
  DB_ERROR = "DB_ERROR",
}

export class BotError extends Error {
  type: ErrorType;
  details?: any;

  constructor(type: ErrorType, message: string, details?: any) {
    super(message);
    this.name = "BotError";
    this.type = type;
    this.details = details;
  }
}

export const handleTransactionError = async (
  ctx: Context,
  error: any
): Promise<void> => {
  let errorMessage = "❌ Something went wrong processing your transaction.";

  console.error("Transaction error:", error);

  if (error instanceof BotError) {
    switch (error.type) {
      case ErrorType.INSUFFICIENT_FUNDS:
        errorMessage = "❌ Insufficient funds to complete the transaction.";
        break;
      case ErrorType.USER_NOT_FOUND:
        errorMessage =
          "❌ User not found. Make sure the username is correct and the user has interacted with this bot.";
        break;
      case ErrorType.WALLET_NOT_FOUND:
        errorMessage = "❌ No wallet found. Create one with /wallet first.";
        break;
      case ErrorType.INVALID_AMOUNT:
        errorMessage = "❌ Invalid amount. Please enter a valid number.";
        break;
      case ErrorType.TRANSACTION_FAILED:
        errorMessage = `❌ Transaction failed: ${error.message}`;
        break;
      case ErrorType.NETWORK_ERROR:
        errorMessage = "❌ Network error. Please try again later.";
        break;
      default:
        errorMessage = `❌ Error: ${error.message}`;
    }
  } else if (error.message?.includes("insufficient funds")) {
    errorMessage = "❌ Insufficient funds to complete the transaction.";
  } else if (error.message?.includes("nonce too low")) {
    errorMessage = "❌ Transaction failed: nonce too low. Please try again.";
  } else if (error.message?.includes("ERC20: transfer from the zero address")) {
    errorMessage =
      "❌ Token transfer error: Unable to identify sender wallet. This might be an issue with the token contract or wallet permissions.";
  } else if (error.message?.includes("execution reverted")) {
    errorMessage =
      "❌ Transaction execution reverted by the blockchain. Please try again later or contact support.";
  }

  await ctx.reply(errorMessage);
};

export const validateAmount = (amount: string): number => {
  try {
    if (!/^\d*\.?\d+$/.test(amount)) {
      throw new BotError(ErrorType.INVALID_AMOUNT, "Invalid amount format");
    }

    const parsedAmount = parseFloat(amount);

    if (parsedAmount <= 0) {
      throw new BotError(
        ErrorType.INVALID_AMOUNT,
        "Amount must be greater than 0"
      );
    }

    return parsedAmount;
  } catch (error) {
    if (error instanceof BotError) {
      throw error;
    }

    throw new BotError(ErrorType.INVALID_AMOUNT, "Invalid amount");
  }
};

export const logError = (source: string, error: any, context?: any): void => {
  console.error(`[ERROR] ${source}:`, {
    message: error.message,
    type: error instanceof BotError ? error.type : "UNKNOWN",
    stack: error.stack,
    context,
    timestamp: new Date().toISOString(),
  });
};

3. Store Usernames in Supabase

Our implementation requires storing Telegram usernames in the Supabase database when users create their wallets. This enables direct lookups for user-to-user transfers without needing to call Telegram’s API.

Make sure your Supabase wallets table includes a username column:

-- Wallet table with username column
CREATE TABLE wallets (
  id SERIAL PRIMARY KEY,
  tg_id BIGINT NOT NULL,
  username TEXT,
  walletAddress TEXT NOT NULL,
  walletID TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

The getWallet utility stores the username when creating a wallet:

// In getWallet.ts
const { error: insertError } = await supabase.from("wallets").insert({
  walletAddress: accountAddress,
  walletID: publicKeyHex,
  tg_id,
  username, // Store the username from Telegram
});

This approach means that users must interact with the bot and create a wallet before they can receive funds. When someone wants to send funds to another user, the bot queries the database for that username instead of calling the Telegram API.

4. Enhance Send Command with Token Support

Enhance the existing send.ts file to support sending various tokens. This command allows users to send ETH or ERC20 tokens (like USDC) to any valid blockchain address, with proper validation and error handling:

commands/send.ts
import type { CommandContext, Context } from "grammy";
import { createWalletClient, http, encodeFunctionData } from "viem";
import { baseSepolia } from "viem/chains";
import { authenticatedEvmClient } from "../lib/dynamic";
import { supabase } from "../lib/supabase";
import {
  TOKENS,
  ERC20_ABI,
  isValidToken,
  parseTokenAmount,
  getTokenBalance,
} from "../utils/tokens";
import { validateAmount } from "../utils/errorHandling";

export const send = async (ctx: CommandContext<Context>) => {
  try {
    const tgId = ctx.from?.id;
    if (!tgId) {
      await ctx.reply("Error: Could not identify user.");
      return;
    }

    const parts = (ctx.message?.text || "").split(" ");
    if (parts.length < 3) {
      await ctx.reply(
        "❌ Incorrect format. Please use:\n/send <address> <amount> [token] - For direct remittances to blockchain addresses"
      );
      return;
    }

    const recipientAddress = parts[1] || "";
    const amount = parts[2] || "0";
    let tokenSymbol = parts[3] ? parts[3].toUpperCase() : "ETH";

    if (!isValidToken(tokenSymbol)) {
      await ctx.reply(
        `❌ Invalid token: ${tokenSymbol}. Supported tokens: ${Object.keys(
          TOKENS
        ).join(", ")}`
      );
      return;
    }

    try {
      validateAmount(amount);
    } catch (error) {
      await ctx.reply(
        "❌ Invalid amount. Please provide a valid number greater than 0."
      );
      return;
    }

    if (!recipientAddress.startsWith("0x") || recipientAddress.length !== 42) {
      await ctx.reply("❌ Invalid recipient address.");
      return;
    }

    const { data: userWallet, error: fetchError } = await supabase
      .from("wallets")
      .select("walletAddress")
      .eq("tg_id", tgId)
      .maybeSingle();

    if (fetchError || !userWallet) {
      await ctx.reply("❌ No wallet found. Create one with /wallet first.");
      return;
    }

    const client = await authenticatedEvmClient();
    const vm = client.createViemPublicClient({
      chain: baseSepolia,
      rpcUrl: "https://sepolia.base.org",
    });

    // Format wallet address correctly
    const senderAddress = userWallet.walletAddress as `0x${string}`;

    const currentBalance = await getTokenBalance(
      vm,
      tokenSymbol,
      senderAddress
    );

    const sendAmount = parseTokenAmount(amount, tokenSymbol);
    if (BigInt(currentBalance.amount) < sendAmount) {
      await ctx.reply(
        `❌ Insufficient ${tokenSymbol} balance. You have ${currentBalance.formatted} ${tokenSymbol}.`
      );
      return;
    }

    const nonce = await vm.getTransactionCount({
      address: senderAddress,
    });

    let tx;
    if (tokenSymbol === "ETH") {
      tx = await vm.prepareTransactionRequest({
        chain: baseSepolia,
        to: recipientAddress as `0x${string}`,
        value: sendAmount,
        nonce,
        from: senderAddress,
        gas: BigInt(21000), // Fixed gas for simple ETH transfers
      });
    } else {
      const token = TOKENS[tokenSymbol];
      if (!token) {
        throw new Error(`Token ${tokenSymbol} is not properly configured`);
      }

      // Ensure wallet address is valid
      if (
        !senderAddress ||
        senderAddress === "0x0000000000000000000000000000000000000000"
      ) {
        throw new Error("Invalid wallet address for token transfer");
      }

      const data = encodeFunctionData({
        abi: ERC20_ABI,
        functionName: "transfer",
        args: [recipientAddress as `0x${string}`, sendAmount],
      });

      // For token transfers, explicitly set a higher gas limit
      tx = await vm.prepareTransactionRequest({
        chain: baseSepolia,
        to: token.contractAddress,
        data,
        nonce,
        from: senderAddress,
        gas: BigInt(150000), // Higher gas limit for token transfers
        type: "eip1559", // Explicitly set transaction type
      });
    }

    await ctx.reply(`🔄 Processing ${tokenSymbol} remittance transaction...`);

    // Sign the transaction using Dynamic's client
    const sig = await client.signTransaction({
      senderAddress: senderAddress,
      transaction: tx as any,
    });

    // Create a wallet client to send the raw transaction
    const walletClient = createWalletClient({
      chain: baseSepolia,
      transport: http("https://sepolia.base.org"),
      account: senderAddress,
    });

    // Send the signed transaction
    const txHash = await walletClient.sendRawTransaction({
      serializedTransaction: sig as `0x${string}`,
    });

    await ctx.reply(
      `✅ Remittance sent!\nFrom: \`${userWallet.walletAddress}\`\nTo: \`${recipientAddress}\`\nAmount: ${amount} ${tokenSymbol}\nTx: \`${txHash}\``,
      { parse_mode: "Markdown" }
    );
  } catch (error) {
    console.error("Error in send command:", error);
    await ctx.reply("❌ Error processing transaction. Please try again.");
  }
};

5. Create User-to-User Transfer Feature

Add a new command for sending crypto directly to other Telegram users by creating sendToUser.ts in the commands folder. This command is what makes our remittance bot special - it allows users to send tokens to other Telegram users by their username instead of requiring wallet addresses:

commands/sendToUser.ts
import type { CommandContext, Context } from "grammy";
import { createWalletClient, encodeFunctionData, http } from "viem";
import { baseSepolia } from "viem/chains";
import { authenticatedEvmClient } from "../lib/dynamic";
import { supabase } from "../lib/supabase";
import {
  BotError,
  ErrorType,
  handleTransactionError,
  logError,
  validateAmount,
} from "../utils/errorHandling";
import {
  ERC20_ABI,
  getTokenBalance,
  isValidToken,
  parseTokenAmount,
  TOKENS,
} from "../utils/tokens";

export const sendToUser = async (ctx: CommandContext<Context>) => {
  try {
    const tgId = ctx.from?.id;
    if (!tgId) {
      throw new BotError(ErrorType.USER_NOT_FOUND, "Could not identify user");
    }

    const parts = (ctx.message?.text || "").split(" ");
    if (parts.length < 3) {
      await ctx.reply(
        "❌ Incorrect format. Please use:\n/sendtouser <username> <amount> [token] [note]"
      );
      return;
    }

    let recipientUsername = parts[1] || "";
    const amount = parts[2] || "0";
    let tokenSymbol = "ETH";
    let note = "";

    if (parts.length >= 4) {
      const part3 = (parts[3] || "").toUpperCase();

      if (isValidToken(part3)) {
        tokenSymbol = part3;
        if (parts.length > 4) {
          note = parts.slice(4).join(" ");
          if (note.startsWith('"') && note.endsWith('"')) {
            note = note.substring(1, note.length - 1);
          }
        }
      } else {
        note = parts.slice(3).join(" ");
        if (note.startsWith('"') && note.endsWith('"')) {
          note = note.substring(1, note.length - 1);
        }
      }
    }

    if (!isValidToken(tokenSymbol)) {
      await ctx.reply(
        `❌ Invalid token: ${tokenSymbol}. Supported tokens: ${Object.keys(
          TOKENS
        ).join(", ")}`
      );
      return;
    }

    try {
      validateAmount(amount);
    } catch (error) {
      if (error instanceof BotError) {
        await ctx.reply(`❌ ${error.message}. Please provide a valid amount.`);
      } else {
        await ctx.reply("❌ Invalid amount. Please provide a valid number.");
      }
      return;
    }

    recipientUsername = recipientUsername.startsWith("@")
      ? recipientUsername.substring(1)
      : recipientUsername;

    if (recipientUsername.length < 3) {
      await ctx.reply(
        "❌ Invalid recipient username. Username should be at least 3 characters."
      );
      return;
    }

    const { data: senderWallet, error: senderFetchError } = await supabase
      .from("wallets")
      .select("walletAddress")
      .eq("tg_id", tgId)
      .maybeSingle();

    if (senderFetchError) {
      logError("sendToUser/fetchWallet", senderFetchError, { tgId });
      throw new BotError(ErrorType.DB_ERROR, "Error fetching wallet data");
    }

    if (!senderWallet) {
      throw new BotError(ErrorType.WALLET_NOT_FOUND, "No wallet found");
    }

    try {
      const botToken = process.env.BOT_TOKEN || "";
      if (!botToken) {
        throw new BotError(ErrorType.AUTH_ERROR, "Bot token not configured");
      }

      // Look up the recipient directly in the database by username
      const recepientUser = await supabase
        .from("wallets")
        .select("*")
        .eq("username", recipientUsername)
        .maybeSingle();

      if (recepientUser.error) {
        logError("sendToUser/fetchRecipientWallet", recepientUser.error, {
          recipientUsername,
        });
        throw new BotError(
          ErrorType.DB_ERROR,
          "Error fetching recipient wallet data"
        );
      }

      if (!recepientUser.data) {
        throw new BotError(
          ErrorType.USER_NOT_FOUND,
          `User @${recipientUsername} not found or doesn't have a wallet`
        );
      }

      const recipientAddress = recepientUser.data.walletAddress;

      if (!recipientAddress) {
        throw new BotError(
          ErrorType.WALLET_NOT_FOUND,
          `No wallet found for user @${recipientUsername}`
        );
      }

      const client = await authenticatedEvmClient();
      const vm = client.createViemPublicClient({
        chain: baseSepolia,
        rpcUrl: "https://sepolia.base.org",
      });

      // Format wallet address correctly
      const senderAddress = senderWallet.walletAddress as `0x${string}`;

      const currentBalance = await getTokenBalance(
        vm,
        tokenSymbol,
        senderAddress
      );

      const sendAmount = parseTokenAmount(amount, tokenSymbol);
      if (BigInt(currentBalance.amount) < sendAmount) {
        throw new BotError(
          ErrorType.INSUFFICIENT_FUNDS,
          `Insufficient ${tokenSymbol} balance. You have ${currentBalance.formatted} ${tokenSymbol}.`
        );
      }

      const nonce = await vm.getTransactionCount({
        address: senderAddress,
      });

      let tx;
      if (tokenSymbol === "ETH") {
        tx = await vm.prepareTransactionRequest({
          chain: baseSepolia,
          to: recipientAddress as `0x${string}`,
          value: sendAmount,
          nonce,
          from: senderAddress,
          gas: BigInt(21000), // Fixed gas for simple ETH transfers
        });
      } else {
        const token = TOKENS[tokenSymbol];
        if (!token) {
          throw new Error(`Token ${tokenSymbol} is not properly configured`);
        }

        // Ensure wallet address is valid
        if (
          !senderAddress ||
          senderAddress === "0x0000000000000000000000000000000000000000"
        ) {
          throw new BotError(
            ErrorType.WALLET_NOT_FOUND,
            "Invalid wallet address for token transfer"
          );
        }

        const data = encodeFunctionData({
          abi: ERC20_ABI,
          functionName: "transfer",
          args: [recipientAddress as `0x${string}`, sendAmount],
        });

        // For token transfers, explicitly set a higher gas limit
        tx = await vm.prepareTransactionRequest({
          chain: baseSepolia,
          to: token.contractAddress,
          data,
          nonce,
          from: senderAddress,
          gas: BigInt(150000), // Higher gas limit for token transfers
          type: "eip1559", // Explicitly set transaction type
        });
      }

      await ctx.reply(
        `🔄 Processing ${tokenSymbol} remittance to @${recipientUsername}...`
      );

      // Sign the transaction using Dynamic's client
      const sig = await client.signTransaction({
        senderAddress: senderAddress,
        transaction: tx as any,
      });

      // Create a wallet client to send the raw transaction
      const walletClient = createWalletClient({
        chain: baseSepolia,
        transport: http("https://sepolia.base.org"),
        account: senderAddress,
      });

      // Send the signed transaction
      const txHash = await walletClient.sendRawTransaction({
        serializedTransaction: sig as `0x${string}`,
      });

      let successMsg = `✅ Remittance of ${amount} ${tokenSymbol} sent to @${recipientUsername}`;
      if (note) {
        successMsg += `\nNote: "${note}"`;
      }
      successMsg += `\nTx: \`${txHash}\``;

      await ctx.reply(successMsg, { parse_mode: "Markdown" });

      // Notify the recipient
      try {
        if (recepientUser.data.username) {
          await ctx.api.sendMessage(
            recepientUser.data.tg_id,
            `💰 You've received a remittance of ${amount} ${tokenSymbol} from ${
              ctx.from?.username ? `@${ctx.from.username}` : "another user"
            }${note ? `\nNote: "${note}"` : ""}\nTx: \`${txHash}\``,
            { parse_mode: "Markdown" }
          );
        }
      } catch (notifyError) {
        console.error("Could not notify recipient:", notifyError);
      }
    } catch (error) {
      throw error;
    }
  } catch (error) {
    await handleTransactionError(ctx, error);
  }
};

We first get the address of the recipient by querying the Supabase database for the username provided. If the user exists and has a wallet, we proceed to create a transaction request similar to the send command, but using the recipient’s address.

6. Add Balance Checking Feature

Create a balance.ts file in the commands folder to allow users to check their token balances. This command queries both ETH and USDC balances and formats them nicely for the user:

commands/balance.ts
import type { CommandContext, Context } from "grammy";
import { baseSepolia } from "viem/chains";
import { authenticatedEvmClient } from "../lib/dynamic";
import { supabase } from "../lib/supabase";
import { getTokenBalance } from "../utils/tokens";

export const balance = async (ctx: CommandContext<Context>) => {
  try {
    const tgId = ctx.from?.id;
    if (!tgId) {
      await ctx.reply("Error: Could not identify user.");
      return;
    }

    const { data: userWallet, error: fetchError } = await supabase
      .from("wallets")
      .select("walletAddress")
      .eq("tg_id", tgId)
      .maybeSingle();

    if (fetchError || !userWallet) {
      await ctx.reply("❌ No wallet found. Create one with /wallet first.");
      return;
    }

    await ctx.reply("🔄 Checking balance...");

    const client = await authenticatedEvmClient();
    const vm = client.createViemPublicClient({
      chain: baseSepolia,
      rpcUrl: "https://sepolia.base.org",
    });

    const ethBalance = await getTokenBalance(
      vm,
      "ETH",
      userWallet.walletAddress as `0x${string}`
    );

    const usdcBalance = await getTokenBalance(
      vm,
      "USDC",
      userWallet.walletAddress as `0x${string}`
    );

    await ctx.reply(
      `💰 *Your Wallet Balance*\n\n` +
        `Address: \`${userWallet.walletAddress}\`\n` +
        `*${ethBalance.formatted} ETH*\n` +
        `*${usdcBalance.formatted} USDC*\n` +
        `Network: Base Sepolia (Testnet)`,
      { parse_mode: "Markdown" }
    );
  } catch (error) {
    console.error("Error in balance command:", error);
    await ctx.reply("❌ Error checking balance. Please try again.");
  }
};

We use the getTokenBalance utility function to retrieve both ETH and USDC balances for the user’s wallet address that we created earlier.

7. Update Bot File to Connect All Components

Update the main index.ts file to include all your new commands and functionality. This file ties everything together by registering all the commands and setting up the command menu that users will see in Telegram:

index.ts
import { Bot } from "grammy";
import { balance } from "./commands/balance";
import { send } from "./commands/send";
import { sendToUser } from "./commands/sendToUser";
import { sign } from "./commands/sign";
import { wallet } from "./commands/wallet";

const bot = new Bot(process.env.BOT_TOKEN || "");

bot.catch((err) => console.error("Bot error:", err.error));

bot.command("wallet", wallet);
bot.command("balance", balance);
bot.command("send", send);
bot.command("sendtouser", sendToUser);
bot.command("sign", sign);

bot.api.setMyCommands([
  { command: "wallet", description: "Create or manage your wallet" },
  { command: "balance", description: "Check your wallet balance" },
  { command: "send", description: "Send funds to a blockchain address" },
  {
    command: "sendtouser",
    description: "Send stablecoin remittance to a Telegram user",
  },
  { command: "sign", description: "Sign a message with your wallet" },
]);

bot.start();
console.log("Stablecoin Remittance Bot started successfully!");

export { bot };

Running the Bot

To run your stablecoin remittance Telegram bot:

# Regular run
bun start

# Development with hot-reload

bun dev

Conclusion

You’ve successfully built a Telegram bot that enables crypto remittances using Dynamic’s server wallet. This solution makes cryptocurrency accessible to anyone with a Telegram account, without requiring technical blockchain knowledge.

For the complete source code, visit our GitHub repository.

For additional help or to join the community: