Skip to content

Add contractevent macro and event support to contract spec#1473

Merged
leighmcculloch merged 15 commits intomainfrom
add-events-to-contract-spec
Jul 15, 2025
Merged

Add contractevent macro and event support to contract spec#1473
leighmcculloch merged 15 commits intomainfrom
add-events-to-contract-spec

Conversation

@leighmcculloch
Copy link
Copy Markdown
Member

@leighmcculloch leighmcculloch commented May 30, 2025

What

Add contractevent macro for defining events, and event support to contract spec.

In it's most basic form, as follows, where an automatic prefix topic based on the name will be published, with any topic fields appended to the topic list, and any other fields emitted as map entries in a map:

    #[contractevent]
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
    pub struct MyEvent {
        #[topic]
        pub my_topic: u32,
        pub my_event_data: u32,
        pub more_event_data: u64,
    }

But also supporting more advanced customisable forms, where prefix topics can be overriden and a max of two provided, and where the data section can be defined as either being a vec or a single-value instead.

Why

The contractevent macro will achieve a couple things for events.

It will allow developers to define the event as a type, in a similar way that they can define an event in Solidity contracts. Defining it as a type makes it easier to reuse the event, and to define the types safely and be clear about what types within the event are being published.

It will provide a foundation that the Rust SDK can use to include in the contract interface (spec) an entry for the event. That entry can then be used by downstream systems to bring more meaning to the raw event data that is visible in transaction / ledger close meta. For an example of what that looks like, see https://github.com/orgs/stellar/discussions/1724#discussioncomment-13118291.

The advanced customisations are a necessity to be able to make the events and the event schema describe existing contract events. The network already has a variety of events and some contracts use two fields as fixed prefix topics, such as the Soroswap contracts. Some use vecs for the data section, and even a single value such as an amount: i128, such as the Stellar Asset Contract.

Close #1097

Docs for the contractevent attribute macro

Generates conversions from the struct into a published event.

Fields of the struct become topics and data parameters in the published event.

Includes the event in the contract spec so that clients can generate bindings for the type and downstream systems can understand the meaning of the event.

Examples

Basic Contract Event

Defining a basic contract event.

The event will have a single fixed prefix topic matching the name of the struct in lower snake case. The fixed prefix topic will appear before any topics listed as fields. In the example below, the topics for the event will be:

  • "my_event"
  • u32 value from the my_topic field

The event’s data will be a Map, containing a key-value pair for each field with the key being the name as a Symbol. In the example below, the data for the event will be:

  • key: my_event_data => val: u32
  • key: more_event_data => val: u64
    #![no_std]
    use soroban_sdk::contractevent;
    
    // Define the event using the `contractevent` attribute macro.
    #[contractevent]
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
    pub struct MyEvent {
        // Mark fields as topics, for the value to be included in the events topic list so
        // that downstream systems know to index it.
        #[topic]
        pub my_topic: u32,
        // Fields not marked as topics will appear in the events data section.
        pub my_event_data: u32,
        pub more_event_data: u64,
    }
Prefix Topics

Defining a contract event with a custom set of fixed prefix topics.

The prefix topic list can be set to another value. In the example below, the topics for the event will be:

  • "my_contract"
  • "an_event"
  • u32 value from the my_topic field
    #![no_std]
    use soroban_sdk::contractevent;
    
    // Define the event using the `contractevent` attribute macro.
    #[contractevent(topics = ["my_contract", "an_event"])]
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
    pub struct MyEvent {
        // Mark fields as topics, for the value to be included in the events topic list so
        // that downstream systems know to index it.
        #[topic]
        pub my_topic: u32,
        // Fields not marked as topics will appear in the events data section.
        pub my_event_data: u32,
        pub more_event_data: u64,
    }
Data Format

Defining a contract event with a different data format. The data format of the event is by default a map, but can alternatively be defined as a vec or single-value.

Vec

In the example below, the data for the event will be a Vec containing:

  • u32
  • u64
    #![no_std]
    use soroban_sdk::contractevent;
    
    // Define the event using the `contractevent` attribute macro.
    #[contractevent(data_format = "vec")]
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
    pub struct MyEvent {
        // Mark fields as topics, for the value to be included in the events topic list so
        // that downstream systems know to index it.
        #[topic]
        pub my_topic: u32,
        // Fields not marked as topics will appear in the events data section.
        pub my_event_data: u32,
        pub more_event_data: u64,
    }
Single Value

In the example below, the data for the event will be a u32.

When the data format is a single value there must be no more than one data field.

    #![no_std]
    use soroban_sdk::contractevent;
    
    // Define the event using the `contractevent` attribute macro.
    #[contractevent(data_format = "single-value")]
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
    pub struct MyEvent {
        // Mark fields as topics, for the value to be included in the events topic list so
        // that downstream systems know to index it.
        #[topic]
        pub my_topic: u32,
        // Fields not marked as topics will appear in the events data section.
        pub my_event_data: u32,
    }
A Full Example

Defining a contract event, publishing it in a contract, and testing it.

    #![no_std]
    use soroban_sdk::{contract, contractevent, contractimpl, contracttype, symbol_short, Env, Symbol};
    
    // Define the event using the `contractevent` attribute macro.
    #[contractevent]
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
    pub struct Increment {
        // Mark fields as topics, for the value to be included in the events topic list so
        // that downstream systems know to index it.
        #[topic]
        pub change: u32,
        // Fields not marked as topics will appear in the events data section.
        pub count: u32,
    }
    
    #[contracttype]
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
    pub struct State {
        pub count: u32,
        pub last_incr: u32,
    }
    
    #[contract]
    pub struct Contract;
    
    #[contractimpl]
    impl Contract {
        /// Increment increments an internal counter, and returns the value.
        /// Publishes an event about the change in the counter.
        pub fn increment(env: Env, incr: u32) -> u32 {
            // Get the current count.
            let mut state = Self::get_state(env.clone());
    
            // Increment the count.
            state.count += incr;
            state.last_incr = incr;
    
            // Save the count.
            env.storage().persistent().set(&symbol_short!("STATE"), &state);
    
            // Publish an event about the change.
            Increment {
                change: incr,
                count: state.count,
            }.publish(&env);
    
            // Return the count to the caller.
            state.count
        }
    
        /// Return the current state.
        pub fn get_state(env: Env) -> State {
            env.storage().persistent()
                .get(&symbol_short!("STATE"))
                .unwrap_or_else(|| State::default()) // If no value set, assume 0.
        }
    }
    
    #[test]
    fn test() {
        let env = Env::default();
        let contract_id = env.register(Contract, ());
        let client = ContractClient::new(&env, &contract_id);
    
        assert_eq!(client.increment(&1), 1);
        assert_eq!(client.increment(&10), 11);
        assert_eq!(
            client.get_state(),
            State {
                count: 11,
                last_incr: 10,
            },
        );
    }

@leighmcculloch leighmcculloch linked an issue Jun 4, 2025 that may be closed by this pull request
@leighmcculloch leighmcculloch force-pushed the add-events-to-contract-spec branch 4 times, most recently from 754c2a6 to b184e22 Compare June 21, 2025 15:39
@leighmcculloch leighmcculloch force-pushed the add-events-to-contract-spec branch from b184e22 to 2bc47ce Compare June 21, 2025 15:42
@leighmcculloch leighmcculloch marked this pull request as ready for review June 24, 2025 14:27
@leighmcculloch
Copy link
Copy Markdown
Member Author

leighmcculloch commented Jul 9, 2025

TODO:

  • What happens if fields are longer than the symbol max length? Will it be a friendly compiler error?
  • Do we have a test for no topic? Yes
  • Consider adding #[data] on data fields. Decided against

@leighmcculloch

This comment was marked as outdated.

@leighmcculloch
Copy link
Copy Markdown
Member Author

Yet another alternative way to define topics, which is largely a simplification, although in the default case there is more to do:

Replace this:

    #[contractevent]
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
    pub struct MyEvent {
        #[topic]
        pub my_topic: u32,
        pub my_event_data: u32,
        pub more_event_data: u64,
    }

With:

    #[contractevent(topics = ["my_event"])]
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
    pub struct MyEvent {
        #[topic]
        pub my_topic: u32,
        pub my_event_data: u32,
        pub more_event_data: u64,
    }

In this way there is no "default" behaviour where a topic is inserted with the type name. Topics are always explicitly defined.

@leighmcculloch
Copy link
Copy Markdown
Member Author

leighmcculloch commented Jul 10, 2025

    #[contractevent(topics = ["my_event"])]

I gave this approach a go, but it's not particularly enjoyable to have to type the topics out in the cases where the struct name matches the event name, and it becomes really easy to publish events with no topics which can make it a little harder to filter different types of events. So I'm leaning away from that back to the original solution.

@leighmcculloch
Copy link
Copy Markdown
Member Author

I got feedback offline about the concept of prefix_topics being unintuitive.

So I've renamed prefix_topics to simply topics.

@leighmcculloch leighmcculloch requested a review from dmkozh July 11, 2025 07:43
Comment thread soroban-spec-rust/src/types.rs Outdated
@leighmcculloch leighmcculloch requested a review from kalepail July 11, 2025 13:02
@leighmcculloch
Copy link
Copy Markdown
Member Author

I found a bug in how the map variant of events are passed to the host.

@leighmcculloch
Copy link
Copy Markdown
Member Author

I found a bug in how the map variant of events are passed to the host.

Fixed in 3f977d4.

@leighmcculloch
Copy link
Copy Markdown
Member Author

I've opened a follow up to this PR that uses the contractevent macro to capture the events for the token (SEP-41) and the Stellar Asset Contract:

@leighmcculloch leighmcculloch added this pull request to the merge queue Jul 15, 2025
Merged via the queue into main with commit dff9656 Jul 15, 2025
18 checks passed
@leighmcculloch leighmcculloch deleted the add-events-to-contract-spec branch July 15, 2025 15:25
github-merge-queue bot pushed a commit that referenced this pull request Jul 16, 2025
### What

Add `contractevent` types for tokens and stellar asset contract.

Remove event publishing helpers.

### Why

With the addition of the `contractevent` macro in #1473 it makes sense
to use the macro to update the specs generated for the token and the
stellar asset contract such that their generated specs include the
events.

The `transfer` event added is represented with two event types:
- `Transfer`, which is the `single-value` data with an `amount`.
- `TransferMuxed`, which is the `map` data with a non-optional
`to_muxed_id`.

Two types are used because of the differing data format type, and the
contract event schema does not support multiple data format types to be
represented in a single type, to avoid complexity.

The reason that a non-optional `to_muxed_id` is used is because without
https://github.com/orgs/stellar/discussions/1750 the option none would
be stored as a void. In the future if that change occurs it may make
sense to update `TransferMuxed` to `Transfer2`, or similar, where-by it
represents some larger set of parameters that may grow over time.

The existing event publishing helpers are removed, rather than
deprecated, because the helpers build versions of the events that will
no longer be used by the stellar asset contract after CAP-67, versions
that include an admin topic. If the helpers remain, folks may continue
to call them and emit the topic. If the functions were updated, it would
be a breaking change anyway. It is more obvious to make the break by
removing them and encouraging use of the new events. Because the new
event types carry parameters as fields, it is very explicit that the
admin topic is gone.

Close #1097
Close #1488
 
### Merging

This change is intended to be merged to `main` after #1473.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Include events in contract interfaces / spec

2 participants