결제 요청
Payment Requests는 가맹점이 결제 파라미터를 담은 링크 하나를 발급해 구매자에게 전달하고, 구매자가 그 링크를 열어 지갑을 연결한 뒤 직접 토큰을 전송하는 W2W(지갑↔지갑) 결제 방식입니다. AppKit은 이 흐름에서 지갑 연결과 요청 라우팅만 담당합니다. 실제 트랜잭션 생성과 온체인 처리는 결제 앱과 지갑이 맡습니다.
AppKit은 프로젝트 설정을 불러오지 못할 때 사용할 내장 네트워크로 Ethereum, Sepolia, GIWA Sepolia, Solana mainnet, Solana devnet을 제공합니다. 프로젝트에서 실제로 노출하는 네트워크는 loadConfig()가 가져온 콘솔 설정을 따릅니다. 아래 전송 예제는 Sepolia의 ERC-20 결제에 한정합니다. Solana 결제는 트랜잭션 생성·제출 방식이 다르므로 서명의 Solana 경로를 사용합니다.
사전 준비
- 구매자 측 dApp에
@scope-connect/appkit,viem을 설치합니다. - 텔레메트리는 보안·개인정보 검토가 끝날 때까지
false로 둡니다. 승인된 환경만 별도 플래그로 활성화합니다. - 결제 링크에 들어갈 체인, 토큰, 수취 주소, 금액을 미리 검증합니다.
1. 결제 흐름
아래 표는 결제 요청의 상태를 업무 관점에서 정리한 것입니다.
| 단계 | 상태 | 설명 | AppKit 관여 |
|---|---|---|---|
| 1 | 준비됨 | 가맹점이 결제 링크를 생성합니다. | 없음 |
| 2 | 전달됨 | 링크 또는 QR이 구매자에게 전달됩니다. | 없음 |
| 3 | 열림 | 구매자가 결제 요청 내용을 확인합니다. | 없음 |
| 4 | 연결됨 | 구매자가 지갑을 연결합니다. | connect() 또는 selectWallet() |
| 5 | 제출됨 | 지갑이 eth_sendTransaction 요청을 승인하고 해시를 반환합니다. | request() |
| 6 | 포함됨 | 트랜잭션이 체인에 포함됩니다. | 없음 |
| 7 | 확정됨 | 앱이 확인된 상태를 관찰합니다. | 없음 |
| 8 | 보고됨 | 확인된 결과를 Console에 보고합니다. | recordTransaction() |
eth_sendTransaction의 반환값은 제출된 트랜잭션 해시입니다. 해시를 받았다는 사실만으로 결제가 끝난 것은 아니며, 포함 또는 확정 확인을 거친 뒤에만 완료로 처리해야 합니다.
2. 가맹점에서 결제 링크 만들기
링크 생성은 AppKit의 역할이 아닙니다. 가맹점 애플리케이션이 chainId, tokenAddress, recipient, amount를 검증한 뒤 결제 링크를 만듭니다.
import { getAddress, isAddress, parseUnits } from "viem";
const chainId = "eip155:11155111" as const;
const recipientInput = import.meta.env.VITE_PAYMENT_RECIPIENT;
const tokenAddressInput = import.meta.env.VITE_PAYMENT_TOKEN_ADDRESS;
const amount = "120";
const tokenDecimals = 6;
const supportedEvmPaymentChains: ReadonlySet<string> = new Set([
"eip155:1",
"eip155:11155111",
]);
if (!supportedEvmPaymentChains.has(chainId)) {
throw new Error("이 예제에서 지원하지 않는 EVM 체인입니다.");
}
if (!isAddress(recipientInput)) {
throw new Error("수취 주소가 올바르지 않습니다.");
}
if (!isAddress(tokenAddressInput)) {
throw new Error("토큰 주소가 올바르지 않습니다.");
}
const recipient = getAddress(recipientInput);
const tokenAddress = getAddress(tokenAddressInput);
const amountInBaseUnits = parseUnits(amount, tokenDecimals);
토큰 주소와 decimals는 대상 체인에 실제 배포된 토큰 메타데이터와 대조합니다. 입력값이 유효하면 가맹점은 링크 또는 QR에 결제 정보를 담아 구매자에게 전달합니다.
3. 구매자 지갑 연결
구매자가 링크를 열면 앱은 결제 내용을 보여 주고, 결제에 사용할 지갑을 연결합니다.
import { AppKit, getOrCreateUserHash } from "@scope-connect/appkit";
const appKit = new AppKit({
clientId: import.meta.env.VITE_SCOPE_CLIENT_ID,
telemetry: import.meta.env.VITE_SCOPE_TELEMETRY_APPROVED === "true",
// 선택 — dApp이 결정한 사용자 식별값(자체 식별자가 없으면 SDK 함수 사용). 개인정보는 포함하지 않습니다.
userHash: getOrCreateUserHash(),
});
appKit.init();
const connection = await appKit.selectWallet("metamask", { chainId });
if (connection.chainId !== chainId) {
throw new Error("결제 네트워크 연결에 실패했습니다.");
}
const from = connection.accounts[0];
if (!from) {
throw new Error("결제에 사용할 계정을 찾지 못했습니다.");
}
selectWallet()은 지정한 체인으로 연결하고 최종 WalletConnection을 반환합니다. 사용자가 다른 EVM 지갑을 선택하게 하려면 UI에서 받은 walletId를 전달하되, 결제 요청의 chainId는 변경하지 않습니다.
4. 토큰 전송
EVM 결제는 request()로 eth_sendTransaction을 전달해 처리합니다. AppKit는 요청을 지갑으로 전달할 뿐이며, 서명과 브로드캐스트는 지갑이 담당합니다.
import { encodeFunctionData, erc20Abi, type Hex } from "viem";
const data = encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [recipient, amountInBaseUnits],
});
const txHash = await appKit.request<Hex>({
method: "eth_sendTransaction",
params: [
{
from,
to: tokenAddress,
data,
},
],
});
네이티브 코인 전송이라면 to에 수취 주소를 넣고 value를 전달합니다. txHash는 제출 결과이며, 실제 완료 상태는 이후에 별도로 확인합니다.
5. 결제 확정 후 SCOPE Console에 기록하기 (선택)
먼저 체인에서 영수증을 조회해 성공을 확인합니다. 다음 예제는 dApp의 Sepolia RPC로 viem public client를 만들지만, 운영에서는 제품의 기존 RPC·확정 정책을 사용합니다.
import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";
const publicClient = createPublicClient({
chain: sepolia,
transport: http(import.meta.env.VITE_SEPOLIA_RPC_URL),
});
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
if (receipt.status !== "success") {
throw new Error("결제 트랜잭션이 실패했습니다.");
}
const telemetryApproved =
import.meta.env.VITE_SCOPE_TELEMETRY_APPROVED === "true";
if (telemetryApproved) {
appKit.recordTransaction({
chain: chainId,
txHash,
ref: crypto.randomUUID(),
method: "eth_sendTransaction",
});
}
recordTransaction()의 전송 실패는 결제 흐름에 영향을 주지 않으며, 텔레메트리가 꺼져 있으면 아무 작업도 하지 않습니다. ref에는 결제 ID나 사용자 ID를 그대로 넣지 말고, 필요한 경우 서버에서 별도 매핑하는 임의의 비민감 연결값을 사용합니다. 트랜잭션 해시를 받은 직후가 아니라 제품이 정한 확정 또는 정산 조건을 확인한 뒤에만 호출합니다.