Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
27 changes: 19 additions & 8 deletions internal/ingest/contractevents/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions internal/ingest/contractevents/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 "<C-strkey>:<id>" 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{
Expand Down
14 changes: 14 additions & 0 deletions internal/ingest/processors/effects_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down
186 changes: 186 additions & 0 deletions internal/ingest/processors/effects_processor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3754,6 +3754,192 @@
}
}

// 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) {

Check failure on line 3763 in internal/ingest/processors/effects_processor_test.go

View workflow job for this annotation

GitHub Actions / golangci

Function 'TestInvokeHostFunctionMuxedContractEffects' is too long (173 > 100) (funlen)
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}

Check failure on line 3853 in internal/ingest/processors/effects_processor_test.go

View workflow job for this annotation

GitHub Actions / golangci

G115: integer overflow conversion int64 -> uint64 (gosec)
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.
Expand Down
Loading
Loading