diff --git a/pkg/core/blockchain.go b/pkg/core/blockchain.go index 50358a848..b2d0c8c8d 100644 --- a/pkg/core/blockchain.go +++ b/pkg/core/blockchain.go @@ -1067,6 +1067,25 @@ func (bc *Blockchain) processNEP5Transfer(cache *dao.Cached, tx *transaction.Tra } } +// ForEachTransfer executes f for each transfer in log. +func (bc *Blockchain) ForEachTransfer(acc util.Uint160, tr *state.Transfer, f func() error) error { + nb, err := bc.dao.GetNextTransferBatch(acc) + if err != nil { + return nil + } + for i := uint32(0); i <= nb; i++ { + lg, err := bc.dao.GetTransferLog(acc, i) + if err != nil { + return nil + } + err = lg.ForEach(state.TransferSize, tr, f) + if err != nil { + return err + } + } + return nil +} + // GetNEP5TransferLog returns NEP5 transfer log for the acc. func (bc *Blockchain) GetNEP5TransferLog(acc util.Uint160) *state.TransferLog { balances, err := bc.dao.GetNEP5Balances(acc) diff --git a/pkg/core/blockchainer.go b/pkg/core/blockchainer.go index 6abbe4c23..910c3074e 100644 --- a/pkg/core/blockchainer.go +++ b/pkg/core/blockchainer.go @@ -26,6 +26,7 @@ type Blockchainer interface { GetBlock(hash util.Uint256) (*block.Block, error) GetContractState(hash util.Uint160) *state.Contract GetEnrollments() ([]*state.Validator, error) + ForEachTransfer(util.Uint160, *state.Transfer, func() error) error GetHeaderHash(int) util.Uint256 GetHeader(hash util.Uint256) (*block.Header, error) CurrentHeaderHash() util.Uint256 diff --git a/pkg/network/helper_test.go b/pkg/network/helper_test.go index 5ee0c456d..8fd5d3d36 100644 --- a/pkg/network/helper_test.go +++ b/pkg/network/helper_test.go @@ -108,6 +108,9 @@ func (chain testChain) GetValidators(...*transaction.Transaction) ([]*keys.Publi func (chain testChain) GetEnrollments() ([]*state.Validator, error) { panic("TODO") } +func (chain testChain) ForEachTransfer(util.Uint160, *state.Transfer, func() error) error { + panic("TODO") +} func (chain testChain) GetScriptHashesForVerifying(*transaction.Transaction) ([]util.Uint160, error) { panic("TODO") } diff --git a/pkg/rpc/response/result/utxo.go b/pkg/rpc/response/result/utxo.go new file mode 100644 index 000000000..1bd2bb437 --- /dev/null +++ b/pkg/rpc/response/result/utxo.go @@ -0,0 +1,27 @@ +package result + +import "github.com/nspcc-dev/neo-go/pkg/util" + +// UTXO represents single output for a single asset. +type UTXO struct { + Index uint32 `json:"block_index"` + Timestamp uint32 `json:"timestamp"` + TxHash util.Uint256 `json:"txid"` + Address util.Uint160 `json:"transfer_address"` + Amount int64 `json:"amount,string"` +} + +// AssetUTXO represents UTXO for a specific asset. +type AssetUTXO struct { + AssetHash util.Uint256 `json:"asset_hash"` + AssetName string `json:"asset"` + TotalAmount int64 `json:"total_amount,string"` + Transactions []UTXO `json:"transactions"` +} + +// GetUTXO is a result of the `getutxotransfers` RPC. +type GetUTXO struct { + Address string `json:"address"` + Sent []AssetUTXO `json:"sent"` + Received []AssetUTXO `json:"received"` +} diff --git a/pkg/rpc/server/server.go b/pkg/rpc/server/server.go index 4cce469d8..d1bf31de8 100644 --- a/pkg/rpc/server/server.go +++ b/pkg/rpc/server/server.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "strconv" + "strings" "sync" "time" @@ -103,6 +104,7 @@ var rpcHandlers = map[string]func(*Server, request.Params) (interface{}, *respon "getunspents": (*Server).getUnspents, "getvalidators": (*Server).getValidators, "getversion": (*Server).getVersion, + "getutxotransfers": (*Server).getUTXOTransfers, "invoke": (*Server).invoke, "invokefunction": (*Server).invokeFunction, "invokescript": (*Server).invokescript, @@ -447,6 +449,133 @@ func (s *Server) getVersion(_ request.Params) (interface{}, *response.Error) { }, nil } +func getTimestamps(p1, p2 *request.Param) (uint32, uint32, error) { + var start, end uint32 + if p1 != nil { + val, err := p1.GetInt() + if err != nil { + return 0, 0, err + } + start = uint32(val) + } + if p2 != nil { + val, err := p2.GetInt() + if err != nil { + return 0, 0, err + } + end = uint32(val) + } + return start, end, nil +} + +func getAssetMaps(name string) (map[util.Uint256]*result.AssetUTXO, map[util.Uint256]*result.AssetUTXO, error) { + sent := make(map[util.Uint256]*result.AssetUTXO) + recv := make(map[util.Uint256]*result.AssetUTXO) + name = strings.ToLower(name) + switch name { + case "neo", "gas", "": + default: + return nil, nil, errors.New("invalid asset") + } + if name == "neo" || name == "" { + sent[core.GoverningTokenID()] = &result.AssetUTXO{ + AssetHash: core.GoverningTokenID(), + AssetName: "NEO", + Transactions: []result.UTXO{}, + } + recv[core.GoverningTokenID()] = &result.AssetUTXO{ + AssetHash: core.GoverningTokenID(), + AssetName: "NEO", + Transactions: []result.UTXO{}, + } + } + if name == "gas" || name == "" { + sent[core.UtilityTokenID()] = &result.AssetUTXO{ + AssetHash: core.UtilityTokenID(), + AssetName: "GAS", + Transactions: []result.UTXO{}, + } + recv[core.UtilityTokenID()] = &result.AssetUTXO{ + AssetHash: core.UtilityTokenID(), + AssetName: "GAS", + Transactions: []result.UTXO{}, + } + } + return sent, recv, nil +} + +func (s *Server) getUTXOTransfers(ps request.Params) (interface{}, *response.Error) { + addr, err := ps.Value(0).GetUint160FromAddressOrHex() + if err != nil { + return nil, response.NewInvalidParamsError("", err) + } + + index := 1 + assetName, err := ps.Value(index).GetString() + if err == nil { + index++ + } + + start, end, err := getTimestamps(ps.Value(index), ps.Value(index+1)) + if err != nil { + return nil, response.NewInvalidParamsError("", err) + } + + sent, recv, err := getAssetMaps(assetName) + if err != nil { + return nil, response.NewInvalidParamsError("", err) + } + tr := new(state.Transfer) + err = s.chain.ForEachTransfer(addr, tr, func() error { + if tr.Timestamp < start || end != 0 && tr.Timestamp > end { + return nil + } + assetID := core.GoverningTokenID() + if !tr.IsGoverning { + assetID = core.UtilityTokenID() + } + a, ok := sent[assetID] + if ok && tr.From.Equals(addr) && !tr.To.Equals(addr) { + a.Transactions = append(a.Transactions, result.UTXO{ + Index: tr.Block, + Timestamp: tr.Timestamp, + TxHash: tr.Tx, + Address: tr.To, + Amount: tr.Amount, + }) + a.TotalAmount += tr.Amount + } + a, ok = recv[assetID] + if ok && tr.To.Equals(addr) && !tr.From.Equals(addr) { + a.Transactions = append(a.Transactions, result.UTXO{ + Index: tr.Block, + Timestamp: tr.Timestamp, + TxHash: tr.Tx, + Address: tr.From, + Amount: tr.Amount, + }) + a.TotalAmount += tr.Amount + } + return nil + }) + if err != nil { + return nil, response.NewInternalServerError("", err) + } + + res := &result.GetUTXO{ + Address: address.Uint160ToString(addr), + Sent: []result.AssetUTXO{}, + Received: []result.AssetUTXO{}, + } + for _, a := range sent { + res.Sent = append(res.Sent, *a) + } + for _, a := range recv { + res.Received = append(res.Received, *a) + } + return res, nil +} + func (s *Server) getPeers(_ request.Params) (interface{}, *response.Error) { peers := result.NewGetPeers() peers.AddUnconnected(s.coreServer.UnconnectedPeers()) diff --git a/pkg/rpc/server/server_test.go b/pkg/rpc/server/server_test.go index e9d130558..458a75603 100644 --- a/pkg/rpc/server/server_test.go +++ b/pkg/rpc/server/server_test.go @@ -290,6 +290,28 @@ var rpcTestCases = map[string][]rpcTestCase{ fail: true, }, }, + "getutxotransfers": { + { + name: "invalid address", + params: `["notanaddress"]`, + fail: true, + }, + { + name: "invalid asset", + params: `["AKkkumHbBipZ46UMZJoFynJMXzSRnBvKcs", "notanasset"]`, + fail: true, + }, + { + name: "invalid start timestamp", + params: `["AKkkumHbBipZ46UMZJoFynJMXzSRnBvKcs", "neo", "notanumber"]`, + fail: true, + }, + { + name: "invalid end timestamp", + params: `["AKkkumHbBipZ46UMZJoFynJMXzSRnBvKcs", "neo", 123, "notanumber"]`, + fail: true, + }, + }, "getassetstate": { { name: "positive", @@ -1104,6 +1126,39 @@ func testRPCProtocol(t *testing.T, doRPCCall func(string, string, *testing.T) [] assert.ElementsMatch(t, expected, actual) }) + + t.Run("getutxotransfers", func(t *testing.T) { + testGetUTXO := func(t *testing.T, asset string, start, stop int) { + ps := []string{`"AKkkumHbBipZ46UMZJoFynJMXzSRnBvKcs"`} + if asset != "" { + ps = append(ps, fmt.Sprintf("%q", asset)) + } + if start != 0 { + if start > int(e.chain.HeaderHeight()) { + ps = append(ps, strconv.Itoa(int(time.Now().Unix()))) + } else { + b, err := e.chain.GetHeader(e.chain.GetHeaderHash(start)) + require.NoError(t, err) + ps = append(ps, strconv.Itoa(int(b.Timestamp))) + } + if stop != 0 { + b, err := e.chain.GetHeader(e.chain.GetHeaderHash(stop)) + require.NoError(t, err) + ps = append(ps, strconv.Itoa(int(b.Timestamp))) + } + } + p := strings.Join(ps, ", ") + rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "getutxotransfers", "params": [%s]}`, p) + body := doRPCCall(rpc, httpSrv.URL, t) + res := checkErrGetResult(t, body, false) + actual := new(result.GetUTXO) + require.NoError(t, json.Unmarshal(res, actual)) + checkTransfers(t, e, actual, asset, start, stop) + } + t.Run("RestrictByAsset", func(t *testing.T) { testGetUTXO(t, "neo", 0, 0) }) + t.Run("TooBigStart", func(t *testing.T) { testGetUTXO(t, "", 300, 0) }) + t.Run("RestrictAll", func(t *testing.T) { testGetUTXO(t, "", 202, 203) }) + }) } func (tc rpcTestCase) getResultPair(e *executor) (expected interface{}, res interface{}) { @@ -1190,3 +1245,45 @@ func checkNep5Transfers(t *testing.T, e *executor, acc interface{}) { require.Equal(t, "AWLYWXB8C9Lt1nHdDZJnC5cpYJjgRDLk17", res.Sent[0].Address) require.Equal(t, uint32(0), res.Sent[0].NotifyIndex) } + +func checkTransfers(t *testing.T, e *executor, acc interface{}, asset string, start, stop int) { + res := acc.(*result.GetUTXO) + require.Equal(t, res.Address, "AKkkumHbBipZ46UMZJoFynJMXzSRnBvKcs") + + // transfer from multisig address to us + u := getUTXOForBlock(res, false, asset, 1) + if start <= 1 && (stop == 0 || stop >= 1) && (asset == "neo" || asset == "") { + require.NotNil(t, u) + require.Equal(t, "be48d3a3f5d10013ab9ffee489706078714f1ea2", u.Address.StringBE()) + require.EqualValues(t, int64(util.Fixed8FromInt64(99999000)), u.Amount) + } else { + require.Nil(t, u) + } + + // transfer from us to another validator + u = getUTXOForBlock(res, true, asset, 206) + if start <= 206 && (stop == 0 || stop >= 206) && (asset == "neo" || asset == "") { + require.NotNil(t, u) + require.Equal(t, "9fbf833320ef6bc52ddee1fe6f5793b42e9b307e", u.Address.StringBE()) + require.EqualValues(t, int64(util.Fixed8FromInt64(1000)), u.Amount) + } else { + require.Nil(t, u) + } +} + +func getUTXOForBlock(res *result.GetUTXO, sent bool, asset string, b uint32) *result.UTXO { + arr := res.Received + if sent { + arr = res.Sent + } + for i := range arr { + if arr[i].AssetName == strings.ToUpper(asset) { + for j := range arr[i].Transactions { + if b == arr[i].Transactions[j].Index { + return &arr[i].Transactions[j] + } + } + } + } + return nil +}