Skip to content

Commit 3e37326

Browse files
authored
[KLC-2394] feat: operator add ms sign subcommand to sign multisi (#66)
## Summary Add operator ms sign subcommand to sign a multisign transaction and post the signature to the multisign API, also supporting an interactive mode to choose between pending multisignature transactions. ## Problem Previously the user would have to fetch, sign and post the signature manually with multiple commands, which was fragile. ## Solution - Add operator ms sign to fetch a pending transaction, sign it, and post the signature to the multisign API. - Support both a specific TX hash and interactive pending transaction selection. - Consolidate helper logic for fetching, signing, encoding, and posting multisign data. - Keep --multisign-api persistent on ms. - Having no pending transactions returns a friendly message - Trying to sign an already signed transaction returns a message indicating the no-op. ## Key Changes -[cmd/operator/multisign.go](): add sign subcommand, interactive selection UI, and reusable helper flow. -[cmd/operator/multsignHelper.go](): add getMSApiTransaction, doSignAndPost, and doPostMSTransaction; clarify API signature posting. -[cmd/operator/main.go](): tighten command detection for operator sign and operator ms encode. -Flags: moved --multisign-api to ms persistent flags;. ## Testing - [x] cmd/operator existing tests pass - [x] Added new tests - [x] Manually tested reading updates to transaction's signature states both between addresses and from the same address. Also tested impeding the signing of a transaction multiple times from the same user. ## Configuration Changes None ## Breaking Changes None ## Related Issues -- ## Checklist - [x] Code follows project style guidelines (`make goimports`) - [x] Tests added/updated and passing - [x] Documentation updated (added ms sign markdown file to cmd/operator/docs) - [x] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] No unnecessary files included - [x] Breaking changes documented above - [x] Configuration changes documented above
1 parent 8bcc600 commit 3e37326

5 files changed

Lines changed: 601 additions & 54 deletions

File tree

cmd/operator/main.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ var rootCmd = &cobra.Command{
9999
}
100100

101101
if createOnly ||
102-
strings.HasPrefix(cmd.Use, "sign") ||
103-
strings.HasPrefix(cmd.Use, "encode") {
102+
cmd.CommandPath() == "operator sign" ||
103+
cmd.CommandPath() == "operator ms encode" {
104104
log.SetLevel(logger.LogNone)
105105
}
106106

@@ -179,7 +179,7 @@ OPERATOR interface to Klever Blockchain
179179
}
180180

181181
func init() {
182-
rootCmd.PersistentFlags().StringVarP(&walletPemFile, "key-file", "k", "./walletKey.pem", "set walelt pem file --key-file=./walletKey.pem")
182+
rootCmd.PersistentFlags().StringVarP(&walletPemFile, "key-file", "k", "./walletKey.pem", "set wallet pem file --key-file=./walletKey.pem")
183183
rootCmd.PersistentFlags().StringVarP(&nodeAPI, "node", "n", "http://localhost:8080", "entrypoint to node API --node=https://node.testnet.klever.org")
184184
rootCmd.PersistentFlags().Uint64Var(&txNonce, "nonce", 0, "set TX nonce --nonce=33")
185185
rootCmd.PersistentFlags().StringVar(&nonceCheck, "nonce-check", "current", fmt.Sprintf("use [%s, %s, %s] nonce for the account", NONCE_CHECK_CURRENT, NONCE_CHECK_FIRST_PENDING, NONCE_CHECK_PENDING))

cmd/operator/multisign.go

Lines changed: 59 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ package main
33
import (
44
"encoding/hex"
55
"encoding/json"
6+
"errors"
67
"fmt"
8+
"io"
79
"strings"
810

911
"github.com/klever-io/klever-go/cmd/operator/utils"
1012
"github.com/klever-io/klever-go/data/transaction"
13+
1114
"github.com/spf13/cobra"
1215
)
1316

@@ -80,38 +83,15 @@ func subMS() []*cobra.Command {
8083
return err
8184
}
8285
}
86+
return doPostMSTransactionSignature(encoded)
8387

84-
// marshal
85-
data, err := json.Marshal(encoded)
86-
if err != nil {
87-
return err
88-
}
89-
90-
broadcastResult := struct {
91-
Status string `json:"status"`
92-
Error string `json:"error"`
93-
}{}
94-
95-
// broadcast
96-
log.Info("broadcasting...")
97-
err = utils.PostURL(fmt.Sprintf("%s/transaction", multisignAPI), string(data), nil, &broadcastResult)
98-
if err != nil {
99-
return err
100-
}
101-
if len(broadcastResult.Error) != 0 {
102-
return fmt.Errorf("error broadcasting transaction: %s", broadcastResult.Error)
103-
}
104-
log.Info("successful added", "txHash", encoded.Hash, "address", encoded.Address)
105-
106-
return nil
10788
},
10889
}
109-
cmdMultisignAddTransaction.Flags().StringVar(&multisignAPI, "multisign-api", "https://multisign.mainnet.klever.org", "multisign API URL")
11090

11191
cmdMultisignBroadcast := &cobra.Command{
11292
Use: "broadcast [Transaction]",
11393
Args: cobra.ExactArgs(1),
114-
Short: "broadcast a transaction form multisign API",
94+
Short: "broadcast a transaction from multisign API",
11595
RunE: func(cmd *cobra.Command, args []string) error {
11696
hash := args[0]
11797
hash = strings.Replace(hash, "0x", "", 1)
@@ -140,33 +120,20 @@ func subMS() []*cobra.Command {
140120
return nil
141121
},
142122
}
143-
cmdMultisignBroadcast.Flags().StringVar(&multisignAPI, "multisign-api", "https://multisign.mainnet.klever.org", "multisign API URL")
144123

145124
cmdMultisignFetch := &cobra.Command{
146125
Use: "by-hash [Transaction]",
147126
Args: cobra.ExactArgs(1),
148127
Short: "fetch a transaction form multisign API",
149128
RunE: func(cmd *cobra.Command, args []string) error {
150129
hash := args[0]
151-
hash = strings.Replace(hash, "0x", "", 1)
152-
if len(hash) != 64 {
153-
return fmt.Errorf("invalid TX hash length: %d", len(hash))
154-
}
155-
_, err := hex.DecodeString(hash)
156-
if len(hash) != 64 || err != nil {
157-
return fmt.Errorf("invalid TX hash %s", hash)
158-
}
159-
160-
result := MSApiTransaction{}
161-
err = utils.GetURL(fmt.Sprintf("%s/transaction/%s", multisignAPI, hash), &result)
130+
result, err := getMSApiTransaction(hash)
162131
if err != nil {
163132
return err
164133
}
165-
166134
return DumpAsJson(result)
167135
},
168136
}
169-
cmdMultisignFetch.Flags().StringVar(&multisignAPI, "multisign-api", "https://multisign.mainnet.klever.org", "multisign API URL")
170137

171138
cmdMultisignByAddress := &cobra.Command{
172139
Use: "by-address [Address]",
@@ -184,15 +151,65 @@ func subMS() []*cobra.Command {
184151
return DumpAsJson(result)
185152
},
186153
}
187-
cmdMultisignByAddress.Flags().StringVar(&multisignAPI, "multisign-api", "https://multisign.mainnet.klever.org", "multisign API URL")
188-
154+
cmdMultiSignAndPost := &cobra.Command{
155+
Use: "sign [txHash]",
156+
Short: "sign a transaction from multisign API and post the signature",
157+
Example: `operator ms sign <txHash> — sign specific TX by hash
158+
operator ms sign <txHash> -s — sign specific TX by hash; skip confirmation prompt
159+
operator ms sign — interactively choose from pending transactions`,
160+
Args: cobra.MaximumNArgs(1),
161+
RunE: func(cmd *cobra.Command, args []string) error {
162+
log.Info("signing transaction from multisign API", "address", signerAddress)
163+
var tx *MSApiTransaction
164+
userTxHash := ""
165+
var err error
166+
if len(args) == 1 {
167+
userTxHash = strings.TrimPrefix(args[0], "0x")
168+
tx, err = getMSApiTransaction(userTxHash)
169+
} else {
170+
autoSign = false
171+
log.Info("fetching pending transactions for signing")
172+
result := make([]MSApiTransaction, 0)
173+
err = utils.GetURL(fmt.Sprintf("%s/transaction/by-address/%s", multisignAPI, signerAddress), &result)
174+
if err != nil && !errors.Is(err, io.EOF) {
175+
return err
176+
}
177+
if len(result) == 0 {
178+
log.Info("no transactions found for signing")
179+
return nil
180+
}
181+
tx, err = pendingMsTransactionPicker(&result)
182+
}
183+
if err != nil {
184+
return err
185+
}
186+
if userTxHash != "" && tx != nil {
187+
gotTxHash, err := computeTxHash(tx.Raw)
188+
if err != nil {
189+
return err
190+
}
191+
encodedGotTxHash := hex.EncodeToString(gotTxHash)
192+
if encodedGotTxHash != userTxHash {
193+
return fmt.Errorf("transaction hash mismatch: expected %s, got %s", userTxHash, encodedGotTxHash)
194+
}
195+
}
196+
if tx != nil {
197+
err = doSignAndPost(tx)
198+
if err != nil {
199+
return err
200+
}
201+
}
202+
return nil
203+
},
204+
}
189205
return []*cobra.Command{
190206
cmdDecodeTransaction,
191207
cmdMultisignEncodeTransaction,
192208
cmdMultisignAddTransaction,
193209
cmdMultisignBroadcast,
194210
cmdMultisignFetch,
195211
cmdMultisignByAddress,
212+
cmdMultiSignAndPost,
196213
}
197214
}
198215

@@ -205,6 +222,6 @@ func init() {
205222
},
206223
}
207224
cmdMS.AddCommand(subMS()...)
208-
225+
cmdMS.PersistentFlags().StringVar(&multisignAPI, "multisign-api", "https://multisign.mainnet.klever.org", "multisign API URL")
209226
rootCmd.AddCommand(cmdMS)
210227
}

cmd/operator/multisign_test.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package main
2+
3+
import (
4+
"crypto/ed25519"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/klever-io/klever-go/common/mock"
11+
"github.com/klever-io/klever-go/crypto"
12+
"github.com/klever-io/klever-go/data/transaction"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// --- helpers ---
18+
19+
const validHash = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
20+
21+
func makeMSServer(t *testing.T, method, path string, status int, body interface{}) *httptest.Server {
22+
t.Helper()
23+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24+
assert.Equal(t, method, r.Method)
25+
assert.Equal(t, path, r.URL.Path)
26+
w.Header().Set("Content-Type", "application/json")
27+
w.WriteHeader(status)
28+
_ = json.NewEncoder(w).Encode(body)
29+
}))
30+
}
31+
32+
// --- getMSApiTransaction ---
33+
34+
func TestGetMSApiTransaction_InvalidHashLength(t *testing.T) {
35+
_, err := getMSApiTransaction("0xdeadbeef")
36+
require.Error(t, err)
37+
assert.Contains(t, err.Error(), "invalid TX hash length")
38+
}
39+
40+
func TestGetMSApiTransaction_InvalidHex(t *testing.T) {
41+
_, err := getMSApiTransaction("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")
42+
require.Error(t, err)
43+
assert.Contains(t, err.Error(), "invalid TX hash")
44+
}
45+
46+
func TestGetMSApiTransaction_NotFound(t *testing.T) {
47+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48+
// EOF — close without writing body
49+
}))
50+
defer srv.Close()
51+
multisignAPI = srv.URL
52+
53+
_, err := getMSApiTransaction(validHash)
54+
require.Error(t, err)
55+
assert.Contains(t, err.Error(), "not found in multisign API")
56+
}
57+
58+
func TestGetMSApiTransaction_Success(t *testing.T) {
59+
want := MSApiTransaction{Hash: validHash}
60+
srv := makeMSServer(t, http.MethodGet, "/transaction/"+validHash, http.StatusOK, want)
61+
defer srv.Close()
62+
multisignAPI = srv.URL
63+
64+
got, err := getMSApiTransaction(validHash)
65+
require.NoError(t, err)
66+
assert.Equal(t, validHash, got.Hash)
67+
}
68+
69+
func TestGetMSApiTransaction_StripPrefix(t *testing.T) {
70+
want := MSApiTransaction{Hash: validHash}
71+
srv := makeMSServer(t, http.MethodGet, "/transaction/"+validHash, http.StatusOK, want)
72+
defer srv.Close()
73+
multisignAPI = srv.URL
74+
75+
got, err := getMSApiTransaction("0x" + validHash)
76+
require.NoError(t, err)
77+
assert.Equal(t, validHash, got.Hash)
78+
}
79+
80+
// --- doSignAndPost ---
81+
82+
func TestDoSignAndPost_NotAuthorized(t *testing.T) {
83+
signerAddress = "addr_A"
84+
tx := &MSApiTransaction{
85+
Hash: validHash,
86+
Signers: []MSApiSigner{{Address: "addr_B", Signed: false}},
87+
Raw: &transaction.Transaction{},
88+
}
89+
err := doSignAndPost(tx)
90+
require.Error(t, err)
91+
assert.Contains(t, err.Error(), "not required to sign")
92+
}
93+
94+
func TestDoSignAndPost_AlreadySigned(t *testing.T) {
95+
signerAddress = "addr_A"
96+
97+
tx := &MSApiTransaction{
98+
Hash: validHash,
99+
Signers: []MSApiSigner{{Address: "addr_A", Signed: true}},
100+
Raw: &transaction.Transaction{},
101+
}
102+
// Should return nil (already signed = no-op)
103+
err := doSignAndPost(tx)
104+
assert.NoError(t, err)
105+
}
106+
107+
func TestDoSignAndPost_AutoSign_Success(t *testing.T) {
108+
prevAutoSign := autoSign
109+
prevSignerAddress := signerAddress
110+
prevPrivateKey := privateKey
111+
prevMSAPI := multisignAPI
112+
t.Cleanup(func() {
113+
autoSign = prevAutoSign
114+
signerAddress = prevSignerAddress
115+
privateKey = prevPrivateKey
116+
multisignAPI = prevMSAPI
117+
})
118+
119+
autoSign = true
120+
signerAddress = "addr_A"
121+
122+
pubKey, rawPriv, _ := ed25519.GenerateKey(nil)
123+
privateKey = &mock.PrivateKeyMock{
124+
ScalarMock: func() crypto.Scalar {
125+
return &mock.ScalarMock{
126+
GetUnderlyingObjStub: func() interface{} {
127+
return rawPriv
128+
},
129+
}
130+
},
131+
}
132+
133+
expectedAddress := walletPubKeyConverter.Encode(pubKey)
134+
135+
var capturedBody MSApiEncoded
136+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
137+
require.NoError(t, json.NewDecoder(r.Body).Decode(&capturedBody))
138+
w.Header().Set("Content-Type", "application/json")
139+
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "error": ""})
140+
}))
141+
defer srv.Close()
142+
multisignAPI = srv.URL
143+
144+
tx := &MSApiTransaction{
145+
Hash: validHash,
146+
Signers: []MSApiSigner{{Address: "addr_A", Signed: false}},
147+
Raw: &transaction.Transaction{
148+
Signature: make([][]byte, 0),
149+
RawData: &transaction.Transaction_Raw{
150+
Sender: pubKey,
151+
},
152+
},
153+
}
154+
155+
err := doSignAndPost(tx)
156+
assert.NoError(t, err)
157+
158+
assert.Equal(t, expectedAddress, capturedBody.Address, "posted address should be the bech32-encoded sender pubkey")
159+
assert.Len(t, tx.Raw.Signature, 1, "a signature should have been appended to the transaction")
160+
assert.NotEmpty(t, tx.Raw.Signature[0], "appended signature should not be empty")
161+
}
162+
163+
// --- doPostMSTransactionSignature ---
164+
165+
func TestDoPostMSTransactionSignature_Success(t *testing.T) {
166+
srv := makeMSServer(t, http.MethodPost, "/transaction", http.StatusOK,
167+
map[string]string{"status": "ok", "error": ""})
168+
defer srv.Close()
169+
multisignAPI = srv.URL
170+
171+
encoded := &MSApiEncoded{Hash: validHash, Address: "addr_A"}
172+
err := doPostMSTransactionSignature(encoded)
173+
assert.NoError(t, err)
174+
}
175+
176+
func TestDoPostMSTransactionSignature_APIError(t *testing.T) {
177+
srv := makeMSServer(t, http.MethodPost, "/transaction", http.StatusOK,
178+
map[string]string{"status": "error", "error": "invalid signature"})
179+
defer srv.Close()
180+
multisignAPI = srv.URL
181+
182+
encoded := &MSApiEncoded{Hash: validHash, Address: "addr_A"}
183+
err := doPostMSTransactionSignature(encoded)
184+
require.Error(t, err)
185+
assert.Contains(t, err.Error(), "invalid signature")
186+
}
187+
188+
func TestDoPostMSTransactionSignature_NetworkError(t *testing.T) {
189+
multisignAPI = "http://127.0.0.1:1" // nothing listening
190+
191+
encoded := &MSApiEncoded{Hash: validHash, Address: "addr_A"}
192+
err := doPostMSTransactionSignature(encoded)
193+
assert.Error(t, err)
194+
}

0 commit comments

Comments
 (0)