From 11295f8510839a9cf5fd7918a4358f1c24d8295e Mon Sep 17 00:00:00 2001 From: Erhnysr Date: Fri, 21 Aug 2026 08:52:18 +0300 Subject: [PATCH 01/16] test(controller): cover UpdateTelemetry nil account from failed GetAccount Add a regression test that drives UpdateTelemetry() with a state machine whose store Get() always fails, forcing GetAccount() to return a nil account with an error. The test asserts UpdateTelemetry() does not panic; it fails on the current code because the account metric branch dereferences the nil account. --- controller/block_test.go | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 controller/block_test.go diff --git a/controller/block_test.go b/controller/block_test.go new file mode 100644 index 0000000000..3f6a53d6d1 --- /dev/null +++ b/controller/block_test.go @@ -0,0 +1,68 @@ +package controller + +import ( + "bytes" + "reflect" + "sync/atomic" + "testing" + "unsafe" + + "github.com/stretchr/testify/require" + + "github.com/canopy-network/canopy/fsm" + "github.com/canopy-network/canopy/lib" + "github.com/canopy-network/canopy/lib/crypto" +) + +// failGetStore is a store whose Get() always fails, used to force GetAccount() and +// GetValidator() to return a nil result alongside an error, mirroring a store read failure. +type failGetStore struct { + lib.RWStoreI +} + +// Get() always returns an error so callers exercise their error handling paths. +func (failGetStore) Get([]byte) ([]byte, lib.ErrorI) { + // return a store 'get' error to stand in for a database read failure + return nil, lib.NewError(lib.CodeStoreGet, lib.StorageModule, "forced get failure") +} + +// TestUpdateTelemetryHandlesGetAccountError ensures UpdateTelemetry() does not panic when +// GetAccount() returns a nil account together with an error (for example a store read failure). +func TestUpdateTelemetryHandlesGetAccountError(t *testing.T) { + // build a state machine backed by a store whose Get() always fails + sm := newFailGetStateMachine(t) + // build a minimal controller wired to the failing state machine (nil Metrics is safe) + c := &Controller{ + Address: bytes.Repeat([]byte{1}, crypto.AddressSize), + FSM: sm, + isSyncing: &atomic.Bool{}, + } + // build a minimal block and quorum certificate for the telemetry update + block := &lib.Block{BlockHeader: &lib.BlockHeader{}} + qc := &lib.QuorumCertificate{} + // the telemetry update must tolerate a failing account lookup without panicking + require.NotPanics(t, func() { + c.UpdateTelemetry(qc, block, 0) + }) +} + +// newFailGetStateMachine builds a minimal StateMachine backed by a store whose Get() always fails. +func newFailGetStateMachine(t *testing.T) *fsm.StateMachine { + t.Helper() + // create an empty state machine + sm := &fsm.StateMachine{} + // wire in the failing store through the exported setter + sm.SetStore(failGetStore{}) + // the cache is an unexported field with no setter; allocate it via reflection so that + // GetAccount()'s cache lookup does not nil-panic before it reaches the failing store + field := reflect.ValueOf(sm).Elem().FieldByName("cache") + require.True(t, field.IsValid()) + // allocate a fresh cache value + cacheValue := reflect.New(field.Type().Elem()) + // initialize the 'accounts' map so the cache lookup is a clean miss + accounts := cacheValue.Elem().FieldByName("accounts") + reflect.NewAt(accounts.Type(), unsafe.Pointer(accounts.UnsafeAddr())).Elem().Set(reflect.MakeMap(accounts.Type())) + // set the cache field on the state machine + reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(cacheValue) + return sm +} From ed2dbfd237d8a2ab239d0a454df7ad27c28e6b52 Mon Sep 17 00:00:00 2001 From: Erhnysr Date: Fri, 21 Aug 2026 08:52:39 +0300 Subject: [PATCH 02/16] fix(controller): guard against nil account in UpdateTelemetry UpdateTelemetry() dropped the error from GetAccount() and dereferenced the returned account directly. GetAccount() returns a nil account together with an error on a store read or unmarshal failure, so the account metric branch could panic with a nil pointer dereference. Add a nil check before reading the account amount, matching the validator metric branch directly above which already guards its result with 'v != nil'. --- controller/block.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/block.go b/controller/block.go index 04d702b6ad..a683ac5711 100644 --- a/controller/block.go +++ b/controller/block.go @@ -772,7 +772,7 @@ func (c *Controller) UpdateTelemetry(qc *lib.QuorumCertificate, block *lib.Block c.Metrics.UpdateValidator(address.String(), v.StakedAmount, v.UnstakingHeight != 0, v.MaxPausedHeight != 0, v.Delegate, v.Compound, isProducer, nonSigners, doubleSigners) } // update account metrics - if a, _ := c.FSM.GetAccount(address); a.Amount != 0 { + if a, _ := c.FSM.GetAccount(address); a != nil && a.Amount != 0 { c.Metrics.UpdateAccount(address.String(), a.Amount) } } From 466af34f3c7aede62a2744b6f76c685ebd1bd37a Mon Sep 17 00:00:00 2001 From: moatus <15126267+moatus@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:32:05 -0400 Subject: [PATCH 03/16] fix(explorer): support copying identifiers on HTTP origins --- .docs/CHANGELOG.md | 7 ++ cmd/rpc/web/explorer/package.json | 1 + .../account/AccountDetailHeader.tsx | 8 +- .../src/components/block/BlockDetailInfo.tsx | 25 +++-- .../src/components/block/BlockSidebar.tsx | 11 ++- .../src/components/search/SearchResults.tsx | 11 ++- .../token-swaps/OrderDetailPage.tsx | 25 +++-- .../transaction/TransactionDetailPage.tsx | 25 +++-- .../src/components/ui/CopyableIdentifier.tsx | 19 +--- .../validator/ValidatorDetailHeader.tsx | 25 +++-- cmd/rpc/web/explorer/src/lib/clipboard.ts | 30 ++++++ cmd/rpc/web/explorer/src/lib/utils.ts | 31 +----- cmd/rpc/web/explorer/tests/clipboard.test.cjs | 97 +++++++++++++++++++ 13 files changed, 223 insertions(+), 92 deletions(-) create mode 100644 .docs/CHANGELOG.md create mode 100644 cmd/rpc/web/explorer/src/lib/clipboard.ts create mode 100644 cmd/rpc/web/explorer/tests/clipboard.test.cjs diff --git a/.docs/CHANGELOG.md b/.docs/CHANGELOG.md new file mode 100644 index 0000000000..7ce9035df6 --- /dev/null +++ b/.docs/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## Unreleased + +### Fixed — 2026-09-16 + +- Fixed explorer copy buttons on HTTP LAN addresses across transaction, block, account, validator, order, search, and identifier table views. Copy failures now show an error instead of success feedback. diff --git a/cmd/rpc/web/explorer/package.json b/cmd/rpc/web/explorer/package.json index 242af38ac6..5157131760 100644 --- a/cmd/rpc/web/explorer/package.json +++ b/cmd/rpc/web/explorer/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test:clipboard": "node --test tests/clipboard.test.cjs", "preview": "vite preview", "type-check": "tsc --noEmit" }, diff --git a/cmd/rpc/web/explorer/src/components/account/AccountDetailHeader.tsx b/cmd/rpc/web/explorer/src/components/account/AccountDetailHeader.tsx index 7fdb8fe73b..4627ccda88 100644 --- a/cmd/rpc/web/explorer/src/components/account/AccountDetailHeader.tsx +++ b/cmd/rpc/web/explorer/src/components/account/AccountDetailHeader.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react' import { motion } from 'framer-motion' import { Check, Copy } from 'lucide-react' +import toast from 'react-hot-toast' +import { copyText } from '../../lib/clipboard' import AnimatedNumber from '../AnimatedNumber' import accountDetailTexts from '../../data/accountDetail.json' import { cnpyDetailFormat, toCNPY } from '../../lib/utils' @@ -37,11 +39,11 @@ const AccountDetailHeader: React.FC = ({ account }) => const copyToClipboard = async () => { try { - await navigator.clipboard.writeText(account.address) + await copyText(account.address) setCopied(true) setTimeout(() => setCopied(false), 2000) - } catch (err) { - console.error('Failed to copy address:', err) + } catch { + toast.error('Unable to copy address') } } diff --git a/cmd/rpc/web/explorer/src/components/block/BlockDetailInfo.tsx b/cmd/rpc/web/explorer/src/components/block/BlockDetailInfo.tsx index 25154133b1..1cbc3d8acf 100644 --- a/cmd/rpc/web/explorer/src/components/block/BlockDetailInfo.tsx +++ b/cmd/rpc/web/explorer/src/components/block/BlockDetailInfo.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react' import { motion } from 'framer-motion' import { Copy } from 'lucide-react' import toast from 'react-hot-toast' +import { copyText } from '../../lib/clipboard' import blockDetailTexts from '../../data/blockDetail.json' import { GREEN_BADGE_CLASS } from '../ui/badgeStyles' import { formatCNPY } from '../../lib/utils' @@ -26,16 +27,20 @@ const CopySymbol = () =>