[Sui 블록체인 데이터 뜯어보기] #3. 데이터 구조 — BCS로 읽는 CheckpointData
목차
Sui 체크포인트의 BCS 바이트를 직접 읽고, digest를 계산해 원본을 검산합니다. 필요한 값만 고정 오프셋으로 꺼내는 방법도 다룹니다. 이 글은 Sui 블록체인 데이터 뜯어보기 시리즈의 3번째 글입니다. 2편에서는 체크포인트를 BCS는 Facebook의 블록체인 프로젝트 Libra(이후 Diem)에서 Move 언어와 함께 만들어졌습니다. 지금은 Diem의 기술을 이어받은 Sui와 Aptos가 쓰고 있습니다. 이름의 Canonical이 말하듯 같은 값은 항상 한 가지 바이트열로만 인코딩됩니다. 그래서 바이트 위에서 계산하는 digest와 서명이 노드마다 달라지지 않습니다. 대신 바이트에 필드 이름이나 타입 정보가 없어서, 타입 정의를 알아야만 읽을 수 있습니다. 이 글에서 다룰 내용은 다음과 같습니다. 예시로는 2편에서 열어 본 체크포인트 330625964의 이 글의 사실과 수치는 2026-10-03 기준입니다. 이후에 확인한 사실과 측정값은 날짜를 함께 적었습니다. BCS가 숫자를 쓰는 방법은 두 가지뿐입니다. 1. 값으로서의 정수: 고정 길이, little-endian 샘플 체크포인트의 epoch 1271( Sui의 체크포인트 데이터에서 epoch, 체크포인트 번호, 누적 트랜잭션 수, timestamp, 가스 비용은 모두 2. 길이와 enum 번호: ULEB128 BCS 바이트에는 필드 사이의 경계 표시가 없습니다. 그래서 두 가지 경우에는 내용 앞에 숫자를 하나 먼저 적습니다. 이 숫자들은 실제로 대부분 작습니다. 체크포인트 하나의 트랜잭션은 수십 건이고, enum의 variant 번호는 0부터 시작해 많아야 수십입니다. 이런 숫자를 매번 ULEB128(Unsigned Little Endian Base 128)은 부호 없는 정수를 7비트씩 잘라, 낮은 자리 조각부터 한 바이트에 하나씩 담는 방식입니다. 한 바이트의 8비트는 이렇게 나뉩니다. 계속 비트가 끝을 알려 주기 때문에, ULEB128로 쓴 숫자 앞에는 길이 정보가 따로 필요 없습니다. 읽는 쪽은 계속 비트가 0인 바이트가 나올 때까지만 읽으면 됩니다. 길이를 적는 숫자에 이 방식을 쓰는 이유가 바로 이것입니다. 바이트 결과는 44 × 1 + 2 × 128 = 300입니다. 값이 127 이하면 첫 바이트의 계속 비트가 0이라 한 바이트로 끝납니다. enum의 variant 번호도 같은 방식입니다. 샘플 체크포인트의 contents BCS는 Canonical을 지키기 위해, BCS는 같은 숫자를 가장 짧은 표현으로만 쓰도록 강제합니다. 예를 들어 0을 참고로 이 인코딩은 2편에서 본 protobuf의 varint와 같습니다. 다만 protobuf는 일반 정수 필드에도 이 방식을 쓰고, BCS는 길이와 variant 번호에만 씁니다. 정수와 ULEB128만 알면 나머지 타입은 그 조합입니다. 체크포인트를 읽는 데 필요한 규칙은 다섯 가지입니다. 길이가 타입에 고정된 배열( 샘플 체크포인트 summary의 뒷부분에서 이 규칙들을 한꺼번에 볼 수 있습니다. 여기서 BCS를 읽을 때 가장 중요한 성질이 드러납니다. 바이트만 봐서는 BCS 규칙 자체는 단순하지만, Sui 타입을 직접 파싱하다 보면 쉽게 틀리는 지점이 있습니다. 같은 32바이트인데 앞에 길이가 붙는 타입과 안 붙는 타입이 섞여 있다는 점입니다. 차이는 Sui 소스에서 각 타입을 직렬화하는 방식에 있습니다. 둘 다 내부는 이 차이를 모르면 Digest 뒤의 필드부터 1바이트씩 어긋나서 이후 값이 모두 엉뚱하게 읽힙니다. 앞 섹션의 같은 서명 정보 안에서도 BLS 서명은 길이 없이 48바이트이고, 서명자 bitmap은 길이가 붙은 바이트열입니다. 결국 규칙은 하나입니다. 길이가 붙는지는 바이트 크기가 아니라 타입 정의가 정합니다. 파서를 만들 때는 바이트를 보고 추측하지 말고 Sui 소스의 타입 정의와 직렬화 지정을 확인해야 합니다. 2편에서 본 것처럼 집계 서명( 가장 먼저 읽을 조각은 1편에서 본 개념이 여기에 대응됩니다. 샘플은 첫 바이트가 contents에는 digest만 있고, 트랜잭션 원본은 세 타입은 모두 enum이고, 첫 바이트가 버전을 알려 줍니다. 샘플에서 샘플의 194개 항목은 서로 다른 Object 102개에서 나왔습니다. 같은 Object가 버전만 달리해 여러 번 들어 있기 때문입니다. 트랜잭션 실행 전 입력 버전과 실행 후 출력 버전이 모두 들어 있고, 한 체크포인트 안에서 여러 트랜잭션이 같은 Object를 연달아 바꾸면 그 중간 버전들도 남습니다. 샘플에서는 63개 Object가 2개 이상의 버전으로 들어 있고, 많게는 한 Object가 5개 버전으로 들어 있습니다 (2026-10-15 확인). 194개 모두 이제 샘플 summary 170바이트를 앞에서부터 직접 읽어 봅니다. 고정 길이 필드는 정해진 바이트 수만큼 읽고, 가변 길이 필드는 앞의 접두어 바이트를 보고 길이를 정합니다. 표의 바이트는 모두 hex입니다. 정수 필드는 little-endian이라 바이트를 거꾸로 읽어야 합니다. 마지막 이렇게 읽은 값은 GraphQL로 조회한 같은 체크포인트의 epoch, 번호, 누적 트랜잭션 수, 시각과 일치합니다 (2026-10-15 확인). contents 4,150바이트도 같은 방식으로 읽습니다. 처음 두 바이트는 1장에서 본 표의 바이트는 모두 hex입니다. 앞에 붙은 ed25519 서명 97바이트는 scheme flag 1바이트, 서명 64바이트, 공개키 32바이트로 이뤄집니다. 첫 항목의 트랜잭션 digest 항목의 길이는 서명 개수와 종류에 따라 달라집니다. 샘플의 항목 길이는 세 가지뿐입니다. secp256k1 서명은 공개키가 33바이트라서 ed25519 서명보다 1바이트 깁니다. 2편에서 본 것처럼 summary는 트랜잭션 부분은 구조가 다릅니다. Sui에서 체크포인트, contents, 트랜잭션, effects, events의 digest는 모두 같은 규칙으로 계산합니다. BCS 바이트 앞에 타입 이름과 타입 이름은 2장에서 본 Rust로는 summary 표에서 본 것처럼, 원본에 기록된 digest 필드에는 길이 접두어 digest와 서명은 둘 다 BCS 바이트 앞에 무언가를 붙여 계산합니다. 하지만 붙이는 것이 다릅니다. 서명에는 타입 이름 대신 intent라는 3바이트를 붙입니다. 3장 「.chk에서는 1바이트 뒤로」에서 정리하면 같은 이 글에서 검산하는 것은 digest입니다. validator 서명을 검증하려면 해당 epoch의 validator 공개키 목록이 추가로 필요하기 때문입니다. 이 부분은 6장에서 다시 짚습니다. 「digest 계산 규칙」의 방식으로 샘플 체크포인트 330625964의 digest를 모두 계산해, 원본에 기록된 값과 비교했습니다 (2026-10-15 확인). events digest는 effects를 끝까지 파싱하지 않고, 계산한 32바이트가 effects 바이트 안에 들어 있는지만 확인했습니다. effects는 다음 편에서 트랜잭션을 열어 보며 필요한 부분을 다시 봅니다. 이 결과를 이어 보면 체크포인트 하나가 처음부터 끝까지 digest로 묶여 있는 것을 확인할 수 있습니다. summary의 digest 하나만 믿을 수 있으면, 나머지 조각은 모두 계산으로 확인할 수 있습니다. 트랜잭션 바이트가 1비트만 바뀌어도 digest가 달라지고, 그러면 contents와 summary의 digest가 연쇄적으로 맞지 않게 됩니다. digest는 데이터의 식별자이면서 무결성 검증 수단이기도 합니다. 남은 것은 summary 자체를 믿을 근거인데, 그것이 validator들의 집계 서명입니다. 1장에서 봤듯이 BCS는 앞에서부터 순서대로 읽어야 합니다. 그래도 앞쪽 필드가 모두 고정 길이라면 오프셋을 미리 계산해 둘 수 있습니다. summary 앞부분이 이 경우입니다. 조건이 하나 있습니다. 체크포인트에 트랜잭션이 몇 건 들어 있는지는 인덱서가 자주 쓰는 값입니다. summary에는 이 값이 따로 없지만, 지금까지 본 필드로 세 가지 방법으로 구할 수 있습니다. 1. 누적 트랜잭션 수의 차이 summary만 읽으면 되는 대신, 체크포인트 두 개가 필요합니다. 2. contents 앞부분의 원소 수 contents는 variant 번호 다음에 바로 트랜잭션 목록의 원소 수가 옵니다. 샘플의 트랜잭션 수가 128 이상이면 원소 수가 2바이트 이상이 됩니다. 그래서 바이트 하나를 바로 읽지 말고 반드시 ULEB128로 읽어야 합니다. 3. protobuf의 샘플에서는 세 방법 모두 22로 일치합니다 (2026-10-15 확인). 한 체크포인트만 볼 때는 contents의 원소 수가 가장 가볍습니다. 체크포인트를 연속으로 처리하는 인덱서라면 직전 체크포인트의 누적값을 기억해 두었다가 차이를 구하는 것만으로도 충분합니다. 4장의 검산은 digest 사슬이 안에서 서로 맞는다는 것까지만 확인합니다. 이 글에서 직접 확인하지 않은 것은 다음과 같습니다. effects는 다음 편에서 트랜잭션을 열어 보며 필요한 부분을 다시 봅니다. 이번 편에서는 이번 편에서는 트랜잭션을 digest로만 다뤘습니다. 다음 편에서는 트랜잭션 안으로 들어갑니다. 체크포인트 안의 트랜잭션 종류( 전체 목차:
개요
.chk, .binpb.zst, gRPC 응답 세 가지 형태로 받아 열어 봤습니다. 결론은 어느 형태로 받든 원본을 그대로 다루려면 결국 BCS(Binary Canonical Serialization, Sui의 직렬화 규칙)를 읽어야 한다는 것이었습니다. 이번 편에서는 2편에서 열어 본 protobuf 봉투 안의 BCS를 직접 읽어 봅니다.Option, enum이 바이트로 어떻게 표현되는지 정리합니다..binpb.zst를 계속 씁니다. 트랜잭션 22건이 담긴 체크포인트입니다. 폐지된 .chk도 같은 BCS 바이트를 담고 있어서, 차이가 나는 부분만 따로 짚겠습니다.1. BCS 인코딩 규칙
정수는 고정 길이, 길이는 ULEB128
u8, u16, u32, u64, u128, u256 같은 정수는 타입이 정한 바이트 수만큼, 낮은 자리 바이트부터(little-endian) 씁니다. 값이 작아도 길이는 줄지 않습니다. bool은 1바이트로, 0x01이 true이고 0x00이 false입니다.u64)은 이렇게 8바이트가 됩니다.1271 = 0x04F7 → f7 04 00 00 00 00 00 00u64입니다. 그래서 위치만 알면 8바이트를 잘라 바로 읽을 수 있습니다. 5장에서 다룰 "전체를 파싱하지 않고 값 꺼내기"가 가능한 이유가 이것입니다.20(10진수 32)이 붙습니다(이유는 뒤에서 다룹니다).u32 4바이트로 쓰면 대부분이 00으로 낭비됩니다. 그래서 BCS는 이 두 숫자에만 작은 값은 짧게, 큰 값은 길게 쓰는 가변 길이 인코딩인 ULEB128을 씁니다. [계속 비트 1개][값 7비트]ac 02를 읽어 보겠습니다.바이트 2진수 계속 비트 값 7비트 자리 ac1 01011001 → 다음 바이트도 읽음 44 ×1 020 00000100 → 여기서 끝 2 ×128 값 ULEB128 바이트 22 16127 7f128 80 01300 ac 0201 16으로 시작합니다. 앞의 01은 variant 번호 1(CheckpointContents::V2)이고, 뒤의 16은 그 안의 트랜잭션 목록 길이 22입니다. 둘 다 ULEB128 한 바이트입니다.80 00처럼 불필요하게 길게 쓰면 거부합니다. 32비트를 넘는 값도 거부합니다.가변 길이 값
타입 인코딩 예 struct 필드를 선언 순서대로 이어 붙임. 필드 이름은 없음 CheckpointSummary = epoch ‖ 번호 ‖ …리스트( Vec<T>)ULEB128 원소 수 + 원소들 [1, 2](Vec<u16>) → 02 01 00 02 00바이트열·문자열 ULEB128 바이트 수 + 바이트들 (문자열은 UTF-8) "abc" → 03 61 62 63Option<T>없으면 00, 있으면 01 + 값None → 00enum ULEB128 variant 번호 + 그 variant의 내용 V2(…) → 01 …[u8; 32] 등)은 길이를 적지 않고 원소만 이어 붙입니다. 이 차이가 다음 섹션의 주제입니다.01 20 bd 97 3c 19 … previous_digest: Option<Digest>
01 = Some, 20 = 길이 32, 뒤로 32바이트
01 01 20 9a f5 e2 … checkpoint_commitments: Vec<CheckpointCommitment>
01 = 원소 1개, 01 = variant 1(CheckpointArtifactsDigest), 20 = 길이 32, 뒤로 32바이트
00 end_of_epoch_data: Option<EndOfEpochData>
00 = None (epoch 마지막 체크포인트가 아님)
02 00 00 version_specific_data: Vec<u8>
02 = 2바이트, 뒤로 00 0001이 Some인지, 원소 1개인지, variant 1인지 알 수 없습니다. 지금 어떤 타입의 어떤 필드를 읽는 중인지 알아야 같은 01을 해석할 수 있습니다. 그래서 BCS 파서는 항상 타입 정의를 따라가며 앞에서부터 순서대로 읽습니다. 중간의 한 필드를 잘못 읽으면 그 뒤가 모두 어긋납니다.Sui 타입에서 헷갈리는 부분
타입 크기 길이 접두어 실제 바이트 Digest (체크포인트·트랜잭션·effects·events 등) 32 B 붙음 20(=32) + digest 32바이트 = 33바이트SuiAddress, ObjectID32 B 안 붙음 32바이트 // digests.rs
pub struct Digest(
#[serde_as(as = "Readable<Base58, Bytes>")] // 바이트열로 직렬화 → 길이 접두어
[u8; 32],
);
// base_types.rs
pub struct SuiAddress(
#[serde_as(as = "Readable<Hex, _>")] // 고정 길이 배열 그대로 → 접두어 없음
[u8; 32],
);[u8; 32]입니다. 그런데 Digest는 바이너리로 직렬화할 때 Bytes, 즉 "바이트열"로 다루라고 지정되어 있습니다. BCS에서 바이트열은 앞 섹션의 규칙대로 길이 + 내용이므로, 항상 32인데도 20이 붙습니다. Address와 ObjectID는 별도 지정이 없어 고정 길이 배열로 직렬화되고, 길이 없이 32바이트만 나옵니다.01 20 bd 97 …에서 20이 바로 이 길이입니다..chk를 읽는다면 하나가 더 있습니다. .chk에서는 summary 바로 뒤에 집계 서명이 BCS로 이어집니다(.binpb.zst에서는 protobuf 필드라 해당하지 않습니다).f7 04 00 00 00 00 00 00 epoch (u64)
86 99 10 50 … BLS 집계 서명: 48바이트 고정, 길이 접두어 없음
ac 01 3a 30 … 서명자 bitmap: 바이트열이라 길이(ac 01 = 172) + 172바이트2. Checkpoint 안의 BCS 조각
summary
.binpb.zst의 protobuf 봉투 안에는 BCS 조각이 여러 개 들어 있습니다. 조각은 모두 Bcs { name, value } 메시지로 감싸져 있습니다. value가 BCS 바이트이고, name은 그 바이트를 어떤 Sui 타입으로 읽을지 알려 주는 타입 이름입니다. BCS 바이트에는 타입 정보가 없으니, protobuf가 이름표를 따로 붙여 주는 셈입니다. 이 이름은 4장에서 digest를 계산할 때도 그대로 쓰입니다.protobuf 위치 bcs.name개수 (330625964) summaryCheckpointSummary1 contentsCheckpointContents1 transactions[].transactionTransactionData22 transactions[].effectsTransactionEffects22 transactions[].eventsTransactionEvents13 (이벤트가 있는 트랜잭션만) objects.objects[]Object194 signature)만 BCS가 아닌 protobuf 필드입니다. 이 장에서는 조각마다 무엇이 들어 있는지 보고, 바이트 단위로 읽는 일은 3장에서 합니다.CheckpointSummary입니다. validator들이 서명하는 대상이자, 체크포인트 digest를 계산하는 대상입니다. 필드는 10개이고 이 순서대로 직렬화됩니다.필드 타입 내용 epochu64체크포인트가 속한 epoch sequence_numberu64체크포인트 번호 network_total_transactionsu64genesis부터 이 체크포인트까지의 누적 트랜잭션 수 content_digestDigest CheckpointContents의 digestprevious_digestOption<Digest>직전 체크포인트의 digest (genesis만 None) epoch_rolling_gas_cost_summaryu64 × 4이번 epoch 시작부터 누적된 가스 비용 (computation, storage, storage rebate, non-refundable storage fee) timestamp_msu64체크포인트 시각 (Unix 밀리초) checkpoint_commitmentsVec<enum>체크포인트의 상태에 대한 commitment digest 목록 end_of_epoch_dataOption<…>epoch 마지막 체크포인트에만 값이 있음 (다음 epoch의 위원회 등, 6편에서 다룸) version_specific_data바이트열 프로토콜 버전별 추가 데이터 (안에 다시 BCS) content_digest가 트랜잭션 목록을 묶고, previous_digest가 직전 체크포인트를 가리켜 체크포인트들이 하나의 사슬을 이룹니다. 이 summary는 트랜잭션 내용 없이도 체크포인트의 핵심 메타데이터를 모두 담고 있으며, 크기는 샘플에서 170바이트에 불과합니다.contents
CheckpointContents는 이 체크포인트에 어떤 트랜잭션이 어떤 순서로 들어갔는지 기록한 목록입니다. 트랜잭션 본문은 없고 digest와 서명만 있습니다. 타입은 버전이 둘인 enum입니다.variant 구조 V1 (0)transactions: Vec<ExecutionDigests>, user_signatures: Vec<Vec<GenericSignature>>. 두 목록이 같은 길이로 따로 놓임V2 (1)transactions: Vec<CheckpointTransactionContents>. 트랜잭션마다 digest와 서명을 한 항목으로 묶음01이라 V2이고, 1장에서 본 것처럼 그다음 16이 트랜잭션 수 22입니다. V2의 항목 하나는 다음과 같습니다.필드 타입 내용 digest.transactionDigest 트랜잭션 digest digest.effectsDigest 실행 결과(effects) digest user_signaturesVec<(GenericSignature, Option<u64>)>사용자 서명 목록. 각 서명 옆에는 검증에 쓴 AddressAliases Object의 버전이 붙음GenericSignature는 BCS에서 길이 접두어가 붙은 바이트열이고, 그 첫 바이트가 서명 방식(scheme flag)입니다. 0은 ed25519, 1은 secp256k1입니다. 샘플 22개 트랜잭션을 세어 보면 다음과 같습니다 (2026-10-15 확인).AddressAliases 버전은 27개 모두 Nonedigest.transaction과 digest.effects는 같은 체크포인트의 transactions[]에 있는 TransactionData와 TransactionEffects 바이트로 다시 계산할 수 있습니다. 그래서 contents는 체크포인트 안의 트랜잭션 원본을 묶는 역할을 합니다. summary의 content_digest가 contents를 묶고, contents가 다시 각 트랜잭션을 묶는 구조입니다. 이 연결을 실제로 계산해 보는 일은 4장에서 합니다.transactions와 objects
transactions[]에 있습니다. 2편에서 본 것처럼 .binpb.zst의 각 트랜잭션은 protobuf ExecutedTransaction 메시지입니다. 이 메시지에는 필드가 여럿 정의돼 있지만, 샘플에 실제로 들어 있는 필드는 BCS 조각 3개뿐입니다.필드 bcs.name내용 샘플 transactionTransactionData보낸 사람, 가스 정보, 실행할 명령 등 사용자가 서명한 내용 22개 effectsTransactionEffects실행 결과. 성공·실패, 가스 사용량, 바뀐 Object 목록, events digest 22개 eventsTransactionEventsMove 코드가 발생시킨 이벤트 목록 13개 signatures나 balance_changes 같은 나머지 필드는 비어 있습니다. 파일을 만드는 sui-checkpoint-blob-indexer가 FieldMask로 담을 필드를 직접 골라 두었기 때문입니다. 이 목록에는 summary, contents, transaction, effects, events, objects의 BCS 값과 집계 서명만 들어 있습니다. 2편에서 본 unchanged_loaded_runtime_objects도 예외로 함께 들어 있습니다. 사용자 서명은 contents에 이미 있습니다. 이벤트가 없는 트랜잭션은 events 필드가 없고, 이벤트가 있으면 effects 안에 events digest가 기록됩니다.TransactionData는 00(V1), TransactionEffects는 01(V2)로 시작합니다. 트랜잭션 종류별 내용은 4편에서 다룹니다.objects에는 이 체크포인트의 트랜잭션들이 읽거나 쓴 Object가 들어 있습니다. 샘플에서는 항목마다 BCS 조각과 함께 object_id와 version이 protobuf 필드로 따로 붙어 있어서, BCS를 풀지 않고도 어떤 Object의 몇 번째 버전인지 알 수 있습니다. BCS 쪽 Object는 다음 4개 필드로 이뤄집니다.필드 내용 dataenum. Move(일반 Object, 타입·버전·내용 바이트) 또는 Package(Move Package)owner소유 형태 (주소 소유, shared, immutable 등) previous_transaction이 버전을 만든 트랜잭션의 digest storage_rebate삭제하면 돌려받는 storage 비용 Move variant이며, BCS 크기는 126바이트부터 7,081바이트까지입니다.3. 바이트 오프셋으로 따라가기
summary 읽기
바이트 필드 원본 바이트 (hex) 해석 [0..8]epochf7 04 00 00 00 00 00 000x04f7 = 1271[8..16]sequence_numberac f3 b4 13 00 00 00 000x13b4f3ac = 330,625,964[16..24]network_total_transactions43 5e e5 6d 01 00 00 006,138,715,715 [24..57]content_digest20 + digest 32바이트길이 32, 4Qzn…Y2Qz[57..91]previous_digest01 20 + digest 32바이트Some, 길이 32, Dm5o…zq31[91..123]epoch_rolling_gas_cost_summaryu64 × 42,742,499,409,228 / 196,623,775,318,400 / 194,341,168,653,984 / 1,963,042,107,616 [123..131]timestamp_ms32 37 19 0d a1 01 00 001,791,221,118,770 = 2026-10-05 17:25:18.770 UTC [131..166]checkpoint_commitments01 01 20 + digest 32바이트원소 1개, variant 1( CheckpointArtifactsDigest), 길이 32[166..167]end_of_epoch_data00None [167..170]version_specific_data02 00 00길이 2짜리 바이트열 epoch의 f7 04는 0x04f7, 즉 1271입니다. digest 필드 앞의 20은 10진수 32로, 1장에서 본 것처럼 digest가 바이트열로 직렬화되면서 붙는 길이 접두어입니다. 그래서 digest 하나가 33바이트를 차지합니다. previous_digest는 그 앞에 Option의 01이 하나 더 붙어 34바이트입니다.version_specific_data는 바이트열 안에 다시 BCS가 들어 있는 경우입니다. 바깥의 02는 길이이고, 안쪽 00 00을 CheckpointVersionSpecificData로 읽습니다. 앞의 00은 variant 0(V1)이고, 뒤의 00은 randomness_rounds 벡터의 원소 수 0입니다. 이 체크포인트에서 생성된 randomness round는 없다는 뜻입니다.contents 읽기
01 16, 즉 V2 variant이고 항목은 22개입니다. 그 뒤로 22개 항목이 이어지는데, 첫 항목 [2..168]은 다음과 같습니다.바이트 필드 원본 바이트 (hex) 해석 [2..35]digest.transaction20 + digest 32바이트Bhig…QRijy[35..68]digest.effects20 + digest 32바이트GmYR…GGm3[68..168]user_signatures01 + 원소 1개서명 1개 └ [69..167]원소 0의 서명 61 + 서명 97바이트길이 97, 첫 바이트 00 = ed25519└ [167..168]원소 0의 AddressAliases 버전00Option None 20(10진수 32), 01, 61(10진수 97)은 별도 필드가 아니라 각 값의 ULEB128 길이 접두어입니다. digest와 서명처럼 바이트열인 값에서는 바이트 수를, user_signatures처럼 벡터인 값에서는 원소 수를 셉니다. 마지막 00은 길이가 아니라 Option의 None 태그입니다.BhigfZ1bvm6vKQ7R4vvLMQHpGM6Sk4jbiUFYaokQRijy를 GraphQL로 조회하면, 체크포인트 330625964에 포함된 트랜잭션으로 나옵니다 (2026-10-15 확인).항목 길이 서명 구성 개수 166바이트 ed25519 1개 17 265바이트 ed25519 2개 4 266바이트 2개, 그중 하나가 secp256k1 1 2 + 166×17 + 265×4 + 266 = 4,150으로, 끝까지 읽으면 contents 크기와 정확히 맞아떨어집니다. 남거나 모자라는 바이트가 없다는 것은 타입 정의대로 읽었다는 확인이기도 합니다..chk에서는 1바이트 뒤로
.chk는 인코딩 태그 01 1바이트 뒤에 CheckpointData의 BCS가 바로 이어지는 파일입니다. CheckpointData는 struct이므로 필드가 선언 순서대로 이어 붙습니다. 그래서 같은 체크포인트 330625964의 .chk(222,158바이트)를 읽으면, 앞에서 읽은 summary와 contents가 그대로 나옵니다. 위치만 다릅니다..chk 바이트내용 .binpb.zst와 비교[0..1]인코딩 태그 01.chk에만 있음[1..171]CheckpointSummary 170바이트summary 바이트와 동일[171..179]서명의 epoch f7 04 … (1271)protobuf signature 필드[179..227]BLS 집계 서명 48바이트 〃 [227..401]서명자 bitmap, ac 01(=172) + 172바이트〃 [401..4551]CheckpointContents 4,150바이트 (01 16 …)contents 바이트와 동일[4551..]transactions (16 = 22개 …)transactions[].binpb.zst에서 읽은 오프셋에 1을 더하면 됩니다. epoch는 [1..9], 체크포인트 번호는 [9..17], timestamp는 [124..132]에 있습니다. 제목의 "1바이트 뒤로"가 이 뜻입니다. contents는 summary 뒤에 집계 서명 230바이트가 끼어 있어서, 401번째 바이트부터 시작합니다..binpb.zst의 transaction 조각은 TransactionData만 담지만, .chk의 트랜잭션은 사용자 서명까지 묶은 SenderSignedData입니다. 첫 트랜잭션은 이렇게 시작합니다.16 transactions: 원소 22개
01 SenderSignedData 안의 서명된 트랜잭션: 원소 1개
00 00 00 intent (scope 0 = TransactionData, version 0, app_id 0 = Sui)
00 09 … TransactionData 시작 (.binpb.zst의 transaction 조각과 같은 바이트)TransactionData 앞에 intent 3바이트가 붙어 있습니다. 이 3바이트는 사용자가 서명할 때 쓰는 접두어로, 4장에서 digest 계산과 비교하며 다시 봅니다. 사용자 서명은 TransactionData 뒤에 이어지고, 그다음으로 effects, events, 입력·출력 Object가 트랜잭션마다 따로 들어 있습니다.4. digest로 원본 검산하기
digest 계산 규칙
digest = blake2b-256( "타입이름::" ‖ BCS 바이트 )::를 ASCII 문자열로 붙이고, 그 전체를 blake2b-256으로 해시합니다. 결과 32바이트를 Base58로 쓴 것이 GraphQL이나 탐색기에서 보는 digest입니다. Sui 소스(crypto.rs)의 구현은 다음과 같습니다.impl<T, W> Signable<W> for T
where
T: bcs_signable::BcsSignable,
W: std::io::Write,
{
fn write(&self, writer: &mut W) {
let name = serde_name::trace_name::<Self>().expect("Self must be a struct or an enum");
// Note: This assumes that names never contain the separator `::`.
write!(writer, "{}::", name).expect("Hasher should not fail");
bcs::serialize_into(writer, &self).expect("Message serialization should not fail");
}
}trace_name은 Rust 타입의 이름을 꺼내는 함수이고, 해시 함수(DefaultHash)는 Blake2b256입니다. 이 방식을 쓰는 타입은 같은 파일의 BcsSignable 목록에 명시되어 있습니다. 체크포인트 데이터와 관련된 타입은 다음과 같습니다.타입 이름 계산 결과 비교 대상 CheckpointSummary체크포인트 digest 다음 체크포인트의 previous_digestCheckpointContentscontents digest summary의 content_digestTransactionData트랜잭션 digest contents의 digest.transactionTransactionEffectseffects digest contents의 digest.effectsTransactionEventsevents digest effects 안의 events digest bcs.name과 같은 문자열입니다. 그래서 .binpb.zst에서는 각 조각의 name과 value만으로 digest를 바로 계산할 수 있습니다. 예를 들어 summary의 해시 입력은 CheckpointSummary:: 19바이트 뒤에 summary 170바이트가 이어진 189바이트입니다.43 68 65 63 6b 70 6f 69 6e 74 53 75 6d 6d 61 72 79 3a 3a "CheckpointSummary::"
f7 04 00 00 00 00 00 00 … summary BCS (epoch부터)blake2 crate로 몇 줄이면 됩니다.use blake2::{digest::consts::U32, Blake2b, Digest};
fn sui_digest(type_name: &str, bcs_bytes: &[u8]) -> [u8; 32] {
let mut h = Blake2b::<U32>::new(); // blake2b-256
h.update(type_name.as_bytes());
h.update(b"::");
h.update(bcs_bytes);
h.finalize().into()
}20이 붙어 있습니다. 계산한 32바이트와 비교할 때는 이 20을 빼고 뒤의 32바이트만 비교해야 합니다.intent signing과 무엇이 다른가
intent = [scope, version, app_id] 각 1바이트scope는 무엇에 대한 서명인지를 나타냅니다. 0은 TransactionData, 1은 TransactionEffects, 2는 CheckpointSummary입니다. version은 현재 0(V0), app_id는 0(Sui)입니다. Sui 문서(Intent Signing)는 intent를 서명이 어떤 종류의 메시지에 대한 것인지 구분하는 domain separator라고 설명하며, 타입 이름 문자열 대신 1바이트 scope를 쓰는 것을 장점으로 듭니다.계산 입력 바이트 처리 digest "TransactionData::" ‖ BCSblake2b-256 사용자 서명 00 00 00 ‖ BCSblake2b-256 후 그 32바이트에 서명 validator 서명 (체크포인트) 02 00 00 ‖ summary BCS ‖ epoch 8바이트해시 없이 BLS 서명 TransactionData 앞에 있던 00 00 00이 바로 사용자 서명의 intent입니다. .chk의 트랜잭션은 사용자가 서명한 메시지 형태(IntentMessage<TransactionData>)를 그대로 저장하기 때문입니다.TransactionData BCS 바이트를 두고도 두 계산의 결과는 다릅니다.검산 결과
계산 대상 계산한 digest와 비교한 값 결과 CheckpointSummaryGraphQL의 체크포인트 digest HUWHdyBRDS7x6vWca63bLGvFYRJJyNn3rY78iDneuUus일치 〃 다음 체크포인트 330625965의 previous_digest일치 직전 체크포인트 330625963의 CheckpointSummary이 체크포인트의 previous_digest Dm5o…zq31일치 CheckpointContentssummary의 content_digest일치 TransactionData × 22contents의 digest.transaction22/22 일치 TransactionEffects × 22contents의 digest.effects22/22 일치 TransactionEvents × 13effects 바이트 안의 events digest 13/13 일치 직전 체크포인트 summary ──digest──▶ previous_digest
│
CheckpointSummary ──digest──▶ 다음 체크포인트의 previous_digest
│
content_digest
│
CheckpointContents
├─ digest.transaction ◀──digest── TransactionData
└─ digest.effects ◀──digest── TransactionEffects
└─ events digest ◀──digest── TransactionEvents5. 전체를 파싱하지 않고 값 꺼내기
고정 오프셋으로 읽는 값
값 .binpb.zst summary.chk고정인 이유 epoch[0..8][1..9]첫 필드 sequence_number[8..16][9..17]앞이 u64뿐network_total_transactions[16..24][17..25]〃 content_digest[25..57][26..58]앞의 20이 항상 같음epoch_rolling_gas_cost_summary[91..123][92..124]previous_digest가 Some일 때timestamp_ms[123..131][124..132]〃 content_digest의 오프셋이 25부터인 것은 앞의 길이 접두어 20을 건너뛰기 때문입니다. digest가 항상 32바이트라서 이 접두어도 항상 20 1바이트이고, 그래서 위치가 고정됩니다.previous_digest는 Option이라 genesis 체크포인트(0번)에서만 None(00 1바이트)이고, 나머지는 모두 Some(34바이트)입니다. 그래서 가스 비용과 timestamp의 오프셋은 0번을 제외한 모든 체크포인트에서 고정입니다. checkpoint_commitments부터는 원소 수가 체크포인트마다 다를 수 있는 Vec이므로, 그 뒤의 end_of_epoch_data와 version_specific_data는 앞에서부터 읽어야 합니다..binpb.zst에서는 summary 바이트를 꺼내려면 protobuf를 조금 거쳐야 합니다. 2편에서 본 것처럼 Checkpoint 메시지의 필드 3이 summary이고, 그 안의 필드 1이 bcs, 다시 그 안의 필드 2가 value입니다. protobuf 전체를 파싱하는 라이브러리 없이도 필요한 필드만 따라가면 됩니다. 의존성은 압축 해제용 zstd crate 하나입니다.use std::fs;
fn varint(b: &[u8], i: &mut usize) -> u64 {
let (mut n, mut shift) = (0u64, 0);
loop {
let x = b[*i];
*i += 1;
n |= ((x & 0x7f) as u64) << shift;
shift += 7;
if x < 0x80 {
return n;
}
}
}
/// protobuf 메시지 b에서 length-delimited 필드 num의 바이트를 찾는다.
fn field(b: &[u8], num: u64) -> &[u8] {
let mut i = 0;
while i < b.len() {
let key = varint(b, &mut i);
match key & 7 {
0 => {
varint(b, &mut i);
}
2 => {
let n = varint(b, &mut i) as usize;
if key >> 3 == num {
return &b[i..i + n];
}
i += n;
}
wt => panic!("wire type {wt}"),
}
}
panic!("field {num} not found")
}
fn main() -> std::io::Result<()> {
let raw = zstd::decode_all(fs::File::open("330625964.binpb.zst")?)?;
let summary = field(field(field(&raw, 3), 1), 2); // summary → bcs → value
let u64_at = |off: usize| u64::from_le_bytes(summary[off..off + 8].try_into().unwrap());
println!("epoch {}", u64_at(0));
println!("sequence_number {}", u64_at(8));
println!("network_total_transactions {}", u64_at(16));
println!("timestamp_ms {}", u64_at(123));
Ok(())
}epoch 1271
sequence_number 330625964
network_total_transactions 6138715715
timestamp_ms 1791221118770varint는 1장에서 본 ULEB128과 같은 방식입니다. 덧붙이면, 체크포인트 번호는 Checkpoint의 필드 1(sequence_number)에 protobuf 정수로도 들어 있어서 BCS를 열지 않고도 읽을 수 있습니다.트랜잭션 수 구하기
network_total_transactions는 genesis부터의 누적값입니다. 그래서 직전 체크포인트의 값을 빼면 이 체크포인트의 트랜잭션 수가 됩니다. 둘 다 고정 오프셋 [16..24]에 있습니다.330625964: 6,138,715,715
330625963: 6,138,715,693
차이 = 2201 16에서 16이 22입니다. 앞 섹션의 field와 varint를 그대로 쓰면, ULEB128 두 개를 읽는 것으로 끝납니다.let contents = field(field(field(&raw, 5), 1), 2); // contents → bcs → value
let mut i = 0;
let variant = varint(contents, &mut i); // 1 = V2
let tx_count = varint(contents, &mut i);
println!("contents variant {variant}, transactions {tx_count}");contents variant 1, transactions 22transactions 개수.binpb.zst의 transactions는 반복(repeated) 필드라서, 필드 6이 몇 번 나오는지 세면 됩니다. 이 방법은 BCS를 열지 않습니다. 대신 트랜잭션 원본이 모두 담긴 파일 전체를 훑어야 합니다.6. 검산하지 못한 것
checkpoint_commitments: 샘플의 commitment는 CheckpointArtifactsDigest입니다. Sui 소스를 보면 체크포인트가 바꾼 Object들의 최종 상태(ID, 버전, Object digest)로 만든 Merkle root입니다. 다시 계산하려면 effects를 파싱해 바뀐 Object 목록을 뽑아야 해서, 이 글에서는 계산하지 않았습니다.마무리
.binpb.zst 안의 BCS 조각을 바이트 단위로 읽고, digest로 검산했습니다.20이 붙고, Address와 ObjectID는 붙지 않습니다. 길이가 붙는지는 바이트 크기가 아니라 타입 정의가 정합니다.transactions[]와 objects에는 원본이 들어 있습니다. .chk도 같은 바이트를 담고 있고, 위치만 다릅니다.blake2b-256("타입이름::" ‖ BCS)로 계산한 digest가 summary → contents → 트랜잭션·effects → events로 이어지며 모두 일치했습니다. 서명은 같은 바이트에 intent를 붙여 계산하므로 digest와 다릅니다.TransactionKind)를 정리하고, 사용자 트랜잭션과 시스템 트랜잭션이 각각 무엇을 하는지 살펴봅니다. 이어서 address balance 도입 이후 잔고 변동이 effects에 어떻게 기록되고 정산되는지 봅니다. 마지막으로 balance changes와 이벤트 본문처럼 원본에는 없어서 직접 계산해야 하는 값을 다룹니다.References
@mysten/bcs로 BCS 다루기CheckpointData·CheckpointTransaction 정의.binpb.zst에 담는 필드(FieldMask)Bcs { name, value }·Object 메시지 정의