P2P 결제 배우기
이 가이드는 Stable에서 P2P 결제 애플리케이션을 구축하는 과정을 안내합니다. 이 앱은 결제의 전체 수명 주기를 처리합니다. 발신자는 USDT0를 직접 전송하고, 수신자는 들어오는 결제를 실시간으로 감지하며, 양쪽 모두 자신의 거래 내역을 조회할 수 있습니다. 모바일 앱이든 웹 체크아웃이든 백엔드 서비스든, 모든 지갑이나 결제 인터페이스와 동일한 아키텍처입니다.
미들웨어도 중개자도 없습니다. 개념적 개요는 P2P 결제를 참고하세요. ABI 작업을 건너뛰고 몇 줄로 작동하는 transfer에 도달하려면 Stable SDK를 사용하세요.
구축할 내용
최소한의 결제 앱을 구성하는 다섯 개의 스크립트:
wallet.ts— 지갑 생성 또는 복원.getBalance.ts— 현재 USDT0 잔액 조회.send.ts— 다른 주소로 USDT0 전송.receive.ts— 들어오는 결제를 실시간으로 감시.history.ts— 특정 주소의 과거 Transfer 이벤트 조회.
데모
step 1. Alice creates wallet → address: 0xAlice...
step 2. Alice's balance: 0.01 USDT0
step 3. Alice sends 0.001 USDT0 to Bob
tx: 0x8f3a...2d41
gas fee: 0.000021 USDT0
Alice balance: 0.008979 USDT0
step 4. Bob receives payment (real-time event)
from: 0xAlice...
amount: 0.001 USDT0
tx: 0x8f3a...2d41사전 요구 사항
- Node.js 20 이상.
- 테스트넷 USDT0가 있는 개인 키 (지갑에 자금을 충전하려면 빠른 시작 참고).
프로젝트 설정
mkdir stable-p2p && cd stable-p2p
npm init -y && npm install ethers dotenvadded 2 packages, audited 3 packages in 1s모든 스크립트가 공유하는 config.ts를 생성합니다:
// config.ts
import { ethers } from "ethers";
import "dotenv/config";
export const STABLE_RPC = "https://rpc.testnet.stable.xyz";
export const STABLE_WS = "wss://rpc.testnet.stable.xyz";
export const USDT0_ADDRESS = "0x78Cf24370174180738C5B8E352B6D14c83a6c9A9";
export const provider = new ethers.JsonRpcProvider(STABLE_RPC);1. 지갑 생성 또는 복원
지갑은 시드 문구에서 파생된 키 쌍입니다. 새 사용자를 위해 하나를 생성하고 백업할 수 있도록 문구를 반환합니다. 돌아온 사용자는 동일한 문구로 지갑을 복원합니다.
// wallet.ts
import { ethers } from "ethers";
import { provider } from "./config";
/** Create a new wallet for a new user. */
export function createWallet() {
const wallet = ethers.Wallet.createRandom(provider);
return {
wallet,
address: wallet.address,
seedPhrase: wallet.mnemonic!.phrase, // display to user for backup
};
}
/** Restore a wallet from a seed phrase (returning user). */
export function restoreWallet(seedPhrase: string) {
const wallet = ethers.Wallet.fromPhrase(seedPhrase, provider);
return { wallet, address: wallet.address };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { address, seedPhrase } = createWallet();
console.log("Address: ", address);
console.log("Seed phrase:", seedPhrase);
}npx tsx wallet.tsAddress: 0xAlice...1234
Seed phrase: liberty shoot ... (12 words)2. 잔액 확인
USDT0는 Stable의 네이티브 자산이므로, 잔액 조회는 Ethereum의 ETH와 정확히 동일하게 작동합니다. 네이티브 잔액은 소수점 18자리이며, 표시할 때는 formatEther를 사용합니다.
// getBalance.ts
import { ethers } from "ethers";
import { provider } from "./config";
export async function getBalance(address: string) {
const balance = await provider.getBalance(address);
return ethers.formatEther(balance); // 18 decimals
}
if (import.meta.url === `file://${process.argv[1]}`) {
const address = process.argv[2];
const balance = await getBalance(address);
console.log("Balance:", balance, "USDT0");
}npx tsx getBalance.ts 0xAlice...1234Balance: 0.01 USDT03. 결제 전송
발신자는 전송을 직접 서명하고 제출합니다. Stable에서 USDT0는 네이티브 자산이므로, 단순한 value 전송이 가장 저렴한 경로입니다(21,000 가스). 이는 모든 결제 앱에서 "보내기"와 동일한 코드 경로입니다.
// send.ts
import { ethers } from "ethers";
import { provider } from "./config";
export async function sendPayment(
senderKey: string,
recipient: string,
amount: string // e.g. "0.001" for 0.001 USDT0
) {
const wallet = new ethers.Wallet(senderKey, provider);
const block = await provider.getBlock("latest");
const baseFee = block!.baseFeePerGas!;
const tx = await wallet.sendTransaction({
to: recipient,
value: ethers.parseEther(amount),
maxFeePerGas: baseFee * 2n,
maxPriorityFeePerGas: 0n, // always 0 on Stable
});
console.log("Payment sent:", tx.hash);
const receipt = await tx.wait(1);
if (receipt!.status === 1) console.log("Payment settled");
return tx.hash;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const [, , recipient, amount] = process.argv;
await sendPayment(process.env.PRIVATE_KEY!, recipient, amount);
}npx tsx send.ts 0xBob...5678 0.001Payment sent: 0x8f3a...2d41
Payment settled4. 결제 실시간 수신
수신자는 들어오는 Transfer 이벤트를 수신 대기합니다. 이는 전통적인 결제 앱의 푸시 알림과 동일합니다. Stable에서는 단일 슬롯 완결성(single-slot finality) 덕분에 수신자가 거의 즉시 결제를 확인할 수 있습니다.
// receive.ts
import { ethers } from "ethers";
import { STABLE_WS, USDT0_ADDRESS } from "./config";
const wsProvider = new ethers.WebSocketProvider(STABLE_WS);
const usdt0 = new ethers.Contract(
USDT0_ADDRESS,
["event Transfer(address indexed from, address indexed to, uint256 value)"],
wsProvider
);
export function watchIncomingPayments(address: string) {
const filter = usdt0.filters.Transfer(null, address);
usdt0.on(filter, (from: string, to: string, value: bigint, event: any) => {
console.log("Payment received:");
console.log(" from: ", from);
console.log(" amount:", ethers.formatUnits(value, 6), "USDT0");
console.log(" tx: ", event.log.transactionHash);
});
console.log("Watching for incoming payments to", address);
}
if (import.meta.url === `file://${process.argv[1]}`) {
watchIncomingPayments(process.argv[2]);
}npx tsx receive.ts 0xBob...5678Watching for incoming payments to 0xBob...5678
Payment received:
from: 0xAlice...1234
amount: 0.001 USDT0
tx: 0x8f3a...2d415. 거래 내역 조회
과거 Transfer 이벤트를 조회하여 은행 명세서나 모든 결제 앱의 거래 목록과 같은 거래 내역 화면을 구성합니다.
// history.ts
import { ethers } from "ethers";
import { provider, USDT0_ADDRESS } from "./config";
const usdt0 = new ethers.Contract(
USDT0_ADDRESS,
["event Transfer(address indexed from, address indexed to, uint256 value)"],
provider
);
export async function getTransactionHistory(address: string, fromBlock?: number) {
if (fromBlock === undefined) {
const latest = await provider.getBlockNumber();
fromBlock = Math.max(0, latest - 10_000);
}
const [sentEvents, receivedEvents] = await Promise.all([
usdt0.queryFilter(usdt0.filters.Transfer(address, null), fromBlock),
usdt0.queryFilter(usdt0.filters.Transfer(null, address), fromBlock),
]);
return [
...sentEvents.map((e: any) => ({
type: "sent" as const,
counterparty: e.args[1],
amount: ethers.formatUnits(e.args[2], 6),
txHash: e.transactionHash,
block: e.blockNumber,
})),
...receivedEvents.map((e: any) => ({
type: "received" as const,
counterparty: e.args[0],
amount: ethers.formatUnits(e.args[2], 6),
txHash: e.transactionHash,
block: e.blockNumber,
})),
].sort((a, b) => b.block - a.block);
}
if (import.meta.url === `file://${process.argv[1]}`) {
const history = await getTransactionHistory(process.argv[2]);
for (const tx of history) {
console.log(`${tx.type} ${tx.amount} USDT0 ${tx.counterparty} ${tx.txHash}`);
}
}npx tsx history.ts 0xAlice...1234sent 0.001 USDT0 0xBob...5678 0x8f3a...2d41
received 0.01 USDT0 0xFaucet... 0x22b1...3f09다음 추천
- 구독 및 수금 — EIP-7702 위임을 사용한 풀 기반 정기 구독.
- 인보이스로 결제하기 — ERC-3009와 결정론적 nonce로 인보이스를 정산합니다.
- 첫 USDT0 보내기 — 기본적인 네이티브 vs. ERC-20 전송 흐름을 참고하세요.

