Skip to content

Conversation

@leighmcculloch
Copy link
Member

@leighmcculloch leighmcculloch commented Jan 9, 2026

What

Embed markers in the WASM data section for each type/event that is used at external boundaries. These markers enable post-build tools to filter unused entries from the contractspecv0 section.

Changes:

  • Add spec_marker module with marker generation logic
  • Update all derive macros (derive_struct, derive_struct_tuple, derive_enum, derive_enum_int, derive_error_enum_int, derive_event) to embed markers
  • Markers are placed inside __include_spec_marker() functions with volatile reads
  • Markers survive dead code elimination (DCE) only if the type/event is actually used

Why

Contract WASM files can contain unused type and event definitions in the contractspecv0 custom section that inflate binary size unnecessarily.

The problem: Any exported contracttypes or contractevents defined in the SDK or any library that don't get used in the importing contract still end up in the contract spec. This results in awkward behaviour where we avoid exporting contracttypes in the SDK, even though doing so would be convenient.

Why the SDK can't fix this alone: Because of how proc-macros run in isolation and cannot coordinate in WASM builds in Rust, there's nothing the soroban-sdk can do to identify whether a contracttype that sometimes needs to be exported hasn't been used and can be excluded.

Solution: The SDK embeds markers in the regular data section for each type/event when it's used. These markers survive DCE only if the corresponding code is reachable. A post-build tool (stellar-cli) extracts these markers and filters the spec accordingly.

Close #1569
Close stellar/stellar-cli#2030

Side-effects

A side-effect of this change, is that types/events brought into a contract from a contractimport can now be automatically exported from that contract if they use the types at their boundary. This is why the change also removes the export = false from types/events that come from a contractimport.

Close #1330

How

The SDK embeds markers using volatile reads in conversion/publish functions:

Marker format (12 bytes):

  • Bytes 0-3: SpEc magic prefix (alternating case to avoid false positives)
  • Bytes 4-11: 64-bit truncated SHA256 hash of the spec entry XDR bytes

Embedding mechanism:

impl MyType {
    #[inline(always)]
    fn __include_spec_marker() {
        #[cfg(target_family = "wasm")]
        {
            static MARKER: [u8; 12] = *b"SpEc\x93\xd5\x05\x04\x36\x68\x40\xd2";
            let _ = unsafe { ::core::ptr::read_volatile(MARKER.as_ptr()) };
        }
    }
}

When markers are included:

  • Types: __include_spec_marker() is called from TryFromVal implementations via IncludeSpec trait
  • Events: __include_spec_marker() is called from the publish() method

Why this works: If a type/event is never used at an external boundary, its __include_spec_marker() function is never called, and DCE removes both the function and its marker. If the type is used, the marker survives in the data section.

CLI Support Required

This feature requires CLI support for filtering specs using these markers. See: stellar/stellar-cli#2353

Try It Out

Install the modified stellar-cli that strips unused specs:

cargo install --locked --git https://github.com/stellar/stellar-cli --branch spec-clean stellar-cli

Import the modified soroban-sdk that marks used specs:

[dependencies]
soroban-sdk = { git = "https://github.com/stellar/rs-soroban-sdk", branch = "spec-markers" }

[dev-dependencies]
soroban-sdk = { git = "https://github.com/stellar/rs-soroban-sdk", branch = "spec-markers", features = ["testutils"] }"

Build using the stellar-cli:

stellar contract build

Inspect the contract interface and it should only show types actually used at the boundary of the contract (inputs, outputs, and events):

stellar contract info interface --wasm target/wasm32v1-none/release/contract.wasm

Todo

  • Figure out a roll out plan, because you can't really use this sdk until you use the new cli, otherwise your specs will increase in size rather than decrease because imported types are now exported then stripped.
  • Evaluate the cost of the volatile read on a large contract (expect impact is small)
  • Look for alternative ways other than a volatile read to force the marker to stick around
    • Try black box
    • Try #[used]
  • Markers need to be included for any nested user defined types and types inside containers

Copy link
Contributor

@mootz12 mootz12 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall. Leaving review as comment for now as I haven't looked at the CLI side or done much testing.

One thing to note is this might have some impacts if the spec-marker ever changes to release complexity between the CLI and SDK.

Might not be anything we can do about it :/

/// 2. Extract the hash from each marker
/// 3. Match against specs in contractspecv0 section (by hashing each spec)
/// 4. Strip unused specs from contractspecv0
// TODO: Move the spec marker logic into a crate that can be shared with the CLI.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed yet?

use super::*;

#[test]
fn test_spec_marker() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(picky) can we add a test here to assert against a fixed spec_marker? It should fail if the structure of spec_market changes, whereas this one won't as long as the first 4 bytes and len don't change.

/// Spec marker generation for identifying used spec entries.
///
/// The marker is a byte array in the data section with a distinctive pattern:
/// - 4 bytes: "SpEc" prefix
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it worthwhile to version tag this at all?

SpEcV1

In the event this ever changes, would we need to maintain backwards compatibility? Or is it more of a "use the correct CLI version for your given SDK version"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add this. The SDK will also have the SDK version embedded inside the meta custom section and I was planning to update the stellar-cli, so I think it can work out from that version what it should do.

Comment on lines 102 to 126
// Create a marker that identifies this spec entry. The marker is a byte array
// in the data section with a distinctive pattern: "SpEc" + truncated SHA256.
// Post-build tools can scan the data section for "SpEc" markers and match
// against specs in contractspecv0.
let marker = spec_marker::spec_marker(spec_xdr);
let marker_lit = proc_macro2::Literal::byte_string(&marker);
let marker_len = marker.len();
Some(quote! {
impl #path::IncludeSpecMarker for #ident {
#[doc(hidden)]
#[inline(always)]
fn include_spec_marker() {
// Include markers for nested field types.
#(<#field_types as #path::IncludeSpecMarker>::include_spec_marker();)*
#[cfg(target_family = "wasm")]
{
// Marker in data section. Post-build tools can scan for "SpEc"
// patterns and match against specs in contractspecv0.
static MARKER: [u8; #marker_len] = *#marker_lit;
// Volatile read prevents DCE within live function.
let _ = unsafe { ::core::ptr::read_volatile(MARKER.as_ptr()) };
}
}
}
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we extract this logic out of the derive_* files to reduce some code duplication?

leighmcculloch and others added 2 commits January 14, 2026 06:18
Co-authored-by: mootz12 <38118608+mootz12@users.noreply.github.com>
@mootz12
Copy link
Contributor

mootz12 commented Jan 14, 2026

Is it worth considering putting this behind a build flag of some sort? This way, the CLI could provide it for users so the process is hidden.

If some1 builds directly with cargo and doesn't provide the flag, the spec markers won't be added to avoid polluting the WASM.

@leighmcculloch
Copy link
Member Author

That's a good idea, at least during a rollout period. Something to note though is that part of this change is changing specs so they always output to the custom section, even specs from libraries and imports, so that the cli can then narrow them down to the ones being used. If you import a library today you get way more pollution than what these markers introduce. If you import with contractimport you get no type exporting which is really broken. So any world where we keep the old way is going to propogate continued broken experiences.

@leighmcculloch
Copy link
Member Author

One thing to note is this might have some impacts if the spec-marker ever changes to release complexity between the CLI and SDK.

Might not be anything we can do about it :/

Is it worth considering putting this behind a build flag of some sort? This way, the CLI could provide it for users so the process is hidden.

If some1 builds directly with cargo and doesn't provide the flag, the spec markers won't be added to avoid polluting the WASM.

I had a look at doing this, so that the sdk worked the way it does today if not used with a new cli. The approach I tried was that the cli would set a special cfg via rustflags, that the sdk would use to control if it should use this new approach or not. But it results in much more complex macro code because there's essentially two paths for everything.

Part of the problem is that as part of this change more specs, all specs actually including those from contractimport!, will now initially end up in the wasm file. Today we selectively export specs as a best effort guess, and so some specs like those generated using contractimport! do not do not get exported. But with this change the selection of specs to keep will be done in the CLI, and we will now export all specs from imports and libraries. Todays selection logic is imperfect. If we kept that old imperfect logic, someone building with cargo build and stellar contract build would probably see missing spec entries which would look like a bug.

I think it'd be preferred if the cargo build output was at least a superset, so I don't think we should try and keep the imperfect spec selection around, and all used specs are always included, and the cli is just an optimisation step now.

So instead I've added a cfg that the cli sets to indicate it supports optimising contract specs, and the SDK can output a warning if its being built with an old CLI or no CLI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants