Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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 dotenv
added 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.ts
Address:     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...1234
Balance: 0.01 USDT0

3. 결제 전송

발신자는 전송을 직접 서명하고 제출합니다. 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.001
Payment sent: 0x8f3a...2d41
Payment settled

4. 결제 실시간 수신

수신자는 들어오는 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...5678
Watching for incoming payments to 0xBob...5678
Payment received:
  from:   0xAlice...1234
  amount: 0.001 USDT0
  tx:     0x8f3a...2d41

5. 거래 내역 조회

과거 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...1234
sent      0.001 USDT0  0xBob...5678    0x8f3a...2d41
received  0.01  USDT0  0xFaucet...     0x22b1...3f09

다음 추천