Context
A ContractCallDecoder decodes an InvokeContract call's arguments for contracts whose payload is only in the call (Band relay(), Soroswap router swaps). Router traffic mostly arrives as a sub-invocation of an aggregator, so top-level-only routing missed it. The lake holds no fn_call diagnostic events, but each op's auth tree is in stellar.operations.body_xdr for all history.
Decision
The dispatcher routes the top-level call and every ContractFn node of the op's auth tree through the decoder chain (extractInvokeContractCallTrees -> walkAuthEntries -> walkAuthTree, pre-order, in internal/dispatcher/dispatcher.go).
Matches(contractID, functionName)runs once per call node, so it stays a cheap string compare. The first matching decoder owns the call.CallPathis position, not identity:[]only for the real top-level call, separately authorized entries are re-rooted beneath it past its own sub-invocations.CallPathContractsis the ancestor-and-self contract chain.- Decoded output must be idempotent over duplicates: persisted rows key on call content plus
AuthOccurrence(ordinal of a byte-identical call within one auth entry), never onCallPath.soroswap_router.RouterSwap.CallSigis the reference. - The auth tree records what was authorized, not what executed:
ExecutionCorroboratedis true only for the top-level call. A decoder whose output can move a price implementsExecutionCorroborationRequirer(Band does), and the dispatcher drops and counts its uncorroborated calls beforeDecode.
Event-based decoders are unaffected: tx.GetTransactionEvents() already includes nested-contract events.
Invariant
- Nested and aggregator-wrapped calls are routed with correct paths:
internal/dispatcher/extract_call_trees_test.go. - The first matching decoder wins:
TestRouteContractCall_routesToFirstMatchininternal/dispatcher/contract_call_test.go. - Repeated identical calls in one auth entry stay distinct in the stored key:
internal/sources/soroswap_router/auth_occurrence_test.go. - A declared-but-unexecuted call never reaches a corroboration-requiring decoder:
TestProcessLedger_ForgedBandAuthEntry_NotRecognisedAsPriceininternal/sources/band/forged_auth_reject_test.go.
Consequences
- Calls reached through any wrapper are decoded;
CallPathContractslets a decoder attribute the wrapping aggregator. - Every decoder pays one
Matchesper auth-tree node, and non-price decoders accept that authorized calls may never have run. - A new ContractCallDecoder must state its dedup key under rule 3 and decide on rule 4; one that reads history needs a replay across its contract's lifetime (AGENTS.md invariant 7 picks the path).
- If diagnostic-event capture is enabled, routing over the execution trace would supersede this ADR for the ledgers that carry it.
Evidence
internal/dispatcher/dispatcher.go (ContractCallDecoder, ContractCallContext, ExecutionCorroborationRequirer, walkAuthEntries), internal/sources/soroswap_router/events.go (CallSig).