From fb4e83e6a39e4f8e22d0729d4bcda3912e4f4141 Mon Sep 17 00:00:00 2001 From: MichelMajdalani Date: Sun, 21 Aug 2022 19:06:37 -0700 Subject: [PATCH 1/2] WIP: Test failing because returning invalid input for valid input --- go.mod | 1 + go.sum | 2 + lib/block_view.go | 24 ++ lib/block_view_flush.go | 50 +++ lib/block_view_reaction.go | 294 +++++++++++++ lib/block_view_reaction_test.go | 726 ++++++++++++++++++++++++++++++++ lib/block_view_types.go | 69 ++- lib/blockchain.go | 54 +++ lib/db_utils.go | 220 +++++++++- lib/errors.go | 4 + lib/mempool.go | 28 ++ lib/network.go | 97 ++++- lib/network_test.go | 58 +++ lib/notifier.go | 15 + lib/postgres.go | 100 +++++ 15 files changed, 1736 insertions(+), 6 deletions(-) create mode 100644 lib/block_view_reaction.go create mode 100644 lib/block_view_reaction_test.go diff --git a/go.mod b/go.mod index d409be68c..50690f694 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( golang.org/x/mod v0.4.2 // indirect golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect + golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect golang.org/x/tools v0.1.0 // indirect gopkg.in/DataDog/dd-trace-go.v1 v1.29.0 diff --git a/go.sum b/go.sum index 7b814c22c..d65c9ef92 100644 --- a/go.sum +++ b/go.sum @@ -665,6 +665,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE= diff --git a/lib/block_view.go b/lib/block_view.go index 20eb61b9f..158f4c2c2 100644 --- a/lib/block_view.go +++ b/lib/block_view.go @@ -64,6 +64,9 @@ type UtxoView struct { // Like data LikeKeyToLikeEntry map[LikeKey]*LikeEntry + // React data + ReactionKeyToReactionEntry map[ReactionKey]*ReactionEntry + // Repost data RepostKeyToRepostEntry map[RepostKey]*RepostEntry @@ -145,6 +148,9 @@ func (bav *UtxoView) _ResetViewMappingsAfterFlush() { // Like data bav.LikeKeyToLikeEntry = make(map[LikeKey]*LikeEntry) + // React data + bav.ReactionKeyToReactionEntry = make(map[ReactionKey]*ReactionEntry) + // Repost data bav.RepostKeyToRepostEntry = make(map[RepostKey]*RepostEntry) @@ -281,6 +287,16 @@ func (bav *UtxoView) CopyUtxoView() (*UtxoView, error) { newView.LikeKeyToLikeEntry[likeKey] = &newLikeEntry } + // Copy the react data + newView.ReactionKeyToReactionEntry = make(map[ReactionKey]*ReactionEntry, len(bav.ReactionKeyToReactionEntry)) + for reactKey, reactEntry := range bav.ReactionKeyToReactionEntry { + if reactEntry == nil { + continue + } + newReactEntry := *reactEntry + newView.ReactionKeyToReactionEntry[reactKey] = &newReactEntry + } + // Copy the repost data newView.RepostKeyToRepostEntry = make(map[RepostKey]*RepostEntry, len(bav.RepostKeyToRepostEntry)) for repostKey, repostEntry := range bav.RepostKeyToRepostEntry { @@ -947,6 +963,10 @@ func (bav *UtxoView) DisconnectTransaction(currentTxn *MsgDeSoTxn, txnHash *Bloc return bav._disconnectLike( OperationTypeLike, currentTxn, txnHash, utxoOpsForTxn, blockHeight) + } else if currentTxn.TxnMeta.GetTxnType() == TxnTypeReact { + return bav._disconnectReact( + OperationTypeReact, currentTxn, txnHash, utxoOpsForTxn, blockHeight) + } else if currentTxn.TxnMeta.GetTxnType() == TxnTypeCreatorCoin { return bav._disconnectCreatorCoin( OperationTypeCreatorCoin, currentTxn, txnHash, utxoOpsForTxn, blockHeight) @@ -2257,6 +2277,10 @@ func (bav *UtxoView) _connectTransaction(txn *MsgDeSoTxn, txHash *BlockHash, totalInput, totalOutput, utxoOpsForTxn, err = bav._connectLike(txn, txHash, blockHeight, verifySignatures) + } else if txn.TxnMeta.GetTxnType() == TxnTypeReact { + totalInput, totalOutput, utxoOpsForTxn, err = + bav._connectReact(txn, txHash, blockHeight, verifySignatures) + } else if txn.TxnMeta.GetTxnType() == TxnTypeCreatorCoin { totalInput, totalOutput, utxoOpsForTxn, err = bav._connectCreatorCoin( diff --git a/lib/block_view_flush.go b/lib/block_view_flush.go index adf1bfcc4..b1de080b8 100644 --- a/lib/block_view_flush.go +++ b/lib/block_view_flush.go @@ -66,6 +66,9 @@ func (bav *UtxoView) FlushToDbWithTxn(txn *badger.Txn, blockHeight uint64) error if err := bav._flushLikeEntriesToDbWithTxn(txn); err != nil { return err } + if err := bav._flushReactEntriesToDbWithTxn(txn); err != nil { + return err + } if err := bav._flushFollowEntriesToDbWithTxn(txn); err != nil { return err } @@ -411,6 +414,53 @@ func (bav *UtxoView) _flushLikeEntriesToDbWithTxn(txn *badger.Txn) error { return nil } +func (bav *UtxoView) _flushReactEntriesToDbWithTxn(txn *badger.Txn) error { + + // Go through all the entries in the ReactionKeyToReactionEntry map. + for reactKeyIter, reactEntry := range bav.ReactionKeyToReactionEntry { + // Make a copy of the iterator since we make references to it below. + reactKey := reactKeyIter + + // Sanity-check that the ReactKey computed from the ReactEntry is + // equal to the ReactKey that maps to that entry. + reactKeyInEntry := MakeReactionKey(reactEntry.ReactorPubKey, *reactEntry.ReactedPostHash, reactEntry.ReactEmoji) + if reactKeyInEntry != reactKey { + return fmt.Errorf("_flushReactEntriesToDbWithTxn: ReactEntry has "+ + "ReactKey: %v, which doesn't match the ReactKeyToReactEntry map key %v", + &reactKeyInEntry, &reactKey) + } + + // Delete the existing mappings in the db for this ReactKey. They will be re-added + // if the corresponding entry in memory has isDeleted=false. + if err := DbDeleteReactMappingsWithTxn( + txn, reactKey.ReactorPubKey[:], reactKey.ReactedPostHash, reactKey.ReactEmoji); err != nil { + + return errors.Wrapf( + err, "_flushReactEntriesToDbWithTxn: Problem deleting mappings "+ + "for LikeKey: %v: ", &reactKey) + } + } + + // Go through all the entries in the LikeKeyToLikeEntry map. + for _, reactEntry := range bav.ReactionKeyToReactionEntry { + + if reactEntry.isDeleted { + // If the LikeEntry has isDeleted=true then there's nothing to do because + // we already deleted the entry above. + } else { + // If the LikeEntry has (isDeleted = false) then we put the corresponding + // mappings for it into the db. + if err := DbPutReactMappingsWithTxn( + txn, reactEntry.ReactorPubKey, *reactEntry.ReactedPostHash, reactEntry.ReactEmoji); err != nil { + + return err + } + } + } + + return nil +} + func (bav *UtxoView) _flushFollowEntriesToDbWithTxn(txn *badger.Txn) error { // Go through all the entries in the FollowKeyToFollowEntry map. diff --git a/lib/block_view_reaction.go b/lib/block_view_reaction.go new file mode 100644 index 000000000..7bc67d877 --- /dev/null +++ b/lib/block_view_reaction.go @@ -0,0 +1,294 @@ +package lib + +import ( + "fmt" + "github.com/btcsuite/btcd/btcec" + "github.com/golang/glog" + "github.com/pkg/errors" + "reflect" +) + +func (bav *UtxoView) _getReactionEntryForReactionKey(reactionKey *ReactionKey) *ReactionEntry { + // If an entry exists in the in-memory map, return the value of that mapping. + mapValue, existsMapValue := bav.ReactionKeyToReactionEntry[*reactionKey] + if existsMapValue { + return mapValue + } + + // If we get here it means no value exists in our in-memory map. In this case, + // defer to the db. If a mapping exists in the db, return it. If not, return + // nil. Either way, save the value to the in-memory view mapping got later. + reactionExists := false + if bav.Postgres != nil { + reactionExists = bav.Postgres.GetReaction(reactionKey.ReactorPubKey[:], &reactionKey.ReactedPostHash, reactionKey.ReactEmoji) != nil + } else { + reactionExists = DbGetReactorPubKeyToPostHashMapping(bav.Handle, reactionKey.ReactorPubKey[:], reactionKey.ReactedPostHash, reactionKey.ReactEmoji) != nil + } + + if reactionExists { + reactionEntry := ReactionEntry{ + ReactorPubKey: reactionKey.ReactorPubKey[:], + ReactedPostHash: &reactionKey.ReactedPostHash, + ReactEmoji: reactionKey.ReactEmoji, + } + bav._setReactionEntryMappings(&reactionEntry) + return &reactionEntry + } + + return nil +} + +func (bav *UtxoView) _setReactionEntryMappings(reactionEntry *ReactionEntry) { + // This function shouldn't be called with nil. + if reactionEntry == nil { + glog.Errorf("_setReactionEntryMappings: Called with nil ReactionEntry; " + + "this should never happen.") + return + } + + reactionKey := MakeReactionKey(reactionEntry.ReactorPubKey, *reactionEntry.ReactedPostHash, reactionEntry.ReactEmoji) + bav.ReactionKeyToReactionEntry[reactionKey] = reactionEntry +} + +func (bav *UtxoView) _deleteReactionEntryMappings(reactionEntry *ReactionEntry) { + + // Create a tombstone entry. + tombstoneReactionEntry := *reactionEntry + tombstoneReactionEntry.isDeleted = true + + // Set the mappings to point to the tombstone entry. + bav._setReactionEntryMappings(&tombstoneReactionEntry) +} + +func (bav *UtxoView) GetReactionByReader(readerPK []byte, postHash *BlockHash, reactEmoji rune) bool { + // Get react state. + reactionKey := MakeReactionKey(readerPK, *postHash, reactEmoji) + reactionEntry := bav._getReactionEntryForReactionKey(&reactionKey) + return reactionEntry != nil && !reactionEntry.isDeleted +} + +//TODO and only update the view if the key constructed from the entry does not exist in the view yet. Otherwise, we risk updating entries in the view +// that haven't been flushed. +func (bav *UtxoView) GetReactorsForPostHash(postHash *BlockHash, reactionEmoji rune) (_ReactorPubKeys [][]byte, _err error) { + adapter := bav.GetDbAdapter() + + if adapter.postgresDb != nil { + reactions := adapter.postgresDb.GetReactionsForPost(postHash) + for _, reaction := range reactions { + bav._setReactionEntryMappings(reaction.NewReactionEntry()) + } + } else { + handle := adapter.badgerDb + dbPrefix := append([]byte{}, Prefixes.PrefixPostHashToReactorPubKey...) + dbPrefix = append(dbPrefix, postHash[:]...) + keysFound, _ := EnumerateKeysForPrefix(handle, dbPrefix) + + // Iterate over all the db keys & values and load them into the view. + expectedKeyLength := 1 + HashSizeBytes + btcec.PubKeyBytesLenCompressed + for _, key := range keysFound { + // Sanity check that this is a reasonable key. + if len(key) != expectedKeyLength { + return nil, fmt.Errorf("UtxoView.GetReactuibsForPostHash: Invalid key length found: %d", len(key)) + } + + reactorPubKey := key[1+HashSizeBytes:] + reactKey := MakeReactionKey(reactorPubKey, *postHash, reactionEmoji) + bav._getReactionEntryForReactionKey(&reactKey) + } + } + + // Iterate over the view and create the final list to return. + var reactorPubKeys [][]byte + for _, reactionEntry := range bav.ReactionKeyToReactionEntry { + if !reactionEntry.isDeleted && reflect.DeepEqual(reactionEntry.ReactedPostHash[:], postHash[:]) { + reactorPubKeys = append(reactorPubKeys, reactionEntry.ReactorPubKey) + } + } + + return reactorPubKeys, nil +} + +func (bav *UtxoView) _connectReact( + txn *MsgDeSoTxn, txHash *BlockHash, blockHeight uint32, verifySignatures bool) ( + _totalInput uint64, _totalOutput uint64, _utxoOps []*UtxoOperation, _err error) { + + // Check that the transaction has the right TxnType. + if txn.TxnMeta.GetTxnType() != TxnTypeReact { + return 0, 0, nil, fmt.Errorf("_connectReact: called with bad TxnType %s", + txn.TxnMeta.GetTxnType().String()) + } + txMeta := txn.TxnMeta.(*ReactMetadata) + + // Connect basic txn to get the total input and the total output without + // considering the transaction metadata. + totalInput, totalOutput, utxoOpsForTxn, err := bav._connectBasicTransfer( + txn, txHash, blockHeight, verifySignatures) + if err != nil { + return 0, 0, nil, errors.Wrapf(err, "_connectReact: ") + } + + if verifySignatures { + // _connectBasicTransfer has already checked that the transaction is + // signed by the top-level public key, which we take to be the sender's + // public key so there is no need to verify anything further. + } + + // At this point the inputs and outputs have been processed. Now we need to handle + // the metadata. + + // There are two main checks that need to be done before allowing a reaction: + // - Check that the post exists + // - Check that the person hasn't already reacted with the same emoji + + // Check that the post to react actually exists. + existingPostEntry := bav.GetPostEntryForPostHash(txMeta.PostHash) + if existingPostEntry == nil || existingPostEntry.isDeleted { + return 0, 0, nil, errors.Wrapf( + RuleErrorCannotReactNonexistentPost, + "_connectReact: Post hash: %v", txMeta.PostHash) + } + + // At this point the code diverges and considers the react flows differently + // since the presence of an existing react entry has a different effect in either case. + + reactionKey := MakeReactionKey(txn.PublicKey, *txMeta.PostHash, txMeta.EmojiReaction) + existingReactEntry := bav._getReactionEntryForReactionKey(&reactionKey) + // We don't need to make a copy of the post entry because all we're modifying is the emoji counts, + // which isn't stored in any of our mappings. But we make a copy here just because it's a little bit + // more foolproof. + updatedPostEntry := *existingPostEntry + + if txMeta.IsRemove { + // Ensure that there *is* an existing emoji entry to delete. + if existingReactEntry == nil || existingReactEntry.isDeleted { + return 0, 0, nil, errors.Wrapf( + RuleErrorCannotRemoveReactionWithoutAnExistingReaction, + "_connectReact: React key: %v", &reactionKey) + } + + // Now that we know there is a react entry, we delete it and decrement the emoji count. + bav._deleteReactionEntryMappings(existingReactEntry) + updatedPostEntry.EmojiCount[txMeta.EmojiReaction] -= 1 + } else { + // Ensure that there *is not* an existing react entry. + if existingReactEntry != nil && !existingReactEntry.isDeleted { + return 0, 0, nil, errors.Wrapf( + RuleErrorReactEntryAlreadyExists, + "_connectReact: Like key: %v", &reactionKey) + } + + // Now that we know there is no pre-existing reactentry, we can create one and + // increment the react s on the react d post. + reactEntry := &ReactionEntry{ + ReactorPubKey: txn.PublicKey, + ReactedPostHash: txMeta.PostHash, + ReactEmoji: txMeta.EmojiReaction, + } + bav._setReactionEntryMappings(reactEntry) + if updatedPostEntry.EmojiCount == nil { + updatedPostEntry.EmojiCount = make(map[rune]uint64) + } + updatedPostEntry.EmojiCount[txMeta.EmojiReaction] += 1 + } + + // Set the updated post entry so it has the new emoji count. + bav._setPostEntryMappings(&updatedPostEntry) + + // Add an operation to the list at the end indicating we've added a follow. + utxoOpsForTxn = append(utxoOpsForTxn, &UtxoOperation{ + Type: OperationTypeReact, + PrevReactEntry: existingReactEntry, + PrevEmojiCount: existingPostEntry.EmojiCount, + }) + + return totalInput, totalOutput, utxoOpsForTxn, nil +} + +func (bav *UtxoView) _disconnectReact( + operationType OperationType, currentTxn *MsgDeSoTxn, txnHash *BlockHash, + utxoOpsForTxn []*UtxoOperation, blockHeight uint32) error { + + // Verify that the last operation is a Reaction operation + if len(utxoOpsForTxn) == 0 { + return fmt.Errorf("_disconnectReact: utxoOperations are missing") + } + operationIndex := len(utxoOpsForTxn) - 1 + if utxoOpsForTxn[operationIndex].Type != OperationTypeReact { + return fmt.Errorf("_disconnectReact: Trying to revert "+ + "OperationTypeReact but found type %v", + utxoOpsForTxn[operationIndex].Type) + } + + // Now we know the txMeta is a React + txMeta := currentTxn.TxnMeta.(*ReactMetadata) + + // Before we do anything, let's get the post so we can adjust the emoji map counter later. + reactedPostEntry := bav.GetPostEntryForPostHash(txMeta.PostHash) + if reactedPostEntry == nil { + return fmt.Errorf("_disconnectReact: Error getting post: %v", txMeta.PostHash) + } + + // Here we diverge and consider the react and unreact cases separately. + if txMeta.IsRemove { + // If this is an remove we just need to add back the previous react entry and react + // react count. We do some sanity checks first though to be extra safe. + + prevReactEntry := utxoOpsForTxn[operationIndex].PrevReactEntry + // Sanity check: verify that the user on the reactEntry matches the transaction sender. + if !reflect.DeepEqual(prevReactEntry.ReactorPubKey, currentTxn.PublicKey) { + return fmt.Errorf("_disconnectReact: User public key on "+ + "ReactionEntry was %s but the PublicKey on the txn was %s", + PkToStringBoth(prevReactEntry.ReactorPubKey), + PkToStringBoth(currentTxn.PublicKey)) + } + + // Sanity check: verify that the post hash on the prevReactEntry matches the transaction's. + if !reflect.DeepEqual(prevReactEntry.ReactedPostHash, txMeta.PostHash) { + return fmt.Errorf("_disconnectLike: Liked post hash on "+ + "ReactionEntry was %s but the ReactedPostHash on the txn was %s", + prevReactEntry.ReactedPostHash, txMeta.PostHash) + } + + // Set the react entry and react count to their previous state. + bav._setReactionEntryMappings(prevReactEntry) + reactedPostEntry.EmojiCount = utxoOpsForTxn[operationIndex].PrevEmojiCount + bav._setPostEntryMappings(reactedPostEntry) + } else { + // If this is a normal "react," we do some sanity checks and then delete the entry. + + // Get the ReactionEntry. If we don't find it or isDeleted=true, that's an error. + reactKey := MakeReactionKey(currentTxn.PublicKey, *txMeta.PostHash, txMeta.EmojiReaction) + reactEntry := bav._getReactionEntryForReactionKey(&reactKey) + if reactEntry == nil || reactEntry.isDeleted { + return fmt.Errorf("_disconnectReact: ReactionEntry for "+ + "reactKey %v was found to be nil or isDeleted not set appropriately: %v", + &reactKey, reactEntry) + } + + // Sanity check: verify that the user on the reactEntry matches the transaction sender. + if !reflect.DeepEqual(reactEntry.ReactorPubKey, currentTxn.PublicKey) { + return fmt.Errorf("_disconnectReact: User public key on "+ + "ReactionEntry was %s but the PublicKey on the txn was %s", + PkToStringBoth(reactEntry.ReactorPubKey), + PkToStringBoth(currentTxn.PublicKey)) + } + + // Sanity check: verify that the post hash on the reactEntry matches the transaction's. + if !reflect.DeepEqual(reactEntry.ReactedPostHash, txMeta.PostHash) { + return fmt.Errorf("_disconnectReact: Reacted post hash on "+ + "ReactionEntry was %s but the ReactedPostHash on the txn was %s", + reactEntry.ReactedPostHash, txMeta.PostHash) + } + + // Now that we're confident the FollowEntry lines up with the transaction we're + // rolling back, delete the mappings and set the reaction counter to its previous value. + bav._deleteReactionEntryMappings(reactEntry) + reactedPostEntry.EmojiCount = utxoOpsForTxn[operationIndex].PrevEmojiCount + bav._setPostEntryMappings(reactedPostEntry) + } + + // Now revert the basic transfer with the remaining operations. Cut off + // the Like operation at the end since we just reverted it. + return bav._disconnectBasicTransfer( + currentTxn, txnHash, utxoOpsForTxn[:operationIndex], blockHeight) +} diff --git a/lib/block_view_reaction_test.go b/lib/block_view_reaction_test.go new file mode 100644 index 000000000..47b6ce669 --- /dev/null +++ b/lib/block_view_reaction_test.go @@ -0,0 +1,726 @@ +package lib + +import ( + "fmt" + "github.com/stretchr/testify/require" + "golang.org/x/text/unicode/norm" + "testing" +) + +var ( + HappyReaction = rune(norm.NFC.String(string('😊'))[0]) + SadReaction = rune(norm.NFC.String(string('😥'))[0]) + AngryReaction = rune(norm.NFC.String(string('😠'))[0]) + SurprisedReaction = rune(norm.NFC.String(string('😮'))[0]) +) + +func _doReactTxn(testMeta *TestMeta, feeRateNanosPerKB uint64, senderPkBase58Check string, + postHash BlockHash, senderPrivBase58Check string, isRemove bool, emojiReaction rune) ( + _utxoOps []*UtxoOperation, _txn *MsgDeSoTxn, _height uint32, _err error) { + + require := require.New(testMeta.t) + + senderPkBytes, _, err := Base58CheckDecode(senderPkBase58Check) + require.NoError(err) + + utxoView, err := NewUtxoView(testMeta.db, testMeta.params, testMeta.chain.postgres, testMeta.chain.snapshot) + require.NoError(err) + + txn, totalInputMake, changeAmountMake, feesMake, err := testMeta.chain.CreateReactTxn( + senderPkBytes, postHash, isRemove, emojiReaction, feeRateNanosPerKB, nil, []*DeSoOutput{}) + if err != nil { + return nil, nil, 0, err + } + + require.Equal(totalInputMake, changeAmountMake+feesMake) + + // Sign the transaction now that its inputs are set up. + _signTxn(testMeta.t, txn, senderPrivBase58Check) + + txHash := txn.Hash() + // Always use height+1 for validation since it's assumed the transaction will + // get mined into the next block. + blockHeight := testMeta.chain.blockTip().Height + 1 + utxoOps, totalInput, totalOutput, fees, err := + utxoView.ConnectTransaction(txn, txHash, getTxnSize(*txn), blockHeight, true, /*verifySignature*/ + false /*ignoreUtxos*/) + // ConnectTransaction should treat the amount locked as contributing to the + // output. + if err != nil { + return nil, nil, 0, err + } + require.Equal(totalInput, totalOutput+fees) + require.Equal(totalInput, totalInputMake) + + // We should have one SPEND UtxoOperation for each input, one ADD operation + // for each output, and one OperationTypeReact operation at the end. + require.Equal(len(txn.TxInputs)+len(txn.TxOutputs)+1, len(utxoOps)) + for ii := 0; ii < len(txn.TxInputs); ii++ { + require.Equal(OperationTypeSpendUtxo, utxoOps[ii].Type) + } + require.Equal(OperationTypeReact, utxoOps[len(utxoOps)-1].Type) + + require.NoError(utxoView.FlushToDb(0)) + + return utxoOps, txn, blockHeight, nil +} + +func TestReactTxns(t *testing.T) { + // Test constants + const feeRateNanosPerKb = uint64(101) + var err error + + //Initialize test chain and miner + chain, params, db := NewLowDifficultyBlockchain() + mempool, miner := NewTestMiner(t, chain, params, true /*isSender*/) + + // Mine a few blocks to give the senderPkString some money. + for ii := 0; ii < 20; ii++ { + _, err := miner.MineAndProcessSingleBlock(0 /*threadIndex*/, mempool) + require.NoError(t, err) + } + + // We build the testMeta obj after mining blocks so that we save the correct block height. + testMeta := &TestMeta{ + t: t, + chain: chain, + params: params, + db: db, + mempool: mempool, + miner: miner, + savedHeight: chain.blockTip().Height + 1, + } + + // Helpers + type User struct { + Pub string + Priv string + PkBytes []byte + PublicKey *PublicKey + Pkid *PKID + } + + //TODO Use this correctly + //deso := User{ + // PublicKey: &ZeroPublicKey, + // Pkid: &ZeroPKID, + //} + + m0 := User{ + Pub: m0Pub, + Priv: m0Priv, + PkBytes: m0PkBytes, + PublicKey: NewPublicKey(m0PkBytes), + Pkid: DBGetPKIDEntryForPublicKey(db, chain.snapshot, m0PkBytes).PKID, + } + + m1 := User{ + Pub: m1Pub, + Priv: m1Priv, + PkBytes: m1PkBytes, + PublicKey: NewPublicKey(m1PkBytes), + Pkid: DBGetPKIDEntryForPublicKey(db, chain.snapshot, m1PkBytes).PKID, + } + + m2 := User{ + Pub: m2Pub, + Priv: m2Priv, + PkBytes: m2PkBytes, + PublicKey: NewPublicKey(m2PkBytes), + Pkid: DBGetPKIDEntryForPublicKey(db, chain.snapshot, m2PkBytes).PKID, + } + + m3 := User{ + Pub: m3Pub, + Priv: m3Priv, + PkBytes: m3PkBytes, + PublicKey: NewPublicKey(m3PkBytes), + Pkid: DBGetPKIDEntryForPublicKey(db, chain.snapshot, m3PkBytes).PKID, + } + + // Setup some convenience functions for the test. + var txnOps [][]*UtxoOperation + var txns []*MsgDeSoTxn + var expectedSenderBalances []uint64 + var expectedRecipientBalances []uint64 + + // We take the block tip to be the blockchain height rather than the + // header chain height. + savedHeight := chain.blockTip().Height + 1 + + // Fund all the keys. + for ii := 0; ii < 5; ii++ { + _registerOrTransferWithTestMeta(testMeta, "m0", senderPkString, m0.Pub, senderPrivString, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1", senderPkString, m1.Pub, senderPrivString, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m2", senderPkString, m2.Pub, senderPrivString, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m3", senderPkString, m3.Pub, senderPrivString, 7e6) + } + + //TODO Fix this + //params.ExtraRegtestParamUpdaterKeys[MakePkMapKey(paramUpdaterPkBytes)] = true + // + //_updateGlobalParamsEntryWithTestMeta( + // testMeta, feeRateNanosPerKB, paramUpdaterPub, + // paramUpdaterPriv, -1, int64(feeRateNanosPerKb), -1, -1, -1, + //) + + doReactTxn := func( + senderPkBase58Check string, postHash BlockHash, + senderPrivBase58Check string, isRemove bool, emojiReaction rune, feeRateNanosPerKB uint64) { + + expectedSenderBalances = append( + expectedSenderBalances, _getBalance(t, chain, nil, senderPkString)) + expectedRecipientBalances = append( + expectedRecipientBalances, _getBalance(t, chain, nil, recipientPkString)) + + currentOps, currentTxn, _, err := _doReactTxn( + testMeta, feeRateNanosPerKB, senderPkBase58Check, + postHash, senderPrivBase58Check, isRemove, emojiReaction) + require.NoError(t, err) + + txnOps = append(txnOps, currentOps) + txns = append(txns, currentTxn) + } + + submitPost := func( + feeRateNanosPerKB uint64, updaterPkBase58Check string, + updaterPrivBase58Check string, + postHashToModify []byte, + parentStakeID []byte, + bodyObj *DeSoBodySchema, + repostedPostHash []byte, + tstampNanos uint64, + isHidden bool) { + + expectedSenderBalances = append( + expectedSenderBalances, _getBalance(t, chain, nil, senderPkString)) + expectedRecipientBalances = append( + expectedRecipientBalances, _getBalance(t, chain, nil, recipientPkString)) + + currentOps, currentTxn, _, err := _submitPost( + t, chain, db, params, feeRateNanosPerKB, + updaterPkBase58Check, + updaterPrivBase58Check, + postHashToModify, + parentStakeID, + bodyObj, + repostedPostHash, + tstampNanos, + isHidden) + + require.NoError(t, err) + + txnOps = append(txnOps, currentOps) + txns = append(txns, currentTxn) + } + + fakePostHash := BlockHash{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, + 0x30, 0x31, + } + // Attempting "m0 -> fakePostHash" should fail since the post doesn't exist. + _, _, _, err = _doReactTxn( + testMeta, 10 /*feeRateNanosPerKB*/, m0Pub, + fakePostHash, m0Priv, false /*isRemove*/, HappyReaction) + require.Error(t, err) + require.Contains(t, err.Error(), RuleErrorCannotReactNonexistentPost) + + // p1 + submitPost( + 10, /*feeRateNanosPerKB*/ + m0Pub, /*updaterPkBase58Check*/ + m0Priv, /*updaterPrivBase58Check*/ + []byte{}, /*postHashToModify*/ + []byte{}, /*parentStakeID*/ + &DeSoBodySchema{Body: "m0 post body 1 no profile"}, /*body*/ + []byte{}, + 1602947011*1e9, /*tstampNanos*/ + false /*isHidden*/) + post1Txn := txns[len(txns)-1] + post1Hash := *post1Txn.Hash() + + // p2 + { + submitPost( + 10, /*feeRateNanosPerKB*/ + m0Pub, /*updaterPkBase58Check*/ + m0Priv, /*updaterPrivBase58Check*/ + []byte{}, /*postHashToModify*/ + []byte{}, /*parentStakeID*/ + &DeSoBodySchema{Body: "m0 post body 2 no profile"}, /*body*/ + []byte{}, + 1502947012*1e9, /*tstampNanos*/ + false /*isHidden*/) + } + post2Txn := txns[len(txns)-1] + post2Hash := *post2Txn.Hash() + + // p3 + { + submitPost( + 10, /*feeRateNanosPerKB*/ + m1Pub, /*updaterPkBase58Check*/ + m1Priv, /*updaterPrivBase58Check*/ + []byte{}, /*postHashToModify*/ + []byte{}, /*parentStakeID*/ + &DeSoBodySchema{Body: "m1 post body 1 no profile"}, /*body*/ + []byte{}, + 1502947013*1e9, /*tstampNanos*/ + false /*isHidden*/) + } + post3Txn := txns[len(txns)-1] + post3Hash := *post3Txn.Hash() + + // m0 -> p1 (happy) + doReactTxn(m0Pub, post1Hash, m0Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // Duplicating "m0 -> p1" should fail. + _, _, _, err = _doReactTxn( + testMeta, 10 /*feeRateNanosPerKB*/, m0Pub, + post1Hash, m0Priv, false /*isRemove*/, HappyReaction) + require.Error(t, err) + require.Contains(t, err.Error(), RuleErrorReactEntryAlreadyExists) + + // m2 -> p1 (happy) + doReactTxn(m2Pub, post1Hash, m2Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // m3 -> p1 (surprised) + doReactTxn(m3Pub, post1Hash, m3Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // m3 -> p2 (sad) + doReactTxn(m3Pub, post2Hash, m3Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // m1 -> p2 (angry) + doReactTxn(m1Pub, post2Hash, m1Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // m2 -> p3 (surprised) + doReactTxn(m2Pub, post3Hash, m2Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + reactingP1 := [][]byte{ + _strToPk(t, m0Pub), + _strToPk(t, m2Pub), + _strToPk(t, m3Pub), + } + + reactingP2 := [][]byte{ + _strToPk(t, m1Pub), + _strToPk(t, m3Pub), + } + + reactingP3 := [][]byte{ + _strToPk(t, m2Pub), + } + + // Verify pks reacting p1 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post1Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP1), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP1, reactingPks[ii]) + } + post1 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post1Hash) + require.Equal(t, uint64(len(reactingP1)), post1.EmojiCount[HappyReaction]) + } + + // Verify pks reacting p2 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post2Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP2), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP2, reactingPks[ii]) + } + post2 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post2Hash) + require.Equal(t, uint64(len(reactingP2)), post2.EmojiCount[HappyReaction]) + } + + // Verify pks reacting p3 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post3Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP3), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP3, reactingPks[ii]) + } + post3 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post3Hash) + require.Equal(t, uint64(len(reactingP3)), post3.EmojiCount[HappyReaction]) + } + + m0Reacts := []BlockHash{ + post1Hash, + } + + m1Reacts := []BlockHash{ + post2Hash, + } + + m2Reacts := []BlockHash{ + post1Hash, + post3Hash, + } + + m3Reacts := []BlockHash{ + post1Hash, + post2Hash, + } + + // Verify m0's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m0Pub)) + require.NoError(t, err) + require.Equal(t, len(m0Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m0Reacts, *reactedPostHashes[ii]) + } + } + + // Verify m1's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m1Pub)) + require.NoError(t, err) + require.Equal(t, len(m1Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m1Reacts, *reactedPostHashes[ii]) + } + } + + // Verify m2's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m2Pub)) + require.NoError(t, err) + require.Equal(t, len(m2Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m2Reacts, *reactedPostHashes[ii]) + } + } + + // Verify m3's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m3Pub)) + require.NoError(t, err) + require.Equal(t, len(m3Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m3Reacts, *reactedPostHashes[ii]) + } + } + + // Try an removing a reaction. + // + // m0 -> p1 (unfollow, happy) + doReactTxn(m0Pub, post1Hash, m0Priv, true /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // m3 -> p2 (unfollow, happy) + doReactTxn(m3Pub, post2Hash, m3Priv, true /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // Duplicating "m0 -> p1" (unfollow) should fail. + _, _, _, err = _doReactTxn( + testMeta, 10 /*feeRateNanosPerKB*/, m0Pub, + post1Hash, m0Priv, true /*isRemove*/, HappyReaction) + require.Error(t, err) + require.Contains(t, err.Error(), RuleErrorCannotRemoveReactionWithoutAnExistingReaction) + + reactingP1 = [][]byte{ + _strToPk(t, m2Pub), + _strToPk(t, m3Pub), + } + + reactingP2 = [][]byte{ + _strToPk(t, m1Pub), + } + + // Verify pks reacting p1 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post1Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP1), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP1, reactingPks[ii]) + } + post1 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post1Hash) + require.Equal(t, uint64(len(reactingP1)), post1.EmojiCount[HappyReaction]) + } + + // Verify pks reacting p2 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post2Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP2), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP2, reactingPks[ii]) + } + post2 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post2Hash) + require.Equal(t, uint64(len(reactingP2)), post2.EmojiCount[HappyReaction]) + } + + m3Reacts = []BlockHash{ + post1Hash, + } + + // Verify m0 has no reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m0Pub)) + require.NoError(t, err) + require.Equal(t, 0, len(reactedPostHashes)) + } + + // Verify m3's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m3Pub)) + require.NoError(t, err) + require.Equal(t, len(m3Reacts), len(reactedPostHashes)) + for i := 0; i < len(reactedPostHashes); i++ { + require.Contains(t, m3Reacts, *reactedPostHashes[i]) + } + } + + // =================================================================================== + // Finish it off with some transactions + // =================================================================================== + _registerOrTransferWithTestMeta(testMeta, "m0", senderPkString, m0.Pub, senderPrivString, 42e6) + _registerOrTransferWithTestMeta(testMeta, "m1", senderPkString, m1.Pub, senderPrivString, 42e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + + // Roll back all of the above using the utxoOps from each. + for ii := 0; ii < len(txnOps); ii++ { + backwardIter := len(txnOps) - 1 - ii + currentOps := txnOps[backwardIter] + currentTxn := txns[backwardIter] + fmt.Printf( + "Disconnecting transaction with type %v index %d (going backwards)\n", + currentTxn.TxnMeta.GetTxnType(), backwardIter) + + utxoView, err := NewUtxoView(testMeta.db, testMeta.params, testMeta.chain.postgres, testMeta.chain.snapshot) + require.NoError(t, err) + + currentHash := currentTxn.Hash() + err = utxoView.DisconnectTransaction(currentTxn, currentHash, currentOps, savedHeight) + require.NoError(t, err) + + require.NoError(t, utxoView.FlushToDb(0)) + + // After disconnecting, the balances should be restored to what they + // were before this transaction was applied. + require.Equal(t, + int64(expectedSenderBalances[backwardIter]), + int64(_getBalance(t, chain, nil, senderPkString))) + require.Equal(t, + expectedRecipientBalances[backwardIter], + _getBalance(t, chain, nil, recipientPkString)) + + // Here we check the reactcounts after all the reactentries have been disconnected. + if backwardIter == 19 { + post1 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post1Hash) + require.Equal(t, uint64(0), post1.EmojiCount[HappyReaction]) + post2 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post2Hash) + require.Equal(t, uint64(0), post2.EmojiCount[HappyReaction]) + post3 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post3Hash) + require.Equal(t, uint64(0), post3.EmojiCount[HappyReaction]) + } + } + + _executeAllTestRollbackAndFlush(testMeta) + + // Apply all the transactions to a mempool object and make sure we don't get any + // errors. Verify the balances align as we go. + for ii, tx := range txns { + // See comment above on this transaction. + fmt.Printf("Adding txn %d of type %v to mempool\n", ii, tx.TxnMeta.GetTxnType()) + + require.Equal(t, expectedSenderBalances[ii], _getBalance(t, chain, mempool, senderPkString)) + require.Equal(t, expectedRecipientBalances[ii], _getBalance(t, chain, mempool, recipientPkString)) + + _, err := mempool.ProcessTransaction(tx, false, false, 0, true) + require.NoError(t, err, "Problem adding transaction %d to mempool: %v", ii, tx) + } + + // Apply all the transactions to a view and flush the view to the db. + utxoView, err := NewUtxoView(testMeta.db, testMeta.params, testMeta.chain.postgres, testMeta.chain.snapshot) + require.NoError(t, err) + for ii, txn := range txns { + fmt.Printf("Adding txn %v of type %v to UtxoView\n", ii, txn.TxnMeta.GetTxnType()) + + // Always use height+1 for validation since it's assumed the transaction will + // get mined into the next block. + txHash := txn.Hash() + blockHeight := chain.blockTip().Height + 1 + _, _, _, _, err := + utxoView.ConnectTransaction(txn, txHash, getTxnSize(*txn), blockHeight, true /*verifySignature*/, false /*ignoreUtxos*/) + require.NoError(t, err) + } + // Flush the utxoView after having added all the transactions. + require.NoError(t, utxoView.FlushToDb(0)) + + testConnectedState := func() { + reactingP1 = [][]byte{ + _strToPk(t, m2Pub), + _strToPk(t, m3Pub), + } + + reactingP2 = [][]byte{ + _strToPk(t, m1Pub), + } + + reactingP3 := [][]byte{ + _strToPk(t, m2Pub), + } + + // Verify pks reacting p1 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post1Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP1), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP1, reactingPks[ii]) + } + post1 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post1Hash) + require.Equal(t, uint64(len(reactingP1)), post1.EmojiCount[HappyReaction]) + } + + // Verify pks reacting p2 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post2Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP2), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP2, reactingPks[ii]) + } + post2 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post2Hash) + require.Equal(t, uint64(len(reactingP2)), post2.EmojiCount[HappyReaction]) + } + + // Verify pks reacting p3 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post3Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP3), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP3, reactingPks[ii]) + } + post3 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post3Hash) + require.Equal(t, uint64(len(reactingP3)), post3.EmojiCount[HappyReaction]) + } + + m1Reacts := []BlockHash{ + post2Hash, + } + + m2Reacts := []BlockHash{ + post1Hash, + post3Hash, + } + + m3Reacts = []BlockHash{ + post1Hash, + } + + // Verify m0 has no reactions. + { + followPks, err := DbGetPostHashesYouReact(db, _strToPk(t, m0Pub)) + require.NoError(t, err) + require.Equal(t, 0, len(followPks)) + } + + // Verify m1's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m1Pub)) + require.NoError(t, err) + require.Equal(t, len(m1Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m1Reacts, *reactedPostHashes[ii]) + } + } + + // Verify m2's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m2Pub)) + require.NoError(t, err) + require.Equal(t, len(m2Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m2Reacts, *reactedPostHashes[ii]) + } + } + + // Verify m3's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m3Pub)) + require.NoError(t, err) + require.Equal(t, len(m3Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m3Reacts, *reactedPostHashes[ii]) + } + } + } + testConnectedState() + + // Disconnect the transactions from a single view in the same way as above + // i.e. without flushing each time. + utxoView2, err := NewUtxoView(testMeta.db, testMeta.params, testMeta.chain.postgres, testMeta.chain.snapshot) + require.NoError(t, err) + for ii := 0; ii < len(txnOps); ii++ { + backwardIter := len(txnOps) - 1 - ii + fmt.Printf("Disconnecting transaction with index %d (going backwards)\n", backwardIter) + currentOps := txnOps[backwardIter] + currentTxn := txns[backwardIter] + + currentHash := currentTxn.Hash() + err = utxoView2.DisconnectTransaction(currentTxn, currentHash, currentOps, savedHeight) + require.NoError(t, err) + } + require.NoError(t, utxoView2.FlushToDb(0)) + require.Equal(t, expectedSenderBalances[0], _getBalance(t, chain, nil, senderPkString)) + require.Equal(t, expectedRecipientBalances[0], _getBalance(t, chain, nil, recipientPkString)) + + _executeAllTestRollbackAndFlush(testMeta) + + // All the txns should be in the mempool already so mining a block should put + // all those transactions in it. + block, err := miner.MineAndProcessSingleBlock(0 /*threadIndex*/, mempool) + require.NoError(t, err) + // Add one for the block reward. Now we have a meaty block. + require.Equal(t, len(txnOps)+1, len(block.Txns)) + // Estimate the transaction fees of the tip block in various ways. + { + // Threshold above what's in the block should return the default fee at all times. + require.Equal(t, int64(0), int64(chain.EstimateDefaultFeeRateNanosPerKB(.1, 0))) + require.Equal(t, int64(7), int64(chain.EstimateDefaultFeeRateNanosPerKB(.1, 7))) + // Threshold below what's in the block should return the max of the median + // and the minfee. This means with a low minfee the value returned should be + // higher. And with a high minfee the value returned should be equal to the + // fee. + require.Equal(t, int64(7), int64(chain.EstimateDefaultFeeRateNanosPerKB(0, 7))) + require.Equal(t, int64(4), int64(chain.EstimateDefaultFeeRateNanosPerKB(0, 0))) + require.Equal(t, int64(7), int64(chain.EstimateDefaultFeeRateNanosPerKB(.01, 7))) + require.Equal(t, int64(4), int64(chain.EstimateDefaultFeeRateNanosPerKB(.01, 1))) + } + + testConnectedState() + + _executeAllTestRollbackAndFlush(testMeta) +} + +// func TestReactTxns +// - one successful happy, sad, angry, confused +// - one failure (invalid character?, not amongst the other characters) + +// func _createReactTxn +// func _connectReactTxn +// func _doReactTxnWithTestMeta +// func _doReactRxnErrorToBeDefined +// func Eq +// func ToEntry +// func TestFlushingReactTxn diff --git a/lib/block_view_types.go b/lib/block_view_types.go index 4700f9129..96f5179d0 100644 --- a/lib/block_view_types.go +++ b/lib/block_view_types.go @@ -79,6 +79,7 @@ const ( EncoderTypeMessagingGroupMember EncoderTypeForbiddenPubKeyEntry EncoderTypeLikeEntry + EncoderTypeReactEntry EncoderTypeNFTEntry EncoderTypeNFTBidEntry EncoderTypeNFTBidEntryBundle @@ -118,6 +119,7 @@ const ( EncoderTypeUpdateProfileTxindexMetadata EncoderTypeSubmitPostTxindexMetadata EncoderTypeLikeTxindexMetadata + EncoderTypeReactTxindexMetadata EncoderTypeFollowTxindexMetadata EncoderTypePrivateMessageTxindexMetadata EncoderTypeSwapIdentityTxindexMetadata @@ -225,6 +227,8 @@ func (encoderType EncoderType) New() DeSoEncoder { return &SubmitPostTxindexMetadata{} case EncoderTypeLikeTxindexMetadata: return &LikeTxindexMetadata{} + case EncoderTypeReactTxindexMetadata: + return &ReactTxindexMetadata{} case EncoderTypeFollowTxindexMetadata: return &FollowTxindexMetadata{} case EncoderTypePrivateMessageTxindexMetadata: @@ -517,8 +521,9 @@ const ( OperationTypeDAOCoinTransfer OperationType = 26 OperationTypeSpendingLimitAccounting OperationType = 27 OperationTypeDAOCoinLimitOrder OperationType = 28 + OperationTypeReact OperationType = 29 - // NEXT_TAG = 29 + // NEXT_TAG = 30 ) func (op OperationType) String() string { @@ -688,6 +693,10 @@ type UtxoOperation struct { PrevLikeEntry *LikeEntry PrevLikeCount uint64 + // Save the previous emoji reactions + PrevReactEntry *ReactionEntry + PrevEmojiCount map[rune]uint64 + // For disconnecting diamonds. PrevDiamondEntry *DiamondEntry @@ -2263,6 +2272,61 @@ func (likeEntry *LikeEntry) GetEncoderType() EncoderType { return EncoderTypeLikeEntry } +func MakeReactionKey(userPk []byte, ReactPostHash BlockHash, ReactEmoji rune) ReactionKey { + return ReactionKey{ + // Avoid using the pointer so that it is easier to compare Reaction key structs + ReactorPubKey: *NewPublicKey(userPk), + ReactedPostHash: ReactPostHash, + ReactEmoji: ReactEmoji, + } +} + +type ReactionKey struct { + ReactorPubKey PublicKey + ReactedPostHash BlockHash + ReactEmoji rune +} + +type ReactionEntry struct { + ReactorPubKey []byte + ReactedPostHash *BlockHash + ReactEmoji rune + // Whether this entry is deleted in the view + isDeleted bool +} + +func (reactEntry *ReactionEntry) RawEncodeWithoutMetadata(blockHeight uint64, skipMetadata ...bool) []byte { + var data []byte + + data = append(data, EncodeByteArray(reactEntry.ReactorPubKey)...) + data = append(data, EncodeToBytes(blockHeight, reactEntry.ReactedPostHash, skipMetadata...)...) + return data +} + +func (reactEntry *ReactionEntry) RawDecodeWithoutMetadata(blockHeight uint64, rr *bytes.Reader) error { + var err error + + reactEntry.ReactorPubKey, err = DecodeByteArray(rr) + if err != nil { + return errors.Wrapf(err, "ReactionEntry.Decode: problem reading ReactorPubKey") + } + reactedPostHash := &BlockHash{} + if exist, err := DecodeFromBytes(reactedPostHash, rr); exist && err == nil { + reactEntry.ReactedPostHash = reactedPostHash + } else if err != nil { + return errors.Wrapf(err, "ReactionEntry.Decode: problem reading ReactedPostHash") + } + return nil +} + +func (reactEntry *ReactionEntry) GetVersionByte(blockHeight uint64) byte { + return 0 +} + +func (reactEntry *ReactionEntry) GetEncoderType() EncoderType { + return EncoderTypeReactEntry +} + func MakeNFTKey(nftPostHash *BlockHash, serialNumber uint64) NFTKey { return NFTKey{ NFTPostHash: *nftPostHash, @@ -2992,6 +3056,9 @@ type PostEntry struct { // Counter of users that have liked this post. LikeCount uint64 + // Counter of emoji reactions that this post has. + EmojiCount map[rune]uint64 + // Counter of users that have reposted this post. RepostCount uint64 diff --git a/lib/blockchain.go b/lib/blockchain.go index 9a894e499..74e45202c 100644 --- a/lib/blockchain.go +++ b/lib/blockchain.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "github.com/holiman/uint256" + "golang.org/x/text/unicode/norm" "math" "math/big" "reflect" @@ -3054,6 +3055,59 @@ func (bc *Blockchain) CreateLikeTxn( return txn, totalInput, changeAmount, fees, nil } +func (bc *Blockchain) CreateReactTxn( + userPublicKey []byte, postHash BlockHash, isRemove bool, emojiReaction rune, + minFeeRateNanosPerKB uint64, mempool *DeSoMempool, additionalOutputs []*DeSoOutput) ( + _txn *MsgDeSoTxn, _totalInput uint64, _changeAmount uint64, _fees uint64, + _err error) { + + //TODO Where would be the best place to place the validation function? + // At the moment, only support happy, sad, angry and surprised + AcceptedReactions := [4]rune{'😊', '😥', '😠', '😮'} + // Validate emoji reaction + var isValidEmoji = func(emoji rune) bool { + for _, acceptedReaction := range AcceptedReactions { + if emoji == acceptedReaction { + return true + } + } + return false + } + + // TODO Fix bug returning invalid for valid inputs + normalizedReaction := norm.NFC.String(string(emojiReaction)) + if !isValidEmoji(rune(normalizedReaction[0])) { + return nil, 0, 0, 0, errors.New("CreateReactTxn: Invalid emoji input: ") + } + + // A React transaction doesn't need any inputs or outputs (except additionalOutputs provided). + txn := &MsgDeSoTxn{ + PublicKey: userPublicKey, + TxnMeta: &ReactMetadata{ + PostHash: &postHash, + EmojiReaction: rune(normalizedReaction[0]), + IsRemove: isRemove, + }, + TxOutputs: additionalOutputs, + // We wait to compute the signature until we've added all the + // inputs and change. + } + + totalInput, spendAmount, changeAmount, fees, err := + bc.AddInputsAndChangeToTransaction(txn, minFeeRateNanosPerKB, mempool) + if err != nil { + return nil, 0, 0, 0, errors.Wrapf( + err, "CreateReactTxn: Problem adding inputs: ") + } + + // Sanity-check that the spendAmount is zero. + if err = amountEqualsAdditionalOutputs(spendAmount, additionalOutputs); err != nil { + return nil, 0, 0, 0, fmt.Errorf("CreateReactTxn: %v", err) + } + + return txn, totalInput, changeAmount, fees, nil +} + func (bc *Blockchain) CreateFollowTxn( senderPublicKey []byte, followedPublicKey []byte, isUnfollow bool, minFeeRateNanosPerKB uint64, mempool *DeSoMempool, additionalOutputs []*DeSoOutput) ( diff --git a/lib/db_utils.go b/lib/db_utils.go index 1cbdeb075..7f1d63993 100644 --- a/lib/db_utils.go +++ b/lib/db_utils.go @@ -174,6 +174,12 @@ type DBPrefixes struct { PrefixLikerPubKeyToLikedPostHash []byte `prefix_id:"[30]" is_state:"true"` PrefixLikedPostHashToLikerPubKey []byte `prefix_id:"[31]" is_state:"true"` + // Prefixes for reactions: + // -> <> + // -> <> + PrefixReactorPubKeyToPostHash []byte `prefix_id:"[63]" is_state:"true"` + PrefixPostHashToReactorPubKey []byte `prefix_id:"[64]" is_state:"true"` + // Prefixes for creator coin fields: // -> // -> @@ -322,7 +328,7 @@ type DBPrefixes struct { PrefixDAOCoinLimitOrder []byte `prefix_id:"[60]" is_state:"true"` PrefixDAOCoinLimitOrderByTransactorPKID []byte `prefix_id:"[61]" is_state:"true"` PrefixDAOCoinLimitOrderByOrderID []byte `prefix_id:"[62]" is_state:"true"` - // NEXT_TAG: 63 + // NEXT_TAG: 65 } // StatePrefixToDeSoEncoder maps each state prefix to a DeSoEncoder type that is stored under that prefix. @@ -479,8 +485,13 @@ func StatePrefixToDeSoEncoder(prefix []byte) (_isEncoder bool, _encoder DeSoEnco } else if bytes.Equal(prefix, Prefixes.PrefixDAOCoinLimitOrderByOrderID) { // prefix_id:"[62]" return true, &DAOCoinLimitOrderEntry{} + } else if bytes.Equal(prefix, Prefixes.PrefixReactorPubKeyToPostHash) { + // prefix_id:"[63]" + return false, nil + } else if bytes.Equal(prefix, Prefixes.PrefixPostHashToReactorPubKey) { + // prefix_id:"[64]" + return false, nil } - return true, nil } @@ -2032,6 +2043,164 @@ func DbGetLikerPubKeysLikingAPostHash(handle *badger.DB, likedPostHash BlockHash return userPubKeys, nil } +// ------------------------------------------------------------------------------------- +// React mapping functions +// -> <> +// -> <> +// ------------------------------------------------------------------------------------- +// +//TODO these will probably need to change slightly to accommodate multiple reactions with different emojis per post +func _dbKeyForReactorPubKeyToPostHashMapping( + userPubKey []byte, postHash BlockHash, reactionEmoji rune) []byte { + // Make a copy to avoid multiple calls to this function re-using the same slice. + prefixCopy := append([]byte{}, Prefixes.PrefixReactorPubKeyToPostHash...) + key := append(prefixCopy, userPubKey...) + key = append(key, postHash[:]...) + key = append(key, []byte(string(reactionEmoji))...) + return key +} + +func _dbKeyForPostHashToReactorPubKeyMapping( + postHash BlockHash, userPubKey []byte) []byte { + // Make a copy to avoid multiple calls to this function re-using the same slice. + prefixCopy := append([]byte{}, Prefixes.PrefixPostHashToReactorPubKey...) + key := append(prefixCopy, postHash[:]...) + key = append(key, userPubKey...) + return key +} + +func _dbSeekPrefixForPostHashesYouReact(yourPubKey []byte) []byte { + // Make a copy to avoid multiple calls to this function re-using the same slice. + prefixCopy := append([]byte{}, Prefixes.PrefixReactorPubKeyToPostHash...) + return append(prefixCopy, yourPubKey...) +} + +func _dbSeekPrefixForReactorPubKeysReactingToPostHash(likedPostHash BlockHash) []byte { + // Make a copy to avoid multiple calls to this function re-using the same slice. + prefixCopy := append([]byte{}, Prefixes.PrefixPostHashToReactorPubKey...) + return append(prefixCopy, likedPostHash[:]...) +} + +// Note that this adds a mapping for the user *and* the liked post. +func DbPutReactMappingsWithTxn( + txn *badger.Txn, userPubKey []byte, likedPostHash BlockHash, reactionEmoji rune) error { + + if len(userPubKey) != btcec.PubKeyBytesLenCompressed { + return fmt.Errorf("DbPutReactMappingsWithTxn: User public key "+ + "length %d != %d", len(userPubKey), btcec.PubKeyBytesLenCompressed) + } + + if err := txn.Set(_dbKeyForReactorPubKeyToPostHashMapping(userPubKey, likedPostHash, reactionEmoji), []byte{}); err != nil { + return errors.Wrapf(err, "DbPutReactMappingsWithTxn: Problem adding user to reacted post mapping: ") + } + + if err := txn.Set(_dbKeyForPostHashToReactorPubKeyMapping(likedPostHash, userPubKey), []byte{}); err != nil { + return errors.Wrapf(err, "DbPutReactMappingsWithTxn: Problem adding reacted post to user mapping: ") + } + + return nil +} + +func DbPutReactMappings( + handle *badger.DB, userPubKey []byte, likedPostHash BlockHash, reactionEmoji rune) error { + + return handle.Update(func(txn *badger.Txn) error { + return DbPutReactMappingsWithTxn(txn, userPubKey, likedPostHash, reactionEmoji) + }) +} + +func DbGetReactorPubKeyToPostHashMappingWithTxn( + txn *badger.Txn, userPubKey []byte, likedPostHash BlockHash, reactionEmoji rune) []byte { + + key := _dbKeyForReactorPubKeyToPostHashMapping(userPubKey, likedPostHash, reactionEmoji) + _, err := txn.Get(key) + if err != nil { + return nil + } + + // Typically we return a DB entry here but we don't store anything for like mappings. + // We use this function instead of one returning true / false for feature consistency. + return []byte{} +} + +func DbGetReactorPubKeyToPostHashMapping( + db *badger.DB, userPubKey []byte, likedPostHash BlockHash, reactionEmoji rune) []byte { + var ret []byte + db.View(func(txn *badger.Txn) error { + ret = DbGetReactorPubKeyToPostHashMappingWithTxn(txn, userPubKey, likedPostHash, reactionEmoji) + return nil + }) + return ret +} + +// Note this deletes the like for the user *and* the liked post since a mapping +// should exist for each. +func DbDeleteReactMappingsWithTxn( + txn *badger.Txn, userPubKey []byte, postHash BlockHash, reactionEmoji rune) error { + + // First check that a mapping exists. If one doesn't exist then there's nothing to do. + existingMapping := DbGetReactorPubKeyToPostHashMappingWithTxn(txn, userPubKey, postHash, reactionEmoji) + if existingMapping == nil { + return nil + } + + // When a message exists, delete the mapping for the sender and receiver. + if err := txn.Delete( + _dbKeyForReactorPubKeyToPostHashMapping(userPubKey, postHash, reactionEmoji)); err != nil { + return errors.Wrapf(err, "DbDeleteLikeMappingsWithTxn: Deleting "+ + "userPubKey %s and postHash %s failed", + PkToStringBoth(userPubKey), postHash) + } + if err := txn.Delete( + _dbKeyForPostHashToReactorPubKeyMapping(postHash, userPubKey)); err != nil { + return errors.Wrapf(err, "DbDeleteLikeMappingsWithTxn: Deleting "+ + "postHash %s and userPubKey %s failed", + PkToStringBoth(postHash[:]), PkToStringBoth(userPubKey)) + } + + return nil +} + +func DbDeleteReactMappings( + handle *badger.DB, userPubKey []byte, postHash BlockHash, reactionEmoji rune) error { + return handle.Update(func(txn *badger.Txn) error { + return DbDeleteReactMappingsWithTxn(txn, userPubKey, postHash, reactionEmoji) + }) +} + +func DbGetPostHashesYouReact(handle *badger.DB, yourPublicKey []byte) ( + _postHashes []*BlockHash, _err error) { + + prefix := _dbSeekPrefixForPostHashesYouReact(yourPublicKey) + keysFound, _ := _enumerateKeysForPrefix(handle, prefix) + + var postHashesYouReact []*BlockHash + for _, keyBytes := range keysFound { + // We must slice off the first byte and userPubKey to get the postHash. + postHash := &BlockHash{} + copy(postHash[:], keyBytes[1+btcec.PubKeyBytesLenCompressed:]) + postHashesYouReact = append(postHashesYouReact, postHash) + } + + return postHashesYouReact, nil +} + +func DbGetReactorPubKeysReactingToPostHash(handle *badger.DB, postHash BlockHash) ( + _pubKeys [][]byte, _err error) { + + prefix := _dbSeekPrefixForReactorPubKeysReactingToPostHash(postHash) + keysFound, _ := _enumerateKeysForPrefix(handle, prefix) + + var userPubKeys [][]byte + for _, keyBytes := range keysFound { + // We must slice off the first byte and postHash to get the userPubKey. + userPubKey := keyBytes[1+HashSizeBytes:] + userPubKeys = append(userPubKeys, userPubKey) + } + + return userPubKeys, nil +} + // ------------------------------------------------------------------------------------- // Reposts mapping functions // -> <> @@ -4844,6 +5013,52 @@ func (txnMeta *LikeTxindexMetadata) GetEncoderType() EncoderType { return EncoderTypeLikeTxindexMetadata } +type ReactTxindexMetadata struct { + // ReactorPublicKeyBase58Check = TransactorPublicKeyBase58Check + IsRemove bool + EmojiReaction rune + + PostHashHex string + // PosterPublicKeyBase58Check in AffectedPublicKeys +} + +func (txnMeta *ReactTxindexMetadata) RawEncodeWithoutMetadata(blockHeight uint64, skipMetadata ...bool) []byte { + var data []byte + + data = append(data, BoolToByte(txnMeta.IsRemove)) + data = append(data, []byte(string(txnMeta.EmojiReaction))...) + data = append(data, EncodeByteArray([]byte(txnMeta.PostHashHex))...) + return data +} + +func (txnMeta *ReactTxindexMetadata) RawDecodeWithoutMetadata(blockHeight uint64, rr *bytes.Reader) error { + var err error + + txnMeta.IsRemove, err = ReadBoolByte(rr) + if err != nil { + return errors.Wrapf(err, "ReactTxindexMetadata.Decode: Empty IsRemove") + } + txnMeta.EmojiReaction, _, err = rr.ReadRune() + if err != nil { + return errors.Wrapf(err, "ReactTxindexMetadata.Decode: Empty EmojiReaction") + } + postHashHexBytes, err := DecodeByteArray(rr) + if err != nil { + return errors.Wrapf(err, "ReactTxindexMetadata.Decode: problem reading PostHashHex") + } + txnMeta.PostHashHex = string(postHashHexBytes) + + return nil +} + +func (txnMeta *ReactTxindexMetadata) GetVersionByte(blockHeight uint64) byte { + return 0 +} + +func (txnMeta *ReactTxindexMetadata) GetEncoderType() EncoderType { + return EncoderTypeReactTxindexMetadata +} + type FollowTxindexMetadata struct { // FollowerPublicKeyBase58Check = TransactorPublicKeyBase58Check // FollowedPublicKeyBase58Check in AffectedPublicKeys @@ -5353,6 +5568,7 @@ type TransactionMetadata struct { UpdateProfileTxindexMetadata *UpdateProfileTxindexMetadata `json:",omitempty"` SubmitPostTxindexMetadata *SubmitPostTxindexMetadata `json:",omitempty"` LikeTxindexMetadata *LikeTxindexMetadata `json:",omitempty"` + ReactTxindexMetadata *ReactTxindexMetadata `json:",omitempty"` FollowTxindexMetadata *FollowTxindexMetadata `json:",omitempty"` PrivateMessageTxindexMetadata *PrivateMessageTxindexMetadata `json:",omitempty"` SwapIdentityTxindexMetadata *SwapIdentityTxindexMetadata `json:",omitempty"` diff --git a/lib/errors.go b/lib/errors.go index 67eef8a21..e91b16e7a 100644 --- a/lib/errors.go +++ b/lib/errors.go @@ -112,6 +112,10 @@ const ( RuleErrorCannotLikeNonexistentPost RuleError = "RuleErrorCannotLikeNonexistentPost" RuleErrorCannotUnlikeWithoutAnExistingLike RuleError = "RuleErrorCannotUnlikeWithoutAnExistingLike" + RuleErrorReactEntryAlreadyExists RuleError = "RuleErrorReactEntryAlreadyExists" + RuleErrorCannotReactNonexistentPost RuleError = "RuleErrorCannotReactNonexistentPost" + RuleErrorCannotRemoveReactionWithoutAnExistingReaction RuleError = "RuleErrorCannotRemoveReactionWithoutAnExistingReaction" + RuleErrorProfileUsernameTooShort RuleError = "RuleErrorProfileUsernameTooShort" RuleErrorProfileDescriptionTooShort RuleError = "RuleErrorProfileDescriptionTooShort" RuleErrorProfileUsernameTooLong RuleError = "RuleErrorProfileUsernameTooLong" diff --git a/lib/mempool.go b/lib/mempool.go index 597e76c0d..067c2246d 100644 --- a/lib/mempool.go +++ b/lib/mempool.go @@ -1387,6 +1387,34 @@ func ComputeTransactionMetadata(txn *MsgDeSoTxn, utxoView *UtxoView, blockHash * Metadata: "PosterPublicKeyBase58Check", }) } + case TxnTypeReact: + realTxMeta := txn.TxnMeta.(*ReactMetadata) + + // ReactorPublicKeyBase58Check = TransactorPublicKeyBase58Check + + txnMeta.ReactTxindexMetadata = &ReactTxindexMetadata{ + IsRemove: realTxMeta.IsRemove, + PostHashHex: hex.EncodeToString(realTxMeta.PostHash[:]), + EmojiReaction: realTxMeta.EmojiReaction, + } + + // Get the public key of the poster and set it as having been affected + // by this like. + // + // PosterPublicKeyBase58Check in AffectedPublicKeys + postHash := &BlockHash{} + copy(postHash[:], realTxMeta.PostHash[:]) + postEntry := utxoView.GetPostEntryForPostHash(postHash) + if postEntry == nil { + glog.V(2).Infof( + "UpdateTxindex: Error creating ReactTxindexMetadata; "+ + "missing post for hash %v: %v", postHash, err) + } else { + txnMeta.AffectedPublicKeys = append(txnMeta.AffectedPublicKeys, &AffectedPublicKey{ + PublicKeyBase58Check: PkToString(postEntry.PosterPublicKey, utxoView.Params), + Metadata: "PosterPublicKeyBase58Check", + }) + } case TxnTypeFollow: realTxMeta := txn.TxnMeta.(*FollowMetadata) diff --git a/lib/network.go b/lib/network.go index f22186f2b..4ce3a7e39 100644 --- a/lib/network.go +++ b/lib/network.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "golang.org/x/text/unicode/norm" "io" "math" "net" @@ -223,8 +224,9 @@ const ( TxnTypeDAOCoin TxnType = 24 TxnTypeDAOCoinTransfer TxnType = 25 TxnTypeDAOCoinLimitOrder TxnType = 26 + TxnTypeReact TxnType = 27 - // NEXT_ID = 27 + // NEXT_ID = 28 ) type TxnString string @@ -256,6 +258,7 @@ const ( TxnStringDAOCoin TxnString = "DAO_COIN" TxnStringDAOCoinTransfer TxnString = "DAO_COIN_TRANSFER" TxnStringDAOCoinLimitOrder TxnString = "DAO_COIN_LIMIT_ORDER" + TxnStringReact TxnString = "REACT" TxnStringUndefined TxnString = "TXN_UNDEFINED" ) @@ -266,7 +269,7 @@ var ( TxnTypeCreatorCoin, TxnTypeSwapIdentity, TxnTypeUpdateGlobalParams, TxnTypeCreatorCoinTransfer, TxnTypeCreateNFT, TxnTypeUpdateNFT, TxnTypeAcceptNFTBid, TxnTypeNFTBid, TxnTypeNFTTransfer, TxnTypeAcceptNFTTransfer, TxnTypeBurnNFT, TxnTypeAuthorizeDerivedKey, TxnTypeMessagingGroup, - TxnTypeDAOCoin, TxnTypeDAOCoinTransfer, TxnTypeDAOCoinLimitOrder, + TxnTypeDAOCoin, TxnTypeDAOCoinTransfer, TxnTypeDAOCoinLimitOrder, TxnTypeReact, } AllTxnString = []TxnString{ TxnStringUnset, TxnStringBlockReward, TxnStringBasicTransfer, TxnStringBitcoinExchange, TxnStringPrivateMessage, @@ -274,7 +277,7 @@ var ( TxnStringCreatorCoin, TxnStringSwapIdentity, TxnStringUpdateGlobalParams, TxnStringCreatorCoinTransfer, TxnStringCreateNFT, TxnStringUpdateNFT, TxnStringAcceptNFTBid, TxnStringNFTBid, TxnStringNFTTransfer, TxnStringAcceptNFTTransfer, TxnStringBurnNFT, TxnStringAuthorizeDerivedKey, TxnStringMessagingGroup, - TxnStringDAOCoin, TxnStringDAOCoinTransfer, TxnStringDAOCoinLimitOrder, + TxnStringDAOCoin, TxnStringDAOCoinTransfer, TxnStringDAOCoinLimitOrder, TxnStringReact, } ) @@ -340,6 +343,8 @@ func (txnType TxnType) GetTxnString() TxnString { return TxnStringDAOCoinTransfer case TxnTypeDAOCoinLimitOrder: return TxnStringDAOCoinLimitOrder + case TxnTypeReact: + return TxnStringReact default: return TxnStringUndefined } @@ -399,6 +404,8 @@ func GetTxnTypeFromString(txnString TxnString) TxnType { return TxnTypeDAOCoinTransfer case TxnStringDAOCoinLimitOrder: return TxnTypeDAOCoinLimitOrder + case TxnStringReact: + return TxnTypeReact default: // TxnTypeUnset means we couldn't find a matching txn type return TxnTypeUnset @@ -466,6 +473,8 @@ func NewTxnMetadata(txType TxnType) (DeSoTxnMetadata, error) { return (&DAOCoinTransferMetadata{}).New(), nil case TxnTypeDAOCoinLimitOrder: return (&DAOCoinLimitOrderMetadata{}).New(), nil + case TxnTypeReact: + return (&ReactMetadata{}).New(), nil default: return nil, fmt.Errorf("NewTxnMetadata: Unrecognized TxnType: %v; make sure you add the new type of transaction to NewTxnMetadata", txType) } @@ -3386,6 +3395,88 @@ func (txnData *LikeMetadata) New() DeSoTxnMetadata { return &LikeMetadata{} } +// ================================================================== +// ReactMetadata +// +// A reaction is an interaction where a user on the platform reacts to a post. +// ================================================================== + +type ReactMetadata struct { + // The user reacting is assumed to be the originator of the + // top-level transaction. + + // The post hash to react to. + PostHash *BlockHash + + // Set to true when a user is requesting to "remove" a reaction. + IsRemove bool + + // The Unicode for the emoji reaction. + EmojiReaction rune +} + +func (txnData *ReactMetadata) GetTxnType() TxnType { + return TxnTypeReact +} + +func (txnData *ReactMetadata) ToBytes(preSignature bool) ([]byte, error) { + // Validate the metadata before encoding it. + // + + var data []byte + + // Add PostHash + // + // We know the post hash is set and has the expected length, so we don't need + // to encode the length here. + data = append(data, txnData.PostHash[:]...) + + // Add IsRemove + data = append(data, BoolToByte(txnData.IsRemove)) + + // Add EmojiReaction. + // It is possible for a single character to be encoded with different code point sequences. + // By normalizing the Unicode (NFC), we ensure that a character will have a unique code point sequence. + data = append(data, norm.NFC.Bytes([]byte(string(txnData.EmojiReaction)))...) + + return data, nil +} + +func (txnData *ReactMetadata) FromBytes(data []byte) error { + ret := ReactMetadata{} + rr := bytes.NewReader(data) + + // PostHash + ret.PostHash = &BlockHash{} + _, err := io.ReadFull(rr, ret.PostHash[:]) + if err != nil { + return fmt.Errorf( + "ReactMetadata.FromBytes: Error reading PostHash: %v", err) + } + + // IsRemove + ret.IsRemove, err = ReadBoolByte(rr) + if err != nil { + return errors.Wrapf(err, "ReactMetadata.FromBytes: Problem reading IsRemove") + } + + // Emoji reaction + reaction, _, err := rr.ReadRune() + if err != nil { + return fmt.Errorf( + "ReactMetadata.FromBytes: Error reading EmojiReaction: %v", err) + } + + ret.EmojiReaction = reaction + *txnData = ret + + return nil +} + +func (txnData *ReactMetadata) New() DeSoTxnMetadata { + return &ReactMetadata{} +} + // ================================================================== // FollowMetadata // diff --git a/lib/network_test.go b/lib/network_test.go index a5653705e..0c5bf3b71 100644 --- a/lib/network_test.go +++ b/lib/network_test.go @@ -732,6 +732,64 @@ func TestSerializeUnlike(t *testing.T) { require.Equal(txMeta, testMeta) } +func TestSerializeNoReaction(t *testing.T) { + require := require.New(t) + + txMeta := &ReactMetadata{PostHash: &postHashForTesting1} + + data, err := txMeta.ToBytes(false) + require.NoError(err) + + testMeta, err := NewTxnMetadata(TxnTypeReact) + require.NoError(err) + err = testMeta.FromBytes(data) + require.NoError(err) + require.Equal(txMeta, testMeta) +} + +func TestSerializeRemoveReaction(t *testing.T) { + require := require.New(t) + + txMeta := &ReactMetadata{ + PostHash: &postHashForTesting1, + IsRemove: true, + } + + data, err := txMeta.ToBytes(false) + require.NoError(err) + + testMeta, err := NewTxnMetadata(TxnTypeReact) + require.NoError(err) + err = testMeta.FromBytes(data) + require.NoError(err) + require.Equal(txMeta, testMeta) +} + +func TestSerializeReactions(t *testing.T) { + ValidReactions := []rune{'😊', '😥', '😠', '😮'} + for _, r := range ValidReactions { + _testSerializeSingleReaction(t, r) + } +} + +func _testSerializeSingleReaction(t *testing.T, emoji rune) { + require := require.New(t) + + txMeta := &ReactMetadata{ + PostHash: &postHashForTesting1, + EmojiReaction: emoji, + } + + data, err := txMeta.ToBytes(false) + require.NoError(err) + + testMeta, err := NewTxnMetadata(TxnTypeReact) + require.NoError(err) + err = testMeta.FromBytes(data) + require.NoError(err) + require.Equal(txMeta, testMeta) +} + func TestSerializeFollow(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/lib/notifier.go b/lib/notifier.go index de5959dfb..4c93437c8 100644 --- a/lib/notifier.go +++ b/lib/notifier.go @@ -46,6 +46,7 @@ func (notifier *Notifier) Update() error { var transactions []*PGTransaction err = notifier.db.Model(&transactions).Where("block_hash = ?", block.Hash). Relation("Outputs").Relation("PGMetadataLike").Relation("PGMetadataFollow"). + Relation("PGMetadataReact"). Relation("PGMetadataCreatorCoin").Relation("PGMetadataCreatorCoinTransfer"). Relation("PGMetadataSubmitPost").Select() // TODO: Add NFTs @@ -97,6 +98,20 @@ func (notifier *Notifier) Update() error { Timestamp: block.Timestamp, }) } + } else if transaction.Type == TxnTypeReact { + postHash := transaction.MetadataReact.PostHash + post := DBGetPostEntryByPostHash(notifier.badger, nil, postHash) + if post != nil { + notifications = append(notifications, &PGNotification{ + TransactionHash: transaction.Hash, + Mined: true, + ToUser: post.PosterPublicKey, + FromUser: transaction.PublicKey, + Type: NotificationReact, + PostHash: postHash, + Timestamp: block.Timestamp, + }) + } } else if transaction.Type == TxnTypeFollow { if !transaction.MetadataFollow.IsUnfollow { notifications = append(notifications, &PGNotification{ diff --git a/lib/postgres.go b/lib/postgres.go index d8d2ccc43..8e56dbdb5 100644 --- a/lib/postgres.go +++ b/lib/postgres.go @@ -136,6 +136,7 @@ type PGTransaction struct { MetadataUpdateProfile *PGMetadataUpdateProfile `pg:"rel:belongs-to,join_fk:transaction_hash"` MetadataFollow *PGMetadataFollow `pg:"rel:belongs-to,join_fk:transaction_hash"` MetadataLike *PGMetadataLike `pg:"rel:belongs-to,join_fk:transaction_hash"` + MetadataReact *PGMetadataReact `pg:"rel:belongs-to,join_fk:transaction_hash"` MetadataCreatorCoin *PGMetadataCreatorCoin `pg:"rel:belongs-to,join_fk:transaction_hash"` MetadataCreatorCoinTransfer *PGMetadataCreatorCoinTransfer `pg:"rel:belongs-to,join_fk:transaction_hash"` MetadataSwapIdentity *PGMetadataSwapIdentity `pg:"rel:belongs-to,join_fk:transaction_hash"` @@ -260,6 +261,16 @@ type PGMetadataLike struct { IsUnlike bool `pg:",use_zero"` } +// PGMetadataReact represents ReactMetadata +type PGMetadataReact struct { + tableName struct{} `pg:"pg_metadata_reactions"` + + TransactionHash *BlockHash `pg:",pk,type:bytea"` + PostHash *BlockHash `pg:",type:bytea"` + IsRemove bool `pg:",use_zero"` + EmojiReaction rune `pg:",type:integer"` +} + // PGMetadataCreatorCoin represents CreatorCoinMetadataa type PGMetadataCreatorCoin struct { tableName struct{} `pg:"pg_metadata_creator_coins"` @@ -455,6 +466,7 @@ const ( NotificationUnknown NotificationType = iota NotificationSendDESO NotificationLike + NotificationReact NotificationFollow NotificationCoinPurchase NotificationCoinTransfer @@ -598,6 +610,22 @@ func (like *PGLike) NewLikeEntry() *LikeEntry { } } +type PGReact struct { + tableName struct{} `pg:"pg_react"` + + ReactorPublicKey []byte `pg:",pk,type:bytea"` + ReactorPostHash *BlockHash `pg:",pk,type:bytea"` + ReactionEmoji rune `pg:",pk,type:bytea"` +} + +func (react *PGReact) NewReactionEntry() *ReactionEntry { + return &ReactionEntry{ + ReactorPubKey: react.ReactorPublicKey, + ReactedPostHash: react.ReactorPostHash, + ReactEmoji: react.ReactionEmoji, + } +} + type PGFollow struct { tableName struct{} `pg:"pg_follows"` @@ -1560,6 +1588,9 @@ func (postgres *Postgres) FlushView(view *UtxoView) error { if err := postgres.flushLikes(tx, view); err != nil { return err } + if err := postgres.flushReacts(tx, view); err != nil { + return err + } if err := postgres.flushFollows(tx, view); err != nil { return err } @@ -1788,6 +1819,44 @@ func (postgres *Postgres) flushLikes(tx *pg.Tx, view *UtxoView) error { return nil } +func (postgres *Postgres) flushReacts(tx *pg.Tx, view *UtxoView) error { + var insertReacts []*PGReact + var deleteReacts []*PGReact + for _, reactionEntry := range view.ReactionKeyToReactionEntry { + if reactionEntry == nil { + continue + } + + react := &PGReact{ + ReactorPublicKey: reactionEntry.ReactorPubKey, + ReactorPostHash: reactionEntry.ReactedPostHash, + ReactionEmoji: reactionEntry.ReactEmoji, + } + + if reactionEntry.isDeleted { + deleteReacts = append(deleteReacts, react) + } else { + insertReacts = append(insertReacts, react) + } + } + + if len(insertReacts) > 0 { + _, err := tx.Model(&insertReacts).WherePK().OnConflict("DO NOTHING").Returning("NULL").Insert() + if err != nil { + return err + } + } + + if len(deleteReacts) > 0 { + _, err := tx.Model(&deleteReacts).Returning("NULL").Delete() + if err != nil { + return err + } + } + + return nil +} + func (postgres *Postgres) flushFollows(tx *pg.Tx, view *UtxoView) error { var insertFollows []*PGFollow var deleteFollows []*PGFollow @@ -2442,6 +2511,37 @@ func (postgres *Postgres) GetLikesForPost(postHash *BlockHash) []*PGLike { return likes } +// +// Reacts +// +func (postgres *Postgres) GetReaction(reactorPublicKey []byte, reactedPostHash *BlockHash, reactionEmoji rune) *PGReact { + react := PGReact{ + ReactorPublicKey: reactorPublicKey, + ReactorPostHash: reactedPostHash, + ReactionEmoji: reactionEmoji, + } + err := postgres.db.Model(&react).WherePK().First() + if err != nil { + return nil + } + return &react +} +func (postgres *Postgres) GetReacts(reacts []*PGReact) []*PGReact { + err := postgres.db.Model(&reacts).WherePK().Select() + if err != nil { + return nil + } + return reacts +} +func (postgres *Postgres) GetReactionsForPost(postHash *BlockHash) []*PGReact { + var reacts []*PGReact + err := postgres.db.Model(&reacts).Where("reactor_post_hash = ?", postHash).Select() + if err != nil { + return nil + } + return reacts +} + // // Follows // From c41575424b21548595ebbf1c57f0a2e587df9d72 Mon Sep 17 00:00:00 2001 From: MichelMajdalani Date: Sun, 21 Aug 2022 19:06:37 -0700 Subject: [PATCH 2/2] WIP: add DBAdapter logic --- go.sum | 2 + lib/block_view.go | 24 ++ lib/block_view_flush.go | 50 +++ lib/block_view_reaction.go | 287 +++++++++++++ lib/block_view_reaction_test.go | 720 ++++++++++++++++++++++++++++++++ lib/block_view_types.go | 70 +++- lib/blockchain.go | 65 ++- lib/db_adapter.go | 37 ++ lib/db_utils.go | 227 +++++++++- lib/errors.go | 4 + lib/mempool.go | 28 ++ lib/network.go | 99 ++++- lib/network_test.go | 58 +++ lib/notifier.go | 15 + lib/postgres.go | 106 +++++ 15 files changed, 1783 insertions(+), 9 deletions(-) create mode 100644 lib/block_view_reaction.go create mode 100644 lib/block_view_reaction_test.go diff --git a/go.sum b/go.sum index 0a34a3666..76adb6f21 100644 --- a/go.sum +++ b/go.sum @@ -642,6 +642,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE= diff --git a/lib/block_view.go b/lib/block_view.go index 147840688..a4f062a78 100644 --- a/lib/block_view.go +++ b/lib/block_view.go @@ -64,6 +64,9 @@ type UtxoView struct { // Like data LikeKeyToLikeEntry map[LikeKey]*LikeEntry + // React data + ReactionKeyToReactionEntry map[ReactionKey]*ReactionEntry + // Repost data RepostKeyToRepostEntry map[RepostKey]*RepostEntry @@ -145,6 +148,9 @@ func (bav *UtxoView) _ResetViewMappingsAfterFlush() { // Like data bav.LikeKeyToLikeEntry = make(map[LikeKey]*LikeEntry) + // React data + bav.ReactionKeyToReactionEntry = make(map[ReactionKey]*ReactionEntry) + // Repost data bav.RepostKeyToRepostEntry = make(map[RepostKey]*RepostEntry) @@ -281,6 +287,16 @@ func (bav *UtxoView) CopyUtxoView() (*UtxoView, error) { newView.LikeKeyToLikeEntry[likeKey] = &newLikeEntry } + // Copy the react data + newView.ReactionKeyToReactionEntry = make(map[ReactionKey]*ReactionEntry, len(bav.ReactionKeyToReactionEntry)) + for reactKey, reactEntry := range bav.ReactionKeyToReactionEntry { + if reactEntry == nil { + continue + } + newReactEntry := *reactEntry + newView.ReactionKeyToReactionEntry[reactKey] = &newReactEntry + } + // Copy the repost data newView.RepostKeyToRepostEntry = make(map[RepostKey]*RepostEntry, len(bav.RepostKeyToRepostEntry)) for repostKey, repostEntry := range bav.RepostKeyToRepostEntry { @@ -948,6 +964,10 @@ func (bav *UtxoView) DisconnectTransaction(currentTxn *MsgDeSoTxn, txnHash *Bloc return bav._disconnectLike( OperationTypeLike, currentTxn, txnHash, utxoOpsForTxn, blockHeight) + } else if currentTxn.TxnMeta.GetTxnType() == TxnTypeReact { + return bav._disconnectReact( + OperationTypeReact, currentTxn, txnHash, utxoOpsForTxn, blockHeight) + } else if currentTxn.TxnMeta.GetTxnType() == TxnTypeCreatorCoin { return bav._disconnectCreatorCoin( OperationTypeCreatorCoin, currentTxn, txnHash, utxoOpsForTxn, blockHeight) @@ -2310,6 +2330,10 @@ func (bav *UtxoView) _connectTransaction(txn *MsgDeSoTxn, txHash *BlockHash, totalInput, totalOutput, utxoOpsForTxn, err = bav._connectLike(txn, txHash, blockHeight, verifySignatures) + } else if txn.TxnMeta.GetTxnType() == TxnTypeReact { + totalInput, totalOutput, utxoOpsForTxn, err = + bav._connectReact(txn, txHash, blockHeight, verifySignatures) + } else if txn.TxnMeta.GetTxnType() == TxnTypeCreatorCoin { totalInput, totalOutput, utxoOpsForTxn, err = bav._connectCreatorCoin( diff --git a/lib/block_view_flush.go b/lib/block_view_flush.go index bf804cfc9..194aae826 100644 --- a/lib/block_view_flush.go +++ b/lib/block_view_flush.go @@ -66,6 +66,9 @@ func (bav *UtxoView) FlushToDbWithTxn(txn *badger.Txn, blockHeight uint64) error if err := bav._flushLikeEntriesToDbWithTxn(txn); err != nil { return err } + if err := bav._flushReactEntriesToDbWithTxn(txn); err != nil { + return err + } if err := bav._flushFollowEntriesToDbWithTxn(txn); err != nil { return err } @@ -411,6 +414,53 @@ func (bav *UtxoView) _flushLikeEntriesToDbWithTxn(txn *badger.Txn) error { return nil } +func (bav *UtxoView) _flushReactEntriesToDbWithTxn(txn *badger.Txn) error { + + // Go through all the entries in the ReactionKeyToReactionEntry map. + for reactKeyIter, reactEntry := range bav.ReactionKeyToReactionEntry { + // Make a copy of the iterator since we make references to it below. + reactKey := reactKeyIter + + // Sanity-check that the ReactKey computed from the ReactEntry is + // equal to the ReactKey that maps to that entry. + reactKeyInEntry := MakeReactionKey(reactEntry.ReactorPubKey, *reactEntry.ReactedPostHash, reactEntry.ReactEmoji) + if reactKeyInEntry != reactKey { + return fmt.Errorf("_flushReactEntriesToDbWithTxn: ReactEntry has "+ + "ReactKey: %v, which doesn't match the ReactKeyToReactEntry map key %v", + &reactKeyInEntry, &reactKey) + } + + // Delete the existing mappings in the db for this ReactKey. They will be re-added + // if the corresponding entry in memory has isDeleted=false. + if err := DbDeleteReactMappingsWithTxn( + txn, reactKey.ReactorPubKey[:], reactKey.ReactedPostHash, reactKey.ReactEmoji); err != nil { + + return errors.Wrapf( + err, "_flushReactEntriesToDbWithTxn: Problem deleting mappings "+ + "for LikeKey: %v: ", &reactKey) + } + } + + // Go through all the entries in the ReactionKeyToReactionEntry map. + for _, reactEntry := range bav.ReactionKeyToReactionEntry { + + if reactEntry.isDeleted { + // If the ReactEntry has isDeleted=true then there's nothing to do because + // we already deleted the entry above. + } else { + // If the ReactEntry has (isDeleted = false) then we put the corresponding + // mappings for it into the db. + if err := DbPutReactMappingsWithTxn( + txn, reactEntry.ReactorPubKey, *reactEntry.ReactedPostHash, reactEntry.ReactEmoji); err != nil { + + return err + } + } + } + + return nil +} + func (bav *UtxoView) _flushFollowEntriesToDbWithTxn(txn *badger.Txn) error { // Go through all the entries in the FollowKeyToFollowEntry map. diff --git a/lib/block_view_reaction.go b/lib/block_view_reaction.go new file mode 100644 index 000000000..b38a1f063 --- /dev/null +++ b/lib/block_view_reaction.go @@ -0,0 +1,287 @@ +package lib + +import ( + "bytes" + "fmt" + "github.com/golang/glog" + "github.com/pkg/errors" + "reflect" +) + +func (bav *UtxoView) _getReactionEntryForReactionKey(reactionKey *ReactionKey) *ReactionEntry { + // If an entry exists in the in-memory map, return the value of that mapping. + mapValue, existsMapValue := bav.ReactionKeyToReactionEntry[*reactionKey] + if existsMapValue { + return mapValue + } + + adapter := bav.GetDbAdapter() + + // If we get here it means no value exists in our in-memory map. In this case, + // defer to the db. If a mapping exists in the db, return it. If not, return + // nil. Either way, save the value to the in-memory view mapping got later. + reactionExists := false + if adapter.postgresDb != nil { + reactionExists = adapter.postgresDb.GetReaction(reactionKey.ReactorPubKey[:], &reactionKey.ReactedPostHash, reactionKey.ReactEmoji) != nil + } else { + reactionExists = DbGetReactorPubKeyToPostHashMapping(adapter.badgerDb, reactionKey.ReactorPubKey[:], reactionKey.ReactedPostHash, reactionKey.ReactEmoji) != nil + } + + if reactionExists { + reactionEntry := ReactionEntry{ + ReactorPubKey: reactionKey.ReactorPubKey[:], + ReactedPostHash: &reactionKey.ReactedPostHash, + ReactEmoji: reactionKey.ReactEmoji, + } + bav._setReactionEntryMappings(&reactionEntry) + return &reactionEntry + } + + return nil +} + +func (bav *UtxoView) _setReactionEntryMappings(reactionEntry *ReactionEntry) { + // This function shouldn't be called with nil. + if reactionEntry == nil { + glog.Errorf("_setReactionEntryMappings: Called with nil ReactionEntry; " + + "this should never happen.") + return + } + + reactionKey := MakeReactionKey(reactionEntry.ReactorPubKey, *reactionEntry.ReactedPostHash, reactionEntry.ReactEmoji) + bav.ReactionKeyToReactionEntry[reactionKey] = reactionEntry +} + +func (bav *UtxoView) _deleteReactionEntryMappings(reactionEntry *ReactionEntry) { + + // Create a tombstone entry. + tombstoneReactionEntry := *reactionEntry + tombstoneReactionEntry.isDeleted = true + + // Set the mappings to point to the tombstone entry. + bav._setReactionEntryMappings(&tombstoneReactionEntry) +} + +func (bav *UtxoView) GetReactionByReader(readerPK []byte, postHash *BlockHash, reactEmoji rune) bool { + // Get react state. + reactionKey := MakeReactionKey(readerPK, *postHash, reactEmoji) + reactionEntry := bav._getReactionEntryForReactionKey(&reactionKey) + return reactionEntry != nil && !reactionEntry.isDeleted +} + +func (bav *UtxoView) GetReactorsForPostHash(postHash *BlockHash, reactionEmoji rune) (_ReactorPubKeys [][]byte, _err error) { + // Returns the public key of the users that reacted to a post with a specific emoji reaction + adapter := bav.GetDbAdapter() + + reactions, _ := adapter.GetReactionsForPost(postHash, reactionEmoji) + + for _, reaction := range reactions { + reactKey := MakeReactionKey(reaction.ReactorPubKey, *reaction.ReactedPostHash, reactionEmoji) + if _, exists := bav.ReactionKeyToReactionEntry[reactKey]; !exists { + bav._setReactionEntryMappings(reaction) + } + } + + // Iterate over the view and create the final list to return + // because there might be values in the view that weren't propagated to the db. + var reactorPubKeys [][]byte + for _, reactionEntry := range bav.ReactionKeyToReactionEntry { + if !reactionEntry.isDeleted && bytes.Equal(reactionEntry.ReactedPostHash[:], postHash[:]) { + reactorPubKeys = append(reactorPubKeys, reactionEntry.ReactorPubKey) + } + } + + return reactorPubKeys, nil +} + +func (bav *UtxoView) _connectReact( + txn *MsgDeSoTxn, txHash *BlockHash, blockHeight uint32, verifySignatures bool) ( + _totalInput uint64, _totalOutput uint64, _utxoOps []*UtxoOperation, _err error) { + //TODO (Michel) Add a block height restriction before doing anything else in this function. You can see a fork height in constants.go + + // Check that the transaction has the right TxnType. + if txn.TxnMeta.GetTxnType() != TxnTypeReact { + return 0, 0, nil, fmt.Errorf("_connectReact: called with bad TxnType %s", + txn.TxnMeta.GetTxnType().String()) + } + txMeta := txn.TxnMeta.(*ReactMetadata) + + // Connect basic txn to get the total input and the total output without + // considering the transaction metadata. + totalInput, totalOutput, utxoOpsForTxn, err := bav._connectBasicTransfer( + txn, txHash, blockHeight, verifySignatures) + if err != nil { + return 0, 0, nil, errors.Wrapf(err, "_connectReact: ") + } + + if verifySignatures { + // _connectBasicTransfer has already checked that the transaction is + // signed by the top-level public key, which we take to be the sender's + // public key so there is no need to verify anything further. + } + + // At this point the inputs and outputs have been processed. Now we need to handle + // the metadata. + + // There are two main checks that need to be done before allowing a reaction: + // - Check that the post exists + // - Check that the person hasn't already reacted with the same emoji + + //TODO (Michel) Validate that EmojiReaction is a valid rune for an emoji before proceeding. + + // Check that the post to react actually exists. + existingPostEntry := bav.GetPostEntryForPostHash(txMeta.PostHash) + if existingPostEntry == nil || existingPostEntry.isDeleted { + return 0, 0, nil, errors.Wrapf( + RuleErrorCannotReactNonexistentPost, + "_connectReact: Post hash: %v", txMeta.PostHash) + } + + // At this point the code diverges and considers the react flows differently + // since the presence of an existing react entry has a different effect in either case. + + reactionKey := MakeReactionKey(txn.PublicKey, *txMeta.PostHash, txMeta.EmojiReaction) + existingReactEntry := bav._getReactionEntryForReactionKey(&reactionKey) + // We don't need to make a copy of the post entry because all we're modifying is the emoji counts, + // which isn't stored in any of our mappings. But we make a copy here just because it's a little bit + // more foolproof. + updatedPostEntry := *existingPostEntry + + if txMeta.IsRemove { + // Ensure that there *is* an existing emoji entry to delete. + if existingReactEntry == nil || existingReactEntry.isDeleted { + return 0, 0, nil, errors.Wrapf( + RuleErrorCannotRemoveReactionWithoutAnExistingReaction, + "_connectReact: React key: %v", &reactionKey) + } + + // Now that we know there is a react entry, we delete it and decrement the emoji count. + bav._deleteReactionEntryMappings(existingReactEntry) + updatedPostEntry.EmojiCount[txMeta.EmojiReaction] -= 1 + } else { + // Ensure that there *is not* an existing react entry. + if existingReactEntry != nil && !existingReactEntry.isDeleted { + return 0, 0, nil, errors.Wrapf( + RuleErrorReactEntryAlreadyExists, + "_connectReact: Like key: %v", &reactionKey) + } + + // Now that we know there is no pre-existing reactentry, we can create one and + // increment the react s on the react d post. + reactEntry := &ReactionEntry{ + ReactorPubKey: txn.PublicKey, + ReactedPostHash: txMeta.PostHash, + ReactEmoji: txMeta.EmojiReaction, + } + bav._setReactionEntryMappings(reactEntry) + if updatedPostEntry.EmojiCount == nil { + updatedPostEntry.EmojiCount = make(map[rune]uint64) + } + updatedPostEntry.EmojiCount[txMeta.EmojiReaction] += 1 + } + + // Set the updated post entry so it has the new emoji count. + bav._setPostEntryMappings(&updatedPostEntry) + + // Add an operation to the list at the end indicating we've added a follow. + utxoOpsForTxn = append(utxoOpsForTxn, &UtxoOperation{ + Type: OperationTypeReact, + PrevReactEntry: existingReactEntry, + PrevEmojiCount: existingPostEntry.EmojiCount, + }) + + return totalInput, totalOutput, utxoOpsForTxn, nil +} + +func (bav *UtxoView) _disconnectReact( + operationType OperationType, currentTxn *MsgDeSoTxn, txnHash *BlockHash, + utxoOpsForTxn []*UtxoOperation, blockHeight uint32) error { + + //TODO (Michel) Add block height check + + // Verify that the last operation is a Reaction operation + if len(utxoOpsForTxn) == 0 { + return fmt.Errorf("_disconnectReact: utxoOperations are missing") + } + operationIndex := len(utxoOpsForTxn) - 1 + if utxoOpsForTxn[operationIndex].Type != OperationTypeReact { + return fmt.Errorf("_disconnectReact: Trying to revert "+ + "OperationTypeReact but found type %v", + utxoOpsForTxn[operationIndex].Type) + } + + // Now we know the txMeta is a React + txMeta := currentTxn.TxnMeta.(*ReactMetadata) + + //TODO (Michel) Check that the post isn't deleted. + + // Before we do anything, let's get the post so we can adjust the emoji map counter later. + reactedPostEntry := bav.GetPostEntryForPostHash(txMeta.PostHash) + if reactedPostEntry == nil { + return fmt.Errorf("_disconnectReact: Error getting post: %v", txMeta.PostHash) + } + + // Here we diverge and consider the react and unreact cases separately. + if txMeta.IsRemove { + // If this is an remove we just need to add back the previous react entry and react + // count. We do some sanity checks first though to be extra safe. + + prevReactEntry := utxoOpsForTxn[operationIndex].PrevReactEntry + // Sanity check: verify that the user on the reactEntry matches the transaction sender. + if !reflect.DeepEqual(prevReactEntry.ReactorPubKey, currentTxn.PublicKey) { + return fmt.Errorf("_disconnectReact: User public key on "+ + "ReactionEntry was %s but the PublicKey on the txn was %s", + PkToStringBoth(prevReactEntry.ReactorPubKey), + PkToStringBoth(currentTxn.PublicKey)) + } + + // Sanity check: verify that the post hash on the prevReactEntry matches the transaction's. + if !reflect.DeepEqual(prevReactEntry.ReactedPostHash, txMeta.PostHash) { + return fmt.Errorf("_disconnectLike: Liked post hash on "+ + "ReactionEntry was %s but the ReactedPostHash on the txn was %s", + prevReactEntry.ReactedPostHash, txMeta.PostHash) + } + + // Set the react entry and react count to their previous state. + bav._setReactionEntryMappings(prevReactEntry) + reactedPostEntry.EmojiCount = utxoOpsForTxn[operationIndex].PrevEmojiCount + bav._setPostEntryMappings(reactedPostEntry) + } else { + // If this is a normal "react," we do some sanity checks and then delete the entry. + + // Get the ReactionEntry. If we don't find it or isDeleted=true, that's an error. + reactKey := MakeReactionKey(currentTxn.PublicKey, *txMeta.PostHash, txMeta.EmojiReaction) + reactEntry := bav._getReactionEntryForReactionKey(&reactKey) + if reactEntry == nil || reactEntry.isDeleted { + return fmt.Errorf("_disconnectReact: ReactionEntry for "+ + "reactKey %v was found to be nil or isDeleted not set appropriately: %v", + &reactKey, reactEntry) + } + + // Sanity check: verify that the user on the reactEntry matches the transaction sender. + if !reflect.DeepEqual(reactEntry.ReactorPubKey, currentTxn.PublicKey) { + return fmt.Errorf("_disconnectReact: User public key on "+ + "ReactionEntry was %s but the PublicKey on the txn was %s", + PkToStringBoth(reactEntry.ReactorPubKey), + PkToStringBoth(currentTxn.PublicKey)) + } + + // Sanity check: verify that the post hash on the reactEntry matches the transaction's. + if !reflect.DeepEqual(reactEntry.ReactedPostHash, txMeta.PostHash) { + return fmt.Errorf("_disconnectReact: Reacted post hash on "+ + "ReactionEntry was %s but the ReactedPostHash on the txn was %s", + reactEntry.ReactedPostHash, txMeta.PostHash) + } + + // Now that we're confident the FollowEntry lines up with the transaction we're + // rolling back, delete the mappings and set the reaction counter to its previous value. + bav._deleteReactionEntryMappings(reactEntry) + reactedPostEntry.EmojiCount = utxoOpsForTxn[operationIndex].PrevEmojiCount + bav._setPostEntryMappings(reactedPostEntry) + } + + // Now revert the basic transfer with the remaining operations. Cut off + // the Like operation at the end since we just reverted it. + return bav._disconnectBasicTransfer( + currentTxn, txnHash, utxoOpsForTxn[:operationIndex], blockHeight) +} diff --git a/lib/block_view_reaction_test.go b/lib/block_view_reaction_test.go new file mode 100644 index 000000000..aecb2dc97 --- /dev/null +++ b/lib/block_view_reaction_test.go @@ -0,0 +1,720 @@ +package lib + +import ( + "fmt" + "github.com/stretchr/testify/require" + "golang.org/x/text/unicode/norm" + "testing" +) + +var ( + HappyReaction = rune(norm.NFC.String(string('😊'))[0]) + SadReaction = rune(norm.NFC.String(string('😥'))[0]) + AngryReaction = rune(norm.NFC.String(string('😠'))[0]) + SurprisedReaction = rune(norm.NFC.String(string('😮'))[0]) +) + +func _doReactTxn(testMeta *TestMeta, feeRateNanosPerKB uint64, senderPkBase58Check string, + postHash BlockHash, senderPrivBase58Check string, isRemove bool, emojiReaction rune) ( + _utxoOps []*UtxoOperation, _txn *MsgDeSoTxn, _height uint32, _err error) { + + require := require.New(testMeta.t) + + senderPkBytes, _, err := Base58CheckDecode(senderPkBase58Check) + require.NoError(err) + + utxoView, err := NewUtxoView(testMeta.db, testMeta.params, testMeta.chain.postgres, testMeta.chain.snapshot) + require.NoError(err) + + txn, totalInputMake, changeAmountMake, feesMake, err := testMeta.chain.CreateReactTxn( + senderPkBytes, postHash, isRemove, emojiReaction, feeRateNanosPerKB, nil, []*DeSoOutput{}) + if err != nil { + return nil, nil, 0, err + } + + require.Equal(totalInputMake, changeAmountMake+feesMake) + + // Sign the transaction now that its inputs are set up. + _signTxn(testMeta.t, txn, senderPrivBase58Check) + + txHash := txn.Hash() + // Always use height+1 for validation since it's assumed the transaction will + // get mined into the next block. + blockHeight := testMeta.chain.blockTip().Height + 1 + utxoOps, totalInput, totalOutput, fees, err := + utxoView.ConnectTransaction(txn, txHash, getTxnSize(*txn), blockHeight, true, /*verifySignature*/ + false /*ignoreUtxos*/) + // ConnectTransaction should treat the amount locked as contributing to the + // output. + if err != nil { + return nil, nil, 0, err + } + require.Equal(totalInput, totalOutput+fees) + require.Equal(totalInput, totalInputMake) + + // We should have one SPEND UtxoOperation for each input, one ADD operation + // for each output, and one OperationTypeReact operation at the end. + require.Equal(len(txn.TxInputs)+len(txn.TxOutputs)+1, len(utxoOps)) + for ii := 0; ii < len(txn.TxInputs); ii++ { + require.Equal(OperationTypeSpendUtxo, utxoOps[ii].Type) + } + require.Equal(OperationTypeReact, utxoOps[len(utxoOps)-1].Type) + + require.NoError(utxoView.FlushToDb(0)) + + return utxoOps, txn, blockHeight, nil +} + +func TestReactTxns(t *testing.T) { + // Test constants + const feeRateNanosPerKb = uint64(101) + var err error + + //Initialize test chain and miner + chain, params, db := NewLowDifficultyBlockchain() + mempool, miner := NewTestMiner(t, chain, params, true /*isSender*/) + + // Mine a few blocks to give the senderPkString some money. + for ii := 0; ii < 20; ii++ { + _, err := miner.MineAndProcessSingleBlock(0 /*threadIndex*/, mempool) + require.NoError(t, err) + } + + // We build the testMeta obj after mining blocks so that we save the correct block height. + testMeta := &TestMeta{ + t: t, + chain: chain, + params: params, + db: db, + mempool: mempool, + miner: miner, + savedHeight: chain.blockTip().Height + 1, + } + + // Helpers + type User struct { + Pub string + Priv string + PkBytes []byte + PublicKey *PublicKey + Pkid *PKID + } + + //TODO Use this correctly + //deso := User{ + // PublicKey: &ZeroPublicKey, + // Pkid: &ZeroPKID, + //} + + m0 := User{ + Pub: m0Pub, + Priv: m0Priv, + PkBytes: m0PkBytes, + PublicKey: NewPublicKey(m0PkBytes), + Pkid: DBGetPKIDEntryForPublicKey(db, chain.snapshot, m0PkBytes).PKID, + } + + m1 := User{ + Pub: m1Pub, + Priv: m1Priv, + PkBytes: m1PkBytes, + PublicKey: NewPublicKey(m1PkBytes), + Pkid: DBGetPKIDEntryForPublicKey(db, chain.snapshot, m1PkBytes).PKID, + } + + m2 := User{ + Pub: m2Pub, + Priv: m2Priv, + PkBytes: m2PkBytes, + PublicKey: NewPublicKey(m2PkBytes), + Pkid: DBGetPKIDEntryForPublicKey(db, chain.snapshot, m2PkBytes).PKID, + } + + m3 := User{ + Pub: m3Pub, + Priv: m3Priv, + PkBytes: m3PkBytes, + PublicKey: NewPublicKey(m3PkBytes), + Pkid: DBGetPKIDEntryForPublicKey(db, chain.snapshot, m3PkBytes).PKID, + } + + // Setup some convenience functions for the test. + var txnOps [][]*UtxoOperation + var txns []*MsgDeSoTxn + var expectedSenderBalances []uint64 + var expectedRecipientBalances []uint64 + + // We take the block tip to be the blockchain height rather than the + // header chain height. + savedHeight := chain.blockTip().Height + 1 + + // Fund all the keys. + for ii := 0; ii < 5; ii++ { + _registerOrTransferWithTestMeta(testMeta, "m0", senderPkString, m0.Pub, senderPrivString, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1", senderPkString, m1.Pub, senderPrivString, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m2", senderPkString, m2.Pub, senderPrivString, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m3", senderPkString, m3.Pub, senderPrivString, 7e6) + } + + doReactTxn := func( + senderPkBase58Check string, postHash BlockHash, + senderPrivBase58Check string, isRemove bool, emojiReaction rune, feeRateNanosPerKB uint64) { + + expectedSenderBalances = append( + expectedSenderBalances, _getBalance(t, chain, nil, senderPkString)) + expectedRecipientBalances = append( + expectedRecipientBalances, _getBalance(t, chain, nil, recipientPkString)) + + currentOps, currentTxn, _, err := _doReactTxn( + testMeta, feeRateNanosPerKB, senderPkBase58Check, + postHash, senderPrivBase58Check, isRemove, emojiReaction) + require.NoError(t, err) + + txnOps = append(txnOps, currentOps) + txns = append(txns, currentTxn) + } + + submitPost := func( + feeRateNanosPerKB uint64, updaterPkBase58Check string, + updaterPrivBase58Check string, + postHashToModify []byte, + parentStakeID []byte, + bodyObj *DeSoBodySchema, + repostedPostHash []byte, + tstampNanos uint64, + isHidden bool) { + + expectedSenderBalances = append( + expectedSenderBalances, _getBalance(t, chain, nil, senderPkString)) + expectedRecipientBalances = append( + expectedRecipientBalances, _getBalance(t, chain, nil, recipientPkString)) + + currentOps, currentTxn, _, err := _submitPost( + t, chain, db, params, feeRateNanosPerKB, + updaterPkBase58Check, + updaterPrivBase58Check, + postHashToModify, + parentStakeID, + bodyObj, + repostedPostHash, + tstampNanos, + isHidden) + + require.NoError(t, err) + + txnOps = append(txnOps, currentOps) + txns = append(txns, currentTxn) + } + + fakePostHash := BlockHash{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, + 0x30, 0x31, + } + // Attempting "m0 -> fakePostHash" should fail since the post doesn't exist. + _, _, _, err = _doReactTxn( + testMeta, 10 /*feeRateNanosPerKB*/, m0Pub, + fakePostHash, m0Priv, false /*isRemove*/, HappyReaction) + require.Error(t, err) + require.Contains(t, err.Error(), RuleErrorCannotReactNonexistentPost) + + // p1 + submitPost( + 10, /*feeRateNanosPerKB*/ + m0Pub, /*updaterPkBase58Check*/ + m0Priv, /*updaterPrivBase58Check*/ + []byte{}, /*postHashToModify*/ + []byte{}, /*parentStakeID*/ + &DeSoBodySchema{Body: "m0 post body 1 no profile"}, /*body*/ + []byte{}, + 1602947011*1e9, /*tstampNanos*/ + false /*isHidden*/) + post1Txn := txns[len(txns)-1] + post1Hash := *post1Txn.Hash() + + // p2 + { + submitPost( + 10, /*feeRateNanosPerKB*/ + m0Pub, /*updaterPkBase58Check*/ + m0Priv, /*updaterPrivBase58Check*/ + []byte{}, /*postHashToModify*/ + []byte{}, /*parentStakeID*/ + &DeSoBodySchema{Body: "m0 post body 2 no profile"}, /*body*/ + []byte{}, + 1502947012*1e9, /*tstampNanos*/ + false /*isHidden*/) + } + post2Txn := txns[len(txns)-1] + post2Hash := *post2Txn.Hash() + + // p3 + { + submitPost( + 10, /*feeRateNanosPerKB*/ + m1Pub, /*updaterPkBase58Check*/ + m1Priv, /*updaterPrivBase58Check*/ + []byte{}, /*postHashToModify*/ + []byte{}, /*parentStakeID*/ + &DeSoBodySchema{Body: "m1 post body 1 no profile"}, /*body*/ + []byte{}, + 1502947013*1e9, /*tstampNanos*/ + false /*isHidden*/) + } + post3Txn := txns[len(txns)-1] + post3Hash := *post3Txn.Hash() + + // m0 -> p1 (happy) + doReactTxn(m0Pub, post1Hash, m0Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // Duplicating "m0 -> p1" should fail. + _, _, _, err = _doReactTxn( + testMeta, 10 /*feeRateNanosPerKB*/, m0Pub, + post1Hash, m0Priv, false /*isRemove*/, HappyReaction) + require.Error(t, err) + require.Contains(t, err.Error(), RuleErrorReactEntryAlreadyExists) + + // m2 -> p1 (happy) + doReactTxn(m2Pub, post1Hash, m2Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // m3 -> p1 (surprised) + doReactTxn(m3Pub, post1Hash, m3Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // m3 -> p2 (sad) + doReactTxn(m3Pub, post2Hash, m3Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // m1 -> p2 (angry) + doReactTxn(m1Pub, post2Hash, m1Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // m2 -> p3 (surprised) + doReactTxn(m2Pub, post3Hash, m2Priv, false /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + reactingP1 := [][]byte{ + _strToPk(t, m0Pub), + _strToPk(t, m2Pub), + _strToPk(t, m3Pub), + } + + reactingP2 := [][]byte{ + _strToPk(t, m1Pub), + _strToPk(t, m3Pub), + } + + reactingP3 := [][]byte{ + _strToPk(t, m2Pub), + } + + // Verify pks reacting p1 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post1Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP1), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP1, reactingPks[ii]) + } + post1 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post1Hash) + require.Equal(t, uint64(len(reactingP1)), post1.EmojiCount[HappyReaction]) + } + + // Verify pks reacting p2 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post2Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP2), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP2, reactingPks[ii]) + } + post2 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post2Hash) + require.Equal(t, uint64(len(reactingP2)), post2.EmojiCount[HappyReaction]) + } + + // Verify pks reacting p3 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post3Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP3), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP3, reactingPks[ii]) + } + post3 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post3Hash) + require.Equal(t, uint64(len(reactingP3)), post3.EmojiCount[HappyReaction]) + } + + m0Reacts := []BlockHash{ + post1Hash, + } + + m1Reacts := []BlockHash{ + post2Hash, + } + + m2Reacts := []BlockHash{ + post1Hash, + post3Hash, + } + + m3Reacts := []BlockHash{ + post1Hash, + post2Hash, + } + + // Verify m0's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m0Pub)) + require.NoError(t, err) + require.Equal(t, len(m0Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m0Reacts, *reactedPostHashes[ii]) + } + } + + // Verify m1's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m1Pub)) + require.NoError(t, err) + require.Equal(t, len(m1Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m1Reacts, *reactedPostHashes[ii]) + } + } + + // Verify m2's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m2Pub)) + require.NoError(t, err) + require.Equal(t, len(m2Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m2Reacts, *reactedPostHashes[ii]) + } + } + + // Verify m3's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m3Pub)) + require.NoError(t, err) + require.Equal(t, len(m3Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m3Reacts, *reactedPostHashes[ii]) + } + } + + // Try an removing a reaction. + // + // m0 -> p1 (remove, happy) + doReactTxn(m0Pub, post1Hash, m0Priv, true /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // m3 -> p2 (remove, happy) + doReactTxn(m3Pub, post2Hash, m3Priv, true /*isRemove*/, HappyReaction, 10 /*feeRateNanosPerKB*/) + + // Duplicating "m0 -> p1" (unfollow) should fail. + _, _, _, err = _doReactTxn( + testMeta, 10 /*feeRateNanosPerKB*/, m0Pub, + post1Hash, m0Priv, true /*isRemove*/, HappyReaction) + require.Error(t, err) + require.Contains(t, err.Error(), RuleErrorCannotRemoveReactionWithoutAnExistingReaction) + + reactingP1 = [][]byte{ + _strToPk(t, m2Pub), + _strToPk(t, m3Pub), + } + + reactingP2 = [][]byte{ + _strToPk(t, m1Pub), + } + + // Verify pks reacting p1 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post1Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP1), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP1, reactingPks[ii]) + } + post1 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post1Hash) + require.Equal(t, uint64(len(reactingP1)), post1.EmojiCount[HappyReaction]) + } + + // Verify pks reacting p2 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post2Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP2), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP2, reactingPks[ii]) + } + post2 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post2Hash) + require.Equal(t, uint64(len(reactingP2)), post2.EmojiCount[HappyReaction]) + } + + m3Reacts = []BlockHash{ + post1Hash, + } + + // Verify m0 has no reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m0Pub)) + require.NoError(t, err) + require.Equal(t, 0, len(reactedPostHashes)) + } + + // Verify m3's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m3Pub)) + require.NoError(t, err) + require.Equal(t, len(m3Reacts), len(reactedPostHashes)) + for i := 0; i < len(reactedPostHashes); i++ { + require.Contains(t, m3Reacts, *reactedPostHashes[i]) + } + } + + // =================================================================================== + // Finish it off with some transactions + // =================================================================================== + _registerOrTransferWithTestMeta(testMeta, "m0", senderPkString, m0.Pub, senderPrivString, 42e6) + _registerOrTransferWithTestMeta(testMeta, "m1", senderPkString, m1.Pub, senderPrivString, 42e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m1 -> m0", m1Pub, m0Pub, m1Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + _registerOrTransferWithTestMeta(testMeta, "m0 -> m1", m0Pub, m1Pub, m0Priv, 7e6) + + // Roll back all of the above using the utxoOps from each. + for ii := 0; ii < len(txnOps); ii++ { + backwardIter := len(txnOps) - 1 - ii + currentOps := txnOps[backwardIter] + currentTxn := txns[backwardIter] + fmt.Printf( + "Disconnecting transaction with type %v index %d (going backwards)\n", + currentTxn.TxnMeta.GetTxnType(), backwardIter) + + utxoView, err := NewUtxoView(testMeta.db, testMeta.params, testMeta.chain.postgres, testMeta.chain.snapshot) + require.NoError(t, err) + + currentHash := currentTxn.Hash() + err = utxoView.DisconnectTransaction(currentTxn, currentHash, currentOps, savedHeight) + require.NoError(t, err) + + require.NoError(t, utxoView.FlushToDb(0)) + + // After disconnecting, the balances should be restored to what they + // were before this transaction was applied. + require.Equal(t, + int64(expectedSenderBalances[backwardIter]), + int64(_getBalance(t, chain, nil, senderPkString))) + require.Equal(t, + expectedRecipientBalances[backwardIter], + _getBalance(t, chain, nil, recipientPkString)) + + // Here we check the reactcounts after all the reactentries have been disconnected. + if backwardIter == 19 { + post1 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post1Hash) + require.Equal(t, uint64(0), post1.EmojiCount[HappyReaction]) + post2 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post2Hash) + require.Equal(t, uint64(0), post2.EmojiCount[HappyReaction]) + post3 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post3Hash) + require.Equal(t, uint64(0), post3.EmojiCount[HappyReaction]) + } + } + + _executeAllTestRollbackAndFlush(testMeta) + + // TODO (Michel) Everything below is unecessary since we call _executeAllTestRollbackAndFlush + + // Apply all the transactions to a mempool object and make sure we don't get any + // errors. Verify the balances align as we go. + for ii, tx := range txns { + // See comment above on this transaction. + fmt.Printf("Adding txn %d of type %v to mempool\n", ii, tx.TxnMeta.GetTxnType()) + + require.Equal(t, expectedSenderBalances[ii], _getBalance(t, chain, mempool, senderPkString)) + require.Equal(t, expectedRecipientBalances[ii], _getBalance(t, chain, mempool, recipientPkString)) + + _, err := mempool.ProcessTransaction(tx, false, false, 0, true) + require.NoError(t, err, "Problem adding transaction %d to mempool: %v", ii, tx) + } + + // Apply all the transactions to a view and flush the view to the db. + utxoView, err := NewUtxoView(testMeta.db, testMeta.params, testMeta.chain.postgres, testMeta.chain.snapshot) + require.NoError(t, err) + for ii, txn := range txns { + fmt.Printf("Adding txn %v of type %v to UtxoView\n", ii, txn.TxnMeta.GetTxnType()) + + // Always use height+1 for validation since it's assumed the transaction will + // get mined into the next block. + txHash := txn.Hash() + blockHeight := chain.blockTip().Height + 1 + _, _, _, _, err := + utxoView.ConnectTransaction(txn, txHash, getTxnSize(*txn), blockHeight, true /*verifySignature*/, false /*ignoreUtxos*/) + require.NoError(t, err) + } + // Flush the utxoView after having added all the transactions. + require.NoError(t, utxoView.FlushToDb(0)) + + testConnectedState := func() { + reactingP1 = [][]byte{ + _strToPk(t, m2Pub), + _strToPk(t, m3Pub), + } + + reactingP2 = [][]byte{ + _strToPk(t, m1Pub), + } + + reactingP3 := [][]byte{ + _strToPk(t, m2Pub), + } + + // Verify pks reacting p1 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post1Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP1), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP1, reactingPks[ii]) + } + post1 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post1Hash) + require.Equal(t, uint64(len(reactingP1)), post1.EmojiCount[HappyReaction]) + } + + // Verify pks reacting p2 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post2Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP2), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP2, reactingPks[ii]) + } + post2 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post2Hash) + require.Equal(t, uint64(len(reactingP2)), post2.EmojiCount[HappyReaction]) + } + + // Verify pks reacting p3 and check reactcount. + { + reactingPks, err := DbGetReactorPubKeysReactingToPostHash(db, post3Hash) + require.NoError(t, err) + require.Equal(t, len(reactingP3), len(reactingPks)) + for ii := 0; ii < len(reactingPks); ii++ { + require.Contains(t, reactingP3, reactingPks[ii]) + } + post3 := DBGetPostEntryByPostHash(testMeta.db, testMeta.chain.snapshot, &post3Hash) + require.Equal(t, uint64(len(reactingP3)), post3.EmojiCount[HappyReaction]) + } + + m1Reacts := []BlockHash{ + post2Hash, + } + + m2Reacts := []BlockHash{ + post1Hash, + post3Hash, + } + + m3Reacts = []BlockHash{ + post1Hash, + } + + // Verify m0 has no reactions. + { + followPks, err := DbGetPostHashesYouReact(db, _strToPk(t, m0Pub)) + require.NoError(t, err) + require.Equal(t, 0, len(followPks)) + } + + // Verify m1's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m1Pub)) + require.NoError(t, err) + require.Equal(t, len(m1Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m1Reacts, *reactedPostHashes[ii]) + } + } + + // Verify m2's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m2Pub)) + require.NoError(t, err) + require.Equal(t, len(m2Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m2Reacts, *reactedPostHashes[ii]) + } + } + + // Verify m3's reactions. + { + reactedPostHashes, err := DbGetPostHashesYouReact(db, _strToPk(t, m3Pub)) + require.NoError(t, err) + require.Equal(t, len(m3Reacts), len(reactedPostHashes)) + for ii := 0; ii < len(reactedPostHashes); ii++ { + require.Contains(t, m3Reacts, *reactedPostHashes[ii]) + } + } + } + testConnectedState() + + // Disconnect the transactions from a single view in the same way as above + // i.e. without flushing each time. + utxoView2, err := NewUtxoView(testMeta.db, testMeta.params, testMeta.chain.postgres, testMeta.chain.snapshot) + require.NoError(t, err) + for ii := 0; ii < len(txnOps); ii++ { + backwardIter := len(txnOps) - 1 - ii + fmt.Printf("Disconnecting transaction with index %d (going backwards)\n", backwardIter) + currentOps := txnOps[backwardIter] + currentTxn := txns[backwardIter] + + currentHash := currentTxn.Hash() + err = utxoView2.DisconnectTransaction(currentTxn, currentHash, currentOps, savedHeight) + require.NoError(t, err) + } + require.NoError(t, utxoView2.FlushToDb(0)) + require.Equal(t, expectedSenderBalances[0], _getBalance(t, chain, nil, senderPkString)) + require.Equal(t, expectedRecipientBalances[0], _getBalance(t, chain, nil, recipientPkString)) + + _executeAllTestRollbackAndFlush(testMeta) + + // All the txns should be in the mempool already so mining a block should put + // all those transactions in it. + block, err := miner.MineAndProcessSingleBlock(0 /*threadIndex*/, mempool) + require.NoError(t, err) + // Add one for the block reward. Now we have a meaty block. + require.Equal(t, len(txnOps)+1, len(block.Txns)) + // Estimate the transaction fees of the tip block in various ways. + { + // Threshold above what's in the block should return the default fee at all times. + require.Equal(t, int64(0), int64(chain.EstimateDefaultFeeRateNanosPerKB(.1, 0))) + require.Equal(t, int64(7), int64(chain.EstimateDefaultFeeRateNanosPerKB(.1, 7))) + // Threshold below what's in the block should return the max of the median + // and the minfee. This means with a low minfee the value returned should be + // higher. And with a high minfee the value returned should be equal to the + // fee. + require.Equal(t, int64(7), int64(chain.EstimateDefaultFeeRateNanosPerKB(0, 7))) + require.Equal(t, int64(4), int64(chain.EstimateDefaultFeeRateNanosPerKB(0, 0))) + require.Equal(t, int64(7), int64(chain.EstimateDefaultFeeRateNanosPerKB(.01, 7))) + require.Equal(t, int64(4), int64(chain.EstimateDefaultFeeRateNanosPerKB(.01, 1))) + } + + testConnectedState() + + _executeAllTestRollbackAndFlush(testMeta) +} + +// func TestReactTxns +// - one successful happy, sad, angry, confused +// - one failure (invalid character?, not amongst the other characters) + +// func _createReactTxn +// func _connectReactTxn +// func _doReactTxnWithTestMeta +// func _doReactRxnErrorToBeDefined +// func Eq +// func ToEntry +// func TestFlushingReactTxn diff --git a/lib/block_view_types.go b/lib/block_view_types.go index 1fc251b2e..ffd7b49d4 100644 --- a/lib/block_view_types.go +++ b/lib/block_view_types.go @@ -79,6 +79,7 @@ const ( EncoderTypeMessagingGroupMember EncoderTypeForbiddenPubKeyEntry EncoderTypeLikeEntry + EncoderTypeReactEntry EncoderTypeNFTEntry EncoderTypeNFTBidEntry EncoderTypeNFTBidEntryBundle @@ -118,6 +119,7 @@ const ( EncoderTypeUpdateProfileTxindexMetadata EncoderTypeSubmitPostTxindexMetadata EncoderTypeLikeTxindexMetadata + EncoderTypeReactTxindexMetadata EncoderTypeFollowTxindexMetadata EncoderTypePrivateMessageTxindexMetadata EncoderTypeSwapIdentityTxindexMetadata @@ -225,6 +227,8 @@ func (encoderType EncoderType) New() DeSoEncoder { return &SubmitPostTxindexMetadata{} case EncoderTypeLikeTxindexMetadata: return &LikeTxindexMetadata{} + case EncoderTypeReactTxindexMetadata: + return &ReactTxindexMetadata{} case EncoderTypeFollowTxindexMetadata: return &FollowTxindexMetadata{} case EncoderTypePrivateMessageTxindexMetadata: @@ -517,8 +521,9 @@ const ( OperationTypeDAOCoinTransfer OperationType = 26 OperationTypeSpendingLimitAccounting OperationType = 27 OperationTypeDAOCoinLimitOrder OperationType = 28 + OperationTypeReact OperationType = 29 - // NEXT_TAG = 29 + // NEXT_TAG = 30 ) func (op OperationType) String() string { @@ -688,6 +693,10 @@ type UtxoOperation struct { PrevLikeEntry *LikeEntry PrevLikeCount uint64 + // Save the previous emoji reactions + PrevReactEntry *ReactionEntry + PrevEmojiCount map[rune]uint64 + // For disconnecting diamonds. PrevDiamondEntry *DiamondEntry @@ -2267,6 +2276,62 @@ func (likeEntry *LikeEntry) GetEncoderType() EncoderType { return EncoderTypeLikeEntry } +func MakeReactionKey(userPk []byte, ReactPostHash BlockHash, ReactEmoji rune) ReactionKey { + return ReactionKey{ + ReactorPubKey: *NewPublicKey(userPk), + ReactedPostHash: ReactPostHash, + ReactEmoji: ReactEmoji, + } +} + +type ReactionKey struct { + ReactorPubKey PublicKey + ReactedPostHash BlockHash + ReactEmoji rune +} + +type ReactionEntry struct { + ReactorPubKey []byte + ReactedPostHash *BlockHash + ReactEmoji rune + // Whether this entry is deleted in the view + isDeleted bool +} + +func (reactEntry *ReactionEntry) RawEncodeWithoutMetadata(blockHeight uint64, skipMetadata ...bool) []byte { + var data []byte + + data = append(data, EncodeByteArray(reactEntry.ReactorPubKey)...) + data = append(data, EncodeToBytes(blockHeight, reactEntry.ReactedPostHash, skipMetadata...)...) + data = append(data, EncodeByteArray([]byte(string(reactEntry.ReactEmoji)))...) + return data +} + +func (reactEntry *ReactionEntry) RawDecodeWithoutMetadata(blockHeight uint64, rr *bytes.Reader) error { + var err error + + reactEntry.ReactorPubKey, err = DecodeByteArray(rr) + if err != nil { + return errors.Wrapf(err, "ReactionEntry.Decode: problem reading ReactorPubKey") + } + reactedPostHash := &BlockHash{} + if exist, err := DecodeFromBytes(reactedPostHash, rr); exist && err == nil { + reactEntry.ReactedPostHash = reactedPostHash + } else if err != nil { + return errors.Wrapf(err, "ReactionEntry.Decode: problem reading ReactedPostHash") + } + //TODO (Michel) we need to decode ReactEmoji here + return nil +} + +func (reactEntry *ReactionEntry) GetVersionByte(blockHeight uint64) byte { + return 0 +} + +func (reactEntry *ReactionEntry) GetEncoderType() EncoderType { + return EncoderTypeReactEntry +} + func MakeNFTKey(nftPostHash *BlockHash, serialNumber uint64) NFTKey { return NFTKey{ NFTPostHash: *nftPostHash, @@ -3000,6 +3065,9 @@ type PostEntry struct { // Counter of users that have liked this post. LikeCount uint64 + // Counter of emoji reactions that this post has. + EmojiCount map[rune]uint64 + // Counter of users that have reposted this post. RepostCount uint64 diff --git a/lib/blockchain.go b/lib/blockchain.go index 0df49703c..e9138a159 100644 --- a/lib/blockchain.go +++ b/lib/blockchain.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "github.com/holiman/uint256" + "golang.org/x/text/unicode/norm" "math" "math/big" "reflect" @@ -1492,8 +1493,8 @@ func CheckTransactionSanity(txn *MsgDeSoTxn) error { // // TODO: The above is easily fixed by requiring something like block height to // be present in the ExtraNonce field. - canHaveZeroInputs := (txn.TxnMeta.GetTxnType() == TxnTypeBitcoinExchange || - txn.TxnMeta.GetTxnType() == TxnTypePrivateMessage) + canHaveZeroInputs := txn.TxnMeta.GetTxnType() == TxnTypeBitcoinExchange || + txn.TxnMeta.GetTxnType() == TxnTypePrivateMessage if len(txn.TxInputs) == 0 && !canHaveZeroInputs { glog.V(2).Infof("CheckTransactionSanity: Txn needs at least one input: %v", spew.Sdump(txn)) return RuleErrorTxnMustHaveAtLeastOneInput @@ -3056,6 +3057,66 @@ func (bc *Blockchain) CreateLikeTxn( return txn, totalInput, changeAmount, fees, nil } +func (bc *Blockchain) CreateReactTxn( + userPublicKey []byte, postHash BlockHash, isRemove bool, emojiReaction rune, + minFeeRateNanosPerKB uint64, mempool *DeSoMempool, additionalOutputs []*DeSoOutput) ( + _txn *MsgDeSoTxn, _totalInput uint64, _changeAmount uint64, _fees uint64, + _err error) { + + //TODO Where would be the best place to place the validation function? + // At the moment, only support happy, sad, angry and surprised + //TODO (Michel) Validation should at a minimum live in the connect + // react logic. I recommend defining a regex or a validation function that + // can be called in the connect logic and then re-using that validation elsewhere. + AcceptedReactions := [4]rune{'😊', '😥', '😠', '😮'} + // Validate emoji reaction + var isValidEmoji = func(emoji rune) bool { + for _, acceptedReaction := range AcceptedReactions { + if emoji == acceptedReaction { + return true + } + } + return false + } + + // TODO Fix bug returning invalid for valid inputs + //TODO (Michel) We need to ensure that the rune is normalized + // in the connect logic. Anybody could construct the transaction + // on their own without using our API, so we need to make sure every + // react emoji is consistent when we're connecting. + normalizedReaction := norm.NFC.String(string(emojiReaction)) + if !isValidEmoji(rune(normalizedReaction[0])) { + return nil, 0, 0, 0, errors.New("CreateReactTxn: Invalid emoji input: ") + } + + // A React transaction doesn't need any inputs or outputs (except additionalOutputs provided). + txn := &MsgDeSoTxn{ + PublicKey: userPublicKey, + TxnMeta: &ReactMetadata{ + PostHash: &postHash, + EmojiReaction: rune(normalizedReaction[0]), + IsRemove: isRemove, + }, + TxOutputs: additionalOutputs, + // We wait to compute the signature until we've added all the + // inputs and change. + } + + totalInput, spendAmount, changeAmount, fees, err := + bc.AddInputsAndChangeToTransaction(txn, minFeeRateNanosPerKB, mempool) + if err != nil { + return nil, 0, 0, 0, errors.Wrapf( + err, "CreateReactTxn: Problem adding inputs: ") + } + + // Sanity-check that the spendAmount is zero. + if err = amountEqualsAdditionalOutputs(spendAmount, additionalOutputs); err != nil { + return nil, 0, 0, 0, fmt.Errorf("CreateReactTxn: %v", err) + } + + return txn, totalInput, changeAmount, fees, nil +} + func (bc *Blockchain) CreateFollowTxn( senderPublicKey []byte, followedPublicKey []byte, isUnfollow bool, minFeeRateNanosPerKB uint64, mempool *DeSoMempool, additionalOutputs []*DeSoOutput) ( diff --git a/lib/db_adapter.go b/lib/db_adapter.go index 1e955af9a..aead4fe87 100644 --- a/lib/db_adapter.go +++ b/lib/db_adapter.go @@ -1,6 +1,8 @@ package lib import ( + "fmt" + "github.com/btcsuite/btcd/btcec" "github.com/dgraph-io/badger/v3" ) @@ -131,3 +133,38 @@ func (adapter *DbAdapter) GetPKIDForPublicKey(pkBytes []byte) *PKID { return DBGetPKIDEntryForPublicKey(adapter.badgerDb, adapter.snapshot, pkBytes).PKID } + +// +// Reactions +// +func (adapter *DbAdapter) GetReactionsForPost(postHash *BlockHash, reactionEmoji rune) ([]*ReactionEntry, error) { + var reactionEntries []*ReactionEntry + if adapter.postgresDb != nil { + reactions := adapter.postgresDb.GetReactionsForPost(postHash, reactionEmoji) + for _, reaction := range reactions { + reactionEntries = append(reactionEntries, reaction.NewReactionEntry()) + } + } else { + handle := adapter.badgerDb + dbPrefix := append([]byte{}, Prefixes.PrefixPostHashToReactorPubKey...) + dbPrefix = append(dbPrefix, postHash[:]...) + keysFound, _ := EnumerateKeysForPrefix(handle, dbPrefix) + + // Iterate over all the db keys & values and load them into the view. + expectedKeyLength := 1 + HashSizeBytes + btcec.PubKeyBytesLenCompressed + for _, key := range keysFound { + // Sanity check that this is a reasonable key. + if len(key) != expectedKeyLength { + return nil, fmt.Errorf("DbAdapter.GetReactionsForPost: Invalid key length found: %d", len(key)) + } + reactorPubKey := key[1+HashSizeBytes:] + reactionEntry := &ReactionEntry{ + ReactorPubKey: reactorPubKey, + ReactedPostHash: postHash, + ReactEmoji: reactionEmoji, + } + reactionEntries = append(reactionEntries, reactionEntry) + } + } + return reactionEntries, nil +} diff --git a/lib/db_utils.go b/lib/db_utils.go index f823f8b01..0d46f2bd3 100644 --- a/lib/db_utils.go +++ b/lib/db_utils.go @@ -174,6 +174,17 @@ type DBPrefixes struct { PrefixLikerPubKeyToLikedPostHash []byte `prefix_id:"[30]" is_state:"true"` PrefixLikedPostHashToLikerPubKey []byte `prefix_id:"[31]" is_state:"true"` + //TODO (Michel) We need to include react emoji in these indices. I think the correct indices would be + // 1. PrefixReactorPubKeyToPostHashReactEmojiL + // 2. PrefixPostHashReactEmojiToReactorPubKey + // this allows us to get all the different reactions a user gave to a single post easily (index 1) as well as get a list of all users who gave a "happy" react to a given post (index 2) + + // Prefixes for reactions: + // -> <> + // -> <> + PrefixReactorPubKeyToPostHash []byte `prefix_id:"[63]" is_state:"true"` + PrefixPostHashToReactorPubKey []byte `prefix_id:"[64]" is_state:"true"` + // Prefixes for creator coin fields: // -> // -> @@ -322,7 +333,7 @@ type DBPrefixes struct { PrefixDAOCoinLimitOrder []byte `prefix_id:"[60]" is_state:"true"` PrefixDAOCoinLimitOrderByTransactorPKID []byte `prefix_id:"[61]" is_state:"true"` PrefixDAOCoinLimitOrderByOrderID []byte `prefix_id:"[62]" is_state:"true"` - // NEXT_TAG: 63 + // NEXT_TAG: 65 } // StatePrefixToDeSoEncoder maps each state prefix to a DeSoEncoder type that is stored under that prefix. @@ -479,8 +490,13 @@ func StatePrefixToDeSoEncoder(prefix []byte) (_isEncoder bool, _encoder DeSoEnco } else if bytes.Equal(prefix, Prefixes.PrefixDAOCoinLimitOrderByOrderID) { // prefix_id:"[62]" return true, &DAOCoinLimitOrderEntry{} + } else if bytes.Equal(prefix, Prefixes.PrefixReactorPubKeyToPostHash) { + // prefix_id:"[63]" + return false, nil + } else if bytes.Equal(prefix, Prefixes.PrefixPostHashToReactorPubKey) { + // prefix_id:"[64]" + return false, nil } - return true, nil } @@ -2032,6 +2048,165 @@ func DbGetLikerPubKeysLikingAPostHash(handle *badger.DB, likedPostHash BlockHash return userPubKeys, nil } +// ------------------------------------------------------------------------------------- +// React mapping functions +// -> <> +// -> <> +// ------------------------------------------------------------------------------------- +// +//TODO (Michel) "reacted" instead of "liked" in all these DB functions +//TODO these will probably need to change slightly to accommodate multiple reactions with different emojis per post +func _dbKeyForReactorPubKeyToPostHashMapping( + userPubKey []byte, postHash BlockHash, reactionEmoji rune) []byte { + // Make a copy to avoid multiple calls to this function re-using the same slice. + prefixCopy := append([]byte{}, Prefixes.PrefixReactorPubKeyToPostHash...) + key := append(prefixCopy, userPubKey...) + key = append(key, postHash[:]...) + key = append(key, []byte(string(reactionEmoji))...) + return key +} + +func _dbKeyForPostHashToReactorPubKeyMapping( + postHash BlockHash, userPubKey []byte) []byte { + // Make a copy to avoid multiple calls to this function re-using the same slice. + prefixCopy := append([]byte{}, Prefixes.PrefixPostHashToReactorPubKey...) + key := append(prefixCopy, postHash[:]...) + key = append(key, userPubKey...) + return key +} + +func _dbSeekPrefixForPostHashesYouReact(yourPubKey []byte) []byte { + // Make a copy to avoid multiple calls to this function re-using the same slice. + prefixCopy := append([]byte{}, Prefixes.PrefixReactorPubKeyToPostHash...) + return append(prefixCopy, yourPubKey...) +} + +func _dbSeekPrefixForReactorPubKeysReactingToPostHash(likedPostHash BlockHash) []byte { + // Make a copy to avoid multiple calls to this function re-using the same slice. + prefixCopy := append([]byte{}, Prefixes.PrefixPostHashToReactorPubKey...) + return append(prefixCopy, likedPostHash[:]...) +} + +// Note that this adds a mapping for the user *and* the liked post. +func DbPutReactMappingsWithTxn( + txn *badger.Txn, userPubKey []byte, likedPostHash BlockHash, reactionEmoji rune) error { + + if len(userPubKey) != btcec.PubKeyBytesLenCompressed { + return fmt.Errorf("DbPutReactMappingsWithTxn: User public key "+ + "length %d != %d", len(userPubKey), btcec.PubKeyBytesLenCompressed) + } + + if err := txn.Set(_dbKeyForReactorPubKeyToPostHashMapping(userPubKey, likedPostHash, reactionEmoji), []byte{}); err != nil { + return errors.Wrapf(err, "DbPutReactMappingsWithTxn: Problem adding user to reacted post mapping: ") + } + + if err := txn.Set(_dbKeyForPostHashToReactorPubKeyMapping(likedPostHash, userPubKey), []byte{}); err != nil { + return errors.Wrapf(err, "DbPutReactMappingsWithTxn: Problem adding reacted post to user mapping: ") + } + + return nil +} + +func DbPutReactMappings( + handle *badger.DB, userPubKey []byte, likedPostHash BlockHash, reactionEmoji rune) error { + + return handle.Update(func(txn *badger.Txn) error { + return DbPutReactMappingsWithTxn(txn, userPubKey, likedPostHash, reactionEmoji) + }) +} + +func DbGetReactorPubKeyToPostHashMappingWithTxn( + txn *badger.Txn, userPubKey []byte, likedPostHash BlockHash, reactionEmoji rune) []byte { + + key := _dbKeyForReactorPubKeyToPostHashMapping(userPubKey, likedPostHash, reactionEmoji) + _, err := txn.Get(key) + if err != nil { + return nil + } + + // Typically we return a DB entry here but we don't store anything for like mappings. + // We use this function instead of one returning true / false for feature consistency. + return []byte{} +} + +func DbGetReactorPubKeyToPostHashMapping( + db *badger.DB, userPubKey []byte, likedPostHash BlockHash, reactionEmoji rune) []byte { + var ret []byte + db.View(func(txn *badger.Txn) error { + ret = DbGetReactorPubKeyToPostHashMappingWithTxn(txn, userPubKey, likedPostHash, reactionEmoji) + return nil + }) + return ret +} + +// Note this deletes the like for the user *and* the liked post since a mapping +// should exist for each. +func DbDeleteReactMappingsWithTxn( + txn *badger.Txn, userPubKey []byte, postHash BlockHash, reactionEmoji rune) error { + + // First check that a mapping exists. If one doesn't exist then there's nothing to do. + existingMapping := DbGetReactorPubKeyToPostHashMappingWithTxn(txn, userPubKey, postHash, reactionEmoji) + if existingMapping == nil { + return nil + } + + // When a message exists, delete the mapping for the sender and receiver. + if err := txn.Delete( + _dbKeyForReactorPubKeyToPostHashMapping(userPubKey, postHash, reactionEmoji)); err != nil { + return errors.Wrapf(err, "DbDeleteLikeMappingsWithTxn: Deleting "+ + "userPubKey %s and postHash %s failed", + PkToStringBoth(userPubKey), postHash) + } + if err := txn.Delete( + _dbKeyForPostHashToReactorPubKeyMapping(postHash, userPubKey)); err != nil { + return errors.Wrapf(err, "DbDeleteLikeMappingsWithTxn: Deleting "+ + "postHash %s and userPubKey %s failed", + PkToStringBoth(postHash[:]), PkToStringBoth(userPubKey)) + } + + return nil +} + +func DbDeleteReactMappings( + handle *badger.DB, userPubKey []byte, postHash BlockHash, reactionEmoji rune) error { + return handle.Update(func(txn *badger.Txn) error { + return DbDeleteReactMappingsWithTxn(txn, userPubKey, postHash, reactionEmoji) + }) +} + +func DbGetPostHashesYouReact(handle *badger.DB, yourPublicKey []byte) ( + _postHashes []*BlockHash, _err error) { + + prefix := _dbSeekPrefixForPostHashesYouReact(yourPublicKey) + keysFound, _ := _enumerateKeysForPrefix(handle, prefix) + + var postHashesYouReact []*BlockHash + for _, keyBytes := range keysFound { + // We must slice off the first byte and userPubKey to get the postHash. + postHash := &BlockHash{} + copy(postHash[:], keyBytes[1+btcec.PubKeyBytesLenCompressed:]) + postHashesYouReact = append(postHashesYouReact, postHash) + } + + return postHashesYouReact, nil +} + +func DbGetReactorPubKeysReactingToPostHash(handle *badger.DB, postHash BlockHash) ( + _pubKeys [][]byte, _err error) { + + prefix := _dbSeekPrefixForReactorPubKeysReactingToPostHash(postHash) + keysFound, _ := _enumerateKeysForPrefix(handle, prefix) + + var userPubKeys [][]byte + for _, keyBytes := range keysFound { + // We must slice off the first byte and postHash to get the userPubKey. + userPubKey := keyBytes[1+HashSizeBytes:] + userPubKeys = append(userPubKeys, userPubKey) + } + + return userPubKeys, nil +} + // ------------------------------------------------------------------------------------- // Reposts mapping functions // -> <> @@ -4878,6 +5053,53 @@ func (txnMeta *LikeTxindexMetadata) GetEncoderType() EncoderType { return EncoderTypeLikeTxindexMetadata } +type ReactTxindexMetadata struct { + // ReactorPublicKeyBase58Check = TransactorPublicKeyBase58Check + IsRemove bool + EmojiReaction rune + + PostHashHex string + // PosterPublicKeyBase58Check in AffectedPublicKeys +} + +func (txnMeta *ReactTxindexMetadata) RawEncodeWithoutMetadata(blockHeight uint64, skipMetadata ...bool) []byte { + var data []byte + + data = append(data, BoolToByte(txnMeta.IsRemove)) + data = append(data, []byte(string(txnMeta.EmojiReaction))...) + data = append(data, EncodeByteArray([]byte(txnMeta.PostHashHex))...) + return data +} + +func (txnMeta *ReactTxindexMetadata) RawDecodeWithoutMetadata(blockHeight uint64, rr *bytes.Reader) error { + var err error + + //TODO (Michel) I usually put isRemove as the last one. I think logically we would expect PostHashHex then EmojiReaction then IsRemove + txnMeta.IsRemove, err = ReadBoolByte(rr) + if err != nil { + return errors.Wrapf(err, "ReactTxindexMetadata.Decode: Empty IsRemove") + } + txnMeta.EmojiReaction, _, err = rr.ReadRune() + if err != nil { + return errors.Wrapf(err, "ReactTxindexMetadata.Decode: Empty EmojiReaction") + } + postHashHexBytes, err := DecodeByteArray(rr) + if err != nil { + return errors.Wrapf(err, "ReactTxindexMetadata.Decode: problem reading PostHashHex") + } + txnMeta.PostHashHex = string(postHashHexBytes) + + return nil +} + +func (txnMeta *ReactTxindexMetadata) GetVersionByte(blockHeight uint64) byte { + return 0 +} + +func (txnMeta *ReactTxindexMetadata) GetEncoderType() EncoderType { + return EncoderTypeReactTxindexMetadata +} + type FollowTxindexMetadata struct { // FollowerPublicKeyBase58Check = TransactorPublicKeyBase58Check // FollowedPublicKeyBase58Check in AffectedPublicKeys @@ -5387,6 +5609,7 @@ type TransactionMetadata struct { UpdateProfileTxindexMetadata *UpdateProfileTxindexMetadata `json:",omitempty"` SubmitPostTxindexMetadata *SubmitPostTxindexMetadata `json:",omitempty"` LikeTxindexMetadata *LikeTxindexMetadata `json:",omitempty"` + ReactTxindexMetadata *ReactTxindexMetadata `json:",omitempty"` FollowTxindexMetadata *FollowTxindexMetadata `json:",omitempty"` PrivateMessageTxindexMetadata *PrivateMessageTxindexMetadata `json:",omitempty"` SwapIdentityTxindexMetadata *SwapIdentityTxindexMetadata `json:",omitempty"` diff --git a/lib/errors.go b/lib/errors.go index afc3fbd5a..eb724659a 100644 --- a/lib/errors.go +++ b/lib/errors.go @@ -114,6 +114,10 @@ const ( RuleErrorCannotLikeNonexistentPost RuleError = "RuleErrorCannotLikeNonexistentPost" RuleErrorCannotUnlikeWithoutAnExistingLike RuleError = "RuleErrorCannotUnlikeWithoutAnExistingLike" + RuleErrorReactEntryAlreadyExists RuleError = "RuleErrorReactEntryAlreadyExists" + RuleErrorCannotReactNonexistentPost RuleError = "RuleErrorCannotReactNonexistentPost" + RuleErrorCannotRemoveReactionWithoutAnExistingReaction RuleError = "RuleErrorCannotRemoveReactionWithoutAnExistingReaction" + RuleErrorProfileUsernameTooShort RuleError = "RuleErrorProfileUsernameTooShort" RuleErrorProfileDescriptionTooShort RuleError = "RuleErrorProfileDescriptionTooShort" RuleErrorProfileUsernameTooLong RuleError = "RuleErrorProfileUsernameTooLong" diff --git a/lib/mempool.go b/lib/mempool.go index 597e76c0d..067c2246d 100644 --- a/lib/mempool.go +++ b/lib/mempool.go @@ -1387,6 +1387,34 @@ func ComputeTransactionMetadata(txn *MsgDeSoTxn, utxoView *UtxoView, blockHash * Metadata: "PosterPublicKeyBase58Check", }) } + case TxnTypeReact: + realTxMeta := txn.TxnMeta.(*ReactMetadata) + + // ReactorPublicKeyBase58Check = TransactorPublicKeyBase58Check + + txnMeta.ReactTxindexMetadata = &ReactTxindexMetadata{ + IsRemove: realTxMeta.IsRemove, + PostHashHex: hex.EncodeToString(realTxMeta.PostHash[:]), + EmojiReaction: realTxMeta.EmojiReaction, + } + + // Get the public key of the poster and set it as having been affected + // by this like. + // + // PosterPublicKeyBase58Check in AffectedPublicKeys + postHash := &BlockHash{} + copy(postHash[:], realTxMeta.PostHash[:]) + postEntry := utxoView.GetPostEntryForPostHash(postHash) + if postEntry == nil { + glog.V(2).Infof( + "UpdateTxindex: Error creating ReactTxindexMetadata; "+ + "missing post for hash %v: %v", postHash, err) + } else { + txnMeta.AffectedPublicKeys = append(txnMeta.AffectedPublicKeys, &AffectedPublicKey{ + PublicKeyBase58Check: PkToString(postEntry.PosterPublicKey, utxoView.Params), + Metadata: "PosterPublicKeyBase58Check", + }) + } case TxnTypeFollow: realTxMeta := txn.TxnMeta.(*FollowMetadata) diff --git a/lib/network.go b/lib/network.go index a35a0e7d4..354a596c0 100644 --- a/lib/network.go +++ b/lib/network.go @@ -8,7 +8,7 @@ import ( "encoding/hex" "encoding/json" "fmt" - "github.com/decred/dcrd/dcrec/secp256k1/v4" + "golang.org/x/text/unicode/norm" "io" "math" "math/big" @@ -227,8 +227,9 @@ const ( TxnTypeDAOCoin TxnType = 24 TxnTypeDAOCoinTransfer TxnType = 25 TxnTypeDAOCoinLimitOrder TxnType = 26 + TxnTypeReact TxnType = 27 - // NEXT_ID = 27 + // NEXT_ID = 28 ) type TxnString string @@ -260,6 +261,7 @@ const ( TxnStringDAOCoin TxnString = "DAO_COIN" TxnStringDAOCoinTransfer TxnString = "DAO_COIN_TRANSFER" TxnStringDAOCoinLimitOrder TxnString = "DAO_COIN_LIMIT_ORDER" + TxnStringReact TxnString = "REACT" TxnStringUndefined TxnString = "TXN_UNDEFINED" ) @@ -270,7 +272,7 @@ var ( TxnTypeCreatorCoin, TxnTypeSwapIdentity, TxnTypeUpdateGlobalParams, TxnTypeCreatorCoinTransfer, TxnTypeCreateNFT, TxnTypeUpdateNFT, TxnTypeAcceptNFTBid, TxnTypeNFTBid, TxnTypeNFTTransfer, TxnTypeAcceptNFTTransfer, TxnTypeBurnNFT, TxnTypeAuthorizeDerivedKey, TxnTypeMessagingGroup, - TxnTypeDAOCoin, TxnTypeDAOCoinTransfer, TxnTypeDAOCoinLimitOrder, + TxnTypeDAOCoin, TxnTypeDAOCoinTransfer, TxnTypeDAOCoinLimitOrder, TxnTypeReact, } AllTxnString = []TxnString{ TxnStringUnset, TxnStringBlockReward, TxnStringBasicTransfer, TxnStringBitcoinExchange, TxnStringPrivateMessage, @@ -278,7 +280,7 @@ var ( TxnStringCreatorCoin, TxnStringSwapIdentity, TxnStringUpdateGlobalParams, TxnStringCreatorCoinTransfer, TxnStringCreateNFT, TxnStringUpdateNFT, TxnStringAcceptNFTBid, TxnStringNFTBid, TxnStringNFTTransfer, TxnStringAcceptNFTTransfer, TxnStringBurnNFT, TxnStringAuthorizeDerivedKey, TxnStringMessagingGroup, - TxnStringDAOCoin, TxnStringDAOCoinTransfer, TxnStringDAOCoinLimitOrder, + TxnStringDAOCoin, TxnStringDAOCoinTransfer, TxnStringDAOCoinLimitOrder, TxnStringReact, } ) @@ -344,6 +346,8 @@ func (txnType TxnType) GetTxnString() TxnString { return TxnStringDAOCoinTransfer case TxnTypeDAOCoinLimitOrder: return TxnStringDAOCoinLimitOrder + case TxnTypeReact: + return TxnStringReact default: return TxnStringUndefined } @@ -403,6 +407,8 @@ func GetTxnTypeFromString(txnString TxnString) TxnType { return TxnTypeDAOCoinTransfer case TxnStringDAOCoinLimitOrder: return TxnTypeDAOCoinLimitOrder + case TxnStringReact: + return TxnTypeReact default: // TxnTypeUnset means we couldn't find a matching txn type return TxnTypeUnset @@ -470,6 +476,8 @@ func NewTxnMetadata(txType TxnType) (DeSoTxnMetadata, error) { return (&DAOCoinTransferMetadata{}).New(), nil case TxnTypeDAOCoinLimitOrder: return (&DAOCoinLimitOrderMetadata{}).New(), nil + case TxnTypeReact: + return (&ReactMetadata{}).New(), nil default: return nil, fmt.Errorf("NewTxnMetadata: Unrecognized TxnType: %v; make sure you add the new type of transaction to NewTxnMetadata", txType) } @@ -3584,6 +3592,89 @@ func (txnData *LikeMetadata) New() DeSoTxnMetadata { return &LikeMetadata{} } +// ================================================================== +// ReactMetadata +// +// A reaction is an interaction where a user on the platform reacts to a post. +// ================================================================== + +type ReactMetadata struct { + // The user reacting is assumed to be the originator of the + // top-level transaction. + + // The post hash to react to. + PostHash *BlockHash + + // The Unicode for the emoji reaction. + EmojiReaction rune + + // Set to true when a user is requesting to "remove" a reaction. + IsRemove bool +} + +func (txnData *ReactMetadata) GetTxnType() TxnType { + return TxnTypeReact +} + +func (txnData *ReactMetadata) ToBytes(preSignature bool) ([]byte, error) { + // Validate the metadata before encoding it. + // + + var data []byte + + // Add PostHash + // + // We know the post hash is set and has the expected length, so we don't need + // to encode the length here. + data = append(data, txnData.PostHash[:]...) + + // Add IsRemove + data = append(data, BoolToByte(txnData.IsRemove)) + + //TODO (Michel) I would flip these two. Also, will the EmojiReaction always be the same exact length of bytes? if not, we will want to prepend the number of bytes. + // Add EmojiReaction. + // It is possible for a single character to be encoded with different code point sequences. + // By normalizing the Unicode (NFC), we ensure that a character will have a unique code point sequence. + data = append(data, norm.NFC.Bytes([]byte(string(txnData.EmojiReaction)))...) + + return data, nil +} + +func (txnData *ReactMetadata) FromBytes(data []byte) error { + ret := ReactMetadata{} + rr := bytes.NewReader(data) + + // PostHash + ret.PostHash = &BlockHash{} + _, err := io.ReadFull(rr, ret.PostHash[:]) + if err != nil { + return fmt.Errorf( + "ReactMetadata.FromBytes: Error reading PostHash: %v", err) + } + + // IsRemove + ret.IsRemove, err = ReadBoolByte(rr) + if err != nil { + return errors.Wrapf(err, "ReactMetadata.FromBytes: Problem reading IsRemove") + } + + // Emoji reaction + reaction, _, err := rr.ReadRune() + if err != nil { + return fmt.Errorf( + "ReactMetadata.FromBytes: Error reading EmojiReaction: %v", err) + } + + ret.EmojiReaction = reaction + *txnData = ret + + return nil +} + +func (txnData *ReactMetadata) New() DeSoTxnMetadata { + return &ReactMetadata{} +} + // ================================================================== // FollowMetadata // diff --git a/lib/network_test.go b/lib/network_test.go index 3a26f78de..c04169479 100644 --- a/lib/network_test.go +++ b/lib/network_test.go @@ -735,6 +735,64 @@ func TestSerializeUnlike(t *testing.T) { require.Equal(txMeta, testMeta) } +func TestSerializeNoReaction(t *testing.T) { + require := require.New(t) + + txMeta := &ReactMetadata{PostHash: &postHashForTesting1} + + data, err := txMeta.ToBytes(false) + require.NoError(err) + + testMeta, err := NewTxnMetadata(TxnTypeReact) + require.NoError(err) + err = testMeta.FromBytes(data) + require.NoError(err) + require.Equal(txMeta, testMeta) +} + +func TestSerializeRemoveReaction(t *testing.T) { + require := require.New(t) + + txMeta := &ReactMetadata{ + PostHash: &postHashForTesting1, + IsRemove: true, + } + + data, err := txMeta.ToBytes(false) + require.NoError(err) + + testMeta, err := NewTxnMetadata(TxnTypeReact) + require.NoError(err) + err = testMeta.FromBytes(data) + require.NoError(err) + require.Equal(txMeta, testMeta) +} + +func TestSerializeReactions(t *testing.T) { + ValidReactions := []rune{'😊', '😥', '😠', '😮'} + for _, r := range ValidReactions { + _testSerializeSingleReaction(t, r) + } +} + +func _testSerializeSingleReaction(t *testing.T, emoji rune) { + require := require.New(t) + + txMeta := &ReactMetadata{ + PostHash: &postHashForTesting1, + EmojiReaction: emoji, + } + + data, err := txMeta.ToBytes(false) + require.NoError(err) + + testMeta, err := NewTxnMetadata(TxnTypeReact) + require.NoError(err) + err = testMeta.FromBytes(data) + require.NoError(err) + require.Equal(txMeta, testMeta) +} + func TestSerializeFollow(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/lib/notifier.go b/lib/notifier.go index de5959dfb..4c93437c8 100644 --- a/lib/notifier.go +++ b/lib/notifier.go @@ -46,6 +46,7 @@ func (notifier *Notifier) Update() error { var transactions []*PGTransaction err = notifier.db.Model(&transactions).Where("block_hash = ?", block.Hash). Relation("Outputs").Relation("PGMetadataLike").Relation("PGMetadataFollow"). + Relation("PGMetadataReact"). Relation("PGMetadataCreatorCoin").Relation("PGMetadataCreatorCoinTransfer"). Relation("PGMetadataSubmitPost").Select() // TODO: Add NFTs @@ -97,6 +98,20 @@ func (notifier *Notifier) Update() error { Timestamp: block.Timestamp, }) } + } else if transaction.Type == TxnTypeReact { + postHash := transaction.MetadataReact.PostHash + post := DBGetPostEntryByPostHash(notifier.badger, nil, postHash) + if post != nil { + notifications = append(notifications, &PGNotification{ + TransactionHash: transaction.Hash, + Mined: true, + ToUser: post.PosterPublicKey, + FromUser: transaction.PublicKey, + Type: NotificationReact, + PostHash: postHash, + Timestamp: block.Timestamp, + }) + } } else if transaction.Type == TxnTypeFollow { if !transaction.MetadataFollow.IsUnfollow { notifications = append(notifications, &PGNotification{ diff --git a/lib/postgres.go b/lib/postgres.go index 29dcf7a34..92e9e33af 100644 --- a/lib/postgres.go +++ b/lib/postgres.go @@ -138,6 +138,7 @@ type PGTransaction struct { MetadataUpdateProfile *PGMetadataUpdateProfile `pg:"rel:belongs-to,join_fk:transaction_hash"` MetadataFollow *PGMetadataFollow `pg:"rel:belongs-to,join_fk:transaction_hash"` MetadataLike *PGMetadataLike `pg:"rel:belongs-to,join_fk:transaction_hash"` + MetadataReact *PGMetadataReact `pg:"rel:belongs-to,join_fk:transaction_hash"` MetadataCreatorCoin *PGMetadataCreatorCoin `pg:"rel:belongs-to,join_fk:transaction_hash"` MetadataCreatorCoinTransfer *PGMetadataCreatorCoinTransfer `pg:"rel:belongs-to,join_fk:transaction_hash"` MetadataSwapIdentity *PGMetadataSwapIdentity `pg:"rel:belongs-to,join_fk:transaction_hash"` @@ -262,6 +263,17 @@ type PGMetadataLike struct { IsUnlike bool `pg:",use_zero"` } +// PGMetadataReact represents ReactMetadata +type PGMetadataReact struct { + tableName struct{} `pg:"pg_metadata_reactions"` + + TransactionHash *BlockHash `pg:",pk,type:bytea"` + PostHash *BlockHash `pg:",type:bytea"` + IsRemove bool `pg:",use_zero"` + //TODO (Michel) Are you sure this is an integer? I just frankly don't know off the top of my head. + EmojiReaction rune `pg:",type:integer"` +} + // PGMetadataCreatorCoin represents CreatorCoinMetadataa type PGMetadataCreatorCoin struct { tableName struct{} `pg:"pg_metadata_creator_coins"` @@ -457,6 +469,7 @@ const ( NotificationUnknown NotificationType = iota NotificationSendDESO NotificationLike + NotificationReact NotificationFollow NotificationCoinPurchase NotificationCoinTransfer @@ -600,6 +613,22 @@ func (like *PGLike) NewLikeEntry() *LikeEntry { } } +type PGReact struct { + tableName struct{} `pg:"pg_react"` + + ReactorPublicKey []byte `pg:",pk,type:bytea"` + ReactorPostHash *BlockHash `pg:",pk,type:bytea"` + ReactionEmoji rune `pg:",pk,type:bytea"` +} + +func (react *PGReact) NewReactionEntry() *ReactionEntry { + return &ReactionEntry{ + ReactorPubKey: react.ReactorPublicKey, + ReactedPostHash: react.ReactorPostHash, + ReactEmoji: react.ReactionEmoji, + } +} + type PGFollow struct { tableName struct{} `pg:"pg_follows"` @@ -1579,6 +1608,9 @@ func (postgres *Postgres) FlushView(view *UtxoView, blockHeight uint64) error { if err := postgres.flushLikes(tx, view); err != nil { return err } + if err := postgres.flushReacts(tx, view); err != nil { + return err + } if err := postgres.flushFollows(tx, view); err != nil { return err } @@ -1807,6 +1839,44 @@ func (postgres *Postgres) flushLikes(tx *pg.Tx, view *UtxoView) error { return nil } +func (postgres *Postgres) flushReacts(tx *pg.Tx, view *UtxoView) error { + var insertReacts []*PGReact + var deleteReacts []*PGReact + for _, reactionEntry := range view.ReactionKeyToReactionEntry { + if reactionEntry == nil { + continue + } + + react := &PGReact{ + ReactorPublicKey: reactionEntry.ReactorPubKey, + ReactorPostHash: reactionEntry.ReactedPostHash, + ReactionEmoji: reactionEntry.ReactEmoji, + } + + if reactionEntry.isDeleted { + deleteReacts = append(deleteReacts, react) + } else { + insertReacts = append(insertReacts, react) + } + } + + if len(insertReacts) > 0 { + _, err := tx.Model(&insertReacts).WherePK().OnConflict("DO NOTHING").Returning("NULL").Insert() + if err != nil { + return err + } + } + + if len(deleteReacts) > 0 { + _, err := tx.Model(&deleteReacts).Returning("NULL").Delete() + if err != nil { + return err + } + } + + return nil +} + func (postgres *Postgres) flushFollows(tx *pg.Tx, view *UtxoView) error { var insertFollows []*PGFollow var deleteFollows []*PGFollow @@ -2464,6 +2534,42 @@ func (postgres *Postgres) GetLikesForPost(postHash *BlockHash) []*PGLike { return likes } +// +// Reacts +// +func (postgres *Postgres) GetReaction(reactorPublicKey []byte, reactedPostHash *BlockHash, reactionEmoji rune) *PGReact { + react := PGReact{ + ReactorPublicKey: reactorPublicKey, + ReactorPostHash: reactedPostHash, + ReactionEmoji: reactionEmoji, + } + err := postgres.db.Model(&react).WherePK().First() + if err != nil { + return nil + } + return &react +} +func (postgres *Postgres) GetReacts(reacts []*PGReact) []*PGReact { + err := postgres.db.Model(&reacts).WherePK().Select() + if err != nil { + return nil + } + return reacts +} + +func (postgres *Postgres) GetReactionsForPost(postHash *BlockHash, reactionEmoji rune) []*PGReact { + var reacts []*PGReact + err := postgres.db.Model(&reacts). + Where("reactor_post_hash = ?", postHash). + Where("reaction_emoji = ?", reactionEmoji). + Select() + if err != nil { + return nil + } + //TODO (Michel) You'll also want to write a migration for postgres in the migrations directory. + return reacts +} + // // Follows //