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