Skip to content

Commit 2a31257

Browse files
committed
cardano-testnet: add nested transaction swap test in Dijkstra
Add `hprop_nested_transaction_swap` ("Nested transaction swap"). It mints 100 TokenA to Alice in Conway and funds Bob, hard forks the cluster into Dijkstra through governance (shared with the "Hard fork to Dijkstra" test), then settles a multi-asset swap with a nested transaction built with `cardano-cli dijkstra transaction sub-transaction`: * Alice's sub-transaction spends her 100 TokenA and pays herself 53 ADA (50 ADA price + the 3 ADA that travelled with the tokens); * Bob's sub-transaction spends 60 ADA and pays himself the 100 TokenA plus 7 ADA change, i.e. exactly 50 ADA; * the batcher's top-level transaction embeds both signed sub-transactions and pays the fee. Each sub-transaction is guarded by and signed with its owner's key only. The proof is read back from `query utxo`: Bob holds the 100 TokenA, Alice gained exactly 50 ADA, Bob lost exactly 50 ADA, the batcher paid the fee. The test needs a cardano-cli with the sub-transaction commands (IntersectMBO/cardano-cli#1453), pointed at via CARDANO_CLI, and cardano-ledger-dijkstra >= 0.4: before IntersectMBO/cardano-ledger#6129, UTxO-HD key fetching through `allInputsTxBodyF` did not load sub-transaction inputs and the ledger rejected the nested transaction with SubBadInputsUTxO.
1 parent 0766d8f commit 2a31257

5 files changed

Lines changed: 391 additions & 38 deletions

File tree

‎cardano-testnet/.changes/20260929_120000_jordan.millar_dijkstra_cluster_start_test.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ pr: 6709
22
kind:
33
- test
44
description: |
5-
Added the `Hard fork to Dijkstra` integration test. It starts a cluster in Conway at protocol version 10, then hard forks it through governance the way mainnet will: a `HardForkInitiation` action to PV 11.0, voted for by the default DReps and SPO and enacted, followed by a chained `HardForkInitiation` action to PV 12.0. It then asserts that `query tip` and the ledger state served over node-to-client both report the Dijkstra era before checking block production and clean shutdown. The generated node configuration now sets `ExperimentalHardForksEnabled: true` for every era: without it the node declares PV11 as its maximum protocol version and rejects its own blocks with `ObsoleteNode` once the ledger reaches PV12.
5+
Added two Dijkstra integration tests. `Hard fork to Dijkstra` starts a cluster in Conway at protocol version 10 and hard forks it through governance the way mainnet will: a `HardForkInitiation` action to PV 11.0, voted for by the default DReps and SPO and enacted, followed by a chained `HardForkInitiation` action to PV 12.0, then asserts that the node reports the Dijkstra era. `Nested transaction swap` additionally mints a native token in Conway and, once in Dijkstra, settles a multi-asset swap with a nested transaction: two independently signed sub-transactions (100 TokenA for 50 ADA) embedded in a top-level transaction signed only by the batcher, checked by reading the resulting UTxO set back from the node. It needs a `cardano-cli` with the `dijkstra transaction sub-transaction` commands (`CARDANO_CLI`). The generated node configuration now sets `ExperimentalHardForksEnabled: true` for every era, since without it the node declares PV11 as its maximum and rejects its own blocks with `ObsoleteNode` once the ledger reaches PV12.

‎cardano-testnet/cardano-testnet.cabal‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ test-suite cardano-testnet-test
222222
Cardano.Testnet.Test.Cli.Transaction.BuildEstimate
223223
Cardano.Testnet.Test.Cli.Transaction.RegisterDeregisterStakeAddress
224224
Cardano.Testnet.Test.Cli.Transaction.WithdrawalReward
225+
Cardano.Testnet.Test.Dijkstra.NestedTransaction
225226
Cardano.Testnet.Test.DumpConfig
226227
Cardano.Testnet.Test.FoldEpochState
227228
Cardano.Testnet.Test.Gov.CommitteeAddNew
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
{-# LANGUAGE DataKinds #-}
2+
{-# LANGUAGE FlexibleContexts #-}
3+
{-# LANGUAGE NamedFieldPuns #-}
4+
{-# LANGUAGE NumericUnderscores #-}
5+
{-# LANGUAGE OverloadedStrings #-}
6+
{-# LANGUAGE ScopedTypeVariables #-}
7+
{-# LANGUAGE TypeApplications #-}
8+
9+
module Cardano.Testnet.Test.Dijkstra.NestedTransaction
10+
( hprop_nested_transaction_swap
11+
) where
12+
13+
import Cardano.Api
14+
15+
import Cardano.Testnet
16+
import Cardano.Testnet.Test.Node.DijkstraEra (assertNodeInDijkstra, hardForkToDijkstra)
17+
18+
import Prelude
19+
20+
import Control.Monad
21+
import Control.Monad.Catch (MonadCatch)
22+
import qualified Data.Aeson as A
23+
import qualified Data.Aeson.Key as K
24+
import qualified Data.Aeson.KeyMap as KM
25+
import qualified Data.Aeson.Types as A
26+
import qualified Data.ByteString.Lazy.Char8 as B
27+
import Data.Default.Class (def)
28+
import Data.List (find, sortOn)
29+
import qualified Data.List.NonEmpty as NEL
30+
import Data.Ord (Down (..))
31+
import qualified Data.Text as Text
32+
import GHC.Stack
33+
import System.Exit (ExitCode (..))
34+
import System.FilePath ((</>))
35+
36+
import Testnet.Components.Query
37+
import Testnet.Defaults (simpleScript)
38+
import Testnet.Process.Run (execCli', execCliAny, mkExecConfig)
39+
import Testnet.Property.Util (integrationRetryWorkspace)
40+
import Testnet.Start.Types
41+
import Testnet.Types
42+
43+
import Hedgehog (Property)
44+
import qualified Hedgehog as H
45+
import qualified Hedgehog.Extras as H
46+
import Hedgehog.Internal.Property (MonadTest)
47+
48+
-- | A multi-asset swap settled with a Dijkstra nested transaction.
49+
--
50+
-- Setup (Conway): the batcher wallet mints 100 @TokenA@ under a @sig@ simple script
51+
-- and pays them to Alice, and pays Bob a 60 ADA UTxO. The cluster is then hard forked
52+
-- into Dijkstra through governance.
53+
--
54+
-- Swap (Dijkstra): Alice offers her 100 @TokenA@ for 50 ADA, i.e. 0.5 ADA per token.
55+
--
56+
-- * Alice's sub-transaction spends her token UTxO and pays herself 53 ADA (the 50 ADA
57+
-- price plus the 3 ADA that accompanied the tokens). It is guarded by, and signed
58+
-- with, Alice's key only.
59+
-- * Bob's sub-transaction spends his 60 ADA UTxO and pays himself the 100 @TokenA@
60+
-- (with 3 ADA) plus 7 ADA change, so he pays exactly 50 ADA. It is guarded by, and
61+
-- signed with, Bob's key only.
62+
-- * The batcher's top-level transaction embeds both signed sub-transactions, adds its
63+
-- own input to pay the fee, and is signed by the batcher only.
64+
--
65+
-- Neither sub-transaction balances on its own; the ledger only accepts the top-level
66+
-- transaction because value is conserved across the batch. So if the transaction is
67+
-- accepted, the exchange rate was honoured. The test then checks the resulting UTxO
68+
-- set: Bob holds the 100 @TokenA@, Alice holds 50 ADA more, Bob 50 ADA less, and the
69+
-- batcher paid the fee.
70+
--
71+
-- Requires a @cardano-cli@ with the @dijkstra transaction sub-transaction@ commands
72+
-- (IntersectMBO/cardano-cli#1453); point @CARDANO_CLI@ at it.
73+
--
74+
-- Needs cardano-ledger-dijkstra >= 0.4: before that, UTxO-HD key fetching through
75+
-- @allInputsTxBodyF@ did not load sub-transaction inputs and the ledger rejected the
76+
-- nested transaction with @SubBadInputsUTxO@ (IntersectMBO/cardano-ledger#6129).
77+
--
78+
-- Execute me with:
79+
-- @DISABLE_RETRIES=1 cabal test cardano-testnet-test --test-options '-p "/Nested transaction swap/"'@
80+
hprop_nested_transaction_swap :: Property
81+
hprop_nested_transaction_swap = integrationRetryWorkspace 2 "nested-transaction-swap" $ \tempAbsBasePath' -> H.runWithDefaultWatchdog_ $ do
82+
conf@Conf { tempAbsPath } <- mkConf tempAbsBasePath'
83+
let tempAbsPath' = unTmpAbsPath tempAbsPath
84+
tempBaseAbsPath = makeTmpBaseAbsPath tempAbsPath
85+
86+
work <- H.createDirectoryIfMissing $ tempAbsPath' </> "work"
87+
88+
let creationOptions = def
89+
{ creationEra = AnyShelleyBasedEra ShelleyBasedEraConway
90+
, creationGenesisOptions = def
91+
{ genesisEpochLength = 300
92+
, genesisActiveSlotsCoeff = 0.3
93+
}
94+
}
95+
96+
TestnetRuntime
97+
{ testnetMagic
98+
, testnetNodes
99+
, wallets = batcher:alice:bob:_
100+
, configurationFile
101+
} <- createAndRunTestnet creationOptions def conf
102+
103+
let node@TestnetNode{nodeSprocket} = NEL.head testnetNodes
104+
socketPath = nodeSocketPath node
105+
execConfig <- mkExecConfig tempBaseAbsPath nodeSprocket testnetMagic
106+
epochStateView <- getEpochStateView configurationFile socketPath
107+
108+
let batcherAddr = Text.unpack $ paymentKeyInfoAddr batcher
109+
aliceAddr = Text.unpack $ paymentKeyInfoAddr alice
110+
bobAddr = Text.unpack $ paymentKeyInfoAddr bob
111+
112+
------------------------------------------------------------------------------
113+
H.note_ "Conway: mint 100 TokenA to Alice and fund Bob with a 60 ADA UTxO"
114+
------------------------------------------------------------------------------
115+
setupDir <- H.createDirectoryIfMissing $ work </> "setup"
116+
117+
batcherKeyHash <- keyHash execConfig (paymentKeyInfoPair batcher)
118+
mintingScriptFp <- H.note $ setupDir </> "tokenA-policy.json"
119+
H.writeFile mintingScriptFp $ Text.unpack $ simpleScript $ Text.pack batcherKeyHash
120+
policyId <- filter (/= '\n') <$> execCli' execConfig
121+
[ "conway", "transaction", "policyid", "--script-file", mintingScriptFp ]
122+
let assetName = "546f6b656e41" -- "TokenA", lowercase hex as the CLI renders it in query output
123+
tokenA n = show @Integer n <> " " <> policyId <> "." <> assetName
124+
125+
setupTxIn <- findLargestUtxoForPaymentKey epochStateView ShelleyBasedEraConway batcher
126+
setupBodyFp <- H.note $ setupDir </> "setup.txbody"
127+
setupTxFp <- H.note $ setupDir </> "setup.tx"
128+
void $ execCli' execConfig
129+
[ "conway", "transaction", "build"
130+
, "--change-address", batcherAddr
131+
, "--tx-in", Text.unpack $ renderTxIn setupTxIn
132+
, "--mint", tokenA 100
133+
, "--mint-script-file", mintingScriptFp
134+
, "--tx-out", aliceAddr <> "+" <> show @Integer tokensAda <> "+" <> tokenA 100
135+
, "--tx-out", bobAddr <> "+" <> show @Integer bobFunds
136+
, "--out-file", setupBodyFp
137+
]
138+
void $ execCli' execConfig
139+
[ "conway", "transaction", "sign"
140+
, "--tx-body-file", setupBodyFp
141+
, "--signing-key-file", signingKeyFp $ paymentKeyInfoPair batcher
142+
, "--out-file", setupTxFp
143+
]
144+
void $ execCli' execConfig [ "conway", "transaction", "submit", "--tx-file", setupTxFp ]
145+
_ <- waitForBlocks epochStateView 2
146+
147+
aliceUtxosBefore <- queryUtxos execConfig policyId assetName aliceAddr
148+
H.assertWith aliceUtxosBefore $ any ((== 100) . ueTokens)
149+
150+
------------------------------------------------------------------------------
151+
H.note_ "Hard fork into Dijkstra through governance"
152+
------------------------------------------------------------------------------
153+
hardForkToDijkstra execConfig epochStateView tempAbsPath work batcher alice
154+
assertNodeInDijkstra execConfig
155+
156+
------------------------------------------------------------------------------
157+
H.note_ "Dijkstra: settle the swap with a nested transaction"
158+
------------------------------------------------------------------------------
159+
-- The sub-transaction commands only exist in cardano-cli with IntersectMBO/cardano-cli#1453.
160+
-- Fail with a pointer rather than an option-parser error if the CLI in use lacks them.
161+
subTxHelp <- execCliAny execConfig [ "dijkstra", "transaction", "sub-transaction", "--help" ]
162+
case subTxHelp of
163+
(ExitSuccess, _, _) -> pure ()
164+
_ -> H.failMessage callStack $ unlines
165+
[ "The cardano-cli in use has no `dijkstra transaction sub-transaction` commands."
166+
, "Point CARDANO_CLI at a build of IntersectMBO/cardano-cli#1453 (branch jordan/basic-sub-tx-integration)."
167+
]
168+
swapDir <- H.createDirectoryIfMissing $ work </> "swap"
169+
170+
aliceBefore <- queryUtxos execConfig policyId assetName aliceAddr
171+
bobBefore <- queryUtxos execConfig policyId assetName bobAddr
172+
batcherBefore <- queryUtxos execConfig policyId assetName batcherAddr
173+
174+
aliceTokenUtxo <- H.nothingFail $ find ((== 100) . ueTokens) aliceBefore
175+
bobAdaUtxo <- H.nothingFail $ find (\u -> ueLovelace u == bobFunds && ueTokens u == 0) bobBefore
176+
batcherUtxo <- H.nothingFail $ listToMaybeHead $ sortOn (Down . ueLovelace) batcherBefore
177+
178+
aliceKeyHash <- keyHash execConfig (paymentKeyInfoPair alice)
179+
bobKeyHash <- keyHash execConfig (paymentKeyInfoPair bob)
180+
181+
H.note_ "Alice's sub-transaction: 100 TokenA out, 50 ADA in (0.5 ADA per token)"
182+
aliceSubUnsigned <- H.note $ swapDir </> "alice.sub.unsigned"
183+
aliceSubSigned <- H.note $ swapDir </> "alice.sub.signed"
184+
void $ execCli' execConfig
185+
[ "dijkstra", "transaction", "sub-transaction", "build-raw"
186+
, "--tx-in", ueTxIn aliceTokenUtxo
187+
, "--tx-out", aliceAddr <> "+" <> show @Integer (tokensAda + price)
188+
, "--guard-key-hash", aliceKeyHash
189+
, "--out-file", aliceSubUnsigned
190+
]
191+
void $ execCli' execConfig
192+
[ "dijkstra", "transaction", "sub-transaction", "sign"
193+
, "--sub-tx-file", aliceSubUnsigned
194+
, "--signing-key-file", signingKeyFp $ paymentKeyInfoPair alice
195+
, "--out-file", aliceSubSigned
196+
]
197+
198+
H.note_ "Bob's sub-transaction: 50 ADA out, 100 TokenA in"
199+
bobSubUnsigned <- H.note $ swapDir </> "bob.sub.unsigned"
200+
bobSubSigned <- H.note $ swapDir </> "bob.sub.signed"
201+
void $ execCli' execConfig
202+
[ "dijkstra", "transaction", "sub-transaction", "build-raw"
203+
, "--tx-in", ueTxIn bobAdaUtxo
204+
, "--tx-out", bobAddr <> "+" <> show @Integer tokensAda <> "+" <> tokenA 100
205+
, "--tx-out", bobAddr <> "+" <> show @Integer (bobFunds - price - tokensAda)
206+
, "--guard-key-hash", bobKeyHash
207+
, "--out-file", bobSubUnsigned
208+
]
209+
void $ execCli' execConfig
210+
[ "dijkstra", "transaction", "sub-transaction", "sign"
211+
, "--sub-tx-file", bobSubUnsigned
212+
, "--signing-key-file", signingKeyFp $ paymentKeyInfoPair bob
213+
, "--out-file", bobSubSigned
214+
]
215+
216+
H.note_ "Batcher's top-level transaction embedding both sub-transactions and paying the fee"
217+
topBodyFp <- H.note $ swapDir </> "swap.txbody"
218+
topTxFp <- H.note $ swapDir </> "swap.tx"
219+
void $ execCli' execConfig
220+
[ "dijkstra", "transaction", "build-raw"
221+
, "--tx-in", ueTxIn batcherUtxo
222+
, "--tx-out", batcherAddr <> "+" <> show @Integer (ueLovelace batcherUtxo - fee)
223+
, "--fee", show @Integer fee
224+
, "--sub-transaction", aliceSubSigned
225+
, "--sub-transaction", bobSubSigned
226+
, "--out-file", topBodyFp
227+
]
228+
void $ execCli' execConfig
229+
[ "dijkstra", "transaction", "sign"
230+
, "--tx-body-file", topBodyFp
231+
, "--signing-key-file", signingKeyFp $ paymentKeyInfoPair batcher
232+
, "--testnet-magic", show testnetMagic
233+
, "--out-file", topTxFp
234+
]
235+
H.noteM_ $ execCli' execConfig [ "dijkstra", "transaction", "submit", "--tx-file", topTxFp ]
236+
_ <- waitForBlocks epochStateView 2
237+
238+
------------------------------------------------------------------------------
239+
H.note_ "Proof: Bob holds the 100 TokenA and 50 ADA moved from Bob to Alice"
240+
------------------------------------------------------------------------------
241+
aliceAfter <- queryUtxos execConfig policyId assetName aliceAddr
242+
bobAfter <- queryUtxos execConfig policyId assetName bobAddr
243+
batcherAfter <- queryUtxos execConfig policyId assetName batcherAddr
244+
245+
H.note_ "Bob received all the TokenA"
246+
totalTokens bobAfter H.=== 100
247+
totalTokens aliceAfter H.=== 0
248+
249+
H.note_ "Alice was paid the asking price and Bob paid it"
250+
totalLovelace aliceAfter H.=== totalLovelace aliceBefore + price
251+
totalLovelace bobAfter H.=== totalLovelace bobBefore - price
252+
253+
H.note_ "the batcher paid only the fee"
254+
totalLovelace batcherAfter H.=== totalLovelace batcherBefore - fee
255+
where
256+
bobFunds, tokensAda, price, fee :: Integer
257+
bobFunds = 60_000_000 -- Bob's UTxO to pay from
258+
tokensAda = 3_000_000 -- ADA travelling with the tokens (covers the min-UTxO value)
259+
price = 50_000_000 -- 100 TokenA at 0.5 ADA each
260+
fee = 1_000_000 -- paid by the batcher; comfortably above the minimum fee
261+
262+
listToMaybeHead :: [a] -> Maybe a
263+
listToMaybeHead (x:_) = Just x
264+
listToMaybeHead [] = Nothing
265+
266+
-- | One entry of @cardano-cli query utxo --output-json@, reduced to what the swap cares about.
267+
data UtxoEntry = UtxoEntry
268+
{ ueTxIn :: String
269+
, ueLovelace :: Integer
270+
, ueTokens :: Integer -- ^ quantity of the swap's token in this UTxO
271+
} deriving Show
272+
273+
totalLovelace, totalTokens :: [UtxoEntry] -> Integer
274+
totalLovelace = sum . map ueLovelace
275+
totalTokens = sum . map ueTokens
276+
277+
-- | Query the UTxOs at an address and extract lovelace and the given asset's quantity.
278+
queryUtxos
279+
:: (HasCallStack, MonadTest m, MonadIO m, MonadCatch m)
280+
=> H.ExecConfig
281+
-> String -- ^ Policy id of the asset to track
282+
-> String -- ^ Hex-encoded asset name
283+
-> String -- ^ Address
284+
-> m [UtxoEntry]
285+
queryUtxos execConfig policyId assetName addr = withFrozenCallStack $ do
286+
out <- execCli' execConfig [ "query", "utxo", "--address", addr, "--output-json" ]
287+
utxoJson <- H.leftFail $ A.eitherDecode $ B.pack out
288+
H.leftFail $ A.parseEither (utxoEntries policyId assetName) utxoJson
289+
290+
utxoEntries :: String -> String -> A.Value -> A.Parser [UtxoEntry]
291+
utxoEntries policyId assetName = A.withObject "utxo set" $ \o ->
292+
forM (KM.toList o) $ \(txIn, entry) ->
293+
flip (A.withObject "utxo entry") entry $ \e -> do
294+
value <- e A..: "value"
295+
lovelace <- value A..: "lovelace"
296+
mPolicy <- value A..:? K.fromString policyId
297+
tokens <- case mPolicy of
298+
Nothing -> pure 0
299+
Just policy -> policy A..:? K.fromString assetName A..!= 0
300+
pure UtxoEntry { ueTxIn = K.toString txIn, ueLovelace = lovelace, ueTokens = tokens }
301+
302+
-- | Hash of a payment verification key, as accepted by @--guard-key-hash@.
303+
keyHash
304+
:: (HasCallStack, MonadTest m, MonadIO m, MonadCatch m)
305+
=> H.ExecConfig
306+
-> KeyPair PaymentKey
307+
-> m String
308+
keyHash execConfig keys = withFrozenCallStack $
309+
filter (/= '\n') <$> execCli' execConfig
310+
[ "address", "key-hash", "--payment-verification-key-file", verificationKeyFp keys ]

0 commit comments

Comments
 (0)