Build, serialize, sign, and deserialize Solana transactions (legacy format).
A Solana transaction consists of signatures and a message. The message contains a header, ordered account keys, a recent blockhash, and compiled instructions. Each signer signs the raw serialized message bytes.
Example: Build and sign a SOL transfer
fee_payer = <<...>> # 32-byte pubkey
recipient = <<...>> # 32-byte pubkey
blockhash = <<...>> # 32 bytes from getLatestBlockhash
instruction = Cartouche.Solana.SystemProgram.transfer(fee_payer, recipient, 1_000_000_000)
message = Cartouche.Solana.Transaction.build_message(fee_payer, [instruction], blockhash)
transaction = Cartouche.Solana.Transaction.sign(message, [fee_payer_seed])
# Serialize for RPC submission
bytes = Cartouche.Solana.Transaction.serialize(transaction)
Summary
Functions
Add a signature to a transaction at a specific signer position.
Build a compiled message from high-level instructions.
Decode a compact-u16 from the beginning of a binary.
Deserialize a legacy transaction from binary.
Deserialize a message from binary.
Encode a non-negative integer as a compact-u16 (variable-length).
Serialize a full transaction (signatures + message) for RPC submission.
Serialize a message to the bytes that get signed.
Sign a message with one or more seeds and produce a full transaction.
Partially sign a message, filling only the specified signer positions.
Types
@type t() :: %Cartouche.Solana.Transaction{ message: Cartouche.Solana.Transaction.Message.t(), signatures: [<<_::512>>] }
Functions
@spec add_signature(t(), non_neg_integer(), <<_::512>>) :: t()
Add a signature to a transaction at a specific signer position.
Used to fill in a missing signature on a partially-signed transaction,
typically by a sponsor or co-signer who receives the transaction from
another party. See sign_partial/2 for the full sponsored transaction flow.
The index is the position in the signatures array (matching the account
keys order in the message). The existing signature at that position is
replaced.
Examples
# Sponsor receives a partially-signed transaction and adds their signature
{:ok, partial} = Transaction.deserialize(bytes_from_user)
msg_bytes = Transaction.serialize_message(partial.message)
sponsor_sig = :crypto.sign(:eddsa, :none, msg_bytes, [sponsor_seed, :ed25519])
full_trx = Transaction.add_signature(partial, 0, sponsor_sig)
@spec build_message( <<_::256>>, [Cartouche.Solana.Transaction.Instruction.t()], <<_::256>> ) :: Cartouche.Solana.Transaction.Message.t()
Build a compiled message from high-level instructions.
Handles account deduplication, permission merging, ordering, and index compilation. The fee payer is always placed first as a writable signer.
@spec decode_compact_u16(binary()) :: {non_neg_integer(), binary()}
Decode a compact-u16 from the beginning of a binary.
Returns {value, rest}. Raises FunctionClauseError on empty or truncated
input — internal callers that need an error tuple use safe_decode_compact_u16/1.
Examples
iex> Cartouche.Solana.Transaction.decode_compact_u16(<<0, 99>>)
{0, <<99>>}
iex> Cartouche.Solana.Transaction.decode_compact_u16(<<128, 1, 99>>)
{128, <<99>>}
Deserialize a legacy transaction from binary.
Returns {:ok, t()} on a complete, well-formed transaction; {:error, atom()}
on malformed input. Possible error atoms:
:truncated_compact_u16— compact-u16 prefix ends mid-byte:insufficient_signature_data— signature-count exceeds remaining bytes:insufficient_pubkey_data— pubkey-count exceeds remaining bytes:insufficient_instruction_data— instruction-count, account-list, or data-payload exceeds remaining bytes:invalid_message_header— fewer than 3 header bytes:invalid_message_body— blockhash truncated or other structural mismatch the inner clauses didn't tag:invalid_transaction— message parsed but trailing bytes remain
@spec deserialize_message(binary()) :: {:ok, Cartouche.Solana.Transaction.Message.t(), binary()} | {:error, term()}
Deserialize a message from binary.
Returns {:ok, Message.t(), rest :: binary()} on success — rest is whatever
bytes follow the message (callers like deserialize/1 enforce rest == <<>>).
Returns {:error, :invalid_message_header} when fewer than 3 header bytes are
present. Specific atoms surface from inner parse clauses
(:truncated_compact_u16, :insufficient_pubkey_data, :insufficient_instruction_data);
{:error, :invalid_message_body} is the catch-all for structural mismatches the
inner clauses didn't tag (notably a truncated blockhash).
@spec encode_compact_u16(non_neg_integer()) :: binary()
Encode a non-negative integer as a compact-u16 (variable-length).
Examples
iex> Cartouche.Solana.Transaction.encode_compact_u16(0)
<<0>>
iex> Cartouche.Solana.Transaction.encode_compact_u16(127)
<<127>>
iex> Cartouche.Solana.Transaction.encode_compact_u16(128)
<<128, 1>>
iex> Cartouche.Solana.Transaction.encode_compact_u16(16384)
<<128, 128, 1>>
Serialize a full transaction (signatures + message) for RPC submission.
@spec serialize_message(Cartouche.Solana.Transaction.Message.t()) :: binary()
Serialize a message to the bytes that get signed.
@spec sign(Cartouche.Solana.Transaction.Message.t(), [<<_::256>>]) :: t()
Sign a message with one or more seeds and produce a full transaction.
Seeds must be ordered to match the signer positions in the message's
account keys (i.e., the first num_required_signatures accounts).
@spec sign_partial(Cartouche.Solana.Transaction.Message.t(), %{ required(non_neg_integer()) => <<_::256>> }) :: t()
Partially sign a message, filling only the specified signer positions.
This is the core primitive for sponsored transactions (where one party pays fees on behalf of another). The typical flow is:
- User builds a message with the sponsor's pubkey as the fee payer
- User calls
sign_partial/2with their own seed to sign their position - User serializes the partially-signed transaction and sends it to the sponsor
- Sponsor deserializes and calls
add_signature/3to fill in their position - Sponsor submits the fully-signed transaction via
Cartouche.Solana.RPC.send_transaction/2
signers is a map of %{account_index => seed} where account_index is
the position of the signer in the message's account keys list (0-based).
Positions not present in the map get zero-filled placeholder signatures.
Examples
# User is account[1], sponsor is account[0] (fee payer)
partial = Transaction.sign_partial(message, %{1 => user_seed})
# => %Transaction{signatures: [<<0::512>>, <user_sig>], ...}
# Serialize and send to sponsor
bytes = Transaction.serialize(partial)