요약
Solana에는 세 가지 트랜잭션 형식이 있습니다: 레거시, v0, v1. v0는 1바이트 인덱스로 계정을 참조하는 주소 조회 테이블(ALT)을 추가합니다. v1은 크기 제한을 4,096바이트로 높이고, 리소스 한도를 메시지 자체로 이동하며, ALT를 제거합니다.
Solana는 레거시, v0, v1의 세 가지 트랜잭션 형식을 지원합니다. 각 형식은 동일한 세 가지 측면으로 설명됩니다: 와이어에서의 바이트 배치 방식, 계정 참조 방식, 리소스 한도의 출처.
형식 비교
| 한도 | 레거시 | v0 | v1 |
|---|---|---|---|
| 최대 트랜잭션 크기 | 1,232바이트 | 1,232바이트 | 4,096바이트 |
| 계정 주소 | ~32개, 크기 제한 | 64개, 조회 테이블 경유 | 64개, 인라인 (SIMD-0596 적용 시 96개) |
| 주소 조회 테이블 | 미지원 | 지원 | 미지원 |
| 리소스 한도 | ComputeBudget 명령어 | ComputeBudget 명령어 | 메시지 설정 |
레거시 형식
원래의 형식으로, 대부분의 툴링에서 여전히 기본값입니다. 버전 접두사가 전혀 없으며, 트랜잭션의 첫 번째 바이트는 서명 배열의 compact-u16 개수이고, 메시지의 첫 번째 바이트는 num_required_signatures로 상위 비트가 항상 설정되지 않습니다.
레거시 와이어 레이아웃
| 필드 | 크기 | 설명 |
|---|---|---|
num_signatures | compact-u16 | 서명 수 |
signatures | num_signatures x 64바이트 | Ed25519 서명 |
header | 3바이트 | MessageHeader — 첫 번째 바이트의 버전 비트가 설정되지 않음 |
num_account_keys | compact-u16 | 계정 키 수 |
account_keys | num_account_keys x 32바이트 | 공개 키, 모두 인라인 |
recent_blockhash | 32바이트 | 수명 지정자 |
num_instructions | compact-u16 | 명령어 수 |
instructions | 가변 | 각 명령어가 연속으로 직렬화됨 |
모든 가변 길이 배열은 compact-u16 길이로 접두사됩니다: 0–127 값은 1바이트, 더 큰 값은 2–3바이트. 명령어별 레이아웃과 크기 계산 예시는 트랜잭션 이진 형식을 참조하세요.
레거시의 계정
모든 계정은 account_keys에 32바이트 공개 키 전체로 기록되며, 명령어는 해당 배열의 1바이트 인덱스로 이를 참조합니다. 트랜잭션에 명시되지 않은 계정을 참조할 방법이 없으므로, 레거시 트랜잭션은 1,232바이트 한도에 도달하기 전에 약 32개의 계정으로 제한됩니다.
레거시의 리소스 한도
컴퓨트 유닛 한도, 로드된 계정 데이터 크기 한도, 힙 크기, 우선순위 수수료는 모두 트랜잭션에 ComputeBudget 프로그램 명령어를 포함하여 요청합니다. 각 명령어는 명령어 슬롯 하나와 150 컴퓨트 유닛을 소비합니다. 생략해도 안전합니다: 런타임은 명령어당 200,000 CU(최대 1.4M), 64 MiB 데이터 크기 한도, 32 KiB 힙, 우선순위 수수료 0의 기본값으로 대체합니다.
V0 형식
v0는 레거시 메시지에 두 가지가 추가된 형식입니다: 0x80 버전 접두사 바이트와 명령어 뒤에 추가되는 address_table_lookups 배열. 그 이전의 모든 것은 레거시와 바이트 단위로 동일합니다.
V0 와이어 레이아웃
| 필드 | 크기 | 설명 |
|---|---|---|
num_signatures | compact-u16 | 서명 수 |
signatures | num_signatures x 64바이트 | Ed25519 서명 |
0x80 | 1바이트 | 버전 접두사 바이트 — 메시지의 첫 번째 바이트 |
header | 3바이트 | MessageHeader(레거시와 동일) |
num_account_keys | compact-u16 | 정적 계정 키 수 |
static_account_keys | num_account_keys x 32바이트 | 트랜잭션에 문자 그대로 나타나는 키 |
recent_blockhash | 32바이트 | 수명 지정자 |
num_instructions | compact-u16 | 명령어 수 |
instructions | 가변 | 레거시와 동일한 형식 |
address_table_lookups | compact-u16 + 가변 | ALT 참조(아래 참조) |
각 주소 테이블 조회 항목에는 다음이 포함됩니다:
| 필드 | 크기 | 설명 |
|---|---|---|
account_key | 32바이트 | ALT 계정의 공개 키 |
writable_indexes | compact-u16 + N x 1바이트 | 쓰기 가능한 계정에 대한 ALT 인덱스 |
readonly_indexes | compact-u16 + N x 1바이트 | 읽기 전용 계정에 대한 ALT 인덱스 |
주소 조회 테이블
ALT는 최대 256개의 공개 키를 저장하는 온체인 계정입니다. ALT를 참조함으로써 트랜잭션은 32바이트 공개 키 대신 1바이트 인덱스를 사용하여 추가 계정을 포함할 수 있으며, 이를 통해 계정당 오버헤드를 크게 줄일 수 있습니다.
런타임에서 실행이 시작되기 전에 validator는 모든 ALT 참조를 전체 공개 키로 해석합니다. 해석된 주소는 정적 계정 키에 추가되어 전체 계정 키 목록을 형성합니다. ALT로 해석된 계정은 정적 계정과 동일한 순서를 따릅니다: 쓰기 가능한 조회가 읽기 전용 조회보다 먼저 옵니다.
주소 조회 테이블은 온와이어 트랜잭션에서 계정이 참조되는 방식에만 영향을 미칩니다. 실행 시점에 런타임은 모든 인덱스를 전체 계정 주소로 해석합니다. ALT로 해석된 계정은 쓰기 가능하거나 읽기 전용(비서명자)일 수만 있으며, 서명자가 될 수 없습니다.
주소 조회 테이블 생성 및 사용
이 예제는 조회 테이블을 생성하고, 주소를 추가한 다음, 해당 테이블을 사용하여 v0 트랜잭션을 컴파일하고 전송합니다.
조회 테이블에서는 서명자가 아닌 계정만 로드할 수 있습니다. 수수료 지불자와 필요한 모든 서명자는 메시지의 정적 계정 목록에 그대로 남습니다. 또한 주소는 추가된 것과 동일한 slot에서는 조회할 수 없으므로, 이 예제에서는 v0 메시지를 컴파일하기 전에 다음 확정된 slot을 기다립니다.
v0의 리소스 한도
레거시와 동일: ComputeBudget 명령어를 사용하며, 생략 시 동일한 기본값이 적용됩니다.
V1 형식
v1은 크기 제한을 4,096바이트로 높이고 트랜잭션 설정을 중심으로 메시지를 재구성합니다: 리소스 한도가 ComputeBudget 명령어에서 벗어나 메시지 자체의 고정 위치 필드로 이동합니다. 이를 통해 네트워크는 명령어 목록을 스캔하고 역직렬화하는 대신, 단일 고정 오프셋 읽기만으로 트랜잭션의 우선순위 수수료를 기준으로 순위를 매길 수 있습니다.
V1 와이어 레이아웃
| 필드 | 크기 | 설명 |
|---|---|---|
0x81 | 1바이트 | 버전 접두사 바이트 — 트랜잭션의 첫 번째 바이트 |
header | 3바이트 | MessageHeader(레거시와 동일) |
config_mask | 4바이트 | 어떤 설정 값이 존재하는지 표시하는 u32 LE 비트마스크 |
recent_blockhash | 32바이트 | 수명 지정자 |
num_instructions | 1바이트 | 고정 너비 카운트, 최대 64 |
num_addresses | 1바이트 | 고정 너비 카운트, 최대 64 (SIMD-0596 적용 시 96) |
addresses | N x 32바이트 | 계정 주소, 모두 인라인 — 조회 테이블 참조 없음 |
config_values | 0–20바이트 | 설정된 마스크 비트당 하나의 값, 비트 순서대로 (아래 참조) |
instruction_headers | N x 4바이트 | 명령어별: program_id_index (u8), num_accounts (u8), data_len (u16 LE) |
instruction_payloads | 가변 | 명령어별: 계정 인덱스, 이후 instruction data |
signatures | N x 64바이트 | 끝부분에 위치, 길이 접두사 없음 — 개수는 헤더에서 가져옴 |
디코더를 작성할 때 레거시 및 v0와의 두 가지 구조적 차이점을 주목해야 합니다. 카운트는 compact-u16 대신 고정 너비 u8 필드이며, 명령어는 두 구간으로 분리됩니다: 각 명령어가 연속으로 오는 대신, 고정 크기 헤더 전체가 먼저 오고 그 다음에 가변 길이 페이로드 전체가 옵니다.
v1의 계정: 주소 조회 테이블 없음
v1은 ALT 지원을 의도적으로 제거했습니다. 원시 주소 64개는 2,048바이트이고, SIMD-0596 이후 허용되는 주소 96개는 3,072바이트로, 둘 다 4,096바이트 한도 안에 여유 있게 들어가므로 레거시와 마찬가지로 모든 주소가 인라인으로 포함됩니다. 애플리케이션이 조회 테이블에 의존한다면, v1로 이동하는 것은 해당 주소를 인라인으로 포함해야 한다는 의미입니다.
v1의 리소스 한도: 트랜잭션 설정
설정은 u32 비트마스크와 각 비트가 설정된 필드의 고정 너비 값으로 구성됩니다:
| 비트 | 필드 | 너비 | 비고 |
|---|---|---|---|
| 0–1 | 우선순위 수수료 | u64 | 총 lamport — 두 비트를 함께 설정 |
| 2 | 컴퓨트 유닛 한도 | u32 | |
| 3 | 로드된 계정 데이터 크기 한도 | u32 | |
| 4 | 요청된 힙 크기 | u32 |
알 수 없는 비트는 거부됩니다. 메시지가 서명되므로, 인식되지 않는 설정 필드는 자동으로 제거될 수 없습니다.
우선순위 수수료는 가격이 아닌 총 lamport입니다
레거시 및 v0에서 우선순위 수수료는 SetComputeUnitPrice를 통해 컴퓨트 유닛당 마이크로 lamport로 설정되며, 컴퓨트 유닛 한도를 곱합니다. v1에서는 lamport 단위의 절대 합계입니다 — 곱셈도, 반올림도 없습니다. 기존의 CU당 계산 방식을 그대로 가져오지 마세요. 총 수수료 공식은 그 외에는 동일합니다: (서명 수 × lamports_per_signature) + priority_fee.
ComputeBudget 명령어는 v1에서 no-op입니다
v1 트랜잭션은 ComputeBudget 명령어를 거부하지 않습니다 — 설정을 위해 무시할 뿐입니다. 해당 명령어는 여전히 성공적인 no-op으로 실행되어 150 컴퓨트 유닛과 64개 명령어 슬롯 중 하나를 소비하지만 예산에는 아무런 영향을 미치지 않습니다. v1 트랜잭션을 빌드할 때는 이를 제거하고, v1 트랜잭션을 읽을 때는 이를 스캔하는 것을 중단하세요: 값은 메시지 설정에 있습니다.
설정 필드는 반드시 명시적으로 설정해야 합니다
송신자에게 가장 중요한 동작 변경 사항: 레거시 및 v0와 달리, v1 트랜잭션은 컴퓨트 유닛 한도와 로드된 계정 데이터 크기 한도를 명시적으로 설정해야 하며, 그렇지 않으면 트랜잭션이 실패합니다.
| 미설정 필드 | 레거시 / v0 | v1 | 생략 시 증상 |
|---|---|---|---|
| 컴퓨트 유닛 한도 | 명령어당 200k, 최대 1.4M | 0 CU | 즉시 실패, 예산 초과 |
| 로드된 계정 데이터 크기 | 64 MiB | 0바이트 | 첫 번째 계정 로드 시 MaxLoadedAccountsDataSizeExceeded |
| 우선순위 수수료 | 0 | 0 | — |
| 힙 크기 | 32 KiB | 32 KiB | — |
권장하는 방법은 두 한도를 모두 최대값으로 설정하여 한 번 시뮬레이션한 후, 반환된 unitsConsumed와 loadedAccountsDataSize를 설정에 다시 기록하는 것입니다. 데이터 크기는 여유 공간 확보를 위해 다음 32 KiB 페이지 경계까지 올림합니다 (블록 비용 모델은 32 KiB 페이지 단위로 청구하므로, 다음 페이지 경계 이전의 여유 공간은 무료입니다).
v1 트랜잭션 빌드하기
v1 메시지는 레거시 또는 v0 메시지와 동일한 파이프라인을 통해 조립됩니다.
차이점은 setTransactionMessageConfig입니다: 리소스 한도는 메시지의 속성이므로
명령어 목록에는 프로그램 명령어만 포함됩니다.
import {appendTransactionMessageInstruction,assertIsTransactionWithBlockhashLifetime,createClient,createTransactionMessage,generateKeyPairSigner,getSignatureFromTransaction,lamports,pipe,sendAndConfirmTransactionFactory,setTransactionMessageConfig,setTransactionMessageFeePayerSigner,setTransactionMessageLifetimeUsingBlockhash,signTransactionMessageWithSigners,} from "@solana/kit";import { rpcAirdrop, solanaRpc } from "@solana/kit-plugin-rpc";import { airdropPayer, payer } from "@solana/kit-plugin-signer";import { getTransferSolInstruction } from "@solana-program/system";const sender = await generateKeyPairSigner();const recipient = await generateKeyPairSigner();const client = await createClient().use(payer(sender)).use(solanaRpc({rpcUrl: "http://localhost:8899",rpcSubscriptionsUrl: "ws://localhost:8900",}),).use(rpcAirdrop()).use(airdropPayer(lamports(1_000_000_000n)));const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send();// A v1 message is assembled through the same pipeline as a legacy or v0 one.// The difference is the config: a v1 transaction carries its resource limits in// the message itself, so no ComputeBudget instructions are compiled in.const transactionMessage = pipe(createTransactionMessage({ version: 1 }),(m) => setTransactionMessageFeePayerSigner(sender, m),(m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),(m) =>appendTransactionMessageInstruction(getTransferSolInstruction({source: sender,destination: recipient.address,amount: lamports(10_000_000n),}),m,),// The compute unit limit and loaded accounts data size limit both default to// zero on v1, so a transaction that leaves them unset always fails. The// priority fee is an absolute total in lamports, not a price per compute// unit.(m) =>setTransactionMessageConfig({computeUnitLimit: 20_000,loadedAccountsDataSizeLimit: 64 * 1024,priorityFeeLamports: 5_000n,},m,),);const signedTransaction =await signTransactionMessageWithSigners(transactionMessage);assertIsTransactionWithBlockhashLifetime(signedTransaction);await sendAndConfirmTransactionFactory({rpc: client.rpc,rpcSubscriptions: client.rpcSubscriptions,})(signedTransaction, { commitment: "confirmed" });const transactionSignature = getSignatureFromTransaction(signedTransaction);console.log("Transaction Signature:", transactionSignature);
Kit은 설정 필드별 setter도 제공합니다 —
setTransactionMessagePriorityFeeLamports,
setTransactionMessageComputeUnitLimit,
setTransactionMessageLoadedAccountsDataSizeLimit,
setTransactionMessageHeapSize — 하나씩 설정할 때 사용할 수 있습니다.
시뮬레이션으로 한도 산정하기
필수 한도 두 가지를 추측하는 대신 직접 측정하세요. 시뮬레이션은 두 한도를 모두 런타임 최대값으로 올려 실행하므로, 측정 대상인 리소스가 부족해서 실패하는 일이 없습니다. 반환되는 수치는 시뮬레이션 1회 실행의 정확한 비용이므로, 이를 사용하기 전에 여유분을 추가하세요.
import {appendTransactionMessageInstruction,assertIsTransactionWithBlockhashLifetime,createClient,createTransactionMessage,estimateAndSetResourceLimitsFactory,estimateResourceLimitsFactory,fillTransactionMessageProvisoryResourceLimits,generateKeyPairSigner,getSignatureFromTransaction,lamports,pipe,sendAndConfirmTransactionFactory,setTransactionMessageFeePayerSigner,setTransactionMessageLifetimeUsingBlockhash,setTransactionMessagePriorityFeeLamports,signTransactionMessageWithSigners,} from "@solana/kit";import { rpcAirdrop, solanaRpc } from "@solana/kit-plugin-rpc";import { airdropPayer, payer } from "@solana/kit-plugin-signer";import { getTransferSolInstruction } from "@solana-program/system";const sender = await generateKeyPairSigner();const recipient = await generateKeyPairSigner();const client = await createClient().use(payer(sender)).use(solanaRpc({rpcUrl: "http://localhost:8899",rpcSubscriptionsUrl: "ws://localhost:8900",}),).use(rpcAirdrop()).use(airdropPayer(lamports(1_000_000_000n)));const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send();// The priority fee is a pricing decision that simulation cannot measure, so it// is set directly. The two resource limits are measured below instead of// guessed. The provisory fill writes placeholder limits so the message// simulates at the same size it will be sent at.const draftMessage = pipe(createTransactionMessage({ version: 1 }),(m) => setTransactionMessageFeePayerSigner(sender, m),(m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),(m) =>appendTransactionMessageInstruction(getTransferSolInstruction({source: sender,destination: recipient.address,amount: lamports(10_000_000n),}),m,),(m) => setTransactionMessagePriorityFeeLamports(5_000n, m),fillTransactionMessageProvisoryResourceLimits,);// Simulation runs with both limits raised to the runtime maximum, so it cannot// fail for want of the resources it is measuring.const estimateAndSetResourceLimits = estimateAndSetResourceLimitsFactory(estimateResourceLimitsFactory({ rpc: client.rpc }),);const transactionMessage = await estimateAndSetResourceLimits(draftMessage, {commitment: "confirmed",});console.log("Compute unit limit:", transactionMessage.config?.computeUnitLimit);console.log("Loaded accounts data size limit:",transactionMessage.config?.loadedAccountsDataSizeLimit,);const signedTransaction =await signTransactionMessageWithSigners(transactionMessage);assertIsTransactionWithBlockhashLifetime(signedTransaction);await sendAndConfirmTransactionFactory({rpc: client.rpc,rpcSubscriptions: client.rpcSubscriptions,})(signedTransaction, { commitment: "confirmed" });// The estimate is the exact cost of one simulated run, with nothing to spare.// Add a margin before relying on it in production.console.log("Transaction Signature:",getSignatureFromTransaction(signedTransaction),);
v1 준비하기
v1은 트랜잭션 전송뿐 아니라 읽기도 변경합니다. 옵트인하지 않고
getTransaction 또는 getBlock을 호출하는 모든 클라이언트는 v1 트랜잭션에서 실패합니다:
getTransaction및getBlock에maxSupportedTransactionVersion: 1— 문자열"1"이 아닌 JSON 정수1— 을 전달하세요.0을 전달하면 매개변수를 생략한 것과 마찬가지로 v1 트랜잭션에서 실패하므로, v0 출시 당시에 업데이트된 코드베이스도 값을 변경해야 합니다.getTransaction은 v1 트랜잭션에서 오류-32015로 실패하며, v1 트랜잭션 하나가 동일한 오류로 전체getBlock응답을 실패시킵니다 — 부분 결과는 없습니다.blockSubscribe는block: null을 반환하고 진행을 멈추므로, 이를 빈 블록으로 읽는 소비자는 첫 번째 v1 slot부터 조용히 뒤처지게 됩니다.getSignaturesForAddress는 트랜잭션 본문을 검사하지 않으므로, v1 서명은 정상적으로 나열됩니다.- 옵트인한 응답에는 v1 트랜잭션의 경우 메시지에
transactionConfig객체가 포함됩니다(레거시와 v0에는 아예 없음). ComputeBudget 명령어를 스캔하여 우선순위 수수료나 컴퓨트 한도를 도출하는 파이프라인은 모든 v1 트랜잭션에 대해 아무 알림 없이 0을 보고합니다. - 클라이언트 측에서 트랜잭션을 디코딩할 때, 그리고 1,232바이트를 초과하는 트랜잭션에
sendTransaction/simulateTransaction을 사용할 때는encoding: 'base64'를 사용하세요 — base58 인코딩은 여전히 이전 크기 한도에 묶여 있습니다. - 클라이언트 라이브러리 지원은 최신 버전이 필요합니다:
@solana/kit8.0 이상, Agave 4.2.x 세대 Rust 크레이트, 또는 web3.js v3. web3.js v1은 1.99.0부터 v1을 읽을 수 있지만, 빌드하거나 전송할 수는 없습니다.
v1 트랜잭션 읽기
옵트인하려면 JSON 정수 1을 전달합니다. 반환된 바이트를 디코딩하면 버전과 설정을
얻을 수 있으며, 리소스 한도는 바로 여기에 있습니다 — 명령어 목록에서 ComputeBudget
명령어를 스캔해도 아무것도 찾을 수 없습니다.
import {decompileTransactionMessage,getBase64Encoder,getCompiledTransactionMessageDecoder,getTransactionDecoder,} from "@solana/kit";// Reading a v1 transaction requires opting in. The integer 1 is mandatory:// omitting the parameter, or passing 0, fails on a v1 transaction rather than// returning it in a degraded form.const fetched = await client.rpc.getTransaction(transactionSignature, {commitment: "confirmed",encoding: "base64",maxSupportedTransactionVersion: 1,}).send();if (fetched === null) {throw new Error("the transaction just sent was not found");}// The compiled message stores the config as a bitmask plus a positional list,// so it is decompiled to address the fields by name. Decompiling fetches no// accounts, because v1 does not support address lookup tables.const wireTransaction = getBase64Encoder().encode(fetched.transaction[0]);const transaction = getTransactionDecoder().decode(wireTransaction);const compiledMessage = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes,);const message = decompileTransactionMessage(compiledMessage);console.log("Version:", message.version);if ("config" in message) {// Scanning the instruction list for ComputeBudget instructions finds nothing// here — on v1 the limits live in the config.console.log("Compute unit limit:", message.config?.computeUnitLimit);console.log("Priority fee (lamports):", message.config?.priorityFeeLamports);}
클라이언트 라이브러리 지원, 스트리밍(Geyser/gRPC) 버전 감지, 시뮬레이션 동작을 포함한 전체 마이그레이션 가이드는 Transaction Format v1 업그레이드 페이지를 참조하세요. TypeScript, Rust, Python, Go로 작성된 실행 가능한 예제는 solana-foundation/transaction-v1-examples에서 확인할 수 있습니다.
Is this page helpful?