diff --git a/test-refactor/README.md b/test-refactor/README.md
index 88e068270..48c7d452f 100644
--- a/test-refactor/README.md
+++ b/test-refactor/README.md
@@ -84,6 +84,7 @@ Translated tests:
| `wh_test_crypto.c::whTest_Crypto` | `client-server/wh_test_crypto_{aes,cmac,curve25519,ecc,ed25519,kdf,keypolicy,mldsa,rng,rsa,sha}.c::whTest_Crypto_*` | Client | Split into per-algorithm suites; key revocation is gated by `WOLFHSM_CFG_TEST_ALLOW_PERSISTENT_NVM_ARTIFACTS`. Legacy ran FLASH and FLASH_LOG backends; the port runs the plain flash backend only -- FLASH_LOG re-run pending (see Known coverage gaps) |
| `wh_test_crypto.c::whTest_CryptoKeyUsagePolicies` (AES CTR/ECB/GCM subset) | `client-server/wh_test_crypto_aes.c::whTest_CryptoAesKeyUsagePolicies` | Client | AES-CTR/ECB/GCM key usage enforcement (non-DMA and DMA variants) |
| `wh_test_clientserver.c` (echo and server-info paths) | `client-server/wh_test_echo.c::whTest_Echo`, `client-server/wh_test_server_info.c::whTest_ServerInfo` | Client | pthread test ported, sequential test dropped |
+| `wh_test_clientserver.c::_testClientCounter` | `client-server/wh_test_counter.c::whTest_Counter` | Client | NVM counter API (reset/init/increment/read/destroy, saturation, slot reuse). Config-agnostic: the counter messages do not use the cryptocb, so it runs unchanged under the DMA and non-DMA builds. The legacy global-NVM-empty check is replaced by an object census bracketing the test, so it is robust to the shared server holding other tests' objects |
| `wh_test_wolfcrypt_test.c::whTest_WolfCryptTest` | `client-server/wh_test_wolfcrypt.c::whTest_WolfCryptTest` | Client | |
| `wh_test_flash_ramsim.c::whTest_Flash_RamSim` | `posix/wh_test_flash_ramsim.c::{whTest_FlashWriteLock, whTest_FlashEraseProgramVerify, whTest_FlashUnitOps}` | POSIX port-specific (`whTestGroup_RunOne`) | remove ramsim coupling and migrate to server group |
| `wh_test_nvm_flash.c::{whTest_NvmFlash, whTest_NvmFlash_Recovery}` | `posix/wh_test_nvm_flash.c::{whTest_NvmAddOverwriteDestroy, whTest_NvmFlashLog, whTest_NvmRecovery}` | POSIX port-specific (`whTestGroup_RunOne`) | remove ramsim coupling and migrate to server group; flash-log backend exercised by `whTest_NvmFlashLog` (skipped unless `WOLFHSM_CFG_SERVER_NVM_FLASH_LOG`) |
@@ -99,7 +100,7 @@ Not yet migrated (still live in `wolfHSM/test/`):
| Legacy (`wolfHSM/test/`) | Notes |
|---|---|
| `wh_test_comm.c::whTest_Comm` | Pthread mem/tcp/shmem variants only; sequential mem variant has been ported |
-| `wh_test_clientserver.c::whTest_ClientServer` | Pthread variant: remaining client-side coverage (NVM ops, etc.) still needs to be split out as new tests. The sequential test is dropped |
+| `wh_test_clientserver.c::whTest_ClientServer` | Pthread variant: remaining client-side coverage (NVM object add/read/list/destroy, including the DMA slice) still needs to be split out as new tests. The counter API is ported (see `whTest_Counter`); the sequential test is dropped |
| `wh_test_crypto.c::whTest_Crypto` | Remaining crypto coverage not yet split out: the AES async family (comm-buffer `whTest_CryptoAesAsync`/`AesAsyncKat` + DMA `whTest_CryptoAesDmaAsync`/`AesDmaAsyncKat`, round-trip & KAT). ECC DMA export-public and the ML-DSA wolfCrypt-API path are now migrated. |
| `wh_test_crypto.c::whTest_KeyCache`, `whTest_NonExportableKeystore` | Keystore tests (key-cache lifecycle and non-exportable-flag enforcement) dispatched from the legacy `whTest_Crypto`. The per-algorithm suites use `wh_Client_KeyCache`, but these dedicated keystore tests are not yet split out. |
| `wh_test_crypto_affinity.c::whTest_CryptoAffinity` | |
diff --git a/test-refactor/client-server/wh_test_counter.c b/test-refactor/client-server/wh_test_counter.c
new file mode 100644
index 000000000..d67fa0cd9
--- /dev/null
+++ b/test-refactor/client-server/wh_test_counter.c
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2026 wolfSSL Inc.
+ *
+ * This file is part of wolfHSM.
+ *
+ * wolfHSM is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * wolfHSM is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with wolfHSM. If not, see .
+ */
+/*
+ * test-refactor/client-server/wh_test_counter.c
+ *
+ * NVM monotonic counter round-trips routed through the server. Covers
+ * reset/init/increment/read/destroy, increment saturation at the uint32_t
+ * max, and slot reuse across more counters than the NVM directory holds.
+ *
+ * The counter API is carried by dedicated counter messages, not the
+ * cryptocb, so it is identical across the DMA and non-DMA builds. The
+ * WOLFHSM_CFG_DMA build option is covered by compiling and running this
+ * test under that configuration, not by toggling the client DMA mode.
+ */
+
+#include
+
+#include "wolfhsm/wh_settings.h"
+#include "wolfhsm/wh_error.h"
+#include "wolfhsm/wh_common.h"
+#include "wolfhsm/wh_client.h"
+
+#include "wh_test_common.h"
+#include "wh_test_list.h"
+
+/* Increment well past the NVM directory size to confirm a single counter
+ * reuses one slot rather than leaking an object per increment. */
+#define WH_TEST_COUNTER_INCREMENTS (2 * WOLFHSM_CFG_NVM_OBJECT_COUNT)
+
+/* Reads the current count of free NVM objects, failing on a server error. */
+static int _whTest_CounterAvailObjects(whClientContext* ctx,
+ whNvmId* outAvailObjects)
+{
+ int32_t serverRc = 0;
+ uint32_t availSize = 0;
+ uint32_t reclaimSize = 0;
+ whNvmId availObjects = 0;
+ whNvmId reclaimObjects = 0;
+
+ WH_TEST_RETURN_ON_FAIL(wh_Client_NvmGetAvailable(
+ ctx, &serverRc, &availSize, &availObjects, &reclaimSize,
+ &reclaimObjects));
+ WH_TEST_ASSERT_RETURN(serverRc == WH_ERROR_OK);
+
+ *outAvailObjects = availObjects;
+ return WH_ERROR_OK;
+}
+
+/* Reset, increment past the directory size, and saturate a single counter. */
+static int _whTest_CounterIncrement(whClientContext* ctx)
+{
+ const whNvmId counterId = 1;
+ const uint32_t maxCounterVal = 0xFFFFFFFF;
+ size_t i = 0;
+ uint32_t counter = 0;
+
+ /* A fresh counter starts at zero. */
+ WH_TEST_RETURN_ON_FAIL(wh_Client_CounterReset(ctx, counterId, &counter));
+ WH_TEST_ASSERT_RETURN(counter == 0);
+
+ /* Each increment advances by one and read-back matches, with no object
+ * leak across more increments than the directory could hold. */
+ for (i = 0; i < WH_TEST_COUNTER_INCREMENTS; i++) {
+ WH_TEST_RETURN_ON_FAIL(
+ wh_Client_CounterIncrement(ctx, counterId, &counter));
+ WH_TEST_ASSERT_RETURN(counter == i + 1);
+
+ WH_TEST_RETURN_ON_FAIL(
+ wh_Client_CounterRead(ctx, counterId, &counter));
+ WH_TEST_ASSERT_RETURN(counter == i + 1);
+ }
+
+ WH_TEST_RETURN_ON_FAIL(wh_Client_CounterReset(ctx, counterId, &counter));
+ WH_TEST_ASSERT_RETURN(counter == 0);
+
+ /* Init near the max and confirm increments saturate instead of rolling. */
+ counter = maxCounterVal;
+ WH_TEST_RETURN_ON_FAIL(wh_Client_CounterInit(ctx, counterId, &counter));
+ WH_TEST_ASSERT_RETURN(counter == maxCounterVal);
+
+ WH_TEST_RETURN_ON_FAIL(
+ wh_Client_CounterIncrement(ctx, counterId, &counter));
+ WH_TEST_ASSERT_RETURN(counter == maxCounterVal);
+
+ WH_TEST_RETURN_ON_FAIL(
+ wh_Client_CounterIncrement(ctx, counterId, &counter));
+ WH_TEST_ASSERT_RETURN(counter == maxCounterVal);
+
+ WH_TEST_RETURN_ON_FAIL(wh_Client_CounterRead(ctx, counterId, &counter));
+ WH_TEST_ASSERT_RETURN(counter == maxCounterVal);
+
+ WH_TEST_RETURN_ON_FAIL(wh_Client_CounterReset(ctx, counterId, &counter));
+ WH_TEST_ASSERT_RETURN(counter == 0);
+
+ WH_TEST_RETURN_ON_FAIL(wh_Client_CounterDestroy(ctx, counterId));
+
+ return WH_ERROR_OK;
+}
+
+/* Create and destroy counters across many ids, confirming destroy frees the
+ * slot and a destroyed counter can no longer be read. */
+static int _whTest_CounterDestroy(whClientContext* ctx)
+{
+ size_t i = 0;
+ uint32_t counter = 0;
+
+ for (i = 1; i < WH_TEST_COUNTER_INCREMENTS; i++) {
+ WH_TEST_RETURN_ON_FAIL(
+ wh_Client_CounterReset(ctx, (whNvmId)i, &counter));
+ WH_TEST_ASSERT_RETURN(counter == 0);
+
+ WH_TEST_RETURN_ON_FAIL(wh_Client_CounterDestroy(ctx, (whNvmId)i));
+
+ /* A destroyed counter must not be readable. */
+ WH_TEST_ASSERT_RETURN(
+ WH_ERROR_NOTFOUND ==
+ wh_Client_CounterRead(ctx, (whNvmId)i, &counter));
+ }
+
+ return WH_ERROR_OK;
+}
+
+/*
+ * NVM counter API test. Brackets the sub-tests with an NVM object census so
+ * the no-leak guarantee holds regardless of any objects other tests left
+ * behind in the shared server.
+ */
+int whTest_Counter(whClientContext* ctx)
+{
+ whNvmId baselineObjects = 0;
+ whNvmId finalObjects = 0;
+
+ WH_TEST_PRINT("Testing NVM counters...\n");
+
+ WH_TEST_RETURN_ON_FAIL(
+ _whTest_CounterAvailObjects(ctx, &baselineObjects));
+
+ WH_TEST_RETURN_ON_FAIL(_whTest_CounterIncrement(ctx));
+ WH_TEST_RETURN_ON_FAIL(_whTest_CounterDestroy(ctx));
+
+ /* Reset and destroy must not leak NVM objects. */
+ WH_TEST_RETURN_ON_FAIL(_whTest_CounterAvailObjects(ctx, &finalObjects));
+ WH_TEST_ASSERT_RETURN(finalObjects == baselineObjects);
+
+ return WH_ERROR_OK;
+}
diff --git a/test-refactor/wh_test_list.c b/test-refactor/wh_test_list.c
index 109536f2b..e3e9cfe58 100644
--- a/test-refactor/wh_test_list.c
+++ b/test-refactor/wh_test_list.c
@@ -67,6 +67,7 @@ WH_TEST_DECL(whTest_CryptoSha256);
WH_TEST_DECL(whTest_She);
WH_TEST_DECL(whTest_SheMasterEcuKeyFallback);
WH_TEST_DECL(whTest_SheReqSizeChecking);
+WH_TEST_DECL(whTest_Counter);
WH_TEST_DECL(whTest_Echo);
WH_TEST_DECL(whTest_ServerInfo);
WH_TEST_DECL(whTest_WolfCryptTest);
@@ -118,6 +119,7 @@ const whTestCase whTestsClient[] = {
{ "whTest_CryptoRsaBufferTooSmall", whTest_CryptoRsaBufferTooSmall },
{ "whTest_CryptoSha256", whTest_CryptoSha256 },
{ "whTest_She", whTest_She },
+ { "whTest_Counter", whTest_Counter },
{ "whTest_Echo", whTest_Echo },
{ "whTest_ServerInfo", whTest_ServerInfo },
{ "whTest_WolfCryptTest", whTest_WolfCryptTest },