본문으로 건너뛰기

하드포크 없이 소유기간을 기록하다: SecurityToken.sol 설계 리뷰

· 약 9분
Bankware Global Engineering

앞선 글에서는 한 시점의 잔액이 아니라 일정 기간의 소유 이력을 수익권과 표결권에 반영할 수 있을지 살펴봤다. 잔액이 달라진 지점만 체크포인트하고 그 사이의 면적을 더하면, 주소별 잔액 × 보유 블록 수를 계산할 수 있다는 아이디어였다.

그 글을 쓴 뒤 2023년의 코드를 다시 펼쳤다. 수식보다 먼저 존재했던 구현이었다.

nigo-protocol-v0SecurityToken.sol은 ERC-20 잔액과 함께 주소별 변경 이력을 저장하고, 정해진 블록 구간의 수익을 그 이력에 따라 청구하는 프로토타입이다. 질문은 이번에 훨씬 구체적이다.

소유기간 증명은 실제 스마트컨트랙트에서 어떤 상태로 표현됐고, 그 코드는 다음 설계에 무엇을 남겼을까?

이 소스는 2023년 5월에 작성된 역사적 프로토타입이다. 이 글은 commit af4cb29SecurityToken.sol을 기준으로 읽었다. 저장소에는 이 컨트랙트 전용 테스트와 migration이 없고, 공개 배포 주소나 보안감사 자료도 확인되지 않는다. 따라서 프로덕션에서 검증된 코드로 보지 않는다. 현재 Nigo Protocol에 탑재된 기능을 설명하는 글도 아니다. 컨트랙트의 SecurityToken이라는 이름과 법적 증권성 역시 별개다.

매 블록이 아니라 변화가 생긴 블록을 적는다

이 구현을 “블록마다 잔액을 저장한다”고 요약하면 정확하지 않다. 실제로 저장하는 것은 잔액이 바뀐 지점이다.

외부 mint와 transfer 경로는 먼저 잔액을 갱신하고, 그 주소에 현재 블록 높이와 변경 후 잔액을 남긴다. 내부 _burn도 같은 기록 함수를 호출하지만, 현재 소스에는 이를 호출하는 외부 함수가 없다. 원문의 상태 모델을 의사코드로 옮기면 다음과 같다.

onBalanceChanged(holder, newBalance):
balances[holder] = newBalance
balanceAt[holder][currentBlock] = newBalance
changedBlocks[holder].append(currentBlock)

주소 A의 체크포인트가 (10, 100), (20, 40)이라면 10번부터 19번 블록까지 100개, 20번 블록부터 다음 변경 전까지 40개를 보유한 것으로 재구성한다. 모든 블록의 복사본을 만들 필요가 없다. 저장량은 체인의 전체 블록 수가 아니라 그 주소의 잔액 변경 횟수에 비례한다.

mint / transfer / internal _burn
|
v
balance update
|
v
(block height, new balance) checkpoint
|
v
income period와 교집합 계산
|
v
holder claim

이 선택에는 의미론이 숨어 있다. 체크포인트는 해당 트랜잭션이 처리된 뒤의 잔액이다. 같은 블록에서 한 주소의 잔액이 여러 번 바뀌면 어느 값을 그 블록의 보유량으로 볼지 정해야 한다. 블록 끝의 최종 상태만 쓸 것인지, 트랜잭션 순서까지 구분할 것인지는 단순한 자료구조 문제가 아니라 권리 계산의 규칙이다. 블록 높이도 벽시계 시간이 아니라 원장의 순서다. 일 단위나 월 단위로 발생한 수익을 나누려면 체인마다 다른 블록 간격을 어떻게 실제 시간과 연결할지도 정해야 한다.

수익 기간과 보유 구간의 교집합

수익을 예치할 때 프로토타입은 네 가지 값을 기록한다.

Income
- startBlock
- endBlock
- amountPerBlock
- totalSupply

예치액을 양 끝을 포함한 블록 수로 나누어 블록당 수익을 정한다.

amountPerBlock
= floor(depositedAmount / (endBlock - startBlock + 1))

보유자가 청구하면 자신의 체크포인트를 잔액이 일정한 구간으로 바꾼다. 각 구간과 수익 캠페인이 겹치는 블록 수를 구한 뒤 다음 값을 더한다.

segmentIncome
= floor(
balanceAtSegment
* amountPerBlock
* overlappingBlockCount
/ campaignTotalSupply
)

예를 들어 한 주소가 10번 블록부터 100개를 보유하다가 20번 블록부터 40개만 보유했다고 하자. 수익 캠페인은 15번부터 24번 블록까지이고, 블록당 수익은 10, 기준 총발행량은 100이다.

15~19 100 * 10 * 5 / 100 = 50
20~24 40 * 10 * 5 / 100 = 20
total 70

원본 소스는 [startBlock, endBlock]처럼 양 끝을 포함하는 폐구간을 사용한다. 앞선 글은 구간 경계를 더 쉽게 합성하기 위해 반열린 구간을 사용했다. 두 표기는 다음처럼 같은 기간을 가리킬 수 있다.

source [S, E] == previous article [S, E + 1)

어느 표기가 더 옳은 것이 아니라, 저장과 계산이 하나의 경계 규칙을 끝까지 지키는지가 중요하다.

수익 분배는 pull 방식이다. 지급자가 모든 주소를 열거해 한 번에 보내는 대신, 보유자가 자신의 이력을 바탕으로 나중에 청구한다. 지급 자산도 SecurityToken 자체로 고정하지 않고 별도의 ERC-20으로 받을 수 있게 했다.

보조 컨트랙트인 SecurityTokenPublisher.sol은 지정된 승인자의 확인을 거쳐 토큰을 배포하고 청약 비율에 따라 초기 물량을 배정하지만, 소유기간 계산의 핵심은 SecurityToken.sol에 있다. 앞선 글에서 제안한 기간가중 표결권과 기여도 장부도 이 코드가 구현한 기능은 아니다.

하드포크 없이 어디까지 가능했나

이 프로토타입의 가장 큰 장점은 특별한 합의 알고리즘이 필요하지 않았다는 점이다.

사용한 것은 EVM의 일반 저장소, block.number와 ERC-20 호출이다. 새로운 opcode를 추가하거나 기존 체인의 합의 규칙을 바꾸지 않아도, 토큰의 전송 경로에 체크포인트를 넣고 기간가중 수익을 계산할 수 있었다. EVM 호환 체인이라면 같은 상태 모델을 새로운 스마트컨트랙트로 실험할 수 있다.

이것은 애플리케이션 계층에서 실험할 때의 중요한 설계상 이점이다. 체인의 운영자와 합의 참여자 전체를 설득하기 전에 권리 모델을 만들고, 작은 범위에서 동작과 수요를 검증할 수 있기 때문이다. 상품마다 수익 기간과 계산 정책을 바꾸기도 쉽다.

하지만 “기존 체인을 하드포크하지 않아도 된다”와 “모든 기존 토큰에 바로 적용된다”는 같은 말이 아니다. 이 체크포인트는 해당 로직을 포함해 새로 발행한 토큰에서만 자연스럽게 쌓인다. 이미 배포된 ERC-20은 업그레이드, 이주 또는 래핑 같은 통합이 필요하고, 이 컨트랙트만으로 배포 전의 과거 이력을 신뢰 없이 소급 생성할 수는 없다.

체크포인트가 보여주는 것도 이 컨트랙트 주소 장부의 잔액이다. 수탁사가 내부 장부에서 나눈 고객별 지분, 법적 실소유자, 담보와 파생상품을 합친 경제적 노출까지 자동으로 증명하지 않는다. 여기서 proof는 새로운 암호학적 증명이 아니라, 온체인에 기록된 체크포인트로 수량 가중 보유시간을 감사 가능하게 재구성한다는 의미다.

아이디어를 코드로 옮기는 것과 돈을 지키는 것 사이

아이디어를 상태로 옮겼다고 정산 프로토콜이 완성되는 것은 아니다. 실제 자금을 다루려면 계산식 주변의 생명주기와 비용에도 불변식이 필요하다.

한 캠페인의 지급 합계는 실제 예치액을 넘지 않는다.
청구 상태는 언제나 다음 미처리 위치를 가리킨다.
종료 전 청구는 금지되거나 부분 청구 진행 상태가 보존된다.
동일 블록의 여러 잔액 변경은 하나의 명시된 규칙으로 합쳐진다.
외부 토큰 호출 실패는 성공한 정산으로 기록되지 않는다.
한 번의 청구가 처리하는 작업량에는 상한이 있다.

2023년 소스를 이 기준으로 읽으면 프로덕션 전에 다시 설계할 부분이 보인다.

첫째, 청구 커서는 “마지막으로 본 캠페인”이 아니라 “다음에 처리할 캠페인”을 명확히 가리켜야 한다. 종료된 캠페인만 한 번 청구하게 하거나, 부분 청구를 허용한다면 캠페인별로 어디까지 지급했는지 저장해야 한다. 예치액, 누적 지급액과 남은 금액도 하나의 회계로 묶어야 중복과 누락을 막을 수 있다.

둘째, 같은 주소에 같은 블록 번호가 반복될 때의 처리가 필요하다. 블록 끝 잔액을 정책으로 택한다면 같은 블록의 마지막 체크포인트를 덮어쓰는 편이 자연스럽다. 더 세밀한 시간을 권리로 인정하려면 트랜잭션 순서나 별도의 시간 단위를 상태 모델에 포함해야 한다.

셋째, 수익 캠페인의 분모는 단순한 입력값이어서는 안 된다. 기간 중 mint와 burn이 가능하다면 총발행량도 체크포인트하고 잔액과 같은 구간에서 지분율을 계산해야 한다. 블록당 나눗셈과 보유자별 나눗셈에서 생기는 dust를 누가 회수하거나 다음 캠페인으로 넘길지도 정책으로 정해야 한다.

넷째, 지급 토큰은 외부 컨트랙트다. 호출 성공 여부와 실제 수령액을 확인하고, 상태를 먼저 확정한 뒤 상호작용하며, 재진입을 막아야 한다. 누가 수익 캠페인을 만들 수 있는지와 잘못된 캠페인을 어떻게 중단할지도 권한 모델에 포함된다.

마지막은 비용이다. 현재 형태의 청구 작업량은 대략 다음에 비례한다.

claim work ~= unprocessed income campaigns * holder checkpoints

보유 이력이 길고 캠페인이 많아질수록 한 번의 청구가 블록 가스 한도를 넘을 수 있다. pull 방식은 지급자가 전체 보유자를 열거하지 않게 해주지만, 각 보유자의 계산 비용까지 자동으로 제한하지는 않는다.

이 평가는 2023년 코드를 현재의 완성품처럼 심사하려는 것이 아니다. 프로토타입은 아이디어를 가장 작은 상태 모델로 옮겨 구현 가능성을 확인하고, 다음 구현이 풀어야 할 문제를 드러내는 역할을 했다.

같은 아이디어를 컨트랙트에서 더 단단하게 만든다면

앞선 글에서는 체크포인트 직접 순회, 누적 면적과 오프체인 계산의 trade-off를 비교했다. 2023년 소스는 그중 보유자별 체크포인트를 청구 때 직접 순회하는 가장 단순한 형태였다. 새 체인을 만들기 전에 스마트컨트랙트만으로도 상당 부분을 개선할 수 있다.

체크포인트마다 단순 잔액뿐 아니라 직전 변경점까지 누적된 balance-blocks를 함께 저장하면, 공급량이 고정된 캠페인에서는 긴 구간을 처음부터 끝까지 매번 순회하지 않고 양 끝의 누적값 차이로 계산할 수 있다. 정렬된 체크포인트를 이진 탐색하고, 같은 블록의 변경은 하나로 합친다.

공급량이 변한다면 주소의 누적 보유량과 누적 총발행량을 각각 구해 나누는 것만으로는 시간가중 지분율이 되지 않는다. 잔액과 총발행량의 변경 구간을 병합해 balance / eligibleSupply를 구간별로 적분하거나, 보상이 발생하는 동안 전역 reward-per-unit 누적 인덱스를 갱신하는 방식을 설계해야 한다.

청구 상태는 nextClaimIndex처럼 다음 미처리 위치로 정의하고, 한 번에 처리할 캠페인 수를 제한한다. 종료된 캠페인만 청구하거나 부분 청구 높이를 별도로 보존한다. 안전한 ERC-20 전송, 실제 입금액 기준의 회계, checks-effects-interactions, 예치·지급·dust 불변식과 재현 가능한 테스트도 필요하다.

오프체인 인덱서와 Merkle distribution도 대안이다. 컨트랙트, 인덱서와 원장 중 어느 계층을 택하든 복잡성이 사라지는 것이 아니라 계산 재현성, 이의제기 또는 공통 인프라 운영 같은 다른 책임으로 이동한다.

컨트랙트로 가능했기 때문에 보인 컨트랙트의 비용

스마트컨트랙트 프로토타입은 하드포크 없이 아이디어를 시험하게 해줬다. 동시에 소유 이력을 모든 자산이 계속 사용할 공통 재료로 본다면 애플리케이션 계층의 반복도 눈에 들어온다.

항목컨트랙트만 구현원장 기본 기능
도입기존 EVM에 새 토큰으로 배포 가능프로토콜 변경 또는 새 네트워크 필요
정책자산별로 빠르게 바꿀 수 있음범용적이고 보수적인 규칙이 필요
저장토큰마다 같은 체크포인트를 반복 저장공통 primitive로 최적화할 여지가 있음
조회컨트랙트별 계산·인덱싱을 설계표준 historical query와 proof를 제공할 수 있음
실패 범위해당 컨트랙트에 주로 한정합의와 모든 자산에 영향을 줄 수 있음

전송 한 번마다 토큰 컨트랙트의 영구 storage가 늘고, 토큰이 많아질수록 같은 기록 구조도 반복된다. 블록 내부의 어느 시점을 권리 계산 기준으로 삼을지, 최종 확정된 블록만 인정할지, 오래된 이력의 보존과 pruning 이후 조회를 어떻게 처리할지도 각 애플리케이션이 정해야 한다.

그렇다면 원장은 무엇을 기본 기능으로 제공할 수 있을까. 자산별 과거 잔액과 총발행량 체크포인트, 누적 기간가중 값, finalized height를 기준으로 한 조회, 이력을 정리한 뒤에도 검증할 수 있는 proof가 후보가 된다. 같은 블록에서 여러 전송이 일어날 때 end-of-block 상태를 쓸지, 누가 장기 이력의 저장 비용을 부담할지도 합의 규칙과 API의 일부가 된다.

물론 이 기능 하나가 새로운 블록체인을 필연적으로 요구하는 것은 아니다. 더 나은 컨트랙트, 인덱서와 Merkle distribution, precompile 또는 특정 목적의 appchain도 대안이다. 네이티브 지원은 애플리케이션의 복잡성을 없애는 대신 체인 구현, 검증, 업그레이드와 장기 호환성이라는 더 큰 책임을 가져온다.

한 컨트랙트가 다음 질문을 만들었다

시작은 토큰의 잔액이 바뀔 때 블록 높이와 새 잔액을 함께 적어두는 작은 상태 모델이었다. 컨트랙트만으로 기간가중 계산을 표현한 순간, 질문은 구현 가능성에서 공통 인프라로 옮겨갔다. 모든 토큰이 같은 이력을 반복 저장하지 않고, 원장이 검증 가능한 과거 잔액 조회를 공통으로 제공한다면 무엇이 달라질까.

이 문제의식은 소유기간 같은 응용 이력을 원장 계층에서 지원할 수 있는 블록체인을 직접 만들어보자는 생각으로 이어졌고, 훗날 Nigo Protocol을 구상한 여러 계기 중 하나가 됐다. 이 글은 현재 Nigo의 구현 범위나 제품 일정을 설명하는 것이 아니라 그 설계 질문의 출발점을 기록한다.

소유의 시간이 권리의 재료라면, 그것을 이해하는 원장은 어떤 모습이어야 할까?