ESC
Start typing to search...

Tutorial: Build a Payment Verification Bot

Step-by-step guide to building a plugin that accepts TON payments and verifies them on-chain.

Goal

By the end of this tutorial, you will have a plugin that:

  • Generates unique payment requests with memos for tracking
  • Verifies incoming TON transfers on-chain using sdk.ton.verifyPayment()
  • Implements replay protection so a single payment cannot be claimed twice
  • Shows wallet balance and recent transactions

Prerequisites

  • Teleton Agent running with a TON wallet configured (config.yaml has a valid mnemonic or wallet file)
  • The wallet should have a small amount of TON for testing
  • @teleton-agent/sdk installed as a dev dependency in your plugin

Step 1: Manifest & Structure

Create a new plugin directory and define the manifest. The manifest declares the plugin's identity and is required for proper registration.

pay-verify/index.ts — Manifest
import type { PluginSDK, SimpleToolDef, PluginManifest } from "@teleton-agent/sdk";
import type Database from "better-sqlite3";

export const manifest: PluginManifest = {
  name: "pay-verify",
  version: "1.0.0",
  description: "Payment verification bot — generate requests, verify TON transfers on-chain",
};

Step 2: Database Migration

The migrate() export runs once when the plugin is first loaded (and on schema changes). We create two tables: one for pending payment requests, and the critical used_transactions table for replay protection.

pay-verify/index.ts — Migration
export const migrate = (db: Database.Database): void => {
  db.exec(`
    CREATE TABLE IF NOT EXISTS payment_requests (
      id TEXT PRIMARY KEY,
      memo TEXT NOT NULL,
      amount_ton REAL NOT NULL,
      requester_chat_id TEXT NOT NULL,
      status TEXT NOT NULL DEFAULT 'pending',
      created_at TEXT NOT NULL DEFAULT (datetime('now')),
      verified_at TEXT,
      tx_hash TEXT
    );

    CREATE TABLE IF NOT EXISTS used_transactions (
      tx_hash TEXT NOT NULL,
      game_type TEXT NOT NULL,
      used_at TEXT NOT NULL DEFAULT (datetime('now')),
      PRIMARY KEY (tx_hash, game_type)
    );
  `);
};

The used_transactions table is required by sdk.ton.verifyPayment(). It uses INSERT OR IGNORE internally to prevent the same blockchain transaction from being claimed more than once. The game_type column lets you separate different payment flows (e.g., "subscription" vs "purchase") so a transaction used for one flow cannot be replayed against another.

Step 3: Tool — generate_payment_request

This tool creates a payment request with a unique memo. The user sends TON to the bot's wallet with this memo, and the verification tool can then match it.

generate_payment_request tool
{
  name: "pay_generate_request",
  description: "Generate a TON payment request with a unique memo for tracking",
  parameters: {
    type: "object",
    properties: {
      amount: { type: "number", description: "Amount in TON (e.g. 1.5)" },
      description: { type: "string", description: "What the payment is for" },
    },
    required: ["amount"],
  },
  async execute(params, context) {
    const walletAddress = sdk.ton.getAddress();
    if (!walletAddress) {
      return { error: "Bot wallet not initialized. Configure TON wallet first." };
    }

    // Generate a unique memo using a short random ID
    const id = Math.random().toString(36).substring(2, 10);
    const memo = `pay-${id}`;
    const amount = params.amount;

    // Store the request in the database
    sdk.db!.prepare(`
      INSERT INTO payment_requests (id, memo, amount_ton, requester_chat_id)
      VALUES (?, ?, ?, ?)
    `).run(id, memo, amount, context.chatId);

    const description = params.description || "Payment";

    return {
      success: true,
      paymentRequest: {
        id,
        walletAddress,
        amount: `${amount} TON`,
        memo,
        description,
        instructions: [
          `Send exactly ${amount} TON to:`,
          walletAddress,
          `Include this memo in the transaction comment: ${memo}`,
          "Then use the verify_payment tool to confirm.",
        ].join("\n"),
      },
    };
  },
}

Step 4: Tool — verify_payment

This tool uses sdk.ton.verifyPayment() to check the blockchain for a matching transaction. The SDK handles amount tolerance (1%), memo matching, time windowing, and replay protection internally.

verify_payment tool
{
  name: "pay_verify_payment",
  description: "Verify a TON payment was received on-chain using the payment request memo",
  parameters: {
    type: "object",
    properties: {
      request_id: { type: "string", description: "Payment request ID to verify" },
    },
    required: ["request_id"],
  },
  async execute(params, context) {
    // Look up the payment request
    const request = sdk.db!.prepare(
      "SELECT * FROM payment_requests WHERE id = ?"
    ).get(params.request_id) as any;

    if (!request) {
      return { error: `Payment request "${params.request_id}" not found.` };
    }

    if (request.status === "verified") {
      return {
        alreadyVerified: true,
        txHash: request.tx_hash,
        message: "This payment was already verified.",
      };
    }

    // Verify the payment on-chain
    const result = await sdk.ton.verifyPayment({
      amount: request.amount_ton,
      memo: request.memo,
      gameType: "pay-verify",  // Replay protection namespace
      maxAgeMinutes: 30,       // override default (10 min)
    });

    if (result.verified) {
      // Mark the request as verified
      sdk.db!.prepare(`
        UPDATE payment_requests
        SET status = 'verified', verified_at = datetime('now'), tx_hash = ?
        WHERE id = ?
      `).run(result.txHash, params.request_id);

      return {
        verified: true,
        txHash: result.txHash,
        amount: `${result.amount} TON`,
        senderWallet: result.playerWallet,
        date: result.date,
        secondsAgo: result.secondsAgo,
        message: "Payment verified successfully!",
      };
    }

    return {
      verified: false,
      error: result.error || "No matching payment found.",
      hint: "Make sure you sent the correct amount with the exact memo. Payments are valid for 30 minutes.",
    };
  },
}

Step 5: Tool — check_balance

A convenience tool that shows the bot's wallet balance and recent incoming transactions.

check_balance tool
{
  name: "pay_check_balance",
  description: "Show the bot wallet balance and recent incoming transactions",
  parameters: { type: "object", properties: {} },
  async execute() {
    const address = sdk.ton.getAddress();
    if (!address) {
      return { error: "Bot wallet not initialized." };
    }

    const [balance, price, transactions] = await Promise.all([
      sdk.ton.getBalance(),
      sdk.ton.getPrice(),
      sdk.ton.getTransactions(address, 10),
    ]);

    const incoming = transactions.filter(
      (tx) => tx.type === "ton_received"
    );

    return {
      address,
      balance: balance?.balance ?? "unknown",
      balanceNano: balance?.balanceNano ?? "unknown",
      usdValue: balance && price
        ? `$${(parseFloat(balance.balance) * price.usd).toFixed(2)}`
        : "unknown",
      tonPrice: price ? `$${price.usd.toFixed(2)}` : "unknown",
      recentIncoming: incoming.map((tx) => ({
        hash: tx.hash,
        amount: tx.amount,
        from: tx.from,
        comment: tx.comment,
        date: tx.date,
      })),
    };
  },
}

Step 6: Putting It All Together

Here is the complete plugin file, ready to copy into your plugins directory:

pay-verify/index.ts — Complete Plugin
import type { PluginSDK, SimpleToolDef, PluginManifest } from "@teleton-agent/sdk";
import type Database from "better-sqlite3";

export const manifest: PluginManifest = {
  name: "pay-verify",
  version: "1.0.0",
  description: "Payment verification bot — generate requests, verify TON transfers on-chain",
};

export const migrate = (db: Database.Database): void => {
  db.exec(`
    CREATE TABLE IF NOT EXISTS payment_requests (
      id TEXT PRIMARY KEY,
      memo TEXT NOT NULL,
      amount_ton REAL NOT NULL,
      requester_chat_id TEXT NOT NULL,
      status TEXT NOT NULL DEFAULT 'pending',
      created_at TEXT NOT NULL DEFAULT (datetime('now')),
      verified_at TEXT,
      tx_hash TEXT
    );

    CREATE TABLE IF NOT EXISTS used_transactions (
      tx_hash TEXT NOT NULL,
      game_type TEXT NOT NULL,
      used_at TEXT NOT NULL DEFAULT (datetime('now')),
      PRIMARY KEY (tx_hash, game_type)
    );
  `);
};

export const tools = (sdk: PluginSDK): SimpleToolDef[] => [
  {
    name: "pay_generate_request",
    description: "Generate a TON payment request with a unique memo for tracking",
    parameters: {
      type: "object",
      properties: {
        amount: { type: "number", description: "Amount in TON (e.g. 1.5)" },
        description: { type: "string", description: "What the payment is for" },
      },
      required: ["amount"],
    },
    async execute(params, context) {
      const walletAddress = sdk.ton.getAddress();
      if (!walletAddress) {
        return { error: "Bot wallet not initialized. Configure TON wallet first." };
      }

      const id = Math.random().toString(36).substring(2, 10);
      const memo = `pay-${id}`;
      const amount = params.amount;

      sdk.db!.prepare(`
        INSERT INTO payment_requests (id, memo, amount_ton, requester_chat_id)
        VALUES (?, ?, ?, ?)
      `).run(id, memo, amount, context.chatId);

      const description = params.description || "Payment";

      return {
        success: true,
        paymentRequest: {
          id,
          walletAddress,
          amount: `${amount} TON`,
          memo,
          description,
          instructions: [
            `Send exactly ${amount} TON to:`,
            walletAddress,
            `Include this memo in the transaction comment: ${memo}`,
            "Then use the verify_payment tool to confirm.",
          ].join("\n"),
        },
      };
    },
  },
  {
    name: "pay_verify_payment",
    description: "Verify a TON payment was received on-chain using the payment request memo",
    parameters: {
      type: "object",
      properties: {
        request_id: { type: "string", description: "Payment request ID to verify" },
      },
      required: ["request_id"],
    },
    async execute(params, context) {
      const request = sdk.db!.prepare(
        "SELECT * FROM payment_requests WHERE id = ?"
      ).get(params.request_id) as any;

      if (!request) {
        return { error: `Payment request "${params.request_id}" not found.` };
      }

      if (request.status === "verified") {
        return {
          alreadyVerified: true,
          txHash: request.tx_hash,
          message: "This payment was already verified.",
        };
      }

      const result = await sdk.ton.verifyPayment({
        amount: request.amount_ton,
        memo: request.memo,
        gameType: "pay-verify",
        maxAgeMinutes: 30,
      });

      if (result.verified) {
        sdk.db!.prepare(`
          UPDATE payment_requests
          SET status = 'verified', verified_at = datetime('now'), tx_hash = ?
          WHERE id = ?
        `).run(result.txHash, params.request_id);

        return {
          verified: true,
          txHash: result.txHash,
          amount: `${result.amount} TON`,
          senderWallet: result.playerWallet,
          date: result.date,
          secondsAgo: result.secondsAgo,
          message: "Payment verified successfully!",
        };
      }

      return {
        verified: false,
        error: result.error || "No matching payment found.",
        hint: "Make sure you sent the correct amount with the exact memo. Payments are valid for 30 minutes.",
      };
    },
  },
  {
    name: "pay_check_balance",
    description: "Show the bot wallet balance and recent incoming transactions",
    parameters: { type: "object", properties: {} },
    async execute() {
      const address = sdk.ton.getAddress();
      if (!address) {
        return { error: "Bot wallet not initialized." };
      }

      const [balance, price, transactions] = await Promise.all([
        sdk.ton.getBalance(),
        sdk.ton.getPrice(),
        sdk.ton.getTransactions(address, 10),
      ]);

      const incoming = transactions.filter(
        (tx) => tx.type === "ton_received"
      );

      return {
        address,
        balance: balance?.balance ?? "unknown",
        balanceNano: balance?.balanceNano ?? "unknown",
        usdValue: balance && price
          ? `$${(parseFloat(balance.balance) * price.usd).toFixed(2)}`
          : "unknown",
        tonPrice: price ? `$${price.usd.toFixed(2)}` : "unknown",
        recentIncoming: incoming.map((tx) => ({
          hash: tx.hash,
          amount: tx.amount,
          from: tx.from,
          comment: tx.comment,
          date: tx.date,
        })),
      };
    },
  },
];

Testing

  1. Place the plugin in your plugins/pay-verify/ directory and restart the agent.
  2. In Telegram, ask the agent: "Generate a payment request for 0.1 TON"
  3. The agent will return a wallet address and memo. Send the TON from another wallet with that memo.
  4. Wait for the transaction to confirm on-chain (usually 5-15 seconds).
  5. Ask the agent: "Verify payment request [id]"
  6. The agent should confirm the payment with the transaction hash and sender address.
  7. Try verifying the same request again — it should report "already verified" (replay protection).

Common Pitfalls

PitfallSolution
Missing used_transactions table Always create it in migrate(). Without it, verifyPayment() throws OPERATION_FAILED.
Amount mismatch due to fees The SDK uses a 1% tolerance by default. If your amounts are very small (under 0.01 TON), the tolerance may still cause false negatives. Use amounts above 0.05 TON.
Payment not found Set maxAgeMinutes appropriately. The default is 10 minutes. For e-commerce flows, use 30 or 60 minutes.
Float precision errors Always pass amounts as human-readable numbers (e.g. 1.5), not nanoTON. The SDK handles string-based decimal conversion internally to avoid floating-point errors.
Memo case sensitivity Memo matching is case-insensitive. "pay-abc123" matches "PAY-ABC123".
Wallet not initialized Always check sdk.ton.getAddress() before calling payment methods. Return a clear error if null.