Статья Принимаем платежи в TON и Jetton'ах (USDT, NOT, ...)

shuriken0x1

CD-диск
Seller
Регистрация
21.10.2021
Сообщения
19
Реакции
3
Наверное многие слышали, что Telegram установил запрет на использование сторонних блокчейнов в мини-приложениях соответственно сделав доступной только 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 кнопку.

Вот так может выглядеть платежная страница (модальное окно):

manual-top-up.png


Или вот так:

auto-top-up.png


Немного про Jetton'ы
Jetton'
ы - это ненативные токены в сети TON, и по смыслу и частично по спецификации походят на ERC-20/TRC-20 токены.
Для хранения и отправки jetton'ов создается вспомогательный кошелек, который называется Jetton Wallet'ом, который
детерминировано вычисляется из обычного адреса и данных, что относятся к Jetton Master (контракт токена).

tonviewer.png


Абстрагироваться от этого нельзя, так как для программного создания транзакции, что трансферит жетоны необходимо иметь доступ к 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
}
}
Создаем абстрактный класс TONDaemon, который содержит вспомогательные методы для обработки транзакций абстрактный handlePage.

Приступаем к разработке производных классов.
Основная идея в том, что каждый производный класс должен уметь провалидировать транзакцию и извлечь необходимые данные:
  • 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()
  }
}
Теперь пишем ToncoinDaemon, который наследует TONDaemon и должен имплементировать handlePage метод.
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")
      }
    }
  }
}
Приступаем к JettonDaemon.
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")
      }
    }
  }
}
Каждый Daemon класс при нахождении валидной и необработанной транзакции вызывает process метод ChargeService
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 уведомления
Текущая реализация 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
}
Вот так выглядит webhook уведомление:
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(),
    })}`;
  }
}

Итоговый флоу
  1. Пользователь хочет пополнить баланс на платформе (магазин или что-то подобное), и триггерит создание реквизитов
    используя API магазина.
  2. Магазин создает на своей стороне инвойс для пополнения внутреннего баланса по запросу пользователя.
  3. Магазин вызывает POST /api/charge/create передавая в payload айдишник инвойса и получает memo, и прикрепляет его к инвойсу.
  4. Клиентский код магазина получает данные инвойса (в том числе и memo) и формирует транзакцию и QR-код.
  5. Пользователь отправляет созданную транзакцию QR-кодом или tonconnect'ом.
  6. Шлюз отлавливает транзакцию, и создает вебхук уведомление.
  7. Магазин получает уведомление с достаточными данными для обработки платежа от шлюза.
Если возможно стоит в качестве PK инвойса использовать uuid v4, и генерировать его на стороне кода до создания инвойса и отправки запроса на создание charge.
Вот псевдокод:
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
 
Последнее редактирование:


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх