diff --git a/CHANGELOG.md b/CHANGELOG.md index 911cd6242..6468e4f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ changes. ### Added +- Add feature-flagged CIP-179 linked survey authoring, rendering, and vote-response submission for governance actions + ### Fixed - Fix disappearing proposals in the governance actions list for the same tx hashes [Issue 3918](https://github.com/IntersectMBO/govtool/issues/3918) diff --git a/govtool/backend/sql/get-survey-definition.sql b/govtool/backend/sql/get-survey-definition.sql new file mode 100644 index 000000000..3a26d975c --- /dev/null +++ b/govtool/backend/sql/get-survey-definition.sql @@ -0,0 +1,6 @@ +SELECT encode(tx_metadata.bytes, 'hex') +FROM tx_metadata +JOIN tx ON tx.id = tx_metadata.tx_id +WHERE tx.hash = decode(?, 'hex') + AND tx_metadata.key = 17 +LIMIT 1; diff --git a/govtool/backend/src/VVA/API.hs b/govtool/backend/src/VVA/API.hs index c0a594f7a..c7a61e799 100644 --- a/govtool/backend/src/VVA/API.hs +++ b/govtool/backend/src/VVA/API.hs @@ -13,10 +13,11 @@ import Control.Exception (throw, throwIO) import Control.Monad.Except (runExceptT, throwError) import Control.Monad.Reader -import Data.Aeson (Array, ToJSON, Value (..), decode, toJSON) +import Data.Aeson (Array, ToJSON, Value (..), decode, object, toJSON, (.=)) import Data.Bool (Bool) import Data.ByteString.Lazy (ByteString) import qualified Data.ByteString.Lazy as BSL +import Data.Hashable (hash, hashWithSalt) import Data.List (sort, sortOn) import qualified Data.Map as Map import Data.Maybe (Maybe (Nothing), catMaybes, fromMaybe, mapMaybe) @@ -28,7 +29,6 @@ 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 Data.Hashable (hash, hashWithSalt) import Numeric.Natural (Natural) @@ -50,6 +50,7 @@ import qualified VVA.Epoch as Epoch import qualified VVA.Ipfs as Ipfs import VVA.Network as Network import qualified VVA.Proposal as Proposal +import qualified VVA.Survey as Survey import qualified VVA.Transaction as Transaction import qualified VVA.Types as Types import VVA.Types (App, AppEnv (..), @@ -89,6 +90,7 @@ type VVAApi = :> QueryParam "search" Text :> Get '[JSON] ListProposalsResponse :<|> "proposal" :> "get" :> Capture "proposalId" GovActionId :> QueryParam "drepId" HexText :> Get '[JSON] GetProposalResponse + :<|> "survey" :> "definition" :> Capture "txId" HexText :> Capture "index" Natural :> Get '[JSON] (Headers '[Header "Cache-Control" Text] AnyValue) :<|> "proposal" :> "enacted-details" :> QueryParam "type" GovernanceActionType :> Get '[JSON] (Maybe EnactedProposalDetailsResponse) :<|> "epoch" :> "params" :> Get '[JSON] GetCurrentEpochParamsResponse :<|> "transaction" :> "status" :> Capture "transactionId" HexText :> Get '[JSON] GetTransactionStatusResponse @@ -109,6 +111,7 @@ server = upload :<|> getStakeKeyVotingPower :<|> listProposals :<|> getProposal + :<|> getSurveyDefinition :<|> getEnactedProposalDetails :<|> getCurrentEpochParams :<|> getTransactionStatus @@ -118,6 +121,21 @@ server = upload :<|> getNetworkTotalStake :<|> getAccountInfo +getSurveyDefinition :: App m => HexText -> Natural -> m (Headers '[Header "Cache-Control" Text] AnyValue) +getSurveyDefinition (unHexText -> txId) surveyIndex = do + when (Text.length txId /= 64) $ + throwError $ ValidationError "txId must be a 64-character hex string" + when (surveyIndex > 65535) $ + throwError $ ValidationError "surveyIndex must be between 0 and 65535" + payloadCborHex <- Survey.getSurveyDefinition txId + pure $ addHeader "public, max-age=31536000, immutable" $ AnyValue $ Just $ + object + [ "txId" .= Text.toLower txId + , "surveyIndex" .= surveyIndex + , "metadataLabel" .= (17 :: Integer) + , "payloadCborHex" .= payloadCborHex + ] + upload :: App m => Maybe Text -> Text -> m UploadResponse upload mFileName fileContentText = do AppEnv {vvaConfig} <- ask diff --git a/govtool/backend/src/VVA/Account.hs b/govtool/backend/src/VVA/Account.hs index 5ce98ae78..6ccbabecf 100644 --- a/govtool/backend/src/VVA/Account.hs +++ b/govtool/backend/src/VVA/Account.hs @@ -4,21 +4,22 @@ module VVA.Account where -import Control.Monad.Except (MonadError, throwError) -import Control.Monad.Reader (MonadIO, MonadReader, liftIO) +import Control.Monad.Except (MonadError, throwError) +import Control.Monad.Reader (MonadIO, MonadReader, liftIO) +import Control.Monad.Trans.Control (MonadBaseControl) -import Data.ByteString (ByteString) -import Data.FileEmbed (embedFile) -import Data.Has (Has) -import Data.String (fromString) -import Data.Text (Text, unpack) -import qualified Data.Text.Encoding as Text -import qualified Data.Text.IO as Text +import Data.ByteString (ByteString) +import Data.FileEmbed (embedFile) +import Data.Has (Has) +import Data.String (fromString) +import Data.Text (Text, unpack) +import qualified Data.Text.Encoding as Text +import qualified Data.Text.IO as Text -import qualified Database.PostgreSQL.Simple as SQL +import qualified Database.PostgreSQL.Simple as SQL -import VVA.Pool (ConnectionPool, withPool) -import VVA.Types (AccountInfo (..), AppError (..)) +import VVA.Pool (ConnectionPool, withPool) +import VVA.Types (AccountInfo (..), AppError (..)) sqlFrom :: ByteString -> SQL.Query sqlFrom = fromString . unpack . Text.decodeUtf8 @@ -27,7 +28,7 @@ accountInfoSql :: SQL.Query accountInfoSql = sqlFrom $(embedFile "sql/get-account-info.sql") accountInfo :: - (Has ConnectionPool r, MonadReader r m, MonadIO m, MonadError AppError m) => + (Has ConnectionPool r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadError AppError m) => Text -> m AccountInfo accountInfo stakeKey = withPool $ \conn -> do diff --git a/govtool/backend/src/VVA/AdaHolder.hs b/govtool/backend/src/VVA/AdaHolder.hs index b708cd26e..9d5ee2de9 100644 --- a/govtool/backend/src/VVA/AdaHolder.hs +++ b/govtool/backend/src/VVA/AdaHolder.hs @@ -6,26 +6,27 @@ module VVA.AdaHolder where -import Control.Exception (SomeException, try) +import Control.Exception (SomeException, try) import Control.Monad.Except import Control.Monad.Reader +import Control.Monad.Trans.Control (MonadBaseControl) import Crypto.Hash -import Data.ByteString (ByteString) -import qualified Data.ByteString.Char8 as C -import Data.FileEmbed (embedFile) -import Data.Has (Has) +import Data.ByteString (ByteString) +import qualified Data.ByteString.Char8 as C +import Data.FileEmbed (embedFile) +import Data.Has (Has) import Data.Scientific -import Data.String (fromString) -import Data.Text (Text, unpack) -import qualified Data.Text.Encoding as Text -import qualified Data.Text.IO as Text +import Data.String (fromString) +import Data.Text (Text, unpack) +import qualified Data.Text.Encoding as Text +import qualified Data.Text.IO as Text -import qualified Database.PostgreSQL.Simple as SQL +import qualified Database.PostgreSQL.Simple as SQL import VVA.Config -import VVA.Pool (ConnectionPool, withPool) +import VVA.Pool (ConnectionPool, withPool) import VVA.Types sqlFrom :: ByteString -> SQL.Query @@ -35,7 +36,7 @@ getCurrentDelegationSql :: SQL.Query getCurrentDelegationSql = sqlFrom $(embedFile "sql/get-current-delegation.sql") getCurrentDelegation :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m) => Text -> m (Maybe Delegation) getCurrentDelegation stakeKey = withPool $ \conn -> do @@ -49,7 +50,7 @@ getVotingPowerSql :: SQL.Query getVotingPowerSql = sqlFrom $(embedFile "sql/get-stake-key-voting-power.sql") getStakeKeyVotingPower :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadFail m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadFail m) => Text -> m Integer getStakeKeyVotingPower stakeKey = withPool $ \conn -> do diff --git a/govtool/backend/src/VVA/DRep.hs b/govtool/backend/src/VVA/DRep.hs index 44b172f7f..6be34da77 100644 --- a/govtool/backend/src/VVA/DRep.hs +++ b/govtool/backend/src/VVA/DRep.hs @@ -8,6 +8,7 @@ module VVA.DRep where import Control.Monad.Except (MonadError) import Control.Monad.Reader +import Control.Monad.Trans.Control (MonadBaseControl) import Crypto.Hash @@ -78,7 +79,7 @@ sqlFrom bs = fromString $ unpack $ Text.decodeUtf8 bs listDRepsSql :: SQL.Query listDRepsSql = sqlFrom $(embedFile "sql/list-dreps.sql") listDReps :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m) => Maybe Text -> m [DRepRegistration] listDReps mSearchQuery = withPool $ \conn -> do let searchParam = fromMaybe "" mSearchQuery @@ -134,7 +135,7 @@ getVotingPowerSql :: SQL.Query getVotingPowerSql = sqlFrom $(embedFile "sql/get-voting-power.sql") getVotingPower :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadFail m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadFail m) => Text -> m Integer getVotingPower drepId = withPool $ \conn -> do @@ -147,7 +148,7 @@ getVotesSql :: SQL.Query getVotesSql = sqlFrom $(embedFile "sql/get-votes.sql") getVotes :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadFail m, MonadError AppError m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadFail m, MonadError AppError m) => Text -> [Text] -> m ([Vote], [Proposal]) @@ -183,6 +184,7 @@ getDRepInfo , Has VVAConfig r , MonadReader r m , MonadIO m + , MonadBaseControl IO m , MonadFail m , MonadError AppError m ) @@ -244,7 +246,7 @@ getFilteredDRepVotingPowerSql :: SQL.Query getFilteredDRepVotingPowerSql = sqlFrom $(embedFile "sql/get-filtered-dreps-voting-power.sql") getDRepsVotingPowerList :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m) => [Text] -> m [DRepVotingPowerList] getDRepsVotingPowerList identifiers = withPool $ \conn -> do diff --git a/govtool/backend/src/VVA/Epoch.hs b/govtool/backend/src/VVA/Epoch.hs index 9c48db028..8c11a026d 100644 --- a/govtool/backend/src/VVA/Epoch.hs +++ b/govtool/backend/src/VVA/Epoch.hs @@ -4,21 +4,22 @@ module VVA.Epoch where -import Control.Monad.Except (MonadError, throwError) +import Control.Monad.Except (MonadError, throwError) import Control.Monad.Reader +import Control.Monad.Trans.Control (MonadBaseControl) -import Data.Aeson (Value) -import Data.ByteString (ByteString) -import Data.FileEmbed (embedFile) -import Data.Has (Has) -import Data.String (fromString) -import Data.Text (Text, unpack) -import qualified Data.Text.Encoding as Text +import Data.Aeson (Value) +import Data.ByteString (ByteString) +import Data.FileEmbed (embedFile) +import Data.Has (Has) +import Data.String (fromString) +import Data.Text (Text, unpack) +import qualified Data.Text.Encoding as Text -import qualified Database.PostgreSQL.Simple as SQL +import qualified Database.PostgreSQL.Simple as SQL import VVA.Config -import VVA.Pool (ConnectionPool, withPool) +import VVA.Pool (ConnectionPool, withPool) sqlFrom :: ByteString -> SQL.Query sqlFrom bs = fromString $ unpack $ Text.decodeUtf8 bs @@ -27,7 +28,7 @@ getCurrentEpochParamsSql :: SQL.Query getCurrentEpochParamsSql = sqlFrom $(embedFile "sql/get-current-epoch-params.sql") getCurrentEpochParams :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m) => m (Maybe Value) getCurrentEpochParams = withPool $ \conn -> do result <- liftIO $ SQL.query_ conn getCurrentEpochParamsSql diff --git a/govtool/backend/src/VVA/Network.hs b/govtool/backend/src/VVA/Network.hs index ec99dc82a..d6178789e 100644 --- a/govtool/backend/src/VVA/Network.hs +++ b/govtool/backend/src/VVA/Network.hs @@ -4,22 +4,23 @@ module VVA.Network where -import Control.Monad.Except (MonadError, throwError) +import Control.Monad.Except (MonadError, throwError) import Control.Monad.Reader +import Control.Monad.Trans.Control (MonadBaseControl) -import Data.Aeson (Value) -import Data.ByteString (ByteString) -import Data.FileEmbed (embedFile) -import Data.Has (Has) -import Data.String (fromString) -import Data.Text (Text, unpack) -import qualified Data.Text.Encoding as Text +import Data.Aeson (Value) +import Data.ByteString (ByteString) +import Data.FileEmbed (embedFile) +import Data.Has (Has) +import Data.String (fromString) +import Data.Text (Text, unpack) +import qualified Data.Text.Encoding as Text import Data.Time.Clock -import qualified Database.PostgreSQL.Simple as SQL +import qualified Database.PostgreSQL.Simple as SQL import VVA.Config -import VVA.Pool (ConnectionPool, withPool) +import VVA.Pool (ConnectionPool, withPool) import VVA.Types sqlFrom :: ByteString -> SQL.Query @@ -29,7 +30,7 @@ networkInfoSql :: SQL.Query networkInfoSql = sqlFrom $(embedFile "sql/get-network-info.sql") networkInfo :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadError AppError m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadError AppError m) => m NetworkInfo networkInfo = withPool $ \conn -> do result <- liftIO $ SQL.query_ conn networkInfoSql @@ -49,7 +50,7 @@ networkTotalStakeSql :: SQL.Query networkTotalStakeSql = sqlFrom $(embedFile "sql/get-network-total-stake.sql") networkTotalStake :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadError AppError m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadError AppError m) => m NetworkTotalStake networkTotalStake = withPool $ \conn -> do result <- liftIO $ SQL.query_ conn networkTotalStakeSql @@ -69,7 +70,7 @@ networkMetricsSql :: SQL.Query networkMetricsSql = sqlFrom $(embedFile "sql/get-network-metrics.sql") networkMetrics :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadError AppError m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadError AppError m) => m NetworkMetrics networkMetrics = withPool $ \conn -> do result <- liftIO $ SQL.query_ conn networkMetricsSql diff --git a/govtool/backend/src/VVA/Pool.hs b/govtool/backend/src/VVA/Pool.hs index ed7c7ea4a..d74e2d24b 100644 --- a/govtool/backend/src/VVA/Pool.hs +++ b/govtool/backend/src/VVA/Pool.hs @@ -2,23 +2,21 @@ module VVA.Pool where -import Control.Monad.IO.Class (MonadIO, liftIO) -import Control.Monad.Reader (MonadReader, asks) +import Control.Monad.IO.Class (MonadIO) +import Control.Monad.Reader (MonadReader, asks) +import Control.Monad.Trans.Control (MonadBaseControl) -import Data.Has (Has, getter) -import Data.Pool (Pool, putResource, takeResource) +import Data.Has (Has, getter) +import Data.Pool (Pool, withResource) -import Database.PostgreSQL.Simple (Connection) +import Database.PostgreSQL.Simple (Connection) type ConnectionPool = Pool Connection withPool - :: (Has ConnectionPool r, MonadReader r m, MonadIO m) + :: (Has ConnectionPool r, MonadReader r m, MonadIO m, MonadBaseControl IO m) => (Connection -> m a) -> m a withPool f = do pool <- asks getter - (conn,localPool) <- liftIO $ takeResource pool - result <- f conn - liftIO $ putResource localPool conn - return result + withResource pool f diff --git a/govtool/backend/src/VVA/Proposal.hs b/govtool/backend/src/VVA/Proposal.hs index b4c5b502d..f6cfea392 100644 --- a/govtool/backend/src/VVA/Proposal.hs +++ b/govtool/backend/src/VVA/Proposal.hs @@ -11,6 +11,7 @@ module VVA.Proposal where import Control.Exception (SomeException, throw, try) import Control.Monad.Except (MonadError, throwError) import Control.Monad.Reader +import Control.Monad.Trans.Control (MonadBaseControl) import Data.Aeson import Data.Aeson.Types (Parser, parseMaybe) @@ -60,12 +61,12 @@ instance ToRow TextArray where toRow (TextArray texts) = map toField texts listProposals :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadFail m, MonadError AppError m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadFail m, MonadError AppError m) => Maybe Text -> m [Proposal] listProposals mSearch = getProposals (fmap (:[]) mSearch) getProposal :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadFail m, MonadError AppError m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadFail m, MonadError AppError m) => Text -> Integer -> m Proposal @@ -78,7 +79,7 @@ getProposal txHash index = do _ -> throwError $ CriticalError ("Multiple proposals found for id: " <> proposalId <> ". This should never happen") getProposals :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadFail m, MonadError AppError m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadFail m, MonadError AppError m) => Maybe [Text] -> m [Proposal] getProposals mSearchTerms = withPool $ \conn -> do let searchParam = maybe "" head mSearchTerms @@ -105,7 +106,7 @@ latestEnactedProposalSql = return rawSql getPreviousEnactedProposal :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadFail m, MonadError AppError m) => + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadFail m, MonadError AppError m) => Text -> m (Maybe EnactedProposalDetails) getPreviousEnactedProposal proposalType = withPool $ \conn -> do diff --git a/govtool/backend/src/VVA/Survey.hs b/govtool/backend/src/VVA/Survey.hs new file mode 100644 index 000000000..176a0f1eb --- /dev/null +++ b/govtool/backend/src/VVA/Survey.hs @@ -0,0 +1,38 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module VVA.Survey where + +import Control.Monad.Except (MonadError, throwError) +import Control.Monad.Reader +import Control.Monad.Trans.Control (MonadBaseControl) + +import Data.ByteString (ByteString) +import Data.FileEmbed (embedFile) +import Data.Has (Has) +import Data.String (fromString) +import Data.Text (Text, unpack) +import qualified Data.Text.Encoding as Text + +import qualified Database.PostgreSQL.Simple as SQL + +import VVA.Pool (ConnectionPool, withPool) +import VVA.Types (AppError (..)) + +sqlFrom :: ByteString -> SQL.Query +sqlFrom bs = fromString $ unpack $ Text.decodeUtf8 bs + +getSurveyDefinitionSql :: SQL.Query +getSurveyDefinitionSql = sqlFrom $(embedFile "sql/get-survey-definition.sql") + +getSurveyDefinition :: + (Has ConnectionPool r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadError AppError m) => + Text -> + m Text +getSurveyDefinition txId = withPool $ \conn -> do + result <- liftIO $ SQL.query conn getSurveyDefinitionSql (SQL.Only txId) + case result of + [SQL.Only payload] -> pure payload + _ -> throwError $ NotFoundError $ + "No metadata label 17 found for transaction " <> txId diff --git a/govtool/backend/src/VVA/Transaction.hs b/govtool/backend/src/VVA/Transaction.hs index 36b6edeaa..83b2c6126 100644 --- a/govtool/backend/src/VVA/Transaction.hs +++ b/govtool/backend/src/VVA/Transaction.hs @@ -4,25 +4,26 @@ module VVA.Transaction where -import Control.Exception (throw) -import Control.Monad.Except (MonadError, throwError) +import Control.Exception (throw) +import Control.Monad.Except (MonadError, throwError) import Control.Monad.Reader +import Control.Monad.Trans.Control (MonadBaseControl) -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.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 qualified Database.PostgreSQL.Simple as SQL +import qualified Database.PostgreSQL.Simple as SQL import VVA.Config -import VVA.Pool (ConnectionPool, withPool) -import VVA.Types (AppError (..), TransactionStatus (..)) +import VVA.Pool (ConnectionPool, withPool) +import VVA.Types (AppError (..), TransactionStatus (..)) sqlFrom :: ByteString -> SQL.Query sqlFrom bs = fromString $ unpack $ Text.decodeUtf8 bs @@ -31,7 +32,7 @@ getTransactionStatusSql :: SQL.Query getTransactionStatusSql = sqlFrom $(embedFile "sql/get-transaction-status.sql") getTransactionStatus :: - (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadError AppError m) + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m, MonadBaseControl IO m, MonadError AppError m) => Text -> m (Maybe TransactionStatus) getTransactionStatus transactionId = withPool $ \conn -> do diff --git a/govtool/backend/src/VVA/Types.hs b/govtool/backend/src/VVA/Types.hs index 9ace0e5bc..4a6432ad0 100644 --- a/govtool/backend/src/VVA/Types.hs +++ b/govtool/backend/src/VVA/Types.hs @@ -15,6 +15,7 @@ import Control.Monad.Except (MonadError) import Control.Monad.Fail (MonadFail) import Control.Monad.IO.Class (MonadIO) import Control.Monad.Reader (MonadReader) +import Control.Monad.Trans.Control (MonadBaseControl) import Data.Aeson (ToJSON (..), Value, object, (.=)) import qualified Data.Aeson as A @@ -34,7 +35,7 @@ import VVA.Cache import VVA.Config import VVA.Ipfs (IpfsError) -type App m = (MonadReader AppEnv m, MonadIO m, MonadFail m, MonadError AppError m) +type App m = (MonadReader AppEnv m, MonadIO m, MonadBaseControl IO m, MonadFail m, MonadError AppError m) data AppEnv = AppEnv @@ -118,7 +119,8 @@ data DRepVotingPowerList } deriving (Eq, Show) -data DRepStatus = Active | Inactive | Retired deriving (Eq, Ord, Show) +data DRepStatus = Active | Inactive | Retired + deriving (Eq, Ord, Show) instance FromField DRepStatus where fromField f mdata = do @@ -129,7 +131,8 @@ instance FromField DRepStatus where "Retired" -> return Retired _ -> returnError ConversionFailed f "Invalid DRepStatus" -data DRepType = DRep | SoleVoter deriving (Eq, Show) +data DRepType = DRep | SoleVoter + deriving (Eq, Show) instance FromField DRepType where fromField f mdata = do diff --git a/govtool/backend/test/PoolSpec.hs b/govtool/backend/test/PoolSpec.hs new file mode 100644 index 000000000..7ccce2b07 --- /dev/null +++ b/govtool/backend/test/PoolSpec.hs @@ -0,0 +1,70 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Main where + +import Control.Concurrent (forkIO, killThread, newEmptyMVar, putMVar, takeMVar, threadDelay) +import Control.Exception (SomeException, bracket, finally, throwIO, try) +import Control.Monad (replicateM_, unless) +import Control.Monad.Except (ExceptT, runExceptT, throwError) +import Control.Monad.IO.Class (liftIO) +import Control.Monad.Reader (ReaderT, runReaderT) + +import Data.IORef (modifyIORef', newIORef, readIORef) +import Data.Pool (createPool, destroyAllResources) +import qualified Data.Text as Text + +import System.Timeout (timeout) + +import qualified VVA.API as API +import VVA.API.Types (HexText (..)) +import VVA.Pool (ConnectionPool, withPool) +import VVA.Types (AppError (ValidationError)) + +within :: IO a -> IO a +within action = timeout 1000000 action >>= maybe (fail "Pool capacity leaked") pure + +run :: ConnectionPool -> ExceptT String (ReaderT ConnectionPool IO) a -> IO (Either String a) +run pool action = runReaderT (runExceptT action) pool + +main :: IO () +main = do + created <- newIORef (0 :: Int) + destroyed <- newIORef (0 :: Int) + -- Only resource ownership is exercised: no SQL operation may force this sentinel. + let acquire = modifyIORef' created (+ 1) >> pure (error "Unexpected database use in pool test") + release _ = modifyIORef' destroyed (+ 1) + bracket (createPool acquire release 1 60 1) destroyAllResources $ \pool -> do + result <- within $ run pool $ withPool $ \_ -> pure (42 :: Int) + unless (result == Right 42) $ fail "Successful result changed" + replicateM_ 3 $ do + missing <- within $ run pool $ withPool $ \_ -> throwError "not found" + unless (missing == (Left "not found" :: Either String ())) $ fail "Application error changed" + count <- readIORef created + unless (count == 1) $ fail "Application errors should return the resource" + failed <- try (within $ run pool $ withPool $ \_ -> liftIO $ throwIO $ userError "SQL failure") + :: IO (Either SomeException (Either String ())) + case failed of + Left _ -> pure () + Right _ -> fail "IO exception was swallowed" + closed <- readIORef destroyed + unless (closed == 1) $ fail "IO failure should destroy the resource" + entered <- newEmptyMVar + finished <- newEmptyMVar + worker <- forkIO $ (do + _ <- try (run pool $ withPool $ \_ -> liftIO $ putMVar entered () >> threadDelay 10000000) + :: IO (Either SomeException (Either String ())) + pure ()) `finally` putMVar finished () + within $ takeMVar entered + killThread worker + within $ takeMVar finished + cancelled <- readIORef destroyed + unless (cancelled == 2) $ fail "Cancellation should destroy the resource" + recovered <- within $ run pool $ withPool $ \_ -> pure () + unless (recovered == Right ()) $ fail "Pool did not recover" + invalid <- within $ runReaderT + (runExceptT $ API.getSurveyDefinition (HexText $ Text.replicate 32 "ab") 65536) + (error "Invalid survey index must not access the application environment") + case invalid of + Left (ValidationError _) -> pure () + _ -> fail "Out-of-range survey index was not rejected" + putStrLn "Pool success, application error, IO exception and cancellation checks passed" diff --git a/govtool/backend/vva-be.cabal b/govtool/backend/vva-be.cabal index 8eb0de86d..abeffb02d 100644 --- a/govtool/backend/vva-be.cabal +++ b/govtool/backend/vva-be.cabal @@ -38,6 +38,7 @@ extra-source-files: sql/get-filtered-dreps-voting-power.sql sql/get-previous-enacted-governance-action-proposal-details.sql sql/get-account-info.sql + sql/get-survey-definition.sql executable vva-be main-is: Main.hs @@ -85,6 +86,7 @@ library , servant-server , conferer , mtl + , monad-control , aeson , aeson-pretty , conferer-aeson @@ -131,4 +133,17 @@ library , VVA.Network , VVA.Account , VVA.Ipfs + , VVA.Survey + ghc-options: -threaded + +test-suite pool-regression + type: exitcode-stdio-1.0 + main-is: PoolSpec.hs + hs-source-dirs: test + default-language: Haskell2010 + build-depends: base >=4.16 && <4.19 + , vva-be + , mtl + , text + , resource-pool == 0.2.3.2 ghc-options: -threaded diff --git a/govtool/frontend/.env.example b/govtool/frontend/.env.example index db8d2ae78..ca3c684b2 100644 --- a/govtool/frontend/.env.example +++ b/govtool/frontend/.env.example @@ -8,7 +8,8 @@ VITE_IS_DEV=true VITE_USERSNAP_SPACE_API_KEY="" VITE_IS_PROPOSAL_DISCUSSION_FORUM_ENABLED='true' VITE_IS_GOVERNANCE_OUTCOMES_PILLAR_ENABLED='true' +VITE_IS_CIP179_ENABLED='false' VITE_PDF_API_URL="" VITE_OUTCOMES_API_URL="" VITE_IPFS_GATEWAY="" -VITE_IPFS_PROJECT_ID="" \ No newline at end of file +VITE_IPFS_PROJECT_ID="" diff --git a/govtool/frontend/docker-entrypoint.sh b/govtool/frontend/docker-entrypoint.sh index 2d4dd61eb..faa8528a2 100644 --- a/govtool/frontend/docker-entrypoint.sh +++ b/govtool/frontend/docker-entrypoint.sh @@ -17,6 +17,7 @@ window.__ENV__ = { VITE_USERSNAP_SPACE_API_KEY: $(json_escape "${VITE_USERSNAP_SPACE_API_KEY:-}"), VITE_IS_PROPOSAL_DISCUSSION_FORUM_ENABLED: $(json_escape "${VITE_IS_PROPOSAL_DISCUSSION_FORUM_ENABLED:-}"), VITE_IS_GOVERNANCE_OUTCOMES_PILLAR_ENABLED: $(json_escape "${VITE_IS_GOVERNANCE_OUTCOMES_PILLAR_ENABLED:-}"), + VITE_IS_CIP179_ENABLED: $(json_escape "${VITE_IS_CIP179_ENABLED:-false}"), VITE_PDF_API_URL: $(json_escape "${VITE_PDF_API_URL:-}"), VITE_OUTCOMES_API_URL: $(json_escape "${VITE_OUTCOMES_API_URL:-}"), VITE_IPFS_GATEWAY: $(json_escape "${VITE_IPFS_GATEWAY:-}"), diff --git a/govtool/frontend/package-lock.json b/govtool/frontend/package-lock.json index fa00acd6f..d5576bf87 100644 --- a/govtool/frontend/package-lock.json +++ b/govtool/frontend/package-lock.json @@ -16,6 +16,7 @@ "@intersect.mbo/govtool-outcomes-pillar-ui": "1.5.10", "@intersect.mbo/intersectmbo.org-icons-set": "^1.0.8", "@intersect.mbo/pdf-ui": "1.0.16-beta", + "@mattpiz/tlock-js": "0.10.0", "@mui/icons-material": "^5.14.3", "@mui/material": "^5.14.4", "@noble/ed25519": "^2.3.0", @@ -28,6 +29,7 @@ "blakejs": "^1.2.1", "buffer": "^6.0.3", "cbor-web": "^10.0.3", + "cip-179": "0.2.0", "date-fns": "^2.30.0", "esbuild": "^0.25.0", "i18next": "^23.7.19", @@ -3707,6 +3709,21 @@ "node": ">=8" } }, + "node_modules/@mattpiz/tlock-js": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@mattpiz/tlock-js/-/tlock-js-0.10.0.tgz", + "integrity": "sha512-IZOeYmVcwVj4OypIb+fmjq6gyqCL3GeyIANnsk237lCYOejSRjaa39qbV19UnregQZNlSK3oMi+K63EFoiDFeg==", + "license": "(Apache-2.0 OR MIT)", + "dependencies": { + "@noble/curves": "1.4.0", + "@noble/hashes": "1.4.0", + "@stablelib/chacha20poly1305": "1.0.1", + "buffer": "6.0.3" + }, + "engines": { + "node": ">= 18.0.0" + } + }, "node_modules/@mdx-js/react": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", @@ -3967,6 +3984,18 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/curves": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.0.tgz", + "integrity": "sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/ed25519": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz", @@ -3976,6 +4005,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5602,6 +5643,73 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@stablelib/aead": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/aead/-/aead-1.0.1.tgz", + "integrity": "sha512-q39ik6sxGHewqtO0nP4BuSe3db5G1fEJE8ukvngS2gLkBXyy6E7pLubhbYgnkDFv6V8cWaxcE4Xn0t6LWcJkyg==", + "license": "MIT" + }, + "node_modules/@stablelib/binary": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", + "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", + "license": "MIT", + "dependencies": { + "@stablelib/int": "^1.0.1" + } + }, + "node_modules/@stablelib/chacha": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/chacha/-/chacha-1.0.1.tgz", + "integrity": "sha512-Pmlrswzr0pBzDofdFuVe1q7KdsHKhhU24e8gkEwnTGOmlC7PADzLVxGdn2PoNVBBabdg0l/IfLKg6sHAbTQugg==", + "license": "MIT", + "dependencies": { + "@stablelib/binary": "^1.0.1", + "@stablelib/wipe": "^1.0.1" + } + }, + "node_modules/@stablelib/chacha20poly1305": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/chacha20poly1305/-/chacha20poly1305-1.0.1.tgz", + "integrity": "sha512-MmViqnqHd1ymwjOQfghRKw2R/jMIGT3wySN7cthjXCBdO+qErNPUBnRzqNpnvIwg7JBCg3LdeCZZO4de/yEhVA==", + "license": "MIT", + "dependencies": { + "@stablelib/aead": "^1.0.1", + "@stablelib/binary": "^1.0.1", + "@stablelib/chacha": "^1.0.1", + "@stablelib/constant-time": "^1.0.1", + "@stablelib/poly1305": "^1.0.1", + "@stablelib/wipe": "^1.0.1" + } + }, + "node_modules/@stablelib/constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/constant-time/-/constant-time-1.0.1.tgz", + "integrity": "sha512-tNOs3uD0vSJcK6z1fvef4Y+buN7DXhzHDPqRLSXUel1UfqMB1PWNsnnAezrKfEwTLpN0cGH2p9NNjs6IqeD0eg==", + "license": "MIT" + }, + "node_modules/@stablelib/int": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", + "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==", + "license": "MIT" + }, + "node_modules/@stablelib/poly1305": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/poly1305/-/poly1305-1.0.1.tgz", + "integrity": "sha512-1HlG3oTSuQDOhSnLwJRKeTRSAdFNVB/1djy2ZbS35rBSJ/PFqx9cf9qatinWghC2UbfOYD8AcrtbUQl8WoxabA==", + "license": "MIT", + "dependencies": { + "@stablelib/constant-time": "^1.0.1", + "@stablelib/wipe": "^1.0.1" + } + }, + "node_modules/@stablelib/wipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", + "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==", + "license": "MIT" + }, "node_modules/@storybook/addon-coverage": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@storybook/addon-coverage/-/addon-coverage-3.0.1.tgz", @@ -8782,6 +8890,43 @@ "node": ">=8" } }, + "node_modules/cip-179": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/cip-179/-/cip-179-0.2.0.tgz", + "integrity": "sha512-BweGxsdUwAoSPMs5cfEG7YnHRL480Ry0OLHOGrkEtBFzdkwJ5kNvIM7IS5hRG+jtnHGQo674Jjdo4bASK/1MIw==", + "license": "Apache-2.0", + "dependencies": { + "@noble/hashes": "^2.2.0" + }, + "engines": { + "node": ">=20", + "pnpm": ">=10" + }, + "peerDependencies": { + "@evolution-sdk/evolution": "^0.5.9", + "@mattpiz/tlock-js": "0.10.0" + }, + "peerDependenciesMeta": { + "@evolution-sdk/evolution": { + "optional": true + }, + "@mattpiz/tlock-js": { + "optional": true + } + } + }, + "node_modules/cip-179/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cjs-module-lexer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", diff --git a/govtool/frontend/package.json b/govtool/frontend/package.json index c5a447d80..356780459 100644 --- a/govtool/frontend/package.json +++ b/govtool/frontend/package.json @@ -42,10 +42,12 @@ "blakejs": "^1.2.1", "buffer": "^6.0.3", "cbor-web": "^10.0.3", + "cip-179": "0.2.0", "date-fns": "^2.30.0", "esbuild": "^0.25.0", "i18next": "^23.7.19", "jsonld": "^9.0.0", + "@mattpiz/tlock-js": "0.10.0", "katex": "^0.16.21", "keen-slider": "^6.8.5", "patch-package": "^8.0.0", diff --git a/govtool/frontend/src/cip179/Cip179Survey.test.tsx b/govtool/frontend/src/cip179/Cip179Survey.test.tsx new file mode 100644 index 000000000..526660938 --- /dev/null +++ b/govtool/frontend/src/cip179/Cip179Survey.test.tsx @@ -0,0 +1,119 @@ +import { type ComponentProps } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { encodePayload, type Question, type SurveyDefinition } from "cip-179"; +import { API as client } from "@services"; +import { Cip179Survey } from "./Cip179Survey"; +import { metadatumCodec } from "./csl"; + +const txId = "ab".repeat(32); +const metadata = { + body: { + cip179: { + specVersion: 5, + kind: "survey-link", + surveyTxId: txId, + surveyIndex: 0, + }, + }, +}; +const proposal = { + expiryEpochNo: 501, + json: metadata, + metadataJson: metadata, +} as unknown as ComponentProps["proposal"]; +const options = { type: "options" as const, labels: ["Extra", "Other"] }; +const questions: Question[] = [ + { type: "numericRange", prompt: "Extra", constraints: { min: 0n, max: 5n } }, + { type: "ranking", prompt: "Rank", options, minRanked: 1, maxRanked: 1 }, + { + type: "rating", + prompt: "Rate", + options, + scale: { type: "numeric", constraints: { min: 0n, max: 5n } }, + requireAll: false, + }, + { + type: "multiSelect", + prompt: "Select", + options, + minSelections: 0, + maxSelections: 1, + }, +]; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); +it.each(questions)( + "clears $type without losing required-answer or omission semantics", + async (question) => { + const definition: SurveyDefinition = { + specVersion: 5, + title: "Survey", + description: "", + owner: { type: "key", keyHash: new Uint8Array(28) }, + eligibleRoles: [0], + endEpoch: 500, + submissionMode: { type: "public" }, + questions: [ + { + type: "singleChoice", + prompt: "Choose", + options: { type: "options", labels: ["Alpha", "Beta"] }, + required: true, + }, + question, + ], + }; + const payload = encodePayload({ + type: "definitions", + definitions: [definition], + }); + const payloadCborHex = Buffer.from( + metadatumCodec.metadatumToCbor(payload), + ).toString("hex"); + vi.spyOn(client, "get").mockResolvedValue({ + data: { txId, surveyIndex: 0, metadataLabel: 17, payloadCborHex }, + }); + const changed = vi.fn["onChange"]>(); + const state = () => changed.mock.calls[changed.mock.calls.length - 1][0]; + render( + , + ); + await screen.findByText("Survey"); + expect(state()).toMatchObject({ + participating: false, + valid: true, + response: null, + }); + fireEvent.click(screen.getByRole("checkbox", { name: /Include a survey/ })); + expect(state().valid).toBe(false); + fireEvent.click(screen.getByRole("radio", { name: "Alpha" })); + expect(state().valid).toBe(true); + const numeric = question.type === "numericRange" || question.type === "rating"; + const input = screen.getByRole(numeric ? "textbox" : "checkbox", { name: "Extra" }); + if (numeric) fireEvent.change(input, { target: { value: "0" } }); + else fireEvent.click(input); + expect(state().valid).toBe(true); + if (numeric) fireEvent.change(input, { target: { value: "" } }); + else fireEvent.click(input); + expect(state().valid).toBe(true); + expect(state().response?.answers).toMatchObject({ + answers: + question.type === "multiSelect" + ? [{ optionIndex: 0 }, { optionIndices: [] }] + : [{ optionIndex: 0 }], + }); + fireEvent.click(screen.getByRole("checkbox", { name: /Include a survey/ })); + expect(state()).toMatchObject({ + participating: false, + valid: true, + response: null, + }); + }, +); diff --git a/govtool/frontend/src/cip179/Cip179Survey.tsx b/govtool/frontend/src/cip179/Cip179Survey.tsx new file mode 100644 index 000000000..05f885c69 --- /dev/null +++ b/govtool/frontend/src/cip179/Cip179Survey.tsx @@ -0,0 +1,491 @@ +import { type ComponentType, useEffect, useMemo, useState } from "react"; +import { + Alert, + Box, + Checkbox, + FormControlLabel, + FormLabel, + MenuItem, + Radio, + RadioGroup, + Select, + TextField, + Typography, +} from "@mui/material"; +import { + Role, + SPEC_VERSION, + validateResponse, + type AnswerItem, + type Metadatum, + type OptionsOrCount, + type Question, + type SurveyDefinition, + type SurveyResponse, +} from "cip-179"; + +import { API } from "@services"; +import type { ProposalData } from "@models"; +import { + decodeDefinition, + enrichDefinition, + getRenderabilityProblem, + parseSurveyLink, + type SurveyDefinitionEnvelope, + type SurveyLink, +} from "./core"; + +export type Cip179Participation = { + participating: boolean; + valid: boolean; + response: SurveyResponse | null; + definition: SurveyDefinition | null; +}; + +export type CustomQuestionRendererProps = { + question: Extract; + value: Metadatum | undefined; + onChange: (value: Metadatum) => void; +}; + +export type CustomQuestionRenderer = ComponentType; +export type CustomQuestionRenderers = Readonly>; + +type Props = { + dRepId?: string; + proposal: ProposalData; + onChange: (state: Cip179Participation) => void; + customRenderers?: CustomQuestionRenderers; +}; + +const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +const surveyItemKey = (...indices: number[]): string => indices.join("-"); + +export const customMethodKey = ( + question: Extract, +): string => `${question.methodSchema.uri}#${bytesToHex(question.methodSchema.hash)}`; + +const optionLabels = (options: OptionsOrCount): readonly string[] => + (options.type === "options" + ? options.labels + : Array.from({ length: options.count }, (_, index) => `Option ${index + 1}`)); + +export const buildAnswer = ( + question: Question, + questionIndex: number, + value: unknown, + touched: boolean, +): AnswerItem | null => { + if (!touched) return null; + switch (question.type) { + case "singleChoice": + return typeof value === "number" + ? { type: "singleChoice", questionIndex, optionIndex: value } + : null; + case "multiSelect": + return Array.isArray(value) + ? { type: "multiSelect", questionIndex, optionIndices: value as number[] } + : null; + case "ranking": + return Array.isArray(value) && value.length + ? { type: "ranking", questionIndex, ranking: value as number[] } + : null; + case "numericRange": + try { + return value === "" || value === undefined + ? null + : { type: "numeric", questionIndex, value: BigInt(String(value)) }; + } catch { + return null; + } + case "pointsAllocation": + return Array.isArray(value) && + value.every( + (points) => + typeof points === "number" && + Number.isSafeInteger(points) && + points >= 0, + ) + ? { + type: "pointsAllocation", + questionIndex, + allocations: (value as number[]).flatMap((points, optionIndex) => + (points > 0 ? [{ optionIndex, points }] : []), + ), + } + : null; + case "rating": + if (!Array.isArray(value)) return null; + try { + const ratings = (value as Array).flatMap( + (rating, optionIndex) => + (rating === null ? [] : [{ optionIndex, rating: BigInt(rating) }]), + ); + if (!ratings.length) return null; + return { + type: "rating", + questionIndex, + ratings, + }; + } catch { + return null; + } + case "custom": + return value === undefined + ? null + : { type: "custom", questionIndex, value: value as Metadatum }; + default: + return null; + } +}; + +const ratingValues = (question: Extract) => { + if (question.scale.type === "labels") { + return question.scale.labels.map((label, index) => ({ + label, + value: BigInt(index), + })); + } + if (question.scale.type === "count") { + return Array.from({ length: question.scale.count }, (_, index) => ({ + label: String(index + 1), + value: BigInt(index), + })); + } + return []; +}; + +export const Cip179Survey = ({ + customRenderers = {}, + dRepId, + proposal, + onChange, +}: Props) => { + const [link, setLink] = useState(null); + const [definition, setDefinition] = useState(null); + const [error, setError] = useState(null); + const [warning, setWarning] = useState(null); + const [participating, setParticipating] = useState(false); + const [values, setValues] = useState>({}); + const [touched, setTouched] = useState>(new Set()); + + useEffect(() => { + const nextLink = parseSurveyLink(proposal.json); + setLink(nextLink); + setDefinition(null); + setError(null); + setWarning(null); + setParticipating(false); + setValues({}); + setTouched(new Set()); + if (!nextLink) return; + let active = true; + API.get( + `/survey/definition/${nextLink.txId}/${nextLink.index}`, + ) + .then(async ({ data }) => { + const decoded = decodeDefinition( + data, + nextLink, + proposal.expiryEpochNo, + ); + const renderabilityProblem = getRenderabilityProblem(decoded); + if (renderabilityProblem) throw new Error(renderabilityProblem); + if (active) setDefinition(decoded); + try { + const loaded = await enrichDefinition(decoded); + if (active) setDefinition(loaded); + } catch (reason) { + if (active) { + setWarning( + reason instanceof Error + ? reason.message + : "External survey presentation is unavailable", + ); + } + } + }) + .catch((reason: unknown) => { + if (active) { + setError(reason instanceof Error ? reason.message : "Survey unavailable"); + } + }); + return () => { + active = false; + }; + }, [proposal.expiryEpochNo, proposal.json]); + + const responseState = useMemo(() => { + if (!participating || !definition || !link || !dRepId) { + return { + participating, + valid: !participating, + response: null, + definition, + }; + } + if (!/^[0-9a-fA-F]{56}$/.test(dRepId)) { + return { participating, valid: false, response: null, definition }; + } + const builtAnswers = definition.questions.map((question, index) => + buildAnswer(question, index, values[index], touched.has(index)), + ); + const answers = builtAnswers.flatMap((answer) => (answer ? [answer] : [])); + const hasInvalidTouchedAnswer = builtAnswers.some( + (answer, index) => touched.has(index) && answer === null, + ); + const response: SurveyResponse = { + specVersion: SPEC_VERSION, + surveyRef: { + txId: Uint8Array.from( + { length: 32 }, + (_, index) => parseInt(link.txId.slice(index * 2, index * 2 + 2), 16), + ), + index: link.index, + }, + role: Role.DRep, + credential: { + type: "key", + keyHash: Uint8Array.from( + { length: 28 }, + (_, index) => parseInt(dRepId.slice(index * 2, index * 2 + 2), 16), + ), + }, + answers: { type: "public", answers }, + }; + const validationDefinition: SurveyDefinition = + definition.submissionMode.type === "sealed" + ? { ...definition, submissionMode: { type: "public" } } + : definition; + const valid = + answers.length > 0 && + !hasInvalidTouchedAnswer && + validateResponse(validationDefinition, response).length === 0; + return { participating, valid, response: valid ? response : null, definition }; + }, [dRepId, definition, link, participating, touched, values]); + + useEffect(() => onChange(responseState), [onChange, responseState]); + + if (!link) return null; + if (error) { + return ( + + Linked survey response unavailable: {error} + + ); + } + if (!definition) return Loading linked survey...; + + const eligible = definition.eligibleRoles.includes(Role.DRep); + return ( + + {definition.title || "Linked survey"} + {definition.description && {definition.description}} + {warning && ( + + External presentation unavailable: {warning}. Index-based labels are shown. + + )} + {!eligible ? ( + + This survey does not accept DRep responses. + + ) : ( + setParticipating(event.target.checked)} + /> + } + label="Include a survey response with this governance vote" + /> + )} + {eligible && participating && ( + + {definition.questions.map((question, index) => { + const labels = "options" in question ? optionLabels(question.options) : []; + const markTouched = (value: unknown) => { + setValues((current) => ({ ...current, [index]: value })); + setTouched((current) => { + const next = new Set(current); + const empty = + (question.type === "numericRange" && value === "") || + (question.type === "ranking" && Array.isArray(value) && value.length === 0) || + (question.type === "rating" && Array.isArray(value) && value.every((rating) => rating === null)); + if (empty) next.delete(index); + else next.add(index); + return next; + }); + }; + return ( + + + {question.prompt} + + {question.type === "singleChoice" && ( + markTouched(Number(event.target.value))} + > + {labels.map((label, optionIndex) => ( + } + label={label} + /> + ))} + + )} + {(question.type === "multiSelect" || question.type === "ranking") && + labels.map((label, optionIndex) => { + const selected = (values[index] as number[] | undefined) ?? []; + const position = selected.indexOf(optionIndex); + return ( + = 0} + onChange={(event) => + markTouched( + event.target.checked + ? [...selected, optionIndex] + : selected.filter((item) => item !== optionIndex), + ) + } + /> + } + label={ + question.type === "ranking" && position >= 0 + ? `${position + 1}. ${label}` + : label + } + /> + ); + })} + {question.type === "numericRange" && ( + markTouched(event.target.value)} + inputProps={{ + "aria-label": question.prompt || `Question ${index + 1}`, + inputMode: "numeric", + }} + helperText={`${question.constraints.min.toString()} to ${question.constraints.max.toString()}${question.constraints.step ? `, step ${question.constraints.step.toString()}` : ""}`} + /> + )} + {question.type === "pointsAllocation" && + labels.map((label, optionIndex) => { + const points = + (values[index] as number[] | undefined) ?? labels.map(() => 0); + return ( + { + const next = [...points]; + next[optionIndex] = Math.max(0, Number(event.target.value)); + markTouched(next); + }} + inputProps={{ min: 0, max: question.budget, step: 1 }} + type="number" + sx={{ mt: 1 }} + /> + ); + })} + {question.type === "rating" && + labels.map((label, optionIndex) => { + const ratings = + (values[index] as Array | undefined) ?? + labels.map(() => null); + return ( + + {label} + {question.scale.type === "numeric" ? ( + { + const next = [...ratings]; + next[optionIndex] = event.target.value || null; + markTouched(next); + }} + inputProps={{ "aria-label": label, inputMode: "numeric" }} + helperText={`${question.scale.constraints.min.toString()} to ${question.scale.constraints.max.toString()}`} + sx={{ minWidth: 180 }} + /> + ) : ( + + )} + + ); + })} + {question.type === "custom" && ( + (() => { + const Renderer = customRenderers[customMethodKey(question)]; + return Renderer ? ( + + ) : ( + + This custom survey method is not supported by GovTool. + + ); + })() + )} + + ); + })} + {!responseState.valid && ( + + Complete the selected survey response before submitting. + + )} + + )} + + ); +}; diff --git a/govtool/frontend/src/cip179/authoring.test.ts b/govtool/frontend/src/cip179/authoring.test.ts new file mode 100644 index 000000000..138d36e25 --- /dev/null +++ b/govtool/frontend/src/cip179/authoring.test.ts @@ -0,0 +1,33 @@ +import { GOVERNANCE_ACTION_CONTEXT_WITH_CIP179 } from "@consts"; +import { generateJsonld } from "@utils"; + +describe("CIP-179 governance-action authoring", () => { + it("keeps the complete survey link inside the witnessed CIP-108 body", async () => { + const surveyTxId = "ab".repeat(32); + const json = await generateJsonld( + { + title: "Survey-linked action", + abstract: "Abstract", + motivation: "Motivation", + rationale: "Rationale", + cip179: { + specVersion: 5, + kind: "survey-link", + surveyTxId, + surveyIndex: 0, + }, + }, + GOVERNANCE_ACTION_CONTEXT_WITH_CIP179, + ); + + const body = json.body as Record; + const context = json["@context"] as Record; + expect(body.cip179).toMatchObject({ + specVersion: 5, + kind: "survey-link", + surveyTxId, + surveyIndex: 0, + }); + expect(context.CIP179).toContain("CIP-0179"); + }); +}); diff --git a/govtool/frontend/src/cip179/core.test.ts b/govtool/frontend/src/cip179/core.test.ts new file mode 100644 index 000000000..1ecfcb3fe --- /dev/null +++ b/govtool/frontend/src/cip179/core.test.ts @@ -0,0 +1,401 @@ +import { blake2bHex } from "blakejs"; +import { encodePayload, Role, type SurveyDefinition } from "cip-179"; + +import { + decodeDefinition, + enrichDefinition, + getRenderabilityProblem, + MAX_RENDERED_SURVEY_ITEMS, + parseSurveyLink, +} from "./core"; +import { buildAnswer } from "./Cip179Survey"; +import { metadatumCodec } from "./csl"; +import dbSyncFixture from "./fixtures/dbSyncMetadata.json"; + +const definition: SurveyDefinition = { + specVersion: 5, + owner: { type: "key", keyHash: new Uint8Array(28) }, + title: "Priorities", + description: "Choose one", + eligibleRoles: [Role.DRep], + endEpoch: 500, + submissionMode: { type: "public" }, + questions: [ + { + type: "singleChoice", + prompt: "Priority?", + options: { type: "options", labels: ["A", "B"] }, + required: true, + }, + ], +}; + +describe("CIP-179 integration helpers", () => { + // Independently encoded fixture following db-sync's Map.singleton key md. + // Pin: cardano-db-sync eb8e862de8f4076523eb4146905bab7eac1d5e1e, + // Cardano/DbSync/Era/Universal/Insert/Tx.hs, insertTxMetadata. + it.each([ + ["inner payload", dbSyncFixture.payloadOnlyCborHex, 0, "Audit"], + ["db-sync row", dbSyncFixture.dbSyncRowCborHex, 0, "Audit"], + ["nonzero batch index", dbSyncFixture.batchedDbSyncRowCborHex, 1, "Second"], + ])("decodes %s", (_name, payloadCborHex, index, title) => { + const link = { txId: dbSyncFixture.txId, index: Number(index) }; + expect( + decodeDefinition( + { + ...link, + surveyIndex: link.index, + metadataLabel: 17, + payloadCborHex: String(payloadCborHex), + }, + link, + 501, + ).title, + ).toBe(title); + }); + + it.each([ + ["wrong metadata label", `a112${dbSyncFixture.payloadOnlyCborHex}`], + ["string metadata label", `a1623137${dbSyncFixture.payloadOnlyCborHex}`], + ["empty map", "a0"], + ["invalid label payload", "a11100"], + ["truncated CBOR", dbSyncFixture.dbSyncRowCborHex.slice(0, -2)], + ["cancellation payload", `a111820281825820${"11".repeat(32)}00`], + ])("rejects %s", (_name, payloadCborHex) => { + expect(() => + decodeDefinition( + { + txId: dbSyncFixture.txId, + surveyIndex: 0, + metadataLabel: 17, + payloadCborHex, + }, + { txId: dbSyncFixture.txId, index: 0 }, + 501, + ), + ).toThrow(); + }); + + it("parses only complete revision 5 links", () => { + const txId = "ab".repeat(32); + expect( + parseSurveyLink({ + body: { + cip179: { + specVersion: 5, + kind: "survey-link", + surveyTxId: txId.toUpperCase(), + surveyIndex: 0, + }, + }, + }), + ).toEqual({ txId, index: 0 }); + expect( + parseSurveyLink({ + body: { + cip179: { kind: "survey-link", surveyTxId: txId, surveyIndex: 0 }, + }, + }), + ).toBeNull(); + }); + + it("decodes exact label payload CBOR and checks action expiry", () => { + const payload = encodePayload({ + type: "definitions", + definitions: [definition], + }); + const payloadCborHex = Buffer.from( + metadatumCodec.metadatumToCbor(payload), + ).toString("hex"); + const link = { txId: "ab".repeat(32), index: 0 }; + expect( + decodeDefinition( + { + txId: "ab".repeat(32), + surveyIndex: 0, + metadataLabel: 17, + payloadCborHex, + }, + link, + 501, + ), + ).toEqual(definition); + expect(() => + decodeDefinition( + { + txId: "ab".repeat(32), + surveyIndex: 0, + metadataLabel: 17, + payloadCborHex, + }, + link, + 502, + ), + ).toThrow("end epoch"); + }); + + it("rejects a definition response for a different survey reference", () => { + const payloadCborHex = Buffer.from( + metadatumCodec.metadatumToCbor( + encodePayload({ type: "definitions", definitions: [definition] }), + ), + ).toString("hex"); + expect(() => + decodeDefinition( + { + txId: "cd".repeat(32), + surveyIndex: 0, + metadataLabel: 17, + payloadCborHex, + }, + { txId: "ab".repeat(32), index: 0 }, + 501, + ), + ).toThrow("requested reference"); + }); + + it("bounds the number of rendered options", () => { + const withOptionCount = (count: number): SurveyDefinition => ({ + ...definition, + questions: [ + { + type: "singleChoice", + prompt: "Choose", + options: { + type: "options", + labels: Array.from({ length: count }, (_, index) => String(index)), + }, + }, + ], + }); + expect( + getRenderabilityProblem(withOptionCount(MAX_RENDERED_SURVEY_ITEMS)), + ).toBeNull(); + expect( + getRenderabilityProblem(withOptionCount(MAX_RENDERED_SURVEY_ITEMS + 1)), + ).toContain("more than 100 options"); + expect( + getRenderabilityProblem({ + ...definition, + questions: Array.from( + { length: MAX_RENDERED_SURVEY_ITEMS + 1 }, + () => definition.questions[0], + ), + }), + ).toContain("more than 100 questions"); + expect( + getRenderabilityProblem({ + ...definition, + questions: [ + { + type: "rating", + prompt: "Rate", + options: { type: "options", labels: ["A", "B"] }, + scale: { type: "count", count: MAX_RENDERED_SURVEY_ITEMS + 1 }, + requireAll: false, + }, + ], + }), + ).toContain("more than 100 rating levels"); + }); + + it("treats an incomplete numeric rating as invalid instead of throwing", () => { + expect( + buildAnswer( + { + type: "rating", + prompt: "Impact", + options: { type: "options", labels: ["A", "B"] }, + scale: { type: "numeric", constraints: { min: 1n, max: 5n } }, + requireAll: false, + }, + 0, + ["-", null], + true, + ), + ).toBeNull(); + }); + + it("rejects an empty rating answer instead of encoding an empty pair list", () => { + expect( + buildAnswer( + { + type: "rating", + prompt: "Impact", + options: { type: "options", labels: ["A", "B"] }, + scale: { type: "numeric", constraints: { min: 1n, max: 5n } }, + requireAll: false, + }, + 0, + [null, null], + true, + ), + ).toBeNull(); + }); + + it("rejects fractional points allocations before response validation", () => { + expect( + buildAnswer( + { + type: "pointsAllocation", + prompt: "Allocate", + options: { type: "options", labels: ["A", "B"] }, + budget: 10, + }, + 0, + [1.5, 8.5], + true, + ), + ).toBeNull(); + }); +}); + +describe("CIP-179 untrusted inputs", () => { + const envelope = { + txId: dbSyncFixture.txId, + surveyIndex: 0, + metadataLabel: 17 as const, + payloadCborHex: dbSyncFixture.dbSyncRowCborHex, + }; + const ref = { txId: dbSyncFixture.txId, index: 0 }; + const read = (expiry: number | undefined) => + decodeDefinition(envelope, ref, expiry); + const survey = read(501); + const document = { + specVersion: 5, + kind: "cardano-survey-presentation", + questions: [{ prompt: "Question", options: ["Yes", "No"] }], + }; + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + vi.useRealTimers(); + }); + const load = (doc: unknown) => { + const bytes = Uint8Array.from(new TextEncoder().encode(JSON.stringify(doc))); + globalThis.fetch = vi + .fn() + .mockImplementation(async () => new Response(bytes)); + return { + ...survey, + title: "", + description: "", + questions: [ + { + ...survey.questions[0], + prompt: "", + options: { type: "count" as const, count: 2 }, + }, + ], + contentAnchor: { + uri: "https://example.invalid/survey.json", + hash: Uint8Array.from( + Buffer.from(blake2bHex(bytes, undefined, 32), "hex"), + ), + }, + }; + }; + it.each([undefined, null, NaN, Infinity, -1, 0, 501.5])( + "rejects unavailable or invalid expiry %s", + (expiry) => { + expect(() => read(expiry as number | undefined)).toThrow(/epoch/); + }, + ); + it.each([65535, 65536])("enforces uint16 link index %s", (index) => { + const link = parseSurveyLink({ + body: { + cip179: { + specVersion: 5, + kind: "survey-link", + surveyTxId: ref.txId, + surveyIndex: index, + }, + }, + }); + expect(link).toEqual(index === 65535 ? { ...ref, index } : null); + }); + it("uses verified strings while preserving on-chain fields", async () => { + const result = await enrichDefinition( + load({ + ...document, + endEpoch: 999, + questions: [{ ...document.questions[0], required: false }], + }), + ); + expect(result.endEpoch).toBe(500); + expect(result.questions[0]).toMatchObject({ + prompt: "Question", + required: true, + options: { type: "options", labels: ["Yes", "No"] }, + }); + const anchored = load(document); + await expect( + enrichDefinition({ + ...anchored, + title: "On-chain", + questions: survey.questions, + }), + ).resolves.toMatchObject({ + title: "On-chain", + questions: survey.questions, + }); + }); + it.each([ + { title: {} }, + { description: [] }, + { questions: {} }, + { questions: [null] }, + { questions: [{ prompt: {} }] }, + { questions: [{ options: [{ bad: true }, "No"] }] }, + { questions: [{ ratingLabels: ["Low", 2] }] }, + ])("rejects hash-valid malformed presentation %j", async (fields) => { + await expect( + enrichDefinition(load({ ...document, ...fields })), + ).rejects.toThrow(/presentation/); + }); + it("rejects a raw-byte hash mismatch", async () => { + const anchored = load(document); + anchored.contentAnchor.hash = new Uint8Array(32); + await expect(enrichDefinition(anchored)).rejects.toThrow(/hash/); + }); + it("bounds streamed content even without Content-Length", async () => { + const anchored = load(document); + let signal: AbortSignal | null | undefined; + globalThis.fetch = vi + .fn() + .mockImplementation(async (_url, init) => { + signal = init?.signal; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(1024 * 1024)); + controller.enqueue(new Uint8Array(1)); + controller.close(); + }, + }), + ); + }); + await expect(enrichDefinition(anchored)).rejects.toThrow(/too large/); + expect(signal?.aborted).toBe(true); + }); + it("keeps the deadline active after response headers", async () => { + vi.useFakeTimers(); + const anchored = load(document); + globalThis.fetch = vi.fn().mockImplementation( + async (_url, init) => + new Response( + new ReadableStream({ + start(controller) { + init?.signal?.addEventListener("abort", () => + controller.error(new Error("Aborted")), + ); + }, + }), + ), + ); + await Promise.all([ + expect(enrichDefinition(anchored)).rejects.toThrow(/Aborted/), + vi.advanceTimersByTimeAsync(10_000), + ]); + }); +}); diff --git a/govtool/frontend/src/cip179/core.ts b/govtool/frontend/src/cip179/core.ts new file mode 100644 index 000000000..2057dfba8 --- /dev/null +++ b/govtool/frontend/src/cip179/core.ts @@ -0,0 +1,269 @@ +import { blake2bHex } from "blakejs"; +import { + decodePayload, + validateDefinition, + type ContentAnchor, + type OptionsOrCount, + type Question, + type RatingScale, + type SurveyDefinition, +} from "cip-179"; +import { parseCip179Link } from "cip-179/domain"; + +import { metadatumCodec } from "./csl"; + +export type SurveyLink = { txId: string; index: number }; +export type SurveyDefinitionEnvelope = { + txId: string; + surveyIndex: number; + metadataLabel: 17; + payloadCborHex: string; +}; + +export const MAX_RENDERED_SURVEY_ITEMS = 100; + +export const getRenderabilityProblem = ( + definition: SurveyDefinition, +): string | null => { + if (definition.questions.length > MAX_RENDERED_SURVEY_ITEMS) { + return `Survey has more than ${MAX_RENDERED_SURVEY_ITEMS} questions`; + } + const optionsIndex = definition.questions.findIndex( + (question) => + "options" in question && + (question.options.type === "options" + ? question.options.labels.length + : question.options.count) > MAX_RENDERED_SURVEY_ITEMS, + ); + if (optionsIndex >= 0) { + return `Question ${optionsIndex + 1} has more than ${MAX_RENDERED_SURVEY_ITEMS} options`; + } + const ratingIndex = definition.questions.findIndex( + (question) => + question.type === "rating" && + question.scale.type !== "numeric" && + (question.scale.type === "labels" + ? question.scale.labels.length + : question.scale.count) > MAX_RENDERED_SURVEY_ITEMS, + ); + if (ratingIndex >= 0) { + return `Question ${ratingIndex + 1} has more than ${MAX_RENDERED_SURVEY_ITEMS} rating levels`; + } + return null; +}; + +export const hexToBytes = (hex: string): Uint8Array => { + if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) { + throw new Error("Invalid hex value"); + } + return Uint8Array.from({ length: hex.length / 2 }, (_, index) => + parseInt(hex.slice(index * 2, index * 2 + 2), 16), + ); +}; + +export const parseSurveyLink = (metadata: unknown): SurveyLink | null => { + if (!metadata || typeof metadata !== "object") return null; + const { body } = metadata as Record; + if (!body || typeof body !== "object") return null; + const link = (body as Record).cip179; + if ( + !link || + typeof link !== "object" || + (link as Record).specVersion !== 5 + ) { + return null; + } + const { surveyRef } = parseCip179Link(metadata); + return surveyRef && surveyRef.index <= 65535 ? surveyRef : null; +}; + +export const decodeDefinition = ( + envelope: SurveyDefinitionEnvelope, + expected: SurveyLink, + expiryEpoch: number | undefined, +): SurveyDefinition => { + if ( + envelope.txId.toLowerCase() !== expected.txId.toLowerCase() || + envelope.surveyIndex !== expected.index || + envelope.metadataLabel !== 17 + ) { + throw new Error( + "Survey definition response does not match the requested reference", + ); + } + const decoded = metadatumCodec.cborToMetadatum( + hexToBytes(envelope.payloadCborHex), + ); + // db-sync stores each tx_metadata.bytes row as a singleton metadata map. + // Keep accepting an inner payload for hosts that already extract the label. + const labelPayload = decoded instanceof Map ? decoded.get(17n) : decoded; + if (labelPayload === undefined) { + throw new Error("Survey metadata does not contain label 17"); + } + const payload = decodePayload(labelPayload); + if (payload.type !== "definitions") { + throw new Error("Label 17 payload is not a survey definition"); + } + const definition = payload.definitions[envelope.surveyIndex]; + if (!definition) throw new Error("Survey index does not exist"); + const problems = validateDefinition(definition); + if (problems.length) throw new Error(problems.join("; ")); + if ( + expiryEpoch === undefined || + !Number.isSafeInteger(expiryEpoch) || + expiryEpoch <= 0 + ) { + throw new Error("Governance action expiry epoch is unavailable"); + } + if (definition.endEpoch !== expiryEpoch - 1) { + throw new Error("Survey end epoch does not match the governance action"); + } + return definition; +}; + +const fillOptions = ( + source: OptionsOrCount, + labels: string[] | undefined, +): OptionsOrCount => + (source.type === "count" && labels?.length === source.count + ? { type: "options", labels } + : source); + +const fillScale = ( + source: RatingScale, + labels: string[] | undefined, +): RatingScale => + (source.type === "count" && labels?.length === source.count + ? { type: "labels", labels } + : source); + +type PresentationQuestion = { + prompt?: string; + options?: string[]; + ratingLabels?: string[]; +}; + +const validatePresentationQuestion = ( + value: unknown, +): value is PresentationQuestion => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const question = value as Record; + return ( + (question.prompt === undefined || typeof question.prompt === "string") && + [question.options, question.ratingLabels].every( + (labels) => + labels === undefined || + (Array.isArray(labels) && + labels.every((label) => typeof label === "string")), + ) + ); +}; + +const fetchContentAnchor = async (anchor: ContentAnchor): Promise => { + const uri = anchor.uri.startsWith("ipfs://") + ? `https://ipfs.io/ipfs/${anchor.uri.slice(7)}` + : anchor.uri; + if (!uri.startsWith("https://")) throw new Error("Unsupported anchor URI"); + const controller = new AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), 10_000); + try { + const response = await fetch(uri, { signal: controller.signal }); + if (!response.ok || !response.body) { + throw new Error("External survey content is unavailable"); + } + // ponytail: cap presentation downloads at 1 MiB; larger documents use on-chain labels. + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + // Read sequentially so the size limit also applies to chunked responses. + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > 1024 * 1024) + throw new Error("External survey content is too large"); + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + chunks.forEach((chunk) => { + bytes.set(chunk, offset); + offset += chunk.length; + }); + const expectedHash = Array.from(anchor.hash, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + if (blake2bHex(bytes, undefined, 32) !== expectedHash) { + throw new Error("External survey content hash does not match"); + } + return JSON.parse(new TextDecoder().decode(bytes)); + } finally { + controller.abort(); + globalThis.clearTimeout(timeout); + } +}; + +const applyQuestion = ( + question: Question, + presentation: PresentationQuestion | undefined, +): Question => { + const prompt = question.prompt || presentation?.prompt || ""; + if (question.type === "custom" || question.type === "numericRange") { + return { ...question, prompt }; + } + if (question.type === "rating") { + return { + ...question, + prompt, + options: fillOptions(question.options, presentation?.options), + scale: fillScale(question.scale, presentation?.ratingLabels), + }; + } + return { + ...question, + prompt, + options: fillOptions(question.options, presentation?.options), + }; +}; + +export const enrichDefinition = async ( + definition: SurveyDefinition, +): Promise => { + let enriched = definition; + if (definition.contentAnchor) { + const raw = await fetchContentAnchor(definition.contentAnchor); + if (!raw || typeof raw !== "object") + throw new Error("Invalid presentation"); + const presentation = raw as Record; + if ( + presentation.specVersion !== 5 || + presentation.kind !== "cardano-survey-presentation" + ) { + throw new Error("Invalid presentation kind"); + } + if ( + [presentation.title, presentation.description].some( + (value) => value !== undefined && typeof value !== "string", + ) || + (presentation.questions !== undefined && + (!Array.isArray(presentation.questions) || + !presentation.questions.every(validatePresentationQuestion))) + ) { + throw new Error("Invalid presentation fields"); + } + const questions = Array.isArray(presentation.questions) + ? (presentation.questions as PresentationQuestion[]) + : []; + enriched = { + ...definition, + title: definition.title || String(presentation.title || ""), + description: + definition.description || String(presentation.description || ""), + questions: definition.questions.map((question, index) => + applyQuestion(question, questions[index]), + ), + }; + } + return enriched; +}; diff --git a/govtool/frontend/src/cip179/csl.test.ts b/govtool/frontend/src/cip179/csl.test.ts new file mode 100644 index 000000000..69da83e75 --- /dev/null +++ b/govtool/frontend/src/cip179/csl.test.ts @@ -0,0 +1,69 @@ +import { + BigNum, + Int, + TransactionMetadatum, +} from "@emurgo/cardano-serialization-lib-asmjs"; +import { + decodePayload, + encodeMetadata, + Role, + type Metadatum, + type SurveyResponse, +} from "cip-179"; + +import { + buildAuxiliaryData, + fromTransactionMetadatum, + metadatumCodec, + toTransactionMetadatum, +} from "./csl"; + +describe("CIP-179 CSL adapter", () => { + it("round-trips every transaction metadatum shape", () => { + const value: Metadatum = new Map([ + [0n, -2n], + [1n, "text"], + [2n, new Uint8Array([0xaa, 0xbb])], + [3n, [1n, "nested"]], + ]); + const encoded = toTransactionMetadatum(value); + const decoded = TransactionMetadatum.from_bytes(encoded.to_bytes()); + expect(decoded.as_map().len()).toBe(4); + const zero = TransactionMetadatum.new_int(Int.new_i32(0)); + expect(decoded.as_map().get(zero)?.as_int().to_json()).toBe('"-2"'); + }); + + it("encodes metadatum integers as native CBOR integers", () => { + expect(Buffer.from(metadatumCodec.metadatumToCbor(1n)).toString("hex")).toBe( + "01", + ); + expect(Buffer.from(metadatumCodec.metadatumToCbor(-1n)).toString("hex")).toBe( + "20", + ); + expect(metadatumCodec.cborToMetadatum(new Uint8Array([0x19, 0x01, 0x00]))).toBe( + 256n, + ); + }); + + it("places a CIP-179 response under metadata label 17", () => { + const response: SurveyResponse = { + specVersion: 5, + surveyRef: { txId: new Uint8Array(32), index: 0 }, + role: Role.DRep, + credential: { type: "key", keyHash: new Uint8Array(28) }, + answers: { + type: "public", + answers: [{ type: "numeric", questionIndex: 0, value: 4n }], + }, + }; + const encoded = encodeMetadata({ type: "responses", responses: [response] }); + if (!(encoded instanceof Map)) throw new Error("Expected metadata map"); + const auxiliaryData = buildAuxiliaryData(encoded); + const label17 = auxiliaryData.metadata()?.get(BigNum.from_str("17")); + expect(label17).toBeDefined(); + expect(decodePayload(fromTransactionMetadatum(label17!))).toEqual({ + type: "responses", + responses: [response], + }); + }); +}); diff --git a/govtool/frontend/src/cip179/csl.ts b/govtool/frontend/src/cip179/csl.ts new file mode 100644 index 000000000..7f6006790 --- /dev/null +++ b/govtool/frontend/src/cip179/csl.ts @@ -0,0 +1,96 @@ +import { + AuxiliaryData, + BigNum, + GeneralTransactionMetadata, + Int, + MetadataList, + MetadataMap, + TransactionMetadatum, + TransactionMetadatumKind, +} from "@emurgo/cardano-serialization-lib-asmjs"; +import type { Metadatum, MetadatumMap } from "cip-179"; + +export const toTransactionMetadatum = ( + value: Metadatum, +): TransactionMetadatum => { + if (typeof value === "bigint") { + const int = + value >= 0n + ? Int.new(BigNum.from_str(value.toString())) + : Int.new_negative(BigNum.from_str((-value).toString())); + return TransactionMetadatum.new_int(int); + } + if (typeof value === "string") return TransactionMetadatum.new_text(value); + if (value instanceof Uint8Array) { + return TransactionMetadatum.new_bytes(value); + } + if (value instanceof Map) { + const map = MetadataMap.new(); + value.forEach((item, key) => + map.insert(toTransactionMetadatum(key), toTransactionMetadatum(item)), + ); + return TransactionMetadatum.new_map(map); + } + const list = MetadataList.new(); + value.forEach((item) => list.add(toTransactionMetadatum(item))); + return TransactionMetadatum.new_list(list); +}; + +export const fromTransactionMetadatum = ( + value: TransactionMetadatum, +): Metadatum => { + switch (value.kind()) { + case TransactionMetadatumKind.Int: + return BigInt(value.as_int().to_str()); + case TransactionMetadatumKind.Text: + return value.as_text(); + case TransactionMetadatumKind.Bytes: + return new Uint8Array(value.as_bytes()); + case TransactionMetadatumKind.MetadataList: { + const list = value.as_list(); + return Array.from({ length: list.len() }, (_, index) => + fromTransactionMetadatum(list.get(index)), + ); + } + case TransactionMetadatumKind.MetadataMap: { + const map = value.as_map(); + const keys = map.keys(); + return new Map( + Array.from({ length: keys.len() }, (_, index) => { + const key = keys.get(index); + return [ + fromTransactionMetadatum(key), + fromTransactionMetadatum(map.get(key)), + ]; + }), + ); + } + default: + throw new Error("Unsupported transaction metadatum kind"); + } +}; + +export const metadatumCodec = { + metadatumToCbor: (value: Metadatum): Uint8Array => + toTransactionMetadatum(value).to_bytes(), + cborToMetadatum: (bytes: Uint8Array): Metadatum => + fromTransactionMetadatum(TransactionMetadatum.from_bytes(bytes)), +}; + +export const buildAuxiliaryData = ( + transactionMetadata: MetadatumMap, +): AuxiliaryData => { + const metadata = GeneralTransactionMetadata.new(); + transactionMetadata.forEach((value, label) => { + if (typeof label !== "bigint" || label < 0n) { + throw new Error("Transaction metadata labels must be non-negative integers"); + } + metadata.insert( + BigNum.from_str(label.toString()), + toTransactionMetadatum(value), + ); + }); + const auxiliaryData = AuxiliaryData.new(); + auxiliaryData.set_metadata(metadata); + return auxiliaryData; +}; diff --git a/govtool/frontend/src/cip179/fixtures/dbSyncMetadata.json b/govtool/frontend/src/cip179/fixtures/dbSyncMetadata.json new file mode 100644 index 000000000..f5fcbd767 --- /dev/null +++ b/govtool/frontend/src/cip179/fixtures/dbSyncMetadata.json @@ -0,0 +1,11 @@ +{ + "txId": "1111111111111111111111111111111111111111111111111111111111111111", + "surveyIndex": 0, + "metadataLabel": 17, + "ledgerExpiry": 500, + "dbSyncExpiration": 501, + "payloadOnlyCborHex": "820081a80005018200581c22222222222222222222222222222222222222222222222222222222026541756469740373496e646570656e64656e742066697874757265048100051901f4068100078184016643686f6f7365826141614201", + "dbSyncRowCborHex": "a111820081a80005018200581c22222222222222222222222222222222222222222222222222222222026541756469740373496e646570656e64656e742066697874757265048100051901f4068100078184016643686f6f7365826141614201", + "batchedDbSyncRowCborHex": "a111820082a80005018200581c22222222222222222222222222222222222222222222222222222222026541756469740373496e646570656e64656e742066697874757265048100051901f4068100078184016643686f6f7365826141614201a80005018200581c2222222222222222222222222222222222222222222222222222222202665365636f6e640373496e646570656e64656e742066697874757265048100051901f4068100078184016643686f6f7365826141614201", + "source": "CIP-179 v5 + db-sync Insert/Tx.hs Map.singleton key md" +} diff --git a/govtool/frontend/src/components/molecules/VoteActionForm.tsx b/govtool/frontend/src/components/molecules/VoteActionForm.tsx index 6a49ac90b..f038d652c 100644 --- a/govtool/frontend/src/components/molecules/VoteActionForm.tsx +++ b/govtool/frontend/src/components/molecules/VoteActionForm.tsx @@ -3,7 +3,7 @@ import { Box } from "@mui/material"; import { Trans } from "react-i18next"; import { Button, Radio, Typography } from "@atoms"; -import { useModal } from "@context"; +import { useCardano, useFeatureFlag, useModal } from "@context"; import { useScreenDimension, useVoteActionForm, @@ -15,6 +15,10 @@ import { formatDisplayDate } from "@utils"; import { errorRed, fadedPurple } from "@/consts"; import { ProposalData, ProposalVote, Vote } from "@/models"; import { VoteContextModalState, SubmittedVotesModalState } from "../organisms"; +import { + Cip179Survey, + type Cip179Participation, +} from "@/cip179/Cip179Survey"; type VoteActionFormProps = { setIsVoteSubmitted: Dispatch>; @@ -34,6 +38,12 @@ export const VoteActionForm = ({ const [voteContextUrl, setVoteContextUrl] = useState(); const [showWholeVoteContext, setShowWholeVoteContext] = useState(false); + const [cip179, setCip179] = useState({ + participating: false, + valid: true, + response: null, + definition: null, + }); const { voter } = useGetVoterInfo(); const { voteContextText, valid: voteContextValid = true } = @@ -46,6 +56,8 @@ export const VoteActionForm = ({ const { isMobile } = useScreenDimension(); const { openModal, closeModal } = useModal(); + const { dRepID } = useCardano(); + const { isCip179Enabled } = useFeatureFlag(); const { t } = useTranslation(); const { @@ -56,7 +68,13 @@ export const VoteActionForm = ({ setValue, vote, canVote, - } = useVoteActionForm({ previousVote, voteContextHash, voteContextUrl, closeModal }); + } = useVoteActionForm({ + previousVote, + voteContextHash, + voteContextUrl, + closeModal, + cip179, + }); const handleVoteClick = (isVoteChanged:boolean) => { openModal({ @@ -340,6 +358,13 @@ export const VoteActionForm = ({ )} + {isCip179Enabled && ( + + )} {previousVote?.vote && previousVote?.vote !== vote ? ( { const { t } = useTranslation(); + const { isCip179Enabled } = useFeatureFlag(); const { control, errors, getValues, register, reset, watch } = useCreateGovernanceActionForm(); @@ -159,6 +161,23 @@ export const CreateGovernanceActionForm = ({ /> {renderGovernanceActionField()} + {isCip179Enabled && ( + + )} {t("createGovernanceAction.references")} diff --git a/govtool/frontend/src/config/env.ts b/govtool/frontend/src/config/env.ts index 9252934f8..42727c852 100644 --- a/govtool/frontend/src/config/env.ts +++ b/govtool/frontend/src/config/env.ts @@ -38,4 +38,5 @@ export const env = { VITE_IS_GOVERNANCE_OUTCOMES_PILLAR_ENABLED: getEnv( "VITE_IS_GOVERNANCE_OUTCOMES_PILLAR_ENABLED", ), + VITE_IS_CIP179_ENABLED: getEnv("VITE_IS_CIP179_ENABLED"), }; diff --git a/govtool/frontend/src/consts/governanceAction/fields.ts b/govtool/frontend/src/consts/governanceAction/fields.ts index 2b8585390..e79942d53 100644 --- a/govtool/frontend/src/consts/governanceAction/fields.ts +++ b/govtool/frontend/src/consts/governanceAction/fields.ts @@ -395,3 +395,24 @@ export const GOVERNANCE_ACTION_CONTEXT = { }, }, }; + +export const GOVERNANCE_ACTION_CONTEXT_WITH_CIP179 = { + ...GOVERNANCE_ACTION_CONTEXT, + CIP179: + "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0179/README.md#", + body: { + ...GOVERNANCE_ACTION_CONTEXT.body, + "@context": { + ...GOVERNANCE_ACTION_CONTEXT.body["@context"], + cip179: { + "@id": "CIP179:link", + "@context": { + specVersion: "CIP179:specVersion", + kind: "CIP179:kind", + surveyTxId: "CIP179:surveyTxId", + surveyIndex: "CIP179:surveyIndex", + }, + }, + }, + }, +}; diff --git a/govtool/frontend/src/context/featureFlag.tsx b/govtool/frontend/src/context/featureFlag.tsx index 5d8c62ce6..f911a0a09 100644 --- a/govtool/frontend/src/context/featureFlag.tsx +++ b/govtool/frontend/src/context/featureFlag.tsx @@ -16,6 +16,7 @@ import { useAppContext } from "./appContext"; type FeatureFlagContextType = { isProposalDiscussionForumEnabled: boolean; isGovernanceOutcomesPillarEnabled: boolean; + isCip179Enabled: boolean; isVotingOnGovernanceActionEnabled: ( governanceActionType: GovernanceActionType, ) => boolean; @@ -35,6 +36,7 @@ type FeatureFlagContextType = { const FeatureFlagContext = createContext({ isProposalDiscussionForumEnabled: false, isGovernanceOutcomesPillarEnabled: false, + isCip179Enabled: false, isVotingOnGovernanceActionEnabled: () => false, areDRepVoteTotalsDisplayed: () => false, areSPOVoteTotalsDisplayed: () => false, @@ -135,6 +137,10 @@ const FeatureFlagProvider = ({ children }: PropsWithChildren) => { env.VITE_IS_GOVERNANCE_OUTCOMES_PILLAR_ENABLED === "true" || env.VITE_IS_GOVERNANCE_OUTCOMES_PILLAR_ENABLED === true || false, + isCip179Enabled: + env.VITE_IS_CIP179_ENABLED === "true" || + env.VITE_IS_CIP179_ENABLED === true || + false, isVotingOnGovernanceActionEnabled, areDRepVoteTotalsDisplayed, areSPOVoteTotalsDisplayed, diff --git a/govtool/frontend/src/context/wallet.test.tsx b/govtool/frontend/src/context/wallet.test.tsx new file mode 100644 index 000000000..578fad877 --- /dev/null +++ b/govtool/frontend/src/context/wallet.test.tsx @@ -0,0 +1,290 @@ +import { act, renderHook } from "@testing-library/react"; +import * as CSL from "@emurgo/cardano-serialization-lib-asmjs"; +import { blake2b } from "blakejs"; +import { + encodeMetadata, + type MetadatumMap, + type SurveyResponse, +} from "cip-179"; + +import { CardanoProvider, useCardano } from "./wallet"; + +const mocks = vi.hoisted(() => ({ + storage: new Map(), + pending: vi.fn(() => false), + update: vi.fn(), +})); +vi.mock("@/config/env", () => ({ env: { VITE_NETWORK_FLAG: "0" } })); +vi.mock("@utils", () => ({ + PROTOCOL_PARAMS_KEY: "params", + NETWORK_INFO_KEY: "network", + WALLET_LS_KEY: "wallet", + checkIsMaintenanceOn: vi.fn(), + getItemFromLocalStorage: (key: string) => mocks.storage.get(key), + setItemToLocalStorage: (key: string, value: unknown) => + mocks.storage.set(key, value), + removeItemFromLocalStorage: (key: string) => mocks.storage.delete(key), + getPubDRepID: async () => ({ + dRepID: key.to_public().hash().to_hex(), + dRepKey: key.to_public().to_hex(), + }), +})); +vi.mock("@hooks", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@consts", () => ({ + PATHS: { home: "/" }, + COMPILED_GUARDRAIL_SCRIPT: "", +})); +vi.mock(".", () => ({ + useAppContext: () => ({ networkName: "Preview" }), + useModal: () => ({ openModal: vi.fn(), closeModal: vi.fn() }), + useSnackbar: () => ({ addSuccessAlert: vi.fn() }), +})); +vi.mock("react-router-dom", () => ({ useNavigate: () => vi.fn() })); +vi.mock("./pendingTransaction", () => ({ + usePendingTransaction: () => ({ + isPendingTransaction: mocks.pending, + updateTransaction: mocks.update, + }), +})); +vi.mock("@sentry/react", () => ({ + addBreadcrumb: vi.fn(), + setTag: vi.fn(), + captureException: vi.fn(), +})); + +// Public deterministic test key and fabricated UTxO; never use on a live network. +const key = CSL.PrivateKey.from_normal_bytes(new Uint8Array(32).fill(7)); +const credential = CSL.Credential.from_keyhash(key.to_public().hash()); +const address = CSL.BaseAddress.new(0, credential, credential).to_address(); +const utxo = CSL.TransactionUnspentOutput.new( + CSL.TransactionInput.new(CSL.TransactionHash.from_hex("33".repeat(32)), 0), + CSL.TransactionOutput.new( + address, + CSL.Value.new(CSL.BigNum.from_str("5000000000")), + ), +); +const actionHash = "44".repeat(32); +const response: SurveyResponse = { + specVersion: 5, + surveyRef: { txId: new Uint8Array(32), index: 1 }, + role: 0, + credential: { type: "key", keyHash: key.to_public().hash().to_bytes() }, + answers: { + type: "public", + answers: [{ type: "singleChoice", questionIndex: 0, optionIndex: 0 }], + }, +}; +const metadata = (value: SurveyResponse): MetadatumMap => { + const encoded = encodeMetadata({ type: "responses", responses: [value] }); + if (!(encoded instanceof Map)) throw new Error("Expected metadata map"); + return encoded; +}; + +const connect = async () => { + let unsigned: CSL.Transaction | undefined; + let signed: CSL.Transaction | undefined; + const api = { + getChangeAddress: async () => address.to_hex(), + getUsedAddresses: async () => [address.to_hex()], + getUnusedAddresses: async () => [], + getExtensions: async () => [{ cip: 95 }], + getNetworkId: async () => 0, + getUtxos: async () => [utxo.to_hex()], + cip95: { + getRegisteredPubStakeKeys: async () => [key.to_public().to_hex()], + getUnregisteredPubStakeKeys: async () => [], + }, + signTx: vi.fn(async (hex: string, partial: boolean) => { + expect(partial).toBe(true); + unsigned = CSL.Transaction.from_hex(hex); + const hash = CSL.TransactionHash.from_bytes( + blake2b(unsigned.body().to_bytes(), undefined, 32), + ); + const witnesses = CSL.TransactionWitnessSet.new(); + const vkeys = CSL.Vkeywitnesses.new(); + vkeys.add(CSL.make_vkey_witness(hash, key)); + witnesses.set_vkeys(vkeys); + return witnesses.to_hex(); + }), + submitTx: vi.fn(async (hex: string) => { + signed = CSL.Transaction.from_hex(hex); + return "55".repeat(32); + }), + }; + vi.stubGlobal("cardano", { + fixture: { + supportedExtensions: [{ cip: 95 }], + enable: async () => api, + isEnabled: async () => true, + }, + }); + const hook = renderHook(() => useCardano(), { wrapper: CardanoProvider }); + await act(async () => { + await hook.result.current.enable("fixture"); + }); + expect(hook.result.current.isEnabled).toBe(true); + return { + hook, + api, + transactions: () => { + if (!unsigned || !signed) + throw new Error("Expected signTx and submitTx calls"); + return { unsigned, signed }; + }, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.pending.mockReturnValue(false); + mocks.storage.clear(); + mocks.storage.set("network_fixture", true); + mocks.storage.set("params", { + min_fee_a: 44, + min_fee_b: 155381, + pool_deposit: 500000000, + key_deposit: 2000000, + coins_per_utxo_size: 4310, + max_val_size: 5000, + max_tx_size: 16384, + }); + vi.spyOn(console, "log").mockImplementation(() => {}); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const checkSigned = (unsigned: CSL.Transaction, signed: CSL.Transaction) => { + expect(signed.body().to_hex()).toBe(unsigned.body().to_hex()); + expect(signed.auxiliary_data()?.to_hex()).toBe( + unsigned.auxiliary_data()?.to_hex(), + ); + const auxiliary = signed.auxiliary_data(); + expect(signed.body().auxiliary_data_hash()?.to_hex()).toBe( + auxiliary ? CSL.hash_auxiliary_data(auxiliary).to_hex() : undefined, + ); + const witness = signed.witness_set().vkeys()!.get(0); + expect( + witness + .vkey() + .public_key() + .verify( + blake2b(signed.body().to_bytes(), undefined, 32), + witness.signature(), + ), + ).toBe(true); +}; + +describe("wallet transaction submission", () => { + it.each([ + ["ordinary vote", undefined], + ["empty metadata", new Map()], + ["public survey", metadata(response)], + [ + "sealed survey", + metadata({ + ...response, + answers: { type: "sealed", ciphertext: new Uint8Array(97).fill(42) }, + }), + ], + ["unrelated metadata", new Map([[674n, "message"]])], + ] as const)( + "preserves body, metadata and witnesses for %s", + async (_name, transactionMetadata) => { + const wallet = await connect(); + const votingBuilder = await wallet.hook.result.current.buildVote( + "yes", + actionHash, + 2, + ); + await act(async () => { + await wallet.hook.result.current.buildSignSubmitConwayCertTx({ + votingBuilder, + type: "vote", + resourceId: "fixture", + transactionMetadata, + }); + }); + const { unsigned, signed } = wallet.transactions(); + expect(!!unsigned.auxiliary_data()).toBe(!!transactionMetadata?.size); + checkSigned(unsigned, signed); + const voter = CSL.Voter.new_drep_credential(credential); + const action = CSL.GovernanceActionId.new( + CSL.TransactionHash.from_hex(actionHash), + 2, + ); + expect( + signed.body().voting_procedures()?.get(voter, action)?.vote_kind(), + ).toBe(1); + expect(wallet.api.submitTx).toHaveBeenCalledTimes(1); + expect(mocks.update).toHaveBeenCalledTimes(1); + }, + ); + + it("preserves a certificate transaction without auxiliary data", async () => { + const wallet = await connect(); + const certBuilder = CSL.Certificate.new_stake_registration( + CSL.StakeRegistration.new(credential), + ); + await act(async () => { + await wallet.hook.result.current.buildSignSubmitConwayCertTx({ + certBuilder, + type: "registerAsDrep", + }); + }); + const { unsigned, signed } = wallet.transactions(); + checkSigned(unsigned, signed); + expect(signed.body().certs()?.get(0).to_hex()).toBe(certBuilder.to_hex()); + expect(signed.auxiliary_data()).toBeUndefined(); + }); + + it("does not submit or mark a transaction pending when the wallet rejects signing", async () => { + const wallet = await connect(); + wallet.api.signTx.mockRejectedValueOnce(new Error("User declined")); + const votingBuilder = await wallet.hook.result.current.buildVote( + "no", + actionHash, + 2, + ); + await expect( + wallet.hook.result.current.buildSignSubmitConwayCertTx({ + votingBuilder, + type: "vote", + resourceId: "fixture", + transactionMetadata: metadata(response), + }), + ).rejects.toThrow("User declined"); + expect(wallet.api.submitTx).not.toHaveBeenCalled(); + expect(mocks.update).not.toHaveBeenCalled(); + }); + + it("preserves a governance-action proposal without survey metadata", async () => { + const wallet = await connect(); + const govActionBuilder = CSL.VotingProposalBuilder.new(); + const proposal = CSL.VotingProposal.new( + CSL.GovernanceAction.new_info_action(CSL.InfoAction.new()), + CSL.Anchor.new( + CSL.URL.new("https://example.com/action.jsonld"), + CSL.AnchorDataHash.from_hex("66".repeat(32)), + ), + CSL.RewardAddress.new(0, credential), + CSL.BigNum.from_str("1000000000"), + ); + govActionBuilder.add(proposal); + await act(async () => { + await wallet.hook.result.current.buildSignSubmitConwayCertTx({ + govActionBuilder, + type: "createGovAction", + }); + }); + const { unsigned, signed } = wallet.transactions(); + checkSigned(unsigned, signed); + expect(signed.body().voting_proposals()?.get(0).to_hex()).toBe( + proposal.to_hex(), + ); + expect(signed.auxiliary_data()).toBeUndefined(); + }); +}); diff --git a/govtool/frontend/src/context/wallet.tsx b/govtool/frontend/src/context/wallet.tsx index aee151011..149602c5e 100644 --- a/govtool/frontend/src/context/wallet.tsx +++ b/govtool/frontend/src/context/wallet.tsx @@ -94,6 +94,8 @@ import { setProtocolParameterUpdate, } from "@utils"; import { useTranslation } from "@hooks"; +import type { MetadatumMap } from "cip-179"; +import { buildAuxiliaryData } from "@/cip179/csl"; import { AutomatedVotingOptionDelegationId } from "@/types/automatedVotingOptions"; import { env } from "@/config/env"; import { getUtxos } from "./getUtxos"; @@ -204,6 +206,7 @@ type BuildSignSubmitConwayCertTxArgs = { govActionBuilder?: VotingProposalBuilder; votingBuilder?: VotingBuilder; voter?: VoterInfo; + transactionMetadata?: MetadatumMap; } & ( | Pick | Pick @@ -579,6 +582,7 @@ const CardanoProvider = (props: Props) => { resourceId, type, votingBuilder, + transactionMetadata, }: BuildSignSubmitConwayCertTxArgs) => { await checkIsMaintenanceOn(); const isPendingTx = isPendingTransaction(); @@ -587,6 +591,7 @@ const CardanoProvider = (props: Props) => { try { const txBuilder = await initTransactionBuilder(); const transactionWitnessSet = TransactionWitnessSet.new(); + let auxiliaryData: ReturnType | undefined; if (!txBuilder) { throw new Error(t("errors.appCannotCreateTransaction")); @@ -618,6 +623,11 @@ const CardanoProvider = (props: Props) => { txBuilder.set_voting_proposal_builder(govActionBuilder); } + if (transactionMetadata?.size) { + auxiliaryData = buildAuxiliaryData(transactionMetadata); + txBuilder.set_auxiliary_data(auxiliaryData); + } + if (isGuardrailScriptUsed.current) { try { const scripts = PlutusScripts.new(); @@ -727,7 +737,7 @@ const CardanoProvider = (props: Props) => { const txBody = txBuilder.build(); // Make a full transaction, passing in empty witness set - const tx = Transaction.new(txBody, transactionWitnessSet); + const tx = Transaction.new(txBody, transactionWitnessSet, auxiliaryData); // Ask wallet to to provide signature (witnesses) for the transaction // Create witness set object using the witnesses provided by the wallet @@ -740,7 +750,13 @@ const CardanoProvider = (props: Props) => { transactionWitnessSet.set_vkeys(vkeys); // Build transaction with witnesses - const signedTx = Transaction.new(tx.body(), transactionWitnessSet); + const signedTx = Transaction.new( + tx.body(), + transactionWitnessSet, + // Transaction.new consumes auxiliaryData; retrieve it from the + // unsigned transaction to preserve its committed metadata hash. + tx.auxiliary_data(), + ); // Submit built signed transaction to chain, via wallet's submit transaction endpoint const result = await walletApi.submitTx(signedTx.to_hex()); diff --git a/govtool/frontend/src/hooks/forms/useCreateGovernanceActionForm.ts b/govtool/frontend/src/hooks/forms/useCreateGovernanceActionForm.ts index c13eb5bb6..7a935a0d4 100644 --- a/govtool/frontend/src/hooks/forms/useCreateGovernanceActionForm.ts +++ b/govtool/frontend/src/hooks/forms/useCreateGovernanceActionForm.ts @@ -13,6 +13,7 @@ import { NodeObject } from "jsonld"; import { GOVERNANCE_ACTION_CONTEXT, + GOVERNANCE_ACTION_CONTEXT_WITH_CIP179, PATHS, storageInformationErrorModals, } from "@consts"; @@ -27,6 +28,12 @@ import { } from "@utils"; import { useWalletErrorModal } from "@hooks"; import { MetadataValidationStatus } from "@models"; +import { API } from "@services"; +import { + decodeDefinition, + getRenderabilityProblem, + type SurveyDefinitionEnvelope, +} from "@/cip179/core"; import { GovernanceActionFieldSchemas, GovernanceActionType, @@ -39,6 +46,7 @@ export type CreateGovernanceActionValues = { storeData?: boolean; storingURL: string; governance_action_type?: GovernanceActionType; + surveyTxId?: string; } & Partial>; export const defaulCreateGovernanceActionValues: CreateGovernanceActionValues = @@ -75,7 +83,7 @@ export const useCreateGovernanceActionForm = ( const navigate = useNavigate(); const { openModal, closeModal } = useModal(); const openWalletErrorModal = useWalletErrorModal(); - const { cExplorerBaseUrl } = useAppContext(); + const { cExplorerBaseUrl, epochParams } = useAppContext(); // Queries const { validateMetadata } = useValidateMutation(); @@ -111,17 +119,62 @@ export const useCreateGovernanceActionForm = ( }, []); // Business Logic + const validateSurveyLink = useCallback( + async (surveyTxId?: string) => { + const normalized = surveyTxId?.trim().toLowerCase(); + if (!normalized) return; + if (!/^[0-9a-f]{64}$/.test(normalized)) { + throw new Error("Survey transaction hash must be 64 hexadecimal characters"); + } + if ( + epochParams?.epoch_no === null || + epochParams?.epoch_no === undefined || + epochParams.gov_action_lifetime === null || + epochParams.gov_action_lifetime === undefined + ) { + throw new Error("Current governance action lifetime is unavailable"); + } + const { data } = await API.get( + `/survey/definition/${normalized}/0`, + ); + const actionExpiration = + epochParams.epoch_no + epochParams.gov_action_lifetime + 1; + const definition = decodeDefinition( + data, + { txId: normalized, index: 0 }, + actionExpiration, + ); + const renderabilityProblem = getRenderabilityProblem(definition); + if (renderabilityProblem) throw new Error(renderabilityProblem); + }, + [epochParams?.epoch_no, epochParams?.gov_action_lifetime], + ); + const generateMetadata = useCallback(async () => { if (!govActionType) { throw new Error("Governance action type is not defined"); } + const values = getValues(); + await validateSurveyLink(values.surveyTxId); const body = await generateMetadataBody({ - data: getValues(), + data: values, acceptedKeys: ["title", "motivation", "abstract", "rationale"], }); + if (!body) throw new Error("Could not generate governance metadata"); + if (values.surveyTxId?.trim()) { + body.cip179 = { + specVersion: 5, + kind: "survey-link", + surveyTxId: values.surveyTxId.trim().toLowerCase(), + surveyIndex: 0, + }; + } - const jsonld = await generateJsonld(body, GOVERNANCE_ACTION_CONTEXT); + const context = values.surveyTxId?.trim() + ? GOVERNANCE_ACTION_CONTEXT_WITH_CIP179 + : GOVERNANCE_ACTION_CONTEXT; + const jsonld = await generateJsonld(body, context); const jsonHash = blake2bHex(JSON.stringify(jsonld, null, 2), undefined, 32); @@ -130,7 +183,7 @@ export const useCreateGovernanceActionForm = ( setJson(jsonld); return jsonld; - }, [getValues]); + }, [getValues, validateSurveyLink]); const onClickDownloadJson = useCallback(() => { if (!json) return; @@ -310,6 +363,7 @@ export const useCreateGovernanceActionForm = ( try { setIsLoading(true); showLoadingModal(); + await validateSurveyLink(data.surveyTxId); if (!hash) throw MetadataValidationStatus.INVALID_HASH; const { status } = await validateMetadata({ url: data.storingURL, @@ -365,7 +419,12 @@ export const useCreateGovernanceActionForm = ( setIsLoading(false); } }, - [hash, buildTransaction, buildSignSubmitConwayCertTx], + [ + hash, + buildTransaction, + buildSignSubmitConwayCertTx, + validateSurveyLink, + ], ); return { diff --git a/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx b/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx index 8d25e7804..213165204 100644 --- a/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx +++ b/govtool/frontend/src/hooks/forms/useVoteActionForm.tsx @@ -7,7 +7,10 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; import { PATHS } from "@consts"; import { useCardano, useSnackbar } from "@context"; import { useWalletErrorModal } from "@hooks"; +import { encodeMetadata, type SurveyResponse } from "cip-179"; import { ProposalVote, Vote } from "@/models"; +import type { Cip179Participation } from "@/cip179/Cip179Survey"; +import { metadatumCodec } from "@/cip179/csl"; export interface VoteActionFormValues { vote: string; @@ -34,11 +37,13 @@ type Props = { voteContextHash?: string; voteContextUrl?: string; closeModal: () => void; + cip179?: Cip179Participation; }; export const useVoteActionForm = ({ previousVote, closeModal, + cip179, }: Props) => { const [isLoading, setIsLoading] = useState(false); const { buildSignSubmitConwayCertTx, buildVote, isPendingTransaction } = @@ -69,6 +74,7 @@ export const useVoteActionForm = ({ index !== undefined && index !== null && !areFormErrors; + const canSubmitSurvey = !cip179?.participating || cip179.valid; const confirmVote = useCallback( async ( @@ -76,7 +82,7 @@ export const useVoteActionForm = ({ url?: string, hashValue?: string | null, ) => { - if (!canVote || !voteValue) return; + if (!canVote || !canSubmitSurvey || !voteValue) return; setIsLoading(true); @@ -95,10 +101,39 @@ export const useVoteActionForm = ({ hashSubmitValue, ); + let surveyResponse: SurveyResponse | null = cip179?.response ?? null; + if ( + surveyResponse && + cip179?.definition?.submissionMode.type === "sealed" && + surveyResponse.answers.type === "public" + ) { + const { isQuicknet, sealAnswers } = await import("cip-179/tlock"); + const mode = cip179.definition.submissionMode; + if (!isQuicknet(mode.chainHash)) { + throw new Error("This sealed survey uses an unsupported drand network"); + } + const ciphertext = await sealAnswers( + metadatumCodec, + surveyResponse.answers.answers, + mode.round, + mode.paddingSize, + ); + surveyResponse = { + ...surveyResponse, + answers: { type: "sealed", ciphertext }, + }; + } + const encodedMetadata = surveyResponse + ? encodeMetadata({ type: "responses", responses: [surveyResponse] }) + : undefined; + const transactionMetadata = + encodedMetadata instanceof Map ? encodedMetadata : undefined; + const result = await buildSignSubmitConwayCertTx({ votingBuilder, type: "vote", resourceId: txHash + index, + transactionMetadata, }); if (result) { @@ -119,7 +154,15 @@ export const useVoteActionForm = ({ setIsLoading(false); } }, - [buildVote, buildSignSubmitConwayCertTx, txHash, index, canVote], + [ + buildVote, + buildSignSubmitConwayCertTx, + txHash, + index, + canVote, + canSubmitSurvey, + cip179, + ], ); return { @@ -130,6 +173,6 @@ export const useVoteActionForm = ({ isDirty, areFormErrors, isVoteLoading: isLoading, - canVote, + canVote: canVote && canSubmitSurvey, }; }; diff --git a/govtool/frontend/src/i18n/locales/en.json b/govtool/frontend/src/i18n/locales/en.json index 48a977b83..95362068a 100644 --- a/govtool/frontend/src/i18n/locales/en.json +++ b/govtool/frontend/src/i18n/locales/en.json @@ -127,6 +127,10 @@ "creatingAGovernanceAction": "Creating a Governance Action: What you need to know", "creatingAGovernanceActionDescription": "To create a Governance Action, you will need to:\n\n• Fill out a form with the relevant data\n• Pay a refundable deposit of ₳{{deposit}}\n• Store the metadata of your Governance Action at your own expense.\n\nYour deposit will be refunded to your wallet when the Governance Action is either enacted or expired.\n\nThe deposit will not affect your Voting Power.", "editSubmission": "Edit submission", + "surveyTxId": "CIP-179 survey transaction hash (optional)", + "surveyTxIdHelp": "Links survey index 0 to this Governance Action.", + "surveyTxIdPlaceholder": "64-character transaction hash", + "surveyTxIdInvalid": "Enter a valid 64-character hexadecimal transaction hash.", "fields": { "declarations": { "abstract": {