diff --git a/amount/main.go b/amount/main.go index b1b7bc1474..bb7b9964b4 100644 --- a/amount/main.go +++ b/amount/main.go @@ -117,6 +117,22 @@ func String(v xdr.Int64) string { return StringFromInt64(int64(v)) } +// String128 converts a signed 128-bit integer into a string, boldly assuming +// 7-decimal precision. +// +// TODO: This should be adapted to variable precision when appopriate, but 7 +// decimals is the correct default for Stellar Classic amounts. +func String128(v xdr.Int128Parts) string { + // the upper half of the i128 always indicates its sign regardless of its + // value, just like a native signed type + val := big.NewInt(int64(v.Hi)) + val.Lsh(val, 64).Add(val, new(big.Int).SetUint64(uint64(v.Lo))) + + rat := new(big.Rat).SetInt(val) + rat.Quo(rat, bigOne) + return rat.FloatString(7) +} + // StringFromInt64 returns an "amount string" from the provided raw int64 value `v`. func StringFromInt64(v int64) string { r := big.NewRat(v, 1) diff --git a/services/horizon/internal/db2/history/main.go b/services/horizon/internal/db2/history/main.go index a066836e63..5af61e139d 100644 --- a/services/horizon/internal/db2/history/main.go +++ b/services/horizon/internal/db2/history/main.go @@ -36,11 +36,20 @@ const ( // EffectAccountRemoved effects occur when one account is merged into another EffectAccountRemoved EffectType = 1 // from merge_account - // EffectAccountCredited effects occur when an account receives some currency - EffectAccountCredited EffectType = 2 // from create_account, payment, path_payment, merge_account + // EffectAccountCredited effects occur when an account receives some + // currency + // + // from create_account, payment, path_payment, merge_account, and SAC events + // involving transfers, mints, and burns. + EffectAccountCredited EffectType = 2 // EffectAccountDebited effects occur when an account sends some currency - EffectAccountDebited EffectType = 3 // from create_account, payment, path_payment, create_account + // + // from create_account, payment, path_payment, create_account, and SAC + // involving transfers, mints, and burns. + // + // https://github.com/stellar/rs-soroban-env/blob/5695440da452837555d8f7f259cc33341fdf07b0/soroban-env-host/src/native_contract/token/contract.rs#L51-L63 + EffectAccountDebited EffectType = 3 // EffectAccountThresholdsUpdated effects occur when an account changes its // multisig thresholds. diff --git a/services/horizon/internal/ingest/processor_runner.go b/services/horizon/internal/ingest/processor_runner.go index 16d5bd34d5..260a7740c7 100644 --- a/services/horizon/internal/ingest/processor_runner.go +++ b/services/horizon/internal/ingest/processor_runner.go @@ -143,7 +143,7 @@ func (s *ProcessorRunner) buildTransactionProcessor( sequence := uint32(ledger.Header.LedgerSeq) return newGroupTransactionProcessors([]horizonTransactionProcessor{ statsLedgerTransactionProcessor, - processors.NewEffectProcessor(s.historyQ, sequence), + processors.NewEffectProcessor(s.historyQ, sequence, s.config.NetworkPassphrase), processors.NewLedgerProcessor(s.historyQ, ledger, CurrentVersion), processors.NewOperationProcessor(s.historyQ, sequence), tradeProcessor, diff --git a/services/horizon/internal/ingest/processors/effects_processor.go b/services/horizon/internal/ingest/processors/effects_processor.go index c5b96bb947..096111decd 100644 --- a/services/horizon/internal/ingest/processors/effects_processor.go +++ b/services/horizon/internal/ingest/processors/effects_processor.go @@ -15,6 +15,8 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/protocols/horizon/base" "github.com/stellar/go/services/horizon/internal/db2/history" + "github.com/stellar/go/strkey" + "github.com/stellar/go/support/contractevents" "github.com/stellar/go/support/errors" "github.com/stellar/go/xdr" ) @@ -24,12 +26,14 @@ type EffectProcessor struct { effects []effect effectsQ history.QEffects sequence uint32 + network string } -func NewEffectProcessor(effectsQ history.QEffects, sequence uint32) *EffectProcessor { +func NewEffectProcessor(effectsQ history.QEffects, sequence uint32, networkPassphrase string) *EffectProcessor { return &EffectProcessor{ effectsQ: effectsQ, sequence: sequence, + network: networkPassphrase, } } @@ -56,7 +60,10 @@ func (p *EffectProcessor) loadAccountIDs(ctx context.Context, accountSet map[str return nil } -func operationsEffects(transaction ingest.LedgerTransaction, sequence uint32) ([]effect, error) { +func operationsEffects( + transaction ingest.LedgerTransaction, + sequence uint32, + networkPassphrase string) ([]effect, error) { effects := []effect{} for opi, op := range transaction.Envelope.Operations() { @@ -65,6 +72,7 @@ func operationsEffects(transaction ingest.LedgerTransaction, sequence uint32) ([ transaction: transaction, operation: op, ledgerSequence: sequence, + network: networkPassphrase, } p, err := operation.effects() @@ -119,7 +127,7 @@ func (p *EffectProcessor) ProcessTransaction(ctx context.Context, transaction in } var effectsForTx []effect - effectsForTx, err = operationsEffects(transaction, p.sequence) + effectsForTx, err = operationsEffects(transaction, p.sequence, p.network) if err != nil { return err } @@ -210,8 +218,11 @@ func (operation *transactionOperationWrapper) effects() ([]effect, error) { err = wrapper.addCreateClaimableBalanceEffects(changes) case xdr.OperationTypeClaimClaimableBalance: err = wrapper.addClaimClaimableBalanceEffects(changes) - case xdr.OperationTypeBeginSponsoringFutureReserves, xdr.OperationTypeEndSponsoringFutureReserves, xdr.OperationTypeRevokeSponsorship: - // The effects of these operations are obtained indirectly from the ledger entries + case xdr.OperationTypeBeginSponsoringFutureReserves, + xdr.OperationTypeEndSponsoringFutureReserves, + xdr.OperationTypeRevokeSponsorship: + // The effects of these operations are obtained indirectly from the + // ledger entries case xdr.OperationTypeClawback: err = wrapper.addClawbackEffects() case xdr.OperationTypeClawbackClaimableBalance: @@ -223,10 +234,19 @@ func (operation *transactionOperationWrapper) effects() ([]effect, error) { case xdr.OperationTypeLiquidityPoolWithdraw: err = wrapper.addLiquidityPoolWithdrawEffect() case xdr.OperationTypeInvokeHostFunction: - // TODO: https://github.com/stellar/go/issues/4585 - return nil, nil + // If there's an invokeHostFunction operation, there's definitely V3 + // meta in the transaction, which means this error is real. + events, innerErr := operation.transaction.GetOperationEvents(operation.index) + if innerErr != nil { + return nil, innerErr + } + + // For now, the only effects are related to the events themselves. + // Possible add'l work: https://github.com/stellar/go/issues/4585 + err = wrapper.addInvokeHostFunctionEffects(events) + default: - return nil, fmt.Errorf("Unknown operation type: %s", op.Body.Type) + return nil, fmt.Errorf("unknown operation type: %s", op.Body.Type) } if err != nil { return nil, err @@ -246,7 +266,8 @@ func (operation *transactionOperationWrapper) effects() ([]effect, error) { // Liquidity pools for _, change := range changes { - // Effects caused by ChangeTrust (creation), AllowTrust and SetTrustlineFlags (removal through revocation) + // Effects caused by ChangeTrust (creation), AllowTrust and + // SetTrustlineFlags (removal through revocation) wrapper.addLedgerEntryLiquidityPoolEffects(change) } @@ -1386,3 +1407,93 @@ func (e *effectsWrapper) addLiquidityPoolWithdrawEffect() error { e.addMuxed(e.operation.SourceAccount(), history.EffectLiquidityPoolWithdrew, details) return nil } + +// addInvokeHostFunctionEffects iterates through the events and generates +// account_credited and account_debited effects when it sees events related to +// the Stellar Asset Contract corresponding to those effects. +func (e *effectsWrapper) addInvokeHostFunctionEffects(events []contractevents.Event) error { + if e.operation.network == "" { + return errors.New("invokeHostFunction effects cannot be determined unless network passphrase is set") + } + + for _, event := range events { + evt, err := contractevents.NewStellarAssetContractEvent(&event, e.operation.network) + if err != nil { + continue // irrelevant or unsupported event + } + + details := make(map[string]interface{}, 4) + addAssetDetails(details, evt.GetAsset(), "") + + // + // Note: We ignore effects that involve contracts (until the day we have + // contract_debited/credited effects, may it never come :pray:) + // + + switch evt.GetType() { + // Transfer events generate an `account_debited` effect for the `from` + // (sender) and an `account_credited` effect for the `to` (recipient). + case contractevents.EventTypeTransfer: + xferEvent := evt.(*contractevents.TransferEvent) + details["amount"] = amount.String128(xferEvent.Amount) + if strkey.IsValidEd25519PublicKey(xferEvent.From) { + e.add( + xferEvent.From, + null.String{}, + history.EffectAccountDebited, + details, + ) + } + if strkey.IsValidEd25519PublicKey(xferEvent.To) { + e.add( + xferEvent.To, + null.String{}, + history.EffectAccountCredited, + details, + ) + } + + // Mint events imply a non-native asset, and it results in a credit to + // the `to` recipient. + case contractevents.EventTypeMint: + mintEvent := evt.(*contractevents.MintEvent) + details["amount"] = amount.String128(mintEvent.Amount) + if strkey.IsValidEd25519PublicKey(mintEvent.To) { + e.add( + mintEvent.To, + null.String{}, + history.EffectAccountCredited, + details, + ) + } + + // Clawback events result in a debit to the `from` address, but acts + // like a burn to the recipient, so these are functionally equivalent + case contractevents.EventTypeClawback: + cbEvent := evt.(*contractevents.ClawbackEvent) + details["amount"] = amount.String128(cbEvent.Amount) + if strkey.IsValidEd25519PublicKey(cbEvent.From) { + e.add( + cbEvent.From, + null.String{}, + history.EffectAccountDebited, + details, + ) + } + + case contractevents.EventTypeBurn: + burnEvent := evt.(*contractevents.BurnEvent) + details["amount"] = amount.String128(burnEvent.Amount) + if strkey.IsValidEd25519PublicKey(burnEvent.From) { + e.add( + burnEvent.From, + null.String{}, + history.EffectAccountDebited, + details, + ) + } + } + } + + return nil +} diff --git a/services/horizon/internal/ingest/processors/effects_processor_test.go b/services/horizon/internal/ingest/processors/effects_processor_test.go index cb0d0dd321..d08b5a18b9 100644 --- a/services/horizon/internal/ingest/processors/effects_processor_test.go +++ b/services/horizon/internal/ingest/processors/effects_processor_test.go @@ -4,11 +4,16 @@ package processors import ( "context" + "crypto/rand" "encoding/hex" + "math/big" + "strings" "testing" "github.com/guregu/null" + "github.com/stellar/go/keypair" "github.com/stellar/go/protocols/horizon/base" + "github.com/stellar/go/strkey" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -16,11 +21,16 @@ import ( "github.com/stellar/go/ingest" "github.com/stellar/go/services/horizon/internal/db2/history" . "github.com/stellar/go/services/horizon/internal/test/transactions" + "github.com/stellar/go/support/contractevents" "github.com/stellar/go/support/errors" "github.com/stellar/go/toid" "github.com/stellar/go/xdr" ) +const ( + networkPassphrase = "Arbitrary Testing Passphrase" +) + type EffectsProcessorTestSuiteLedger struct { suite.Suite ctx context.Context @@ -121,6 +131,7 @@ func (s *EffectsProcessorTestSuiteLedger) SetupTest() { s.processor = NewEffectProcessor( s.mockQ, 20, + networkPassphrase, ) s.txs = []ingest.LedgerTransaction{ @@ -444,24 +455,25 @@ func TestEffectsCoversAllOperationTypes(t *testing.T) { }, } operation := transactionOperationWrapper{ - index: 0, + index: 0, + ledgerSequence: 1, transaction: ingest.LedgerTransaction{ UnsafeMeta: xdr.TransactionMeta{ V: 2, V2: &xdr.TransactionMetaV2{}, }, }, - operation: op, - ledgerSequence: 1, + operation: op, + network: "test passphrase", } - // calling effects should either panic (because the operation field is set to nil) - // or not error + // calling effects should either panic (because the operation field is + // set to nil) or not error func() { var err error defer func() { err2 := recover() if err != nil { - assert.NotContains(t, err.Error(), "Unknown operation type") + assert.NotContains(t, err.Error(), "unknown operation type") } assert.True(t, err2 != nil || err == nil, s) }() @@ -488,7 +500,7 @@ func TestEffectsCoversAllOperationTypes(t *testing.T) { } // calling effects should error due to the unknown operation _, err := operation.effects() - assert.Contains(t, err.Error(), "Unknown operation type") + assert.Contains(t, err.Error(), "unknown operation type") } func TestOperationEffects(t *testing.T) { @@ -3453,5 +3465,287 @@ func TestLiquidityPoolEffects(t *testing.T) { assert.Equal(t, tc.expected, effects) }) } +} + +func TestInvokeHostFunctionEffects(t *testing.T) { + randAddr := func() string { + return keypair.MustRandom().Address() + } + + admin := randAddr() + asset := xdr.MustNewCreditAsset("TESTER", admin) + nativeAsset := xdr.MustNewNativeAsset() + from, to := randAddr(), randAddr() + amount := big.NewInt(12345) + + rawContractId := [64]byte{} + rand.Read(rawContractId[:]) + contractName := strkey.MustEncode(strkey.VersionByteContract, rawContractId[:]) + testCases := []struct { + desc string + asset xdr.Asset + from, to string + eventType contractevents.EventType + expected []effect + }{ + { + desc: "transfer", + asset: asset, + eventType: contractevents.EventTypeTransfer, + expected: []effect{ + { + order: 1, + address: from, + effectType: history.EffectAccountDebited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: map[string]interface{}{ + "amount": "0.0012345", + "asset_code": strings.Trim(asset.GetCode(), "\x00"), + "asset_issuer": asset.GetIssuer(), + "asset_type": "credit_alphanum12", + }, + }, { + order: 2, + address: to, + effectType: history.EffectAccountCredited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: map[string]interface{}{ + "amount": "0.0012345", + "asset_code": strings.Trim(asset.GetCode(), "\x00"), + "asset_issuer": asset.GetIssuer(), + "asset_type": "credit_alphanum12", + }, + }, + }, + }, { + desc: "mint", + asset: asset, + eventType: contractevents.EventTypeMint, + expected: []effect{ + { + order: 1, + address: to, + effectType: history.EffectAccountCredited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: map[string]interface{}{ + "amount": "0.0012345", + "asset_code": strings.Trim(asset.GetCode(), "\x00"), + "asset_issuer": asset.GetIssuer(), + "asset_type": "credit_alphanum12", + }, + }, + }, + }, { + desc: "burn", + asset: asset, + eventType: contractevents.EventTypeBurn, + expected: []effect{ + { + order: 1, + address: from, + effectType: history.EffectAccountDebited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: map[string]interface{}{ + "amount": "0.0012345", + "asset_code": strings.Trim(asset.GetCode(), "\x00"), + "asset_issuer": asset.GetIssuer(), + "asset_type": "credit_alphanum12", + }, + }, + }, + }, { + desc: "clawback", + asset: asset, + eventType: contractevents.EventTypeClawback, + expected: []effect{ + { + order: 1, + address: from, + effectType: history.EffectAccountDebited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: map[string]interface{}{ + "amount": "0.0012345", + "asset_code": strings.Trim(asset.GetCode(), "\x00"), + "asset_issuer": asset.GetIssuer(), + "asset_type": "credit_alphanum12", + }, + }, + }, + }, { + desc: "transfer native", + asset: nativeAsset, + eventType: contractevents.EventTypeTransfer, + expected: []effect{ + { + order: 1, + address: from, + effectType: history.EffectAccountDebited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: map[string]interface{}{ + "amount": "0.0012345", + "asset_type": "native", + }, + }, { + order: 2, + address: to, + effectType: history.EffectAccountCredited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: map[string]interface{}{ + "amount": "0.0012345", + "asset_type": "native", + }, + }, + }, + }, { + desc: "transfer into contract", + asset: asset, + to: contractName, + eventType: contractevents.EventTypeTransfer, + expected: []effect{ + { + order: 1, + address: from, + effectType: history.EffectAccountDebited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: map[string]interface{}{ + "amount": "0.0012345", + "asset_code": strings.Trim(asset.GetCode(), "\x00"), + "asset_issuer": asset.GetIssuer(), + "asset_type": "credit_alphanum12", + }, + }, + }, + }, { + desc: "transfer out of contract", + asset: asset, + from: contractName, + eventType: contractevents.EventTypeTransfer, + expected: []effect{ + { + order: 1, + address: to, + effectType: history.EffectAccountCredited, + operationID: toid.New(1, 0, 1).ToInt64(), + details: map[string]interface{}{ + "amount": "0.0012345", + "asset_code": strings.Trim(asset.GetCode(), "\x00"), + "asset_issuer": asset.GetIssuer(), + "asset_type": "credit_alphanum12", + }, + }, + }, + }, { + desc: "transfer between contracts", + asset: asset, + from: contractName, + to: contractName, + eventType: contractevents.EventTypeTransfer, + expected: []effect{}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.desc, func(t *testing.T) { + var tx ingest.LedgerTransaction + + fromAddr := from + if testCase.from != "" { + fromAddr = testCase.from + } + + toAddr := to + if testCase.to != "" { + toAddr = testCase.to + } + + tx = makeInvocationTransaction( + fromAddr, toAddr, + admin, + testCase.asset, + amount, + testCase.eventType, + ) + assert.True(t, tx.Result.Successful()) // sanity check + + operation := transactionOperationWrapper{ + index: 0, + transaction: tx, + operation: tx.Envelope.Operations()[0], + ledgerSequence: 1, + network: networkPassphrase, + } + + effects, err := operation.effects() + assert.NoErrorf(t, err, "event type %v", testCase.eventType) + assert.Lenf(t, effects, len(testCase.expected), "event type %v", testCase.eventType) + assert.Equalf(t, testCase.expected, effects, "event type %v", testCase.eventType) + }) + } +} + +// makeInvocationTransaction returns a single transaction containing a single +// invokeHostFunction operation that generates the specified Stellar Asset +// Contract events in its txmeta. +func makeInvocationTransaction( + from, to, admin string, + asset xdr.Asset, + amount *big.Int, + types ...contractevents.EventType, +) ingest.LedgerTransaction { + meta := xdr.TransactionMetaV3{ + // irrelevant for contract invocations: only events are inspected + Operations: []xdr.OperationMeta{}, + Events: []xdr.OperationEvents{{ + Events: make([]xdr.ContractEvent, len(types)), + }}, + } + + for idx, type_ := range types { + event := contractevents.GenerateEvent( + type_, + from, to, admin, + asset, + amount, + networkPassphrase, + ) + meta.Events[0].Events[idx] = event + } + + envelope := xdr.TransactionV1Envelope{ + Tx: xdr.Transaction{ + // the rest doesn't matter for effect ingestion + Operations: []xdr.Operation{ + { + SourceAccount: xdr.MustMuxedAddressPtr(admin), + Body: xdr.OperationBody{ + Type: xdr.OperationTypeInvokeHostFunction, + // contents of the op are irrelevant as they aren't + // parsed by anyone yet, e.g. effects are generated + // purely from events + InvokeHostFunctionOp: &xdr.InvokeHostFunctionOp{}, + }, + }, + }, + }, + } + + return ingest.LedgerTransaction{ + Index: 0, + Envelope: xdr.TransactionEnvelope{ + Type: xdr.EnvelopeTypeEnvelopeTypeTx, + V1: &envelope, + }, + // the result just needs enough to look successful + Result: xdr.TransactionResultPair{ + TransactionHash: xdr.Hash([32]byte{}), + Result: xdr.TransactionResult{ + FeeCharged: 1234, + Result: xdr.TransactionResultResult{ + Code: xdr.TransactionResultCodeTxSuccess, + }, + }, + }, + UnsafeMeta: xdr.TransactionMeta{V: 3, V3: &meta}, + } } diff --git a/services/horizon/internal/ingest/processors/operations_processor.go b/services/horizon/internal/ingest/processors/operations_processor.go index 7a9f31ff46..2b22ee6d96 100644 --- a/services/horizon/internal/ingest/processors/operations_processor.go +++ b/services/horizon/internal/ingest/processors/operations_processor.go @@ -83,6 +83,7 @@ type transactionOperationWrapper struct { transaction ingest.LedgerTransaction operation xdr.Operation ledgerSequence uint32 + network string } // ID returns the ID for the operation. diff --git a/services/horizon/internal/integration/sac_test.go b/services/horizon/internal/integration/sac_test.go index 39a963a564..22a32f2cf6 100644 --- a/services/horizon/internal/integration/sac_test.go +++ b/services/horizon/internal/integration/sac_test.go @@ -10,6 +10,7 @@ import ( "github.com/stellar/go/amount" "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/keypair" + "github.com/stellar/go/protocols/horizon/effects" "github.com/stellar/go/protocols/stellarcore" "github.com/stellar/go/services/horizon/internal/test/integration" "github.com/stellar/go/strkey" @@ -48,7 +49,7 @@ func TestContractMintToAccount(t *testing.T) { recipientKp, recipient := itest.CreateAccount("100") itest.MustEstablishTrustline(recipientKp, recipient, txnbuild.MustAssetFromXDR(asset)) - assertInvokeHostFnSucceeds( + _, mintTx := assertInvokeHostFnSucceeds( itest, itest.Master(), mint(itest, issuer, asset, "20", accountAddressParam(recipient.GetAccountID())), @@ -65,17 +66,32 @@ func TestContractMintToAccount(t *testing.T) { contractID: stellarAssetContractID(itest, asset), }) + fx := getTxEffects(itest, mintTx, asset) + require.Len(t, fx, 1) + creditEffect := assertContainsEffect(t, fx, + effects.EffectAccountCredited)[0].(effects.AccountCredited) + assert.Equal(t, recipientKp.Address(), creditEffect.Account) + assert.Equal(t, issuer, creditEffect.Asset.Issuer) + assert.Equal(t, code, creditEffect.Asset.Code) + assert.Equal(t, "20.0000000", creditEffect.Amount) + otherRecipientKp, otherRecipient := itest.CreateAccount("100") itest.MustEstablishTrustline(otherRecipientKp, otherRecipient, txnbuild.MustAssetFromXDR(asset)) // calling xfer from the issuer account will also mint the asset - assertInvokeHostFnSucceeds( + _, xferTx := assertInvokeHostFnSucceeds( itest, itest.Master(), xfer(itest, issuer, asset, "30", accountAddressParam(otherRecipient.GetAccountID())), ) assertContainsBalance(itest, recipientKp, issuer, code, amount.MustParse("20")) assertContainsBalance(itest, otherRecipientKp, issuer, code, amount.MustParse("30")) + + fx = getTxEffects(itest, xferTx, asset) + assert.Len(t, fx, 2) + assertContainsEffect(t, fx, + effects.EffectAccountCredited, + effects.EffectAccountDebited) assertAssetStats(itest, assetStats{ code: code, issuer: issuer, @@ -106,13 +122,18 @@ func TestContractMintToContract(t *testing.T) { // Create recipient contract recipientContractID := mustCreateAndInstallContract(itest, itest.Master(), "a1", add_u64_contract) - assertInvokeHostFnSucceeds( + _, mintTx := assertInvokeHostFnSucceeds( itest, itest.Master(), - mintWithAmt(itest, issuer, asset, i128Param(math.MaxInt64, math.MaxUint64-3), contractAddressParam(recipientContractID)), + mintWithAmt( + itest, + issuer, asset, + i128Param(math.MaxInt64, math.MaxUint64-3), + contractAddressParam(recipientContractID)), ) + assert.Empty(t, getTxEffects(itest, mintTx, asset)) - balanceAmount := assertInvokeHostFnSucceeds( + balanceAmount, _ := assertInvokeHostFnSucceeds( itest, itest.Master(), balance(itest, issuer, asset, contractAddressParam(recipientContractID)), @@ -124,13 +145,18 @@ func TestContractMintToContract(t *testing.T) { assert.Equal(itest.CurrentTest(), xdr.Uint64(math.MaxInt64), (*balanceAmount.Obj).I128.Hi) // calling xfer from the issuer account will also mint the asset - assertInvokeHostFnSucceeds( + _, xferTx := assertInvokeHostFnSucceeds( itest, itest.Master(), xferWithAmount(itest, issuer, asset, i128Param(0, 3), contractAddressParam(recipientContractID)), ) - balanceAmount = assertInvokeHostFnSucceeds( + // while contract-to-contract shouldn't have effects (i.e. the mintTx), the + // xfer comes from the issuer account, so it *should* generate a debit + assertContainsEffect(t, getTxEffects(itest, xferTx, asset), + effects.EffectAccountDebited) + + balanceAmount, _ = assertInvokeHostFnSucceeds( itest, itest.Master(), balance(itest, issuer, asset, contractAddressParam(recipientContractID)), @@ -199,7 +225,7 @@ func TestContractTransferBetweenAccounts(t *testing.T) { otherRecipientKp, otherRecipient := itest.CreateAccount("100") itest.MustEstablishTrustline(otherRecipientKp, otherRecipient, txnbuild.MustAssetFromXDR(asset)) - assertInvokeHostFnSucceeds( + _, xferTx := assertInvokeHostFnSucceeds( itest, recipientKp, xfer(itest, recipientKp.Address(), asset, "30", accountAddressParam(otherRecipient.GetAccountID())), @@ -207,6 +233,10 @@ func TestContractTransferBetweenAccounts(t *testing.T) { assertContainsBalance(itest, recipientKp, issuer, code, amount.MustParse("970")) assertContainsBalance(itest, otherRecipientKp, issuer, code, amount.MustParse("30")) + + fx := getTxEffects(itest, xferTx, asset) + assert.NotEmpty(t, fx) + assertContainsEffect(t, fx, effects.EffectAccountCredited, effects.EffectAccountDebited) assertAssetStats(itest, assetStats{ code: code, issuer: issuer, @@ -262,12 +292,13 @@ func TestContractTransferBetweenAccountAndContract(t *testing.T) { ) // Add funds to recipient contract - assertInvokeHostFnSucceeds( + _, mintTx := assertInvokeHostFnSucceeds( itest, itest.Master(), mint(itest, issuer, asset, "1000", contractAddressParam(recipientContractID)), ) assertContainsBalance(itest, recipientKp, issuer, code, amount.MustParse("1000")) + assert.Empty(t, getTxEffects(itest, mintTx, asset)) // no effects: the only actor is a contract assertAssetStats(itest, assetStats{ code: code, issuer: issuer, @@ -279,12 +310,14 @@ func TestContractTransferBetweenAccountAndContract(t *testing.T) { }) // transfer from account to contract - assertInvokeHostFnSucceeds( + _, xferTx := assertInvokeHostFnSucceeds( itest, recipientKp, xfer(itest, recipientKp.Address(), asset, "30", contractAddressParam(recipientContractID)), ) assertContainsBalance(itest, recipientKp, issuer, code, amount.MustParse("970")) + assertContainsEffect(t, getTxEffects(itest, xferTx, asset), + effects.EffectAccountDebited) // effects: account is involved, contract ignored assertAssetStats(itest, assetStats{ code: code, issuer: issuer, @@ -296,11 +329,17 @@ func TestContractTransferBetweenAccountAndContract(t *testing.T) { }) // transfer from contract to account - assertInvokeHostFnSucceeds( + _, xferTx = assertInvokeHostFnSucceeds( itest, recipientKp, - xferFromContract(itest, recipientKp.Address(), recipientContractID, "500", accountAddressParam(recipient.GetAccountID())), + xferFromContract(itest, + recipientKp.Address(), + recipientContractID, + "500", + accountAddressParam(recipient.GetAccountID())), ) + assertContainsEffect(t, getTxEffects(itest, xferTx, asset), + effects.EffectAccountCredited) // effects: account is involved, contract ignored assertContainsBalance(itest, recipientKp, issuer, code, amount.MustParse("1470")) assertAssetStats(itest, assetStats{ code: code, @@ -312,7 +351,7 @@ func TestContractTransferBetweenAccountAndContract(t *testing.T) { contractID: stellarAssetContractID(itest, asset), }) - balanceAmount := assertInvokeHostFnSucceeds( + balanceAmount, _ := assertInvokeHostFnSucceeds( itest, itest.Master(), balance(itest, issuer, asset, contractAddressParam(recipientContractID)), @@ -358,14 +397,15 @@ func TestContractTransferBetweenContracts(t *testing.T) { ) // Transfer funds from emitter to recipient - assertInvokeHostFnSucceeds( + _, xferTx := assertInvokeHostFnSucceeds( itest, itest.Master(), xferFromContract(itest, issuer, emitterContractID, "10", contractAddressParam(recipientContractID)), ) + assert.Empty(t, getTxEffects(itest, xferTx, asset)) // Check balances of emitter and recipient - emitterBalanceAmount := assertInvokeHostFnSucceeds( + emitterBalanceAmount, _ := assertInvokeHostFnSucceeds( itest, itest.Master(), balance(itest, issuer, asset, contractAddressParam(emitterContractID)), @@ -374,7 +414,7 @@ func TestContractTransferBetweenContracts(t *testing.T) { assert.Equal(itest.CurrentTest(), xdr.Uint64(9900000000), (*emitterBalanceAmount.Obj).I128.Lo) assert.Equal(itest.CurrentTest(), xdr.Uint64(0), (*emitterBalanceAmount.Obj).I128.Hi) - recipientBalanceAmount := assertInvokeHostFnSucceeds( + recipientBalanceAmount, _ := assertInvokeHostFnSucceeds( itest, itest.Master(), balance(itest, issuer, asset, contractAddressParam(recipientContractID)), @@ -438,12 +478,21 @@ func TestContractBurnFromAccount(t *testing.T) { contractID: stellarAssetContractID(itest, asset), }) - assertInvokeHostFnSucceeds( + _, burnTx := assertInvokeHostFnSucceeds( itest, recipientKp, burn(itest, recipientKp.Address(), asset, "500"), ) + fx := getTxEffects(itest, burnTx, asset) + assert.Len(t, fx, 1) + burnEffect := assertContainsEffect(t, fx, + effects.EffectAccountDebited)[0].(effects.AccountDebited) + + assert.Equal(t, issuer, burnEffect.Asset.Issuer) + assert.Equal(t, code, burnEffect.Asset.Code) + assert.Equal(t, "500.0000000", burnEffect.Amount) + assert.Equal(t, recipientKp.Address(), burnEffect.Account) assertAssetStats(itest, assetStats{ code: code, issuer: issuer, @@ -495,7 +544,7 @@ func TestContractBurnFromContract(t *testing.T) { burnSelf(itest, issuer, recipientContractID, "10"), ) - balanceAmount := assertInvokeHostFnSucceeds( + balanceAmount, burnTx := assertInvokeHostFnSucceeds( itest, itest.Master(), balance(itest, issuer, asset, contractAddressParam(recipientContractID)), @@ -503,6 +552,10 @@ func TestContractBurnFromContract(t *testing.T) { assert.Equal(itest.CurrentTest(), xdr.Uint64(9900000000), (*balanceAmount.Obj).I128.Lo) assert.Equal(itest.CurrentTest(), xdr.Uint64(0), (*balanceAmount.Obj).I128.Hi) + + // Burn transactions across contracts generate burn events, but these + // shouldn't be included as account-related effects. + assert.Empty(t, getTxEffects(itest, burnTx, asset)) assertAssetStats(itest, assetStats{ code: code, issuer: issuer, @@ -568,12 +621,13 @@ func TestContractClawbackFromAccount(t *testing.T) { contractID: stellarAssetContractID(itest, asset), }) - assertInvokeHostFnSucceeds( + _, clawTx := assertInvokeHostFnSucceeds( itest, itest.Master(), clawback(itest, issuer, asset, "1000", accountAddressParam(recipientKp.Address())), ) + assertContainsEffect(t, getTxEffects(itest, clawTx, asset), effects.EffectAccountDebited) assertContainsBalance(itest, recipientKp, issuer, code, 0) assertAssetStats(itest, assetStats{ code: code, @@ -623,13 +677,13 @@ func TestContractClawbackFromContract(t *testing.T) { ) // Clawback funds - assertInvokeHostFnSucceeds( + _, clawTx := assertInvokeHostFnSucceeds( itest, itest.Master(), clawback(itest, issuer, asset, "10", contractAddressParam(recipientContractID)), ) - balanceAmount := assertInvokeHostFnSucceeds( + balanceAmount, _ := assertInvokeHostFnSucceeds( itest, itest.Master(), balance(itest, issuer, asset, contractAddressParam(recipientContractID)), @@ -637,6 +691,9 @@ func TestContractClawbackFromContract(t *testing.T) { assert.Equal(itest.CurrentTest(), xdr.Uint64(9900000000), (*balanceAmount.Obj).I128.Lo) assert.Equal(itest.CurrentTest(), xdr.Uint64(0), (*balanceAmount.Obj).I128.Hi) + + // clawbacks between contracts generate events but not effects + assert.Empty(t, getTxEffects(itest, clawTx, asset)) assertAssetStats(itest, assetStats{ code: code, issuer: issuer, @@ -700,6 +757,42 @@ func assertAssetStats(itest *integration.Test, expected assetStats) { assert.Equal(itest.CurrentTest(), strkey.MustEncode(strkey.VersionByteContract, expected.contractID[:]), asset.ContractID) } +// assertContainsEffect checks that the list of json effects contains the given +// effect type(s) by name (no other details are checked). It returns the last +// effect matching each given type. +func assertContainsEffect(t *testing.T, fx []effects.Effect, effectTypes ...effects.EffectType) []effects.Effect { + found := map[string]int{} + for idx, effect := range fx { + found[effect.GetType()] = idx + } + + for _, type_ := range effectTypes { + assert.Containsf(t, found, effects.EffectTypeNames[type_], "effects: %v", fx) + } + + var rv []effects.Effect + for _, i := range found { + rv = append(rv, fx[i]) + } + + return rv +} + +// getTxEffects returns a transaction's effects, limited to 2 because it's to be +// used for checking SAC effects. +func getTxEffects(itest *integration.Test, txHash string, asset xdr.Asset) []effects.Effect { + t := itest.CurrentTest() + effects, err := itest.Client().Effects(horizonclient.EffectRequest{ + ForTransaction: txHash, + Order: horizonclient.OrderDesc, + }) + assert.NoError(t, err) + result := effects.Embedded.Records + + assert.LessOrEqualf(t, len(result), 2, "txhash: %s", txHash) + return result +} + func functionNameParam(name string) xdr.ScVal { contractFnParameterSym := xdr.ScSymbol(name) return xdr.ScVal{ @@ -1005,7 +1098,7 @@ func addFootprint(itest *integration.Test, invokeHostFn *txnbuild.InvokeHostFunc return invokeHostFn } -func assertInvokeHostFnSucceeds(itest *integration.Test, signer *keypair.Full, op *txnbuild.InvokeHostFunction) *xdr.ScVal { +func assertInvokeHostFnSucceeds(itest *integration.Test, signer *keypair.Full, op *txnbuild.InvokeHostFunction) (*xdr.ScVal, string) { acc := itest.MustGetAccount(signer) tx, err := itest.SubmitOperations(&acc, signer, op) require.NoError(itest.CurrentTest(), err) @@ -1013,13 +1106,6 @@ func assertInvokeHostFnSucceeds(itest *integration.Test, signer *keypair.Full, o clientTx, err := itest.Client().TransactionDetail(tx.Hash) require.NoError(itest.CurrentTest(), err) - effects, err := itest.Client().Effects(horizonclient.EffectRequest{ - ForTransaction: tx.Hash, - }) - require.NoError(itest.CurrentTest(), err) - // Horizon currently does not support effects for smart contract invocations - require.Empty(itest.CurrentTest(), effects.Embedded.Records) - assert.Equal(itest.CurrentTest(), tx.Hash, clientTx.Hash) var txResult xdr.TransactionResult err = xdr.SafeUnmarshalBase64(clientTx.ResultXdr, &txResult) @@ -1031,7 +1117,7 @@ func assertInvokeHostFnSucceeds(itest *integration.Test, signer *keypair.Full, o invokeHostFunctionResult, ok := opResults[0].MustTr().GetInvokeHostFunctionResult() assert.True(itest.CurrentTest(), ok) assert.Equal(itest.CurrentTest(), invokeHostFunctionResult.Code, xdr.InvokeHostFunctionResultCodeInvokeHostFunctionSuccess) - return invokeHostFunctionResult.Success + return invokeHostFunctionResult.Success, tx.Hash } func stellarAssetContractID(itest *integration.Test, asset xdr.Asset) xdr.Hash { diff --git a/staticcheck.sh b/staticcheck.sh index 139ccb6e45..7e2eb41f28 100755 --- a/staticcheck.sh +++ b/staticcheck.sh @@ -1,7 +1,7 @@ #! /bin/bash set -e -version='2022.1' +version='2023.1' staticcheck='go run honnef.co/go/tools/cmd/staticcheck@'"$version" diff --git a/support/contractevents/burn.go b/support/contractevents/burn.go index 508f18bacb..f030c99573 100644 --- a/support/contractevents/burn.go +++ b/support/contractevents/burn.go @@ -42,7 +42,11 @@ func (event *BurnEvent) parse(topics xdr.ScVec, value xdr.ScVal) error { return ErrNotBurnEvent } - event.From = MustScAddressToString(from) + var err error + event.From, err = from.String() + if err != nil { + return errors.Wrap(err, ErrNotBurnEvent.Error()) + } amount := parseAmount(&value) if amount == nil { diff --git a/support/contractevents/utils.go b/support/contractevents/utils.go index 18295b32ae..1769137beb 100644 --- a/support/contractevents/utils.go +++ b/support/contractevents/utils.go @@ -1,52 +1,12 @@ 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...), panicking on any -// error. If the address is a nil pointer, this returns the empty string. -func MustScAddressToString(address *xdr.ScAddress) string { - str, err := ScAddressToString(address) - if err != nil { - panic(err) - } - return str -} - -func ScAddressToString(address *xdr.ScAddress) (string, error) { - if address == nil { - return "", nil - } - - 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: - return "", fmt.Errorf("unfamiliar address type: %v", address.Type) - } - - if err != nil { - return "", err - } - - return result, nil -} - func parseAddress(val *xdr.ScVal) *xdr.ScAddress { if val == nil { return nil @@ -87,7 +47,15 @@ func parseBalanceChangeEvent(topics xdr.ScVec, value xdr.ScVal) (string, string, return first, second, amount, ErrNotBalanceChangeEvent } - first, second = MustScAddressToString(firstSc), MustScAddressToString(secondSc) + first, err := firstSc.String() + if err != nil { + return first, second, amount, errors.Wrap(err, ErrNotBalanceChangeEvent.Error()) + } + + second, err = secondSc.String() + if err != nil { + return first, second, amount, errors.Wrap(err, ErrNotBalanceChangeEvent.Error()) + } amountPtr := parseAmount(&value) if amountPtr == nil { diff --git a/xdr/scval.go b/xdr/scval.go index 7bf31e2f1d..0758445d3f 100644 --- a/xdr/scval.go +++ b/xdr/scval.go @@ -1,11 +1,38 @@ package xdr -import "bytes" +import ( + "bytes" + "fmt" + + "github.com/stellar/go/strkey" +) func (s Int128Parts) Equals(o Int128Parts) bool { return s.Lo == o.Lo && s.Hi == o.Hi } +func (address ScAddress) String() (string, error) { + var result string + var err error + + switch address.Type { + case ScAddressTypeScAddressTypeAccount: + pubkey := address.MustAccountId().Ed25519 + result, err = strkey.Encode(strkey.VersionByteAccountID, pubkey[:]) + case ScAddressTypeScAddressTypeContract: + contractId := *address.ContractId + result, err = strkey.Encode(strkey.VersionByteContract, contractId[:]) + default: + return "", fmt.Errorf("unfamiliar address type: %v", address.Type) + } + + if err != nil { + return "", err + } + + return result, nil +} + func (s ScContractCode) Equals(o ScContractCode) bool { if s.Type != o.Type { return false