Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
);
}
38 changes: 29 additions & 9 deletions crates/tinywallet-web3/src/crypto/chains/solana/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, &quote, 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, &quote, 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;
Expand Down
59 changes: 46 additions & 13 deletions crates/tinywallet-web3/src/crypto/chains/solana/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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();
Expand All @@ -112,22 +114,41 @@ 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()
.contains(&SignerCall::Derive(WalletChain::Solana))
);
}

#[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();
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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}");
Expand All @@ -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}");
Expand Down
Loading