From 8ee97a98a690ed469be349d40e920a8fa03b5369 Mon Sep 17 00:00:00 2001 From: Jordan Millar Date: Thu, 15 Oct 2026 12:51:35 -0400 Subject: [PATCH 1/2] Expose direct ADA deposits in Dijkstra transaction builders --- .../20261015_cip159_account_build_flags.yml | 5 + cardano-cli/cardano-cli.cabal | 1 + .../src/Cardano/CLI/EraBased/Common/Option.hs | 16 ++ .../CLI/EraBased/Transaction/Command.hs | 6 + .../EraBased/Transaction/Internal/Common.hs | 12 ++ .../CLI/EraBased/Transaction/Option.hs | 3 + .../Cardano/CLI/EraBased/Transaction/Run.hs | 19 ++ .../Transaction/SubTransaction/Command.hs | 2 + .../Transaction/SubTransaction/Option.hs | 1 + .../Transaction/SubTransaction/Run.hs | 11 +- .../src/Cardano/CLI/Type/Error/TxCmdError.hs | 5 + .../cardano-cli-golden/files/golden/help.cli | 4 + .../dijkstra_sub-transaction_build-raw.cli | 4 + .../dijkstra_transaction_build-estimate.cli | 4 + .../help/dijkstra_transaction_build-raw.cli | 4 + .../help/dijkstra_transaction_build.cli | 4 + .../Test/Cli/Transaction/DirectDeposit.hs | 179 ++++++++++++++++++ 17 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 .changes/20261015_cip159_account_build_flags.yml create mode 100644 cardano-cli/test/cardano-cli-test/Test/Cli/Transaction/DirectDeposit.hs diff --git a/.changes/20261015_cip159_account_build_flags.yml b/.changes/20261015_cip159_account_build_flags.yml new file mode 100644 index 0000000000..3f366d2a3f --- /dev/null +++ b/.changes/20261015_cip159_account_build_flags.yml @@ -0,0 +1,5 @@ +project: cardano-cli +pr: 1469 +kind: + - feature +description: Add --direct-deposit STAKE_ADDRESS+LOVELACE to Dijkstra transaction build, build-raw and build-estimate, and sub-transaction build-raw, to deposit ADA into registered accounts. Reject duplicate recipients and include deposits in automatic fee balancing. diff --git a/cardano-cli/cardano-cli.cabal b/cardano-cli/cardano-cli.cabal index a8d709cb36..eba01f4547 100644 --- a/cardano-cli/cardano-cli.cabal +++ b/cardano-cli/cardano-cli.cabal @@ -431,6 +431,7 @@ test-suite cardano-cli-test Test.Cli.Run.Query Test.Cli.Shelley.Genesis.Create Test.Cli.Transaction.Build + Test.Cli.Transaction.DirectDeposit Test.Cli.Transaction.SubTransaction Test.Cli.VerificationKey diff --git a/cardano-cli/src/Cardano/CLI/EraBased/Common/Option.hs b/cardano-cli/src/Cardano/CLI/EraBased/Common/Option.hs index a77acdd5f3..55f1c55975 100644 --- a/cardano-cli/src/Cardano/CLI/EraBased/Common/Option.hs +++ b/cardano-cli/src/Cardano/CLI/EraBased/Common/Option.hs @@ -1500,6 +1500,22 @@ pMetadataFile = File <$> parseFilePath "metadata-cbor-file" "Filepath of the metadata, in raw CBOR format." ] +-- | Parse repeatable @--direct-deposit STAKE_ADDRESS+LOVELACE@ options for +-- ADA deposits into registered accounts. Available only in Dijkstra. +pDirectDeposits :: Exp.Era era -> Parser [(StakeAddress, Lovelace)] +pDirectDeposits era = case era of + Exp.ConwayEra -> pure [] + Exp.DijkstraEra -> + many $ + Opt.option (readerFromParsecParser parseDeposit) $ + Opt.long "direct-deposit" + <> Opt.metavar "STAKE_ADDRESS+LOVELACE" + <> Opt.help + "Deposit ADA into a registered account. Amount in lovelace; each account may occur once." + where + parseDeposit :: P.Parser (StakeAddress, Lovelace) + parseDeposit = (,) <$> parseAddressAny <* P.char '+' <*> parseLovelace + pWithdrawalForEra :: Exp.Era era -> BalanceTxExecUnits diff --git a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Command.hs b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Command.hs index 4265fa8af1..3978fcb57c 100644 --- a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Command.hs +++ b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Command.hs @@ -84,6 +84,8 @@ data TransactionBuildRawCmdArgs era = TransactionBuildRawCmdArgs , certificates :: ![(CertificateFile, Maybe AnyNonAssetScript)] -- ^ Certificates with potential script witness , withdrawals :: ![(StakeAddress, Coin, Maybe AnyNonAssetScript)] + , directDeposits :: ![(StakeAddress, Coin)] + -- ^ Direct ADA deposits as recipient stake addresses and amounts in lovelace. , metadataSchema :: !TxMetadataJsonSchema , scriptFiles :: ![ScriptFile] -- ^ Auxiliary scripts @@ -150,6 +152,8 @@ data TransactionBuildCmdArgs era = TransactionBuildCmdArgs -- ^ Certificates with potential script witness , withdrawals :: ![(StakeAddress, Coin, Maybe AnyNonAssetScript)] -- ^ Withdrawals with potential script witness + , directDeposits :: ![(StakeAddress, Coin)] + -- ^ Direct ADA deposits as recipient stake addresses and amounts in lovelace. , metadataSchema :: !TxMetadataJsonSchema , scriptFiles :: ![ScriptFile] -- ^ Auxiliary scripts @@ -197,6 +201,8 @@ data TransactionBuildEstimateCmdArgs era = TransactionBuildEstimateCmdArgs -- ^ Certificates with potential script witness , withdrawals :: ![(StakeAddress, Coin, Maybe AnyNonAssetScript)] -- ^ Withdrawals with potential script witness + , directDeposits :: ![(StakeAddress, Coin)] + -- ^ Direct ADA deposits as recipient stake addresses and amounts in lovelace. , plutusCollateral :: !(Maybe Coin) -- ^ Total collateral , totalReferenceScriptSize :: !(Maybe ReferenceScriptSize) diff --git a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Internal/Common.hs b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Internal/Common.hs index 9d0891bfaa..ce559700ab 100644 --- a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Internal/Common.hs +++ b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Internal/Common.hs @@ -9,6 +9,7 @@ module Cardano.CLI.EraBased.Transaction.Internal.Common , getAllReferenceInputs , createTxMintValue , convertWithdrawals + , convertDirectDeposits , convertVotingProcedures , scriptInEraToSimpleScript , partitionSomeWitnesses @@ -27,6 +28,7 @@ import Cardano.CLI.Compatible.Transaction.TxOut import Cardano.CLI.Read import Cardano.CLI.Type.Common import Cardano.CLI.Type.Error.TxCmdError +import Cardano.Ledger.Address qualified as L import Cardano.Ledger.Hashes (DataHash) import RIO hiding (toList) @@ -178,3 +180,13 @@ partitionSomeWitnesses = reversePartitionedWits . Foldable.foldl' go mempty (byronWit : byronAcc, shelleyKeyAcc) AShelleyKeyWitness shelleyKeyWit -> (byronAcc, shelleyKeyWit : shelleyKeyAcc) + +-- | Reject duplicate recipients before converting deposits to the ledger map. +convertDirectDeposits :: [(StakeAddress, Lovelace)] -> Either TxCmdError L.DirectDeposits +convertDirectDeposits deposits = + case [address | address : _ : _ <- List.group (List.sort $ map fst deposits)] of + [] -> + Right $ + L.DirectDeposits $ + Map.fromList [(toShelleyStakeAddr address, amount) | (address, amount) <- deposits] + duplicates -> Left $ TxCmdDuplicateDirectDeposits duplicates diff --git a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Option.hs b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Option.hs index 5358cc0f78..05540f7c82 100644 --- a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Option.hs +++ b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Option.hs @@ -210,6 +210,7 @@ pTransactionBuildCmd envCli = do <*> pInvalidHereafter era' <*> many (pCertificateFileForEra (Exp.useEra @era) AutoBalance) <*> many (pWithdrawalForEra (Exp.useEra @era) AutoBalance) + <*> pDirectDeposits (Exp.useEra @era) <*> pTxMetadataJsonSchema <*> many ( pScriptFor @@ -272,6 +273,7 @@ pTransactionBuildEstimateCmd _envCli = do <*> pInvalidHereafter Exp.useEra <*> many (pCertificateFileForEra (Exp.useEra @era) ManualBalance) <*> many (pWithdrawalForEra (Exp.useEra @era) ManualBalance) + <*> pDirectDeposits (Exp.useEra @era) <*> optional pTotalCollateral <*> optional pReferenceScriptSize <*> pTxMetadataJsonSchema @@ -317,6 +319,7 @@ pTransactionBuildRaw = <*> pTxFee <*> many (pCertificateFileForEra (Exp.useEra @era) ManualBalance) <*> many (pWithdrawalForEra (Exp.useEra @era) ManualBalance) + <*> pDirectDeposits (Exp.useEra @era) <*> pTxMetadataJsonSchema <*> many (pScriptFor "auxiliary-script-file" Nothing "Filepath of auxiliary script(s)") <*> many pMetadataFile diff --git a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Run.hs b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Run.hs index aef33e5c08..4990c34923 100644 --- a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Run.hs +++ b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Run.hs @@ -148,6 +148,7 @@ runTransactionBuildCmd , mValidityUpperBound , certificates , withdrawals + , directDeposits , metadataSchema , scriptFiles , metadataFiles @@ -308,6 +309,7 @@ runTransactionBuildCmd mValidityUpperBound certsAndMaybeScriptWits withdrawalsAndMaybeScriptWits + directDeposits requiredSigners txAuxScripts txMetadata @@ -393,6 +395,7 @@ runTransactionBuildEstimateCmd -- TODO change type , mValidityUpperBound , certificates , withdrawals + , directDeposits , metadataSchema , scriptFiles , metadataFiles @@ -485,6 +488,7 @@ runTransactionBuildEstimateCmd -- TODO change type valuesWithScriptWits certsAndMaybeScriptWits withdrawalsAndMaybeScriptWits + directDeposits requiredSigners 0 txAuxScripts @@ -599,6 +603,7 @@ runTransactionBuildRawCmd , fee , certificates , withdrawals + , directDeposits , metadataSchema , scriptFiles , metadataFiles @@ -688,6 +693,7 @@ runTransactionBuildRawCmd valuesWithScriptWits certsAndMaybeScriptWits withdrawalsAndMaybeScriptWits + directDeposits requiredSigners txAuxScripts txMetadata @@ -733,6 +739,8 @@ runTxBuildRaw -> [(Exp.Certificate (Exp.LedgerEra era), Exp.AnyWitness (Exp.LedgerEra era))] -- ^ Certificate with potential script witness -> [(StakeAddress, Lovelace, Exp.AnyWitness (Exp.LedgerEra era))] + -> [(StakeAddress, Lovelace)] + -- ^ Direct ADA deposits as recipient stake addresses and amounts in lovelace. -> [Hash PaymentKey] -- ^ Required signers -> TxAuxScripts era @@ -760,6 +768,7 @@ runTxBuildRaw valuesWithScriptWits certsAndMaybeSriptWits withdrawals + directDeposits reqSigners txAuxScripts txMetadata @@ -785,6 +794,7 @@ runTxBuildRaw valuesWithScriptWits certsAndMaybeSriptWits withdrawals + directDeposits reqSigners fee txAuxScripts @@ -826,6 +836,8 @@ constructTxBodyContent -- ^ Certificate with potential script witness -> [(StakeAddress, Lovelace, Exp.AnyWitness (Exp.LedgerEra era))] -- ^ Withdrawals + -> [(StakeAddress, Lovelace)] + -- ^ Direct ADA deposits as recipient stake addresses and amounts in lovelace. -> [Hash PaymentKey] -- ^ Required signers -> Lovelace @@ -857,6 +869,7 @@ constructTxBodyContent valuesWithScriptWits certsAndMaybeScriptWits withdrawals + directDeposits reqSigners fee txAuxScripts @@ -904,6 +917,7 @@ constructTxBodyContent Exp.mkTxProposalProcedures txProposals let validatedCurrentTreasuryValue = unTxCurrentTreasuryValue <$> mCurrentTreasury validatedTreasuryDonation = unTxTreasuryDonation <$> mTreasuryDonation + validatedDirectDeposits <- convertDirectDeposits directDeposits let validatedWithdrawals = convertWithdrawals withdrawals return ( Exp.defaultTxBodyContent @@ -919,6 +933,7 @@ constructTxBodyContent & Exp.setTxMetadata expTxMetadata & Exp.setTxAuxScripts auxScripts & Exp.setTxWithdrawals validatedWithdrawals + & Exp.setTxDirectDeposits validatedDirectDeposits & Exp.setTxExtraKeyWits (Exp.TxExtraKeyWitnesses reqSigners) & maybe id (Exp.setTxProtocolParams . Exp.obtainCommonConstraints (Exp.useEra @era)) mPparams & Exp.setTxCertificates @@ -972,6 +987,8 @@ runTxBuild -> [(Exp.Certificate (Exp.LedgerEra era), Exp.AnyWitness (Exp.LedgerEra era))] -- ^ Certificate with potential script witness -> [(StakeAddress, Lovelace, Exp.AnyWitness (Exp.LedgerEra era))] + -> [(StakeAddress, Lovelace)] + -- ^ Direct ADA deposits as recipient stake addresses and amounts in lovelace. -> [Hash PaymentKey] -- ^ Required signers -> TxAuxScripts era @@ -1001,6 +1018,7 @@ runTxBuild mUpperBound certsAndMaybeScriptWits withdrawals + directDeposits reqSigners txAuxScripts txMetadata @@ -1069,6 +1087,7 @@ runTxBuild mintValueWithScriptWits certsAndMaybeScriptWits withdrawals + directDeposits reqSigners 0 txAuxScripts diff --git a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Command.hs b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Command.hs index 3631b438d3..c8cc7721d4 100644 --- a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Command.hs +++ b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Command.hs @@ -50,6 +50,8 @@ data SubTransactionBuildRawCmdArgs = SubTransactionBuildRawCmdArgs , certificates :: ![(CertificateFile, Maybe AnyNonAssetScript)] -- ^ Certificates with potential script witness , withdrawals :: ![(StakeAddress, Coin, Maybe AnyNonAssetScript)] + , directDeposits :: ![(StakeAddress, Coin)] + -- ^ Direct ADA deposits as recipient stake addresses and amounts in lovelace. , metadataSchema :: !TxMetadataJsonSchema , scriptFiles :: ![ScriptFile] -- ^ Auxiliary scripts diff --git a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Option.hs b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Option.hs index ebb4c24e67..349aecff64 100644 --- a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Option.hs +++ b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Option.hs @@ -112,6 +112,7 @@ pSubTransactionBuildRaw = "the withdrawal of rewards. Only native scripts or Plutus V4 scripts are supported." ManualBalance ) + <*> pDirectDeposits Exp.DijkstraEra <*> pTxMetadataJsonSchema <*> many (pScriptFor "auxiliary-script-file" Nothing "Filepath of auxiliary script(s)") <*> many pMetadataFile diff --git a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Run.hs b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Run.hs index 164cac03a8..b28af3cf30 100644 --- a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Run.hs +++ b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/SubTransaction/Run.hs @@ -98,6 +98,7 @@ runSubTransactionBuildRawCmd , mValidityUpperBound , certificates , withdrawals + , directDeposits , metadataSchema , scriptFiles , metadataFiles @@ -180,6 +181,7 @@ runSubTransactionBuildRawCmd valuesWithScriptWits certsAndMaybeScriptWits withdrawalsAndMaybeScriptWits + directDeposits txAuxScripts txMetadata votingProceduresAndMaybeScriptWits @@ -349,6 +351,8 @@ constructSubTx -- ^ Certificate with potential script witness -> [(StakeAddress, Lovelace, Exp.AnyWitness (Exp.LedgerEra era))] -- ^ Withdrawals + -> [(StakeAddress, Lovelace)] + -- ^ Direct ADA deposits as recipient stake addresses and amounts in lovelace. -> TxAuxScripts era -> TxMetadataInEra era -> [(VotingProcedures era, Exp.AnyWitness (Exp.LedgerEra era))] @@ -372,6 +376,7 @@ constructSubTx valuesWithScriptWits certsAndMaybeScriptWits withdrawals + directDeposits txAuxScripts txMetadata votingProcedures @@ -417,6 +422,7 @@ constructSubTx TxMetadataNone -> TxMetadata mempty TxMetadataInEra _ mDat -> mDat + validatedDirectDeposits <- convertDirectDeposits directDeposits validatedMintValue <- createTxMintValue valuesWithScriptWits validatedVotingProcedures <- first (TxCmdVotingError . TxVotingError) $ @@ -432,14 +438,15 @@ constructSubTx & Exp.setTxMetadata expTxMetadata & Exp.setTxAuxScripts auxScripts & Exp.setTxWithdrawals (convertWithdrawals withdrawals) + & Exp.setTxDirectDeposits validatedDirectDeposits & maybe id (Exp.setTxProtocolParams . Exp.obtainCommonConstraints (Exp.useEra @era)) mPparams & Exp.setTxCertificates (Exp.mkTxCertificates Exp.useEra certsAndMaybeScriptWits) & Exp.setTxMintValue validatedMintValue & Exp.setTxVotingProcedures validatedVotingProcedures & Exp.setTxProposalProcedures (Exp.mkTxProposalProcedures txProposals) - & maybe id Exp.setTxCurrentTreasuryValue (unTxCurrentTreasuryValue <$> mCurrentTreasury) - & maybe id Exp.setTxTreasuryDonation (unTxTreasuryDonation <$> mTreasuryDonation) + & maybe id (Exp.setTxCurrentTreasuryValue . unTxCurrentTreasuryValue) mCurrentTreasury + & maybe id (Exp.setTxTreasuryDonation . unTxTreasuryDonation) mTreasuryDonation & Exp.setTxSupplementalDatums suppDatums & Exp.setTxGuards guards & Exp.setTxRequiredTopLevelGuards requiredTopLevelGuards diff --git a/cardano-cli/src/Cardano/CLI/Type/Error/TxCmdError.hs b/cardano-cli/src/Cardano/CLI/Type/Error/TxCmdError.hs index 877cac1ecd..ce6e74619d 100644 --- a/cardano-cli/src/Cardano/CLI/Type/Error/TxCmdError.hs +++ b/cardano-cli/src/Cardano/CLI/Type/Error/TxCmdError.hs @@ -34,6 +34,7 @@ import Cardano.Prelude qualified as List import RIO +import Data.Text qualified as Text import Data.Text.Lazy.Builder (toLazyText) import Formatting.Buildable (Buildable (build)) @@ -86,6 +87,7 @@ data TxCmdError | TxCmdSubTxCborError !DecoderError | TxCmdSubTxUnsupportedPlutusLanguage !L.Language | TxCmdSubTxByronWitnessUnsupported + | TxCmdDuplicateDirectDeposits ![StakeAddress] | TxCmdDuplicateGuards ![L.Credential L.Guard] | TxCmdPlutusGuardMissingRedeemer !(L.Credential L.Guard) | TxCmdNativeGuardWithRedeemer !(L.Credential L.Guard) @@ -219,6 +221,9 @@ renderTxCmdError = \case "Sub-transactions require Plutus V4 for script execution; found " <> pshow language <> "." TxCmdSubTxByronWitnessUnsupported -> "Byron (bootstrap) witnesses are not supported for sub-transactions." + TxCmdDuplicateDirectDeposits addresses -> + "Duplicate direct-deposit accounts: " + <> pretty (Text.intercalate ", " $ map serialiseAddress addresses) TxCmdDuplicateGuards credentials -> "Duplicate guards: " <> hsep (map (pretty . L.credToText) credentials) TxCmdPlutusGuardMissingRedeemer credential -> diff --git a/cardano-cli/test/cardano-cli-golden/files/golden/help.cli b/cardano-cli/test/cardano-cli-golden/files/golden/help.cli index b0b71cc3d9..08e3efb13e 100644 --- a/cardano-cli/test/cardano-cli-golden/files/golden/help.cli +++ b/cardano-cli/test/cardano-cli-golden/files/golden/help.cli @@ -5361,6 +5361,7 @@ Usage: cardano-cli dijkstra sub-transaction build-raw (--tx-in TX_IN ) --withdrawal-reference-tx-in-execution-units (INT, INT) ]] + [--direct-deposit STAKE_ADDRESS+LOVELACE] [ --json-metadata-no-schema | --json-metadata-detailed-schema ] @@ -5607,6 +5608,7 @@ Usage: cardano-cli dijkstra transaction build-raw ) --withdrawal-reference-tx-in-execution-units (INT, INT) ]] + [--direct-deposit STAKE_ADDRESS+LOVELACE] [ --json-metadata-no-schema | --json-metadata-detailed-schema ] @@ -5775,6 +5777,7 @@ Usage: cardano-cli dijkstra transaction build | --withdrawal-reference-tx-in-redeemer-value JSON_VALUE ) ]] + [--direct-deposit STAKE_ADDRESS+LOVELACE] [ --json-metadata-no-schema | --json-metadata-detailed-schema ] @@ -5945,6 +5948,7 @@ Usage: cardano-cli dijkstra transaction build-estimate ) --withdrawal-reference-tx-in-execution-units (INT, INT) ]] + [--direct-deposit STAKE_ADDRESS+LOVELACE] [--tx-total-collateral INTEGER] [--reference-script-size NATURAL] [ --json-metadata-no-schema diff --git a/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_sub-transaction_build-raw.cli b/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_sub-transaction_build-raw.cli index aef314ad97..e00b25548a 100644 --- a/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_sub-transaction_build-raw.cli +++ b/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_sub-transaction_build-raw.cli @@ -93,6 +93,7 @@ Usage: cardano-cli dijkstra sub-transaction build-raw (--tx-in TX_IN ) --withdrawal-reference-tx-in-execution-units (INT, INT) ]] + [--direct-deposit STAKE_ADDRESS+LOVELACE] [ --json-metadata-no-schema | --json-metadata-detailed-schema ] @@ -396,6 +397,9 @@ Available options: top-level strings and numbers. --withdrawal-reference-tx-in-execution-units (INT, INT) The time and space units needed by the script. + --direct-deposit STAKE_ADDRESS+LOVELACE + Deposit ADA into a registered account. Amount in + lovelace; each account may occur once. --json-metadata-no-schema Use the "no schema" conversion from JSON to tx metadata (default). diff --git a/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build-estimate.cli b/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build-estimate.cli index d6379565f0..fb4edfd1dc 100644 --- a/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build-estimate.cli +++ b/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build-estimate.cli @@ -119,6 +119,7 @@ Usage: cardano-cli dijkstra transaction build-estimate ) --withdrawal-reference-tx-in-execution-units (INT, INT) ]] + [--direct-deposit STAKE_ADDRESS+LOVELACE] [--tx-total-collateral INTEGER] [--reference-script-size NATURAL] [ --json-metadata-no-schema @@ -441,6 +442,9 @@ Available options: top-level strings and numbers. --withdrawal-reference-tx-in-execution-units (INT, INT) The time and space units needed by the script. + --direct-deposit STAKE_ADDRESS+LOVELACE + Deposit ADA into a registered account. Amount in + lovelace; each account may occur once. --tx-total-collateral INTEGER The total amount of collateral that will be collected as fees in the event of a Plutus script failure. Must diff --git a/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build-raw.cli b/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build-raw.cli index 503291a453..b68714ee70 100644 --- a/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build-raw.cli +++ b/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build-raw.cli @@ -115,6 +115,7 @@ Usage: cardano-cli dijkstra transaction build-raw ) --withdrawal-reference-tx-in-execution-units (INT, INT) ]] + [--direct-deposit STAKE_ADDRESS+LOVELACE] [ --json-metadata-no-schema | --json-metadata-detailed-schema ] @@ -432,6 +433,9 @@ Available options: top-level strings and numbers. --withdrawal-reference-tx-in-execution-units (INT, INT) The time and space units needed by the script. + --direct-deposit STAKE_ADDRESS+LOVELACE + Deposit ADA into a registered account. Amount in + lovelace; each account may occur once. --json-metadata-no-schema Use the "no schema" conversion from JSON to tx metadata (default). diff --git a/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build.cli b/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build.cli index 68364805bf..61cd80bbfc 100644 --- a/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build.cli +++ b/cardano-cli/test/cardano-cli-golden/files/golden/help/dijkstra_transaction_build.cli @@ -111,6 +111,7 @@ Usage: cardano-cli dijkstra transaction build | --withdrawal-reference-tx-in-redeemer-value JSON_VALUE ) ]] + [--direct-deposit STAKE_ADDRESS+LOVELACE] [ --json-metadata-no-schema | --json-metadata-detailed-schema ] @@ -421,6 +422,9 @@ Available options: The script redeemer value. There is no schema: (almost) any JSON value is supported, including top-level strings and numbers. + --direct-deposit STAKE_ADDRESS+LOVELACE + Deposit ADA into a registered account. Amount in + lovelace; each account may occur once. --json-metadata-no-schema Use the "no schema" conversion from JSON to tx metadata (default). diff --git a/cardano-cli/test/cardano-cli-test/Test/Cli/Transaction/DirectDeposit.hs b/cardano-cli/test/cardano-cli-test/Test/Cli/Transaction/DirectDeposit.hs new file mode 100644 index 0000000000..d76cf0f047 --- /dev/null +++ b/cardano-cli/test/cardano-cli-test/Test/Cli/Transaction/DirectDeposit.hs @@ -0,0 +1,179 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE OverloadedStrings #-} + +module Test.Cli.Transaction.DirectDeposit where + +import Cardano.Api +import Cardano.Api.Experimental qualified as Exp +import Cardano.Api.Ledger qualified as L + +import Cardano.Ledger.Address qualified as L +import Cardano.Ledger.Coin qualified as L +import Cardano.Ledger.Core qualified as L +import Cardano.Ledger.Dijkstra.TxBody qualified as L + +import Control.Monad (forM_, void) +import Data.Aeson qualified as Aeson +import Data.Foldable (toList) +import Data.List (isInfixOf) +import Data.Map.Strict qualified as Map +import Data.Text qualified as Text +import Lens.Micro ((&), (.~), (^.)) +import System.Directory (doesFileExist) +import System.Exit (ExitCode (..)) + +import Test.Cardano.CLI.Util + +import Hedgehog (Property) +import Hedgehog qualified as H +import Hedgehog.Extras qualified as H +import Test.Cli.Transaction.SubTransaction + ( buildSubTransaction + , readDijkstraTransaction + , signSubTransaction + , signingKey + , subTransactionArgs + , transactionArgs + , txIn + ) + +account :: String +account = "stake_test17qvxuvh64q9zdqgrjt76d42eclk5wgdxtnsun4808cwg0dqxv5r99" + +otherAccount :: String +otherAccount = "stake_test1uz855drx5szvz845zqcnq9dc3ez8mqdkqzy7yhmpzcqwvpg59zzfl" + +accountAddress :: H.MonadTest m => m L.AccountAddress +accountAddress = toShelleyStakeAddr <$> H.evalMaybe (deserialiseAddress AsStakeAddress $ Text.pack account) + +-- Decode both the top-level and signed nested transaction to catch deposits +-- being accepted by the parser but lost during construction or embedding. +hprop_cip159_direct_deposits_roundtrip :: Property +hprop_cip159_direct_deposits_roundtrip = + watchdogProp . propertyOnce $ H.moduleWorkspace "tmp" $ \tempDir -> do + outFile <- H.noteTempFile tempDir "tx.body" + subFile <- H.noteTempFile tempDir "sub.body" + signedFile <- H.noteTempFile tempDir "sub.signed" + let cases = + [[(account, amount)] | amount <- [0, 2000000, 18446744073709551615]] + ++ [[(account, 2000000), (otherAccount, 3000000)]] + forM_ cases $ \deposits -> do + recipients <- mapM (H.evalMaybe . deserialiseAddress AsStakeAddress . Text.pack . fst) deposits + let args = + concat + [ ["--direct-deposit", recipient ++ "+" ++ show (amount :: Integer)] | (recipient, amount) <- deposits + ] + expected = + L.DirectDeposits $ + Map.fromList $ + zip (map toShelleyStakeAddr recipients) (map (Coin . snd) deposits) + Exp.UnsignedSubTx sub <- buildSubTransaction subFile args + sub ^. L.bodyTxL . L.directDepositsTxBodyL H.=== expected + signSubTransaction subFile signingKey signedFile + void $ + execCardanoCLI $ + transactionArgs "dijkstra" + ++ args + ++ ["--signed-sub-tx-file", signedFile, "--out-file", outFile] + ShelleyTx _ tx <- readDijkstraTransaction outFile + let body = tx ^. L.bodyTxL + body ^. L.directDepositsTxBodyL H.=== expected + let nested = toList $ body ^. L.subTransactionsTxBodyL + length nested H.=== 1 + forM_ nested $ \child -> + child ^. L.bodyTxL . L.directDepositsTxBodyL H.=== expected + +hprop_cip159_rejects_invalid_direct_deposits :: Property +hprop_cip159_rejects_invalid_direct_deposits = + watchdogProp . propertyOnce $ H.moduleWorkspace "tmp" $ \tempDir -> do + outFile <- H.noteTempFile tempDir "unused" + let invalid = + [ ["--direct-deposit", account ++ "+" ++ value] + | value <- ["-1", "18446744073709551616", "1garbage", "1+1 token", ""] + ] + ++ [["--direct-deposit", "addr_test1vp0t4dfa9ktc2uvv7sg9leafuhtwyu0xcj4q4kf5pqkpjwqhklklg+1"]] + forM_ [transactionArgs "dijkstra", subTransactionArgs] $ \base -> do + forM_ invalid $ \args -> do + H.noteShow_ args + (exitCode, _, _) <- execDetailCardanoCLI $ base ++ args ++ ["--out-file", outFile] + exitCode H.=== ExitFailure 1 + H.evalIO (doesFileExist outFile) >>= (H.=== False) + (exitCode, _, stderr) <- + execDetailCardanoCLI $ + base + ++ ["--direct-deposit", account ++ "+1", "--direct-deposit", account ++ "+2", "--out-file", outFile] + exitCode H.=== ExitFailure 1 + H.assertWith stderr ("Duplicate direct-deposit accounts" `isInfixOf`) + H.evalIO (doesFileExist outFile) >>= (H.=== False) + +hprop_cip159_direct_deposit_era_gating :: Property +hprop_cip159_direct_deposit_era_gating = + watchdogProp . propertyOnce $ do + forM_ ["build", "build-raw", "build-estimate"] $ \command -> do + help <- execCardanoCLI ["dijkstra", "transaction", command, "--help"] + H.assertWith help ("--direct-deposit" `isInfixOf`) + forM_ ["conway", "latest"] $ \era -> do + (exitCode, _, stderr) <- + execDetailCardanoCLI + [era, "transaction", command, "--direct-deposit", account ++ "+1", "--out-file", "unused"] + exitCode H.=== ExitFailure 1 + H.assertWith stderr ("Invalid option `--direct-deposit'" `isInfixOf`) + +-- | With 100 ADA of inputs and direct deposits of 20 and 30 ADA, build-estimate +-- must return 50 ADA minus the estimated fee as change. This catches deposits being +-- recorded in the body but omitted from the change calculation. +hprop_cip159_build_estimate_deducts_deposits_from_change :: Property +hprop_cip159_build_estimate_deducts_deposits_from_change = + watchdogProp . propertyOnce $ H.moduleWorkspace "tmp" $ \tempDir -> do + paramsFile <- H.noteTempFile tempDir "params.json" + outFile <- H.noteTempFile tempDir "tx.body" + let inputValue = Coin 100000000 -- 100 ADA, expressed in lovelace. + depositAmount = Coin 20000000 -- 20 ADA, expressed in lovelace. + otherDepositAmount = Coin 30000000 -- 30 ADA to a different recipient. + params = + (L.PParams L.emptyPParamsIdentity :: L.PParams (Exp.LedgerEra Exp.DijkstraEra)) + & L.ppMaxTxSizeL .~ 16384 + & L.ppTxFeePerByteL .~ L.CoinPerByte (L.CompactCoin 44) + & L.ppTxFeeFixedL .~ 155381 + H.evalIO $ Aeson.encodeFile paramsFile params + -- build-estimate works offline: supply the input value explicitly. There + -- are no --tx-out arguments, so every generated output is change. + H.note_ "The 100 ADA input must fund deposits of 20 and 30 ADA, the estimated fee, and change." + void $ + execCardanoCLI + [ "dijkstra" + , "transaction" + , "build-estimate" + , "--tx-in" + , txIn + , "--total-utxo-value" + , show (L.unCoin inputValue) + , "--shelley-key-witnesses" + , "1" + , "--protocol-params-file" + , paramsFile + , "--change-address" + , "addr_test1vp0t4dfa9ktc2uvv7sg9leafuhtwyu0xcj4q4kf5pqkpjwqhklklg" + , "--direct-deposit" + , account ++ "+" ++ show (L.unCoin depositAmount) + , "--direct-deposit" + , otherAccount ++ "+" ++ show (L.unCoin otherDepositAmount) + , "--out-file" + , outFile + ] + ShelleyTx _ tx <- readDijkstraTransaction outFile + address <- accountAddress + otherAddress <- + toShelleyStakeAddr <$> H.evalMaybe (deserialiseAddress AsStakeAddress $ Text.pack otherAccount) + let body = tx ^. L.bodyTxL + estimatedFee = body ^. L.feeTxBodyL + changeAmount = sum [output ^. L.coinTxOutL | output <- toList $ body ^. L.outputsTxBodyL] + -- Check that the requested deposit was included in the serialized body. + body ^. L.directDepositsTxBodyL + H.=== L.DirectDeposits (Map.fromList [(address, depositAmount), (otherAddress, otherDepositAmount)]) + + -- Use the fee calculated by build-estimate, since it depends on transaction + -- size. The remaining change must be 100 ADA - 20 ADA - 30 ADA - that fee. + H.note_ $ "Estimated fee (lovelace): " ++ show (L.unCoin estimatedFee) + H.note_ $ "Returned change (lovelace): " ++ show (L.unCoin changeAmount) + changeAmount H.=== inputValue - depositAmount - otherDepositAmount - estimatedFee From 59902078d7823e63f0bd7b73e16850a291c56cbe Mon Sep 17 00:00:00 2001 From: Jordan Millar Date: Fri, 16 Oct 2026 10:48:17 -0400 Subject: [PATCH 2/2] Address direct-deposit review feedback --- .../src/Cardano/CLI/EraBased/Common/Option.hs | 14 ++--- .../Cardano/CLI/EraBased/Transaction/Run.hs | 2 + .../src/Cardano/CLI/Type/Error/TxCmdError.hs | 3 +- .../Test/Cli/Transaction/DirectDeposit.hs | 54 ++++++++++++++----- 4 files changed, 50 insertions(+), 23 deletions(-) diff --git a/cardano-cli/src/Cardano/CLI/EraBased/Common/Option.hs b/cardano-cli/src/Cardano/CLI/EraBased/Common/Option.hs index 55f1c55975..38b39b97b5 100644 --- a/cardano-cli/src/Cardano/CLI/EraBased/Common/Option.hs +++ b/cardano-cli/src/Cardano/CLI/EraBased/Common/Option.hs @@ -380,6 +380,9 @@ parseLovelace = do then fail $ show i <> " lovelace exceeds the Word64 upper bound" else return $ L.Coin i +parseStakeAddressAndLovelace :: P.Parser (StakeAddress, Lovelace) +parseStakeAddressAndLovelace = (,) <$> parseAddressAny <* P.char '+' <*> parseLovelace + parseCoinPerByte :: P.Parser L.CoinPerByte parseCoinPerByte = do i <- P.parseDecimal @@ -1507,14 +1510,11 @@ pDirectDeposits era = case era of Exp.ConwayEra -> pure [] Exp.DijkstraEra -> many $ - Opt.option (readerFromParsecParser parseDeposit) $ + Opt.option (readerFromParsecParser parseStakeAddressAndLovelace) $ Opt.long "direct-deposit" <> Opt.metavar "STAKE_ADDRESS+LOVELACE" <> Opt.help "Deposit ADA into a registered account. Amount in lovelace; each account may occur once." - where - parseDeposit :: P.Parser (StakeAddress, Lovelace) - parseDeposit = (,) <$> parseAddressAny <* P.char '+' <*> parseLovelace pWithdrawalForEra :: Exp.Era era @@ -1538,7 +1538,7 @@ pWithdrawalWithPlutusReferenceLanguage pWithdrawalWithPlutusReferenceLanguage pLanguage scriptFileHelp balance = (\(stakeAddr, lovelace) maybeScriptFp -> (stakeAddr, lovelace, maybeScriptFp)) <$> Opt.option - (readerFromParsecParser parseWithdrawal) + (readerFromParsecParser parseStakeAddressAndLovelace) ( Opt.long "withdrawal" <> Opt.metavar "WITHDRAWAL" <> Opt.help helpText @@ -1562,10 +1562,6 @@ pWithdrawalWithPlutusReferenceLanguage pLanguage scriptFileHelp balance = , "a script witness." ] - parseWithdrawal :: P.Parser (StakeAddress, Lovelace) - parseWithdrawal = - (,) <$> parseAddressAny <* P.char '+' <*> parseLovelace - pWithdrawalScriptWitness :: BalanceTxExecUnits -> String diff --git a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Run.hs b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Run.hs index 4990c34923..f3680ef4fc 100644 --- a/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Run.hs +++ b/cardano-cli/src/Cardano/CLI/EraBased/Transaction/Run.hs @@ -159,6 +159,8 @@ runTransactionBuildCmd , isCborOutCanonical , buildOutputOptions } = do + -- Reject invalid deposits before any node queries, including the network check. + void $ fromEitherCli $ convertDirectDeposits directDeposits checkNodeNetworkId nodeConnInfo let eon = convert currentEra diff --git a/cardano-cli/src/Cardano/CLI/Type/Error/TxCmdError.hs b/cardano-cli/src/Cardano/CLI/Type/Error/TxCmdError.hs index ce6e74619d..cf1a4b195e 100644 --- a/cardano-cli/src/Cardano/CLI/Type/Error/TxCmdError.hs +++ b/cardano-cli/src/Cardano/CLI/Type/Error/TxCmdError.hs @@ -34,7 +34,6 @@ import Cardano.Prelude qualified as List import RIO -import Data.Text qualified as Text import Data.Text.Lazy.Builder (toLazyText) import Formatting.Buildable (Buildable (build)) @@ -223,7 +222,7 @@ renderTxCmdError = \case "Byron (bootstrap) witnesses are not supported for sub-transactions." TxCmdDuplicateDirectDeposits addresses -> "Duplicate direct-deposit accounts: " - <> pretty (Text.intercalate ", " $ map serialiseAddress addresses) + <> hsep (map (pretty . serialiseAddress) addresses) TxCmdDuplicateGuards credentials -> "Duplicate guards: " <> hsep (map (pretty . L.credToText) credentials) TxCmdPlutusGuardMissingRedeemer credential -> diff --git a/cardano-cli/test/cardano-cli-test/Test/Cli/Transaction/DirectDeposit.hs b/cardano-cli/test/cardano-cli-test/Test/Cli/Transaction/DirectDeposit.hs index d76cf0f047..2fdf789042 100644 --- a/cardano-cli/test/cardano-cli-test/Test/Cli/Transaction/DirectDeposit.hs +++ b/cardano-cli/test/cardano-cli-test/Test/Cli/Transaction/DirectDeposit.hs @@ -24,7 +24,7 @@ import System.Exit (ExitCode (..)) import Test.Cardano.CLI.Util -import Hedgehog (Property) +import Hedgehog (Property, (===)) import Hedgehog qualified as H import Hedgehog.Extras qualified as H import Test.Cli.Transaction.SubTransaction @@ -68,7 +68,7 @@ hprop_cip159_direct_deposits_roundtrip = Map.fromList $ zip (map toShelleyStakeAddr recipients) (map (Coin . snd) deposits) Exp.UnsignedSubTx sub <- buildSubTransaction subFile args - sub ^. L.bodyTxL . L.directDepositsTxBodyL H.=== expected + sub ^. L.bodyTxL . L.directDepositsTxBodyL === expected signSubTransaction subFile signingKey signedFile void $ execCardanoCLI $ @@ -77,11 +77,11 @@ hprop_cip159_direct_deposits_roundtrip = ++ ["--signed-sub-tx-file", signedFile, "--out-file", outFile] ShelleyTx _ tx <- readDijkstraTransaction outFile let body = tx ^. L.bodyTxL - body ^. L.directDepositsTxBodyL H.=== expected + body ^. L.directDepositsTxBodyL === expected let nested = toList $ body ^. L.subTransactionsTxBodyL - length nested H.=== 1 + length nested === 1 forM_ nested $ \child -> - child ^. L.bodyTxL . L.directDepositsTxBodyL H.=== expected + child ^. L.bodyTxL . L.directDepositsTxBodyL === expected hprop_cip159_rejects_invalid_direct_deposits :: Property hprop_cip159_rejects_invalid_direct_deposits = @@ -96,15 +96,45 @@ hprop_cip159_rejects_invalid_direct_deposits = forM_ invalid $ \args -> do H.noteShow_ args (exitCode, _, _) <- execDetailCardanoCLI $ base ++ args ++ ["--out-file", outFile] - exitCode H.=== ExitFailure 1 - H.evalIO (doesFileExist outFile) >>= (H.=== False) + exitCode === ExitFailure 1 + H.evalIO (doesFileExist outFile) >>= (=== False) (exitCode, _, stderr) <- execDetailCardanoCLI $ base ++ ["--direct-deposit", account ++ "+1", "--direct-deposit", account ++ "+2", "--out-file", outFile] - exitCode H.=== ExitFailure 1 + exitCode === ExitFailure 1 H.assertWith stderr ("Duplicate direct-deposit accounts" `isInfixOf`) - H.evalIO (doesFileExist outFile) >>= (H.=== False) + H.evalIO (doesFileExist outFile) >>= (=== False) + +-- | Invalid arguments must be reported even when the node is unavailable. +hprop_cip159_build_rejects_duplicate_deposits_before_node_query :: Property +hprop_cip159_build_rejects_duplicate_deposits_before_node_query = + watchdogProp . propertyOnce $ H.moduleWorkspace "tmp" $ \tempDir -> do + outFile <- H.noteTempFile tempDir "unused" + socketPath <- H.noteTempFile tempDir "missing.socket" + (exitCode, _, stderr) <- + execDetailCardanoCLI + [ "dijkstra" + , "transaction" + , "build" + , "--testnet-magic" + , "42" + , "--socket-path" + , socketPath + , "--tx-in" + , txIn + , "--change-address" + , "addr_test1vp0t4dfa9ktc2uvv7sg9leafuhtwyu0xcj4q4kf5pqkpjwqhklklg" + , "--direct-deposit" + , account ++ "+1" + , "--direct-deposit" + , account ++ "+2" + , "--out-file" + , outFile + ] + exitCode === ExitFailure 1 + H.assertWith stderr (("Duplicate direct-deposit accounts: " ++ account) `isInfixOf`) + H.evalIO (doesFileExist outFile) >>= (=== False) hprop_cip159_direct_deposit_era_gating :: Property hprop_cip159_direct_deposit_era_gating = @@ -116,7 +146,7 @@ hprop_cip159_direct_deposit_era_gating = (exitCode, _, stderr) <- execDetailCardanoCLI [era, "transaction", command, "--direct-deposit", account ++ "+1", "--out-file", "unused"] - exitCode H.=== ExitFailure 1 + exitCode === ExitFailure 1 H.assertWith stderr ("Invalid option `--direct-deposit'" `isInfixOf`) -- | With 100 ADA of inputs and direct deposits of 20 and 30 ADA, build-estimate @@ -170,10 +200,10 @@ hprop_cip159_build_estimate_deducts_deposits_from_change = changeAmount = sum [output ^. L.coinTxOutL | output <- toList $ body ^. L.outputsTxBodyL] -- Check that the requested deposit was included in the serialized body. body ^. L.directDepositsTxBodyL - H.=== L.DirectDeposits (Map.fromList [(address, depositAmount), (otherAddress, otherDepositAmount)]) + === L.DirectDeposits (Map.fromList [(address, depositAmount), (otherAddress, otherDepositAmount)]) -- Use the fee calculated by build-estimate, since it depends on transaction -- size. The remaining change must be 100 ADA - 20 ADA - 30 ADA - that fee. H.note_ $ "Estimated fee (lovelace): " ++ show (L.unCoin estimatedFee) H.note_ $ "Returned change (lovelace): " ++ show (L.unCoin changeAmount) - changeAmount H.=== inputValue - depositAmount - otherDepositAmount - estimatedFee + changeAmount === inputValue - depositAmount - otherDepositAmount - estimatedFee