- Новое
- Добавить закладку
- #1
Наверное многие слышали, что Telegram установил запрет на использование сторонних блокчейнов в мини-приложениях соответственно сделав доступной только TON сеть.
Не знаю как часто принимают санкции по отношению к тем, кто не соблюдает данный запрет, но если проект долгосрочный, то и рисковать не стоит.
В этой статье хочу рассказать как можно легко и без каких-либо сторонних решений принимать платежи в TON, USDT и NOT.
Введение
Из-за особенности работы TON сети идея генерировать адрес на каждый инвойс не кажется оптимальной.
Не только из-за низкого block time и необходимости вытаскивать транзакции из каждого shard блока, а из-за того, что из
транзакции, совершающей трансфер жетонов нельзя извлечь основной адрес без создания доп. запроса к API.
Решение, что я предлагаю базируется на такой вещи, как memo.
Memo - это обычный текстовый комментарий, что прикрепляется к транзакции.
То есть вместо идентификации платежа по уникальному адресу будет идентификация по уникальному значению в memo.
Типовая платежная страница содержит: QR-код, сумму, адрес и таймер (опционально).
Скажу сразу что само собой можно добавить memo на страницу, и все будет работать.
Проблема в том, что любой TON кошелек позволит создать трансфер без установки текстового комментарий, и соответственно некоторые пользователи могут отправить транзакцию без memo.
На опыте скажу, что бывало такое даже очень часто, и выделение необходимости указания memo при переводе не слишком помогало.
К счастью в QR-код помимо адреса и суммы можно засунуть текстовый комментарий.
Если боитесь частых обращений в поддержку рекомендую на платежной странице использовать QR-код или TONConnect кнопку.
Вот так может выглядеть платежная страница (модальное окно):
Или вот так:
Немного про Jetton'ы
Jetton'ы - это ненативные токены в сети TON, и по смыслу и частично по спецификации походят на ERC-20/TRC-20 токены.
Для хранения и отправки jetton'ов создается вспомогательный кошелек, который называется Jetton Wallet'ом, который
детерминировано вычисляется из обычного адреса и данных, что относятся к Jetton Master (контракт токена).
Абстрагироваться от этого нельзя, так как для программного создания транзакции, что трансферит жетоны необходимо иметь доступ к Jetton Wallet.
Благодаря TONConnect имеем доступ к обычному адресу кошелька пользователя, а Jetton Master адрес извлекаем из статичного конфига.
Под каждую пару holder address и master wallet address будет определенный jetton wallet address.
При большом кол-ве пользователей API будет сильно нагружаться.
Может помочь кеширование, но есть способ вычисления jetton wallet адрес локально:
А использовать этот класс можно так:
Серверная часть
Начнем с описания моделей:
Переходим к самой обработке транзакций:
Все будет крутится вокруг метода getTransactions, который предназначен для получения транзакций по определенному адресу.
Как ранее было сообщено для хранения Jetton'ов создается отдельный кошелек, то есть чтобы и получить трансферы жетонов нужно вызывать getTransactions передавая в первый аргумент Jetton Wallet адрес.
Например, если нужно иметь поддержку не только TON, но и USDT и NOT, то это добавит 2 новых запроса.
К делу.
Первым дело создаем TONTransactionIterator. Это класс отвечает за возможность загрузить все транзакции с кошелька.
Обычно getTransactions метод имеет ограничение в 100 транзакций. Может и не хватить, поэтому нужно иметь возможность получить и более старые транзакции.
Создаем абстрактный класс TONDaemon, который содержит вспомогательные методы для обработки транзакций абстрактный handlePage.
Приступаем к разработке производных классов.
Основная идея в том, что каждый производный класс должен уметь провалидировать транзакцию и извлечь необходимые данные:
Теперь пишем ToncoinDaemon, который наследует TONDaemon и должен имплементировать handlePage метод.
Приступаем к JettonDaemon.
Каждый Daemon класс при нахождении валидной и необработанной транзакции вызывает process метод ChargeService
Помимо сохранения транзакции в базе данных происходит создание webhook уведомления.
Немного про Webhook уведомления
Текущая реализация webhook уведомлений:
Вот так выглядит webhook уведомление:
Реализация не ахти, но вполне надежна.
Для того чтобы исключить интервальный поллинг обычно используются очереди, но для гарантии порядка доставки
распараллелить отправку уведомлений все равно не получится.
Клиентская часть
Ниже приведу блоки кода, которых достаточно для того, чтобы программно создать транзакцию для перевода нативных токенов или жетонов.
О вещах типа как приконнектить кошелек или вытащить адрес приконеченного кошелька думаю нет смысла писать.
Отправка TONs:
Отправка Jettons:
QR-Code
Для рендера QR-кода рекомендую использовать qr-code-styling библиотеку.
Гибко кастоминизируется, и есть возможность создать конфиг онлайн: https://qr-code-styling.com/
https://docs.ton.org/ecosystem/wallet-apps/deep-links - документация по формату платежной ссылки.
Итоговый флоу
Вот псевдокод:
Заключение
На базе информации в статье можно реализовать свое решение особенно если знаете TypeScript или использовать "как есть", что подойдет для небольших проектов.
Исходный код + README.md: https://github.com/shuriken0x/0xPay-ton
Не знаю как часто принимают санкции по отношению к тем, кто не соблюдает данный запрет, но если проект долгосрочный, то и рисковать не стоит.
В этой статье хочу рассказать как можно легко и без каких-либо сторонних решений принимать платежи в TON, USDT и NOT.
Введение
Из-за особенности работы TON сети идея генерировать адрес на каждый инвойс не кажется оптимальной.
Не только из-за низкого block time и необходимости вытаскивать транзакции из каждого shard блока, а из-за того, что из
транзакции, совершающей трансфер жетонов нельзя извлечь основной адрес без создания доп. запроса к API.
Решение, что я предлагаю базируется на такой вещи, как memo.
Memo - это обычный текстовый комментарий, что прикрепляется к транзакции.
То есть вместо идентификации платежа по уникальному адресу будет идентификация по уникальному значению в memo.
- Особенности memo-based подхода:
Нет необходимости управлять множеством кошельков, не нужно хранить приватные ключи на сервере. - Минимальная нагрузка на API.
Достаточно бесплатного toncenter тарифа для того, чтобы иметь возможность загружать 100 транзакций каждую секунду. - Отсутствие доп. расходов.
Все средства идут на главный кошелек, то есть и необходимость для того того, чтобы переводить средства с других
кошельков на главный попросту отсутствует.
Например, при выводе TRC20 USDT с инвойс-кошелька потребуется несколько долларов в TRX.
Типовая платежная страница содержит: QR-код, сумму, адрес и таймер (опционально).
Скажу сразу что само собой можно добавить memo на страницу, и все будет работать.
Проблема в том, что любой TON кошелек позволит создать трансфер без установки текстового комментарий, и соответственно некоторые пользователи могут отправить транзакцию без memo.
На опыте скажу, что бывало такое даже очень часто, и выделение необходимости указания memo при переводе не слишком помогало.
К счастью в QR-код помимо адреса и суммы можно засунуть текстовый комментарий.
Если боитесь частых обращений в поддержку рекомендую на платежной странице использовать QR-код или TONConnect кнопку.
Вот так может выглядеть платежная страница (модальное окно):
Или вот так:
Немного про Jetton'ы
Jetton'ы - это ненативные токены в сети TON, и по смыслу и частично по спецификации походят на ERC-20/TRC-20 токены.
Для хранения и отправки jetton'ов создается вспомогательный кошелек, который называется Jetton Wallet'ом, который
детерминировано вычисляется из обычного адреса и данных, что относятся к Jetton Master (контракт токена).
Абстрагироваться от этого нельзя, так как для программного создания транзакции, что трансферит жетоны необходимо иметь доступ к Jetton Wallet.
Благодаря TONConnect имеем доступ к обычному адресу кошелька пользователя, а Jetton Master адрес извлекаем из статичного конфига.
JavaScript:
const client = new TonClient({
endpoint: 'https://toncenter.com/api/v2/jsonRPC',
});
async function getJettonWalletAddress(holderAddress: string, masterAddress: string) {
const jettonMasterAddress = Address.parse(
masterAddress,
);
const walletAddress = Address.parse(holderAddress);
const walletAddressCell = beginCell().storeAddress(walletAddress).endCell();
const el: TupleItemSlice = {
type: 'slice',
cell: walletAddressCell,
};
const data = await client.runMethod(
jettonMasterAddress,
'get_wallet_address',
[el],
);
return data.stack.readAddress();
}
Под каждую пару holder address и master wallet address будет определенный jetton wallet address.
При большом кол-ве пользователей API будет сильно нагружаться.
Может помочь кеширование, но есть способ вычисления jetton wallet адрес локально:
JavaScript:
import { Injectable } from '@nestjs/common';
import {
Address,
Cell,
Contract,
contractAddress,
ContractProvider,
Sender,
StateInit,
toNano,
TupleBuilder,
} from '@ton/core';
import { Blockchain, createShardAccount, type SandboxContract } from '@ton/sandbox';
@Injectable()
export class JettonService {
protected constructor(
protected jettonMaster: Address,
protected contract: SandboxContract<JettonContract>,
) {}
public static async initialize(jettonContractCode: string, jettonContractData: string, jettonMasterAddress: string) {
const blockchain = await Blockchain.create();
const contractCode = Cell.fromHex(jettonContractCode);
const contractData = Cell.fromHex(jettonContractData);
const masterAddress = Address.parse(jettonMasterAddress);
const openedContract = blockchain.openContract(new JettonContract(masterAddress));
await blockchain.setShardAccount(
masterAddress,
createShardAccount({
address: masterAddress,
code: contractCode,
data: contractData,
balance: toNano('1'),
workchain: 0,
}),
);
return new JettonService(masterAddress, openedContract);
}
public getJettonMaster() {
return this.jettonMaster;
}
public async getJettonWallet(holder: string) {
const stack = new TupleBuilder();
stack.writeAddress(Address.parse(holder));
const result = await this.contract.getRunMethod('get_wallet_address', stack);
return result.readAddress();
}
}
class JettonContract implements Contract {
readonly address: Address;
readonly init?: StateInit;
static fromInit(code: Cell, data: Cell) {
return new JettonContract(contractAddress(0, { code: code, data: data }), { code: code, data: data });
}
constructor(address: Address, init?: StateInit) {
this.address = address;
this.init = init;
}
async send(
provider: ContractProvider,
via: Sender,
args: { value: bigint; bounce?: boolean | null | undefined },
body: Cell,
) {
await provider.internal(via, { ...args, body: body });
}
async getRunMethod(provider: ContractProvider, id: number | string, stack: TupleBuilder = new TupleBuilder()) {
return (await provider.get(id, stack.build())).stack;
}
}
А использовать этот класс можно так:
JavaScript:
async function main() {
const service = await JettonService.initialize(
'b5ee9c72010218010005bb000114ff00f4a413f4bcf2c80b0102016202030202cb0405020120141502f3d0cb434c0c05c6c238ecc200835c874c7c0608405e351466ea44c38601035c87e800c3b51343e803e903e90353534541168504d3214017e809400f3c58073c5b333327b55383e903e900c7e800c7d007e800c7e80004c5c3e0e80b4c7c04074cfc044bb51343e803e903e9035353449a084190adf41eeb8c089a0607001da23864658380e78b64814183fa0bc0019635355161c705f2e04904fa4021fa4430c000f2e14dfa00d4d120d0d31f018210178d4519baf2e0488040d721fa00fa4031fa4031fa0020d70b009ad74bc00101c001b0f2b19130e254431b0803fa82107bdd97deba8ee7363805fa00fa40f82854120a70546004131503c8cb0358fa0201cf1601cf16c921c8cb0113f40012f400cb00c9f9007074c8cb02ca07cbffc9d05008c705f2e04a12a14414506603c85005fa025003cf1601cf16ccccc9ed54fa40d120d70b01c000b3915be30de02682102c76b973bae30235250a0b0c018e2191729171e2f839206e938124279120e2216e94318128739101e25023a813a0738103a370f83ca00270f83612a00170f836a07381040982100966018070f837a0bcf2b025597f0900ec82103b9aca0070fb02f828450470546004131503c8cb0358fa0201cf1601cf16c921c8cb0113f40012f400cb00c920f9007074c8cb02ca07cbffc9d0c8801801cb0501cf1658fa02029858775003cb6bcccc9730017158cb6acce2c98011fb005005a04314c85005fa025003cf1601cf16ccccc9ed540044c8801001cb0501cf1670fa027001cb6a8210d53276db01cb1f0101cb3fc98042fb0001fc145f04323401fa40d2000101d195c821cf16c9916de2c8801001cb055004cf1670fa027001cb6a8210d173540001cb1f500401cb3f23fa4430c0008e35f828440470546004131503c8cb0358fa0201cf1601cf16c921c8cb0113f40012f400cb00c9f9007074c8cb02ca07cbffc9d012cf1697316c127001cb01e2f400c90d04f882106501f354ba8e223134365145c705f2e04902fa40d1103402c85005fa025003cf1601cf16ccccc9ed54e0258210fb88e119ba8e2132343603d15131c705f2e0498b025512c85005fa025003cf1601cf16ccccc9ed54e034248210235caf52bae30237238210cb862902bae302365b2082102508d66abae3026c310e0f101100088050fb0002ec3031325033c705f2e049fa40fa00d4d120d0d31f01018040d7212182100f8a7ea5ba8e4d36208210595f07bcba8e2c3004fa0031fa4031f401d120f839206e943081169fde718102f270f8380170f836a0811a7770f836a0bcf2b08e138210eed236d3ba9504d30331d19434f2c048e2e2e30d50037012130044335142c705f2e049c85003cf16c9134440c85005fa025003cf1601cf16ccccc9ed54001e3002c705f2e049d4d4d101ed54fb0400188210d372158cbadc840ff2f000ce31fa0031fa4031fa4031f401fa0020d70b009ad74bc00101c001b0f2b19130e25442162191729171e2f839206e938124279120e2216e94318128739101e25023a813a0738103a370f83ca00270f83612a00170f836a07381040982100966018070f837a0bcf2b000c082103b9aca0070fb02f828450470546004131503c8cb0358fa0201cf1601cf16c921c8cb0113f40012f400cb00c920f9007074c8cb02ca07cbffc9d0c8801801cb0501cf1658fa02029858775003cb6bcccc9730017158cb6acce2c98011fb000025bd9adf6a2687d007d207d206a6a6888122f82402027116170085adbcf6a2687d007d207d206a6a688a2f827c1400b82a3002098a81e46581ac7d0100e78b00e78b6490e4658089fa00097a00658064fc80383a6465816503e5ffe4e84000cfaf16f6a2687d007d207d206a6a68bf99e836c1783872ebdb514d9c97c283b7f0ae5179029e2b6119c39462719e4f46ed8f7413e62c780a417877407e978f01a40711411b1acb773a96bdd93fa83bb5ca8435013c8c4b3ac91f4589b4780a38646583fa0064a18040',
'b5ee9c72010104010075000253705148e3baabcb0800c881fc78d28207072c728a2e7896228f37e17369ae121cb0eef7b4b0385f33304001020842028f452d7a4dfd74066b682365177259ed05734435be76b5fd4bd5d8af2b7c3d68010003003e68747470733a2f2f7465746865722e746f2f757364742d746f6e2e6a736f6e',
'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs',
);
console.log(service.getJettonMaster()); // Address EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs
console.log(await service.getJettonWallet('UQDKHZ7e70CzqdvZCC83Z4WVR8POC_ZB0J1Y4zo88G-zCSRH')); // Address EQC7aZ-_G_tWeSn0GZ0HclwZvGIBp-CRrSsbMibTHN6l4kr7
}
Серверная часть
Начнем с описания моделей:
- Charge - считайте, что это многоразовый инвойс.
Если мы бы писали решение под EVM, то charge имел бы уникальный адрес, но в memo-based решении у charge будет
уникальный memo. - ChargeTransaction - отловленные транзакции, что относятся к определенному charge (memo).
- ProcessedTransaction - вспомогательная таблица, что содержит идентификаторы обработанных транзакций.
Будут транзакции, которые не нужно обрабатывать и их неоходимо будет заносить в эту таблицу.
Например, транзакции без memo или с невалидным memo. - Webhook - вебхук уведомления.
Каждая отловленная валидная транзакция триггерит создание webhook уведомления.
JavaScript:
import {
Column,
CreateDateColumn,
Entity,
Generated,
Index,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
} from "typeorm"
import { ChargeTransaction } from "./charge-transaction.entity"
@Entity()
export class Charge {
@PrimaryGeneratedColumn("increment", { type: "bigint" })
id: string
@Generated("increment")
@Column("bigint", {
unique: true,
})
memo: string
@Index({
unique: false,
nullFiltered: true,
})
@Column("varchar", {
length: 255,
nullable: true,
})
payload: string | null
@OneToMany(() => ChargeTransaction, (tx) => tx.charge)
txs: Relation<ChargeTransaction>[]
@CreateDateColumn({ type: "timestamptz" })
createdAt: Date
}
JavaScript:
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, type Relation } from "typeorm"
import { Token } from "../consts/token"
import { Charge } from "./charge.entity"
@Entity()
export class ChargeTransaction {
@PrimaryGeneratedColumn("increment", { type: "bigint" })
id: string
@Column("varchar", {
length: 255,
unique: true,
})
txid: string
@Column("decimal", {
precision: 78,
scale: 0,
})
amount: string
@Column("enum", {
enum: Token,
})
token: Token
@ManyToOne(() => Charge, (charge) => charge.txs)
charge: Relation<Charge>
@Column("bigint")
chargeId: string
@CreateDateColumn({ type: "timestamptz" })
createdAt: Date
}
JavaScript:
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm"
@Entity()
export class ProcessedTransaction {
@PrimaryGeneratedColumn("increment", { type: "bigint" })
id: string
@Column("varchar", {
length: 255,
nullable: true,
unique: true,
})
txid: string | null
@Column("text", {
nullable: true,
})
note: string | null
@CreateDateColumn({ type: "timestamptz" })
createdAt: Date
}
JavaScript:
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm"
@Entity()
export class Webhook {
@PrimaryGeneratedColumn("increment", { type: "bigint" })
id: string
@Column("varchar", {
length: 255,
})
event: string
@Column("jsonb")
data: object
@Column("boolean")
sent: boolean
@CreateDateColumn({ type: "timestamptz" })
createdAt: Date
}
Переходим к самой обработке транзакций:
Все будет крутится вокруг метода getTransactions, который предназначен для получения транзакций по определенному адресу.
Как ранее было сообщено для хранения Jetton'ов создается отдельный кошелек, то есть чтобы и получить трансферы жетонов нужно вызывать getTransactions передавая в первый аргумент Jetton Wallet адрес.
Например, если нужно иметь поддержку не только TON, но и USDT и NOT, то это добавит 2 новых запроса.
К делу.
Первым дело создаем TONTransactionIterator. Это класс отвечает за возможность загрузить все транзакции с кошелька.
Обычно getTransactions метод имеет ограничение в 100 транзакций. Может и не хватить, поэтому нужно иметь возможность получить и более старые транзакции.
JavaScript:
import { Address, TonClient } from "@ton/ton"
type Cursor = { lt: string; hash: string } | undefined
export class TONTransactionIterator {
protected stack: Cursor[]
public cursor: Cursor | undefined
protected _canNext: boolean = true
constructor(
protected provider: TonClient,
protected address: Address,
protected limit: number,
) {
this.stack = []
this.cursor = undefined
}
async next() {
if (!this.canNext()) {
throw new Error("Cannot next")
}
const transactions = await this.provider.getTransactions(this.address, {
limit: this.limit,
archival: true,
...this.cursor,
})
this.stack.push(this.cursor)
if (transactions.length < this.limit) {
this._canNext = false
} else {
const lastTx = transactions[transactions.length - 1]
if (lastTx) {
this.cursor = { lt: lastTx.lt.toString(), hash: lastTx.hash().toString("base64") }
} else {
this._canNext = false
}
}
return transactions
}
public canNext() {
return this._canNext
}
public canBack() {
return this.stack.length > 0
}
async back() {
if (!this.canBack()) {
throw new Error("Cannot back")
}
this.cursor = this.stack.pop()
const transactions = await this.provider.getTransactions(this.address, {
limit: this.limit,
archival: true,
...this.cursor,
})
return transactions
}
}
Приступаем к разработке производных классов.
Основная идея в том, что каждый производный класс должен уметь провалидировать транзакцию и извлечь необходимые данные:
- txid - идентификатор транзакции
- amount - сумма трансфера
- memo - текстовый комментарий
JavaScript:
import { Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"
import { Address, TonClient, Transaction } from "@ton/ton"
import { InjectRepository } from "@nestjs/typeorm"
import { In, Repository } from "typeorm"
import { catchError, concatMap, EMPTY, from, Subscription, timer } from "rxjs"
import ms from "ms"
import { serializeError } from "serialize-error-cjs"
import z from "zod"
import { omit } from "lodash"
import { Token } from "../consts/token"
import { ProcessedTransaction } from "./processed-transaction.entity"
import { ChargeService } from "../charge/charge.service"
import { TONTransactionIterator } from "./ton-transaction.iterator"
import { ZeroPayConfig } from "../../config"
export abstract class TONDaemon implements OnModuleInit, OnModuleDestroy {
protected abstract logger: Logger
protected abstract token: Token
protected abstract address: Address
protected intervalPeriod = ms("30s")
protected provider: TonClient
private subscription: Subscription
protected limit: number = 100
protected constructor(
@InjectRepository(ProcessedTransaction) protected repository: Repository<ProcessedTransaction>,
protected service: ChargeService,
) {
this.provider = new TonClient({
endpoint: ZeroPayConfig.ton.apiEndpoint,
apiKey: ZeroPayConfig.ton.apiKey,
})
}
protected async process() {
let txs: Transaction[] | null = null
let foundProcessedPage = false
const iterator = new TONTransactionIterator(this.provider, this.address, this.limit)
while (iterator.canNext()) {
txs = await iterator.next()
const hasProcessed = await this.checkPage(txs)
if (hasProcessed) {
foundProcessedPage = true
break
}
}
if (!foundProcessedPage && txs == null) {
if (iterator.canNext()) {
txs = await iterator.next()
}
}
if (!foundProcessedPage) {
await this.handleBackward(iterator)
return
}
if (txs) {
await this.handlePage(txs)
}
if (iterator.canBack()) {
await this.handleBackward(iterator)
}
}
protected async handleBackward(iterator: TONTransactionIterator): Promise<void> {
do {
const txs = await iterator.back()
await this.handlePage(txs)
} while (iterator.canBack())
}
protected async checkPage(transactions: Transaction[]) {
return await this.repository.exists({
where: {
txid: In(transactions.map((tx) => tx.hash().toString("hex"))),
},
})
}
protected abstract handlePage(transactions: Transaction[]): Promise<void>
protected async isProcessed(txid: string) {
return await this.repository.exists({
where: {
txid,
},
})
}
protected async markAsProcessed(txid: string, note: string) {
await this.repository.insert({
txid,
note,
})
}
protected validateComment(comment: unknown) {
return z.preprocess((v) => (typeof v === "string" ? v.trim() : v), z.coerce.bigint().positive()).parse(comment)
}
async onModuleInit() {
this.subscription = timer(ms("15s"), this.intervalPeriod)
.pipe(
concatMap(() => {
return from(this.process()).pipe(
catchError((e) => {
this.logger.warn({
message: "An error occurred while attempting to receive and process transactions",
error: omit(serializeError(e), ["stack"]),
})
return EMPTY
}),
)
}),
)
.subscribe({
error: (e) => this.logger.error(e),
})
}
async onModuleDestroy() {
this.subscription && this.subscription.unsubscribe()
}
}
JavaScript:
import { Address, Transaction } from "@ton/ton"
import { Injectable, Logger, Provider } from "@nestjs/common"
import { getRepositoryToken, InjectRepository } from "@nestjs/typeorm"
import { Repository } from "typeorm"
import { TONDaemon } from "./ton.daemon"
import { ProcessedTransaction } from "./processed-transaction.entity"
import { ChargeService } from "../charge/charge.service"
import { TONUtilities } from "./ton.utilities"
import { Token } from "../consts/token"
@Injectable()
export class ToncoinDaemon extends TONDaemon {
protected logger = new Logger(ToncoinDaemon.name)
protected token = Token.TON
constructor(
protected address: Address,
@InjectRepository(ProcessedTransaction) protected repository: Repository<ProcessedTransaction>,
protected service: ChargeService,
) {
super(repository, service)
}
async handlePage(transactions: Transaction[]) {
for (let tx of transactions) {
const txid = tx.hash().toString("hex")
if (await this.isProcessed(txid)) {
continue
}
const inMsg = tx.inMessage
const outMsgs = tx.outMessages
if (inMsg && inMsg.info.type === "internal" && outMsgs.size === 0) {
const from = TONUtilities.standardizeAddress(inMsg.info.src)
const to = TONUtilities.standardizeAddress(inMsg.info.dest)
const body = inMsg.body.beginParse()
const op = body.remainingBits < 32 ? null : body.loadUint(32)
if (to !== TONUtilities.standardizeAddress(this.address)) {
this.logger.warn({
message: "Transaction with incorrect [dest] detected",
txid,
})
await this.markAsProcessed(txid, "incorrect address in dest")
continue
}
if (op !== 0 && op !== null) {
this.logger.warn({
message: "Transaction with unexpected opcode detected",
txid,
})
await this.markAsProcessed(txid, "unexpected opcode")
continue
}
if (op === null) {
this.logger.warn({
message: "Transaction without payload detected",
txid,
})
await this.markAsProcessed(txid, "memo isn't exists")
continue
}
try {
const _comment = body.loadStringTail()
const comment = this.validateComment(_comment)
await this.service.process({
txid,
amount: inMsg.info.value.coins,
token: this.token,
memo: comment,
})
} catch (e: any) {
this.logger.warn({
message: "Transaction has invalid memo in payload",
txid,
})
await this.markAsProcessed(txid, "invalid memo")
}
} else {
await this.markAsProcessed(txid, "not interested transaction")
}
}
}
}
JavaScript:
import { Injectable, Logger, Provider } from "@nestjs/common"
import { getRepositoryToken, InjectRepository } from "@nestjs/typeorm"
import { Repository } from "typeorm"
import { getJettonDaemonToken } from "./get-jetton-daemon-token"
import { ChargeService } from "../../charge/charge.service"
import { ProcessedTransaction } from "../processed-transaction.entity"
import { Address, Transaction } from "@ton/ton"
import { TONDaemon } from "../ton.daemon"
import { TONUtilities } from "../ton.utilities"
import { JettonServiceLocator } from "./jetton-service.locator"
import { type Jetton } from "./jetton"
@Injectable()
export class JettonDaemon extends TONDaemon {
protected logger: Logger
constructor(
protected token: Jetton,
protected address: Address,
protected jettonMaster: Address,
@InjectRepository(ProcessedTransaction) protected repository: Repository<ProcessedTransaction>,
protected service: ChargeService,
) {
super(repository, service)
this.logger = new Logger(`${JettonDaemon.name}-${this.token}`)
}
async handlePage(transactions: Transaction[]) {
for (let tx of transactions) {
const txid = tx.hash().toString("hex")
if (await this.isProcessed(txid)) {
continue
}
const inMsg = tx.inMessage
const outMsgs = tx.outMessages
if (inMsg && inMsg.info.type === "internal") {
const body = inMsg.body.beginParse()
const op = body.remainingBits < 32 ? null : body.loadUint(32)
if (op !== 0x178d4519) {
this.logger.warn({
message: "Transaction with unexpected opcode detected",
txid,
})
await this.markAsProcessed(txid, "unexpected opcode")
continue
}
if (TONUtilities.standardizeAddress(this.address) !== TONUtilities.standardizeAddress(inMsg.info.dest)) {
this.logger.warn({
message: "Transaction with incorrect [dest] detected",
txid,
})
await this.markAsProcessed(txid, "Incorrect address in dest")
continue
}
const result = await this.provider.runMethod(inMsg.info.src, "get_wallet_data")
const stack = result.stack
const balance = BigInt(stack.readBigNumber())
const owner = stack.readAddress()
const jettonMaster = stack.readAddress()
if (TONUtilities.standardizeAddress(jettonMaster) !== TONUtilities.standardizeAddress(this.jettonMaster)) {
this.logger.warn({
message: "Wrong jetton master",
txid,
})
await this.markAsProcessed(txid, "scam transaction, wrong jetton master")
continue
}
try {
let _comment: null | string = null
const queryId = body.loadUintBig(64)
const amount = body.loadCoins()
const from = body.loadAddress()
const responseDestination = body.loadAddress() // response_destination
const forwardTonAmount = body.loadCoins() // uint64
if (body.remainingRefs > 0) {
const payloadCell = body.loadRef()
const payloadSlice = payloadCell.beginParse()
const maybeOp = payloadSlice.loadUint(32)
if (maybeOp === 0) {
_comment = payloadSlice.loadStringTail() // UTF-8 строка
}
}
if (!_comment) {
this.logger.warn({
message: "Transaction without payload detected",
txid,
})
await this.markAsProcessed(txid, "memo isn't exists")
continue
}
try {
const comment = this.validateComment(_comment)
await this.service.process({
txid,
amount,
token: this.token,
memo: comment,
})
} catch (e) {
this.logger.warn({
message: "Transaction has invalid memo in payload",
txid,
})
await this.markAsProcessed(txid, "invalid memo")
}
} catch (e) {
this.logger.error(e)
await this.markAsProcessed(txid, "Invalid transaction, parsing error")
continue
}
} else {
await this.markAsProcessed(txid, "not interested transaction")
}
}
}
}
JavaScript:
import { Injectable, Logger } from "@nestjs/common"
import { Token } from "../consts/token"
import { z } from "zod"
import { Charge } from "./charge.entity"
import { IsNull, Repository } from "typeorm"
import { InjectRepository } from "@nestjs/typeorm"
import { ProcessedTransaction } from "../ton/processed-transaction.entity"
import { WebhookService } from "../webhook/webhook.service"
import { ChargeListDto } from "./dto/charge-list.dto"
import { paginate } from "nestjs-typeorm-paginate"
import { ChargeTransaction } from "./charge-transaction.entity"
import { TokenService } from "../utils/token.service"
@Injectable()
export class ChargeService {
protected logger = new Logger(ChargeService.name)
constructor(
@InjectRepository(Charge) protected repository: Repository<Charge>,
protected webhookService: WebhookService,
) {}
async create({ payload }: CreatePaymentParams) {
const insertResult = await this.repository
.createQueryBuilder()
.insert()
.into(Charge)
.values({
payload,
})
.returning("*")
.execute()
return this.repository.create(insertResult.raw[0] as object)
}
async retrieve(id: string) {
return this.repository.findOneOrFail({
where: {
id,
},
relations: {
txs: true
}
})
}
async list({ page, limit, filter, sort }: ChargeListDto) {
return await paginate(
this.repository,
{ page, limit },
{
where: {
id: filter.id,
memo: filter.memo,
payload: filter.payload === null ? IsNull() : filter.payload,
},
relations: {
txs: true,
},
order: {
createdAt: sort.createdAt,
},
},
)
}
async process(params: ProcessPaymentParams) {
const result = schema.safeParse(params)
if (!result.success) {
throw new Error(`Invalid data provided, data: ${JSON.stringify(params)}`)
}
const { txid, amount, token, memo } = result.data
await this.repository.manager.transaction("READ COMMITTED", async (manager) => {
const repository = manager.getRepository(Charge)
const isProcessed = await manager.exists(ProcessedTransaction, {
where: {
txid,
},
lock: {
mode: "pessimistic_write",
},
})
if (isProcessed) {
return
}
const charge = await repository.findOne({
where: {
memo: memo.toString(),
},
})
if (!charge) {
await manager.insert(ProcessedTransaction, {
txid,
note: "no associated charge",
})
this.logger.log({
message: "no associated charge",
data: {
memo,
},
})
return
}
const txRepository = manager.getRepository(ChargeTransaction)
const tx = await txRepository.findOne({
where: {
txid,
},
lock: {
mode: "pessimistic_write",
},
})
if (tx) {
await manager.insert(ProcessedTransaction, {
txid,
note: null,
})
return
}
await txRepository.insert({
txid,
amount: amount.toString(),
token,
chargeId: charge.id,
})
await manager.getRepository(ProcessedTransaction).insert({
txid,
note: null,
})
await this.webhookService.create(
{
event: "payment:new",
data: {
id: charge.id,
txid,
amount: TokenService.format(amount, { token }),
token,
memo: memo.toString(),
payload: charge.payload,
},
},
manager,
)
})
}
}
const schema = z.object({
txid: z.string().min(1),
amount: z.bigint().positive(),
token: z.enum(Token),
memo: z.bigint().positive(),
})
type ProcessPaymentParams = z.infer<typeof schema>
type CreatePaymentParams = {
payload: string | null
}
Немного про Webhook уведомления
Текущая реализация webhook уведомлений:
JavaScript:
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"
import { InjectRepository } from "@nestjs/typeorm"
import { Webhook } from "./webhook.entity"
import { EntityManager, Repository } from "typeorm"
import { Agent as HttpsAgent } from "https"
import { Agent as HttpAgent } from "http"
import axios from "axios"
import { ZeroPayConfig } from "../../config"
import ms from "ms"
import { catchError, concatMap, EMPTY, from, interval, Subscription } from "rxjs"
import crypto from "crypto"
import { get } from "lodash"
@Injectable()
export class WebhookService implements OnModuleInit, OnModuleDestroy {
protected logger = new Logger(WebhookService.name)
public static readonly signatureHeader = "X-Webhook-Signature" as string
protected readonly url = ZeroPayConfig.webhookUrl
protected agent: any
protected subscription: Subscription
protected secret = Buffer.from(ZeroPayConfig.apiSecret, "hex")
protected static readonly intervalPeriod = ms("5s")
constructor(@InjectRepository(Webhook) protected repository: Repository<Webhook>) {
this.agent = this.url.startsWith("https://")
? new HttpsAgent({
keepAlive: true,
keepAliveMsecs: ms("10m"),
})
: new HttpAgent({
keepAlive: true,
keepAliveMsecs: ms("10m"),
})
}
async create({ event, data }: CreateWebhookParams, manager: EntityManager) {
const insertResult = await manager
.createQueryBuilder()
.insert()
.into(Webhook)
.values({
event,
data,
sent: false,
})
.returning("*")
.execute()
return this.repository.create(insertResult.raw[0] as object)
}
async retrieve(id: string) {
return this.repository.findOneOrFail({
where: {
id,
},
})
}
async process() {
const webhooks = await this.repository.find({
where: {
sent: false,
},
order: {
createdAt: "asc",
},
})
for (let webhook of webhooks) {
try {
await this.sendMessage({
id: webhook.id,
event: webhook.event,
data: webhook.data,
timestamp: Math.floor(Date.now() / 1000),
})
await this.repository.update({ id: webhook.id }, { sent: true })
this.logger.log({
message: "Webhook notification successfully sent",
data: {
id: webhook.id,
},
})
} catch (e) {
this.logger.warn({
message: `The server did not respond with a 2XX response`,
data: {
id: webhook.id,
error: get(e, "message", "unknown error"),
},
})
return
}
}
}
protected async sendMessage(data: object) {
await axios.post(this.url, data, {
httpAgent: this.agent,
httpsAgent: this.agent,
timeout: ms("10s"),
headers: {
[WebhookService.signatureHeader]: WebhookService.computeMessageSignature(
WebhookService.plainObjectToBuffer(data),
this.secret,
),
"Content-Type": "application/json",
},
responseType: "json",
validateStatus: (status) => {
return status >= 200 && status <= 299
},
})
}
protected static computeMessageSignature(message: Buffer, apiSecret: Buffer) {
return crypto.createHmac(`sha256`, apiSecret).update(message).digest(`hex`)
}
protected static plainObjectToBuffer(o: object) {
return Buffer.from(JSON.stringify(o), "utf-8")
}
async onModuleInit() {
this.subscription = interval(WebhookService.intervalPeriod)
.pipe(
concatMap(() => {
return from(this.process()).pipe(
catchError((e) => {
this.logger.error(e)
return EMPTY
}),
)
}),
)
.subscribe()
}
async onModuleDestroy() {
this.subscription && this.subscription.unsubscribe()
}
}
type CreateWebhookParams = {
event: string
data: object
}
JSON:
{
"id": "1", // webhook id, should be used for idempotency. If the webhook is sent more than once, the server must return a 2XX response.
"event": "payment:new",
"data": {// payment data
"id": "1", // payment id
"txid": "1f0ad53d845255...", // transaction hash
"amount": "100.5", // formatted amount
"token": "USDT", // TON, USDT, NOT
"memo": "111", // Memo (unique)
"payload": "123" // Your payload (can be null)
},
"timestamp": 1762340097
}
Для того чтобы исключить интервальный поллинг обычно используются очереди, но для гарантии порядка доставки
распараллелить отправку уведомлений все равно не получится.
Клиентская часть
Ниже приведу блоки кода, которых достаточно для того, чтобы программно создать транзакцию для перевода нативных токенов или жетонов.
О вещах типа как приконнектить кошелек или вытащить адрес приконеченного кошелька думаю нет смысла писать.
Отправка TONs:
JavaScript:
type SendTONParams = {
recipient: string
amount: bigint
memo: bigint
}
async function sendTON({ recipient, amount, memo }: SendTONParams) {
const payloadCell = beginCell().storeUint(0, 32).storeStringTail(memo.toString()).endCell();
const transaction: SendTransactionRequest = {
validUntil: Math.floor(Date.now() / 1000) + 300,
messages: [
{
address: recipient,
amount: amount.toString(),
payload: payloadCell.toBoc().toString('base64'),
},
],
};
try {
await tonConnectUI.sendTransaction(transaction);
console.log('Транзакция успешно отправлена в сеть');
} catch (e: any) {
console.error(`Ошибка при попытке собрать транзакцию и отправить ее в сеть: ${e.message}`);
}
}
Отправка Jettons:
JavaScript:
type SendUSDTParams = {
recipient: string
amount: bigint
memo: bigint
senderAddress: string
jettonMasterWallet: string
}
async function sendUSDT({ recipient, amount, memo, senderAddress, jettonMasterWallet }: SendTONParams) {
const resp = await axios.get('/api/jetton/get-jetton-wallet', {
params: {
holder: senderAddress,
token: 'USDT',
},
});
const senderJettonWallet = resp.data.address;
const payloadCell = beginCell().storeUint(0, 32).storeStringTail(memo.toString()).endCell();
const jettonTransfer = beginCell()
.storeUint(0x0f8a7ea5, 32)
.storeUint(0, 64)
.storeCoins(amount)
.storeAddress(Address.parse(recipient))
.storeAddress(Address.parse(senderAddress))
.storeUint(0, 1)
.storeCoins(toNano('0.01'))
.storeBit(1)
.storeRef(payloadCell)
.endCell();
const transaction: SendTransactionRequest = {
validUntil: Math.floor(Date.now() / 1000) + 300,
messages: [
{
address: senderJettonWallet,
amount: toNano('0.05').toString(),
payload: jettonTransfer.toBoc().toString('base64'),
},
],
};
try {
await tonConnectUI.sendTransaction(transaction);
console.log('Транзакция успешно отправлена в сеть');
} catch (e: any) {
console.error(`Ошибка при попытке собрать транзакцию и отправить ее в сеть: ${e.message}`);
}
}
QR-Code
Для рендера QR-кода рекомендую использовать qr-code-styling библиотеку.
Гибко кастоминизируется, и есть возможность создать конфиг онлайн: https://qr-code-styling.com/
https://docs.ton.org/ecosystem/wallet-apps/deep-links - документация по формату платежной ссылки.
JavaScript:
type GetPayLinkParams = {
address: string
amount: bigint
memo: bigint
contract?: string // Master Wallet Address
}
function getPayLink({ address, amount, memo, contract }: GetPayLinkParams) {
if (!contract) {
return `ton://transfer/${address}?${qs.stringify({
amount: amount.toString(),
text: memo.toString(),
})}`;
} else {
return `ton://transfer/${address}?${qs.stringify({
jetton: contract,
amount: amount.toString(),
text: memo.toString(),
})}`;
}
}
Итоговый флоу
- Пользователь хочет пополнить баланс на платформе (магазин или что-то подобное), и триггерит создание реквизитов
используя API магазина. - Магазин создает на своей стороне инвойс для пополнения внутреннего баланса по запросу пользователя.
- Магазин вызывает POST /api/charge/create передавая в payload айдишник инвойса и получает memo, и прикрепляет его к инвойсу.
- Клиентский код магазина получает данные инвойса (в том числе и memo) и формирует транзакцию и QR-код.
- Пользователь отправляет созданную транзакцию QR-кодом или tonconnect'ом.
- Шлюз отлавливает транзакцию, и создает вебхук уведомление.
- Магазин получает уведомление с достаточными данными для обработки платежа от шлюза.
Вот псевдокод:
JavaScript:
const id = uuidv4()
const charge = await api.charge.create({ payload: id })
const invoice = await api.invoice.create({
id,
token: "USDT",
amount: "100.00",
userId: "1",
paid: false
})
Заключение
На базе информации в статье можно реализовать свое решение особенно если знаете TypeScript или использовать "как есть", что подойдет для небольших проектов.
Исходный код + README.md: https://github.com/shuriken0x/0xPay-ton
Последнее редактирование: