Cartouche.Solana.Transaction (Cartouche v0.2.1)

Copy Markdown View Source

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

FunctionArityDescriptionParam Kinds
add_signature3Add or replace a signature at a signer position in a Solana transaction.transaction: value, index: value, signature: value
sign_partial2Partially sign a Solana message by signer account index.message: value, signers: value
sign2Sign a Solana message with ordered Ed25519 seeds.message: value, seeds: value
deserialize_message1Deserialize a Solana transaction message prefix.binary: value
deserialize1Deserialize legacy Solana transaction bytes.binary: value
serialize1Serialize a full legacy Solana transaction for RPC submission.transaction: value
serialize_message1Serialize a Solana message to the bytes that signers sign.msg: value
build_message3Build a compiled Solana transaction message from high-level instructions.fee_payer: value, instructions: value, recent_blockhash: exchange_data
decode_compact_u161Decode a Solana compact-u16 prefix.binary: value
encode_compact_u161Encode 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

t()

@type t() :: %Cartouche.Solana.Transaction{
  message: Cartouche.Solana.Transaction.Message.t(),
  signatures: [<<_::512>>]
}

Functions

add_signature(transaction, index, arg)

@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.

build_message(arg1, instructions, arg2)

@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.

decode_compact_u16(binary)

@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(binary)

@spec deserialize(binary()) :: {:ok, t()} | {:error, term()}

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

deserialize_message(arg1)

@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).

encode_compact_u16(value)

@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(transaction)

@spec serialize(t()) :: binary()

Serialize a full transaction (signatures + message) for RPC submission.

serialize_message(msg)

@spec serialize_message(Cartouche.Solana.Transaction.Message.t()) :: binary()

Serialize a message to the bytes that get signed.

sign(message, seeds)

@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.

sign_partial(message, signers)

@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:

  1. User builds a message with the sponsor's pubkey as the fee payer
  2. User calls sign_partial/2 with their own seed to sign their position
  3. User serializes the partially-signed transaction and sends it to the sponsor
  4. Sponsor deserializes and calls add_signature/3 to fill in their position
  5. 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)