diff --git a/SYSCOIN_5_API_UPDATES.md b/SYSCOIN_5_API_UPDATES.md index eb68ffa95d..4f33c45c91 100644 --- a/SYSCOIN_5_API_UPDATES.md +++ b/SYSCOIN_5_API_UPDATES.md @@ -19,6 +19,11 @@ - **Purpose**: Returns filtered list of assets matching search criteria - **Response**: Paginated list of assets with basic information +#### Send transaction, optional params +- **Endpoint**: `POST` or `GET` `/api/v2/sendtx/`; explorer `POST` sendtx +- **Purpose**: Optional `maxfeerate` / `maxburnamount` for Syscoin Core `sendrawtransaction` (JSON body starting with `{` and `hex` field, else raw hex; `GET` query string; optional form fields) — **Syscoin** only; the defaults are set to **0.10** and **150** SYS +- **Response**: Same as before (`result` = txid) + ### 3. Enhanced Transaction Responses #### SPT Transaction Support diff --git a/bchain/coins/blockchain.go b/bchain/coins/blockchain.go index 89aa19e9d8..4a3f4704bd 100644 --- a/bchain/coins/blockchain.go +++ b/bchain/coins/blockchain.go @@ -283,6 +283,16 @@ func (c *blockChainWithMetrics) SendRawTransaction(tx string) (v string, err err return c.b.SendRawTransaction(tx) } +func (c *blockChainWithMetrics) SendRawTransactionWithOpts(p bchain.SendRawTransactionParams) (string, error) { + o, ok := c.b.(bchain.SendRawTransactionOpts) + if !ok { + return "", fmt.Errorf("maxfeerate and maxburnamount are supported only on Syscoin") + } + var err error + defer func(s time.Time) { c.observeRPCLatency("SendRawTransactionWithOpts", s, err) }(time.Now()) + return o.SendRawTransactionWithOpts(p) +} + func (c *blockChainWithMetrics) GetMempoolEntry(txid string) (v *bchain.MempoolEntry, err error) { defer func(s time.Time) { c.observeRPCLatency("GetMempoolEntry", s, err) }(time.Now()) return c.b.GetMempoolEntry(txid) diff --git a/bchain/coins/btc/bitcoinrpc.go b/bchain/coins/btc/bitcoinrpc.go index 73b380290e..7d61010b73 100644 --- a/bchain/coins/btc/bitcoinrpc.go +++ b/bchain/coins/btc/bitcoinrpc.go @@ -420,8 +420,8 @@ type ResEstimateFee struct { // sendrawtransaction type CmdSendRawTransaction struct { - Method string `json:"method"` - Params []string `json:"params"` + Method string `json:"method"` + Params []interface{} `json:"params"` } type ResSendRawTransaction struct { @@ -887,13 +887,29 @@ func (b *BitcoinRPC) EstimateFee(blocks int) (big.Int, error) { return r, nil } -// SendRawTransaction sends raw transaction -func (b *BitcoinRPC) SendRawTransaction(tx string) (string, error) { +func nonEmptyPtr(s *string) bool { + return s != nil && *s != "" +} + +// SendRawTransactionWithParams calls sendrawtransaction with optional Core-compatible +// maxfeerate / maxburnamount. Package-level function so types embedding BitcoinRPC do not +// promote it as SendRawTransactionOpts (only SyscoinRPC implements that interface). +func SendRawTransactionWithParams(b *BitcoinRPC, p bchain.SendRawTransactionParams) (string, error) { glog.V(1).Info("rpc: sendrawtransaction") + params := []interface{}{p.Hex} + if nonEmptyPtr(p.MaxFeeRate) { + params = append(params, *p.MaxFeeRate) + } + if nonEmptyPtr(p.MaxBurnAmount) { + if !nonEmptyPtr(p.MaxFeeRate) { + return "", errors.New("maxfeerate is required when maxburnamount is set") + } + params = append(params, *p.MaxBurnAmount) + } + res := ResSendRawTransaction{} - req := CmdSendRawTransaction{Method: "sendrawtransaction"} - req.Params = []string{tx} + req := CmdSendRawTransaction{Method: "sendrawtransaction", Params: params} err := b.Call(&req, &res) if err != nil { @@ -905,6 +921,11 @@ func (b *BitcoinRPC) SendRawTransaction(tx string) (string, error) { return res.Result, nil } +// SendRawTransaction sends raw transaction +func (b *BitcoinRPC) SendRawTransaction(tx string) (string, error) { + return SendRawTransactionWithParams(b, bchain.SendRawTransactionParams{Hex: tx}) +} + // GetMempoolEntry returns mempool data for given transaction func (b *BitcoinRPC) GetMempoolEntry(txid string) (*bchain.MempoolEntry, error) { glog.V(1).Info("rpc: getmempoolentry") diff --git a/bchain/coins/sys/syscoinparser_test.go b/bchain/coins/sys/syscoinparser_test.go index bc646e6560..26885900c9 100644 --- a/bchain/coins/sys/syscoinparser_test.go +++ b/bchain/coins/sys/syscoinparser_test.go @@ -20,6 +20,58 @@ func TestMain(m *testing.M) { os.Exit(c) } +func TestSyscoinSendRawParams(t *testing.T) { + customFeeRate := "0.25" + customBurnAmount := "42" + empty := "" + + tests := []struct { + name string + params bchain.SendRawTransactionParams + wantFeeRate string + wantBurnAmount string + }{ + { + name: "defaults both optional params", + params: bchain.SendRawTransactionParams{Hex: "abc"}, + wantFeeRate: defaultSyscoinMaxFeeRate, + wantBurnAmount: defaultSyscoinMaxBurnAmount, + }, + { + name: "preserves explicit params", + params: bchain.SendRawTransactionParams{ + Hex: "abc", + MaxFeeRate: &customFeeRate, + MaxBurnAmount: &customBurnAmount, + }, + wantFeeRate: customFeeRate, + wantBurnAmount: customBurnAmount, + }, + { + name: "defaults empty strings", + params: bchain.SendRawTransactionParams{ + Hex: "abc", + MaxFeeRate: &empty, + MaxBurnAmount: &empty, + }, + wantFeeRate: defaultSyscoinMaxFeeRate, + wantBurnAmount: defaultSyscoinMaxBurnAmount, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := syscoinSendRawParams(tt.params) + if got.MaxFeeRate == nil || *got.MaxFeeRate != tt.wantFeeRate { + t.Errorf("MaxFeeRate = %v, want %v", got.MaxFeeRate, tt.wantFeeRate) + } + if got.MaxBurnAmount == nil || *got.MaxBurnAmount != tt.wantBurnAmount { + t.Errorf("MaxBurnAmount = %v, want %v", got.MaxBurnAmount, tt.wantBurnAmount) + } + }) + } +} + func Test_GetAddrDescFromAddress_Mainnet(t *testing.T) { type args struct { address string diff --git a/bchain/coins/sys/syscoinrpc.go b/bchain/coins/sys/syscoinrpc.go index 3872416c51..566a33d466 100644 --- a/bchain/coins/sys/syscoinrpc.go +++ b/bchain/coins/sys/syscoinrpc.go @@ -146,4 +146,38 @@ func (b *SyscoinRPC) GetSPVProof(hash string) (string, error) { // It could be optimized for mempool, i.e. without block time and confirmations func (b *SyscoinRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { return b.GetTransaction(txid) -} \ No newline at end of file +} + +// Syscoin Core sendrawtransaction default maxfeerate is 0.10. +// Syscoin Core sendrawtransaction default maxburnamount is 0.0. +// Governance Proposal needs maxburnamount of 150. +// This change allows sending governance proposals without explicitly setting both parameters. +const ( + defaultSyscoinMaxFeeRate = "0.10" + defaultSyscoinMaxBurnAmount = "150" +) + +func syscoinSendRawParams(p bchain.SendRawTransactionParams) bchain.SendRawTransactionParams { + if p.MaxFeeRate == nil || *p.MaxFeeRate == "" { + s := defaultSyscoinMaxFeeRate + p.MaxFeeRate = &s + } + if p.MaxBurnAmount == nil || *p.MaxBurnAmount == "" { + s := defaultSyscoinMaxBurnAmount + p.MaxBurnAmount = &s + } + return p +} + +// Override BitcoinRPC to apply Syscoin default maxfeerate / maxburnamount. +func (b *SyscoinRPC) SendRawTransaction(tx string) (string, error) { + return b.SendRawTransactionWithOpts(bchain.SendRawTransactionParams{Hex: tx}) +} + +// Forwards maxfeerate / maxburnamount to Syscoin Core sendrawtransaction. +func (b *SyscoinRPC) SendRawTransactionWithOpts(p bchain.SendRawTransactionParams) (string, error) { + p = syscoinSendRawParams(p) + return btc.SendRawTransactionWithParams(b.BitcoinRPC, p) +} + +var _ bchain.SendRawTransactionOpts = (*SyscoinRPC)(nil) \ No newline at end of file diff --git a/bchain/types.go b/bchain/types.go index 94e9b20119..99df1c21d3 100644 --- a/bchain/types.go +++ b/bchain/types.go @@ -645,6 +645,21 @@ const ( AddressBalanceDetailUTXOIndexed = 2 ) +// SendRawTransactionParams carries optional arguments for Syscoin Core sendrawtransaction +// (maxfeerate, maxburnamount). Used with SendRawTransactionOpts. +// The implementation (see syscoinrpc.go) takes care of the proper optional argument settings. +type SendRawTransactionParams struct { + Hex string + MaxFeeRate *string + MaxBurnAmount *string +} + +// SendRawTransactionOpts is implemented by chains whose node supports +// sendrawtransaction with maxfeerate / maxburnamount (Syscoin). +type SendRawTransactionOpts interface { + SendRawTransactionWithOpts(p SendRawTransactionParams) (string, error) +} + // BlockChain defines common interface to block chain daemon type BlockChain interface { // life-cycle methods diff --git a/server/public.go b/server/public.go index 4de86122df..13a874eca4 100644 --- a/server/public.go +++ b/server/public.go @@ -1,8 +1,10 @@ package server import ( + "bytes" "context" "encoding/json" + "errors" "fmt" "html/template" "io/ioutil" @@ -991,9 +993,16 @@ func (s *PublicServer) explorerSendTx(w http.ResponseWriter, r *http.Request) (t if err != nil { return sendTransactionTpl, data, err } - hex := r.FormValue("hex") + hex := strings.TrimSpace(r.FormValue("hex")) if len(hex) > 0 { - res, err := s.chain.SendRawTransaction(hex) + p := bchain.SendRawTransactionParams{Hex: hex} + if v := strings.TrimSpace(r.FormValue("maxfeerate")); v != "" { + p.MaxFeeRate = &v + } + if v := strings.TrimSpace(r.FormValue("maxburnamount")); v != "" { + p.MaxBurnAmount = &v + } + res, err := s.submitSendRawTx(p) if err != nil { data.SendTxHex = hex data.Error = &api.APIError{Text: err.Error(), Public: true} @@ -1381,24 +1390,69 @@ type resultSendTransaction struct { Result string `json:"result"` } +func optionalNonEmptyQueryString(r *http.Request, key string) *string { + v := strings.TrimSpace(r.URL.Query().Get(key)) + if v == "" { + return nil + } + return &v +} + +func sendTxOptsRequested(p bchain.SendRawTransactionParams) bool { + return nonEmptyOptionalStr(p.MaxFeeRate) || nonEmptyOptionalStr(p.MaxBurnAmount) +} + +func nonEmptyOptionalStr(s *string) bool { + return s != nil && *s != "" +} + +func (s *PublicServer) submitSendRawTx(p bchain.SendRawTransactionParams) (string, error) { + if sendTxOptsRequested(p) { + o, ok := s.chain.(bchain.SendRawTransactionOpts) + if !ok { + return "", errors.New("maxfeerate and maxburnamount are supported only on Syscoin") + } + return o.SendRawTransactionWithOpts(p) + } + return s.chain.SendRawTransaction(p.Hex) +} + func (s *PublicServer) apiSendTx(r *http.Request, apiVersion int) (interface{}, error) { var err error var res resultSendTransaction - var hex string + var p bchain.SendRawTransactionParams s.metrics.ExplorerViews.With(common.Labels{"action": "api-sendtx"}).Inc() if r.Method == http.MethodPost { data, err := ioutil.ReadAll(r.Body) if err != nil { return nil, api.NewAPIError("Missing tx blob", true) } - hex = string(data) + data = bytes.TrimSpace(data) + if len(data) > 0 && data[0] == '{' { + var body struct { + Hex string `json:"hex"` + MaxFeeRate *string `json:"maxfeerate,omitempty"` + MaxBurnAmount *string `json:"maxburnamount,omitempty"` + } + if err := json.Unmarshal(data, &body); err != nil { + return nil, api.NewAPIError("Invalid JSON body", true) + } + p.Hex = strings.TrimSpace(body.Hex) + p.MaxFeeRate = body.MaxFeeRate + p.MaxBurnAmount = body.MaxBurnAmount + } else { + p.Hex = string(data) + } } else { if i := strings.LastIndexByte(r.URL.Path, '/'); i > 0 { - hex = r.URL.Path[i+1:] + p.Hex = r.URL.Path[i+1:] } + p.MaxFeeRate = optionalNonEmptyQueryString(r, "maxfeerate") + p.MaxBurnAmount = optionalNonEmptyQueryString(r, "maxburnamount") } - if len(hex) > 0 { - res.Result, err = s.chain.SendRawTransaction(hex) + p.Hex = strings.TrimSpace(p.Hex) + if len(p.Hex) > 0 { + res.Result, err = s.submitSendRawTx(p) if err != nil { return nil, api.NewAPIError(err.Error(), true) } diff --git a/server/public_test.go b/server/public_test.go index d2f0fdc7f9..09c38a5c2c 100644 --- a/server/public_test.go +++ b/server/public_test.go @@ -910,6 +910,24 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { `{"result":"9876"}`, }, }, + { + name: "apiSendTx POST JSON", + r: newPostRequest(ts.URL+"/api/v2/sendtx/", `{"hex":"123456"}`), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"result":"9876"}`, + }, + }, + { + name: "apiSendTx POST JSON maxburn unsupported", + r: newPostRequest(ts.URL+"/api/v2/sendtx/", `{"hex":"123456","maxburnamount":"1"}`), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"maxfeerate and maxburnamount are supported only on Syscoin"}`, + }, + }, { name: "apiSendTx POST empty", r: newPostRequest(ts.URL+"/api/v2/sendtx", ""),