[ANCHOR-1242]: Contract sub invocation transfers are skipped#1971
Open
amandagonsalves wants to merge 11 commits into
Open
[ANCHOR-1242]: Contract sub invocation transfers are skipped#1971amandagonsalves wants to merge 11 commits into
amandagonsalves wants to merge 11 commits into
Conversation
* add `buildsubinvocationoperation` to create synthetic operations for contract sub-invocation transfers. * update `processtransferevent` to credit sub-invocation transfers that lack a direct top-level operation. * update `buildpaymenttransferevent` to conditionally map sac to asset. * add tests to verify the crediting of sub-invocation transfers. * add tests to verify skipping sub-invocation transfers with malformed asset data.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds support for crediting Soroban contract sub-invocation transfers missing from the compacted operation list.
Changes:
- Matches operations by TOID and synthesizes missing invoke-host-function operations.
- Preserves event-derived assets during processing.
- Updates unit and scheduler tests for crediting and malformed assets.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
StellarRpcPaymentObserver.java |
Adds TOID lookup and synthetic transfer processing. |
StellarRpcPaymentObserverTest.kt |
Tests synthetic crediting and malformed assets. |
StellarRpcObserverIndexDomainTest.kt |
Updates scheduler and mixed-batch expectations. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
* delete test for sub-invocation transfer events * update mixed batch test to remove sub-invocation event assertions
* refactor amount field type from long to biginteger * update amount initialization to biginteger * refactor amount parsing from scv_i128 to biginteger
* delete `buildsubinvocationoperation` method * update logic to remove synthetic sub-invocation operation creation * refactor contract event handling when no direct operation is found
* delete test case for processing contract sub-invocation transfers without top-level operations * update test name to more accurately reflect skipping behavior when no operation matches
* add a configurable sac resolver to stellar rpc * add contract event parsing to transaction responses
* add ability to parse stellar smart contract transfer events. * update ledger operation generation to include synthesized operations from contract events. * add helper methods for contract event processing and transfer data extraction.
* add logic to synthesize invoke host function operations from sac transfer events * update stellar rpc client to resolve sac contract ids to assets * add tests for soroban contract event processing and validation
* refactor ledger client helper to synthesize all verified transfers from a single operation's contract events, not just the first one * update ledger client helper to prioritize contract event based transfers, replacing generic invoke host function operations when verified transfers are found * fix stellar rpc payment observer to match contract event operations by content when multiple candidates with the same operation index exist
* add test for sub-invocation transfer from a verified sac * add test for disambiguating multiple operations with same toid by event content * add test for skipping events when no operation matches content for same toid
Comment on lines
+505
to
+518
| SCVal amountVal = entries[0].getVal(); | ||
| SCVal memoVal = entries[1].getVal(); | ||
| if (amountVal.getDiscriminant() != SCValType.SCV_I128) { | ||
| return null; | ||
| } | ||
| amount = Scv.fromInt128(amountVal); | ||
| eventMemo = | ||
| switch (memoVal.getDiscriminant()) { | ||
| case SCV_STRING -> memoVal.getStr().getSCString().toString(); | ||
| case SCV_U64 -> memoVal.getU64().toString(); | ||
| case SCV_BYTES -> | ||
| new String(Base64.getEncoder().encode(memoVal.getBytes().getSCBytes())); | ||
| default -> null; | ||
| }; |
Comment on lines
289
to
293
| SCVal amountVal = entries[0].getVal(); | ||
| SCVal memoVal = entries[1].getVal(); | ||
| if (amountVal.getDiscriminant() != SCValType.SCV_I128) { | ||
| return builder.build(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Before this change,
StellarRpcPaymentObserver.processTransferEventselected the credited operation with:event.getOperationIndex()is a 0-based index into the full on-chain operation array as returned by the Soroban-RPCgetEventsAPI.txn.getOperations()is not that full array — it is the compacted output ofLedgerClientHelper.getLedgerOperations(), which silently drops every operation for whichconvert()returnsnull(MANAGE_DATA,CHANGE_TRUST,CREATE_ACCOUNT,BUMP_SEQUENCE,SET_OPTIONS, anyINVOKE_HOST_FUNCTIONwhose direct function name is not"transfer", etc.) with no placeholder. Using the full-list index against the shorter compacted list produced two failure modes:IndexOutOfBoundsException→ caught by the generic handler → event permanently dropped, SEP-6/24/31 transaction frozen, user funds received but never credited.INVOKE_HOST_FUNCTIONwhose direct function name is not"transfer"(contract/SEP-45 sub-invocation) emits atransferevent at the full-list index of that op, whichconvert()drops → same out-of-bounds or wrong-element result.The fix stops trusting positional alignment.
convert()stores the TOID — derived from(sequenceNumber, applicationOrder, 1-based opIndex)— as each operation'sid. The event'soperationIndex(0-based) is converted to the same 1-based coordinate, the TOID for the target operation is derived, and the compacted list is stream-filtered by identity match.Crediting Variant B correctly required more than a first pass covered, surfaced by review:
buildEventRequest's Soroban event filters match on topic shape only, not on which contract emitted the event — any Soroban contract can emit atransfer-shaped event naming the anchor's distribution account and an arbitrary SEP-11 asset string, with no real token movement. Trusting the event's own asset claim (and pre-populatingLedgerInvokeHostFunctionOperation.asset, which also causedprocessOperation's SAC-verification guard to skip itself) let a forged event synthesize a credited payment for any asset/amount.Scv.fromInt128(...).longValue()before the value was ever used, silently wrapping any amount outsideLongrange.DefaultPaymentListeneronly forwards(transactionId, stellarTxnHash, amount)tonotifyOnchainFundsReceived.NotifyOnchainFundsReceivedHandlerindependently re-fetches the transaction to record payment details (PaymentHelper.addStellarTransaction) and, for SEP-31, to setfromAccount. A synthetic operation that only existed inside the observer's local, in-memoryLedgerTransactionobject never survived that re-fetch — the re-fetch reconstructed the operations list from scratch via the exact same filtering that dropped the sub-invocation operation the first time, so it came back empty again. The credit (status/amount) happened, but the payment record (operationId/from/to/asset) was silently lost, and SEP-31'sfromAccountwas never set.Fix for all three: moved sub-invocation reconstruction out of the observer and into the shared
LedgerClientHelper.getLedgerOperations(), the same compaction step used both by the observer's initial fetch and byNotifyOnchainFundsReceivedHandler's later re-fetch (GetTransactionResponse.getEvents().parseContractEventsXdr()already exposes the transaction's own emitted contract events per top-level operation, from the same fetch — no second RPC round-trip needed). For each operationconvert()drops,LedgerClientHelpernow scans that operation's contract events for a CAP-67transferevent, resolves the emitting contract through asacResolverfunction, and only synthesizes an operation if the contract resolves to a real SAC and its canonical asset matches the event's claimed asset — otherwise the operation stays dropped. The amount is carried asBigIntegerthroughout, never narrowed tolong. Because this now happens inside the shared compaction step, both the observer andNotifyOnchainFundsReceivedHandler's re-fetch see the same complete, verified operations list —StellarRpcPaymentObserver's own synthesis logic became dead code once this landed and was removed, andNotifyOnchainFundsReceivedHandler/PaymentHelperneeded no changes at all.StellarRpc(the onlyLedgerClientimplementation with Soroban event access) exposes the resolver via a mutable field and asetSacResolver(...)setter rather than a constructor parameter, so its existing constructors andUtilityBeans' wiring are untouched; the setter is called once, inPaymentObserverBeans, which already has bothstellarRpcandsacToAssetMapperin scope for constructing the observer.Changes
LedgerClientHelper.parseTransferEvent: new — decodes a CAP-67transferContractEvent(topics + i128/map data, including the muxed-memo destination case) into aTransferEventDatarecord, mirroring the decode logic the observer'sshouldProcess()already used for its own event-index-sourced events, now operating on the transaction's embeddedContractEventXDR instead.LedgerClientHelper.synthesizeSubInvocationTransfer: new — given a dropped operation's contract events, finds a transfer event, resolves the emitting contract via the suppliedsacResolver, requires the canonical asset to match the event's claimed asset, and only then builds aLedgerInvokeHostFunctionOperationwith the correct TOID-derived id.LedgerClientHelper.getLedgerOperations: new overload acceptingList<List<ContractEvent>> perOperationContractEventsand aFunction<String, Asset> sacResolver; for each dropped operation, attempts synthesis via the above. Original 4-arg signature preserved as a delegating overload (null,null) —Horizon.javaand existing callers unchanged.StellarRpc: added a mutablesacResolverfield (defaultid -> null) andsetSacResolver(...);getTransaction/fromGetTransactionResponsenow parseGetTransactionResponse.getEvents().parseContractEventsXdr()and pass it plus the resolver intogetLedgerOperations. No constructor or existing method signature changed.PaymentObserverBeans.stellarPaymentObserver: one line —stellarRpc.setSacResolver(sacToAssetMapper::getAssetFromSac)before constructingStellarRpcPaymentObserver, using theSacToAssetMapperbean already injected there.StellarRpcPaymentObserver: removedbuildSubInvocationOperationand the sub-invocation branch inprocessTransferEvent(now dead —txn.getOperations()already contains the synthesized, verified operation by the time the TOID lookup runs);ShouldProcessResult.amountchanged fromLongtoBigInteger.LedgerClientHelperTest: new tests — synthesizes a sub-invocation transfer from a verified SAC; refuses when the contract isn't a resolvable SAC; refuses when the event's claimed asset doesn't match the contract's canonical asset; preserves an i128 amount aboveLong.MAX_VALUEwithout wrapping.StellarRpcPaymentObserverTest/StellarRpcObserverIndexDomainTest: removed the sub-invocation-crediting tests that mockedstellarRpc.getTransactiondirectly (that mocking bypasses the real synthesis, which now lives belowStellarRpc); the generic "no matching operation → skip" case is still covered at the observer level.DefaultPaymentListenerTest: addedWITHDRAWAL_EXCHANGE/DEPOSIT_EXCHANGEcoverage for the sibling SEP-6 asset-mismatch fix (report #3856257), verifying the exchange kinds resolveenforceAssetMatchthe same way their non-exchange counterparts do.Acceptance Criteria
[MANAGE_DATA, PAYMENT→dist]withtransferevent atoperationIndex=1is credited with the correct from/to/amount/operationId.fromAccount) afterNotifyOnchainFundsReceivedHandler's own re-fetch.Long.MAX_VALUEis credited with its exact value, not a wrapped one.RUNNINGafter any of the above scenarios.HorizonPaymentObserver) is unaffected — it never calls the newgetLedgerOperationsoverload.Context
HackerOne #3791580
Testing
./gradlew :core:test --tests "org.stellar.anchor.ledger.LedgerClientHelperTest"./gradlew :platform:test --tests "org.stellar.anchor.platform.observer.stellar.StellarRpcPaymentObserverTest"./gradlew :platform:test --tests "org.stellar.anchor.platform.observer.stellar.StellarRpcObserverIndexDomainTest"./gradlew :core:test :platform:testDocumentation
N/A
Known limitations
N/A