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