Solana v0 transaction message building, serialization, and decoding.
Implements exactly what the exact SVM scheme needs, with no RPC and no
new dependencies:
- Instruction constructors for the reference client's instruction set —
Compute Budget
SetComputeUnitLimit/SetComputeUnitPrice, SPL Token / Token-2022TransferChecked, and SPL Memo compile/3— compiles instructions into a serialized version 0 message (the version the reference TypeScript and Python clients produce), including compact-u16 (shortvec) encoding, account deduplication, and the account ordering used by@solana/kit, so the output is byte-identical to the reference clientserialize/2— the wire transaction: compact-u16 signature count followed by 64-byte signature slots (missing signatures are all-zero placeholders, which is how a partially signed transaction represents the fee payer's pending signature) and the message bytesdecode/1— parses a wire transaction (v0 or legacy) back into its parts for structural validation
Account ordering
Static accounts are ordered: fee payer first, then writable signers,
read-only signers, writable non-signers, read-only non-signers. Within a
group, addresses sort with @solana/kit's comparator (case-insensitive
primary pass, lowercase-first tiebreak) so compiled messages are
byte-identical to the reference client's.
Signing
Ed25519 signatures cover the entire serialized message returned by
compile/3, including the leading 0x80 version byte.
Summary
Types
An account referenced by an instruction.
A compiled v0 message ready to sign.
A decoded wire transaction.
An instruction to compile into a message.
Functions
Splices a 64-byte Ed25519 signature into a decoded transaction's slot.
Compiles instructions into a serialized v0 message.
Decodes a wire transaction (v0 or legacy) into its parts.
Decodes a compact-u16 prefix, returning the value and the rest.
Encodes a non-negative integer as compact-u16 (shortvec).
The Solana network's maximum serialized transaction size in bytes.
An SPL Memo instruction carrying UTF-8 data (no accounts).
Serializes a wire transaction from message bytes and signatures.
The Compute Budget SetComputeUnitLimit instruction (discriminator 2,
u32 little-endian units).
The Compute Budget SetComputeUnitPrice instruction (discriminator 3,
u64 little-endian microlamports).
The SPL Token / Token-2022 TransferChecked instruction.
Types
@type account_meta() :: %{ address: X402.Solana.address(), signer?: boolean(), writable?: boolean() }
An account referenced by an instruction.
@type compiled() :: %{bytes: binary(), signers: [X402.Solana.address()]}
A compiled v0 message ready to sign.
@type decoded() :: %{ version: 0 | :legacy, num_required_signatures: non_neg_integer(), num_readonly_signed: non_neg_integer(), num_readonly_unsigned: non_neg_integer(), signatures: [binary()], static_accounts: [X402.Solana.pubkey()], recent_blockhash: X402.Solana.pubkey(), instructions: [ %{program_index: byte(), account_indices: [byte()], data: binary()} ], address_table_lookups: non_neg_integer(), message_bytes: binary() }
A decoded wire transaction.
@type instruction() :: %{ program: X402.Solana.address(), accounts: [account_meta()], data: binary() }
An instruction to compile into a message.
Functions
@spec attach_signature(decoded(), non_neg_integer(), binary()) :: {:ok, binary()} | {:error, :invalid_signature | :invalid_slot}
Splices a 64-byte Ed25519 signature into a decoded transaction's slot.
Rebuilds the wire transaction from a decode/1 result — compact-u16
signature count, the signature slots with signature at index, then the
message bytes — preserving every other existing signature. This is how
a facilitator fills the fee payer's empty slot 0 at settlement without
disturbing the payer's signature.
Unlike serialize/2 (which zero-fills missing signatures by design, the
partially-signed representation), a malformed signature here returns
{:error, :invalid_signature}: silently broadcasting a zeroed fee-payer
slot would only fail later on chain. An out-of-range index returns
{:error, :invalid_slot}.
Examples
iex> decoded = %{
...> num_required_signatures: 2,
...> signatures: [<<0::512>>, <<1::512>>],
...> message_bytes: <<0x80, 2, 1, 4>>
...> }
iex> {:ok, wire} = X402.Solana.Transaction.attach_signature(decoded, 0, <<9::512>>)
iex> wire == <<2>> <> <<9::512>> <> <<1::512>> <> <<0x80, 2, 1, 4>>
true
iex> X402.Solana.Transaction.attach_signature(
...> %{num_required_signatures: 1, signatures: [<<0::512>>], message_bytes: <<0x80>>},
...> 0,
...> <<1, 2, 3>>
...> )
{:error, :invalid_signature}
@spec compile(X402.Solana.address(), [instruction()], X402.Solana.address()) :: {:ok, compiled()} | {:error, :invalid_address}
Compiles instructions into a serialized v0 message.
Returns the message bytes (starting with the 0x80 version prefix —
these are the bytes Ed25519 signatures cover) and the required signer
addresses in signature-slot order (the fee payer is always first).
Returns {:error, :invalid_address} when any address fails Base58
decoding.
Decodes a wire transaction (v0 or legacy) into its parts.
Used by the server-side structural checks in X402.Scheme.ExactSVM.
Rejects trailing bytes and truncated sections with
{:error, :invalid_transaction}.
@spec decode_compact_u16(binary()) :: {:ok, non_neg_integer(), binary()} | :error
Decodes a compact-u16 prefix, returning the value and the rest.
Examples
iex> X402.Solana.Transaction.decode_compact_u16(<<0x80, 0x01, "rest">>)
{:ok, 128, "rest"}
iex> X402.Solana.Transaction.decode_compact_u16(<<0xFF, 0x7F>>)
{:ok, 16_383, ""}
iex> X402.Solana.Transaction.decode_compact_u16(<<0x80>>)
:error
@spec encode_compact_u16(non_neg_integer()) :: binary()
Encodes a non-negative integer as compact-u16 (shortvec).
Little-endian 7-bit groups with a continuation bit, as used for all counts in Solana's wire format.
Examples
iex> X402.Solana.Transaction.encode_compact_u16(0)
<<0>>
iex> X402.Solana.Transaction.encode_compact_u16(127)
<<0x7F>>
iex> X402.Solana.Transaction.encode_compact_u16(128)
<<0x80, 0x01>>
iex> X402.Solana.Transaction.encode_compact_u16(16_383)
<<0xFF, 0x7F>>
iex> X402.Solana.Transaction.encode_compact_u16(16_384)
<<0x80, 0x80, 0x01>>
@spec max_transaction_size() :: pos_integer()
The Solana network's maximum serialized transaction size in bytes.
Examples
iex> X402.Solana.Transaction.max_transaction_size()
1232
@spec memo(binary()) :: instruction()
An SPL Memo instruction carrying UTF-8 data (no accounts).
Examples
iex> ix = X402.Solana.Transaction.memo("pi_3abc123def456")
iex> {ix.program, ix.data}
{"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", "pi_3abc123def456"}
@spec serialize(compiled(), %{required(X402.Solana.address()) => binary()}) :: binary()
Serializes a wire transaction from message bytes and signatures.
signatures maps signer addresses to 64-byte Ed25519 signatures;
signers without an entry get a 64-byte zero placeholder — how a
partially signed transaction leaves the fee payer's slot empty for the
facilitator to fill at settlement.
@spec set_compute_unit_limit(non_neg_integer()) :: instruction()
The Compute Budget SetComputeUnitLimit instruction (discriminator 2,
u32 little-endian units).
Examples
iex> ix = X402.Solana.Transaction.set_compute_unit_limit(20_000)
iex> {ix.program, ix.accounts, ix.data}
{"ComputeBudget111111111111111111111111111111", [], <<2, 32, 78, 0, 0>>}
@spec set_compute_unit_price(non_neg_integer()) :: instruction()
The Compute Budget SetComputeUnitPrice instruction (discriminator 3,
u64 little-endian microlamports).
Examples
iex> ix = X402.Solana.Transaction.set_compute_unit_price(1)
iex> ix.data
<<3, 1, 0, 0, 0, 0, 0, 0, 0>>
@spec transfer_checked(%{ source: X402.Solana.address(), mint: X402.Solana.address(), destination: X402.Solana.address(), authority: X402.Solana.address(), amount: non_neg_integer(), decimals: byte(), token_program: X402.Solana.address() }) :: instruction()
The SPL Token / Token-2022 TransferChecked instruction.
Discriminator 12, u64 little-endian amount, u8 decimals; accounts
[source (writable), mint, destination (writable), authority (signer)] —
the layout the facilitator's static verification path parses.