feat(bindings): generate typed event interfaces and filter helpers - #1556
Conversation
There was a problem hiding this comment.
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.
| topicsOut[param.name().toString()] = spec.scValToNative( | ||
| val, | ||
| param.type(), | ||
| ); |
| const param = dParams[0]; | ||
| if (param) { | ||
| dataOut[param.name().toString()] = spec.scValToNative( | ||
| dataVal, | ||
| param.type(), | ||
| ); | ||
| } |
| if (vec.length < dParams.length) { | ||
| continue; | ||
| } |
| const map = dataVal.map() ?? []; | ||
| dParams.forEach((param) => { | ||
| const name = param.name().toString(); | ||
| const entry = map.find( | ||
| (e) => |
| // Treat any decode failure as a non-match and try the next candidate. | ||
| try { | ||
| const prefixLen = event.prefixTopics().length; | ||
| const topicsOut: Record<string, any> = {}; |
| }); | ||
|
|
||
| const dParams = dataParams(event); | ||
| const dataOut: Record<string, any> = {}; |
| private eventInterfaceName(event: xdr.ScSpecEventV0): string { | ||
| return `${toPascalCase(sanitizeIdentifier(event.name().toString()))}Event`; | ||
| } |
| const rawName = event.name().toString(); | ||
| const methodName = `${toCamelCase(sanitizeIdentifier(rawName))}EventFilter`; |
There was a problem hiding this comment.
Do we need to consider name collision?
There was a problem hiding this comment.
Fixes are in this pr as I forget to address these concerns.
| const fieldName = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(rawParamName) | ||
| ? rawParamName | ||
| : `"${escapeStringLiteral(rawParamName)}"`; | ||
| const fieldType = parseTypeFromTypeDef(param.type()); |
| export * from "./assembled_transaction.js"; | ||
| export * from "./basic_node_signer.js"; | ||
| export * from "./client.js"; | ||
| export * from "./event_spec.js"; |
leighmcculloch
left a comment
There was a problem hiding this comment.
Some feedback inline on the resulting ParseEvent.
| 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. |
There was a problem hiding this comment.
Nice. This should support well the mixed data value types for the same event that we see in SEP-41.
| ```ts | ||
| interface ParsedEvent { | ||
| data: Record<string, any>; | ||
| name: string; | ||
| topics: Record<string, any>; |
There was a problem hiding this comment.
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"
}There was a problem hiding this comment.
So maybe merge topics and data to retain the top-level name:
interface ParsedEvent {
name: string;
data: Record<string, any>;
}| } | ||
| for (let i = 0; i < prefixTopics.length; i++) { | ||
| const topic = topics[i]; | ||
| if (topic.switch().value !== xdr.ScValType.scvSymbol().value) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Should this also tolerate
SCV_STRINGtopics?
+1 Absolutely, good catch.
| ): xdr.ScSpecEventParamV0[] | undefined { | ||
| const prefixTopics = event.prefixTopics(); | ||
| const tlParams = topicListParams(event); | ||
| if (topics.length !== prefixTopics.length + tlParams.length) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
+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.
There was a problem hiding this comment.
Another good catch! I was not aware of this for SAC events
| const rawName = event.name().toString(); | ||
| const methodName = `${toCamelCase(sanitizeIdentifier(rawName))}EventFilter`; |
There was a problem hiding this comment.
Do we need to consider name collision?
| const fieldType = parseTypeFromTypeDef(param.type()); | ||
| const fieldDoc = formatJSDocComment(param.doc().toString(), 4); |
There was a problem hiding this comment.
Are we always guaranteed to have .type() and .doc()?
There was a problem hiding this comment.
We are guaranteed to have .type(). .doc() could be an empty string but formatJSDocComment handles that
| }) | ||
| .join("\n"); | ||
|
|
||
| return `${doc}export interface ${name} { |
There was a problem hiding this comment.
Will doc have the extra space here?
There was a problem hiding this comment.
yup formatJSDocComment appends a \n at the end of the js doc
| if (!found) { | ||
| throw new Error(`no such event: ${name}`); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Decided to change the behavior to return undefined. Fixes are in this pr as I forget to address these concerns.
| * const topics = contractSpec.eventTopicFilter('transfer', { to: someAddress }); | ||
| * ``` |
There was a problem hiding this comment.
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", () => { |
There was a problem hiding this comment.
It might be good to add a test for when there is no match in eventTopicFilter().
| /** | ||
| * Event: AuthEvent | ||
| */ | ||
| export interface AuthEventEvent { |
There was a problem hiding this comment.
This looks great but I'd love to see outputs for more complicated specs, particularly with UDTs and nesting
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); | ||
| }); |
There was a problem hiding this comment.
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: ... }
});There was a problem hiding this comment.
Done in 1fec1b5 — the parseEvent tests now assert with a single deep equal on the whole parsed object.
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
What
Adds CAP-67 event support to
contract.Specand the client generator.src/contract/event_spec.ts, changes tospec.ts):events()andfindEvent(name)list a contract's declared events.parseEvent(topics, data)decodes a fired event's topics and data (asScValor base64 strings) into{ name, topics, data }, matched by prefix topics and topic count.eventTopicFilter(name, topicValues?)builds onegetEventsfilter row, with"*"for any topic param left unset.src/bindings/): for each event, generates a typed<Name>Eventinterface, aContractEventunion of all events, aparseEvent()method, and a per-event<name>EventFilter()method.docs/reference/contracts-client.mdto match.parseEventno longer throws on malformed base64 or on data that doesn't match a candidate event's shape — it now returnsundefinedor falls through to the next candidate instead. The generatedeventTopicFilter()call now escapes the event name from the contract spec, closing a code-generation injection risk.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 rawScValdecoding with no type safety and no link back to the spec. This givesSpecthat decode/filter logic directly, and the generated client exposes it as typed, per-event methods — the same experience contract functions already get.