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