diff --git a/lib/customer-deposit-wallet/customer-deposit-wallet.cabal b/lib/customer-deposit-wallet/customer-deposit-wallet.cabal index e57053fd19b..4cd0e1a7a2f 100644 --- a/lib/customer-deposit-wallet/customer-deposit-wallet.cabal +++ b/lib/customer-deposit-wallet/customer-deposit-wallet.cabal @@ -55,9 +55,11 @@ library , async , base , base58-bytestring + , base16-bytestring , bech32 , bech32-th , bytestring + , cardano-addresses , cardano-balance-tx , cardano-crypto , cardano-ledger-api @@ -223,7 +225,9 @@ test-suite unit , openapi3 , pretty-simple , QuickCheck + , serialise , temporary + , text , time , transformers , with-utf8 diff --git a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Server.hs b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Server.hs index 0e40f298548..d789ef9fa94 100644 --- a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Server.hs +++ b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Server.hs @@ -22,6 +22,10 @@ import Cardano.Wallet.Deposit.HTTP.Types.JSON import Cardano.Wallet.Deposit.IO ( WalletBootEnv ) +import Cardano.Wallet.Deposit.Pure.State.Creation + ( credentialsFromEncodedXPub + , credentialsFromMnemonics + ) import Cardano.Wallet.Deposit.REST ( WalletResource , WalletResourceM @@ -32,10 +36,8 @@ import Cardano.Wallet.Deposit.REST.Catch ( catchRunWalletResourceM ) import Cardano.Wallet.Deposit.REST.Wallet.Create - ( PostWalletViaMenmonic (..) + ( PostWalletViaMnemonic (..) , PostWalletViaXPub (..) - , decodeXPub - , xpubFromMnemonics ) import Control.Tracer ( Tracer @@ -81,23 +83,23 @@ createWalletViaMnemonic -> FilePath -> WalletBootEnv IO -> WalletResource - -> PostWalletViaMenmonic + -> PostWalletViaMnemonic -> Handler NoContent createWalletViaMnemonic tracer dir boot resource - (PostWalletViaMenmonic mnemonics' users') = + (PostWalletViaMnemonic mnemonics' passphrase' users') = onlyOnWalletIntance resource initWallet $> NoContent where initWallet :: WalletResourceM () initWallet = - REST.initXPubWallet + REST.initWallet tracer boot dir - (xpubFromMnemonics mnemonics') + (credentialsFromMnemonics mnemonics' passphrase') (fromIntegral users') createWalletViaXPub @@ -119,17 +121,16 @@ createWalletViaXPub Right () -> pure NoContent where initWallet :: WalletResourceM (Either String ()) - initWallet = case decodeXPub xpubText of - Left e -> pure $ Left e - Right (Just xpub') -> + initWallet = case credentialsFromEncodedXPub xpubText of + Left e -> pure $ Left $ show e + Right credentials -> Right - <$> REST.initXPubWallet + <$> REST.initWallet tracer boot dir - xpub' + credentials (fromIntegral users') - Right Nothing -> pure $ Left "Invalid XPub" listCustomerH :: WalletResource diff --git a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/API.hs b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/API.hs index 04dead6c4f0..0732c56fde9 100644 --- a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/API.hs +++ b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/API.hs @@ -20,7 +20,7 @@ import Cardano.Wallet.Deposit.HTTP.Types.JSON , CustomerList ) import Cardano.Wallet.Deposit.REST.Wallet.Create - ( PostWalletViaMenmonic + ( PostWalletViaMnemonic , PostWalletViaXPub ) import Servant.API @@ -47,7 +47,7 @@ type API = :> Capture "customerId" (ApiT Customer) :> Put '[JSON] (ApiT Address) :<|> "mnemonics" - :> ReqBody '[JSON] PostWalletViaMenmonic + :> ReqBody '[JSON] PostWalletViaMnemonic :> PutNoContent :<|> "xpub" :> ReqBody '[JSON] PostWalletViaXPub diff --git a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/JSON.hs b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/JSON.hs index 3ed7695775e..7fe975c6234 100644 --- a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/JSON.hs +++ b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/JSON.hs @@ -39,7 +39,7 @@ import Cardano.Wallet.Deposit.Read , ChainPoint (..) ) import Cardano.Wallet.Deposit.REST.Wallet.Create - ( PostWalletViaMenmonic + ( PostWalletViaMnemonic , PostWalletViaXPub ) import Control.Applicative @@ -223,6 +223,6 @@ instance ToSchema (ApiT ChainPoint) where (Just "ApiT ChainPoint") chainPointSchema -instance FromJSON PostWalletViaMenmonic +instance FromJSON PostWalletViaMnemonic instance FromJSON PostWalletViaXPub diff --git a/lib/customer-deposit-wallet/rest/Cardano/Wallet/Deposit/REST.hs b/lib/customer-deposit-wallet/rest/Cardano/Wallet/Deposit/REST.hs index 5f6c9c916dd..d79aa1957f1 100644 --- a/lib/customer-deposit-wallet/rest/Cardano/Wallet/Deposit/REST.hs +++ b/lib/customer-deposit-wallet/rest/Cardano/Wallet/Deposit/REST.hs @@ -3,6 +3,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -Wno-orphans #-} -- | -- Copyright: © 2024 Cardano Foundation @@ -26,7 +27,7 @@ module Cardano.Wallet.Deposit.REST -- * Operations -- ** Initialization - , initXPubWallet + , initWallet , loadWallet -- ** Mapping between customers and addresses @@ -49,19 +50,26 @@ module Cardano.Wallet.Deposit.REST , walletPublicIdentity , deleteWallet , deleteTheDepositWalletOnDisk + , inspectTx -- * Internals , onWalletInstance + , resolveCurrentEraTx + , canSign ) where import Prelude import Cardano.Address.Derivation - ( xpubFromBytes - , xpubToBytes + ( xpubToBytes ) import Cardano.Crypto.Wallet - ( XPub (..) + ( XPrv + , XPub (..) + , unXPrv + , unXPub + , xprv + , xpub ) import Cardano.Wallet.Address.BIP32 ( BIP32Path @@ -74,20 +82,28 @@ import Cardano.Wallet.Deposit.IO.Resource , ErrResourceMissing (..) ) import Cardano.Wallet.Deposit.Pure - ( Customer + ( CanSign + , Credentials + , CurrentEraResolvedTx + , Customer , ErrCreatePayment + , InspectTx , Word31 - , fromXPubAndGenesis + , fromCredentialsAndGenesis ) import Cardano.Wallet.Deposit.Pure.API.TxHistory ( ByCustomer , ByTime ) +import Cardano.Wallet.Deposit.Pure.State.Creation + ( xpubFromCredentials + ) import Cardano.Wallet.Deposit.Read ( Address ) import Codec.Serialise - ( deserialise + ( Serialise (..) + , deserialise , serialise ) import Control.DeepSeq @@ -120,6 +136,9 @@ import Data.ByteArray.Encoding ( Base (..) , convertToBase ) +import Data.ByteString + ( ByteString + ) import Data.List ( isPrefixOf ) @@ -245,52 +264,69 @@ findTheDepositWalletOnDisk dir action = do ds <- scanDirectoryForDepositPrefix dir case ds of [d] -> do - (xpub, users) <- deserialise <$> BL.readFile (dir d) - case xpubFromBytes xpub of - Nothing -> action $ Left $ ErrDatabaseCorrupted (dir d) - Just identity -> do - let state = - fromXPubAndGenesis - identity - (fromIntegral @Int users) - Read.mockGenesisDataMainnet - store <- newStore - writeS store state - action $ Right store + (credentials, users) <- + deserialise <$> BL.readFile (dir d) + let state = + fromCredentialsAndGenesis + credentials + (fromIntegral @Int users) + Read.mockGenesisDataMainnet + store <- newStore + writeS store state + action $ Right store [] -> action $ Left $ ErrDatabaseNotFound dir ds' -> action $ Left $ ErrMultipleDatabases ((dir ) <$> ds') +instance Serialise XPub where + encode = encode . unXPub + decode = do + b <- decode + case xpub b of + Right x -> pure x + Left e -> fail e + +instance Serialise XPrv where + encode = encode . unXPrv + decode = do + b :: ByteString <- decode + case xprv b of + Right x -> pure x + Left e -> fail e + +instance Serialise Credentials + -- | Try to create a new wallet createTheDepositWalletOnDisk :: Tracer IO String -- ^ Tracer for logging -> FilePath -- ^ Path to the wallet database directory - -> XPub + -> Credentials -- ^ Id of the wallet -> Word31 -- ^ Max number of users ? -> (Maybe WalletIO.WalletStore -> IO a) -- ^ Action to run if the wallet is created -> IO a -createTheDepositWalletOnDisk _tr dir identity users action = do +createTheDepositWalletOnDisk _tr dir credentials users action = do ds <- scanDirectoryForDepositPrefix dir case ds of [] -> do - let fp = dir depositPrefix <> hashWalletId identity + let fp = dir depositPrefix <> hashWalletId credentials BL.writeFile fp - $ serialise (xpubToBytes identity, fromIntegral users :: Int) + $ serialise (credentials, fromIntegral users :: Int) store <- newStore action $ Just store _ -> do action Nothing where - hashWalletId :: XPub -> String + hashWalletId :: Credentials -> String hashWalletId = B8.unpack . convertToBase Base16 . blake2b160 - . xpubPublicKey + . xpubToBytes + . xpubFromCredentials -- | Load an existing wallet from disk. loadWallet @@ -316,27 +352,27 @@ loadWallet bootEnv dir = do <$> Resource.putResource action resource -- | Initialize a new wallet from an 'XPub'. -initXPubWallet +initWallet :: Tracer IO String -- ^ Tracer for logging -> WalletIO.WalletBootEnv IO -- ^ Environment for the wallet -> FilePath -- ^ Path to the wallet database directory - -> XPub + -> Credentials -- ^ Id of the wallet -> Word31 -- ^ Max number of users ? -> WalletResourceM () -initXPubWallet tr bootEnv dir xpub users = do +initWallet tr bootEnv dir credentials users = do let action :: (WalletIO.WalletInstance -> IO b) -> IO (Either ErrDatabase b) - action f = createTheDepositWalletOnDisk tr dir xpub users $ \case + action f = createTheDepositWalletOnDisk tr dir credentials users $ \case Just wallet -> do fmap Right $ WalletIO.withWalletInit (WalletIO.WalletEnv bootEnv wallet) - xpub + credentials users $ \i -> do addresses <- map snd <$> WalletIO.listCustomers i @@ -409,7 +445,7 @@ getTxHistoryByTime = onWalletInstance WalletIO.getTxHistoryByTime createPayment :: [(Address, Read.Value)] - -> WalletResourceM (Either ErrCreatePayment Write.Tx) + -> WalletResourceM (Either ErrCreatePayment CurrentEraResolvedTx) createPayment = onWalletInstance . WalletIO.createPayment getBIP32PathsForOwnedInputs @@ -418,7 +454,18 @@ getBIP32PathsForOwnedInputs getBIP32PathsForOwnedInputs = onWalletInstance . WalletIO.getBIP32PathsForOwnedInputs +canSign :: WalletResourceM CanSign +canSign = onWalletInstance WalletIO.canSign + signTx :: Write.Tx -> WalletResourceM (Maybe Write.Tx) signTx = onWalletInstance . WalletIO.signTx + +inspectTx + :: CurrentEraResolvedTx + -> WalletResourceM InspectTx +inspectTx = onWalletInstance . WalletIO.inspectTx + +resolveCurrentEraTx :: Write.Tx -> WalletResourceM CurrentEraResolvedTx +resolveCurrentEraTx = onWalletInstance . WalletIO.resolveCurrentEraTx diff --git a/lib/customer-deposit-wallet/rest/Cardano/Wallet/Deposit/REST/Wallet/Create.hs b/lib/customer-deposit-wallet/rest/Cardano/Wallet/Deposit/REST/Wallet/Create.hs index e2c133db022..715b4d413ab 100644 --- a/lib/customer-deposit-wallet/rest/Cardano/Wallet/Deposit/REST/Wallet/Create.hs +++ b/lib/customer-deposit-wallet/rest/Cardano/Wallet/Deposit/REST/Wallet/Create.hs @@ -2,31 +2,13 @@ {-# LANGUAGE DuplicateRecordFields #-} module Cardano.Wallet.Deposit.REST.Wallet.Create - ( PostWalletViaMenmonic (..) + ( PostWalletViaMnemonic (..) , PostWalletViaXPub (..) - , decodeXPub - , xpubFromMnemonics - , encodeXPub ) where import Prelude -import Cardano.Address.Derivation - ( XPub - , generate - , toXPub - , xpubFromBytes - , xpubToBytes - ) -import Data.ByteArray.Encoding - ( Base (Base64) - , convertFromBase - , convertToBase - ) -import Data.ByteString.Char8 - ( ByteString - ) import Data.Text ( Text ) @@ -34,11 +16,10 @@ import GHC.Generics ( Generic ) -import qualified Data.Text.Encoding as T - -- | Data for a request to create a wallet via a mnemonic. -data PostWalletViaMenmonic = PostWalletViaMenmonic +data PostWalletViaMnemonic = PostWalletViaMnemonic { mnemonics :: Text + , password :: Text , trackedCustomers :: Int } deriving (Generic) @@ -49,19 +30,3 @@ data PostWalletViaXPub = PostWalletViaXPub , trackedCustomers :: Int } deriving (Generic) - -unBase64 :: ByteString -> Either String ByteString -unBase64 = convertFromBase Base64 - --- | Decode an extended public key from a base64-encoded text. -decodeXPub :: Text -> Either String (Maybe XPub) -decodeXPub = fmap xpubFromBytes . unBase64 . T.encodeUtf8 - --- | Encode an extended public key to a base64-encoded text. -encodeXPub :: XPub -> Text -encodeXPub = T.decodeUtf8 . convertToBase Base64 . xpubToBytes - --- | Generate an extended public key from a mnemonic. --- this is not what one wants to use in production -xpubFromMnemonics :: Text -> XPub -xpubFromMnemonics = toXPub . generate . T.encodeUtf8 diff --git a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/IO.hs b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/IO.hs index 2bc02133217..7248d9fa951 100644 --- a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/IO.hs +++ b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/IO.hs @@ -36,6 +36,7 @@ module Cardano.Wallet.Deposit.IO -- *** Create transactions , createPayment + , inspectTx -- *** Sign transactions , getBIP32PathsForOwnedInputs @@ -45,15 +46,15 @@ module Cardano.Wallet.Deposit.IO , submitTx , listTxsInSubmission - -- * Internals - , onWalletState + -- * Internals + , onWalletState + , readWalletState + , resolveCurrentEraTx + , canSign ) where import Prelude -import Cardano.Crypto.Wallet - ( XPub - ) import Cardano.Wallet.Address.BIP32 ( BIP32Path ) @@ -61,7 +62,9 @@ import Cardano.Wallet.Deposit.IO.Network.Type ( NetworkEnv (slotToUTCTime) ) import Cardano.Wallet.Deposit.Pure - ( Customer + ( Credentials + , CurrentEraResolvedTx + , Customer , ValueTransfer , WalletPublicIdentity (..) , WalletState @@ -72,6 +75,9 @@ import Cardano.Wallet.Deposit.Pure.API.TxHistory , ByTime , LookupTimeFromSlot ) +import Cardano.Wallet.Deposit.Pure.State.Creation + ( CanSign + ) import Cardano.Wallet.Deposit.Read ( Address , TxId @@ -170,7 +176,7 @@ readWalletState WalletInstance{walletState} = -- | Initialize a new wallet in the given environment. withWalletInit :: WalletEnv IO - -> XPub + -> Credentials -> Word31 -> (WalletInstance -> IO a) -> IO a @@ -179,12 +185,15 @@ withWalletInit { bootEnv = WalletBootEnv{genesisData} , .. } - xpub + credentials knownCustomerCount action = do walletState <- DBVar.initDBVar store - $ Wallet.fromXPubAndGenesis xpub knownCustomerCount genesisData + $ Wallet.fromCredentialsAndGenesis + credentials + knownCustomerCount + genesisData withWalletDBVar env walletState action -- | Load an existing wallet from the given environment. @@ -323,7 +332,7 @@ slotResolver w = do createPayment :: [(Address, Read.Value)] -> WalletInstance - -> IO (Either Wallet.ErrCreatePayment Write.Tx) + -> IO (Either Wallet.ErrCreatePayment CurrentEraResolvedTx) createPayment a w = do timeTranslation <- Time.toTimeTranslation <$> Network.getTimeInterpreter network @@ -333,11 +342,28 @@ createPayment a w = do where network = networkEnv $ bootEnv $ env w +inspectTx + :: CurrentEraResolvedTx + -> WalletInstance + -> IO Wallet.InspectTx +inspectTx tx w = flip Wallet.inspectTx tx <$> readWalletState w + +resolveCurrentEraTx + :: Write.Tx + -> WalletInstance + -> IO CurrentEraResolvedTx +resolveCurrentEraTx tx w = + Wallet.resolveCurrentEraTx tx <$> readWalletState w + {----------------------------------------------------------------------------- Operations Signing transactions ------------------------------------------------------------------------------} +canSign :: WalletInstance -> IO CanSign +canSign w = do + Wallet.canSign <$> readWalletState w + getBIP32PathsForOwnedInputs :: Write.Tx -> WalletInstance -> IO [BIP32Path] getBIP32PathsForOwnedInputs a w = diff --git a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure.hs b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure.hs index 82d8e1fd4d3..67496327280 100644 --- a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure.hs +++ b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure.hs @@ -10,6 +10,10 @@ module Cardano.Wallet.Deposit.Pure , DeltaWalletState , WalletPublicIdentity (..) + -- * Creation + , Credentials (..) + , fromCredentialsAndGenesis + -- * Operations -- ** Mapping between customers and addresses @@ -26,7 +30,6 @@ module Cardano.Wallet.Deposit.Pure , walletXPub -- ** Reading from the blockchain - , fromXPubAndGenesis , Word31 , getWalletTip , availableBalance @@ -44,12 +47,19 @@ module Cardano.Wallet.Deposit.Pure -- ** Writing to the blockchain , ErrCreatePayment (..) , createPayment + , resolveCurrentEraTx + , CurrentEraResolvedTx , BIP32Path (..) , DerivationType (..) + , ResolvedTx (..) + , canSign + , CanSign (..) , getBIP32PathsForOwnedInputs , signTx , addTxSubmission , listTxsInSubmission + , inspectTx + , InspectTx (..) ) where import Cardano.Wallet.Address.BIP32 @@ -57,12 +67,21 @@ import Cardano.Wallet.Address.BIP32 , DerivationType (..) ) import Cardano.Wallet.Deposit.Pure.State.Creation - ( WalletPublicIdentity (..) - , fromXPubAndGenesis + ( CanSign (..) + , Credentials (..) + , WalletPublicIdentity (..) + , canSign + , fromCredentialsAndGenesis ) import Cardano.Wallet.Deposit.Pure.State.Payment - ( ErrCreatePayment (..) + ( CurrentEraResolvedTx + , ErrCreatePayment (..) , createPayment + , resolveCurrentEraTx + ) +import Cardano.Wallet.Deposit.Pure.State.Payment.Inspect + ( InspectTx (..) + , inspectTx ) import Cardano.Wallet.Deposit.Pure.State.Rolling ( rollBackward @@ -101,6 +120,9 @@ import Cardano.Wallet.Deposit.Pure.State.Type , trackedCustomers , walletXPub ) +import Cardano.Wallet.Deposit.Pure.UTxO.Tx + ( ResolvedTx (..) + ) import Cardano.Wallet.Deposit.Pure.UTxO.ValueTransfer ( ValueTransfer (..) ) diff --git a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Creation.hs b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Creation.hs index 8158ac55595..5424693a89c 100644 --- a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Creation.hs +++ b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Creation.hs @@ -1,26 +1,55 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# OPTIONS_GHC -Wno-orphans #-} +{-# LANGUAGE NamedFieldPuns #-} module Cardano.Wallet.Deposit.Pure.State.Creation ( WalletPublicIdentity (..) - , fromXPubAndGenesis + , fromCredentialsAndGenesis + , Credentials (..) + , credentialsFromMnemonics + , credentialsFromEncodedXPub + , xpubFromCredentials + , xprvFromCredentials + , ErrDecodingXPub (..) + , encodedXPubFromCredentials + , canSign + , CanSign (..) ) where import Prelude hiding ( lookup ) +import Cardano.Address.Derivation + ( xpubFromBytes + , xpubToBytes + ) import Cardano.Crypto.Wallet - ( XPub + ( XPrv + , XPub + , generate + , toXPub + , unXPrv ) import Cardano.Wallet.Deposit.Pure.State.Type ( WalletState (..) ) +import Data.Text + ( Text + ) import Data.Word.Odd ( Word31 ) +import GHC.Generics + ( Generic + ) import qualified Cardano.Wallet.Deposit.Pure.Address as Address import qualified Cardano.Wallet.Deposit.Pure.Submissions as Sbm import qualified Cardano.Wallet.Deposit.Pure.UTxO.UTxOHistory as UTxOHistory import qualified Cardano.Wallet.Deposit.Read as Read +import qualified Data.ByteString.Base16 as B16 +import qualified Data.ByteString.Char8 as B8 +import qualified Data.Text.Encoding as T data WalletPublicIdentity = WalletPublicIdentity { pubXpub :: XPub @@ -28,18 +57,93 @@ data WalletPublicIdentity = WalletPublicIdentity } deriving (Show) -fromXPubAndGenesis - :: XPub -> Word31 -> Read.GenesisData -> WalletState -fromXPubAndGenesis xpub knownCustomerCount genesisData = +data Credentials + = XPubCredentials !XPub + | XPrvCredentials !XPrv !XPub + deriving (Generic, Show, Eq) + +instance Show XPrv where + show = B8.unpack . B16.encode . unXPrv + +instance Eq XPrv where + a == b = unXPrv a == unXPrv b + +xpubFromCredentials :: Credentials -> XPub +xpubFromCredentials (XPubCredentials xpub) = xpub +xpubFromCredentials (XPrvCredentials _ xpub) = xpub + +xprvFromCredentials :: Credentials -> Maybe XPrv +xprvFromCredentials (XPubCredentials _) = Nothing +xprvFromCredentials (XPrvCredentials xprv _) = Just xprv + +fromCredentialsAndGenesis + :: Credentials -> Word31 -> Read.GenesisData -> WalletState +fromCredentialsAndGenesis credentials knownCustomerCount genesisData = WalletState { walletTip = Read.GenesisPoint , addresses = - Address.fromXPubAndCount network xpub knownCustomerCount + Address.fromXPubAndCount + network + (xpubFromCredentials credentials) + knownCustomerCount , utxoHistory = UTxOHistory.fromOrigin initialUTxO , txHistory = mempty , submissions = Sbm.empty - , rootXSignKey = Nothing + , rootXSignKey = xprvFromCredentials credentials } where network = Read.getNetworkId genesisData initialUTxO = mempty + +-- | Create 'Credentials' from a mnemonic sentence and a passphrase. +credentialsFromMnemonics + :: Text + -- ^ Mnemonics + -> Text + -- ^ Passphrase + -> Credentials +credentialsFromMnemonics mnemonics passphrase = + let + unencryptedXPrv = + generate + (T.encodeUtf8 mnemonics) + (T.encodeUtf8 mempty) + encryptedXPrv = + generate + (T.encodeUtf8 mnemonics) + (T.encodeUtf8 passphrase) + in + XPrvCredentials encryptedXPrv (toXPub unencryptedXPrv) + +data CanSign = CanSign | CannotSign + deriving (Eq, Show) + +canSign :: WalletState -> CanSign +canSign WalletState{rootXSignKey} = case rootXSignKey of + Nothing -> CannotSign + Just _ -> CanSign + +-- | Create 'Credentials' from an extended public key failures to decode +data ErrDecodingXPub = ErrFromXPubBase16 | ErrFromXPubDecodeKey + deriving (Show, Eq) + +-- | Create 'Credentials' from an extended public key encoded in base16. +credentialsFromEncodedXPub + :: Text + -> Either ErrDecodingXPub Credentials +credentialsFromEncodedXPub xpub = case B16.decode (T.encodeUtf8 xpub) of + Left _ -> Left ErrFromXPubBase16 + Right bytes -> case xpubFromBytes bytes of + Nothing -> Left ErrFromXPubDecodeKey + Just key -> Right $ XPubCredentials key + +-- | Encode an extended public key to base16. +encodedXPubFromCredentials + :: Credentials + -> Text +encodedXPubFromCredentials (XPubCredentials xpub) = + T.decodeUtf8 + $ B16.encode + $ xpubToBytes xpub +encodedXPubFromCredentials (XPrvCredentials _ xpub) = + encodedXPubFromCredentials (XPubCredentials xpub) diff --git a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Payment.hs b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Payment.hs index 5d0c4d1cecf..24f003ff5c7 100644 --- a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Payment.hs +++ b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Payment.hs @@ -1,11 +1,13 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE ScopedTypeVariables #-} module Cardano.Wallet.Deposit.Pure.State.Payment ( ErrCreatePayment (..) , createPayment , CurrentEraResolvedTx + , resolveCurrentEraTx ) where import Prelude hiding @@ -20,12 +22,14 @@ import Cardano.Wallet.Deposit.Pure.State.Type ) import Cardano.Wallet.Deposit.Pure.UTxO.Tx ( ResolvedTx (..) + , resolveInputs ) import Cardano.Wallet.Deposit.Read ( Address ) import Cardano.Wallet.Deposit.Write - ( TxBody (..) + ( Tx + , TxBody (..) ) import Control.Monad.Trans.Except ( runExceptT @@ -36,6 +40,9 @@ import Data.Bifunctor import Data.Digest.CRC32 ( crc32 ) +import Data.Text.Class.Extended + ( ToText (..) + ) import qualified Cardano.Wallet.Deposit.Pure.Address as Address import qualified Cardano.Wallet.Deposit.Read as Read @@ -43,26 +50,38 @@ import qualified Cardano.Wallet.Deposit.Write as Write import qualified Cardano.Wallet.Read.Hash as Hash import qualified Control.Monad.Random.Strict as Random import qualified Data.Map.Strict as Map +import qualified Data.Text as T data ErrCreatePayment = ErrCreatePaymentNotRecentEra (Read.EraValue Read.Era) | ErrCreatePaymentBalanceTx (Write.ErrBalanceTx Write.Conway) deriving (Eq, Show) +instance ToText ErrCreatePayment where + toText = \case + ErrCreatePaymentNotRecentEra era -> + "Cannot create a payment in the era: " <> T.pack (show era) + ErrCreatePaymentBalanceTx err -> + "Cannot create a payment: " <> T.pack (show err) + type CurrentEraResolvedTx = ResolvedTx Read.Conway +resolveCurrentEraTx :: Tx -> WalletState -> CurrentEraResolvedTx +resolveCurrentEraTx tx w = resolveInputs (availableUTxO w) tx + -- | Create a payment to a list of destinations. createPayment :: Read.EraValue Read.PParams -> Write.TimeTranslation -> [(Address, Write.Value)] -> WalletState - -> Either ErrCreatePayment Write.Tx + -> Either ErrCreatePayment CurrentEraResolvedTx createPayment (Read.EraValue (Read.PParams pparams :: Read.PParams era)) a b w = case Read.theEra :: Read.Era era of Read.Conway -> first ErrCreatePaymentBalanceTx - $ createPaymentConway pparams a b w + $ flip resolveCurrentEraTx w + <$> createPaymentConway pparams a b w era' -> Left $ ErrCreatePaymentNotRecentEra (Read.EraValue era') -- | In the Conway era: Create a payment to a list of destinations. diff --git a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Payment/Inspect.hs b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Payment/Inspect.hs index d1eb72edf9c..675bf61396d 100644 --- a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Payment/Inspect.hs +++ b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Pure/State/Payment/Inspect.hs @@ -7,6 +7,7 @@ module Cardano.Wallet.Deposit.Pure.State.Payment.Inspect ( inspectTx , CurrentEraResolvedTx , InspectTx (..) + , transactionBalance ) where import Cardano.Read.Ledger.Tx.Fee @@ -49,6 +50,11 @@ import Cardano.Wallet.Read , mkEraTxOut , pattern TxIn ) +import Control.Lens + ( Field2 (_2) + , Field3 (_3) + , (^.) + ) import Data.Foldable ( Foldable (..) , fold @@ -58,17 +64,35 @@ import Prelude import qualified Data.Map.Strict as Map import qualified Data.Set as Set +-- | Inspect the inputs and outputs of a transaction. data InspectTx = InspectTx { ourInputs :: [(TxId, TxIx, Coin)] + -- ^ Our inputs. , otherInputs :: [(TxId, TxIx)] + -- ^ Other inputs, there shouldn't be any. , change :: [(Address, Coin)] + -- ^ Change outputs. , ourOutputs :: [(Address, Customer, Coin)] + -- ^ Our outputs. The customer is the owner of the address. There could be + -- reasons the user wants to move funds among customer addresses. , otherOutputs :: [(Address, Coin)] + -- ^ Other outputs. This is regular money leaving the wallet. , fee :: Coin } + deriving (Eq, Show) + +-- | Calculate the output balance of a transaction, which is the sum of the +-- values of the outputs minus the sum of the values of the change outputs. +-- Eventual payments towards our addresses will be included in the output. +-- Fee will be added to the inputs. +transactionBalance :: InspectTx -> Coin +transactionBalance InspectTx{..} = + (ourInputs ^. traverse . _3) + - (change ^. traverse . _2) + - (ourOutputs ^. traverse . _3) -inspectTx - :: WalletState -> CurrentEraResolvedTx -> InspectTx +-- | Inspect a transaction where inputs have been resolved to our UTxO. +inspectTx :: WalletState -> CurrentEraResolvedTx -> InspectTx inspectTx ws (ResolvedTx tx ourUTxO) = let (ourInputs, otherInputs) = fold $ do diff --git a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Write.hs b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Write.hs index 7c6743f3e06..f293c844364 100644 --- a/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Write.hs +++ b/lib/customer-deposit-wallet/src/Cardano/Wallet/Deposit/Write.hs @@ -1,3 +1,5 @@ +{-# OPTIONS_GHC -Wno-missing-fields #-} + -- | Indirection module that re-exports types -- used for writing transactions to the blockchain, -- in the most recent and the next future eras. @@ -41,6 +43,8 @@ module Cardano.Wallet.Deposit.Write , addTxIn , addTxOut , emptyTxBody + , UTxO.resolvedTx + , UTxO.resolvedInputs ) where import Prelude @@ -84,6 +88,7 @@ import Data.Set import qualified Cardano.Ledger.Api as L import qualified Cardano.Ledger.Api.Tx.In as L import qualified Cardano.Ledger.Slot as L +import qualified Cardano.Wallet.Deposit.Pure.UTxO.Tx as UTxO import qualified Cardano.Wallet.Read as Read import qualified Cardano.Write.Eras as Write import qualified Cardano.Write.Tx as Write diff --git a/lib/customer-deposit-wallet/test/scenario/Test/Scenario/Wallet/Deposit/Exchanges.lhs.md b/lib/customer-deposit-wallet/test/scenario/Test/Scenario/Wallet/Deposit/Exchanges.lhs.md index 13b9da3de80..080684b7079 100644 --- a/lib/customer-deposit-wallet/test/scenario/Test/Scenario/Wallet/Deposit/Exchanges.lhs.md +++ b/lib/customer-deposit-wallet/test/scenario/Test/Scenario/Wallet/Deposit/Exchanges.lhs.md @@ -34,6 +34,8 @@ import Cardano.Wallet.Deposit.IO import Cardano.Wallet.Deposit.Pure ( Customer , ValueTransfer (..) + , Credentials (..) + , ResolvedTx (..) ) import Cardano.Wallet.Deposit.Read ( Address @@ -79,7 +81,7 @@ scenarioRestore :: XPub -> WalletEnv IO -> IO () scenarioRestore xpub env = do let knownCustomerCount = 127 - Wallet.withWalletInit env xpub knownCustomerCount $ \w -> do + Wallet.withWalletInit env (XPubCredentials xpub) knownCustomerCount $ \w -> do value <- Wallet.availableBalance w assert $ value == ada 0 ``` @@ -205,7 +207,7 @@ scenarioCreatePayment xprv env destination w = do assert $ value1 == (coin <> coin) -- createPayment - Right txUnsigned <- Wallet.createPayment [(destination, coin)] w + Right (ResolvedTx txUnsigned _) <- Wallet.createPayment [(destination, coin)] w paths <- Wallet.getBIP32PathsForOwnedInputs txUnsigned w let tx = signTx xprv paths txUnsigned submitTx env tx diff --git a/lib/customer-deposit-wallet/test/scenario/Test/Scenario/Wallet/Deposit/Run.hs b/lib/customer-deposit-wallet/test/scenario/Test/Scenario/Wallet/Deposit/Run.hs index 535add1dc35..ca440b7a038 100644 --- a/lib/customer-deposit-wallet/test/scenario/Test/Scenario/Wallet/Deposit/Run.hs +++ b/lib/customer-deposit-wallet/test/scenario/Test/Scenario/Wallet/Deposit/Run.hs @@ -1,9 +1,8 @@ -{-| -Copyright: © 2024 Cardano Foundation -License: Apache-2.0 - -Execute usage scenarios for the deposit wallet. --} +-- | +-- Copyright: © 2024 Cardano Foundation +-- License: Apache-2.0 +-- +-- Execute usage scenarios for the deposit wallet. module Test.Scenario.Wallet.Deposit.Run ( main ) where @@ -16,6 +15,9 @@ import Cardano.Crypto.Wallet , generate , toXPub ) +import Cardano.Wallet.Deposit.Pure.State.Creation + ( Credentials (..) + ) import Test.Hspec ( SpecWith , describe @@ -43,14 +45,14 @@ import qualified Test.Scenario.Wallet.Deposit.Exchanges as Exchanges main :: IO () main = hspecMain - $ aroundAll withScenarioEnvMock scenarios + $ aroundAll withScenarioEnvMock scenarios scenarios :: SpecWith ScenarioEnv scenarios = do describe "Scenarios for centralized exchanges" $ do it "0. Restore a wallet" $ \env -> - withWalletEnvMock env $ - Exchanges.scenarioRestore xpub + withWalletEnvMock env + $ Exchanges.scenarioRestore xpub it "0. Start a wallet" $ \env -> withWalletEnvMock env $ \w -> do @@ -59,19 +61,25 @@ scenarios = do it "1. Assign an address to a customer ID" $ \env -> do withWalletEnvMock env $ \walletEnv -> - Wallet.withWalletInit walletEnv (freshXPub 1) 32 + Wallet.withWalletInit + walletEnv + (XPubCredentials $ freshXPub 1) + 32 Exchanges.scenarioCreateAddressList it "4. Create payments to a different wallet" $ \env -> do withWalletEnvMock env $ \walletEnv -> - Wallet.withWalletInit walletEnv xpub 32 + Wallet.withWalletInit walletEnv (XPubCredentials xpub) 32 $ Exchanges.scenarioCreatePayment xprv env mockAddress describe "Temporary tests" $ do it "Wallet receives funds that are sent to customer address" $ \env -> do withWalletEnvMock env $ \walletEnv -> - Wallet.withWalletInit walletEnv (freshXPub 0) 8 $ - testBalance env + Wallet.withWalletInit + walletEnv + (XPubCredentials $ freshXPub 0) + 8 + $ testBalance env xpub :: XPub xpub = toXPub xprv @@ -82,9 +90,9 @@ xprv = generate (B8.pack "random seed for a testing xpub lala") B8.empty freshXPub :: Integer -> XPub freshXPub i = toXPub - $ generate - (B8.pack $ "random seed for a testing xpub lala" <> show i) - B8.empty + $ generate + (B8.pack $ "random seed for a testing xpub lala" <> show i) + B8.empty mockAddress :: Read.Address mockAddress = diff --git a/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/PureSpec.hs b/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/PureSpec.hs index 74b66e60622..a2436a0fffe 100644 --- a/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/PureSpec.hs +++ b/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/PureSpec.hs @@ -13,17 +13,16 @@ module Cardano.Wallet.Deposit.PureSpec import Prelude -import Cardano.Crypto.Wallet - ( XPub - , generate - , toXPub - ) import Cardano.Wallet.Deposit.Pure - ( Customer + ( Credentials + , Customer ) import Cardano.Wallet.Deposit.Pure.API.TxHistory ( LookupTimeFromSlot ) +import Cardano.Wallet.Deposit.Pure.State.Creation + ( credentialsFromMnemonics + ) import Cardano.Wallet.Deposit.Testing.DSL ( InterpreterState (..) , ScenarioP @@ -79,7 +78,6 @@ import qualified Cardano.Wallet.Deposit.Pure as Wallet import qualified Cardano.Wallet.Deposit.Pure.UTxO as UTxO import qualified Cardano.Wallet.Deposit.Read as Read import qualified Cardano.Wallet.Deposit.Write as Write -import qualified Data.ByteString.Char8 as B8 import qualified Data.Map.Strict as Map import qualified Data.Set as Set @@ -300,12 +298,11 @@ prop_availableBalance_rollForward_rollBackward = emptyWalletWith17Addresses :: Wallet.WalletState emptyWalletWith17Addresses = - Wallet.fromXPubAndGenesis testXPub 17 testGenesis + Wallet.fromCredentialsAndGenesis testCredentials 17 testGenesis -testXPub :: XPub -testXPub = - toXPub - $ generate (B8.pack "random seed for a testing xpub lala") B8.empty +testCredentials :: Credentials +testCredentials = + credentialsFromMnemonics "random seed for a testing xpub lala" mempty {----------------------------------------------------------------------------- Test blockchain diff --git a/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/RESTSpec.hs b/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/RESTSpec.hs index b82a2f28fb9..4cf0a9cac40 100644 --- a/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/RESTSpec.hs +++ b/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/RESTSpec.hs @@ -6,9 +6,8 @@ where import Prelude import Cardano.Crypto.Wallet - ( XPub - , generate - , toXPub + ( sign + , verify ) import Cardano.Wallet.Deposit.IO ( WalletBootEnv (WalletBootEnv) @@ -17,6 +16,12 @@ import Cardano.Wallet.Deposit.IO.Resource ( ErrResourceMissing (..) , withResource ) +import Cardano.Wallet.Deposit.Pure.State.Creation + ( Credentials + , credentialsFromMnemonics + , xprvFromCredentials + , xpubFromCredentials + ) import Cardano.Wallet.Deposit.REST ( ErrCreatingDatabase (..) , ErrDatabase (..) @@ -24,11 +29,15 @@ import Cardano.Wallet.Deposit.REST , ErrWalletResource (..) , WalletResourceM , availableBalance - , initXPubWallet + , initWallet , loadWallet , runWalletResourceM , walletExists ) +import Codec.Serialise + ( deserialise + , serialise + ) import Control.Concurrent ( threadDelay ) @@ -38,6 +47,15 @@ import Control.Monad.IO.Class import Control.Tracer ( nullTracer ) +import Data.ByteString + ( ByteString + ) +import Data.Maybe + ( fromJust + ) +import Data.Text + ( Text + ) import System.IO.Temp ( withSystemTempDirectory ) @@ -48,16 +66,33 @@ import Test.Hspec , shouldBe ) +import Control.Monad.Trans.Cont + ( cont + , evalCont + ) +import Test.QuickCheck + ( Gen + , arbitrary + , forAll + , listOf + , suchThat + , vectorOf + , (===) + ) + import qualified Cardano.Wallet.Deposit.Read as Read import qualified Data.ByteString.Char8 as B8 +import qualified Data.Text as T +import qualified Data.Text.Encoding as T fakeBootEnv :: WalletBootEnv IO fakeBootEnv = WalletBootEnv nullTracer Read.mockGenesisDataMainnet undefined -xpub :: XPub -xpub = - toXPub - $ generate (B8.pack "random seed for a testing xpub lala") B8.empty +mnemonics :: Text +mnemonics = "random seed for a testing xpub lala" + +credentials :: Credentials +credentials = credentialsFromMnemonics mnemonics mempty letItInitialize :: WalletResourceM () letItInitialize = liftIO $ threadDelay 100000 @@ -77,7 +112,7 @@ withInitializedWallet -> WalletResourceM a -> IO (Either ErrWalletResource a) withInitializedWallet dir f = withWallet $ do - initXPubWallet nullTracer fakeBootEnv dir xpub 0 + initWallet nullTracer fakeBootEnv dir credentials 0 letItInitialize f @@ -96,8 +131,54 @@ doNothing = pure () inADirectory :: (FilePath -> IO a) -> IO a inADirectory = withSystemTempDirectory "deposit-rest" +byteStringGen :: Gen ByteString +byteStringGen = B8.pack <$> listOf arbitrary + +textGen :: Gen Text +textGen = T.pack <$> listOf arbitrary + +textNGen :: Int -> Gen Text +textNGen n = do + n' <- arbitrary `suchThat` (>= n) + T.pack <$> vectorOf n' arbitrary + +credentialsGen :: Gen (Credentials, Text) +credentialsGen = do + mnemonics' <- textNGen 32 + passphrase' <- textGen + pure (credentialsFromMnemonics mnemonics' passphrase', passphrase') + spec :: Spec spec = do + describe "XPub" $ do + it "can be serialised and deserialised" $ do + forAll credentialsGen $ \(credentials', _) -> + deserialise (serialise $ xpubFromCredentials credentials') + === xpubFromCredentials credentials' + describe "XPrv" $ do + it "can be serialised and deserialised" $ do + forAll credentialsGen $ \(credentials', _) -> + deserialise (serialise $ xprvFromCredentials credentials') + === xprvFromCredentials credentials' + describe "Credentials" $ do + it "can be serialised and deserialised" $ do + forAll credentialsGen $ \(credentials', _) -> + deserialise (serialise credentials') === credentials' + describe "Credentials with mnemonics" $ do + it "can sign and verify a message" $ evalCont $ do + (credentials', passphrase') <- cont $ forAll credentialsGen + message <- cont $ forAll byteStringGen + let + sig = + sign + (T.encodeUtf8 passphrase') + ( fromJust + $ xprvFromCredentials credentials' + ) + message + pure + $ verify (xpubFromCredentials credentials') message sig === True + describe "REST Deposit interface" $ do it "can initialize a wallet" $ inADirectory diff --git a/lib/faucet/lib/Cardano/Faucet.hs b/lib/faucet/lib/Cardano/Faucet.hs index 7a769f1be70..3e20a19e5d8 100644 --- a/lib/faucet/lib/Cardano/Faucet.hs +++ b/lib/faucet/lib/Cardano/Faucet.hs @@ -8,7 +8,7 @@ module Cardano.Faucet ( initialState , serveMnemonics - , serveMenmonic + , serveMnemonic , serveAddresses ) where @@ -100,8 +100,8 @@ serveMnemonics mnLen minIndex maxIndex = do & NE.filter \(IndexedMnemonic index _mnemonic) -> index >= minIndex && index <= maxIndex -serveMenmonic :: MnemonicLength -> MnemonicIndex -> FaucetM Mnemonic -serveMenmonic mnLen index = +serveMnemonic :: MnemonicLength -> MnemonicIndex -> FaucetM Mnemonic +serveMnemonic mnLen index = serveMnemonics mnLen index index >>= \case [IndexedMnemonic _index mnemonic] -> pure mnemonic _ -> throwError err404 @@ -115,7 +115,7 @@ serveAddresses -> AddressIndex -> FaucetM [IndexedAddress] serveAddresses mnLen mnIdx style netTag minAddrIdx maxAddrIdx = do - Mnemonic (SomeMnemonic mnemonic) <- serveMenmonic mnLen mnIdx + Mnemonic (SomeMnemonic mnemonic) <- serveMnemonic mnLen mnIdx let stylishEncoder = case style of AddressStyleShelley -> Addresses.shelley AddressStyleByron -> Addresses.byron diff --git a/lib/faucet/lib/Cardano/Faucet/Http/Server.hs b/lib/faucet/lib/Cardano/Faucet/Http/Server.hs index 028fe9a73b5..86e845ce063 100644 --- a/lib/faucet/lib/Cardano/Faucet/Http/Server.hs +++ b/lib/faucet/lib/Cardano/Faucet/Http/Server.hs @@ -17,7 +17,7 @@ import qualified Servant import Cardano.Faucet ( serveAddresses - , serveMenmonic + , serveMnemonic , serveMnemonics ) import Cardano.Faucet.FaucetM @@ -60,6 +60,6 @@ server state0 = Servant.hoistServer api (runFaucetM state0) faucetServer where serveMnemmonicOrAddresses len index = - serveMenmonic len index :<|> serveAddresses len index + serveMnemonic len index :<|> serveAddresses len index faucetServer :: Servant.ServerT FaucetApi FaucetM faucetServer len = serveMnemonics len :<|> serveMnemmonicOrAddresses len diff --git a/lib/ui/cardano-wallet-ui.cabal b/lib/ui/cardano-wallet-ui.cabal index c74a34d9263..42e1dfe2945 100644 --- a/lib/ui/cardano-wallet-ui.cabal +++ b/lib/ui/cardano-wallet-ui.cabal @@ -63,12 +63,15 @@ library Cardano.Wallet.UI.Deposit.API.Addresses.Transactions Cardano.Wallet.UI.Deposit.API.Common Cardano.Wallet.UI.Deposit.API.Deposits.Deposits + Cardano.Wallet.UI.Deposit.API.Payments Cardano.Wallet.UI.Deposit.Handlers.Addresses Cardano.Wallet.UI.Deposit.Handlers.Addresses.Transactions Cardano.Wallet.UI.Deposit.Handlers.Deposits.Customers Cardano.Wallet.UI.Deposit.Handlers.Deposits.Times Cardano.Wallet.UI.Deposit.Handlers.Deposits.TxIds Cardano.Wallet.UI.Deposit.Handlers.Lib + Cardano.Wallet.UI.Deposit.Handlers.Payments.Balance + Cardano.Wallet.UI.Deposit.Handlers.Payments.Transaction Cardano.Wallet.UI.Deposit.Handlers.Wallet Cardano.Wallet.UI.Deposit.Html.Common Cardano.Wallet.UI.Deposit.Html.Pages.About @@ -79,6 +82,7 @@ library Cardano.Wallet.UI.Deposit.Html.Pages.Deposits.Times Cardano.Wallet.UI.Deposit.Html.Pages.Deposits.TxIds Cardano.Wallet.UI.Deposit.Html.Pages.Page + Cardano.Wallet.UI.Deposit.Html.Pages.Payments.Page Cardano.Wallet.UI.Deposit.Html.Pages.Wallet Cardano.Wallet.UI.Deposit.Server Cardano.Wallet.UI.Deposit.Server.Addresses @@ -87,12 +91,14 @@ library Cardano.Wallet.UI.Deposit.Server.Deposits.Times Cardano.Wallet.UI.Deposit.Server.Deposits.TxIds Cardano.Wallet.UI.Deposit.Server.Lib + Cardano.Wallet.UI.Deposit.Server.Payments.Page Cardano.Wallet.UI.Deposit.Server.Wallet + Cardano.Wallet.UI.Deposit.Types.Payments Cardano.Wallet.UI.Lib.Address Cardano.Wallet.UI.Lib.Discretization Cardano.Wallet.UI.Lib.ListOf - Cardano.Wallet.UI.Lib.Pagination.TimedSeq Cardano.Wallet.UI.Lib.Pagination.Map + Cardano.Wallet.UI.Lib.Pagination.TimedSeq Cardano.Wallet.UI.Lib.Pagination.Type Cardano.Wallet.UI.Lib.Time.Direction Cardano.Wallet.UI.Shelley.API @@ -115,6 +121,7 @@ library , aeson , aeson-pretty , base + , base16-bytestring , bech32 , bech32-th , bytestring @@ -137,7 +144,6 @@ library , http-media , lens , lucid - , memory , mmorph , monoidal-containers , mtl @@ -170,17 +176,23 @@ test-suite unit build-depends: , base + , bytestring + , cardano-crypto , cardano-wallet-ui + , contra-tracer , containers , hspec , mtl , QuickCheck , time + , customer-deposit-wallet:rest + , customer-deposit-wallet:customer-deposit-wallet build-tool-depends: hspec-discover:hspec-discover type: exitcode-stdio-1.0 hs-source-dirs: test/unit main-is: unit-test.hs other-modules: + Cardano.Wallet.UI.Deposit.Html.Pages.Payments.PageSpec Cardano.Wallet.UI.Lib.DiscretizationSpec Cardano.Wallet.UI.Lib.Pagination.MapSpec diff --git a/lib/ui/src/Cardano/Wallet/UI/Common/Html/Lib.hs b/lib/ui/src/Cardano/Wallet/UI/Common/Html/Lib.hs index 1dcde4e0509..eab602c04bc 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Common/Html/Lib.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Common/Html/Lib.hs @@ -143,7 +143,7 @@ thEnd :: Monad m => Maybe Int -> HtmlT m () -> HtmlT m () thEnd mw = th_ $ [ class_ "text-end p-1 align-bottom" - , style_ "background:#26264d;" + , style_ "background:#26263d;font-weight:normal" ] <> maybe [] (\w -> [style_ $ "width: " <> T.pack (show w) <> "em;"]) mw diff --git a/lib/ui/src/Cardano/Wallet/UI/Common/Html/Pages/Lib.hs b/lib/ui/src/Cardano/Wallet/UI/Common/Html/Pages/Lib.hs index 67ba8761d5e..8427b978713 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Common/Html/Pages/Lib.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Common/Html/Pages/Lib.hs @@ -27,11 +27,19 @@ module Cardano.Wallet.UI.Common.Html.Pages.Lib , Striped (..) , onStriped , box + , addressH + , sseWithControlsH ) where import Prelude +import Cardano.Wallet.Deposit.Pure.API.Address + ( encodeAddress + ) +import Cardano.Wallet.Deposit.Read + ( Address + ) import Cardano.Wallet.UI.Common.Html.Htmx ( hxExt_ , hxGet_ @@ -40,7 +48,9 @@ import Cardano.Wallet.UI.Common.Html.Htmx , hxTrigger_ ) import Cardano.Wallet.UI.Common.Html.Lib - ( linkText + ( WithCopy + , linkText + , truncatableText ) import Cardano.Wallet.UI.Lib.ListOf ( Cons (..) @@ -58,7 +68,6 @@ import Lucid , Html , HtmlT , ToHtml (..) - , b_ , class_ , div_ , hr_ @@ -117,7 +126,7 @@ data AssocRow m -- | Render an 'AssocRow' as a table row. assocRowH :: Maybe Int -> AssocRow m -> Monad m => HtmlT m () assocRowH mn AssocRow{..} = tr_ ([scope_ "row"] <> rowAttributes) $ do - td_ [scope_ "col", class_ "align-bottom p-1", style_ width] $ b_ key + td_ [scope_ "col", class_ "align-bottom p-1", style_ width] key td_ [scope_ "col", class_ "align-bottom flex-fill p-1"] val where width = T.pack @@ -141,10 +150,21 @@ onStriped s a b = case s of -- | Render a list of 'AssocRow' as a table. We use 'listOf' to allow 'do' notation -- in the definition of the rows -record :: Maybe Int -> Width -> Striped -> ListOf (AssocRow m) -> Monad m => HtmlT m () +record + :: Maybe Int + -> Width + -> Striped + -> ListOf (AssocRow m) + -> Monad m + => HtmlT m () record n w s xs = table_ - [ class_ $ "border-top table table-hover mb-0" <> onStriped s " table-striped" "" + [ class_ + $ "tabls-sm border-top table table-hover mb-0" + <> onStriped + s + " table-striped-columns" + "" , style_ $ onWidth w "width: auto" "" ] @@ -152,7 +172,8 @@ record n w s xs = $ listOf xs -- | Create an 'AssocRow' from a key and a value. -field :: [Attribute] -> HtmlT m () -> HtmlT m () -> ListOf (AssocRow m) +field + :: [Attribute] -> HtmlT m () -> HtmlT m () -> ListOf (AssocRow m) field attrs key val = singleton $ Elem $ AssocRow attrs key val -- | Create a simple 'AssocRow' from a key and a value. where the key is a 'Text'. @@ -160,11 +181,13 @@ simpleField :: Monad m => Text -> HtmlT m () -> ListOf (AssocRow m) simpleField = field [] . toHtml -- | Create an 'AssocRow' from a key and a value where the value is an 'Html'. -fieldHtml :: Monad m => [Attribute] -> Text -> HtmlT m () -> ListOf (AssocRow m) +fieldHtml + :: Monad m => [Attribute] -> Text -> HtmlT m () -> ListOf (AssocRow m) fieldHtml as = field as . toHtml -- | Create an 'AssocRow' from a key and a value where the value is a 'Show' instance. -fieldShow :: (Show a, Monad m) => [Attribute] -> Text -> a -> ListOf (AssocRow m) +fieldShow + :: (Show a, Monad m) => [Attribute] -> Text -> a -> ListOf (AssocRow m) fieldShow attrs key val = field attrs (toHtml key) (toHtml $ show val) fadeInId :: Monad m => HtmlT m () @@ -178,29 +201,36 @@ fadeInId = -- whenever some specific events are received from an SSE endpoint. -- It also self populate on load. sseH - :: Link + :: Monad m + => Link -- ^ Link to fetch data from -> Text -- ^ Target element -> [Text] -- ^ Events to trigger onto - -> Monad m - => HtmlT m () -sseH link target events = do - do - div_ - [ hxTrigger_ triggered - , hxGet_ $ linkText link - , hxTarget_ $ "#" <> target - , hxSwap_ "innerHTML" - ] - $ div_ - [ id_ target - , hxGet_ $ linkText link - , hxTrigger_ "load" - , class_ "smooth" - ] - "" + -> HtmlT m () +sseH link = sseWithControlsH attrs + where + attrs = [hxGet_ $ linkText link] + +sseWithControlsH + :: Monad m => [Attribute] -> Text -> [Text] -> HtmlT m () +sseWithControlsH attrs target events = do + div_ + ( [ hxTrigger_ triggered + , hxTarget_ $ "#" <> target + , hxSwap_ "innerHTML" + ] + <> attrs + ) + $ div_ + ( [ id_ target + , hxTrigger_ "load" + , class_ "smooth" + ] + <> attrs + ) + "" where triggered = T.intercalate "," $ ("sse:" <>) <$> events @@ -254,7 +284,23 @@ showThousandDots = reverse . showThousandDots' . reverse . show in a <> if null b then [] else "." <> showThousandDots' b -box :: Monad m => HtmlT m () -> HtmlT m () -> HtmlT m () -> HtmlT m () +addressH :: Monad m => WithCopy -> Address -> HtmlT m () +addressH copy addr = + truncatableText copy ("address-text-" <> encodedAddr) + $ toHtml encodedAddr + where + encodedAddr = encodeAddress addr + +-- | A box with a title, a subtitle and a content. +box + :: Monad m + => HtmlT m () + -- ^ title + -> HtmlT m () + -- ^ subtitle + -> HtmlT m () + -- ^ content + -> HtmlT m () box x y z = div_ [class_ "bg-body-secondary pb-1"] $ do nav_ [class_ "navbar p-1 justify-content-center pb-0"] $ do diff --git a/lib/ui/src/Cardano/Wallet/UI/Common/Html/Pages/Wallet.hs b/lib/ui/src/Cardano/Wallet/UI/Common/Html/Pages/Wallet.hs index 6ff0b8b3a78..35ffb72cea6 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Common/Html/Pages/Wallet.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Common/Html/Pages/Wallet.hs @@ -144,12 +144,11 @@ mnemonicSetupFieldsH walletMnemonicLink PostWalletConfig{..} = do , name_ "name" , placeholder_ "Wallet Name" ] - onShelley - $ input_ + input_ [ formControl , type_ "password" , name_ "password" - , placeholder_ "Wallet Password" + , placeholder_ "Signing Passphrase" ] div_ [class_ "d-flex justify-content-end align-items-center"] $ do button_ diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/API.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/API.hs index 5f08fe7745d..24db8beafba 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/API.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/API.hs @@ -15,7 +15,7 @@ import Cardano.Wallet.Deposit.Read ( TxId ) import Cardano.Wallet.Deposit.REST.Wallet.Create - ( PostWalletViaMenmonic + ( PostWalletViaMnemonic , PostWalletViaXPub ) import Cardano.Wallet.Read @@ -42,9 +42,18 @@ import Cardano.Wallet.UI.Deposit.API.Common import Cardano.Wallet.UI.Deposit.API.Deposits.Deposits ( DepositsParams ) +import Cardano.Wallet.UI.Deposit.API.Payments + ( NewReceiver + , NewReceiverValidation + , TransactionExport + , WithReceivers + ) import Control.Lens ( makePrisms ) +import Data.Text + ( Text + ) import Data.Time ( UTCTime ) @@ -69,7 +78,7 @@ import Web.FormUrlEncoded import qualified Data.ByteString.Lazy as BL -instance FromForm PostWalletViaMenmonic +instance FromForm PostWalletViaMnemonic instance FromForm PostWalletViaXPub @@ -80,6 +89,7 @@ data Page | Wallet | Addresses | Deposits + | Payments makePrisms ''Page @@ -90,6 +100,7 @@ instance ToHttpApiData Page where toUrlPiece Wallet = "wallet" toUrlPiece Addresses = "addresses" toUrlPiece Deposits = "deposits" + toUrlPiece Payments = "payments" instance FromHttpApiData Page where parseUrlPiece "about" = Right About @@ -98,6 +109,7 @@ instance FromHttpApiData Page where parseUrlPiece "wallet" = Right Wallet parseUrlPiece "addresses" = Right Addresses parseUrlPiece "deposits" = Right Deposits + parseUrlPiece "payments" = Right Payments parseUrlPiece _ = Left "Invalid page" -- | Pages endpoints @@ -108,6 +120,7 @@ type Pages = :<|> "wallet" :> SessionedHtml Get :<|> "addresses" :> SessionedHtml Get :<|> "deposits" :> SessionedHtml Get + :<|> "payments" :> SessionedHtml Get -- | Data endpoints type Data = @@ -123,7 +136,7 @@ type Data = :<|> "wallet" :> SessionedHtml Get :<|> "wallet" :> "mnemonic" - :> ReqBody '[FormUrlEncoded] PostWalletViaMenmonic + :> ReqBody '[FormUrlEncoded] PostWalletViaMnemonic :> SessionedHtml Post :<|> "wallet" :> "xpub" @@ -187,6 +200,41 @@ type Data = :> QueryParam "customer" Customer :> QueryParam "tx-id" TxId :> SessionedHtml Post + :<|> "payments" :> SessionedHtml Get + :<|> "payments" + :> "receiver" + :> ReqBody '[FormUrlEncoded] NewReceiver + :> SessionedHtml Post + :<|> "payments" + :> "receiver" + :> "delete" + :> ReqBody '[FormUrlEncoded] (WithReceivers ()) + :> QueryParam "receiver-number" Int + :> SessionedHtml Post + :<|> "payments" + :> "balance" + :> "available" + :> SessionedHtml Get + :<|> "payments" + :> "receiver" + :> "address" + :> "validation" + :> ReqBody '[FormUrlEncoded] NewReceiverValidation + :> SessionedHtml Post + :<|> "payments" + :> "receiver" + :> "amount" + :> "validation" + :> ReqBody '[FormUrlEncoded] NewReceiverValidation + :> SessionedHtml Post + :<|> "modal" :> "info" + :> QueryParam "title" Text + :> QueryParam "text" Text + :> SessionedHtml Get + :<|> "payments" + :> "restore" + :> ReqBody '[FormUrlEncoded] TransactionExport + :> SessionedHtml Post type Home = SessionedHtml Get @@ -205,6 +253,7 @@ settingsPageLink :: Link walletPageLink :: Link addressesPageLink :: Link depositPageLink :: Link +paymentsPageLink :: Link networkInfoLink :: Link settingsGetLink :: Link settingsSseToggleLink :: Link @@ -232,6 +281,14 @@ depositsTxIdsLink :: Maybe (WithOrigin UTCTime) -> Maybe Customer -> Maybe Expand -> Link depositsTxIdsPaginatingLink :: Maybe (WithOrigin UTCTime) -> Maybe Customer -> Maybe TxId -> Link +paymentsLink :: Link +paymentsNewReceiverLink :: Link +paymentsDeleteReceiverLink :: Maybe Int -> Link +paymentsBalanceAvailableLink :: Link +paymentsReceiverAddressValidationLink :: Link +paymentsReceiverAmountValidationLink :: Link +modalLink :: Maybe Text -> Maybe Text -> Link +paymentsRestoreLink :: Link homePageLink :<|> aboutPageLink :<|> networkPageLink @@ -239,6 +296,7 @@ homePageLink :<|> walletPageLink :<|> addressesPageLink :<|> depositPageLink + :<|> paymentsPageLink :<|> networkInfoLink :<|> settingsGetLink :<|> settingsSseToggleLink @@ -260,5 +318,13 @@ homePageLink :<|> depositsCustomersLink :<|> depositsCustomersPaginatingLink :<|> depositsTxIdsLink - :<|> depositsTxIdsPaginatingLink = + :<|> depositsTxIdsPaginatingLink + :<|> paymentsLink + :<|> paymentsNewReceiverLink + :<|> paymentsDeleteReceiverLink + :<|> paymentsBalanceAvailableLink + :<|> paymentsReceiverAddressValidationLink + :<|> paymentsReceiverAmountValidationLink + :<|> modalLink + :<|> paymentsRestoreLink = allLinks (Proxy @UI) diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/API/Payments.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/API/Payments.hs new file mode 100644 index 00000000000..c091e736230 --- /dev/null +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/API/Payments.hs @@ -0,0 +1,109 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE NumericUnderscores #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# OPTIONS_GHC -Wno-orphans #-} + +module Cardano.Wallet.UI.Deposit.API.Payments + ( NewReceiver (..) + , WithReceivers (..) + , NewReceiverValidation (..) + , TransactionExport (..) + ) +where + +import Prelude + +import Cardano.Wallet.UI.Deposit.Types.Payments + ( Receiver (..) + ) +import Data.Aeson + ( FromJSON (parseJSON) + , KeyValue ((.=)) + , ToJSON (toJSON) + , object + , withObject + , (.:) + ) +import Data.Text + ( Text + ) + +import qualified Data.Aeson as Aeson +import qualified Data.Text.Lazy as T +import qualified Data.Text.Lazy.Encoding as TL +import Web.FormUrlEncoded + ( FromForm (..) + , lookupUnique + , parseAll + , parseMaybe + , parseUnique + ) + +data WithReceivers a + = WithReceivers + { receivers :: [Receiver] + , what :: a + } + +instance FromForm (WithReceivers ()) where + fromForm form = do + receivers <- parseAll "receiver-defined" form + pure + $ WithReceivers{receivers, what = ()} + +newtype NewReceiver = NewReceiver (WithReceivers Receiver) + +instance FromForm NewReceiver where + fromForm form = do + WithReceivers receivers () <- fromForm form + address <- parseUnique "new-receiver-address" form + amountDouble :: Double <- parseUnique "new-receiver-amount" form + let amount = round $ amountDouble * 1_000_000 + pure + $ NewReceiver + $ WithReceivers{receivers, what = Receiver{address, amount}} + +data NewReceiverValidation + = NewReceiverValidation + { addressValidation :: Maybe Text + , amountValidation :: Maybe Text + } + +instance FromForm NewReceiverValidation where + fromForm form = do + addressValidation <- parseMaybe "new-receiver-address" form + amountValidation <- parseMaybe "new-receiver-amount" form + pure $ NewReceiverValidation{addressValidation, amountValidation} + +data TransactionExport + = TransactionExport + { dataType :: Text + , description :: Text + , cborHex :: Text + } + deriving (Eq, Show) + +instance ToJSON TransactionExport where + toJSON TransactionExport{dataType, description, cborHex} = + object + [ "type" .= dataType + , "description" .= description + , "cborHex" .= cborHex + ] + +instance FromJSON TransactionExport where + parseJSON = withObject "TransactionExport" $ \o -> do + dataType <- o .: "type" + description <- o .: "description" + cborHex <- o .: "cborHex" + pure TransactionExport{dataType, description, cborHex} + +instance FromForm TransactionExport where + fromForm form = do + dataType <- lookupUnique "restore-transaction" form + case Aeson.decode $ TL.encodeUtf8 $ T.fromStrict dataType of + Nothing -> Left "Invalid JSON for a TransactionExport" + Just tx -> pure tx diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Handlers/Payments/Balance.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Handlers/Payments/Balance.hs new file mode 100644 index 00000000000..0dfc06d9b8b --- /dev/null +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Handlers/Payments/Balance.hs @@ -0,0 +1,36 @@ +module Cardano.Wallet.UI.Deposit.Handlers.Payments.Balance + ( getAvailableBalance + ) +where + +import Prelude + +import Cardano.Wallet.Deposit.REST + ( WalletResource + , availableBalance + ) +import Cardano.Wallet.Read + ( Coin (..) + , Value (..) + ) +import Cardano.Wallet.UI.Common.Layer + ( SessionLayer + ) +import Cardano.Wallet.UI.Deposit.Handlers.Lib + ( catchRunWalletResourceHtml + ) +import Servant + ( Handler + ) + +import qualified Data.ByteString.Lazy.Char8 as BL8 + +getAvailableBalance + :: SessionLayer WalletResource + -> (Coin -> html) + -> (BL8.ByteString -> html) + -> Handler html +getAvailableBalance layer render alert = + catchRunWalletResourceHtml layer alert id $ do + ValueC r _ <- availableBalance + pure $ render r diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Handlers/Payments/Transaction.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Handlers/Payments/Transaction.hs new file mode 100644 index 00000000000..0440f8af900 --- /dev/null +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Handlers/Payments/Transaction.hs @@ -0,0 +1,191 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} + +module Cardano.Wallet.UI.Deposit.Handlers.Payments.Transaction +where + +import Prelude + +import Cardano.Read.Ledger.Tx.CBOR + ( deserializeTx + , serializeTx + ) +import Cardano.Wallet.Deposit.Pure + ( CanSign + , CurrentEraResolvedTx + , ErrCreatePayment + , InspectTx (..) + ) +import Cardano.Wallet.Deposit.Read + ( Address + ) +import Cardano.Wallet.Deposit.REST + ( WalletResource + , WalletResourceM + , availableBalance + , canSign + , createPayment + , inspectTx + , resolveCurrentEraTx + ) +import Cardano.Wallet.Deposit.Write + ( Tx + , resolvedTx + ) +import Cardano.Wallet.Read + ( Coin (..) + , Value (..) + ) +import Cardano.Wallet.UI.Common.Layer + ( SessionLayer + ) +import Cardano.Wallet.UI.Deposit.API.Payments + ( NewReceiverValidation (..) + , TransactionExport (..) + ) +import Cardano.Wallet.UI.Deposit.Handlers.Lib + ( catchRunWalletResourceHtml + ) +import Cardano.Wallet.UI.Deposit.Types.Payments + ( Receiver (..) + ) +import Control.Monad + ( (<=<) + ) +import Data.Text + ( Text + ) +import Servant + ( FromHttpApiData (..) + , Handler + ) + +import qualified Data.ByteString.Base16 as B16 +import qualified Data.ByteString.Lazy as BL +import qualified Data.ByteString.Lazy.Char8 as BL8 +import qualified Data.Text.Encoding as T + +data PaymentHandlerResponse + = ResponseSuccess TransactionExport InspectTx CanSign + | ResponseExceptionPayments ErrCreatePayment + | ResponseNoReceivers + deriving (Eq, Show) + +createPaymentHandler + :: SessionLayer WalletResource + -> (BL.ByteString -> html) + -- ^ Function to render the exception as HTML + -> ( [Receiver] + -> Coin + -> PaymentHandlerResponse + -> html + ) + -- ^ Function to render the transaction cbor + -> Either Tx [Receiver] + -- ^ Current receivers + -> Handler html +createPaymentHandler layer alert render receiversOrTx = do + catchRunWalletResourceHtml layer alert id $ do + ValueC available _ <- availableBalance + receivers <- case receiversOrTx of + Left tx -> do + tx' <- resolveCurrentEraTx tx + itx <- inspectTx tx' + pure $ extractReceivers itx + Right rs -> pure rs + let render' = render receivers available + case receivers of + [] -> pure $ render' ResponseNoReceivers + _ -> do + respondCreatePayment render' <=< createPayment $ do + Receiver{address, amount} <- receivers + pure (address, ValueC (CoinC $ fromIntegral amount) mempty) + +conwayEraTransactionExport :: Text -> TransactionExport +conwayEraTransactionExport cborHex = + TransactionExport + { dataType = "Unwitnessed Tx ConwayEra" + , description = "Ledger Cddl Format" + , cborHex + } + +respondCreatePayment + :: (PaymentHandlerResponse -> html) + -> Either ErrCreatePayment CurrentEraResolvedTx + -> WalletResourceM html +respondCreatePayment render' r = do + case r of + Left e -> + pure $ render' $ ResponseExceptionPayments e + Right tx -> do + signing <- canSign + itx <- inspectTx tx + let + mt = + conwayEraTransactionExport + $ T.decodeUtf8 + $ B16.encode + $ BL.toStrict + $ serializeTx + $ resolvedTx tx + pure $ render' $ ResponseSuccess mt itx signing + +data AddressValidationResponse + = ValidAddress Address Bool + | InvalidAddress Text + +data AmountValidationResponse + = ValidAmount Double Bool + | InvalidAmount Text + +receiverAddressValidation + :: NewReceiverValidation + -> AddressValidationResponse +receiverAddressValidation + nrv@NewReceiverValidation{addressValidation} = + case parseUrlPiece <$> addressValidation of + Nothing -> InvalidAddress "Address cannot be empty" + Just (Left e) -> InvalidAddress $ "Invalid address: " <> e + Just (Right addr) -> + ValidAddress addr + $ case receiverAmountValidation nrv of + ValidAmount _ _ -> True + _ -> False + +receiverAmountValidation + :: NewReceiverValidation -> AmountValidationResponse +receiverAmountValidation nrv@NewReceiverValidation{amountValidation} = + case parseUrlPiece <$> amountValidation of + Nothing -> InvalidAmount "Amount cannot be empty" + Just (Left e) -> InvalidAmount $ "Invalid amount: " <> e + Just (Right amount) + | amount <= 0 -> InvalidAmount "Amount must be positive" + | otherwise -> ValidAmount amount + $ case receiverAddressValidation nrv of + ValidAddress _ _ -> True + _ -> False + +restoreTransaction + :: SessionLayer WalletResource + -> (BL.ByteString -> html) + -> ([Receiver] -> Coin -> PaymentHandlerResponse -> html) + -> TransactionExport + -> Handler html +restoreTransaction layer alert render' TransactionExport{cborHex} = do + let eTx = case B16.decode $ T.encodeUtf8 cborHex of + Left e -> Left $ "Hex: " <> e + Right cbor -> case deserializeTx $ BL.fromStrict cbor of + Right tx -> Right tx + Left e -> Left $ "CBOR: " <> show e + case eTx of + Right tx -> createPaymentHandler layer alert render' $ Left tx + Left e -> pure $ alert $ BL8.pack e + +extractReceivers :: InspectTx -> [Receiver] +extractReceivers InspectTx{otherOutputs} = + [ Receiver + { address = addr + , amount = fromIntegral coin + } + | (addr, coin) <- otherOutputs + ] diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Handlers/Wallet.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Handlers/Wallet.hs index f0638fcd87e..7f0688ac590 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/Handlers/Wallet.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Handlers/Wallet.hs @@ -5,21 +5,21 @@ where import Prelude -import Cardano.Address.Derivation - ( XPub - ) import Cardano.Wallet.Deposit.Pure - ( Customer + ( Credentials + , Customer + ) +import Cardano.Wallet.Deposit.Pure.State.Creation + ( credentialsFromEncodedXPub + , credentialsFromMnemonics ) import Cardano.Wallet.Deposit.REST ( WalletResource , WalletResourceM ) import Cardano.Wallet.Deposit.REST.Wallet.Create - ( PostWalletViaMenmonic (..) + ( PostWalletViaMnemonic (..) , PostWalletViaXPub (..) - , decodeXPub - , xpubFromMnemonics ) import Cardano.Wallet.UI.Common.Layer ( Push (Push) @@ -61,25 +61,25 @@ initWalletWithXPub l@SessionLayer{sendSSE} alert render initWallet = do postMnemonicWallet :: SessionLayer WalletResource - -> (XPub -> Customer -> WalletResourceM ()) + -> (Credentials -> Customer -> WalletResourceM ()) -> (BL.ByteString -> html) -> (() -> html) - -> PostWalletViaMenmonic + -> PostWalletViaMnemonic -> Handler html postMnemonicWallet l initWallet alert render - (PostWalletViaMenmonic mnemonic customers) = do - let xpub = xpubFromMnemonics mnemonic + (PostWalletViaMnemonic mnemonic passphrase customers) = do + let credentials = credentialsFromMnemonics mnemonic passphrase initWalletWithXPub l alert render - $ initWallet xpub + $ initWallet credentials $ fromIntegral customers postXPubWallet :: SessionLayer WalletResource - -> (XPub -> Customer -> WalletResourceM ()) + -> (Credentials -> Customer -> WalletResourceM ()) -> (BL.ByteString -> html) -> (() -> html) -> PostWalletViaXPub @@ -90,16 +90,11 @@ postXPubWallet alert render (PostWalletViaXPub xpubText customers) = - case decodeXPub xpubText of - Left e -> pure $ alert $ BL.pack $ "Invalid base64: " <> e - Right Nothing -> - pure - $ alert - $ BL.pack - $ "Invalid xpub: " <> show xpubText - Right (Just xpub) -> + case credentialsFromEncodedXPub xpubText of + Left e -> pure $ alert $ BL.pack $ show e + Right credentials -> initWalletWithXPub l alert render - $ initWallet xpub + $ initWallet credentials $ fromIntegral customers walletIsLoading diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Common.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Common.hs index ded8aaa8d87..f8ae51d84a4 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Common.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Common.hs @@ -11,6 +11,8 @@ module Cardano.Wallet.UI.Deposit.Html.Common , showTimeSecs , withOriginH , valueH + , lovelaceH + , modalElementH ) where @@ -35,11 +37,19 @@ import Cardano.Wallet.Read.Hash ) import Cardano.Wallet.UI.Common.Html.Lib ( WithCopy (..) + , dataBsDismiss_ , truncatableText ) +import Cardano.Wallet.UI.Common.Html.Modal + ( ModalData (..) + , mkModal + ) import Data.Ord ( Down (..) ) +import Data.Text + ( Text + ) import Data.Text.Class ( ToText (..) ) @@ -51,12 +61,16 @@ import Data.Time import Lucid ( Html , ToHtml (..) + , button_ , class_ , span_ ) import Numeric ( showFFloatAlt ) +import Numeric.Natural + ( Natural + ) showTime :: UTCTime -> String showTime = formatTime defaultTimeLocale "%Y-%m-%d %H:%M" @@ -91,8 +105,28 @@ txIdH txId = txId valueH :: Value -> Html () -valueH (ValueC (CoinC c) _) = do - span_ $ toHtml $ a "" +valueH (ValueC (CoinC c) _) = lovelaceH $ fromIntegral c + +lovelaceH :: Natural -> Html () +lovelaceH c = do + span_ $ toHtml $ showLovelaceAsAda c span_ [class_ "opacity-25"] "₳" - where - a = showFFloatAlt @Double (Just 2) $ fromIntegral c / 1_000_000 + +showLovelaceAsAda :: Integral a => a -> String +showLovelaceAsAda c = + showFFloatAlt @Double (Just 2) (fromIntegral c / 1_000_000) "" + +modalElementH :: Maybe Text -> Maybe Text -> Html () +modalElementH (Just t) (Just b) = + mkModal + $ ModalData + { modalTitle = toHtml t + , modalBody = toHtml b + , modalFooter = + button_ + [ class_ "btn btn-secondary" + , dataBsDismiss_ "modal" + ] + "Dismiss" + } +modalElementH _ _ = mempty diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Addresses.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Addresses.hs index 20b6ffd5d83..1790d46c56f 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Addresses.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Addresses.hs @@ -10,9 +10,6 @@ import Prelude import Cardano.Wallet.Deposit.IO ( WalletPublicIdentity (..) ) -import Cardano.Wallet.Deposit.Read - ( Address - ) import Cardano.Wallet.UI.Common.Html.Htmx ( hxInclude_ , hxPost_ @@ -21,9 +18,7 @@ import Cardano.Wallet.UI.Common.Html.Htmx ) import Cardano.Wallet.UI.Common.Html.Lib ( AlertH - , WithCopy , linkText - , truncatableText ) import Cardano.Wallet.UI.Common.Html.Pages.Lib ( AssocRow @@ -45,9 +40,6 @@ import Cardano.Wallet.UI.Deposit.Html.Pages.Wallet ( WalletPresent (..) , onWalletPresentH ) -import Cardano.Wallet.UI.Lib.Address - ( encodeMainnetAddress - ) import Cardano.Wallet.UI.Lib.ListOf ( ListOf ) @@ -65,8 +57,6 @@ import Data.Time ) import Lucid ( Html - , HtmlT - , ToHtml (..) , class_ , div_ , id_ @@ -90,13 +80,6 @@ addressesH :: WHtml () addressesH = do sseH addressesLink "addresses" ["wallet"] -customerAddressH :: Monad m => WithCopy -> Address -> HtmlT m () -customerAddressH copy addr = - truncatableText copy ("address-text-" <> encodedAddr) - $ toHtml encodedAddr - where - encodedAddr = encodeMainnetAddress addr - addressElementH :: UTCTime -> UTCTime -> AlertH -> WalletPresent -> Html () addressElementH now origin = onWalletPresentH $ \case diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Addresses/Transactions.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Addresses/Transactions.hs index 102235b39ae..e451e7f555a 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Addresses/Transactions.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Addresses/Transactions.hs @@ -126,7 +126,7 @@ customerHistoryH -> HtmlT m () customerHistoryH params@TransactionHistoryParams{..} txs = table_ - [ class_ "border-top table table-striped table-hover m-0" + [ class_ "border-top table table-sm table-striped table-hover m-0" ] $ do thead_ diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Deposits/Customers.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Deposits/Customers.hs index dc84958b9e4..0c55da3f0be 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Deposits/Customers.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Deposits/Customers.hs @@ -120,7 +120,7 @@ scrollableDepositsCustomers scrollableWidget :: [Attribute] -> Html () -> Html () scrollableWidget attrs content = do let attrs' = - [ class_ "border-top table table-striped table-hover m-0" + [ class_ "border-top table table-sm table-striped table-hover m-0" ] table_ (attrs' <> attrs) $ do diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Deposits/TxIds.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Deposits/TxIds.hs index 9869b9483d8..107749452e7 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Deposits/TxIds.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Deposits/TxIds.hs @@ -102,7 +102,7 @@ scrollableDepositsCustomersTxIds scrollableWidget :: [Attribute] -> Html () -> Html () scrollableWidget attrs content = do let attrs' = - [ class_ "border-top table table-striped table-hover m-0" + [ class_ "border-top table-sm table table-striped table-hover m-0" ] table_ (attrs' <> attrs) $ do diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Page.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Page.hs index e9d326d06d2..e37e1607015 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Page.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Page.hs @@ -45,6 +45,7 @@ import Cardano.Wallet.UI.Deposit.API , _Addresses , _Deposits , _Network + , _Payments , _Settings , _Wallet , aboutPageLink @@ -55,6 +56,8 @@ import Cardano.Wallet.UI.Deposit.API , navigationLink , networkInfoLink , networkPageLink + , paymentsLink + , paymentsPageLink , settingsGetLink , settingsPageLink , sseLink @@ -69,6 +72,9 @@ import Cardano.Wallet.UI.Deposit.Html.Pages.Addresses import Cardano.Wallet.UI.Deposit.Html.Pages.Deposits.Page ( depositsH ) +import Cardano.Wallet.UI.Deposit.Html.Pages.Payments.Page + ( paymentsH + ) import Cardano.Wallet.UI.Deposit.Html.Pages.Wallet ( WalletPresent , isPresent @@ -111,6 +117,7 @@ page c p = RawHtml Wallet -> walletH Addresses -> addressesH Deposits -> depositsH depositsLink + Payments -> paymentsH paymentsLink headerH :: Monad m => Page -> HtmlT m () headerH p = sseH (navigationLink $ Just p) "header" ["wallet"] @@ -126,6 +133,9 @@ headerElementH p wp = <> [ (is' _Deposits, depositPageLink, "Deposits") | isPresent wp ] + <> [ (is' _Payments, paymentsPageLink, "Payments") + | isPresent wp + ] <> [ (is' _Network, networkPageLink, "Network") , (is' _Settings, settingsPageLink, "Settings") , (is' _About, aboutPageLink, "About") diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Payments/Page.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Payments/Page.hs new file mode 100644 index 00000000000..4632016dc31 --- /dev/null +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Payments/Page.hs @@ -0,0 +1,479 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE RecordWildCards #-} + +module Cardano.Wallet.UI.Deposit.Html.Pages.Payments.Page + ( paymentsH + , paymentsElementH + , receiversH + , updateReceiversH + , availableBalanceElementH + , receiverAddressValidationH + , receiverAmountValidationH + ) +where + +import Prelude + +import Cardano.Wallet.Deposit.Pure + ( CanSign (..) + , InspectTx (..) + ) +import Cardano.Wallet.Deposit.Pure.State.Payment.Inspect + ( transactionBalance + ) +import Cardano.Wallet.Read + ( Coin (..) + ) +import Cardano.Wallet.UI.Common.Html.Htmx + ( hxGet_ + , hxInclude_ + , hxPost_ + , hxSwapOob_ + , hxTarget_ + , hxTrigger_ + ) +import Cardano.Wallet.UI.Common.Html.Lib + ( WithCopy (..) + , linkText + , tdEnd + , thEnd + , truncatableText + ) +import Cardano.Wallet.UI.Common.Html.Modal + ( mkModalButton + ) +import Cardano.Wallet.UI.Common.Html.Pages.Lib + ( Striped (..) + , Width (..) + , addressH + , alertH + , box + , field + , record + , simpleField + , sseH + ) +import Cardano.Wallet.UI.Deposit.API + ( modalLink + , paymentsBalanceAvailableLink + , paymentsDeleteReceiverLink + , paymentsNewReceiverLink + , paymentsReceiverAddressValidationLink + , paymentsReceiverAmountValidationLink + , paymentsRestoreLink + ) +import Cardano.Wallet.UI.Deposit.API.Payments + ( TransactionExport + ) +import Cardano.Wallet.UI.Deposit.Handlers.Payments.Transaction + ( AddressValidationResponse (..) + , AmountValidationResponse (..) + , PaymentHandlerResponse (..) + ) +import Cardano.Wallet.UI.Deposit.Html.Common + ( lovelaceH + , txIdH + ) +import Cardano.Wallet.UI.Deposit.Types.Payments + ( Receiver (..) + ) +import Cardano.Wallet.UI.Type + ( WHtml + ) +import Control.Monad + ( forM_ + , when + ) +import Data.Text + ( Text + ) +import Data.Text.Class + ( ToText (..) + ) +import Lucid + ( Html + , ToHtml (..) + , button_ + , class_ + , data_ + , div_ + , hidden_ + , i_ + , id_ + , input_ + , name_ + , placeholder_ + , span_ + , table_ + , tbody_ + , thead_ + , tr_ + , type_ + , value_ + ) +import Servant + ( Link + , ToHttpApiData (toUrlPiece) + ) + +import qualified Data.Aeson as Aeson + +paymentsH :: Link -> WHtml () +paymentsH paymentsLink = do + sseH paymentsLink "payments-page" ["payments"] + +newReceiverH :: Html () +newReceiverH = do + tbody_ [id_ "new-receiver-form"] + $ tr_ + $ do + tdEnd + $ span_ [class_ "d-flex"] + $ do + div_ [id_ "receiver-address-validation"] mempty + input_ + [ class_ "form-control" + , type_ "text" + , name_ "new-receiver-address" + , hxPost_ + $ linkText + paymentsReceiverAddressValidationLink + , hxTarget_ "#receiver-address-validation" + , hxInclude_ "#new-receiver-form" + , hxTrigger_ "input" + , placeholder_ "payment address" + ] + tdEnd + $ span_ [class_ "d-flex"] + $ do + div_ [id_ "receiver-amount-validation"] mempty + input_ + [ class_ "form-control" + , type_ "text" + , name_ "new-receiver-amount" + , hxPost_ + $ linkText + paymentsReceiverAmountValidationLink + , hxTarget_ "#receiver-amount-validation" + , hxInclude_ "#new-receiver-form" + , hxTrigger_ "input" + , placeholder_ "amount in ada" + ] + + tdEnd + $ button_ + [ class_ "btn w-100" + , hxPost_ $ linkText paymentsNewReceiverLink + , hxTarget_ "#receivers" + , hxInclude_ "#receivers-state , #new-receiver-form" + , id_ "new-receiver-button" + ] + mempty + +receiversH :: [Receiver] -> Html () +receiversH rs = do + table_ [class_ "table table-sm"] $ do + thead_ $ do + tr_ $ do + thEnd Nothing "Address" + thEnd (Just 9) "Amount" + thEnd (Just 5) "Actions" + tbody_ [id_ "receivers-state"] + $ forM_ (zip [0 ..] rs) + $ \(n, r@Receiver{address, amount}) -> do + tr_ $ do + tdEnd $ do + addressH WithCopy address + input_ + [ hidden_ "" + , value_ $ toUrlPiece r + , name_ "receiver-defined" + ] + tdEnd $ lovelaceH amount + tdEnd + $ button_ + [ hxPost_ + $ linkText + $ paymentsDeleteReceiverLink + $ Just n + , hxTarget_ "#receivers" + , hxInclude_ "#receivers-state" + , class_ "btn w-100" + ] + $ i_ [class_ "bi bi-trash"] mempty + newReceiverH + +ifNotEmpty :: (Foldable t, Monoid b) => t a -> b -> b +ifNotEmpty xs b = if null xs then mempty else b + +unsignedTransactionH + :: TransactionExport + -> InspectTx + -> Html () +unsignedTransactionH mt InspectTx{..} = do + let table = table_ [class_ "table table-sm m-0"] + div_ [class_ ""] $ do + record (Just 7) Full NotStriped $ do + field [] "serialized tx" + $ transactionCBORH mt + field [] "fee" + $ lovelaceH + $ fromIntegral fee + field [] "our inputs" + $ ifNotEmpty ourInputs + $ table + $ do + thead_ $ do + tr_ $ do + thEnd Nothing "Transaction" + thEnd (Just 4) "Index" + thEnd (Just 7) "Amount" + tbody_ + $ forM_ ourInputs + $ \(txId, txIx, CoinC amount) -> do + tr_ $ do + tdEnd $ txIdH txId + tdEnd $ toHtml $ show $ fromEnum txIx + tdEnd $ lovelaceH $ fromIntegral amount + field [] "other inputs" + $ ifNotEmpty otherInputs + $ table + $ do + thead_ $ do + tr_ $ do + thEnd Nothing "Transaction" + thEnd (Just 4) "Index" + tbody_ + $ forM_ otherInputs + $ \(txId, txIx) -> do + tr_ $ do + tdEnd $ txIdH txId + tdEnd $ toHtml $ show $ fromEnum txIx + field [] "change" + $ ifNotEmpty change + $ table + $ do + thead_ $ do + tr_ $ do + thEnd Nothing "Change Address" + thEnd (Just 7) "Amount" + tbody_ + $ forM_ change + $ \(addr, CoinC amount) -> do + tr_ $ do + tdEnd $ addressH WithCopy addr + tdEnd $ lovelaceH $ fromIntegral amount + field [] "customer outputs" + $ ifNotEmpty ourOutputs + $ table + $ do + thead_ $ do + tr_ $ do + thEnd Nothing "Address" + thEnd (Just 6) "Customer" + thEnd (Just 7) "Amount" + tbody_ + $ forM_ ourOutputs + $ \(addr, customer, CoinC amount) -> do + tr_ $ do + tdEnd $ addressH WithCopy addr + tdEnd $ toHtml $ show customer + tdEnd $ lovelaceH $ fromIntegral amount + field [] "other outputs" + $ ifNotEmpty otherOutputs + $ table + $ do + thead_ $ do + tr_ $ do + thEnd Nothing "Address" + thEnd (Just 7) "Amount" + tbody_ + $ forM_ otherOutputs + $ \(addr, CoinC amount) -> do + tr_ $ do + tdEnd $ addressH WithCopy addr + tdEnd $ lovelaceH $ fromIntegral amount + +transactionCBORH :: TransactionExport -> Html () +transactionCBORH cbor = + truncatableText WithCopy "unsigned-transaction-copy" + $ toHtml + $ Aeson.encode cbor + +signatureH :: CanSign -> Html () +signatureH = \case + CanSign -> do + div_ [class_ "input-group"] $ do + input_ + [ id_ "signature-password" + , class_ "form-control" + , type_ "password" + , name_ "passphrase" + , placeholder_ "passphrase" + ] + button_ + [ class_ "btn" + -- , hxPost_ $ linkText paymentsLink + -- , hxTarget_ "#receivers" + , hxInclude_ "#signature-password" + ] + $ i_ [class_ "bi bi-check2-all"] mempty + CannotSign -> do + div_ [class_ "input-group"] $ do + input_ + [ class_ "form-control" + , type_ "text" + , name_ "signed-tx" + , placeholder_ "signed tx" + ] + button_ + [ class_ "btn primary" + ] + $ i_ [class_ "bi bi-check2-all"] mempty + +updateReceiversH + :: [Receiver] -> Coin -> PaymentHandlerResponse -> Html () +updateReceiversH rs balance transaction = do + receiversH rs + case transaction of + ResponseNoReceivers -> do + div_ [id_ "unsigned-transaction-error", hxSwapOob_ "innerHTML"] + $ alertH ("No transaction defined" :: Text) + div_ [id_ "signature", hxSwapOob_ "innerHTML"] mempty + ResponseExceptionPayments e -> do + div_ [id_ "unsigned-transaction-error", hxSwapOob_ "innerHTML"] + $ alertH + $ toText e + div_ [id_ "signature", hxSwapOob_ "innerHTML"] mempty + ResponseSuccess m tx canSign -> do + div_ [id_ "unsigned-transaction-inspection", hxSwapOob_ "innerHTML"] + $ unsignedTransactionH m tx + div_ [id_ "signature", hxSwapOob_ "innerHTML"] $ signatureH canSign + div_ + [id_ "unsigned-transaction-error", hxSwapOob_ "innerHTML"] + mempty + + div_ [id_ "available-balance", hxSwapOob_ "innerHTML"] + $ availableBalanceElementH balance + $ case transaction of + ResponseSuccess _ inspect _ -> + Just + $ fromIntegral + $ transactionBalance inspect + _ -> Nothing + div_ [id_ "restoration", hxSwapOob_ "innerHTML"] restoreH + +availableBalanceElementH :: Coin -> Maybe Coin -> Html () +availableBalanceElementH balance mTxBalance = + record Nothing Full NotStriped $ do + simpleField "Before transaction" + $ lovelaceH + $ fromIntegral balance + case mTxBalance of + Nothing -> pure () + Just txBalance -> do + simpleField "Transaction balance" + $ lovelaceH + $ fromIntegral txBalance + simpleField "After transaction" + $ lovelaceH + $ fromIntegral + $ balance - txBalance + +restoreH :: Html () +restoreH = div_ [class_ "input-group"] $ do + input_ + [ class_ "form-control" + , type_ "text" + , name_ "restore-transaction" + , placeholder_ "serialized tx" + ] + button_ + [ class_ "btn" + , hxPost_ $ linkText paymentsRestoreLink + , hxTarget_ "#receivers" + , hxInclude_ "#restoration" + ] + $ i_ [class_ "bi bi-upload"] mempty + +collapseBtn :: Text -> Html () +collapseBtn identifier = + button_ + [ class_ "btn" + , type_ "button" + , data_ "bs-toggle" "collapse" + , data_ + "bs-target" + $ "#" <> identifier + ] + $ i_ [class_ "bi bi-arrows-collapse"] mempty + +paymentsElementH + :: Html () +paymentsElementH = + div_ + [ class_ "row mt-3 gx-0" + ] + $ do + box "New" mempty + $ div_ [class_ "ms-3"] + $ do + box "Wallet balance" mempty + $ div_ + [ class_ "" + , id_ "available-balance" + , hxTrigger_ "load" + , hxGet_ $ linkText paymentsBalanceAvailableLink + , hxInclude_ "#receivers-state" + , hxTarget_ "#available-balance" + ] + mempty + box "Payments" mempty + $ do + div_ [class_ "", id_ "receivers"] + $ receiversH [] + div_ [id_ "unsigned-transaction-error"] mempty + box "Signature" mempty + $ div_ [id_ "signature"] mempty + box + "Inspection" + (collapseBtn "unsigned-transaction-inspection") + $ div_ + [ class_ "collapse" + , id_ "unsigned-transaction-inspection" + ] + mempty + box + "Restoration" + (collapseBtn "restoration") + $ div_ + [ class_ "collapse" + , id_ "restoration" + ] + restoreH + +receiverAddressValidationH :: AddressValidationResponse -> Html () +receiverAddressValidationH (ValidAddress _ m) = + div_ [id_ "new-receiver-button", hxSwapOob_ "innerHTML"] + $ when m + $ i_ [class_ "bi bi-plus-lg"] mempty +receiverAddressValidationH (InvalidAddress e) = do + validationFailedButton "Invalid Address" $ toText e + div_ [id_ "new-receiver-button"] mempty + +receiverAmountValidationH :: AmountValidationResponse -> Html () +receiverAmountValidationH (ValidAmount _ m) = do + div_ [id_ "new-receiver-button", hxSwapOob_ "innerHTML"] + $ when m + $ i_ [class_ "bi bi-plus-lg"] mempty +receiverAmountValidationH (InvalidAmount e) = do + validationFailedButton "Invalid Amount" $ toText e + div_ [id_ "new-receiver-button"] mempty + +validationFailedButton :: Text -> Text -> Html () +validationFailedButton t e = + mkModalButton + (modalLink (Just t) $ Just e) + [class_ "btn px-1"] + $ i_ [class_ "bi bi-exclamation-triangle text-danger-emphasis"] mempty diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Wallet.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Wallet.hs index 377ac8b908e..83f3edd2bd0 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Wallet.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Html/Pages/Wallet.hs @@ -64,10 +64,6 @@ import Cardano.Wallet.UI.Type import Control.Exception ( SomeException ) -import Data.ByteArray.Encoding - ( Base (..) - , convertToBase - ) import Data.ByteString ( ByteString ) @@ -88,6 +84,7 @@ import Lucid , p_ ) +import qualified Data.ByteString.Base16 as B16 import qualified Data.ByteString.Char8 as B8 import qualified Data.ByteString.Lazy.Char8 as BL @@ -114,14 +111,11 @@ instance Show WalletPresent where walletH :: WHtml () walletH = sseH walletLink "wallet" ["wallet"] -base64 :: ByteString -> ByteString -base64 = convertToBase Base64 - pubKeyH :: Monad m => XPub -> HtmlT m () pubKeyH xpub = truncatableText WithCopy "public_key" $ toHtml - $ base64 + $ B16.encode $ xpubToBytes xpub headAndTail :: Int -> ByteString -> ByteString diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Server.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Server.hs index 7a43fcf0294..6a74844fb35 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/Server.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Server.hs @@ -77,7 +77,8 @@ import Cardano.Wallet.UI.Deposit.Handlers.Lib ( walletPresence ) import Cardano.Wallet.UI.Deposit.Html.Common - ( showTimeSecs + ( modalElementH + , showTimeSecs ) import Cardano.Wallet.UI.Deposit.Html.Pages.Page ( Page (..) @@ -107,6 +108,15 @@ import Cardano.Wallet.UI.Deposit.Server.Deposits.TxIds import Cardano.Wallet.UI.Deposit.Server.Lib ( renderSmoothHtml ) +import Cardano.Wallet.UI.Deposit.Server.Payments.Page + ( servePaymentsBalanceAvailable + , servePaymentsDeleteReceiver + , servePaymentsNewReceiver + , servePaymentsPage + , servePaymentsReceiverAddressValidation + , servePaymentsReceiverAmountValidation + , servePaymentsRestore + ) import Cardano.Wallet.UI.Deposit.Server.Wallet ( serveDeleteWallet , serveDeleteWalletModal @@ -124,6 +134,9 @@ import Control.Tracer import Data.Functor ( ($>) ) +import Data.Text + ( Text + ) import Paths_cardano_wallet_ui ( getDataFileName ) @@ -159,6 +172,7 @@ serveUI tr ul env dbDir config nid nl bs = :<|> serveTabPage ul config Wallet :<|> serveTabPage ul config Addresses :<|> serveTabPage ul config Deposits + :<|> serveTabPage ul config Payments :<|> serveNetworkInformation nid nl bs :<|> serveSSESettings ul :<|> serveToggleSSE ul @@ -181,6 +195,23 @@ serveUI tr ul env dbDir config nid nl bs = :<|> serveDepositsCustomerPagination ul :<|> serveDepositsCustomersTxIds ul :<|> serveDepositsCustomersTxIdsPagination ul + :<|> servePaymentsPage ul + :<|> servePaymentsNewReceiver ul + :<|> servePaymentsDeleteReceiver ul + :<|> servePaymentsBalanceAvailable ul + :<|> servePaymentsReceiverAddressValidation ul + :<|> servePaymentsReceiverAmountValidation ul + :<|> serveModal ul + :<|> servePaymentsRestore ul + +serveModal + :: UILayer WalletResource + -> Maybe Text + -> Maybe Text + -> Maybe RequestCookies + -> Handler (CookieResponse RawHtml) +serveModal ul mtitle mbody = withSessionLayer ul $ \_ -> pure $ + renderSmoothHtml $ modalElementH mtitle mbody serveTabPage :: UILayer s diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Server/Addresses.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Server/Addresses.hs index d55ec879826..c5cabea363a 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/Server/Addresses.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Server/Addresses.hs @@ -23,7 +23,8 @@ import Cardano.Wallet.UI.Common.Html.Lib ( WithCopy (..) ) import Cardano.Wallet.UI.Common.Html.Pages.Lib - ( alertH + ( addressH + , alertH ) import Cardano.Wallet.UI.Common.Layer ( UILayer (..) @@ -44,7 +45,6 @@ import Cardano.Wallet.UI.Deposit.Handlers.Addresses.Transactions ) import Cardano.Wallet.UI.Deposit.Html.Pages.Addresses ( addressElementH - , customerAddressH ) import Cardano.Wallet.UI.Deposit.Html.Pages.Addresses.Transactions ( customerHistoryH @@ -80,7 +80,7 @@ serveGetAddress serveGetAddress ul c = withSessionLayer ul $ \l -> do getCustomerAddress l - (renderSmoothHtml . customerAddressH WithCopy) + (renderSmoothHtml . addressH WithCopy) alert c diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Server/Payments/Page.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Server/Payments/Page.hs new file mode 100644 index 00000000000..18d7408b9c3 --- /dev/null +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Server/Payments/Page.hs @@ -0,0 +1,157 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module Cardano.Wallet.UI.Deposit.Server.Payments.Page + ( servePaymentsPage + , servePaymentsNewReceiver + , servePaymentsDeleteReceiver + , servePaymentsBalanceAvailable + , servePaymentsReceiverAddressValidation + , servePaymentsReceiverAmountValidation + , servePaymentsRestore + ) +where + +import Prelude + +import Cardano.Wallet.Deposit.REST + ( WalletResource + ) +import Cardano.Wallet.UI.Common.Handlers.Session + ( withSessionLayer + ) +import Cardano.Wallet.UI.Common.Html.Html + ( RawHtml (..) + , renderHtml + ) +import Cardano.Wallet.UI.Common.Html.Pages.Lib + ( alertH + ) +import Cardano.Wallet.UI.Common.Layer + ( UILayer (..) + ) +import Cardano.Wallet.UI.Cookies + ( CookieResponse + , RequestCookies + ) +import Cardano.Wallet.UI.Deposit.API.Payments + ( NewReceiver (..) + , NewReceiverValidation + , TransactionExport (..) + , WithReceivers (..) + ) +import Cardano.Wallet.UI.Deposit.Handlers.Lib + ( walletPresence + ) +import Cardano.Wallet.UI.Deposit.Handlers.Payments.Balance + ( getAvailableBalance + ) +import Cardano.Wallet.UI.Deposit.Handlers.Payments.Transaction + ( createPaymentHandler + , receiverAddressValidation + , receiverAmountValidation + , restoreTransaction + ) +import Cardano.Wallet.UI.Deposit.Html.Pages.Payments.Page + ( availableBalanceElementH + , paymentsElementH + , receiverAddressValidationH + , receiverAmountValidationH + , updateReceiversH + ) +import Cardano.Wallet.UI.Deposit.Server.Lib + ( renderSmoothHtml + ) +import Servant + ( Handler + ) + +servePaymentsPage + :: UILayer WalletResource + -> Maybe RequestCookies + -> Handler (CookieResponse RawHtml) +servePaymentsPage ul = withSessionLayer ul $ \_layer -> do + pure $ renderSmoothHtml paymentsElementH + +servePaymentsNewReceiver + :: UILayer WalletResource + -> NewReceiver + -> Maybe RequestCookies + -> Handler (CookieResponse RawHtml) +servePaymentsNewReceiver + ul + (NewReceiver WithReceivers{receivers, what = newReceiver}) = + withSessionLayer ul $ \layer -> do + let newReceivers = receivers ++ [newReceiver] + renderHtml + <$> createPaymentHandler + layer + alertH + updateReceiversH + (Right newReceivers) + +servePaymentsDeleteReceiver + :: UILayer WalletResource + -> WithReceivers () + -> Maybe Int + -> Maybe RequestCookies + -> Handler (CookieResponse RawHtml) +servePaymentsDeleteReceiver + ul + WithReceivers{receivers} + (Just receiverN) = + withSessionLayer ul $ \layer -> do + _wp <- walletPresence layer + let newReceivers = + fmap snd + $ filter ((/= receiverN) . fst) + $ zip [0 ..] receivers + renderHtml + <$> createPaymentHandler + layer + alertH + updateReceiversH + (Right newReceivers) +servePaymentsDeleteReceiver _ _ _ = + error "servePaymentsDeleteReceiver: receiver-number is required" + +servePaymentsBalanceAvailable + :: UILayer WalletResource + -> Maybe RequestCookies + -> Handler (CookieResponse RawHtml) +servePaymentsBalanceAvailable ul = withSessionLayer ul $ \layer -> do + renderSmoothHtml + <$> getAvailableBalance + layer + (`availableBalanceElementH` Nothing) + alertH + +servePaymentsReceiverAddressValidation + :: UILayer WalletResource + -> NewReceiverValidation + -> Maybe RequestCookies + -> Handler (CookieResponse RawHtml) +servePaymentsReceiverAddressValidation ul receiver = withSessionLayer ul + $ \_ -> do + let response = receiverAddressValidation receiver + pure $ renderHtml $ receiverAddressValidationH response + +servePaymentsReceiverAmountValidation + :: UILayer WalletResource + -> NewReceiverValidation + -> Maybe RequestCookies + -> Handler (CookieResponse RawHtml) +servePaymentsReceiverAmountValidation ul amount = withSessionLayer ul + $ \_ -> do + let response = receiverAmountValidation amount + pure $ renderHtml $ receiverAmountValidationH response + +servePaymentsRestore + :: UILayer WalletResource + -> TransactionExport + -> Maybe RequestCookies + -> Handler (CookieResponse RawHtml) +servePaymentsRestore ul txe = + withSessionLayer ul $ \layer -> do + renderHtml + <$> restoreTransaction layer alertH + updateReceiversH txe diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Server/Wallet.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Server/Wallet.hs index 6a8ad4bdfa2..4c8d863b4e3 100644 --- a/lib/ui/src/Cardano/Wallet/UI/Deposit/Server/Wallet.hs +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Server/Wallet.hs @@ -16,7 +16,7 @@ import Cardano.Wallet.Deposit.IO import Cardano.Wallet.Deposit.REST ( WalletResource , deleteWallet - , initXPubWallet + , initWallet ) import Cardano.Wallet.UI.Common.Handlers.Session ( withSessionLayer @@ -71,7 +71,7 @@ import Servant ) import Cardano.Wallet.Deposit.REST.Wallet.Create - ( PostWalletViaMenmonic + ( PostWalletViaMnemonic , PostWalletViaXPub ) @@ -96,15 +96,15 @@ servePostMnemonicWallet -> WalletBootEnv IO -> FilePath -> UILayer WalletResource - -> PostWalletViaMenmonic + -> PostWalletViaMnemonic -> Maybe RequestCookies -> Handler (CookieResponse RawHtml) servePostMnemonicWallet tr env dbDir ul request = withSessionLayer ul $ \layer -> do - postMnemonicWallet layer initWallet alert ok request + postMnemonicWallet layer initWallet' alert ok request where ok _ = renderHtml . rogerH @Text $ "ok" - initWallet = initXPubWallet tr env dbDir + initWallet' = initWallet tr env dbDir servePostXPubWallet :: Tracer IO String @@ -116,10 +116,10 @@ servePostXPubWallet -> Handler (CookieResponse RawHtml) servePostXPubWallet tr env dbDir ul request = withSessionLayer ul $ \layer -> do - postXPubWallet layer initWallet alert ok request + postXPubWallet layer initWallet' alert ok request where ok _ = renderHtml . rogerH @Text $ "ok" - initWallet = initXPubWallet tr env dbDir + initWallet' = initWallet tr env dbDir serveDeleteWallet :: UILayer WalletResource @@ -137,19 +137,3 @@ serveDeleteWalletModal -> Handler (CookieResponse RawHtml) serveDeleteWalletModal ul = withSessionLayer ul $ \_ -> pure $ renderSmoothHtml deleteWalletModalH - -{- :<|> (\c -> ) - :<|> wsl (\l -> deleteWalletHandler l (deleteWallet dbDir) alert ok) - :<|> wsl (\_l -> pure $ renderSmoothHtml deleteWalletModalH) - :<|> ( \c -> - wsl - ( \l -> - getCustomerAddress - l - ( renderSmoothHtml - . customerAddressH WithCopy - ) - alert - c - ) - ) -} diff --git a/lib/ui/src/Cardano/Wallet/UI/Deposit/Types/Payments.hs b/lib/ui/src/Cardano/Wallet/UI/Deposit/Types/Payments.hs new file mode 100644 index 00000000000..7d22d2a4527 --- /dev/null +++ b/lib/ui/src/Cardano/Wallet/UI/Deposit/Types/Payments.hs @@ -0,0 +1,62 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# OPTIONS_GHC -Wno-orphans #-} + +module Cardano.Wallet.UI.Deposit.Types.Payments + ( Receiver (..) + ) +where + +import Prelude + +import Cardano.Wallet.Deposit.Pure.API.Address + ( decodeAddress + , encodeAddress + ) +import Cardano.Wallet.Deposit.Read + ( Address + ) +import Numeric.Natural + ( Natural + ) +import Web.HttpApiData + ( FromHttpApiData (parseUrlPiece) + , ToHttpApiData (toUrlPiece) + ) + +import qualified Data.Text as T + +-- | A receiver of a payment. +data Receiver = Receiver + { address :: Address + -- ^ The address of the receiver. + , amount :: Natural + -- ^ The amount of lovelace to send to the receiver. + } + deriving (Eq, Show) + +instance FromHttpApiData Receiver where + parseUrlPiece t = case T.splitOn "," t of + [addressText, amountText] -> do + amount :: Natural <- case reads (T.unpack amountText) of + [(n, "")] -> pure n + _ -> Left "Amount must be a number" + address <- parseUrlPiece addressText + pure $ Receiver{address, amount} + _ -> Left "Receiver must be in the format 'address,amount'" + +instance ToHttpApiData Receiver where + toUrlPiece Receiver{address, amount} = + T.intercalate "," + [ encodeAddress address + , T.pack $ show amount + ] + +instance FromHttpApiData Address where + parseUrlPiece t = case decodeAddress t of + Left err -> Left $ T.pack $ show err + Right address -> pure address diff --git a/lib/ui/test/unit/Cardano/Wallet/UI/Deposit/Html/Pages/Payments/PageSpec.hs b/lib/ui/test/unit/Cardano/Wallet/UI/Deposit/Html/Pages/Payments/PageSpec.hs new file mode 100644 index 00000000000..19551db1036 --- /dev/null +++ b/lib/ui/test/unit/Cardano/Wallet/UI/Deposit/Html/Pages/Payments/PageSpec.hs @@ -0,0 +1,110 @@ +{-# LANGUAGE LambdaCase #-} +{-# OPTIONS_GHC -Wno-unused-top-binds #-} + +module Cardano.Wallet.UI.Deposit.Html.Pages.Payments.PageSpec + ( spec + ) +where + +import Prelude + +import Cardano.Wallet.Deposit.IO + ( WalletBootEnv (WalletBootEnv) + ) +import Cardano.Wallet.Deposit.IO.Resource + ( withResource + ) +import Cardano.Wallet.Deposit.Pure + ( Credentials + ) +import Cardano.Wallet.Deposit.Pure.State.Creation + ( credentialsFromMnemonics + ) +import Cardano.Wallet.Deposit.Pure.State.Payment + ( ErrCreatePayment (..) + ) +import Cardano.Wallet.Deposit.REST + ( ErrWalletResource (..) + , WalletResourceM + , initWallet + , loadWallet + , runWalletResourceM + ) +import Cardano.Wallet.UI.Deposit.Handlers.Payments.Transaction + ( PaymentHandlerResponse (ResponseExceptionPayments) + , respondCreatePayment + ) +import Control.Concurrent + ( threadDelay + ) +import Control.Monad.IO.Class + ( MonadIO (..) + ) +import Control.Tracer + ( nullTracer + ) +import Test.Hspec + ( Spec + , describe + , it + , shouldBe + ) + +import qualified Cardano.Wallet.Deposit.Read as Read + +fakeBootEnv :: WalletBootEnv IO +fakeBootEnv = WalletBootEnv nullTracer Read.mockGenesisDataMainnet undefined + +credentials :: Credentials +credentials = + credentialsFromMnemonics "random seed for a testing xpub lala" mempty + +letItInitialize :: WalletResourceM () +letItInitialize = liftIO $ threadDelay 100000 + +onSuccess :: (Show e, MonadFail m) => Either e a -> (a -> m b) -> m b +onSuccess (Left e) _ = fail $ show e +onSuccess (Right a) f = f a + +matchEmptyValue :: Show e => Either e Read.Value -> IO () +matchEmptyValue x = onSuccess x $ \v -> v `shouldBe` mempty + +withWallet :: WalletResourceM a -> IO (Either ErrWalletResource a) +withWallet f = withResource $ runWalletResourceM f + +withRightWallet :: WalletResourceM a -> IO a +withRightWallet f = + withWallet f >>= \case + Left e -> fail $ show e + Right a -> pure a + +withInitializedWallet + :: FilePath + -> WalletResourceM a + -> IO (Either ErrWalletResource a) +withInitializedWallet dir f = withWallet $ do + initWallet nullTracer fakeBootEnv dir credentials 0 + letItInitialize + f + +withLoadedWallet + :: FilePath + -> WalletResourceM a + -> IO (Either ErrWalletResource a) +withLoadedWallet dir f = withWallet $ do + loadWallet fakeBootEnv dir + letItInitialize + f + +spec :: Spec +spec = do + describe "create payment handler" $ do + it "responds with RenderExceptionPayments on handler failure" $ do + let failure = + ErrCreatePaymentNotRecentEra + $ Read.EraValue Read.Babbage + render (ResponseExceptionPayments _) = () + render e = error $ "unexpected: " <> show e + withRightWallet + $ respondCreatePayment render + $ Left failure