Skip to content

humanizeEvents cannot consume xdr.TransactionEvent (CAP-67 transaction events) #1555

Description

@oceans404

Summary

humanizeEvents does not accept xdr.TransactionEvent, the CAP-67 transaction-level event type that the SDK itself returns from getTransaction / getTransactions. Passing one in surfaces two ways depending on the caller:

  • TypeScript callers: a compile error (TS2345), because the parameter type is xdr.ContractEvent[] | xdr.DiagnosticEvent[].
  • Plain-JS or as any callers: a runtime TypeError: event.type is not a function.

So the SDK decodes events (getTransaction().events.transactionEventsXdr) that its own event prettifier cannot consume. This is a capability gap, medium severity, not a silent-data-loss bug.

Reproduction

Verified against @stellar/[email protected], Node v24.

import { humanizeEvents, xdr, nativeToScVal } from "@stellar/stellar-sdk";

// A minimal ContractEvent (what a CAP-67 fee event wraps)
const inner = new xdr.ContractEvent({
  ext: new xdr.ExtensionPoint(0),
  contractId: null,
  type: xdr.ContractEventType.contract(),
  body: new xdr.ContractEventBody(0, new xdr.ContractEventV0({
    topics: [xdr.ScVal.scvSymbol("fee")],
    data: nativeToScVal(100n),
  })),
});

// Plain ContractEvent works
humanizeEvents([inner]);
// => [{ type: "contract", topics: ["fee"], data: 100n }]

// The CAP-67 transaction-level envelope, exactly what
// getTransaction().events.transactionEventsXdr contains
const txEvent = new xdr.TransactionEvent({
  stage: xdr.TransactionEventStage.transactionEventStageBeforeAllTxes(),
  event: inner,
});
const decoded = xdr.TransactionEvent.fromXDR(txEvent.toXDR("base64"), "base64");

humanizeEvents([decoded]);
// TypeError: event.type is not a function

Observed output:

ContractEvent ok: [{"type":"contract","topics":["fee"],"data":"100"}]
stage: transactionEventStageBeforeAllTxes
THREW: TypeError: event.type is not a function

Root cause

In src/base/events.ts:

  • The routing check if ("inSuccessfulContractCall" in e) (events.ts:54) is false for a TransactionEvent (its only accessors are stage() and event()), so it falls through to extractEvent(e) treating it as a ContractEvent.
  • extractEvent then calls event.type() (events.ts:23). TransactionEvent has no type() / body() (it wraps a ContractEvent under event()), so this throws.

Impact

transactionEventsXdr carries CAP-67 transaction-level events, wrapped as TransactionEvent { stage, event } and delivered via TransactionMetaV4.events. These include the fee-charge / fee-refund events emitted per transaction under Protocol 23's unified event stream, so they are routine post-P23, not an edge case.

Affected: anyone building fee accounting, wallet history, or generic event display from getTransaction / getTransactions. They either crash (JS) or find the SDK's only humanizer cannot consume events the SDK itself decodes (TS). Separately, stage (the only thing distinguishing a fee charge from a refund) has no supported path into human-readable output today.

Proposed fix

Widen the signature and route TransactionEvent through its inner event, carrying stage:

export function humanizeEvents(
  events: (xdr.ContractEvent | xdr.DiagnosticEvent | xdr.TransactionEvent)[],
): SorobanEvent[] {
  return events.map((e) => {
    if ("inSuccessfulContractCall" in e) return extractEvent(e.event());
    if ("stage" in e) return { ...extractEvent(e.event()), stage: e.stage().name };
    return extractEvent(e);
  });
}

Notes:

  1. Signature change is a pure widening (union-of-arrays to array-of-union). Non-breaking for existing callers, and it also fixes a related gap: the current ContractEvent[] | DiagnosticEvent[] type rejects a mixed (ContractEvent | DiagnosticEvent)[] array, which the runtime already handles fine.
  2. stage representation: e.stage().name yields "transactionEventStageBeforeAllTxes" etc. Decide whether to expose the raw enum name (consistent with type: event.type().name today) or map to cleaner literals like "beforeAllTxs" | "afterTx" | "afterAllTxs".
  3. Duck-check safety: "stage" in e is unambiguous. Neither ContractEvent nor DiagnosticEvent has a stage member, and it matches the file's existing pseudo-instanceof convention.
  4. Export SorobanEvent (currently unexported, events.ts:5) while touching the file, so callers can name the return type.
  5. Optional: inSuccessfulContractCall is silently dropped for DiagnosticEvents today. Surfacing it is out of scope for the minimal fix, noting it here so it isn't rediscovered.

References

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

Status
Done

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions