Skip to content
Go back

Wallet SDK 고도화 #2 — 메타 트랜잭션으로 가스비 대납

1편에서 Keystore V3와 메모리 보안을 다뤘다. 이번엔 메타 트랜잭션이다. 풀려는 문제는 단순했다 — 사용자에게 가스비를 부담시키지 않겠다.

블록체인에서 상태를 바꾸는 모든 행위(TX)에는 가스비가 든다. 퍼블릭 체인이든 프라이빗 체인이든 구조는 같다. 사용자에게 “지갑 만들었으면 ETH부터 충전하세요”라고 하는 건 온보딩 장벽이 된다. 메타 트랜잭션은 이 문제를 서명과 전송을 분리하는 것으로 해결한다.

구조

메타 트랜잭션의 핵심은 두 단계로 나뉜다:

sequenceDiagram
    participant M as 모바일/브라우저
    participant S as 서버 (릴레이)
    participant C as 블록체인 컨트랙트

    M->>M: 1. 데이터에 개인키로 서명 (오프체인, 가스비 0)
    M->>S: 서명값 전달
    S->>C: 2. 서명을 포함해 TX 전송 (서버 PK, 가스비 서버 부담)
    C->>C: 3. 서명 검증 → 상태 변경

사용자는 TX를 보내지 않는다. 메시지에 서명만 하고, 서버가 그 서명을 컨트랙트에 제출한다. 컨트랙트는 서명을 검증해서 “이건 진짜 이 사용자가 동의한 거다”를 확인하고 상태를 바꾼다.

오프체인 서명 — signVote

서명할 메시지는 컨트랙트의 검증 로직과 정확히 같은 포맷이어야 한다. 서명과 검증이 같은 해시를 만들어야 하니까.

// relay/sign-vote.ts (요약)
export async function signVote(
  batteryHash: `0x${string}`,
  approve: boolean,
  contractAddress: `0x${string}`,
  privateKey: `0x${string}`,
  chainId: number = DEFAULT_CHAIN_ID,
): Promise<`0x${string}`> {
  // 컨트랙트와 동일한 포맷으로 해시 생성
  const messageHash = keccak256(
    encodePacked(
      ['bytes32', 'bool', 'address', 'uint256'],
      [batteryHash, approve, contractAddress, BigInt(chainId)],
    ),
  );

  const account = privateKeyToAccount(privateKey);
  return account.signMessage({ message: { raw: toBytes(messageHash) } });
}

encodePacked는 Solidity의 abi.encodePacked와 동일한 인코딩을 JavaScript에서 수행한다. 컨트랙트 쪽에서 같은 인자로 keccak256(abi.encodePacked(batteryHash, approve, contractAddress, chainId))를 계산하면 같은 해시가 나온다. 서명에서 주소를 복원(ecrecover)해서 허가된 검사업체인지 확인하면 검증 완료.

chainId를 메시지에 포함한 건 크로스체인 리플레이 방지다. 테스트넷에서 만든 서명을 메인넷에서 쓸 수 없게 한다. 퍼블릭 체인에서는 필수이고, 프라이빗 체인에서도 다중 네트워크 운영 시 보호가 된다.

릴레이 클라이언트 — createRelayClient

서버 쪽에서는 릴레이 클라이언트를 만든다. 서버의 개인키를 넣어서 TX 전송 권한을 부여하는 구조다.

// relay/create-relay-client.ts (요약)
export function createRelayClient(
  config: RelayConfig,
  signerKey: `0x${string}`,
): RelayClient {
  const account = privateKeyToAccount(signerKey);
  const publicClient = createProvider(rpcUrl, chainId);
  const walletClient = createWalletClient({ account, chain, transport: http(rpcUrl) });

  return { publicClient, walletClient, account, config };
}

RelayClientpublicClient(읽기)와 walletClient(쓰기)를 묶은 객체다. RPC URL을 직접 지정할 수도 있고, Alchemy API 키만 넘기면 자동으로 URL을 생성한다. 프라이빗 체인이면 RPC URL 직접 지정, 퍼블릭 체인이면 Alchemy 자동 생성 — 두 환경을 모두 지원한다.

컨트랙트 호출 — sendRelayContractCall

서명값을 컨트랙트에 제출하는 함수다.

// relay/send-relay-tx.ts (요약)
export async function sendRelayContractCall(
  client: RelayClient,
  params: {
    contractAddress: `0x${string}`;
    abi: Abi;
    functionName: string;
    args: readonly unknown[];
  },
): Promise<`0x${string}`> {
  const data = encodeFunctionData({
    abi: params.abi,
    functionName: params.functionName,
    args: params.args,
  });
  return sendRelayTx(client, { to: params.contractAddress, data });
}

ABI와 함수명, 인자를 넘기면 encodeFunctionData로 calldata를 인코딩하고, sendRelayTx가 서버 PK로 TX를 전송한다. 사용자 입장에서는 signVote → 서명값 → 서버로 전달, 서버 입장에서는 sendRelayContractCall → 컨트랙트 호출. 가스비는 서버가 낸다.

전체 흐름 — 배터리 검사 투표

실제 사용 예시로 보면 흐름이 명확해진다.

// 1. 모바일: 배터리 ID를 해싱하고 투표 서명
const batteryHash = hashData('BAT-20260807-555');
const signature = await signVote(batteryHash, true, registryAddr, inspectorPK, 1344);

// 2. 서버: 릴레이 클라이언트로 컨트랙트 호출
const client = createRelayClient({ rpcUrl, chainId: 1344, apiKey: '', policyId: '' }, serverPK);
await sendRelayContractCall(client, {
  contractAddress: registryAddr,
  abi: INSPECTION_REGISTRY_ABI,
  functionName: 'voteWithSignature',
  args: [batteryHash, true, signature],
});

모바일은 signVote 한 줄로 서명하고, 서버는 sendRelayContractCall 한 줄로 TX를 보낸다. 모바일에는 ETH가 없어도 된다.

왜 직접 TX 전송이 아닌가

“서버가 대신 보내는 게 중앙화 아닌가?”라는 의문이 생긴다. 맞다 — 릴레이 서버는 중앙 포인트다. 하지만 서버가 할 수 있는 건 TX 전송뿐이다. 서명을 위조할 수 없으니 사용자의 의사를 조작하지 못한다. 컨트랙트는 서명을 검증하기 때문에, 서버가 approve: false로 바꿔서 제출하면 서명 검증에서 실패한다.

트레이드오프는 있다:

엔터프라이즈 환경에서는 이 트레이드오프가 합리적이다. 사용자 온보딩을 위해 가스비 장벽을 없애는 것이 더 중요하기 때문이다.

회고

서명과 전송을 분리하면, 사용자는 블록체인을 의식하지 않아도 된다.

메타 트랜잭션의 본질은 “블록체인의 복잡성을 서버 뒤로 숨기는 것”이었다. 사용자는 가스비 걱정 없이 버튼 하나로 서명하고, 나머지는 서버가 처리한다. SDK 관점에서는 signVote(오프체인)와 sendRelayContractCall(온체인) 두 함수로 역할이 깔끔하게 분리됐다.


이전 편: Wallet SDK 고도화 #1 — Keystore V3와 메모리 보안 다음 편: Wallet SDK 고도화 #3 — DID Auth 챌린지-응답 인증


Share this post on:

Comments


Previous Post
Wallet SDK 고도화 #3 — DID Auth 챌린지-응답 인증
Next Post
Wallet SDK 고도화 #1 — Keystore V3와 메모리 보안