From 87432848fde329a7b6e13b80d34688da36c378ad Mon Sep 17 00:00:00 2001 From: George Date: Thu, 9 Mar 2023 13:58:10 -0800 Subject: [PATCH] ingest: Move SAC event parsing to its own package, add more events. (#4802) * Move contract event parsing to its own package * Add helper to parse ScAddresses * Add mint test cases, contract address parsing * Add fuzzing to parser to ensure no panics * Add clawback event parsing, abstract to generic parser * Add burn event support * Add helper to parse i128 amounts --- ingest/event_test.go | 152 ---------- support/contractevents/burn.go | 54 ++++ support/contractevents/clawback.go | 41 +++ {ingest => support/contractevents}/event.go | 153 +++------- support/contractevents/event_test.go | 296 ++++++++++++++++++++ support/contractevents/mint.go | 41 +++ support/contractevents/transfer.go | 41 +++ support/contractevents/utils.go | 90 ++++++ 8 files changed, 596 insertions(+), 272 deletions(-) delete mode 100644 ingest/event_test.go create mode 100644 support/contractevents/burn.go create mode 100644 support/contractevents/clawback.go rename {ingest => support/contractevents}/event.go (54%) create mode 100644 support/contractevents/event_test.go create mode 100644 support/contractevents/mint.go create mode 100644 support/contractevents/transfer.go create mode 100644 support/contractevents/utils.go diff --git a/ingest/event_test.go b/ingest/event_test.go deleted file mode 100644 index bf7dcca707..0000000000 --- a/ingest/event_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package ingest - -import ( - "crypto/rand" - "fmt" - "testing" - - "github.com/stellar/go/keypair" - "github.com/stellar/go/xdr" - - "github.com/stretchr/testify/require" -) - -const passphrase = "passphrase" - -func TestSACTransferEvent(t *testing.T) { - randomIssuer := keypair.MustRandom() - randomAsset := xdr.MustNewCreditAsset("TESTING", randomIssuer.Address()) - randomAccount := keypair.MustRandom().Address() - - rawNativeContractId, err := xdr.MustNewNativeAsset().ContractID(passphrase) - require.NoError(t, err) - rawContractId, err := randomAsset.ContractID(passphrase) - require.NoError(t, err) - - nativeContractId := xdr.Hash(rawNativeContractId) - contractId := xdr.Hash(rawContractId) - - baseXdrEvent := xdr.ContractEvent{ - Ext: xdr.ExtensionPoint{V: 0}, - ContractId: &contractId, - Type: xdr.ContractEventTypeContract, - Body: xdr.ContractEventBody{ - V: 0, - V0: nil, - }, - } - - baseXdrEvent.Body.V0 = &xdr.ContractEventV0{ - Topics: makeTransferTopic(randomAsset, randomAccount), - Data: makeAmount(10000), - } - - // Ensure the happy path for transfer events works - sacEvent, err := NewStellarAssetContractEvent(&baseXdrEvent, passphrase) - require.NoError(t, err) - require.NotNil(t, sacEvent) - require.Equal(t, EventTypeTransfer, sacEvent.GetType()) - - xferEvent := sacEvent.(*TransferEvent) - require.Equal(t, randomAccount, xferEvent.From) - require.Equal(t, randomAccount, xferEvent.To) - require.EqualValues(t, 10000, xferEvent.Amount.Lo) - require.EqualValues(t, 0, xferEvent.Amount.Hi) - - // Ensure that changing the passphrase invalidates the event - _, err = NewStellarAssetContractEvent(&baseXdrEvent, "different") - require.Error(t, err) - - // Ensure that it works for the native asset - baseXdrEvent.ContractId = &nativeContractId - baseXdrEvent.Body.V0.Topics = makeTransferTopic(xdr.MustNewNativeAsset(), randomAccount) - sacEvent, err = NewStellarAssetContractEvent(&baseXdrEvent, passphrase) - require.NoError(t, err) - require.Equal(t, xdr.AssetTypeAssetTypeNative, sacEvent.GetAsset().Type) - - // Ensure that invalid asset binaries are rejected - bsAsset := make([]byte, 42) - rand.Read(bsAsset) - (*baseXdrEvent.Body.V0.Topics[3].Obj).Bin = &bsAsset - _, err = NewStellarAssetContractEvent(&baseXdrEvent, passphrase) - require.Error(t, err) - - // Ensure that valid asset binaries that mismatch the contract are rejected - baseXdrEvent.ContractId = &nativeContractId - baseXdrEvent.Body.V0.Topics = makeTransferTopic(randomAsset, randomAccount) - _, err = NewStellarAssetContractEvent(&baseXdrEvent, passphrase) - require.Error(t, err) - baseXdrEvent.ContractId = &contractId - _, err = NewStellarAssetContractEvent(&baseXdrEvent, passphrase) - require.NoError(t, err) - - // Ensure that system events are invalid - baseXdrEvent.Type = xdr.ContractEventTypeSystem - _, err = NewStellarAssetContractEvent(&baseXdrEvent, passphrase) - require.Error(t, err) - baseXdrEvent.Type = xdr.ContractEventTypeContract -} - -func makeTransferTopic(asset xdr.Asset, participant string) xdr.ScVec { - accountId, err := xdr.AddressToAccountId(participant) - if err != nil { - panic(fmt.Errorf("participant (%s) isn't an account ID: %v", - participant, err)) - } - - fnName := xdr.ScSymbol("transfer") - account := &xdr.ScObject{ - Type: xdr.ScObjectTypeScoAddress, - Address: &xdr.ScAddress{ - Type: xdr.ScAddressTypeScAddressTypeAccount, - AccountId: &accountId, - }, - } - - slice := []byte("native") - if asset.Type != xdr.AssetTypeAssetTypeNative { - slice = []byte(asset.StringCanonical()) - } - assetDetails := &xdr.ScObject{ - Type: xdr.ScObjectTypeScoBytes, - Bin: &slice, - } - - return xdr.ScVec([]xdr.ScVal{ - // event name - { - Type: xdr.ScValTypeScvSymbol, - Sym: &fnName, - }, - // from - { - Type: xdr.ScValTypeScvObject, - Obj: &account, - }, - // to - { - Type: xdr.ScValTypeScvObject, - Obj: &account, - }, - // asset details - { - Type: xdr.ScValTypeScvObject, - Obj: &assetDetails, - }, - }) -} - -func makeAmount(amount int) xdr.ScVal { - amountObj := &xdr.ScObject{ - Type: xdr.ScObjectTypeScoI128, - I128: &xdr.Int128Parts{ - Lo: xdr.Uint64(amount), - Hi: 0, - }, - } - - return xdr.ScVal{ - Type: xdr.ScValTypeScvObject, - Obj: &amountObj, - } -} diff --git a/support/contractevents/burn.go b/support/contractevents/burn.go new file mode 100644 index 0000000000..508f18bacb --- /dev/null +++ b/support/contractevents/burn.go @@ -0,0 +1,54 @@ +package contractevents + +import ( + "github.com/stellar/go/support/errors" + "github.com/stellar/go/xdr" +) + +var ErrNotBurnEvent = errors.New("event is not a valid 'burn' event") + +type BurnEvent struct { + sacEvent + + From string + Amount xdr.Int128Parts +} + +// parseBurnEvent tries to parse the given topics and value as a SAC "burn" +// event. +// +// Internally, it assumes that the `topics` array has already validated both the +// function name AND the asset <--> contract ID relationship. It will return a +// best-effort parsing even in error cases. +func (event *BurnEvent) parse(topics xdr.ScVec, value xdr.ScVal) error { + // + // The burn event format is: + // + // "burn" Symbol + // Address + // Bytes + // + // i128 + // + // Reference: https://github.com/stellar/rs-soroban-env/blob/main/soroban-env-host/src/native_contract/token/event.rs#L102-L109 + // + if len(topics) != 3 { + return ErrNotBurnEvent + } + + rawFrom := topics[1] + from := parseAddress(&rawFrom) + if from == nil { + return ErrNotBurnEvent + } + + event.From = MustScAddressToString(from) + + amount := parseAmount(&value) + if amount == nil { + return ErrNotBurnEvent + } + + event.Amount = *amount + return nil +} diff --git a/support/contractevents/clawback.go b/support/contractevents/clawback.go new file mode 100644 index 0000000000..847c619d88 --- /dev/null +++ b/support/contractevents/clawback.go @@ -0,0 +1,41 @@ +package contractevents + +import ( + "github.com/stellar/go/support/errors" + "github.com/stellar/go/xdr" +) + +var ErrNotClawbackEvent = errors.New("event is not a valid 'clawback' event") + +type ClawbackEvent struct { + sacEvent + + Admin string + From string + Amount xdr.Int128Parts +} + +// parseClawbackEvent tries to parse the given topics and value as a SAC +// "clawback" event. +// +// Internally, it assumes that the `topics` array has already validated both the +// function name AND the asset <--> contract ID relationship. It will return a +// best-effort parsing even in error cases. +func (event *ClawbackEvent) parse(topics xdr.ScVec, value xdr.ScVal) error { + // + // The clawback event format is: + // + // "clawback" Symbol + // Address + // Address + // Bytes + // + // i128 + // + var err error + event.Admin, event.From, event.Amount, err = parseBalanceChangeEvent(topics, value) + if err != nil { + return ErrNotClawbackEvent + } + return nil +} diff --git a/ingest/event.go b/support/contractevents/event.go similarity index 54% rename from ingest/event.go rename to support/contractevents/event.go index 7a0eb9b676..8f8614fb64 100644 --- a/ingest/event.go +++ b/support/contractevents/event.go @@ -1,54 +1,50 @@ -package ingest +package contractevents import ( - "fmt" - - "github.com/stellar/go/strkey" "github.com/stellar/go/support/errors" "github.com/stellar/go/txnbuild" "github.com/stellar/go/xdr" ) type Event = xdr.ContractEvent -type EventType = int +type EventType int // Note that there is no distinction between xfer() and xfer_from() in events, // nor the other *_from variants. This is intentional from the host environment. const ( // Implemented - EventTypeTransfer = iota + EventTypeTransfer EventType = iota + EventTypeMint + EventTypeClawback + EventTypeBurn // TODO: Not implemented - EventTypeIncrAllow = iota - EventTypeDecrAllow = iota - EventTypeSetAuth = iota - EventTypeSetAdmin = iota - EventTypeMint = iota - EventTypeClawback = iota - EventTypeBurn = iota + EventTypeIncrAllow + EventTypeDecrAllow + EventTypeSetAuth + EventTypeSetAdmin ) var ( STELLAR_ASSET_CONTRACT_TOPICS = map[xdr.ScSymbol]EventType{ - xdr.ScSymbol("mint"): EventTypeMint, xdr.ScSymbol("transfer"): EventTypeTransfer, + xdr.ScSymbol("mint"): EventTypeMint, xdr.ScSymbol("clawback"): EventTypeClawback, xdr.ScSymbol("burn"): EventTypeBurn, } - // TODO: Better parsing errors + // TODO: Finer-grained parsing errors ErrNotStellarAssetContract = errors.New("event was not from a Stellar Asset Contract") ErrEventUnsupported = errors.New("this type of Stellar Asset Contract event is unsupported") - ErrNotTransferEvent = errors.New("event is an invalid 'transfer' event") ) type StellarAssetContractEvent interface { - GetType() int + GetType() EventType GetAsset() xdr.Asset } type sacEvent struct { - Type int + Type EventType Asset xdr.Asset } @@ -56,18 +52,10 @@ func (e *sacEvent) GetAsset() xdr.Asset { return e.Asset } -func (e *sacEvent) GetType() int { +func (e *sacEvent) GetType() EventType { return e.Type } -type TransferEvent struct { - sacEvent - - From string - To string - Amount *xdr.Int128Parts -} - func NewStellarAssetContractEvent(event *Event, networkPassphrase string) (StellarAssetContractEvent, error) { evt := &sacEvent{} @@ -92,7 +80,8 @@ func NewStellarAssetContractEvent(event *Event, networkPassphrase string) (Stell if fn.Type != xdr.ScValTypeScvSymbol { return evt, ErrNotStellarAssetContract } - if eventType, ok := STELLAR_ASSET_CONTRACT_TOPICS[fn.MustSym()]; !ok { + + if eventType, ok := STELLAR_ASSET_CONTRACT_TOPICS[*fn.Sym]; !ok { return evt, ErrNotStellarAssetContract } else { evt.Type = eventType @@ -123,14 +112,13 @@ func NewStellarAssetContractEvent(event *Event, networkPassphrase string) (Stell return evt, errors.Wrap(ErrNotStellarAssetContract, err.Error()) } - switch asset.IsNative() { - case true: - evt.Asset = xdr.MustNewNativeAsset() - case false: + if !asset.IsNative() { evt.Asset, err = xdr.NewCreditAsset(asset.GetCode(), asset.GetIssuer()) - } - if err != nil { - return evt, errors.Wrap(ErrNotStellarAssetContract, err.Error()) + if err != nil { + return evt, errors.Wrap(ErrNotStellarAssetContract, err.Error()) + } + } else { + evt.Asset = xdr.MustNewNativeAsset() } expectedId, err := evt.Asset.ContractID(networkPassphrase) @@ -141,104 +129,29 @@ func NewStellarAssetContractEvent(event *Event, networkPassphrase string) (Stell // This is the DEFINITIVE integrity check for whether or not this is a // SAC event. At this point, we can parse the event and treat it as // truth, mapping it to effects where appropriate. - fmt.Println(asset.IsNative(), "here?") - if expectedId != *event.ContractId { // nil check was earlier return evt, ErrNotStellarAssetContract } switch evt.GetType() { case EventTypeTransfer: - xferEvent := TransferEvent{} + xferEvent := TransferEvent{sacEvent: *evt} return &xferEvent, xferEvent.parse(topics, value) case EventTypeMint: + mintEvent := MintEvent{sacEvent: *evt} + return &mintEvent, mintEvent.parse(topics, value) + case EventTypeClawback: + cbEvent := ClawbackEvent{sacEvent: *evt} + return &cbEvent, cbEvent.parse(topics, value) + case EventTypeBurn: + burnEvent := BurnEvent{sacEvent: *evt} + return &burnEvent, burnEvent.parse(topics, value) + default: return evt, errors.Wrapf(ErrEventUnsupported, "event type %d ('%s') unsupported", evt.Type, fn.MustSym()) } - - return evt, nil -} - -// parseTransferEvent tries to parse the given topics and value as a SAC -// "transfer" event. It assumes that the `topics` array has already validated -// both the function name AND the asset <--> contract ID relationship. It will -// return a best-effort parsing even in error cases. -func (event *TransferEvent) parse(topics xdr.ScVec, value xdr.ScVal) error { - // - // The transfer event format is: - // - // "transfer" Symbol - // Address - // Address - // Bytes - // - // i128 - // - if len(topics) != 4 { - return ErrNotTransferEvent - } - - from, to := topics[1], topics[2] - if from.Type != xdr.ScValTypeScvObject || to.Type != xdr.ScValTypeScvObject { - return ErrNotTransferEvent - } - - fromObj, ok := from.GetObj() - if !ok || fromObj == nil || fromObj.Type != xdr.ScObjectTypeScoAddress { - return ErrNotTransferEvent - } - - toObj, ok := from.GetObj() - if !ok || toObj == nil || toObj.Type != xdr.ScObjectTypeScoAddress { - return ErrNotTransferEvent - } - - event.From = ScAddressToString(fromObj.Address) - event.To = ScAddressToString(toObj.Address) - event.Asset = xdr.Asset{} // TODO - - valueObj, ok := value.GetObj() - if !ok || valueObj == nil || valueObj.Type != xdr.ScObjectTypeScoI128 { - return ErrNotTransferEvent - } - - event.Amount = valueObj.I128 - return nil -} - -// ScAddressToString converts the low-level `xdr.ScAddress` union into the -// appropriate strkey (contract C... or account ID G...). -// -// TODO: Should this return errors or just panic? Maybe just slap the "Must" -// prefix on the helper name? -func ScAddressToString(address *xdr.ScAddress) string { - if address == nil { - return "" - } - - var result string - var err error - - switch address.Type { - case xdr.ScAddressTypeScAddressTypeAccount: - pubkey := address.MustAccountId().Ed25519 - fmt.Println("pubkey:", address.MustAccountId()) - - result, err = strkey.Encode(strkey.VersionByteAccountID, pubkey[:]) - case xdr.ScAddressTypeScAddressTypeContract: - contractId := *address.ContractId - result, err = strkey.Encode(strkey.VersionByteContract, contractId[:]) - default: - panic(fmt.Errorf("unfamiliar address type: %v", address.Type)) - } - - if err != nil { - panic(err) - } - - return result } diff --git a/support/contractevents/event_test.go b/support/contractevents/event_test.go new file mode 100644 index 0000000000..11e56368ed --- /dev/null +++ b/support/contractevents/event_test.go @@ -0,0 +1,296 @@ +package contractevents + +import ( + "crypto/rand" + "testing" + + "github.com/stellar/go/gxdr" + "github.com/stellar/go/keypair" + "github.com/stellar/go/randxdr" + "github.com/stellar/go/strkey" + "github.com/stellar/go/xdr" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const passphrase = "passphrase" + +var ( + randomIssuer = keypair.MustRandom() + randomAsset = xdr.MustNewCreditAsset("TESTING", randomIssuer.Address()) + randomAccount = keypair.MustRandom().Address() + zeroContractHash = xdr.Hash([32]byte{}) + zeroContract = strkey.MustEncode(strkey.VersionByteContract, zeroContractHash[:]) +) + +func TestSACTransferEvent(t *testing.T) { + rawNativeContractId, err := xdr.MustNewNativeAsset().ContractID(passphrase) + require.NoError(t, err) + nativeContractId := xdr.Hash(rawNativeContractId) + + var baseXdrEvent xdr.ContractEvent + resetEvent := func() { + baseXdrEvent = makeEvent() + baseXdrEvent.Body.V0 = &xdr.ContractEventV0{ + Topics: makeTransferTopic(randomAsset), + Data: makeAmount(10000), + } + + } + resetEvent() + + // Ensure the happy path for transfer events works + sacEvent, err := NewStellarAssetContractEvent(&baseXdrEvent, passphrase) + require.NoError(t, err) + require.NotNil(t, sacEvent) + require.Equal(t, EventTypeTransfer, sacEvent.GetType()) + + xferEvent := sacEvent.(*TransferEvent) + require.Equal(t, randomAccount, xferEvent.From) + require.Equal(t, zeroContract, xferEvent.To) + require.EqualValues(t, 10000, xferEvent.Amount.Lo) + require.EqualValues(t, 0, xferEvent.Amount.Hi) + + // Ensure that changing the passphrase invalidates the event + t.Run("wrong passphrase", func(t *testing.T) { + _, err = NewStellarAssetContractEvent(&baseXdrEvent, "different") + require.Error(t, err) + }) + + // Ensure that the native asset still works + t.Run("native transfer", func(t *testing.T) { + resetEvent() + baseXdrEvent.ContractId = &nativeContractId + baseXdrEvent.Body.V0.Topics = makeTransferTopic(xdr.MustNewNativeAsset()) + sacEvent, err = NewStellarAssetContractEvent(&baseXdrEvent, passphrase) + require.NoError(t, err) + require.Equal(t, xdr.AssetTypeAssetTypeNative, sacEvent.GetAsset().Type) + }) + + // Ensure that invalid asset binaries are rejected + t.Run("bad asset binary", func(t *testing.T) { + resetEvent() + bsAsset := make([]byte, 42) + rand.Read(bsAsset) + (*baseXdrEvent.Body.V0.Topics[3].Obj).Bin = &bsAsset + _, err = NewStellarAssetContractEvent(&baseXdrEvent, passphrase) + require.Error(t, err) + }) + + // Ensure that valid asset binaries that mismatch the contract are rejected + t.Run("mismatching ID", func(t *testing.T) { + resetEvent() + // change the ID but keep the asset + baseXdrEvent.ContractId = &nativeContractId + _, err = NewStellarAssetContractEvent(&baseXdrEvent, passphrase) + require.Error(t, err) + + // now change the asset but keep the ID + resetEvent() + diffRandomAsset := xdr.MustNewCreditAsset("TESTING", keypair.MustRandom().Address()) + baseXdrEvent.Body.V0.Topics = makeTransferTopic(diffRandomAsset) + _, err = NewStellarAssetContractEvent(&baseXdrEvent, passphrase) + require.Error(t, err) + }) + + // Ensure that system events are rejected + t.Run("system events", func(t *testing.T) { + resetEvent() + baseXdrEvent.Type = xdr.ContractEventTypeSystem + _, err = NewStellarAssetContractEvent(&baseXdrEvent, passphrase) + require.Error(t, err) + baseXdrEvent.Type = xdr.ContractEventTypeContract + }) +} + +func TestSACMintEvent(t *testing.T) { + baseXdrEvent := makeEvent() + baseXdrEvent.Body.V0 = &xdr.ContractEventV0{ + Topics: makeMintTopic(randomAsset), + Data: makeAmount(10000), + } + + // Ensure the happy path for mint events works + sacEvent, err := NewStellarAssetContractEvent(&baseXdrEvent, passphrase) + require.NoError(t, err) + require.NotNil(t, sacEvent) + require.Equal(t, EventTypeMint, sacEvent.GetType()) + + mintEvent := sacEvent.(*MintEvent) + require.Equal(t, randomAccount, mintEvent.Admin) + require.Equal(t, zeroContract, mintEvent.To) + require.EqualValues(t, 10000, mintEvent.Amount.Lo) + require.EqualValues(t, 0, mintEvent.Amount.Hi) +} + +func TestSACClawbackEvent(t *testing.T) { + baseXdrEvent := makeEvent() + baseXdrEvent.Body.V0 = &xdr.ContractEventV0{ + Topics: makeClawbackTopic(randomAsset), + Data: makeAmount(10000), + } + + // Ensure the happy path for clawback events works + sacEvent, err := NewStellarAssetContractEvent(&baseXdrEvent, passphrase) + require.NoError(t, err) + require.NotNil(t, sacEvent) + require.Equal(t, EventTypeClawback, sacEvent.GetType()) + + clawEvent := sacEvent.(*ClawbackEvent) + require.Equal(t, randomAccount, clawEvent.Admin) + require.Equal(t, zeroContract, clawEvent.From) + require.EqualValues(t, 10000, clawEvent.Amount.Lo) + require.EqualValues(t, 0, clawEvent.Amount.Hi) +} + +func TestSACBurnEvent(t *testing.T) { + baseXdrEvent := makeEvent() + baseXdrEvent.Body.V0 = &xdr.ContractEventV0{ + Topics: makeBurnTopic(randomAsset), + Data: makeAmount(10000), + } + + // Ensure the happy path for burn events works + sacEvent, err := NewStellarAssetContractEvent(&baseXdrEvent, passphrase) + require.NoError(t, err) + require.NotNil(t, sacEvent) + require.Equal(t, EventTypeBurn, sacEvent.GetType()) + + burnEvent := sacEvent.(*BurnEvent) + require.Equal(t, randomAccount, burnEvent.From) + require.EqualValues(t, 10000, burnEvent.Amount.Lo) + require.EqualValues(t, 0, burnEvent.Amount.Hi) +} + +func TestFuzzingSACEventParser(t *testing.T) { + gen := randxdr.NewGenerator() + for i := 0; i < 100_000; i++ { + event, shape := xdr.ContractEvent{}, &gxdr.ContractEvent{} + + gen.Next( + shape, + []randxdr.Preset{}, + ) + assert.NoError(t, gxdr.Convert(shape, &event)) + + // return values are ignored, but this should never panic + NewStellarAssetContractEvent(&event, "passphrase") + } +} + +// +// Test suite helpers below +// + +func makeEvent() xdr.ContractEvent { + rawContractId, err := randomAsset.ContractID(passphrase) + if err != nil { + panic(err) + } + contractId := xdr.Hash(rawContractId) + + baseXdrEvent := xdr.ContractEvent{ + Ext: xdr.ExtensionPoint{V: 0}, + ContractId: &contractId, + Type: xdr.ContractEventTypeContract, + Body: xdr.ContractEventBody{ + V: 0, + V0: &xdr.ContractEventV0{}, + }, + } + + return baseXdrEvent +} + +func makeTransferTopic(asset xdr.Asset) xdr.ScVec { + accountId := xdr.MustAddress(randomAccount) + + fnName := xdr.ScSymbol("transfer") + account := &xdr.ScObject{ + Type: xdr.ScObjectTypeScoAddress, + Address: &xdr.ScAddress{ + Type: xdr.ScAddressTypeScAddressTypeAccount, + AccountId: &accountId, + }, + } + contract := &xdr.ScObject{ + Type: xdr.ScObjectTypeScoAddress, + Address: &xdr.ScAddress{ + Type: xdr.ScAddressTypeScAddressTypeContract, + ContractId: &zeroContractHash, + }, + } + + slice := []byte("native") + if asset.Type != xdr.AssetTypeAssetTypeNative { + slice = []byte(asset.StringCanonical()) + } + assetDetails := &xdr.ScObject{ + Type: xdr.ScObjectTypeScoBytes, + Bin: &slice, + } + + return xdr.ScVec([]xdr.ScVal{ + // event name + { + Type: xdr.ScValTypeScvSymbol, + Sym: &fnName, + }, + // from + { + Type: xdr.ScValTypeScvObject, + Obj: &account, + }, + // to + { + Type: xdr.ScValTypeScvObject, + Obj: &contract, + }, + // asset details + { + Type: xdr.ScValTypeScvObject, + Obj: &assetDetails, + }, + }) +} + +func makeMintTopic(asset xdr.Asset) xdr.ScVec { + // mint is just transfer but with an admin instead of a from... nice + fnName := xdr.ScSymbol("mint") + topics := makeTransferTopic(asset) + topics[0].Sym = &fnName + return topics +} + +func makeClawbackTopic(asset xdr.Asset) xdr.ScVec { + // clawback is just mint but with a from instead of a to + fnName := xdr.ScSymbol("clawback") + topics := makeTransferTopic(asset) + topics[0].Sym = &fnName + return topics +} + +func makeBurnTopic(asset xdr.Asset) xdr.ScVec { + // burn is like clawback but without a "to", so we drop that topic + fnName := xdr.ScSymbol("burn") + topics := makeTransferTopic(asset) + topics[0].Sym = &fnName + topics = append(topics[:2], topics[3:]...) + return topics +} + +func makeAmount(amount int) xdr.ScVal { + amountObj := &xdr.ScObject{ + Type: xdr.ScObjectTypeScoI128, + I128: &xdr.Int128Parts{ + Lo: xdr.Uint64(amount), + Hi: 0, + }, + } + + return xdr.ScVal{ + Type: xdr.ScValTypeScvObject, + Obj: &amountObj, + } +} diff --git a/support/contractevents/mint.go b/support/contractevents/mint.go new file mode 100644 index 0000000000..a2c72be2fc --- /dev/null +++ b/support/contractevents/mint.go @@ -0,0 +1,41 @@ +package contractevents + +import ( + "github.com/stellar/go/support/errors" + "github.com/stellar/go/xdr" +) + +var ErrNotMintEvent = errors.New("event is not a valid 'mint' event") + +type MintEvent struct { + sacEvent + + Admin string + To string + Amount xdr.Int128Parts +} + +// parseMintEvent tries to parse the given topics and value as a SAC "mint" +// event. +// +// Internally, it assumes that the `topics` array has already validated both the +// function name AND the asset <--> contract ID relationship. It will return a +// best-effort parsing even in error cases. +func (event *MintEvent) parse(topics xdr.ScVec, value xdr.ScVal) error { + // + // The mint event format is: + // + // "mint" Symbol + // Address + // Address + // Bytes + // + // i128 + // + var err error + event.Admin, event.To, event.Amount, err = parseBalanceChangeEvent(topics, value) + if err != nil { + return ErrNotMintEvent + } + return nil +} diff --git a/support/contractevents/transfer.go b/support/contractevents/transfer.go new file mode 100644 index 0000000000..23c8896cc3 --- /dev/null +++ b/support/contractevents/transfer.go @@ -0,0 +1,41 @@ +package contractevents + +import ( + "github.com/stellar/go/support/errors" + "github.com/stellar/go/xdr" +) + +var ErrNotTransferEvent = errors.New("event is not a valid 'transfer' event") + +type TransferEvent struct { + sacEvent + + From string + To string + Amount xdr.Int128Parts +} + +// parseTransferEvent tries to parse the given topics and value as a SAC +// "transfer" event. +// +// Internally, it assumes that the `topics` array has already validated both the +// function name AND the asset <--> contract ID relationship. It will return a +// best-effort parsing even in error cases. +func (event *TransferEvent) parse(topics xdr.ScVec, value xdr.ScVal) error { + // + // The transfer event format is: + // + // "transfer" Symbol + // Address + // Address + // Bytes + // + // i128 + // + var err error + event.From, event.To, event.Amount, err = parseBalanceChangeEvent(topics, value) + if err != nil { + return ErrNotTransferEvent + } + return nil +} diff --git a/support/contractevents/utils.go b/support/contractevents/utils.go new file mode 100644 index 0000000000..99e8d8b3d0 --- /dev/null +++ b/support/contractevents/utils.go @@ -0,0 +1,90 @@ +package contractevents + +import ( + "fmt" + + "github.com/stellar/go/strkey" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/xdr" +) + +var ErrNotBalanceChangeEvent = errors.New("event doesn't represent a balance change") + +// MustScAddressToString converts the low-level `xdr.ScAddress` union into the +// appropriate strkey (contract C... or account ID G...). +func MustScAddressToString(address *xdr.ScAddress) string { + if address == nil { + return "" + } + + var result string + var err error + + switch address.Type { + case xdr.ScAddressTypeScAddressTypeAccount: + pubkey := address.MustAccountId().Ed25519 + result, err = strkey.Encode(strkey.VersionByteAccountID, pubkey[:]) + case xdr.ScAddressTypeScAddressTypeContract: + contractId := *address.ContractId + result, err = strkey.Encode(strkey.VersionByteContract, contractId[:]) + default: + panic(fmt.Errorf("unfamiliar address type: %v", address.Type)) + } + + if err != nil { + panic(err) + } + + return result +} + +func parseAddress(val *xdr.ScVal) *xdr.ScAddress { + if val == nil { + return nil + } + + address, ok := val.GetObj() + if !ok || address == nil || address.Type != xdr.ScObjectTypeScoAddress { + return nil + } + + return address.Address +} + +func parseAmount(val *xdr.ScVal) *xdr.Int128Parts { + valueObj, ok := val.GetObj() + if !ok || valueObj == nil || valueObj.Type != xdr.ScObjectTypeScoI128 { + return nil + } + + return valueObj.I128 +} + +// parseBalanceChangeEvent is a generalization of a subset of the Stellar Asset +// Contract events. Transfer, mint, clawback, and burn events all have two +// addresses and an amount involved. The addresses represent different things in +// different event types (e.g. "from" or "admin"), but the parsing is identical. +// This helper extracts all three parts or returns a generic error if it can't. +func parseBalanceChangeEvent(topics xdr.ScVec, value xdr.ScVal) (string, string, xdr.Int128Parts, error) { + first, second, amount := "", "", xdr.Int128Parts{} + + if len(topics) != 4 { + return first, second, amount, ErrNotBalanceChangeEvent + } + + rawFirst, rawSecond := topics[1], topics[2] + firstSc, secondSc := parseAddress(&rawFirst), parseAddress(&rawSecond) + if firstSc == nil || secondSc == nil { + return first, second, amount, ErrNotBalanceChangeEvent + } + + first, second = MustScAddressToString(firstSc), MustScAddressToString(secondSc) + + amountPtr := parseAmount(&value) + if amountPtr == nil { + return first, second, amount, ErrNotBalanceChangeEvent + } + + amount = *amountPtr + return first, second, amount, nil +}