같은 지갑의 정상 거래를 SDK가 대체 거래로 오해한 이유
같은 지갑으로 자산을 등록하고, 이어서 컨트랙트를 배포했다. 등록은 이미 끝났고 새 배포 요청도 접수됐다. 그런데 결과를 기다리던 SDK가 “다른 거래로 대체됐다”는 오류를 반환했다. 대체했다고 지목한 것은 방금 보낸 거래와 경쟁할 이유가 없는, 앞서 끝난 자산 등록이었다.
10월 15일 nigo-protocol의 실제 SDK 인수 과정에서 발견한 문제다. Ethereum 거래의 순번은 정상적으로 유지되고 있었다. 문제는 다른 규칙으로 처리한 Native 거래를 Ethereum 거래처럼 보여 준 조회 응답에 있었다.
EVM 호환 글에서는 같은 컨트랙트와 ethers·viem·Web3j를 이어 쓰는 개발 흐름을 설명했다. 이번 사례는 그 호환성이 거래를 만드는 단계뿐 아니라, SDK가 결과를 기다리고 해석하는 단계까지 이어져야 하는 이유를 보여 준다.
같은 주소가 보낸 두 거래
상황을 읽기 쉽게 줄여 보자. 주소 A가 먼저 Native 거래 N으로 자산을 등록한다. N이 확정된 뒤, 같은 A가 Ethereum 형식의 Type-2 거래 E에 서명해 연계용 컨트랙트를 배포한다. N과 E는 설명을 위한 이름이며 실제 거래의 긴 식별값을 대신한다.
두 거래의 해시는 서로 다르다. 해시는 서명된 거래 하나를 가리키는 식별값이다. 반면 nonce는 Ethereum 계정이 보내는 거래의 순번으로, 같은 요청의 재사용과 거래 순서를 다루는 데 쓰인다. 같은 발신자가 같은 nonce로 서로 다른 Ethereum 거래를 제출하면 SDK는 어느 거래가 확정됐는지 확인한다. 기다리던 거래 대신 다른 거래가 그 순번으로 확정됐다는 판단이 ‘대체 거래’의 출발점이다.
nigo-protocol에서 Native 요청과 Ethereum 요청은 같은 주소를 사용할 수 있지만 재사용을 막는 규칙까지 같지는 않다. Native 거래 N은 Ethereum nonce를 소비하지 않는다. 따라서 N이 끝나도 A가 아직 Ethereum 거래를 보내지 않았다면, E가 사용할 nonce는 여전히 0이다. N을 기다린 뒤 E를 보낸 순서도 올바르다.
표의 모든 단계에서 Native 등록 N은 이미 확정된 상태다.
| 단계 | 배포 E의 상태 | 확정 nonce |
|---|---|---|
| 등록 완료 | 아직 없음 | 0 |
| 배포 접수 | nonce 0으로 대기 | 0 |
| 배포 확정 | nonce 0 사용 완료 | 1 |
이 표의 nonce는 확정된 Ethereum 계정 상태를 조회한 값이다. 대기 중인 거래까지 고려해 다음 전송에 쓸 순번을 안내하는 표는 아니다. 마지막 행은 수정 뒤 실제 재검증에서 확인한 결과다. 최초 실패에서는 SDK 대기가 먼저 중단되어 E의 최종 결과를 조회하지 못했다. 당시의 오류 메시지만으로 E의 실행이 실패했다거나 자산 정산에 문제가 생겼다고 판단할 수는 없었다.
순번이 없는 거래에 붙인 0이 만든 오해
SDK는 노드에 RPC 요청을 보내 거래와 블록을 조회한다. RPC는 앱과 노드가 요청·응답을 주고받는 인터페이스다. 문제가 있던 구현은 Ethereum 형식의 블록 거래 목록에도 Native 거래 N을 넣었다. 그리고 N에 해당하는 Ethereum nonce가 없으면 그 자리에 기본값 0을 채웠다.
사람이 내부 사정을 안다면 “이 0은 실제 Ethereum 순번이 아니다”라고 구별할 수 있다. 하지만 표준 SDK가 받는 응답에는 같은 발신자 A, nonce 0, 이미 확정된 블록 위치가 함께 있었다. E가 아직 대기 중인 동안, N이 E의 자리를 먼저 차지한 거래처럼 보인 것이다.
실제 ethers 6.17.0의 결과 대기 함수 wait()는 이 블록을 살펴보고 N을 대체 거래로 판정했다.
당시 노드의 계정 조회는 Ethereum nonce가 여전히 0이라고 응답했고, E의 거래 조회도 대기 상태를 보여 주었다.
동일한 원장을 읽는 응답들이 서로 다른 이야기를 하고 있었다.
문서에 “Native 거래의 nonce 필드는 순번으로 해석하지 말라”고 적어 두는 것으로는 부족했다. 기존 SDK를 그대로 쓴다는 것은 그 SDK가 기대하는 필드의 의미도 지킨다는 약속이다. 숫자 형식이 맞아도 의미가 다르면 앱은 정상적인 절차로 잘못된 결론을 내릴 수 있다.
Ethereum 이력의 범위를 명확하게 바꾸다
수정은 Ethereum 조회에서 실제 Ethereum 형식으로 서명된 거래만 거래 이력으로 제공하는 방식을 택했다. 지원하는 Legacy·Type-2·Type-4 거래가 이 목록에 들어간다. 프로토콜 고유의 서명 형식인 NIGO 거래는 EVM 코드를 실행한 경우에도 이 Ethereum 거래 이력에 넣지 않는다.
그래서 N만 담긴 블록을 Ethereum RPC로 읽으면 블록은 같은 높이와 해시로 존재하되 거래 목록은 비어 있다. N의 전체 내용과 처리 결과는 NIGO 조회와 Console에 그대로 남는다. 원장에서 거래를 삭제하거나, 다른 블록체인으로 옮긴 것이 아니다. 같은 원장에 대해 어떤 종류의 이력을 제공하는지 경계를 정한 것이다.
이제 SDK는 Native 등록 N을 Ethereum nonce 0을 차지한 거래로 만나지 않는다.
E가 확정될 때까지 기존 wait() 경로로 기다리고 E 자신의 결과를 받는다.
Native 등록 때문에 Ethereum nonce를 강제로 올리거나, 앱에서 대체 거래 검사를 끄는 변경도 필요하지 않았다.
이 선택은 개발자가 사용할 조회 경로도 분명하게 한다. Ethereum 거래의 처리 이력은 Ethereum RPC에서 읽고, Native 거래까지 포함한 전체 이력은 NIGO 조회를 사용한다. 잔액·컨트랙트 코드·저장소를 읽고 함수를 호출할 때 참조하는 공유 상태는 유지한다. 이력을 나누어 보여 준다고 상태까지 두 원장으로 나누는 것은 아니다.
거래 목록을 바꾸면 순번과 합계도 함께 맞춰야 한다
블록 목록에서 Native 거래만 빼면 수정이 끝날까? 다음은 조회 규칙을 설명하기 위한 혼합 블록이다. 원장에는 네 거래가 이 순서로 저장되어 있다고 하자. 여기서 ‘위치’는 블록 안의 배열 순번이며, 앞서 설명한 계정의 nonce와 다르다.
| 원장에 저장된 거래 | 원래 위치 | Ethereum 조회의 위치 |
|---|---|---|
| Native N1 | 0 | 목록에 없음 |
| Ethereum E1 | 1 | 0 |
| Native N2 | 2 | 목록에 없음 |
| Ethereum E2 | 3 | 1 |
Ethereum 목록에는 E1과 E2 두 개만 있으므로 조회 위치도 0과 1로 이어져야 한다. E2를 해시로 직접 찾았을 때나 처리 결과인 receipt를 읽었을 때도 같은 위치를 반환해야 한다. 목록에서는 두 번째인데 receipt에는 네 번째라고 적혀 있으면 다른 종류의 불일치가 생긴다. 원장의 실제 저장 위치 1과 3은 보존하고, 응답을 만들 때만 조회용 위치로 변환했다.
실행 자원 사용량인 gas와 이벤트 로그도 같은 기준을 적용했다. Ethereum 블록의 gas 합계와 receipt의 누적 gas에는 Ethereum 거래의 사용량만 포함한다. 로그의 순번과 검색용 요약도 같은 이력을 따른다. Native 거래에서 EVM 이벤트가 발생했더라도 그 이벤트를 Ethereum 이력에 섞지 않으며, 전체 기록은 NIGO 쪽에 남는다.
이 조회는 nigo-protocol 블록을 Ethereum 도구에서 읽기 위한 표현이다. 블록의 원래 해시와 원장 증명 구조까지 Ethereum의 것으로 교체한 것은 아니다. 또 저장된 거래와 블록 위치가 실제로 일치하는지 먼저 검사하므로, 잘못된 저장 정보를 그럴듯한 조회 위치로 바꾸어 숨기지 않는다.
같은 SDK로 실패와 수정을 다시 확인했다
먼저 기록된 응답 순서를 작은 HTTP 시험으로 재현했다. SDK의 서명·전송·대체 거래 탐색·대기 로직은 그대로 두고 노드가 돌려주는 응답만 통제했다. 이전 응답에서는 N을 대체 거래로 잘못 판단했고, 새 응답에서는 N만 있는 블록을 지나 E의 성공 결과를 받았다. 이 비교로 원인이 SDK 바깥의 조회 응답에 있다는 것을 좁혔다.
이어 실제 노드 실행 파일로 원래 순서를 다시 밟았다. Native 등록 뒤 같은 지갑의 Type-2 배포가 블록 2에서 성공했고, 배포된 코드와 성공 receipt, Ethereum nonce가 1로 바뀐 결과를 확인했다. 대기 경로를 우회하지 않은 채 처음 실패했던 흐름이 통과한 것이다.
후속 인수에서는 H2·RocksDB 저장소와 ZERO·FIXED 수수료 정책의 네 조합에서, 같은 실행 파일로 SDK 업무와 정상 재시작 뒤의 조회를 확인했다. Native 원본은 남고 Ethereum 이력에는 해당 서명 거래만 보이는 규칙도 재시작 전후에 대조했다. 이 과정은 중간의 별도 시험 준비 오류를 보완하고, 실행 시간 제한으로 멈춘 마지막 조합을 재개해 완료한 기록이다. 최초부터 중단 없이 모든 시험이 통과한 결과나 모든 SDK·운영 환경에 대한 보장은 아니다.
호환성은 결과를 읽는 순간까지 이어진다
이번 문제를 해결하면서 유지한 것은 같은 지갑, 원래 거래 순서, 표준 SDK의 결과 대기 방식이었다. 맞춘 것은 그 도구에 제공하는 거래 이력의 의미다. 이 경계가 정확해야 개발자는 컨트랙트뿐 아니라 익숙한 전송·대기·조회 코드도 이어 쓸 수 있다.
서로 다른 거래 방식이 하나의 원장을 쓴다고 해서 모든 거래를 한 형식의 이력으로 표현할 필요는 없다. 대신 함께 쓰는 자산과 상태는 어떤 공통 규칙으로 연결되는지가 다음 질문으로 남는다. 후속 글에서는 NIGO도 하나의 자산으로 다루는 통합 모델을 통해 그 연결을 살펴본다.