diff --git a/crates/tinywallet-module/src/service/web3_native_wallet_tests.rs b/crates/tinywallet-module/src/service/web3_native_wallet_tests.rs index b53e10e8..492c7946 100644 --- a/crates/tinywallet-module/src/service/web3_native_wallet_tests.rs +++ b/crates/tinywallet-module/src/service/web3_native_wallet_tests.rs @@ -486,6 +486,9 @@ async fn native_signer_can_derive_and_then_sign_with_one_scoped_secret() { broker_task.abort(); } +#[path = "web3_native_wallet_tests/native_solana_signing_tests.rs"] +mod native_solana_signing_tests; + struct LegacyHost; #[tinybus::interface(name = "ai.tinyhumans.tinywallet.Host")] diff --git a/crates/tinywallet-module/src/service/web3_native_wallet_tests/native_solana_signing_tests.rs b/crates/tinywallet-module/src/service/web3_native_wallet_tests/native_solana_signing_tests.rs new file mode 100644 index 00000000..00b7d27d --- /dev/null +++ b/crates/tinywallet-module/src/service/web3_native_wallet_tests/native_solana_signing_tests.rs @@ -0,0 +1,154 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +use base64::Engine as _; +use tinywallet_bus::wire::{ + NativeWalletExecutionIntent, PreparedKind, PreparedStatus, PreparedTransaction, SecretMaterial, + TransactionSpec, +}; +use tinywallet_web3::crypto::seams::WalletSigner; +use tinywallet_web3::crypto::wallet::WalletChain; + +const BLOCKHASH: &str = "GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi"; +const RECIPIENT: &str = "Vote111111111111111111111111111111111111111"; +const LAMPORTS: u64 = 9_876; + +fn read_shortvec(bytes: &[u8], offset: &mut usize) -> usize { + let mut value = 0usize; + let mut shift = 0; + loop { + let byte = bytes[*offset]; + *offset += 1; + value |= usize::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return value; + } + shift += 7; + } +} + +#[tokio::test] +async fn native_solana_signing_decodes_to_the_approved_transfer() { + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let derivation_path = "m/44'/501'/0'/0'"; + let sender = tinywallet::key::derive(tinywallet_bus::Chain::Solana, mnemonic, derivation_path) + .unwrap() + .address() + .to_string(); + let transaction = TransactionSpec::Solana { + from: sender.clone(), + to: RECIPIENT.to_string(), + lamports: LAMPORTS, + recent_blockhash: BLOCKHASH.to_string(), + }; + let quote = PreparedTransaction { + quote_id: "solana-native-signing-test".to_string(), + kind: PreparedKind::NativeTransfer, + chain: WalletChain::Solana, + evm_network: None, + from_address: sender.clone(), + to_address: RECIPIENT.to_string(), + asset_symbol: "SOL".to_string(), + amount_raw: LAMPORTS.to_string(), + amount_formatted: "0.000009876 SOL".to_string(), + receive_symbol: None, + min_receive_raw: None, + calldata: None, + token_address: None, + estimated_fee_raw: "5000".to_string(), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: 1, + expires_at_ms: u64::MAX, + notes: Vec::new(), + }; + let (callbacks, _, native_requests, _, _host, broker_task) = callbacks().await; + let signer = ModuleSigner(callbacks.clone()); + let approval = NativeApprovalContext { + callbacks, + quote: quote.clone(), + }; + let secret = SecretMaterial { + mnemonic: mnemonic.to_string(), + derivation_path: derivation_path.to_string(), + chain: tinywallet_bus::Chain::Solana, + }; + let signed_transaction = with_execution_context(None, secret, None, async { + with_native_approval( + approval, + signer.sign_transaction(WalletChain::Solana, &transaction), + ) + .await + }) + .await + .unwrap(); + + let requests = native_requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].quote, quote); + assert_eq!( + requests[0].intent, + NativeWalletExecutionIntent::Transaction { transaction } + ); + drop(requests); + + assert_signed_wire_matches_quote(&signed_transaction.raw, &sender, RECIPIENT, LAMPORTS); + broker_task.abort(); +} + +fn assert_signed_wire_matches_quote(raw: &str, sender: &str, recipient: &str, lamports: u64) { + let wire = base64::engine::general_purpose::STANDARD + .decode(raw) + .unwrap(); + let mut offset = 0; + assert_eq!(read_shortvec(&wire, &mut offset), 1, "one signature slot"); + offset += 64; + let message = &wire[offset..]; + assert_eq!(&message[..3], &[1, 0, 1], "legacy transfer header"); + let mut message_offset = 3; + assert_eq!(read_shortvec(message, &mut message_offset), 3); + let keys_start = message_offset; + let keys_end = keys_start + 3 * 32; + let keys = &message[keys_start..keys_end]; + assert_eq!( + &keys[..32], + &tinywallet::address::solana::decode(sender).unwrap(), + "payer matches the approved sender" + ); + assert_eq!( + &keys[32..64], + &tinywallet::address::solana::decode(recipient).unwrap(), + "recipient matches the approved transfer" + ); + assert_eq!( + &message[keys_end..keys_end + 32], + &tinywallet::address::solana::decode(BLOCKHASH).unwrap(), + "blockhash matches the typed transaction" + ); + message_offset = keys_end + 32; + assert_eq!(read_shortvec(message, &mut message_offset), 1); + assert_eq!(message[message_offset], 2, "system program account index"); + message_offset += 1; + assert_eq!(read_shortvec(message, &mut message_offset), 2); + assert_eq!(&message[message_offset..message_offset + 2], &[0, 1]); + message_offset += 2; + assert_eq!(read_shortvec(message, &mut message_offset), 12); + assert_eq!( + u32::from_le_bytes( + message[message_offset..message_offset + 4] + .try_into() + .unwrap() + ), + 2, + "SystemInstruction::Transfer" + ); + assert_eq!( + u64::from_le_bytes( + message[message_offset + 4..message_offset + 12] + .try_into() + .unwrap() + ), + lamports, + "lamports match the approved quote" + ); +} diff --git a/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs b/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs index 9936a12b..9546d3b0 100644 --- a/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs +++ b/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs @@ -20,7 +20,7 @@ use base64::engine::general_purpose::STANDARD as B64; use log::debug; use serde::Deserialize; use serde_json::json; -use tinywallet_bus::wire::{Scheme, Signature}; +use tinywallet_bus::wire::{Scheme, Signature, TransactionSpec}; use crate::crypto::execution::{ ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, @@ -218,14 +218,34 @@ pub(crate) async fn execute_solana_quote( )); } - let to_pubkey = b58_to_pubkey(&to_addr)?; - let message_bytes = transfer_message(engine, "e, from_pk, to_pubkey, amount).await?; - - let sig_bytes = solana_sign(engine, &message_bytes).await?; - let mut wire = Vec::with_capacity(1 + 64 + message_bytes.len()); - wire.extend(encode_shortvec(1)); - wire.extend(sig_bytes); - wire.extend(&message_bytes); + let wire = match quote.kind { + PreparedKind::NativeTransfer => { + let recent_blockhash = fetch_recent_blockhash(engine).await?; + let transaction = TransactionSpec::Solana { + from: from_addr, + to: to_addr, + lamports: amount, + recent_blockhash: pubkey_to_b58(&recent_blockhash), + }; + let signed = engine + .signer + .sign_transaction(WalletChain::Solana, &transaction) + .await?; + B64.decode(signed.raw) + .map_err(|error| format!("invalid signed Solana transaction: {error}"))? + } + PreparedKind::TokenTransfer => { + let to_pubkey = b58_to_pubkey(&to_addr)?; + let message_bytes = + transfer_message(engine, "e, from_pk, to_pubkey, amount).await?; + let sig_bytes = solana_sign(engine, &message_bytes).await?; + let mut wire = Vec::with_capacity(1 + 64 + message_bytes.len()); + wire.extend(encode_shortvec(1)); + wire.extend(sig_bytes); + wire.extend(&message_bytes); + wire + } + }; let tx_sig = broadcast_solana(engine, &wire).await?; quote.status = PreparedStatus::Broadcasted; diff --git a/crates/tinywallet-web3/src/crypto/chains/solana/mod_tests.rs b/crates/tinywallet-web3/src/crypto/chains/solana/mod_tests.rs index d621b336..a9931eb0 100644 --- a/crates/tinywallet-web3/src/crypto/chains/solana/mod_tests.rs +++ b/crates/tinywallet-web3/src/crypto/chains/solana/mod_tests.rs @@ -7,7 +7,7 @@ use base64::Engine as _; use base64::engine::general_purpose::STANDARD as B64; use ed25519_dalek::Verifier as _; use serde_json::{Value, json}; -use tinywallet_bus::wire::Signature; +use tinywallet_bus::wire::{Signature, TransactionSpec}; use super::wire::{b58_to_pubkey, encode_shortvec, token_program_id}; use super::{ @@ -93,6 +93,8 @@ async fn balance_reads_lamports() { async fn a_native_transfer_is_signed_by_the_wallet_and_broadcast() { let rig = Rig::new(); script_node(&rig); + rig.signer + .set_raw(&B64.encode(b"signed native transaction")); let result = execute_solana_quote(&rig.engine, sol_quote(PreparedKind::NativeTransfer)) .await .unwrap(); @@ -112,15 +114,7 @@ async fn a_native_transfer_is_signed_by_the_wallet_and_broadcast() { .collect(); assert_eq!(methods, ["getLatestBlockhash", "sendTransaction"]); - // The broadcast wire is one signature slot plus the message, and the - // signature verifies against the wallet's key. - let wire = broadcast_wire(&rig); - assert_eq!(wire[0], 1, "exactly one signature"); - let (sig, message) = wire[1..].split_at(64); - let key = FakeSigner::solana_key().verifying_key(); - let signature = ed25519_dalek::Signature::from_slice(sig).unwrap(); - key.verify(message, &signature) - .expect("the broadcast signature is valid over the message"); + assert_eq!(broadcast_wire(&rig), b"signed native transaction"); assert!( rig.signer .calls() @@ -128,6 +122,33 @@ async fn a_native_transfer_is_signed_by_the_wallet_and_broadcast() { ); } +#[tokio::test] +async fn a_native_transfer_uses_quote_bound_transaction_signing() { + let rig = Rig::new(); + script_node(&rig); + let quote = sol_quote(PreparedKind::NativeTransfer); + execute_solana_quote(&rig.engine, quote.clone()) + .await + .unwrap(); + + assert_eq!( + rig.signer.transactions(), + vec![TransactionSpec::Solana { + from: quote.from_address, + to: quote.to_address, + lamports: 1000, + recent_blockhash: BLOCKHASH.to_string(), + }] + ); + assert!( + !rig.signer + .calls() + .iter() + .any(|call| matches!(call, SignerCall::Message(..))), + "native SOL transfers must not use opaque message signing" + ); +} + #[tokio::test] async fn a_devnet_transfer_links_to_the_devnet_explorer() { let rig = Rig::new(); @@ -262,9 +283,13 @@ async fn signer_failures_are_surfaced_verbatim() { let rig = Rig::new(); script_node(&rig); + rig.transport.on_rpc( + "getAccountInfo", + json!({"context": {"slot": 0}, "value": {"lamports": 2_039_280u64, "owner": "Tokenkeg", "data": ["", "base64"]}}), + ); rig.signer .fail_message("failed to sign the Solana message: module unavailable"); - let err = execute_solana_quote(&rig.engine, sol_quote(PreparedKind::NativeTransfer)) + let err = execute_solana_quote(&rig.engine, sol_quote(PreparedKind::TokenTransfer)) .await .unwrap_err(); assert_eq!(err, "failed to sign the Solana message: module unavailable"); @@ -302,8 +327,12 @@ async fn a_wrong_kind_or_malformed_signature_from_the_signer_is_refused() { for (reply, expected) in cases { let rig = Rig::new(); script_node(&rig); + rig.transport.on_rpc( + "getAccountInfo", + json!({"context": {"slot": 0}, "value": {"lamports": 2_039_280u64, "owner": "Tokenkeg", "data": ["", "base64"]}}), + ); rig.signer.reply_message(reply); - let err = execute_solana_quote(&rig.engine, sol_quote(PreparedKind::NativeTransfer)) + let err = execute_solana_quote(&rig.engine, sol_quote(PreparedKind::TokenTransfer)) .await .unwrap_err(); assert!(err.starts_with(expected), "{err}"); @@ -315,10 +344,14 @@ async fn a_wrong_kind_or_malformed_signature_from_the_signer_is_refused() { async fn a_non_ascii_signature_is_an_error_not_a_panic() { let rig = Rig::new(); script_node(&rig); + rig.transport.on_rpc( + "getAccountInfo", + json!({"context": {"slot": 0}, "value": {"lamports": 2_039_280u64, "owner": "Tokenkeg", "data": ["", "base64"]}}), + ); rig.signer.reply_message(Signature::Ed25519 { signature_hex: "é".repeat(64), }); - let err = execute_solana_quote(&rig.engine, sol_quote(PreparedKind::NativeTransfer)) + let err = execute_solana_quote(&rig.engine, sol_quote(PreparedKind::TokenTransfer)) .await .unwrap_err(); assert!(err.contains("from the wallet module"), "{err}");