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.yamlhas a valid mnemonic or wallet file) - The wallet should have a small amount of TON for testing
@teleton-agent/sdkinstalled 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.
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.
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.
{
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.
{
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.
{
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:
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
- Place the plugin in your
plugins/pay-verify/directory and restart the agent. - In Telegram, ask the agent: "Generate a payment request for 0.1 TON"
- The agent will return a wallet address and memo. Send the TON from another wallet with that memo.
- Wait for the transaction to confirm on-chain (usually 5-15 seconds).
- Ask the agent: "Verify payment request [id]"
- The agent should confirm the payment with the transaction hash and sender address.
- Try verifying the same request again — it should report "already verified" (replay protection).
Common Pitfalls
| Pitfall | Solution |
|---|---|
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. |