Skip to content

Commit 3e30495

Browse files
committed
fix: verify input VTXOs are spent before breaking poll loop
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.
1 parent 56c9d6f commit 3e30495

File tree

2 files changed

+27
-1
lines changed

2 files changed

+27
-1
lines changed

NArk.Core/Events/PostSpendVtxoPollingHandler.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public async Task HandleAsync(PostCoinsSpendActionEvent @event, CancellationToke
4747

4848

4949
var inputScripts = @event.ArkCoins.Select(c => c.ScriptPubKey.ToHex()).ToHashSet();
50+
var inputOutpoints = @event.ArkCoins.Select(c => c.Outpoint).ToHashSet();
5051
var outputScripts = @event.Psbt.Outputs.Select(o => o.ScriptPubKey.ToHex()).ToHashSet();
5152
outputScripts.Remove("51024e73");
5253

@@ -59,6 +60,9 @@ public async Task HandleAsync(PostCoinsSpendActionEvent @event, CancellationToke
5960
string.Join(", ", outputScripts));
6061

6162
// Retry with backoff — arkd's indexer may not have processed the VTXOs yet
63+
// We must verify that input VTXOs are marked as spent, not just that any VTXO was found.
64+
// Breaking early on `found > 0` caused input VTXOs to remain "unspent" locally
65+
// when arkd returned the new output VTXOs before updating the spent state of inputs.
6266
const int maxAttempts = 5;
6367
for (var attempt = 1; attempt <= maxAttempts; attempt++)
6468
{
@@ -68,7 +72,18 @@ public async Task HandleAsync(PostCoinsSpendActionEvent @event, CancellationToke
6872
attempt, maxAttempts, @event.TransactionId, found);
6973

7074
if (found > 0)
71-
break;
75+
{
76+
// Verify input VTXOs are now marked as spent in local storage
77+
var inputVtxos = await vtxoSyncService.GetVtxosByOutpoints(inputOutpoints, cancellationToken);
78+
var allInputsSpent = inputVtxos.Count > 0 && inputVtxos.All(v => v.IsSpent());
79+
if (allInputsSpent)
80+
break;
81+
82+
logger?.LogInformation(
83+
"PostSpendVtxoPolling: attempt {Attempt}/{Max} for TxId={TxId} — output VTXOs found but {Unspent} input(s) still unspent",
84+
attempt, maxAttempts, @event.TransactionId,
85+
inputVtxos.Count(v => !v.IsSpent()));
86+
}
7287

7388
if (attempt < maxAttempts)
7489
await Task.Delay(delay, cancellationToken);

NArk.Core/Services/VtxoSynchronizationService.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using NArk.Abstractions.Scripts;
55
using NArk.Abstractions.VTXOs;
66
using NArk.Core.Transport;
7+
using NBitcoin;
78

89
namespace NArk.Core.Services;
910

@@ -248,6 +249,16 @@ public async Task<int> PollScriptsForVtxos(IReadOnlySet<string> scripts, Cancell
248249
return count;
249250
}
250251

252+
/// <summary>
253+
/// Retrieves VTXOs from local storage by their outpoints (including spent ones).
254+
/// </summary>
255+
public async Task<IReadOnlyCollection<ArkVtxo>> GetVtxosByOutpoints(
256+
IReadOnlyCollection<OutPoint> outpoints, CancellationToken cancellationToken = default)
257+
{
258+
return await _vtxoStorage.GetVtxos(outpoints: outpoints.ToList(), includeSpent: true,
259+
cancellationToken: cancellationToken);
260+
}
261+
251262
public async ValueTask DisposeAsync()
252263
{
253264
_logger?.LogDebug("Disposing VTXO synchronization service");

0 commit comments

Comments
 (0)