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

Stable에서 MPP 엔드포인트 구축하기

이 가이드는 Stable에서 USDT0에 대한 사용자 정의 MPP 결제 메서드를 작성하고 MPP 게이트 엔드포인트를 제공하는 과정을 안내합니다. 구매자는 ERC-3009 transferWithAuthorization에 서명하고, 서버는 mppxverify() 훅을 통해 이를 검증하며, 결제는 사용자가 제어하는 별도의 단계에서 이루어집니다.

구축할 내용

MPP WWW-Authenticate 챌린지와 함께 402 Payment Required를 반환하고, Authorization 헤더에 서명된 자격 증명을 수락하고, 이를 검증하고, USDT0에서 transferWithAuthorization을 결제하고, Payment-Receipt 헤더와 함께 응답을 반환하는 HTTP 엔드포인트입니다.

step 1. 클라이언트: GET /weather (Authorization 헤더 없음)
        서버: 402 Payment Required
                WWW-Authenticate: Payment realm="...", challenges="[...usdt0-stable charge for $0.001...]"

step 2. 클라이언트는 viem 계정으로 ERC-3009 승인에 서명합니다.

step 3. 클라이언트: GET /weather + 직렬화된 자격 증명이 포함된 Authorization 헤더
        서버: verify()는 EIP-712 서명을 검증합니다.
        서버: settle()은 Stable에서 transferWithAuthorization을 제출합니다.
                (~700ms 블록 확인)
        서버: 200 OK { weather: "sunny" }
                Payment-Receipt: reference="0x8f3a...", status="success"

step 4. Stablescan에서 결제 확인
        https://stablescan.xyz/tx/0x8f3a...

전제 조건

  • Stable에 자금을 지원받은 USDT0 지갑. Faucet 사용하기 또는 USDT0 이동하기를 참조하세요.
  • mppx, viem, zod가 설치된 Node 20 이상.
  • Stable의 판매자 계정 (EOA). 기본 결제 경로의 경우 판매자는 USDT0으로 가스비를 지불합니다. 대안: Gas Waiver를 통한 결제 섹션은 가스비 없는 버전을 보여줍니다.
npm install mppx viem zod express

1. 공유 스키마 정의

Method.from()은 의도와 요청(Challenge) 및 자격 증명 페이로드에 대한 스키마를 선언합니다. 클라이언트와 서버 모두 이 정의를 가져옵니다.

// src/method.ts
import { Method } from "mppx";
import { z } from "zod";
import { parseUnits } from "viem";
 
export const USDT0_STABLE = "0x779Ded0c9e1022225f8E0630b35a9b54bE713736";
export const CHAIN_ID = 988;
 
// Request: 서버가 클라이언트에 보내는 Challenge 페이로드.
const zRequest = z.pipe(
  z.object({
    chainId: z.literal(CHAIN_ID),
    asset: z.literal(USDT0_STABLE),
    amount: z.string(),             // 사람이 읽을 수 있는 형식, 예: "0.001"
    decimals: z.literal(6),
    payTo: z.string().regex(/^0x[a-fA-F0-9]{40}$/),
    validAfter: z.number().int().nonnegative(),
    validBefore: z.number().int().positive(),
    nonce: z.string().regex(/^0x[a-fA-F0-9]{64}$/),
  }),
  z.transform(({ amount, decimals, ...rest }) => ({
    ...rest,
    amount: parseUnits(amount, decimals).toString(),  // 원자 단위
  })),
);
 
// Credential 페이로드: 서명 후 클라이언트가 반환하는 내용.
const zPayload = z.object({
  from: z.string().regex(/^0x[a-fA-F0-9]{40}$/),
  signature: z.string().regex(/^0x[a-fA-F0-9]{130}$/), // 65-바이트 16진수
});
 
export const usdt0Stable = Method.from({
  intent: "charge",
  name: "usdt0-stable",
  schema: { request: zRequest, credential: { payload: zPayload } },
});
 
// 클라이언트와 서버 모두에서 사용되는 EIP-712 도메인 + 유형.
export const EIP712_DOMAIN = {
  name: "USDT0",
  version: "1",
  chainId: CHAIN_ID,
  verifyingContract: USDT0_STABLE,
} as const;
 
export const TRANSFER_WITH_AUTHORIZATION_TYPES = {
  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" },
  ],
} as const;
usdt0Stable.name === "usdt0-stable"
usdt0Stable.intent === "charge"

2. 서버: 자격 증명 확인

Method.toSerververify()mppx에 연결합니다. 함수는 역직렬화된 자격 증명 (챌린지 + 페이로드)을 수신하며, 유효하지 않은 증명이 있으면 예외를 발생시키거나 Receipt를 반환해야 합니다.

// src/server-method.ts
import { Method, Receipt } from "mppx";
import { verifyTypedData } from "viem";
import {
  usdt0Stable,
  EIP712_DOMAIN,
  TRANSFER_WITH_AUTHORIZATION_TYPES,
} from "./method";
 
export const usdt0StableServer = Method.toServer(usdt0Stable, {
  async verify({ credential }) {
    const { request } = credential.challenge;
    const { from, signature } = credential.payload;
 
    const valid = await verifyTypedData({
      address: from as `0x${string}`,
      domain: EIP712_DOMAIN,
      types: TRANSFER_WITH_AUTHORIZATION_TYPES,
      primaryType: "TransferWithAuthorization",
      message: {
        from: from as `0x${string}`,
        to: request.payTo as `0x${string}`,
        value: BigInt(request.amount),
        validAfter: BigInt(request.validAfter),
        validBefore: BigInt(request.validBefore),
        nonce: request.nonce as `0x${string}`,
      },
      signature: signature as `0x${string}`,
    });
 
    if (!valid) throw new Error("유효하지 않은 ERC-3009 서명");
 
    // Receipt의 참조는 settle() 후 tx 해시로 채워집니다.
    return Receipt.from({
      method: usdt0Stable.name,
      reference: "pending",
      status: "success",
      timestamp: new Date().toISOString(),
    });
  },
});
{
  method: "usdt0-stable",
  reference: "pending",
  status: "success",
  timestamp: "2026-06-01T12:34:56.000Z"
}

3. 결제: transferWithAuthorization 제출

결제는 의도적으로 verify()와 분리되어 있습니다. verify()가 반환된 후, 운영 모델에 맞는 경로를 통해 승인을 온체인으로 제출합니다. 세 가지 옵션은 권장 순서입니다.

기본값: 서버가 직접 제출

판매자의 EOA가 서명된 승인과 함께 transferWithAuthorization을 USDT0에 제출합니다. 판매자는 USDT0(Stable의 기본 가스 토큰)으로 가스비를 지불하므로 별도의 가스 토큰 잔액을 관리할 필요가 없습니다.

// src/settle.ts
import { createWalletClient, http, parseSignature } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { stable } from "viem/chains";
import { USDT0_STABLE } from "./method";
 
const USDT0_ABI = [
  {
    name: "transferWithAuthorization",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "from", type: "address" },
      { name: "to", type: "address" },
      { name: "value", type: "uint256" },
      { name: "validAfter", type: "uint256" },
      { name: "validBefore", type: "uint256" },
      { name: "nonce", type: "bytes32" },
      { name: "v", type: "uint8" },
      { name: "r", type: "bytes32" },
      { name: "s", type: "bytes32" },
    ],
    outputs: [],
  },
] as const;
 
const seller = privateKeyToAccount(process.env.SELLER_KEY as `0x${string}`);
const wallet = createWalletClient({
  account: seller,
  chain: stable,
  transport: http("https://rpc.stable.xyz"),
});
 
export async function settleDirect(credential: {
  challenge: { request: any };
  payload: { from: string; signature: string };
}): Promise<{ txHash: `0x${string}` }> {
  const { request } = credential.challenge;
  const { v, r, s } = parseSignature(credential.payload.signature as `0x${string}`);
 
  const txHash = await wallet.writeContract({
    address: USDT0_STABLE,
    abi: USDT0_ABI,
    functionName: "transferWithAuthorization",
    args: [
      credential.payload.from as `0x${string}`,
      request.payTo as `0x${string}`,
      BigInt(request.amount),
      BigInt(request.validAfter),
      BigInt(request.validBefore),
      request.nonce as `0x${string}`,
      Number(v),
      r as `0x${string}`,
      s as `0x${string}`,
    ],
  });
 
  return { txHash };
}
{ txHash: "0x8f3a1b2c..." }

대안: Gas Waiver를 통한 결제

Stable의 Gas Waiver를 사용하여 gasPrice = 0으로 내부 트랜잭션을 제출합니다. 판매자는 래핑 트랜잭션에 계속 서명하지만 가스비를 지불하지 않습니다. Waiver Server API 키가 필요합니다.

// src/settle-waiver.ts
import { encodeFunctionData } from "viem";
import { USDT0_STABLE } from "./method";
import { USDT0_ABI } from "./settle";
 
const WAIVER_SERVER = "https://waiver.stable.xyz"; // 메인넷 엔드포인트
 
export async function settleViaWaiver(
  credential: { challenge: { request: any }; payload: { from: string; signature: string } },
  signedInnerTxHex: `0x${string}`,
): Promise<{ txHash: `0x${string}` }> {
  const res = await fetch(`${WAIVER_SERVER}/v1/submit`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.WAIVER_API_KEY}`,
    },
    body: JSON.stringify({ transactions: [signedInnerTxHex] }),
  });
 
  const lines = (await res.text()).trim().split("\n");
  const result = JSON.parse(lines[0]);
  if (!result.success) throw new Error(`결제 실패: ${result.error?.message}`);
  return { txHash: result.txHash };
}
{ txHash: "0x8f3a1b2c..." }

서명된 내부 트랜잭션(gasPrice: 0, 인코딩된 transferWithAuthorization 호출)을 게시하기 전에 빌드하는 방법에 대한 자세한 내용은 Gas waiver protocol을 참조하세요.

대안: x402 통신자에게 넘기기

이미 x402 통신자 통합(Semantic Pay 또는 Heurist)을 운영하고 있다면, 이를 결제 대상으로 재사용할 수 있습니다. /settlepaymentPayload를 POST하면 통신자가 온체인 호출을 제출합니다.

정확한 paymentPayload 형식은 x402 미들웨어 내부이며 와이어 레벨에서 지정되지 않습니다. 가장 간단한 방법은 통신자의 자체 SDK를 사용하여 페이로드를 구축하거나 위에서 설명한 직접 제출 경로를 유지하는 것입니다. 통신자는 MPP를 말할 필요가 없습니다. transferWithAuthorization 필드만 봅니다.

4. 클라이언트: 자격 증명 서명

Method.toClientcreateCredential()mppx에 연결합니다. 클라이언트는 챌린지를 읽고, 에이전트의 viem 계정으로 EIP-712 승인에 서명하며, 자격 증명을 직렬화합니다.

// src/client-method.ts
import { Credential, Method } from "mppx";
import { hexToSignature, parseSignature } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import {
  usdt0Stable,
  EIP712_DOMAIN,
  TRANSFER_WITH_AUTHORIZATION_TYPES,
} from "./method";
 
export function createUsdt0StableClient(privateKey: `0x${string}`) {
  const account = privateKeyToAccount(privateKey);
 
  return Method.toClient(usdt0Stable, {
    async createCredential({ challenge }) {
      const { request } = challenge;
 
      const signature = await account.signTypedData({
        domain: EIP712_DOMAIN,
        types: TRANSFER_WITH_AUTHORIZATION_TYPES,
        primaryType: "TransferWithAuthorization",
        message: {
          from: account.address,
          to: request.payTo as `0x${string}`,
          value: BigInt(request.amount),
          validAfter: BigInt(request.validAfter),
          validBefore: BigInt(request.validBefore),
          nonce: request.nonce as `0x${string}`,
        },
      });
 
      return Credential.serialize({
        challenge,
        payload: { from: account.address, signature },
      });
    },
  });
}
"eyJjaGFsbGVuZ2UiOnsi..." // base64 직렬화된 자격 증명, ~600바이트

5. 서버 연결

mppx의 Express 미들웨어를 사용하여 챌린지를 발행하고, 들어오는 Authorization 헤더를 파싱하고, verify()를 실행하고, 결제 함수를 호출하고, Payment-Receipt 헤더를 내보냅니다.

// src/server.ts
import express from "express";
import { Mppx } from "mppx/express";
import { randomBytes } from "node:crypto";
import { usdt0StableServer } from "./server-method";
import { settleDirect } from "./settle";
 
const PAY_TO = process.env.PAY_TO_ADDRESS as `0x${string}`;
const PORT = Number(process.env.PORT ?? 4022);
 
const mppx = Mppx.create({
  secretKey: process.env.MPP_SECRET_KEY!,
  methods: [usdt0StableServer],
  onVerified: async ({ credential, receipt }) => {
    const { txHash } = await settleDirect(credential);
    return { ...receipt, reference: txHash };
  },
});
 
const app = express();
 
app.get(
  "/weather",
  mppx.charge({
    amount: "0.001",
    method: "usdt0-stable",
    request: {
      chainId: 988,
      asset: "0x779Ded0c9e1022225f8E0630b35a9b54bE713736",
      decimals: 6,
      payTo: PAY_TO,
      validAfter: 0,
      validBefore: Math.floor(Date.now() / 1000) + 300,
      nonce: `0x${randomBytes(32).toString("hex")}`,
    },
  })((_req, res) => {
    res.json({ weather: "sunny", temperature: 70 });
  }),
);
 
app.listen(PORT, () => {
  console.log(`MPP 서버가 http://localhost:${PORT}에서 수신 중입니다`);
});
MPP 서버가 http://localhost:4022에서 수신 중입니다

6. 전체 흐름 실행

서버를 시작하고, 챌린지를 확인하고, 클라이언트를 실행하고, 결제를 확인합니다.

챌린지 확인

curl -i http://localhost:4022/weather
HTTP/1.1 402 Payment Required
WWW-Authenticate: Payment realm="...", challenges="[{\"method\":\"usdt0-stable\",\"request\":{...}}]"
Content-Type: application/json

{"error":"Payment required"}

유료 요청 보내기

// src/client.ts
import { Mppx } from "mppx/client";
import { createUsdt0StableClient } from "./client-method";
 
const client = Mppx.create({
  methods: [createUsdt0StableClient(process.env.BUYER_KEY as `0x${string}`)],
});
 
const res = await fetch("http://localhost:4022/weather", {
  // mppx는 fetch를 402 재시도 루프와 함께 래핑합니다:
  ...client.fetchOptions(),
});
 
console.log(res.status, await res.json());
console.log("Payment-Receipt:", res.headers.get("Payment-Receipt"));
npx tsx src/client.ts
200 { weather: "sunny", temperature: 70 }
Payment-Receipt: reference="0x8f3a1b2c...", status="success", timestamp="2026-06-01T12:34:56.000Z"

Stablescan에서 확인

https://stablescan.xyz/tx/0x8f3a1b2c...를 열고 transferWithAuthorizationPAY_TO 주소로 결제되었는지 확인합니다.

방금 한 일

  • USDT0으로 달러화된 결제를 구매자 측에서 가스 토큰 잔액을 관리할 필요 없이 지불했습니다.
  • 클라이언트-서버 홉에서 MPP의 WWW-Authenticate / Authorization / Payment-Receipt 와이어 형식을 사용했습니다.
  • 동일한 HTTP 요청 수명 주기(~700ms 블록 시간)에서 transferWithAuthorization으로 Stable에서 결제를 완료했습니다.

다음 권장 사항

  • MPP 개념: MPP가 x402와 어떤 관련이 있는지, 다른 의도가 어떻게 보이는지 알아보세요.
  • MPP 세션: 요청당 결제가 너무 비쌀 때 오프체인 바우처로 마이크로 결제를 스트리밍합니다.
  • 통신자: 직접 제출하는 대신 Semantic Pay 또는 Heurist를 결제 대상으로 사용합니다.