diff --git a/.gitignore b/.gitignore index 090a1f0..617aa17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea .DS_Store +node_modules/ diff --git a/cardano/mainnet/00_common.sh b/cardano/mainnet/00_common.sh index c2b7332..8ba59f3 100755 --- a/cardano/mainnet/00_common.sh +++ b/cardano/mainnet/00_common.sh @@ -1194,7 +1194,7 @@ convert_actionUTXO2Bech() { local govActionID="${1}" # local govActionUTXO=$(trimString "${1%%#*}"); govActionUTXO=${govActionUTXO,,} #takes the part before the # separator # local govActionIdx=$(trimString "${1#*#}"); #takes the part after the # separator - if [[ "${govActionID}" =~ ^([[:xdigit:]]{64}+#[[:digit:]]{1,})$ ]]; then + if [[ "${govActionID}" =~ ^([[:xdigit:]]{64}#[[:digit:]]{1,})$ ]]; then local govActionUTXO=${govActionID:0:64}; govActionUTXO=${govActionUTXO,,} #make sure its lower case local govActionIdx=$(( ${govActionID:65} + 0 )) #make sure to have single digits if provided like #00 #01 #02... local govActionIdxHex="00$(bc <<< "obase=16;ibase=10;${govActionIdx}")"; govActionIdxHex=${govActionIdxHex: -$(( (${#govActionIdxHex}-1)/2*2 ))} #make sure its with a leading zero and always in pairs like 03, 04af diff --git a/cardano/mainnet/24a_genVote.sh b/cardano/mainnet/24a_genVote.sh index b24fac3..72b586b 100755 --- a/cardano/mainnet/24a_genVote.sh +++ b/cardano/mainnet/24a_genVote.sh @@ -100,7 +100,7 @@ if [[ "${govActionID:0:11}" == "gov_action1" ]]; then #parameter is most likely if [ $? -ne 0 ]; then echo -e "\n\n\e[91mERROR - \"${2,,}\" is not a valid Bech32 ACTION-ID.\e[0m"; exit 1; fi govActionUTXO=${govActionID:0:64} govActionIdx=$(( ${govActionID:65} + 0 )) #make sure to have single digits if provided like #00 #01 #02... -elif [[ "${govActionID}" =~ ^([[:xdigit:]]{64}+#[[:digit:]]{1,})$ ]]; then +elif [[ "${govActionID}" =~ ^([[:xdigit:]]{64}#[[:digit:]]{1,})$ ]]; then govActionUTXO=${govActionID:0:64} govActionIdx=$(( ${govActionID:65} + 0 )) #make sure to have single digits if provided like #00 #01 #02... elif [[ "${govActionID}" == "all" ]]; then #do the voting on all current gov-actions @@ -559,6 +559,7 @@ do dRepAcceptIcon=""; poolAcceptIcon=""; committeeAcceptIcon=""; dRepPowerThreshold="N/A"; poolPowerThreshold="N/A"; #N/A -> not available govActionTitle=""; + cip179SurveyTxId=""; cip179SurveyIndex=""; echo echo -e "\e[36m--- Entry $((${tmpCnt}+1)) of ${actionStateEntryCnt} --- Action-ID ${actionUTXO}#${actionIdx}\e[0m" @@ -629,6 +630,22 @@ do errorMsg=$(jq -r .errorMsg <<< ${signerJSON} 2> /dev/null) echo -e "\e[0m Anchor-Data: ${iconYes}\e[32m JSONLD structure is ok\e[0m"; { read govActionTitle; read proofDepositReturnAddr; read proofWithdrawalAddr; } <<< $(jq -r '.body.title // "-", .body.onChain.depositReturnAddress // "-", if (.body.onChain.withdrawals[0]) then ([.body.onChain.withdrawals[].withdrawalAddress] | add) else "-" end' ${tmpAnchorContent} 2> /dev/null) + if jq -e ' + .body.cip179 as $link | + ($link.specVersion == 5 and $link.kind == "survey-link" and + ($link.surveyTxId | test("^[0-9a-fA-F]{64}$")) and + ($link.surveyIndex | type == "number" and . >= 0 and . <= 65535 and floor == .) and + ."@context".CIP179 == "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0179/README.md#" and + ."@context".body."@context".cip179."@id" == "CIP179:link" and + ."@context".body."@context".cip179."@context".specVersion == "CIP179:specVersion" and + ."@context".body."@context".cip179."@context".kind == "CIP179:kind" and + ."@context".body."@context".cip179."@context".surveyTxId == "CIP179:surveyTxId" and + ."@context".body."@context".cip179."@context".surveyIndex == "CIP179:surveyIndex")' "${tmpAnchorContent}" >/dev/null 2>&1; then + { read cip179SurveyTxId; read cip179SurveyIndex; } <<< $(jq -r '.body.cip179.surveyTxId, .body.cip179.surveyIndex' "${tmpAnchorContent}") + echo -e "\e[0m CIP-179: ${iconYes}\e[32m linked survey ${cip179SurveyTxId}#${cip179SurveyIndex}\e[0m"; + elif jq -e '.body.cip179 != null' "${tmpAnchorContent}" >/dev/null 2>&1; then + echo -e "\e[0m CIP-179: ${iconNo}\e[35m malformed v5 survey link or @context; survey ignored\e[0m"; + fi if [[ "${errorMsg}" != "" ]]; then echo -e "\e[0m Notice: ${iconNo} ${errorMsg}\e[0m"; fi authors=$(jq -r --arg iconYes "${iconYes}" --arg iconNo "${iconNo}" '.authors[] | "\\e[0m Signature: \(if .valid then $iconYes else $iconNo end) \(.name) (PubKey \(.publicKey))\\e[0m"' <<< ${signerJSON} 2> /dev/null) if [[ "${authors}" != "" ]]; then echo -e "${authors}\e[0m"; fi @@ -1167,12 +1184,31 @@ if [[ "${voteParam}" != "" ]]; then esac #Generate the vote file depending on the choice made above + cip179ResponseFile="" + if [[ "${cip179SurveyTxId}" != "" ]] && ask "\nThis action links a CIP-179 survey. Answer it with this governance vote?" N; then + if ! exists node || [[ ! -f "${scriptDir}/cip179-vote.mjs" ]]; then + echo -e "\n\e[35mCIP-179 survey voting needs cip179-vote.mjs, Node.js 20+, and cip-179@0.2.0.\nInstall the helper beside these scripts, then run 'npm install --no-save --no-package-lock cip-179@0.2.0' there.\e[0m\n"; exit 1 + fi + case ${voterType} in "DRep") cip179Role=0;; "Pool") cip179Role=1;; "Committee-Hot") cip179Role=2;; esac + cip179ResponseFile="${votingFile}.cip179.json" + CIP179_KOIOS_API="${koiosAPI}" CIP179_KOIOS_AUTH="${koiosAuthorizationHeader}" \ + node "${scriptDir}/cip179-vote.mjs" respond "${cip179SurveyTxId}" "${cip179SurveyIndex}" "${cip179Role}" "${voterHash}" "${actionExpiresAfterEpoch}" "${cip179ResponseFile}" <${termTTY} + cip179Result=$? + if [[ ${cip179Result} -eq 10 ]]; then cip179ResponseFile=""; + elif [[ ${cip179Result} -ne 0 ]]; then + if ask "Continue with the governance vote without a survey response?" N; then cip179ResponseFile=""; else exit 1; fi + fi + fi + voteJSON=$(${cardanocli} ${cliEra} governance vote create ${voteParam} --governance-action-tx-id "${actionUTXO}" --governance-action-index "${actionIdx}" ${vkeyParam} "${voterVkeyFile}" ${anchorPARAM} --out-file /dev/stdout 2> /dev/stdout) checkError "$?"; if [ $? -ne 0 ]; then echo -e "\e[35mERROR - ${voteJSON}\e[0m\n"; exit 1; fi #Inject the GovActionTitle into the voting file voteJSON=$(jq -r ". += { \"description\": \"${govActionTitle//[^[:alnum:][:space:]-_\/\!§$%&()?<>@|.,:;=*\']}\" }" <<< ${voteJSON} 2> /dev/null) checkError "$?"; if [ $? -ne 0 ]; then echo -e "\e[35mERROR - ${voteJSON}\e[0m\n"; exit 1; fi + if [[ "${cip179ResponseFile}" != "" ]]; then + voteJSON=$(jq --arg responseFile "$(basename "${cip179ResponseFile}")" '.cip179Response = $responseFile' <<< "${voteJSON}") + fi echo "${voteJSON}" > "${votingFile}"; checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi echo -e "\e[0mCreated the Vote-Certificate file: \e[32m${votingFile}\e[90m" diff --git a/cardano/mainnet/24b_regVote.sh b/cardano/mainnet/24b_regVote.sh index 05aa678..2c207f4 100755 --- a/cardano/mainnet/24b_regVote.sh +++ b/cardano/mainnet/24b_regVote.sh @@ -154,8 +154,9 @@ echo #Setting default variables -metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; +metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; metadataJsonFile=""; metadataCborFile=""; votefileParameter=""; actionIdCollector=""; voterHashCollector=""; voteCounter=0; +cip179ResponseFiles=() #Check all optional parameters about there types and set the corresponding variables #Starting with the 3th parameter (index=2) up to the last parameter @@ -175,11 +176,13 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) metadatum=$(jq -r "keys_unsorted[0]" "${metafile}" 2> /dev/null) if [[ $? -ne 0 ]]; then echo -e "\n\e[35mERROR - '${metafile}' is not a valid JSON file!\n\e[0m"; exit 1; fi #Check if it is null, a number, lower then zero, higher then 65535, otherwise exit with an error - if [ "${metadatum}" == null ] || [ -z "${metadatum##*[!0-9]*}" ] || [ "${metadatum}" -lt 0 ] || [ "${metadatum}" -gt 65535 ]; then + if [ "${metadatum}" == null ] || [ -z "${metadatum##*[!0-9]*}" ] || [ "${metadatum}" -lt 0 ] || [ "${metadatum}" -gt 65535 ]; then echo -e "\n\e[35mERROR - MetaDatum Value '${metadatum}' in '${metafile}' must be in the range of 0..65535!\n\e[0m"; exit 1; fi - metafileParameter+="--metadata-json-file ${metafile} "; metafileList+="'${metafile}' " + metadataJsonFile="${metafile}" + metafileParameter+="--metadata-json-file ${metafile} "; metafileList+="'${metafile}' " elif [[ -f "${metafile}" && "${metafileExt^^}" == "CBOR" ]]; then #its a cbor file + metadataCborFile="${metafile}" metafileParameter+="--metadata-cbor-file ${metafile} "; metafileList+="'${metafile}' " elif [[ -f "${metafile}" && "${metafileExt^^}" == "VOTE" ]]; then #its a vote file @@ -199,6 +202,12 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) #Additionally read the description from the voting file voteActionDescription=$(jq -r '.description // "-"' 2> /dev/null "${metafile}"); + cip179ResponseFile=$(jq -r '.cip179Response // empty' 2> /dev/null "${metafile}") + if [[ "${cip179ResponseFile}" != "" ]]; then + if [[ "${cip179ResponseFile}" != /* ]]; then cip179ResponseFile="$(dirname "${metafile}")/${cip179ResponseFile}"; fi + if [[ ! -f "${cip179ResponseFile}" ]]; then echo -e "\n\e[35mERROR - CIP-179 response file '${cip179ResponseFile}' referenced by '${metafile}' does not exist.\e[0m\n"; exit 1; fi + cip179ResponseFiles+=("${cip179ResponseFile}") + fi #Show the Description echo -e "\e[0m Description: \e[33m${voteActionDescription}\e[0m"; @@ -286,6 +295,16 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) done +if [[ ${#cip179ResponseFiles[@]} -gt 0 ]]; then + if [[ "${metadataJsonFile}" != "" ]]; then echo -e "\n\e[35mERROR - JSON metadata '${metadataJsonFile}' cannot be combined with CIP-179 response sidecars because they use different cardano-cli JSON schemas.\e[0m\n"; exit 1; fi + if [[ "${metadataCborFile}" != "" ]]; then echo -e "\n\e[35mERROR - CBOR metadata '${metadataCborFile}' cannot be safely checked for a label 17 collision with CIP-179 response sidecars.\e[0m\n"; exit 1; fi + if ! exists node || [[ ! -f "${scriptDir}/cip179-vote.mjs" ]]; then echo -e "\n\e[35mERROR - Node.js and '${scriptDir}/cip179-vote.mjs' are required to merge CIP-179 responses.\e[0m\n"; exit 1; fi + cip179MetadataFile="${tempDir}/cip179-responses.json" + node "${scriptDir}/cip179-vote.mjs" merge "${cip179MetadataFile}" "${cip179ResponseFiles[@]}" + checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi + metafileParameter="--json-metadata-detailed-schema --metadata-json-file ${cip179MetadataFile} "; metafileList+="'${cip179MetadataFile}' " +fi + #Check if there is only one vote included if also a hardware wallet is used (limitation by the hardware wallet firmware) if [[ ${voteCounter} -gt 1 ]] && [[ -f "${fromAddr}.hwsfile" || "${voterSigningFile}" == *".hwsfile" ]]; then echo -e "\n\e[91mPlease include only one vote-file in case a hardware-wallet is involved in the transaction.\nThis is a limitation of the hardware-wallet firmware!\n\e[0m"; exit 1; fi @@ -312,6 +331,11 @@ if [[ ! "${transactionMessage}" == "{}" ]]; then echo -e "\n\e[35mERROR - The given encryption mode '${encryption,,}' is not on the supported list of encryption methods. Only 'basic' from CIP-0083 is currently supported\n\n\e[0m"; exit 1; fi + if [[ ${#cip179ResponseFiles[@]} -gt 0 ]]; then + tmp=$(jq 'with_entries(.value |= {map: [to_entries[] | {k: {string: .key}, v: (if (.value | type) == "array" then {list: [.value[] | {string: .}]} else {string: .value} end)}]})' <<< "${tmp}") + checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi + fi + echo "${tmp}" > ${transactionMessageMetadataFile}; metafileParameter="${metafileParameter}--metadata-json-file ${transactionMessageMetadataFile} "; #add it to the list of metadata.jsons to attach else diff --git a/cardano/mainnet/24c_queryVote.sh b/cardano/mainnet/24c_queryVote.sh index af0a758..0559baf 100755 --- a/cardano/mainnet/24c_queryVote.sh +++ b/cardano/mainnet/24c_queryVote.sh @@ -87,7 +87,7 @@ for (( tmpCnt=0; tmpCnt<${paramCnt}; tmpCnt++ )) paramValue=${allParameters[$tmpCnt]} #Check if its a Governance Action-ID - if [[ "${paramValue,,}" =~ ^([[:xdigit:]]{64}+#[[:digit:]]{1,})$ ]]; then + if [[ "${paramValue,,}" =~ ^([[:xdigit:]]{64}#[[:digit:]]{1,})$ ]]; then if [[ "${govActionID}" != "" ]]; then echo -e "\n\e[91mERROR - Only one Action-ID is allowed as parameter!\e[0m\n"; exit 1; fi govActionID="${paramValue,,}" echo -e "\e[0mUsing Governance Action-ID:\e[32m ${govActionID}\e[0m\n" diff --git a/cardano/mainnet/25a_genAction.sh b/cardano/mainnet/25a_genAction.sh index c311d8f..5eda49e 100755 --- a/cardano/mainnet/25a_genAction.sh +++ b/cardano/mainnet/25a_genAction.sh @@ -103,6 +103,7 @@ echo #Setting default variables anchorURL=""; anchorHASH=""; #Setting defaults +cip179AnchorDeposit=""; cip179AnchorRewardAccount=""; committeeTermEpoch=0; paramCnt=$#; @@ -178,6 +179,17 @@ if ${onlineMode}; then else #anchor-url is a json + #For CIP-179-linked anchors, retain the declared on-chain values so + #they can be checked against the live parameters and selected return + #address before an action file is created. + if jq -e '.body.cip179 != null' "${tmpAnchorContent}" >/dev/null 2>&1; then + cip179AnchorDeposit=$(jq -er '.body.onChain.deposit | strings | select(test("^[1-9][0-9]*$"))' "${tmpAnchorContent}" 2>/dev/null) || cip179AnchorDeposit="" + cip179AnchorRewardAccount=$(jq -er '.body.onChain.reward_account | strings | select(length > 0)' "${tmpAnchorContent}" 2>/dev/null) || cip179AnchorRewardAccount="" + if [[ "${cip179AnchorDeposit}" == "" || "${cip179AnchorRewardAccount}" == "" ]]; then + echo -e "\n\e[91mERROR - The CIP-179-linked anchor does not contain a valid positive body.onChain.deposit and body.onChain.reward_account.\n\e[0m"; exit 1; + fi + fi + contentHASH=$(b2sum -l 256 "${tmpAnchorContent}" 2> /dev/null | cut -d' ' -f 1) checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi echo -e "\e[0mAnchor-URL(HASH):\e[32m ${anchorURL} \e[0m(\e[94m${contentHASH}\e[0m)" @@ -261,6 +273,18 @@ if [[ ${protocolVersionMajor} -lt 9 ]]; then if [[ ${actionDepositFee} -lt 0 ]]; then echo -e "\n\e[91mERROR - Could not query the current Action-Deposit fee amount!\n\e[0m"; exit 1; fi +# A linked survey anchor must describe the deposit and return account this +# script will use. Never silently correct stale, already-signed metadata. +if [[ "${cip179AnchorDeposit}" != "" ]]; then + if [[ "${cip179AnchorDeposit}" != "${actionDepositFee}" ]]; then + echo -e "\n\e[91mERROR - The CIP-179-linked anchor declares a governance action deposit of ${cip179AnchorDeposit} lovelace, but the current network requires ${actionDepositFee}. Regenerate and re-sign the anchor metadata; no action file was created.\n\e[0m"; exit 1; + fi + if [[ "${cip179AnchorRewardAccount}" != "${stakeAddr}" ]]; then + echo -e "\n\e[91mERROR - The CIP-179-linked anchor reward account does not match the selected deposit-return stake address. Regenerate and re-sign the anchor metadata; no action file was created.\n\e[0m"; exit 1; + fi + echo -e "\e[0mCIP-179 Anchor: \e[32m deposit and reward account match the action\e[0m" +fi + echo -e "\e[0mAction-Deposit Fee:\e[32m $(convertToADA ${actionDepositFee}) ADA / ${actionDepositFee} lovelaces\n\e[0m" if [[ ${committeeMaxTermLength} -lt 0 ]]; then @@ -810,5 +834,3 @@ echo -e "\"./25b_regAction.sh myWallet ${actionFile}\"\e[0m" echo echo -e "\e[0m" - - diff --git a/cardano/mainnet/cip179-vote.mjs b/cardano/mainnet/cip179-vote.mjs new file mode 100755 index 0000000..8d85a04 --- /dev/null +++ b/cardano/mainnet/cip179-vote.mjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline/promises"; + +const usage = () => { + console.error( + "Usage: cip179-vote.mjs respond | merge ...", + ); + process.exit(2); +}; + +const hex = (bytes) => Buffer.from(bytes).toString("hex"); +const fromHex = (value, bytes, label) => { + if (!new RegExp(`^[0-9a-fA-F]{${bytes * 2}}$`).test(value)) { + throw new Error(`${label} must be ${bytes * 2} hexadecimal characters`); + } + return Uint8Array.from(Buffer.from(value, "hex")); +}; + +function koiosToMetadatum(value, depth = 0) { + if (depth > 64) throw new Error("Koios metadata nesting exceeds 64 levels"); + if (value === null || typeof value === "boolean") { + throw new Error("Koios returned a value that Cardano metadata cannot represent"); + } + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) throw new Error("Unsafe Koios metadata integer"); + return BigInt(value); + } + if (typeof value === "string") { + return /^0x(?:[0-9a-fA-F]{2})*$/.test(value) + ? Uint8Array.from(Buffer.from(value.slice(2), "hex")) + : value; + } + if (Array.isArray(value)) return value.map((item) => koiosToMetadatum(item, depth + 1)); + return new Map( + Object.entries(value).map(([key, item]) => [ + /^-?\d+$/.test(key) ? BigInt(key) : key, + koiosToMetadatum(item, depth + 1), + ]), + ); +} + +function detailed(value) { + if (typeof value === "bigint") { + if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < BigInt(Number.MIN_SAFE_INTEGER)) { + throw new Error(`cardano-cli JSON cannot safely represent integer ${value}`); + } + return { int: Number(value) }; + } + if (typeof value === "string") return { string: value }; + if (value instanceof Uint8Array) return { bytes: hex(value) }; + if (Array.isArray(value)) return { list: value.map(detailed) }; + if (value instanceof Map) { + return { map: [...value].map(([key, item]) => ({ k: detailed(key), v: detailed(item) })) }; + } + throw new Error("Unsupported metadata value"); +} + +function metadataLabel(metadata, label) { + if (Array.isArray(metadata)) { + for (const item of metadata) { + if (item && typeof item === "object") { + if (Object.hasOwn(item, label)) return item[label]; + if (String(item.key ?? item.label) === label) return item.value ?? item.json; + } + } + return undefined; + } + return metadata?.[label]; +} + +async function loadPackage() { + if (Number(process.versions.node.split(".")[0]) < 20) { + throw new Error("The optional CIP-179 voter requires Node.js 20 or newer"); + } + try { + return await import("cip-179"); + } catch (error) { + throw new Error( + `The optional CIP-179 voter needs Node.js 20+ and cip-179@0.2.0. Run 'npm install --no-save --no-package-lock cip-179@0.2.0' in the scripts directory. (${error.message})`, + ); + } +} + +async function fetchSurvey(txId, index, cip179) { + const api = (process.env.CIP179_KOIOS_API || "https://api.koios.rest/api/v1").replace(/\/$/, ""); + const headers = { Accept: "application/json", "Content-Type": "application/json" }; + const auth = process.env.CIP179_KOIOS_AUTH || ""; + const separator = auth.indexOf(":"); + if (separator > 0) headers[auth.slice(0, separator).trim()] = auth.slice(separator + 1).trim(); + const response = await fetch(`${api}/tx_metadata?select=tx_hash,metadata`, { + method: "POST", + headers, + body: JSON.stringify({ _tx_hashes: [txId] }), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`Koios tx_metadata request failed (${response.status})`); + const rows = await response.json(); + const metadata = rows?.[0]?.json_metadata ?? rows?.[0]?.metadata; + const raw = metadataLabel(metadata, "17"); + if (raw === undefined) throw new Error(`Transaction ${txId} has no metadata label 17`); + const payload = cip179.decodePayload(koiosToMetadatum(raw)); + if (payload.type !== "definitions" || !payload.definitions[index]) { + throw new Error(`CIP-179 survey definition ${txId}#${index} was not found`); + } + const survey = payload.definitions[index]; + const problems = cip179.validateDefinition(survey); + if (problems.length) throw new Error(`Invalid survey: ${problems.join("; ")}`); + return survey; +} + +async function presentationFor(survey) { + if (!survey.contentAnchor) return null; + const uri = survey.contentAnchor.uri; + const url = uri.startsWith("ipfs://") ? `https://ipfs.io/ipfs/${uri.slice(7)}` : uri; + const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`Survey presentation request failed (${response.status})`); + const bytes = new Uint8Array(await response.arrayBuffer()); + let blake2b; + try { + ({ blake2b } = await import("@noble/hashes/blake2.js")); + } catch (error) { + throw new Error(`Unable to verify the survey presentation hash (${error.message})`); + } + if (hex(blake2b(bytes, { dkLen: 32 })) !== hex(survey.contentAnchor.hash)) { + throw new Error("Survey presentation hash does not match its content anchor"); + } + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new Error("Survey presentation is not valid JSON"); + } +} + +const optionCount = (options) => + options.type === "options" ? options.labels.length : options.count; + +function displayQuestion(question, index, presentation) { + const external = presentation?.questions?.[index] ?? {}; + const labels = question.options?.type === "options" ? question.options.labels : external.options; + if (question.options && (!Array.isArray(labels) || labels.length !== optionCount(question.options) || labels.some((label) => typeof label !== "string"))) { + throw new Error(`Question ${index + 1} is missing its externally anchored option labels`); + } + const prompt = question.prompt || external.prompt; + if (typeof prompt !== "string" || !prompt) throw new Error(`Question ${index + 1} is missing its externally anchored prompt`); + const ratingLabels = + question.type === "rating" && question.scale.type === "labels" + ? question.scale.labels + : external.ratingLabels; + if (ratingLabels !== undefined && (!Array.isArray(ratingLabels) || ratingLabels.some((label) => typeof label !== "string"))) { + throw new Error(`Question ${index + 1} has invalid externally anchored rating labels`); + } + if (question.type === "rating" && question.scale.type === "count" && ratingLabels && ratingLabels.length !== question.scale.count) { + throw new Error(`Question ${index + 1} has the wrong number of externally anchored rating labels`); + } + return { prompt, labels, ratingLabels }; +} + +const unique = (values) => new Set(values).size === values.length; +const parseList = (input) => { + if (!/^\d+(\s*,\s*\d+)*$/.test(input)) return null; + return input.split(",").map((value) => Number(value.trim()) - 1); +}; +const ratingValid = (rating, scale) => { + if (scale.type === "numeric") { + const { min, max, step } = scale.constraints; + return rating >= min && rating <= max && (!step || (rating - min) % step === 0n); + } + const count = scale.type === "count" ? scale.count : scale.labels.length; + return rating >= 0n && rating < BigInt(count); +}; + +async function askQuestion(rl, question, index, view) { + console.log(`\n${index + 1}. ${view.prompt}${question.required ? " (required)" : ""}`); + view.labels?.forEach((label, option) => console.log(` ${option + 1}) ${label}`)); + const abstain = question.required ? "" : " Press Enter to abstain."; + for (;;) { + let input; + switch (question.type) { + case "custom": + throw new Error("Custom CIP-179 question methods are not supported by this CLI helper"); + case "singleChoice": { + input = (await rl.question(`Choose one option.${abstain} `)).trim(); + if (!input && !question.required) return null; + const selected = Number(input) - 1; + if (Number.isInteger(selected) && selected >= 0 && selected < view.labels.length) { + return { type: "singleChoice", questionIndex: index, optionIndex: selected }; + } + break; + } + case "multiSelect": { + input = (await rl.question(`Choose ${question.minSelections}-${question.maxSelections} options, comma-separated (use 'none' for an explicit empty selection).${abstain} `)).trim(); + if (!input && !question.required) return null; + const selected = input.toLowerCase() === "none" ? [] : parseList(input); + if (selected && unique(selected) && selected.every((item) => item >= 0 && item < view.labels.length) && selected.length >= question.minSelections && selected.length <= question.maxSelections) { + return { type: "multiSelect", questionIndex: index, optionIndices: selected }; + } + break; + } + case "ranking": { + input = (await rl.question(`Rank ${question.minRanked}-${question.maxRanked} options from most to least preferred, comma-separated.${abstain} `)).trim(); + if (!input && !question.required) return null; + const ranking = parseList(input); + if (ranking && unique(ranking) && ranking.every((item) => item >= 0 && item < view.labels.length) && ranking.length >= question.minRanked && ranking.length <= question.maxRanked) { + return { type: "ranking", questionIndex: index, ranking }; + } + break; + } + case "numericRange": { + const { min, max, step } = question.constraints; + input = (await rl.question(`Enter an integer from ${min} to ${max}${step ? ` in steps of ${step}` : ""}.${abstain} `)).trim(); + if (!input && !question.required) return null; + if (/^-?\d+$/.test(input)) { + const value = BigInt(input); + if (value >= min && value <= max && (!step || (value - min) % step === 0n)) { + return { type: "numeric", questionIndex: index, value }; + } + } + break; + } + case "pointsAllocation": { + input = (await rl.question(`Allocate exactly ${question.budget} points as option=points pairs (example: 1=5,2=5).${abstain} `)).trim(); + if (!input && !question.required) return null; + const pairs = input.split(",").map((pair) => pair.trim().match(/^(\d+)\s*=\s*(\d+)$/)); + if (pairs.every(Boolean)) { + const allocations = pairs.map((match) => ({ optionIndex: Number(match[1]) - 1, points: Number(match[2]) })); + if (unique(allocations.map((item) => item.optionIndex)) && allocations.every((item) => item.optionIndex >= 0 && item.optionIndex < view.labels.length && Number.isSafeInteger(item.points)) && allocations.reduce((sum, item) => sum + BigInt(item.points), 0n) === BigInt(question.budget)) { + return { type: "pointsAllocation", questionIndex: index, allocations }; + } + } + break; + } + case "rating": { + const scale = question.scale; + if (scale.type === "numeric") console.log(` Rating scale: ${scale.constraints.min} to ${scale.constraints.max}${scale.constraints.step ? ` in steps of ${scale.constraints.step}` : ""}`); + else if (scale.type === "count" && !view.ratingLabels) console.log(` Rating scale: 1 to ${scale.count}`); + else (scale.type === "labels" ? scale.labels : view.ratingLabels)?.forEach((label, rating) => console.log(` Rating ${rating + 1}: ${label}`)); + input = (await rl.question(`Rate options as option=rating pairs.${question.requireAll ? " Every option must be rated." : ""}${abstain} `)).trim(); + if (!input && !question.required) return null; + const pairs = input.split(",").map((pair) => pair.trim().match(/^(\d+)\s*=\s*(-?\d+)$/)); + if (pairs.every(Boolean)) { + const ratings = pairs.map((match) => { + let rating = BigInt(match[2]); + if (scale.type !== "numeric") rating -= 1n; + return { optionIndex: Number(match[1]) - 1, rating }; + }); + if (unique(ratings.map((item) => item.optionIndex)) && ratings.every((item) => item.optionIndex >= 0 && item.optionIndex < view.labels.length && ratingValid(item.rating, scale)) && (!question.requireAll || ratings.length === view.labels.length)) { + return { type: "rating", questionIndex: index, ratings }; + } + } + break; + } + } + console.log("That answer does not satisfy this question's constraints. Please try again."); + } +} + +async function respond(args) { + if (args.length !== 6) usage(); + const [txIdRaw, indexRaw, roleRaw, credentialRaw, expiryRaw, output] = args; + const txId = txIdRaw.toLowerCase(); + const surveyTxId = fromHex(txId, 32, "Survey transaction id"); + const credential = fromHex(credentialRaw, 28, "Voter credential"); + const index = Number(indexRaw); + const role = Number(roleRaw); + const expiry = Number(expiryRaw); + if (!Number.isInteger(index) || index < 0 || index > 65535) throw new Error("Invalid survey index"); + if (![0, 1, 2].includes(role)) throw new Error("Only DRep, SPO, and CC voters are supported"); + if (!Number.isInteger(expiry) || expiry < 0) throw new Error("Invalid action expiry epoch"); + + const cip179 = await loadPackage(); + const survey = await fetchSurvey(txId, index, cip179); + if (survey.endEpoch !== expiry) throw new Error(`Survey ends in epoch ${survey.endEpoch}, but the action expires in epoch ${expiry}`); + if (!survey.eligibleRoles.includes(role)) throw new Error("This survey is not open to this voter role"); + if (survey.submissionMode.type !== "public") throw new Error("Sealed CIP-179 surveys are not supported by this CLI helper"); + const presentation = await presentationFor(survey); + const views = survey.questions.map((question, questionIndex) => displayQuestion(question, questionIndex, presentation)); + + console.log(`\nCIP-179 survey: ${survey.title || presentation?.title || "Untitled survey"}`); + if (survey.description || presentation?.description) console.log(survey.description || presentation.description); + if (!process.stdin.isTTY) throw new Error("Interactive survey voting requires a terminal"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answers = []; + for (let questionIndex = 0; questionIndex < survey.questions.length; questionIndex += 1) { + const answer = await askQuestion(rl, survey.questions[questionIndex], questionIndex, views[questionIndex]); + if (answer) answers.push(answer); + } + const confirmed = (await rl.question("\nCreate this CIP-179 survey response? (Y/n): ")).trim().toLowerCase(); + if (confirmed.startsWith("n")) { + console.log("Survey response skipped."); + process.exitCode = 10; + return; + } + const response = { + specVersion: cip179.SPEC_VERSION, + surveyRef: { txId: surveyTxId, index }, + role, + credential: { type: "key", keyHash: credential }, + answers: { type: "public", answers }, + }; + const problems = cip179.validateResponse(survey, response); + if (problems.length) throw new Error(`Invalid response: ${problems.join("; ")}`); + const payload = cip179.encodePayload({ type: "responses", responses: [response] }); + await writeFile(output, `${JSON.stringify({ 17: detailed(payload) }, null, 2)}\n`, { flag: "wx" }); + console.log(`CIP-179 response metadata created: ${output}`); + } finally { + rl.close(); + } +} + +async function merge(args) { + if (args.length < 2) usage(); + const [output, ...inputs] = args; + const responses = []; + for (const input of inputs) { + const document = JSON.parse(await readFile(input, "utf8")); + const payload = document?.["17"]?.list; + if (!Array.isArray(payload) || payload[0]?.int !== 1 || !Array.isArray(payload[1]?.list)) { + throw new Error(`${input} is not CIP-179 detailed-schema response metadata`); + } + if (payload[1].list.length === 0) throw new Error(`${input} contains no CIP-179 responses`); + responses.push(...payload[1].list); + } + await writeFile(output, `${JSON.stringify({ 17: { list: [{ int: 1 }, { list: responses }] } }, null, 2)}\n`); +} + +try { + const [command, ...args] = process.argv.slice(2); + if (command === "respond") await respond(args); + else if (command === "merge") await merge(args); + else usage(); +} catch (error) { + console.error(`CIP-179: ${error.message}`); + process.exit(1); +} diff --git a/cardano/mainnet/usage_governance.md b/cardano/mainnet/usage_governance.md index 63a0752..455d65e 100644 --- a/cardano/mainnet/usage_governance.md +++ b/cardano/mainnet/usage_governance.md @@ -819,6 +819,8 @@ Examples: As you can see the only needed parameters are the name of the DRep/CC/SPO file and the Governance-Action-ID. The Governance-Action-ID can be in CIP105 format like `0b19476e40bbbb5e1e8ce153523762e2b6859e7ecacbaf06eae0ee6a447e79b9#0` or in the new CIP129 formal like `gov_action1pvv5wmjqhwa4u85vu9f4ydmzu2mgt8n7et967ph2urhx53r70xusqnmm525`. +If the verified action metadata links a public CIP-179 v5 survey, the script offers to answer it before creating the Vote-File. This optional path needs Node.js 20 or newer and the reusable package installed beside the scripts with `npm install --no-save --no-package-lock cip-179@0.2.0`. The generated survey-response sidecar must stay beside its Vote-File; script `24b` automatically attaches it to the same transaction and merges sidecars when several votes are submitted together. + In this example i wanna describe how to vote as an SPO. If you're already using the SPO-Scripts you're familiar with the file-naming-scheme. To generate the Vote-File as an SPO you need the `.node.vkey` file. Lets do an example: diff --git a/cardano/testnet/00_common.sh b/cardano/testnet/00_common.sh index c2b7332..8ba59f3 100755 --- a/cardano/testnet/00_common.sh +++ b/cardano/testnet/00_common.sh @@ -1194,7 +1194,7 @@ convert_actionUTXO2Bech() { local govActionID="${1}" # local govActionUTXO=$(trimString "${1%%#*}"); govActionUTXO=${govActionUTXO,,} #takes the part before the # separator # local govActionIdx=$(trimString "${1#*#}"); #takes the part after the # separator - if [[ "${govActionID}" =~ ^([[:xdigit:]]{64}+#[[:digit:]]{1,})$ ]]; then + if [[ "${govActionID}" =~ ^([[:xdigit:]]{64}#[[:digit:]]{1,})$ ]]; then local govActionUTXO=${govActionID:0:64}; govActionUTXO=${govActionUTXO,,} #make sure its lower case local govActionIdx=$(( ${govActionID:65} + 0 )) #make sure to have single digits if provided like #00 #01 #02... local govActionIdxHex="00$(bc <<< "obase=16;ibase=10;${govActionIdx}")"; govActionIdxHex=${govActionIdxHex: -$(( (${#govActionIdxHex}-1)/2*2 ))} #make sure its with a leading zero and always in pairs like 03, 04af diff --git a/cardano/testnet/24a_genVote.sh b/cardano/testnet/24a_genVote.sh index b24fac3..72b586b 100755 --- a/cardano/testnet/24a_genVote.sh +++ b/cardano/testnet/24a_genVote.sh @@ -100,7 +100,7 @@ if [[ "${govActionID:0:11}" == "gov_action1" ]]; then #parameter is most likely if [ $? -ne 0 ]; then echo -e "\n\n\e[91mERROR - \"${2,,}\" is not a valid Bech32 ACTION-ID.\e[0m"; exit 1; fi govActionUTXO=${govActionID:0:64} govActionIdx=$(( ${govActionID:65} + 0 )) #make sure to have single digits if provided like #00 #01 #02... -elif [[ "${govActionID}" =~ ^([[:xdigit:]]{64}+#[[:digit:]]{1,})$ ]]; then +elif [[ "${govActionID}" =~ ^([[:xdigit:]]{64}#[[:digit:]]{1,})$ ]]; then govActionUTXO=${govActionID:0:64} govActionIdx=$(( ${govActionID:65} + 0 )) #make sure to have single digits if provided like #00 #01 #02... elif [[ "${govActionID}" == "all" ]]; then #do the voting on all current gov-actions @@ -559,6 +559,7 @@ do dRepAcceptIcon=""; poolAcceptIcon=""; committeeAcceptIcon=""; dRepPowerThreshold="N/A"; poolPowerThreshold="N/A"; #N/A -> not available govActionTitle=""; + cip179SurveyTxId=""; cip179SurveyIndex=""; echo echo -e "\e[36m--- Entry $((${tmpCnt}+1)) of ${actionStateEntryCnt} --- Action-ID ${actionUTXO}#${actionIdx}\e[0m" @@ -629,6 +630,22 @@ do errorMsg=$(jq -r .errorMsg <<< ${signerJSON} 2> /dev/null) echo -e "\e[0m Anchor-Data: ${iconYes}\e[32m JSONLD structure is ok\e[0m"; { read govActionTitle; read proofDepositReturnAddr; read proofWithdrawalAddr; } <<< $(jq -r '.body.title // "-", .body.onChain.depositReturnAddress // "-", if (.body.onChain.withdrawals[0]) then ([.body.onChain.withdrawals[].withdrawalAddress] | add) else "-" end' ${tmpAnchorContent} 2> /dev/null) + if jq -e ' + .body.cip179 as $link | + ($link.specVersion == 5 and $link.kind == "survey-link" and + ($link.surveyTxId | test("^[0-9a-fA-F]{64}$")) and + ($link.surveyIndex | type == "number" and . >= 0 and . <= 65535 and floor == .) and + ."@context".CIP179 == "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0179/README.md#" and + ."@context".body."@context".cip179."@id" == "CIP179:link" and + ."@context".body."@context".cip179."@context".specVersion == "CIP179:specVersion" and + ."@context".body."@context".cip179."@context".kind == "CIP179:kind" and + ."@context".body."@context".cip179."@context".surveyTxId == "CIP179:surveyTxId" and + ."@context".body."@context".cip179."@context".surveyIndex == "CIP179:surveyIndex")' "${tmpAnchorContent}" >/dev/null 2>&1; then + { read cip179SurveyTxId; read cip179SurveyIndex; } <<< $(jq -r '.body.cip179.surveyTxId, .body.cip179.surveyIndex' "${tmpAnchorContent}") + echo -e "\e[0m CIP-179: ${iconYes}\e[32m linked survey ${cip179SurveyTxId}#${cip179SurveyIndex}\e[0m"; + elif jq -e '.body.cip179 != null' "${tmpAnchorContent}" >/dev/null 2>&1; then + echo -e "\e[0m CIP-179: ${iconNo}\e[35m malformed v5 survey link or @context; survey ignored\e[0m"; + fi if [[ "${errorMsg}" != "" ]]; then echo -e "\e[0m Notice: ${iconNo} ${errorMsg}\e[0m"; fi authors=$(jq -r --arg iconYes "${iconYes}" --arg iconNo "${iconNo}" '.authors[] | "\\e[0m Signature: \(if .valid then $iconYes else $iconNo end) \(.name) (PubKey \(.publicKey))\\e[0m"' <<< ${signerJSON} 2> /dev/null) if [[ "${authors}" != "" ]]; then echo -e "${authors}\e[0m"; fi @@ -1167,12 +1184,31 @@ if [[ "${voteParam}" != "" ]]; then esac #Generate the vote file depending on the choice made above + cip179ResponseFile="" + if [[ "${cip179SurveyTxId}" != "" ]] && ask "\nThis action links a CIP-179 survey. Answer it with this governance vote?" N; then + if ! exists node || [[ ! -f "${scriptDir}/cip179-vote.mjs" ]]; then + echo -e "\n\e[35mCIP-179 survey voting needs cip179-vote.mjs, Node.js 20+, and cip-179@0.2.0.\nInstall the helper beside these scripts, then run 'npm install --no-save --no-package-lock cip-179@0.2.0' there.\e[0m\n"; exit 1 + fi + case ${voterType} in "DRep") cip179Role=0;; "Pool") cip179Role=1;; "Committee-Hot") cip179Role=2;; esac + cip179ResponseFile="${votingFile}.cip179.json" + CIP179_KOIOS_API="${koiosAPI}" CIP179_KOIOS_AUTH="${koiosAuthorizationHeader}" \ + node "${scriptDir}/cip179-vote.mjs" respond "${cip179SurveyTxId}" "${cip179SurveyIndex}" "${cip179Role}" "${voterHash}" "${actionExpiresAfterEpoch}" "${cip179ResponseFile}" <${termTTY} + cip179Result=$? + if [[ ${cip179Result} -eq 10 ]]; then cip179ResponseFile=""; + elif [[ ${cip179Result} -ne 0 ]]; then + if ask "Continue with the governance vote without a survey response?" N; then cip179ResponseFile=""; else exit 1; fi + fi + fi + voteJSON=$(${cardanocli} ${cliEra} governance vote create ${voteParam} --governance-action-tx-id "${actionUTXO}" --governance-action-index "${actionIdx}" ${vkeyParam} "${voterVkeyFile}" ${anchorPARAM} --out-file /dev/stdout 2> /dev/stdout) checkError "$?"; if [ $? -ne 0 ]; then echo -e "\e[35mERROR - ${voteJSON}\e[0m\n"; exit 1; fi #Inject the GovActionTitle into the voting file voteJSON=$(jq -r ". += { \"description\": \"${govActionTitle//[^[:alnum:][:space:]-_\/\!§$%&()?<>@|.,:;=*\']}\" }" <<< ${voteJSON} 2> /dev/null) checkError "$?"; if [ $? -ne 0 ]; then echo -e "\e[35mERROR - ${voteJSON}\e[0m\n"; exit 1; fi + if [[ "${cip179ResponseFile}" != "" ]]; then + voteJSON=$(jq --arg responseFile "$(basename "${cip179ResponseFile}")" '.cip179Response = $responseFile' <<< "${voteJSON}") + fi echo "${voteJSON}" > "${votingFile}"; checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi echo -e "\e[0mCreated the Vote-Certificate file: \e[32m${votingFile}\e[90m" diff --git a/cardano/testnet/24b_regVote.sh b/cardano/testnet/24b_regVote.sh index 05aa678..2c207f4 100755 --- a/cardano/testnet/24b_regVote.sh +++ b/cardano/testnet/24b_regVote.sh @@ -154,8 +154,9 @@ echo #Setting default variables -metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; +metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; metadataJsonFile=""; metadataCborFile=""; votefileParameter=""; actionIdCollector=""; voterHashCollector=""; voteCounter=0; +cip179ResponseFiles=() #Check all optional parameters about there types and set the corresponding variables #Starting with the 3th parameter (index=2) up to the last parameter @@ -175,11 +176,13 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) metadatum=$(jq -r "keys_unsorted[0]" "${metafile}" 2> /dev/null) if [[ $? -ne 0 ]]; then echo -e "\n\e[35mERROR - '${metafile}' is not a valid JSON file!\n\e[0m"; exit 1; fi #Check if it is null, a number, lower then zero, higher then 65535, otherwise exit with an error - if [ "${metadatum}" == null ] || [ -z "${metadatum##*[!0-9]*}" ] || [ "${metadatum}" -lt 0 ] || [ "${metadatum}" -gt 65535 ]; then + if [ "${metadatum}" == null ] || [ -z "${metadatum##*[!0-9]*}" ] || [ "${metadatum}" -lt 0 ] || [ "${metadatum}" -gt 65535 ]; then echo -e "\n\e[35mERROR - MetaDatum Value '${metadatum}' in '${metafile}' must be in the range of 0..65535!\n\e[0m"; exit 1; fi - metafileParameter+="--metadata-json-file ${metafile} "; metafileList+="'${metafile}' " + metadataJsonFile="${metafile}" + metafileParameter+="--metadata-json-file ${metafile} "; metafileList+="'${metafile}' " elif [[ -f "${metafile}" && "${metafileExt^^}" == "CBOR" ]]; then #its a cbor file + metadataCborFile="${metafile}" metafileParameter+="--metadata-cbor-file ${metafile} "; metafileList+="'${metafile}' " elif [[ -f "${metafile}" && "${metafileExt^^}" == "VOTE" ]]; then #its a vote file @@ -199,6 +202,12 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) #Additionally read the description from the voting file voteActionDescription=$(jq -r '.description // "-"' 2> /dev/null "${metafile}"); + cip179ResponseFile=$(jq -r '.cip179Response // empty' 2> /dev/null "${metafile}") + if [[ "${cip179ResponseFile}" != "" ]]; then + if [[ "${cip179ResponseFile}" != /* ]]; then cip179ResponseFile="$(dirname "${metafile}")/${cip179ResponseFile}"; fi + if [[ ! -f "${cip179ResponseFile}" ]]; then echo -e "\n\e[35mERROR - CIP-179 response file '${cip179ResponseFile}' referenced by '${metafile}' does not exist.\e[0m\n"; exit 1; fi + cip179ResponseFiles+=("${cip179ResponseFile}") + fi #Show the Description echo -e "\e[0m Description: \e[33m${voteActionDescription}\e[0m"; @@ -286,6 +295,16 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) done +if [[ ${#cip179ResponseFiles[@]} -gt 0 ]]; then + if [[ "${metadataJsonFile}" != "" ]]; then echo -e "\n\e[35mERROR - JSON metadata '${metadataJsonFile}' cannot be combined with CIP-179 response sidecars because they use different cardano-cli JSON schemas.\e[0m\n"; exit 1; fi + if [[ "${metadataCborFile}" != "" ]]; then echo -e "\n\e[35mERROR - CBOR metadata '${metadataCborFile}' cannot be safely checked for a label 17 collision with CIP-179 response sidecars.\e[0m\n"; exit 1; fi + if ! exists node || [[ ! -f "${scriptDir}/cip179-vote.mjs" ]]; then echo -e "\n\e[35mERROR - Node.js and '${scriptDir}/cip179-vote.mjs' are required to merge CIP-179 responses.\e[0m\n"; exit 1; fi + cip179MetadataFile="${tempDir}/cip179-responses.json" + node "${scriptDir}/cip179-vote.mjs" merge "${cip179MetadataFile}" "${cip179ResponseFiles[@]}" + checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi + metafileParameter="--json-metadata-detailed-schema --metadata-json-file ${cip179MetadataFile} "; metafileList+="'${cip179MetadataFile}' " +fi + #Check if there is only one vote included if also a hardware wallet is used (limitation by the hardware wallet firmware) if [[ ${voteCounter} -gt 1 ]] && [[ -f "${fromAddr}.hwsfile" || "${voterSigningFile}" == *".hwsfile" ]]; then echo -e "\n\e[91mPlease include only one vote-file in case a hardware-wallet is involved in the transaction.\nThis is a limitation of the hardware-wallet firmware!\n\e[0m"; exit 1; fi @@ -312,6 +331,11 @@ if [[ ! "${transactionMessage}" == "{}" ]]; then echo -e "\n\e[35mERROR - The given encryption mode '${encryption,,}' is not on the supported list of encryption methods. Only 'basic' from CIP-0083 is currently supported\n\n\e[0m"; exit 1; fi + if [[ ${#cip179ResponseFiles[@]} -gt 0 ]]; then + tmp=$(jq 'with_entries(.value |= {map: [to_entries[] | {k: {string: .key}, v: (if (.value | type) == "array" then {list: [.value[] | {string: .}]} else {string: .value} end)}]})' <<< "${tmp}") + checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi + fi + echo "${tmp}" > ${transactionMessageMetadataFile}; metafileParameter="${metafileParameter}--metadata-json-file ${transactionMessageMetadataFile} "; #add it to the list of metadata.jsons to attach else diff --git a/cardano/testnet/24c_queryVote.sh b/cardano/testnet/24c_queryVote.sh index af0a758..0559baf 100755 --- a/cardano/testnet/24c_queryVote.sh +++ b/cardano/testnet/24c_queryVote.sh @@ -87,7 +87,7 @@ for (( tmpCnt=0; tmpCnt<${paramCnt}; tmpCnt++ )) paramValue=${allParameters[$tmpCnt]} #Check if its a Governance Action-ID - if [[ "${paramValue,,}" =~ ^([[:xdigit:]]{64}+#[[:digit:]]{1,})$ ]]; then + if [[ "${paramValue,,}" =~ ^([[:xdigit:]]{64}#[[:digit:]]{1,})$ ]]; then if [[ "${govActionID}" != "" ]]; then echo -e "\n\e[91mERROR - Only one Action-ID is allowed as parameter!\e[0m\n"; exit 1; fi govActionID="${paramValue,,}" echo -e "\e[0mUsing Governance Action-ID:\e[32m ${govActionID}\e[0m\n" diff --git a/cardano/testnet/25a_genAction.sh b/cardano/testnet/25a_genAction.sh index c311d8f..5eda49e 100755 --- a/cardano/testnet/25a_genAction.sh +++ b/cardano/testnet/25a_genAction.sh @@ -103,6 +103,7 @@ echo #Setting default variables anchorURL=""; anchorHASH=""; #Setting defaults +cip179AnchorDeposit=""; cip179AnchorRewardAccount=""; committeeTermEpoch=0; paramCnt=$#; @@ -178,6 +179,17 @@ if ${onlineMode}; then else #anchor-url is a json + #For CIP-179-linked anchors, retain the declared on-chain values so + #they can be checked against the live parameters and selected return + #address before an action file is created. + if jq -e '.body.cip179 != null' "${tmpAnchorContent}" >/dev/null 2>&1; then + cip179AnchorDeposit=$(jq -er '.body.onChain.deposit | strings | select(test("^[1-9][0-9]*$"))' "${tmpAnchorContent}" 2>/dev/null) || cip179AnchorDeposit="" + cip179AnchorRewardAccount=$(jq -er '.body.onChain.reward_account | strings | select(length > 0)' "${tmpAnchorContent}" 2>/dev/null) || cip179AnchorRewardAccount="" + if [[ "${cip179AnchorDeposit}" == "" || "${cip179AnchorRewardAccount}" == "" ]]; then + echo -e "\n\e[91mERROR - The CIP-179-linked anchor does not contain a valid positive body.onChain.deposit and body.onChain.reward_account.\n\e[0m"; exit 1; + fi + fi + contentHASH=$(b2sum -l 256 "${tmpAnchorContent}" 2> /dev/null | cut -d' ' -f 1) checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi echo -e "\e[0mAnchor-URL(HASH):\e[32m ${anchorURL} \e[0m(\e[94m${contentHASH}\e[0m)" @@ -261,6 +273,18 @@ if [[ ${protocolVersionMajor} -lt 9 ]]; then if [[ ${actionDepositFee} -lt 0 ]]; then echo -e "\n\e[91mERROR - Could not query the current Action-Deposit fee amount!\n\e[0m"; exit 1; fi +# A linked survey anchor must describe the deposit and return account this +# script will use. Never silently correct stale, already-signed metadata. +if [[ "${cip179AnchorDeposit}" != "" ]]; then + if [[ "${cip179AnchorDeposit}" != "${actionDepositFee}" ]]; then + echo -e "\n\e[91mERROR - The CIP-179-linked anchor declares a governance action deposit of ${cip179AnchorDeposit} lovelace, but the current network requires ${actionDepositFee}. Regenerate and re-sign the anchor metadata; no action file was created.\n\e[0m"; exit 1; + fi + if [[ "${cip179AnchorRewardAccount}" != "${stakeAddr}" ]]; then + echo -e "\n\e[91mERROR - The CIP-179-linked anchor reward account does not match the selected deposit-return stake address. Regenerate and re-sign the anchor metadata; no action file was created.\n\e[0m"; exit 1; + fi + echo -e "\e[0mCIP-179 Anchor: \e[32m deposit and reward account match the action\e[0m" +fi + echo -e "\e[0mAction-Deposit Fee:\e[32m $(convertToADA ${actionDepositFee}) ADA / ${actionDepositFee} lovelaces\n\e[0m" if [[ ${committeeMaxTermLength} -lt 0 ]]; then @@ -810,5 +834,3 @@ echo -e "\"./25b_regAction.sh myWallet ${actionFile}\"\e[0m" echo echo -e "\e[0m" - - diff --git a/cardano/testnet/cip179-vote.mjs b/cardano/testnet/cip179-vote.mjs new file mode 100755 index 0000000..8d85a04 --- /dev/null +++ b/cardano/testnet/cip179-vote.mjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline/promises"; + +const usage = () => { + console.error( + "Usage: cip179-vote.mjs respond | merge ...", + ); + process.exit(2); +}; + +const hex = (bytes) => Buffer.from(bytes).toString("hex"); +const fromHex = (value, bytes, label) => { + if (!new RegExp(`^[0-9a-fA-F]{${bytes * 2}}$`).test(value)) { + throw new Error(`${label} must be ${bytes * 2} hexadecimal characters`); + } + return Uint8Array.from(Buffer.from(value, "hex")); +}; + +function koiosToMetadatum(value, depth = 0) { + if (depth > 64) throw new Error("Koios metadata nesting exceeds 64 levels"); + if (value === null || typeof value === "boolean") { + throw new Error("Koios returned a value that Cardano metadata cannot represent"); + } + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) throw new Error("Unsafe Koios metadata integer"); + return BigInt(value); + } + if (typeof value === "string") { + return /^0x(?:[0-9a-fA-F]{2})*$/.test(value) + ? Uint8Array.from(Buffer.from(value.slice(2), "hex")) + : value; + } + if (Array.isArray(value)) return value.map((item) => koiosToMetadatum(item, depth + 1)); + return new Map( + Object.entries(value).map(([key, item]) => [ + /^-?\d+$/.test(key) ? BigInt(key) : key, + koiosToMetadatum(item, depth + 1), + ]), + ); +} + +function detailed(value) { + if (typeof value === "bigint") { + if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < BigInt(Number.MIN_SAFE_INTEGER)) { + throw new Error(`cardano-cli JSON cannot safely represent integer ${value}`); + } + return { int: Number(value) }; + } + if (typeof value === "string") return { string: value }; + if (value instanceof Uint8Array) return { bytes: hex(value) }; + if (Array.isArray(value)) return { list: value.map(detailed) }; + if (value instanceof Map) { + return { map: [...value].map(([key, item]) => ({ k: detailed(key), v: detailed(item) })) }; + } + throw new Error("Unsupported metadata value"); +} + +function metadataLabel(metadata, label) { + if (Array.isArray(metadata)) { + for (const item of metadata) { + if (item && typeof item === "object") { + if (Object.hasOwn(item, label)) return item[label]; + if (String(item.key ?? item.label) === label) return item.value ?? item.json; + } + } + return undefined; + } + return metadata?.[label]; +} + +async function loadPackage() { + if (Number(process.versions.node.split(".")[0]) < 20) { + throw new Error("The optional CIP-179 voter requires Node.js 20 or newer"); + } + try { + return await import("cip-179"); + } catch (error) { + throw new Error( + `The optional CIP-179 voter needs Node.js 20+ and cip-179@0.2.0. Run 'npm install --no-save --no-package-lock cip-179@0.2.0' in the scripts directory. (${error.message})`, + ); + } +} + +async function fetchSurvey(txId, index, cip179) { + const api = (process.env.CIP179_KOIOS_API || "https://api.koios.rest/api/v1").replace(/\/$/, ""); + const headers = { Accept: "application/json", "Content-Type": "application/json" }; + const auth = process.env.CIP179_KOIOS_AUTH || ""; + const separator = auth.indexOf(":"); + if (separator > 0) headers[auth.slice(0, separator).trim()] = auth.slice(separator + 1).trim(); + const response = await fetch(`${api}/tx_metadata?select=tx_hash,metadata`, { + method: "POST", + headers, + body: JSON.stringify({ _tx_hashes: [txId] }), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`Koios tx_metadata request failed (${response.status})`); + const rows = await response.json(); + const metadata = rows?.[0]?.json_metadata ?? rows?.[0]?.metadata; + const raw = metadataLabel(metadata, "17"); + if (raw === undefined) throw new Error(`Transaction ${txId} has no metadata label 17`); + const payload = cip179.decodePayload(koiosToMetadatum(raw)); + if (payload.type !== "definitions" || !payload.definitions[index]) { + throw new Error(`CIP-179 survey definition ${txId}#${index} was not found`); + } + const survey = payload.definitions[index]; + const problems = cip179.validateDefinition(survey); + if (problems.length) throw new Error(`Invalid survey: ${problems.join("; ")}`); + return survey; +} + +async function presentationFor(survey) { + if (!survey.contentAnchor) return null; + const uri = survey.contentAnchor.uri; + const url = uri.startsWith("ipfs://") ? `https://ipfs.io/ipfs/${uri.slice(7)}` : uri; + const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`Survey presentation request failed (${response.status})`); + const bytes = new Uint8Array(await response.arrayBuffer()); + let blake2b; + try { + ({ blake2b } = await import("@noble/hashes/blake2.js")); + } catch (error) { + throw new Error(`Unable to verify the survey presentation hash (${error.message})`); + } + if (hex(blake2b(bytes, { dkLen: 32 })) !== hex(survey.contentAnchor.hash)) { + throw new Error("Survey presentation hash does not match its content anchor"); + } + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new Error("Survey presentation is not valid JSON"); + } +} + +const optionCount = (options) => + options.type === "options" ? options.labels.length : options.count; + +function displayQuestion(question, index, presentation) { + const external = presentation?.questions?.[index] ?? {}; + const labels = question.options?.type === "options" ? question.options.labels : external.options; + if (question.options && (!Array.isArray(labels) || labels.length !== optionCount(question.options) || labels.some((label) => typeof label !== "string"))) { + throw new Error(`Question ${index + 1} is missing its externally anchored option labels`); + } + const prompt = question.prompt || external.prompt; + if (typeof prompt !== "string" || !prompt) throw new Error(`Question ${index + 1} is missing its externally anchored prompt`); + const ratingLabels = + question.type === "rating" && question.scale.type === "labels" + ? question.scale.labels + : external.ratingLabels; + if (ratingLabels !== undefined && (!Array.isArray(ratingLabels) || ratingLabels.some((label) => typeof label !== "string"))) { + throw new Error(`Question ${index + 1} has invalid externally anchored rating labels`); + } + if (question.type === "rating" && question.scale.type === "count" && ratingLabels && ratingLabels.length !== question.scale.count) { + throw new Error(`Question ${index + 1} has the wrong number of externally anchored rating labels`); + } + return { prompt, labels, ratingLabels }; +} + +const unique = (values) => new Set(values).size === values.length; +const parseList = (input) => { + if (!/^\d+(\s*,\s*\d+)*$/.test(input)) return null; + return input.split(",").map((value) => Number(value.trim()) - 1); +}; +const ratingValid = (rating, scale) => { + if (scale.type === "numeric") { + const { min, max, step } = scale.constraints; + return rating >= min && rating <= max && (!step || (rating - min) % step === 0n); + } + const count = scale.type === "count" ? scale.count : scale.labels.length; + return rating >= 0n && rating < BigInt(count); +}; + +async function askQuestion(rl, question, index, view) { + console.log(`\n${index + 1}. ${view.prompt}${question.required ? " (required)" : ""}`); + view.labels?.forEach((label, option) => console.log(` ${option + 1}) ${label}`)); + const abstain = question.required ? "" : " Press Enter to abstain."; + for (;;) { + let input; + switch (question.type) { + case "custom": + throw new Error("Custom CIP-179 question methods are not supported by this CLI helper"); + case "singleChoice": { + input = (await rl.question(`Choose one option.${abstain} `)).trim(); + if (!input && !question.required) return null; + const selected = Number(input) - 1; + if (Number.isInteger(selected) && selected >= 0 && selected < view.labels.length) { + return { type: "singleChoice", questionIndex: index, optionIndex: selected }; + } + break; + } + case "multiSelect": { + input = (await rl.question(`Choose ${question.minSelections}-${question.maxSelections} options, comma-separated (use 'none' for an explicit empty selection).${abstain} `)).trim(); + if (!input && !question.required) return null; + const selected = input.toLowerCase() === "none" ? [] : parseList(input); + if (selected && unique(selected) && selected.every((item) => item >= 0 && item < view.labels.length) && selected.length >= question.minSelections && selected.length <= question.maxSelections) { + return { type: "multiSelect", questionIndex: index, optionIndices: selected }; + } + break; + } + case "ranking": { + input = (await rl.question(`Rank ${question.minRanked}-${question.maxRanked} options from most to least preferred, comma-separated.${abstain} `)).trim(); + if (!input && !question.required) return null; + const ranking = parseList(input); + if (ranking && unique(ranking) && ranking.every((item) => item >= 0 && item < view.labels.length) && ranking.length >= question.minRanked && ranking.length <= question.maxRanked) { + return { type: "ranking", questionIndex: index, ranking }; + } + break; + } + case "numericRange": { + const { min, max, step } = question.constraints; + input = (await rl.question(`Enter an integer from ${min} to ${max}${step ? ` in steps of ${step}` : ""}.${abstain} `)).trim(); + if (!input && !question.required) return null; + if (/^-?\d+$/.test(input)) { + const value = BigInt(input); + if (value >= min && value <= max && (!step || (value - min) % step === 0n)) { + return { type: "numeric", questionIndex: index, value }; + } + } + break; + } + case "pointsAllocation": { + input = (await rl.question(`Allocate exactly ${question.budget} points as option=points pairs (example: 1=5,2=5).${abstain} `)).trim(); + if (!input && !question.required) return null; + const pairs = input.split(",").map((pair) => pair.trim().match(/^(\d+)\s*=\s*(\d+)$/)); + if (pairs.every(Boolean)) { + const allocations = pairs.map((match) => ({ optionIndex: Number(match[1]) - 1, points: Number(match[2]) })); + if (unique(allocations.map((item) => item.optionIndex)) && allocations.every((item) => item.optionIndex >= 0 && item.optionIndex < view.labels.length && Number.isSafeInteger(item.points)) && allocations.reduce((sum, item) => sum + BigInt(item.points), 0n) === BigInt(question.budget)) { + return { type: "pointsAllocation", questionIndex: index, allocations }; + } + } + break; + } + case "rating": { + const scale = question.scale; + if (scale.type === "numeric") console.log(` Rating scale: ${scale.constraints.min} to ${scale.constraints.max}${scale.constraints.step ? ` in steps of ${scale.constraints.step}` : ""}`); + else if (scale.type === "count" && !view.ratingLabels) console.log(` Rating scale: 1 to ${scale.count}`); + else (scale.type === "labels" ? scale.labels : view.ratingLabels)?.forEach((label, rating) => console.log(` Rating ${rating + 1}: ${label}`)); + input = (await rl.question(`Rate options as option=rating pairs.${question.requireAll ? " Every option must be rated." : ""}${abstain} `)).trim(); + if (!input && !question.required) return null; + const pairs = input.split(",").map((pair) => pair.trim().match(/^(\d+)\s*=\s*(-?\d+)$/)); + if (pairs.every(Boolean)) { + const ratings = pairs.map((match) => { + let rating = BigInt(match[2]); + if (scale.type !== "numeric") rating -= 1n; + return { optionIndex: Number(match[1]) - 1, rating }; + }); + if (unique(ratings.map((item) => item.optionIndex)) && ratings.every((item) => item.optionIndex >= 0 && item.optionIndex < view.labels.length && ratingValid(item.rating, scale)) && (!question.requireAll || ratings.length === view.labels.length)) { + return { type: "rating", questionIndex: index, ratings }; + } + } + break; + } + } + console.log("That answer does not satisfy this question's constraints. Please try again."); + } +} + +async function respond(args) { + if (args.length !== 6) usage(); + const [txIdRaw, indexRaw, roleRaw, credentialRaw, expiryRaw, output] = args; + const txId = txIdRaw.toLowerCase(); + const surveyTxId = fromHex(txId, 32, "Survey transaction id"); + const credential = fromHex(credentialRaw, 28, "Voter credential"); + const index = Number(indexRaw); + const role = Number(roleRaw); + const expiry = Number(expiryRaw); + if (!Number.isInteger(index) || index < 0 || index > 65535) throw new Error("Invalid survey index"); + if (![0, 1, 2].includes(role)) throw new Error("Only DRep, SPO, and CC voters are supported"); + if (!Number.isInteger(expiry) || expiry < 0) throw new Error("Invalid action expiry epoch"); + + const cip179 = await loadPackage(); + const survey = await fetchSurvey(txId, index, cip179); + if (survey.endEpoch !== expiry) throw new Error(`Survey ends in epoch ${survey.endEpoch}, but the action expires in epoch ${expiry}`); + if (!survey.eligibleRoles.includes(role)) throw new Error("This survey is not open to this voter role"); + if (survey.submissionMode.type !== "public") throw new Error("Sealed CIP-179 surveys are not supported by this CLI helper"); + const presentation = await presentationFor(survey); + const views = survey.questions.map((question, questionIndex) => displayQuestion(question, questionIndex, presentation)); + + console.log(`\nCIP-179 survey: ${survey.title || presentation?.title || "Untitled survey"}`); + if (survey.description || presentation?.description) console.log(survey.description || presentation.description); + if (!process.stdin.isTTY) throw new Error("Interactive survey voting requires a terminal"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answers = []; + for (let questionIndex = 0; questionIndex < survey.questions.length; questionIndex += 1) { + const answer = await askQuestion(rl, survey.questions[questionIndex], questionIndex, views[questionIndex]); + if (answer) answers.push(answer); + } + const confirmed = (await rl.question("\nCreate this CIP-179 survey response? (Y/n): ")).trim().toLowerCase(); + if (confirmed.startsWith("n")) { + console.log("Survey response skipped."); + process.exitCode = 10; + return; + } + const response = { + specVersion: cip179.SPEC_VERSION, + surveyRef: { txId: surveyTxId, index }, + role, + credential: { type: "key", keyHash: credential }, + answers: { type: "public", answers }, + }; + const problems = cip179.validateResponse(survey, response); + if (problems.length) throw new Error(`Invalid response: ${problems.join("; ")}`); + const payload = cip179.encodePayload({ type: "responses", responses: [response] }); + await writeFile(output, `${JSON.stringify({ 17: detailed(payload) }, null, 2)}\n`, { flag: "wx" }); + console.log(`CIP-179 response metadata created: ${output}`); + } finally { + rl.close(); + } +} + +async function merge(args) { + if (args.length < 2) usage(); + const [output, ...inputs] = args; + const responses = []; + for (const input of inputs) { + const document = JSON.parse(await readFile(input, "utf8")); + const payload = document?.["17"]?.list; + if (!Array.isArray(payload) || payload[0]?.int !== 1 || !Array.isArray(payload[1]?.list)) { + throw new Error(`${input} is not CIP-179 detailed-schema response metadata`); + } + if (payload[1].list.length === 0) throw new Error(`${input} contains no CIP-179 responses`); + responses.push(...payload[1].list); + } + await writeFile(output, `${JSON.stringify({ 17: { list: [{ int: 1 }, { list: responses }] } }, null, 2)}\n`); +} + +try { + const [command, ...args] = process.argv.slice(2); + if (command === "respond") await respond(args); + else if (command === "merge") await merge(args); + else usage(); +} catch (error) { + console.error(`CIP-179: ${error.message}`); + process.exit(1); +} diff --git a/cardano/testnet/usage_governance.md b/cardano/testnet/usage_governance.md index 63a0752..455d65e 100644 --- a/cardano/testnet/usage_governance.md +++ b/cardano/testnet/usage_governance.md @@ -819,6 +819,8 @@ Examples: As you can see the only needed parameters are the name of the DRep/CC/SPO file and the Governance-Action-ID. The Governance-Action-ID can be in CIP105 format like `0b19476e40bbbb5e1e8ce153523762e2b6859e7ecacbaf06eae0ee6a447e79b9#0` or in the new CIP129 formal like `gov_action1pvv5wmjqhwa4u85vu9f4ydmzu2mgt8n7et967ph2urhx53r70xusqnmm525`. +If the verified action metadata links a public CIP-179 v5 survey, the script offers to answer it before creating the Vote-File. This optional path needs Node.js 20 or newer and the reusable package installed beside the scripts with `npm install --no-save --no-package-lock cip-179@0.2.0`. The generated survey-response sidecar must stay beside its Vote-File; script `24b` automatically attaches it to the same transaction and merges sidecars when several votes are submitted together. + In this example i wanna describe how to vote as an SPO. If you're already using the SPO-Scripts you're familiar with the file-naming-scheme. To generate the Vote-File as an SPO you need the `.node.vkey` file. Lets do an example: