diff --git a/CHANGELOG.md b/CHANGELOG.md index ffadf9ef..a8a0f956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ file. This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fixed the history lookup-table reaper deleting rows (e.g. `history_accounts`) that live ingestion was concurrently inserting references to, which left dangling references and could make an account's history endpoints return 404 or silently omit records until a reingest ([#222](https://github.com/stellar/stellar-horizon/pull/222)). +### Changed +- Stopped requiring a `to_muxed_id`, and an exact entry count, in the data map of a V4 Stellar Asset Contract event. A transfer with no muxed destination may express that by omitting the field — as a CAP-0086 sparse map (protocol 28) would — or by binding the key to `Void`, and unrecognized keys are now ignored rather than rejected. This is forward-compat hardening: the Stellar Asset Contract emits a map only when a muxed destination exists, and always with both keys, so no event in ledger history today is affected and no reingestion is needed. It matters because every caller discards an event that fails to parse instead of surfacing the error, so a shape the parser rejects silently costs that event its `account_credited`/`account_debited` effects, its `balance_changes` operation details, and its participants ([#223](https://github.com/stellar/stellar-horizon/pull/223)). + ## 28.0.0 **This release adds support for Protocol 28.** diff --git a/internal/ingest/contractevents/events.go b/internal/ingest/contractevents/events.go index a1b31940..b7bf4411 100644 --- a/internal/ingest/contractevents/events.go +++ b/internal/ingest/contractevents/events.go @@ -321,15 +321,17 @@ func parseSacEventFromTxMetaV4(event *xdr.ContractEvent, networkPassphrase strin // 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). +// +// Only amount is required. A to_muxed_id holding None arrives either bound to +// Void or, for a CAP-86 sparse map, with no key at all, and both mean the +// transfer has no muxed destination. Unrecognized keys are ignored for the same +// reason: callers discard an event whose parse fails rather than surfacing an +// error, so rejecting one silently drops its effects and participants. func parseSacEventMap(mapData xdr.ScMap) (xdr.Int128Parts, xdr.Memo, error) { - var foundAmount, foundMuxedId bool + var foundAmount bool var amount xdr.Int128Parts var memo xdr.Memo - if len(mapData) != 2 { - return amount, memo, fmt.Errorf("expected exactly 2 elements in map data, but found %d", len(mapData)) - } - for _, entry := range mapData { key, ok := entry.Key.GetSym() if !ok { @@ -345,7 +347,6 @@ func parseSacEventMap(mapData xdr.ScMap) (xdr.Int128Parts, xdr.Memo, error) { foundAmount = true 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. @@ -354,6 +355,8 @@ func parseSacEventMap(mapData xdr.ScMap) (xdr.Int128Parts, xdr.Memo, error) { if val, ok := entry.Val.GetU64(); ok { memo = xdr.MemoID(uint64(val)) } + case xdr.ScValTypeScvVoid: + // No muxed destination, leaving memo as MemoNone. default: return amount, memo, fmt.Errorf("invalid to_muxed_id type in SAC event: expected ScvU64, got %s", entry.Val.Type) } @@ -362,8 +365,6 @@ func parseSacEventMap(mapData xdr.ScMap) (xdr.Int128Parts, xdr.Memo, error) { if !foundAmount { return amount, memo, errors.New("amount field not found in map") - } else if !foundMuxedId { - return amount, memo, errors.New("to_muxed_id field not found in map") } return amount, memo, nil diff --git a/internal/ingest/contractevents/events_test.go b/internal/ingest/contractevents/events_test.go index e5118b98..48b1bd77 100644 --- a/internal/ingest/contractevents/events_test.go +++ b/internal/ingest/contractevents/events_test.go @@ -242,6 +242,55 @@ func TestStellarAssetContractEventParsing(t *testing.T) { DestinationMemo: xdr.MemoID(12345), }, }, + { + // CAP-86 sparse maps omit a field holding None, so the map carries + // nothing but the amount. + name: "Valid V4 transfer with to_muxed_id omitted", + txMetaVersion: 4, + eventType: EventTypeTransfer, + topics: []xdr.ScVal{ + makeSymbol("transfer"), + makeAddress(randomAccount), + makeAddress(zeroContract), + makeAsset(randomAsset), + }, + data: makeV4MapData(big.NewInt(1000), xdr.Memo{}), + asset: randomAsset, + contractID: mustGetContractID(randomAsset), + expectedResult: &StellarAssetContractEvent{ + Type: EventTypeTransfer, + Asset: randomAsset, + From: randomAccount, + To: zeroContract, + Amount: xdr.Int128Parts{Lo: 1000, Hi: 0}, + }, + }, + { + // The encoding a contract built before CAP-86 produces for the same + // None field: the key is present, bound to Void. + name: "Valid V4 transfer with void to_muxed_id", + txMetaVersion: 4, + eventType: EventTypeTransfer, + topics: []xdr.ScVal{ + makeSymbol("transfer"), + makeAddress(randomAccount), + makeAddress(zeroContract), + makeAsset(randomAsset), + }, + data: makeV4MapDataWithMuxedID( + big.NewInt(1000), + &xdr.ScVal{Type: xdr.ScValTypeScvVoid}, + ), + asset: randomAsset, + contractID: mustGetContractID(randomAsset), + expectedResult: &StellarAssetContractEvent{ + Type: EventTypeTransfer, + Asset: randomAsset, + From: randomAccount, + To: zeroContract, + Amount: xdr.Int128Parts{Lo: 1000, Hi: 0}, + }, + }, { name: "V4 SAC event rejects ScvString for to_muxed_id", txMetaVersion: 4, @@ -362,7 +411,7 @@ func TestStellarAssetContractEventParsing(t *testing.T) { expectedError: "invalid from address", }, { - name: "V4 map data insufficient elements", + name: "V4 map data with no entries", txMetaVersion: 4, eventType: EventTypeTransfer, topics: []xdr.ScVal{ @@ -380,7 +429,7 @@ func TestStellarAssetContractEventParsing(t *testing.T) { }(), asset: randomAsset, contractID: mustGetContractID(randomAsset), - expectedError: "failed to parse V4 map data: expected exactly 2 elements in map data", + expectedError: "failed to parse V4 map data: amount field not found in map", }, { name: "V4 map data - missing amount", @@ -413,7 +462,10 @@ func TestStellarAssetContractEventParsing(t *testing.T) { expectedError: "amount field not found in map", }, { - name: "V4 map data - missing muxed id", + // A key the parser does not recognize is ignored rather than + // rejected, so that a future field added to the event data does not + // take payment effects down with it. + name: "V4 map data with an unrecognized key", txMetaVersion: 4, eventType: EventTypeTransfer, topics: []xdr.ScVal{ @@ -438,9 +490,15 @@ func TestStellarAssetContractEventParsing(t *testing.T) { Map: &mapData, } }(), - asset: randomAsset, - contractID: mustGetContractID(randomAsset), - expectedError: "failed to parse V4 map data: to_muxed_id field not found in map", + asset: randomAsset, + contractID: mustGetContractID(randomAsset), + expectedResult: &StellarAssetContractEvent{ + Type: EventTypeTransfer, + Asset: randomAsset, + From: randomAccount, + To: zeroContract, + Amount: xdr.Int128Parts{Lo: 1000, Hi: 0}, + }, }, { name: "V3 Invalid amount data type", diff --git a/internal/ingest/contractevents/generate.go b/internal/ingest/contractevents/generate.go index dae05005..dd1c57f3 100644 --- a/internal/ingest/contractevents/generate.go +++ b/internal/ingest/contractevents/generate.go @@ -161,39 +161,28 @@ func makeAsset(asset xdr.Asset) xdr.ScVal { } } +// makeV4MapData builds the V4 event data map for the given amount and memo, +// omitting to_muxed_id entirely for a MemoNone memo the way a CAP-86 sparse map +// does. func makeV4MapData(amount *big.Int, memo xdr.Memo) xdr.ScVal { - mapEntries := xdr.ScMap{} - - // Add amount entry - amountEntry := xdr.ScMapEntry{ - Key: xdr.ScVal{ - Type: xdr.ScValTypeScvSymbol, - Sym: &[]xdr.ScSymbol{"amount"}[0], - }, - Val: makeBigAmount(amount), - } - mapEntries = append(mapEntries, amountEntry) - - // Add to_muxed_id entry based on memo type - var muxedIdVal xdr.ScVal + var muxedIdVal *xdr.ScVal switch memo.Type { + case xdr.MemoTypeMemoNone: case xdr.MemoTypeMemoId: - id := memo.Id - val := *id - muxedIdVal = xdr.ScVal{ + val := *memo.Id + muxedIdVal = &xdr.ScVal{ Type: xdr.ScValTypeScvU64, U64: &val, } case xdr.MemoTypeMemoText: - str := memo.Text - val := xdr.ScString(*str) - muxedIdVal = xdr.ScVal{ + val := xdr.ScString(*memo.Text) + muxedIdVal = &xdr.ScVal{ Type: xdr.ScValTypeScvString, Str: &val, } case xdr.MemoTypeMemoHash: bytes := xdr.ScBytes(memo.Hash[:]) - muxedIdVal = xdr.ScVal{ + muxedIdVal = &xdr.ScVal{ Type: xdr.ScValTypeScvBytes, Bytes: &bytes, } @@ -201,14 +190,22 @@ func makeV4MapData(amount *big.Int, memo xdr.Memo) xdr.ScVal { panic(fmt.Errorf("unsupported memo type: %v", memo.Type)) } - muxedIdEntry := xdr.ScMapEntry{ - Key: xdr.ScVal{ - Type: xdr.ScValTypeScvSymbol, - Sym: &[]xdr.ScSymbol{"to_muxed_id"}[0], - }, - Val: muxedIdVal, + return makeV4MapDataWithMuxedID(amount, muxedIdVal) +} + +// makeV4MapDataWithMuxedID builds the V4 event data map with to_muxed_id bound +// to the given value, or without the key at all when muxedID is nil. +func makeV4MapDataWithMuxedID(amount *big.Int, muxedID *xdr.ScVal) xdr.ScVal { + mapEntries := xdr.ScMap{{ + Key: makeSymbol("amount"), + Val: makeBigAmount(amount), + }} + if muxedID != nil { + mapEntries = append(mapEntries, xdr.ScMapEntry{ + Key: makeSymbol("to_muxed_id"), + Val: *muxedID, + }) } - mapEntries = append(mapEntries, muxedIdEntry) mapPtr := &mapEntries // Need to use double pointer for Map field