TON USDT 결제 감지: 누락도 중복 입금도 없는 안전한 방법
TON USDT 결제 감지: get_transactions로 제톤 transfer_notification과 internal_transfer 파싱, get_jetton_info의 decimals, 금액 검증과 멱등성까지 정리했습니다.
TON에서 결제 수신을 한 번이라도 만들어 본 사람이라면 누구나 같은 함정에 빠져 봤을 겁니다. 결제는 도착했는데 백엔드가 이를 "보지 못하는" 상황이죠. 고객은 50 USDT를 보냈고 지갑에는 입금이 찍혀 있는데, 인보이스는 여전히 "대기" 상태에 매달려 있습니다. 10분 뒤에는 성난 지원 티켓이 날아옵니다. 그리고 한 시간쯤 지나면, 폴러가 재시도에서 한 번 더 돌아 같은 결제를 두 번 반영했다는 사실이 드러납니다. TON에서 수신 USDT 결제(incoming USDT payment)를 안정적으로 감지한다는 것은 "1분에 한 번 잔액을 확인한다"가 아니라, 제톤의 구체적인 내부 메시지를 멱등성과 위조 방어를 갖춰 파싱한다는 뜻입니다. 이를 제대로 하는 방법을 단계별로 살펴보겠습니다. 모두 TONNode MCP 서버의 실제 도구로, 로컬에서 무료로 호출할 수 있는 것들입니다.
TON USDT 결제 감지가 지갑 잔액 문제가 아닌 이유
가장 순진한 접근은 주기적으로 지갑 잔액을 읽고, 잔액이 늘었으면 결제가 들어왔다고 간주하는 것입니다. 이건 서랍 안의 총액만 볼 줄 아는 금전등록기와 같습니다. +10 USDT가 들어왔다 — 그런데 누구한테서, 어떤 청구 건으로, 예전 주문 때문인지 새 주문 때문인지는 알 수 없습니다. 게다가 두 고객이 동시에 10씩 지불했다면, 여러분 눈에는 +20이 한 번에 찍힌 것으로만 보입니다.
TON에서 이 접근은 여러 이유로 곧바로 무너집니다.
- USDT는 네이티브 코인이 아니라 제톤입니다(TEP-74 표준). 자금은 메인 지갑으로 직접 들어오지 않고 그 지갑의 제톤 지갑으로 들어옵니다. 제톤 지갑은 특정 제톤 마스터 컨트랙트에 묶인 별도의 컨트랙트입니다. 이때 메인 지갑의 GRAM 잔액은 전혀 변하지 않습니다.
- 잔액 증감은 누가 무엇에 대해 지불했는지 알려주지 않습니다. 잔액에는 인보이스와의 연결 정보가 담겨 있지 않습니다.
- 경쟁 상태와 합산. 두 번의 폴링 사이에 여러 결제가 들어오면, 여러분은 개별 이벤트가 아니라 합산된 증감만 보게 됩니다.
- 이중 입금. 폴링 재시도나 워커 재시작 한 번이면 결제 하나가 두 번 반영됩니다.
- Decimals. USDT는 제톤에서 흔한 9가 아니라 6입니다. 나눗수 하나만 틀려도 고객에게 1000배 어긋난 금액이 "반영"됩니다.
올바른 길은 잔액이 아니라 수취인 제톤 지갑의 트랜잭션 흐름을 다루고, 각 전송 이벤트를 개별적으로 파싱하는 것입니다. 읽기 작업은 전부 로컬에서 무료로 가능합니다.
{ "mcpServers": { "ton": { "command": "npx", "args": ["-y", "@tonnode/mcp"] } } }
수신 USDT 결제의 내부 구조: 제톤의 internal_transfer (op 0x178d4519)
누군가 여러분에게 USDT를 보내면 다음과 같은 연쇄가 일어납니다.
- 발신자의 제톤 지갑이 금액을 차감하고, 여러분의 제톤 지갑에
internal_transferinternal 메시지(op0x178d4519)를 보냅니다. - 여러분의 제톤 지갑은 자금을 반영하고, 또한 — 발신자가
forward_ton_amount > 0을 붙인 경우에만 — 여러분의 메인 지갑에transfer_notification내부 메시지(op0x7362d09c)를 추가로 보냅니다.
바로 여기에 가장 중요한 갈림길이 숨어 있습니다. transfer_notification은 "제톤이 도착했습니다"라는 편리한 알림이지만, 이것은 메인 소유자 지갑으로 가며 오직 forward_ton_amount > 0일 때만 발생합니다. 발신자가 0을 넣었다면 제톤 지갑은 자금을 그대로 반영하지만 소유자에게 가는 알림은 없습니다.
반면 제톤 지갑 자체의 히스토리에는 입금이 언제나 남습니다. forward_ton_amount와 무관하게 수신 internal_transfer로 기록되기 때문입니다. 그래서 안정적인 감지는 수취인 제톤 지갑의 히스토리를 폴링하고 바로 이 internal_transfer를 파싱하는 방식으로 만들어집니다. TEP-74 기준 구조는 이렇습니다.
internal_transfer#178d4519
query_id: uint64
amount: (VarUInteger 16) // 제톤의 raw 단위
from: MsgAddress // 소유자=지불자(사람)의 주소
response_address: MsgAddress
forward_ton_amount: (VarUInteger 16)
forward_payload: (Either Cell ^Cell) // 코멘트/memo
중요한 지점이 두 가지 있습니다.
from필드에 들어 있는 것은 실제 지불자(사람인 소유자)의 주소이지, 그 사람의 제톤 지갑 주소가 아닙니다. 발신자로 로깅해야 할 것은 바로 이 값입니다. 유의할 점: 메시지 레벨의 주소(tx.in_msg.source)는 지불자의 제톤 지갑이고, 사람인 지불자는 메시지 본문의from필드에 들어 있습니다.amount필드의 금액은 raw 단위입니다. 환산은 아래에서 다시 다루겠습니다.
여기서 나오는 아키텍처의 핵심 결론
안정적인 감지는 메인 지갑에서 notification을 기다리는 방식이 아니라, 수취인 제톤 지갑 자체의 히스토리를 폴링하는 방식으로 만들어집니다. 이 히스토리에는 forward_ton_amount와 무관하게 입금이 항상 보입니다. 메인 지갑도 함께 듣고 있다면 거기서 transfer_notification(op 0x7362d09c, 발신자는 sender 필드)을 잡게 되지만, 이는 forward_ton_amount > 0인 경우에 한한 보너스일 뿐 유일한 진실의 원천이 아닙니다.
get_transactions로 히스토리를 읽고 internal_transfer를 파싱하기
먼저 수취인의 제톤 지갑 주소가 필요합니다. 이 주소를 오프라인에서 유도하려 하지 말고, 티커도 믿지 마십시오. USDT 마스터 컨트랙트에 직접 물어보면 됩니다. USDT 마스터에 대한 get_wallet_address 호출(run_get_method 경유)은 진짜 마스터에 묶인 여러분의 USDT 제톤 지갑 주소를 결정론적으로 반환합니다. 그다음 이 제톤 지갑의 트랜잭션을 get_transactions로 폴링합니다.
폴링 루프에서 에이전트에게 줄 프롬프트입니다.
USDT 마스터 EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs에
run_get_method를 호출하세요. 메서드는 get_wallet_address,
인자는 소유자 주소 UQ...myservice입니다. 우리 USDT 제톤 지갑의
주소를 반환하세요. 그다음 get_transactions로 그 제톤 지갑의
최근 트랜잭션을 읽으세요. 각 수신 internal_transfer(op 0x178d4519)에
대해 query_id, amount(raw), from 필드(지불자 주소),
forward_payload의 텍스트 코멘트, 그리고 트랜잭션의 lt와 hash를
반환하세요.
트랜잭션 하나를 처리하는 의사코드입니다.
for (const tx of txs) {
const body = tx.in_msg?.decoded; // 파싱된 수신 메시지 본문
if (body?.op !== 0x178d4519) continue; // internal_transfer가 아니면 건너뜀
const rawAmount = BigInt(body.amount); // raw 단위, 사람이 읽는 값이 아님
const payer = body.from; // 소유자=지불자의 주소
const memo = parseComment(body.forward_payload);
const key = `${tx.lt}:${tx.hash}`; // 중복 제거용 식별자
// …검증과 입금 처리는 아래에서
}
메인 계정의 상태를 get_account_state로 한 번씩 확인해 두는 것도 유용합니다(활성 상태인지, 마지막 트랜잭션이 언제였는지). 폴러가 "살아 있는지", 뒤처지지는 않았는지 파악하는 데 도움이 됩니다.
decimals가 모든 것을 결정합니다: get_jetton_info와 raw 금액 환산 (USDT = 6, 9가 아님)
전송의 amount 필드는 제톤의 raw 단위, 즉 소수점 없는 정수입니다. 이를 사람이 읽는 금액으로 바꾸려면 decimals가 필요합니다. 그리고 가장 많은 연동이 깨지는 지점이 바로 여기입니다.
대부분의 제톤은 decimals = 9입니다. TON의 USDT(Tether)는 decimals = 6입니다. 즉,
1 USDT = 1 000 000 raw (10^6, 10^9가 아님)
관성적으로 USDT의 raw 금액을 10^9로 나누면, 고객의 50 USDT 결제가 여러분 쪽에서는 0.05가 됩니다. 1000배 적은 금액이죠. 반대 방향의 실수도 똑같이 쉽게 1000배 많은 금액을 반영해 버립니다. 나눗수를 하드코딩하지 말고, get_jetton_info로 온체인 메타데이터에서 decimals를 가져오십시오.
const info = await getJettonInfo(USDT_MASTER); // name, symbol, decimals, 발행량
const decimals = info.decimals; // USDT는 6
const human = Number(rawAmount) / 10 ** decimals; // 50000000 → 50.0
decimals는 티커가 아니라 마스터 주소 기준으로 캐시하십시오. 티커는 위조되며, decimals는 구체적인 컨트랙트와 묶여야 합니다. 규칙은 단순합니다. decimals는 언제나 get_jetton_info에서 가져오고, 금액은 화면에 표시하기 직전까지 오직 BigInt/raw로만 다룹니다.
출처, 금액, 코멘트를 검증하고 가짜 제톤을 걸러내기
이제 보안에서 가장 중요한 부분입니다. 이 검증들 없이는 입금 처리를 해서는 안 됩니다.
1. 마스터 컨트랙트는 오직 진짜 USDT만. 누구나 이름이 "USDT"이고 심볼이 "USD₮"인 제톤을 발행해 1000 "USDT" 전송을 여러분에게 보낼 수 있습니다. 결제를 티커로 매칭한다면 5분 만에 사기당합니다. 진짜 Tether와의 유일하게 신뢰할 수 있는 연결 고리는, 진짜 USDT 마스터 컨트랙트가 여러분의 소유자 주소에 대해 계산해 준 그 제톤 지갑을 다루는 것입니다.
Tether USD₮ master: EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs
그렇기 때문에 제톤 지갑 주소는 이 마스터에 대한 온체인 호출 get_wallet_address로 얻고(위 참조), 히스토리는 다른 어떤 것도 아닌 오직 이 컨트랙트 하나에서만 폴링합니다. 이 지갑으로 입금된 internal_transfer는 무엇이든 같은 마스터의 정당한 형제 제톤 지갑에서 왔음이 보장됩니다. 가짜 "USDT"는 다른 마스터의 지갑에 살고 있어서 여러분의 주소까지 아예 도달하지 못하기 때문입니다. 따라서 이 아키텍처에서는 tx.in_msg.source를 무언가와 대조할 필요가 없습니다. 관측 지점을 어디로 잡았느냐 자체가 방어가 됩니다. 그리고 한 가지 유의할 점이 있습니다. parse_address는 EQ/UQ/raw 형식 간의 오프라인 변환일 뿐이며 제톤 지갑 주소를 계산해 주지 않습니다. 그러려면 반드시 온체인 호출 get_wallet_address가 필요합니다. 다만 비교 전에 주소를 정규화해서, 같은 주소의 서로 다른 표현을 다른 주소로 취급하지 않도록 하는 데는 유용합니다.
2. 인보이스 매칭을 위한 코멘트(memo). 텍스트 코멘트는 forward_payload에 표준 형식으로 들어갑니다. op 접두사 0x00000000(0 바이트 4개) + UTF-8 문자열입니다. 이 값으로 수신 전송을 구체적인 주문과 연결합니다.
3. 금액과 지불자. 환산한 금액을 인보이스의 예상 금액과 비교하고, 필요하다면 from(지불자 주소)을 기록 보관과 어뷰징 방지용으로 남겨 두십시오.
// 1. 출처는 보장됨: 진짜 USDT 마스터의 get_wallet_address가
// 반환한 바로 그 제톤 지갑의 히스토리를 읽고 있으므로,
// 여기에 있는 어떤 전송이든 진짜다.
// 2. 코멘트 → 인보이스
const invoice = invoices.get(memo);
if (!invoice) continue;
// 3. 금액이 맞는가?
if (human < invoice.expectedAmount) markUnderpaid(invoice);
// 4. 지불자 — 로그/어뷰징 방지용
log({ payer, memo, human, lt: tx.lt, hash: tx.hash });
멱등성: lt + hash 중복 제거로 이중 입금 막기
폴링은 언제나 자기 자신과 겹칩니다. 스케줄에 따라 실행되고, 죽고, 재시도되고, 조회 구간이 서로 포개집니다. 중복 제거가 없으면 조만간 같은 결제를 두 번 반영하게 됩니다.
TON에서 모든 트랜잭션은 logical time(lt) + hash 쌍으로 고유하게 식별됩니다. 이것이 여러분의 자연스러운 멱등성 키입니다.
const key = `${tx.lt}:${tx.hash}`;
if (await seen.has(key)) continue; // 이미 처리함 — 빠져나감
await creditInvoice(invoice, human); // 입금 처리
await seen.add(key); // 같은 DB 트랜잭션에서 기록
키로는 전송의 query_id를 쓸 수도 있지만, (lt, hash)는 forward_ton_amount = 0인 경우를 포함해 제톤 지갑의 모든 수신 트랜잭션에 대해 동작합니다. 처리한 키는 영속적으로 저장하고, 입금 처리는 유일성 검사와 함께 하나의 DB 트랜잭션 안에서 수행하십시오. 그러면 병렬 워커들이 중복을 만들지 않습니다.
주기적으로 정산 대사(reconciliation)를 해 두는 것도 좋습니다. N분마다 제톤 지갑의 실제 잔액(get_jetton_balance — 주소를 온체인으로 계산해 한 번의 호출로 잔액을 돌려줍니다)을 반영된 모든 결제의 합계와 비교하는 것입니다. 차이가 난다면 어딘가에서 전송을 놓쳤거나 중복 처리했다는 신호입니다. 폴링 사이클 하나가 조용히 실패한 경우라도 이렇게 잡아낼 수 있습니다.
부하에서도 안정적인 처리량: 공개 한도 대신 자기 키
결제 폴링은 끊임없고 균일한 요청 흐름입니다. N개의 지갑 × 조회 빈도. 그리고 바로 여기서 공개 인프라가 병목이 됩니다.
글로벌 설정에 들어 있는 공개 라이트서버는 공용이고 한도가 걸려 있습니다. 부하가 걸리면 not ready를 반환하거나 ADNL 타임아웃으로 빠집니다. 키 없는 공개 HTTP API(toncenter, tonapi.io)는 대략 초당 1요청 수준을 버티며, 초과하면 정직하게 HTTP 429 "Too Many Requests" 응답을 돌려줍니다. 유의하십시오. 실제 한도 코드는 바로 429이며, TON 커뮤니티에 떠도는 밈 "228"은 API 코드가 아닙니다. 몇 초마다 히스토리를 조회하는 결제 시스템에서 이런 한도는 곧 입금 누락과 지연으로 이어집니다. 자기 키를 쓸 수 있는 프로바이더를 고르는 방법은 별도의 글에서 다뤘습니다.
결제 감지에 필요한 읽기 도구 전부 — get_transactions, run_get_method, get_jetton_balance, get_jetton_info, parse_address, get_account_state — 는 로컬에서 무료로 사용할 수 있습니다: npx -y @tonnode/mcp. @tonnode/mcp 패키지는 오픈소스(MIT)이며 HTTP 중간 계층 없이 TON 네이티브 ADNL 프로토콜로 동작합니다. 폴링이 프로덕션으로 넘어가고 보장된 처리량이 필요해지면, 자기 키가 있는 hosted 엔드포인트를 연결합니다.
{
"mcpServers": {
"ton": {
"type": "http",
"url": "https://mcp.tonnode.io/mcp",
"headers": { "Authorization": "Bearer tn_live_…" }
}
}
}
요금제는 분당 요청 한도만 다르며, 16개 도구 전부를 모든 플랜에서 쓸 수 있습니다.
- Hobby — 영구 무료, 분당 60 요청. 로그인 직후 바로 키가 발급되며 카드가 필요 없습니다.
- Pro — 월 $29, 분당 300 요청.
- Scale — 월 $199, 분당 1200 요청.
결제 폴링을 시작하는 데는 무료 분당 60 요청만으로도 429 없이 안정적인 조회를 유지하기에 충분합니다. 이 위에 결제를 스스로 파싱하는 AI 에이전트를 만들 생각이라면, 그 구조는 TON AI 에이전트를 위한 MCP 가이드에 정리해 두었습니다.
무료 Hobby 키로 시작하십시오 — 결제 폴링용 60 req/min, 카드 불필요: tonnode.io/dashboard?plan=hobby. 부하가 커져 워커 하나로 부족해지면 Pro와 Scale의 한도를 비교해 보십시오.
TON USDT 결제 감지 체크리스트:
- 메인 지갑의 잔액이 아니라 수취인 제톤 지갑의 히스토리를
get_transactions로 폴링하십시오. - 이 제톤 지갑의 주소는 진짜 USD₮ 마스터(
EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs)에 대한 온체인 호출get_wallet_address(run_get_method)로 얻으십시오. 그러면 여기로 들어오는 모든 전송은 진짜 USDT임이 보장됩니다. - op
0x178d4519인 수신internal_transfer를 찾으십시오.forward_ton_amount와 무관하게 제톤 지갑 히스토리에 항상 남는 것이 바로 이것입니다.transfer_notification(0x7362d09c)은 메인 지갑으로 가며forward_ton_amount > 0일 때만 발생합니다. - 지불자 주소는
from필드에서, 금액은amount(raw)에서 가져오십시오. decimals는get_jetton_info에서 가져오십시오. USDT = 6, 9가 아닙니다.- 인보이스는
forward_payload의 코멘트(op0x00000000+ UTF-8)로 매칭하십시오. (lt, hash)로 중복을 제거하고, 입금 처리는 엄격히 한 번만 하십시오.- 공개 한도가 아니라 자기 키로 안정적인 처리량을 확보하십시오.