Skip to main content

palinurus_core/
versioned_tx.rs

1//! Versioned transaction (V0) construction — unsigned, T1 custody.
2//!
3//! Hand-rolled, `wasm32-wasip2`-friendly reimplementation of the pieces of
4//! `solana-sdk`/`solana-message` that a Solana tool plugin needs to **build an
5//! unsigned versioned transaction** for a human / Squads multisig to sign.
6//! `solana-sdk` cannot compile inside a WIT component, so this module
7//! hand-rolls the minimal V0 message + transaction serialization.
8//!
9//! ## Consensus-critical — oracle-verified
10//!
11//! The account-key ordering and the short-vec (compact-u16) serialization are
12//! consensus-critical: a wrong order or a wrong length prefix produces a
13//! valid-looking but consensus-invalid transaction. Every byte is verified
14//! against the canonical `solana_program::message::v0::Message::try_compile`
15//! and `bincode::serialize(&VersionedMessage::V0(..))` oracle in
16//! `tests/versioned_tx.rs` — the same byte-for-byte rigor as the PDA spike.
17//!
18//! ## Account-key ordering (matches `solana_message::compiled_keys`)
19//!
20//! 1. **writable signers** — payer first, then other signer+writable keys
21//!    (sorted by pubkey bytes ascending, BTreeMap order).
22//! 2. **readonly signers** — signer && !writable (sorted).
23//! 3. **writable non-signers** — !signer && writable (sorted).
24//! 4. **readonly non-signers** — !signer && !writable (sorted; includes program ids).
25//!
26//! Header: `num_required_signatures` = signer count, `num_readonly_signed_accounts`
27//! = readonly-signer count, `num_readonly_unsigned_accounts` = readonly-non-signer count.
28//!
29//! ## Wire format (V0)
30//!
31//! `VersionedTransaction` = `[short-vec sig count][sig0..sigN][0x80][V0 message body]`.
32//! V0 message body = `[header 3B][short-vec account_keys][32B blockhash]
33//! [short-vec instructions][short-vec address_table_lookups]`.
34//! `CompiledInstruction` = `[u8 program_id_index][short-vec accounts][short-vec data]`.
35
36use crate::base58::Pubkey;
37use std::collections::BTreeMap;
38
39/// A Solana blockhash (32 bytes).
40pub type Blockhash = [u8; 32];
41
42/// An account used by an instruction, with its signer / writable flags.
43#[derive(Clone, PartialEq, Eq, Debug)]
44pub struct AccountMeta {
45    pub pubkey: Pubkey,
46    pub is_signer: bool,
47    pub is_writable: bool,
48}
49
50impl AccountMeta {
51    pub fn signer_writable(pubkey: Pubkey) -> Self { Self { pubkey, is_signer: true, is_writable: true } }
52    pub fn signer_readonly(pubkey: Pubkey) -> Self { Self { pubkey, is_signer: true, is_writable: false } }
53    pub fn writable(pubkey: Pubkey) -> Self { Self { pubkey, is_signer: false, is_writable: true } }
54    pub fn readonly(pubkey: Pubkey) -> Self { Self { pubkey, is_signer: false, is_writable: false } }
55}
56
57/// A Solana instruction: program + accounts + data.
58#[derive(Clone, PartialEq, Eq, Debug)]
59pub struct Instruction {
60    pub program_id: Pubkey,
61    pub accounts: Vec<AccountMeta>,
62    pub data: Vec<u8>,
63}
64
65/// Message header — describes the account-key layout (3 bytes).
66#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
67pub struct MessageHeader {
68    pub num_required_signatures: u8,
69    pub num_readonly_signed_accounts: u8,
70    pub num_readonly_unsigned_accounts: u8,
71}
72
73/// A compact instruction (account indices into the message's `account_keys`).
74#[derive(Clone, PartialEq, Eq, Debug)]
75pub struct CompiledInstruction {
76    pub program_id_index: u8,
77    pub accounts: Vec<u8>,
78    pub data: Vec<u8>,
79}
80
81/// An address-table lookup (V0). Unused by our plugins (no ALTs) but kept for
82/// format completeness.
83#[derive(Clone, PartialEq, Eq, Debug)]
84pub struct MessageAddressTableLookup {
85    pub account_key: Pubkey,
86    pub writable_indexes: Vec<u8>,
87    pub readonly_indexes: Vec<u8>,
88}
89
90/// A V0 message.
91#[derive(Clone, PartialEq, Eq, Debug)]
92pub struct MessageV0 {
93    pub header: MessageHeader,
94    pub account_keys: Vec<Pubkey>,
95    pub recent_blockhash: Blockhash,
96    pub instructions: Vec<CompiledInstruction>,
97    pub address_table_lookups: Vec<MessageAddressTableLookup>,
98}
99
100/// A versioned transaction. For T1 (unsigned) the `signatures` vec is empty;
101/// a human / Squads multisig signs the message hash and submits.
102#[derive(Clone, PartialEq, Eq, Debug)]
103pub struct VersionedTransaction {
104    pub signatures: Vec<[u8; 64]>,
105    pub message: MessageV0,
106}
107
108/// Per-key signer/writable flags accumulated across all instructions + the payer.
109#[derive(Default, Clone, Copy)]
110struct KeyMeta {
111    is_signer: bool,
112    is_writable: bool,
113}
114
115/// Build an **unsigned** V0 versioned transaction (T1) from `instructions`,
116/// a `payer`, and a `blockhash`. Signatures are empty — a human or Squads
117/// multisig signs the message hash and submits.
118///
119/// Account keys are ordered canonical-Solana (payer → writable-signers →
120/// readonly-signers → writable-non-signers → readonly-non-signers, sorted by
121/// pubkey bytes within each category). Throws via panic on >255 accounts
122/// (mirrors `solana_message`'s `CompileError::AccountIndexOverflow`); unreachable
123/// for our plugins (≤ ~10 accounts).
124pub fn build_unsigned(ixs: &[Instruction], payer: Pubkey, blockhash: Blockhash) -> VersionedTransaction {
125    let mut key_map: BTreeMap<Pubkey, KeyMeta> = BTreeMap::new();
126
127    // Accumulate signer/writable flags across all instructions. Program ids are
128    // inserted (so they get an index) but default to !signer && !writable →
129    // readonly-non-signer, matching solana_message (is_invoked doesn't affect ordering).
130    for ix in ixs {
131        key_map.entry(ix.program_id).or_default();
132        for am in &ix.accounts {
133            let m = key_map.entry(am.pubkey).or_default();
134            m.is_signer |= am.is_signer;
135            m.is_writable |= am.is_writable;
136        }
137    }
138
139    // Payer is always a writable signer.
140    {
141        let m = key_map.entry(payer).or_default();
142        m.is_signer = true;
143        m.is_writable = true;
144    }
145    // Remove payer from the map so the category filters don't re-include it;
146    // payer is prepended to writable_signers explicitly.
147    key_map.remove(&payer);
148
149    let writable_signers: Vec<Pubkey> = std::iter::once(payer)
150        .chain(
151            key_map
152                .iter()
153                .filter(|(_, m)| m.is_signer && m.is_writable)
154                .map(|(k, _)| *k),
155        )
156        .collect();
157    let readonly_signers: Vec<Pubkey> = key_map
158        .iter()
159        .filter(|(_, m)| m.is_signer && !m.is_writable)
160        .map(|(k, _)| *k)
161        .collect();
162    let writable_non_signers: Vec<Pubkey> = key_map
163        .iter()
164        .filter(|(_, m)| !m.is_signer && m.is_writable)
165        .map(|(k, _)| *k)
166        .collect();
167    let readonly_non_signers: Vec<Pubkey> = key_map
168        .iter()
169        .filter(|(_, m)| !m.is_signer && !m.is_writable)
170        .map(|(k, _)| *k)
171        .collect();
172
173    let account_keys: Vec<Pubkey> = writable_signers
174        .iter()
175        .chain(readonly_signers.iter())
176        .chain(writable_non_signers.iter())
177        .chain(readonly_non_signers.iter())
178        .copied()
179        .collect();
180
181    let header = MessageHeader {
182        num_required_signatures: u8::try_from(writable_signers.len() + readonly_signers.len())
183            .expect("<=255 signers"),
184        num_readonly_signed_accounts: u8::try_from(readonly_signers.len()).expect("<=255 readonly signers"),
185        num_readonly_unsigned_accounts: u8::try_from(readonly_non_signers.len())
186            .expect("<=255 readonly non-signers"),
187    };
188
189    // Index map for compiling instructions.
190    let mut index_map: BTreeMap<Pubkey, u8> = BTreeMap::new();
191    for (i, k) in account_keys.iter().enumerate() {
192        index_map.insert(*k, u8::try_from(i).expect("<=255 account keys"));
193    }
194
195    let instructions: Vec<CompiledInstruction> = ixs
196        .iter()
197        .map(|ix| {
198            let accounts: Vec<u8> = ix
199                .accounts
200                .iter()
201                .map(|am| *index_map.get(&am.pubkey).expect("account key present in account_keys"))
202                .collect();
203            CompiledInstruction {
204                program_id_index: *index_map.get(&ix.program_id).expect("program id present in account_keys"),
205                accounts,
206                data: ix.data.clone(),
207            }
208        })
209        .collect();
210
211    let message = MessageV0 {
212        header,
213        account_keys,
214        recent_blockhash: blockhash,
215        instructions,
216        address_table_lookups: Vec::new(),
217    };
218    VersionedTransaction { signatures: Vec::new(), message }
219}
220
221/// Serialize a `VersionedTransaction` to the on-wire bytes (bincode-compatible,
222/// matches `solana_sdk::versioned_transaction::VersionedTransaction::serialize`).
223///
224/// Layout: `[short-vec sig count][sig0..sigN][0x80][V0 message body]`.
225pub fn serialize(tx: &VersionedTransaction) -> Vec<u8> {
226    let mut out = Vec::new();
227    encode_short_vec(tx.signatures.len(), &mut out);
228    for sig in &tx.signatures {
229        out.extend_from_slice(sig);
230    }
231    encode_v0_message(&tx.message, &mut out);
232    out
233}
234
235/// Serialize a V0 message with the `0x80` version prefix (bincode-compatible,
236/// matches `bincode::serialize(&VersionedMessage::V0(msg))`).
237pub fn serialize_message(msg: &MessageV0) -> Vec<u8> {
238    let mut out = Vec::new();
239    encode_v0_message(msg, &mut out);
240    out
241}
242
243fn encode_v0_message(msg: &MessageV0, out: &mut Vec<u8>) {
244    out.push(0x80); // V0 version prefix
245    // header (3 bytes, bincode struct = fields in order, no wrapper)
246    out.push(msg.header.num_required_signatures);
247    out.push(msg.header.num_readonly_signed_accounts);
248    out.push(msg.header.num_readonly_unsigned_accounts);
249    // account_keys (short-vec + 32B each)
250    encode_short_vec(msg.account_keys.len(), out);
251    for k in &msg.account_keys {
252        out.extend_from_slice(k.as_bytes());
253    }
254    // recent_blockhash (32 raw bytes)
255    out.extend_from_slice(&msg.recent_blockhash);
256    // instructions (short-vec + each compiled instruction)
257    encode_short_vec(msg.instructions.len(), out);
258    for ci in &msg.instructions {
259        out.push(ci.program_id_index);
260        encode_short_vec(ci.accounts.len(), out);
261        out.extend_from_slice(&ci.accounts);
262        encode_short_vec(ci.data.len(), out);
263        out.extend_from_slice(&ci.data);
264    }
265    // address_table_lookups (short-vec + each)
266    encode_short_vec(msg.address_table_lookups.len(), out);
267    for alt in &msg.address_table_lookups {
268        out.extend_from_slice(alt.account_key.as_bytes());
269        encode_short_vec(alt.writable_indexes.len(), out);
270        out.extend_from_slice(&alt.writable_indexes);
271        encode_short_vec(alt.readonly_indexes.len(), out);
272        out.extend_from_slice(&alt.readonly_indexes);
273    }
274}
275
276/// Encode a length as a Solana short-vec (compact-u16) prefix:
277/// `< 0x80` → 1 byte; `< 0x4000` → 2 bytes; else 3 bytes. Matches
278/// `solana_short_vec::ShortU16` bincode serde exactly.
279fn encode_short_vec(len: usize, out: &mut Vec<u8>) {
280    let len = u16::try_from(len).expect("short-vec length fits in u16 (<=65535)");
281    if len < 0x80 {
282        out.push(len as u8);
283    } else if len < 0x4000 {
284        out.push(0x80 | (len & 0x7f) as u8);
285        out.push((len >> 7) as u8);
286    } else {
287        out.push(0x80 | (len & 0x7f) as u8);
288        out.push(0x80 | ((len >> 7) & 0x7f) as u8);
289        out.push((len >> 14) as u8);
290    }
291}