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/3] 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/3] 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/3] 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(