Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,370 changes: 1,224 additions & 1,146 deletions Cargo.lock

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,43 @@ version = "=27.0.0"
path = "cmd/crates/stellar-ledger"

# Dependencies from the rs-stellar-xdr repo:
# Protocol 28 (CAP-0085, externally managed contract executables) needs the
# CAP-85 XDR types, which are gated behind `cap_0085_executable_ref` and are in
# no crates.io release yet. Pinned to the same rev rs-soroban-env pins so the
# whole graph agrees on one stellar-xdr; the [patch.crates-io] below redirects
# the transitive users (soroban-spec, soroban-sdk, stellar-rpc-client, ...)
# onto it, without which they would resolve a second, CAP-85-blind copy.
# Productionize -> a released 28.x version on crates.io once one is cut.
[workspace.dependencies.stellar-xdr]
version = "27.0.0"
git = "https://github.com/stellar/rs-stellar-xdr"
rev = "a90de0c0d6eba8be668e9074cfb7b285ac0ecd34"
features = ["cap_0085_executable_ref"]

# Protocol 28 (CAP-0085): the CAP-85 XDR types force the whole stack to move
# together. Patching stellar-xdr alone makes crates.io soroban-env-common 27.0.0
# fail to build -- its ScVal / ScValType matches do not cover ExecutableTag --
# and leaving it unpatched resolves a second CAP-85-blind stellar-xdr that then
# mismatches types at every crate boundary. So env and the SDK family move too.
#
# The soroban-* patches point at rs-soroban-sdk#1929 (CAP-0085), which carries
# the merged-upstream env/xdr pins and the contract-facing CAP-85 surface. That
# branch is fork-hosted while the PR is open, hence the sisuresh git URL; the env
# and xdr revs above are already upstream `stellar` revs.
# Productionize -> re-point the soroban-* patches at a stellar/rs-soroban-sdk rev
# once #1929 lands, then drop this whole block in favour of released 28.x
# versions on crates.io.
[patch.crates-io]
stellar-xdr = { git = "https://github.com/stellar/rs-stellar-xdr", rev = "a90de0c0d6eba8be668e9074cfb7b285ac0ecd34" }
soroban-env-common = { git = "https://github.com/stellar/rs-soroban-env", rev = "358d8bafde7332ad176165b8f9da0bd979e33cd9" }
soroban-env-guest = { git = "https://github.com/stellar/rs-soroban-env", rev = "358d8bafde7332ad176165b8f9da0bd979e33cd9" }
soroban-env-host = { git = "https://github.com/stellar/rs-soroban-env", rev = "358d8bafde7332ad176165b8f9da0bd979e33cd9" }
soroban-sdk = { git = "https://github.com/sisuresh/rs-soroban-sdk", rev = "b30d8e3dc95448f2b19fd651edc05604a66cfd2e" }
soroban-spec = { git = "https://github.com/sisuresh/rs-soroban-sdk", rev = "b30d8e3dc95448f2b19fd651edc05604a66cfd2e" }
soroban-spec-rust = { git = "https://github.com/sisuresh/rs-soroban-sdk", rev = "b30d8e3dc95448f2b19fd651edc05604a66cfd2e" }
soroban-token-sdk = { git = "https://github.com/sisuresh/rs-soroban-sdk", rev = "b30d8e3dc95448f2b19fd651edc05604a66cfd2e" }
soroban-ledger-snapshot = { git = "https://github.com/sisuresh/rs-soroban-sdk", rev = "b30d8e3dc95448f2b19fd651edc05604a66cfd2e" }
stellar-asset-spec = { git = "https://github.com/sisuresh/rs-soroban-sdk", rev = "b30d8e3dc95448f2b19fd651edc05604a66cfd2e" }

# Dependencies from the rs-stellar-env repo:
[workspace.dependencies.soroban-env-host]
Expand Down
27 changes: 27 additions & 0 deletions cmd/crates/soroban-spec-tools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,20 @@ pub fn to_json(v: &ScVal) -> Result<Value, Error> {
Value::Object(m)
}
ScVal::Bytes(v) => Value::String(to_lower_hex(v.as_slice())),
// CAP-0085. Rendered as a tagged object rather than a bare string so it
// stays distinguishable from ScVal::String, which shares its payload.
ScVal::ExecutableTag(v) => {
let mut m = serde_json::Map::<String, Value>::with_capacity(1);
m.insert(
"executable_tag".to_string(),
Value::String(
std::str::from_utf8(v.as_slice())
.map_err(|_| Error::InvalidValue(Some(ScType::String)))?
.to_string(),
),
);
Value::Object(m)
}
ScVal::Address(v) => sc_address_to_json(v),
ScVal::U128(n) => {
let hi: [u8; 8] = n.hi.to_be_bytes();
Expand Down Expand Up @@ -1094,6 +1108,19 @@ pub fn to_json(v: &ScVal) -> Result<Value, Error> {
executable: ContractExecutable::StellarAsset,
..
}) => json!({"SAC": true}),
// CAP-0085. The instance stores the reference, not the resolved hash:
// the Wasm actually executed is whatever the owner's entry holds right
// now, so report the reference and let the caller resolve it.
ScVal::ContractInstance(ScContractInstance {
executable: ContractExecutable::ExternalRef(r),
..
}) => json!({
"external_ref": {
"executable_owner": sc_address_to_json(&r.executable_owner),
"tag": std::str::from_utf8(r.tag.as_slice())
.map_err(|_| Error::InvalidValue(Some(ScType::String)))?,
}
}),
ScVal::LedgerKeyNonce(ScNonceKey { nonce }) => {
Value::Number(serde_json::Number::from(*nonce))
}
Expand Down
149 changes: 133 additions & 16 deletions cmd/soroban-cli/src/commands/contract/deploy/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ use crate::config::address::AliasName;
use crate::resources;
use crate::tx::sim_sign_and_send_tx;
use crate::xdr::{
AccountId, ContractExecutable, ContractIdPreimage, ContractIdPreimageFromAddress,
CreateContractArgs, CreateContractArgsV2, Error as XdrError, Hash, HostFunction,
InvokeContractArgs, InvokeHostFunctionOp, Limits, Memo, MuxedAccount, Operation, OperationBody,
Preconditions, PublicKey, ScAddress, SequenceNumber, Transaction, TransactionExt, Uint256,
VecM, WriteXdr,
AccountId, ContractExecutable, ContractExecutableExternalRef, ContractIdPreimage,
ContractIdPreimageFromAddress, CreateContractArgs, CreateContractArgsV2, Error as XdrError,
Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Limits, Memo, MuxedAccount,
Operation, OperationBody, Preconditions, PublicKey, ScAddress, ScString, SequenceNumber,
Transaction, TransactionExt, Uint256, VecM, WriteXdr,
};

use crate::commands::tx::fetch;
Expand All @@ -29,7 +29,10 @@ use crate::{
config::{self, data, locator, network},
print::Print,
rpc,
utils::{self, rpc::get_remote_wasm_from_hash},
utils::{
self,
rpc::{get_remote_wasm_from_hash, resolve_executable_ref},
},
wasm,
};

Expand All @@ -51,6 +54,16 @@ pub struct Cmd {
/// Hash of the already installed/deployed WASM file
#[arg(long = "wasm-hash", conflicts_with = "wasm", group = "wasm_src")]
pub wasm_hash: Option<String>,
/// Contract that owns the executable reference entry to deploy against
/// (CAP-0085). The deployed contract runs whichever WASM that entry names,
/// and follows it whenever the owner re-points it — so the owner can
/// replace this contract's code at any time. Requires --executable-tag.
#[arg(long, requires = "executable_tag", conflicts_with = "wasm_src")]
pub executable_owner: Option<String>,
/// Tag naming the executable reference entry in the owner's storage
/// (CAP-0085). Requires --executable-owner.
#[arg(long, requires = "executable_owner", conflicts_with = "wasm_src")]
pub executable_tag: Option<String>,
/// Custom salt 32-byte salt for the token id
#[arg(long)]
pub salt: Option<String>,
Expand Down Expand Up @@ -109,6 +122,10 @@ pub enum Error {
wasm_hash: String,
error: stellar_strkey::DecodeError,
},
#[error("cannot parse executable owner {executable_owner}: expected a contract address")]
CannotParseExecutableOwner { executable_owner: String },
#[error("cannot parse executable tag {executable_tag}")]
CannotParseExecutableTag { executable_tag: String },

#[error("Must provide either --wasm or --wasm-hash")]
WasmNotProvided,
Expand Down Expand Up @@ -298,8 +315,10 @@ impl Cmd {
}]);
}

// If --wasm-hash is provided, no WASM file paths needed
if self.wasm_hash.is_some() {
// If --wasm-hash is provided, no WASM file paths needed. Same for a
// CAP-0085 executable reference: the code is named by the owner's
// entry, so there is nothing local to build.
if self.wasm_hash.is_some() || self.executable_owner.is_some() {
return Ok(vec![]);
}

Expand Down Expand Up @@ -332,6 +351,82 @@ impl Cmd {
self.auth_mode.validate_not_enforce()?;

let print = Print::new(quiet);

// CAP-0085: --executable-owner/--executable-tag deploy against an
// executable reference rather than a wasm hash. Nothing is uploaded and
// no local wasm is involved; the code is whatever the owner's entry
// names at apply time.
let external_ref = self.external_ref()?;

let wasm_hash = if let Some(r) = &external_ref {
print.infoln(
format!(
"Deploying contract using executable reference {} of contract {}",
String::from_utf8_lossy(r.tag.as_slice()),
r.executable_owner
)
.as_str(),
);
None
} else {
Some(self.resolve_wasm_hash(config, quiet, no_cache, &print).await?)
};

let network = config.get_network()?;
let client = network.rpc_client()?;

let executable = match &external_ref {
Some(r) => ContractExecutable::ExternalRef(r.clone()),
None => ContractExecutable::Wasm(
wasm_hash.clone().expect("wasm hash set when not a reference"),
),
};

self.execute_inner(
config,
quiet,
no_cache,
print,
network,
client,
executable,
wasm_hash,
external_ref,
)
.await
}

/// Parse the CAP-0085 executable reference arguments, if given. Clap
/// enforces that the two flags appear together and never alongside
/// --wasm/--wasm-hash.
fn external_ref(&self) -> Result<Option<ContractExecutableExternalRef>, Error> {
let (Some(owner), Some(tag)) = (&self.executable_owner, &self.executable_tag) else {
return Ok(None);
};
let owner_key = stellar_strkey::Contract::from_string(owner).map_err(|_| {
Error::CannotParseExecutableOwner {
executable_owner: owner.clone(),
}
})?;
let executable_owner = ScAddress::Contract(Hash(owner_key.0).into());
let tag = ScString(tag.as_str().try_into().map_err(|_| {
Error::CannotParseExecutableTag {
executable_tag: tag.clone(),
}
})?);
Ok(Some(ContractExecutableExternalRef {
executable_owner,
tag,
}))
}

async fn resolve_wasm_hash(
&self,
config: &config::Args,
quiet: bool,
no_cache: bool,
print: &Print,
) -> Result<Hash, Error> {
let wasm_hash = if let Some(wasm) = &self.wasm {
let is_build = self.build_only;
let hash = if is_build {
Expand Down Expand Up @@ -371,8 +466,22 @@ impl Cmd {
);

print.infoln(format!("Deploying contract using wasm hash {wasm_hash}").as_str());
Ok(wasm_hash)
}

let network = config.get_network()?;
#[allow(clippy::too_many_arguments)]
async fn execute_inner(
&self,
config: &config::Args,
quiet: bool,
no_cache: bool,
print: Print,
network: config::network::Network,
client: rpc::Client,
executable: ContractExecutable,
wasm_hash: Option<Hash>,
external_ref: Option<ContractExecutableExternalRef>,
) -> Result<TxnResult<stellar_strkey::Contract>, Error> {
let salt: [u8; 32] = match &self.salt {
Some(h) => soroban_spec_tools::utils::padded_hex_from_str(h, 32)
.map_err(|_| Error::CannotParseSalt { salt: h.clone() })?
Expand All @@ -381,7 +490,6 @@ impl Cmd {
None => rand::thread_rng().gen::<[u8; 32]>(),
};

let client = network.rpc_client()?;
let MuxedAccount::Ed25519(bytes) = config.source_account()? else {
return Err(Error::OnlyEd25519AccountsAllowed);
};
Expand All @@ -394,11 +502,20 @@ impl Cmd {
get_contract_id(contract_id_preimage.clone(), &network.network_passphrase)?;
let raw_wasm = if let Some(wasm) = self.wasm.as_ref() {
wasm::Args { wasm: wasm.clone() }.read()?
} else if let Some(r) = &external_ref {
// The constructor signature still has to come from the actual code,
// so resolve the reference and fetch whatever it names right now.
let hash = resolve_executable_ref(&client, r).await?;
get_remote_wasm_from_hash(&client, &hash).await?
} else {
if self.build_only {
return Err(Error::WasmNotProvided);
}
get_remote_wasm_from_hash(&client, &wasm_hash).await?
get_remote_wasm_from_hash(
&client,
wasm_hash.as_ref().expect("wasm hash set when not a reference"),
)
.await?
};
let entries = soroban_spec_tools::contract::Spec::new(&raw_wasm)?.spec;
let res = soroban_spec_tools::Spec::new(entries.clone().as_slice());
Expand Down Expand Up @@ -430,7 +547,7 @@ impl Cmd {
let account_details = client.get_account(&source_account.to_string()).await?;
let sequence: i64 = account_details.seq_num.into();
let txn = Box::new(build_create_contract_tx(
wasm_hash,
executable,
sequence + 1,
config.get_inclusion_fee()?,
source_account,
Expand Down Expand Up @@ -482,7 +599,7 @@ fn reserved_package_alias(
}

fn build_create_contract_tx(
wasm_hash: Hash,
executable: ContractExecutable,
sequence: i64,
fee: u32,
key: AccountId,
Expand All @@ -495,7 +612,7 @@ fn build_create_contract_tx(
body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
host_function: HostFunction::CreateContractV2(CreateContractArgsV2 {
contract_id_preimage,
executable: ContractExecutable::Wasm(wasm_hash),
executable,
constructor_args: args.clone(),
}),
auth: VecM::default(),
Expand All @@ -507,7 +624,7 @@ fn build_create_contract_tx(
body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
host_function: HostFunction::CreateContract(CreateContractArgs {
contract_id_preimage,
executable: ContractExecutable::Wasm(wasm_hash),
executable,
}),
auth: VecM::default(),
}),
Expand Down Expand Up @@ -550,7 +667,7 @@ mod tests {
});

let result = build_create_contract_tx(
Hash(hash),
ContractExecutable::Wasm(Hash(hash)),
300,
1,
source_account,
Expand Down
20 changes: 19 additions & 1 deletion cmd/soroban-cli/src/get_spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub use soroban_spec_tools::contract as contract_spec;
use crate::commands::global;
use crate::config::{self, data, locator, network};
use crate::rpc;
use crate::utils::rpc::get_remote_wasm_from_hash;
use crate::utils::rpc::{get_remote_wasm_from_hash, resolve_executable_ref};

#[derive(thiserror::Error, Debug)]
pub enum Error {
Expand Down Expand Up @@ -76,5 +76,23 @@ pub async fn get_remote_contract_spec(
ContractExecutable::StellarAsset => {
soroban_spec::read::parse_raw(stellar_asset_spec::xdr())?
}
// CAP-0085: the spec lives in whichever Wasm the reference currently
// resolves to. Caching stays keyed on that resolved hash, not on the
// contract id, so re-pointing the reference naturally misses the cache
// instead of serving a stale spec.
ContractExecutable::ExternalRef(r) => {
let hash = resolve_executable_ref(&client, &r).await?;
let hash_str = hash.to_string();
if let Ok(entries) = data::read_spec(&hash_str) {
entries
} else {
let raw_wasm = get_remote_wasm_from_hash(&client, &hash).await?;
let res = contract_spec::Spec::new(&raw_wasm)?.spec;
if global_args.is_none_or(|a| !a.no_cache) {
data::write_spec(&hash_str, &res)?;
}
res
}
}
})
}
17 changes: 17 additions & 0 deletions cmd/soroban-cli/src/log/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,23 @@ fn format_create_contract(
ContractExecutable::StellarAsset => {
let _ = writeln!(result, "{prefix} Executable: StellarAsset");
}
// CAP-0085. This is a signing-review surface, so show the reference
// rather than resolving it: what is being authorized is a contract
// whose code the owner can replace at will, and the resolved hash
// would misrepresent that as a fixed executable.
ContractExecutable::ExternalRef(r) => {
let _ = writeln!(result, "{prefix} Executable: ExternalRef");
let _ = writeln!(
result,
"{prefix} Owner: {}",
format_address(&r.executable_owner)
);
let _ = writeln!(
result,
"{prefix} Tag: {}",
String::from_utf8_lossy(r.tag.as_slice())
);
}
}
if let Some(args) = constructor_args {
if !args.is_empty() {
Expand Down
Loading