diff --git a/.github/workflows/code_check_backend.yml b/.github/workflows/code_check_backend.yml index fca3fdf7c..747818b1c 100644 --- a/.github/workflows/code_check_backend.yml +++ b/.github/workflows/code_check_backend.yml @@ -13,8 +13,9 @@ jobs: - name: Use Haskell uses: haskell-actions/setup@v2 with: - ghc-version: '9.2.7' - cabal-version: '3.6.0.0' + ghc-version: '9.2.8' + cabal-version: '3.8.1.0' + cabal-update: false - name: Use Python uses: actions/setup-python@v2 @@ -26,6 +27,13 @@ jobs: - name: Install HLint run: | + rm -rf ~/.cabal ~/.ghcup + mkdir -p ~/.cabal + cat < ~/.cabal/config + repository hackage.haskell.org + url: http://hackage.haskell.org/ + secure: False + EOF cabal update cabal install hlint @@ -41,8 +49,9 @@ jobs: - name: Use Haskell uses: haskell-actions/setup@v2 with: - ghc-version: '9.2.7' - cabal-version: '3.6.0.0' + ghc-version: '9.2.8' + cabal-version: '3.8.1.0' + cabal-update: false - name: Use Python uses: actions/setup-python@v2 @@ -54,6 +63,13 @@ jobs: - name: Install HLint run: | + rm -rf ~/.cabal ~/.ghcup + mkdir -p ~/.cabal + cat < ~/.cabal/config + repository hackage.haskell.org + url: http://hackage.haskell.org/ + secure: False + EOF cabal update cabal install stylish-haskell diff --git a/.github/workflows/merge.yaml b/.github/workflows/merge.yaml index 0511b4023..a753c4c22 100644 --- a/.github/workflows/merge.yaml +++ b/.github/workflows/merge.yaml @@ -12,11 +12,11 @@ permissions: packages: write env: - ENVIRONMENT: ${{ (github.ref_name == 'main' && 'prod-govtool') || (github.ref_name == 'staging' && 'pre-prod-govtool') || (github.ref_name == 'test' && 'qa-govtool') || (github.ref_name == 'develop' && 'dev-govtool') }} + ENVIRONMENT: ${{ (github.ref_name == 'develop' && 'dev-govtool') }} jobs: check-build-deploy: - environment: ${{ (github.ref_name == 'main' && 'prod-govtool') || (github.ref_name == 'staging' && 'pre-prod-govtool') || (github.ref_name == 'test' && 'qa-govtool') || (github.ref_name == 'develop' && 'dev-govtool') }} + environment: ${{ (github.ref_name == 'develop' && 'dev-govtool') }} strategy: fail-fast: false matrix: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c630fce7..911cd6242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ changes. ### Fixed - Fix disappearing proposals in the governance actions list for the same tx hashes [Issue 3918](https://github.com/IntersectMBO/govtool/issues/3918) +- Fix incorrect display of new committee parameters in Governance Action details [Issue 3954](https://github.com/IntersectMBO/govtool/issues/3954) ### Changed diff --git a/govtool/backend/app/Main.hs b/govtool/backend/app/Main.hs index d82821424..4ba525ad0 100644 --- a/govtool/backend/app/Main.hs +++ b/govtool/backend/app/Main.hs @@ -16,6 +16,7 @@ import Control.Monad.Trans.Except import Control.Monad.Trans.Reader import Data.Aeson hiding (Error) +import Data.Aeson (encode) import qualified Data.ByteString as BS import Data.ByteString.Char8 (unpack) import qualified Data.Cache as Cache @@ -35,7 +36,7 @@ import Data.Text.Encoding (encodeUtf8) import qualified Data.Text.IO as Text import qualified Data.Text.Lazy as LazyText import qualified Data.Text.Lazy.Encoding as LazyText - +import qualified Data.ByteString.Lazy.Char8 as BS8 import Database.PostgreSQL.Simple (close, connectPostgreSQL, Connection) import Network.Wai @@ -62,8 +63,10 @@ import VVA.API.Types import VVA.CommandLine import VVA.Config import VVA.Types (AppEnv (..), - AppError (CriticalError, InternalError, NotFoundError, ValidationError), + AppError (..), CacheEnv (..)) +import VVA.Ipfs (IpfsError(..)) + -- Function to create a connection pool with optimized settings createOptimizedConnectionPool :: BS.ByteString -> IO (Pool Connection) @@ -288,10 +291,15 @@ liftServer appEnv = where handleErrors :: Either AppError a -> Handler a handleErrors (Right x) = pure x - handleErrors (Left (ValidationError msg)) = throwError $ err400 { errBody = BS.fromStrict $ encodeUtf8 msg } - handleErrors (Left (NotFoundError msg)) = throwError $ err404 { errBody = BS.fromStrict $ encodeUtf8 msg } - handleErrors (Left (CriticalError msg)) = throwError $ err500 { errBody = BS.fromStrict $ encodeUtf8 msg } - handleErrors (Left (InternalError msg)) = throwError $ err500 { errBody = BS.fromStrict $ encodeUtf8 msg } + handleErrors (Left appError) = do + let status = case appError of + ValidationError _ -> err400 + NotFoundError _ -> err404 + CriticalError _ -> err500 + InternalError _ -> err500 + AppIpfsError (OtherIpfsError _) -> err400 + AppIpfsError _ -> err503 + throwError $ status { errBody = encode appError, errHeaders = [("Content-Type", "application/json")] } -- * Swagger type SwaggerAPI = SwaggerSchemaUI "swagger-ui" "swagger.json" diff --git a/govtool/backend/example-config.json b/govtool/backend/example-config.json index fe6a47420..800f7dde1 100644 --- a/govtool/backend/example-config.json +++ b/govtool/backend/example-config.json @@ -6,6 +6,7 @@ "password" : "postgres", "port" : 5432 }, + "pinataapijwt": "", "port" : 9999, "host" : "localhost", "cachedurationseconds": 20, diff --git a/govtool/backend/sql/list-proposals.sql b/govtool/backend/sql/list-proposals.sql index c0779c6d8..931c069b8 100644 --- a/govtool/backend/sql/list-proposals.sql +++ b/govtool/backend/sql/list-proposals.sql @@ -42,6 +42,21 @@ CommitteeData AS ( FROM committee_member cm JOIN committee_hash ch ON cm.committee_hash_id = ch.id + WHERE EXISTS ( + SELECT 1 + FROM committee_registration cr + WHERE cr.cold_key_id = ch.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM committee_de_registration cdr + WHERE cdr.cold_key_id = ch.id + AND cdr.tx_id > ( + SELECT MAX(cr2.tx_id) + FROM committee_registration cr2 + WHERE cr2.cold_key_id = ch.id + ) + ) ORDER BY ch.raw, cm.expiration_epoch DESC ), @@ -61,10 +76,31 @@ ParsedDescription AS ( MembersToBeRemoved AS ( SELECT id, - json_agg(VALUE->>'keyHash') AS members_to_be_removed + json_agg( + json_build_object( + 'hash', COALESCE( + VALUE->>'keyHash', + VALUE->>'scriptHash' + ), + 'type', CASE + WHEN VALUE->>'keyHash' IS NOT NULL THEN 'keyHash' + WHEN VALUE->>'scriptHash' IS NOT NULL THEN 'scriptHash' + ELSE 'unknown' + END, + 'hasScript', CASE + WHEN VALUE->>'scriptHash' IS NOT NULL THEN true + ELSE false + END + ) + ) AS members_to_be_removed FROM ParsedDescription pd, - json_array_elements(members_to_be_removed::json) AS value + json_array_elements( + CASE + WHEN pd.members_to_be_removed IS NULL THEN '[]'::json + ELSE pd.members_to_be_removed::json + END + ) AS value GROUP BY id ), @@ -73,7 +109,15 @@ ProcessedCurrentMembers AS ( pd.id, json_agg( json_build_object( - 'hash', regexp_replace(kv.key, '^keyHash-', ''), + 'hash', COALESCE( + regexp_replace(kv.key, '^keyHash-', ''), + regexp_replace(kv.key, '^scriptHash-', '') + ), + 'type', CASE + WHEN kv.key LIKE 'keyHash-%' THEN 'keyHash' + WHEN kv.key LIKE 'scriptHash-%' THEN 'scriptHash' + ELSE 'unknown' + END, 'newExpirationEpoch', kv.value::int ) ) AS current_members @@ -88,9 +132,17 @@ EnrichedCurrentMembers AS ( pcm.id, json_agg( json_build_object( - 'hash', cm.hash, + 'hash', CASE + WHEN (member->>'hash') LIKE 'scriptHash-%' THEN + regexp_replace(member->>'hash', '^scriptHash-', '') + WHEN (member->>'hash') LIKE 'keyHash-%' THEN + regexp_replace(member->>'hash', '^keyHash-', '') + ELSE + member->>'hash' + END, + 'type', member->>'type', 'expirationEpoch', cm.expiration_epoch, - 'hasScript', cm.has_script, + 'hasScript', COALESCE(cm.has_script, member->>'type' = 'scriptHash'), 'newExpirationEpoch', (member->>'newExpirationEpoch')::int ) ) AS enriched_members @@ -247,9 +299,9 @@ SELECT ) FROM ParsedDescription pd - JOIN + LEFT JOIN MembersToBeRemoved mtr ON pd.id = mtr.id - JOIN + LEFT JOIN EnrichedCurrentMembers em ON pd.id = em.id WHERE pd.id = gov_action_proposal.id diff --git a/govtool/backend/src/VVA/API.hs b/govtool/backend/src/VVA/API.hs index 06b9fd39f..5185c3882 100644 --- a/govtool/backend/src/VVA/API.hs +++ b/govtool/backend/src/VVA/API.hs @@ -13,44 +13,52 @@ import Control.Exception (throw, throwIO) import Control.Monad.Except (runExceptT, throwError) import Control.Monad.Reader -import Data.Aeson (Value(..), Array, decode, encode, ToJSON, toJSON) +import Data.Aeson (Array, ToJSON, Value (..), decode, toJSON) import Data.Bool (Bool) -import Data.List (sortOn, sort) +import Data.ByteString.Lazy (ByteString) +import qualified Data.ByteString.Lazy as BSL +import Data.List (sort, sortOn) import qualified Data.Map as Map import Data.Maybe (Maybe (Nothing), catMaybes, fromMaybe, mapMaybe) import Data.Ord (Down (..)) import Data.Text hiding (any, drop, elem, filter, length, map, null, take) import qualified Data.Text as Text -import qualified Data.Vector as V +import qualified Data.Text.Lazy as TL +import qualified Data.Text.Lazy.Encoding as TL +import Data.Time (TimeZone, localTimeToUTC) import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) - +import qualified Data.Vector as V import Numeric.Natural (Natural) import Servant.API +import Servant.Exception (Throws) import Servant.Server + import System.Random (randomRIO) import Text.Read (readMaybe) +import VVA.Account as Account import qualified VVA.AdaHolder as AdaHolder import VVA.API.Types import VVA.Cache (cacheRequest) import VVA.Config import qualified VVA.DRep as DRep import qualified VVA.Epoch as Epoch +import qualified VVA.Ipfs as Ipfs import VVA.Network as Network -import VVA.Account as Account import qualified VVA.Proposal as Proposal import qualified VVA.Transaction as Transaction import qualified VVA.Types as Types import VVA.Types (App, AppEnv (..), - AppError (CriticalError, InternalError, ValidationError), + AppError (AppIpfsError, CriticalError, InternalError, ValidationError), CacheEnv (..)) -import Data.Time (TimeZone, localTimeToUTC) type VVAApi = - "drep" :> "list" + "ipfs" + :> "upload" :> QueryParam "fileName" Text :> ReqBody '[PlainText] Text :> Post '[JSON] UploadResponse + :<|> "drep" :> "list" :> QueryParam "search" Text :> QueryParams "status" DRepStatus :> QueryParam "sort" DRepSortMode @@ -89,7 +97,8 @@ type VVAApi = :<|> "account" :> Capture "stakeKey" HexText :> Get '[JSON] GetAccountInfoResponse server :: App m => ServerT VVAApi m -server = drepList +server = upload + :<|> drepList :<|> getVotingPower :<|> getVotes :<|> drepInfo @@ -107,6 +116,19 @@ server = drepList :<|> getNetworkTotalStake :<|> getAccountInfo +upload :: App m => Maybe Text -> Text -> m UploadResponse +upload mFileName fileContentText = do + AppEnv {vvaConfig} <- ask + let fileContent = TL.encodeUtf8 $ TL.fromStrict fileContentText + vvaPinataJwt = pinataApiJwt vvaConfig + fileName = fromMaybe "data.txt" mFileName -- Default to data.txt if no filename is provided + when (BSL.length fileContent > 1024 * 512) $ + throwError $ ValidationError "The uploaded file is larger than 500Kb" + eIpfsHash <- liftIO $ Ipfs.ipfsUpload vvaPinataJwt fileName fileContent + case eIpfsHash of + Left err -> throwError $ AppIpfsError err + Right ipfsHash -> return $ UploadResponse ipfsHash + mapDRepType :: Types.DRepType -> DRepType mapDRepType Types.DRep = NormalDRep mapDRepType Types.SoleVoter = SoleVoter @@ -165,9 +187,9 @@ drepList mSearchQuery statuses mSortMode mPage mPageSize = do viewLower = Text.toLower dRepRegistrationView hashLower = Text.toLower dRepRegistrationDRepHash in case dRepRegistrationType of - Types.SoleVoter -> + Types.SoleVoter -> searchLower == viewLower || searchLower == hashLower - Types.DRep -> + Types.DRep -> True @@ -295,13 +317,13 @@ getVotes :: App m => HexText -> [GovernanceActionType] -> Maybe GovernanceAction getVotes (unHexText -> dRepId) selectedTypes sortMode mSearch = do CacheEnv {dRepGetVotesCache} <- asks vvaCache (votes, proposals) <- cacheRequest dRepGetVotesCache dRepId $ DRep.getVotes dRepId [] - + let voteMapById = Map.fromList $ map (\vote -> (Types.voteGovActionId vote, vote)) votes - - processedProposals <- filter (isProposalSearchedFor mSearch) <$> + + processedProposals <- filter (isProposalSearchedFor mSearch) <$> mapSortAndFilterProposals selectedTypes sortMode proposals - + return [ VoteResponse { voteResponseVote = voteToResponse vote @@ -311,7 +333,7 @@ getVotes (unHexText -> dRepId) selectedTypes sortMode mSearch = do , let govActionId = unHexText (proposalResponseTxHash proposalResponse) <> "#" <> pack (show $ proposalResponseIndex proposalResponse) , Just vote <- [Map.lookup govActionId voteMapById] ] - + drepInfo :: App m => HexText -> m DRepInfoResponse drepInfo (unHexText -> dRepId) = do CacheEnv {dRepInfoCache} <- asks vvaCache @@ -342,15 +364,15 @@ drepInfo (unHexText -> dRepId) = do drepVotingPowerList :: App m => [Text] -> m [DRepVotingPowerListResponse] drepVotingPowerList identifiers = do CacheEnv {dRepVotingPowerListCache} <- asks vvaCache - + let cacheKey = Text.intercalate "," (sort identifiers) - - results <- cacheRequest dRepVotingPowerListCache cacheKey $ + + results <- cacheRequest dRepVotingPowerListCache cacheKey $ DRep.getDRepsVotingPowerList identifiers - + return $ map toDRepVotingPowerListResponse results where - toDRepVotingPowerListResponse Types.DRepVotingPowerList{..} = + toDRepVotingPowerListResponse Types.DRepVotingPowerList{..} = DRepVotingPowerListResponse { drepVotingPowerListResponseView = drepView , drepVotingPowerListResponseHashRaw = HexText drepHashRaw @@ -433,9 +455,9 @@ getProposal g@(GovActionId govActionTxHash govActionIndex) mDrepId' = do let mDrepId = unHexText <$> mDrepId' CacheEnv {getProposalCache} <- asks vvaCache proposal@Types.Proposal {proposalUrl, proposalDocHash} <- cacheRequest getProposalCache (unHexText govActionTxHash, govActionIndex) (Proposal.getProposal (unHexText govActionTxHash) govActionIndex) - + timeZone <- liftIO getCurrentTimeZone - + let proposalResponse = proposalToResponse timeZone proposal voteResponse <- case mDrepId of Nothing -> return Nothing @@ -455,20 +477,20 @@ getProposal g@(GovActionId govActionTxHash govActionIndex) mDrepId' = do getEnactedProposalDetails :: App m => Maybe GovernanceActionType -> m (Maybe EnactedProposalDetailsResponse) getEnactedProposalDetails maybeType = do let proposalType = maybe "HardForkInitiation" governanceActionTypeToText maybeType - + mDetails <- Proposal.getPreviousEnactedProposal proposalType - + let response = enactedProposalDetailsToResponse <$> mDetails - + return response where governanceActionTypeToText :: GovernanceActionType -> Text - governanceActionTypeToText actionType = + governanceActionTypeToText actionType = case actionType of HardForkInitiation -> "HardForkInitiation" - ParameterChange -> "ParameterChange" - _ -> "HardForkInitiation" - + ParameterChange -> "ParameterChange" + _ -> "HardForkInitiation" + enactedProposalDetailsToResponse :: Types.EnactedProposalDetails -> EnactedProposalDetailsResponse enactedProposalDetailsToResponse Types.EnactedProposalDetails{..} = EnactedProposalDetailsResponse @@ -490,7 +512,7 @@ getTransactionStatus (unHexText -> transactionId) = do return $ GetTransactionStatusResponse $ case status of Just value -> Just $ toJSON value Nothing -> Nothing - + throw500 :: App m => m () throw500 = throwError $ CriticalError "intentional system break for testing purposes" diff --git a/govtool/backend/src/VVA/API/Types.hs b/govtool/backend/src/VVA/API/Types.hs index d14fd54cb..2b1badf27 100644 --- a/govtool/backend/src/VVA/API/Types.hs +++ b/govtool/backend/src/VVA/API/Types.hs @@ -205,7 +205,14 @@ instance ToParamSchema GovernanceActionType where & enum_ ?~ map toJSON (enumFromTo minBound maxBound :: [GovernanceActionType]) -data DRepSortMode = Random | VotingPower | RegistrationDate | Status deriving (Bounded, Enum, Eq, Generic, Read, Show) +data DRepSortMode = Random | VotingPower | RegistrationDate | Status deriving + ( Bounded + , Enum + , Eq + , Generic + , Read + , Show + ) instance FromJSON DRepSortMode where parseJSON (Aeson.String dRepSortMode) = pure $ fromJust $ readMaybe (Text.unpack dRepSortMode) @@ -406,7 +413,8 @@ data ProposalResponse } deriving (Generic, Show) -newtype ProposalAuthors = ProposalAuthors { getProposalAuthors :: Value } +newtype ProposalAuthors + = ProposalAuthors { getProposalAuthors :: Value } deriving newtype (Show) instance FromJSON ProposalAuthors where @@ -659,7 +667,7 @@ data DRepInfoResponse , dRepInfoResponseGivenName :: Maybe Text , dRepInfoResponseObjectives :: Maybe Text , dRepInfoResponseMotivations :: Maybe Text - , dRepInfoResponseQualifications :: Maybe Text + , dRepInfoResponseQualifications :: Maybe Text , dRepInfoResponseImageUrl :: Maybe Text , dRepInfoResponseImageHash :: Maybe HexText } @@ -906,7 +914,7 @@ data DRep , dRepGivenName :: Maybe Text , dRepObjectives :: Maybe Text , dRepMotivations :: Maybe Text - , dRepQualifications :: Maybe Text + , dRepQualifications :: Maybe Text , dRepImageUrl :: Maybe Text , dRepImageHash :: Maybe HexText , dRepIdentityReferences :: Maybe DRepReferences @@ -1011,11 +1019,11 @@ instance ToSchema DelegationResponse where data GetNetworkInfoResponse = GetNetworkInfoResponse - { getNetworkInfoResponseCurrentTime :: UTCTime - , getNetworkInfoResponseEpochNo :: Integer - , getNetworkInfoResponseBlockNo :: Integer - , getNetworkInfoResponseNetworkName :: Text - } + { getNetworkInfoResponseCurrentTime :: UTCTime + , getNetworkInfoResponseEpochNo :: Integer + , getNetworkInfoResponseBlockNo :: Integer + , getNetworkInfoResponseNetworkName :: Text + } deriveJSON (jsonOptions "getNetworkInfoResponse") ''GetNetworkInfoResponse @@ -1035,11 +1043,11 @@ instance ToSchema GetNetworkInfoResponse where data GetNetworkTotalStakeResponse = GetNetworkTotalStakeResponse - { getNetworkTotalStakeResponseTotalStakeControlledByDReps :: Integer - , getNetworkTotalStakeResponseTotalStakeControlledBySPOs :: Integer - , getNetworkTotalStakeResponseAlwaysAbstainVotingPower :: Integer - , getNetworkTotalStakeResponseAlwaysNoConfidenceVotingPower :: Integer - } + { getNetworkTotalStakeResponseTotalStakeControlledByDReps :: Integer + , getNetworkTotalStakeResponseTotalStakeControlledBySPOs :: Integer + , getNetworkTotalStakeResponseAlwaysAbstainVotingPower :: Integer + , getNetworkTotalStakeResponseAlwaysNoConfidenceVotingPower :: Integer + } deriveJSON (jsonOptions "getNetworkTotalStakeResponse") ''GetNetworkTotalStakeResponse @@ -1112,6 +1120,12 @@ data GetAccountInfoResponse } deriving (Generic, Show) deriveJSON (jsonOptions "getAccountInfoResponse") ''GetAccountInfoResponse + +newtype UploadResponse + = UploadResponse { uploadResponseIpfsCid :: Text } + deriving (Generic, Show) +deriveJSON (jsonOptions "uploadResponse") ''UploadResponse + exampleGetAccountInfoResponse :: Text exampleGetAccountInfoResponse = "{\"stakeKey\": \"stake1u9\"," @@ -1125,3 +1139,14 @@ instance ToSchema GetAccountInfoResponse where & description ?~ "GetAccountInfoResponse" & example ?~ toJSON exampleGetAccountInfoResponse + +exampleUploadResponse :: Text +exampleUploadResponse = + "{\"ipfsHash\": \"QmZKLGf2D3Z3F2J2K5J2L5J2L5J2L5J2L5J2L5J2L5J2L5\"}" + +instance ToSchema UploadResponse where + declareNamedSchema _ = pure $ NamedSchema (Just "UploadResponse") $ mempty + & type_ ?~ OpenApiObject + & description ?~ "UploadResponse" + & example + ?~ toJSON exampleUploadResponse diff --git a/govtool/backend/src/VVA/Account.hs b/govtool/backend/src/VVA/Account.hs index 10aa92e52..5ce98ae78 100644 --- a/govtool/backend/src/VVA/Account.hs +++ b/govtool/backend/src/VVA/Account.hs @@ -1,21 +1,24 @@ -{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TemplateHaskell #-} module VVA.Account where import Control.Monad.Except (MonadError, throwError) import Control.Monad.Reader (MonadIO, MonadReader, liftIO) + import Data.ByteString (ByteString) import Data.FileEmbed (embedFile) +import Data.Has (Has) import Data.String (fromString) -import qualified Database.PostgreSQL.Simple as SQL -import VVA.Types (AppError(..), AccountInfo(..)) import Data.Text (Text, unpack) import qualified Data.Text.Encoding as Text import qualified Data.Text.IO as Text -import Data.Has (Has) + +import qualified Database.PostgreSQL.Simple as SQL + import VVA.Pool (ConnectionPool, withPool) +import VVA.Types (AccountInfo (..), AppError (..)) sqlFrom :: ByteString -> SQL.Query sqlFrom = fromString . unpack . Text.decodeUtf8 diff --git a/govtool/backend/src/VVA/AdaHolder.hs b/govtool/backend/src/VVA/AdaHolder.hs index 6232a8310..b708cd26e 100644 --- a/govtool/backend/src/VVA/AdaHolder.hs +++ b/govtool/backend/src/VVA/AdaHolder.hs @@ -1,12 +1,12 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} -{-# LANGUAGE ScopedTypeVariables #-} module VVA.AdaHolder where -import Control.Exception (try, SomeException) +import Control.Exception (SomeException, try) import Control.Monad.Except import Control.Monad.Reader @@ -65,4 +65,4 @@ getStakeKeyVotingPower stakeKey = withPool $ \conn -> do return 0 Right _ -> do Text.putStrLn ("Unexpected result for stake key: " <> stakeKey) - return 0 \ No newline at end of file + return 0 diff --git a/govtool/backend/src/VVA/Config.hs b/govtool/backend/src/VVA/Config.hs index cea9e3eb8..997a3c231 100644 --- a/govtool/backend/src/VVA/Config.hs +++ b/govtool/backend/src/VVA/Config.hs @@ -32,7 +32,7 @@ import qualified Conferer.Source.Env as Env import Control.Monad.Reader -import Data.Aeson +import Data.Aeson as Aeson import qualified Data.Aeson.Encode.Pretty as AP import Data.ByteString (ByteString, toStrict) import Data.Has (Has, getter) @@ -58,7 +58,7 @@ data DBConfig -- | Port , dBConfigPort :: Int } - deriving (FromConfig, Generic, Show) + deriving (FromConfig, FromJSON, Generic, Show) instance DefaultConfig DBConfig where configDef = DBConfig "localhost" "cexplorer" "postgres" "test" 9903 @@ -68,20 +68,23 @@ instance DefaultConfig DBConfig where data VVAConfigInternal = VVAConfigInternal { -- | db-sync database access. - vVAConfigInternalDbsyncconfig :: DBConfig + vVAConfigInternalDbsyncconfig :: DBConfig -- | Server port. - , vVAConfigInternalPort :: Int + , vVAConfigInternalPort :: Int -- | Server host. - , vVAConfigInternalHost :: Text + , vVAConfigInternalHost :: Text -- | Request cache duration - , vVaConfigInternalCacheDurationSeconds :: Int + , vVaConfigInternalCacheDurationSeconds :: Int -- | Sentry DSN - , vVAConfigInternalSentrydsn :: String + , vVAConfigInternalSentrydsn :: String -- | Sentry environment - , vVAConfigInternalSentryEnv :: String + , vVAConfigInternalSentryEnv :: String + -- | Pinata API JWT + , vVAConfigInternalPinataApiJwt :: Maybe Text } deriving (FromConfig, Generic, Show) + instance DefaultConfig VVAConfigInternal where configDef = VVAConfigInternal @@ -90,24 +93,27 @@ instance DefaultConfig VVAConfigInternal where vVAConfigInternalHost = "localhost", vVaConfigInternalCacheDurationSeconds = 20, vVAConfigInternalSentrydsn = "https://username:password@senty.host/id", - vVAConfigInternalSentryEnv = "development" + vVAConfigInternalSentryEnv = "development", + vVAConfigInternalPinataApiJwt = Nothing } -- | DEX configuration. data VVAConfig = VVAConfig { -- | db-sync database credentials. - dbSyncConnectionString :: Text + dbSyncConnectionString :: Text -- | Server port. - , serverPort :: Int + , serverPort :: Int -- | Server host. - , serverHost :: Text + , serverHost :: Text -- | Request cache duration - , cacheDurationSeconds :: Int + , cacheDurationSeconds :: Int -- | Sentry DSN - , sentryDSN :: String + , sentryDSN :: String -- | Sentry environment - , sentryEnv :: String + , sentryEnv :: String + -- | Pinata API JWT + , pinataApiJwt :: Maybe Text } deriving (Generic, Show, ToJSON) @@ -148,7 +154,8 @@ convertConfig VVAConfigInternal {..} = serverHost = vVAConfigInternalHost, cacheDurationSeconds = vVaConfigInternalCacheDurationSeconds, sentryDSN = vVAConfigInternalSentrydsn, - sentryEnv = vVAConfigInternalSentryEnv + sentryEnv = vVAConfigInternalSentryEnv, + pinataApiJwt = vVAConfigInternalPinataApiJwt } -- | Load configuration from a file specified on the command line. Load from @@ -163,7 +170,7 @@ loadVVAConfig configFile = do where buildConfig :: IO Config buildConfig = - Conferer.mkConfig' + mkConfig' [] [ Env.fromConfig "vva", JSON.fromFilePath (fromMaybe "example-config.json" configFile) @@ -185,4 +192,4 @@ getServerPort = asks (serverPort . getter) getServerHost :: (Has VVAConfig r, MonadReader r m) => m Text -getServerHost = asks (serverHost . getter) \ No newline at end of file +getServerHost = asks (serverHost . getter) diff --git a/govtool/backend/src/VVA/DRep.hs b/govtool/backend/src/VVA/DRep.hs index f8c9f8737..39342a7eb 100644 --- a/govtool/backend/src/VVA/DRep.hs +++ b/govtool/backend/src/VVA/DRep.hs @@ -13,56 +13,59 @@ import Crypto.Hash import Data.Aeson (Value) import Data.ByteString (ByteString) -import qualified Data.ByteString.Base16 as Base16 -import qualified Data.ByteString.Char8 as C +import qualified Data.ByteString.Base16 as Base16 +import qualified Data.ByteString.Char8 as C import Data.FileEmbed (embedFile) import Data.Foldable (Foldable (sum)) import Data.Has (Has) -import qualified Data.Map as M +import qualified Data.Map as M import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Scientific import Data.String (fromString) -import Data.Text (Text, pack, unpack, intercalate) -import qualified Data.Text.Encoding as Text +import Data.Text (Text, intercalate, pack, unpack) +import qualified Data.Text.Encoding as Text import Data.Time -import qualified Database.PostgreSQL.Simple as SQL -import Database.PostgreSQL.Simple.Types (In(..)) +import qualified Database.PostgreSQL.Simple as SQL import Database.PostgreSQL.Simple.FromRow +import Database.PostgreSQL.Simple.Types (In (..)) import VVA.Config import VVA.Pool (ConnectionPool, withPool) -import qualified VVA.Proposal as Proposal -import VVA.Types (AppError, DRepInfo (..), DRepRegistration (..), DRepStatus (..), - DRepType (..), Proposal (..), Vote (..), DRepVotingPowerList (..)) +import qualified VVA.Proposal as Proposal +import VVA.Types (AppError, DRepInfo (..), DRepRegistration (..), + DRepStatus (..), DRepType (..), DRepVotingPowerList (..), + Proposal (..), Vote (..)) -data DRepQueryResult = DRepQueryResult - { queryDrepHash :: Text - , queryDrepView :: Text - , queryIsScriptBased :: Bool - , queryUrl :: Maybe Text - , queryDataHash :: Maybe Text - , queryDeposit :: Scientific - , queryVotingPower :: Maybe Integer - , queryIsActive :: Bool - , queryTxHash :: Maybe Text - , queryDate :: LocalTime - , queryLatestDeposit :: Scientific - , queryLatestNonDeregisterVotingAnchorWasNotNull :: Bool - , queryMetadataError :: Maybe Text - , queryPaymentAddress :: Maybe Text - , queryGivenName :: Maybe Text - , queryObjectives :: Maybe Text - , queryMotivations :: Maybe Text - , queryQualifications :: Maybe Text - , queryImageUrl :: Maybe Text - , queryImageHash :: Maybe Text - , queryIdentityReferences :: Maybe Value - , queryLinkReferences :: Maybe Value - } deriving (Show) +data DRepQueryResult + = DRepQueryResult + { queryDrepHash :: Text + , queryDrepView :: Text + , queryIsScriptBased :: Bool + , queryUrl :: Maybe Text + , queryDataHash :: Maybe Text + , queryDeposit :: Scientific + , queryVotingPower :: Maybe Integer + , queryIsActive :: Bool + , queryTxHash :: Maybe Text + , queryDate :: LocalTime + , queryLatestDeposit :: Scientific + , queryLatestNonDeregisterVotingAnchorWasNotNull :: Bool + , queryMetadataError :: Maybe Text + , queryPaymentAddress :: Maybe Text + , queryGivenName :: Maybe Text + , queryObjectives :: Maybe Text + , queryMotivations :: Maybe Text + , queryQualifications :: Maybe Text + , queryImageUrl :: Maybe Text + , queryImageHash :: Maybe Text + , queryIdentityReferences :: Maybe Value + , queryLinkReferences :: Maybe Value + } + deriving (Show) instance FromRow DRepQueryResult where - fromRow = DRepQueryResult + fromRow = DRepQueryResult <$> field <*> field <*> field <*> field <*> field <*> field <*> field <*> field <*> field <*> field <*> field <*> field <*> field <*> field <*> field <*> field <*> field <*> field @@ -93,7 +96,7 @@ listDReps mSearchQuery = withPool $ \conn -> do timeZone <- liftIO getCurrentTimeZone return - [ DRepRegistration + [ DRepRegistration (queryDrepHash result) (queryDrepView result) (queryIsScriptBased result) @@ -127,7 +130,7 @@ listDReps mSearchQuery = withPool $ \conn -> do | latestDeposit' < 0 && queryLatestNonDeregisterVotingAnchorWasNotNull result = DRep | Data.Maybe.isJust (queryUrl result) = DRep ] - + getVotingPowerSql :: SQL.Query getVotingPowerSql = sqlFrom $(embedFile "sql/get-voting-power.sql") @@ -165,7 +168,7 @@ getVotes drepId selectedProposals = withPool $ \conn -> do timeZone <- liftIO getCurrentTimeZone - let votes = + let votes = [ Vote proposalId' govActionId' drepId' vote' url' docHash' epochNo' (localTimeToUTC timeZone date') voteTxHash' | (proposalId', govActionId', drepId', vote', url', docHash', epochNo', date', voteTxHash') <- results , govActionId' `elem` proposalsToSelect @@ -252,11 +255,11 @@ getDRepsVotingPowerList identifiers = withPool $ \conn -> do else do resultsPerIdentifier <- forM identifiers $ \identifier -> do liftIO $ SQL.query conn getFilteredDRepVotingPowerSql (identifier, identifier) - + return $ concat resultsPerIdentifier - + return [ DRepVotingPowerList view hashRaw votingPower givenName | (view, hashRaw, votingPower', givenName) <- results , let votingPower = floor @Scientific votingPower' - ] \ No newline at end of file + ] diff --git a/govtool/backend/src/VVA/Ipfs.hs b/govtool/backend/src/VVA/Ipfs.hs new file mode 100644 index 000000000..067ff43db --- /dev/null +++ b/govtool/backend/src/VVA/Ipfs.hs @@ -0,0 +1,145 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module VVA.Ipfs + ( IpfsError (..) + , ipfsUpload + ) where + +import Control.Exception (SomeException, try) +import Control.Monad.IO.Class (liftIO) + +import Data.Aeson (FromJSON (parseJSON), ToJSON (..), eitherDecode, + encode, object, withObject, (.:), (.=)) +import qualified Data.Aeson as A +import qualified Data.ByteString.Lazy as LBS +import qualified Data.ByteString.Lazy.Char8 as LBS8 +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import qualified Data.Text.Lazy as TL +import qualified Data.Text.Lazy.Encoding as TL + +import GHC.Generics (Generic) + +import Network.HTTP.Client (Request, RequestBody (..), httpLbs, method, + newManager, parseRequest, requestHeaders, + responseBody, responseStatus) +import Network.HTTP.Client.MultipartFormData (formDataBody, partBS, partFileRequestBody) +import Network.HTTP.Client.TLS (tlsManagerSettings) +import Network.HTTP.Types.Status (Status, status400, status503, statusIsSuccessful) + +import Servant.Exception (Exception (..), ToServantErr (..)) +import Servant.Server (ServerError (errBody)) + + +data PinataData + = PinataData + { cid :: Text + , size :: Int + , created_at :: Text + , isDuplicate :: Maybe Bool + } + deriving (Generic, Show) + +instance FromJSON PinataData + +newtype PinataSuccessResponse + = PinataSuccessResponse { pinataData :: PinataData } + deriving (Show) + +instance FromJSON PinataSuccessResponse where + parseJSON = withObject "PinataSuccessResponse" $ \v -> PinataSuccessResponse + <$> v .: "data" + +data IpfsError + = PinataConnectionError String + | PinataAPIError Status LBS.ByteString + | PinataDecodingError String LBS.ByteString + | IpfsUnconfiguredError + | OtherIpfsError String + deriving (Generic, Show) + +instance ToJSON IpfsError where + toJSON (PinataConnectionError msg) = + object ["errorType" .= A.String "PinataConnectionError", "message" .= msg] + + toJSON (PinataAPIError status body) = + object + [ "errorType" .= A.String "PinataAPIError" + , "message" .= ("Pinata API returned error status : " ++ show status) + , "pinataResponse" .= object + [ "status" .= show status + , "body" .= TL.unpack (TL.decodeUtf8 body) + ] + ] + + toJSON (PinataDecodingError msg body) = + object + [ "errorType" .= A.String "PinataDecodingError" + , "message" .= msg + , "pinataResponse" .= object + [ "status" .= ("unknown" :: String) + , "body" .= TL.unpack (TL.decodeUtf8 body) + ] + ] + + toJSON IpfsUnconfiguredError = + object ["errorType" .= A.String "IpfsUnconfiguredError", "message" .= ("Backend is not configured for upfs upload" :: String)] + + toJSON (OtherIpfsError msg) = + object ["errorType" .= A.String "OtherIpfsError", "message" .= msg] + + +instance Exception IpfsError + + + +instance ToServantErr IpfsError where + status (OtherIpfsError _) = status400 + status _ = status503 + + message (PinataConnectionError msg) = T.pack ("Pinata service connection error: " <> msg) + message (PinataAPIError status body) = T.pack ("Pinata API error: " <> show status <> " - " <> LBS8.unpack body) + message (PinataDecodingError msg body) = T.pack ("Pinata decoding error: " <> msg <> " - " <> LBS8.unpack body) + message IpfsUnconfiguredError = T.pack "Backend is not configured to support ipfs upload" + message (OtherIpfsError msg) = T.pack msg + +ipfsUpload :: Maybe Text -> Text -> LBS.ByteString -> IO (Either IpfsError Text) +ipfsUpload maybeJwt fileName fileContent = + case maybeJwt of + Nothing -> pure $ Left IpfsUnconfiguredError + Just "" -> pure $ Left IpfsUnconfiguredError + Just jwt -> do + manager <- newManager tlsManagerSettings + initialRequest <- parseRequest "https://uploads.pinata.cloud/v3/files" + let req = initialRequest + { method = "POST" + , requestHeaders = [("Authorization", "Bearer " <> TE.encodeUtf8 jwt)] + } + result <- try $ flip httpLbs manager =<< formDataBody + [ partBS "network" "public" + , partFileRequestBody "file" (T.unpack fileName) $ RequestBodyLBS fileContent + ] + req + + case result of + Left (e :: SomeException) -> do + let errMsg = show e + liftIO $ putStrLn errMsg + pure $ Left $ PinataConnectionError errMsg + Right response -> do + let body = responseBody response + let status = responseStatus response + if statusIsSuccessful status + then case eitherDecode body of + Left err -> do + let errMsg = "Failed to decode Pinata API reponse: " <> err + liftIO $ putStrLn errMsg + pure $ Left $ PinataDecodingError errMsg body + Right (res :: PinataSuccessResponse) -> pure $ Right $ cid $ pinataData res + else do + let errMsg = "Pinata API request failed with status: " <> show status + liftIO $ putStrLn errMsg + pure $ Left $ PinataAPIError status body diff --git a/govtool/backend/src/VVA/Network.hs b/govtool/backend/src/VVA/Network.hs index 797d47ac3..ec99dc82a 100644 --- a/govtool/backend/src/VVA/Network.hs +++ b/govtool/backend/src/VVA/Network.hs @@ -34,7 +34,7 @@ networkInfo :: networkInfo = withPool $ \conn -> do result <- liftIO $ SQL.query_ conn networkInfoSql current_time <- liftIO getCurrentTime - case result of + case result of [( network_epoch , network_block_no , network_name diff --git a/govtool/backend/src/VVA/Proposal.hs b/govtool/backend/src/VVA/Proposal.hs index 4d61ec752..b4c5b502d 100644 --- a/govtool/backend/src/VVA/Proposal.hs +++ b/govtool/backend/src/VVA/Proposal.hs @@ -2,42 +2,43 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} -{-# LANGUAGE ScopedTypeVariables #-} module VVA.Proposal where -import Control.Exception (throw, SomeException, try) -import Control.Monad.Except (MonadError, throwError) +import Control.Exception (SomeException, throw, try) +import Control.Monad.Except (MonadError, throwError) import Control.Monad.Reader import Data.Aeson -import Data.Aeson.Types (Parser, parseMaybe) -import Data.ByteString (ByteString) -import Data.FileEmbed (embedFile) -import Data.Foldable (fold) -import Data.Has (Has, getter) -import qualified Data.Map as Map -import Data.Maybe (fromMaybe, isJust) -import Data.Monoid (Sum (..), getSum) +import Data.Aeson.Types (Parser, parseMaybe) +import Data.ByteString (ByteString) +import Data.FileEmbed (embedFile) +import Data.Foldable (fold) +import Data.Has (Has, getter) +import qualified Data.Map as Map +import Data.Maybe (fromMaybe, isJust) +import Data.Monoid (Sum (..), getSum) import Data.Scientific -import Data.String (fromString) -import Data.Text (Text, pack, unpack) -import qualified Data.Text.Encoding as Text -import qualified Data.Text.IO as Text +import Data.String (fromString) +import Data.Text (Text, pack, unpack) +import qualified Data.Text.Encoding as Text +import qualified Data.Text.IO as Text import Data.Time -import qualified Database.PostgreSQL.Simple as SQL -import qualified Database.PostgreSQL.Simple.Types as PG -import Database.PostgreSQL.Simple.ToField (ToField(..)) -import Database.PostgreSQL.Simple.ToRow (ToRow(..)) +import qualified Database.PostgreSQL.Simple as SQL +import Database.PostgreSQL.Simple.ToField (ToField (..)) +import Database.PostgreSQL.Simple.ToRow (ToRow (..)) +import qualified Database.PostgreSQL.Simple.Types as PG -import GHC.IO.Unsafe (unsafePerformIO) +import GHC.IO.Unsafe (unsafePerformIO) import VVA.Config -import VVA.Pool (ConnectionPool, withPool) -import VVA.Types (AppError (..), Proposal (..), EnactedProposalDetails (..)) +import VVA.Pool (ConnectionPool, withPool) +import VVA.Types (AppError (..), EnactedProposalDetails (..), + Proposal (..)) query1 :: (SQL.ToRow q, SQL.FromRow r) => SQL.Connection -> SQL.Query -> q -> IO (Maybe r) query1 conn q params = do @@ -52,7 +53,8 @@ sqlFrom bs = fromString $ unpack $ Text.decodeUtf8 bs listProposalsSql :: SQL.Query listProposalsSql = sqlFrom $(embedFile "sql/list-proposals.sql") -newtype TextArray = TextArray [Text] +newtype TextArray + = TextArray [Text] instance ToRow TextArray where toRow (TextArray texts) = map toField texts @@ -81,7 +83,7 @@ getProposals :: getProposals mSearchTerms = withPool $ \conn -> do let searchParam = maybe "" head mSearchTerms liftIO $ do - result <- try $ SQL.query conn listProposalsSql + result <- try $ SQL.query conn listProposalsSql ( searchParam , "%" <> searchParam <> "%" , "%" <> searchParam <> "%" @@ -96,7 +98,7 @@ getProposals mSearchTerms = withPool $ \conn -> do Right rows -> return rows latestEnactedProposalSql :: SQL.Query -latestEnactedProposalSql = +latestEnactedProposalSql = let rawSql = sqlFrom $(embedFile "sql/get-previous-enacted-governance-action-proposal-details.sql") in unsafePerformIO $ do putStrLn $ "[DEBUG] SQL query content: " ++ show rawSql @@ -109,13 +111,13 @@ getPreviousEnactedProposal :: getPreviousEnactedProposal proposalType = withPool $ \conn -> do let query = latestEnactedProposalSql let params = [proposalType] - + result <- liftIO $ try $ do rows <- SQL.query conn query params :: IO [EnactedProposalDetails] case rows of [x] -> return (Just x) _ -> return Nothing - + case result of Left err -> do throwError $ CriticalError $ "Database error: " <> pack (show (err :: SomeException)) @@ -123,6 +125,6 @@ getPreviousEnactedProposal proposalType = withPool $ \conn -> do case proposal of Just details -> do liftIO $ putStrLn $ "[DEBUG] Previous enacted proposal details: " ++ show details - Nothing -> + Nothing -> liftIO $ putStrLn "[DEBUG] No previous enacted proposal found" return proposal diff --git a/govtool/backend/src/VVA/Transaction.hs b/govtool/backend/src/VVA/Transaction.hs index 11c1e349e..36b6edeaa 100644 --- a/govtool/backend/src/VVA/Transaction.hs +++ b/govtool/backend/src/VVA/Transaction.hs @@ -12,11 +12,11 @@ import Data.Aeson (Value) import Data.ByteString (ByteString) import Data.FileEmbed (embedFile) import Data.Has (Has) +import Data.Maybe (fromMaybe) import Data.String (fromString) import Data.Text (Text, pack, unpack) import qualified Data.Text.Encoding as Text import qualified Data.Text.IO as Text -import Data.Maybe (fromMaybe) import qualified Database.PostgreSQL.Simple as SQL @@ -39,4 +39,4 @@ getTransactionStatus transactionId = withPool $ \conn -> do case result of [(transactionConfirmed, votingProcedure)] -> do return $ Just $ TransactionStatus transactionConfirmed votingProcedure - _ -> return Nothing \ No newline at end of file + _ -> return Nothing diff --git a/govtool/backend/src/VVA/Types.hs b/govtool/backend/src/VVA/Types.hs index 25c248bd3..98fb7b44e 100644 --- a/govtool/backend/src/VVA/Types.hs +++ b/govtool/backend/src/VVA/Types.hs @@ -1,35 +1,38 @@ -{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} module VVA.Types where import Control.Concurrent.QSem import Control.Exception -import Control.Monad.Except (MonadError) -import Control.Monad.Fail (MonadFail) -import Control.Monad.IO.Class (MonadIO) -import Control.Monad.Reader (MonadReader) - -import Data.Aeson (Value, ToJSON (..), object, (.=)) -import qualified Data.Cache as Cache +import Control.Monad.Except (MonadError) +import Control.Monad.Fail (MonadFail) +import Control.Monad.IO.Class (MonadIO) +import Control.Monad.Reader (MonadReader) + +import Data.Aeson (ToJSON (..), Value, object, (.=)) +import qualified Data.Aeson as A +import qualified Data.Cache as Cache import Data.Has -import Data.Pool (Pool) -import Data.Text (Text) -import Data.Time (UTCTime, LocalTime) +import Data.Pool (Pool) import Data.Scientific +import Data.Text (Text) +import Data.Time (LocalTime, UTCTime) -import Database.PostgreSQL.Simple (Connection) +import Database.PostgreSQL.Simple (Connection) +import Database.PostgreSQL.Simple.FromField (FromField (..), ResultError (ConversionFailed), + returnError) import Database.PostgreSQL.Simple.FromRow -import Database.PostgreSQL.Simple.FromField (FromField(..), returnError, ResultError(ConversionFailed)) import VVA.Cache import VVA.Config +import VVA.Ipfs (IpfsError) type App m = (MonadReader AppEnv m, MonadIO m, MonadFail m, MonadError AppError m) @@ -57,10 +60,18 @@ data AppError | NotFoundError Text | CriticalError Text | InternalError Text + | AppIpfsError IpfsError deriving (Show) instance Exception AppError +instance ToJSON AppError where + toJSON (ValidationError msg) = object ["errorType" .= A.String "ValidationError", "message" .= msg] + toJSON (NotFoundError msg) = object ["errorType" .= A.String "NotFoundError", "message" .= msg] + toJSON (CriticalError msg) = object ["errorType" .= A.String "CriticalError", "message" .= msg] + toJSON (InternalError msg) = object ["errorType" .= A.String "InternalError", "message" .= msg] + toJSON (AppIpfsError err) = toJSON err + data Vote = Vote { voteProposalId :: Integer @@ -105,28 +116,28 @@ data DRepVotingPowerList , drepVotingPower :: Integer , drepGivenName :: Maybe Text } - deriving (Show, Eq) + deriving (Eq, Show) -data DRepStatus = Active | Inactive | Retired deriving (Show, Eq, Ord) +data DRepStatus = Active | Inactive | Retired deriving (Eq, Ord, Show) instance FromField DRepStatus where fromField f mdata = do (value :: Text) <- fromField f mdata case value of - "Active" -> return Active + "Active" -> return Active "Inactive" -> return Inactive - "Retired" -> return Retired - _ -> returnError ConversionFailed f "Invalid DRepStatus" + "Retired" -> return Retired + _ -> returnError ConversionFailed f "Invalid DRepStatus" -data DRepType = DRep | SoleVoter deriving (Show, Eq) +data DRepType = DRep | SoleVoter deriving (Eq, Show) instance FromField DRepType where fromField f mdata = do (value :: Text) <- fromField f mdata case value of - "DRep" -> return DRep + "DRep" -> return DRep "SoleVoter" -> return SoleVoter - _ -> returnError ConversionFailed f "Invalid DRepType" + _ -> returnError ConversionFailed f "Invalid DRepType" data DRepRegistration = DRepRegistration @@ -146,7 +157,7 @@ data DRepRegistration , dRepRegistrationGivenName :: Maybe Text , dRepRegistrationObjectives :: Maybe Text , dRepRegistrationMotivations :: Maybe Text - , dRepRegistrationQualifications :: Maybe Text + , dRepRegistrationQualifications :: Maybe Text , dRepRegistrationImageUrl :: Maybe Text , dRepRegistrationImageHash :: Maybe Text , dRepRegistrationIdentityReferences :: Maybe Value @@ -179,37 +190,37 @@ instance FromRow DRepRegistration where <*> field -- dRepRegistrationIdentityReferences <*> field -- dRepRegistrationLinkReferences -data Proposal +data Proposal = Proposal - { proposalId :: Integer - , proposalTxHash :: Text - , proposalIndex :: Integer - , proposalType :: Text - , proposalDetails :: Maybe Value - , proposalExpiryDate :: Maybe LocalTime - , proposalExpiryEpochNo :: Maybe Integer - , proposalCreatedDate :: LocalTime - , proposalCreatedEpochNo :: Integer - , proposalUrl :: Text - , proposalDocHash :: Text - , proposalProtocolParams :: Maybe Value - , proposalTitle :: Maybe Text - , proposalAbstract :: Maybe Text - , proposalMotivation :: Maybe Text - , proposalRationale :: Maybe Text - , proposalDRepYesVotes :: Integer - , proposalDRepNoVotes :: Integer - , proposalDRepAbstainVotes :: Integer - , proposalPoolYesVotes :: Integer - , proposalPoolNoVotes :: Integer - , proposalPoolAbstainVotes :: Integer - , proposalCcYesVotes :: Integer - , proposalCcNoVotes :: Integer - , proposalCcAbstainVotes :: Integer - , proposalPrevGovActionIndex :: Maybe Integer - , proposalPrevGovActionTxHash :: Maybe Text - , proposalJson :: Maybe Value - , proposalAuthors :: Maybe Value + { proposalId :: Integer + , proposalTxHash :: Text + , proposalIndex :: Integer + , proposalType :: Text + , proposalDetails :: Maybe Value + , proposalExpiryDate :: Maybe LocalTime + , proposalExpiryEpochNo :: Maybe Integer + , proposalCreatedDate :: LocalTime + , proposalCreatedEpochNo :: Integer + , proposalUrl :: Text + , proposalDocHash :: Text + , proposalProtocolParams :: Maybe Value + , proposalTitle :: Maybe Text + , proposalAbstract :: Maybe Text + , proposalMotivation :: Maybe Text + , proposalRationale :: Maybe Text + , proposalDRepYesVotes :: Integer + , proposalDRepNoVotes :: Integer + , proposalDRepAbstainVotes :: Integer + , proposalPoolYesVotes :: Integer + , proposalPoolNoVotes :: Integer + , proposalPoolAbstainVotes :: Integer + , proposalCcYesVotes :: Integer + , proposalCcNoVotes :: Integer + , proposalCcAbstainVotes :: Integer + , proposalPrevGovActionIndex :: Maybe Integer + , proposalPrevGovActionTxHash :: Maybe Text + , proposalJson :: Maybe Value + , proposalAuthors :: Maybe Value } deriving (Show) @@ -246,10 +257,11 @@ instance FromRow Proposal where <*> field -- proposalJson <*> field -- proposalAuthors -data TransactionStatus = TransactionStatus - { transactionConfirmed :: Bool - , votingProcedure :: Maybe Value - } +data TransactionStatus + = TransactionStatus + { transactionConfirmed :: Bool + , votingProcedure :: Maybe Value + } instance FromRow TransactionStatus where fromRow = TransactionStatus <$> field <*> field @@ -261,13 +273,14 @@ instance ToJSON TransactionStatus where , "votingProcedure" .= votingProcedure ] -data EnactedProposalDetails = EnactedProposalDetails - { enactedProposalDetailsId :: Integer - , enactedProposalDetailsTxId :: Integer - , enactedProposalDetailsIndex :: Integer - , enactedProposalDetailsDescription :: Maybe Value - , enactedProposalDetailsHash :: Text - } +data EnactedProposalDetails + = EnactedProposalDetails + { enactedProposalDetailsId :: Integer + , enactedProposalDetailsTxId :: Integer + , enactedProposalDetailsIndex :: Integer + , enactedProposalDetailsDescription :: Maybe Value + , enactedProposalDetailsHash :: Text + } deriving (Show) instance FromRow EnactedProposalDetails where @@ -297,20 +310,20 @@ instance ToJSON EnactedProposalDetails where data CacheEnv = CacheEnv - { proposalListCache :: Cache.Cache () [Proposal] - , getProposalCache :: Cache.Cache (Text, Integer) Proposal - , currentEpochCache :: Cache.Cache () (Maybe Value) - , adaHolderVotingPowerCache :: Cache.Cache Text Integer - , adaHolderGetCurrentDelegationCache :: Cache.Cache Text (Maybe Delegation) - , dRepGetVotesCache :: Cache.Cache Text ([Vote], [Proposal]) - , dRepInfoCache :: Cache.Cache Text DRepInfo - , dRepVotingPowerCache :: Cache.Cache Text Integer - , dRepListCache :: Cache.Cache Text [DRepRegistration] - , networkMetricsCache :: Cache.Cache () NetworkMetrics - , networkInfoCache :: Cache.Cache () NetworkInfo - , networkTotalStakeCache :: Cache.Cache () NetworkTotalStake - , dRepVotingPowerListCache :: Cache.Cache Text [DRepVotingPowerList] - , accountInfoCache :: Cache.Cache Text AccountInfo + { proposalListCache :: Cache.Cache () [Proposal] + , getProposalCache :: Cache.Cache (Text, Integer) Proposal + , currentEpochCache :: Cache.Cache () (Maybe Value) + , adaHolderVotingPowerCache :: Cache.Cache Text Integer + , adaHolderGetCurrentDelegationCache :: Cache.Cache Text (Maybe Delegation) + , dRepGetVotesCache :: Cache.Cache Text ([Vote], [Proposal]) + , dRepInfoCache :: Cache.Cache Text DRepInfo + , dRepVotingPowerCache :: Cache.Cache Text Integer + , dRepListCache :: Cache.Cache Text [DRepRegistration] + , networkMetricsCache :: Cache.Cache () NetworkMetrics + , networkInfoCache :: Cache.Cache () NetworkInfo + , networkTotalStakeCache :: Cache.Cache () NetworkTotalStake + , dRepVotingPowerListCache :: Cache.Cache Text [DRepVotingPowerList] + , accountInfoCache :: Cache.Cache Text AccountInfo } data NetworkInfo @@ -326,24 +339,24 @@ data NetworkTotalStake { networkTotalStakeControlledByDReps :: Integer , networkTotalStakeControlledBySPOs :: Integer , networkTotalAlwaysAbstainVotingPower :: Integer - , networkTotalAlwaysNoConfidenceVotingPower :: Integer + , networkTotalAlwaysNoConfidenceVotingPower :: Integer } data NetworkMetrics = NetworkMetrics - { networkMetricsUniqueDelegators :: Integer - , networkMetricsTotalDelegations :: Integer - , networkMetricsTotalGovernanceActions :: Integer - , networkMetricsTotalDRepVotes :: Integer - , networkMetricsTotalRegisteredDReps :: Integer - , networkMetricsTotalDRepDistr :: Integer - , networkMetricsTotalActiveDReps :: Integer - , networkMetricsTotalInactiveDReps :: Integer - , networkMetricsTotalActiveCIP119CompliantDReps :: Integer - , networkMetricsTotalRegisteredDirectVoters :: Integer - , networkMetricsNoOfCommitteeMembers :: Integer - , networkMetricsQuorumNumerator :: Integer - , networkMetricsQuorumDenominator :: Integer + { networkMetricsUniqueDelegators :: Integer + , networkMetricsTotalDelegations :: Integer + , networkMetricsTotalGovernanceActions :: Integer + , networkMetricsTotalDRepVotes :: Integer + , networkMetricsTotalRegisteredDReps :: Integer + , networkMetricsTotalDRepDistr :: Integer + , networkMetricsTotalActiveDReps :: Integer + , networkMetricsTotalInactiveDReps :: Integer + , networkMetricsTotalActiveCIP119CompliantDReps :: Integer + , networkMetricsTotalRegisteredDirectVoters :: Integer + , networkMetricsNoOfCommitteeMembers :: Integer + , networkMetricsQuorumNumerator :: Integer + , networkMetricsQuorumDenominator :: Integer } data Delegation diff --git a/govtool/backend/stack.yaml b/govtool/backend/stack.yaml index b6238c401..8de57aa42 100644 --- a/govtool/backend/stack.yaml +++ b/govtool/backend/stack.yaml @@ -4,3 +4,4 @@ packages: extra-deps: - raven-haskell-0.1.4.1@sha256:9187272adc064197528645b5ad9b89163b668f386f34016d97fa646d5c790784 +- http-client-multipart-0.3.0.0@sha256:d675f10cba69c98233467dd533ba46e64f34798fc2ea528efe662ad2ea6c89bf,554 \ No newline at end of file diff --git a/govtool/backend/stack.yaml.lock b/govtool/backend/stack.yaml.lock index 985ab39a4..d03add71d 100644 --- a/govtool/backend/stack.yaml.lock +++ b/govtool/backend/stack.yaml.lock @@ -1,7 +1,7 @@ # This file was autogenerated by Stack. # You should not edit this file by hand. # For more information, please see the documentation at: -# https://docs.haskellstack.org/en/stable/lock_files +# https://docs.haskellstack.org/en/stable/topics/lock_files packages: - completed: @@ -11,6 +11,13 @@ packages: size: 632 original: hackage: raven-haskell-0.1.4.1@sha256:9187272adc064197528645b5ad9b89163b668f386f34016d97fa646d5c790784 +- completed: + hackage: http-client-multipart-0.3.0.0@sha256:d675f10cba69c98233467dd533ba46e64f34798fc2ea528efe662ad2ea6c89bf,554 + pantry-tree: + sha256: a35e249bf5a162c18e5fa2309c5cfcdaaead1d8fc914be029f3f1239102bd648 + size: 164 + original: + hackage: http-client-multipart-0.3.0.0@sha256:d675f10cba69c98233467dd533ba46e64f34798fc2ea528efe662ad2ea6c89bf,554 snapshots: - completed: sha256: e019cd29e3f7f9dbad500225829a3f7a50f73c674614f2f452e21bb8bf5d99ea diff --git a/govtool/backend/vva-be.cabal b/govtool/backend/vva-be.cabal index b644dad9a..8eb0de86d 100644 --- a/govtool/backend/vva-be.cabal +++ b/govtool/backend/vva-be.cabal @@ -45,13 +45,14 @@ executable vva-be -- other-modules: -- LANGUAGE extensions used by modules in this package. - -- other-extensions: - build-depends: base >=4.16 && <4.18 + -- other-extensions9 + build-depends: base >=4.16 && <4.19 , vva-be , optparse-applicative , text , servant-swagger-ui , servant-server + , servant-exceptions , servant-openapi3 , servant , wai @@ -80,7 +81,7 @@ executable vva-be library hs-source-dirs: src - build-depends: base >=4.16 && <4.18 + build-depends: base >=4.16 && <4.19 , servant-server , conferer , mtl @@ -91,6 +92,7 @@ library , bytestring , optparse-applicative , servant + , servant-exceptions , openapi3 , lens , postgresql-simple @@ -107,9 +109,11 @@ library , swagger2 , http-client , http-client-tls + , http-client-multipart , vector , async , random + , http-types exposed-modules: VVA.Config , VVA.CommandLine @@ -126,4 +130,5 @@ library , VVA.Types , VVA.Network , VVA.Account + , VVA.Ipfs ghc-options: -threaded diff --git a/govtool/frontend/package-lock.json b/govtool/frontend/package-lock.json index 39903d276..f823350c9 100644 --- a/govtool/frontend/package-lock.json +++ b/govtool/frontend/package-lock.json @@ -13,9 +13,9 @@ "@emotion/styled": "^11.11.0", "@emurgo/cardano-serialization-lib-asmjs": "^14.1.1", "@hookform/resolvers": "^3.3.1", - "@intersect.mbo/govtool-outcomes-pillar-ui": "v1.5.3", + "@intersect.mbo/govtool-outcomes-pillar-ui": "v1.5.5", "@intersect.mbo/intersectmbo.org-icons-set": "^1.0.8", - "@intersect.mbo/pdf-ui": "1.0.9-beta", + "@intersect.mbo/pdf-ui": "1.0.11-beta", "@mui/icons-material": "^5.14.3", "@mui/material": "^5.14.4", "@noble/ed25519": "^2.3.0", @@ -3392,14 +3392,14 @@ } }, "node_modules/@intersect.mbo/govtool-outcomes-pillar-ui": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@intersect.mbo/govtool-outcomes-pillar-ui/-/govtool-outcomes-pillar-ui-1.5.3.tgz", - "integrity": "sha512-dMIiaqyS7S3c3UO/NDweCwMsX197WwNdn6elkbizQbZjBLNwKr0mqsoKOMvkbeGDx7UgaRqjB+Nere2eu1xAQA==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@intersect.mbo/govtool-outcomes-pillar-ui/-/govtool-outcomes-pillar-ui-1.5.5.tgz", + "integrity": "sha512-m91glJAw3Hi/tw2S00EmyNByupaBuDw2g2h88RCAc3TdC6AHA2jkPzjmRVr21rl/gk9/p9S+GY0X+4kr4d6ULw==", "license": "ISC", "dependencies": { "@fontsource/poppins": "^5.0.14", "@intersect.mbo/intersectmbo.org-icons-set": "^1.0.8", - "axios": "^1.8.4", + "axios": "^1.10.0", "bech32": "^2.0.0", "buffer": "^6.0.3", "react-diff-view": "^3.2.1", @@ -3426,9 +3426,9 @@ "license": "ISC" }, "node_modules/@intersect.mbo/pdf-ui": { - "version": "1.0.9-beta", - "resolved": "https://registry.npmjs.org/@intersect.mbo/pdf-ui/-/pdf-ui-1.0.9-beta.tgz", - "integrity": "sha512-rFWt8PrRt6ecbW0mN2p3HQVUGtJyYEmJSaaMYcN/XgwGfehmJ5CblP9S7/W5ZoDiQrFTaH3bBLl9Bu31mbcJpA==", + "version": "1.0.11-beta", + "resolved": "https://registry.npmjs.org/@intersect.mbo/pdf-ui/-/pdf-ui-1.0.11-beta.tgz", + "integrity": "sha512-F/IMaJHDKXvXVuGG2Qb8BrpfFInRVApTbHSk7ERvz6OsW1Xe2bKWEBAn4nIa1BF5KwKEWq62bor4EC8ybB11ug==", "dependencies": { "@emurgo/cardano-serialization-lib-asmjs": "^12.0.0-beta.2", "@fontsource/poppins": "^5.0.14", @@ -9352,13 +9352,13 @@ } }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -10074,6 +10074,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -12089,6 +12102,20 @@ "license": "BSD-2-Clause", "peer": true }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -12283,13 +12310,10 @@ "peer": true }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -12357,9 +12381,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -12369,14 +12393,15 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -14952,13 +14977,15 @@ } }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -15146,16 +15173,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -15180,6 +15212,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -15364,12 +15409,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -15470,9 +15515,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -20044,6 +20089,15 @@ "remove-accents": "0.5.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", diff --git a/govtool/frontend/package.json b/govtool/frontend/package.json index bf96a1742..2ef6e7e8f 100644 --- a/govtool/frontend/package.json +++ b/govtool/frontend/package.json @@ -27,9 +27,9 @@ "@emotion/styled": "^11.11.0", "@emurgo/cardano-serialization-lib-asmjs": "^14.1.1", "@hookform/resolvers": "^3.3.1", - "@intersect.mbo/govtool-outcomes-pillar-ui": "v1.5.3", + "@intersect.mbo/govtool-outcomes-pillar-ui": "v1.5.5", "@intersect.mbo/intersectmbo.org-icons-set": "^1.0.8", - "@intersect.mbo/pdf-ui": "1.0.9-beta", + "@intersect.mbo/pdf-ui": "1.0.11-beta", "@mui/icons-material": "^5.14.3", "@mui/material": "^5.14.4", "@noble/ed25519": "^2.3.0", diff --git a/govtool/frontend/src/components/molecules/GovernanceActionNewCommitteeDetailsTabContent.tsx b/govtool/frontend/src/components/molecules/GovernanceActionNewCommitteeDetailsTabContent.tsx index e3a2a286a..ffa415025 100644 --- a/govtool/frontend/src/components/molecules/GovernanceActionNewCommitteeDetailsTabContent.tsx +++ b/govtool/frontend/src/components/molecules/GovernanceActionNewCommitteeDetailsTabContent.tsx @@ -12,41 +12,51 @@ type CCMember = { hash: string; newExpirationEpoch?: number; }; +type CCMemberToBeRemoved = { + hash: string; + hasScript?: boolean; +}; -function isArrayOfStrings(value: unknown): value is string[] { - return ( - Array.isArray(value) && value.every((item) => typeof item === "string") - ); -} +const getCip129Identifier = (hash: string, hasScript?: boolean) => + encodeCIP129Identifier({ + txID: (hasScript ? "13" : "12") + hash, + bech32Prefix: "cc_cold", + }); export const GovernanceActionNewCommitteeDetailsTabContent = ({ details, }: Pick) => { const { t } = useTranslation(); const membersToBeAdded = ((details?.members as CCMember[]) || []) - .filter((member) => member.newExpirationEpoch === undefined) + .filter( + (member) => + member?.expirationEpoch === undefined || + member?.expirationEpoch === null, + ) + .filter((member) => member?.hash) .map((member) => ({ - cip129Identifier: encodeCIP129Identifier({ - txID: (member.hasScript ? "02" : "13") + member.hash, - bech32Prefix: member.hasScript ? "cc_hot" : "cc_cold", - }), + cip129Identifier: getCip129Identifier(member.hash, member.hasScript), expirationEpoch: member.expirationEpoch, })); const membersToBeUpdated = ((details?.members as CCMember[]) || []) - .filter((member) => member.newExpirationEpoch !== undefined) + .filter( + (member) => !!member?.expirationEpoch && !!member?.newExpirationEpoch, + ) + .filter((member) => member?.hash) .map((member) => ({ - cip129Identifier: encodeCIP129Identifier({ - txID: (member.hasScript ? "02" : "13") + member.hash, - bech32Prefix: member.hasScript ? "cc_hot" : "cc_cold", - }), + cip129Identifier: getCip129Identifier(member.hash, member.hasScript), expirationEpoch: member.expirationEpoch, newExpirationEpoch: member.newExpirationEpoch, })); - const membersToBeRemoved = isArrayOfStrings(details?.membersToBeRemoved) - ? details.membersToBeRemoved - : []; + const membersToBeRemoved = ( + (details?.membersToBeRemoved as CCMemberToBeRemoved[]) || [] + ) + .filter((member) => member?.hash && member.hash.trim() !== "") + .map((member) => ({ + cip129Identifier: getCip129Identifier(member.hash, member.hasScript), + })); return ( @@ -63,7 +73,7 @@ export const GovernanceActionNewCommitteeDetailsTabContent = ({ whiteSpace: "nowrap", }} > - {t("govActions.membersToBeAdded")} + {t("govActions.membersToBeAddedToTheCommittee")} {membersToBeAdded.map(({ cip129Identifier }) => ( @@ -101,10 +111,10 @@ export const GovernanceActionNewCommitteeDetailsTabContent = ({ whiteSpace: "nowrap", }} > - {t("govActions.membersToBeRemoved")} + {t("govActions.membersToBeRemovedFromTheCommittee")} - {membersToBeRemoved.map((hash) => ( - + {membersToBeRemoved.map(({ cip129Identifier }) => ( + - {encodeCIP129Identifier({ - txID: hash, - bech32Prefix: "cc_cold", - })} + {cip129Identifier} - + ))} @@ -179,8 +180,8 @@ export const GovernanceActionNewCommitteeDetailsTabContent = ({ }} > {t("govActions.changeToTermsEpochs", { - epochTo: newExpirationEpoch, - epochFrom: expirationEpoch, + epochTo: newExpirationEpoch ?? "N/A", + epochFrom: expirationEpoch ?? "N/A", })} diff --git a/govtool/frontend/src/components/molecules/VoteActionForm.tsx b/govtool/frontend/src/components/molecules/VoteActionForm.tsx index 9bc79a797..25c1807d2 100644 --- a/govtool/frontend/src/components/molecules/VoteActionForm.tsx +++ b/govtool/frontend/src/components/molecules/VoteActionForm.tsx @@ -3,7 +3,6 @@ import { Box } from "@mui/material"; import { Trans } from "react-i18next"; import { Button, Radio, Typography } from "@atoms"; -import { orange } from "@consts"; import { useModal } from "@context"; import { useScreenDimension, @@ -13,7 +12,8 @@ import { useGetVoteContextTextFromFile, } from "@hooks"; import { formatDisplayDate } from "@utils"; -import { ProposalData, ProposalVote } from "@/models"; +import { errorRed, fadedPurple } from "@/consts"; +import { ProposalData, ProposalVote, Vote } from "@/models"; import { VoteContextModalState, SubmittedVotesModalState } from "../organisms"; type VoteActionFormProps = { @@ -36,10 +36,16 @@ export const VoteActionForm = ({ useState(false); const { voter } = useGetVoterInfo(); - const { voteContextText } = useGetVoteContextTextFromFile(voteContextUrl); + const { voteContextText, valid: voteContextValid = true } = + useGetVoteContextTextFromFile(voteContextUrl, voteContextHash) || {}; - const { isMobile, screenWidth } = useScreenDimension(); - const { openModal } = useModal(); + const finalVoteContextText = + ((previousVote != null || undefined) && !voteContextUrl && !voteContextHash) + ? "" + : voteContextText; + + const { isMobile } = useScreenDimension(); + const { openModal, closeModal } = useModal(); const { t } = useTranslation(); const { @@ -50,7 +56,24 @@ export const VoteActionForm = ({ setValue, vote, canVote, - } = useVoteActionForm({ previousVote, voteContextHash, voteContextUrl }); + } = useVoteActionForm({ previousVote, voteContextHash, voteContextUrl, closeModal }); + + const handleVoteClick = (isVoteChanged:boolean) => { + openModal({ + type: "voteContext", + state: { + onSubmit: (url, hash) => { + setVoteContextUrl(url); + setVoteContextHash(hash ?? undefined); + confirmVote(vote as Vote, url, hash); + setVoteContextData(url, hash); + }, + vote: vote as Vote, + confirmVote, + previousRationale: isVoteChanged ? undefined : finalVoteContextText + } satisfies VoteContextModalState, + }); + }; const setVoteContextData = (url: string, hash: string | null) => { setVoteContextUrl(url); @@ -68,6 +91,9 @@ export const VoteActionForm = ({ if (previousVote?.url) { setVoteContextUrl(previousVote.url); } + if (previousVote?.metadataHash) { + setVoteContextHash(previousVote.metadataHash); + } }, [previousVote?.url, setVoteContextUrl]); const renderCancelButton = useMemo( @@ -91,7 +117,7 @@ export const VoteActionForm = ({ () => ( )} - - {t("optional")} - - - {voteContextText - ? t("govActions.contextAboutYourVote") - : t("govActions.youCanProvideContext")} - - {voteContextText && ( - + {t("govActions.invalidVoteContext")} + + } + {finalVoteContextText && ( + <> + {t("govActions.yourVoteRationale")} + - + {finalVoteContextText && ( + + - {voteContextText} - - - + > + {t("showMore")} + + + + )} + + )} + + + )} - + + - - {t("govActions.selectDifferentOption")} - {previousVote?.vote && previousVote?.vote !== vote ? ( ) : ( + // this button appears on gov action detail page to change vote or rationale. )} diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextCheckResult.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextCheckResult.tsx index 2e5ab3def..e75f63bda 100644 --- a/govtool/frontend/src/components/organisms/VoteContext/VoteContextCheckResult.tsx +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextCheckResult.tsx @@ -48,7 +48,10 @@ export const VoteContextCheckResult = ({ {errorMessage ? "Data validation failed" : "Success"} - {errorMessage ?? "Data check has been successful"} + {errorMessage ?? "GovTool has processed has your rationale"} + + + {errorMessage ?? "You can now proceed to vote."} {!errorMessage ? ( ) : ( + + + + ); +}; diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextGovTool.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextGovTool.tsx new file mode 100644 index 000000000..152464a09 --- /dev/null +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextGovTool.tsx @@ -0,0 +1,144 @@ +import { useEffect, Dispatch, SetStateAction, useState } from "react"; +import { Box, Button, CircularProgress, Link, Typography } from "@mui/material"; +import { useMutation } from "react-query"; + +import { VoteContextWrapper } from "@organisms"; +import { postIpfs } from "@services"; +import { downloadTextFile, openInNewTab } from "@utils"; +import { NodeObject } from "jsonld"; +import { UseFormSetValue } from "react-hook-form"; +import { VoteContextFormValues, useTranslation } from "@hooks"; +import { LINKS } from "@/consts/links"; +import { ICONS } from "@/consts/icons"; +import { primaryBlue } from "@/consts"; + +interface PostIpfsResponse { + ipfsCid: string; +} + +type VoteContextGovToolProps = { + setStep: Dispatch>; + setSavedHash: Dispatch>; + jsonldContent: NodeObject | null; + metadataHash: string | null; + setValue: UseFormSetValue; +}; + +export const VoteContextGovTool = ({ + setStep, + setSavedHash, + jsonldContent, + metadataHash, + setValue, +}: VoteContextGovToolProps) => { + const [apiResponse, setApiResponse] = useState(null); + const [uploadInitiated, setUploadInitiated] = useState(false); // New state to track upload + const { t } = useTranslation(); + + const openLink = () => openInNewTab(LINKS.STORING_INFORMATION_OFFLINE); + + const { mutate, isLoading, isError } = useMutation({ + mutationFn: postIpfs, + onSuccess: (data) => { + const ipfsUrl = `ipfs://${data.ipfsCid}`; + setValue("storingURL", ipfsUrl); + setSavedHash(metadataHash); // Set savedHash to metadataHash + setApiResponse(data); + }, + }); + + useEffect(() => { + if (jsonldContent && !uploadInitiated) { + mutate({ content: JSON.stringify(jsonldContent, null, 2) }); + setUploadInitiated(true); // Set flag after initiating upload + } + }, [jsonldContent, mutate, uploadInitiated]); + + const handleDownload = () => { + if (jsonldContent) { + downloadTextFile(JSON.stringify(jsonldContent, null, 2), "voteContext.jsonld"); + } + }; + + return ( + { setStep(2); }} + onContinue={() => { setStep(5); }} + useBackLabel + isContinueDisabled={!apiResponse || isError} + isVoteWithMetadata + > + + {t("createGovernanceAction.rationalePinnedToIPFS")} + + + {t("createGovernanceAction.learnMore")} + + {isError ? ( + + {t("createGovernanceAction.uploadToIPFSError")} + + ) : isLoading ? ( + + + + ) : apiResponse ? ( + <> + + {t("createGovernanceAction.optionalDownloadAndStoreMetadataFile")} + + + + {t("createGovernanceAction.rePinYourFileToIPFS")} + + + + {apiResponse.ipfsCid ? ( + + IPFS URI: {`https://ipfs.io/ipfs/${apiResponse.ipfsCid}`} + + ) : ( + "[URI]" + )} + + + + ) : ( + + {t("createGovernanceAction.uploadingToIPFS")} + + )} + + ); +}; diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextModal.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextModal.tsx index 4ceca049a..8bd2582bf 100644 --- a/govtool/frontend/src/components/organisms/VoteContext/VoteContextModal.tsx +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextModal.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useForm, FormProvider } from "react-hook-form"; +import { Dispatch, SetStateAction, useState } from "react"; +import { useForm, FormProvider, UseFormReturn } from "react-hook-form"; import { ModalWrapper } from "@atoms"; import { useModal } from "@context"; @@ -8,27 +8,47 @@ import { VoteContextCheckResult, VoteContextTerms, VoteContextText, + VoteContextChoice, + VoteContextGovTool, } from "@organisms"; -import { VoteContextFormValues } from "@hooks"; +import { NodeObject } from "jsonld"; +import { VoteContextFormValues, useVoteContextForm } from "@hooks"; +import { Vote } from "@/models"; export type VoteContextModalState = { onSubmit: (url: string, hash: string | null, voteContextText: string) => void; + vote?: Vote; + previousRationale?: string + confirmVote: ( + vote?: Vote, + url?: string, + hash?: string | null, + ) => void; + // onRationaleChange : () }; export const VoteContextModal = () => { const [step, setStep] = useState(1); + const [storeDataYourself, setStoreDataYourself] = useState(true); const [savedHash, setSavedHash] = useState(""); const [errorMessage, setErrorMessage] = useState( undefined, ); + const [jsonldContent, setJsonldContent] = useState(null); + const [metadataHash, setMetadataHash] = useState(null); const { state, closeModal } = useModal(); - const methods = useForm({ mode: "onChange" }); + const methods = useForm({ + mode: "onChange", + defaultValues: { + voteContextText: state?.previousRationale || "", + }, + }); const { getValues } = methods; const submitVoteContext = () => { - if (state && savedHash) { + if (state) { state.onSubmit( getValues("storingURL"), savedHash, @@ -47,29 +67,112 @@ export const VoteContextModal = () => { }} > - {step === 1 && ( - - )} - {step === 2 && ( - - )} - {step === 3 && ( - )} - {step === 4 && ( - {})} + vote={state?.vote} + previousRationale={state?.previousRationale} /> )} ); }; + +// New component to encapsulate the flow that uses useVoteContextForm +const VoteContextFlow = ({ + step, + setStep, + storeDataYourself, + setStoreDataYourself, + setSavedHash, + setErrorMessage, + jsonldContent, + setJsonldContent, + metadataHash, + setMetadataHash, + submitVoteContext, + onCancel, + errorMessage, + methods, // Accept methods +}: { + step: number; + setStep: Dispatch>; + storeDataYourself: boolean; + setStoreDataYourself: Dispatch>; + setSavedHash: Dispatch>; + setErrorMessage: Dispatch>; + jsonldContent: NodeObject | null; + setJsonldContent: Dispatch>; + metadataHash: string | null; + setMetadataHash: Dispatch>; + submitVoteContext: () => void; + onCancel: () => void; + errorMessage: string | undefined; + methods: UseFormReturn; // Type for methods +}) => { + const { generateMetadata } = useVoteContextForm(); + + return ( + <> + {step === 2 && ( + + )} + {step === 3 && storeDataYourself && ( + + )} + {step === 3 && !storeDataYourself && ( + + )} + {step === 4 && ( + + )} + {step === 5 && ( + + )} + + ); +}; diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextStoringInformation.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextStoringInformation.tsx index 953528fba..47f3b86e9 100644 --- a/govtool/frontend/src/components/organisms/VoteContext/VoteContextStoringInformation.tsx +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextStoringInformation.tsx @@ -49,6 +49,8 @@ export const VoteContextStoringInformation = ({ onContinue={validateURL} isContinueDisabled={isContinueDisabled} onCancel={onCancel} + isVoteWithMetadata + useSubmitLabel > {t("createGovernanceAction.storingInformationTitle")} diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextTerms.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextTerms.tsx index d59d1af38..beadd03ca 100644 --- a/govtool/frontend/src/components/organisms/VoteContext/VoteContextTerms.tsx +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextTerms.tsx @@ -23,11 +23,12 @@ export const VoteContextTerms = ({ setStep, onCancel }: StoreDataInfoProps) => { return ( setStep(3)} + onContinue={() => setStep(4)} isContinueDisabled={isContinueDisabled} onCancel={onCancel} + isVoteWithMetadata > - + {t("createGovernanceAction.storeDataTitle")} >; onCancel: () => void; + confirmVote: ( + vote?: Vote, + url?: string, + hash?: string | null, + ) => void; + vote?: Vote; + previousRationale? : string }; const MAX_LENGTH = 10000; export const VoteContextText = ({ setStep, onCancel, + confirmVote, + vote, + previousRationale }: VoteContextTextProps) => { const { t } = useTranslation(); const { control, errors, watch } = useVoteContextForm(); - const isContinueDisabled = !watch("voteContextText"); + const currentRationale = watch("voteContextText"); + + const isRationaleChanged = useMemo(() => currentRationale !== previousRationale, + [currentRationale, previousRationale]); + + const buttonLabel = useMemo(() => { + if (currentRationale === "") { + return t("govActions.voting.voteWithoutMetadata"); + } + return t("govActions.voting.continue"); + }, [currentRationale, t]); const fieldProps = { layoutStyles: { mb: 3 }, name: "voteContextText", placeholder: t("govActions.provideContext"), rules: { - required: { - value: true, - message: t("createGovernanceAction.fields.validations.required"), - }, maxLength: { value: MAX_LENGTH, message: t("createGovernanceAction.fields.validations.maxLength", { @@ -38,12 +55,15 @@ export const VoteContextText = ({ }, }, }; - return ( setStep(2)} - isContinueDisabled={isContinueDisabled} + isVoteWithMetadata={currentRationale !== ""} onCancel={onCancel} + onSkip={() => confirmVote(vote)} + continueLabel={buttonLabel} + isContinueDisabled={(previousRationale !== undefined && previousRationale !== null) + && !isRationaleChanged} > ); diff --git a/govtool/frontend/src/components/organisms/VoteContext/VoteContextWrapper.tsx b/govtool/frontend/src/components/organisms/VoteContext/VoteContextWrapper.tsx index 91ceb8ed5..6f56dda1f 100644 --- a/govtool/frontend/src/components/organisms/VoteContext/VoteContextWrapper.tsx +++ b/govtool/frontend/src/components/organisms/VoteContext/VoteContextWrapper.tsx @@ -5,17 +5,33 @@ import { useScreenDimension, useTranslation } from "@hooks"; import { Button } from "@atoms"; type VoteContextWrapperProps = { - onContinue: () => void; - isContinueDisabled?: boolean; - onCancel: () => void; + onContinue?: () => void; + isVoteWithMetadata?: boolean; + onCancel?: () => void; + hideAllBtn?: boolean; + useBackLabel?: boolean; + useSubmitLabel?: boolean; + onSkip?: () => void; + continueLabel?: string; + isContinueDisabled?:boolean }; export const VoteContextWrapper: FC< PropsWithChildren -> = ({ onContinue, isContinueDisabled, onCancel, children }) => { +> = ({ + onContinue, + isVoteWithMetadata, + onCancel, + children, + hideAllBtn = false, + useBackLabel = false, + useSubmitLabel = false, + onSkip, + continueLabel, + isContinueDisabled +}) => { const { isMobile } = useScreenDimension(); const { t } = useTranslation(); - return ( <> {children} - - - - + { + !hideAllBtn && + + + + + } ); }; diff --git a/govtool/frontend/src/components/organisms/index.ts b/govtool/frontend/src/components/organisms/index.ts index add7263b4..9ef017f4e 100644 --- a/govtool/frontend/src/components/organisms/index.ts +++ b/govtool/frontend/src/components/organisms/index.ts @@ -28,5 +28,7 @@ export * from "./UncontrolledImageInput"; export * from "./ValidatedGovernanceActionCard"; export * from "./ValidatedGovernanceVotedOnCard"; export * from "./VoteContext"; +export * from "./VoteContext/VoteContextChoice"; +export * from "./VoteContext/VoteContextGovTool"; export * from "./WrongRouteInfo"; export * from "./MaintenanceEndingBanner"; diff --git a/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx b/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx index 343ff146f..8d25e7804 100644 --- a/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx +++ b/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx @@ -7,7 +7,7 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; import { PATHS } from "@consts"; import { useCardano, useSnackbar } from "@context"; import { useWalletErrorModal } from "@hooks"; -import { ProposalVote } from "@/models"; +import { ProposalVote, Vote } from "@/models"; export interface VoteActionFormValues { vote: string; @@ -33,12 +33,12 @@ type Props = { previousVote?: ProposalVote | null; voteContextHash?: string; voteContextUrl?: string; + closeModal: () => void; }; export const useVoteActionForm = ({ previousVote, - voteContextHash, - voteContextUrl, + closeModal, }: Props) => { const [isLoading, setIsLoading] = useState(false); const { buildSignSubmitConwayCertTx, buildVote, isPendingTransaction } = @@ -52,7 +52,6 @@ export const useVoteActionForm = ({ const { control, - handleSubmit, formState: { errors, isDirty }, setValue, register: registerInput, @@ -71,52 +70,60 @@ export const useVoteActionForm = ({ index !== null && !areFormErrors; - const confirmVote = useCallback( - async (values: VoteActionFormValues) => { - if (!canVote) return; - - setIsLoading(true); - - const urlSubmitValue = voteContextUrl ?? ""; - const hashSubmitValue = voteContextHash ?? ""; - - try { - const isPendingTx = isPendingTransaction(); - if (isPendingTx) return; - const votingBuilder = await buildVote( - values.vote, - txHash, - index, - urlSubmitValue, - hashSubmitValue, - ); - const result = await buildSignSubmitConwayCertTx({ - votingBuilder, - type: "vote", - resourceId: txHash + index, - }); - if (result) { - addSuccessAlert("Vote submitted"); - navigate(PATHS.dashboardGovernanceActions, { - state: { - isVotedListOnLoad: !!previousVote?.vote, - }, - }); - } - } catch (error) { - openWalletErrorModal({ - error, - dataTestId: "vote-transaction-error-modal", + const confirmVote = useCallback( + async ( + voteValue?: Vote, + url?: string, + hashValue?: string | null, + ) => { + if (!canVote || !voteValue) return; + + setIsLoading(true); + + const urlSubmitValue = url ?? ""; + const hashSubmitValue = hashValue ?? ""; + + try { + const isPendingTx = isPendingTransaction(); + if (isPendingTx) return; + + const votingBuilder = await buildVote( + voteValue, + txHash, + index, + urlSubmitValue, + hashSubmitValue, + ); + + const result = await buildSignSubmitConwayCertTx({ + votingBuilder, + type: "vote", + resourceId: txHash + index, + }); + + if (result) { + addSuccessAlert("Vote submitted"); + navigate(PATHS.dashboardGovernanceActions, { + state: { + isVotedListOnLoad: !!previousVote?.vote, + }, }); - } finally { - setIsLoading(false); + closeModal(); } - }, + } catch (error) { + openWalletErrorModal({ + error, + dataTestId: "vote-transaction-error-modal", + }); + } finally { + setIsLoading(false); + } + }, [buildVote, buildSignSubmitConwayCertTx, txHash, index, canVote], ); return { - confirmVote: handleSubmit(confirmVote), + confirmVote, setValue, vote, registerInput, diff --git a/govtool/frontend/src/hooks/forms/useVoteContextForm.tsx b/govtool/frontend/src/hooks/forms/useVoteContextForm.tsx index e7f8c0144..e53fb168c 100644 --- a/govtool/frontend/src/hooks/forms/useVoteContextForm.tsx +++ b/govtool/frontend/src/hooks/forms/useVoteContextForm.tsx @@ -51,7 +51,7 @@ export const useVoteContextForm = ( setHash(jsonHash); setJson(jsonld); - return jsonld; + return { jsonld, jsonHash }; }, [getValues]); const onClickDownloadJson = () => { @@ -79,11 +79,11 @@ export const useVoteContextForm = ( } catch (error: any) { if (Object.values(MetadataValidationStatus).includes(error)) { if (setErrorMessage) setErrorMessage(error); - if (setStep) setStep(4); + if (setStep) setStep(5); } } finally { if (setSavedHash) setSavedHash(hash); - if (setStep) setStep(4); + if (setStep) setStep(5); } }, [hash], @@ -102,5 +102,6 @@ export const useVoteContextForm = ( setValue, watch, hash, + json, }; }; diff --git a/govtool/frontend/src/hooks/queries/useGetProposalsQuery.ts b/govtool/frontend/src/hooks/queries/useGetProposalsQuery.ts index 27f349615..bee3cd39d 100644 --- a/govtool/frontend/src/hooks/queries/useGetProposalsQuery.ts +++ b/govtool/frontend/src/hooks/queries/useGetProposalsQuery.ts @@ -48,6 +48,7 @@ export const useGetProposalsQuery = ({ enabled, refetchOnWindowFocus: true, keepPreviousData: true, + cacheTime: Infinity, }, ); diff --git a/govtool/frontend/src/hooks/queries/useGetVoteContextTextFromFile.ts b/govtool/frontend/src/hooks/queries/useGetVoteContextTextFromFile.ts index cab05659c..216f9ad39 100644 --- a/govtool/frontend/src/hooks/queries/useGetVoteContextTextFromFile.ts +++ b/govtool/frontend/src/hooks/queries/useGetVoteContextTextFromFile.ts @@ -5,20 +5,47 @@ import { QUERY_KEYS } from "@/consts"; import { useCardano } from "@/context"; import { useGetVoterInfo } from "."; -export const useGetVoteContextTextFromFile = (url: string | undefined) => { +export const useGetVoteContextTextFromFile = (url: string | undefined, + contextHash : string | undefined) => { const { dRepID } = useCardano(); const { voter } = useGetVoterInfo(); + if (url && url.startsWith("ipfs://")) { + url = url.replace("ipfs://", "https://ipfs.io/ipfs/"); + } + const { data, isLoading } = useQuery( - [QUERY_KEYS.useGetVoteContextFromFile, url], - () => getVoteContextTextFromFile(url), - { - enabled: - !!url && - !!dRepID && - (!!voter?.isRegisteredAsDRep || !!voter?.isRegisteredAsSoleVoter), - }, + [QUERY_KEYS.useGetVoteContextFromFile, url], + () => getVoteContextTextFromFile(url, contextHash), + { + enabled: + !!url && + !!dRepID && + (!!voter?.isRegisteredAsDRep || !!voter?.isRegisteredAsSoleVoter), + }, ); - return { voteContextText: data, isLoading }; + const voteContextText = (data?.metadata as { comment?: string })?.comment || ""; + + if (url === undefined || contextHash === undefined) { + return { + voteContextText: undefined, + isLoading: false, + valid: true + }; + } + if (data) { + if (data?.valid) { + return { + voteContextText, + isLoading, + valid: true + }; + } + return { + voteContextText: undefined, + isLoading, + valid: false + }; + } }; diff --git a/govtool/frontend/src/i18n/locales/en.json b/govtool/frontend/src/i18n/locales/en.json index 73836a465..f8ebb5efd 100644 --- a/govtool/frontend/src/i18n/locales/en.json +++ b/govtool/frontend/src/i18n/locales/en.json @@ -243,15 +243,33 @@ "storingInformationStep2Link": "Read full guide", "storingInformationStep3Label": "Paste the URL here", "storingInformationTitle": "Information Storage Steps", + "uploadToIPFSError" : "An Error occured while trying to upload to IPFS", "storingInformationURLPlaceholder": "URL", "supportingLinks": "Supporting links", "title": "Create a Governance Action", + "storeAndMaintainDataTitle": "Store and Maintain the Data Yourself", + "learnMoreAboutStoringInformation": "Learn more about storing information", + "govToolProvidesOptions": "GovTool currently provides two options for storing your rationale:", + "govToolCanPinToIPFS": "GovTool can pin it to IPFS", + "storeYourselfInRepo": "Store it yourself in a repository such as GitHub", + "storingOptionsForYourVoterRationale" : "Storing options for your voter rationale", + "chooseDataStorageOption": "Choose a data storage option:", + "govToolPinsDataToIPFS": "GovTool pins data to IPFS", + "downloadAndStoreYourself": "Download and store yourself", + "uploadingToIPFS": "Uploading to IPFS...", + "rationalePinnedToIPFS": "GovTool has pinned your rationale to IPFS", + "learnMore" : "Learn more", + "optionalDownloadAndStoreMetadataFile": "Optional: Download and store a backup copy of your metadata file", + "rePinYourFileToIPFS": "Re-pin your file to IPFS", + "oneMomentPlease" : "One Moment please", "modals": { "submitTransactionSuccess": { "message": "Your Governance Action may take a little time to submit to the chain.", "title": "Governance Action submitted!" } - } + }, + "back" : "Back", + "continue" : "Continue" }, "delegation": { "description": "You can delegate your voting power to a DRep or to a pre-defined voting option.", @@ -434,9 +452,11 @@ "castVote": "<0>You voted {{vote}} on this proposal\non {{date}} (Epoch {{epoch}})", "castVoteDeadline": "You can change your vote up to {{date}} UTC (Epoch {{epoch}})", "changeVote": "Change vote", + "changeRationale": "Change Rationale", "changeYourVote": "Change your vote", "chooseHowToVote": "Choose how you want to vote:", - "contextAboutYourVote": "Context about your vote", + "yourVoteRationale": "Your Vote Rationale", + "invalidVoteContext": "Invalid Vote Context", "dataMissing": "Data Missing", "dataMissingTooltipExplanation": "Please click “View Details” for more information.", "details": "Governance Details:", @@ -450,11 +470,16 @@ "governanceActionId": "Legacy Governance Action ID (CIP-105):", "cip129GovernanceActionId": "Governance Action ID:", "governanceActionType": "Governance Action Type:", + "voting":{ + "submitVote": "Submit Vote", + "continue": "Continue", + "voteWithoutMetadata": "Submit Vote" + }, "goToVote": "Go to Vote", "membersToBeRemovedFromTheCommittee": "Members to be removed from the Committee", "membersToBeAddedToTheCommittee": "Members to be added to the Committee", "changeToTermsOfExistingMembers": "Change to terms of existing members", - "changeToTermsEpochs": "To {{epochTo}} epoch {{epochFrom}} epoch", + "changeToTermsEpochs": "To {{epochTo}} epoch, from {{epochFrom}} epoch", "newThresholdValue": "New threshold value", "protocolParamsDetails": { "existing": "Existing", diff --git a/govtool/frontend/src/models/metadataValidation.ts b/govtool/frontend/src/models/metadataValidation.ts index cbd6356ee..ea5d7bb79 100644 --- a/govtool/frontend/src/models/metadataValidation.ts +++ b/govtool/frontend/src/models/metadataValidation.ts @@ -9,6 +9,7 @@ export enum MetadataValidationStatus { export enum MetadataStandard { CIP108 = "CIP108", CIP119 = "CIP119", + CIP100 = "CIP100" } export type ValidateMetadataResult = { diff --git a/govtool/frontend/src/services/requests/getVoteContextTextFromFile.ts b/govtool/frontend/src/services/requests/getVoteContextTextFromFile.ts index 0690e03b7..32778cf8b 100644 --- a/govtool/frontend/src/services/requests/getVoteContextTextFromFile.ts +++ b/govtool/frontend/src/services/requests/getVoteContextTextFromFile.ts @@ -1,11 +1,16 @@ -import axios from "axios"; +import { postValidate } from "./metadataValidation"; +import { MetadataStandard } from "@/models"; -export const getVoteContextTextFromFile = async (url: string | undefined) => { - if (!url) { - throw new Error("URL is undefined"); +export const getVoteContextTextFromFile = async (url: string | undefined, + contextHash : string | undefined) => { + if (!url || !contextHash) { + throw new Error("Missing Vote Context values"); } + const response = await postValidate({ + standard: MetadataStandard.CIP100, + url, + hash: contextHash + }); - const response = await axios.get(url); - - return response.data.body?.body?.comment ?? ""; + return { valid: response.valid, metadata: response.metadata }; }; diff --git a/govtool/frontend/src/services/requests/index.ts b/govtool/frontend/src/services/requests/index.ts index 598fe76d3..5887c0ee9 100644 --- a/govtool/frontend/src/services/requests/index.ts +++ b/govtool/frontend/src/services/requests/index.ts @@ -21,5 +21,6 @@ export * from "./postDRepRegister"; export * from "./postDRepRemoveVote"; export * from "./postDRepRetire"; export * from "./postDRepVote"; +export * from "./postIpfs"; export * from "./getDRepVotingPowerList"; export * from "./getAccount"; diff --git a/govtool/frontend/src/services/requests/postIpfs.ts b/govtool/frontend/src/services/requests/postIpfs.ts new file mode 100644 index 000000000..346dff3c4 --- /dev/null +++ b/govtool/frontend/src/services/requests/postIpfs.ts @@ -0,0 +1,10 @@ +import { API } from "../API"; + +export const postIpfs = async ({ content }: { content: string }) => { + const response = await API.post("/ipfs/upload", content, { + headers: { + "Content-Type": "text/plain;charset=utf-8" + } + }); + return response.data; +}; diff --git a/govtool/frontend/src/utils/clipboard.ts b/govtool/frontend/src/utils/clipboard.ts new file mode 100644 index 000000000..63e04ad3d --- /dev/null +++ b/govtool/frontend/src/utils/clipboard.ts @@ -0,0 +1,3 @@ +export const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); +}; diff --git a/govtool/frontend/src/utils/index.ts b/govtool/frontend/src/utils/index.ts index f331202b3..9ab32b8f0 100644 --- a/govtool/frontend/src/utils/index.ts +++ b/govtool/frontend/src/utils/index.ts @@ -39,3 +39,4 @@ export * from "./getBase64ImageDetails"; export * from "./parseBoolean"; export * from "./validateSignature"; export * from "./cip8verification"; +export * from "./clipboard";