Skip to content

Commit 7fda03e

Browse files
authored
Reject too low deposits (#2106)
fix #1902 Following deposit workflow leads to failing deposits: - User requests a decommit providing the UTxO with less than `minADAUTxO` value - Deposit utxo get's recorded (with this small value) - Hydra node autobalances the transaction so the locked UTxO has increased value but otherwise looks the same - When trying to post increment transaction we experience `H4` because of value mismatch between the UTxO we recorded and used for posting an increment and what was actually locked in the deposit script As a simple workaround we want to calculate `minADAUTxO` when receiving the deposit UTxO and reject the request in case the provided value is _too small_. --- <!-- 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 a93a04f + 8f85a64 commit 7fda03e

File tree

14 files changed

+163
-19
lines changed

14 files changed

+163
-19
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ changes.
1616

1717
- Bugfix for incorrect logic around fanning out with decommit/commit in progress
1818

19+
- Hydra node now rejects requests for incremental commits if provided UTxO is below the limit.
20+
1921
## [0.22.2] - 2025.06.30
2022

2123
* Fix wrong hydra-script-tx-ids in networks.json

hydra-cardano-api/src/Hydra/Cardano/Api/Value.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ containsValue a b =
2222
positive (_, q) = q >= 0
2323

2424
-- | Calculate minimum ada as 'Value' for a 'TxOut'.
25+
-- NOTE: This function can throw although you can't tell from the signature.
26+
-- 'toLedgerValue' can error out with _Illegal Value in TxOut_
2527
minUTxOValue ::
2628
PParams LedgerEra ->
2729
TxOut CtxTx Era ->

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

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import Hydra.Cardano.Api (
9191
pattern TxOut,
9292
pattern TxOutDatumNone,
9393
)
94+
import Hydra.Chain (PostTxError (..))
9495
import Hydra.Chain.Backend (ChainBackend, buildTransaction, buildTransactionWithPParams, buildTransactionWithPParams')
9596
import Hydra.Chain.Backend qualified as Backend
9697
import Hydra.Cluster.Faucet (FaucetLog, createOutputAtAddress, seedFromFaucet, seedFromFaucet_)
@@ -150,7 +151,7 @@ import Network.HTTP.Types (urlEncode)
150151
import System.FilePath ((</>))
151152
import System.Process (callProcess)
152153
import Test.Hydra.Tx.Fixture (testNetworkId)
153-
import Test.Hydra.Tx.Gen (genKeyPair)
154+
import Test.Hydra.Tx.Gen (genDatum, genKeyPair, genTxOutWithReferenceScript)
154155
import Test.QuickCheck (choose, elements, generate)
155156

156157
data EndToEndLog
@@ -1292,6 +1293,54 @@ canCommit tracer workDir blockTime backend hydraScriptsTxId =
12921293

12931294
hydraNodeBaseUrl HydraClient{hydraNodeId} = "http://127.0.0.1:" <> show (4000 + hydraNodeId)
12941295

1296+
rejectCommit :: ChainBackend backend => Tracer IO EndToEndLog -> FilePath -> NominalDiffTime -> backend -> [TxId] -> IO ()
1297+
rejectCommit tracer workDir blockTime backend hydraScriptsTxId =
1298+
(`finally` returnFundsToFaucet tracer backend Alice) $ do
1299+
refuelIfNeeded tracer backend Alice 30_000_000
1300+
-- NOTE: Adapt periods to block times
1301+
let contestationPeriod = truncate $ 10 * blockTime
1302+
depositPeriod = truncate $ 100 * blockTime
1303+
networkId <- Backend.queryNetworkId backend
1304+
aliceChainConfig <-
1305+
chainConfigFor Alice workDir backend hydraScriptsTxId [] contestationPeriod
1306+
<&> setNetworkId networkId . modifyConfig (\c -> c{depositPeriod})
1307+
1308+
let pparamsDecorator = atKey "utxoCostPerByte" ?~ toJSON (Aeson.Number 4310)
1309+
optionsWithUTxOCostPerByte <- prepareHydraNode aliceChainConfig workDir 1 aliceSk [] [] pparamsDecorator
1310+
1311+
withPreparedHydraNode hydraTracer workDir 1 optionsWithUTxOCostPerByte $ \n1 -> do
1312+
send n1 $ input "Init" []
1313+
headId <- waitMatch (10 * blockTime) n1 $ headIsInitializingWith (Set.fromList [alice])
1314+
1315+
-- Commit nothing
1316+
requestCommitTx n1 mempty >>= Backend.submitTransaction backend
1317+
waitFor hydraTracer (20 * blockTime) [n1] $
1318+
output "HeadIsOpen" ["utxo" .= object mempty, "headId" .= headId]
1319+
1320+
-- Get some L1 funds
1321+
(walletVk, _) <- generate genKeyPair
1322+
commitUTxO' <- seedFromFaucet backend walletVk 1_000_000 (contramap FromFaucet tracer)
1323+
TxOut _ _ _ refScript <- generate genTxOutWithReferenceScript
1324+
datum <- generate genDatum
1325+
let commitUTxO :: UTxO.UTxO =
1326+
UTxO.fromList $
1327+
(\(i, TxOut addr _ _ _) -> (i, TxOut addr (lovelaceToValue 0) datum refScript))
1328+
<$> UTxO.toList commitUTxO'
1329+
response <-
1330+
L.parseRequest ("POST " <> hydraNodeBaseUrl n1 <> "/commit")
1331+
<&> setRequestBodyJSON (commitUTxO :: UTxO.UTxO)
1332+
>>= httpJSON
1333+
1334+
let expectedError = getResponseBody response :: PostTxError Tx
1335+
1336+
expectedError `shouldSatisfy` \case
1337+
DepositTooLow{minimumValue, providedValue} -> providedValue < minimumValue
1338+
_ -> False
1339+
where
1340+
hydraTracer = contramap FromHydraNode tracer
1341+
1342+
hydraNodeBaseUrl HydraClient{hydraNodeId} = "http://127.0.0.1:" <> show (4000 + hydraNodeId)
1343+
12951344
-- | Open a a single participant head, deposit and then recover it.
12961345
canRecoverDeposit :: ChainBackend backend => Tracer IO EndToEndLog -> FilePath -> backend -> [TxId] -> IO ()
12971346
canRecoverDeposit tracer workDir backend hydraScriptsTxId =

hydra-cluster/src/HydraNode.hs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,14 +357,15 @@ preparePParams chainConfig stateDir paramsDecorator = do
357357
prj <- Blockfrost.projectFromFile projectPath
358358
toJSON <$> Blockfrost.runBlockfrostM prj Blockfrost.queryProtocolParameters
359359
Aeson.encodeFile cardanoLedgerProtocolParametersFile $
360-
paramsDecorator protocolParameters
360+
protocolParameters
361361
& atKey "txFeeFixed" ?~ toJSON (Number 0)
362362
& atKey "txFeePerByte" ?~ toJSON (Number 0)
363363
& key "executionUnitPrices" . atKey "priceMemory" ?~ toJSON (Number 0)
364364
& key "executionUnitPrices" . atKey "priceSteps" ?~ toJSON (Number 0)
365365
& atKey "utxoCostPerByte" ?~ toJSON (Number 0)
366366
& atKey "treasuryCut" ?~ toJSON (Number 0)
367367
& atKey "minFeeRefScriptCostPerByte" ?~ toJSON (Number 0)
368+
& paramsDecorator
368369
pure cardanoLedgerProtocolParametersFile
369370

370371
-- | Prepare 'RunOptions' to run a hydra-node with given 'ChainConfig' and using the config from

hydra-cluster/test/Test/EndToEndSpec.hs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import Hydra.Cluster.Scenarios (
6363
oneOfThreeNodesStopsForAWhile,
6464
persistenceCanLoadWithEmptyCommit,
6565
refuelIfNeeded,
66+
rejectCommit,
6667
restartedNodeCanAbort,
6768
restartedNodeCanObserveCommitTx,
6869
singlePartyCommitsFromExternal,
@@ -294,6 +295,11 @@ spec = around (showLogsOnFailure "EndToEndSpec") $ do
294295
withBackend (contramap FromCardanoNode tracer) tmpDir $ \blockTime backend -> do
295296
publishHydraScriptsAs backend Faucet
296297
>>= canCommit tracer tmpDir blockTime backend
298+
it "reject commits with too low value" $ \tracer -> do
299+
withClusterTempDir $ \tmpDir -> do
300+
withBackend (contramap FromCardanoNode tracer) tmpDir $ \blockTime backend -> do
301+
publishHydraScriptsAs backend Faucet
302+
>>= rejectCommit tracer tmpDir blockTime backend
297303
it "can recover deposit" $ \tracer -> do
298304
withClusterTempDir $ \tmpDir -> do
299305
withBackend (contramap FromCardanoNode tracer) tmpDir $ \_ backend -> do

hydra-node/json-schemas/api.yaml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ channels:
118118
- $ref: "api.yaml#/components/messages/Contest"
119119
- $ref: "api.yaml#/components/messages/Fanout"
120120
- $ref: "api.yaml#/components/messages/SideLoadSnapshot"
121-
121+
122122
/head:
123123
servers:
124124
- localhost-http
@@ -2605,6 +2605,25 @@ components:
26052605
tag:
26062606
type: string
26072607
enum: ["FailedToConstructFanoutTx"]
2608+
- title: DepositTooLow
2609+
description: |
2610+
Raised if the user requests a deposit using too small UTxO.
2611+
type: object
2612+
additionalProperties: false
2613+
required:
2614+
- tag
2615+
- minimumValue
2616+
- providedValue
2617+
properties:
2618+
tag:
2619+
type: string
2620+
enum: ["DepositTooLow"]
2621+
minimumValue:
2622+
type: integer
2623+
minimum: 0
2624+
providedValue:
2625+
type: integer
2626+
minimum: 0
26082627

26092628
Signature:
26102629
type: string

hydra-node/src/Hydra/API/HTTPServer.hs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ httpApp tracer directChain env pparams getHeadState getCommitInfo getPendingDepo
189189
>>= respond
190190
("POST", ["commit"]) ->
191191
consumeRequestBodyStrict request
192-
>>= handleDraftCommitUtxo env directChain getCommitInfo
192+
>>= handleDraftCommitUtxo env pparams directChain getCommitInfo
193193
>>= respond
194194
("DELETE", ["commits", _]) ->
195195
consumeRequestBodyStrict request
@@ -219,13 +219,14 @@ handleDraftCommitUtxo ::
219219
forall tx.
220220
IsChainState tx =>
221221
Environment ->
222+
PParams LedgerEra ->
222223
Chain tx IO ->
223224
-- | A means to get commit info.
224225
IO CommitInfo ->
225226
-- | Request body.
226227
LBS.ByteString ->
227228
IO Response
228-
handleDraftCommitUtxo env directChain getCommitInfo body = do
229+
handleDraftCommitUtxo env pparams directChain getCommitInfo body = do
229230
case Aeson.eitherDecode' body :: Either String (DraftCommitTxRequest tx) of
230231
Left err ->
231232
pure $ responseLBS status400 jsonContent (Aeson.encode $ Aeson.String $ pack err)
@@ -251,7 +252,7 @@ handleDraftCommitUtxo env directChain getCommitInfo body = do
251252
-- increment because a deposit only activates after one deposit period and
252253
-- expires one deposit period before deadline.
253254
deadline <- addUTCTime (3 * toNominalDiffTime depositPeriod) <$> getCurrentTime
254-
draftDepositTx headId commitBlueprint deadline <&> \case
255+
draftDepositTx headId pparams commitBlueprint deadline <&> \case
255256
Left e -> responseLBS status400 jsonContent (Aeson.encode $ toJSON e)
256257
Right depositTx -> okJSON $ DraftCommitTxResponse depositTx
257258

@@ -264,6 +265,7 @@ handleDraftCommitUtxo env directChain getCommitInfo body = do
264265
CommittedTooMuchADAForMainnet _ _ -> badRequest e
265266
UnsupportedLegacyOutput _ -> badRequest e
266267
CannotFindOwnInitial _ -> badRequest e
268+
DepositTooLow _ _ -> badRequest e
267269
_ -> responseLBS status500 [] (Aeson.encode $ toJSON e)
268270
Right commitTx ->
269271
okJSON $ DraftCommitTxResponse commitTx

hydra-node/src/Hydra/Chain.hs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ module Hydra.Chain where
1313

1414
import Hydra.Prelude
1515

16+
import Cardano.Ledger.Core (PParams)
1617
import Data.List.NonEmpty ((<|))
1718
import Hydra.Cardano.Api (
1819
Address,
1920
ByronAddr,
2021
Coin (..),
22+
LedgerEra,
2123
)
2224
import Hydra.Chain.ChainState (ChainSlot, IsChainState (..))
2325
import Hydra.Tx (
@@ -200,6 +202,7 @@ data PostTxError tx
200202
| FailedToConstructIncrementTx {failureReason :: Text}
201203
| FailedToConstructDecrementTx {failureReason :: Text}
202204
| FailedToConstructFanoutTx
205+
| DepositTooLow {providedValue :: Coin, minimumValue :: Coin}
203206
deriving stock (Generic)
204207

205208
deriving stock instance IsChainState tx => Eq (PostTxError tx)
@@ -268,6 +271,7 @@ data Chain tx m = Chain
268271
, draftDepositTx ::
269272
MonadThrow m =>
270273
HeadId ->
274+
PParams LedgerEra ->
271275
CommitBlueprintTx tx ->
272276
UTCTime ->
273277
m (Either (PostTxError tx) tx)

hydra-node/src/Hydra/Chain/Direct/Handlers.hs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,25 @@ module Hydra.Chain.Direct.Handlers where
1010
import Hydra.Prelude
1111

1212
import Cardano.Api.UTxO qualified as UTxO
13+
import Cardano.Ledger.Core (PParams)
1314
import Cardano.Slotting.Slot (SlotNo (..))
1415
import Control.Concurrent.Class.MonadSTM (modifyTVar, newTVarIO, writeTVar)
1516
import Control.Monad.Class.MonadSTM (throwSTM)
17+
import Data.List qualified as List
1618
import Hydra.Cardano.Api (
1719
BlockHeader,
1820
ChainPoint (..),
21+
LedgerEra,
1922
Tx,
2023
TxId,
24+
calculateMinimumUTxO,
2125
chainPointToSlotNo,
26+
fromCtxUTxOTxOut,
2227
getChainPoint,
2328
getTxBody,
2429
getTxId,
30+
liftEither,
31+
shelleyBasedEra,
2532
throwError,
2633
)
2734
import Hydra.Chain (
@@ -69,6 +76,7 @@ import Hydra.Logging (Tracer, traceWith)
6976
import Hydra.Tx (
7077
CommitBlueprintTx (..),
7178
HeadParameters (..),
79+
IsTx (..),
7280
UTxOType,
7381
headSeedToTxIn,
7482
)
@@ -171,12 +179,13 @@ mkChain tracer queryTimeHandle wallet ctx LocalChainState{getLatest} submitTx =
171179
let CommitBlueprintTx{lookupUTxO} = commitBlueprintTx
172180
traverse (finalizeTx wallet ctx spendableUTxO lookupUTxO) $
173181
commit' ctx headId spendableUTxO commitBlueprintTx
174-
, draftDepositTx = \headId commitBlueprintTx deadline -> do
182+
, draftDepositTx = \headId pparams commitBlueprintTx deadline -> do
175183
let CommitBlueprintTx{lookupUTxO} = commitBlueprintTx
176184
ChainStateAt{spendableUTxO} <- atomically getLatest
177185
TimeHandle{currentPointInTime} <- queryTimeHandle
178186
-- XXX: What an error handling mess
179187
runExceptT $ do
188+
liftEither $ rejectLowDeposits pparams lookupUTxO
180189
(currentSlot, currentTime) <- case currentPointInTime of
181190
Left failureReason -> throwError FailedToConstructDepositTx{failureReason}
182191
Right (s, t) -> pure (s, t)
@@ -195,6 +204,25 @@ mkChain tracer queryTimeHandle wallet ctx LocalChainState{getLatest} submitTx =
195204
submitTx
196205
}
197206

207+
-- Check each UTxO entry against the minADAUTxO value.
208+
-- Throws 'DepositTooLow' exception.
209+
rejectLowDeposits :: PParams LedgerEra -> UTxO.UTxO -> Either (PostTxError Tx) ()
210+
rejectLowDeposits pparams utxo = do
211+
let insAndOuts = UTxO.toList utxo
212+
let providedValues = (\(i, o) -> (i, UTxO.totalLovelace $ UTxO.singleton i o)) <$> insAndOuts
213+
let minimumValues = (\(i, o) -> (i, calculateMinimumUTxO shelleyBasedEra pparams $ fromCtxUTxOTxOut o)) <$> insAndOuts
214+
let results =
215+
( \(i, minVal) ->
216+
case List.find (\(ix, providedVal) -> i == ix && providedVal < minVal) providedValues of
217+
Nothing -> Right ()
218+
Just (_, tooLowValue) ->
219+
Left (DepositTooLow{providedValue = tooLowValue, minimumValue = minVal} :: PostTxError Tx)
220+
)
221+
<$> minimumValues
222+
case lefts results of
223+
[] -> pure ()
224+
(e : _) -> Left e
225+
198226
-- | Balance and sign the given partial transaction.
199227
finalizeTx ::
200228
MonadThrow m =>

hydra-node/src/Hydra/Chain/Offline.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ withOfflineChain config party otherParties chainStateHistory callback action = d
8686
{ mkChainState = initialChainState
8787
, submitTx = const $ pure ()
8888
, draftCommitTx = \_ _ -> pure $ Left FailedToDraftTxNotInitializing
89-
, draftDepositTx = \_ _ _ -> pure $ Left FailedToConstructDepositTx{failureReason = "not implemented"}
89+
, draftDepositTx = \_ _ _ _ -> pure $ Left FailedToConstructDepositTx{failureReason = "not implemented"}
9090
, postTx = const $ pure ()
9191
}
9292

0 commit comments

Comments
 (0)