Developers
Soroban Rust SDK v28
Author
Leigh McCulloch
Publishing date
Version 28 of the Soroban Rust SDK has been released and contains a variety of build system changes, new behavior to better support contract data migration, and new features. For most contracts this should be a seamless experience.
- Spec shaking v2
- Contract data migration
- Events publish sparsely
- Executable references
- Native contracts can be uploaded in tests
Spec shaking v2
A Wasm contract stores within it a specification of the contract’s interface. It holds an entry for every function, type, error, and event the contract defines.
Before v28 the soroban-sdk decided what to include from Rust visibility modifiers plus an export argument: pub types and all events got an entry, non-pub types did not, and the export argument allowed a developer to choose for a type. For many contracts this worked well enough, but for complex contracts, contracts that import other contracts, or newer contracts importing large libraries like the OpenZeppelin libraries, it resulted in large numbers of types being included in the spec that the contract didn’t need. Whether a type belongs in the spec is a property of the contract's interface, not of the module it happens to be declared in or its Rust visibility.
Spec shaking v2 changes the way that specs are included, using one of the same principles that compilers use to determine if code should be included in the final binary: dead code elimination. The SDK now emits an entry for everything, and the build system in stellar-cli strips the entries the contract does not use. The mechanism is a 14-byte marker in the Wasm data section for each type and event. Markers exist in code for all types, errors, and events, but they are only included in the final Wasm binary if they are used by the contract at the boundary. Markers for types that are not used, or are only used internally for operations like storage, are eliminated by Rust’s dead code elimination. A type is used at the boundary when it is used as a function parameter, return value, in a panic_with_error! call, or an event publish(). The stellar-cli scans for the markers that survived and strips spec entries without one.
The spec shaking v2 feature (experimental_spec_shaking_v2) has been available for opt-in since soroban-sdk v26, and it has been enabled for any contract built with OpenZeppelin's stellar-contracts v0.7.0, so a large share of contracts already build with the v28 behavior. In v28 spec shaking v2 is always enabled.
There are two other ways developers can expect to see this impact:
contractimport! produces spec entries for the types in the imported contract. If the importing contract uses any of those types in its interface, those types are exported automatically. Previously, types from imported contracts were never exported, which left contract interfaces incomplete.
The export argument on contracttype and contracterror is removed. It was deprecated in v27, and is now a compile error.
Because the final strip happens after the compiler runs, contracts must be built with stellar contract build from stellar-cli v25.2.0 or newer. Building for Wasm with anything else now fails:
error: soroban-sdk requires stellar-cli v25.2.0+ to build a contractThe check only fires for Wasm builds. Tests are unaffected and can be run with cargo test.
Contract data migration
A #[contracttype] struct is represented on the ledger as a map keyed by field names. Before v28 that map had to match the struct exactly. Every field of the struct had to be present as a key, and no other key could be present. Anything else was an error.
That made the structure of stored data effectively frozen. Adding a field meant every value written by the previous version failed to unpack. Removing a field did the same. The only way to migrate data was to plan ahead and use versioned structures or use other complex migration strategies.
CAP-86 added host functions that unpack maps sparsely, described in migration-friendly contract data in the Protocol 28 announcement. v28 of the SDK uses them for contracttype unpacking.
A field absent from the map unpacks as void. Void unpacks into an Option as None, so a field added to a struct as an Option reads back as None from data stored before the field existed.
#[contracttype]
pub struct State {
pub count: u32,
pub label: Option<u32>, // 👈 added after `count` was already stored
}
// A map holding only `count`, as stored before `label` existed.
let val = map![&env, (symbol_short!("count"), 5u32)].to_val();
assert_eq!(
State::try_from_val(&env, &val),
Ok(State { count: 5, label: None }),
);A key in the map that is not a field of the struct is ignored. A field removed from a struct is discarded when reading data stored while the field still existed.
The migration strategy that becomes available is:
- To add new fields, add them to the struct with an
Option<T>type. - To remove fields, remove them from the struct.
Packing is unchanged. Every field of the struct is written, including Option fields that are None, which are written as void.
The change applies to structs with named fields, and to every unpack of one including reading from storage and incoming input values. Tuple structs and enums are represented as vecs, not maps, and are unaffected, as are contractevent and contracterror types.
Events publish sparsely
An event declared with data_format = "map", the default, publishes its data fields as a map keyed by field names. Every field used to be written, including fields whose value is void, or an Option::None.
In v28 a void or Option::None field is omitted from the published map.
#[contractevent]
pub struct Transfer {
#[topic]
to: Address,
to_muxed_id: Option<u64>,
amount: i128,
}The above transfer event emits only the to and amount fields when to_muxed_id is Option::None.
Events can now carry fields that are only sometimes meaningful, without paying for it in every event. The SEP-41 transfer event is the case in point: a single Transfer with an optional to_muxed_id, rather than two event types, one with the muxed id and one without. Consumers see the field when it means something and no trace of it when it does not.
This applies only to the top level of an event's data map. Packing everywhere else is unchanged: a contracttype struct writes all of its fields, whether it is stored, passed to a function, or nested inside the value of an event field.
An event that must keep publishing every field can opt out:
#[contractevent(sparse = false)]
pub struct Transfer {
#[topic]
to: Address,
to_muxed_id: Option<u64>,
amount: i128,
}Executable references
The soroban-sdk adds support for executable references that were introduced in CAP-85. A contract's executable can now be a reference to an entry rather than a Wasm hash directly. An executable reference entry is a persistent entry owned by a contract and keyed by a tag, holding a Wasm hash; contracts deployed against it load their code from whatever hash it currently holds. Updating the entry switches every contract using it at its next invocation, which makes upgrading a fleet of contracts a single write. Contracts can manage their own executable reference entries with env.executable_refs().
Deploying and upgrading now take a ContractExecutable that may be a Wasm(hash) or ExternalRef(ContractExecutableRef { owner, tag }). For example:
#[contractimpl]
impl Contract {
/// Set the executable reference, keyed by `name`, pointing at
/// `wasm_hash`.
pub fn set_ref(env: Env, name: String, wasm_hash: BytesN<32>) {
env.executable_refs().set(&name, &wasm_hash);
}
/// Deploy a contract using the executable reference entry.
pub fn deploy(env: Env, name: String) -> Address {
let salt = [0u8; 32];
let deployer = env.deployer().with_current_contract(salt);
deployer.deploy_contract(
ContractExecutable::ExternalRef(ContractExecutableRef {
owner: env.current_contract_address(),
tag: name,
}),
(),
)
}
}The ContractExecutable type may also now be seen by custom accounts in __check_auth. A __check_auth function that inspects the executable before approving a deployment must handle ExternalRef. For example:
#[contractimpl]
impl CustomAccountInterface for Account {
type Signature = (); // Replace with the signature
type Error = Error;
fn __check_auth(
env: Env,
signature_payload: Hash<32>,
signature: (),
auth_contexts: Vec<Context>,
) -> Result<(), Error> {
// ... verify the signature ...
for context in auth_contexts.iter() {
let executable = match context {
Context::Contract(_) => continue,
Context::CreateContractHostFn(c) => c.executable,
Context::CreateContractWithCtorHostFn(c) => c.executable,
};
match executable {
ContractExecutable::Wasm(_wasm_hash) => {
// ... consider the wasm hash ...
}
ContractExecutable::ExternalRef(_external_ref) => {
// ... consider the external ref ...
}
}
}
Ok(())
}
}Native contracts can be uploaded in tests
The Env::upload function has been added and supports uploading a natively defined contract that exists in the Rust code as if it were a Wasm, returning a hash that can be deployed from as many times as needed. It exists to support testing factories, or any contract that deploys from an already-uploaded code entry, without compiling to Wasm first.
// Upload in test setup.
let wasm_hash = env.upload(Contract);
// Deploy from within a contract.
let contract_a = env
.deployer()
.with_address(deployer.clone(), [0u8; 32])
.deploy_contract(ContractExecutable::Wasm(wasm_hash.clone()), ());Upgrading to v28
When upgrading to soroban-sdk v28:
- Build with
stellar contract build. - Remove any
exportargument oncontracttypeorcontracterror. - Review offchain consumers that read an event's data map, because a field they expect present with a void value will be absent for contracts built with v28.
- Review code that relied on unpacking a
contracttypeneeding to fail due to missing fields. - Review custom accounts that use
__check_authto ensureContractExecutableis properly handled when validating deployment arguments.
This post provides a technical overview only. Developers should review the complete migration documentation and test their contracts and integrations before deploying with Soroban Rust SDK v28.
The full set of migration instructions, with runnable examples, is in the SDK's Rust docs:
For a full set of release notes, see the SDK’s release page:
This version is not yet audited. For a list of audited versions of the Soroban Rust SDK, consult:
