Skip to content

Commit 500aca4

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 Uses official Solana crates exclusively: - solana-transaction for Transaction type and bincode serialization - solana-message is_signer()/is_maybe_writable() for account flags - solana-keypair Keypair::new_from_array() for 32-byte seed support Removed ed25519-dalek dependency (-44KB WASM, -36KB gzipped). Replaces @solana/web3.js Transaction.from() in BitGoJS. Ticket: BTC-2929
1 parent 739b7e1 commit 500aca4

10 files changed

Lines changed: 1049 additions & 113 deletions

File tree

packages/wasm-solana/Cargo.lock

Lines changed: 387 additions & 92 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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ js-sys = "0.3"
1616
solana-pubkey = { version = "2.0", features = ["curve25519"] }
1717
solana-keypair = "2.0"
1818
solana-signer = "2.0"
19-
# Ed25519 for deriving pubkey from 32-byte seed (solana-keypair expects 64-byte format)
20-
ed25519-dalek = { version = "2.1", default-features = false, features = ["std"] }
19+
solana-transaction = { version = "3.0", features = ["serde", "bincode"] }
20+
# Serialization for transaction deserialization
21+
bincode = "1.3"
22+
base64 = "0.22"
2123

2224
[dev-dependencies]
2325
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/keypair.rs

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,15 @@ pub trait KeypairExt {
2020
impl KeypairExt for Keypair {
2121
/// Create a keypair from a 32-byte secret key (Ed25519 seed).
2222
fn from_secret_key_bytes(secret_key: &[u8]) -> Result<Keypair, WasmSolanaError> {
23-
if secret_key.len() != 32 {
24-
return Err(WasmSolanaError::new(&format!(
23+
let bytes: [u8; 32] = secret_key.try_into().map_err(|_| {
24+
WasmSolanaError::new(&format!(
2525
"Secret key must be 32 bytes, got {}",
2626
secret_key.len()
27-
)));
28-
}
27+
))
28+
})?;
2929

30-
// Generate public key from secret to create full 64-byte format
31-
use ed25519_dalek::SigningKey;
32-
let bytes: [u8; 32] = secret_key
33-
.try_into()
34-
.map_err(|_| WasmSolanaError::new("Failed to convert secret key to array"))?;
35-
let signing_key = SigningKey::from_bytes(&bytes);
36-
let pubkey_bytes = signing_key.verifying_key().to_bytes();
37-
38-
let mut full_secret = [0u8; 64];
39-
full_secret[..32].copy_from_slice(secret_key);
40-
full_secret[32..].copy_from_slice(&pubkey_bytes);
41-
42-
Keypair::try_from(full_secret.as_slice())
43-
.map_err(|e| WasmSolanaError::new(&format!("Invalid keypair: {}", e)))
30+
// Use official solana-keypair method that handles 32-byte seeds
31+
Ok(Keypair::new_from_array(bytes))
4432
}
4533

4634
/// Create a keypair from a 64-byte Solana secret key (secret + public concatenated).

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};

0 commit comments

Comments
 (0)