From 8a07d8521a2fb2bdd94e24cdae456bd54384fa0b Mon Sep 17 00:00:00 2001 From: Lazy Nina <81658138+lazynina@users.noreply.github.com> Date: Tue, 6 Jul 2021 12:11:45 -0700 Subject: [PATCH] Ln/nifties (#71) * Fix nil pointer error on invalid file upload * Add lock to seed BitClout and monkey-patch blockchain.com last_price bug * Trigger rebuild * Trigger rebuild * return graylist and blacklist status in get-single-profile request (#55) * Ln/send seed bitclout (#56) * retry sending BitClout once in the event of an error after sleeping for 5 seconds. glog the errors * more glog * add more details in error logs when send seed bitclout fails * Don't use the 24h price when fetching from Blockchain.com * Track last hour of pricing data (#59) * keep one hours worth of last trade price history and take the max of those values * add more comments to explain the culling logic * add datadog logging * [stable] Release 1.0.4 * Move github actions to buildkite * Upgrade badgerdb * [stable] Release 1.0.5 * rename WyreBTCAddress to BuyBitCloutBTCAddress to more accurately reflect this flag's purpose (#61) * Ln/txn ids only (#62) * add support for IDsOnly as described in the documentation * only set the TransactionIdBase58Check in the Transactions slice * omit transaction response fields if empty * [stable] Release 1.0.6 * fix IDsOnly mempool transactions for public key (#64) * add get nfts for user logic Co-authored-by: maebeam Co-authored-by: diamondhands0 --- .github/workflows/ci.yml | 131 -------------------------- cmd/config.go | 8 +- cmd/node.go | 2 +- cmd/run.go | 4 +- go.mod | 5 +- go.sum | 92 +++++++++++++++++- routes/admin_transaction.go | 21 ++++- routes/base.go | 109 ++++++++++++++++------ routes/exchange.go | 72 +++++++++------ routes/exchange_test.go | 25 +++++ routes/media.go | 4 +- routes/nft.go | 179 ++++++++++++++++++++++++++++++++++-- routes/server.go | 63 ++++++++++--- routes/shared.go | 107 ++++++++++++--------- routes/transaction.go | 6 +- routes/user.go | 50 ++++++---- routes/verify.go | 4 +- routes/wyre.go | 4 +- 18 files changed, 594 insertions(+), 292 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index ef950edb..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,131 +0,0 @@ -name: CI - -on: - push: - branches: - - main - pull_request: - branches: - - main - workflow_dispatch: - -defaults: - run: - shell: bash - -env: - BUILD_CACHE: /home/runner/.docker/buildkit - -jobs: - build: - runs-on: ubuntu-latest - env: - IMAGE_NAME: ghcr.io/${{ github.repository }} - steps: - - uses: actions/checkout@v2 - with: - path: backend - - - uses: actions/checkout@v2 - with: - repository: 'bitclout/core' - path: core - - - uses: actions/cache@v2 - with: - path: ${{ env.BUILD_CACHE }} - key: ${{ runner.os }}-buildkit-v3-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildkit-v3- - - - name: Install the latest buildkit release - run: | - BUILDKIT_URL="$(curl -sL https://api.github.com/repos/moby/buildkit/releases \ - | jq -r 'map(select(.name|startswith("v")))|sort_by(.name)[-1].assets[]|select(.name|endswith(".linux-amd64.tar.gz")).browser_download_url')" - curl -L "${BUILDKIT_URL}" | sudo tar -xz -C /usr/local - - - name: Start buildkit daemon - run: | - sudo --non-interactive --shell <= 0 && uint64(requestData.MaxCopiesPerNFT) != utxoView.GlobalParamsEntry.MaxCopiesPerNFT { + maxCopiesPerNFT = requestData.MaxCopiesPerNFT + } // Try and create the update txn for the user. txn, totalInput, changeAmount, fees, err := fes.blockchain.CreateUpdateGlobalParamsTxn( diff --git a/routes/base.go b/routes/base.go index 91cec4fb..2e5792b6 100644 --- a/routes/base.go +++ b/routes/base.go @@ -6,9 +6,11 @@ import ( "fmt" "github.com/bitclout/core/lib" "github.com/golang/glog" + "github.com/montanaflynn/stats" "io" "io/ioutil" "net/http" + "time" ) // Index ... @@ -111,42 +113,97 @@ type BlockchainBitCloutTickerResponse struct { // UpdateUSDCentsToBitCloutExchangeRate updates app state's USD Cents per BitClout value func (fes *APIServer) UpdateUSDCentsToBitCloutExchangeRate() { + glog.Infof("Refreshing exchange rate...") + // Get the ticker from Blockchain.com - url := "https://api.blockchain.com/v3/exchange/tickers/CLOUT-USD" - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("Content-Type", "application/json") - client := &http.Client{} - resp, err := client.Do(req) + // Do several fetches and take the max + // + // TODO: This is due to a bug in Blockchain's API that returns random values ~30% of the + // time for the last_price field. Once that bug is fixed, this multi-fetching will no + // longer be needed. + exchangeRatesFetched := []float64{} + for ii := 0; ii < 10; ii++ { + url := "https://api.blockchain.com/v3/exchange/tickers/CLOUT-USD" + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + glog.Errorf("GetExchangePriceFromBlockchain: Problem with HTTP request %s: %v", url, err) + return + } + defer resp.Body.Close() + + // Decode the response into the appropriate struct. + body, _ := ioutil.ReadAll(resp.Body) + responseData := &BlockchainBitCloutTickerResponse{} + decoder := json.NewDecoder(bytes.NewReader(body)) + if err = decoder.Decode(responseData); err != nil { + glog.Errorf("GetExchangePriceFromBlockchain: Problem decoding response JSON into "+ + "interface %v, response: %v, error: %v", responseData, resp, err) + return + } + + // Return the last trade price. + usdCentsToBitCloutExchangePrice := uint64(responseData.LastTradePrice * 100) + + exchangeRatesFetched = append(exchangeRatesFetched, float64(usdCentsToBitCloutExchangePrice)) + } + blockchainDotComExchangeRate, err := stats.Max(exchangeRatesFetched) if err != nil { - glog.Errorf("GetExchangePriceFromBlockchain: Problem with HTTP request %s: %v", url, err) - return + glog.Error(err) } - defer resp.Body.Close() - - // Decode the response into the appropriate struct. - body, _ := ioutil.ReadAll(resp.Body) - responseData := &BlockchainBitCloutTickerResponse{} - decoder := json.NewDecoder(bytes.NewReader(body)) - if err = decoder.Decode(responseData); err != nil { - glog.Errorf("GetExchangePriceFromBlockchain: Problem decoding response JSON into "+ - "interface %v, response: %v, error: %v", responseData, resp, err) - return + glog.Infof("Blockchain exchange rate: %v %v", blockchainDotComExchangeRate, exchangeRatesFetched) + if fes.backendServer != nil && fes.backendServer.GetStatsdClient() != nil { + if err = fes.backendServer.GetStatsdClient().Gauge("BLOCKCHAIN_LAST_TRADE_PRICE", blockchainDotComExchangeRate, []string{}, 1); err != nil { + glog.Errorf("GetExchangePriceFromBlockchain: Error logging Last Trade Price of %f to datadog: %v", blockchainDotComExchangeRate, err) + } } + + // Get the current timestamp and append the current last trade price to the LastTradeBitCloutPriceHistory slice + timestamp := uint64(time.Now().UnixNano()) + fes.LastTradeBitCloutPriceHistory = append(fes.LastTradeBitCloutPriceHistory, LastTradePriceHistoryItem{ + LastTradePrice: uint64(blockchainDotComExchangeRate), + Timestamp: timestamp, + }) + + // Get the max price within the lookback window and remove elements that are no longer valid. + maxPrice := fes.getMaxPriceFromHistoryAndCull(timestamp) + // Get the reserve price for this node. reservePrice, err := fes.GetUSDCentsToBitCloutReserveExchangeRateFromGlobalState() - // Use the max of the last trade price and 24H price - var usdCentsToBitCloutExchangePrice uint64 - if responseData.LastTradePrice > responseData.Price24H { - usdCentsToBitCloutExchangePrice = uint64(responseData.LastTradePrice * 100) - } else { - usdCentsToBitCloutExchangePrice = uint64(responseData.Price24H * 100) - } // If the max of last trade price and 24H price is less than the reserve price, use the reserve price. - if reservePrice > usdCentsToBitCloutExchangePrice { + if reservePrice > maxPrice { fes.UsdCentsPerBitCloutExchangeRate = reservePrice } else { - fes.UsdCentsPerBitCloutExchangeRate = usdCentsToBitCloutExchangePrice + fes.UsdCentsPerBitCloutExchangeRate = maxPrice + } + + glog.Infof("Final exchange rate: %v", fes.UsdCentsPerBitCloutExchangeRate) +} + +// getMaxPriceFromHistoryAndCull removes elements that are outside of the lookback window and return the max price +// from valid elements. +func (fes *APIServer) getMaxPriceFromHistoryAndCull(currentTimestamp uint64) uint64 { + maxPrice := uint64(0) + // This function culls invalid values (outside of the lookback window) from the LastTradeBitCloutPriceHistory slice + // in place, so we need to keep track of the index at which we will place the next valid item. + validIndex := 0 + for _, priceHistoryItem := range fes.LastTradeBitCloutPriceHistory { + tstampDiff := currentTimestamp - priceHistoryItem.Timestamp + if tstampDiff <= fes.LastTradePriceLookback { + // copy and increment index. This overwrites invalid values with valid ones in the order valid items + // are seen. + fes.LastTradeBitCloutPriceHistory[validIndex] = priceHistoryItem + validIndex++ + if priceHistoryItem.LastTradePrice > maxPrice { + maxPrice = priceHistoryItem.LastTradePrice + } + } } + // Reduce the slice to only valid elements - all elements up to validIndex are within the lookback window. + fes.LastTradeBitCloutPriceHistory = fes.LastTradeBitCloutPriceHistory[:validIndex] + return maxPrice } type GetAppStateRequest struct { diff --git a/routes/exchange.go b/routes/exchange.go index b1214783..ab72b278 100644 --- a/routes/exchange.go +++ b/routes/exchange.go @@ -423,23 +423,23 @@ type TransactionResponse struct { TransactionIDBase58Check string // The raw hex of the transaction data. This can be fully-constructed from // the human-readable portions of this object. - RawTransactionHex string + RawTransactionHex string `json:",omitempty"` // The inputs and outputs for this transaction. - Inputs []*InputResponse - Outputs []*OutputResponse + Inputs []*InputResponse `json:",omitempty"` + Outputs []*OutputResponse `json:",omitempty"` // The signature of the transaction in hex format. - SignatureHex string + SignatureHex string `json:",omitempty"` // Will always be “0” for basic transfers - TransactionType string + TransactionType string `json:",omitempty"` // TODO: Create a TransactionMeta portion for the response. // The hash of the block in which this transaction was mined. If the // transaction is unconfirmed, this field will be empty. To look up // how many confirmations a transaction has, simply plug this value // into the "block" endpoint. - BlockHashHex string + BlockHashHex string `json:",omitempty"` - TransactionMetadata *lib.TransactionMetadata + TransactionMetadata *lib.TransactionMetadata `json:",omitempty"` } // TransactionInfoResponse contains information about the transaction @@ -720,6 +720,8 @@ type APITransactionInfoRequest struct { // with “BC”) to get transaction IDs for. When set, // TransactionIDBase58Check is ignored. PublicKeyBase58Check string + + IDsOnly bool } // APITransactionInfoResponse specifies the response for a call to the @@ -799,16 +801,21 @@ func (fes *APIServer) APITransactionInfo(ww http.ResponseWriter, rr *http.Reques res := &APITransactionInfoResponse{} res.Transactions = []*TransactionResponse{} for _, poolTx := range poolTxns { - txnMeta, err := lib.ConnectTxnAndComputeTransactionMetadata( - poolTx.Tx, utxoView, &lib.BlockHash{} /*Block hash*/, nextBlockHeight, - uint64(0) /*txnIndexInBlock*/) - if err != nil { - APIAddError(ww, fmt.Sprintf("Update: Error connecting "+ - "txn for mempool request: %v: %v", poolTx.Tx, err)) - return + if transactionInfoRequest.IDsOnly { + res.Transactions = append(res.Transactions, + &TransactionResponse{ TransactionIDBase58Check: lib.PkToString(poolTx.Tx.Hash()[:], fes.Params) }) + } else { + txnMeta, err := lib.ConnectTxnAndComputeTransactionMetadata( + poolTx.Tx, utxoView, &lib.BlockHash{} /*Block hash*/, nextBlockHeight, + uint64(0) /*txnIndexInBlock*/) + if err != nil { + APIAddError(ww, fmt.Sprintf("Update: Error connecting "+ + "txn for mempool request: %v: %v", poolTx.Tx, err)) + return + } + res.Transactions = append(res.Transactions, + APITransactionToResponse(poolTx.Tx, txnMeta, fes.Params)) } - res.Transactions = append(res.Transactions, - APITransactionToResponse(poolTx.Tx, txnMeta, fes.Params)) } // At this point, all the transactions should have been added to the request. @@ -945,17 +952,21 @@ func (fes *APIServer) APITransactionInfo(ww http.ResponseWriter, rr *http.Reques // Process all the transactions found and add them to the response. for _, txHash := range txHashes { txIDString := lib.PkToString(txHash[:], fes.Params) - // In this case we need to look up the full transaction and convert - // it into a proper transaction response. - fullTxn, txnMeta := lib.DbGetTxindexFullTransactionByTxID( - fes.TXIndex.TXIndexChain.DB(), fes.blockchain.DB(), txHash) - if fullTxn == nil || txnMeta == nil { - APIAddError(ww, fmt.Sprintf("APITransactionInfo: Problem looking up "+ - "transaction with TxID: %v; this should never happen", txIDString)) - return + if transactionInfoRequest.IDsOnly { + res.Transactions = append(res.Transactions, &TransactionResponse{ TransactionIDBase58Check: txIDString }) + } else { + // In this case we need to look up the full transaction and convert + // it into a proper transaction response. + fullTxn, txnMeta := lib.DbGetTxindexFullTransactionByTxID( + fes.TXIndex.TXIndexChain.DB(), fes.blockchain.DB(), txHash) + if fullTxn == nil || txnMeta == nil { + APIAddError(ww, fmt.Sprintf("APITransactionInfo: Problem looking up "+ + "transaction with TxID: %v; this should never happen", txIDString)) + return + } + res.Transactions = append(res.Transactions, + APITransactionToResponse(fullTxn, txnMeta, fes.Params)) } - res.Transactions = append(res.Transactions, - APITransactionToResponse(fullTxn, txnMeta, fes.Params)) } // Get all the txns from the mempool. @@ -1004,8 +1015,13 @@ func (fes *APIServer) APITransactionInfo(ww http.ResponseWriter, rr *http.Reques } // Finally, add the transaction to our list if it's relevant if isRelevantTxn { - res.Transactions = append(res.Transactions, - APITransactionToResponse(poolTx.Tx, txnMeta, fes.Params)) + if transactionInfoRequest.IDsOnly { + res.Transactions = append(res.Transactions, + &TransactionResponse{ TransactionIDBase58Check: lib.PkToString(poolTx.Tx.Hash()[:], fes.Params) }) + } else { + res.Transactions = append(res.Transactions, + APITransactionToResponse(poolTx.Tx, txnMeta, fes.Params)) + } } } diff --git a/routes/exchange_test.go b/routes/exchange_test.go index 2be0f06a..2033c0eb 100644 --- a/routes/exchange_test.go +++ b/routes/exchange_test.go @@ -1352,6 +1352,31 @@ func TestAPI(t *testing.T) { assert.Equal(0, len(transactionInfoRes.Transactions[0].Inputs)) assert.Equal(1, len(transactionInfoRes.Transactions[0].Outputs)) } + { + // Test IDs only + transactionInfoRequest := &APITransactionInfoRequest{ + PublicKeyBase58Check: senderPkString, + IDsOnly: true, + } + jsonRequest, err := json.Marshal(transactionInfoRequest) + require.NoError(err) + request, _ := http.NewRequest( + "POST", RoutePathAPITransactionInfo, bytes.NewBuffer(jsonRequest)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + apiServer.router.ServeHTTP(response, request) + assert.Equal(200, response.Code, "200 response expected") + + decoder := json.NewDecoder(io.LimitReader(response.Body, MaxRequestBodySizeBytes)) + transactionInfoRes := APITransactionInfoResponse{} + if err := decoder.Decode(&transactionInfoRes); err != nil { + require.NoError(err, "Problem decoding response") + } + assert.Equal("", transactionInfoRes.Error) + assert.Equal(1, len(transactionInfoRes.Transactions)) + assert.Equal(lib.PkToString(firstBlockTxn.Hash()[:], apiServer.Params), + transactionInfoRes.Transactions[0].TransactionIDBase58Check) + } // Roll back the change we made to the chain. apiServer.blockchain.SetBestChain(oldBestChain) diff --git a/routes/media.go b/routes/media.go index 8976675f..86744cb4 100644 --- a/routes/media.go +++ b/routes/media.go @@ -131,7 +131,9 @@ func (fes *APIServer) UploadImage(ww http.ResponseWriter, req *http.Request) { } file, fileHeader, err := req.FormFile("file") - defer file.Close() + if file != nil { + defer file.Close() + } if err != nil { _AddBadRequestError(ww, fmt.Sprintf("UploadImage: Problem getting file from form data: %v", err)) return diff --git a/routes/nft.go b/routes/nft.go index 30f78db1..35bdbc78 100644 --- a/routes/nft.go +++ b/routes/nft.go @@ -11,11 +11,20 @@ import ( ) type NFTEntryResponse struct { - PostEntry *PostEntryResponse - NumCopies int `safeForLogging:"true"` - NFTRoyaltyToCreatorBasisPoints int `safeForLogging:"true"` - NFTRoyaltyToCoinBasisPoints int `safeForLogging:"true"` - HasUnlockable bool `safeForLogging:"true"` + OwnerPublicKeyBase58Check string `safeForLogging:"true"` + PostEntryResponse *PostEntryResponse `json:",omitempty"` + SerialNumber uint64 `safeForLogging:"true"` + IsForSale bool `safeForLogging:"true"` + MinBidAmountNanos uint64 `safeForLogging:"true"` +} + +type NFTBidEntryResponse struct { + PublicKeyBase58Check string + ProfileEntryResponse *ProfileEntryResponse + // likely nil if included in a list of NFTBidEntryResponses for a single NFT + PostEntryResponse *PostEntryResponse `json:",omitempty"` + SerialNumber uint64 `safeForLogging:"true"` + BidAmountNanos uint64 `safeForLogging:"true"` } type CreateNFTRequest struct { @@ -617,8 +626,13 @@ type GetNFTsForUserRequest struct { ReaderPublicKeyBase58Check string `safeForLogging:"true"` } +type NFTEntryAndPostEntryResponse struct { + PostEntryResponse *PostEntryResponse + NFTEntryResponses []*NFTEntryResponse +} + type GetNFTsForUserResponse struct { - NFTEntries []*NFTEntryResponse + NFTsMap map[string]*NFTEntryAndPostEntryResponse } func (fes *APIServer) GetNFTsForUser(ww http.ResponseWriter, req *http.Request) { @@ -629,8 +643,17 @@ func (fes *APIServer) GetNFTsForUser(ww http.ResponseWriter, req *http.Request) return } + if requestData.UserPublicKeyBase58Check == "" { + _AddBadRequestError(ww, fmt.Sprintf("GetNFTsForUser: must provide UserPublicKeyBase58Check")) + return + } + userPublicKey, _, err := lib.Base58CheckDecode(requestData.UserPublicKeyBase58Check) + if err != nil { + _AddBadRequestError(ww, fmt.Sprintf("GetNFTsForUser: Problem decoding reader public key: %v", err)) + return + } + var readerPublicKeyBytes []byte - var err error if requestData.ReaderPublicKeyBase58Check != "" { readerPublicKeyBytes, _, err = lib.Base58CheckDecode(requestData.ReaderPublicKeyBase58Check) if err != nil { @@ -647,10 +670,37 @@ func (fes *APIServer) GetNFTsForUser(ww http.ResponseWriter, req *http.Request) } // RPH-FIXME: Get the correct feed of NFTs to show the user. - _, _ = readerPublicKeyBytes, utxoView - // Return all the data associated with the transaction in the response - res := GetNFTsForUserResponse{} + res := GetNFTsForUserResponse{ + NFTsMap: map[string]*NFTEntryAndPostEntryResponse{}, + } + pkid := utxoView.GetPKIDForPublicKey(userPublicKey) + + nftEntries := utxoView.GetNFTEntriesForPKID(pkid.PKID) + + postHashToEntryResponseMap := make(map[*lib.BlockHash]*PostEntryResponse) + for _, nftEntry := range nftEntries { + postEntryResponse := postHashToEntryResponseMap[nftEntry.NFTPostHash] + if postEntryResponse == nil { + postEntry := utxoView.GetPostEntryForPostHash(nftEntry.NFTPostHash) + postEntryResponse, err = fes._postEntryToResponse(postEntry, true, fes.Params, utxoView, readerPublicKeyBytes, 2) + if err != nil { + _AddBadRequestError(ww, fmt.Sprintf("GetNFTsForUser: Problem converting post entry to response: %v", err)) + return + } + postHashToEntryResponseMap[nftEntry.NFTPostHash] = postEntryResponse + } + if res.NFTsMap[postEntryResponse.PostHashHex] == nil { + res.NFTsMap[postEntryResponse.PostHashHex] = &NFTEntryAndPostEntryResponse{ + PostEntryResponse: postEntryResponse, + NFTEntryResponses: []*NFTEntryResponse{}, + } + } + res.NFTsMap[postEntryResponse.PostHashHex].NFTEntryResponses = append( + res.NFTsMap[postEntryResponse.PostHashHex].NFTEntryResponses, + fes._nftEntryToResponse(nftEntry, postEntryResponse, utxoView)) + } + if err = json.NewEncoder(ww).Encode(res); err != nil { _AddInternalServerError(ww, fmt.Sprintf("GetNFTsForUser: Problem serializing object to JSON: %v", err)) @@ -703,3 +753,112 @@ func (fes *APIServer) GetNFTBidsForUser(ww http.ResponseWriter, req *http.Reques return } } + +type GetNFTBidsForNFTPostRequest struct { + ReaderPublicKeyBase58Check string + PostHashHex string +} + +type GetNFTBidsForNFTPostResponse struct { + PostEntryResponse *PostEntryResponse + NFTEntryResponses []*NFTEntryResponse + BidEntryResponses []*NFTBidEntryResponse +} + +func (fes *APIServer) GetNFTBidsForNFTPost(ww http.ResponseWriter, req *http.Request) { + decoder := json.NewDecoder(io.LimitReader(req.Body, MaxRequestBodySizeBytes)) + requestData := GetNFTBidsForNFTPostRequest{} + if err := decoder.Decode(&requestData); err != nil { + _AddBadRequestError(ww, fmt.Sprintf("GetBidsForNFTPost: Error parsing request body: %v", err)) + return + } + + var readerPublicKeyBytes []byte + var err error + if requestData.ReaderPublicKeyBase58Check != "" { + readerPublicKeyBytes, _, err = lib.Base58CheckDecode(requestData.ReaderPublicKeyBase58Check) + if err != nil { + _AddBadRequestError(ww, fmt.Sprintf("GetBidsForNFTPost: Problem decoding reader public key: %v", err)) + return + } + } + + // Decode the postHash. + postHash := &lib.BlockHash{} + if requestData.PostHashHex != "" { + var postHashBytes []byte + postHashBytes, err = hex.DecodeString(requestData.PostHashHex) + if err != nil || len(postHashBytes) != lib.HashSizeBytes { + _AddBadRequestError(ww, fmt.Sprintf("GetNFTBidsForNFTPost: Error parsing post hash %v: %v", + requestData.PostHashHex, err)) + return + } + copy(postHash[:], postHashBytes) + } else { + _AddBadRequestError(ww, fmt.Sprintf("GetNFTBidsForNFTPost: Request missing PostHashHex")) + return + } + + utxoView, err := fes.backendServer.GetMempool().GetAugmentedUniversalView() + if err != nil { + _AddBadRequestError(ww, fmt.Sprintf("GetNFTBidsForNFTPost: Error getting utxoView: %v", err)) + return + } + postEntry := utxoView.GetPostEntryForPostHash(postHash) + postEntryResponse, err := fes._postEntryToResponse(postEntry, true, fes.Params, utxoView, readerPublicKeyBytes, 2) + if err != nil { + _AddBadRequestError(ww, fmt.Sprintf("GetNFTBidsForNFTPost: Error converting post entry to response: %v", err)) + } + verifiedMap, err := fes.GetVerifiedUsernameToPKIDMap() + if err != nil { + _AddBadRequestError(ww, fmt.Sprintf("GetNFTBidsForNFTPost: Error getting verified user map: %v", err)) + } + + res := GetNFTBidsForNFTPostResponse{ + PostEntryResponse: postEntryResponse, + } + // Do I need to add something to get bid entries for serial # 0? + nftEntries := utxoView.GetNFTEntriesForPostHash(postHash) + for _, nftEntry := range nftEntries { + res.NFTEntryResponses = append(res.NFTEntryResponses, fes._nftEntryToResponse(nftEntry, nil, utxoView)) + bidEntries := utxoView.GetAllNFTBidEntries(postHash, nftEntry.SerialNumber) + for _, bidEntry := range bidEntries { + res.BidEntryResponses = append(res.BidEntryResponses, fes._bidEntryToResponse(bidEntry, nil, verifiedMap, utxoView)) + } + } + if err = json.NewEncoder(ww).Encode(res); err != nil { + _AddInternalServerError(ww, fmt.Sprintf("GetNFTBidsForNFTPost: Problem serializing object to JSON: %v", err)) + return + } +} + +func (fes *APIServer) _nftEntryToResponse(nftEntry *lib.NFTEntry, postEntryResponse *PostEntryResponse, utxoView *lib.UtxoView) *NFTEntryResponse { + ownerPublicKey := utxoView.GetPublicKeyForPKID(nftEntry.OwnerPKID) + return &NFTEntryResponse{ + OwnerPublicKeyBase58Check: lib.PkToString(ownerPublicKey, fes.Params), + PostEntryResponse: postEntryResponse, + SerialNumber: nftEntry.SerialNumber, + IsForSale: nftEntry.IsForSale, + MinBidAmountNanos: nftEntry.MinBidAmountNanos, + } +} + +func (fes *APIServer) _bidEntryToResponse(bidEntry *lib.NFTBidEntry, postEntryResponse *PostEntryResponse, verifiedUsernameMap map[string]*lib.PKID, utxoView *lib.UtxoView) (*NFTBidEntryResponse) { + profileEntry := utxoView.GetProfileEntryForPKID(bidEntry.BidderPKID) + var profileEntryResponse *ProfileEntryResponse + var publicKeyBase58Check string + if profileEntry != nil { + profileEntryResponse = _profileEntryToResponse(profileEntry, fes.Params, verifiedUsernameMap, utxoView) + publicKeyBase58Check = profileEntryResponse.PublicKeyBase58Check + } else { + publicKey := utxoView.GetPublicKeyForPKID(bidEntry.BidderPKID) + publicKeyBase58Check = lib.PkToString(publicKey, fes.Params) + } + return &NFTBidEntryResponse{ + PublicKeyBase58Check: publicKeyBase58Check, + ProfileEntryResponse: profileEntryResponse, + PostEntryResponse: postEntryResponse, + SerialNumber: bidEntry.SerialNumber, + BidAmountNanos: bidEntry.BidAmountNanos, + } +} diff --git a/routes/server.go b/routes/server.go index 60498456..7a42f06a 100644 --- a/routes/server.go +++ b/routes/server.go @@ -8,6 +8,7 @@ import ( "io/ioutil" "net/http" "strings" + "sync" "time" "github.com/btcsuite/btcd/btcec" @@ -76,13 +77,14 @@ const ( RoutePathGetDiamondedPosts = "/api/v0/get-diamonded-posts" // nft.go - RoutePathCreateNFT = "/api/v0/create-nft" - RoutePathUpdateNFT = "/api/v0/update-nft" - RoutePathGetNFTFeed = "/api/v0/get-nft-feed" - RoutePathGetNFTsForUser = "/api/v0/get-nfts-for-user" - RoutePathGetNFTBidsForUser = "/api/v0/get-nft-bids-for-user" - RoutePathCreateNFTBid = "/api/v0/create-nft-bid" - RoutePathAcceptNFTBid = "/api/v0/accept-nft-bid" + RoutePathCreateNFT = "/api/v0/create-nft" + RoutePathUpdateNFT = "/api/v0/update-nft" + RoutePathGetNFTFeed = "/api/v0/get-nft-feed" + RoutePathGetNFTsForUser = "/api/v0/get-nfts-for-user" + RoutePathGetNFTBidsForUser = "/api/v0/get-nft-bids-for-user" + RoutePathCreateNFTBid = "/api/v0/create-nft-bid" + RoutePathAcceptNFTBid = "/api/v0/accept-nft-bid" + RoutePathGetNFTBidsForNFTPost = "/api/v0/get-nft-bids-for-nft-post" // media.go RoutePathUploadImage = "/api/v0/upload-image" @@ -217,18 +219,33 @@ type APIServer struct { SuperAdminPublicKeys []string // Wyre - WyreUrl string - WyreAccountId string - WyreApiKey string - WyreSecretKey string - WyreBTCAddress string + WyreUrl string + WyreAccountId string + WyreApiKey string + WyreSecretKey string + BuyBitCloutBTCAddress string BuyBitCloutSeed string + // This lock is used when sending seed BitClout to avoid a race condition + // in which two calls to sending the seed BitClout use the same UTXO, + // causing one to error. + mtxSeedBitClout sync.RWMutex + UsdCentsPerBitCloutExchangeRate uint64 + + // List of prices retrieved. This is culled everytime we update the current price. + LastTradeBitCloutPriceHistory []LastTradePriceHistoryItem + // How far back do we consider trade prices when we set the current price of $CLOUT in nanoseconds + LastTradePriceLookback uint64 // Signals that the frontend server is in a stopped state quit chan struct{} } +type LastTradePriceHistoryItem struct { + LastTradePrice uint64 + Timestamp uint64 +} + // NewAPIServer ... func NewAPIServer(_backendServer *lib.Server, _mempool *lib.BitCloutMempool, @@ -264,7 +281,7 @@ func NewAPIServer(_backendServer *lib.Server, wyreAccountId string, wyreApiKey string, wyreSecretKey string, - wyreBTCAddress string, + buyBitCloutBTCAddress string, buyBitCloutSeed string, ) (*APIServer, error) { @@ -312,8 +329,12 @@ func NewAPIServer(_backendServer *lib.Server, WyreAccountId: wyreAccountId, WyreApiKey: wyreApiKey, WyreSecretKey: wyreSecretKey, - WyreBTCAddress: wyreBTCAddress, + BuyBitCloutBTCAddress: buyBitCloutBTCAddress, BuyBitCloutSeed: buyBitCloutSeed, + LastTradeBitCloutPriceHistory: []LastTradePriceHistoryItem{}, + // We consider last trade prices from the last hour when determining the current price of BitClout. + // This helps prevents attacks that attempt to purchase $CLOUT at below market value. + LastTradePriceLookback: uint64(time.Hour.Nanoseconds()), } fes.StartSeedBalancesMonitoring() @@ -537,6 +558,20 @@ func (fes *APIServer) NewRouter() *muxtrace.Router { fes.AcceptNFTBid, PublicAccess, }, + { + "GetNFTBidsForNFTPost", + []string{"POST", "OPTIONS"}, + RoutePathGetNFTBidsForNFTPost, + fes.GetNFTBidsForNFTPost, + PublicAccess, + }, + { + "GetNFTsForUser", + []string{"POST", "OPTIONS"}, + RoutePathGetNFTsForUser, + fes.GetNFTsForUser, + PublicAccess, + }, { "GetHodlersForPublicKey", []string{"POST", "OPTIONS"}, diff --git a/routes/shared.go b/routes/shared.go index 0f6d72ac..33c8ad81 100644 --- a/routes/shared.go +++ b/routes/shared.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "time" "github.com/bitclout/core/lib" "github.com/golang/glog" @@ -250,66 +251,88 @@ func (fes *APIServer) putUserMetadataInGlobalState( } func (fes *APIServer) SendSeedBitClout(recipientPkBytes []byte, amountNanos uint64, useBuyBitCloutSeed bool) (txnHash *lib.BlockHash, _err error) { + fes.mtxSeedBitClout.Lock() + defer fes.mtxSeedBitClout.Unlock() + senderSeed := fes.StarterBitCloutSeed if useBuyBitCloutSeed { senderSeed = fes.BuyBitCloutSeed } starterSeedBytes, err := bip39.NewSeedWithErrorChecking(senderSeed, "") if err != nil { + glog.Errorf("SendSeedBitClout: error converting mnemonic: %v", err) return nil, fmt.Errorf("SendSeedBitClout: Error converting mnemonic: %+v", err) } starterPubKey, starterPrivKey, _, err := lib.ComputeKeysFromSeed(starterSeedBytes, 0, fes.Params) if err != nil { + glog.Errorf("SendSeedBitClout: Error computing keys from seed: %v", err) return nil, fmt.Errorf("SendSeedBitClout: Error computing keys from seed: %+v", err) } - // Create the transaction outputs and add the recipient's public key and the - // amount we want to pay them - txnOutputs := []*lib.BitCloutOutput{} - txnOutputs = append(txnOutputs, &lib.BitCloutOutput{ - PublicKey: recipientPkBytes, - // If we get here we know the amount is non-negative. - AmountNanos: amountNanos, - }) - - // Assemble the transaction so that inputs can be found and fees can - // be computed. - txn := &lib.MsgBitCloutTxn{ - // The inputs will be set below. - TxInputs: []*lib.BitCloutInput{}, - TxOutputs: txnOutputs, - PublicKey: starterPubKey.SerializeCompressed(), - TxnMeta: &lib.BasicTransferMetadata{}, - // We wait to compute the signature until we've added all the - // inputs and change. - } + sendBitClout := func() (txnHash *lib.BlockHash, _err error) { + // Create the transaction outputs and add the recipient's public key and the + // amount we want to pay them + txnOutputs := []*lib.BitCloutOutput{} + txnOutputs = append(txnOutputs, &lib.BitCloutOutput{ + PublicKey: recipientPkBytes, + // If we get here we know the amount is non-negative. + AmountNanos: amountNanos, + }) + + // Assemble the transaction so that inputs can be found and fees can + // be computed. + txn := &lib.MsgBitCloutTxn{ + // The inputs will be set below. + TxInputs: []*lib.BitCloutInput{}, + TxOutputs: txnOutputs, + PublicKey: starterPubKey.SerializeCompressed(), + TxnMeta: &lib.BasicTransferMetadata{}, + // We wait to compute the signature until we've added all the + // inputs and change. + } - // Add inputs to the transaction and do signing, validation, and broadcast - // depending on what the user requested. - utxoView, err := fes.backendServer.GetMempool().GetAugmentedUniversalView() - if err != nil { - return nil, err - } - minFee := fes.MinFeeRateNanosPerKB - if utxoView.GlobalParamsEntry != nil && utxoView.GlobalParamsEntry.MinimumNetworkFeeNanosPerKB > 0 { - minFee = utxoView.GlobalParamsEntry.MinimumNetworkFeeNanosPerKB - } - _, _, _, _, err = fes.blockchain.AddInputsAndChangeToTransaction(txn, minFee, fes.mempool) - if err != nil { - return nil, fmt.Errorf("SendSeedBitClout: Error adding inputs for seed BitClout: %v", err) - } + // Add inputs to the transaction and do signing, validation, and broadcast + // depending on what the user requested. + utxoView, err := fes.backendServer.GetMempool().GetAugmentedUniversalView() + if err != nil { + return nil, err + } + minFee := fes.MinFeeRateNanosPerKB + if utxoView.GlobalParamsEntry != nil && utxoView.GlobalParamsEntry.MinimumNetworkFeeNanosPerKB > 0 { + minFee = utxoView.GlobalParamsEntry.MinimumNetworkFeeNanosPerKB + } + _, _, _, _, err = fes.blockchain.AddInputsAndChangeToTransaction(txn, minFee, fes.mempool) + if err != nil { + return nil, fmt.Errorf("SendSeedBitClout: Error adding inputs for seed BitClout: %v", err) + } - txnSignature, err := txn.Sign(starterPrivKey) - if err != nil { - return nil, fmt.Errorf("SendSeedBitClout: Error adding inputs for seed BitClout: %v", err) + txnSignature, err := txn.Sign(starterPrivKey) + if err != nil { + return nil, fmt.Errorf("SendSeedBitClout: Error adding inputs for seed BitClout: %v", err) + } + txn.Signature = txnSignature + + err = fes.backendServer.VerifyAndBroadcastTransaction(txn) + if err != nil { + return nil, fmt.Errorf("SendSeedBitClout: Problem processing starter seed transaction: %v", err) + } + + return txn.Hash(), nil } - txn.Signature = txnSignature - err = fes.backendServer.VerifyAndBroadcastTransaction(txn) + // Here we retry sending BitClout once if there is an error. This is concerning, but we believe it is safe at this + // time as no Clout will be sent if there is an error. We wait for 5 seconds + var hash *lib.BlockHash + hash, err = sendBitClout() if err != nil { - return nil, fmt.Errorf("SendSeedBitClout: Problem processing starter seed transaction: %v", err) + publicKeyBase58Check := lib.PkToString(recipientPkBytes, fes.Params) + glog.Errorf("SendSeedBitClout: 1st attempt - error sending %d nanos of Clout to public key %v: error - %v", amountNanos, publicKeyBase58Check, err) + time.Sleep(5 * time.Second) + hash, err = sendBitClout() + if err != nil { + glog.Errorf("SendSeedBitClout: 2nd attempt - error sending %d nanos of Clout to public key %v: error - %v", amountNanos, publicKeyBase58Check, err) + } } - - return txn.Hash(), nil + return hash, err } diff --git a/routes/transaction.go b/routes/transaction.go index 0c5874e6..4b958e29 100644 --- a/routes/transaction.go +++ b/routes/transaction.go @@ -616,7 +616,7 @@ func (fes *APIServer) ExchangeBitcoinStateless(ww http.ResponseWriter, req *http uint64(burnAmountSatoshis), uint64(requestData.FeeRateSatoshisPerKB), pubKey, - fes.WyreBTCAddress, + fes.BuyBitCloutBTCAddress, fes.Params, utxoSource) @@ -675,6 +675,10 @@ func (fes *APIServer) ExchangeBitcoinStateless(ww http.ResponseWriter, req *http _AddBadRequestError(ww, fmt.Sprintf("WyreWalletOrderSubscription: error getting buy bitclout premium basis points from global state: %v", err)) return } + + // Update the current exchange price. + fes.UpdateUSDCentsToBitCloutExchangeRate() + nanosPurchased, err := fes.GetNanosFromSats(uint64(burnAmountSatoshis), feeBasisPoints) if err != nil { _AddBadRequestError(ww, fmt.Sprintf("ExchangeBitcoinStateless: Error computing nanos purchased: %v", err)) diff --git a/routes/user.go b/routes/user.go index 99b9b3c5..e18dbe4d 100644 --- a/routes/user.go +++ b/routes/user.go @@ -1007,6 +1007,8 @@ type GetSingleProfileRequest struct { type GetSingleProfileResponse struct { Profile *ProfileEntryResponse + IsBlacklisted bool + IsGraylisted bool } // GetSingleProfile... @@ -1026,8 +1028,9 @@ func (fes *APIServer) GetSingleProfile(ww http.ResponseWriter, req *http.Request // Get profile entry by public key. If public key not provided, get profileEntry by username. var profileEntry *lib.ProfileEntry + var publicKeyBytes []byte if requestData.PublicKeyBase58Check != "" { - var publicKeyBytes []byte + publicKeyBytes, _, err = lib.Base58CheckDecode(requestData.PublicKeyBase58Check) if err != nil { _AddBadRequestError(ww, fmt.Sprintf("GetSingleProfile: Problem decoding user public key: %v", err)) @@ -1036,34 +1039,41 @@ func (fes *APIServer) GetSingleProfile(ww http.ResponseWriter, req *http.Request profileEntry = utxoView.GetProfileEntryForPublicKey(publicKeyBytes) } else { profileEntry = utxoView.GetProfileEntryForUsername([]byte(requestData.Username)) + publicKeyBytes = profileEntry.PublicKey } // Return an error if we failed to find a profile entry if profileEntry == nil { _AddNotFoundError(ww, fmt.Sprintf("GetSingleProfile: could not find profile for username or public key: %v, %v", requestData.Username, requestData.PublicKeyBase58Check)) return } - filteredPubKeys, err := fes.FilterOutRestrictedPubKeysFromList([][]byte{profileEntry.PublicKey}, nil, "") + // Grab verified username map pointer + verifiedMap, err := fes.GetVerifiedUsernameToPKIDMap() if err != nil { - _AddBadRequestError(ww, fmt.Sprintf("GetSingleProfile: Error filtering out blacklisted users: %v, %v, %v", err, requestData.Username, requestData.PublicKeyBase58Check)) + _AddBadRequestError(ww, fmt.Sprintf("GetSingleProfile: could not get verified map: %v", err)) return } - var res GetSingleProfileResponse - // If this public key/username has been blacklisted, we do not return the profile. - if len(filteredPubKeys) != 1 { - res = GetSingleProfileResponse{ - Profile: nil, - } - } else { - // Grab verified username map pointer - verifiedMap, err := fes.GetVerifiedUsernameToPKIDMap() - if err != nil { - _AddBadRequestError(ww, fmt.Sprintf("GetSingleProfile: could not get verified map: %v", err)) - return - } - profileEntryResponse := _profileEntryToResponse(profileEntry, fes.Params, verifiedMap, utxoView) - res = GetSingleProfileResponse{ - Profile: profileEntryResponse, - } + profileEntryResponse := _profileEntryToResponse(profileEntry, fes.Params, verifiedMap, utxoView) + res := GetSingleProfileResponse{ + Profile: profileEntryResponse, + } + // Check if the user is blacklisted/graylisted + blacklistKey := GlobalStateKeyForBlacklistedProfile(publicKeyBytes[:]) + userBlacklistState, err := fes.GlobalStateGet(blacklistKey) + if err != nil { + _AddBadRequestError(ww, fmt.Sprintf("GetSingleProfile: Problem getting blacklist: %v", err)) + return + } + if reflect.DeepEqual(userBlacklistState, lib.IsBlacklisted) { + res.IsBlacklisted = true + } + graylistKey := GlobalStateKeyForGraylistedProfile(publicKeyBytes[:]) + userGraylistState, err := fes.GlobalStateGet(graylistKey) + if err != nil { + _AddBadRequestError(ww, fmt.Sprintf("GetSingleProfile: Problem getting graylist: %v", err)) + return + } + if reflect.DeepEqual(userGraylistState, lib.IsGraylisted) { + res.IsGraylisted = true } if err = json.NewEncoder(ww).Encode(res); err != nil { _AddInternalServerError(ww, fmt.Sprintf("GetSingleProfile: Problem serializing object to JSON: %v", err)) diff --git a/routes/verify.go b/routes/verify.go index 084a747e..6aa042bb 100644 --- a/routes/verify.go +++ b/routes/verify.go @@ -13,7 +13,6 @@ import ( "strings" "github.com/bitclout/core/lib" - "github.com/golang/glog" "github.com/nyaruka/phonenumbers" "github.com/pkg/errors" ) @@ -307,7 +306,8 @@ func (fes *APIServer) SubmitPhoneNumberVerificationCode(ww http.ResponseWriter, var txnHash *lib.BlockHash txnHash, err = fes.SendSeedBitClout(userMetadata.PublicKey, amountToSendNanos, false) if err != nil { - glog.Errorf("SubmitPhoneNumberVerificationCode: Error sending seed BitClout: %v", err) + _AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificationCode: Error sending seed BitClout: %v", err)) + return } res := SubmitPhoneNumberVerificationCodeResponse{ TxnHashHex: txnHash.String(), diff --git a/routes/wyre.go b/routes/wyre.go index bab1d8f5..b8cccff9 100644 --- a/routes/wyre.go +++ b/routes/wyre.go @@ -370,7 +370,7 @@ func (fes *APIServer) GetWyreWalletOrderQuotation(ww http.ResponseWriter, req *h // Make and marshal the payload body := WyreWalletOrderQuotationPayload{ AccountId: fes.WyreAccountId, - Dest: fmt.Sprintf("bitcoin:%v", fes.WyreBTCAddress), + Dest: fmt.Sprintf("bitcoin:%v", fes.BuyBitCloutBTCAddress), AmountIncludeFees: true, DestCurrency: "BTC", SourceCurrency: wyreWalletOrderQuotationRequest.SourceCurrency, @@ -558,7 +558,7 @@ func (fes *APIServer) SetWyreRequestHeaders(req *http.Request, dataBytes []byte) } func (fes *APIServer) GetBTCAddress() string { - return fmt.Sprintf("bitcoin:%v", fes.WyreBTCAddress) + return fmt.Sprintf("bitcoin:%v", fes.BuyBitCloutBTCAddress) } type GetWyreWalletOrderForPublicKeyRequest struct {