From 62358df198dd6d6def79a4b5a0d8fff6bc61681d Mon Sep 17 00:00:00 2001 From: DragonX Developers Date: Wed, 26 Aug 2026 02:11:46 -0500 Subject: [PATCH] frontend: stop an unconfirmed transaction from killing the daemon GetTransaction asserted the height out of getrawtransaction's reply without checking it: txHeight = txinfo.(map[string]interface{})["height"].(float64) dragonxd emits "height" only for a transaction that is in a block -- rawtransaction.cpp puts it inside `if (!hashBlock.IsNull())` -- so every mempool transaction comes back without the key. The assertion then runs nil.(float64) and panics. Nothing recovers it: grpc-go v1.24.0 installs no recovery interceptor (there is no recover() in its server.go) and this daemon adds none, so the panic takes down the whole process and every wallet connected to that endpoint with it. Any client can trigger it deliberately: broadcast a transaction, then ask for it before it is mined. GetMempoolStream, added in b1b0d45, hands out unconfirmed txids by design, so ordinary 0-conf use walks straight into it. Verified against a live mempool transaction on this node, whose reply has neither "height" nor "blockhash": the old expression panics with "interface conversion: interface {} is nil, not float64"; the new one returns cleanly. An unconfirmed transaction is now reported as tip+1, which is what GetMempoolStream already advertises for the same transactions (mempool.go:109). Also harden GetSaplingInfo, which had six more unchecked assertions on the getblockchaininfo reply. Those run on the block-ingestor goroutine, where a panic is equally fatal. The top-level object is asserted once and every field is read with the comma-ok form; a missing field now degrades instead of crashing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FU87LdsJZiZkfq1eXubpeo --- common/common.go | 27 +++++++++++++++++---------- frontend/service.go | 21 ++++++++++++++++++++- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/common/common.go b/common/common.go index ba57fca..24172b4 100644 --- a/common/common.go +++ b/common/common.go @@ -38,25 +38,32 @@ func GetSaplingInfo(rpcClient *rpcclient.Client) (int, int, string, string, int, return -1, -1, "", "", -1, -1, -1, errors.Wrap(err, "error reading JSON response") } - chainName := f.(map[string]interface{})["chain"].(string) + // Assert the top-level object once, then read every field with the comma-ok + // form. These run on the block-ingestor goroutine, and nothing in this + // daemon or in grpc-go v1.24.0 recovers a panic, so one unexpected response + // shape would take the whole process down rather than fail one call. + fmap, ok := f.(map[string]interface{}) + if !ok { + return -1, -1, "", "", -1, -1, -1, errors.New("getblockchaininfo: unexpected response shape") + } + chainName, _ := fmap["chain"].(string) // DragonX has Sapling active from block 1 but sets NO_ACTIVATION_HEIGHT in // chainparams, so dragonxd omits it from the upgrades map. Fall back to // height 1 when the key is absent. saplingHeight := float64(1) - upgradeJSON, ok := f.(map[string]interface{})["upgrades"] - if ok { - if upgradesMap, ok := upgradeJSON.(map[string]interface{}); ok { - if saplingJSON, ok := upgradesMap["76b809bb"]; ok { - saplingHeight = saplingJSON.(map[string]interface{})["activationheight"].(float64) + if upgradesMap, ok := fmap["upgrades"].(map[string]interface{}); ok { + if saplingJSON, ok := upgradesMap["76b809bb"].(map[string]interface{}); ok { + if h, ok := saplingJSON["activationheight"].(float64); ok { + saplingHeight = h } } } - blockHeight := f.(map[string]interface{})["headers"].(float64) - difficulty := f.(map[string]interface{})["difficulty"].(float64) - longestchain := f.(map[string]interface{})["longestchain"].(float64) - notarized := f.(map[string]interface{})["notarized"].(float64) + blockHeight, _ := fmap["headers"].(float64) + difficulty, _ := fmap["difficulty"].(float64) + longestchain, _ := fmap["longestchain"].(float64) + notarized, _ := fmap["notarized"].(float64) // DragonX always uses Sapling consensus rules but CurrentEpochBranchId() // returns Sprout (0) for full nodes because the activation heights are diff --git a/frontend/service.go b/frontend/service.go index 280d9e4..10f932c 100644 --- a/frontend/service.go +++ b/frontend/service.go @@ -293,7 +293,26 @@ func (s *SqlStreamer) GetTransaction(ctx context.Context, txf *walletrpc.TxFilte if err != nil { return nil, err } - txHeight = txinfo.(map[string]interface{})["height"].(float64) + // dragonxd emits "height" only for a transaction that is in a block: + // rawtransaction.cpp puts it inside `if (!hashBlock.IsNull())`. Every + // mempool transaction therefore comes back WITHOUT the key, and an + // unchecked assertion on the missing value panics -- which, with no + // recover() anywhere in grpc-go v1.24.0 or in this daemon, kills the + // whole process and every wallet connected to it. Any client can reach + // this by broadcasting a transaction and immediately asking for it, and + // GetMempoolStream hands out unconfirmed txids by design. + // + // Report an unconfirmed transaction as tip+1, matching what + // GetMempoolStream already advertises (mempool.go:109). + txmap, ok := txinfo.(map[string]interface{}) + if !ok { + return nil, errors.New("getrawtransaction: unexpected response shape") + } + if h, ok := txmap["height"].(float64); ok { + txHeight = h + } else { + txHeight = float64(s.cache.GetLatestBlock() + 1) + } return &walletrpc.RawTransaction{Data: txBytes, Height: uint64(txHeight)}, nil }