Skip to content

client: refresh/join round can fail after operator key rotation #520

Description

@sputn1ck

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions