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, 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/// Sign all SorobanAuthorizationEntry's in the transaction with the given signers. Returns a new
68/// transaction with the signatures added to each SorobanAuthorizationEntry.
69///
70/// If no SorobanAuthorizationEntry's need signing (including if none exist), return Ok(None).
71///
72/// If a SorobanAuthorizationEntry needs signing, but a signature cannot be produced for it,
73/// return an Error
74#[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    // Check if we have exactly one operation and it's InvokeHostFunction
84    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            // Source account is authorized by the transaction signature itself; nothing to sign here.
109            SorobanCredentials::SourceAccount => {
110                signed_auths.push(raw_auth.clone());
111                continue;
112            }
113        };
114        let SorobanAddressCredentials { address, .. } = credentials;
115
116        // Before we attempt to sign, validate the auth entry is strict
117        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        // See if we have a signer for this authorizationEntry
131        // If not, then we Error
132        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                // This address is for a contract. This means we're using a custom
139                // smart-contract account. Currently the CLI doesn't support that yet.
140                return Err(Error::MissingSignerForAddress {
141                    address: format!(
142                        "{}",
143                        stellar_strkey::Strkey::Contract(stellar_strkey::Contract(*c))
144                    ),
145                });
146            }
147        };
148
149        // Auth entries should not request a signature from the tx source account via the `Address` credential type
150        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 we didn't modify any entries, return Ok(None) to indicate no changes needed to the transaction
191    if !auths_modified {
192        return Ok(None);
193    }
194
195    // Build updated transaction with signed auth entries
196    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
207/// Handle a non-strict auth entry. Under `--auto-sign` (`skip_approval`), log
208/// the entry through `Print` so the relaxed policy leaves an audit trail that
209/// the user can silence with `--quiet`. Otherwise, prompt the user
210/// interactively for approval.
211fn 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    // ignore quiet flag here as we are prompting the user
227    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    // `Address` (V1) and `AddressV2` share the same credential struct; only the
257    // signature payload differs. The RPC returns V1 by default, but we sign
258    // whichever variant the entry carries and preserve it on the way out.
259    let (credentials, is_v2) = match &mut auth.credentials {
260        SorobanCredentials::Address(credentials) => (credentials, false),
261        SorobanCredentials::AddressV2(credentials) => (credentials, true),
262        // SourceAccount does not need special signing
263        // AddressWithDelegates is not supported yet, and a warning is emitted earlier
264        _ => return Ok(auth),
265    };
266
267    // V2 binds the signer's address into the payload (CAP-0071-02) via the
268    // `SorobanAuthorizationWithAddress` preimage; V1 omits it.
269    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
331// It is advised to use the sign_with module, which handles creating a Signer with the appropriate SignerKind
332impl 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
496/// Extract the Ed25519 public key bytes from a MuxedAccount
497fn 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    /// Pull the embedded public_key bytes out of a signed Address-cred entry.
588    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    // ---- AddressV2 (CAP-0071-02) ----
769
770    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    /// Pull the embedded 64-byte signature out of a signed Address-cred entry.
786    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    /// Borrow the `SorobanAddressCredentials` from the first auth entry,
804    /// whether it is an `Address` (V1) or `AddressV2` entry.
805    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        // The variant must be preserved as AddressV2 (not downgraded to V1).
844        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        // The signature must validate against the V2 `WithAddress` preimage,
851        // which binds the signer's address into the payload.
852        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        // Same address/nonce/invocation, but V2 binds the address into the
920        // payload, so the resulting signatures must differ.
921        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}