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