Skip to content
Go back

Wallet SDK 고도화 #5 — M-of-N 오프체인 멀티시그

4편에서 TX 안정성을 다뤘다. 이번엔 다중 서명이다. 풀려는 문제 — 한 사람의 서명만으로 중요한 결정이 실행되면 안 된다.

배터리 검사를 예로 들면, 한 검사업체가 단독으로 “통과”를 선언하면 신뢰할 수 없다. 3개 업체 중 2개 이상이 동의해야 통과 — 이게 M-of-N 멀티시그다. 온체인 멀티시그 컨트랙트(Gnosis Safe 등)도 있지만, 서명을 모으는 과정까지 매번 TX를 보내면 가스비가 낭비된다. 서명은 오프체인에서 모으고, 결과만 온체인에 제출하는 구조를 선택했다.

구조

세 단계로 나뉜다:

sequenceDiagram
    participant A as 제안자
    participant B as 서명자 1
    participant C as 서명자 2
    participant V as 검증자

    A->>A: createProposal(dataHash, config)
    A->>B: 제안 전달
    B->>B: addSignature(proposal, pk1)
    B->>C: 서명 추가된 제안 전달
    C->>C: addSignature(proposal, pk2)
    C->>V: 제안 전달
    V->>V: verifyMultisig(proposal)
    V-->>V: threshold 충족 → approved

모든 과정이 오프체인이다. TX를 보내지 않고 서명만 모은다. threshold에 도달하면 그 결과를 온체인에 한 번만 제출하면 된다.

createProposal — 제안 생성

// multisig/proposal.ts (요약)
export function createProposal(
  dataHash: `0x${string}`,
  config: MultisigConfig,
  options?: { description?: string; ttlMs?: number },
): MultisigProposal {
  if (config.threshold > config.signers.length) {
    throw new MultisigError('threshold가 서명자 수보다 큽니다');
  }

  const uniqueSigners = new Set(config.signers.map((s) => s.toLowerCase()));
  if (uniqueSigners.size !== config.signers.length) {
    throw new MultisigError('중복된 서명자가 있습니다');
  }

  return {
    id: crypto.randomUUID(),
    dataHash,
    config,
    signatures: [],
    status: 'pending',
    expiresAt: new Date(Date.now() + (options?.ttlMs ?? 3600000)).toISOString(),
  };
}

MultisigConfig{ threshold: number; signers: readonly \0x${string}`[] }다. 예를 들어 3명 중 2명이면 { threshold: 2, signers: [addr1, addr2, addr3] }`.

검증이 세 가지 있다:

addSignature — 서명 수집

// multisig/collector.ts (요약)
export async function addSignature(
  proposal: MultisigProposal,
  privateKey: `0x${string}`,
): Promise<MultisigProposal> {
  if (new Date() > new Date(proposal.expiresAt)) {
    throw new MultisigError('제안이 만료되었습니다');
  }

  const account = privateKeyToAccount(privateKey);
  const signature = await account.signMessage({ message: proposal.dataHash });
  const recoveredAddress = await recoverMessageAddress({
    message: proposal.dataHash,
    signature,
  });

  // 허용된 서명자인지 + 중복 서명 확인
  // ...

  const newSignatures = [...proposal.signatures, entry];
  const approved = newSignatures.length >= proposal.config.threshold;

  return {
    ...proposal,
    signatures: newSignatures,
    status: approved ? 'approved' : 'pending',
  };
}

핵심은 불변 객체 패턴이다. addSignature는 원본 proposal을 수정하지 않고, 서명이 추가된 새 객체를 반환한다. { ...proposal, signatures: newSignatures }로 스프레드해서 새로 만든다. 이렇게 하면 여러 곳에서 같은 proposal을 참조하더라도 상태가 꼬이지 않는다.

방어 로직이 네 겹이다:

  1. 만료 확인 — 1시간 지나면 거부
  2. 승인 완료 확인 — 이미 approved면 추가 서명 불가
  3. 서명자 검증recoverMessageAddress로 서명에서 주소를 복원하고, config.signers에 포함된 주소인지 확인
  4. 중복 방지 — 같은 주소로 두 번 서명 불가

verifyMultisig — 최종 검증

// multisig/verify-multisig.ts (요약)
export async function verifyMultisig(proposal: MultisigProposal): Promise<MultisigResult> {
  if (new Date() > new Date(proposal.expiresAt)) {
    return { approved: false, progress: { current: 0, required: threshold }, validSignatures: [] };
  }

  const validSignatures = [];
  const seen = new Set<string>();

  for (const entry of proposal.signatures) {
    const recovered = await recoverMessageAddress({
      message: proposal.dataHash,
      signature: entry.signature,
    });

    // 주소 일치 + 허용된 서명자 + 중복 제거
    if (isValid && !seen.has(key)) {
      seen.add(key);
      validSignatures.push(entry);
    }
  }

  return {
    approved: validSignatures.length >= threshold,
    progress: { current: validSignatures.length, required: threshold },
    validSignatures,
  };
}

addSignature에서도 검증하는데 왜 verifyMultisig에서 다시 하느냐? — 신뢰 경계가 다르기 때문이다. addSignature는 서명을 모으는 과정에서의 검증이고, verifyMultisig는 모인 서명을 제3자가 독립적으로 검증하는 것이다. 네트워크를 거쳐 전달받은 proposal의 서명이 조작되지 않았는지 처음부터 다시 확인한다.

전체 사용 예시

import { createProposal, addSignature, verifyMultisig, hashData } from '@trust-core/wallet';

// 1. 제안 생성 (3명 중 2명)
const config = { threshold: 2, signers: [addr1, addr2, addr3] };
const dataHash = hashData('BAT-20260807-555');
let proposal = createProposal(dataHash, config, { description: '배터리 검사 투표' });

// 2. 서명 수집
proposal = await addSignature(proposal, pk1);  // 1/2
proposal = await addSignature(proposal, pk2);  // 2/2 → status: 'approved'

// 3. 검증
const result = await verifyMultisig(proposal);
// → { approved: true, progress: { current: 2, required: 2 }, validSignatures: [...] }

서명이 2개 모이면 status가 자동으로 approved로 바뀐다. verifyMultisig로 독립 검증까지 하면 온체인에 제출할 준비가 된다.

온체인 멀티시그와의 차이

오프체인 (이 구현)온체인 (Gnosis Safe 등)
서명 수집오프체인 (가스비 0)매 서명마다 TX (가스비 발생)
최종 실행결과만 TX 1건마지막 서명이 자동 실행
서명 보관애플리케이션 레벨컨트랙트 Storage
적합한 환경가스비 최소화, 빠른 합의자산 관리, 높은 보안 요구

프라이빗 체인에서는 가스비가 0이라 차이가 적지만, 퍼블릭 체인에서는 오프체인 방식이 비용 효율적이다. 대신 서명을 전달하는 통신 레이어(API 서버 등)를 별도로 구축해야 한다.

회고

멀티시그의 본질은 “한 사람이 못 하게 하는 것”이 아니라 “여러 사람이 동의해야 하는 것”이었다.

불변 객체 패턴은 이런 합의 구조에 잘 맞는다. addSignature가 원본을 수정하지 않으므로, 서명 수집 과정에서 중간 상태를 안전하게 추적할 수 있다. 각 서명자가 독립적으로 서명하고 결과를 합치는 흐름이 멀티시그의 탈중앙적 성격과 자연스럽게 일치한다.


이전 편: Wallet SDK 고도화 #4 — Nonce·Gas·TxQueue로 TX 안정성 확보


Share this post on:

Comments


Previous Post
검증 코드 한 줄의 값어치 — 엔트로피 0과 EIP-7702 스윕
Next Post
Wallet SDK 고도화 #4 — Nonce·Gas·TxQueue로 TX 안정성 확보