diff --git a/cmd.go b/cmd.go index d02072a..8640941 100644 --- a/cmd.go +++ b/cmd.go @@ -24,6 +24,7 @@ func CreateRootCommand() *cobra.Command { waggle currently supports signatures for the following types of contracts: - Dropper (dropper-v0.2.0) + - Dropper (dropperV3 3.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. @@ -197,6 +198,26 @@ func CreateSignCommand() *cobra.Command { 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.") + dropperV3HashSubcommand := &cobra.Command{ + Use: "v3-hash", + Short: "Generate a message hash for a claim method call", + RunE: func(cmd *cobra.Command, args []string) error { + messageHash, err := DropperV3ClaimMessageHash(chainId, dropperAddress, dropId, requestId, claimant, blockDeadline, amount) + if err != nil { + return err + } + cmd.Println(hex.EncodeToString(messageHash)) + return nil + }, + } + dropperV3HashSubcommand.Flags().Int64Var(&chainId, "chain-id", 1, "Chain ID of the network you are signing for.") + dropperV3HashSubcommand.Flags().StringVar(&dropperAddress, "dropper", "0x0000000000000000000000000000000000000000", "Address of Dropper contract") + dropperV3HashSubcommand.Flags().StringVar(&dropId, "drop-id", "0", "ID of the drop.") + dropperV3HashSubcommand.Flags().StringVar(&requestId, "request-id", "0", "ID of the request.") + dropperV3HashSubcommand.Flags().StringVar(&claimant, "claimant", "", "Address of the intended claimant.") + dropperV3HashSubcommand.Flags().StringVar(&blockDeadline, "block-deadline", "0", "Time Stamp by which the claim must be made.") + dropperV3HashSubcommand.Flags().StringVar(&amount, "amount", "0", "Amount of tokens to distribute.") + dropperSingleSubcommand := &cobra.Command{ Use: "single", Short: "Sign a single claim method call", @@ -247,6 +268,56 @@ func CreateSignCommand() *cobra.Command { dropperSingleSubcommand.Flags().StringVar(&amount, "amount", "0", "Amount of tokens to distribute.") dropperSingleSubcommand.Flags().BoolVar(&hashFlag, "hash", false, "Output the message hash instead of the signature.") + dropperV3SingleSubcommand := &cobra.Command{ + Use: "single-v3", + Short: "Sign a single claim method call", + RunE: func(cmd *cobra.Command, args []string) error { + messageHash, hashErr := DropperV3ClaimMessageHash(chainId, dropperAddress, dropId, requestId, claimant, blockDeadline, amount) + if hashErr != nil { + return hashErr + } + + if hashFlag { + 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 + }, + } + dropperV3SingleSubcommand.Flags().Int64Var(&chainId, "chain-id", 1, "Chain ID of the network you are signing for.") + dropperV3SingleSubcommand.Flags().StringVar(&dropperAddress, "dropper", "0x0000000000000000000000000000000000000000", "Address of Dropper contract") + dropperV3SingleSubcommand.Flags().StringVar(&dropId, "drop-id", "0", "ID of the drop.") + dropperV3SingleSubcommand.Flags().StringVar(&requestId, "request-id", "0", "ID of the request.") + dropperV3SingleSubcommand.Flags().StringVar(&claimant, "claimant", "", "Address of the intended claimant.") + dropperV3SingleSubcommand.Flags().StringVar(&blockDeadline, "block-deadline", "0", "Time stamp by which the claim must be made.") + dropperV3SingleSubcommand.Flags().StringVar(&amount, "amount", "0", "Amount of tokens to distribute.") + dropperV3SingleSubcommand.Flags().BoolVar(&hashFlag, "hash", false, "Output the message hash instead of the signature.") + dropperBatchSubcommand := &cobra.Command{ Use: "batch", Short: "Sign a batch of claim method calls", @@ -352,6 +423,111 @@ func CreateSignCommand() *cobra.Command { 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.") + dropperV3BatchSubcommand := &cobra.Command{ + Use: "batch-v3", + 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 := DropperV3ClaimMessageHash(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 + }, + } + dropperV3BatchSubcommand.Flags().Int64Var(&chainId, "chain-id", 1, "Chain ID of the network you are signing for.") + dropperV3BatchSubcommand.Flags().StringVar(&dropperAddress, "dropper", "0x0000000000000000000000000000000000000000", "Address of Dropper contract") + dropperV3BatchSubcommand.Flags().StringVar(&infile, "infile", "", "Input file. If not specified, input will be expected from stdin.") + dropperV3BatchSubcommand.Flags().StringVar(&outfile, "outfile", "", "Output file. If not specified, output will be written to stdout.") + dropperV3BatchSubcommand.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", diff --git a/server.go b/server.go index aaa13a9..771dacd 100644 --- a/server.go +++ b/server.go @@ -387,6 +387,8 @@ func (server *Server) signersHandler(w http.ResponseWriter, r *http.Request) { // TODO: (kompotkot): Re-write in subroutes and subapps when times come server.signDropperRoute(w, r, requestedSigner) return + case strings.Contains(r.URL.Path, "/dropperV3/sign"): + server.signDropperV3Route(w, r, requestedSigner) default: http.Error(w, "Not found", http.StatusNotFound) return @@ -644,6 +646,209 @@ func (server *Server) signDropperRoute(w http.ResponseWriter, r *http.Request, s } } +// signDropperV3Route sign dropperV3 call requests +// If the query metatx is set to strict, then an error will be raised at the checkCallRequests step. If the metatx is soft (which is the default), then requests minus existing requests from checkCallRequests will be pushed. +func (server *Server) signDropperV3Route(w http.ResponseWriter, r *http.Request, signer string) { + authorizationContext := r.Context().Value("authorizationContext").(AuthorizationContext) + authorizationToken := authorizationContext.AuthorizationToken + + 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 + } + + if req.Dropper == "" && req.RegisteredContractId == "" { + http.Error(w, "Dropper address or registered contract ID should be specified", http.StatusBadRequest) + return + } + + if req.RegisteredContractId != "" { + contractStatusCode, registeredContract, contractStatus := server.MoonstreamEngineAPIClient.GetRegisteredContract(authorizationToken, req.RegisteredContractId) + if contractStatusCode == 500 { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if contractStatusCode != 200 { + http.Error(w, contractStatus, contractStatusCode) + return + } + + req.ChainId = registeredContract.ChainId + req.Dropper = registeredContract.Address + } + + batchSize := 100 + callRequestsLen := len(req.Requests) + + var currentBatch []CallRequestSpecification + var callRequestBatches [][]CallRequestSpecification + + var callRequestSpecifications []CallRequestSpecification + + for i, message := range req.Requests { + messageHash, hashErr := DropperV3ClaimMessageHash(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[signer].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[signer].key.Address.Hex() + + if !req.NoMetatx { + // If no_metatx key not provided with request, prepare slices for push to metatx call requests creation endpoint + newCallRequest := CallRequestSpecification{ + Caller: message.Claimant, + Method: "claim", + RequestId: message.RequestID, + Parameters: DropperCallRequestParameters{ + DropId: message.DropId, + BlockDeadline: message.BlockDeadline, + Amount: message.Amount, + Signer: signer, + Signature: message.Signature, + }, + } + callRequestSpecifications = append(callRequestSpecifications, newCallRequest) + currentBatch = append(currentBatch, newCallRequest) + if (i+1)%batchSize == 0 || i == callRequestsLen-1 { + if currentBatch != nil { + callRequestBatches = append(callRequestBatches, currentBatch) + } + currentBatch = nil // Reset the batch + } + } + } + + // Run check of existing call_requests in database + if !req.NoMetatx && !req.NoCheckMetatx { + checkStatusCode, existingRequests, checkStatus := server.MoonstreamEngineAPIClient.checkCallRequests(authorizationToken, req.RegisteredContractId, req.Dropper, callRequestSpecifications) + + if checkStatusCode == 0 { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } else if checkStatusCode == 200 { + if len(existingRequests.ExistingRequests) != 0 { + var existingReqIds string + for i, r := range existingRequests.ExistingRequests { + if i == 0 { + existingReqIds += r[1] + continue + } + existingReqIds += fmt.Sprintf(",%s", r[1]) + } + http.Error(w, fmt.Sprintf("Conflicting records were found in the database: [%s]", existingReqIds), http.StatusConflict) + return + } + } else { + http.Error(w, checkStatus, checkStatusCode) + return + } + } + + resp := SignDropperResponse{ + ChainId: req.ChainId, + Dropper: req.Dropper, + TtlDays: req.TtlDays, + Sensible: req.Sensible, + Requests: req.Requests, + } + + var jobEntry *spire.Entry + // Prepare job entry for report + if !req.NoMetatx { + resp.MetatxRegistered = true + + var createJobErr error + jobEntry, createJobErr = CreateJobInJournal(&server.BugoutAPIClient.BugoutSpireClient, signer) + if createJobErr != nil { + log.Printf("Unable to create job entry in journal, error: %v", createJobErr) + } + } + + if jobEntry != nil { + resp.JobEntryId = jobEntry.Id + resp.JobEntryUrl = fmt.Sprintf("%s/journals/%s/entries/%s", server.BugoutAPIClient.SpireBaseURL, BUGOUT_METATX_JOBS_JOURNAL_ID, jobEntry.Id) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + + if !req.NoMetatx { + pushedCallRequestIds := []string{} + failedCallRequestIds := []string{} + + // Push batch by batch to metatx call requests creation endpoint in background + go func() { + for i, batchSpecs := range callRequestBatches { + requestBody := CreateCallRequestsRequest{ + TTLDays: req.TtlDays, + Specifications: batchSpecs, + } + if req.RegisteredContractId != "" { + requestBody.ContractID = req.RegisteredContractId + } else { + requestBody.ContractAddress = req.Dropper + } + + requestBodyBytes, requestBodyBytesErr := json.Marshal(requestBody) + if requestBodyBytesErr != nil { + log.Printf("Unable to marshal body, error: %v", requestBodyBytesErr) + for _, r := range batchSpecs { + failedCallRequestIds = append(failedCallRequestIds, r.RequestId) + } + continue + } + + statusCode, responseBodyStr := server.MoonstreamEngineAPIClient.sendCallRequests(authorizationToken, requestBodyBytes) + if statusCode == 200 { + for _, r := range batchSpecs { + pushedCallRequestIds = append(pushedCallRequestIds, r.RequestId) + } + log.Printf("Batch %d of %d total with %d call_requests successfully pushed to API", i+1, len(callRequestBatches), callRequestsLen) + continue + } + + if statusCode == 409 { + log.Printf("Batch %d of %d total with %d call_requests failed with duplication error: %v", i+1, len(callRequestBatches), callRequestsLen, responseBodyStr) + } else { + log.Printf("Batch %d of %d total with %d call_requests failed with error: %v", i+1, len(callRequestBatches), callRequestsLen, responseBodyStr) + } + for _, r := range batchSpecs { + failedCallRequestIds = append(failedCallRequestIds, r.RequestId) + } + } + + // Send job report to entry + if jobEntry != nil { + jobStatusCode, writeJobErr := server.BugoutAPIClient.UpdateJobInJournal(jobEntry.Id, signer, pushedCallRequestIds, failedCallRequestIds) + if writeJobErr != nil { + log.Printf("Unable to push waggle job to journal, status code %d, error: %v", jobStatusCode, writeJobErr) + } + } else { + log.Printf("Job entry creation failed, job report not pushed") + } + }() + } +} + // Serve handles server run func (server *Server) Serve() error { serveMux := http.NewServeMux() diff --git a/sign.go b/sign.go index 456f440..3f94e82 100644 --- a/sign.go +++ b/sign.go @@ -145,3 +145,41 @@ func DropperClaimMessageHash(chainId int64, dropperAddress string, dropId, reque messageHash, _, err := apitypes.TypedDataAndHash(signerData) return messageHash, err } + +func DropperV3ClaimMessageHash(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: "Game7 Dropper", + Version: "3.0", + ChainId: (*math.HexOrDecimal256)(big.NewInt(chainId)), + VerifyingContract: dropperAddress, + }, + Message: apitypes.TypedDataMessage{ + "dropId": dropId, + "requestID": requestId, + "claimant": claimant, + "blockDeadline": blockDeadline, + "amount": amount, + }, + } + + messageHash, _, err := apitypes.TypedDataAndHash(signerData) + return messageHash, err +}