From 120ea165ec362ce592ef376f3a6ae1410355b98b Mon Sep 17 00:00:00 2001 From: kompotkot Date: Thu, 24 Aug 2023 11:22:18 +0000 Subject: [PATCH 1/3] GoDoc for API routes --- bugout.go | 139 ----------- cmd.go | 628 -------------------------------------------------- main.go | 4 +- moonstream.go | 287 ----------------------- server.go | 207 ----------------- settings.go | 75 ------ sign.go | 142 ------------ sign_test.go | 22 -- version.go | 3 - 9 files changed, 3 insertions(+), 1504 deletions(-) delete mode 100644 bugout.go delete mode 100644 cmd.go delete mode 100644 moonstream.go delete mode 100644 server.go delete mode 100644 settings.go delete mode 100644 sign.go delete mode 100644 sign_test.go delete mode 100644 version.go diff --git a/bugout.go b/bugout.go deleted file mode 100644 index 041ae34..0000000 --- a/bugout.go +++ /dev/null @@ -1,139 +0,0 @@ -package main - -// Much of this code is copied from waggle: https://github.com/bugout-dev/waggle/blob/main/main.go - -import ( - "encoding/csv" - "encoding/json" - "fmt" - "io" - "os" - "strings" - - bugout "github.com/bugout-dev/bugout-go/pkg" - spire "github.com/bugout-dev/bugout-go/pkg/spire" -) - -func CleanTimestamp(rawTimestamp string) string { - return strings.ReplaceAll(rawTimestamp, " ", "T") -} - -func GetCursorFromJournal(client *bugout.BugoutClient, token, journalID, cursorName string) (string, error) { - query := fmt.Sprintf("context_type:waggle tag:type:cursor tag:cursor:%s", cursorName) - parameters := map[string]string{ - "order": "desc", - "content": "true", // We may use the content in the future, even though we are simply using context_url right now - } - results, err := client.Spire.SearchEntries(token, journalID, query, 1, 0, parameters) - if err != nil { - return "", err - } - - if results.TotalResults == 0 { - return "", nil - } - - return results.Results[0].ContextUrl, nil -} - -func WriteCursorToJournal(client *bugout.BugoutClient, token, journalID, cursorName, cursor, queryTerms string) error { - title := fmt.Sprintf("waggle cursor: %s", cursorName) - entryContext := spire.EntryContext{ - ContextType: "waggle", - ContextID: cursor, - ContextURL: cursor, - } - tags := []string{ - "type:cursor", - fmt.Sprintf("cursor:%s", cursorName), - fmt.Sprintf("waggle_version:%s", WAGGLE_VERSION), - } - content := fmt.Sprintf("Cursor: %s at %s\nQuery: %s", cursorName, cursor, queryTerms) - _, err := client.Spire.CreateEntry(token, journalID, title, content, tags, entryContext) - return err -} - -func ReportsIterator(client *bugout.BugoutClient, token, journalID, cursor, queryTerms string, limit, offset int) (spire.EntryResultsPage, error) { - var query string = fmt.Sprintf("!tag:type:cursor %s", queryTerms) - if cursor != "" { - cleanedCursor := CleanTimestamp(cursor) - query = fmt.Sprintf("%s created_at:>%s", query, cleanedCursor) - fmt.Fprintln(os.Stderr, "query:", query) - } - parameters := map[string]string{ - "order": "asc", - "content": "false", - } - return client.Spire.SearchEntries(token, journalID, query, limit, offset, parameters) -} - -func LoadDropperReports(searchResults spire.EntryResultsPage) ([]DropperClaimMessage, error) { - reports := make([]DropperClaimMessage, len(searchResults.Results)) - for i, result := range searchResults.Results { - parseErr := json.Unmarshal([]byte(result.Content), &reports[i]) - if parseErr != nil { - return reports, parseErr - } - } - return reports, nil -} - -func DropperReportsToCSV(reports []DropperClaimMessage, header bool, w io.Writer) error { - numRecords := len(reports) - startIndex := 0 - if header { - numRecords++ - startIndex++ - } - - records := make([][]string, numRecords) - if header { - records[0] = []string{ - "dropId", "requestID", "claimant", "blockDeadline", "amount", "signer", "signature", - } - } - - for i, report := range reports { - records[i+startIndex] = []string{ - report.DropId, - report.RequestID, - report.Claimant, - report.BlockDeadline, - report.Amount, - report.Signer, - report.Signature, - } - } - - csvWriter := csv.NewWriter(w) - return csvWriter.WriteAll(records) -} - -func ProcessDropperClaims(client *bugout.BugoutClient, bugoutToken, journalID, cursorName, query string, batchSize int, header bool, w io.Writer) error { - cursor, cursorErr := GetCursorFromJournal(client, bugoutToken, journalID, cursorName) - if cursorErr != nil { - return cursorErr - } - - searchResults, searchErr := ReportsIterator(client, bugoutToken, journalID, cursor, query, batchSize, 0) - if searchErr != nil { - return searchErr - } - - reports, loadErr := LoadDropperReports(searchResults) - if loadErr != nil { - return loadErr - } - - writeErr := DropperReportsToCSV(reports, header, w) - if writeErr != nil { - return writeErr - } - - var processedErr error - if len(searchResults.Results) > 0 { - processedErr = WriteCursorToJournal(client, bugoutToken, journalID, cursorName, searchResults.Results[len(searchResults.Results)-1].CreatedAt, query) - } - - return processedErr -} diff --git a/cmd.go b/cmd.go deleted file mode 100644 index 643fce0..0000000 --- a/cmd.go +++ /dev/null @@ -1,628 +0,0 @@ -package main - -import ( - "encoding/csv" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "os" - "path/filepath" - "strings" - - bugout "github.com/bugout-dev/bugout-go/pkg" - "github.com/spf13/cobra" - "golang.org/x/term" -) - -func CreateRootCommand() *cobra.Command { - // rootCmd represents the base command when called without any subcommands - rootCmd := &cobra.Command{ - Use: "waggle", - Short: "Sign Moonstream transaction requests", - Long: `waggle is a CLI that allows you to sign requests for transactions on Moonstream contracts. - - waggle currently supports signatures for the following types of contracts: - - Dropper (dropper-v0.2.0) - - waggle makes it easy to sign large numbers of requests in a very short amount of time. It also allows - you to automatically send transaction requests to the Moonstream API. - `, - Run: func(cmd *cobra.Command, args []string) {}, - } - - versionCmd := CreateVersionCommand() - signCmd := CreateSignCommand() - accountsCmd := CreateAccountsCommand() - moonstreamCommand := CreateMoonstreamCommand() - serverCommand := CreateServerCommand() - rootCmd.AddCommand(versionCmd, signCmd, accountsCmd, moonstreamCommand, serverCommand) - - completionCmd := CreateCompletionCommand(rootCmd) - rootCmd.AddCommand(completionCmd) - - return rootCmd -} - -func CreateCompletionCommand(rootCmd *cobra.Command) *cobra.Command { - completionCmd := &cobra.Command{ - Use: "completion", - Short: "Generate shell completion scripts for waggle", - Long: `Generate shell completion scripts for waggle. - -The command for each shell will print a completion script to stdout. You can source this script to get -completions in your current shell session. You can add this script to the completion directory for your -shell to get completions for all future sessions. - -For example, to activate bash completions in your current shell: - $ . <(wagggle completion bash) - -To add waggle completions for all bash sessions: - $ waggle completion bash > /etc/bash_completion.d/waggle_completions`, - } - - bashCompletionCmd := &cobra.Command{ - Use: "bash", - Short: "bash completions for waggle", - Run: func(cmd *cobra.Command, args []string) { - rootCmd.GenBashCompletion(cmd.OutOrStdout()) - }, - } - - zshCompletionCmd := &cobra.Command{ - Use: "zsh", - Short: "zsh completions for waggle", - Run: func(cmd *cobra.Command, args []string) { - rootCmd.GenZshCompletion(cmd.OutOrStdout()) - }, - } - - fishCompletionCmd := &cobra.Command{ - Use: "fish", - Short: "fish completions for waggle", - Run: func(cmd *cobra.Command, args []string) { - rootCmd.GenFishCompletion(cmd.OutOrStdout(), true) - }, - } - - powershellCompletionCmd := &cobra.Command{ - Use: "powershell", - Short: "powershell completions for waggle", - Run: func(cmd *cobra.Command, args []string) { - rootCmd.GenPowerShellCompletion(cmd.OutOrStdout()) - }, - } - - completionCmd.AddCommand(bashCompletionCmd, zshCompletionCmd, fishCompletionCmd, powershellCompletionCmd) - - return completionCmd -} - -func CreateVersionCommand() *cobra.Command { - versionCmd := &cobra.Command{ - Use: "version", - Short: "Print the version number of waggle", - Long: `All software has versions. This is waggle's`, - Run: func(cmd *cobra.Command, args []string) { - cmd.Println(WAGGLE_VERSION) - }, - } - return versionCmd -} - -func CreateAccountsCommand() *cobra.Command { - accountsCommand := &cobra.Command{ - Use: "accounts", - Short: "Set up signing accounts", - } - - var keyfile string - - accountsCommand.PersistentFlags().StringVarP(&keyfile, "keystore", "k", "", "Path to keystore file.") - - importCommand := &cobra.Command{ - Use: "import", - Short: "Import a signing account from a private key.", - Long: "Import a signing account from a private key. This will be stored at the given keystore path.", - RunE: func(cmd *cobra.Command, args []string) error { - return KeyfileFromPrivateKey(keyfile) - }, - } - - accountsCommand.AddCommand(importCommand) - - return accountsCommand -} - -func CreateSignCommand() *cobra.Command { - signCommand := &cobra.Command{ - Use: "sign", - Short: "Sign transaction requests", - Long: "Contains various commands that help you sign transaction requests", - } - - // All variables to be used for arguments. - var chainId int64 - var batchSize int - var bugoutToken, cursorName, journalID, keyfile, password, claimant, dropperAddress, dropId, requestId, blockDeadline, amount, infile, outfile, query string - var sensible, header, isCSV bool - - signCommand.PersistentFlags().StringVarP(&keyfile, "keystore", "k", "", "Path to keystore file (this should be a JSON file).") - signCommand.PersistentFlags().StringVarP(&password, "password", "p", "", "Password for keystore file. If not provided, you will be prompted for it when you sign with the key.") - signCommand.PersistentFlags().BoolVar(&sensible, "sensible", false, "Set this flag if you do not want to shift the final, v, byte of all signatures by 27. For reference: https://github.com/ethereum/go-ethereum/issues/2053") - - var rawMessage []byte - rawSubcommand := &cobra.Command{ - Use: "hash", - Short: "Sign a raw message hash", - RunE: func(cmd *cobra.Command, args []string) error { - key, err := KeyFromFile(keyfile, password) - if err != nil { - return err - } - - signature, err := SignRawMessage(rawMessage, key, sensible) - if err != nil { - return err - } - - cmd.Println(hex.EncodeToString(signature)) - return nil - }, - } - rawSubcommand.Flags().BytesHexVarP(&rawMessage, "message", "m", []byte{}, "Raw message to sign (do not include the 0x prefix).") - - dropperSubcommand := &cobra.Command{ - Use: "dropper", - Short: "Dropper-related signing functionality", - } - - dropperHashSubcommand := &cobra.Command{ - Use: "hash", - Short: "Generate a message hash for a claim method call", - RunE: func(cmd *cobra.Command, args []string) error { - messageHash, err := DropperClaimMessageHash(chainId, dropperAddress, dropId, requestId, claimant, blockDeadline, amount) - if err != nil { - return err - } - cmd.Println(hex.EncodeToString(messageHash)) - return nil - }, - } - dropperHashSubcommand.Flags().Int64Var(&chainId, "chain-id", 1, "Chain ID of the network you are signing for.") - dropperHashSubcommand.Flags().StringVar(&dropperAddress, "dropper", "0x0000000000000000000000000000000000000000", "Address of Dropper contract") - dropperHashSubcommand.Flags().StringVar(&dropId, "drop-id", "0", "ID of the drop.") - dropperHashSubcommand.Flags().StringVar(&requestId, "request-id", "0", "ID of the request.") - dropperHashSubcommand.Flags().StringVar(&claimant, "claimant", "", "Address of the intended claimant.") - dropperHashSubcommand.Flags().StringVar(&blockDeadline, "block-deadline", "0", "Block number by which the claim must be made.") - dropperHashSubcommand.Flags().StringVar(&amount, "amount", "0", "Amount of tokens to distribute.") - - dropperSingleSubcommand := &cobra.Command{ - Use: "single", - Short: "Sign a single claim method call", - RunE: func(cmd *cobra.Command, args []string) error { - messageHash, hashErr := DropperClaimMessageHash(chainId, dropperAddress, dropId, requestId, claimant, blockDeadline, amount) - if hashErr != nil { - return hashErr - } - - if header { - cmd.Println(hex.EncodeToString(messageHash)) - return nil - } - - key, keyErr := KeyFromFile(keyfile, password) - if keyErr != nil { - return keyErr - } - - signedMessage, err := SignRawMessage(messageHash, key, sensible) - if err != nil { - return err - } - - result := DropperClaimMessage{ - DropId: dropId, - RequestID: requestId, - Claimant: claimant, - BlockDeadline: blockDeadline, - Amount: amount, - Signature: hex.EncodeToString(signedMessage), - Signer: key.Address.Hex(), - } - resultJSON, encodeErr := json.Marshal(result) - if encodeErr != nil { - return encodeErr - } - os.Stdout.Write(resultJSON) - return nil - }, - } - dropperSingleSubcommand.Flags().Int64Var(&chainId, "chain-id", 1, "Chain ID of the network you are signing for.") - dropperSingleSubcommand.Flags().StringVar(&dropperAddress, "dropper", "0x0000000000000000000000000000000000000000", "Address of Dropper contract") - dropperSingleSubcommand.Flags().StringVar(&dropId, "drop-id", "0", "ID of the drop.") - dropperSingleSubcommand.Flags().StringVar(&requestId, "request-id", "0", "ID of the request.") - dropperSingleSubcommand.Flags().StringVar(&claimant, "claimant", "", "Address of the intended claimant.") - dropperSingleSubcommand.Flags().StringVar(&blockDeadline, "block-deadline", "0", "Block number by which the claim must be made.") - dropperSingleSubcommand.Flags().StringVar(&amount, "amount", "0", "Amount of tokens to distribute.") - dropperSingleSubcommand.Flags().BoolVar(&header, "hash", false, "Output the message hash instead of the signature.") - - dropperBatchSubcommand := &cobra.Command{ - Use: "batch", - Short: "Sign a batch of claim method calls", - RunE: func(cmd *cobra.Command, args []string) error { - key, keyErr := KeyFromFile(keyfile, password) - if keyErr != nil { - return keyErr - } - - var batchRaw []byte - var readErr error - - var batch []*DropperClaimMessage - - if !isCSV { - if infile != "" { - batchRaw, readErr = os.ReadFile(infile) - } else { - batchRaw, readErr = io.ReadAll(os.Stdin) - } - if readErr != nil { - return readErr - } - - parseErr := json.Unmarshal(batchRaw, &batch) - if parseErr != nil { - return parseErr - } - } else { - var csvReader *csv.Reader - if infile == "" { - csvReader = csv.NewReader(os.Stdin) - } else { - r, csvOpenErr := os.Open(infile) - if csvOpenErr != nil { - return csvOpenErr - } - defer r.Close() - - csvReader = csv.NewReader(r) - } - - csvData, csvReadErr := csvReader.ReadAll() - if csvReadErr != nil { - return csvReadErr - } - - csvHeaders := csvData[0] - csvData = csvData[1:] - batch = make([]*DropperClaimMessage, len(csvData)) - - for i, row := range csvData { - jsonData := make(map[string]string) - - for j, value := range row { - jsonData[csvHeaders[j]] = value - } - - jsonString, rowMarshalErr := json.Marshal(jsonData) - if rowMarshalErr != nil { - return rowMarshalErr - } - - rowParseErr := json.Unmarshal(jsonString, &batch[i]) - if rowParseErr != nil { - return rowParseErr - } - } - } - - for _, message := range batch { - messageHash, hashErr := DropperClaimMessageHash(chainId, dropperAddress, message.DropId, message.RequestID, message.Claimant, message.BlockDeadline, message.Amount) - if hashErr != nil { - return hashErr - } - - signedMessage, signatureErr := SignRawMessage(messageHash, key, sensible) - if signatureErr != nil { - return signatureErr - } - - message.Signature = hex.EncodeToString(signedMessage) - message.Signer = key.Address.Hex() - } - - resultJSON, encodeErr := json.Marshal(batch) - if encodeErr != nil { - return encodeErr - } - - if outfile != "" { - os.WriteFile(outfile, resultJSON, 0644) - } else { - os.Stdout.Write(resultJSON) - } - - return nil - }, - } - dropperBatchSubcommand.Flags().Int64Var(&chainId, "chain-id", 1, "Chain ID of the network you are signing for.") - dropperBatchSubcommand.Flags().StringVar(&dropperAddress, "dropper", "0x0000000000000000000000000000000000000000", "Address of Dropper contract") - dropperBatchSubcommand.Flags().StringVar(&infile, "infile", "", "Input file. If not specified, input will be expected from stdin.") - dropperBatchSubcommand.Flags().StringVar(&outfile, "outfile", "", "Output file. If not specified, output will be written to stdout.") - dropperBatchSubcommand.Flags().BoolVar(&isCSV, "csv", false, "Set this flag if the --infile is a CSV file.") - - dropperPullSubcommand := &cobra.Command{ - Use: "pull", - Short: "Pull unprocessed claim requests from the Bugout API", - Long: "Pull unprocessed claim requests from the Bugout API and write them to a CSV file.", - RunE: func(cmd *cobra.Command, args []string) error { - bugoutClient, bugoutErr := bugout.ClientFromEnv() - if bugoutErr != nil { - return bugoutErr - } - - if bugoutToken == "" { - return errors.New("please specify a Bugout API access token, either by passing it as the --token/-t argument or by setting the BUGOUT_ACCESS_TOKEN environment variable") - } - - if journalID == "" { - return errors.New("please specify a Bugout journal ID, by passing it as the --journal/-j argument") - } - - return ProcessDropperClaims(&bugoutClient, bugoutToken, journalID, cursorName, query, batchSize, header, os.Stdout) - }, - } - dropperPullSubcommand.Flags().StringVarP(&bugoutToken, "token", "t", BUGOUT_ACCESS_TOKEN, "Bugout API access token. If you don't have one, you can generate one at https://bugout.dev/account/tokens.") - dropperPullSubcommand.Flags().StringVarP(&journalID, "journal", "j", "", "ID of Bugout journal from which to pull claim requests.") - dropperPullSubcommand.Flags().StringVarP(&cursorName, "cursor", "c", "", "Name of cursor which defines which requests are processed and which ones are not.") - dropperPullSubcommand.Flags().StringVarP(&query, "query", "q", "", "Additional Bugout search query to apply in Bugout.") - dropperPullSubcommand.Flags().IntVarP(&batchSize, "batch-size", "N", 500, "Maximum number of messages to process.") - dropperPullSubcommand.Flags().BoolVarP(&header, "header", "H", true, "Set this flag to include header row in output CSV.") - - dropperSubcommand.AddCommand(dropperHashSubcommand, dropperSingleSubcommand, dropperBatchSubcommand, dropperPullSubcommand) - - signCommand.AddCommand(rawSubcommand, dropperSubcommand) - - return signCommand -} - -func CreateMoonstreamCommand() *cobra.Command { - moonstreamCommand := &cobra.Command{ - Use: "moonstream", - Short: "Interact with the Moonstream Engine API", - Long: "Commands that help you interact with the Moonstream Engine API from your command-line.", - } - - var blockchain, address, contractType, contractId, contractAddress, infile string - var limit, offset, batchSize int - var showExpired bool - - contractsSubcommand := &cobra.Command{ - Use: "contracts", - Short: "List all your registered contracts.", - RunE: func(cmd *cobra.Command, args []string) error { - client, clientErr := ClientFromEnv() - if clientErr != nil { - return clientErr - } - - contracts, err := client.ListRegisteredContracts(blockchain, address, contractType, limit, offset) - if err != nil { - return err - } - - encodeErr := json.NewEncoder(cmd.OutOrStdout()).Encode(contracts) - return encodeErr - }, - } - contractsSubcommand.Flags().StringVar(&blockchain, "blockchain", "", "Blockchain") - contractsSubcommand.Flags().StringVar(&address, "address", "", "Contract address") - contractsSubcommand.Flags().StringVar(&contractType, "contract-type", "", "Contract type (valid types: \"raw\", \"dropper-v0.2.0\")") - contractsSubcommand.Flags().IntVar(&limit, "limit", 100, "Limit") - contractsSubcommand.Flags().IntVar(&offset, "offset", 0, "Offset") - - callRequestsSubcommand := &cobra.Command{ - Use: "call-requests", - Short: "List call requests for a given caller.", - RunE: func(cmd *cobra.Command, args []string) error { - client, clientErr := ClientFromEnv() - if clientErr != nil { - return clientErr - } - - callRequests, err := client.ListCallRequests(contractId, contractAddress, address, limit, offset, showExpired) - if err != nil { - return err - } - - encodeErr := json.NewEncoder(cmd.OutOrStdout()).Encode(callRequests) - return encodeErr - }, - } - callRequestsSubcommand.Flags().StringVar(&contractId, "contract-id", "", "Moonstream Engine ID of the registered contract") - callRequestsSubcommand.Flags().StringVar(&contractAddress, "contract-address", "", "Address of the contract (at least one of --contract-id or --contract-address must be specified)") - callRequestsSubcommand.Flags().StringVar(&address, "caller", "", "Address of caller") - callRequestsSubcommand.Flags().IntVar(&limit, "limit", 100, "Limit") - callRequestsSubcommand.Flags().IntVar(&offset, "offset", 0, "Offset") - callRequestsSubcommand.Flags().BoolVar(&showExpired, "show-expired", false, "Specify this flag to show expired call requests") - - createCallRequestsSubcommand := &cobra.Command{ - Use: "drop", - Short: "Submit Dropper call requests to the Moonstream Engine API.", - RunE: func(cmd *cobra.Command, args []string) error { - client, clientErr := ClientFromEnv() - if clientErr != nil { - return clientErr - } - - var messagesRaw []byte - var readErr error - if infile != "" { - messagesRaw, readErr = os.ReadFile(infile) - } else { - messagesRaw, readErr = io.ReadAll(os.Stdin) - } - if readErr != nil { - return readErr - } - - if batchSize == 0 { - return fmt.Errorf("wor") - } - - var messages []*DropperClaimMessage - parseErr := json.Unmarshal(messagesRaw, &messages) - if parseErr != nil { - return parseErr - } - - callRequests := make([]CallRequestSpecification, len(messages)) - for i, message := range messages { - callRequests[i] = CallRequestSpecification{ - Caller: message.Claimant, - Method: "claim", - RequestId: message.RequestID, - Parameters: DropperCallRequestParameters{ - DropId: message.DropId, - BlockDeadline: message.BlockDeadline, - Amount: message.Amount, - Signer: message.Signer, - Signature: message.Signature, - }, - } - } - - err := client.CreateCallRequests(contractId, contractAddress, limit, callRequests, batchSize) - return err - }, - } - createCallRequestsSubcommand.Flags().StringVar(&contractId, "contract-id", "", "Moonstream Engine ID of the registered contract") - createCallRequestsSubcommand.Flags().StringVar(&contractAddress, "contract-address", "", "Address of the contract (at least one of --contract-id or --contract-address must be specified)") - createCallRequestsSubcommand.Flags().IntVar(&limit, "ttl-days", 30, "Number of days for which request will remain active") - createCallRequestsSubcommand.Flags().StringVar(&infile, "infile", "", "Input file. If not specified, input will be expected from stdin.") - createCallRequestsSubcommand.Flags().IntVar(&batchSize, "batch-size", 100, "Number of rows per request to API") - - moonstreamCommand.AddCommand(contractsSubcommand, callRequestsSubcommand, createCallRequestsSubcommand) - - return moonstreamCommand -} - -func CreateServerCommand() *cobra.Command { - serverCommand := &cobra.Command{ - Use: "server", - Short: "API of signing and registration of call requests", - } - - var host, config string - var port, logLevel int - runSubcommand := &cobra.Command{ - Use: "run", - Short: "Run API server.", - RunE: func(cmd *cobra.Command, args []string) error { - configs, configsErr := ReadServerSignerConfig(config) - if configsErr != nil { - return configsErr - } - if len(*configs) == 0 { - return fmt.Errorf("no signers available") - } - - availableSigners := make(map[string]AvailableSigner) - for _, c := range *configs { - passwordRaw, readErr := os.ReadFile(c.KeyfilePasswordPath) - if readErr != nil { - return readErr - } - key, keyErr := KeyFromFile(c.KeyfilePath, string(passwordRaw)) - if keyErr != nil { - return keyErr - } - availableSigners[key.Address.String()] = AvailableSigner{ - key: key, - } - log.Printf("Loaded signer %s", key.Address.String()) - } - corsWhitelist := make(map[string]bool) - for _, o := range strings.Split(WAGGLE_CORS_ALLOWED_ORIGINS, ",") { - corsWhitelist[o] = true - } - - server := Server{ - Host: host, - Port: port, - AvailableSigners: availableSigners, - CORSWhitelist: corsWhitelist, - } - - serveErr := server.Serve() - return serveErr - }, - } - runSubcommand.Flags().StringVar(&host, "host", "127.0.0.1", "Server listening address") - runSubcommand.Flags().IntVar(&port, "port", 7379, "Server listening port") - runSubcommand.Flags().StringVar(&config, "config", "./config.json", "Path to server configuration file") - runSubcommand.Flags().IntVar(&logLevel, "log-level", 1, "Log verbosity level") - - var keyfile, password, outfile string - - configureCommand := &cobra.Command{ - Use: "configure", - Short: "Prepare configuration for waggle API server.", - RunE: func(cmd *cobra.Command, args []string) error { - serverSignerConfigs := []ServerSignerConfig{} - var passwordRaw []byte - var err error - if password == "" { - fmt.Print("Enter password for keyfile (it will not be displayed on screen): ") - passwordRaw, err = term.ReadPassword(int(os.Stdin.Fd())) - fmt.Print("\n") - if err != nil { - return fmt.Errorf("error reading password from input: %s", err.Error()) - } - } else { - passwordRaw = []byte(password) - } - - keyfilePath := strings.TrimSuffix(keyfile, "/") - _, err = os.Stat(keyfilePath) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("file %s not found, err: %v", keyfilePath, err) - } - return fmt.Errorf("error due checking keyfile path %s, err: %v", keyfilePath, err) - } - dir, file := filepath.Split(keyfilePath) - passwordFilePath := fmt.Sprintf("%spassword-%s", dir, file) - os.WriteFile(passwordFilePath, passwordRaw, 0640) - - // TODO(kompotkot): Provide functionality to generate config with multiple keyfiles - serverSignerConfigs = append(serverSignerConfigs, ServerSignerConfig{ - KeyfilePath: keyfile, - KeyfilePasswordPath: passwordFilePath, - }) - resultJSON, err := json.Marshal(serverSignerConfigs) - if err != nil { - return err - } - - if outfile != "" { - os.WriteFile(outfile, resultJSON, 0644) - } else { - os.Stdout.Write(resultJSON) - } - - return nil - }, - } - - configureCommand.PersistentFlags().StringVarP(&keyfile, "keystore", "k", "", "Path to keystore file (this should be a JSON file)") - configureCommand.PersistentFlags().StringVarP(&password, "password", "p", "", "Password for keystore file. If not provided, you will be prompted for it when you sign with the key") - configureCommand.PersistentFlags().StringVarP(&outfile, "outfile", "o", "config.json", "Config file output path") - - serverCommand.AddCommand(runSubcommand, configureCommand) - - return serverCommand -} diff --git a/main.go b/main.go index 1e05a86..07554d0 100644 --- a/main.go +++ b/main.go @@ -3,10 +3,12 @@ package main import ( "fmt" "os" + + "github.com/moonstream-to/waggle/cmd/waggle" ) func main() { - command := CreateRootCommand() + command := waggle.CreateRootCommand() err := command.Execute() if err != nil { fmt.Println(err.Error()) diff --git a/moonstream.go b/moonstream.go deleted file mode 100644 index a6c09a2..0000000 --- a/moonstream.go +++ /dev/null @@ -1,287 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "time" -) - -type RegisteredContract struct { - Id string `json:"id"` - Blockchain string `json:"blockchain"` - Address string `json:"address"` - MetatxRequesterId string `json:"metatx_requester_id"` - Title string `json:"title"` - Description string `json:"description"` - ImageURI string `json:"image_uri"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type CallRequest struct { - Id string `json:"id"` - ContractId string `json:"contract_id"` - ContractAddress string `json:"contract_address"` - MetatxRequesterId string `json:"metatx_requester_id"` - CallRequestType string `json:"call_request_type"` - Caller string `json:"caller"` - Method string `json:"method"` - RequestId string `json:"request_id"` - Parameters interface{} `json:"parameters"` - ExpiresAt time.Time `json:"expires_at"` - CreatedAt time.Time `json:"created_at"` - UpdateAt time.Time `json:"updated_at"` -} - -type CallRequestSpecification struct { - Caller string `json:"caller"` - Method string `json:"method"` - RequestId string `json:"request_id"` - Parameters interface{} `json:"parameters"` -} - -type CreateCallRequestsRequest struct { - ContractID string `json:"contract_id,omitempty"` - ContractAddress string `json:"contract_address,omitempty"` - TTLDays int `json:"ttl_days"` - Specifications []CallRequestSpecification `json:"specifications"` -} - -type DropperCallRequestParameters struct { - DropId string `json:"dropId"` - BlockDeadline string `json:"blockDeadline"` - Amount string `json:"amount"` - Signer string `json:"signer"` - Signature string `json:"signature"` -} - -type MoonstreamEngineAPIClient struct { - AccessToken string - BaseURL string - HTTPClient *http.Client -} - -func ClientFromEnv() (*MoonstreamEngineAPIClient, error) { - if MOONSTREAM_ACCESS_TOKEN == "" { - return nil, fmt.Errorf("set the MOONSTREAM_ACCESS_TOKEN environment variable") - } - if MOONSTREAM_API_URL == "" { - MOONSTREAM_API_URL = "https://api.moonstream.to" - } - if MOONSTREAM_API_TIMEOUT_SECONDS == "" { - MOONSTREAM_API_TIMEOUT_SECONDS = "30" - } - timeoutSeconds, conversionErr := strconv.Atoi(MOONSTREAM_API_TIMEOUT_SECONDS) - if conversionErr != nil { - return nil, conversionErr - } - timeout := time.Duration(timeoutSeconds) * time.Second - httpClient := http.Client{Timeout: timeout} - - return &MoonstreamEngineAPIClient{ - AccessToken: MOONSTREAM_ACCESS_TOKEN, - BaseURL: MOONSTREAM_API_URL, - HTTPClient: &httpClient, - }, nil -} - -func (client *MoonstreamEngineAPIClient) ListRegisteredContracts(blockchain, address, contractType string, limit, offset int) ([]RegisteredContract, error) { - var contracts []RegisteredContract - - request, requestCreationErr := http.NewRequest("GET", fmt.Sprintf("%s/metatx/contracts/", client.BaseURL), nil) - if requestCreationErr != nil { - return contracts, requestCreationErr - } - - request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", client.AccessToken)) - request.Header.Add("Accept", "application/json") - - queryParameters := request.URL.Query() - if blockchain != "" { - queryParameters.Add("blockchain", blockchain) - } - if address != "" { - queryParameters.Add("address", address) - } - if contractType != "" { - queryParameters.Add("contract_type", contractType) - } - queryParameters.Add("limit", strconv.Itoa(limit)) - queryParameters.Add("offset", strconv.Itoa(offset)) - - request.URL.RawQuery = queryParameters.Encode() - - response, responseErr := client.HTTPClient.Do(request) - if responseErr != nil { - return contracts, responseErr - } - defer response.Body.Close() - - responseBody, responseBodyErr := io.ReadAll(response.Body) - - if response.StatusCode < 200 || response.StatusCode >= 300 { - if responseBodyErr != nil { - return contracts, fmt.Errorf("unexpected status code: %d -- could not read response body: %s", response.StatusCode, responseBodyErr.Error()) - } - responseBodyString := string(responseBody) - return contracts, fmt.Errorf("unexpected status code: %d -- response body: %s", response.StatusCode, responseBodyString) - } - - if responseBodyErr != nil { - return contracts, fmt.Errorf("could not read response body: %s", responseBodyErr.Error()) - } - - unmarshalErr := json.Unmarshal(responseBody, &contracts) - if unmarshalErr != nil { - return contracts, fmt.Errorf("could not parse response body: %s", unmarshalErr.Error()) - } - - return contracts, nil -} - -func (client *MoonstreamEngineAPIClient) ListCallRequests(contractId, contractAddress, caller string, limit, offset int, showExpired bool) ([]CallRequest, error) { - var callRequests []CallRequest - - if caller == "" { - return callRequests, fmt.Errorf("you must specify caller when listing call requests") - } - - request, requestCreationErr := http.NewRequest("GET", fmt.Sprintf("%s/metatx/requests", client.BaseURL), nil) - if requestCreationErr != nil { - return callRequests, requestCreationErr - } - - request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", client.AccessToken)) - request.Header.Add("Accept", "application/json") - - queryParameters := request.URL.Query() - if contractId != "" { - queryParameters.Add("contract_id", contractId) - } - if contractAddress != "" { - queryParameters.Add("contract_address", contractAddress) - } - queryParameters.Add("caller", caller) - queryParameters.Add("limit", strconv.Itoa(limit)) - queryParameters.Add("offset", strconv.Itoa(offset)) - queryParameters.Add("show_expired", strconv.FormatBool(showExpired)) - - request.URL.RawQuery = queryParameters.Encode() - - response, responseErr := client.HTTPClient.Do(request) - if responseErr != nil { - return callRequests, responseErr - } - defer response.Body.Close() - - responseBody, responseBodyErr := io.ReadAll(response.Body) - - if response.StatusCode < 200 || response.StatusCode >= 300 { - if responseBodyErr != nil { - return callRequests, fmt.Errorf("unexpected status code: %d -- could not read response body: %s", response.StatusCode, responseBodyErr.Error()) - } - responseBodyString := string(responseBody) - return callRequests, fmt.Errorf("unexpected status code: %d -- response body: %s", response.StatusCode, responseBodyString) - } - - if responseBodyErr != nil { - return callRequests, fmt.Errorf("could not read response body: %s", responseBodyErr.Error()) - } - - unmarshalErr := json.Unmarshal(responseBody, &callRequests) - if unmarshalErr != nil { - return callRequests, fmt.Errorf("could not parse response body: %s", unmarshalErr.Error()) - } - - return callRequests, nil -} - -// sendCallRequests sends a POST request to metatx API -func (client *MoonstreamEngineAPIClient) sendCallRequests(requestBodyBytes []byte) error { - request, requestCreationErr := http.NewRequest("POST", fmt.Sprintf("%s/metatx/requests", client.BaseURL), bytes.NewBuffer(requestBodyBytes)) - if requestCreationErr != nil { - return requestCreationErr - } - - request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", client.AccessToken)) - request.Header.Add("Accept", "application/json") - request.Header.Add("Content-Type", "application/json") - - response, responseErr := client.HTTPClient.Do(request) - if responseErr != nil { - return responseErr - } - defer response.Body.Close() - - responseBody, responseBodyErr := io.ReadAll(response.Body) - - if response.StatusCode < 200 || response.StatusCode >= 300 { - if responseBodyErr != nil { - return fmt.Errorf("unexpected status code: %d -- could not read response body: %s", response.StatusCode, responseBodyErr.Error()) - } - responseBodyString := string(responseBody) - return fmt.Errorf("unexpected status code: %d -- response body: %s", response.StatusCode, responseBodyString) - } - - return nil -} - -func (client *MoonstreamEngineAPIClient) CreateCallRequests(contractId, contractAddress string, ttlDays int, specs []CallRequestSpecification, batchSize int) error { - if contractId == "" && contractAddress == "" { - return fmt.Errorf("you must specify at least one of contractId or contractAddress when creating call requests") - } - - var specBatches [][]CallRequestSpecification - for i := 0; i <= len(specs); i += batchSize { - if i+batchSize > len(specs) { - specBatches = append(specBatches, specs[i:]) - break - } - specBatches = append(specBatches, specs[i:i+batchSize]) - } - - for i, batchSpecs := range specBatches { - requestBody := CreateCallRequestsRequest{ - TTLDays: ttlDays, - Specifications: batchSpecs, - } - - if contractId != "" { - requestBody.ContractID = contractId - } - - if contractAddress != "" { - requestBody.ContractAddress = contractAddress - } - - requestBodyBytes, requestBodyBytesErr := json.Marshal(requestBody) - if requestBodyBytesErr != nil { - return requestBodyBytesErr - } - - sendReTryCnt := 1 - maxSendReTryCnt := 3 - SEND_RETRY: - for sendReTryCnt <= maxSendReTryCnt { - sendCallRequestsErr := client.sendCallRequests(requestBodyBytes) - if sendCallRequestsErr == nil { - break SEND_RETRY - } - fmt.Printf("During sending call requests an error ocurred: %v, retry %d\n", sendCallRequestsErr, sendReTryCnt) - sendReTryCnt++ - time.Sleep(time.Duration(sendReTryCnt) * time.Second) - - if sendReTryCnt > maxSendReTryCnt { - return fmt.Errorf("failed to send call requests") - } - } - - fmt.Printf("Successfully pushed %d batch of %d total with %d call_requests to API\n", i+1, len(specBatches), len(batchSpecs)) - } - - return nil -} diff --git a/server.go b/server.go deleted file mode 100644 index 13f91b0..0000000 --- a/server.go +++ /dev/null @@ -1,207 +0,0 @@ -package main - -import ( - "bytes" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "log" - "net" - "net/http" - "time" - - "github.com/ethereum/go-ethereum/accounts/keystore" - "github.com/ethereum/go-ethereum/common" -) - -type AvailableSigner struct { - key *keystore.Key -} - -type Server struct { - Host string - Port int - AvailableSigners map[string]AvailableSigner - LogLevel int - CORSWhitelist map[string]bool -} - -type PingResponse struct { - Status string `json:"status"` -} - -type SignDropperRequest struct { - ChainId int `json:"chain_id"` - Dropper string `json:"dropper"` - Signer string `json:"signer"` - Sensible bool `json:"sensible"` - Requests []*DropperClaimMessage `json:"requests"` -} - -// CORS middleware -func (server *Server) corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodOptions { - var allowedOrigin string - if server.CORSWhitelist["*"] { - allowedOrigin = "*" - } else { - for o := range server.CORSWhitelist { - if r.Header.Get("Origin") == o { - allowedOrigin = o - } - } - } - // If origin in list of CORS allowed origins, extend with required headers - if allowedOrigin != "" { - w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) - w.Header().Set("Access-Control-Allow-Methods", "GET,POST") - // Credentials are cookies, authorization headers, or TLS client certificates - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") - } - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} - -// Log access requests in proper format -func (server *Server) logMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, readErr := io.ReadAll(r.Body) - if readErr != nil { - http.Error(w, "Unable to read body", http.StatusBadRequest) - return - } - r.Body = io.NopCloser(bytes.NewBuffer(body)) - if len(body) > 0 { - defer r.Body.Close() - } - - next.ServeHTTP(w, r) - - var ip string - var splitErr error - realIp := r.Header["X-Real-Ip"] - if len(realIp) == 0 { - ip, _, splitErr = net.SplitHostPort(r.RemoteAddr) - if splitErr != nil { - http.Error(w, fmt.Sprintf("Unable to parse client IP: %s", r.RemoteAddr), http.StatusBadRequest) - return - } - } else { - ip = realIp[0] - } - logStr := fmt.Sprintf("%s %s %s", ip, r.Method, r.URL.Path) - - if server.LogLevel >= 2 { - if r.URL.RawQuery != "" { - logStr += fmt.Sprintf(" %s", r.URL.RawQuery) - } - } - log.Printf("%s\n", logStr) - }) -} - -// Handle panic errors to prevent server shutdown -func (server *Server) panicMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - if recoverErr := recover(); recoverErr != nil { - log.Println("recovered", recoverErr) - http.Error(w, "Internal server error", 500) - } - }() - - // There will be a defer with panic handler in each next function - next.ServeHTTP(w, r) - }) -} - -// pingRoute response with status of load balancer server itself -func (server *Server) pingRoute(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - response := PingResponse{Status: "ok"} - json.NewEncoder(w).Encode(response) -} - -// signDropperRoute response with status of load balancer server itself -func (server *Server) signDropperRoute(w http.ResponseWriter, r *http.Request) { - body, readErr := io.ReadAll(r.Body) - if readErr != nil { - http.Error(w, "Unable to read body", http.StatusBadRequest) - return - } - r.Body = io.NopCloser(bytes.NewBuffer(body)) - if len(body) > 0 { - defer r.Body.Close() - } - var req *SignDropperRequest - parseErr := json.Unmarshal(body, &req) - if parseErr != nil { - http.Error(w, "Unable to parse body", http.StatusBadRequest) - return - } - - // Check if server can sign with provided signer address - var chosenSigner string - for addr := range server.AvailableSigners { - if addr == common.HexToAddress(req.Signer).String() { - chosenSigner = addr - } - } - if chosenSigner == "" { - http.Error(w, "Unable to find signer", http.StatusBadRequest) - return - } - - for _, message := range req.Requests { - messageHash, hashErr := DropperClaimMessageHash(int64(req.ChainId), req.Dropper, message.DropId, message.RequestID, message.Claimant, message.BlockDeadline, message.Amount) - if hashErr != nil { - http.Error(w, "Unable to generate message hash", http.StatusInternalServerError) - return - } - - signedMessage, signatureErr := SignRawMessage(messageHash, server.AvailableSigners[chosenSigner].key, req.Sensible) - if signatureErr != nil { - http.Error(w, "Unable to sign message", http.StatusInternalServerError) - return - } - - message.Signature = hex.EncodeToString(signedMessage) - message.Signer = server.AvailableSigners[chosenSigner].key.Address.Hex() - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(req) -} - -// Serve handles server run -func (server *Server) Serve() error { - serveMux := http.NewServeMux() - serveMux.HandleFunc("/ping", server.pingRoute) - serveMux.HandleFunc("/sign/dropper", server.signDropperRoute) - - // Set list of common middleware, from bottom to top - commonHandler := server.corsMiddleware(serveMux) - commonHandler = server.logMiddleware(commonHandler) - commonHandler = server.panicMiddleware(commonHandler) - - s := http.Server{ - Addr: fmt.Sprintf("%s:%d", server.Host, server.Port), - Handler: commonHandler, - ReadTimeout: 40 * time.Second, - WriteTimeout: 40 * time.Second, - } - - log.Printf("Starting node load balancer HTTP server at %s:%d", server.Host, server.Port) - sErr := s.ListenAndServe() - if sErr != nil { - return fmt.Errorf("failed to start server listener, err: %v", sErr) - } - - return nil -} diff --git a/settings.go b/settings.go deleted file mode 100644 index 7cd5910..0000000 --- a/settings.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "os" - "strings" -) - -var ( - MOONSTREAM_ACCESS_TOKEN = os.Getenv("MOONSTREAM_ACCESS_TOKEN") - MOONSTREAM_API_URL = os.Getenv("MOONSTREAM_API_URL") - MOONSTREAM_API_TIMEOUT_SECONDS = os.Getenv("MOONSTREAM_API_TIMEOUT_SECONDS") - - BUGOUT_ACCESS_TOKEN = os.Getenv("BUGOUT_ACCESS_TOKEN") - - WAGGLE_CORS_ALLOWED_ORIGINS = os.Getenv("WAGGLE_CORS_ALLOWED_ORIGINS") -) - -type ServerSignerConfig struct { - KeyfilePath string `json:"keyfile_path"` - KeyfilePasswordPath string `json:"keyfile_password_path"` -} - -// ReadConfig parses list of configuration file paths to list of Application Probes configs -func ReadServerSignerConfig(rawConfigPath string) (*[]ServerSignerConfig, error) { - var configs []ServerSignerConfig - - configPath := strings.TrimSuffix(rawConfigPath, "/") - _, err := os.Stat(configPath) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("file %s not found, err: %v", configPath, err) - } - return nil, fmt.Errorf("error due checking config path %s, err: %v", configPath, err) - } - - rawBytes, err := os.ReadFile(configPath) - if err != nil { - log.Fatal(err) - } - configTemp := &[]ServerSignerConfig{} - err = json.Unmarshal(rawBytes, configTemp) - if err != nil { - return nil, err - } - - for _, ct := range *configTemp { - _, err := os.Stat(ct.KeyfilePath) - if err != nil { - if os.IsNotExist(err) { - log.Printf("Signer ignored, file %s not found, err: %v\n", ct.KeyfilePath, err) - continue - } - log.Printf("Signer ignored, error due checking config path %s, err: %v\n", ct.KeyfilePath, err) - continue - } - _, err = os.Stat(ct.KeyfilePasswordPath) - if err != nil { - if os.IsNotExist(err) { - log.Printf("Signer ignored, file %s not found, err: %v\n", ct.KeyfilePasswordPath, err) - continue - } - log.Printf("Signer ignored, error due checking config path %s, err: %v\n", ct.KeyfilePasswordPath, err) - continue - } - configs = append(configs, ServerSignerConfig{ - KeyfilePath: ct.KeyfilePath, - KeyfilePasswordPath: ct.KeyfilePasswordPath, - }) - } - - return &configs, nil -} diff --git a/sign.go b/sign.go deleted file mode 100644 index 364cfd6..0000000 --- a/sign.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "fmt" - "math/big" - "os" - - "github.com/ethereum/go-ethereum/accounts/keystore" - "github.com/ethereum/go-ethereum/common/math" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/signer/core/apitypes" - "github.com/google/uuid" - "golang.org/x/term" -) - -func KeyfileFromPrivateKey(outfile string) error { - fmt.Print("Enter private key (it will not be displayed on screen): ") - privateKeyRaw, inputErr := term.ReadPassword(int(os.Stdin.Fd())) - if inputErr != nil { - return fmt.Errorf("error reading private key: %s", inputErr.Error()) - } - fmt.Print("\n") - privateKey := string(privateKeyRaw) - - parsedPrivateKey, parseErr := crypto.HexToECDSA(privateKey) - if parseErr != nil { - return fmt.Errorf("error parsing private key: %s", parseErr.Error()) - } - - keyUUID, uuidErr := uuid.NewRandom() - if uuidErr != nil { - return fmt.Errorf("error generating UUID for keystore: %s", uuidErr.Error()) - } - - key := &keystore.Key{ - Id: keyUUID, - PrivateKey: parsedPrivateKey, - Address: crypto.PubkeyToAddress(parsedPrivateKey.PublicKey), - } - scryptN := keystore.StandardScryptN - scryptP := keystore.StandardScryptP - - fmt.Printf("Enter the passphrase you would like to secure the keyfile (%s) with: ", outfile) - passphraseRaw, passphraseInputErr := term.ReadPassword(int(os.Stdin.Fd())) - if passphraseInputErr != nil { - return fmt.Errorf("error reading passphrase: %s", inputErr.Error()) - } - fmt.Print("\n") - passphrase := string(passphraseRaw) - - keystoreJSON, encryptErr := keystore.EncryptKey(key, passphrase, scryptN, scryptP) - if encryptErr != nil { - return fmt.Errorf("could not generate encrypted keystore: %s", encryptErr.Error()) - } - - err := os.WriteFile(outfile, keystoreJSON, 0600) - return err -} - -func KeyFromFile(keystoreFile string, password string) (*keystore.Key, error) { - var emptyKey *keystore.Key - keystoreContent, readErr := os.ReadFile(keystoreFile) - if readErr != nil { - return emptyKey, readErr - } - - // If password is "", prompt user for password. - if password == "" { - fmt.Printf("Please provide a password for keystore (%s): ", keystoreFile) - passwordRaw, inputErr := term.ReadPassword(int(os.Stdin.Fd())) - if inputErr != nil { - return emptyKey, fmt.Errorf("error reading password: %s", inputErr.Error()) - } - fmt.Print("\n") - password = string(passwordRaw) - } - - key, err := keystore.DecryptKey(keystoreContent, password) - return key, err -} - -func SignRawMessage(message []byte, key *keystore.Key, sensible bool) ([]byte, error) { - signature, err := crypto.Sign(message, key.PrivateKey) - if !sensible { - // This refers to a bug in an early Ethereum client implementation where the v parameter byte was - // shifted by 27: https://github.com/ethereum/go-ethereum/issues/2053 - // Default for callers should be NOT sensible. - // Defensively, we only shift if the 65th byte is 0 or 1. - if signature[64] < 2 { - signature[64] += 27 - } - } - return signature, err -} - -type DropperClaimMessage struct { - DropId string `json:"dropId"` - RequestID string `json:"requestID"` - Claimant string `json:"claimant"` - BlockDeadline string `json:"blockDeadline"` - Amount string `json:"amount"` - Signature string `json:"signature,omitempty"` - Signer string `json:"signer,omitempty"` -} - -func DropperClaimMessageHash(chainId int64, dropperAddress string, dropId, requestId string, claimant string, blockDeadline, amount string) ([]byte, error) { - // Inspired by: https://medium.com/alpineintel/issuing-and-verifying-eip-712-challenges-with-go-32635ca78aaf - signerData := apitypes.TypedData{ - Types: apitypes.Types{ - "EIP712Domain": { - {Name: "name", Type: "string"}, - {Name: "version", Type: "string"}, - {Name: "chainId", Type: "uint256"}, - {Name: "verifyingContract", Type: "address"}, - }, - "ClaimPayload": { - {Name: "dropId", Type: "uint256"}, - {Name: "requestID", Type: "uint256"}, - {Name: "claimant", Type: "address"}, - {Name: "blockDeadline", Type: "uint256"}, - {Name: "amount", Type: "uint256"}, - }, - }, - PrimaryType: "ClaimPayload", - Domain: apitypes.TypedDataDomain{ - Name: "Moonstream Dropper", - Version: "0.2.0", - ChainId: (*math.HexOrDecimal256)(big.NewInt(int64(chainId))), - VerifyingContract: dropperAddress, - }, - Message: apitypes.TypedDataMessage{ - "dropId": dropId, - "requestID": requestId, - "claimant": claimant, - "blockDeadline": blockDeadline, - "amount": amount, - }, - } - - messageHash, _, err := apitypes.TypedDataAndHash(signerData) - return messageHash, err -} diff --git a/sign_test.go b/sign_test.go deleted file mode 100644 index 643d892..0000000 --- a/sign_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "encoding/hex" - "testing" -) - -func TestDropperClaimMessageHash(t *testing.T) { - messageHash, err := DropperClaimMessageHash(80001, "0x4ec36E288E1b5d6914851a141cb041152Cf95328", "2", "5", "0x000000000000000000000000000000000000dEaD", "40000000", "3000000000000000000") - if err != nil { - t.Errorf("Unexpected error in DropperClaimMessageHash: %s", err.Error()) - } - messageHashString := hex.EncodeToString(messageHash) - - // Taken from claimMessageHash method on the Moonstream Dropper v0.2.0 contract deployed at 0x4ec36E288E1b5d6914851a141cb041152Cf95328 - // on Polygon Mumbai testnet (chainId: 80001). - expectedMessageHashString := "48033e41d47cd4cbfe7cd183e1c30ad6af92ea445475913bf96eed42d599bf20" - - if messageHashString != expectedMessageHashString { - t.Errorf("Incorrect calculation of message hash for Dropper claim. expected: %s, actual: %s", expectedMessageHashString, messageHashString) - } -} diff --git a/version.go b/version.go deleted file mode 100644 index c945d23..0000000 --- a/version.go +++ /dev/null @@ -1,3 +0,0 @@ -package main - -var WAGGLE_VERSION = "0.1.0" From fba97a947a873ad33695b09ca62b3593f9dd7e2a Mon Sep 17 00:00:00 2001 From: kompotkot Date: Thu, 24 Aug 2023 11:23:05 +0000 Subject: [PATCH 2/3] GoDoc restruct of waggle --- .gitignore | 1 - cmd/waggle/bugout.go | 139 ++++++++ cmd/waggle/cmd.go | 628 +++++++++++++++++++++++++++++++++++++ cmd/waggle/example_test.go | 78 +++++ cmd/waggle/moonstream.go | 287 +++++++++++++++++ cmd/waggle/server.go | 272 ++++++++++++++++ cmd/waggle/server_test.go | 28 ++ cmd/waggle/settings.go | 75 +++++ cmd/waggle/sign.go | 142 +++++++++ cmd/waggle/sign_test.go | 22 ++ cmd/waggle/version.go | 3 + 11 files changed, 1674 insertions(+), 1 deletion(-) create mode 100644 cmd/waggle/bugout.go create mode 100644 cmd/waggle/cmd.go create mode 100644 cmd/waggle/example_test.go create mode 100644 cmd/waggle/moonstream.go create mode 100644 cmd/waggle/server.go create mode 100644 cmd/waggle/server_test.go create mode 100644 cmd/waggle/settings.go create mode 100644 cmd/waggle/sign.go create mode 100644 cmd/waggle/sign_test.go create mode 100644 cmd/waggle/version.go diff --git a/.gitignore b/.gitignore index dab8513..3051806 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,6 @@ go.work # End of https://www.toptal.com/developers/gitignore/api/go,visualstudiocode # Custom -waggle waggle_dev .secrets/ prod.env diff --git a/cmd/waggle/bugout.go b/cmd/waggle/bugout.go new file mode 100644 index 0000000..a8d9bfc --- /dev/null +++ b/cmd/waggle/bugout.go @@ -0,0 +1,139 @@ +package waggle + +// Much of this code is copied from waggle: https://github.com/bugout-dev/waggle/blob/main/main.go + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + bugout "github.com/bugout-dev/bugout-go/pkg" + spire "github.com/bugout-dev/bugout-go/pkg/spire" +) + +func CleanTimestamp(rawTimestamp string) string { + return strings.ReplaceAll(rawTimestamp, " ", "T") +} + +func GetCursorFromJournal(client *bugout.BugoutClient, token, journalID, cursorName string) (string, error) { + query := fmt.Sprintf("context_type:waggle tag:type:cursor tag:cursor:%s", cursorName) + parameters := map[string]string{ + "order": "desc", + "content": "true", // We may use the content in the future, even though we are simply using context_url right now + } + results, err := client.Spire.SearchEntries(token, journalID, query, 1, 0, parameters) + if err != nil { + return "", err + } + + if results.TotalResults == 0 { + return "", nil + } + + return results.Results[0].ContextUrl, nil +} + +func WriteCursorToJournal(client *bugout.BugoutClient, token, journalID, cursorName, cursor, queryTerms string) error { + title := fmt.Sprintf("waggle cursor: %s", cursorName) + entryContext := spire.EntryContext{ + ContextType: "waggle", + ContextID: cursor, + ContextURL: cursor, + } + tags := []string{ + "type:cursor", + fmt.Sprintf("cursor:%s", cursorName), + fmt.Sprintf("waggle_version:%s", WAGGLE_VERSION), + } + content := fmt.Sprintf("Cursor: %s at %s\nQuery: %s", cursorName, cursor, queryTerms) + _, err := client.Spire.CreateEntry(token, journalID, title, content, tags, entryContext) + return err +} + +func ReportsIterator(client *bugout.BugoutClient, token, journalID, cursor, queryTerms string, limit, offset int) (spire.EntryResultsPage, error) { + var query string = fmt.Sprintf("!tag:type:cursor %s", queryTerms) + if cursor != "" { + cleanedCursor := CleanTimestamp(cursor) + query = fmt.Sprintf("%s created_at:>%s", query, cleanedCursor) + fmt.Fprintln(os.Stderr, "query:", query) + } + parameters := map[string]string{ + "order": "asc", + "content": "false", + } + return client.Spire.SearchEntries(token, journalID, query, limit, offset, parameters) +} + +func LoadDropperReports(searchResults spire.EntryResultsPage) ([]DropperClaimMessage, error) { + reports := make([]DropperClaimMessage, len(searchResults.Results)) + for i, result := range searchResults.Results { + parseErr := json.Unmarshal([]byte(result.Content), &reports[i]) + if parseErr != nil { + return reports, parseErr + } + } + return reports, nil +} + +func DropperReportsToCSV(reports []DropperClaimMessage, header bool, w io.Writer) error { + numRecords := len(reports) + startIndex := 0 + if header { + numRecords++ + startIndex++ + } + + records := make([][]string, numRecords) + if header { + records[0] = []string{ + "dropId", "requestID", "claimant", "blockDeadline", "amount", "signer", "signature", + } + } + + for i, report := range reports { + records[i+startIndex] = []string{ + report.DropId, + report.RequestID, + report.Claimant, + report.BlockDeadline, + report.Amount, + report.Signer, + report.Signature, + } + } + + csvWriter := csv.NewWriter(w) + return csvWriter.WriteAll(records) +} + +func ProcessDropperClaims(client *bugout.BugoutClient, bugoutToken, journalID, cursorName, query string, batchSize int, header bool, w io.Writer) error { + cursor, cursorErr := GetCursorFromJournal(client, bugoutToken, journalID, cursorName) + if cursorErr != nil { + return cursorErr + } + + searchResults, searchErr := ReportsIterator(client, bugoutToken, journalID, cursor, query, batchSize, 0) + if searchErr != nil { + return searchErr + } + + reports, loadErr := LoadDropperReports(searchResults) + if loadErr != nil { + return loadErr + } + + writeErr := DropperReportsToCSV(reports, header, w) + if writeErr != nil { + return writeErr + } + + var processedErr error + if len(searchResults.Results) > 0 { + processedErr = WriteCursorToJournal(client, bugoutToken, journalID, cursorName, searchResults.Results[len(searchResults.Results)-1].CreatedAt, query) + } + + return processedErr +} diff --git a/cmd/waggle/cmd.go b/cmd/waggle/cmd.go new file mode 100644 index 0000000..d2bd2c6 --- /dev/null +++ b/cmd/waggle/cmd.go @@ -0,0 +1,628 @@ +package waggle + +import ( + "encoding/csv" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + bugout "github.com/bugout-dev/bugout-go/pkg" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +func CreateRootCommand() *cobra.Command { + // rootCmd represents the base command when called without any subcommands + rootCmd := &cobra.Command{ + Use: "waggle", + Short: "Sign Moonstream transaction requests", + Long: `waggle is a CLI that allows you to sign requests for transactions on Moonstream contracts. + + waggle currently supports signatures for the following types of contracts: + - Dropper (dropper-v0.2.0) + + waggle makes it easy to sign large numbers of requests in a very short amount of time. It also allows + you to automatically send transaction requests to the Moonstream API. + `, + Run: func(cmd *cobra.Command, args []string) {}, + } + + versionCmd := CreateVersionCommand() + signCmd := CreateSignCommand() + accountsCmd := CreateAccountsCommand() + moonstreamCommand := CreateMoonstreamCommand() + serverCommand := CreateServerCommand() + rootCmd.AddCommand(versionCmd, signCmd, accountsCmd, moonstreamCommand, serverCommand) + + completionCmd := CreateCompletionCommand(rootCmd) + rootCmd.AddCommand(completionCmd) + + return rootCmd +} + +func CreateCompletionCommand(rootCmd *cobra.Command) *cobra.Command { + completionCmd := &cobra.Command{ + Use: "completion", + Short: "Generate shell completion scripts for waggle", + Long: `Generate shell completion scripts for waggle. + +The command for each shell will print a completion script to stdout. You can source this script to get +completions in your current shell session. You can add this script to the completion directory for your +shell to get completions for all future sessions. + +For example, to activate bash completions in your current shell: + $ . <(wagggle completion bash) + +To add waggle completions for all bash sessions: + $ waggle completion bash > /etc/bash_completion.d/waggle_completions`, + } + + bashCompletionCmd := &cobra.Command{ + Use: "bash", + Short: "bash completions for waggle", + Run: func(cmd *cobra.Command, args []string) { + rootCmd.GenBashCompletion(cmd.OutOrStdout()) + }, + } + + zshCompletionCmd := &cobra.Command{ + Use: "zsh", + Short: "zsh completions for waggle", + Run: func(cmd *cobra.Command, args []string) { + rootCmd.GenZshCompletion(cmd.OutOrStdout()) + }, + } + + fishCompletionCmd := &cobra.Command{ + Use: "fish", + Short: "fish completions for waggle", + Run: func(cmd *cobra.Command, args []string) { + rootCmd.GenFishCompletion(cmd.OutOrStdout(), true) + }, + } + + powershellCompletionCmd := &cobra.Command{ + Use: "powershell", + Short: "powershell completions for waggle", + Run: func(cmd *cobra.Command, args []string) { + rootCmd.GenPowerShellCompletion(cmd.OutOrStdout()) + }, + } + + completionCmd.AddCommand(bashCompletionCmd, zshCompletionCmd, fishCompletionCmd, powershellCompletionCmd) + + return completionCmd +} + +func CreateVersionCommand() *cobra.Command { + versionCmd := &cobra.Command{ + Use: "version", + Short: "Print the version number of waggle", + Long: `All software has versions. This is waggle's`, + Run: func(cmd *cobra.Command, args []string) { + cmd.Println(WAGGLE_VERSION) + }, + } + return versionCmd +} + +func CreateAccountsCommand() *cobra.Command { + accountsCommand := &cobra.Command{ + Use: "accounts", + Short: "Set up signing accounts", + } + + var keyfile string + + accountsCommand.PersistentFlags().StringVarP(&keyfile, "keystore", "k", "", "Path to keystore file.") + + importCommand := &cobra.Command{ + Use: "import", + Short: "Import a signing account from a private key.", + Long: "Import a signing account from a private key. This will be stored at the given keystore path.", + RunE: func(cmd *cobra.Command, args []string) error { + return KeyfileFromPrivateKey(keyfile) + }, + } + + accountsCommand.AddCommand(importCommand) + + return accountsCommand +} + +func CreateSignCommand() *cobra.Command { + signCommand := &cobra.Command{ + Use: "sign", + Short: "Sign transaction requests", + Long: "Contains various commands that help you sign transaction requests", + } + + // All variables to be used for arguments. + var chainId int64 + var batchSize int + var bugoutToken, cursorName, journalID, keyfile, password, claimant, dropperAddress, dropId, requestId, blockDeadline, amount, infile, outfile, query string + var sensible, header, isCSV bool + + signCommand.PersistentFlags().StringVarP(&keyfile, "keystore", "k", "", "Path to keystore file (this should be a JSON file).") + signCommand.PersistentFlags().StringVarP(&password, "password", "p", "", "Password for keystore file. If not provided, you will be prompted for it when you sign with the key.") + signCommand.PersistentFlags().BoolVar(&sensible, "sensible", false, "Set this flag if you do not want to shift the final, v, byte of all signatures by 27. For reference: https://github.com/ethereum/go-ethereum/issues/2053") + + var rawMessage []byte + rawSubcommand := &cobra.Command{ + Use: "hash", + Short: "Sign a raw message hash", + RunE: func(cmd *cobra.Command, args []string) error { + key, err := KeyFromFile(keyfile, password) + if err != nil { + return err + } + + signature, err := SignRawMessage(rawMessage, key, sensible) + if err != nil { + return err + } + + cmd.Println(hex.EncodeToString(signature)) + return nil + }, + } + rawSubcommand.Flags().BytesHexVarP(&rawMessage, "message", "m", []byte{}, "Raw message to sign (do not include the 0x prefix).") + + dropperSubcommand := &cobra.Command{ + Use: "dropper", + Short: "Dropper-related signing functionality", + } + + dropperHashSubcommand := &cobra.Command{ + Use: "hash", + Short: "Generate a message hash for a claim method call", + RunE: func(cmd *cobra.Command, args []string) error { + messageHash, err := DropperClaimMessageHash(chainId, dropperAddress, dropId, requestId, claimant, blockDeadline, amount) + if err != nil { + return err + } + cmd.Println(hex.EncodeToString(messageHash)) + return nil + }, + } + dropperHashSubcommand.Flags().Int64Var(&chainId, "chain-id", 1, "Chain ID of the network you are signing for.") + dropperHashSubcommand.Flags().StringVar(&dropperAddress, "dropper", "0x0000000000000000000000000000000000000000", "Address of Dropper contract") + dropperHashSubcommand.Flags().StringVar(&dropId, "drop-id", "0", "ID of the drop.") + dropperHashSubcommand.Flags().StringVar(&requestId, "request-id", "0", "ID of the request.") + dropperHashSubcommand.Flags().StringVar(&claimant, "claimant", "", "Address of the intended claimant.") + dropperHashSubcommand.Flags().StringVar(&blockDeadline, "block-deadline", "0", "Block number by which the claim must be made.") + dropperHashSubcommand.Flags().StringVar(&amount, "amount", "0", "Amount of tokens to distribute.") + + dropperSingleSubcommand := &cobra.Command{ + Use: "single", + Short: "Sign a single claim method call", + RunE: func(cmd *cobra.Command, args []string) error { + messageHash, hashErr := DropperClaimMessageHash(chainId, dropperAddress, dropId, requestId, claimant, blockDeadline, amount) + if hashErr != nil { + return hashErr + } + + if header { + cmd.Println(hex.EncodeToString(messageHash)) + return nil + } + + key, keyErr := KeyFromFile(keyfile, password) + if keyErr != nil { + return keyErr + } + + signedMessage, err := SignRawMessage(messageHash, key, sensible) + if err != nil { + return err + } + + result := DropperClaimMessage{ + DropId: dropId, + RequestID: requestId, + Claimant: claimant, + BlockDeadline: blockDeadline, + Amount: amount, + Signature: hex.EncodeToString(signedMessage), + Signer: key.Address.Hex(), + } + resultJSON, encodeErr := json.Marshal(result) + if encodeErr != nil { + return encodeErr + } + os.Stdout.Write(resultJSON) + return nil + }, + } + dropperSingleSubcommand.Flags().Int64Var(&chainId, "chain-id", 1, "Chain ID of the network you are signing for.") + dropperSingleSubcommand.Flags().StringVar(&dropperAddress, "dropper", "0x0000000000000000000000000000000000000000", "Address of Dropper contract") + dropperSingleSubcommand.Flags().StringVar(&dropId, "drop-id", "0", "ID of the drop.") + dropperSingleSubcommand.Flags().StringVar(&requestId, "request-id", "0", "ID of the request.") + dropperSingleSubcommand.Flags().StringVar(&claimant, "claimant", "", "Address of the intended claimant.") + dropperSingleSubcommand.Flags().StringVar(&blockDeadline, "block-deadline", "0", "Block number by which the claim must be made.") + dropperSingleSubcommand.Flags().StringVar(&amount, "amount", "0", "Amount of tokens to distribute.") + dropperSingleSubcommand.Flags().BoolVar(&header, "hash", false, "Output the message hash instead of the signature.") + + dropperBatchSubcommand := &cobra.Command{ + Use: "batch", + Short: "Sign a batch of claim method calls", + RunE: func(cmd *cobra.Command, args []string) error { + key, keyErr := KeyFromFile(keyfile, password) + if keyErr != nil { + return keyErr + } + + var batchRaw []byte + var readErr error + + var batch []*DropperClaimMessage + + if !isCSV { + if infile != "" { + batchRaw, readErr = os.ReadFile(infile) + } else { + batchRaw, readErr = io.ReadAll(os.Stdin) + } + if readErr != nil { + return readErr + } + + parseErr := json.Unmarshal(batchRaw, &batch) + if parseErr != nil { + return parseErr + } + } else { + var csvReader *csv.Reader + if infile == "" { + csvReader = csv.NewReader(os.Stdin) + } else { + r, csvOpenErr := os.Open(infile) + if csvOpenErr != nil { + return csvOpenErr + } + defer r.Close() + + csvReader = csv.NewReader(r) + } + + csvData, csvReadErr := csvReader.ReadAll() + if csvReadErr != nil { + return csvReadErr + } + + csvHeaders := csvData[0] + csvData = csvData[1:] + batch = make([]*DropperClaimMessage, len(csvData)) + + for i, row := range csvData { + jsonData := make(map[string]string) + + for j, value := range row { + jsonData[csvHeaders[j]] = value + } + + jsonString, rowMarshalErr := json.Marshal(jsonData) + if rowMarshalErr != nil { + return rowMarshalErr + } + + rowParseErr := json.Unmarshal(jsonString, &batch[i]) + if rowParseErr != nil { + return rowParseErr + } + } + } + + for _, message := range batch { + messageHash, hashErr := DropperClaimMessageHash(chainId, dropperAddress, message.DropId, message.RequestID, message.Claimant, message.BlockDeadline, message.Amount) + if hashErr != nil { + return hashErr + } + + signedMessage, signatureErr := SignRawMessage(messageHash, key, sensible) + if signatureErr != nil { + return signatureErr + } + + message.Signature = hex.EncodeToString(signedMessage) + message.Signer = key.Address.Hex() + } + + resultJSON, encodeErr := json.Marshal(batch) + if encodeErr != nil { + return encodeErr + } + + if outfile != "" { + os.WriteFile(outfile, resultJSON, 0644) + } else { + os.Stdout.Write(resultJSON) + } + + return nil + }, + } + dropperBatchSubcommand.Flags().Int64Var(&chainId, "chain-id", 1, "Chain ID of the network you are signing for.") + dropperBatchSubcommand.Flags().StringVar(&dropperAddress, "dropper", "0x0000000000000000000000000000000000000000", "Address of Dropper contract") + dropperBatchSubcommand.Flags().StringVar(&infile, "infile", "", "Input file. If not specified, input will be expected from stdin.") + dropperBatchSubcommand.Flags().StringVar(&outfile, "outfile", "", "Output file. If not specified, output will be written to stdout.") + dropperBatchSubcommand.Flags().BoolVar(&isCSV, "csv", false, "Set this flag if the --infile is a CSV file.") + + dropperPullSubcommand := &cobra.Command{ + Use: "pull", + Short: "Pull unprocessed claim requests from the Bugout API", + Long: "Pull unprocessed claim requests from the Bugout API and write them to a CSV file.", + RunE: func(cmd *cobra.Command, args []string) error { + bugoutClient, bugoutErr := bugout.ClientFromEnv() + if bugoutErr != nil { + return bugoutErr + } + + if bugoutToken == "" { + return errors.New("please specify a Bugout API access token, either by passing it as the --token/-t argument or by setting the BUGOUT_ACCESS_TOKEN environment variable") + } + + if journalID == "" { + return errors.New("please specify a Bugout journal ID, by passing it as the --journal/-j argument") + } + + return ProcessDropperClaims(&bugoutClient, bugoutToken, journalID, cursorName, query, batchSize, header, os.Stdout) + }, + } + dropperPullSubcommand.Flags().StringVarP(&bugoutToken, "token", "t", BUGOUT_ACCESS_TOKEN, "Bugout API access token. If you don't have one, you can generate one at https://bugout.dev/account/tokens.") + dropperPullSubcommand.Flags().StringVarP(&journalID, "journal", "j", "", "ID of Bugout journal from which to pull claim requests.") + dropperPullSubcommand.Flags().StringVarP(&cursorName, "cursor", "c", "", "Name of cursor which defines which requests are processed and which ones are not.") + dropperPullSubcommand.Flags().StringVarP(&query, "query", "q", "", "Additional Bugout search query to apply in Bugout.") + dropperPullSubcommand.Flags().IntVarP(&batchSize, "batch-size", "N", 500, "Maximum number of messages to process.") + dropperPullSubcommand.Flags().BoolVarP(&header, "header", "H", true, "Set this flag to include header row in output CSV.") + + dropperSubcommand.AddCommand(dropperHashSubcommand, dropperSingleSubcommand, dropperBatchSubcommand, dropperPullSubcommand) + + signCommand.AddCommand(rawSubcommand, dropperSubcommand) + + return signCommand +} + +func CreateMoonstreamCommand() *cobra.Command { + moonstreamCommand := &cobra.Command{ + Use: "moonstream", + Short: "Interact with the Moonstream Engine API", + Long: "Commands that help you interact with the Moonstream Engine API from your command-line.", + } + + var blockchain, address, contractType, contractId, contractAddress, infile string + var limit, offset, batchSize int + var showExpired bool + + contractsSubcommand := &cobra.Command{ + Use: "contracts", + Short: "List all your registered contracts.", + RunE: func(cmd *cobra.Command, args []string) error { + client, clientErr := ClientFromEnv() + if clientErr != nil { + return clientErr + } + + contracts, err := client.ListRegisteredContracts(blockchain, address, contractType, limit, offset) + if err != nil { + return err + } + + encodeErr := json.NewEncoder(cmd.OutOrStdout()).Encode(contracts) + return encodeErr + }, + } + contractsSubcommand.Flags().StringVar(&blockchain, "blockchain", "", "Blockchain") + contractsSubcommand.Flags().StringVar(&address, "address", "", "Contract address") + contractsSubcommand.Flags().StringVar(&contractType, "contract-type", "", "Contract type (valid types: \"raw\", \"dropper-v0.2.0\")") + contractsSubcommand.Flags().IntVar(&limit, "limit", 100, "Limit") + contractsSubcommand.Flags().IntVar(&offset, "offset", 0, "Offset") + + callRequestsSubcommand := &cobra.Command{ + Use: "call-requests", + Short: "List call requests for a given caller.", + RunE: func(cmd *cobra.Command, args []string) error { + client, clientErr := ClientFromEnv() + if clientErr != nil { + return clientErr + } + + callRequests, err := client.ListCallRequests(contractId, contractAddress, address, limit, offset, showExpired) + if err != nil { + return err + } + + encodeErr := json.NewEncoder(cmd.OutOrStdout()).Encode(callRequests) + return encodeErr + }, + } + callRequestsSubcommand.Flags().StringVar(&contractId, "contract-id", "", "Moonstream Engine ID of the registered contract") + callRequestsSubcommand.Flags().StringVar(&contractAddress, "contract-address", "", "Address of the contract (at least one of --contract-id or --contract-address must be specified)") + callRequestsSubcommand.Flags().StringVar(&address, "caller", "", "Address of caller") + callRequestsSubcommand.Flags().IntVar(&limit, "limit", 100, "Limit") + callRequestsSubcommand.Flags().IntVar(&offset, "offset", 0, "Offset") + callRequestsSubcommand.Flags().BoolVar(&showExpired, "show-expired", false, "Specify this flag to show expired call requests") + + createCallRequestsSubcommand := &cobra.Command{ + Use: "drop", + Short: "Submit Dropper call requests to the Moonstream Engine API.", + RunE: func(cmd *cobra.Command, args []string) error { + client, clientErr := ClientFromEnv() + if clientErr != nil { + return clientErr + } + + var messagesRaw []byte + var readErr error + if infile != "" { + messagesRaw, readErr = os.ReadFile(infile) + } else { + messagesRaw, readErr = io.ReadAll(os.Stdin) + } + if readErr != nil { + return readErr + } + + if batchSize == 0 { + return fmt.Errorf("wor") + } + + var messages []*DropperClaimMessage + parseErr := json.Unmarshal(messagesRaw, &messages) + if parseErr != nil { + return parseErr + } + + callRequests := make([]CallRequestSpecification, len(messages)) + for i, message := range messages { + callRequests[i] = CallRequestSpecification{ + Caller: message.Claimant, + Method: "claim", + RequestId: message.RequestID, + Parameters: DropperCallRequestParameters{ + DropId: message.DropId, + BlockDeadline: message.BlockDeadline, + Amount: message.Amount, + Signer: message.Signer, + Signature: message.Signature, + }, + } + } + + err := client.CreateCallRequests(contractId, contractAddress, limit, callRequests, batchSize) + return err + }, + } + createCallRequestsSubcommand.Flags().StringVar(&contractId, "contract-id", "", "Moonstream Engine ID of the registered contract") + createCallRequestsSubcommand.Flags().StringVar(&contractAddress, "contract-address", "", "Address of the contract (at least one of --contract-id or --contract-address must be specified)") + createCallRequestsSubcommand.Flags().IntVar(&limit, "ttl-days", 30, "Number of days for which request will remain active") + createCallRequestsSubcommand.Flags().StringVar(&infile, "infile", "", "Input file. If not specified, input will be expected from stdin.") + createCallRequestsSubcommand.Flags().IntVar(&batchSize, "batch-size", 100, "Number of rows per request to API") + + moonstreamCommand.AddCommand(contractsSubcommand, callRequestsSubcommand, createCallRequestsSubcommand) + + return moonstreamCommand +} + +func CreateServerCommand() *cobra.Command { + serverCommand := &cobra.Command{ + Use: "server", + Short: "API of signing and registration of call requests", + } + + var host, config string + var port, logLevel int + runSubcommand := &cobra.Command{ + Use: "run", + Short: "Run API server.", + RunE: func(cmd *cobra.Command, args []string) error { + configs, configsErr := ReadServerSignerConfig(config) + if configsErr != nil { + return configsErr + } + if len(*configs) == 0 { + return fmt.Errorf("no signers available") + } + + availableSigners := make(map[string]AvailableSigner) + for _, c := range *configs { + passwordRaw, readErr := os.ReadFile(c.KeyfilePasswordPath) + if readErr != nil { + return readErr + } + key, keyErr := KeyFromFile(c.KeyfilePath, string(passwordRaw)) + if keyErr != nil { + return keyErr + } + availableSigners[key.Address.String()] = AvailableSigner{ + key: key, + } + log.Printf("Loaded signer %s", key.Address.String()) + } + corsWhitelist := make(map[string]bool) + for _, o := range strings.Split(WAGGLE_CORS_ALLOWED_ORIGINS, ",") { + corsWhitelist[o] = true + } + + server := Server{ + Host: host, + Port: port, + AvailableSigners: availableSigners, + CORSWhitelist: corsWhitelist, + } + + serveErr := server.Serve() + return serveErr + }, + } + runSubcommand.Flags().StringVar(&host, "host", "127.0.0.1", "Server listening address") + runSubcommand.Flags().IntVar(&port, "port", 7379, "Server listening port") + runSubcommand.Flags().StringVar(&config, "config", "./config.json", "Path to server configuration file") + runSubcommand.Flags().IntVar(&logLevel, "log-level", 1, "Log verbosity level") + + var keyfile, password, outfile string + + configureCommand := &cobra.Command{ + Use: "configure", + Short: "Prepare configuration for waggle API server.", + RunE: func(cmd *cobra.Command, args []string) error { + serverSignerConfigs := []ServerSignerConfig{} + var passwordRaw []byte + var err error + if password == "" { + fmt.Print("Enter password for keyfile (it will not be displayed on screen): ") + passwordRaw, err = term.ReadPassword(int(os.Stdin.Fd())) + fmt.Print("\n") + if err != nil { + return fmt.Errorf("error reading password from input: %s", err.Error()) + } + } else { + passwordRaw = []byte(password) + } + + keyfilePath := strings.TrimSuffix(keyfile, "/") + _, err = os.Stat(keyfilePath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("file %s not found, err: %v", keyfilePath, err) + } + return fmt.Errorf("error due checking keyfile path %s, err: %v", keyfilePath, err) + } + dir, file := filepath.Split(keyfilePath) + passwordFilePath := fmt.Sprintf("%spassword-%s", dir, file) + os.WriteFile(passwordFilePath, passwordRaw, 0640) + + // TODO(kompotkot): Provide functionality to generate config with multiple keyfiles + serverSignerConfigs = append(serverSignerConfigs, ServerSignerConfig{ + KeyfilePath: keyfile, + KeyfilePasswordPath: passwordFilePath, + }) + resultJSON, err := json.Marshal(serverSignerConfigs) + if err != nil { + return err + } + + if outfile != "" { + os.WriteFile(outfile, resultJSON, 0644) + } else { + os.Stdout.Write(resultJSON) + } + + return nil + }, + } + + configureCommand.PersistentFlags().StringVarP(&keyfile, "keystore", "k", "", "Path to keystore file (this should be a JSON file)") + configureCommand.PersistentFlags().StringVarP(&password, "password", "p", "", "Password for keystore file. If not provided, you will be prompted for it when you sign with the key") + configureCommand.PersistentFlags().StringVarP(&outfile, "outfile", "o", "config.json", "Config file output path") + + serverCommand.AddCommand(runSubcommand, configureCommand) + + return serverCommand +} diff --git a/cmd/waggle/example_test.go b/cmd/waggle/example_test.go new file mode 100644 index 0000000..ee58400 --- /dev/null +++ b/cmd/waggle/example_test.go @@ -0,0 +1,78 @@ +package waggle + +func ExampleServer_PingRoute() { + // Example of request and response from PingRoute + + // Output: + // # Request + // GET /ping HTTP/1.1 + // + // # Response + // HTTP/1.1 200 OK + // + // { + // "status": "ok" + // } +} + +func ExampleServer_SignDropperRoute() { + // Example of request and response from SignDropperRoute + + // Output: + // # Request + // POST /sign/dropper HTTP/1.1 + // Content-Type: application/json + // + // { + // "chain_id": 80001, + // "dropper": "0x4ec36E288E1b5d6914851a141cb041152Cf95328", + // "signer": "0x629c51488a18fc75f4b8993743f3c132316951c9", + // "requests": [ + // { + // "dropId": "2", + // "requestID": "5", + // "claimant": "0x000000000000000000000000000000000000dEaD", + // "blockDeadline": "40000000", + // "amount": "3000000000000000000" + // }, + // { + // "dropId": "2", + // "requestID": "6", + // "claimant": "0x000000000000000000000000000000000000dEaD", + // "blockDeadline": "40000000", + // "amount": "3000000000000000000" + // } + // ] + // } + // + // # Response + // HTTP/1.1 200 OK + // Content-Type: application/json + // + // { + // "chain_id": 80001, + // "dropper": "0x4ec36E288E1b5d6914851a141cb041152Cf95328", + // "signer": "0x629c51488a18fc75f4b8993743f3c132316951c9", + // "sensible": false, + // "requests": [ + // { + // "dropId": "2", + // "requestID": "5", + // "claimant": "0x000000000000000000000000000000000000dEaD", + // "blockDeadline": "40000000", + // "amount": "3000000000000000000", + // "signature": "8165f3e1edba760f570c833891ef238c9e40d3e2d1c6d66ab39904d1934c4bb9642e463bd24e0464cceb16a91ea96a48965bb7603d59dc6b859f1112d077a5e61b", + // "signer": "0x629c51488a18fc75f4b8993743f3c132316951c9" + // }, + // { + // "dropId": "2", + // "requestID": "6", + // "claimant": "0x000000000000000000000000000000000000dEaD", + // "blockDeadline": "40000000", + // "amount": "3000000000000000000", + // "signature": "85177edfac02da74776e761f230dc8d1c367ec3fb400881224d8e6001b00b17326048930b0b6e4a03ce407933e12399f80b325cc0be075fad855a3c6168f3b221c", + // "signer": "0x629c51488a18fc75f4b8993743f3c132316951c9" + // } + // ] + // } +} diff --git a/cmd/waggle/moonstream.go b/cmd/waggle/moonstream.go new file mode 100644 index 0000000..0e2c0cd --- /dev/null +++ b/cmd/waggle/moonstream.go @@ -0,0 +1,287 @@ +package waggle + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +type RegisteredContract struct { + Id string `json:"id"` + Blockchain string `json:"blockchain"` + Address string `json:"address"` + MetatxRequesterId string `json:"metatx_requester_id"` + Title string `json:"title"` + Description string `json:"description"` + ImageURI string `json:"image_uri"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CallRequest struct { + Id string `json:"id"` + ContractId string `json:"contract_id"` + ContractAddress string `json:"contract_address"` + MetatxRequesterId string `json:"metatx_requester_id"` + CallRequestType string `json:"call_request_type"` + Caller string `json:"caller"` + Method string `json:"method"` + RequestId string `json:"request_id"` + Parameters interface{} `json:"parameters"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdateAt time.Time `json:"updated_at"` +} + +type CallRequestSpecification struct { + Caller string `json:"caller"` + Method string `json:"method"` + RequestId string `json:"request_id"` + Parameters interface{} `json:"parameters"` +} + +type CreateCallRequestsRequest struct { + ContractID string `json:"contract_id,omitempty"` + ContractAddress string `json:"contract_address,omitempty"` + TTLDays int `json:"ttl_days"` + Specifications []CallRequestSpecification `json:"specifications"` +} + +type DropperCallRequestParameters struct { + DropId string `json:"dropId"` + BlockDeadline string `json:"blockDeadline"` + Amount string `json:"amount"` + Signer string `json:"signer"` + Signature string `json:"signature"` +} + +type MoonstreamEngineAPIClient struct { + AccessToken string + BaseURL string + HTTPClient *http.Client +} + +func ClientFromEnv() (*MoonstreamEngineAPIClient, error) { + if MOONSTREAM_ACCESS_TOKEN == "" { + return nil, fmt.Errorf("set the MOONSTREAM_ACCESS_TOKEN environment variable") + } + if MOONSTREAM_API_URL == "" { + MOONSTREAM_API_URL = "https://api.moonstream.to" + } + if MOONSTREAM_API_TIMEOUT_SECONDS == "" { + MOONSTREAM_API_TIMEOUT_SECONDS = "30" + } + timeoutSeconds, conversionErr := strconv.Atoi(MOONSTREAM_API_TIMEOUT_SECONDS) + if conversionErr != nil { + return nil, conversionErr + } + timeout := time.Duration(timeoutSeconds) * time.Second + httpClient := http.Client{Timeout: timeout} + + return &MoonstreamEngineAPIClient{ + AccessToken: MOONSTREAM_ACCESS_TOKEN, + BaseURL: MOONSTREAM_API_URL, + HTTPClient: &httpClient, + }, nil +} + +func (client *MoonstreamEngineAPIClient) ListRegisteredContracts(blockchain, address, contractType string, limit, offset int) ([]RegisteredContract, error) { + var contracts []RegisteredContract + + request, requestCreationErr := http.NewRequest("GET", fmt.Sprintf("%s/metatx/contracts/", client.BaseURL), nil) + if requestCreationErr != nil { + return contracts, requestCreationErr + } + + request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", client.AccessToken)) + request.Header.Add("Accept", "application/json") + + queryParameters := request.URL.Query() + if blockchain != "" { + queryParameters.Add("blockchain", blockchain) + } + if address != "" { + queryParameters.Add("address", address) + } + if contractType != "" { + queryParameters.Add("contract_type", contractType) + } + queryParameters.Add("limit", strconv.Itoa(limit)) + queryParameters.Add("offset", strconv.Itoa(offset)) + + request.URL.RawQuery = queryParameters.Encode() + + response, responseErr := client.HTTPClient.Do(request) + if responseErr != nil { + return contracts, responseErr + } + defer response.Body.Close() + + responseBody, responseBodyErr := io.ReadAll(response.Body) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + if responseBodyErr != nil { + return contracts, fmt.Errorf("unexpected status code: %d -- could not read response body: %s", response.StatusCode, responseBodyErr.Error()) + } + responseBodyString := string(responseBody) + return contracts, fmt.Errorf("unexpected status code: %d -- response body: %s", response.StatusCode, responseBodyString) + } + + if responseBodyErr != nil { + return contracts, fmt.Errorf("could not read response body: %s", responseBodyErr.Error()) + } + + unmarshalErr := json.Unmarshal(responseBody, &contracts) + if unmarshalErr != nil { + return contracts, fmt.Errorf("could not parse response body: %s", unmarshalErr.Error()) + } + + return contracts, nil +} + +func (client *MoonstreamEngineAPIClient) ListCallRequests(contractId, contractAddress, caller string, limit, offset int, showExpired bool) ([]CallRequest, error) { + var callRequests []CallRequest + + if caller == "" { + return callRequests, fmt.Errorf("you must specify caller when listing call requests") + } + + request, requestCreationErr := http.NewRequest("GET", fmt.Sprintf("%s/metatx/requests", client.BaseURL), nil) + if requestCreationErr != nil { + return callRequests, requestCreationErr + } + + request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", client.AccessToken)) + request.Header.Add("Accept", "application/json") + + queryParameters := request.URL.Query() + if contractId != "" { + queryParameters.Add("contract_id", contractId) + } + if contractAddress != "" { + queryParameters.Add("contract_address", contractAddress) + } + queryParameters.Add("caller", caller) + queryParameters.Add("limit", strconv.Itoa(limit)) + queryParameters.Add("offset", strconv.Itoa(offset)) + queryParameters.Add("show_expired", strconv.FormatBool(showExpired)) + + request.URL.RawQuery = queryParameters.Encode() + + response, responseErr := client.HTTPClient.Do(request) + if responseErr != nil { + return callRequests, responseErr + } + defer response.Body.Close() + + responseBody, responseBodyErr := io.ReadAll(response.Body) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + if responseBodyErr != nil { + return callRequests, fmt.Errorf("unexpected status code: %d -- could not read response body: %s", response.StatusCode, responseBodyErr.Error()) + } + responseBodyString := string(responseBody) + return callRequests, fmt.Errorf("unexpected status code: %d -- response body: %s", response.StatusCode, responseBodyString) + } + + if responseBodyErr != nil { + return callRequests, fmt.Errorf("could not read response body: %s", responseBodyErr.Error()) + } + + unmarshalErr := json.Unmarshal(responseBody, &callRequests) + if unmarshalErr != nil { + return callRequests, fmt.Errorf("could not parse response body: %s", unmarshalErr.Error()) + } + + return callRequests, nil +} + +// sendCallRequests sends a POST request to metatx API +func (client *MoonstreamEngineAPIClient) sendCallRequests(requestBodyBytes []byte) error { + request, requestCreationErr := http.NewRequest("POST", fmt.Sprintf("%s/metatx/requests", client.BaseURL), bytes.NewBuffer(requestBodyBytes)) + if requestCreationErr != nil { + return requestCreationErr + } + + request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", client.AccessToken)) + request.Header.Add("Accept", "application/json") + request.Header.Add("Content-Type", "application/json") + + response, responseErr := client.HTTPClient.Do(request) + if responseErr != nil { + return responseErr + } + defer response.Body.Close() + + responseBody, responseBodyErr := io.ReadAll(response.Body) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + if responseBodyErr != nil { + return fmt.Errorf("unexpected status code: %d -- could not read response body: %s", response.StatusCode, responseBodyErr.Error()) + } + responseBodyString := string(responseBody) + return fmt.Errorf("unexpected status code: %d -- response body: %s", response.StatusCode, responseBodyString) + } + + return nil +} + +func (client *MoonstreamEngineAPIClient) CreateCallRequests(contractId, contractAddress string, ttlDays int, specs []CallRequestSpecification, batchSize int) error { + if contractId == "" && contractAddress == "" { + return fmt.Errorf("you must specify at least one of contractId or contractAddress when creating call requests") + } + + var specBatches [][]CallRequestSpecification + for i := 0; i <= len(specs); i += batchSize { + if i+batchSize > len(specs) { + specBatches = append(specBatches, specs[i:]) + break + } + specBatches = append(specBatches, specs[i:i+batchSize]) + } + + for i, batchSpecs := range specBatches { + requestBody := CreateCallRequestsRequest{ + TTLDays: ttlDays, + Specifications: batchSpecs, + } + + if contractId != "" { + requestBody.ContractID = contractId + } + + if contractAddress != "" { + requestBody.ContractAddress = contractAddress + } + + requestBodyBytes, requestBodyBytesErr := json.Marshal(requestBody) + if requestBodyBytesErr != nil { + return requestBodyBytesErr + } + + sendReTryCnt := 1 + maxSendReTryCnt := 3 + SEND_RETRY: + for sendReTryCnt <= maxSendReTryCnt { + sendCallRequestsErr := client.sendCallRequests(requestBodyBytes) + if sendCallRequestsErr == nil { + break SEND_RETRY + } + fmt.Printf("During sending call requests an error ocurred: %v, retry %d\n", sendCallRequestsErr, sendReTryCnt) + sendReTryCnt++ + time.Sleep(time.Duration(sendReTryCnt) * time.Second) + + if sendReTryCnt > maxSendReTryCnt { + return fmt.Errorf("failed to send call requests") + } + } + + fmt.Printf("Successfully pushed %d batch of %d total with %d call_requests to API\n", i+1, len(specBatches), len(batchSpecs)) + } + + return nil +} diff --git a/cmd/waggle/server.go b/cmd/waggle/server.go new file mode 100644 index 0000000..c25b4f3 --- /dev/null +++ b/cmd/waggle/server.go @@ -0,0 +1,272 @@ +package waggle + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "time" + + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" +) + +type AvailableSigner struct { + key *keystore.Key +} + +type Server struct { + Host string + Port int + AvailableSigners map[string]AvailableSigner + LogLevel int + CORSWhitelist map[string]bool +} + +type PingResponse struct { + Status string `json:"status"` +} + +type SignDropperRequest struct { + ChainId int `json:"chain_id"` + Dropper string `json:"dropper"` + Signer string `json:"signer"` + Sensible bool `json:"sensible"` + Requests []*DropperClaimMessage `json:"requests"` +} + +// CORS middleware +func (server *Server) corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + var allowedOrigin string + if server.CORSWhitelist["*"] { + allowedOrigin = "*" + } else { + for o := range server.CORSWhitelist { + if r.Header.Get("Origin") == o { + allowedOrigin = o + } + } + } + // If origin in list of CORS allowed origins, extend with required headers + if allowedOrigin != "" { + w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) + w.Header().Set("Access-Control-Allow-Methods", "GET,POST") + // Credentials are cookies, authorization headers, or TLS client certificates + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + } + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +// Log access requests in proper format +func (server *Server) logMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, readErr := io.ReadAll(r.Body) + if readErr != nil { + http.Error(w, "Unable to read body", http.StatusBadRequest) + return + } + r.Body = io.NopCloser(bytes.NewBuffer(body)) + if len(body) > 0 { + defer r.Body.Close() + } + + next.ServeHTTP(w, r) + + var ip string + var splitErr error + realIp := r.Header["X-Real-Ip"] + if len(realIp) == 0 { + ip, _, splitErr = net.SplitHostPort(r.RemoteAddr) + if splitErr != nil { + http.Error(w, fmt.Sprintf("Unable to parse client IP: %s", r.RemoteAddr), http.StatusBadRequest) + return + } + } else { + ip = realIp[0] + } + logStr := fmt.Sprintf("%s %s %s", ip, r.Method, r.URL.Path) + + if server.LogLevel >= 2 { + if r.URL.RawQuery != "" { + logStr += fmt.Sprintf(" %s", r.URL.RawQuery) + } + } + log.Printf("%s\n", logStr) + }) +} + +// Handle panic errors to prevent server shutdown +func (server *Server) panicMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if recoverErr := recover(); recoverErr != nil { + log.Println("recovered", recoverErr) + http.Error(w, "Internal server error", 500) + } + }() + + // There will be a defer with panic handler in each next function + next.ServeHTTP(w, r) + }) +} + +// PingRoute handles a GET request to the server status endpoint. +// +// # Request +// +// GET /ping +// +// # Response +// +// HTTP/1.1 200 OK +// +// Headers: +// +// Content-Type: application/json +// +// Body: +// +// status: string +// +// This endpoint responds with a JSON object indicating the status of the waggle server. +func (server *Server) PingRoute(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + response := PingResponse{Status: "ok"} + json.NewEncoder(w).Encode(response) +} + +// SignDropperRoute handles a POST request to the signing endpoint. +// +// # Request +// +// POST /sign/dropper +// +// Headers: +// +// Content-Type: application/json +// +// Body: +// +// chain_id: int +// dropper: string +// signer: string +// requests: list[ +// dropId: string +// requestID: string +// claimant: string +// blockDeadline: string +// amount: string +// ] +// +// # Response +// +// HTTP/1.1 200 OK +// +// Headers: +// +// Content-Type: application/json +// +// Body: +// +// chain_id: int +// dropper: string +// signer: string +// sensible: bool +// requests: list[ +// dropId: string +// requestID: string +// claimant: string +// blockDeadline: string +// amount: string +// signature: string +// signer: string +// ] +// +// This endpoint responds with signed message from Dropper signer. +func (server *Server) SignDropperRoute(w http.ResponseWriter, r *http.Request) { + body, readErr := io.ReadAll(r.Body) + if readErr != nil { + http.Error(w, "Unable to read body", http.StatusBadRequest) + return + } + r.Body = io.NopCloser(bytes.NewBuffer(body)) + if len(body) > 0 { + defer r.Body.Close() + } + var req *SignDropperRequest + parseErr := json.Unmarshal(body, &req) + if parseErr != nil { + http.Error(w, "Unable to parse body", http.StatusBadRequest) + return + } + + // Check if server can sign with provided signer address + var chosenSigner string + for addr := range server.AvailableSigners { + if addr == common.HexToAddress(req.Signer).String() { + chosenSigner = addr + } + } + if chosenSigner == "" { + http.Error(w, "Unable to find signer", http.StatusBadRequest) + return + } + + for _, message := range req.Requests { + messageHash, hashErr := DropperClaimMessageHash(int64(req.ChainId), req.Dropper, message.DropId, message.RequestID, message.Claimant, message.BlockDeadline, message.Amount) + if hashErr != nil { + http.Error(w, "Unable to generate message hash", http.StatusInternalServerError) + return + } + + signedMessage, signatureErr := SignRawMessage(messageHash, server.AvailableSigners[chosenSigner].key, req.Sensible) + if signatureErr != nil { + http.Error(w, "Unable to sign message", http.StatusInternalServerError) + return + } + + message.Signature = hex.EncodeToString(signedMessage) + message.Signer = server.AvailableSigners[chosenSigner].key.Address.Hex() + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(req) +} + +// Serve handles server run +func (server *Server) Serve() error { + serveMux := http.NewServeMux() + serveMux.HandleFunc("/ping", server.PingRoute) + serveMux.HandleFunc("/sign/dropper", server.SignDropperRoute) + + // Set list of common middleware, from bottom to top + commonHandler := server.corsMiddleware(serveMux) + commonHandler = server.logMiddleware(commonHandler) + commonHandler = server.panicMiddleware(commonHandler) + + s := http.Server{ + Addr: fmt.Sprintf("%s:%d", server.Host, server.Port), + Handler: commonHandler, + ReadTimeout: 40 * time.Second, + WriteTimeout: 40 * time.Second, + } + + log.Printf("Starting node load balancer HTTP server at %s:%d", server.Host, server.Port) + sErr := s.ListenAndServe() + if sErr != nil { + return fmt.Errorf("failed to start server listener, err: %v", sErr) + } + + return nil +} diff --git a/cmd/waggle/server_test.go b/cmd/waggle/server_test.go new file mode 100644 index 0000000..8e1034d --- /dev/null +++ b/cmd/waggle/server_test.go @@ -0,0 +1,28 @@ +package waggle + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "testing" +) + + +func TestServerPingRoute(t *testing.T) { + // Initialize Server instance and create Request to pass handler + server := &Server{} + r := httptest.NewRequest("GET", "/ping", nil) + + // Create ResponseRecoreder which statisfies http.ResponseWriter + // to record the response. + w := httptest.NewRecorder() + + server.PingRoute(w, r) + + result := w.Result() + var resp PingResponse + if err := json.NewDecoder(result.Body).Decode(&resp); err != nil { + fmt.Printf("Error decoding response: %v", err) + } + defer result.Body.Close() +} diff --git a/cmd/waggle/settings.go b/cmd/waggle/settings.go new file mode 100644 index 0000000..24f6df0 --- /dev/null +++ b/cmd/waggle/settings.go @@ -0,0 +1,75 @@ +package waggle + +import ( + "encoding/json" + "fmt" + "log" + "os" + "strings" +) + +var ( + MOONSTREAM_ACCESS_TOKEN = os.Getenv("MOONSTREAM_ACCESS_TOKEN") + MOONSTREAM_API_URL = os.Getenv("MOONSTREAM_API_URL") + MOONSTREAM_API_TIMEOUT_SECONDS = os.Getenv("MOONSTREAM_API_TIMEOUT_SECONDS") + + BUGOUT_ACCESS_TOKEN = os.Getenv("BUGOUT_ACCESS_TOKEN") + + WAGGLE_CORS_ALLOWED_ORIGINS = os.Getenv("WAGGLE_CORS_ALLOWED_ORIGINS") +) + +type ServerSignerConfig struct { + KeyfilePath string `json:"keyfile_path"` + KeyfilePasswordPath string `json:"keyfile_password_path"` +} + +// ReadConfig parses list of configuration file paths to list of Application Probes configs +func ReadServerSignerConfig(rawConfigPath string) (*[]ServerSignerConfig, error) { + var configs []ServerSignerConfig + + configPath := strings.TrimSuffix(rawConfigPath, "/") + _, err := os.Stat(configPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("file %s not found, err: %v", configPath, err) + } + return nil, fmt.Errorf("error due checking config path %s, err: %v", configPath, err) + } + + rawBytes, err := os.ReadFile(configPath) + if err != nil { + log.Fatal(err) + } + configTemp := &[]ServerSignerConfig{} + err = json.Unmarshal(rawBytes, configTemp) + if err != nil { + return nil, err + } + + for _, ct := range *configTemp { + _, err := os.Stat(ct.KeyfilePath) + if err != nil { + if os.IsNotExist(err) { + log.Printf("Signer ignored, file %s not found, err: %v\n", ct.KeyfilePath, err) + continue + } + log.Printf("Signer ignored, error due checking config path %s, err: %v\n", ct.KeyfilePath, err) + continue + } + _, err = os.Stat(ct.KeyfilePasswordPath) + if err != nil { + if os.IsNotExist(err) { + log.Printf("Signer ignored, file %s not found, err: %v\n", ct.KeyfilePasswordPath, err) + continue + } + log.Printf("Signer ignored, error due checking config path %s, err: %v\n", ct.KeyfilePasswordPath, err) + continue + } + configs = append(configs, ServerSignerConfig{ + KeyfilePath: ct.KeyfilePath, + KeyfilePasswordPath: ct.KeyfilePasswordPath, + }) + } + + return &configs, nil +} diff --git a/cmd/waggle/sign.go b/cmd/waggle/sign.go new file mode 100644 index 0000000..60f8290 --- /dev/null +++ b/cmd/waggle/sign.go @@ -0,0 +1,142 @@ +package waggle + +import ( + "fmt" + "math/big" + "os" + + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + "github.com/google/uuid" + "golang.org/x/term" +) + +func KeyfileFromPrivateKey(outfile string) error { + fmt.Print("Enter private key (it will not be displayed on screen): ") + privateKeyRaw, inputErr := term.ReadPassword(int(os.Stdin.Fd())) + if inputErr != nil { + return fmt.Errorf("error reading private key: %s", inputErr.Error()) + } + fmt.Print("\n") + privateKey := string(privateKeyRaw) + + parsedPrivateKey, parseErr := crypto.HexToECDSA(privateKey) + if parseErr != nil { + return fmt.Errorf("error parsing private key: %s", parseErr.Error()) + } + + keyUUID, uuidErr := uuid.NewRandom() + if uuidErr != nil { + return fmt.Errorf("error generating UUID for keystore: %s", uuidErr.Error()) + } + + key := &keystore.Key{ + Id: keyUUID, + PrivateKey: parsedPrivateKey, + Address: crypto.PubkeyToAddress(parsedPrivateKey.PublicKey), + } + scryptN := keystore.StandardScryptN + scryptP := keystore.StandardScryptP + + fmt.Printf("Enter the passphrase you would like to secure the keyfile (%s) with: ", outfile) + passphraseRaw, passphraseInputErr := term.ReadPassword(int(os.Stdin.Fd())) + if passphraseInputErr != nil { + return fmt.Errorf("error reading passphrase: %s", inputErr.Error()) + } + fmt.Print("\n") + passphrase := string(passphraseRaw) + + keystoreJSON, encryptErr := keystore.EncryptKey(key, passphrase, scryptN, scryptP) + if encryptErr != nil { + return fmt.Errorf("could not generate encrypted keystore: %s", encryptErr.Error()) + } + + err := os.WriteFile(outfile, keystoreJSON, 0600) + return err +} + +func KeyFromFile(keystoreFile string, password string) (*keystore.Key, error) { + var emptyKey *keystore.Key + keystoreContent, readErr := os.ReadFile(keystoreFile) + if readErr != nil { + return emptyKey, readErr + } + + // If password is "", prompt user for password. + if password == "" { + fmt.Printf("Please provide a password for keystore (%s): ", keystoreFile) + passwordRaw, inputErr := term.ReadPassword(int(os.Stdin.Fd())) + if inputErr != nil { + return emptyKey, fmt.Errorf("error reading password: %s", inputErr.Error()) + } + fmt.Print("\n") + password = string(passwordRaw) + } + + key, err := keystore.DecryptKey(keystoreContent, password) + return key, err +} + +func SignRawMessage(message []byte, key *keystore.Key, sensible bool) ([]byte, error) { + signature, err := crypto.Sign(message, key.PrivateKey) + if !sensible { + // This refers to a bug in an early Ethereum client implementation where the v parameter byte was + // shifted by 27: https://github.com/ethereum/go-ethereum/issues/2053 + // Default for callers should be NOT sensible. + // Defensively, we only shift if the 65th byte is 0 or 1. + if signature[64] < 2 { + signature[64] += 27 + } + } + return signature, err +} + +type DropperClaimMessage struct { + DropId string `json:"dropId"` + RequestID string `json:"requestID"` + Claimant string `json:"claimant"` + BlockDeadline string `json:"blockDeadline"` + Amount string `json:"amount"` + Signature string `json:"signature,omitempty"` + Signer string `json:"signer,omitempty"` +} + +func DropperClaimMessageHash(chainId int64, dropperAddress string, dropId, requestId string, claimant string, blockDeadline, amount string) ([]byte, error) { + // Inspired by: https://medium.com/alpineintel/issuing-and-verifying-eip-712-challenges-with-go-32635ca78aaf + signerData := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": { + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + "ClaimPayload": { + {Name: "dropId", Type: "uint256"}, + {Name: "requestID", Type: "uint256"}, + {Name: "claimant", Type: "address"}, + {Name: "blockDeadline", Type: "uint256"}, + {Name: "amount", Type: "uint256"}, + }, + }, + PrimaryType: "ClaimPayload", + Domain: apitypes.TypedDataDomain{ + Name: "Moonstream Dropper", + Version: "0.2.0", + ChainId: (*math.HexOrDecimal256)(big.NewInt(int64(chainId))), + VerifyingContract: dropperAddress, + }, + Message: apitypes.TypedDataMessage{ + "dropId": dropId, + "requestID": requestId, + "claimant": claimant, + "blockDeadline": blockDeadline, + "amount": amount, + }, + } + + messageHash, _, err := apitypes.TypedDataAndHash(signerData) + return messageHash, err +} diff --git a/cmd/waggle/sign_test.go b/cmd/waggle/sign_test.go new file mode 100644 index 0000000..41ae61d --- /dev/null +++ b/cmd/waggle/sign_test.go @@ -0,0 +1,22 @@ +package waggle + +import ( + "encoding/hex" + "testing" +) + +func TestDropperClaimMessageHash(t *testing.T) { + messageHash, err := DropperClaimMessageHash(80001, "0x4ec36E288E1b5d6914851a141cb041152Cf95328", "2", "5", "0x000000000000000000000000000000000000dEaD", "40000000", "3000000000000000000") + if err != nil { + t.Errorf("Unexpected error in DropperClaimMessageHash: %s", err.Error()) + } + messageHashString := hex.EncodeToString(messageHash) + + // Taken from claimMessageHash method on the Moonstream Dropper v0.2.0 contract deployed at 0x4ec36E288E1b5d6914851a141cb041152Cf95328 + // on Polygon Mumbai testnet (chainId: 80001). + expectedMessageHashString := "48033e41d47cd4cbfe7cd183e1c30ad6af92ea445475913bf96eed42d599bf20" + + if messageHashString != expectedMessageHashString { + t.Errorf("Incorrect calculation of message hash for Dropper claim. expected: %s, actual: %s", expectedMessageHashString, messageHashString) + } +} diff --git a/cmd/waggle/version.go b/cmd/waggle/version.go new file mode 100644 index 0000000..ae3806a --- /dev/null +++ b/cmd/waggle/version.go @@ -0,0 +1,3 @@ +package waggle + +var WAGGLE_VERSION = "0.1.0" From ab061501fb6ec6ee9b9b28292abbbb5199f0f188 Mon Sep 17 00:00:00 2001 From: kompotkot Date: Tue, 5 Sep 2023 11:22:14 +0000 Subject: [PATCH 3/3] Version txt del --- cmd/waggle/version.go | 2 +- version.txt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 version.txt diff --git a/cmd/waggle/version.go b/cmd/waggle/version.go index ae3806a..4879dae 100644 --- a/cmd/waggle/version.go +++ b/cmd/waggle/version.go @@ -1,3 +1,3 @@ package waggle -var WAGGLE_VERSION = "0.1.0" +var WAGGLE_VERSION = "0.1.2" diff --git a/version.txt b/version.txt deleted file mode 100644 index 17e51c3..0000000 --- a/version.txt +++ /dev/null @@ -1 +0,0 @@ -0.1.1