Problem
A normal client refresh/join-round path can fail after an Ark operator key rotation. This is separate from the swap-specific stale operator-key issue in #514 and the swap quote binding race in #519.
Observed log from a manual refresh followed by JoinNextRound:
2026-05-22 16:14:58.888 [INF] ROND: temp(c3bb96e6) State transition from_state=Idle to_state=PendingRoundAssembly
2026-05-22 16:14:58.888 [INF] ROND: Registered intent package forfeits=1 vtxos=1 leaves=0
2026-05-22 16:14:58.888 [INF] ARKW: Registered refresh intent package forfeits=1 vtxos=1 errors=0
2026-05-22 16:14:58.888 [INF] DRPD: VTXOs queued for refresh queued_count=1 error_count=0
2026-05-22 16:14:58.888 [INF] ROND: temp(c3bb96e6) Registration requested, preparing to join round boarding_intent_count=0 vtxo_intent_count=1
2026-05-22 16:14:58.889 [INF] ROND: temp(c3bb96e6) Intent balance check passed total_input="0.00001000 BTC" total_output="0.00001000 BTC" estimated_operator_fee="0 BTC"
2026-05-22 16:14:58.889 [INF] ROND: temp(c3bb96e6) Sending JoinRoundRequest to server boarding_requests=0 vtxo_requests=1 forfeit_requests=1 leave_requests=0
2026-05-22 16:14:58.902 [INF] ROND: temp(c3bb96e6) Building join round auth boarding_intent_count=0 vtxo_request_count=1 forfeit_request_count=1 leave_request_count=0
2026-05-22 16:14:58.904 [INF] ROND: temp(c3bb96e6) Built join round auth proof_input_count=1 message_len=288 signature_len=342 valid_from_block=305525 valid_until_block=305669
2026-05-22 16:14:58.904 [INF] ROND: temp(c3bb96e6) State transition from_state=PendingRoundAssembly to_state=IntentSent
2026-05-22 16:14:58.908 [INF] DRPD: JoinNextRound accepted
2026-05-22 16:14:59.256 [INF] ROND: temp(c3bb96e6) State transition from_state=IntentSent to_state="ClientFailed: join request invalid: operator key does not match: got 024f08048a96d562fc6271aef61dfd0be72953ee60764692547279293a941b3c8a, want 023c444b1d06e4608309dcd3947914f6b489c9582776bc0f07c91290825b4d7283"
The client successfully queues the refresh intent and emits a JoinRoundRequest, but the operator rejects it because the request contains VTXO/forfeit material tied to an old operator key.
Likely cause
Normal VTXO descriptors and refresh requests persist/carry the operator key that was used when the VTXO was created. After an operator key rotation, refresh/join-round logic may reuse that stored key when building the refresh output, forfeit proof, or join auth request, while the operator validates against its current key.
Relevant client-side areas:
darepod/rpc_server.go: RefreshVTXOs loads operator terms and builds wallet.RefreshVTXOsRequest.
wallet/wallet.go: handleRefreshVTXOs registers refresh intents and logs Registered refresh intent package.
round/actor.go: buildVTXORequestFromRefresh and handleRefreshVTXORequest build the round VTXO request from the refresh intent.
round/join_auth.go: join auth material includes VTXO operator keys and is sent with JoinRoundRequest.
db/vtxo_store.go / db/round_store.go: VTXO/round descriptors persist operator_pubkey / operator_key.
Desired behavior
A client with spendable VTXOs should have a deterministic recovery path after an operator key rotation. At minimum:
- Refresh should not silently construct an invalid
JoinRoundRequest that the operator rejects with operator key does not match.
- The client should either refresh/rebuild the new output side with the current operator key while preserving the historical key needed to spend the old input, or fail early with an actionable rotation-specific error before queueing a doomed round intent.
- Any old-key material that is still required to spend the existing VTXO must remain tied to the VTXO being spent; only newly-created outputs should use the active operator key.
Acceptance criteria
- Add a regression test that simulates a VTXO created with operator key
K1, then rotates the live operator key to K2, then attempts RefreshVTXOs + JoinNextRound.
- The test should prove the client no longer emits a join request that fails with
operator key does not match.
- New VTXO outputs created by refresh use the current operator key
K2.
- Historical spend/forfeit material for the old VTXO still uses the key committed to that VTXO (
K1) where required by the original script.
- The user-facing state/logging should make the recovery behavior clear if automatic refresh across rotation is not possible.
Problem
A normal client refresh/join-round path can fail after an Ark operator key rotation. This is separate from the swap-specific stale operator-key issue in #514 and the swap quote binding race in #519.
Observed log from a manual refresh followed by
JoinNextRound:The client successfully queues the refresh intent and emits a
JoinRoundRequest, but the operator rejects it because the request contains VTXO/forfeit material tied to an old operator key.Likely cause
Normal VTXO descriptors and refresh requests persist/carry the operator key that was used when the VTXO was created. After an operator key rotation, refresh/join-round logic may reuse that stored key when building the refresh output, forfeit proof, or join auth request, while the operator validates against its current key.
Relevant client-side areas:
darepod/rpc_server.go:RefreshVTXOsloads operator terms and buildswallet.RefreshVTXOsRequest.wallet/wallet.go:handleRefreshVTXOsregisters refresh intents and logsRegistered refresh intent package.round/actor.go:buildVTXORequestFromRefreshandhandleRefreshVTXORequestbuild the round VTXO request from the refresh intent.round/join_auth.go: join auth material includes VTXO operator keys and is sent withJoinRoundRequest.db/vtxo_store.go/db/round_store.go: VTXO/round descriptors persistoperator_pubkey/operator_key.Desired behavior
A client with spendable VTXOs should have a deterministic recovery path after an operator key rotation. At minimum:
JoinRoundRequestthat the operator rejects withoperator key does not match.Acceptance criteria
K1, then rotates the live operator key toK2, then attemptsRefreshVTXOs+JoinNextRound.operator key does not match.K2.K1) where required by the original script.