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:
- 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.
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".
- 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.
- Export
SorobanEvent (currently unexported, events.ts:5) while touching the file, so callers can name the return type.
- 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
Summary
humanizeEventsdoes not acceptxdr.TransactionEvent, the CAP-67 transaction-level event type that the SDK itself returns fromgetTransaction/getTransactions. Passing one in surfaces two ways depending on the caller:xdr.ContractEvent[] | xdr.DiagnosticEvent[].as anycallers: a runtimeTypeError: 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.Observed output:
Root cause
In
src/base/events.ts:if ("inSuccessfulContractCall" in e)(events.ts:54) is false for aTransactionEvent(its only accessors arestage()andevent()), so it falls through toextractEvent(e)treating it as aContractEvent.extractEventthen callsevent.type()(events.ts:23).TransactionEventhas notype()/body()(it wraps aContractEventunderevent()), so this throws.Impact
transactionEventsXdrcarries CAP-67 transaction-level events, wrapped asTransactionEvent { stage, event }and delivered viaTransactionMetaV4.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
TransactionEventthrough its inner event, carryingstage:Notes:
ContractEvent[] | DiagnosticEvent[]type rejects a mixed(ContractEvent | DiagnosticEvent)[]array, which the runtime already handles fine.stagerepresentation:e.stage().nameyields"transactionEventStageBeforeAllTxes"etc. Decide whether to expose the raw enum name (consistent withtype: event.type().nametoday) or map to cleaner literals like"beforeAllTxs" | "afterTx" | "afterAllTxs"."stage" in eis unambiguous. NeitherContractEventnorDiagnosticEventhas astagemember, and it matches the file's existing pseudo-instanceof convention.SorobanEvent(currently unexported, events.ts:5) while touching the file, so callers can name the return type.inSuccessfulContractCallis silently dropped forDiagnosticEvents today. Surfacing it is out of scope for the minimal fix, noting it here so it isn't rediscovered.References