Skip to content
Open
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
57 changes: 41 additions & 16 deletions cmd/crates/soroban-test/tests/it/emulator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@ use std::sync::Arc;

use stellar_ledger::emulator_test_support::*;

use soroban_cli::{
tx::builder::TxExt,
xdr::{self, Limits, OperationBody, ReadXdr, TransactionEnvelope, WriteXdr},
use soroban_cli::xdr::{
self, Limits, ReadXdr, TransactionEnvelope, TransactionV1Envelope, VecM, WriteXdr,
};

use test_case::test_case;

const HELLO_WORLD: &Wasm = &Wasm::Custom("test-wasms", "test_hello_world");

// Sign a classic Payment envelope with a Ledger identity end-to-end. After the
// blind-signing fix the CLI sends the full `TransactionSignaturePayload` to the
// device via APDU `INS=0x04` (SIGN_TX), so the user approves the parsed
// operation — not a hex hash. The Speculos approval flow used here
// (`approve_tx_signature`) and the transaction shape mirror stellar-ledger's
// `test_sign_tx`, whose Speculos click counts are calibrated for this exact
// Payment + memo layout.
Comment thread
fnando marked this conversation as resolved.
#[test_case("nanos", 0; "when the device is NanoS")]
#[test_case("nanox", 1; "when the device is NanoX")]
#[test_case("nanosp", 2; "when the device is NanoS Plus")]
Expand All @@ -27,23 +33,41 @@ async fn test_signer(ledger_device_model: &str, hd_path: u32) {
let ledger = ledger(host_port).await;

let key = ledger.get_public_key(&hd_path.into()).await.unwrap();

let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&key.0).unwrap();
let body: OperationBody =
(&soroban_cli::commands::tx::new::bump_sequence::Args { bump_to: 100 }).into();
let operation = xdr::Operation {
body,
source_account: None,
};

let destination = stellar_strkey::ed25519::PublicKey::from_string(
"GCKUD4BHIYSAYHU7HBB5FDSW6CSYH3GSOUBPWD2KE7KNBERP4BSKEJDV",
)
.unwrap();
let source_account = xdr::MuxedAccount::Ed25519(key.0.into());
let tx_env: TransactionEnvelope =
xdr::Transaction::new_tx(source_account, 100, 100, operation).into();
let tx_env = tx_env.to_xdr_base64(Limits::none()).unwrap();
let tx = xdr::Transaction {
source_account: source_account.clone(),
fee: 100,
seq_num: xdr::SequenceNumber(1),
cond: xdr::Preconditions::None,
memo: xdr::Memo::Text("Stellar".try_into().unwrap()),
ext: xdr::TransactionExt::V0,
operations: [xdr::Operation {
source_account: Some(source_account),
body: xdr::OperationBody::Payment(xdr::PaymentOp {
destination: xdr::MuxedAccount::Ed25519(destination.0.into()),
asset: xdr::Asset::Native,
amount: 100,
}),
}]
.try_into()
.unwrap(),
};
let tx_env: TransactionEnvelope = TransactionEnvelope::Tx(TransactionV1Envelope {
tx,
signatures: VecM::default(),
});
let tx_env_b64 = tx_env.to_xdr_base64(Limits::none()).unwrap();

let hash: xdr::Hash = sandbox
.new_assert_cmd("tx")
.arg("hash")
.write_stdin(tx_env.as_bytes())
.write_stdin(tx_env_b64.as_bytes())
.assert()
.success()
.stdout_as_str()
Expand All @@ -52,6 +76,7 @@ async fn test_signer(ledger_device_model: &str, hd_path: u32) {

let sign = tokio::task::spawn_blocking({
let sandbox = Arc::clone(&sandbox);
let tx_env_b64 = tx_env_b64.clone();

move || {
sandbox
Expand All @@ -60,7 +85,7 @@ async fn test_signer(ledger_device_model: &str, hd_path: u32) {
.arg("--sign-with-ledger")
.arg("--hd-path")
.arg(hd_path.to_string())
.write_stdin(tx_env.as_bytes())
.write_stdin(tx_env_b64.as_bytes())
.env("SPECULOS_PORT", host_port.to_string())
.env("RUST_LOGS", "trace")
.assert()
Expand All @@ -69,7 +94,7 @@ async fn test_signer(ledger_device_model: &str, hd_path: u32) {
}
});

let approve = tokio::task::spawn(approve_tx_hash_signature(
let approve = tokio::task::spawn(approve_tx_signature(
ui_host_port,
ledger_device_model.to_string(),
));
Expand Down
29 changes: 19 additions & 10 deletions cmd/crates/stellar-ledger/src/emulator_test_support/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,20 +192,29 @@ pub async fn get_emulator_events_with_retries(
}
}

pub async fn approve_tx_hash_signature(ui_host_port: u16, device_model: String) {
pub async fn approve_tx_hash_signature(ui_host_port: u16, _device_model: String) {
wait_for_review_transaction_text(ui_host_port).await;
let number_of_right_clicks = if device_model == "nanos" { 10 } else { 6 };
for _ in 0..number_of_right_clicks {
click(ui_host_port, "button/right").await;
}
advance_to_approve_and_confirm(ui_host_port).await;
}

click(ui_host_port, "button/both").await;
pub async fn approve_tx_signature(ui_host_port: u16, _device_model: String) {
wait_for_review_transaction_text(ui_host_port).await;
advance_to_approve_and_confirm(ui_host_port).await;
}

pub async fn approve_tx_signature(ui_host_port: u16, device_model: String) {
let number_of_right_clicks = if device_model == "nanos" { 17 } else { 11 };
for _ in 0..number_of_right_clicks {
// Right-click through the device review screens until the on-screen text
// shows "Approve", then click both buttons to confirm. Replaces hard-coded
// click counts that needed recalibration for every change in transaction
// shape, device model, or app version.
async fn advance_to_approve_and_confirm(ui_host_port: u16) {
const MAX_CLICKS: usize = 50;
for _ in 0..MAX_CLICKS {
let events = get_emulator_events(ui_host_port).await;
if events.iter().any(|event| event.text == "Approve") {
click(ui_host_port, "button/both").await;
return;
}
click(ui_host_port, "button/right").await;
}
click(ui_host_port, "button/both").await;
panic!("Approve screen not reached after {MAX_CLICKS} right-clicks");
}
139 changes: 135 additions & 4 deletions cmd/crates/stellar-ledger/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pub use ledger_transport_hid::TransportNativeHID;
use std::vec;
use stellar_strkey::DecodeError;
use stellar_xdr::curr::{
self as xdr, Hash, Limits, Transaction, TransactionSignaturePayload,
self as xdr, FeeBumpTransaction, Hash, Limits, Transaction, TransactionSignaturePayload,
TransactionSignaturePayloadTaggedTransaction, WriteXdr,
};

Expand Down Expand Up @@ -149,14 +149,43 @@ where
/// Sign a Stellar transaction with the account on the Ledger device
/// # Errors
/// Returns an error if there is an issue with connecting with the device or signing the given tx on the device
#[allow(clippy::missing_panics_doc)]
pub async fn sign_transaction(
&self,
hd_path: impl Into<HdPath>,
transaction: Transaction,
network_id: Hash,
) -> Result<Vec<u8>, Error> {
let tagged_transaction = TransactionSignaturePayloadTaggedTransaction::Tx(transaction);
self.sign_tagged_transaction(
hd_path,
TransactionSignaturePayloadTaggedTransaction::Tx(transaction),
network_id,
)
.await
}

/// Sign a Stellar fee-bump transaction with the account on the Ledger device.
/// # Errors
/// Returns an error if there is an issue with connecting with the device or signing the given tx on the device
pub async fn sign_fee_bump_transaction(
&self,
hd_path: impl Into<HdPath>,
transaction: FeeBumpTransaction,
network_id: Hash,
) -> Result<Vec<u8>, Error> {
self.sign_tagged_transaction(
hd_path,
TransactionSignaturePayloadTaggedTransaction::TxFeeBump(transaction),
network_id,
)
.await
}
Comment thread
fnando marked this conversation as resolved.

async fn sign_tagged_transaction(
&self,
hd_path: impl Into<HdPath>,
tagged_transaction: TransactionSignaturePayloadTaggedTransaction,
network_id: Hash,
) -> Result<Vec<u8>, Error> {
let signature_payload = TransactionSignaturePayload {
network_id,
tagged_transaction,
Expand Down Expand Up @@ -346,7 +375,10 @@ mod test {
use crate::{test_network_hash, Error, LedgerSigner};

use stellar_xdr::curr::{
Memo, MuxedAccount, PaymentOp, Preconditions, SequenceNumber, TransactionExt,
FeeBumpTransaction, FeeBumpTransactionExt, FeeBumpTransactionInnerTx, Limits, Memo,
MuxedAccount, PaymentOp, Preconditions, SequenceNumber, TransactionExt,
TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction,
TransactionV1Envelope, VecM, WriteXdr,
};

fn ledger(server: &MockServer) -> LedgerSigner<Emulator> {
Expand Down Expand Up @@ -457,6 +489,105 @@ mod test {
mock_request_2.assert();
}

#[tokio::test]
async fn test_sign_fee_bump_tx() {
// Wraps the Payment from `test_sign_tx` in a FeeBumpTransaction and
// signs the outer envelope. Exercises the new `sign_fee_bump_transaction`
// path, which differs from `sign_transaction` only in the
// TaggedTransaction discriminator (`TxFeeBump` vs `Tx`); the chunking
// and APDU framing are shared via `sign_tagged_transaction`.

let fake_acct = [0; 32];
let inner_tx = Transaction {
source_account: MuxedAccount::Ed25519(Uint256(fake_acct)),
fee: 100,
seq_num: SequenceNumber(1),
cond: Preconditions::None,
memo: Memo::Text("Stellar".as_bytes().try_into().unwrap()),
ext: TransactionExt::V0,
operations: [Operation {
source_account: Some(MuxedAccount::Ed25519(Uint256(fake_acct))),
body: OperationBody::Payment(PaymentOp {
destination: MuxedAccount::Ed25519(Uint256(fake_acct)),
asset: xdr::Asset::Native,
amount: 100,
}),
}]
.try_into()
.unwrap(),
};

let fee_source = [1u8; 32];
let fee_bump_tx = FeeBumpTransaction {
fee_source: MuxedAccount::Ed25519(Uint256(fee_source)),
fee: 200,
inner_tx: FeeBumpTransactionInnerTx::Tx(TransactionV1Envelope {
tx: inner_tx,
signatures: VecM::default(),
}),
ext: FeeBumpTransactionExt::V0,
};

// Build the expected APDU chunks the same way `sign_tagged_transaction`
// does, so the mock can match exact request bodies.
let payload = TransactionSignaturePayload {
network_id: test_network_hash(),
tagged_transaction: TransactionSignaturePayloadTaggedTransaction::TxFeeBump(
fee_bump_tx.clone(),
),
};
let payload_bytes = payload.to_xdr(Limits::none()).unwrap();
let mut data = vec![super::HD_PATH_ELEMENTS_COUNT];
// HD path for index 0: m/44'/148'/0' (hardened).
data.extend_from_slice(&[0x80, 0, 0, 0x2c, 0x80, 0, 0, 0x94, 0x80, 0, 0, 0]);
data.extend(&payload_bytes);
let chunks: Vec<Vec<u8>> = data
.chunks(super::CHUNK_SIZE as usize)
.map(<[u8]>::to_vec)
.collect();
assert_eq!(
chunks.len(),
2,
"fee-bump payload should split into two SIGN_TX chunks"
);
let apdu1 = format!("e0040080{:02x}{}", chunks[0].len(), hex::encode(&chunks[0]));
let apdu2 = format!("e0048000{:02x}{}", chunks[1].len(), hex::encode(&chunks[1]));

let expected_sig = "5c2f8eb41e11ab922800071990a25cf9713cc6e7c43e50e0780ddc4c0c6da50c784609ef14c528a12f520d8ea9343b49083f59c51e3f28af8c62b3edeaade60e";

let server = MockServer::start();
let mock_request_1 = server.mock(|when, then| {
when.method(POST)
.path("/")
.header("accept", "application/json")
.header("content-type", "application/json")
.json_body(json!({ "apduHex": apdu1 }));
then.status(200)
.header("content-type", "application/json")
.json_body(json!({ "data": "9000" }));
});
let mock_request_2 = server.mock(|when, then| {
when.method(POST)
.path("/")
.header("accept", "application/json")
.header("content-type", "application/json")
.json_body(json!({ "apduHex": apdu2 }));
then.status(200)
.header("content-type", "application/json")
.json_body(json!({ "data": format!("{expected_sig}9000") }));
});

let ledger = ledger(&server);
let response = ledger
.sign_fee_bump_transaction(0, fee_bump_tx, test_network_hash())
.await
.unwrap();
assert_eq!(hex::encode(response), expected_sig);

mock_request_1.assert();
mock_request_2.assert();
}

#[tokio::test]
async fn test_sign_tx_hash_when_hash_signing_is_not_enabled() {
let server = MockServer::start();
Expand Down
Loading
Loading