gitlab.com/SiaPrime/SiaPrime@v1.4.1/modules/miningpool/handler.go (about) 1 package pool 2 3 import ( 4 "bufio" 5 "bytes" 6 "encoding/binary" 7 "encoding/hex" 8 "encoding/json" 9 "errors" 10 "fmt" 11 "io" 12 // "math/big" 13 "net" 14 "strconv" 15 "strings" 16 "time" 17 18 "github.com/sasha-s/go-deadlock" 19 20 "gitlab.com/SiaPrime/SiaPrime/encoding" 21 "gitlab.com/SiaPrime/SiaPrime/modules" 22 "gitlab.com/SiaPrime/SiaPrime/persist" 23 "gitlab.com/SiaPrime/SiaPrime/types" 24 ) 25 26 const ( 27 extraNonce2Size = 4 28 ) 29 30 // Handler represents the status (open/closed) of each connection 31 type Handler struct { 32 mu deadlock.RWMutex 33 conn net.Conn 34 closed chan bool 35 notify chan bool 36 p *Pool 37 log *persist.Logger 38 s *Session 39 } 40 41 const ( 42 blockTimeout = 5 * time.Second 43 // This should represent the max number of pending notifications (new blocks found) within blockTimeout seconds 44 // better to have too many, than not enough (causes a deadlock otherwise) 45 numPendingNotifies = 20 46 ) 47 48 func (h *Handler) parseRequest() (*types.StratumRequest, error) { 49 var m types.StratumRequest 50 // fmt.Printf("%s: Setting timeout for %v\n", h.s.printID(), time.Now().Add(blockTimeout)) 51 h.conn.SetReadDeadline(time.Now().Add(blockTimeout)) 52 //dec := json.NewDecoder(h.conn) 53 buf := bufio.NewReader(h.conn) 54 select { 55 case <-h.p.tg.StopChan(): 56 return nil, errors.New("Stopping handler") 57 case <-h.notify: 58 m.Method = "mining.notify" 59 default: 60 // try reading from the connection 61 //err = dec.Decode(&m) 62 str, err := buf.ReadString('\n') 63 // if we timeout 64 if nerr, ok := err.(net.Error); ok && nerr.Timeout() { 65 // h.log.Printf("%s: Harmless timeout occurred\n", h.s.printID()) 66 //h.conn.SetReadDeadline(time.Time{}) 67 // check last job time and if over 25 seconds, send a new job. 68 // if time.Now().Sub(h.s.lastJobTimestamp) > (time.Second * 25) { 69 // m.Method = "mining.notify" 70 // break 71 // } 72 if h.s.DetectDisconnected() { 73 h.log.Println("Non-responsive disconnect detected!") 74 return nil, errors.New("Non-responsive disconnect detected") 75 } 76 77 // ratio := h.s.CurrentDifficulty() / h.s.HighestDifficulty() 78 // h.log.Printf("Non-responsive disconnect ratio: %f\n", ratio) 79 80 if h.s.checkDiffOnNewShare() { 81 err = h.sendSetDifficulty(h.s.CurrentDifficulty()) 82 if err != nil { 83 h.log.Println("Error sending SetDifficulty") 84 return nil, err 85 } 86 err = h.sendStratumNotify(true) 87 if err != nil { 88 h.log.Println("Error sending stratum notify") 89 return nil, err 90 } 91 } 92 93 return nil, nil 94 // if we don't timeout but have some other error 95 } else if err != nil { 96 if err == io.EOF { 97 //fmt.Println("End connection") 98 h.log.Println("End connection") 99 } else { 100 h.log.Println("Unusual error") 101 h.log.Println(err) 102 } 103 return nil, err 104 } else { 105 // NOTE: we were getting weird cases where the buffer would read a full 106 // string, then read part of the string again (like the last few chars). 107 // Somehow it seemed like the socket data wasn't getting purged correctly 108 // on reading. 109 // Not seeing it lately, but that's why we have this debugging code, 110 // and that's why we don't just read straight into the JSON decoder. 111 // Reading the data into a string buffer lets us debug more easily. 112 113 // If we encounter this issue again, we may want to drop down into 114 // lower-level socket manipulation to try to troubleshoot it. 115 // h.log.Println(str) 116 dec := json.NewDecoder(strings.NewReader(str)) 117 err = dec.Decode(&m) 118 if err != nil { 119 h.log.Println("Decoding error") 120 h.log.Println(err) 121 h.log.Println(str) 122 //return nil, err 123 // don't disconnect here, just treat it like a harmless timeout 124 return nil, nil 125 } 126 } 127 } 128 // h.log.Printf("%s: Clearing timeout\n", h.s.printID()) 129 return &m, nil 130 } 131 132 func (h *Handler) handleRequest(m *types.StratumRequest) error { 133 h.s.SetHeartbeat() 134 var err error 135 switch m.Method { 136 case "mining.subscribe": 137 return h.handleStratumSubscribe(m) 138 case "mining.authorize": 139 err = h.handleStratumAuthorize(m) 140 if err != nil { 141 h.log.Printf("Failed to authorize client\n") 142 h.log.Println(err) 143 return err 144 } 145 return h.sendStratumNotify(true) 146 case "mining.extranonce.subscribe": 147 return h.handleStratumNonceSubscribe(m) 148 case "mining.submit": 149 return h.handleStratumSubmit(m) 150 case "mining.notify": 151 h.log.Printf("mining.notify:New block to mine on\n") 152 return h.sendStratumNotify(true) 153 default: 154 h.log.Debugln("Unknown stratum method: ", m.Method) 155 } 156 return nil 157 } 158 159 // Listen listens on a connection for incoming data and acts on it 160 func (h *Handler) Listen() { 161 defer func() { 162 h.closed <- true // send to dispatcher, that connection is closed 163 h.conn.Close() 164 if h.s != nil && h.s.CurrentWorker != nil { 165 // delete a worker record when a session disconnections 166 h.s.CurrentWorker.deleteWorkerRecord() 167 // when we shut down the pool we get an error here because the log 168 // is already closed... TODO 169 h.s.log.Printf("Closed worker: %d\n", h.s.CurrentWorker.wr.workerID) 170 } 171 }() 172 err := h.p.tg.Add() 173 if err != nil { 174 // If this goroutine is not run before shutdown starts, this 175 // codeblock is reachable. 176 return 177 } 178 defer h.p.tg.Done() 179 180 h.log.Println("New connection from " + h.conn.RemoteAddr().String()) 181 h.mu.Lock() 182 h.s, _ = newSession(h.p, h.conn.RemoteAddr().String()) 183 h.mu.Unlock() 184 h.log.Println("New session: " + sPrintID(h.s.SessionID)) 185 for { 186 m, err := h.parseRequest() 187 h.conn.SetReadDeadline(time.Time{}) 188 // if we timed out 189 if m == nil && err == nil { 190 continue 191 // else if we got a request 192 } else if m != nil { 193 err = h.handleRequest(m) 194 if err != nil { 195 h.log.Println(err) 196 return 197 } 198 // else if we got an error 199 } else if err != nil { 200 h.log.Println(err) 201 return 202 } 203 } 204 } 205 206 func (h *Handler) sendResponse(r types.StratumResponse) error { 207 b, err := json.Marshal(r) 208 //fmt.Printf("SERVER: %s\n", b) 209 if err != nil { 210 h.log.Debugln("json marshal failed for id: ", r.ID, err) 211 return err 212 } 213 b = append(b, '\n') 214 _, err = h.conn.Write(b) 215 if err != nil { 216 h.log.Debugln("connection write failed for id: ", r.ID, err) 217 return err 218 } 219 return nil 220 } 221 222 func (h *Handler) sendRequest(r types.StratumRequest) error { 223 b, err := json.Marshal(r) 224 if err != nil { 225 h.log.Debugln("json marshal failed for id: ", r.ID, err) 226 return err 227 } 228 b = append(b, '\n') 229 _, err = h.conn.Write(b) 230 h.log.Debugln("sending request: ", string(b)) 231 if err != nil { 232 h.log.Debugln("connection write failed for id: ", r.ID, err) 233 return err 234 } 235 return nil 236 } 237 238 // handleStratumSubscribe message is the first message received and allows the pool to tell the miner 239 // the difficulty as well as notify, extranonce1 and extranonce2 240 // 241 // TODO: Pull the appropriate data from either in memory or persistent store as required 242 func (h *Handler) handleStratumSubscribe(m *types.StratumRequest) error { 243 if len(m.Params) > 0 { 244 h.log.Printf("Client subscribe name:%s", m.Params[0].(string)) 245 h.s.SetClientVersion(m.Params[0].(string)) 246 } 247 248 if len(m.Params) > 0 && m.Params[0].(string) == "sgminer/4.4.2" { 249 h.s.SetHighestDifficulty(11500) 250 h.s.SetCurrentDifficulty(11500) 251 h.s.SetDisableVarDiff(true) 252 } 253 if len(m.Params) > 0 && m.Params[0].(string) == "cgminer/4.9.0" { 254 h.s.SetHighestDifficulty(1024) 255 h.s.SetCurrentDifficulty(1024) 256 h.s.SetDisableVarDiff(true) 257 } 258 if len(m.Params) > 0 && m.Params[0].(string) == "cgminer/4.10.0" { 259 h.s.SetHighestDifficulty(700) 260 h.s.SetCurrentDifficulty(700) 261 h.s.SetDisableVarDiff(true) 262 } 263 if len(m.Params) > 0 && m.Params[0].(string) == "gominer" { 264 h.s.SetHighestDifficulty(0.03) 265 h.s.SetCurrentDifficulty(0.03) 266 h.s.SetDisableVarDiff(true) 267 } 268 if len(m.Params) > 0 && m.Params[0].(string) == "NiceHash/1.0.0" { 269 r := types.StratumResponse{ID: m.ID} 270 r.Result = false 271 r.Error = interfaceify([]string{"NiceHash client is not supported"}) 272 return h.sendResponse(r) 273 } 274 275 r := types.StratumResponse{ID: m.ID} 276 r.Method = m.Method 277 /* 278 if !h.s.Authorized() { 279 r.Result = false 280 r.Error = interfaceify([]string{"Session not authorized - authorize before subscribe"}) 281 return h.sendResponse(r) 282 } 283 */ 284 285 // diff := "b4b6693b72a50c7116db18d6497cac52" 286 t, _ := h.p.persist.Target.Difficulty().Uint64() 287 h.log.Debugf("Block Difficulty: %x\n", t) 288 tb := make([]byte, 8) 289 binary.LittleEndian.PutUint64(tb, t) 290 diff := hex.EncodeToString(tb) 291 //notify := "ae6812eb4cd7735a302a8a9dd95cf71f" 292 notify := h.s.printID() 293 extranonce1 := h.s.printNonce() 294 extranonce2 := extraNonce2Size 295 raw := fmt.Sprintf(`[ [ ["mining.set_difficulty", "%s"], ["mining.notify", "%s"]], "%s", %d]`, diff, notify, extranonce1, extranonce2) 296 r.Result = json.RawMessage(raw) 297 r.Error = nil 298 return h.sendResponse(r) 299 } 300 301 // this is thread-safe, when we're looking for and possibly creating a client, 302 // we need to make sure the action is atomic 303 func (h *Handler) setupClient(client, worker string) (*Client, error) { 304 var err error 305 h.p.clientSetupMutex.Lock() 306 defer h.p.clientSetupMutex.Unlock() 307 c, err := h.p.FindClientDB(client) 308 if err != ErrNoUsernameInDatabase { 309 return c, err 310 } 311 if c == nil { 312 //fmt.Printf("Unable to find client in db: %s\n", client) 313 c, err = newClient(h.p, client) 314 if err != nil { 315 //fmt.Println("Failed to create a new Client") 316 h.p.log.Printf("Failed to create a new Client: %s\n", err) 317 return nil, err 318 } 319 err = h.p.AddClientDB(c) 320 if err != nil { 321 h.p.log.Printf("Failed to add client to DB: %s\n", err) 322 return nil, err 323 } 324 } else { 325 //fmt.Printf("Found client: %s\n", client) 326 } 327 if h.p.Client(client) == nil { 328 h.p.log.Printf("Adding client in memory: %s\n", client) 329 h.p.AddClient(c) 330 } 331 return c, nil 332 } 333 334 func (h *Handler) setupWorker(c *Client, workerName string) (*Worker, error) { 335 w, err := newWorker(c, workerName, h.s) 336 if err != nil { 337 c.log.Printf("Failed to add worker: %s\n", err) 338 return nil, err 339 } 340 341 err = c.addWorkerDB(w) 342 if err != nil { 343 c.log.Printf("Failed to add worker: %s\n", err) 344 return nil, err 345 } 346 h.s.log = w.log 347 c.log.Printf("Adding new worker: %s, %d\n", workerName, w.wr.workerID) 348 w.log.Printf("Adding new worker: %s, %d\n", workerName, w.wr.workerID) 349 h.log.Debugln("client = " + c.Name() + ", worker = " + workerName) 350 return w, nil 351 } 352 353 // handleStratumAuthorize allows the pool to tie the miner connection to a particular user 354 func (h *Handler) handleStratumAuthorize(m *types.StratumRequest) error { 355 var err error 356 357 r := types.StratumResponse{ID: m.ID} 358 359 r.Method = "mining.authorize" 360 r.Result = true 361 r.Error = nil 362 clientName := m.Params[0].(string) 363 workerName := "" 364 if strings.Contains(clientName, ".") { 365 s := strings.SplitN(clientName, ".", -1) 366 clientName = s[0] 367 workerName = s[1] 368 } 369 370 // load wallet and check validity 371 var walletTester types.UnlockHash 372 err = walletTester.LoadString(clientName) 373 if err != nil { 374 r.Result = false 375 r.Error = interfaceify([]string{"Client Name must be valid wallet address"}) 376 h.log.Println("Client Name must be valid wallet address. Client name is: " + clientName) 377 err = errors.New("Client name must be a valid wallet address") 378 h.sendResponse(r) 379 return err 380 } 381 382 c, err := h.setupClient(clientName, workerName) 383 if err != nil { 384 r.Result = false 385 r.Error = interfaceify([]string{err.Error()}) 386 h.sendResponse(r) 387 return err 388 } 389 w, err := h.setupWorker(c, workerName) 390 if err != nil { 391 r.Result = false 392 r.Error = interfaceify([]string{err.Error()}) 393 h.sendResponse(r) 394 return err 395 } 396 397 // if everything is fine, setup the client and send a response and difficulty 398 h.s.addClient(c) 399 h.s.addWorker(w) 400 h.s.addShift(h.p.newShift(h.s.CurrentWorker)) 401 h.s.SetAuthorized(true) 402 err = h.sendResponse(r) 403 if err != nil { 404 return err 405 } 406 err = h.sendSetDifficulty(h.s.CurrentDifficulty()) 407 if err != nil { 408 return err 409 } 410 return nil 411 } 412 413 // handleStratumNonceSubscribe tells the pool that this client can handle the extranonce info 414 // TODO: Not sure we have to anything if all our clients support this. 415 func (h *Handler) handleStratumNonceSubscribe(m *types.StratumRequest) error { 416 h.p.log.Debugln("ID = "+strconv.FormatUint(m.ID, 10)+", Method = "+m.Method+", params = ", m.Params) 417 418 // not sure why 3 is right, but ccminer expects it to be 3 419 r := types.StratumResponse{ID: 3} 420 r.Result = true 421 r.Error = nil 422 423 return h.sendResponse(r) 424 } 425 426 // request is sent as [name, jobid, extranonce2, nTime, nonce] 427 func (h *Handler) handleStratumSubmit(m *types.StratumRequest) error { 428 // fmt.Printf("%s: %s Handle submit\n", time.Now(), h.s.printID()) 429 r := types.StratumResponse{ID: m.ID} 430 r.Method = "mining.submit" 431 r.Result = true 432 r.Error = nil 433 /* 434 err := json.Unmarshal(m.Params, &p) 435 if err != nil { 436 h.log.Printf("Unable to parse mining.submit params: %v\n", err) 437 r.Result = false //json.RawMessage(`false`) 438 r.Error = interfaceify([]string{"20","Parse Error"}) //json.RawMessage(`["20","Parse Error"]`) 439 } 440 */ 441 // name := m.Params[0].(string) 442 var jobID uint64 443 fmt.Sscanf(m.Params[1].(string), "%x", &jobID) 444 extraNonce2 := m.Params[2].(string) 445 nTime := m.Params[3].(string) 446 nonce := m.Params[4].(string) 447 // h.s.log.Printf("Submit jobid:%d nonce: %s, extraNonce2: %s", jobID, nonce, extraNonce2) 448 449 needNewJob := false 450 defer func() { 451 if needNewJob == true { 452 h.sendStratumNotify(true) 453 } 454 }() 455 456 if h.s.CurrentWorker == nil { 457 // worker failed to authorize 458 h.log.Printf("Worker failed to authorize - dropping\n") 459 return errors.New("Worker failed to authorize - dropping") 460 } 461 462 h.s.SetLastShareTimestamp(time.Now()) 463 sessionPoolDifficulty := h.s.CurrentDifficulty() 464 if h.s.checkDiffOnNewShare() { 465 h.sendSetDifficulty(h.s.CurrentDifficulty()) 466 needNewJob = true 467 } 468 469 // h.log.Debugln("name = " + name + ", jobID = " + fmt.Sprintf("%X", jobID) + ", extraNonce2 = " + extraNonce2 + ", nTime = " + nTime + ", nonce = " + nonce) 470 if h.s.log != nil { 471 // h.s.log.Debugln("name = " + name + ", jobID = " + fmt.Sprintf("%X", jobID) + ", extraNonce2 = " + extraNonce2 + ", nTime = " + nTime + ", nonce = " + nonce) 472 } else { 473 h.log.Debugln("session log not ready") 474 } 475 476 var b types.Block 477 j, err := h.s.getJob(jobID, nonce) 478 if err != nil { 479 r.Result = false 480 r.Error = interfaceify([]string{"23", err.Error()}) //json.RawMessage(`["21","Stale - old/unknown job"]`) 481 h.s.CurrentWorker.IncrementInvalidShares() 482 return h.sendResponse(r) 483 } 484 if j != nil { 485 encoding.Unmarshal(j.MarshalledBlock, &b) 486 } 487 488 if len(b.MinerPayouts) == 0 { 489 r.Result = false 490 r.Error = interfaceify([]string{"22", "Stale - old/unknown job"}) //json.RawMessage(`["21","Stale - old/unknown job"]`) 491 h.s.CurrentWorker.log.Printf("Stale Share rejected - old/unknown job\n") 492 h.s.CurrentWorker.IncrementInvalidShares() 493 return h.sendResponse(r) 494 } 495 496 bhNonce, err := hex.DecodeString(nonce) 497 for i := range b.Nonce { // there has to be a better way to do this in golang 498 b.Nonce[i] = bhNonce[i] 499 } 500 tb, _ := hex.DecodeString(nTime) 501 b.Timestamp = types.Timestamp(encoding.DecUint64(tb)) 502 503 cointxn := h.p.coinB1() 504 ex1, _ := hex.DecodeString(h.s.printNonce()) 505 ex2, _ := hex.DecodeString(extraNonce2) 506 cointxn.ArbitraryData[0] = append(cointxn.ArbitraryData[0], ex1...) 507 cointxn.ArbitraryData[0] = append(cointxn.ArbitraryData[0], ex2...) 508 509 b.Transactions = append(b.Transactions, []types.Transaction{cointxn}...) 510 blockHash := b.ID() 511 // bh := new(big.Int).SetBytes(blockHash[:]) 512 513 sessionPoolTarget, _ := difficultyToTarget(sessionPoolDifficulty) 514 515 // h.s.CurrentWorker.log.Printf("session diff: %f, block version diff: %s", 516 // sessionPoolDifficulty, printWithSuffix(sessionPoolTarget.Difficulty())) 517 518 // need to checkout the block hashrate reach pool target or not 519 // h.s.CurrentWorker.log.Printf("Submit target: %064x\n", bh) 520 // h.s.CurrentWorker.log.Printf("Session target: %064x\n", sessionPoolTarget.Int()) 521 if bytes.Compare(sessionPoolTarget[:], blockHash[:]) < 0 { 522 r.Result = false 523 r.Error = interfaceify([]string{"22", "Submit nonce not reach pool diff target"}) //json.RawMessage(`["21","Stale - old/unknown job"]`) 524 h.s.CurrentWorker.log.Printf("Submit nonce not reach pool diff target\n") 525 h.s.CurrentWorker.IncrementInvalidShares() 526 return h.sendResponse(r) 527 } 528 529 t := h.p.persist.GetTarget() 530 // h.s.CurrentWorker.log.Printf("Submit block hash is %064x\n", bh) 531 // h.s.CurrentWorker.log.Printf("Chain block target is %064x\n", t.Int()) 532 // h.s.CurrentWorker.log.Printf("Difficulty %s/%s\n", 533 // printWithSuffix(types.IntToTarget(bh).Difficulty()), printWithSuffix(t.Difficulty())) 534 if bytes.Compare(t[:], blockHash[:]) < 0 { 535 // h.s.CurrentWorker.log.Printf("Block hash is greater than block target\n") 536 h.s.CurrentWorker.log.Printf("Share Accepted\n") 537 h.s.CurrentWorker.IncrementShares(h.s.CurrentDifficulty(), currencyToAmount(b.MinerPayouts[0].Value)) 538 h.s.CurrentWorker.SetLastShareTime(time.Now()) 539 return h.sendResponse(r) 540 } 541 err = h.p.managedSubmitBlock(b) 542 if err != nil && err != modules.ErrBlockUnsolved { 543 h.log.Printf("Failed to SubmitBlock(): %v\n", err) 544 h.log.Printf(sPrintBlock(b)) 545 panic(fmt.Sprintf("Failed to SubmitBlock(): %v\n", err)) 546 /*r.Result = false //json.RawMessage(`false`) 547 r.Error = interfaceify([]string{"20", "Stale share"}) 548 h.s.CurrentWorker.IncrementInvalidShares() 549 return h.sendResponse(r)*/ 550 } 551 552 h.s.CurrentWorker.log.Printf("Share Accepted\n") 553 h.s.CurrentWorker.IncrementShares(h.s.CurrentDifficulty(), currencyToAmount(b.MinerPayouts[0].Value)) 554 h.s.CurrentWorker.SetLastShareTime(time.Now()) 555 556 // TODO: why not err == nil ? 557 if err != modules.ErrBlockUnsolved { 558 h.s.CurrentWorker.Parent().log.Printf("Yay!!! Solved a block!!\n") 559 // h.s.CurrentWorker.log.Printf("Yay!!! Solved a block!!\n") 560 h.s.clearJobs() 561 err = h.s.CurrentWorker.addFoundBlock(&b) 562 if err != nil { 563 h.s.CurrentWorker.log.Printf("Failed to update block in database: %s\n", err) 564 } 565 h.p.shiftChan <- true 566 } 567 return h.sendResponse(r) 568 } 569 570 func (h *Handler) sendSetDifficulty(d float64) error { 571 var r types.StratumRequest 572 573 r.Method = "mining.set_difficulty" 574 // assuming this ID is the response to the original subscribe which appears to be a 1 575 r.ID = 0 576 params := make([]interface{}, 1) 577 r.Params = params 578 r.Params[0] = d 579 return h.sendRequest(r) 580 } 581 582 func (h *Handler) sendStratumNotify(cleanJobs bool) error { 583 var r types.StratumRequest 584 r.Method = "mining.notify" 585 r.ID = 0 // gominer requires notify to use an id of 0 586 job, _ := newJob(h.p) 587 h.s.addJob(job) 588 jobid := job.printID() 589 var b types.Block 590 h.p.persist.mu.Lock() 591 // make a copy of the block and hold it until the solution is submitted 592 job.MarshalledBlock = encoding.Marshal(&h.p.sourceBlock) 593 h.p.persist.mu.Unlock() 594 encoding.Unmarshal(job.MarshalledBlock, &b) 595 job.MerkleRoot = b.MerkleRoot() 596 mbj := b.MerkleBranches() 597 h.log.Debugf("merkleBranch: %s\n", mbj) 598 599 version := "" 600 nbits := fmt.Sprintf("%08x", BigToCompact(h.p.persist.Target.Int())) 601 602 var buf bytes.Buffer 603 encoding.WriteUint64(&buf, uint64(b.Timestamp)) 604 ntime := hex.EncodeToString(buf.Bytes()) 605 606 params := make([]interface{}, 9) 607 r.Params = params 608 r.Params[0] = jobid 609 r.Params[1] = b.ParentID.String() 610 r.Params[2] = h.p.coinB1Txn() 611 r.Params[3] = h.p.coinB2() 612 r.Params[4] = mbj 613 r.Params[5] = version 614 r.Params[6] = nbits 615 r.Params[7] = ntime 616 r.Params[8] = cleanJobs 617 //h.log.Debugf("send.notify: %s\n", raw) 618 h.log.Debugf("send.notify: %s\n", r.Params) 619 return h.sendRequest(r) 620 }