EVM: 함수 호출은 어떻게 같은 원장의 상태 변경이 될까?
A가 가진 토큰 100 중 20을 B에게 보내려 한다. A는 S에게 30까지 사용할 권한을 주었고, 이번 거래는 S가 보낸다. 끝나면 A에게 80, B에게 20이 남고, S가 더 사용할 수 있는 한도는 10이다.
이 결과를 거래에 미리 적는 방법이 있다. 사용할 기록과 새로 남길 기록을 준비한 뒤, 그 변경이 허용되는지 프로그램에 검사받는 것이다. Native Program의 변경안 검증에서 살펴본 방식이다.
그런데 일반적인 컨트랙트 호출에서는 S가 변경 후 잔액과 한도를 출력으로 적지 않는다.
토큰 컨트랙트에 transferFrom(A, B, 20)을 실행해 달라고 요청한다.
현재 값을 읽고 다음 값을 만드는 일은 노드에서 실행하는 코드가 맡는다.
구체적인 변경안을 보내지 않았는데도, 함수 호출은 어떻게 같은 원장의 상태 변경이 될까? nigo-protocol의 세 번째 실행 경로인 EVM을 호출 하나의 안쪽에서 살펴보자.
요청에는 결과 대신 실행할 함수를 담는다
예제의 토큰 컨트랙트를 T라고 하자. T는 주소별 잔액과 소유자가 다른 사용자에게 허용한 사용 한도를 자신의 저장소에 기록한다. S는 토큰의 소유자가 아니라, A가 허용한 범위에서 토큰을 보낼 수 있는 사용자다.
이번 예제는 앞선 글의 Native Asset을 EVM으로 옮기는 거래가 아니다. 같은 이전 업무를 별도 토큰 컨트랙트의 저장값으로 표현한 경우다. 앞 글에서 거래한 뒤 다시 20을 보내는 것도 아니며, 다음 상태에서 새로 출발한다.
T에 저장된 현재 값
A의 잔액: 100
B의 잔액: 0
A가 S에게 허용한 한도: 30
S가 서명할 요청의 업무 내용
호출 대상: T
실행할 함수: transferFrom
함수 입력: A, B, 20
transferFrom은 승인받은 사용자가 다른 사람의 토큰을 이전하는 함수다.
여기서는 사용량만큼 승인 한도를 줄이는 단순한 T를 가정한다. 별도의 전송 수수료나 발행·소각은 없고,
이 함수가 다른 컨트랙트를 호출하지도 않는다. 프로토콜 수수료 금액은 0으로 두되 실행량 한도는 적용한다.
토큰 20의 이전과 별개로 호출에 첨부하는 기본 코인 수량도 0이다.
실제 요청에는 서명과 실행량 한도 등 처리에 필요한 정보도 담긴다. 위 그림은 그중 업무 요청만 보여준다. S가 정한 것은 대상과 함수 입력이며, A=80, B=20, 한도=10이라는 변경 후 값을 거래의 출력 셀로 지정한 것은 아니다.
잔액과 한도는 실행 시점에 계산한다
EVM은 컨트랙트 코드를 실행하는 가상 머신이다. 노드는 T의 코드를 불러오고, 이번 호출의 서명자 S와 함수 입력을 전달해 실행한다. T의 함수는 다음 순서로 동작한다고 가정하자.
- A의 잔액 100과 A가 S에게 허용한 한도 30을 읽는다.
- 잔액과 한도가 모두 보낼 수량 20 이상인지 검사한다.
- B의 잔액 0을 읽고, 새 잔액과 남은 한도를 계산한다.
- A의 잔액 80, B의 잔액 20, 남은 한도 10을 저장하고 이전 이벤트를 남긴다.
결과는 처음의 목표와 같다. 토큰 잔액의 합계는 100 + 0 = 80 + 20이고,
사용 한도는 30 - 20 = 10이다. 사용 한도 30은 별도로 보유한 토큰이 아니므로 잔액 합계에 더하지 않는다.
이 계산에 쓰이는 값은 서명자가 요청을 준비할 때 본 화면의 숫자가 아니라 해당 거래를 실행하는 시점의 상태다. S가 요청을 준비한 뒤 앞선 거래에서 한도가 10으로 줄었다면, 같은 20의 이전 요청도 T의 검사를 통과하지 못한다.
함수 호출을 미리 실행해 결과를 예상하는 일은 가능하다. 다만 그 결과는 선택한 상태와 실행 환경을 전제로 한다. 요청에 함수 입력을 고정하는 것과, 그 함수가 읽을 현재 값까지 고정하는 것은 다르다. 잔액·한도·기한 같은 조건을 어디까지 검사할지는 컨트랙트 코드와 요청의 설계에 달려 있다.
컨트랙트 저장소도 StateCell로 표현한다
T의 코드에는 주소별 잔액과 승인 한도가 보인다. EVM이 실제로 다루는 저장소에서는 이 값들이 슬롯이라는 저장 위치에 놓인다. 슬롯은 컨트랙트 저장소 안에서 값을 찾기 위한 주소라고 보면 된다. 아래는 실제 슬롯 번호 계산을 생략한 개념도다.
T의 코드가 다루는 값
A의 잔액
↓
EVM 저장 위치
컨트랙트 T + 해당 슬롯
↓
nigo-protocol의 기록
그 위치에 대응하는 StateCell
값: 100 → 80
nigo-protocol은 컨트랙트 주소와 슬롯으로 해당 저장 위치의 셀을 찾는다. 읽기에서는 그 셀의 값을 돌려주고, 쓰기에서는 같은 저장 위치에 대응하는 셀의 값을 교체한다. 예제에서는 A의 잔액뿐 아니라 B의 잔액과 승인 한도에 해당하는 위치도 함께 바뀐다.
여기에는 Direct Cell과 중요한 차이가 있다. Direct Cell 거래는 사용할 입력 셀과 새 출력 셀을 명시하며, 입력과 구별되는 새로운 출력 ID를 만든다. EVM 저장소는 같은 컨트랙트·슬롯에서 같은 Cell ID를 유지하며 그 위치의 값을 교체한다. StateCell이라는 기록 형식이 같다고 모든 변경이 동일한 UTXO 거래 형식인 것은 아니다.
또한 “A의 잔액”이라는 값이 들어 있어도 그 저장 셀은 T의 저장소에 속한다. A의 주소는 T가 잔액을 구별하는 데 쓰는 업무 데이터이며, A가 그 셀을 Direct Cell 거래로 직접 바꿀 권한을 뜻하지 않는다. EVM 상태 영역은 일반 Direct Cell이나 Native Program 변경안으로 덮어쓸 수 없고, 허용된 EVM 실행 경로가 관리한다.
StateCell의 소유자에서 구분한 상태의 관리 주체와 업무상 권리자가 여기서도 다르게 나타난다. 공통 기록 형식은 두 역할을 하나로 합치기 위한 장치가 아니라, 서로 다른 실행 규칙의 결과를 같은 원장에 담는 기반이다.
토큰 규칙과 원장 규칙은 누가 맡을까
T의 저장값이 잔액인지, 승인 한도인지, 투표 수인지는 T의 코드가 해석한다. EVM은 그 코드의 명령을 실행하고, nigo-protocol의 실행 계층인 Core는 허용된 상태 변경과 거래 처리 규칙을 집행한다.
예제에서 A의 잔액을 20 줄이고 B의 잔액을 20 늘리는 규칙은 T에 있다. Core가 모든 EVM 저장값을 Native Asset의 수량으로 해석해 합계를 검사하는 것은 아니다. 컨트랙트가 잘못 작성되어 A에서는 20을 빼고 B에는 25를 더하더라도, 그 업무상 오류를 EVM의 명령 실행 규칙만으로 찾아낼 수는 없다.
반대로 컨트랙트가 정한다고 모든 원장 규칙을 바꿀 수 있는 것은 아니다. 실행량 한도, 허용된 상태 접근, 프로토콜 기본 코인의 가치 이전과 수수료 같은 공통 규칙은 프로토콜의 실행 계층이 적용한다. T의 토큰 잔액과 프로토콜 기본 코인의 회계를 같은 것으로 취급하지 않는다.
이 구분은 EVM 경로를 함께 두는 이유이기도 하다. 개발자는 주소별 잔액이나 여러 조건을 저장소와 함수로 표현하고, 호출자는 함수 입력을 준비한다. 변경안을 미리 모두 구성하는 대신, 저장소를 읽으며 다음 상태를 계산하는 개발 방식을 사용할 수 있다. 그만큼 업무 규칙의 정확성은 해당 컨트랙트가 책임져야 한다.
자산 정책과 기존 승인에서 살펴본 Native Asset의 정책 교체 규약도 일반 EVM 토큰의 저장소에 자동 적용되는 규칙은 아니다. T의 코드를 바꿀 수 있는지, 바꾼 뒤 기존 승인 값을 어떻게 해석할지는 T가 채택한 별도의 설계를 살펴야 한다.
계산 도중의 값과 원장에 남는 값을 나눈다
T가 A의 잔액을 80으로 기록한 순간 B의 잔액이 아직 0이라면 어떨까? 업무가 끝나기 전의 중간 값을 그대로 최종 원장에 남겨서는 안 된다.
운영 실행 경로는 컨트랙트의 상태 변경을 임시 작업 공간에 모은다. EVM 실행과 뒤따르는 전이 검사를 통과하면 변경을 반영하고, 실행 결과 기록인 receipt를 만든다. receipt에는 성공·실패와 실행량, 이벤트 등 실행 결과가 담긴다. 이 결과를 블록에 모으고 합의로 확정해 영속 저장하는 일은 그 뒤의 공통 원장 처리 과정이다.
예제의 최상위 호출이 성공하면 A=80, B=20, 한도=10이라는 업무 변경이 함께 남는다.
계산 도중 호출 전체가 REVERT로 실패한다면 그 호출의 저장소 변경과 이벤트는 버린다.
출발 상태가 A=100, B=0, 한도=30이었다면 토큰의 업무 상태도 그 값으로 돌아간다.
이것이 실패한 거래의 모든 흔적이 사라진다는 뜻은 아니다. 블록에 포함된 실행 실패의 결과 기록과 수수료, 거래 형식별 재실행 방지 상태는 별도 규칙을 따른다. 여기서는 컨트랙트의 업무 변경을 취소하는 범위와 거래 자체를 처리하는 범위가 다르다는 점까지만 구분하자.
같은 EVM 실행기를 써도 Native Program과 역할은 다르다
앞선 Native Program 글에서도 Solidity와 EVM이 등장했다. 두 경로가 같은 코드 실행기를 사용한다면, 왜 실행 방식을 구별할까?
이 시리즈에서 다룬 Native Program의 변경안 검증은 Core가 전달한 입력·출력과 맥락을 검사한다. 검증 프로그램이 원장의 임의 저장소를 읽고 쓰거나 다른 컨트랙트를 호출해 다음 상태를 만드는 방식은 허용하지 않는다. 사용할 기록과 변경 결과는 거래를 구성하는 쪽에서 준비한다.
일반 EVM 호출에서는 코드가 저장소를 읽고 쓰며, 필요하면 다른 컨트랙트를 호출한다. 예제의 T가 잔액과 한도를 직접 읽고 갱신하는 일이 여기에 해당한다. 호출자는 함수 입력을 주고, 실행하는 쪽이 다음 상태를 계산한다.
따라서 구분의 기준은 Solidity로 작성했는지나 EVM 바이트코드인지가 아니다. 프로그램에 어떤 상태 접근을 허용하고, 변경안을 검사하는 일과 변경 결과를 만드는 일 중 무엇을 맡기는가다. 실행기를 공유하면서도 서로 다른 책임과 제약을 둘 수 있다.
이 실행 경로는 기존 컨트랙트와 SDK를 이어 쓰는 기반이기도 하다.
EVM 호환과 개발 도구 재사용에서는
토큰 샘플로 검증한 배포·서명·조회 흐름과, set_code 계정 위임·표준 precompile 지원을 살펴본다.
같은 결과를 얻는 것과 접근 범위를 미리 아는 것은 다르다
EVM에서 다음 상태를 실행 중 계산한다고 결과가 임의로 달라지는 것은 아니다. 같은 시작 상태와 코드, 요청, 블록 맥락과 실행 규칙이 주어지면 노드들은 같은 결과를 계산해야 한다. 이 결정론은 앞선 Direct Cell과 Native Program 경로에서도 필요한 조건이다.
다만 거래가 어떤 상태를 읽고 쓸지 실행 전에 충분히 알 수 있는가는 다른 질문이다. 예제의 T는 비교적 단순하지만, 일반 컨트랙트는 저장값에 따라 분기하거나 다른 컨트랙트를 호출할 수 있다. 함수 이름과 입력만 보아서는 실제 접근할 모든 상태를 곧바로 확정하기 어려울 수 있다.
현재 nigo-protocol은 일반 EVM 거래의 전체 상태 접근을 사전에 정확하게 증명하지 않는다. 그래서 병렬 실행에서는 그 거래를 순차 처리하는 경계로 둔다. 앞에서 모아 둔 병렬 작업의 결과를 반영한 뒤 EVM 거래를 실행하고, 그 결과 상태에서 뒤 거래를 판단한다. 이는 EVM의 결정론이 부족해서도, EVM 거래는 원리적으로 병렬화할 수 없어서도 아니다. 현재 실행기가 안전하게 독립성을 판정할 수 있는 범위에 맞춘 선택이다.
이제 세 경로의 차이를 하나의 질문으로 묶을 수 있다. Direct Cell은 미리 적은 변경안을 Core의 원장 규칙으로 확인하고, Native Program 경로는 프로그램의 업무 조건 검증과 Core의 원장 검사를 결합한다. EVM은 함수 실행으로 다음 상태를 만든다. 같은 원장에 도착하지만, 변경을 준비하는 위치와 미리 드러나는 정보가 다르다.
동시에 실행해도 같은 원장이 되려면에서는 그 차이를 여러 거래의 실행으로 넓힌다. 함께 계산해도 되는 거래를 구별하고, 계산이 끝나는 순서와 관계없이 같은 원장을 만드는 방법이 다음 질문이다.