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

송장으로 결제하기

이 가이드는 송장 메타데이터에서 파생된 결정론적 논스를 사용하여 ERC-3009로 온체인에서 송장을 결제하는 방법을 안내합니다. 논스는 각 결제를 송장과 연결하고 이중 결제를 방지합니다.

구현할 기능

전체 송장 수명 주기: 구매자는 오프체인에서 ERC-3009 승인을 서명하고, 판매자는 온체인에 제출하며, 조정은 결정론적 논스를 통해 결과 AuthorizationUsed 이벤트를 송장에 다시 일치시킵니다.

데모

단계 1. 송장 발행
        번호: INV-2026-001234
        금액: 5000 USDT0
        기한: 2026-04-30

단계 2. 구매자가 승인 서명 (오프체인, 가스 없음)
        논스: 0xa1b2...c3d4 (송장 메타데이터에서)
        서명: 0xf0e9...1234

단계 3. 판매자가 transferWithAuthorization 제출
        거래: 0x8f3a...2d41
        금액: 5000 USDT0이 판매자에게 전송됨

단계 4. 조정
        AuthorizationUsed(nonce=0xa1b2...) → 송장 INV-2026-001234
        올바른 금액 및 당사자에 대해 검증된 전송 이벤트
        ERP: 블록 1284371에 PAID로 표시됨

개요

구매자:
─── 구매자 ───────────────────────────────────────────
nonce = getInvoiceNonce(invoice)
authorization = { from: buyer, to: vendor, value: amount, nonce, ... }
signature = signTypedData(authorization)

// 옵션 A: 구매자가 거래를 직접 제출합니다.
usdt0.transferWithAuthorization(authorization, signature)

// 옵션 B: 구매자가 {authorization, signature}를 판매자에게 보냅니다.
//           판매자(또는 촉진자)가 구매자를 대신하여 제출합니다.
판매자:
─── 판매자 ──────────────────────────────────────────
// 옵션 B인 경우: 구매자의 서명을 사용하여 transferWithAuthorization 제출

// AuthorizationUsed 이벤트를 통해 조정
on AuthorizationUsed(authorizer, nonce):
    invoice = nonceToInvoice.get(nonce)
    transferLog = receipt.logs.find(송장.구매자, 송장.판매자, 송장.금액과 일치하는 전송)
    if transferLog:
        erpSystem.markPaid(invoice.id, txHash, settledAt)

구성

// config.ts
import { ethers } from "ethers";
 
export const STABLE_TESTNET_RPC = "https://rpc.testnet.stable.xyz";
export const CHAIN_ID = 2201;
export const USDT0_ADDRESS = "0x78Cf24370174180738C5B8E352B6D14c83a6c9A9";
 
export const provider = new ethers.JsonRpcProvider(STABLE_TESTNET_RPC);
 
export const EIP712_DOMAIN = {
  name: "USDT0",
  version: "1",
  chainId: CHAIN_ID,
  verifyingContract: USDT0_ADDRESS,
};
 
export const TRANSFER_WITH_AUTHORIZATION_TYPE = {
  TransferWithAuthorization: [
    { name: "from", type: "address" },
    { name: "to", type: "address" },
    { name: "value", type: "uint256" },
    { name: "validAfter", type: "uint256" },
    { name: "validBefore", type: "uint256" },
    { name: "nonce", type: "bytes32" },
  ],
};
 
export interface Invoice {
  number: string;   // 예: "INV-2026-001234"
  vendor: string;   // 판매자 지갑 주소
  buyer: string;    // 구매자 지갑 주소
  amount: bigint;   // USDT0 원자 단위 (6진수) 금액
  dueDate: number;  // Unix 타임스탬프
}

1단계: 결정론적 논스 생성

구매자와 판매자 모두 송장 메타데이터에서 동일한 논스를 독립적으로 계산할 수 있습니다. 외부 레지스트리는 필요하지 않습니다.

// nonce.ts
import { ethers } from "ethers";
import { Invoice } from "./config";
 
export function getInvoiceNonce(invoice: Invoice): string {
  return ethers.solidityPackedKeccak256(
    ["string", "address", "address", "uint256", "uint256"],
    [
      invoice.number,
      invoice.vendor,
      invoice.buyer,
      invoice.amount,
      invoice.dueDate,
    ]
  );
}
 
// 예시
const invoice: Invoice = {
  number: "INV-2026-001234",
  vendor: "0xVendorAddress",
  buyer: "0xBuyerAddress",
  amount: ethers.parseUnits("5000", 6), // 5,000 USDT0
  dueDate: Math.floor(new Date("2026-04-30").getTime() / 1000),
};
 
const nonce = getInvoiceNonce(invoice);
// 동일한 입력은 항상 동일한 논스를 생성합니다.
// 이 논스는 결제 시 온체인에서 사용되며 이중 결제를 방지합니다.

2단계: 승인 서명 (구매자)

구매자는 1단계에서 얻은 결정론적 논스를 사용하여 ERC-3009 transferWithAuthorization에 서명합니다.

// sign-invoice.ts
import { ethers } from "ethers";
import {
  provider,
  EIP712_DOMAIN,
  TRANSFER_WITH_AUTHORIZATION_TYPE,
  Invoice,
} from "./config";
import { getInvoiceNonce } from "./nonce";
 
const buyerWallet = new ethers.Wallet(process.env.BUYER_KEY!, provider);
 
async function signInvoiceAuthorization(invoice: Invoice) {
  const nonce = getInvoiceNonce(invoice);
  const gracePeriod = 30 * 24 * 60 * 60; // 기한 후 30일
 
  const authorization = {
    from: invoice.buyer,
    to: invoice.vendor,
    value: invoice.amount,
    validAfter: 0,
    validBefore: invoice.dueDate + gracePeriod,
    nonce,
  };
 
  const signature = await buyerWallet.signTypedData(
    EIP712_DOMAIN,
    TRANSFER_WITH_AUTHORIZATION_TYPE,
    authorization
  );
 
  return { authorization, signature };
}

3단계: 트랜잭션 제출

누가 제출하는지에 따라 두 가지 옵션이 있습니다.

옵션 A: 구매자가 제출

구매자가 transferWithAuthorization 트랜잭션을 직접 제출하고 가스를 지불합니다. 구매자의 회계 시스템이 내부 승인 흐름과 연결된 트랜잭션 해시를 필요로 하는 경우와 같이 구매자가 결제가 실행되는 시기와 방법을 제어할 때 사용합니다.

// pay.ts
import { ethers } from "ethers";
import { provider, USDT0_ADDRESS } from "./config";
 
const buyerWallet = new ethers.Wallet(process.env.BUYER_KEY!, provider);
 
const usdt0 = new ethers.Contract(
  USDT0_ADDRESS,
  [
    "function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s)",
  ],
  buyerWallet,
);
 
async function payInvoice(
  authorization: { from: string; to: string; value: bigint; validAfter: number; validBefore: number; nonce: string },
  signature: string,
) {
  const { v, r, s } = ethers.Signature.from(signature);
 
  const tx = await usdt0.transferWithAuthorization(
    authorization.from,
    authorization.to,
    authorization.value,
    authorization.validAfter,
    authorization.validBefore,
    authorization.nonce,
    v, r, s,
  );
 
  const receipt = await tx.wait(1);
  console.log("Invoice paid, tx:", receipt.hash);
  // 논스는 이제 사용되었으므로 동일한 송장을 두 번 결제할 수 없습니다.
  return { txHash: receipt.hash, blockNumber: receipt.blockNumber };
}

옵션 B: 판매자가 제출

구매자는 API, 이메일 또는 기타 채널을 통해 {authorization, signature}를 판매자에게 보냅니다. 판매자(또는 촉진자)는 구매자를 대신하여 트랜잭션을 제출하므로 구매자는 가스를 관리할 필요가 없습니다. 동일한 요청 흐름 내에서 판매자가 동기식 확인을 필요로 하는 경우에 사용합니다.

// settle.ts
import { ethers } from "ethers";
import { provider, USDT0_ADDRESS } from "./config";
 
const vendorWallet = new ethers.Wallet(process.env.VENDOR_KEY!, provider);
 
const usdt0 = new ethers.Contract(
  USDT0_ADDRESS,
  [
    "function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s)",
  ],
  vendorWallet,
);
 
async function settleInvoice(
  authorization: { from: string; to: string; value: bigint; validAfter: number; validBefore: number; nonce: string },
  signature: string,
) {
  const { v, r, s } = ethers.Signature.from(signature);
 
  const tx = await usdt0.transferWithAuthorization(
    authorization.from,
    authorization.to,
    authorization.value,
    authorization.validAfter,
    authorization.validBefore,
    authorization.nonce,
    v, r, s,
  );
 
  const receipt = await tx.wait(1);
  console.log("Invoice settled, tx:", receipt.hash);
  return { txHash: receipt.hash, blockNumber: receipt.blockNumber };
}

4단계: 온체인 이벤트를 통해 조정 (판매자)

누가 트랜잭션을 제출했는지에 관계없이 모든 송장 결제는 결정론적 논스를 포함하는 AuthorizationUsed 이벤트를 발생시킵니다. 판매자는 이 이벤트를 수신하고 논스를 통해 보류 중인 송장과 일치시킵니다. 논스는 송장 메타데이터에서 파생되므로 정확한 일치가 가능합니다.

// reconcile.ts
import { ethers } from "ethers";
import { provider, USDT0_ADDRESS, Invoice } from "./config";
import { getInvoiceNonce } from "./nonce";
 
const usdt0 = new ethers.Contract(
  USDT0_ADDRESS,
  [
    "event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)",
    "event Transfer(address indexed from, address indexed to, uint256 value)",
  ],
  provider,
);
 
// 조회 맵 구축: 논스 -> 송장
// 실제 환경에서는 송장 데이터베이스에서 가져옵니다.
const invoices: Invoice[] = [
  {
    number: "INV-2026-001234",
    vendor: "0xVendorAddress",
    buyer: "0xBuyerAddress",
    amount: ethers.parseUnits("5000", 6),
    dueDate: Math.floor(new Date("2026-04-30").getTime() / 1000),
  },
];
 
const nonceToInvoice = new Map<string, Invoice>();
for (const inv of invoices) {
  nonceToInvoice.set(getInvoiceNonce(inv), inv);
}
 
usdt0.on("AuthorizationUsed", async (authorizer: string, nonce: string, event: any) => {
  const invoice = nonceToInvoice.get(nonce);
  if (!invoice) return; // 우리의 송장이 아닌 경우
 
  const receipt = await event.getTransactionReceipt();
 
  const transferLog = receipt.logs
    .map((log: any) => {
      try { return usdt0.interface.parseLog(log); } catch { return null; }
    })
    .find(
      (parsed: any) =>
        parsed?.name === "Transfer" &&
        parsed.args[0].toLowerCase() === invoice.buyer.toLowerCase() &&
        parsed.args[1].toLowerCase() === invoice.vendor.toLowerCase() &&
        parsed.args[2] === invoice.amount
    );
 
  if (!transferLog) {
    console.error("송장과 일치하는 전송 이벤트 없음:", invoice.number);
    return;
  }
 
  // 모든 확인 통과
  console.log(`송장 ${invoice.number} 결제 완료`);
  console.log("  거래:", receipt.hash);
  console.log("  블록에서 결제 완료:", receipt.blockNumber);
 
  // 실제 환경: 여기에서 ERP/회계 시스템 업데이트
  // erpSystem.markPaid(invoice.number, receipt.hash, receipt.blockNumber);
});
 
console.log("송장 결제를 기다리는 중...");
npx tsx reconcile.ts
송장 결제를 기다리는 중...
송장 INV-2026-001234 결제 완료
  거래: 0x8f3a...2d41
  블록에서 결제 완료: 1284371

실패한 결제 처리

제출된 transferWithAuthorization은 여러 가지 이유로 되돌려질 수 있습니다. 송장이 재시도되거나 닫힐 수 있도록 각 원인을 공급업체 또는 구매자에게 감지하고 알립니다.

되돌리기 사유원인복구
FiatTokenV2: invalid signature서명이 승인 필드와 일치하지 않습니다.구매자에게 변경되지 않은 송장 데이터로 다시 서명하도록 요청합니다.
FiatTokenV2: authorization is used or canceled논스가 이미 사용되었거나(이중 제출) 구매자가 취소했습니다.송장을 이미 결제된 것으로 표시하고 논스로 원본 거래를 조회합니다.
FiatTokenV2: authorization is not yet validvalidAfter 이전에 제출되었습니다.validAfter까지 기다리거나 새 승인을 발급합니다.
FiatTokenV2: authorization is expiredvalidBefore 이후에 제출되었습니다.기간이 연장된 새 승인을 발급합니다.
FiatTokenV2: transfer amount exceeds balance구매자의 USDT0 잔액이 부족합니다.구매자에게 지갑에 자금을 지원하도록 알린 다음 동일한 서명을 재시도합니다.

오류를 분류한 후 재시도를 위해 되돌리기를 catch합니다.

// retry.ts
import { ethers } from "ethers";
 
async function submitWithRetry(
  submit: () => Promise<ethers.ContractTransactionResponse>,
): Promise<string> {
  try {
    const tx = await submit();
    const receipt = await tx.wait(1);
    return receipt!.hash;
  } catch (err: any) {
    const reason = err?.info?.error?.message || err?.reason || err?.message || "";
 
    if (reason.includes("authorization is used or canceled")) {
      // AuthorizationUsed 이벤트를 통해 원본 거래를 조회하고 송장을 결제된 것으로 표시합니다.
      throw new Error("ALREADY_PAID");
    }
    if (reason.includes("authorization is expired")) {
      throw new Error("AUTHORIZATION_EXPIRED");
    }
    if (reason.includes("invalid signature")) {
      throw new Error("INVALID_SIGNATURE");
    }
    if (reason.includes("transfer amount exceeds balance")) {
      throw new Error("INSUFFICIENT_BALANCE");
    }
    throw err;
  }
}

다음 권장 사항