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)API Functions
| Function | Arity | Description | Param Kinds |
|---|---|---|---|
add_signature | 3 | Add or replace a signature at a signer position in a Solana transaction. | transaction: value, index: value, signature: value |
sign_partial | 2 | Partially sign a Solana message by signer account index. | message: value, signers: value |
sign | 2 | Sign a Solana message with ordered Ed25519 seeds. | message: value, seeds: value |
deserialize_message | 1 | Deserialize a Solana transaction message prefix. | binary: value |
deserialize | 1 | Deserialize legacy Solana transaction bytes. | binary: value |
serialize | 1 | Serialize a full legacy Solana transaction for RPC submission. | transaction: value |
serialize_message | 1 | Serialize a Solana message to the bytes that signers sign. | msg: value |
build_message | 3 | Build a compiled Solana transaction message from high-level instructions. | fee_payer: value, instructions: value, recent_blockhash: exchange_data |
decode_compact_u16 | 1 | Decode a Solana compact-u16 prefix. | binary: value |
encode_compact_u16 | 1 | Encode a non-negative integer as Solana compact-u16 bytes. | value: value |
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)Raises ArgumentError if index is out of bounds for transaction.signatures
(i.e., index < 0 or index >= length(transaction.signatures)). List.replace_at/3
silently returns the list unchanged on out-of-bounds indices, which would mask a
partially-signed transaction as successfully signed in sponsored-transaction flows
— guard at the boundary.
@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).
Raises ArgumentError if length(seeds) != message.header.num_required_signatures.
Solana's runtime rejects transactions whose signatures array length doesn't
match num_required_signatures, so emitting a mismatched count would surface
only as an opaque submission failure downstream — guard at the boundary.
@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)