잔액은 충분한데, 왜 작은 송금도 실패했을까?
하나의 자산을 공통 원장에 기록하고 Native 프로그램과 EVM 양쪽에서 사용할 수 있게 해도, 사용자가 실제로 지출할 수 있는지는 더 확인해야 한다. 보내려는 금액은 작고 잔액도 충분한데, 그 잔액이 너무 많은 조각에 나뉘어 있다는 이유로 송금이 막힐 수 있기 때문이다.
2026년 9월 28일, nigo-protocol에서 이런 결함을 확인하고 수정했다. 같은 자산을 가진 한 소유자의 미사용 Cell이 257개가 되자 표준 경로의 잔액 조회와 작은 전송까지 실패했다. Cell은 소유자와 금액을 담은 원장의 상태 단위다. 여기서 257은 자산 종류나 거래 금액이 아니라, 한 소유자가 보유한 조각의 개수였다.
거래 한 번의 작업량을 제한하려던 규칙이, 사용자가 그동안 받은 자산의 누적 개수까지 제한하고 있었다. 이 글은 그 개발 기록을 돌아본다. 이후 보완 사항은 10월 19일까지 확인한 구현을 기준으로 구분한다.
1,000 X가 있는데 20 X를 보내지 못한다면
설명을 위해 앞선 글의 100 X 상황과는 별도의 지갑을 생각해 보자. A는 자산 X를 1,000개 가지고 있고 B의 잔액은 0이다. A의 보유분은 각각 2 X인 Cell 500개로 나뉘어 있다. A가 B에게 20 X를 보내려면 그중 10개만 소비하면 된다.
이 예제에서는 전송의 승인 조건이 충족되고, 추가 발행과 소각은 없다고 가정한다. 수수료 정책은 ZERO다. 따라서 아래 계산은 X의 입력과 출력만 다룬다. 수수료가 0이어도 실행량을 제한하는 gas, 입력 개수의 한도, 같은 거래를 다시 적용하지 못하게 하는 검증은 유지된다.
전송 후 A에게는 기존 Cell 490개, 합계 980 X가 남는다. B에게는 20 X를 담은 새 Cell이 생긴다. 두 사람의 합계는 여전히 1,000 X다. 이 작업에는 A가 가진 500개를 모두 소비할 이유가 없다.
그런데 수정 전의 표준 정책은 입력을 준비하면서 소유자의 Cell 전체를 조회했다. 이 조회는 “찾은 몇 개”를 반환하는 기능이 아니라 **“이 범위에 있는 것은 이것이 전부”**라고 확인하는 기능이었다. 최대 256개라는 한도가 있어서 257번째가 있으면 실패했다. 위 설명용 지갑도 필요한 10개를 고르기 전에 전체 조회 단계에서 막히는 셈이다.
500개라는 숫자는 원인을 설명하기 위한 모델이다. 실제 결함은 257개에서 재현했고, 수정 후에는 더 많은 조각을 가진 상태를 따로 검증했다.
잔액과 입력 목록은 서로 다른 답이다
해결의 중심은 조회 한도를 키우는 일이 아니었다. 지갑과 정책이 원장에 묻는 세 가지 질문을 나누는 일이었다.
| 질문 | 필요한 답 |
|---|---|
| 모두 합쳐 얼마인가? | 같은 실행 시점에서 계산한 정확한 잔액 |
| 이번 거래에 무엇을 쓸까? | 제한된 후보와 그중 실제로 소비할 입력 |
| 이 범위가 전부인가? | 빠진 항목이 없다는 완전한 범위의 증거 |
첫 번째 질문에는 파생 잔액 인덱스로 답한다. 원본 Cell의 금액을 자산과 소유자별로 합산해 유지하는 구조다. A의 Cell이 500개라고 해서 첫 256개의 합계인 512 X를 잔액으로 반환해서는 안 된다. 잔액은 정확히 1,000 X여야 한다.
두 번째 질문에는 ID 순서로 제한된 수의 후보를 가져온다. 위 예제에서는 그 안의 2 X짜리 Cell 10개를 선택하면 20 X를 마련할 수 있다. 후보를 받았다고 소비 권한이 생기지는 않는다. 실제 입력이 아직 사용 가능한지, 서명과 자산 정책이 그 소비를 허용하는지는 기존 검증 절차가 확인한다.
세 번째 질문은 그대로 남겼다. 어떤 정책은 특정 범위 전체를 보고 조건을 판단할 수 있다. 그때 일부만 돌려주면서 전체라고 취급하면, 존재하는 항목을 없는 것으로 판단하거나 빠진 금액을 놓치게 된다. 기존의 완전 범위 조회를 부분 조회로 몰래 바꾸지 않은 이유다.
표준 경로가 정확한 잔액과 제한된 선택을 사용하도록 바뀌었다고 해서, 임의의 커스텀 정책까지 모두 257개 이상을 처리하게 된 것은 아니다. 전체 조회를 요구하는 정책에는 그 계약과 한도가 계속 적용된다. 일부를 고르는 일과 전체를 증명하는 일은 서로 대신할 수 없다.
후보가 부족하다는 말은 잔액이 없다는 말이 아니다
보유 조각의 금액이 모두 같은 경우에는 입력 선택이 단순하다. 하지만 실제 지갑에서는 앞쪽 후보에 작은 조각이 몰리고 뒤쪽에 큰 조각이 있을 수 있다. 처음 가져온 후보의 합계가 필요한 금액보다 작아도, 지갑 전체의 잔액까지 부족하다고 결론 낼 수는 없다.
따라서 선택된 후보가 부족한 경우, 전체 잔액이 부족한 경우, 거래에 담을 입력 수가 한도를 넘는 경우를 구분해야 한다. 잔액 숫자만 확인한 뒤 같은 후보로 끝없이 재시도해서도 안 된다. 필요한 입력을 다시 찾거나, 허용된 전송으로 여러 작은 조각을 자신에게 모아 두는 방법이 필요하다.
이 자기 통합도 정상 거래다. 여러 입력의 합계를 같은 소유자에게 돌려주는 전송으로 처리하므로, 원래 필요했던 소유자 또는 정책의 승인이 그대로 필요하다. 관리자에게 남의 자산을 합칠 권한을 주거나, 정책이 요구하는 승인을 생략하지 않는다. 한 번에 처리하는 입력과 gas의 한도도 유지한다.
A가 20 X를 보낸 뒤 남은 980 X 중 일부를 통합하더라도 총액은 바뀌지 않는다. 달라지는 것은 다음 거래에서 필요한 입력의 개수다. 다만 계속되는 수신이나 동시 지출 아래에서 모든 큰 송금이 한 번에 성공한다고 보장할 수는 없다. 누적 보유 수 때문에 작은 지출까지 막히는 결함을 고치는 것과, 거래별 한도를 없애는 것은 다른 일이다.
정확한 잔액은 원본 변경과 함께 움직여야 한다
파생 잔액을 별도로 유지하면 조회가 쉬워지지만, 원본과 어긋나지 않게 만드는 책임이 생긴다. Cell을 소비하고 새로 만드는 변경과 잔액의 증감은 하나의 원자적인 저장 작업에 포함된다. 일부만 반영되는 상태를 허용하지 않는다.
같은 원칙은 실행 도중에도 필요하다. A의 20 X 전송을 실행하는 관점에서는 A 980 X와 B 20 X가 함께 보여야 한다. 그 자산 변경을 되돌리는 상황이라면 잔액 변화도 함께 취소돼야 한다. 거래를 준비할 때 관측한 잔액에 의존했다면, 최종 검증에서도 그 관측이 여전히 유효한지 확인한다. 서로 다른 시점의 입력 목록과 잔액을 섞어 판단할 수는 없다.
이 인덱스를 추가했다고 원장이 계정 잔액 모델로 바뀐 것은 아니다. 원본은 여전히 개별 Cell이고, 집계는 그 원본에서 계산되는 값이다. 잔액 조회 결과 자체를 거래 입력으로 소비할 수도 없다. “1,000 X가 있다”는 관측과 “이 Cell 10개를 소비한다”는 전송은 역할이 다르다.
원본에서 합계를 다시 계산해 인덱스와 대조하는 오프라인 검사·복구도 9월 29일 후속으로 구현했다. 이 도구는 거래 실행 중 전체 원장을 매번 훑거나 자동으로 수선하는 방식은 아니다. 파생 정보가 틀렸을 때 무엇을 기준으로 복구할지는 별도로 다룰 만한 주제다.
작은 송금이 다시 가능해졌는가
수정 후 검증에서는 일반 수신을 반복해 개인 계정과 EVM 계약 계정이 각각 513개의 Cell을 가진 상태를 만들었다. 그 뒤 정확한 잔액 조회, Native·EVM을 통한 부분 지출, 공개 통합 기능, 정상 재시작 후 상태 보존을 확인했다. RDB와 RocksDB 저장소, ZERO와 FIXED 수수료 정책에서 수행한 로컬 회귀·인수 결과다.
여기서 계약 계정을 함께 확인한 것이 중요했다. 개인이 직접 서명해 수동으로 입력을 골라 우회할 수 있다고 해도, 그런 인터페이스가 없는 계약의 문제까지 해결되지는 않는다. 사용자와 앱이 사용하는 정상 경로에서 다시 지출할 수 있어야 했다.
이 결함은 자산이 사라진 사건이 아니다. 원장에 있는 자산을 정상 경로에서 조회하거나 사용하는 일을 막은 가용성 결함이었다. 수정의 의미는 모든 정책과 운영 환경을 승인했다는 데 있지 않다. 누적 보유 조각 수와 이번 거래의 작업량을 분리하면서, 정확한 잔액과 기존 승인 규칙을 함께 지킨 데 있다.
공통 자산 모델과 실행 영역의 연결은 이제 “같은 자산을 사용할 수 있다”에서 “실제로 소비할 입력을 준비할 수 있다”로 이어진다. 그다음에는 실패의 위치와 그 뒤에 남는 효과를 더 세밀하게 봐야 한다. 입력을 준비하다 거절된 거래와, 블록에 포함된 뒤 실행에 실패한 거래에서 남는 효과는 같지 않을 수 있다. 자산 변경을 되돌렸다는 말이 수수료와 재사용 방지 상태까지 모두 되돌렸다는 뜻인지가 다음 질문이다.