diff --git a/go.mod b/go.mod index 152ffc76..40635dcd 100644 --- a/go.mod +++ b/go.mod @@ -170,3 +170,5 @@ require ( google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/stellar/go-stellar-sdk => github.com/sisuresh/go v0.0.0-20260625074621-094cee3852e4 diff --git a/go.sum b/go.sum index 75a8c147..ccd48de4 100644 --- a/go.sum +++ b/go.sum @@ -413,6 +413,8 @@ github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+Yg github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c/go.mod h1:owqhoLW1qZoYLZzLnBw+QkPP9WZnjlSWihhxAJC1+/M= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sisuresh/go v0.0.0-20260625074621-094cee3852e4 h1:48qIWdammLh11C2xWd7xskvV/rc4GNqTClgyYiQyP6E= +github.com/sisuresh/go v0.0.0-20260625074621-094cee3852e4/go.mod h1:IkcqcrE9UQi7n/1y+MxKB+7qzdjG1T2kGOD7Ss8dqjw= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= @@ -429,8 +431,6 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= -github.com/stellar/go-stellar-sdk v0.6.1-0.20260616165505-26fec7c0e891 h1:Y/PtLm4L4ZHLDI1dXIflRjI5pY+m+KD7FUVFM07tV+E= -github.com/stellar/go-stellar-sdk v0.6.1-0.20260616165505-26fec7c0e891/go.mod h1:IkcqcrE9UQi7n/1y+MxKB+7qzdjG1T2kGOD7Ss8dqjw= github.com/stellar/go-xdr v0.0.0-20260529210834-0bf8f4956364 h1:gOKrfuWdZ92LFlv0TAwgZ7OsWKeBsOMDlGLyFgduI1w= github.com/stellar/go-xdr v0.0.0-20260529210834-0bf8f4956364/go.mod h1:If+U9Z1W5xU97VrOgJandQT+2dN7/iOpkCrxBJEyF80= github.com/stellar/throttled v2.2.3-0.20190823235211-89d75816f59d+incompatible h1:jMXXAcz6xTarGDQ4VtVbtERogcmDQw4RaE85Cr9CgoQ= diff --git a/internal/ingest/contractevents/events.go b/internal/ingest/contractevents/events.go index a1b31940..e1bb7473 100644 --- a/internal/ingest/contractevents/events.go +++ b/internal/ingest/contractevents/events.go @@ -42,7 +42,12 @@ type StellarAssetContractEvent struct { DestinationMemo xdr.Memo // Can be uint64, []byte, or string (V4 only) } -// parseAddress extracts and converts an address from an ScVal +// parseAddress extracts and converts an address from an ScVal. +// +// CAP-0084 (P28) adds a muxed-contract destination arm +// (SC_ADDRESS_TYPE_MUXED_CONTRACT); ScAddress.String() renders it directly, and +// the destination mux id is surfaced via the to_muxed_id event-map convention +// (see parseSacEventMap), mirroring the CAP-67 muxed-ACCOUNT handling. func parseAddress(topic xdr.ScVal) (string, error) { addr, ok := topic.GetAddress() if !ok { @@ -317,10 +322,14 @@ func parseSacEventFromTxMetaV4(event *xdr.ContractEvent, networkPassphrase strin } // parseSacEventMap parses the ScMap data format used in V4 SAC events. -// For SAC events, to_muxed_id represents the muxed account ID from -// MuxedAddressObject - which is always a uint64. ScvBytes and ScvString are NOT -// valid for SAC events (those are only used for classic transaction memo mappings -// per CAP-67, which are processed through a different code path). +// For SAC events, to_muxed_id represents the muxed id of the destination - +// which is always a uint64. This applies both to a muxed-ACCOUNT destination +// (CAP-67) and, under CAP-0084 (P28, Muxed Contract Addresses), to a +// muxed-CONTRACT destination: transfer and mint accept a muxed-contract `to` +// and surface its mux id via the exact same to_muxed_id event-map convention, +// so no new decode branch is needed here. ScvBytes and ScvString are NOT valid +// for SAC events (those are only used for classic transaction memo mappings per +// CAP-67, which are processed through a different code path). func parseSacEventMap(mapData xdr.ScMap) (xdr.Int128Parts, xdr.Memo, error) { var foundAmount, foundMuxedId bool var amount xdr.Int128Parts @@ -346,9 +355,11 @@ func parseSacEventMap(mapData xdr.ScMap) (xdr.Int128Parts, xdr.Memo, error) { case "to_muxed_id": foundMuxedId = true - // SAC events only emit uint64 for to_muxed_id (muxed account ID). - // ScvBytes/ScvString are NOT valid here - those are only for classic - // transaction memo mappings which use a different code path. + // SAC events only emit uint64 for to_muxed_id: the mux id of a + // muxed-account destination (CAP-67) or a muxed-contract + // destination (CAP-0084). ScvBytes/ScvString are NOT valid here - + // those are only for classic transaction memo mappings which use a + // different code path. switch entry.Val.Type { case xdr.ScValTypeScvU64: if val, ok := entry.Val.GetU64(); ok { diff --git a/internal/ingest/contractevents/events_test.go b/internal/ingest/contractevents/events_test.go index e5118b98..2a18e3a5 100644 --- a/internal/ingest/contractevents/events_test.go +++ b/internal/ingest/contractevents/events_test.go @@ -242,6 +242,32 @@ func TestStellarAssetContractEventParsing(t *testing.T) { DestinationMemo: xdr.MemoID(12345), }, }, + { + // CAP-0084 muxed-contract destination: in a well-formed SAC event + // the `to` topic is the BASE contract address (type Contract); the + // mux id rides in the to_muxed_id map entry (exactly as CAP-67 does + // for muxed accounts). A mux id of 0 is a valid id and must parse. + name: "Valid V4 transfer to muxed-contract destination (mux id 0)", + txMetaVersion: 4, + eventType: EventTypeTransfer, + topics: []xdr.ScVal{ + makeSymbol("transfer"), + makeAddress(randomAccount), + makeAddress(zeroContract), + makeAsset(randomAsset), + }, + data: makeV4MapData(big.NewInt(1000), xdr.MemoID(0)), + asset: randomAsset, + contractID: mustGetContractID(randomAsset), + expectedResult: &StellarAssetContractEvent{ + Type: EventTypeTransfer, + Asset: randomAsset, + From: randomAccount, + To: zeroContract, + Amount: xdr.Int128Parts{Lo: 1000, Hi: 0}, + DestinationMemo: xdr.MemoID(0), + }, + }, { name: "V4 SAC event rejects ScvString for to_muxed_id", txMetaVersion: 4, @@ -523,6 +549,28 @@ func TestStellarAssetContractEventParsing(t *testing.T) { } } +// TestParseAddressMuxedContract locks in the go-stellar-sdk CAP-0084 behavior: +// a SC_ADDRESS_TYPE_MUXED_CONTRACT ScAddress renders as ":" via +// ScAddress.String(). This guards against regressing to the interim placeholder +// that errored on the muxed-contract arm. +func TestParseAddressMuxedContract(t *testing.T) { + muxedID := xdr.Uint64(42) + topic := xdr.ScVal{ + Type: xdr.ScValTypeScvAddress, + Address: &xdr.ScAddress{ + Type: xdr.ScAddressTypeScAddressTypeMuxedContract, + MuxedContract: &xdr.MuxedContract{ + Id: muxedID, + ContractId: xdr.ContractId(zeroContractHash), + }, + }, + } + + addr, err := parseAddress(topic) + require.NoError(t, err) + assert.Equal(t, zeroContract+":42", addr) +} + // Test helper functions func someLedgerTransaction(version int32) ingest.LedgerTransaction { return ingest.LedgerTransaction{ diff --git a/internal/ingest/processors/effects_processor.go b/internal/ingest/processors/effects_processor.go index 0a32a03f..ca782087 100644 --- a/internal/ingest/processors/effects_processor.go +++ b/internal/ingest/processors/effects_processor.go @@ -1627,6 +1627,13 @@ func (e *effectsWrapper) addInvokeHostFunctionEffects(events []xdr.ContractEvent } } else { toDetails["contract"] = evt.To + // CAP-0084: a muxed-contract destination carries its mux id in + // to_muxed_id. No canonical muxed-contract strkey exists yet, so + // surface the raw id as a detail field, mirroring + // operations_processor's SAC balance-change handling. + if memoId != nil { + toDetails["destination_muxed_id"] = fmt.Sprintf("%d", *memoId) + } e.addMuxed(source, history.EffectContractCredited, toDetails) } @@ -1666,6 +1673,13 @@ func (e *effectsWrapper) addInvokeHostFunctionEffects(events []xdr.ContractEvent } } else { details["contract"] = evt.To + // CAP-0084: a muxed-contract destination carries its mux id in + // to_muxed_id. No canonical muxed-contract strkey exists yet, so + // surface the raw id as a detail field, mirroring + // operations_processor's SAC balance-change handling. + if memoId != nil { + details["destination_muxed_id"] = fmt.Sprintf("%d", *memoId) + } e.addMuxed(source, history.EffectContractCredited, details) } diff --git a/internal/ingest/processors/effects_processor_test.go b/internal/ingest/processors/effects_processor_test.go index aaa8465b..76d25089 100644 --- a/internal/ingest/processors/effects_processor_test.go +++ b/internal/ingest/processors/effects_processor_test.go @@ -3754,6 +3754,192 @@ func TestInvokeHostFunctionEffects(t *testing.T) { } } +// TestInvokeHostFunctionMuxedContractEffects verifies that a SAC +// transfer/mint whose `to` is a muxed contract (CAP-0084) surfaces the +// destination mux id as `destination_muxed_id` on the contract_credited +// effect. The mux id rides in the V4 to_muxed_id event-map entry. A mux id of +// 0 is a valid id and must be surfaced as the string "0" (not omitted): the +// SDK resource field is a string precisely so "0" survives the round-trip. +func TestInvokeHostFunctionMuxedContractEffects(t *testing.T) { + admin := keypair.MustRandom().Address() + asset := xdr.MustNewCreditAsset("TESTER", admin) + fromAccount := keypair.MustRandom().Address() + toContractBytes := xdr.Hash{1} + toContract := strkey.MustEncode(strkey.VersionByteContract, toContractBytes[:]) + amount := big.NewInt(12345) + + assetDetails := func() map[string]interface{} { + return map[string]interface{}{ + "amount": "0.0012345", + "asset_code": strings.Trim(asset.GetCode(), "\x00"), + "asset_issuer": asset.GetIssuer(), + "asset_type": "credit_alphanum12", + } + } + + contractCreditedDetails := func(muxedID string) map[string]interface{} { + d := assetDetails() + d["contract"] = toContract + d["destination_muxed_id"] = muxedID + return d + } + + testCases := []struct { + desc string + eventType contractevents.EventType + muxedID uint64 + expected []effect + }{ + { + desc: "transfer to muxed contract", + eventType: contractevents.EventTypeTransfer, + muxedID: 111, + expected: []effect{ + { + order: 1, + address: fromAccount, + effectType: history.EffectAccountDebited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: assetDetails(), + }, { + order: 2, + address: admin, + effectType: history.EffectContractCredited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: contractCreditedDetails("111"), + }, + }, + }, { + desc: "mint to muxed contract (mux id 0)", + eventType: contractevents.EventTypeMint, + muxedID: 0, + expected: []effect{ + { + order: 1, + address: admin, + effectType: history.EffectContractCredited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: contractCreditedDetails("0"), + }, + }, + }, + } + + // Build a V4 SAC event by hand: GenerateEvent always emits the V3-shaped + // mint (admin topic + 4 topics), but the V4 parser requires the 3-topic + // admin-less mint. Both transfer and mint carry the destination mux id in + // the to_muxed_id event-map entry. + sym := func(s string) xdr.ScVal { + v := xdr.ScSymbol(s) + return xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &v} + } + contractAddr := func(c string) xdr.ScVal { + var cid xdr.ContractId + copy(cid[:], strkey.MustDecode(strkey.VersionByteContract, c)) + return xdr.ScVal{Type: xdr.ScValTypeScvAddress, Address: &xdr.ScAddress{ + Type: xdr.ScAddressTypeScAddressTypeContract, ContractId: &cid, + }} + } + accountAddr := func(g string) xdr.ScVal { + return xdr.ScVal{Type: xdr.ScValTypeScvAddress, Address: &xdr.ScAddress{ + Type: xdr.ScAddressTypeScAddressTypeAccount, AccountId: xdr.MustAddressPtr(g), + }} + } + assetSep11 := func() xdr.ScVal { + s := xdr.ScString(asset.StringCanonical()) + return xdr.ScVal{Type: xdr.ScValTypeScvString, Str: &s} + } + mapData := func(muxedID uint64) xdr.ScVal { + amt := xdr.Int128Parts{Lo: xdr.Uint64(amount.Int64()), Hi: 0} + mux := xdr.Uint64(muxedID) + m := &xdr.ScMap{ + xdr.ScMapEntry{Key: sym("amount"), Val: xdr.ScVal{Type: xdr.ScValTypeScvI128, I128: &amt}}, + xdr.ScMapEntry{Key: sym("to_muxed_id"), Val: xdr.ScVal{Type: xdr.ScValTypeScvU64, U64: &mux}}, + } + return xdr.ScVal{Type: xdr.ScValTypeScvMap, Map: &m} + } + buildEvent := func(t *testing.T, eventType contractevents.EventType, muxedID uint64) xdr.ContractEvent { + var topics []xdr.ScVal + switch eventType { + case contractevents.EventTypeTransfer: + topics = []xdr.ScVal{sym("transfer"), accountAddr(fromAccount), contractAddr(toContract), assetSep11()} + case contractevents.EventTypeMint: + topics = []xdr.ScVal{sym("mint"), contractAddr(toContract), assetSep11()} + default: + t.Fatalf("unsupported event type %q", eventType) + } + rawID, err := asset.ContractID(networkPassphrase) + if err != nil { + t.Fatal(err) + } + cid := xdr.ContractId(rawID) + return xdr.ContractEvent{ + Type: xdr.ContractEventTypeContract, + ContractId: &cid, + Body: xdr.ContractEventBody{ + V: 0, + V0: &xdr.ContractEventV0{Topics: xdr.ScVec(topics), Data: mapData(muxedID)}, + }, + } + } + + for _, testCase := range testCases { + t.Run(testCase.desc, func(t *testing.T) { + event := buildEvent(t, testCase.eventType, testCase.muxedID) + + meta := xdr.TransactionMetaV4{ + Operations: []xdr.OperationMetaV2{ + {Events: []xdr.ContractEvent{event}}, + }, + } + envelope := xdr.TransactionV1Envelope{ + Tx: xdr.Transaction{ + Ext: xdr.TransactionExt{ + V: 1, + SorobanData: &xdr.SorobanTransactionData{}, + }, + Operations: []xdr.Operation{ + { + SourceAccount: xdr.MustMuxedAddressPtr(admin), + Body: xdr.OperationBody{ + Type: xdr.OperationTypeInvokeHostFunction, + InvokeHostFunctionOp: &xdr.InvokeHostFunctionOp{}, + }, + }, + }, + }, + } + tx := ingest.LedgerTransaction{ + Index: 0, + Envelope: xdr.TransactionEnvelope{ + Type: xdr.EnvelopeTypeEnvelopeTypeTx, + V1: &envelope, + }, + Result: xdr.TransactionResultPair{ + TransactionHash: xdr.Hash([32]byte{}), + Result: xdr.TransactionResult{ + FeeCharged: 1234, + Result: xdr.TransactionResultResult{ + Code: xdr.TransactionResultCodeTxSuccess, + }, + }, + }, + UnsafeMeta: xdr.TransactionMeta{V: 4, V4: &meta}, + } + + operation := transactionOperationWrapper{ + index: 0, + transaction: tx, + operation: tx.Envelope.Operations()[0], + ledgerSequence: 1, + network: networkPassphrase, + } + + assertIngestEffects(t, operation, testCase.expected) + }) + } +} + // makeInvocationTransaction returns a single transaction containing a single // invokeHostFunction operation that generates the specified Stellar Asset // Contract events in its txmeta. diff --git a/internal/resourceadapter/effects_test.go b/internal/resourceadapter/effects_test.go index dddfe50d..f746f45f 100644 --- a/internal/resourceadapter/effects_test.go +++ b/internal/resourceadapter/effects_test.go @@ -90,6 +90,65 @@ func TestNewEffect_EffectTrustlineAuthorizedToMaintainLiabilities(t *testing.T) tt.Equal(effect, page.Embedded.Records[0].(effects.TrustlineAuthorizedToMaintainLiabilities)) } +// TestNewEffect_EffectContractCredited_MuxedDestination is the load-bearing +// round-trip test for CAP-0084: it proves that destination_muxed_id written to +// an effect's `details` survives the details -> SDK struct -> API JSON path. +// The "0" sub-case is the regression guard for the zero-value trap — it fails +// if the SDK field is ever changed back to uint64 + ",string" + omitempty, +// which silently drops a mux id of 0. +func TestNewEffect_EffectContractCredited_MuxedDestination(t *testing.T) { + for _, tc := range []struct { + name string + muxedID string + }{ + {name: "normal id", muxedID: "42"}, + {name: "zero id round-trips", muxedID: "0"}, + } { + t.Run(tc.name, func(t *testing.T) { + tt := assert.New(t) + ctx, _ := test.ContextWithLogBuffer() + + details := `{ + "asset_type": "credit_alphanum4", + "asset_code": "USD", + "asset_issuer": "GDRW375MAYR46ODGF2WGANQC2RRZL7O246DYHHCGWTV2RE7IHE2QUQLD", + "contract": "CB7KKMERLBFGQQILA4LM5VHDVL2RQVAQNLPMQHYBAM3J42UIIVZJSGEC", + "amount": "0.0012345", + "destination_muxed_id": "` + tc.muxedID + `" + }` + + hEffect := history.Effect{ + Account: "GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3", + HistoryOperationID: 1, + Order: 1, + Type: history.EffectContractCredited, + DetailsString: null.StringFrom(details), + } + resource, err := NewEffect(ctx, hEffect, history.Ledger{}) + tt.NoError(err) + + effect, ok := resource.(effects.ContractCredited) + tt.True(ok) + tt.Equal("contract_credited", effect.Base.Type) + tt.Equal(tc.muxedID, effect.DestinationMuxedID) + + var resourcePage hal.Page + resourcePage.Add(resource) + binary, err := json.Marshal(resourcePage) + tt.NoError(err) + + // The id must be present in the serialized API JSON, including "0". + tt.Contains(string(binary), `"destination_muxed_id":"`+tc.muxedID+`"`) + + var page effects.EffectsPage + tt.NoError(json.Unmarshal(binary, &page)) + tt.Len(page.Embedded.Records, 1) + tt.Equal(effect, page.Embedded.Records[0].(effects.ContractCredited)) + tt.Equal(tc.muxedID, page.Embedded.Records[0].(effects.ContractCredited).DestinationMuxedID) + }) + } +} + func TestNewEffect_EffectTrade_Muxed(t *testing.T) { tt := assert.New(t) ctx, _ := test.ContextWithLogBuffer()