fix: verify input VTXOs are spent before breaking poll loop#35
Conversation
PostSpendVtxoPollingHandler broke out of the retry loop as soon as any VTXO was returned (found > 0). When arkd returned the new output VTXOs before marking the input VTXOs as spent, the handler exited early and the input VTXOs remained "unspent" in local storage. Now the handler checks that all input VTXOs have SpentByTransactionId set before breaking the loop, ensuring the local state accurately reflects the spend.
The handler now verifies input VTXOs are marked as spent before breaking the poll loop. Update the test mock to return a spent input VTXO from local storage so the retry loop exits on the first attempt.
Inject IVtxoStorage into PostSpendVtxoPollingHandler and use its GetVtxos(outpoints:, includeSpent:) filter directly instead of going through a wrapper on VtxoSynchronizationService. Removes the GetVtxosByOutpoints helper that was only used here.
…d polling Instead of querying local IVtxoStorage for spent state, query arkd directly via the new GetVtxosByOutpoints transport method with spent_only=true. This avoids relying on local storage which may lag behind arkd's indexer.
|
[Arkana Review — dotnet-sdk PR #35: fix: verify input VTXOs are spent before breaking poll loop] Reviewed at: 2026-03-14 14:00 UTC SummaryFixes a race condition in What looks goodCorrect diagnosis: The bug is real — local storage upsert can complete before arkd's indexer processes the spend event, causing the loop to exit prematurely and leaving the wallet in an inconsistent state. Fix approach: Querying arkd with Chunked pagination in
Logging improvement: The new log line at "inputs spent on arkd" gives operators visibility into the retry loop progress. Questions / potential issues1. Using 2. Pagination assumption in The pagination loop 3. Cancellation token threading
4. In the new 5. When Nits
VerdictSolid fix for a real correctness issue. The spent-status check via arkd transport is the right approach. Points 2 and 5 are the most important to confirm before merge. — Arkana |
🔍 Arkana Review —
|
There was a problem hiding this comment.
🔍 Arkana Review — arkade-os/dotnet-sdk#35
Summary: Fixes a race condition where PostSpendVtxoPollingHandler broke out of the retry loop as soon as any VTXO was found (found > 0), without verifying that input VTXOs were actually marked as spent by arkd's indexer. This caused swept VTXOs to remain "unspent" in local storage after auto-sweep.
✅ What looks good
-
Correct diagnosis. The old code broke on
found > 0— which only meant some script matched, not that the spend was indexed. Verifying spent status of inputs viatransport.GetVtxosByOutpoints(..., spentOnly: true)is the right fix. -
New
GetVtxosByOutpointsAPI. Clean addition toIClientTransportwith implementations in both gRPC and REST transports. The chunking (1000 per batch) and pagination logic mirrors the existingGetVtxoByScriptsAsSnapshotpattern — consistent. -
Dependency change. Swapping
IContractStorage→IClientTransportin the handler constructor is correct — the handler no longer needs local contract storage, it queries arkd directly for spent state. This removes a layer of indirection that was the source of the bug. -
Test coverage. Updated
VtxoPollingHandlerTestsproperly mocks the newGetVtxosByOutpointscall and verifies the two-phase check (scripts first, then spent verification).
⚠️ Minor observations
-
Partial spent detection. If only some inputs are marked spent (e.g.,
spentCount < inputOutpoints.Count), the handler continues retrying, which is correct. But if arkd never marks all inputs as spent within 5 attempts (e.g., indexer bug), we silently exit the loop. The existing logging handles this adequately for debugging, but worth noting. -
gRPC request fields. In
GrpcClientTransport.Vtxo.cs, the new request setsSpendableOnly = false,RecoverableOnly = false,PendingOnly = false,Before = 0,After = 0. These defaults look correct for a raw outpoint lookup with spent-only filter. -
REST transport query string. Using
HttpUtility.ParseQueryStringand adding multipleoutpointskeys for the same parameter is correct for repeated query params in .NET — just confirming the arkd REST API accepts this format (it should per standard HTTP query encoding).
🔒 Security
- No key material handling changes.
- No new attack surface — the transport call is read-only (GET on indexer).
- Fixes a correctness issue that could lead to double-spend vectors if local storage showed VTXOs as unspent when they were actually spent on-chain.
Verdict: Clean, well-scoped fix. LGTM.
Summary
found > 0), even if the input VTXOs weren't yet marked as spent by arkd's indexerSpentByTransactionIdset before breaking the poll loopGetVtxosByOutpointshelper toVtxoSynchronizationServicefor the spent-state checkTest plan