트랜잭션 구조

요약

트랜잭션은 서명과 메시지로 구성됩니다. 메시지에는 헤더, 계정 주소, 최근 블록해시 및 컴파일된 명령어가 포함됩니다. 최대 직렬화 크기: 1,232바이트.

Transaction는 두 개의 최상위 필드를 가집니다:

  • signatures: 서명 배열
  • message: 처리할 명령어 목록을 포함한 트랜잭션 정보
Transaction
pub struct Transaction {
pub signatures: Vec<Signature>,
pub message: Message,
}

트랜잭션의 두 부분을 보여주는 다이어그램트랜잭션의 두 부분을 보여주는 다이어그램

트랜잭션의 총 직렬화 크기는 PACKET_DATA_SIZE(1,232바이트)를 초과할 수 없습니다. 이 제한은 1,280바이트(IPv6 최소 MTU)에서 네트워크 헤더용 48바이트(IPv6 40바이트 + 프래그먼트 헤더 8바이트)를 뺀 값입니다. 1,232바이트에는 signatures 배열과 message 구조체가 모두 포함됩니다.

트랜잭션 형식과 크기 제한을 보여주는 다이어그램트랜잭션 형식과 크기 제한을 보여주는 다이어그램

서명

signatures 필드는 Signature 값의 컴팩트 인코딩 배열입니다. 각 Signature는 서명자 계정의 개인 키로 서명된 직렬화된 Message의 64바이트 Ed25519 서명입니다. 트랜잭션의 명령어가 참조하는 모든 서명자 계정에 대해 하나의 서명이 필요합니다.

배열의 첫 번째 서명은 트랜잭션 기본 수수료 및 우선순위 수수료를 지불하는 계정인 수수료 지불자의 것입니다. 이 첫 번째 서명은 네트워크에서 트랜잭션을 조회하는 데 사용되는 트랜잭션 ID로도 사용됩니다. 트랜잭션 ID는 일반적으로 트랜잭션 서명이라고 합니다.

수수료 지불자 요구사항:

  • 메시지의 첫 번째 계정(인덱스 0)이어야 하며 서명자여야 합니다.
  • 시스템 프로그램 소유 계정 또는 논스 계정이어야 합니다(validate_fee_payer에서 검증됨).
  • rent_exempt_minimum + total_fee를 충당할 수 있는 충분한 램포트를 보유해야 하며, 그렇지 않으면 트랜잭션이 InsufficientFundsForFee 로 실패합니다.

메시지

message 필드는 트랜잭션의 페이로드를 포함하는 Message 구조체입니다:

Message
pub struct Message {
/// The message header, identifying signed and read-only `account_keys`.
pub header: MessageHeader,
/// All the account keys used by this transaction.
#[serde(with = "short_vec")]
pub account_keys: Vec<Pubkey>,
/// The id of a recent ledger entry.
pub recent_blockhash: Hash,
/// Programs that will be executed in sequence and committed in
/// one atomic transaction if all succeed.
#[serde(with = "short_vec")]
pub instructions: Vec<CompiledInstruction>,
}

헤더

header 필드는 account_keys 배열을 권한 그룹으로 분할하는 세 개의 u8 필드를 가진 MessageHeader 구조체입니다:

  • num_required_signatures: 트랜잭션에 필요한 총 서명 수.
  • num_readonly_signed_accounts: 읽기 전용인 서명된 계정의 수.
  • num_readonly_unsigned_accounts: 읽기 전용인 서명되지 않은 계정의 수.
MessageHeader
pub struct MessageHeader {
/// The number of signatures required for this message to be considered
/// valid. The signers of those signatures must match the first
/// `num_required_signatures` of [`Message::account_keys`].
pub num_required_signatures: u8,
/// The last `num_readonly_signed_accounts` of the signed keys are read-only
/// accounts.
pub num_readonly_signed_accounts: u8,
/// The last `num_readonly_unsigned_accounts` of the unsigned keys are
/// read-only accounts.
pub num_readonly_unsigned_accounts: u8,
}

메시지 헤더의 세 부분을 보여주는 다이어그램메시지 헤더의 세 부분을 보여주는 다이어그램

계정 주소

account_keys 필드는 공개 키의 압축 인코딩된 배열입니다. 각 항목은 트랜잭션의 인스트럭션 중 하나 이상에서 사용되는 계정을 식별합니다. 배열은 모든 계정을 포함해야 하며 다음과 같은 엄격한 순서를 따라야 합니다:

  1. 서명자 + 쓰기 가능
  2. 서명자 + 읽기 전용
  3. 비서명자 + 쓰기 가능
  4. 비서명자 + 읽기 전용

이러한 엄격한 순서는 account_keys 배열을 메시지의 header에 있는 세 개의 카운트와 결합하여 계정별 메타데이터 플래그를 저장하지 않고도 각 계정의 권한을 결정할 수 있게 합니다. 헤더 카운트는 배열을 위에 나열된 네 개의 권한 그룹으로 분할합니다.

계정 주소 배열의 순서를 보여주는 다이어그램계정 주소 배열의 순서를 보여주는 다이어그램

최근 블록해시

recent_blockhash 필드는 두 가지 목적을 제공하는 32바이트 해시입니다:

  1. 타임스탬프: 트랜잭션이 최근에 생성되었음을 증명합니다.
  2. 중복 제거: 동일한 트랜잭션이 두 번 처리되는 것을 방지합니다.

블록해시는 150개의 슬롯 후에 만료됩니다. 트랜잭션이 도착했을 때 블록해시가 더 이상 유효하지 않으면 BlockhashNotFound 로 거부되며, 유효한 지속 가능한 논스 트랜잭션이 아닌 경우에 해당합니다.

getLatestBlockhash RPC 메서드를 사용하면 현재 블록해시와 블록해시가 유효한 마지막 블록 높이를 가져올 수 있습니다.

인스트럭션

instructions 필드는 CompiledInstruction 구조체의 컴팩트 인코딩 배열입니다. 각 CompiledInstruction 는 전체 공개 키가 아닌 account_keys 배열의 인덱스로 계정을 참조합니다. 다음을 포함합니다:

  1. program_id_index: 호출할 프로그램을 식별하는 account_keys의 인덱스.
  2. accounts: 프로그램에 전달할 계정을 지정하는 account_keys의 인덱스 배열.
  3. data: 인스트럭션 식별자와 직렬화된 인수를 포함하는 바이트 배열.
CompiledInstruction
pub struct CompiledInstruction {
/// Index into the transaction keys array indicating the program account that executes this instruction.
pub program_id_index: u8,
/// Ordered indices into the transaction keys array indicating which accounts to pass to the program.
#[serde(with = "short_vec")]
pub accounts: Vec<u8>,
/// The program input data.
#[serde(with = "short_vec")]
pub data: Vec<u8>,
}

인스트럭션의 컴팩트 배열인스트럭션의 컴팩트 배열

트랜잭션 바이너리 형식

트랜잭션은 컴팩트 인코딩 방식을 사용하여 직렬화됩니다. 모든 가변 길이 배열(서명, 계정 키, 인스트럭션)은 컴팩트-u16 길이 인코딩으로 접두사가 붙습니다. 이 형식은 0-127 값에 대해 1바이트를 사용하고 더 큰 값에 대해 2-3바이트를 사용합니다.

레거시 트랜잭션 레이아웃 (네트워크 상):

필드크기설명
num_signatures1-3바이트 (컴팩트-u16)서명 개수
signaturesnum_signatures x 64바이트Ed25519 서명
num_required_signatures1바이트MessageHeader 필드 1
num_readonly_signed1바이트MessageHeader 필드 2
num_readonly_unsigned1바이트MessageHeader 필드 3
num_account_keys1-3바이트 (컴팩트-u16)정적 계정 키 개수
account_keysnum_account_keys x 32바이트공개 키
recent_blockhash32바이트블록해시
num_instructions1-3바이트 (컴팩트-u16)인스트럭션 개수
instructions가변컴파일된 인스트럭션 배열

각 컴파일된 명령어는 다음과 같이 직렬화됩니다:

필드크기설명
program_id_index1바이트계정 키 인덱스
num_accounts1-3바이트 (compact-u16)계정 인덱스 개수
account_indicesnum_accounts x 1바이트계정 키 인덱스
data_len1-3바이트 (compact-u16)instruction data 길이
datadata_len 바이트불투명 instruction data

크기 계산

PACKET_DATA_SIZE = 1,232바이트일 때, 사용 가능한 공간은 다음과 같이 계산할 수 있습니다:

Total = 1232 bytes
- compact-u16(num_sigs) # 1 byte
- num_sigs * 64 # signature bytes
- 3 # message header
- compact-u16(num_keys) # 1 byte
- num_keys * 32 # account key bytes
- 32 # recent blockhash
- compact-u16(num_ixs) # 1 byte
- sum(instruction_sizes) # per-instruction overhead + data

예시: SOL 전송 트랜잭션

아래 다이어그램은 트랜잭션과 명령어가 함께 작동하여 사용자가 네트워크와 상호작용할 수 있도록 하는 방법을 보여줍니다. 이 예시에서는 한 계정에서 다른 계정으로 SOL이 전송됩니다.

발신자 계정의 메타데이터는 트랜잭션에 서명해야 함을 나타냅니다. 이를 통해 System Program이 lamport를 차감할 수 있습니다. lamport 잔액이 변경되려면 발신자와 수신자 계정 모두 쓰기 가능해야 합니다. 이 명령어를 실행하기 위해 발신자의 지갑은 서명과 SOL 전송 명령어가 포함된 메시지를 담은 트랜잭션을 전송합니다.

SOL 전송 다이어그램SOL 전송 다이어그램

트랜잭션이 전송된 후, System Program은 전송 명령어를 처리하고 두 계정의 lamport 잔액을 업데이트합니다.

SOL 전송 프로세스 다이어그램SOL 전송 프로세스 다이어그램

SOL을 전송하기 전에 수신자를 확인하세요

System Program 전송은 모든 계정에 lamport를 추가합니다. 수신자가 SOL을 다시 꺼낼 수 있는지에 대한 프로토콜 수준의 검사는 없습니다. lamport는 계정의 소유 프로그램에 의해서만 이동할 수 있으므로, 토큰 민트, 프로그램, 또는 직접 제어하지 않는 PDA로 SOL을 전송하면 영구적인 자금 손실의 위험이 있습니다 — 소유 프로그램이 지정한 권한자만이 이를 반환할 수 있습니다. token account로 전송된 SOL은 해당 계정의 소유자만 회수할 수 있으며, 발신자는 회수할 수 없습니다.

SPL 토큰 전송은 부분적으로 자체 보호 기능이 있습니다. Token Program은 계정이 예상한 민트와 일치하지 않는 전송을 거부합니다. 네이티브 SOL 전송에는 이러한 보호 장치가 없으므로, 서명 전에 발신자가 수신자를 직접 확인해야 합니다. 전체 분류 로직은 주소 확인을 참고하세요.

아래 예제는 위 다이어그램과 관련된 코드를 보여줍니다. System Program의 transfer 함수를 참고하세요.

import { createClient, generateKeyPairSigner, lamports } from "@solana/kit";
import { solanaRpc, rpcAirdrop } from "@solana/kit-plugin-rpc";
import { generatedPayer, airdropPayer } from "@solana/kit-plugin-signer";
import { systemProgram } from "@solana-program/system";
const client = await createClient()
.use(generatedPayer())
.use(
solanaRpc({
rpcUrl: "http://localhost:8899",
rpcSubscriptionsUrl: "ws://localhost:8900"
})
)
.use(rpcAirdrop())
.use(airdropPayer(lamports(1_000_000_000n)))
.use(systemProgram());
const sender = client.payer;
const recipient = await generateKeyPairSigner();
const LAMPORTS_PER_SOL = 1_000_000_000n;
const transferAmount = lamports(LAMPORTS_PER_SOL / 100n); // 0.01 SOL
// Check balance before transfer
const { value: preBalance1 } = await client.rpc
.getBalance(sender.address)
.send();
const { value: preBalance2 } = await client.rpc
.getBalance(recipient.address)
.send();
// Create a transfer instruction for transferring SOL from sender to recipient
const transferInstruction = client.system.instructions.transferSol({
source: sender,
destination: recipient.address,
amount: transferAmount // 0.01 SOL in lamports
});
const transactionSignature = await client.sendTransaction([
transferInstruction
]);
// Check balance after transfer
const { value: postBalance1 } = await client.rpc
.getBalance(sender.address)
.send();
const { value: postBalance2 } = await client.rpc
.getBalance(recipient.address)
.send();
console.log(
"Sender prebalance:",
Number(preBalance1) / Number(LAMPORTS_PER_SOL)
);
console.log(
"Recipient prebalance:",
Number(preBalance2) / Number(LAMPORTS_PER_SOL)
);
console.log(
"Sender postbalance:",
Number(postBalance1) / Number(LAMPORTS_PER_SOL)
);
console.log(
"Recipient postbalance:",
Number(postBalance2) / Number(LAMPORTS_PER_SOL)
);
console.log("Transaction Signature:", transactionSignature.context.signature);
Console
Click to execute the code.

다음 예제는 단일 SOL 전송 명령어를 포함하는 트랜잭션의 구조를 보여줍니다.

import {
createClient,
generateKeyPairSigner,
lamports,
createTransactionMessage,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstructions,
pipe,
signTransactionMessageWithSigners,
getCompiledTransactionMessageDecoder
} from "@solana/kit";
import { solanaRpc, rpcAirdrop } from "@solana/kit-plugin-rpc";
import { generatedPayer, airdropPayer } from "@solana/kit-plugin-signer";
import { systemProgram } from "@solana-program/system";
const client = await createClient()
.use(generatedPayer())
.use(
solanaRpc({
rpcUrl: "http://localhost:8899",
rpcSubscriptionsUrl: "ws://localhost:8900"
})
)
.use(rpcAirdrop())
.use(airdropPayer(lamports(1_000_000_000n)))
.use(systemProgram());
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send();
const sender = client.payer;
const recipient = await generateKeyPairSigner();
// Define the amount to transfer
const LAMPORTS_PER_SOL = 1_000_000_000n;
const transferAmount = lamports(LAMPORTS_PER_SOL / 100n); // 0.01 SOL
// Create a transfer instruction for transferring SOL from sender to recipient
const transferInstruction = client.system.instructions.transferSol({
source: sender,
destination: recipient.address,
amount: transferAmount
});
// Create transaction message
const transactionMessage = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(sender, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions([transferInstruction], tx)
);
const signedTransaction =
await signTransactionMessageWithSigners(transactionMessage);
// Decode the messageBytes
const compiledTransactionMessage =
getCompiledTransactionMessageDecoder().decode(signedTransaction.messageBytes);
console.log(JSON.stringify(compiledTransactionMessage, null, 2));
Console
Click to execute the code.

아래 코드는 이전 코드 스니펫의 출력 결과를 보여줍니다. SDK마다 형식은 다르지만, 각 명령어에는 동일한 필수 정보가 포함되어 있다는 점에 주목하세요.

{
"version": 0,
"header": {
"numSignerAccounts": 1,
"numReadonlySignerAccounts": 0,
"numReadonlyNonSignerAccounts": 1
},
"staticAccounts": [
"HoCy8p5xxDDYTYWEbQZasEjVNM5rxvidx8AfyqA4ywBa",
"5T388jBjovy7d8mQ3emHxMDTbUF8b7nWvAnSiP3EAdFL",
"11111111111111111111111111111111"
],
"lifetimeToken": "EGCWPUEXhqHJWYBfDirq3mHZb4qDpATmYqBZMBy9TBC1",
"instructions": [
{
"programAddressIndex": 2,
"accountIndices": [0, 1],
"data": {
"0": 2,
"1": 0,
"2": 0,
"3": 0,
"4": 128,
"5": 150,
"6": 152,
"7": 0,
"8": 0,
"9": 0,
"10": 0,
"11": 0
}
}
]
}

전송 전에 수신자를 확인하세요

SOL 전송은 모든 계정으로 성공적으로 이루어지기 때문에, 서명 전에 수신자를 확인해야 합니다. 계정을 조회하고 System Program 지갑(또는 자금이 없는 온커브 주소)으로만 전송하세요. 민트, token account, 프로그램, 그리고 직접 제어하지 않는 PDA로의 전송은 거부해야 합니다.

Kit
import {
type Address,
createSolanaRpc,
fetchJsonParsedAccount,
isOffCurveAddress
} from "@solana/kit";
const rpc = createSolanaRpc("https://api.mainnet-beta.solana.com");
const SYSTEM_PROGRAM = "11111111111111111111111111111111" as Address;
/**
* Throws if `recipient` cannot safely receive native SOL.
*
* Only System Program wallets (or unfunded on-curve addresses) are safe. Any
* other account locks the lamports because no authority can debit them.
*/
async function assertSafeSolRecipient(recipient: Address): Promise<void> {
const account = await fetchJsonParsedAccount(rpc, recipient);
if (!account.exists) {
// Off-curve = a PDA with no account; reject conservatively.
if (isOffCurveAddress(recipient)) {
throw new Error(
"Recipient is a PDA with no account; SOL would be locked"
);
}
// On-curve = an unfunded wallet, safe to fund.
return;
}
if (account.programAddress !== SYSTEM_PROGRAM) {
throw new Error(
`Recipient is owned by ${account.programAddress}, not a wallet; SOL would be locked`
);
}
}
// A wallet: safe.
await assertSafeSolRecipient(
"H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS" as Address
);
// The USDC mint: rejected before any SOL leaves the sender.
await assertSafeSolRecipient(
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" as Address
);
Console
Click to execute the code.

이 코드 조각은 네이티브 SOL 수신자를 확인합니다. SPL 토큰 전송(token account, ATA, Token-2022)도 처리하는 전체 분류 방법은 주소 확인을 참조하세요.

트랜잭션 세부 정보 가져오기

제출 후, 트랜잭션 서명과 getTransaction RPC 메서드를 사용하여 트랜잭션 세부 정보를 조회할 수 있습니다.

Solana Explorer를 사용하여 트랜잭션을 조회할 수도 있습니다.

Transaction Data
{
"blockTime": 1745196488,
"meta": {
"computeUnitsConsumed": 150,
"err": null,
"fee": 5000,
"innerInstructions": [],
"loadedAddresses": {
"readonly": [],
"writable": []
},
"logMessages": [
"Program 11111111111111111111111111111111 invoke [1]",
"Program 11111111111111111111111111111111 success"
],
"postBalances": [989995000, 10000000, 1],
"postTokenBalances": [],
"preBalances": [1000000000, 0, 1],
"preTokenBalances": [],
"rewards": [],
"status": {
"Ok": null
}
},
"slot": 13049,
"transaction": {
"message": {
"header": {
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 1,
"numRequiredSignatures": 1
},
"accountKeys": [
"8PLdpLxkuv9Nt8w3XcGXvNa663LXDjSrSNon4EK7QSjQ",
"7GLg7bqgLBv1HVWXKgWAm6YoPf1LoWnyWGABbgk487Ma",
"11111111111111111111111111111111"
],
"recentBlockhash": "7ZCxc2SDhzV2bYgEQqdxTpweYJkpwshVSDtXuY7uPtjf",
"instructions": [
{
"accounts": [0, 1],
"data": "3Bxs4NN8M2Yn4TLb",
"programIdIndex": 2,
"stackHeight": null
}
],
"indexToProgramIds": {}
},
"signatures": [
"3jUKrQp1UGq5ih6FTDUUt2kkqUfoG2o4kY5T1DoVHK2tXXDLdxJSXzuJGY4JPoRivgbi45U2bc7LZfMa6C4R3szX"
]
},
"version": "legacy"
}

Is this page helpful?

목차

페이지 편집