From 97aebf5388e5f72fff4f93b0e7c9c371823bc0eb Mon Sep 17 00:00:00 2001 From: Cerkoryn <30681834+Cerkoryn@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:53:52 -0400 Subject: [PATCH 1/7] feat: support CIP-179 linked surveys --- CHANGELOG.md | 2 + govtool/backend/sql/get-survey-definition.sql | 6 + govtool/backend/src/VVA/API.hs | 18 +- govtool/backend/src/VVA/Survey.hs | 34 ++ govtool/backend/vva-be.cabal | 2 + govtool/frontend/.env.example | 3 +- govtool/frontend/package-lock.json | 145 ++++++ govtool/frontend/package.json | 2 + govtool/frontend/src/cip179/Cip179Survey.tsx | 470 ++++++++++++++++++ govtool/frontend/src/cip179/authoring.test.ts | 33 ++ govtool/frontend/src/cip179/core.test.ts | 150 ++++++ govtool/frontend/src/cip179/core.ts | 204 ++++++++ govtool/frontend/src/cip179/csl.test.ts | 62 +++ govtool/frontend/src/cip179/csl.ts | 96 ++++ .../components/molecules/VoteActionForm.tsx | 29 +- .../CreateGovernanceActionForm.tsx | 19 + govtool/frontend/src/config/env.ts | 1 + .../src/consts/governanceAction/fields.ts | 21 + govtool/frontend/src/context/featureFlag.tsx | 6 + govtool/frontend/src/context/wallet.tsx | 18 +- .../forms/useCreateGovernanceActionForm.ts | 69 ++- .../src/hooks/forms/useVoteActionForm.tsx | 49 +- govtool/frontend/src/i18n/locales/en.json | 4 + 23 files changed, 1429 insertions(+), 14 deletions(-) create mode 100644 govtool/backend/sql/get-survey-definition.sql create mode 100644 govtool/backend/src/VVA/Survey.hs create mode 100644 govtool/frontend/src/cip179/Cip179Survey.tsx create mode 100644 govtool/frontend/src/cip179/authoring.test.ts create mode 100644 govtool/frontend/src/cip179/core.test.ts create mode 100644 govtool/frontend/src/cip179/core.ts create mode 100644 govtool/frontend/src/cip179/csl.test.ts create mode 100644 govtool/frontend/src/cip179/csl.ts 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..e1fcf8b27 100644 --- a/govtool/backend/src/VVA/API.hs +++ b/govtool/backend/src/VVA/API.hs @@ -13,7 +13,7 @@ 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 @@ -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,19 @@ 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" + 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/Survey.hs b/govtool/backend/src/VVA/Survey.hs new file mode 100644 index 000000000..63e8ba6cd --- /dev/null +++ b/govtool/backend/src/VVA/Survey.hs @@ -0,0 +1,34 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module VVA.Survey where + +import Control.Monad.Except (MonadError, throwError) +import Control.Monad.Reader +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, 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/vva-be.cabal b/govtool/backend/vva-be.cabal index 8eb0de86d..26b1e0e1e 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 @@ -131,4 +132,5 @@ library , VVA.Network , VVA.Account , VVA.Ipfs + , VVA.Survey 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/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.tsx b/govtool/frontend/src/cip179/Cip179Survey.tsx new file mode 100644 index 000000000..b97d0a44d --- /dev/null +++ b/govtool/frontend/src/cip179/Cip179Survey.tsx @@ -0,0 +1,470 @@ +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) + ? { + type: "pointsAllocation", + questionIndex, + allocations: (value as number[]).flatMap((points, optionIndex) => + (points > 0 ? [{ optionIndex, points }] : []), + ), + } + : null; + case "rating": + if (!Array.isArray(value)) return null; + try { + return { + type: "rating", + questionIndex, + ratings: (value as Array).flatMap( + (rating, optionIndex) => + (rating === null ? [] : [{ optionIndex, rating: BigInt(rating) }]), + ), + }; + } 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 answers = definition.questions.flatMap((question, index) => { + const answer = buildAnswer(question, index, values[index], touched.has(index)); + return answer ? [answer] : []; + }); + 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 && + 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) => new Set(current).add(index)); + }; + 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..b99b6335e --- /dev/null +++ b/govtool/frontend/src/cip179/core.test.ts @@ -0,0 +1,150 @@ +import { encodePayload, Role, type SurveyDefinition } from "cip-179"; + +import { + decodeDefinition, + getRenderabilityProblem, + MAX_RENDERED_SURVEY_ITEMS, + parseSurveyLink, +} from "./core"; +import { buildAnswer } from "./Cip179Survey"; +import { metadatumCodec } from "./csl"; + +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", () => { + 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(); + }); +}); diff --git a/govtool/frontend/src/cip179/core.ts b/govtool/frontend/src/cip179/core.ts new file mode 100644 index 000000000..a88dcd4e0 --- /dev/null +++ b/govtool/frontend/src/cip179/core.ts @@ -0,0 +1,204 @@ +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; +}; + +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), + ); + const payload = decodePayload(decoded); + 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 && 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 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); + const response = await fetch(uri, { signal: controller.signal }).finally(() => + globalThis.clearTimeout(timeout), + ); + if (!response.ok) throw new Error("External survey content is unavailable"); + const bytes = new Uint8Array(await response.arrayBuffer()); + if ( + blake2bHex(bytes, undefined, 32) !== + Array.from(anchor.hash, (byte) => byte.toString(16).padStart(2, "0")).join("") + ) { + throw new Error("External survey content hash does not match"); + } + return JSON.parse(new TextDecoder().decode(bytes)); +}; + +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"); + } + 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..eab469f86 --- /dev/null +++ b/govtool/frontend/src/cip179/csl.test.ts @@ -0,0 +1,62 @@ +import { + BigNum, + Int, + TransactionMetadatum, +} from "@emurgo/cardano-serialization-lib-asmjs"; +import { + encodeMetadata, + Role, + type Metadatum, + type SurveyResponse, +} from "cip-179"; + +import { + buildAuxiliaryData, + 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); + expect(auxiliaryData.metadata()?.get(BigNum.from_str("17"))).toBeDefined(); + }); +}); 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/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.tsx b/govtool/frontend/src/context/wallet.tsx index aee151011..78bf309d5 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,11 @@ 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, + auxiliaryData, + ); // 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": { From e40bdd9f8798fcb6bad8a67c32de518b3ea29f5d Mon Sep 17 00:00:00 2001 From: Cerkoryn <30681834+Cerkoryn@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:27:08 -0400 Subject: [PATCH 2/7] fix: validate CIP-179 points allocations --- govtool/frontend/src/cip179/Cip179Survey.tsx | 20 +++++++++++++++----- govtool/frontend/src/cip179/core.test.ts | 16 ++++++++++++++++ govtool/frontend/src/cip179/csl.test.ts | 9 ++++++++- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/govtool/frontend/src/cip179/Cip179Survey.tsx b/govtool/frontend/src/cip179/Cip179Survey.tsx index b97d0a44d..4c9f768e2 100644 --- a/govtool/frontend/src/cip179/Cip179Survey.tsx +++ b/govtool/frontend/src/cip179/Cip179Survey.tsx @@ -101,7 +101,13 @@ export const buildAnswer = ( return null; } case "pointsAllocation": - return Array.isArray(value) + return Array.isArray(value) && + value.every( + (points) => + typeof points === "number" && + Number.isSafeInteger(points) && + points >= 0, + ) ? { type: "pointsAllocation", questionIndex, @@ -221,10 +227,13 @@ export const Cip179Survey = ({ if (!/^[0-9a-fA-F]{56}$/.test(dRepId)) { return { participating, valid: false, response: null, definition }; } - const answers = definition.questions.flatMap((question, index) => { - const answer = buildAnswer(question, index, values[index], touched.has(index)); - return answer ? [answer] : []; - }); + 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: { @@ -250,6 +259,7 @@ export const Cip179Survey = ({ : 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]); diff --git a/govtool/frontend/src/cip179/core.test.ts b/govtool/frontend/src/cip179/core.test.ts index b99b6335e..648d28f32 100644 --- a/govtool/frontend/src/cip179/core.test.ts +++ b/govtool/frontend/src/cip179/core.test.ts @@ -147,4 +147,20 @@ describe("CIP-179 integration helpers", () => { ), ).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(); + }); }); diff --git a/govtool/frontend/src/cip179/csl.test.ts b/govtool/frontend/src/cip179/csl.test.ts index eab469f86..69da83e75 100644 --- a/govtool/frontend/src/cip179/csl.test.ts +++ b/govtool/frontend/src/cip179/csl.test.ts @@ -4,6 +4,7 @@ import { TransactionMetadatum, } from "@emurgo/cardano-serialization-lib-asmjs"; import { + decodePayload, encodeMetadata, Role, type Metadatum, @@ -12,6 +13,7 @@ import { import { buildAuxiliaryData, + fromTransactionMetadatum, metadatumCodec, toTransactionMetadatum, } from "./csl"; @@ -57,6 +59,11 @@ describe("CIP-179 CSL adapter", () => { const encoded = encodeMetadata({ type: "responses", responses: [response] }); if (!(encoded instanceof Map)) throw new Error("Expected metadata map"); const auxiliaryData = buildAuxiliaryData(encoded); - expect(auxiliaryData.metadata()?.get(BigNum.from_str("17"))).toBeDefined(); + const label17 = auxiliaryData.metadata()?.get(BigNum.from_str("17")); + expect(label17).toBeDefined(); + expect(decodePayload(fromTransactionMetadatum(label17!))).toEqual({ + type: "responses", + responses: [response], + }); }); }); From 83a9678bfbb826f27bbea416b3c99a242d1aa275 Mon Sep 17 00:00:00 2001 From: Cerkoryn <30681834+Cerkoryn@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:02:35 -0400 Subject: [PATCH 3/7] fix: reject empty CIP-179 rating answers --- govtool/frontend/src/cip179/Cip179Survey.tsx | 16 +++++++++------- govtool/frontend/src/cip179/core.test.ts | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/govtool/frontend/src/cip179/Cip179Survey.tsx b/govtool/frontend/src/cip179/Cip179Survey.tsx index 4c9f768e2..29a0ceb5c 100644 --- a/govtool/frontend/src/cip179/Cip179Survey.tsx +++ b/govtool/frontend/src/cip179/Cip179Survey.tsx @@ -119,14 +119,16 @@ export const buildAnswer = ( 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: (value as Array).flatMap( - (rating, optionIndex) => - (rating === null ? [] : [{ optionIndex, rating: BigInt(rating) }]), - ), - }; + type: "rating", + questionIndex, + ratings, + }; } catch { return null; } diff --git a/govtool/frontend/src/cip179/core.test.ts b/govtool/frontend/src/cip179/core.test.ts index 648d28f32..43f7c7883 100644 --- a/govtool/frontend/src/cip179/core.test.ts +++ b/govtool/frontend/src/cip179/core.test.ts @@ -148,6 +148,23 @@ describe("CIP-179 integration helpers", () => { ).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( From 176d6e4cadbde9883ed110a6b7919be30c0d4ea4 Mon Sep 17 00:00:00 2001 From: Cerkoryn <30681834+Cerkoryn@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:32:09 -0400 Subject: [PATCH 4/7] fix: decode db-sync surveys and preserve signed vote metadata Unwrap label 17 from the singleton metadata map stored by db-sync, while preserving compatibility with already-extracted payloads. Reject maps that do not contain the integer label 17. Read auxiliary data back from the unsigned transaction when assembling the signed transaction. CSL consumes the original auxiliary-data object in Transaction.new; reusing it silently dropped the metadata while retaining its hash in the transaction body. Add independent CBOR fixtures and CardanoProvider regression tests with real coin selection, serialization and signatures. Cover public/sealed survey votes, unrelated/empty/absent metadata, certificate and proposal transactions, and wallet rejection. Leave feature flags unchanged. Validation: 235 frontend tests pass with TZ=UTC; frontend type check, lint and production build pass. Independent cross-repository decoder and actual wallet-callback checks pass. Live Preview acceptance remains pending redeployment. --- govtool/frontend/src/cip179/core.test.ts | 46 +++ govtool/frontend/src/cip179/core.ts | 8 +- .../src/cip179/fixtures/dbSyncMetadata.json | 11 + govtool/frontend/src/context/wallet.test.tsx | 290 ++++++++++++++++++ govtool/frontend/src/context/wallet.tsx | 4 +- 5 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 govtool/frontend/src/cip179/fixtures/dbSyncMetadata.json create mode 100644 govtool/frontend/src/context/wallet.test.tsx diff --git a/govtool/frontend/src/cip179/core.test.ts b/govtool/frontend/src/cip179/core.test.ts index 43f7c7883..b3fd77bdb 100644 --- a/govtool/frontend/src/cip179/core.test.ts +++ b/govtool/frontend/src/cip179/core.test.ts @@ -8,6 +8,7 @@ import { } from "./core"; import { buildAnswer } from "./Cip179Survey"; import { metadatumCodec } from "./csl"; +import dbSyncFixture from "./fixtures/dbSyncMetadata.json"; const definition: SurveyDefinition = { specVersion: 5, @@ -28,6 +29,51 @@ const definition: SurveyDefinition = { }; 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( diff --git a/govtool/frontend/src/cip179/core.ts b/govtool/frontend/src/cip179/core.ts index a88dcd4e0..55600fbee 100644 --- a/govtool/frontend/src/cip179/core.ts +++ b/govtool/frontend/src/cip179/core.ts @@ -93,7 +93,13 @@ export const decodeDefinition = ( const decoded = metadatumCodec.cborToMetadatum( hexToBytes(envelope.payloadCborHex), ); - const payload = decodePayload(decoded); + // 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"); } 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/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 78bf309d5..149602c5e 100644 --- a/govtool/frontend/src/context/wallet.tsx +++ b/govtool/frontend/src/context/wallet.tsx @@ -753,7 +753,9 @@ const CardanoProvider = (props: Props) => { const signedTx = Transaction.new( tx.body(), transactionWitnessSet, - auxiliaryData, + // 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 From b58199dcaedc7259acc0c4f4afd03576e3638ecf Mon Sep 17 00:00:00 2001 From: Kushal Acharya Date: Wed, 9 Sep 2026 15:02:15 +0545 Subject: [PATCH 5/7] fix(frontend): inject CIP-179 runtime feature flag --- govtool/frontend/docker-entrypoint.sh | 1 + 1 file changed, 1 insertion(+) 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:-}"), From cbf6b6a452221b20db97d4e7c72c241670f3646f Mon Sep 17 00:00:00 2001 From: Cerkoryn <30681834+Cerkoryn@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:34:34 -0400 Subject: [PATCH 6/7] fix: validate survey presentations and preserve optional omissions --- .../frontend/src/cip179/Cip179Survey.test.tsx | 119 ++++++++++++ govtool/frontend/src/cip179/Cip179Survey.tsx | 11 +- govtool/frontend/src/cip179/core.test.ts | 182 +++++++++++++++++- govtool/frontend/src/cip179/core.ts | 95 +++++++-- 4 files changed, 383 insertions(+), 24 deletions(-) create mode 100644 govtool/frontend/src/cip179/Cip179Survey.test.tsx 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 index 29a0ceb5c..05f885c69 100644 --- a/govtool/frontend/src/cip179/Cip179Survey.tsx +++ b/govtool/frontend/src/cip179/Cip179Survey.tsx @@ -310,7 +310,16 @@ export const Cip179Survey = ({ const labels = "options" in question ? optionLabels(question.options) : []; const markTouched = (value: unknown) => { setValues((current) => ({ ...current, [index]: value })); - setTouched((current) => new Set(current).add(index)); + 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 ( { ).toEqual({ txId, index: 0 }); expect( parseSurveyLink({ - body: { cip179: { kind: "survey-link", surveyTxId: txId, surveyIndex: 0 } }, + 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 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 }, + { + txId: "ab".repeat(32), + surveyIndex: 0, + metadataLabel: 17, + payloadCborHex, + }, link, 501, ), ).toEqual(definition); expect(() => decodeDefinition( - { txId: "ab".repeat(32), surveyIndex: 0, metadataLabel: 17, payloadCborHex }, + { + txId: "ab".repeat(32), + surveyIndex: 0, + metadataLabel: 17, + payloadCborHex, + }, link, 502, ), @@ -125,7 +142,12 @@ describe("CIP-179 integration helpers", () => { ).toString("hex"); expect(() => decodeDefinition( - { txId: "cd".repeat(32), surveyIndex: 0, metadataLabel: 17, payloadCborHex }, + { + txId: "cd".repeat(32), + surveyIndex: 0, + metadataLabel: 17, + payloadCborHex, + }, { txId: "ab".repeat(32), index: 0 }, 501, ), @@ -227,3 +249,153 @@ describe("CIP-179 integration helpers", () => { ).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 index 55600fbee..2057dfba8 100644 --- a/govtool/frontend/src/cip179/core.ts +++ b/govtool/frontend/src/cip179/core.ts @@ -56,9 +56,8 @@ 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), + return Uint8Array.from({ length: hex.length / 2 }, (_, index) => + parseInt(hex.slice(index * 2, index * 2 + 2), 16), ); }; @@ -75,7 +74,7 @@ export const parseSurveyLink = (metadata: unknown): SurveyLink | null => { return null; } const { surveyRef } = parseCip179Link(metadata); - return surveyRef; + return surveyRef && surveyRef.index <= 65535 ? surveyRef : null; }; export const decodeDefinition = ( @@ -88,7 +87,9 @@ export const decodeDefinition = ( envelope.surveyIndex !== expected.index || envelope.metadataLabel !== 17 ) { - throw new Error("Survey definition response does not match the requested reference"); + throw new Error( + "Survey definition response does not match the requested reference", + ); } const decoded = metadatumCodec.cborToMetadatum( hexToBytes(envelope.payloadCborHex), @@ -107,7 +108,14 @@ export const decodeDefinition = ( 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 && definition.endEpoch !== expiryEpoch - 1) { + 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; @@ -135,6 +143,22 @@ type PresentationQuestion = { 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)}` @@ -142,18 +166,42 @@ const fetchContentAnchor = async (anchor: ContentAnchor): Promise => { if (!uri.startsWith("https://")) throw new Error("Unsupported anchor URI"); const controller = new AbortController(); const timeout = globalThis.setTimeout(() => controller.abort(), 10_000); - const response = await fetch(uri, { signal: controller.signal }).finally(() => - globalThis.clearTimeout(timeout), - ); - if (!response.ok) throw new Error("External survey content is unavailable"); - const bytes = new Uint8Array(await response.arrayBuffer()); - if ( - blake2bHex(bytes, undefined, 32) !== - Array.from(anchor.hash, (byte) => byte.toString(16).padStart(2, "0")).join("") - ) { - throw new Error("External survey content hash does not match"); + 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); } - return JSON.parse(new TextDecoder().decode(bytes)); }; const applyQuestion = ( @@ -185,7 +233,8 @@ export const enrichDefinition = async ( let enriched = definition; if (definition.contentAnchor) { const raw = await fetchContentAnchor(definition.contentAnchor); - if (!raw || typeof raw !== "object") throw new Error("Invalid presentation"); + if (!raw || typeof raw !== "object") + throw new Error("Invalid presentation"); const presentation = raw as Record; if ( presentation.specVersion !== 5 || @@ -193,6 +242,16 @@ export const enrichDefinition = async ( ) { 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[]) : []; From aead1e2d88c758832ab368fc9c284e8a6914c75b Mon Sep 17 00:00:00 2001 From: Cerkoryn <30681834+Cerkoryn@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:37:42 -0400 Subject: [PATCH 7/7] fix: release database pool resources on failed requests --- govtool/backend/src/VVA/API.hs | 4 +- govtool/backend/src/VVA/Account.hs | 27 +++++----- govtool/backend/src/VVA/AdaHolder.hs | 27 +++++----- govtool/backend/src/VVA/DRep.hs | 10 ++-- govtool/backend/src/VVA/Epoch.hs | 23 +++++---- govtool/backend/src/VVA/Network.hs | 27 +++++----- govtool/backend/src/VVA/Pool.hs | 18 +++---- govtool/backend/src/VVA/Proposal.hs | 9 ++-- govtool/backend/src/VVA/Survey.hs | 26 ++++++---- govtool/backend/src/VVA/Transaction.hs | 31 ++++++------ govtool/backend/src/VVA/Types.hs | 9 ++-- govtool/backend/test/PoolSpec.hs | 70 ++++++++++++++++++++++++++ govtool/backend/vva-be.cabal | 13 +++++ 13 files changed, 196 insertions(+), 98 deletions(-) create mode 100644 govtool/backend/test/PoolSpec.hs diff --git a/govtool/backend/src/VVA/API.hs b/govtool/backend/src/VVA/API.hs index e1fcf8b27..c7a61e799 100644 --- a/govtool/backend/src/VVA/API.hs +++ b/govtool/backend/src/VVA/API.hs @@ -17,6 +17,7 @@ import Data.Aeson (Array, ToJSON, Value (..), decode, ob 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) @@ -125,6 +125,8 @@ getSurveyDefinition :: App m => HexText -> Natural -> m (Headers '[Header "Cache 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 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 index 63e8ba6cd..176a0f1eb 100644 --- a/govtool/backend/src/VVA/Survey.hs +++ b/govtool/backend/src/VVA/Survey.hs @@ -4,17 +4,21 @@ module VVA.Survey where -import Control.Monad.Except (MonadError, throwError) +import Control.Monad.Except (MonadError, throwError) import Control.Monad.Reader -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 (..)) +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 @@ -23,7 +27,7 @@ getSurveyDefinitionSql :: SQL.Query getSurveyDefinitionSql = sqlFrom $(embedFile "sql/get-survey-definition.sql") getSurveyDefinition :: - (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 Text getSurveyDefinition txId = withPool $ \conn -> do 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 26b1e0e1e..abeffb02d 100644 --- a/govtool/backend/vva-be.cabal +++ b/govtool/backend/vva-be.cabal @@ -86,6 +86,7 @@ library , servant-server , conferer , mtl + , monad-control , aeson , aeson-pretty , conferer-aeson @@ -134,3 +135,15 @@ library , 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