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] }`.
검증이 세 가지 있다:
- threshold는 1 이상, 서명자 수 이하
- 서명자 중복 불가
- 기본 만료 시간 1시간 (ttlMs로 조정 가능)
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시간 지나면 거부
- 승인 완료 확인 — 이미 approved면 추가 서명 불가
- 서명자 검증 —
recoverMessageAddress로 서명에서 주소를 복원하고, config.signers에 포함된 주소인지 확인 - 중복 방지 — 같은 주소로 두 번 서명 불가
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가 원본을 수정하지 않으므로, 서명 수집 과정에서 중간 상태를 안전하게 추적할 수 있다. 각 서명자가 독립적으로 서명하고 결과를 합치는 흐름이 멀티시그의 탈중앙적 성격과 자연스럽게 일치한다.