Skip to content

Commit 5a73543

Browse files
committed
feat(wasm-solana): add transaction deserialization
Add WASM bindings for Solana transaction parsing and inspection: - Transaction.fromBase64() / fromBytes() for deserialization - Access to fee payer, recent blockhash, account keys - Instruction decoding with programId, accounts, and data - AccountMeta with isSigner/isWritable flags - Signature access by index (base58 or bytes) - Signable payload extraction for verification Ticket: BTC-2929
1 parent 739b7e1 commit 5a73543

9 files changed

Lines changed: 1082 additions & 27 deletions

File tree

packages/wasm-solana/Cargo.lock

Lines changed: 400 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/wasm-solana/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@ js-sys = "0.3"
1616
solana-pubkey = { version = "2.0", features = ["curve25519"] }
1717
solana-keypair = "2.0"
1818
solana-signer = "2.0"
19+
solana-transaction = { version = "3.0", features = ["serde", "bincode"] }
1920
# Ed25519 for deriving pubkey from 32-byte seed (solana-keypair expects 64-byte format)
2021
ed25519-dalek = { version = "2.1", default-features = false, features = ["std"] }
22+
# Serialization for transaction deserialization
23+
bincode = "1.3"
24+
base64 = "0.22"
2125

2226
[dev-dependencies]
2327
wasm-bindgen-test = "0.3"

packages/wasm-solana/js/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ void wasm;
66
// Namespace exports for explicit imports
77
export * as keypair from "./keypair.js";
88
export * as pubkey from "./pubkey.js";
9+
export * as transaction from "./transaction.js";
910

1011
// Top-level class exports for convenience
1112
export { Keypair } from "./keypair.js";
1213
export { Pubkey } from "./pubkey.js";
14+
export { Transaction } from "./transaction.js";
15+
16+
// Type exports
17+
export type { AccountMeta, Instruction } from "./transaction.js";
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { WasmTransaction } from "./wasm/wasm_solana.js";
2+
3+
/**
4+
* Account metadata for an instruction
5+
*/
6+
export interface AccountMeta {
7+
/** The account public key as a base58 string */
8+
pubkey: string;
9+
/** Whether this account is a signer */
10+
isSigner: boolean;
11+
/** Whether this account is writable */
12+
isWritable: boolean;
13+
}
14+
15+
/**
16+
* A decoded Solana instruction
17+
*/
18+
export interface Instruction {
19+
/** The program ID (base58 string) that will execute this instruction */
20+
programId: string;
21+
/** The accounts required by this instruction */
22+
accounts: AccountMeta[];
23+
/** The instruction data */
24+
data: Uint8Array;
25+
}
26+
27+
/**
28+
* Solana Transaction wrapper for deserialization and inspection
29+
*
30+
* This class wraps a deserialized Solana transaction and provides
31+
* accessors for its components (instructions, signatures, etc.).
32+
*/
33+
export class Transaction {
34+
private constructor(private _wasm: WasmTransaction) {}
35+
36+
/**
37+
* Deserialize a transaction from a base64-encoded string
38+
* This is the format used by @solana/web3.js Transaction.serialize()
39+
* @param base64 - The base64-encoded transaction
40+
* @returns A Transaction instance
41+
*/
42+
static fromBase64(base64: string): Transaction {
43+
const wasm = WasmTransaction.from_base64(base64);
44+
return new Transaction(wasm);
45+
}
46+
47+
/**
48+
* Deserialize a transaction from raw bytes
49+
* @param bytes - The raw transaction bytes
50+
* @returns A Transaction instance
51+
*/
52+
static fromBytes(bytes: Uint8Array): Transaction {
53+
const wasm = WasmTransaction.from_bytes(bytes);
54+
return new Transaction(wasm);
55+
}
56+
57+
/**
58+
* Get the fee payer address as a base58 string
59+
* Returns null if there are no account keys (shouldn't happen for valid transactions)
60+
*/
61+
get feePayer(): string | null {
62+
return this._wasm.fee_payer ?? null;
63+
}
64+
65+
/**
66+
* Get the recent blockhash as a base58 string
67+
*/
68+
get recentBlockhash(): string {
69+
return this._wasm.recent_blockhash;
70+
}
71+
72+
/**
73+
* Get the number of instructions in the transaction
74+
*/
75+
get numInstructions(): number {
76+
return this._wasm.num_instructions;
77+
}
78+
79+
/**
80+
* Get the number of signatures in the transaction
81+
*/
82+
get numSignatures(): number {
83+
return this._wasm.num_signatures;
84+
}
85+
86+
/**
87+
* Get the signable message payload (what gets signed)
88+
* This is the serialized message that signers sign
89+
* @returns The message bytes
90+
*/
91+
signablePayload(): Uint8Array {
92+
return this._wasm.signable_payload();
93+
}
94+
95+
/**
96+
* Serialize the transaction to bytes
97+
* @returns The serialized transaction bytes
98+
*/
99+
toBytes(): Uint8Array {
100+
return this._wasm.to_bytes();
101+
}
102+
103+
/**
104+
* Serialize the transaction to base64
105+
* @returns The base64-encoded transaction
106+
*/
107+
toBase64(): string {
108+
return this._wasm.to_base64();
109+
}
110+
111+
/**
112+
* Get all account keys as an array of base58 strings
113+
* @returns Array of account public keys
114+
*/
115+
accountKeys(): string[] {
116+
return Array.from(this._wasm.account_keys()) as string[];
117+
}
118+
119+
/**
120+
* Get a signature at the given index as a base58 string
121+
* @param index - The signature index
122+
* @returns The signature as a base58 string, or null if index is out of bounds
123+
*/
124+
signatureAt(index: number): string | null {
125+
return this._wasm.signature_at(index) ?? null;
126+
}
127+
128+
/**
129+
* Get a signature at the given index as bytes
130+
* @param index - The signature index
131+
* @returns The signature bytes, or null if index is out of bounds
132+
*/
133+
signatureBytesAt(index: number): Uint8Array | null {
134+
return this._wasm.signature_bytes_at(index) ?? null;
135+
}
136+
137+
/**
138+
* Get all instructions in the transaction
139+
* @returns Array of instructions with programId, accounts, and data
140+
*/
141+
instructions(): Instruction[] {
142+
const rawInstructions = this._wasm.instructions();
143+
return Array.from(rawInstructions) as Instruction[];
144+
}
145+
146+
/**
147+
* Get an instruction at the given index
148+
* @param index - The instruction index
149+
* @returns The instruction, or null if index is out of bounds
150+
*/
151+
instructionAt(index: number): Instruction | null {
152+
const instr = this._wasm.instruction_at(index);
153+
return (instr as Instruction) ?? null;
154+
}
155+
156+
/**
157+
* Get the underlying WASM instance (internal use only)
158+
* @internal
159+
*/
160+
get wasm(): WasmTransaction {
161+
return this._wasm;
162+
}
163+
}

packages/wasm-solana/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@
2626
mod error;
2727
pub mod keypair;
2828
pub mod pubkey;
29+
pub mod transaction;
2930
pub mod wasm;
3031

3132
// Re-export core types at crate root
3233
pub use error::WasmSolanaError;
3334
pub use keypair::{Keypair, KeypairExt};
3435
pub use pubkey::{Pubkey, PubkeyExt};
36+
pub use transaction::{Transaction, TransactionExt};
3537

3638
// Re-export WASM types
37-
pub use wasm::{WasmKeypair, WasmPubkey};
39+
pub use wasm::{WasmKeypair, WasmPubkey, WasmTransaction};
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
//! Solana transaction deserialization.
2+
//!
3+
//! Wraps `solana_transaction::Transaction` for WASM compatibility.
4+
//!
5+
//! # Wire Format
6+
//!
7+
//! Solana transactions use a compact binary format:
8+
//! - Signatures (variable length array)
9+
//! - Message (contains instructions, accounts, blockhash)
10+
//!
11+
//! This module deserializes base64-encoded transactions as used by
12+
//! `@solana/web3.js` `Transaction.from()`.
13+
14+
use crate::error::WasmSolanaError;
15+
16+
/// Re-export the underlying Solana Transaction type.
17+
pub use solana_transaction::Transaction;
18+
19+
/// Extension trait for Transaction to add WASM-friendly methods.
20+
pub trait TransactionExt {
21+
/// Deserialize a transaction from base64-encoded wire format.
22+
fn from_base64(base64_str: &str) -> Result<Transaction, WasmSolanaError>;
23+
24+
/// Deserialize a transaction from raw bytes (wire format).
25+
fn from_bytes(bytes: &[u8]) -> Result<Transaction, WasmSolanaError>;
26+
27+
/// Get the fee payer address as base58 string.
28+
fn fee_payer_string(&self) -> Option<String>;
29+
30+
/// Get the recent blockhash as base58 string.
31+
fn blockhash_string(&self) -> String;
32+
33+
/// Get the number of instructions.
34+
fn num_instructions(&self) -> usize;
35+
36+
/// Get the number of signatures.
37+
fn num_signatures(&self) -> usize;
38+
39+
/// Get the signable message bytes (what gets signed).
40+
fn signable_payload(&self) -> Vec<u8>;
41+
42+
/// Serialize transaction to bytes (wire format).
43+
fn to_bytes(&self) -> Result<Vec<u8>, WasmSolanaError>;
44+
45+
/// Serialize transaction to base64.
46+
fn to_base64(&self) -> Result<String, WasmSolanaError>;
47+
}
48+
49+
impl TransactionExt for Transaction {
50+
fn from_base64(base64_str: &str) -> Result<Transaction, WasmSolanaError> {
51+
// Decode base64
52+
use base64::prelude::*;
53+
let bytes = BASE64_STANDARD
54+
.decode(base64_str)
55+
.map_err(|e| WasmSolanaError::new(&format!("Invalid base64: {}", e)))?;
56+
57+
Self::from_bytes(&bytes)
58+
}
59+
60+
fn from_bytes(bytes: &[u8]) -> Result<Transaction, WasmSolanaError> {
61+
bincode::deserialize(bytes)
62+
.map_err(|e| WasmSolanaError::new(&format!("Failed to deserialize transaction: {}", e)))
63+
}
64+
65+
fn fee_payer_string(&self) -> Option<String> {
66+
self.message.account_keys.first().map(|p| p.to_string())
67+
}
68+
69+
fn blockhash_string(&self) -> String {
70+
self.message.recent_blockhash.to_string()
71+
}
72+
73+
fn num_instructions(&self) -> usize {
74+
self.message.instructions.len()
75+
}
76+
77+
fn num_signatures(&self) -> usize {
78+
self.signatures.len()
79+
}
80+
81+
fn signable_payload(&self) -> Vec<u8> {
82+
self.message.serialize()
83+
}
84+
85+
fn to_bytes(&self) -> Result<Vec<u8>, WasmSolanaError> {
86+
bincode::serialize(self)
87+
.map_err(|e| WasmSolanaError::new(&format!("Failed to serialize transaction: {}", e)))
88+
}
89+
90+
fn to_base64(&self) -> Result<String, WasmSolanaError> {
91+
use base64::prelude::*;
92+
let bytes = self.to_bytes()?;
93+
Ok(BASE64_STANDARD.encode(&bytes))
94+
}
95+
}
96+
97+
#[cfg(test)]
98+
mod tests {
99+
use super::*;
100+
101+
// Test transaction from @solana/web3.js - a simple SOL transfer
102+
// This is a real transaction serialized with Transaction.serialize()
103+
const TEST_TX_BASE64: &str = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEDFVMqpim7tqEi2XL8R6KKkP0DYJvY3eiRXLlL1P9EjYgXKQC+k0FKnqyC4AZGJR7OhJXfpPP3NHOhS8t/6G7bLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/1c7Oaj3RbyLIjU0/ZPpsmVfVUWAzc8g36fK5g6A0JoBAgIAAQwCAAAAoIYBAAAAAAA=";
104+
105+
#[test]
106+
fn test_deserialize_transaction() {
107+
let tx = Transaction::from_base64(TEST_TX_BASE64).unwrap();
108+
109+
// Check we got valid data
110+
assert!(tx.num_signatures() > 0);
111+
assert!(tx.num_instructions() > 0);
112+
}
113+
114+
#[test]
115+
fn test_fee_payer() {
116+
let tx = Transaction::from_base64(TEST_TX_BASE64).unwrap();
117+
let fee_payer = tx.fee_payer_string();
118+
assert!(fee_payer.is_some());
119+
// Fee payer should be a valid base58 Solana address
120+
let payer = fee_payer.unwrap();
121+
assert!(payer.len() >= 32 && payer.len() <= 44);
122+
}
123+
124+
#[test]
125+
fn test_blockhash() {
126+
let tx = Transaction::from_base64(TEST_TX_BASE64).unwrap();
127+
let blockhash = tx.blockhash_string();
128+
// Blockhash should be a valid base58 string
129+
assert!(blockhash.len() >= 32 && blockhash.len() <= 44);
130+
}
131+
132+
#[test]
133+
fn test_roundtrip() {
134+
let tx = Transaction::from_base64(TEST_TX_BASE64).unwrap();
135+
let serialized = tx.to_base64().unwrap();
136+
137+
// Deserialize again
138+
let tx2 = Transaction::from_base64(&serialized).unwrap();
139+
assert_eq!(tx.num_signatures(), tx2.num_signatures());
140+
assert_eq!(tx.num_instructions(), tx2.num_instructions());
141+
assert_eq!(tx.blockhash_string(), tx2.blockhash_string());
142+
}
143+
144+
#[test]
145+
fn test_signable_payload() {
146+
let tx = Transaction::from_base64(TEST_TX_BASE64).unwrap();
147+
let payload = tx.signable_payload();
148+
// Message should have some content
149+
assert!(!payload.is_empty());
150+
}
151+
152+
#[test]
153+
fn test_invalid_base64() {
154+
let result = Transaction::from_base64("not valid base64!!!");
155+
assert!(result.is_err());
156+
}
157+
158+
#[test]
159+
fn test_invalid_transaction() {
160+
let result = Transaction::from_bytes(&[0, 1, 2, 3]);
161+
assert!(result.is_err());
162+
}
163+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
mod keypair;
22
mod pubkey;
3+
mod transaction;
34

45
pub use keypair::WasmKeypair;
56
pub use pubkey::WasmPubkey;
7+
pub use transaction::WasmTransaction;

0 commit comments

Comments
 (0)