1use crate::{
2 log::format_auth_entry,
3 signer::ledger::LedgerEntry,
4 utils::fee_bump_transaction_hash,
5 xdr::{
6 self, AccountId, DecoratedSignature, FeeBumpTransactionEnvelope, Hash, HashIdPreimage,
7 HashIdPreimageSorobanAuthorization, HashIdPreimageSorobanAuthorizationWithAddress, Limits,
8 MuxedAccount, Operation, OperationBody, PublicKey, ScAddress, ScMap, ScSymbol, ScVal,
9 Signature, SignatureHint, SorobanAddressCredentials, SorobanAuthorizationEntry,
10 SorobanCredentials, Transaction, TransactionEnvelope, TransactionV1Envelope, Uint256, VecM,
11 WriteXdr,
12 },
13};
14use ed25519_dalek::{ed25519::signature::Signer as _, Signature as Ed25519Signature};
15use sha2::{Digest, Sha256};
16
17use crate::{config::network::Network, print::Print, utils::transaction_hash};
18use std::io::{self, BufRead, IsTerminal};
19
20pub mod ledger;
21pub mod validation;
22
23#[cfg(feature = "additional-libs")]
24mod keyring;
25pub mod secure_store;
26
27#[derive(thiserror::Error, Debug)]
28pub enum Error {
29 #[error("Contract addresses are not supported to sign auth entries {address}")]
30 ContractAddressAreNotSupported { address: String },
31 #[error(transparent)]
32 Ed25519(#[from] ed25519_dalek::SignatureError),
33 #[error("Missing signing key for account {address}")]
34 MissingSignerForAddress { address: String },
35 #[error(transparent)]
36 TryFromSlice(#[from] std::array::TryFromSliceError),
37 #[error("Invalid Soroban authorization entry - {reason}:\n{auth_entry_str}")]
38 InvalidAuthEntry {
39 reason: String,
40 auth_entry_str: String,
41 },
42 #[error("An authorization entry requires confirmation, but stdin is not interactive. Rerun with --auto-sign to sign anyway.")]
43 AuthEntryRequiresConfirmation,
44 #[error("signing cancelled by user")]
45 AuthRejected,
46 #[error(transparent)]
47 Xdr(#[from] xdr::Error),
48 #[error("Transaction envelope type not supported")]
49 UnsupportedTransactionEnvelopeType,
50 #[error(transparent)]
51 Url(#[from] url::ParseError),
52 #[error(transparent)]
53 Open(#[from] std::io::Error),
54 #[error("Returning a signature from Lab is not yet supported; Transaction can be found and submitted in lab")]
55 ReturningSignatureFromLab,
56 #[error(transparent)]
57 SecureStore(#[from] secure_store::Error),
58 #[error(transparent)]
59 Ledger(#[from] ledger::Error),
60 #[error(transparent)]
61 Decode(#[from] stellar_strkey::DecodeError),
62}
63
64#[allow(clippy::too_many_lines)]
72pub async fn sign_soroban_authorizations(
73 raw: &Transaction,
74 signers: &[Signer],
75 signature_expiration_ledger: u32,
76 network_passphrase: &str,
77 skip_approval: bool,
78 print: &Print,
79) -> Result<Option<Transaction>, Error> {
80 let [op @ Operation {
82 body: OperationBody::InvokeHostFunction(body),
83 ..
84 }] = raw.operations.as_slice()
85 else {
86 return Ok(None);
87 };
88
89 let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into());
90 let source_bytes = muxed_account_bytes(&raw.source_account);
91
92 let mut auths_modified = false;
93 let mut signed_auths = Vec::with_capacity(body.auth.len());
94 for raw_auth in body.auth.as_slice() {
95 let credentials = match &raw_auth.credentials {
96 SorobanCredentials::Address(credentials)
97 | SorobanCredentials::AddressV2(credentials) => credentials,
98 SorobanCredentials::AddressWithDelegates(_) => {
99 print.warnln(
100 "Skipping auth entry with delegated signers: not supported yet; entry left unsigned.",
101 );
102 signed_auths.push(raw_auth.clone());
103 continue;
104 }
105 SorobanCredentials::SourceAccount => {
107 signed_auths.push(raw_auth.clone());
108 continue;
109 }
110 };
111 let SorobanAddressCredentials { address, .. } = credentials;
112
113 match validation::classify_auth_invocation(&body.host_function, &raw_auth.root_invocation) {
115 validation::AuthStyle::Strict => {}
116 validation::AuthStyle::NonStrict => {
117 handle_non_strict_authorization(raw_auth, skip_approval, print)?;
118 }
119 validation::AuthStyle::Invalid => {
120 return Err(Error::InvalidAuthEntry {
121 reason: "authorization entry is not expected for the transaction".to_string(),
122 auth_entry_str: format_auth_entry(raw_auth),
123 });
124 }
125 }
126
127 let auth_address_bytes: &[u8; 32] = match address {
130 ScAddress::MuxedAccount(_) => todo!("muxed accounts are not supported"),
131 ScAddress::ClaimableBalance(_) => todo!("claimable balance not supported"),
132 ScAddress::LiquidityPool(_) => todo!("liquidity pool not supported"),
133 ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(ref a)))) => a,
134 ScAddress::Contract(stellar_xdr::ContractId(Hash(c))) => {
135 return Err(Error::MissingSignerForAddress {
138 address: format!(
139 "{}",
140 stellar_strkey::Strkey::Contract(stellar_strkey::Contract(*c))
141 ),
142 });
143 }
144 };
145
146 if auth_address_bytes == source_bytes {
148 return Err(Error::InvalidAuthEntry {
149 reason: "transaction source account is used as credentials".to_string(),
150 auth_entry_str: format_auth_entry(raw_auth),
151 });
152 }
153
154 let mut signer: Option<&Signer> = None;
155 for s in signers {
156 if auth_address_bytes == &s.get_public_key()?.0 {
157 signer = Some(s);
158 break;
159 }
160 }
161
162 match signer {
163 Some(signer) => {
164 let signed_entry = sign_soroban_authorization_entry(
165 raw_auth,
166 signer,
167 signature_expiration_ledger,
168 &network_id,
169 )
170 .await?;
171 signed_auths.push(signed_entry);
172 auths_modified = true;
173 }
174 None => {
175 return Err(Error::MissingSignerForAddress {
176 address: format!(
177 "{}",
178 stellar_strkey::Strkey::PublicKeyEd25519(
179 stellar_strkey::ed25519::PublicKey(*auth_address_bytes),
180 )
181 ),
182 });
183 }
184 }
185 }
186
187 if !auths_modified {
189 return Ok(None);
190 }
191
192 let mut tx = raw.clone();
194 let mut new_body = body.clone();
195 new_body.auth = signed_auths.try_into()?;
196 tx.operations = vec![Operation {
197 source_account: op.source_account.clone(),
198 body: OperationBody::InvokeHostFunction(new_body),
199 }]
200 .try_into()?;
201 Ok(Some(tx))
202}
203
204fn handle_non_strict_authorization(
209 auth: &SorobanAuthorizationEntry,
210 skip_approval: bool,
211 print: &Print,
212) -> Result<(), Error> {
213 if skip_approval {
214 print.warnln("Signing authorization entry without approval (--auto-sign):");
215 print.println(format_auth_entry(auth));
216 Ok(())
217 } else {
218 confirm_non_strict_authorization(auth)
219 }
220}
221
222fn confirm_non_strict_authorization(auth: &SorobanAuthorizationEntry) -> Result<(), Error> {
223 let print = Print::new(false);
225 print.warnln(
226 "Authorization entry does not match the current contract call, and needs approval:",
227 );
228 print.println(format_auth_entry(auth));
229
230 let stdin = io::stdin();
231 if !stdin.is_terminal() {
232 return Err(Error::AuthEntryRequiresConfirmation);
233 }
234
235 print.warnln("Sign this authorization entry? (y/N)");
236 let mut response = String::new();
237 stdin.lock().read_line(&mut response)?;
238 if response.trim().eq_ignore_ascii_case("y") {
239 Ok(())
240 } else {
241 Err(Error::AuthRejected)
242 }
243}
244
245async fn sign_soroban_authorization_entry(
246 raw: &SorobanAuthorizationEntry,
247 signer: &Signer,
248 signature_expiration_ledger: u32,
249 network_id: &Hash,
250) -> Result<SorobanAuthorizationEntry, Error> {
251 let mut auth = raw.clone();
252 let invocation = auth.root_invocation.clone();
253 let (credentials, is_v2) = match &mut auth.credentials {
257 SorobanCredentials::Address(credentials) => (credentials, false),
258 SorobanCredentials::AddressV2(credentials) => (credentials, true),
259 _ => return Ok(auth),
262 };
263
264 let preimage = if is_v2 {
267 HashIdPreimage::SorobanAuthorizationWithAddress(
268 HashIdPreimageSorobanAuthorizationWithAddress {
269 network_id: network_id.clone(),
270 nonce: credentials.nonce,
271 signature_expiration_ledger,
272 address: credentials.address.clone(),
273 invocation,
274 },
275 )
276 } else {
277 HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization {
278 network_id: network_id.clone(),
279 invocation,
280 nonce: credentials.nonce,
281 signature_expiration_ledger,
282 })
283 }
284 .to_xdr(Limits::none())?;
285
286 let payload = Sha256::digest(preimage);
287 let p: [u8; 32] = payload.as_slice().try_into()?;
288 let signature = signer.sign_payload(p).await?;
289 let public_key_vec = signer.get_public_key()?.0.to_vec();
290
291 let map = ScMap::sorted_from(vec![
292 (
293 ScVal::Symbol(ScSymbol("public_key".try_into()?)),
294 ScVal::Bytes(public_key_vec.try_into().map_err(Error::Xdr)?),
295 ),
296 (
297 ScVal::Symbol(ScSymbol("signature".try_into()?)),
298 ScVal::Bytes(
299 signature
300 .to_bytes()
301 .to_vec()
302 .try_into()
303 .map_err(Error::Xdr)?,
304 ),
305 ),
306 ])
307 .map_err(Error::Xdr)?;
308 credentials.signature = ScVal::Vec(Some(
309 vec![ScVal::Map(Some(map))].try_into().map_err(Error::Xdr)?,
310 ));
311 credentials.signature_expiration_ledger = signature_expiration_ledger;
312 Ok(auth)
313}
314
315pub struct Signer {
316 pub kind: SignerKind,
317 pub print: Print,
318}
319
320#[allow(clippy::module_name_repetitions, clippy::large_enum_variant)]
321pub enum SignerKind {
322 Local(LocalKey),
323 Ledger(LedgerEntry),
324 Lab,
325 SecureStore(SecureStoreEntry),
326}
327
328impl Signer {
330 pub async fn sign_tx(
331 &self,
332 tx: Transaction,
333 network: &Network,
334 ) -> Result<TransactionEnvelope, Error> {
335 let tx_env = TransactionEnvelope::Tx(TransactionV1Envelope {
336 tx,
337 signatures: VecM::default(),
338 });
339 self.sign_tx_env(&tx_env, network).await
340 }
341
342 pub async fn sign_tx_env(
343 &self,
344 tx_env: &TransactionEnvelope,
345 network: &Network,
346 ) -> Result<TransactionEnvelope, Error> {
347 match &tx_env {
348 TransactionEnvelope::Tx(TransactionV1Envelope { tx, signatures }) => {
349 let tx_hash = transaction_hash(tx, &network.network_passphrase)?;
350 self.print
351 .infoln(format!("Signing transaction: {}", hex::encode(tx_hash)));
352 let decorated_signature = self.sign_tx_hash(tx_hash, tx_env, network).await?;
353 let mut sigs = signatures.clone().into_vec();
354 sigs.push(decorated_signature);
355 Ok(TransactionEnvelope::Tx(TransactionV1Envelope {
356 tx: tx.clone(),
357 signatures: sigs.try_into()?,
358 }))
359 }
360 TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { tx, signatures }) => {
361 let tx_hash = fee_bump_transaction_hash(tx, &network.network_passphrase)?;
362 self.print.infoln(format!(
363 "Signing fee bump transaction: {}",
364 hex::encode(tx_hash),
365 ));
366 let decorated_signature = self.sign_tx_hash(tx_hash, tx_env, network).await?;
367 let mut sigs = signatures.clone().into_vec();
368 sigs.push(decorated_signature);
369 Ok(TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope {
370 tx: tx.clone(),
371 signatures: sigs.try_into()?,
372 }))
373 }
374 TransactionEnvelope::TxV0(_) => Err(Error::UnsupportedTransactionEnvelopeType),
375 }
376 }
377
378 pub fn get_public_key(&self) -> Result<stellar_strkey::ed25519::PublicKey, Error> {
379 match &self.kind {
380 SignerKind::Local(local_key) => Ok(stellar_strkey::ed25519::PublicKey::from_payload(
381 local_key.key.verifying_key().as_bytes(),
382 )?),
383 SignerKind::Ledger(ledger) => Ok(ledger
384 .public_key
385 .expect("Ledger signers reachable here are built from Secret::Ledger and always carry a cached public key")),
386 SignerKind::Lab => Err(Error::ReturningSignatureFromLab),
387 SignerKind::SecureStore(secure_store_entry) => secure_store_entry.get_public_key(),
388 }
389 }
390
391 pub async fn sign_payload(&self, payload: [u8; 32]) -> Result<Ed25519Signature, Error> {
392 match &self.kind {
393 SignerKind::Local(local_key) => local_key.sign_payload(payload),
394 SignerKind::Ledger(ledger) => Ok(ledger.sign_payload(payload).await?),
395 SignerKind::Lab => Err(Error::ReturningSignatureFromLab),
396 SignerKind::SecureStore(secure_store_entry) => secure_store_entry.sign_payload(payload),
397 }
398 }
399
400 async fn sign_tx_hash(
401 &self,
402 tx_hash: [u8; 32],
403 tx_env: &TransactionEnvelope,
404 network: &Network,
405 ) -> Result<DecoratedSignature, Error> {
406 match &self.kind {
407 SignerKind::Local(key) => key.sign_tx_hash(tx_hash),
408 SignerKind::Lab => Lab::sign_tx_env(tx_env, network, &self.print),
409 SignerKind::Ledger(ledger) => ledger.sign_tx_hash(tx_hash).await.map_err(Error::from),
410 SignerKind::SecureStore(entry) => entry.sign_tx_hash(tx_hash),
411 }
412 }
413}
414
415pub struct LocalKey {
416 pub key: ed25519_dalek::SigningKey,
417}
418
419impl LocalKey {
420 pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result<DecoratedSignature, Error> {
421 let hint = SignatureHint(self.key.verifying_key().to_bytes()[28..].try_into()?);
422 let signature = Signature(self.key.sign(&tx_hash).to_bytes().to_vec().try_into()?);
423 Ok(DecoratedSignature { hint, signature })
424 }
425
426 pub fn sign_payload(&self, payload: [u8; 32]) -> Result<Ed25519Signature, Error> {
427 Ok(self.key.sign(&payload))
428 }
429}
430
431pub struct Lab;
432
433impl Lab {
434 const URL: &str = "https://lab.stellar.org/transaction/cli-sign";
435
436 pub fn sign_tx_env(
437 tx_env: &TransactionEnvelope,
438 network: &Network,
439 printer: &Print,
440 ) -> Result<DecoratedSignature, Error> {
441 let xdr = tx_env.to_xdr_base64(Limits::none())?;
442
443 let mut url = url::Url::parse(Self::URL)?;
444 url.query_pairs_mut()
445 .append_pair("networkPassphrase", &network.network_passphrase)
446 .append_pair("xdr", &xdr);
447 let url = url.to_string();
448
449 printer.globeln(format!("Opening lab to sign transaction: {url}"));
450 open::that(url)?;
451
452 Err(Error::ReturningSignatureFromLab)
453 }
454}
455
456pub struct SecureStoreEntry {
457 pub name: String,
458 pub hd_path: Option<u32>,
459 pub public_key: Option<stellar_strkey::ed25519::PublicKey>,
460}
461
462impl SecureStoreEntry {
463 pub fn get_public_key(&self) -> Result<stellar_strkey::ed25519::PublicKey, Error> {
464 if let Some(pk) = &self.public_key {
465 return Ok(*pk);
466 }
467 Ok(secure_store::get_public_key(&self.name, self.hd_path)?)
468 }
469
470 pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result<DecoratedSignature, Error> {
471 let hint = SignatureHint(self.get_public_key()?.0[28..].try_into()?);
472
473 let signed_tx_hash = secure_store::sign_tx_data(&self.name, self.hd_path, &tx_hash)?;
474
475 let signature = Signature(signed_tx_hash.clone().try_into()?);
476 Ok(DecoratedSignature { hint, signature })
477 }
478
479 pub fn sign_payload(&self, payload: [u8; 32]) -> Result<Ed25519Signature, Error> {
480 let signed_bytes = secure_store::sign_tx_data(&self.name, self.hd_path, &payload)?;
481 let sig = Ed25519Signature::from_bytes(signed_bytes.as_slice().try_into()?);
482 Ok(sig)
483 }
484}
485
486fn muxed_account_bytes(source: &MuxedAccount) -> &[u8; 32] {
488 match source {
489 MuxedAccount::Ed25519(Uint256(bytes)) => bytes,
490 MuxedAccount::MuxedEd25519(muxed) => &muxed.ed25519.0,
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::signer::ledger::LedgerEntry;
498 use crate::xdr::{
499 BytesM, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, Preconditions,
500 SequenceNumber, SorobanAuthorizedFunction, SorobanAuthorizedInvocation, TransactionExt,
501 };
502
503 const NETWORK: &str = "Test SDF Network ; September 2015";
504 const EXPIRATION_LEDGER: u32 = 100;
505
506 fn local_signer(seed: [u8; 32]) -> Signer {
507 Signer {
508 kind: SignerKind::Local(LocalKey {
509 key: ed25519_dalek::SigningKey::from_bytes(&seed),
510 }),
511 print: Print::new(true),
512 }
513 }
514
515 fn signer_pubkey(signer: &Signer) -> [u8; 32] {
516 signer.get_public_key().unwrap().0
517 }
518
519 fn ed25519_address(bytes: [u8; 32]) -> ScAddress {
520 ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(bytes))))
521 }
522
523 fn invoke_args(contract: [u8; 32], fn_name: &str) -> InvokeContractArgs {
524 InvokeContractArgs {
525 contract_address: ScAddress::Contract(stellar_xdr::ContractId(Hash(contract))),
526 function_name: ScSymbol(fn_name.try_into().unwrap()),
527 args: VecM::default(),
528 }
529 }
530
531 fn invocation(contract: [u8; 32], fn_name: &str) -> SorobanAuthorizedInvocation {
532 SorobanAuthorizedInvocation {
533 function: SorobanAuthorizedFunction::ContractFn(invoke_args(contract, fn_name)),
534 sub_invocations: VecM::default(),
535 }
536 }
537
538 fn address_auth(
539 address: ScAddress,
540 invocation: SorobanAuthorizedInvocation,
541 ) -> SorobanAuthorizationEntry {
542 SorobanAuthorizationEntry {
543 credentials: SorobanCredentials::Address(SorobanAddressCredentials {
544 address,
545 nonce: 0,
546 signature_expiration_ledger: 0,
547 signature: ScVal::Void,
548 }),
549 root_invocation: invocation,
550 }
551 }
552
553 fn build_tx(
554 source: MuxedAccount,
555 host_function: HostFunction,
556 auth: Vec<SorobanAuthorizationEntry>,
557 ) -> Transaction {
558 Transaction {
559 source_account: source,
560 fee: 100,
561 seq_num: SequenceNumber(1),
562 cond: Preconditions::None,
563 memo: Memo::None,
564 operations: vec![Operation {
565 source_account: None,
566 body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
567 host_function,
568 auth: auth.try_into().unwrap(),
569 }),
570 }]
571 .try_into()
572 .unwrap(),
573 ext: TransactionExt::V0,
574 }
575 }
576
577 fn extract_signed_pubkey(creds: &SorobanAddressCredentials) -> [u8; 32] {
579 let ScVal::Vec(Some(outer)) = &creds.signature else {
580 panic!("expected ScVal::Vec signature");
581 };
582 let Some(ScVal::Map(Some(map))) = outer.first() else {
583 panic!("expected ScVal::Map inside signature vec");
584 };
585 map.iter()
586 .find_map(|e| match (&e.key, &e.val) {
587 (ScVal::Symbol(s), ScVal::Bytes(b)) if s.0.as_slice() == b"public_key" => {
588 Some(b.as_slice().try_into().unwrap())
589 }
590 _ => None,
591 })
592 .expect("public_key entry")
593 }
594
595 #[tokio::test]
596 async fn test_signs_address_auth_entry_with_matching_signer() {
597 let signer = local_signer([1u8; 32]);
598 let signer_unused = local_signer([2u8; 32]);
599 let signer_pk = signer_pubkey(&signer);
600 let source = MuxedAccount::Ed25519(Uint256([9u8; 32]));
601 let contract = [42u8; 32];
602
603 let entry = address_auth(ed25519_address(signer_pk), invocation(contract, "hello"));
604 let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello"));
605 let tx = build_tx(source, host_fn, vec![entry]);
606
607 let signed_auth_tx = sign_soroban_authorizations(
608 &tx,
609 &[signer_unused, signer],
610 EXPIRATION_LEDGER,
611 NETWORK,
612 false,
613 &Print::new(true),
614 )
615 .await
616 .unwrap()
617 .expect("signing modifies the transaction");
618
619 let OperationBody::InvokeHostFunction(body) = &signed_auth_tx.operations[0].body else {
620 panic!("expected InvokeHostFunction");
621 };
622 let SorobanCredentials::Address(creds) = &body.auth[0].credentials else {
623 panic!("expected Address credentials");
624 };
625 assert!(
626 !matches!(creds.signature, ScVal::Void),
627 "signature should be filled in"
628 );
629 assert_eq!(creds.signature_expiration_ledger, EXPIRATION_LEDGER);
630 assert_eq!(
631 extract_signed_pubkey(creds),
632 signer_pk,
633 "embedded public_key should match the signer"
634 );
635 }
636
637 #[tokio::test]
638 async fn test_non_strict_auth_signs_when_allowed() {
639 let signer = local_signer([1u8; 32]);
640 let signer_pk = signer_pubkey(&signer);
641 let source = MuxedAccount::Ed25519(Uint256([9u8; 32]));
642 let contract = [42u8; 32];
643 let other_contract = [99u8; 32];
644
645 let entry = address_auth(
646 ed25519_address(signer_pk),
647 invocation(other_contract, "hello"),
648 );
649 let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello"));
650 let tx = build_tx(source, host_fn, vec![entry]);
651
652 let signed_auth_tx = sign_soroban_authorizations(
653 &tx,
654 &[signer],
655 EXPIRATION_LEDGER,
656 NETWORK,
657 true,
658 &Print::new(true),
659 )
660 .await
661 .unwrap()
662 .expect("signing modifies the transaction");
663
664 let OperationBody::InvokeHostFunction(body) = &signed_auth_tx.operations[0].body else {
665 panic!("expected InvokeHostFunction");
666 };
667 let SorobanCredentials::Address(creds) = &body.auth[0].credentials else {
668 panic!("expected Address credentials");
669 };
670 assert!(!matches!(creds.signature, ScVal::Void));
671 }
672
673 #[tokio::test]
674 async fn test_upload_wasm_with_auth_returns_invalid() {
675 let signer = local_signer([1u8; 32]);
676 let signer_pk = signer_pubkey(&signer);
677 let source = MuxedAccount::Ed25519(Uint256([9u8; 32]));
678 let wasm: BytesM = [0u8; 32].try_into().unwrap();
679
680 let entry = address_auth(ed25519_address(signer_pk), invocation([42u8; 32], "hello"));
681 let host_fn = HostFunction::UploadContractWasm(wasm);
682 let tx = build_tx(source, host_fn, vec![entry]);
683
684 let result = sign_soroban_authorizations(
685 &tx,
686 &[signer],
687 EXPIRATION_LEDGER,
688 NETWORK,
689 false,
690 &Print::new(true),
691 )
692 .await;
693 assert!(matches!(result, Err(Error::InvalidAuthEntry { .. })));
694 }
695
696 #[tokio::test]
697 async fn test_source_account_as_address_returns_invalid() {
698 let signer = local_signer([1u8; 32]);
699 let signer_pk = signer_pubkey(&signer);
700 let source = MuxedAccount::Ed25519(Uint256(signer_pk));
701 let contract = [42u8; 32];
702
703 let entry = address_auth(ed25519_address(signer_pk), invocation(contract, "hello"));
704 let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello"));
705 let tx = build_tx(source, host_fn, vec![entry]);
706
707 let result = sign_soroban_authorizations(
708 &tx,
709 &[signer],
710 EXPIRATION_LEDGER,
711 NETWORK,
712 false,
713 &Print::new(true),
714 )
715 .await;
716 assert!(matches!(result, Err(Error::InvalidAuthEntry { .. })));
717 }
718
719 #[tokio::test]
720 async fn test_missing_signer_returns_error() {
721 let source = MuxedAccount::Ed25519(Uint256([9u8; 32]));
722 let contract = [42u8; 32];
723 let unknown = [77u8; 32];
724
725 let entry = address_auth(ed25519_address(unknown), invocation(contract, "hello"));
726 let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello"));
727 let tx = build_tx(source, host_fn, vec![entry]);
728
729 let result = sign_soroban_authorizations(
730 &tx,
731 &[],
732 EXPIRATION_LEDGER,
733 NETWORK,
734 false,
735 &Print::new(true),
736 )
737 .await;
738 assert!(matches!(result, Err(Error::MissingSignerForAddress { .. })));
739 }
740
741 #[test]
742 fn ledger_signer_get_public_key_returns_cached_without_device() {
743 const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ";
744 let pk = stellar_strkey::ed25519::PublicKey::from_string(TEST_PUBLIC_KEY).unwrap();
745 let signer = Signer {
746 kind: SignerKind::Ledger(LedgerEntry {
747 hd_path: 0,
748 public_key: Some(pk),
749 }),
750 print: Print::new(true),
751 };
752 assert_eq!(
753 signer.get_public_key().unwrap().to_string(),
754 TEST_PUBLIC_KEY
755 );
756 }
757
758 fn address_auth_v2(
761 address: ScAddress,
762 invocation: SorobanAuthorizedInvocation,
763 ) -> SorobanAuthorizationEntry {
764 SorobanAuthorizationEntry {
765 credentials: SorobanCredentials::AddressV2(SorobanAddressCredentials {
766 address,
767 nonce: 0,
768 signature_expiration_ledger: 0,
769 signature: ScVal::Void,
770 }),
771 root_invocation: invocation,
772 }
773 }
774
775 fn extract_signed_signature(creds: &SorobanAddressCredentials) -> [u8; 64] {
777 let ScVal::Vec(Some(outer)) = &creds.signature else {
778 panic!("expected ScVal::Vec signature");
779 };
780 let Some(ScVal::Map(Some(map))) = outer.first() else {
781 panic!("expected ScVal::Map inside signature vec");
782 };
783 map.iter()
784 .find_map(|e| match (&e.key, &e.val) {
785 (ScVal::Symbol(s), ScVal::Bytes(b)) if s.0.as_slice() == b"signature" => {
786 Some(b.as_slice().try_into().unwrap())
787 }
788 _ => None,
789 })
790 .expect("signature entry")
791 }
792
793 fn first_address_creds(tx: &Transaction) -> &SorobanAddressCredentials {
796 let OperationBody::InvokeHostFunction(body) = &tx.operations[0].body else {
797 panic!("expected InvokeHostFunction");
798 };
799 match &body.auth[0].credentials {
800 SorobanCredentials::Address(c) | SorobanCredentials::AddressV2(c) => c,
801 _ => panic!("expected address credentials"),
802 }
803 }
804
805 #[tokio::test]
806 async fn test_signs_address_v2_entry_with_with_address_preimage() {
807 use ed25519_dalek::{Verifier, VerifyingKey};
808
809 let signer = local_signer([1u8; 32]);
810 let signer_pk = signer_pubkey(&signer);
811 let source = MuxedAccount::Ed25519(Uint256([9u8; 32]));
812 let contract = [42u8; 32];
813
814 let entry = address_auth_v2(ed25519_address(signer_pk), invocation(contract, "hello"));
815 let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello"));
816 let tx = build_tx(source, host_fn, vec![entry]);
817
818 let signed_auth_tx = sign_soroban_authorizations(
819 &tx,
820 &[signer],
821 EXPIRATION_LEDGER,
822 NETWORK,
823 false,
824 &Print::new(true),
825 )
826 .await
827 .unwrap()
828 .expect("signing modifies the transaction");
829
830 let OperationBody::InvokeHostFunction(body) = &signed_auth_tx.operations[0].body else {
831 panic!("expected InvokeHostFunction");
832 };
833 let SorobanCredentials::AddressV2(creds) = &body.auth[0].credentials else {
835 panic!("expected AddressV2 credentials to be preserved");
836 };
837 assert_eq!(creds.signature_expiration_ledger, EXPIRATION_LEDGER);
838 assert_eq!(extract_signed_pubkey(creds), signer_pk);
839
840 let network_id = Hash(Sha256::digest(NETWORK.as_bytes()).into());
843 let preimage = HashIdPreimage::SorobanAuthorizationWithAddress(
844 HashIdPreimageSorobanAuthorizationWithAddress {
845 network_id,
846 nonce: creds.nonce,
847 signature_expiration_ledger: EXPIRATION_LEDGER,
848 address: creds.address.clone(),
849 invocation: body.auth[0].root_invocation.clone(),
850 },
851 )
852 .to_xdr(Limits::none())
853 .unwrap();
854 let payload: [u8; 32] = Sha256::digest(preimage).into();
855
856 let vk = VerifyingKey::from_bytes(&signer_pk).unwrap();
857 let sig = Ed25519Signature::from_bytes(&extract_signed_signature(creds));
858 vk.verify(&payload, &sig)
859 .expect("V2 signature must validate against the WithAddress preimage");
860 }
861
862 #[tokio::test]
863 async fn test_address_v1_and_v2_signatures_differ() {
864 let signer_pk = signer_pubkey(&local_signer([1u8; 32]));
865 let source = MuxedAccount::Ed25519(Uint256([9u8; 32]));
866 let contract = [42u8; 32];
867 let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello"));
868
869 let v1_tx = build_tx(
870 source.clone(),
871 host_fn.clone(),
872 vec![address_auth(
873 ed25519_address(signer_pk),
874 invocation(contract, "hello"),
875 )],
876 );
877 let v2_tx = build_tx(
878 source,
879 host_fn,
880 vec![address_auth_v2(
881 ed25519_address(signer_pk),
882 invocation(contract, "hello"),
883 )],
884 );
885
886 let v1_signed = sign_soroban_authorizations(
887 &v1_tx,
888 &[local_signer([1u8; 32])],
889 EXPIRATION_LEDGER,
890 NETWORK,
891 false,
892 &Print::new(true),
893 )
894 .await
895 .unwrap()
896 .expect("signing modifies the transaction");
897 let v2_signed = sign_soroban_authorizations(
898 &v2_tx,
899 &[local_signer([1u8; 32])],
900 EXPIRATION_LEDGER,
901 NETWORK,
902 false,
903 &Print::new(true),
904 )
905 .await
906 .unwrap()
907 .expect("signing modifies the transaction");
908
909 assert_ne!(
912 first_address_creds(&v1_signed).signature,
913 first_address_creds(&v2_signed).signature,
914 "V2 must bind the address, producing a different signature than V1",
915 );
916 }
917}