ERC-5805 위임 기반 투표권 추적 표준 (Voting with delegation)
Review · 토큰 · 2022-07-04 작성
토큰 보유자가 토큰을 넘기지 않고 투표권만 다른 계정에 위임하고 과거 특정 시점의 투표권을 조회할 수 있게 하는 표준입니다.
왜 필요한가
많은 탈중앙 자율 조직(DAO)은 토큰 수량으로 투표권을 정합니다. 그런데 대부분의 토큰이 제공하는 잔액 조회(balanceOf)만으로 투표를 처리하면 두 가지 문제가 생깁니다. 첫째, 잔액이 시점별로 기록되지 않아서 한 사람이 투표한 뒤 토큰을 다른 계정으로 옮겨 같은 토큰으로 또 투표할 수 있습니다. 둘째, 토큰 소유권을 통째로 넘기지 않고는 투표권만 다른 사람에게 맡길 방법이 없습니다. 그래서 컴파운드 등 여러 프로젝트가 위임과 시점 기록(체크포인트)을 갖춘 투표 토큰을 각자 방식으로 만들어 왔지만 표준이 없었습니다. 또 기존 구현은 블록 번호 기준 기록만 지원해서 블록 간격이 일정하지 않은 레이어2 같은 체인에서는 문제가 됩니다. 이 표준은 위임과 투표권 조회 인터페이스를 통일하고 블록 번호든 타임스탬프든 줄어들지 않는 어떤 시간 기준이든 쓸 수 있게 합니다.
구현해야 하는 것
event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate);: 어떤 계정의 위임 대상이 바뀔 때 발생하는 이벤트입니다. delegate나 delegateBySig로 위임이 수정되면 반드시 발생합니다.event DelegateVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance);: 위임받은 계정의 투표권이 바뀔 때 발생하는 이벤트입니다. 위임 변경이나 토큰 전송으로 투표권이 변하면 발생합니다.function getVotes(address account) external view returns (uint256);: 해당 계정이 지금 가진 투표권을 반환합니다. 호출 시점에 그 계정에 위임된 투표권 전부입니다.function getPastVotes(address account, uint256 timepoint) external view returns (uint256);: 과거 특정 시점에 그 계정이 가졌던 투표권을 반환합니다. 시점 인자는 컨트랙트의 시계 모드와 같은 단위여야 합니다.function delegates(address account) external view returns (address);: 해당 계정의 투표권이 현재 누구에게 위임되어 있는지 그 주소를 반환합니다.function nonces(address owner) public view virtual returns (uint256): 서명 위임의 재사용을 막기 위한 계정별 논스 값을 반환합니다. delegateBySig가 실행될 때마다 1씩 올라갑니다.function delegate(address delegatee) external;: 호출한 계정의 위임 대상을 바꾸고 투표권 기록을 갱신합니다.function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) external;: 직접 트랜잭션을 보내지 않고 EIP-712 서명으로 위임을 바꿉니다. 서명자의 논스와 만료 시각이 맞아야 실행됩니다.
심층 설명
위임 메커니즘
각 계정은 자신, 제3자, 또는 address(0) 중 하나를 위임 대상으로 지정할 수 있습니다. 보유 자산은 위임되기 전에는 투표권으로 쓸 수 없습니다. 위임자가 위임하면 그 잔액이 대리인의 투표권에 더해지고 위임을 바꾸면 이전 대리인에게서 빼서 새 대리인에게 더합니다. 토큰이 전송되면 보내는 쪽 대리인의 투표권에서 차감하고 받는 쪽 대리인에게 더해야 합니다. address(0)에 위임된 토큰은 추적하지 않으므로, 사용자는 대리인 체크포인트 갱신을 건너뛰어 전송 가스비를 아낄 수 있습니다. 토큰은 한 시점에 최대 한 명에게만 위임되므로 중복 투표가 방지됩니다.
시계(clock)와 ERC-6372
표준을 따르는 컨트랙트는 ERC-6372(컨트랙트 시계)를 구현해 투표 추적에 쓰는 시간 기준을 알리는 것이 권장됩니다(SHOULD). ERC-6372를 구현하지 않으면 CLOCK_MODE가 mode=blocknumber&from=default를 반환한 것과 똑같이 블록 번호 시계로 동작해야 합니다(MUST). 메인넷에서는 블록 번호가 기존 앱과의 호환성이 좋고 타임스탬프는 사용자가 이해하기에 의미가 더 나으며 초 단위 기간 표현에 맞습니다. 거버너 같은 외부 컨트랙트는 clock() 값을 읽어 토큰 체크포인트와 일관성을 유지할 수 있습니다.
getVotes와 getPastVotes의 규칙
getVotes(account)는 호출 시점에 그 계정에 위임된 전체 투표권을 반환합니다. getPastVotes(account, timepoint)는 과거 시점의 투표권을 반환하며 timepoint는 컨트랙트의 동작 모드와 일치해야 합니다(MUST). 현재 시계 이상의 시점으로 호출하면 되돌려야(revert) 하고 현재 시계보다 작은 시점은 되돌리지 않아야 하며 한 번 반환된 과거 값은 이후 언제 다시 호출해도 같아야 합니다(모두 SHOULD). address(0)에 위임된 토큰은 집계하지 않으므로 getVotes(0)과 getPastVotes(0,x)는 항상 0을 반환해야 합니다. ERC-6372 시계는 uint48이지만 getPastVotes의 인자는 하위 호환을 위해 uint256이고 2**48 이상의 시점은 미래 조회이므로 되돌려야 합니다.
delegateBySig와 EIP-712 서명
delegateBySig(delegatee, nonce, expiry, v, r, s)는 Delegation(address delegatee,uint256 nonce,uint256 expiry) 타입의 EIP-712 서명으로 서명자의 위임을 바꿉니다. 현재 타임스탬프가 expiry 이하이고 nonces(signer)가 nonce와 같을 때만 실행되며 조건이 안 맞으면 반드시 되돌려야 합니다. 성공하면 서명자의 논스를 1 올리고 DelegateChanged 이벤트(그리고 필요 시 DelegateVotesChanged)를 발생시킵니다. 함수 호출자는 누구든 될 수 있어 msg.sender를 참조하지 않습니다. 이 함수 덕분에 가스비를 내고 싶지 않은 보유자도 서명만으로 위임할 수 있고 nonces 매핑이 재사용(replay)을 막습니다. DOMAIN_SEPARATOR는 다른 도메인에서의 재사용 공격을 막도록 컨트랙트와 체인별로 고유해야 합니다.
이벤트 발생 조건
DelegateChanged는 delegate(address)나 delegateBySig로 계정의 위임 대상이 수정될 때 반드시 발생해야 합니다(MUST). DelegateVotesChanged는 두 경우에 반드시 발생해야 합니다. 자산을 0개보다 많이 가진 계정이 해당 대리인에게로 또는 대리인에게서 위임을 바꿀 때, 그리고 그 대리인에게 위임된 계정으로 또는 그 계정에서 자산이 전송될 때입니다. 이벤트에는 이전 투표권(previousBalance)과 새 투표권(newBalance)이 담깁니다.
지켜야 할 불변 속성
현재 시계를 clock이라 할 때, 0이 아닌 모든 계정 a에 대해 getVotes(a)는 a에게 위임한 모든 계정의 잔액 합이어야 합니다(SHOULD). 과거 시점 t에 대한 getPastVotes(a, t)는 시계가 t를 지난 시점에 a에게 위임돼 있던 계정들의 잔액 합이어야 하고 t가 과거가 된 뒤에는 값이 변하지 않아야 합니다(MUST). 위임을 b에서 c로 바꾸는 행동은 b의 현재 투표권을 늘리면 안 되고 c의 현재 투표권을 줄이면 안 됩니다(MUST).
기존 구현과의 호환성
컴파운드와 오픈제플린이 이미 투표 토큰 구현을 제공하며 위임 관련 메서드는 두 구현과 이 표준에서 같습니다. 투표권 조회는 오픈제플린 방식(반환 타입 uint256)을 따르는데, 컴파운드 구현은 반환 타입이 uint96이라 허용 값에 큰 제약이 생기기 때문입니다. 두 구현 모두 블록 번호 체크포인트를 쓰고 ERC-6372를 구현하지 않는데, 이는 이 표준과 호환됩니다. 오픈제플린 구현과 호환되는 기존 거버너는 이 표준의 블록 번호 모드와 호환됩니다. 참고로 이 제안은 아직 검토(Review) 상태라 내용이 바뀔 수 있습니다.
쓸 때 주의할 점
- 시계 모드 확인 후 조회: 조회 전에 clock()의 반환값을 확인해 조회 인자가 일관된지 확인해야 합니다. 블록 번호를 쓰는 컨트랙트에 타임스탬프 인자로 조회하면 되돌려질 가능성이 높고 반대의 경우에는 0이 반환되기 쉽습니다.
- 서명 위임의 선점 실행: Delegation 서명자가 특정 상대가 트랜잭션을 제출하길 기대하더라도, 다른 당사자가 먼저 delegateBySig를 호출할 수 있습니다. 다만 서명자 입장에서 결과는 같습니다.
- ecrecover의 조용한 실패: ecrecover 프리컴파일은 잘못된 메시지를 받으면 실패 대신 0 주소를 반환합니다. signer != address(0)을 확인해 0 주소에 속한 자금이 위임되는 것을 막아야 합니다.
- 서명 위임의 검열 가능성: 서명을 받은 중계자가 제출하지 않고 보류할 수 있습니다. expiry 매개변수가 한 가지 완화책이고 서명자가 ETH를 갖고 있다면 직접 제출해 이전 서명을 무효화할 수 있습니다.
- 체인 분기 시 재사용 위험: DOMAIN_SEPARATOR에 chainId를 넣되 매 서명마다 다시 만들지 않고 배포 시점에 고정하면, 향후 체인이 분기할 때 체인 간 재사용 공격 위험이 있습니다.