Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions darepod/rpc_oor_idempotency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,76 @@ func TestSubmittedOORCleanupTimeoutReleasesCustomInput(t *testing.T) {
}, time.Second, 10*time.Millisecond)
}

// TestSubmittedOORCleanupTimeoutReleasesSelectedVTXOs verifies that when the
// detached OOR cleanup waiter times out, the wallet-selected VTXOs are still
// unlocked. The cleanupCtx is expired by the timeout, so the unlock must run on
// a fresh context or the wallet mailbox would reject the already-expired Tell
// and leave the VTXOs pinned.
func TestSubmittedOORCleanupTimeoutReleasesSelectedVTXOs(t *testing.T) {
t.Parallel()

testWallet := &sendOORTestWallet{}

system := actor.NewActorSystem()
t.Cleanup(func() {
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 5*time.Second,
)
defer cancel()

require.NoError(t, system.Shutdown(shutdownCtx))
})

walletKey := actor.NewServiceKey[
wallet.WalletMsg, wallet.WalletResp,
](
"send-oor-vtxo-unlock-test-wallet",
)
walletRef := walletKey.Spawn(
system, "send-oor-vtxo-unlock-test-wallet", testWallet,
)

rpcServer := &RPCServer{
server: &Server{
log: btclog.Disabled,
walletRef: fn.Some(walletRef),
},
customInputLocks: make(map[wire.OutPoint]struct{}),
}

locked := &wallet.SelectAndLockVTXOsResponse{
SelectedVTXOs: []wallet.SelectedVTXO{
{
Outpoint: wire.OutPoint{
Hash: chainhash.HashH(
[]byte("send-oor-vtxo-unlock"),
),
Index: 0,
},
Amount: 1000,
},
},
}

// The promise is never completed, forcing the cleanup waiter down the
// timeout branch where cleanupCtx expires.
promise := actor.NewPromise[oor.ActorResp]()
rpcServer.cleanupSubmittedOORStartWithTimeout(
context.Background(), promise.Future(), locked, nil,
10*time.Millisecond,
)

require.Eventually(t, func() bool {
return len(testWallet.unlockBatches()) == 1
}, time.Second, 10*time.Millisecond)

batches := testWallet.unlockBatches()
require.Len(t, batches, 1)
require.Equal(
t, locked.SelectedVTXOs[0].Outpoint, batches[0][0],
)
}

func TestIsAwaitContextError(t *testing.T) {
t.Parallel()

Expand Down
26 changes: 25 additions & 1 deletion darepod/rpc_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ const (
// from retaining wallet/custom input reservations forever.
submittedOORCleanupTimeout = 10 * time.Minute

// submittedOORUnlockTimeout bounds the fresh context used to unlock
// wallet-selected VTXOs when the detached OOR cleanup waiter itself
// timed out. The cleanupCtx is deliberately expired in that branch, so
// the unlock must run on a new bounded context to avoid the wallet
// mailbox rejecting an already-expired Tell.
submittedOORUnlockTimeout = 30 * time.Second

// maxOORRecipients mirrors the in-round send cap at the daemon
// boundary. The OOR actor has its own package-size limits, but the
// RPC handler resolves scripts and policy templates before handing
Expand Down Expand Up @@ -2818,6 +2825,15 @@ func (r *RPCServer) cleanupSubmittedOORStartWithTimeout(ctx context.Context,
oorResult := future.Await(cleanupCtx)
oorResp, err := oorResult.Unpack()
if err != nil {
// The unlock context defaults to cleanupCtx, which is
// still live on a real actor failure. If the await
// instead ended because cleanupCtx hit its deadline,
// that same context is now expired and the wallet
// actor's mailbox would reject the unlock Tell before
// enqueue, silently pinning the wallet-selected VTXOs.
// Derive a fresh bounded context from the detached base
// in that case so the unlock still lands.
unlockCtx := cleanupCtx
if cleanupCtx.Err() != nil {
r.server.log.ErrorS(
cleanupCtx,
Expand All @@ -2826,9 +2842,17 @@ func (r *RPCServer) cleanupSubmittedOORStartWithTimeout(ctx context.Context,
err,
slog.Duration("timeout", timeout),
)

freshCtx, freshCancel := context.WithTimeout(
context.WithoutCancel(ctx),
submittedOORUnlockTimeout,
)
defer freshCancel()

unlockCtx = freshCtx
}

r.unlockSelectedVTXOsBestEffort(cleanupCtx, locked)
r.unlockSelectedVTXOsBestEffort(unlockCtx, locked)
if releaseCustomInputs != nil {
releaseCustomInputs()
}
Expand Down
Loading