Skip to main content

soroban_cli/signer/
mod.rs

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, Limits, MuxedAccount, Operation, OperationBody,
8        PublicKey, ScAddress, ScMap, ScSymbol, ScVal, Signature, SignatureHint,
9        SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanCredentials, Transaction,
10        TransactionEnvelope, TransactionV1Envelope, Uint256, VecM, WriteXdr,
11    },
12};
13use ed25519_dalek::{ed25519::signature::Signer as _, Signature as Ed25519Signature};
14use sha2::{Digest, Sha256};
15
16use crate::{config::network::Network, print::Print, utils::transaction_hash};
17use std::io::{self, BufRead, IsTerminal};
18
19pub mod ledger;
20pub mod validation;
21
22#[cfg(feature = "additional-libs")]
23mod keyring;
24pub mod secure_store;
25
26#[derive(thiserror::Error, Debug)]
27pub enum Error {
28    #[error("Contract addresses are not supported to sign auth entries {address}")]
29    ContractAddressAreNotSupported { address: String },
30    #[error(transparent)]
31    Ed25519(#[from] ed25519_dalek::SignatureError),
32    #[error("Missing signing key for account {address}")]
33    MissingSignerForAddress { address: String },
34    #[error(transparent)]
35    TryFromSlice(#[from] std::array::TryFromSliceError),
36    #[error("Invalid Soroban authorization entry - {reason}:\n{auth_entry_str}")]
37    InvalidAuthEntry {
38        reason: String,
39        auth_entry_str: String,
40    },
41    #[error("An authorization entry requires confirmation, but stdin is not interactive. Rerun with --auto-sign to sign anyway.")]
42    AuthEntryRequiresConfirmation,
43    #[error("signing cancelled by user")]
44    AuthRejected,
45    #[error(transparent)]
46    Xdr(#[from] xdr::Error),
47    #[error("Transaction envelope type not supported")]
48    UnsupportedTransactionEnvelopeType,
49    #[error(transparent)]
50    Url(#[from] url::ParseError),
51    #[error(transparent)]
52    Open(#[from] std::io::Error),
53    #[error("Returning a signature from Lab is not yet supported; Transaction can be found and submitted in lab")]
54    ReturningSignatureFromLab,
55    #[error(transparent)]
56    SecureStore(#[from] secure_store::Error),
57    #[error(transparent)]
58    Ledger(#[from] ledger::Error),
59    #[error(transparent)]
60    Decode(#[from] stellar_strkey::DecodeError),
61}
62
63/// Sign all SorobanAuthorizationEntry's in the transaction with the given signers. Returns a new
64/// transaction with the signatures added to each SorobanAuthorizationEntry.
65///
66/// If no SorobanAuthorizationEntry's need signing (including if none exist), return Ok(None).
67///
68/// If a SorobanAuthorizationEntry needs signing, but a signature cannot be produced for it,
69/// return an Error
70pub async fn sign_soroban_authorizations(
71    raw: &Transaction,
72    signers: &[Signer],
73    signature_expiration_ledger: u32,
74    network_passphrase: &str,
75    skip_approval: bool,
76    print: &Print,
77) -> Result<Option<Transaction>, Error> {
78    // Check if we have exactly one operation and it's InvokeHostFunction
79    let [op @ Operation {
80        body: OperationBody::InvokeHostFunction(body),
81        ..
82    }] = raw.operations.as_slice()
83    else {
84        return Ok(None);
85    };
86
87    let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into());
88    let source_bytes = muxed_account_bytes(&raw.source_account);
89
90    let mut auths_modified = false;
91    let mut signed_auths = Vec::with_capacity(body.auth.len());
92    for raw_auth in body.auth.as_slice() {
93        let SorobanAuthorizationEntry {
94            credentials: SorobanCredentials::Address(credentials),
95            ..
96        } = raw_auth
97        else {
98            // Doesn't need special signing
99            signed_auths.push(raw_auth.clone());
100            continue;
101        };
102        let SorobanAddressCredentials { address, .. } = credentials;
103
104        // Before we attempt to sign, validate the auth entry is strict
105        match validation::classify_auth_invocation(&body.host_function, &raw_auth.root_invocation) {
106            validation::AuthStyle::Strict => {}
107            validation::AuthStyle::NonStrict => {
108                handle_non_strict_authorization(raw_auth, skip_approval, print)?;
109            }
110            validation::AuthStyle::Invalid => {
111                return Err(Error::InvalidAuthEntry {
112                    reason: "authorization entry is not expected for the transaction".to_string(),
113                    auth_entry_str: format_auth_entry(raw_auth),
114                });
115            }
116        }
117
118        // See if we have a signer for this authorizationEntry
119        // If not, then we Error
120        let auth_address_bytes: &[u8; 32] = match address {
121            ScAddress::MuxedAccount(_) => todo!("muxed accounts are not supported"),
122            ScAddress::ClaimableBalance(_) => todo!("claimable balance not supported"),
123            ScAddress::LiquidityPool(_) => todo!("liquidity pool not supported"),
124            ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(ref a)))) => a,
125            ScAddress::Contract(stellar_xdr::curr::ContractId(Hash(c))) => {
126                // This address is for a contract. This means we're using a custom
127                // smart-contract account. Currently the CLI doesn't support that yet.
128                return Err(Error::MissingSignerForAddress {
129                    address: format!(
130                        "{}",
131                        stellar_strkey::Strkey::Contract(stellar_strkey::Contract(*c))
132                    ),
133                });
134            }
135        };
136
137        // Auth entries should not request a signature from the tx source account via the `Address` credential type
138        if auth_address_bytes == source_bytes {
139            return Err(Error::InvalidAuthEntry {
140                reason: "transaction source account is used as credentials".to_string(),
141                auth_entry_str: format_auth_entry(raw_auth),
142            });
143        }
144
145        let mut signer: Option<&Signer> = None;
146        for s in signers {
147            if auth_address_bytes == &s.get_public_key()?.0 {
148                signer = Some(s);
149                break;
150            }
151        }
152
153        match signer {
154            Some(signer) => {
155                let signed_entry = sign_soroban_authorization_entry(
156                    raw_auth,
157                    signer,
158                    signature_expiration_ledger,
159                    &network_id,
160                )
161                .await?;
162                signed_auths.push(signed_entry);
163                auths_modified = true;
164            }
165            None => {
166                return Err(Error::MissingSignerForAddress {
167                    address: format!(
168                        "{}",
169                        stellar_strkey::Strkey::PublicKeyEd25519(
170                            stellar_strkey::ed25519::PublicKey(*auth_address_bytes),
171                        )
172                    ),
173                });
174            }
175        }
176    }
177
178    // If we didn't modify any entries, return Ok(None) to indicate no changes needed to the transaction
179    if !auths_modified {
180        return Ok(None);
181    }
182
183    // Build updated transaction with signed auth entries
184    let mut tx = raw.clone();
185    let mut new_body = body.clone();
186    new_body.auth = signed_auths.try_into()?;
187    tx.operations = vec![Operation {
188        source_account: op.source_account.clone(),
189        body: OperationBody::InvokeHostFunction(new_body),
190    }]
191    .try_into()?;
192    Ok(Some(tx))
193}
194
195/// Handle a non-strict auth entry. Under `--auto-sign` (`skip_approval`), log
196/// the entry through `Print` so the relaxed policy leaves an audit trail that
197/// the user can silence with `--quiet`. Otherwise, prompt the user
198/// interactively for approval.
199fn handle_non_strict_authorization(
200    auth: &SorobanAuthorizationEntry,
201    skip_approval: bool,
202    print: &Print,
203) -> Result<(), Error> {
204    if skip_approval {
205        print.warnln("Signing authorization entry without approval (--auto-sign):");
206        print.println(format_auth_entry(auth));
207        Ok(())
208    } else {
209        confirm_non_strict_authorization(auth)
210    }
211}
212
213fn confirm_non_strict_authorization(auth: &SorobanAuthorizationEntry) -> Result<(), Error> {
214    // ignore quiet flag here as we are prompting the user
215    let print = Print::new(false);
216    print.warnln(
217        "Authorization entry does not match the current contract call, and needs approval:",
218    );
219    print.println(format_auth_entry(auth));
220
221    let stdin = io::stdin();
222    if !stdin.is_terminal() {
223        return Err(Error::AuthEntryRequiresConfirmation);
224    }
225
226    print.warnln("Sign this authorization entry? (y/N)");
227    let mut response = String::new();
228    stdin.lock().read_line(&mut response)?;
229    if response.trim().eq_ignore_ascii_case("y") {
230        Ok(())
231    } else {
232        Err(Error::AuthRejected)
233    }
234}
235
236async fn sign_soroban_authorization_entry(
237    raw: &SorobanAuthorizationEntry,
238    signer: &Signer,
239    signature_expiration_ledger: u32,
240    network_id: &Hash,
241) -> Result<SorobanAuthorizationEntry, Error> {
242    let mut auth = raw.clone();
243    let SorobanAuthorizationEntry {
244        credentials: SorobanCredentials::Address(ref mut credentials),
245        ..
246    } = auth
247    else {
248        // Doesn't need special signing
249        return Ok(auth);
250    };
251    let SorobanAddressCredentials { nonce, .. } = credentials;
252
253    let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization {
254        network_id: network_id.clone(),
255        invocation: auth.root_invocation.clone(),
256        nonce: *nonce,
257        signature_expiration_ledger,
258    })
259    .to_xdr(Limits::none())?;
260
261    let payload = Sha256::digest(preimage);
262    let p: [u8; 32] = payload.as_slice().try_into()?;
263    let signature = signer.sign_payload(p).await?;
264    let public_key_vec = signer.get_public_key()?.0.to_vec();
265
266    let map = ScMap::sorted_from(vec![
267        (
268            ScVal::Symbol(ScSymbol("public_key".try_into()?)),
269            ScVal::Bytes(public_key_vec.try_into().map_err(Error::Xdr)?),
270        ),
271        (
272            ScVal::Symbol(ScSymbol("signature".try_into()?)),
273            ScVal::Bytes(
274                signature
275                    .to_bytes()
276                    .to_vec()
277                    .try_into()
278                    .map_err(Error::Xdr)?,
279            ),
280        ),
281    ])
282    .map_err(Error::Xdr)?;
283    credentials.signature = ScVal::Vec(Some(
284        vec![ScVal::Map(Some(map))].try_into().map_err(Error::Xdr)?,
285    ));
286    credentials.signature_expiration_ledger = signature_expiration_ledger;
287    auth.credentials = SorobanCredentials::Address(credentials.clone());
288    Ok(auth)
289}
290
291pub struct Signer {
292    pub kind: SignerKind,
293    pub print: Print,
294}
295
296#[allow(clippy::module_name_repetitions, clippy::large_enum_variant)]
297pub enum SignerKind {
298    Local(LocalKey),
299    Ledger(LedgerEntry),
300    Lab,
301    SecureStore(SecureStoreEntry),
302}
303
304// It is advised to use the sign_with module, which handles creating a Signer with the appropriate SignerKind
305impl Signer {
306    pub async fn sign_tx(
307        &self,
308        tx: Transaction,
309        network: &Network,
310    ) -> Result<TransactionEnvelope, Error> {
311        let tx_env = TransactionEnvelope::Tx(TransactionV1Envelope {
312            tx,
313            signatures: VecM::default(),
314        });
315        self.sign_tx_env(&tx_env, network).await
316    }
317
318    pub async fn sign_tx_env(
319        &self,
320        tx_env: &TransactionEnvelope,
321        network: &Network,
322    ) -> Result<TransactionEnvelope, Error> {
323        match &tx_env {
324            TransactionEnvelope::Tx(TransactionV1Envelope { tx, signatures }) => {
325                let tx_hash = transaction_hash(tx, &network.network_passphrase)?;
326                self.print
327                    .infoln(format!("Signing transaction: {}", hex::encode(tx_hash)));
328                let decorated_signature = self.sign_tx_hash(tx_hash, tx_env, network).await?;
329                let mut sigs = signatures.clone().into_vec();
330                sigs.push(decorated_signature);
331                Ok(TransactionEnvelope::Tx(TransactionV1Envelope {
332                    tx: tx.clone(),
333                    signatures: sigs.try_into()?,
334                }))
335            }
336            TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { tx, signatures }) => {
337                let tx_hash = fee_bump_transaction_hash(tx, &network.network_passphrase)?;
338                self.print.infoln(format!(
339                    "Signing fee bump transaction: {}",
340                    hex::encode(tx_hash),
341                ));
342                let decorated_signature = self.sign_tx_hash(tx_hash, tx_env, network).await?;
343                let mut sigs = signatures.clone().into_vec();
344                sigs.push(decorated_signature);
345                Ok(TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope {
346                    tx: tx.clone(),
347                    signatures: sigs.try_into()?,
348                }))
349            }
350            TransactionEnvelope::TxV0(_) => Err(Error::UnsupportedTransactionEnvelopeType),
351        }
352    }
353
354    pub fn get_public_key(&self) -> Result<stellar_strkey::ed25519::PublicKey, Error> {
355        match &self.kind {
356            SignerKind::Local(local_key) => Ok(stellar_strkey::ed25519::PublicKey::from_payload(
357                local_key.key.verifying_key().as_bytes(),
358            )?),
359            SignerKind::Ledger(ledger) => Ok(ledger
360                .public_key
361                .expect("Ledger signers reachable here are built from Secret::Ledger and always carry a cached public key")),
362            SignerKind::Lab => Err(Error::ReturningSignatureFromLab),
363            SignerKind::SecureStore(secure_store_entry) => secure_store_entry.get_public_key(),
364        }
365    }
366
367    pub async fn sign_payload(&self, payload: [u8; 32]) -> Result<Ed25519Signature, Error> {
368        match &self.kind {
369            SignerKind::Local(local_key) => local_key.sign_payload(payload),
370            SignerKind::Ledger(ledger) => Ok(ledger.sign_payload(payload).await?),
371            SignerKind::Lab => Err(Error::ReturningSignatureFromLab),
372            SignerKind::SecureStore(secure_store_entry) => secure_store_entry.sign_payload(payload),
373        }
374    }
375
376    async fn sign_tx_hash(
377        &self,
378        tx_hash: [u8; 32],
379        tx_env: &TransactionEnvelope,
380        network: &Network,
381    ) -> Result<DecoratedSignature, Error> {
382        match &self.kind {
383            SignerKind::Local(key) => key.sign_tx_hash(tx_hash),
384            SignerKind::Lab => Lab::sign_tx_env(tx_env, network, &self.print),
385            SignerKind::Ledger(ledger) => ledger.sign_tx_hash(tx_hash).await.map_err(Error::from),
386            SignerKind::SecureStore(entry) => entry.sign_tx_hash(tx_hash),
387        }
388    }
389}
390
391pub struct LocalKey {
392    pub key: ed25519_dalek::SigningKey,
393}
394
395impl LocalKey {
396    pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result<DecoratedSignature, Error> {
397        let hint = SignatureHint(self.key.verifying_key().to_bytes()[28..].try_into()?);
398        let signature = Signature(self.key.sign(&tx_hash).to_bytes().to_vec().try_into()?);
399        Ok(DecoratedSignature { hint, signature })
400    }
401
402    pub fn sign_payload(&self, payload: [u8; 32]) -> Result<Ed25519Signature, Error> {
403        Ok(self.key.sign(&payload))
404    }
405}
406
407pub struct Lab;
408
409impl Lab {
410    const URL: &str = "https://lab.stellar.org/transaction/cli-sign";
411
412    pub fn sign_tx_env(
413        tx_env: &TransactionEnvelope,
414        network: &Network,
415        printer: &Print,
416    ) -> Result<DecoratedSignature, Error> {
417        let xdr = tx_env.to_xdr_base64(Limits::none())?;
418
419        let mut url = url::Url::parse(Self::URL)?;
420        url.query_pairs_mut()
421            .append_pair("networkPassphrase", &network.network_passphrase)
422            .append_pair("xdr", &xdr);
423        let url = url.to_string();
424
425        printer.globeln(format!("Opening lab to sign transaction: {url}"));
426        open::that(url)?;
427
428        Err(Error::ReturningSignatureFromLab)
429    }
430}
431
432pub struct SecureStoreEntry {
433    pub name: String,
434    pub hd_path: Option<u32>,
435    pub public_key: Option<stellar_strkey::ed25519::PublicKey>,
436}
437
438impl SecureStoreEntry {
439    pub fn get_public_key(&self) -> Result<stellar_strkey::ed25519::PublicKey, Error> {
440        if let Some(pk) = &self.public_key {
441            return Ok(*pk);
442        }
443        Ok(secure_store::get_public_key(&self.name, self.hd_path)?)
444    }
445
446    pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result<DecoratedSignature, Error> {
447        let hint = SignatureHint(self.get_public_key()?.0[28..].try_into()?);
448
449        let signed_tx_hash = secure_store::sign_tx_data(&self.name, self.hd_path, &tx_hash)?;
450
451        let signature = Signature(signed_tx_hash.clone().try_into()?);
452        Ok(DecoratedSignature { hint, signature })
453    }
454
455    pub fn sign_payload(&self, payload: [u8; 32]) -> Result<Ed25519Signature, Error> {
456        let signed_bytes = secure_store::sign_tx_data(&self.name, self.hd_path, &payload)?;
457        let sig = Ed25519Signature::from_bytes(signed_bytes.as_slice().try_into()?);
458        Ok(sig)
459    }
460}
461
462/// Extract the Ed25519 public key bytes from a MuxedAccount
463fn muxed_account_bytes(source: &MuxedAccount) -> &[u8; 32] {
464    match source {
465        MuxedAccount::Ed25519(Uint256(bytes)) => bytes,
466        MuxedAccount::MuxedEd25519(muxed) => &muxed.ed25519.0,
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::signer::ledger::LedgerEntry;
474    use crate::xdr::{
475        BytesM, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, Preconditions,
476        SequenceNumber, SorobanAuthorizedFunction, SorobanAuthorizedInvocation, TransactionExt,
477    };
478
479    const NETWORK: &str = "Test SDF Network ; September 2015";
480    const EXPIRATION_LEDGER: u32 = 100;
481
482    fn local_signer(seed: [u8; 32]) -> Signer {
483        Signer {
484            kind: SignerKind::Local(LocalKey {
485                key: ed25519_dalek::SigningKey::from_bytes(&seed),
486            }),
487            print: Print::new(true),
488        }
489    }
490
491    fn signer_pubkey(signer: &Signer) -> [u8; 32] {
492        signer.get_public_key().unwrap().0
493    }
494
495    fn ed25519_address(bytes: [u8; 32]) -> ScAddress {
496        ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(bytes))))
497    }
498
499    fn invoke_args(contract: [u8; 32], fn_name: &str) -> InvokeContractArgs {
500        InvokeContractArgs {
501            contract_address: ScAddress::Contract(stellar_xdr::curr::ContractId(Hash(contract))),
502            function_name: ScSymbol(fn_name.try_into().unwrap()),
503            args: VecM::default(),
504        }
505    }
506
507    fn invocation(contract: [u8; 32], fn_name: &str) -> SorobanAuthorizedInvocation {
508        SorobanAuthorizedInvocation {
509            function: SorobanAuthorizedFunction::ContractFn(invoke_args(contract, fn_name)),
510            sub_invocations: VecM::default(),
511        }
512    }
513
514    fn address_auth(
515        address: ScAddress,
516        invocation: SorobanAuthorizedInvocation,
517    ) -> SorobanAuthorizationEntry {
518        SorobanAuthorizationEntry {
519            credentials: SorobanCredentials::Address(SorobanAddressCredentials {
520                address,
521                nonce: 0,
522                signature_expiration_ledger: 0,
523                signature: ScVal::Void,
524            }),
525            root_invocation: invocation,
526        }
527    }
528
529    fn build_tx(
530        source: MuxedAccount,
531        host_function: HostFunction,
532        auth: Vec<SorobanAuthorizationEntry>,
533    ) -> Transaction {
534        Transaction {
535            source_account: source,
536            fee: 100,
537            seq_num: SequenceNumber(1),
538            cond: Preconditions::None,
539            memo: Memo::None,
540            operations: vec![Operation {
541                source_account: None,
542                body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
543                    host_function,
544                    auth: auth.try_into().unwrap(),
545                }),
546            }]
547            .try_into()
548            .unwrap(),
549            ext: TransactionExt::V0,
550        }
551    }
552
553    /// Pull the embedded public_key bytes out of a signed Address-cred entry.
554    fn extract_signed_pubkey(creds: &SorobanAddressCredentials) -> [u8; 32] {
555        let ScVal::Vec(Some(outer)) = &creds.signature else {
556            panic!("expected ScVal::Vec signature");
557        };
558        let Some(ScVal::Map(Some(map))) = outer.first() else {
559            panic!("expected ScVal::Map inside signature vec");
560        };
561        map.iter()
562            .find_map(|e| match (&e.key, &e.val) {
563                (ScVal::Symbol(s), ScVal::Bytes(b)) if s.0.as_slice() == b"public_key" => {
564                    Some(b.as_slice().try_into().unwrap())
565                }
566                _ => None,
567            })
568            .expect("public_key entry")
569    }
570
571    #[tokio::test]
572    async fn test_signs_address_auth_entry_with_matching_signer() {
573        let signer = local_signer([1u8; 32]);
574        let signer_unused = local_signer([2u8; 32]);
575        let signer_pk = signer_pubkey(&signer);
576        let source = MuxedAccount::Ed25519(Uint256([9u8; 32]));
577        let contract = [42u8; 32];
578
579        let entry = address_auth(ed25519_address(signer_pk), invocation(contract, "hello"));
580        let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello"));
581        let tx = build_tx(source, host_fn, vec![entry]);
582
583        let signed_auth_tx = sign_soroban_authorizations(
584            &tx,
585            &[signer_unused, signer],
586            EXPIRATION_LEDGER,
587            NETWORK,
588            false,
589            &Print::new(true),
590        )
591        .await
592        .unwrap()
593        .expect("signing modifies the transaction");
594
595        let OperationBody::InvokeHostFunction(body) = &signed_auth_tx.operations[0].body else {
596            panic!("expected InvokeHostFunction");
597        };
598        let SorobanCredentials::Address(creds) = &body.auth[0].credentials else {
599            panic!("expected Address credentials");
600        };
601        assert!(
602            !matches!(creds.signature, ScVal::Void),
603            "signature should be filled in"
604        );
605        assert_eq!(creds.signature_expiration_ledger, EXPIRATION_LEDGER);
606        assert_eq!(
607            extract_signed_pubkey(creds),
608            signer_pk,
609            "embedded public_key should match the signer"
610        );
611    }
612
613    #[tokio::test]
614    async fn test_non_strict_auth_signs_when_allowed() {
615        let signer = local_signer([1u8; 32]);
616        let signer_pk = signer_pubkey(&signer);
617        let source = MuxedAccount::Ed25519(Uint256([9u8; 32]));
618        let contract = [42u8; 32];
619        let other_contract = [99u8; 32];
620
621        let entry = address_auth(
622            ed25519_address(signer_pk),
623            invocation(other_contract, "hello"),
624        );
625        let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello"));
626        let tx = build_tx(source, host_fn, vec![entry]);
627
628        let signed_auth_tx = sign_soroban_authorizations(
629            &tx,
630            &[signer],
631            EXPIRATION_LEDGER,
632            NETWORK,
633            true,
634            &Print::new(true),
635        )
636        .await
637        .unwrap()
638        .expect("signing modifies the transaction");
639
640        let OperationBody::InvokeHostFunction(body) = &signed_auth_tx.operations[0].body else {
641            panic!("expected InvokeHostFunction");
642        };
643        let SorobanCredentials::Address(creds) = &body.auth[0].credentials else {
644            panic!("expected Address credentials");
645        };
646        assert!(!matches!(creds.signature, ScVal::Void));
647    }
648
649    #[tokio::test]
650    async fn test_upload_wasm_with_auth_returns_invalid() {
651        let signer = local_signer([1u8; 32]);
652        let signer_pk = signer_pubkey(&signer);
653        let source = MuxedAccount::Ed25519(Uint256([9u8; 32]));
654        let wasm: BytesM = [0u8; 32].try_into().unwrap();
655
656        let entry = address_auth(ed25519_address(signer_pk), invocation([42u8; 32], "hello"));
657        let host_fn = HostFunction::UploadContractWasm(wasm);
658        let tx = build_tx(source, host_fn, vec![entry]);
659
660        let result = sign_soroban_authorizations(
661            &tx,
662            &[signer],
663            EXPIRATION_LEDGER,
664            NETWORK,
665            false,
666            &Print::new(true),
667        )
668        .await;
669        assert!(matches!(result, Err(Error::InvalidAuthEntry { .. })));
670    }
671
672    #[tokio::test]
673    async fn test_source_account_as_address_returns_invalid() {
674        let signer = local_signer([1u8; 32]);
675        let signer_pk = signer_pubkey(&signer);
676        let source = MuxedAccount::Ed25519(Uint256(signer_pk));
677        let contract = [42u8; 32];
678
679        let entry = address_auth(ed25519_address(signer_pk), invocation(contract, "hello"));
680        let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello"));
681        let tx = build_tx(source, host_fn, vec![entry]);
682
683        let result = sign_soroban_authorizations(
684            &tx,
685            &[signer],
686            EXPIRATION_LEDGER,
687            NETWORK,
688            false,
689            &Print::new(true),
690        )
691        .await;
692        assert!(matches!(result, Err(Error::InvalidAuthEntry { .. })));
693    }
694
695    #[tokio::test]
696    async fn test_missing_signer_returns_error() {
697        let source = MuxedAccount::Ed25519(Uint256([9u8; 32]));
698        let contract = [42u8; 32];
699        let unknown = [77u8; 32];
700
701        let entry = address_auth(ed25519_address(unknown), invocation(contract, "hello"));
702        let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello"));
703        let tx = build_tx(source, host_fn, vec![entry]);
704
705        let result = sign_soroban_authorizations(
706            &tx,
707            &[],
708            EXPIRATION_LEDGER,
709            NETWORK,
710            false,
711            &Print::new(true),
712        )
713        .await;
714        assert!(matches!(result, Err(Error::MissingSignerForAddress { .. })));
715    }
716
717    #[test]
718    fn ledger_signer_get_public_key_returns_cached_without_device() {
719        const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ";
720        let pk = stellar_strkey::ed25519::PublicKey::from_string(TEST_PUBLIC_KEY).unwrap();
721        let signer = Signer {
722            kind: SignerKind::Ledger(LedgerEntry {
723                hd_path: 0,
724                public_key: Some(pk),
725            }),
726            print: Print::new(true),
727        };
728        assert_eq!(
729            signer.get_public_key().unwrap().to_string(),
730            TEST_PUBLIC_KEY
731        );
732    }
733}