Skip to content

Commit e21238b

Browse files
authored
Enable deposit recovery from any state (#2217)
<!-- Describe your change here --> 🔑 **Motivation** > Closes #1812 Pending deposits are currently only tracked in the `Open` state. This prevents clients from recovering funds through their running node once the head transitions into later states such as `Closed` or after `Fanout`. As a result, users are forced to build and submit recovery transactions manually, which is error-prone and inconvenient. To improve usability, deposit recovery should be possible through the API regardless of the current state of the Hydra head. 🔄 **Breaking Changes** * `HeadState` no longer tracks pending deposits or the current chain slot. These are now part of the newly introduced node-level `NodeState`. This refactor also opens the door to moving `ChainState` out of `HeadState`, as noted in [this TODO](https://github.com/cardano-scaling/hydra/blob/3dffe727ea31d908e4516f37447385fe50051578/hydra-node/src/Hydra/HeadLogic/State.hs#L39). * The `Checkpoint` event, and consequently the `EventLogRotated` server output, now carry the full `NodeState` instead of just the `HeadState`. This enables clients (e.g. the TUI) to fully recover after event-log rotation. 📝 **Notes** * Deposits are still only **recorded, activated, and expired** while the head is `Open`, so recovery is limited to deposits observed in previously opened heads. * `HeadLogic` now aggregates events in two phases: 1. Head-level 2. Node-level > _This introduces a dependency between the two update steps: the correctness of the node-level state update relies on the head-level state update. Any bug or inconsistency in the head-level update will cascade into the node-level view._ --- <!-- Consider each and tick it off one way or the other --> * [x] CHANGELOG updated or not needed * [x] Documentation updated or not needed * [x] Haddocks updated or not needed * [x] No new TODOs introduced or explained herafter
2 parents e100bf2 + 6c07a8b commit e21238b

27 files changed

+7856
-2748
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ changes.
3939

4040
- Label threads, queues and vars.
4141

42+
- **BREAKING** Enable handling client recover in all head states.
43+
- See [Issue #1812](https://github.com/cardano-scaling/hydra/issues/1812) and [PR #2217](https://github.com/cardano-scaling/hydra/pull/2217).
44+
4245
## [0.22.4] - 2025-08-05
4346

4447
- Fix API not correctly handling event log rotation. This was evident in not

hydra-cluster/src/Hydra/Cluster/Scenarios.hs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,6 +1515,129 @@ canRecoverDeposit tracer workDir backend hydraScriptsTxId =
15151515
where
15161516
hydraTracer = contramap FromHydraNode tracer
15171517

1518+
-- | Open a single-participant head, perform 3 deposits, and then:
1519+
-- 1. Close the head and recover deposit #1
1520+
-- 2. Fanout the head and recover deposit #2
1521+
-- 3. Open a new head and recover deposit #3
1522+
canRecoverDepositInAnyState :: ChainBackend backend => Tracer IO EndToEndLog -> FilePath -> backend -> [TxId] -> IO ()
1523+
canRecoverDepositInAnyState tracer workDir backend hydraScriptsTxId =
1524+
(`finally` returnFundsToFaucet tracer backend Alice) $ do
1525+
refuelIfNeeded tracer backend Alice 30_000_000
1526+
-- NOTE: Directly expire deposits
1527+
contestationPeriod <- CP.fromNominalDiffTime 2
1528+
blockTime <- Backend.getBlockTime backend
1529+
let depositPeriod = 1
1530+
networkId <- Backend.queryNetworkId backend
1531+
aliceChainConfig <-
1532+
chainConfigFor Alice workDir backend hydraScriptsTxId [] contestationPeriod
1533+
<&> setNetworkId networkId . modifyConfig (\c -> c{depositPeriod})
1534+
withHydraNode hydraTracer aliceChainConfig workDir 1 aliceSk [] [1] $ \n1 -> do
1535+
-- Init the head
1536+
send n1 $ input "Init" []
1537+
headId <- waitMatch 10 n1 $ headIsInitializingWith (Set.fromList [alice])
1538+
1539+
-- Commit nothing
1540+
requestCommitTx n1 mempty >>= Backend.submitTransaction backend
1541+
1542+
waitFor hydraTracer (20 * blockTime) [n1] $
1543+
output "HeadIsOpen" ["utxo" .= object mempty, "headId" .= headId]
1544+
1545+
-- Get some L1 funds
1546+
(walletVk, walletSk) <- generate genKeyPair
1547+
let commitAmount = 5_000_000
1548+
commitUTxO1 <- seedFromFaucet backend walletVk commitAmount (contramap FromFaucet tracer)
1549+
commitUTxO2 <- seedFromFaucet backend walletVk commitAmount (contramap FromFaucet tracer)
1550+
commitUTxO3 <- seedFromFaucet backend walletVk commitAmount (contramap FromFaucet tracer)
1551+
1552+
queryWalletBalance walletVk `shouldReturn` lovelaceToValue (commitAmount * 3)
1553+
1554+
-- Increment commit #1
1555+
depositReceipt1 <- increment n1 walletSk commitUTxO1
1556+
queryWalletBalance walletVk `shouldReturn` lovelaceToValue (commitAmount * 2)
1557+
1558+
-- Increment commit #2
1559+
depositReceipt2 <- increment n1 walletSk commitUTxO2
1560+
queryWalletBalance walletVk `shouldReturn` lovelaceToValue commitAmount
1561+
1562+
-- Increment commit #3
1563+
depositReceipt3 <- increment n1 walletSk commitUTxO3
1564+
selectLovelace <$> queryWalletBalance walletVk `shouldReturn` 0
1565+
1566+
-- 1. Close the head
1567+
send n1 $ input "Close" []
1568+
1569+
contestationDeadline <- waitMatch (10 * blockTime) n1 $ \v -> do
1570+
guard $ v ^? key "tag" == Just "HeadIsClosed"
1571+
v ^? key "contestationDeadline" . _JSON
1572+
1573+
-- Recover deposit #1
1574+
recover n1 depositReceipt1 commitUTxO1
1575+
queryWalletBalance walletVk `shouldReturn` balance commitUTxO1
1576+
1577+
-- 2. Fanout the head
1578+
remainingTime <- diffUTCTime contestationDeadline <$> getCurrentTime
1579+
waitFor hydraTracer (remainingTime + 3 * blockTime) [n1] $
1580+
output "ReadyToFanout" ["headId" .= headId]
1581+
send n1 $ input "Fanout" []
1582+
waitMatch (20 * blockTime) n1 $ \v ->
1583+
guard $ v ^? key "tag" == Just "HeadIsFinalized"
1584+
1585+
-- Recover deposit #2
1586+
recover n1 depositReceipt2 commitUTxO2
1587+
queryWalletBalance walletVk `shouldReturn` balance (commitUTxO1 <> commitUTxO2)
1588+
1589+
-- 3. Open a new head
1590+
send n1 $ input "Init" []
1591+
headId2 <- waitMatch 10 n1 $ headIsInitializingWith (Set.fromList [alice])
1592+
1593+
-- Commit nothing
1594+
requestCommitTx n1 mempty >>= Backend.submitTransaction backend
1595+
1596+
waitFor hydraTracer (20 * blockTime) [n1] $
1597+
output "HeadIsOpen" ["utxo" .= object mempty, "headId" .= headId2]
1598+
1599+
-- Recover deposit #3
1600+
recover n1 depositReceipt3 commitUTxO3
1601+
queryWalletBalance walletVk `shouldReturn` balance (commitUTxO1 <> commitUTxO2 <> commitUTxO3)
1602+
where
1603+
hydraTracer = contramap FromHydraNode tracer
1604+
1605+
queryWalletBalance walletVk =
1606+
balance <$> Backend.queryUTxOFor backend QueryTip walletVk
1607+
1608+
increment :: HydraClient -> SigningKey PaymentKey -> UTxO -> IO (TxId, UTCTime)
1609+
increment n walletSk commitUTxO = do
1610+
depositTransaction <-
1611+
parseUrlThrow ("POST " <> hydraNodeBaseUrl n <> "/commit")
1612+
<&> setRequestBodyJSON commitUTxO
1613+
>>= httpJSON
1614+
<&> getResponseBody
1615+
1616+
let tx = signTx walletSk depositTransaction
1617+
Backend.submitTransaction backend tx
1618+
1619+
deadline <- waitMatch 10 n $ \v -> do
1620+
guard $ v ^? key "tag" == Just "CommitRecorded"
1621+
v ^? key "deadline" >>= parseMaybe parseJSON
1622+
1623+
pure (getTxId $ getTxBody tx, deadline)
1624+
1625+
recover :: HydraClient -> (TxId, UTCTime) -> UTxO -> IO ()
1626+
recover n (depositId, deadline) commitUTxO = do
1627+
-- NOTE: we need to wait for the deadline to pass before we can recover the deposit
1628+
diff <- realToFrac . diffUTCTime deadline <$> getCurrentTime
1629+
threadDelay $ diff + 1
1630+
1631+
let path = BSC.unpack $ urlEncode False $ encodeUtf8 $ T.pack $ show depositId
1632+
(`shouldReturn` "OK") $
1633+
parseUrlThrow ("DELETE " <> hydraNodeBaseUrl n <> "/commits/" <> path)
1634+
>>= httpJSON
1635+
<&> getResponseBody @String
1636+
1637+
waitMatch 20 n $ \v -> do
1638+
guard $ v ^? key "tag" == Just "CommitRecovered"
1639+
guard $ v ^? key "recoveredUTxO" == Just (toJSON commitUTxO)
1640+
15181641
-- | Make sure to be able to see pending deposits.
15191642
canSeePendingDeposits :: ChainBackend backend => Tracer IO EndToEndLog -> FilePath -> NominalDiffTime -> backend -> [TxId] -> IO ()
15201643
canSeePendingDeposits tracer workDir blockTime backend hydraScriptsTxId =

hydra-cluster/test/Test/EndToEndSpec.hs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import Hydra.Cluster.Scenarios (
5454
canDecommit,
5555
canDepositPartially,
5656
canRecoverDeposit,
57+
canRecoverDepositInAnyState,
5758
canResumeOnMemberAlreadyBootstrapped,
5859
canSeePendingDeposits,
5960
canSideLoadSnapshot,
@@ -311,6 +312,11 @@ spec = around (showLogsOnFailure "EndToEndSpec") $ do
311312
withBackend (contramap FromCardanoNode tracer) tmpDir $ \_ backend -> do
312313
publishHydraScriptsAs backend Faucet
313314
>>= canRecoverDeposit tracer tmpDir backend
315+
it "can recover deposit in any state" $ \tracer -> do
316+
withClusterTempDir $ \tmpDir -> do
317+
withBackend (contramap FromCardanoNode tracer) tmpDir $ \_ backend -> do
318+
publishHydraScriptsAs backend Faucet
319+
>>= canRecoverDepositInAnyState tracer tmpDir backend
314320
it "can see pending deposits" $ \tracer -> do
315321
withClusterTempDir $ \tmpDir -> do
316322
withBackend (contramap FromCardanoNode tracer) tmpDir $ \blockTime backend -> do

0 commit comments

Comments
 (0)