Skip to content

feat(bindings): generate typed event interfaces and filter helpers - #1556

Merged
Ryang-21 merged 14 commits into
mainfrom
event-bindings
Jul 24, 2026
Merged

feat(bindings): generate typed event interfaces and filter helpers#1556
Ryang-21 merged 14 commits into
mainfrom
event-bindings

Conversation

@Ryang-21

Copy link
Copy Markdown
Contributor

What

Adds CAP-67 event support to contract.Spec and the client generator.

  • Spec additions (new src/contract/event_spec.ts, changes to spec.ts): events() and findEvent(name) list a contract's declared events. parseEvent(topics, data) decodes a fired event's topics and data (as ScVal or base64 strings) into { name, topics, data }, matched by prefix topics and topic count. eventTopicFilter(name, topicValues?) builds one getEvents filter row, with "*" for any topic param left unset.
  • Generated client bindings (src/bindings/): for each event, generates a typed <Name>Event interface, a ContractEvent union of all events, a parseEvent() method, and a per-event <name>EventFilter() method.
  • Docs: regenerated docs/reference/contracts-client.md to match.
  • Fixes: parseEvent no longer throws on malformed base64 or on data that doesn't match a candidate event's shape — it now returns undefined or falls through to the next candidate instead. The generated eventTopicFilter() call now escapes the event name from the contract spec, closing a code-generation injection risk.
  • Full test coverage: all three data formats (single value, vec, map), multi-prefix-topic events, base64 round-tripping, and the generator's import handling.

Why

Contracts declare events in their spec (CAP-67), but the SDK had no way to decode a fired event back into typed values or build the matching topic filter for getEvents. Consumers had to hand-roll raw ScVal decoding with no type safety and no link back to the spec. This gives Spec that decode/filter logic directly, and the generated client exposes it as typed, per-event methods — the same experience contract functions already get.

Copilot AI review requested due to automatic review settings July 20, 2026 20:20
@github-project-automation github-project-automation Bot moved this to Backlog (Not Ready) in DevX Jul 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds typed CAP-67 event decoding and filtering to contract specs and generated clients.

Changes:

  • Adds event discovery, parsing, and topic-filter helpers.
  • Generates typed event interfaces, unions, and client methods.
  • Adds tests and regenerated API documentation.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/contract/event_spec.ts Implements event decoding and filters.
src/contract/spec.ts Exposes event APIs through Spec.
src/contract/index.ts Exports event functionality.
src/bindings/utils.ts Adds identifier case conversion helpers.
src/bindings/types.ts Generates typed event interfaces and unions.
src/bindings/client.ts Generates parser and filter methods.
test/unit/spec/event_spec.test.ts Tests event parsing and filtering.
test/integration/bindings.test.ts Tests generated event bindings.
docs/reference/contracts-client.md Documents the new public APIs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/contract/event_spec.ts Outdated
Comment on lines +170 to +173
topicsOut[param.name().toString()] = spec.scValToNative(
val,
param.type(),
);
Comment on lines +183 to +189
const param = dParams[0];
if (param) {
dataOut[param.name().toString()] = spec.scValToNative(
dataVal,
param.type(),
);
}
Comment on lines +194 to +196
if (vec.length < dParams.length) {
continue;
}
Comment on lines +206 to +210
const map = dataVal.map() ?? [];
dParams.forEach((param) => {
const name = param.name().toString();
const entry = map.find(
(e) =>
Comment thread src/contract/event_spec.ts Outdated
// Treat any decode failure as a non-match and try the next candidate.
try {
const prefixLen = event.prefixTopics().length;
const topicsOut: Record<string, any> = {};
Comment thread src/contract/event_spec.ts Outdated
});

const dParams = dataParams(event);
const dataOut: Record<string, any> = {};
Comment thread src/bindings/types.ts
Comment on lines +281 to +283
private eventInterfaceName(event: xdr.ScSpecEventV0): string {
return `${toPascalCase(sanitizeIdentifier(event.name().toString()))}Event`;
}
Comment thread src/bindings/client.ts
Comment on lines +155 to +156
const rawName = event.name().toString();
const methodName = `${toCamelCase(sanitizeIdentifier(rawName))}EventFilter`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need to consider name collision?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixes are in this pr as I forget to address these concerns.

Comment thread src/bindings/client.ts
const fieldName = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(rawParamName)
? rawParamName
: `"${escapeStringLiteral(rawParamName)}"`;
const fieldType = parseTypeFromTypeDef(param.type());
Comment thread src/contract/index.ts
export * from "./assembled_transaction.js";
export * from "./basic_node_signer.js";
export * from "./client.js";
export * from "./event_spec.js";

@leighmcculloch leighmcculloch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Some feedback inline on the resulting ParseEvent.

Comment on lines +1354 to +1357
Note that matching compares only the prefix topics and the topic count;
if two event specs share both (in particular, events with no prefix
topics match on arity alone), the first declared spec whose values
decode successfully wins.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice. This should support well the mixed data value types for the same event that we see in SEP-41.

Comment thread docs/reference/contracts-client.md Outdated
Comment on lines +1560 to +1564
```ts
interface ParsedEvent {
data: Record<string, any>;
name: string;
topics: Record<string, any>;

@leighmcculloch leighmcculloch Jul 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Once an event is parsed the distinction between topics and data shouldn't matter.

For example, consider the following event:

#[contractevent(topics = ["transfer"], data_format = "map", export = false)]
pub struct TransferWithMuxedString {
    #[topic]
    pub from: Address,
    #[topic]
    pub to: Address,
    pub to_muxed_id: Option<String>,
    pub amount: i128,
}

When rendered to JSON such as demonstrated in https://github.com/orgs/stellar/discussions/1765#discussion-8618356, it is reasonable and intuitive to render it as something where all the fields share the same space much like the original definition, and where the topic list — which is just a way to indicate which fields are indexed — disappear:

{
  "from": "G...",
  "to": "G...",
  "to_muxed_id": "1",
  "amount": "100"
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So maybe merge topics and data to retain the top-level name:

interface ParsedEvent {
  name: string;
  data: Record<string, any>;
}

@Ryang-21
Ryang-21 requested a review from leighmcculloch July 21, 2026 14:11
Comment thread src/contract/event_spec.ts Outdated
}
for (let i = 0; i < prefixTopics.length; i++) {
const topic = topics[i];
if (topic.switch().value !== xdr.ScValType.scvSymbol().value) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should this also tolerate SCV_STRING topics? SEP-48 says parsers "should tolerate static topics being of the SCVal type SCV_SYMBOL or SCV_STRING because some contracts have emitted their topics as strings." Some live contracts do emit string topics, e.g. Soroswap pair events with [String("SoroswapPair"), Symbol("swap")], tx dbe0ad8e797a4247ea9d9dafea574c862c2f014466d9ade7169cd4079295fa11.

@leighmcculloch leighmcculloch Jul 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this also tolerate SCV_STRING topics?

+1 Absolutely, good catch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great catch 🔥

Comment thread src/contract/event_spec.ts Outdated
): xdr.ScSpecEventParamV0[] | undefined {
const prefixTopics = event.prefixTopics();
const tlParams = topicListParams(event);
if (topics.length !== prefixTopics.length + tlParams.length) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is the exact topic-count match intentional? SAC events carry a trailing SEP-11 asset topic that the token event declarations deliberately leave undeclared (see https://github.com/orgs/stellar/discussions/1553#discussioncomment-12075676), e.g. tx 16ccd58cff4c6bd0a1ad45a111664876f55ec1203b2957d438b93f5c45993f73 emits [Symbol("transfer"), from, to, String("native")], 4 topics vs 3 declared, so every live SAC event would fail to parse here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

+1 We shouldn't match on topic count, just match on the defined topics and ignore any others. This means the parsed form will discard some data but I think that sounds like the right way to handle it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Another good catch! I was not aware of this for SAC events

Comment thread src/bindings/client.ts
Comment on lines +155 to +156
const rawName = event.name().toString();
const methodName = `${toCamelCase(sanitizeIdentifier(rawName))}EventFilter`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need to consider name collision?

Comment thread src/bindings/types.ts
Comment on lines +315 to +316
const fieldType = parseTypeFromTypeDef(param.type());
const fieldDoc = formatJSDocComment(param.doc().toString(), 4);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are we always guaranteed to have .type() and .doc()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We are guaranteed to have .type(). .doc() could be an empty string but formatJSDocComment handles that

Comment thread src/bindings/types.ts
})
.join("\n");

return `${doc}export interface ${name} {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Will doc have the extra space here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yup formatJSDocComment appends a \n at the end of the js doc

Comment on lines +53 to +55
if (!found) {
throw new Error(`no such event: ${name}`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this the pattern we use in JS SDK when there is nothing to return? I mean, instead of throwing we could return null or something.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

So the other searching functions in the Spec class such as findEntry and getFunc both throw if the user requested input does not exist. I think returning undefined is better but this mean in the next major version we should switch those two functions to also return undefined

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Decided to change the behavior to return undefined. Fixes are in this pr as I forget to address these concerns.

Comment thread src/contract/spec.ts
Comment on lines +1282 to +1283
* const topics = contractSpec.eventTopicFilter('transfer', { to: someAddress });
* ```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If we throw in case the event is not found, shouldn't we wrap this in try-catch? This is related to the question I asked earlier. Maybe it's better to return undefined or null instead of throwing in cases like this.

expect(parsed!.data.amount).toBe(42);
});

it("builds eventTopicFilter with and without provided values", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It might be good to add a test for when there is no match in eventTopicFilter().

/**
* Event: AuthEvent
*/
export interface AuthEventEvent {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks great but I'd love to see outputs for more complicated specs, particularly with UDTs and nesting

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added SwapEvent (map format, UDT topic + data params: RoyalCard topic, Test/ComplexEnum/Vec/Option data) and NestedEvent (vec format, TupleStruct and Map<u32, Test>) to the custom-types test contract (soroban-test-examples branch event-bindings-nested-events) and regenerated the snapshot in 1fec1b5 — it now covers UDT topics, nested structs, enums, tuple structs, maps, and optionals.

@leighmcculloch leighmcculloch Jul 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reminder that we have https://github.com/stellar-experimental/contract-wasms, a repo that contains every contract on mainnet, both its .wasm, as well as contract specs extracted. You could script up running this on all specs in the repo so we can sample a variety of contracts to see what the code will look like.

expect(parsed!.data.from).toBe(from.toString());
expect(parsed!.data.to).toBe(to.toString());
expect(parsed!.data.amount).toBe(12345n);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It'd be cool and easier to read as a test author to see a deep comparison to the exact output we'd want, e.g.

expect(parsed!).to.deep.equal({
  name: "transfer",
  data: { from: ... }
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 1fec1b5 — the parseEvent tests now assert with a single deep equal on the whole parsed object.

Ryang-21 and others added 2 commits July 24, 2026 10:54
Adds SwapEvent and NestedEvent to the custom-types test contract
(submodule branch event-bindings-nested-events) so the bindings
snapshot exercises UDT topics, nested structs, enums, tuple structs,
maps, and optionals. Rewrites parseEvent unit test assertions as
single deep-equal comparisons per shaptic's review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	docs/reference/contracts-client.md
@Ryang-21
Ryang-21 merged commit aa00dd1 into main Jul 24, 2026
12 checks passed
@Ryang-21
Ryang-21 deleted the event-bindings branch July 24, 2026 21:23
@github-project-automation github-project-automation Bot moved this from Backlog (Not Ready) to Done in DevX Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

humanizeEvents cannot consume xdr.TransactionEvent (CAP-67 transaction events) Event bindings and parsers for Soroban contract events

6 participants