From b06308b819f6981cfc66e4b9d5f1662e11ab0f9f Mon Sep 17 00:00:00 2001 From: Cerkoryn <30681834+Cerkoryn@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:42:14 -0400 Subject: [PATCH 1/3] Add CIP-179 governance survey voting --- .gitignore | 1 + cardano/mainnet/00_common.sh | 2 +- cardano/mainnet/24a_genVote.sh | 38 +++- cardano/mainnet/24b_regVote.sh | 23 +- cardano/mainnet/24c_queryVote.sh | 2 +- cardano/mainnet/25a_genAction.sh | 26 ++- cardano/mainnet/cip179-vote.mjs | 337 ++++++++++++++++++++++++++++ cardano/mainnet/usage_governance.md | 2 + cardano/testnet/00_common.sh | 2 +- cardano/testnet/24a_genVote.sh | 38 +++- cardano/testnet/24b_regVote.sh | 23 +- cardano/testnet/24c_queryVote.sh | 2 +- cardano/testnet/25a_genAction.sh | 26 ++- cardano/testnet/cip179-vote.mjs | 337 ++++++++++++++++++++++++++++ cardano/testnet/usage_governance.md | 2 + 15 files changed, 847 insertions(+), 14 deletions(-) create mode 100755 cardano/mainnet/cip179-vote.mjs create mode 100755 cardano/testnet/cip179-vote.mjs diff --git a/.gitignore b/.gitignore index 090a1f02..617aa170 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 c2b73329..8ba59f34 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 b24fac3b..72b586bd 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 05aa6783..79aa38db 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"; metadataLabel17File=""; 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 + if jq -e 'has("17")' "${metafile}" >/dev/null 2>&1; then metadataLabel17File="${metafile}"; fi 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 [[ "${metadataLabel17File}" != "" ]]; then echo -e "\n\e[35mERROR - '${metadataLabel17File}' already supplies metadata label 17; it cannot be combined with CIP-179 response sidecars.\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+="--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 diff --git a/cardano/mainnet/24c_queryVote.sh b/cardano/mainnet/24c_queryVote.sh index af0a7587..0559bafa 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 c311d8f2..5eda49e0 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 00000000..8d85a048 --- /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 63a07523..455d65ee 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 c2b73329..8ba59f34 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 b24fac3b..72b586bd 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 05aa6783..79aa38db 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"; metadataLabel17File=""; 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 + if jq -e 'has("17")' "${metafile}" >/dev/null 2>&1; then metadataLabel17File="${metafile}"; fi 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 [[ "${metadataLabel17File}" != "" ]]; then echo -e "\n\e[35mERROR - '${metadataLabel17File}' already supplies metadata label 17; it cannot be combined with CIP-179 response sidecars.\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+="--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 diff --git a/cardano/testnet/24c_queryVote.sh b/cardano/testnet/24c_queryVote.sh index af0a7587..0559bafa 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 c311d8f2..5eda49e0 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 00000000..8d85a048 --- /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 63a07523..455d65ee 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: From 6ae706e9820fea4602c1d607f4b97a910a040ad6 Mon Sep 17 00:00:00 2001 From: Cerkoryn <30681834+Cerkoryn@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:28:50 -0400 Subject: [PATCH 2/3] Fix CIP-179 metadata encoding --- cardano/mainnet/24b_regVote.sh | 15 ++++++++++----- cardano/testnet/24b_regVote.sh | 15 ++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/cardano/mainnet/24b_regVote.sh b/cardano/mainnet/24b_regVote.sh index 79aa38db..2c207f4d 100755 --- a/cardano/mainnet/24b_regVote.sh +++ b/cardano/mainnet/24b_regVote.sh @@ -154,7 +154,7 @@ echo #Setting default variables -metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; metadataLabel17File=""; metadataCborFile=""; +metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; metadataJsonFile=""; metadataCborFile=""; votefileParameter=""; actionIdCollector=""; voterHashCollector=""; voteCounter=0; cip179ResponseFiles=() @@ -178,8 +178,8 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) #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 echo -e "\n\e[35mERROR - MetaDatum Value '${metadatum}' in '${metafile}' must be in the range of 0..65535!\n\e[0m"; exit 1; fi - if jq -e 'has("17")' "${metafile}" >/dev/null 2>&1; then metadataLabel17File="${metafile}"; 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}" @@ -296,13 +296,13 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) done if [[ ${#cip179ResponseFiles[@]} -gt 0 ]]; then - if [[ "${metadataLabel17File}" != "" ]]; then echo -e "\n\e[35mERROR - '${metadataLabel17File}' already supplies metadata label 17; it cannot be combined with CIP-179 response sidecars.\e[0m\n"; exit 1; fi + 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+="--metadata-json-file ${cip179MetadataFile} "; metafileList+="'${cip179MetadataFile}' " + 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) @@ -331,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/24b_regVote.sh b/cardano/testnet/24b_regVote.sh index 79aa38db..2c207f4d 100755 --- a/cardano/testnet/24b_regVote.sh +++ b/cardano/testnet/24b_regVote.sh @@ -154,7 +154,7 @@ echo #Setting default variables -metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; metadataLabel17File=""; metadataCborFile=""; +metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; metadataJsonFile=""; metadataCborFile=""; votefileParameter=""; actionIdCollector=""; voterHashCollector=""; voteCounter=0; cip179ResponseFiles=() @@ -178,8 +178,8 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) #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 echo -e "\n\e[35mERROR - MetaDatum Value '${metadatum}' in '${metafile}' must be in the range of 0..65535!\n\e[0m"; exit 1; fi - if jq -e 'has("17")' "${metafile}" >/dev/null 2>&1; then metadataLabel17File="${metafile}"; 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}" @@ -296,13 +296,13 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) done if [[ ${#cip179ResponseFiles[@]} -gt 0 ]]; then - if [[ "${metadataLabel17File}" != "" ]]; then echo -e "\n\e[35mERROR - '${metadataLabel17File}' already supplies metadata label 17; it cannot be combined with CIP-179 response sidecars.\e[0m\n"; exit 1; fi + 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+="--metadata-json-file ${cip179MetadataFile} "; metafileList+="'${cip179MetadataFile}' " + 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) @@ -331,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 From 5e45d63b78a71d0f5c81d9c57bfdebdb662c2f10 Mon Sep 17 00:00:00 2001 From: Cerkoryn <30681834+Cerkoryn@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:51:09 -0400 Subject: [PATCH 3/3] fix(cip179): validate native survey data and bind vote sidecars --- .gitignore | 2 + CIP179-VALIDATION.md | 32 ++ cardano/mainnet/24a_genVote.sh | 4 +- cardano/mainnet/24b_regVote.sh | 10 +- cardano/mainnet/25a_genAction.sh | 2 +- cardano/mainnet/cip179-vote.mjs | 648 ++++++++++++++++++++++------ cardano/mainnet/usage_governance.md | 10 +- cardano/testnet/24a_genVote.sh | 4 +- cardano/testnet/24b_regVote.sh | 10 +- cardano/testnet/25a_genAction.sh | 2 +- cardano/testnet/cip179-vote.mjs | 648 ++++++++++++++++++++++------ cardano/testnet/usage_governance.md | 10 +- package-lock.json | 61 +++ package.json | 15 + test/cip179.test.mjs | 114 +++++ test/cip179_cli_test.py | 112 +++++ test/cli-fetch.mjs | 8 + test/fixtures/cli-multi.json | 42 ++ test/fixtures/cli-numeric.json | 42 ++ test/fixtures/cli-optional.json | 40 ++ test/fixtures/cli-points.json | 42 ++ test/fixtures/cli-ranking.json | 43 ++ test/fixtures/cli-rating.json | 46 ++ test/fixtures/cli-single.json | 41 ++ test/fixtures/native-base.hex | 1 + test/koios-fixture.mjs | 27 ++ test/native-fixture.mjs | 51 +++ 27 files changed, 1791 insertions(+), 276 deletions(-) create mode 100644 CIP179-VALIDATION.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 test/cip179.test.mjs create mode 100644 test/cip179_cli_test.py create mode 100644 test/cli-fetch.mjs create mode 100644 test/fixtures/cli-multi.json create mode 100644 test/fixtures/cli-numeric.json create mode 100644 test/fixtures/cli-optional.json create mode 100644 test/fixtures/cli-points.json create mode 100644 test/fixtures/cli-ranking.json create mode 100644 test/fixtures/cli-rating.json create mode 100644 test/fixtures/cli-single.json create mode 100644 test/fixtures/native-base.hex create mode 100644 test/koios-fixture.mjs create mode 100644 test/native-fixture.mjs diff --git a/.gitignore b/.gitignore index 617aa170..dfd263a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea .DS_Store node_modules/ + +.test-artifacts/ diff --git a/CIP179-VALIDATION.md b/CIP179-VALIDATION.md new file mode 100644 index 00000000..8f1344fb --- /dev/null +++ b/CIP179-VALIDATION.md @@ -0,0 +1,32 @@ +# CIP-179 public-response validation + +Install the locked graph with `npm ci` under Node 22.12 or newer. Run: + +```sh +npm run test:cip179 +python3 test/cip179_cli_test.py +``` + +The three Node tests and 21 Python cases exercise the real helper, six built-in +question types, omitted versus explicitly empty answers, full int64 values, +terminal escaping, cancellation, exclusive output creation, merge validation, +voter/survey binding, and the host scripts' anchor and CIP-20 message blocks. +Python tests need Bash, jq and a Unix PTY. All generated fixtures stay in ignored +`.test-artifacts/`. Provider responses and node operations are simulated. + +Both network helpers decode `/tx_cbor` using native ledger types and check the +transaction ID and auxiliary-data hash. A sidecar contains label 17 and a local +`_cip179.definitionCbor` binding. `verify` checks it against the VoteFile's saved +survey and voter, and `merge` revalidates answers against the embedded definition. +Only label 17 is emitted into transaction metadata. Existing unbound sidecars +must be regenerated. Existing output files are never overwritten. + +The CLI supports public key-credential DRep, SPO and CC responses to built-in +questions. It does not independently establish owner proof, cancellation, +registration or finalization. Native metadata integrity is not a substitute for +those checks. Custom and sealed responses are unsupported. The existing voting +workflow supplies the governance vote needed for mechanism B; offline tests do +not demonstrate ledger acceptance, hardware-wallet support or live Koios support. + +CIP-169 deposit/return-account checks apply when that extension is present in a +linked anchor. A CIP-108/CIP-179 link without CIP-169 remains valid input. diff --git a/cardano/mainnet/24a_genVote.sh b/cardano/mainnet/24a_genVote.sh index 72b586bd..7c68ec00 100755 --- a/cardano/mainnet/24a_genVote.sh +++ b/cardano/mainnet/24a_genVote.sh @@ -1187,7 +1187,7 @@ if [[ "${voteParam}" != "" ]]; then 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 + echo -e "\n\e[35mCIP-179 survey voting needs cip179-vote.mjs, Node.js 22.12+, and cip-179@0.2.0.\nInstall the helper beside these scripts, then run 'npm ci (in the checkout root)' 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" @@ -1207,7 +1207,7 @@ if [[ "${voteParam}" != "" ]]; then 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}") + voteJSON=$(jq --arg responseFile "$(basename "${cip179ResponseFile}")" --arg txId "${cip179SurveyTxId}" --arg index "${cip179SurveyIndex}" '.cip179Response = $responseFile | .cip179Survey = {txId: $txId, index: $index}' <<< "${voteJSON}") fi echo "${voteJSON}" > "${votingFile}"; checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi diff --git a/cardano/mainnet/24b_regVote.sh b/cardano/mainnet/24b_regVote.sh index 2c207f4d..b2e3df1b 100755 --- a/cardano/mainnet/24b_regVote.sh +++ b/cardano/mainnet/24b_regVote.sh @@ -226,6 +226,13 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) voteHash=${voteActionVoter##*-} echo -e "\e[0m Voter-HASH: \e[94m${voteHash}\e[0m" + if [[ "${cip179ResponseFile}" != "" ]]; then + case ${voteType} in DRep) cip179Role=0;; Pool) cip179Role=1;; Committee) cip179Role=2;; *) exit 1;; esac + cip179SurveyTxId=$(jq -r '.cip179Survey.txId // empty' "${metafile}") + cip179SurveyIndex=$(jq -r '.cip179Survey.index // empty' "${metafile}") + node "${scriptDir}/cip179-vote.mjs" verify "${cip179ResponseFile}" "${cip179Role}" "${voteHash}" "${cip179SurveyTxId}" "${cip179SurveyIndex}" || exit 1 + fi + #Get action-id voteActionUTXO=${voteActionID:0:64} voteActionIdx=${voteActionID:65} @@ -299,7 +306,8 @@ 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" + cip179MetadataDir=$(mktemp -d "${tempDir}/cip179.XXXXXXXX") || exit 1 + cip179MetadataFile="${cip179MetadataDir}/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}' " diff --git a/cardano/mainnet/25a_genAction.sh b/cardano/mainnet/25a_genAction.sh index 5eda49e0..7104a7c5 100755 --- a/cardano/mainnet/25a_genAction.sh +++ b/cardano/mainnet/25a_genAction.sh @@ -182,7 +182,7 @@ if ${onlineMode}; then #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 + if jq -e '.body.cip179 != null and .body.onChain != 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 diff --git a/cardano/mainnet/cip179-vote.mjs b/cardano/mainnet/cip179-vote.mjs index 8d85a048..03f99721 100755 --- a/cardano/mainnet/cip179-vote.mjs +++ b/cardano/mainnet/cip179-vote.mjs @@ -1,6 +1,9 @@ #!/usr/bin/env node -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile, link, unlink } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { createInterface } from "node:readline/promises"; const usage = () => { @@ -13,121 +16,224 @@ const usage = () => { 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`); + throw new Error( + `${terminalText(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) }; + return { int: 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) })) }; + 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; - } +// Node's source-aware JSON reviver preserves ledger integers without rounding. +export function parseExactJson(text) { + return JSON.parse(text, (_key, value, context) => { + if (typeof value !== "number") return value; + if (!Number.isFinite(value) || !/^-?\d+$/.test(context.source)) + throw new Error("Metadata integers must be decimal integers"); + return Number.isSafeInteger(value) ? value : BigInt(context.source); + }); +} +const stringifyExact = (value) => + JSON.stringify( + value, + (_key, item) => + typeof item === "bigint" ? JSON.rawJSON(String(item)) : item, + 2, + ); + +export const terminalText = (value) => + String(value).replace( + /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, + (char) => `\\u${char.codePointAt(0).toString(16).padStart(4, "0")}`, + ); + +async function writeExclusive(output, content) { + const temporary = join(dirname(resolve(output)), `.cip179-${randomUUID()}`); + try { + await writeFile(temporary, content, { flag: "wx", mode: 0o600 }); + await link(temporary, output); + } finally { + await unlink(temporary).catch(() => {}); + } +} + +async function boundedBytes(response, limit = 1_048_576) { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (Number(response.headers.get("content-length")) > limit) + throw new Error("Response too large"); + const reader = response.body?.getReader(); + if (!reader) throw new Error("Empty response body"); + const chunks = []; + let length = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + length += value.length; + if (length > limit) throw new Error("Response too large"); + chunks.push(value); } - return undefined; + return Buffer.concat(chunks, length); + } finally { + await reader.cancel(); } - 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"); - } + const [major, minor] = process.versions.node.split(".").map(Number); + if (major < 22 || (major === 22 && minor < 12)) + throw new Error( + "The optional CIP-179 voter requires Node.js 22.12 or newer", + ); try { return await import("cip-179"); - } catch (error) { + } catch { 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})`, + "Install the optional helper dependencies with npm ci in this checkout's root (Node.js 22.12+)", ); } } +export async function decodeSurveyNative(cbor, txId) { + const CSL = await import("@emurgo/cardano-serialization-lib-asmjs"); + const { blake2b } = await import("@noble/hashes/blake2.js"); + if ( + typeof cbor !== "string" || + !/^(?:[a-fA-F0-9]{2})+$/.test(cbor) || + cbor.length > 131072 + ) + throw new Error("Native transaction CBOR is missing or invalid"); + const tx = CSL.FixedTransaction.from_hex(cbor); + if (!tx.is_valid() || tx.transaction_hash().to_hex() !== txId) + throw new Error("Native transaction does not match the survey reference"); + const raw = tx.raw_auxiliary_data(); + if ( + !raw || + hex(blake2b(raw, { dkLen: 32 })) !== + tx.body().auxiliary_data_hash()?.to_hex() + ) + throw new Error("Native metadata hash mismatch"); + const metadata = tx + .auxiliary_data() + ?.metadata() + ?.get(CSL.BigNum.from_str("17")); + if (!metadata) throw new Error("Transaction has no metadata label 17"); + const decode = (value, depth = 0) => { + if (depth > 64) throw new Error("Native metadata is too deeply nested"); + switch (value.kind()) { + case CSL.TransactionMetadatumKind.Int: + return BigInt(value.as_int().to_str()); + case CSL.TransactionMetadatumKind.Text: + return value.as_text(); + case CSL.TransactionMetadatumKind.Bytes: + return value.as_bytes(); + case CSL.TransactionMetadatumKind.MetadataList: { + const list = value.as_list(); + return Array.from({ length: list.len() }, (_, i) => + decode(list.get(i), depth + 1), + ); + } + case CSL.TransactionMetadatumKind.MetadataMap: { + const map = value.as_map(), + keys = map.keys(); + return new Map( + Array.from({ length: keys.len() }, (_, i) => [ + decode(keys.get(i), depth + 1), + decode(map.get(keys.get(i)), depth + 1), + ]), + ); + } + default: + throw new Error("Unknown native metadata type"); + } + }; + return decode(metadata); +} + 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 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`, { + if (separator > 0) + headers[auth.slice(0, separator).trim()] = auth.slice(separator + 1).trim(); + const response = await fetch(`${api}/tx_cbor`, { 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 rows = JSON.parse((await boundedBytes(response)).toString("utf8")); + const row = rows.find((item) => item.tx_hash === txId); + const payload = cip179.decodePayload( + await decodeSurveyNative(row?.cbor, txId), + ); + if (payload.type !== "definitions" || !payload.definitions[index]) + throw new Error(`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; + if (problems.length) + throw new Error(`Invalid survey: ${problems.join("; ")}`); + return { survey, definitionCbor: row.cbor }; } 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 url = uri.startsWith("ipfs://") + ? `https://ipfs.io/ipfs/${uri.slice(7)}` + : uri; + if (!url.startsWith("https://")) + throw new Error("Only HTTPS/IPFS presentations are supported"); 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()); + if (!response.ok) + throw new Error(`Survey presentation request failed (${response.status})`); + const bytes = new Uint8Array(await boundedBytes(response)); let blake2b; try { ({ blake2b } = await import("@noble/hashes/blake2.js")); } catch (error) { - throw new Error(`Unable to verify the survey presentation hash (${error.message})`); + 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"); + throw new Error( + "Survey presentation hash does not match its content anchor", + ); } try { - return JSON.parse(new TextDecoder().decode(bytes)); + const document = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ); + if ( + document?.specVersion !== 5 || + document?.kind !== "cardano-survey-presentation" + ) + throw new Error("Not a v5 presentation"); + return document; } catch { throw new Error("Survey presentation is not valid JSON"); } @@ -138,21 +244,47 @@ const optionCount = (options) => 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 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`); + 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 ( + 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`); + 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 }; } @@ -165,94 +297,190 @@ const parseList = (input) => { const ratingValid = (rating, scale) => { if (scale.type === "numeric") { const { min, max, step } = scale.constraints; - return rating >= min && rating <= max && (!step || (rating - min) % step === 0n); + 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}`)); + console.log( + `\n${index + 1}. ${terminalText(view.prompt)}${question.required ? " (required)" : ""}`, + ); + view.labels?.forEach((label, option) => + console.log(` ${option + 1}) ${terminalText(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"); + 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 }; + 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(); + 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 }; + 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(); + 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) { + 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(); + 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)) { + 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(); + 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+)$/)); + 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 }; + 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 (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}: ${terminalText(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+)$/)); + 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)) { + 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."); + console.log( + "That answer does not satisfy this question's constraints. Please try again.", + ); } } @@ -265,29 +493,71 @@ async function respond(args) { 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"); + 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 { survey, definitionCbor } = 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)); + if ( + survey.questions.length > 100 || + survey.questions.some((q) => q.options && optionCount(q.options) > 100) + ) + throw new Error("This CLI supports at most 100 questions/options"); + console.log( + "Survey shape checked. Owner proof, cancellation and role registration require independent chain validation.", + ); + 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"); + console.log( + `\nCIP-179 survey: ${terminalText(survey.title || presentation?.title || "Untitled survey")}`, + ); + if (survey.description || presentation?.description) + console.log(terminalText(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]); + 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 (answers.length === 0) { + console.log("Survey response skipped: no questions answered."); + process.exitCode = 10; + return; + } + 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; @@ -301,37 +571,149 @@ async function respond(args) { 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" }); + if (problems.length) + throw new Error(`Invalid response: ${problems.join("; ")}`); + const payload = cip179.encodePayload({ + type: "responses", + responses: [response], + }); + await writeExclusive( + output, + `${stringifyExact({ 17: detailed(payload), _cip179: { definitionCbor } })}\n`, + ); console.log(`CIP-179 response metadata created: ${output}`); } finally { rl.close(); } } +export function fromDetailed(value, depth = 0) { + if ( + depth > 64 || + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.keys(value).length !== 1 + ) + throw new Error("Invalid detailed metadata"); + if (Object.hasOwn(value, "int")) { + if (typeof value.int !== "bigint" && !Number.isSafeInteger(value.int)) + throw new Error("Invalid integer"); + const n = BigInt(value.int); + if (n < -18446744073709551616n || n > 18446744073709551615n) + throw new Error("Integer exceeds ledger range"); + return n; + } + if (typeof value.string === "string" && Buffer.byteLength(value.string) <= 64) + return value.string; + if ( + typeof value.bytes === "string" && + /^(?:[a-fA-F0-9]{2}){0,64}$/.test(value.bytes) + ) + return Buffer.from(value.bytes, "hex"); + if (Array.isArray(value.list)) + return value.list.map((v) => fromDetailed(v, depth + 1)); + if (Array.isArray(value.map)) + return new Map( + value.map.map((p) => { + if (!p || Object.keys(p).sort().join() !== "k,v") + throw new Error("Invalid map pair"); + return [fromDetailed(p.k, depth + 1), fromDetailed(p.v, depth + 1)]; + }), + ); + throw new Error("Invalid detailed metadata value"); +} + +async function readResponses(input, cip179) { + const text = await readFile(input, "utf8"); + if (Buffer.byteLength(text) > 1_048_576) throw new Error("Sidecar too large"); + const document = parseExactJson(text); + if ( + !document || + Object.keys(document).some((key) => !["17", "_cip179"].includes(key)) || + typeof document._cip179?.definitionCbor !== "string" + ) + throw new Error( + "A bound response sidecar with native definition CBOR is required; regenerate the response", + ); + const payload = cip179.decodePayload(fromDetailed(document["17"])); + if (payload.type !== "responses" || payload.responses.length === 0) + throw new Error("Expected nonempty response metadata"); + for (const response of payload.responses) { + if ( + response.specVersion !== 5 || + ![0, 1, 2].includes(response.role) || + response.credential.type !== "key" || + response.answers.type !== "public" || + response.answers.answers.length === 0 + ) + throw new Error("Unsupported or empty response sidecar"); + const definitions = cip179.decodePayload( + await decodeSurveyNative( + document._cip179.definitionCbor, + hex(response.surveyRef.txId), + ), + ); + const survey = + definitions.type === "definitions" + ? definitions.definitions[response.surveyRef.index] + : null; + if (!survey) throw new Error("Sidecar definition reference does not exist"); + const problems = [ + ...cip179.validateDefinition(survey), + ...cip179.validateResponse(survey, response), + ]; + if (problems.length) + throw new Error(`Invalid sidecar response: ${problems.join("; ")}`); + } + return payload.responses; +} + +async function verify(args) { + if (args.length !== 5) usage(); + const [input, role, credential, txId, index] = args; + const responses = await readResponses(input, await loadPackage()); + if ( + responses.length !== 1 || + responses.some( + (r) => + r.role !== Number(role) || + hex(r.credential.keyHash) !== credential.toLowerCase() || + hex(r.surveyRef.txId) !== txId.toLowerCase() || + String(r.surveyRef.index) !== index, + ) + ) + throw new Error( + "Sidecar does not match the vote's recorded survey, role and credential; regenerate the vote", + ); +} + async function merge(args) { if (args.length < 2) usage(); const [output, ...inputs] = args; + const cip179 = await loadPackage(); 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`); + for (const input of inputs) + responses.push(...(await readResponses(input, cip179))); + const payload = cip179.encodePayload({ type: "responses", responses }); + await writeExclusive( + output, + `${stringifyExact({ 17: detailed(payload) })}\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); +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + const [command, ...args] = process.argv.slice(2); + if (command === "respond") await respond(args); + else if (command === "merge") await merge(args); + else if (command === "verify") await verify(args); + else usage(); + } catch (error) { + console.error(`CIP-179: ${terminalText(error.message)}`); + process.exitCode = 1; + } } diff --git a/cardano/mainnet/usage_governance.md b/cardano/mainnet/usage_governance.md index 455d65ee..06581233 100644 --- a/cardano/mainnet/usage_governance.md +++ b/cardano/mainnet/usage_governance.md @@ -819,7 +819,7 @@ 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. +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 22.12 or newer and the locked optional dependencies installed with `npm ci` in the checkout root. 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. @@ -1157,3 +1157,11 @@ etc... 😄
 
----- + +The helper checks shape and answer constraints, not the definition owner's chain proof or cancellation history. Verify survey lifecycle separately. Custom methods and sealed responses are unsupported. Definitions are read from Koios `/tx_cbor`, with transaction and auxiliary hashes checked. A provider without native CBOR cannot be used for this optional helper. Detailed JSON sidecars preserve exact integer literals. Survey text is escaped for safe terminal display. + +New Vote-Files record the intended survey reference. `24b` checks the sidecar against that reference and the vote credential/role before merging. Regenerate older CIP-179 Vote-Files without this binding. Sidecars and merged files are published atomically and never overwritten. Mainnet and testnet helper copies must stay byte-identical. + +Run `npm run test:cip179` and `python3 test/cip179_cli_test.py` from the checkout root. Test artifacts stay under `.test-artifacts` or a caller-provided `TMPDIR`. + +Response sidecars include `_cip179.definitionCbor` for offline verification. Pass them through `24b`/the helper's merge command; they are not standalone cardano-cli metadata files. Merge checks the native definition hash and every response constraint, then emits only label 17 for cardano-cli. diff --git a/cardano/testnet/24a_genVote.sh b/cardano/testnet/24a_genVote.sh index 72b586bd..7c68ec00 100755 --- a/cardano/testnet/24a_genVote.sh +++ b/cardano/testnet/24a_genVote.sh @@ -1187,7 +1187,7 @@ if [[ "${voteParam}" != "" ]]; then 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 + echo -e "\n\e[35mCIP-179 survey voting needs cip179-vote.mjs, Node.js 22.12+, and cip-179@0.2.0.\nInstall the helper beside these scripts, then run 'npm ci (in the checkout root)' 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" @@ -1207,7 +1207,7 @@ if [[ "${voteParam}" != "" ]]; then 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}") + voteJSON=$(jq --arg responseFile "$(basename "${cip179ResponseFile}")" --arg txId "${cip179SurveyTxId}" --arg index "${cip179SurveyIndex}" '.cip179Response = $responseFile | .cip179Survey = {txId: $txId, index: $index}' <<< "${voteJSON}") fi echo "${voteJSON}" > "${votingFile}"; checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi diff --git a/cardano/testnet/24b_regVote.sh b/cardano/testnet/24b_regVote.sh index 2c207f4d..b2e3df1b 100755 --- a/cardano/testnet/24b_regVote.sh +++ b/cardano/testnet/24b_regVote.sh @@ -226,6 +226,13 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) voteHash=${voteActionVoter##*-} echo -e "\e[0m Voter-HASH: \e[94m${voteHash}\e[0m" + if [[ "${cip179ResponseFile}" != "" ]]; then + case ${voteType} in DRep) cip179Role=0;; Pool) cip179Role=1;; Committee) cip179Role=2;; *) exit 1;; esac + cip179SurveyTxId=$(jq -r '.cip179Survey.txId // empty' "${metafile}") + cip179SurveyIndex=$(jq -r '.cip179Survey.index // empty' "${metafile}") + node "${scriptDir}/cip179-vote.mjs" verify "${cip179ResponseFile}" "${cip179Role}" "${voteHash}" "${cip179SurveyTxId}" "${cip179SurveyIndex}" || exit 1 + fi + #Get action-id voteActionUTXO=${voteActionID:0:64} voteActionIdx=${voteActionID:65} @@ -299,7 +306,8 @@ 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" + cip179MetadataDir=$(mktemp -d "${tempDir}/cip179.XXXXXXXX") || exit 1 + cip179MetadataFile="${cip179MetadataDir}/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}' " diff --git a/cardano/testnet/25a_genAction.sh b/cardano/testnet/25a_genAction.sh index 5eda49e0..7104a7c5 100755 --- a/cardano/testnet/25a_genAction.sh +++ b/cardano/testnet/25a_genAction.sh @@ -182,7 +182,7 @@ if ${onlineMode}; then #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 + if jq -e '.body.cip179 != null and .body.onChain != 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 diff --git a/cardano/testnet/cip179-vote.mjs b/cardano/testnet/cip179-vote.mjs index 8d85a048..03f99721 100755 --- a/cardano/testnet/cip179-vote.mjs +++ b/cardano/testnet/cip179-vote.mjs @@ -1,6 +1,9 @@ #!/usr/bin/env node -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile, link, unlink } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { createInterface } from "node:readline/promises"; const usage = () => { @@ -13,121 +16,224 @@ const usage = () => { 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`); + throw new Error( + `${terminalText(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) }; + return { int: 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) })) }; + 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; - } +// Node's source-aware JSON reviver preserves ledger integers without rounding. +export function parseExactJson(text) { + return JSON.parse(text, (_key, value, context) => { + if (typeof value !== "number") return value; + if (!Number.isFinite(value) || !/^-?\d+$/.test(context.source)) + throw new Error("Metadata integers must be decimal integers"); + return Number.isSafeInteger(value) ? value : BigInt(context.source); + }); +} +const stringifyExact = (value) => + JSON.stringify( + value, + (_key, item) => + typeof item === "bigint" ? JSON.rawJSON(String(item)) : item, + 2, + ); + +export const terminalText = (value) => + String(value).replace( + /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, + (char) => `\\u${char.codePointAt(0).toString(16).padStart(4, "0")}`, + ); + +async function writeExclusive(output, content) { + const temporary = join(dirname(resolve(output)), `.cip179-${randomUUID()}`); + try { + await writeFile(temporary, content, { flag: "wx", mode: 0o600 }); + await link(temporary, output); + } finally { + await unlink(temporary).catch(() => {}); + } +} + +async function boundedBytes(response, limit = 1_048_576) { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (Number(response.headers.get("content-length")) > limit) + throw new Error("Response too large"); + const reader = response.body?.getReader(); + if (!reader) throw new Error("Empty response body"); + const chunks = []; + let length = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + length += value.length; + if (length > limit) throw new Error("Response too large"); + chunks.push(value); } - return undefined; + return Buffer.concat(chunks, length); + } finally { + await reader.cancel(); } - 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"); - } + const [major, minor] = process.versions.node.split(".").map(Number); + if (major < 22 || (major === 22 && minor < 12)) + throw new Error( + "The optional CIP-179 voter requires Node.js 22.12 or newer", + ); try { return await import("cip-179"); - } catch (error) { + } catch { 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})`, + "Install the optional helper dependencies with npm ci in this checkout's root (Node.js 22.12+)", ); } } +export async function decodeSurveyNative(cbor, txId) { + const CSL = await import("@emurgo/cardano-serialization-lib-asmjs"); + const { blake2b } = await import("@noble/hashes/blake2.js"); + if ( + typeof cbor !== "string" || + !/^(?:[a-fA-F0-9]{2})+$/.test(cbor) || + cbor.length > 131072 + ) + throw new Error("Native transaction CBOR is missing or invalid"); + const tx = CSL.FixedTransaction.from_hex(cbor); + if (!tx.is_valid() || tx.transaction_hash().to_hex() !== txId) + throw new Error("Native transaction does not match the survey reference"); + const raw = tx.raw_auxiliary_data(); + if ( + !raw || + hex(blake2b(raw, { dkLen: 32 })) !== + tx.body().auxiliary_data_hash()?.to_hex() + ) + throw new Error("Native metadata hash mismatch"); + const metadata = tx + .auxiliary_data() + ?.metadata() + ?.get(CSL.BigNum.from_str("17")); + if (!metadata) throw new Error("Transaction has no metadata label 17"); + const decode = (value, depth = 0) => { + if (depth > 64) throw new Error("Native metadata is too deeply nested"); + switch (value.kind()) { + case CSL.TransactionMetadatumKind.Int: + return BigInt(value.as_int().to_str()); + case CSL.TransactionMetadatumKind.Text: + return value.as_text(); + case CSL.TransactionMetadatumKind.Bytes: + return value.as_bytes(); + case CSL.TransactionMetadatumKind.MetadataList: { + const list = value.as_list(); + return Array.from({ length: list.len() }, (_, i) => + decode(list.get(i), depth + 1), + ); + } + case CSL.TransactionMetadatumKind.MetadataMap: { + const map = value.as_map(), + keys = map.keys(); + return new Map( + Array.from({ length: keys.len() }, (_, i) => [ + decode(keys.get(i), depth + 1), + decode(map.get(keys.get(i)), depth + 1), + ]), + ); + } + default: + throw new Error("Unknown native metadata type"); + } + }; + return decode(metadata); +} + 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 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`, { + if (separator > 0) + headers[auth.slice(0, separator).trim()] = auth.slice(separator + 1).trim(); + const response = await fetch(`${api}/tx_cbor`, { 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 rows = JSON.parse((await boundedBytes(response)).toString("utf8")); + const row = rows.find((item) => item.tx_hash === txId); + const payload = cip179.decodePayload( + await decodeSurveyNative(row?.cbor, txId), + ); + if (payload.type !== "definitions" || !payload.definitions[index]) + throw new Error(`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; + if (problems.length) + throw new Error(`Invalid survey: ${problems.join("; ")}`); + return { survey, definitionCbor: row.cbor }; } 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 url = uri.startsWith("ipfs://") + ? `https://ipfs.io/ipfs/${uri.slice(7)}` + : uri; + if (!url.startsWith("https://")) + throw new Error("Only HTTPS/IPFS presentations are supported"); 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()); + if (!response.ok) + throw new Error(`Survey presentation request failed (${response.status})`); + const bytes = new Uint8Array(await boundedBytes(response)); let blake2b; try { ({ blake2b } = await import("@noble/hashes/blake2.js")); } catch (error) { - throw new Error(`Unable to verify the survey presentation hash (${error.message})`); + 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"); + throw new Error( + "Survey presentation hash does not match its content anchor", + ); } try { - return JSON.parse(new TextDecoder().decode(bytes)); + const document = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ); + if ( + document?.specVersion !== 5 || + document?.kind !== "cardano-survey-presentation" + ) + throw new Error("Not a v5 presentation"); + return document; } catch { throw new Error("Survey presentation is not valid JSON"); } @@ -138,21 +244,47 @@ const optionCount = (options) => 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 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`); + 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 ( + 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`); + 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 }; } @@ -165,94 +297,190 @@ const parseList = (input) => { const ratingValid = (rating, scale) => { if (scale.type === "numeric") { const { min, max, step } = scale.constraints; - return rating >= min && rating <= max && (!step || (rating - min) % step === 0n); + 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}`)); + console.log( + `\n${index + 1}. ${terminalText(view.prompt)}${question.required ? " (required)" : ""}`, + ); + view.labels?.forEach((label, option) => + console.log(` ${option + 1}) ${terminalText(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"); + 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 }; + 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(); + 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 }; + 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(); + 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) { + 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(); + 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)) { + 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(); + 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+)$/)); + 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 }; + 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 (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}: ${terminalText(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+)$/)); + 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)) { + 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."); + console.log( + "That answer does not satisfy this question's constraints. Please try again.", + ); } } @@ -265,29 +493,71 @@ async function respond(args) { 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"); + 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 { survey, definitionCbor } = 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)); + if ( + survey.questions.length > 100 || + survey.questions.some((q) => q.options && optionCount(q.options) > 100) + ) + throw new Error("This CLI supports at most 100 questions/options"); + console.log( + "Survey shape checked. Owner proof, cancellation and role registration require independent chain validation.", + ); + 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"); + console.log( + `\nCIP-179 survey: ${terminalText(survey.title || presentation?.title || "Untitled survey")}`, + ); + if (survey.description || presentation?.description) + console.log(terminalText(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]); + 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 (answers.length === 0) { + console.log("Survey response skipped: no questions answered."); + process.exitCode = 10; + return; + } + 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; @@ -301,37 +571,149 @@ async function respond(args) { 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" }); + if (problems.length) + throw new Error(`Invalid response: ${problems.join("; ")}`); + const payload = cip179.encodePayload({ + type: "responses", + responses: [response], + }); + await writeExclusive( + output, + `${stringifyExact({ 17: detailed(payload), _cip179: { definitionCbor } })}\n`, + ); console.log(`CIP-179 response metadata created: ${output}`); } finally { rl.close(); } } +export function fromDetailed(value, depth = 0) { + if ( + depth > 64 || + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.keys(value).length !== 1 + ) + throw new Error("Invalid detailed metadata"); + if (Object.hasOwn(value, "int")) { + if (typeof value.int !== "bigint" && !Number.isSafeInteger(value.int)) + throw new Error("Invalid integer"); + const n = BigInt(value.int); + if (n < -18446744073709551616n || n > 18446744073709551615n) + throw new Error("Integer exceeds ledger range"); + return n; + } + if (typeof value.string === "string" && Buffer.byteLength(value.string) <= 64) + return value.string; + if ( + typeof value.bytes === "string" && + /^(?:[a-fA-F0-9]{2}){0,64}$/.test(value.bytes) + ) + return Buffer.from(value.bytes, "hex"); + if (Array.isArray(value.list)) + return value.list.map((v) => fromDetailed(v, depth + 1)); + if (Array.isArray(value.map)) + return new Map( + value.map.map((p) => { + if (!p || Object.keys(p).sort().join() !== "k,v") + throw new Error("Invalid map pair"); + return [fromDetailed(p.k, depth + 1), fromDetailed(p.v, depth + 1)]; + }), + ); + throw new Error("Invalid detailed metadata value"); +} + +async function readResponses(input, cip179) { + const text = await readFile(input, "utf8"); + if (Buffer.byteLength(text) > 1_048_576) throw new Error("Sidecar too large"); + const document = parseExactJson(text); + if ( + !document || + Object.keys(document).some((key) => !["17", "_cip179"].includes(key)) || + typeof document._cip179?.definitionCbor !== "string" + ) + throw new Error( + "A bound response sidecar with native definition CBOR is required; regenerate the response", + ); + const payload = cip179.decodePayload(fromDetailed(document["17"])); + if (payload.type !== "responses" || payload.responses.length === 0) + throw new Error("Expected nonempty response metadata"); + for (const response of payload.responses) { + if ( + response.specVersion !== 5 || + ![0, 1, 2].includes(response.role) || + response.credential.type !== "key" || + response.answers.type !== "public" || + response.answers.answers.length === 0 + ) + throw new Error("Unsupported or empty response sidecar"); + const definitions = cip179.decodePayload( + await decodeSurveyNative( + document._cip179.definitionCbor, + hex(response.surveyRef.txId), + ), + ); + const survey = + definitions.type === "definitions" + ? definitions.definitions[response.surveyRef.index] + : null; + if (!survey) throw new Error("Sidecar definition reference does not exist"); + const problems = [ + ...cip179.validateDefinition(survey), + ...cip179.validateResponse(survey, response), + ]; + if (problems.length) + throw new Error(`Invalid sidecar response: ${problems.join("; ")}`); + } + return payload.responses; +} + +async function verify(args) { + if (args.length !== 5) usage(); + const [input, role, credential, txId, index] = args; + const responses = await readResponses(input, await loadPackage()); + if ( + responses.length !== 1 || + responses.some( + (r) => + r.role !== Number(role) || + hex(r.credential.keyHash) !== credential.toLowerCase() || + hex(r.surveyRef.txId) !== txId.toLowerCase() || + String(r.surveyRef.index) !== index, + ) + ) + throw new Error( + "Sidecar does not match the vote's recorded survey, role and credential; regenerate the vote", + ); +} + async function merge(args) { if (args.length < 2) usage(); const [output, ...inputs] = args; + const cip179 = await loadPackage(); 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`); + for (const input of inputs) + responses.push(...(await readResponses(input, cip179))); + const payload = cip179.encodePayload({ type: "responses", responses }); + await writeExclusive( + output, + `${stringifyExact({ 17: detailed(payload) })}\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); +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + const [command, ...args] = process.argv.slice(2); + if (command === "respond") await respond(args); + else if (command === "merge") await merge(args); + else if (command === "verify") await verify(args); + else usage(); + } catch (error) { + console.error(`CIP-179: ${terminalText(error.message)}`); + process.exitCode = 1; + } } diff --git a/cardano/testnet/usage_governance.md b/cardano/testnet/usage_governance.md index 455d65ee..06581233 100644 --- a/cardano/testnet/usage_governance.md +++ b/cardano/testnet/usage_governance.md @@ -819,7 +819,7 @@ 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. +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 22.12 or newer and the locked optional dependencies installed with `npm ci` in the checkout root. 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. @@ -1157,3 +1157,11 @@ etc... 😄
 
----- + +The helper checks shape and answer constraints, not the definition owner's chain proof or cancellation history. Verify survey lifecycle separately. Custom methods and sealed responses are unsupported. Definitions are read from Koios `/tx_cbor`, with transaction and auxiliary hashes checked. A provider without native CBOR cannot be used for this optional helper. Detailed JSON sidecars preserve exact integer literals. Survey text is escaped for safe terminal display. + +New Vote-Files record the intended survey reference. `24b` checks the sidecar against that reference and the vote credential/role before merging. Regenerate older CIP-179 Vote-Files without this binding. Sidecars and merged files are published atomically and never overwritten. Mainnet and testnet helper copies must stay byte-identical. + +Run `npm run test:cip179` and `python3 test/cip179_cli_test.py` from the checkout root. Test artifacts stay under `.test-artifacts` or a caller-provided `TMPDIR`. + +Response sidecars include `_cip179.definitionCbor` for offline verification. Pass them through `24b`/the helper's merge command; they are not standalone cardano-cli metadata files. Merge checks the native definition hash and every response constraint, then emits only label 17 for cardano-cli. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..c3168ec1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,61 @@ +{ + "name": "atada-cip179-helper", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "atada-cip179-helper", + "dependencies": { + "@emurgo/cardano-serialization-lib-asmjs": "14.1.2", + "@noble/hashes": "2.2.0", + "cip-179": "0.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@emurgo/cardano-serialization-lib-asmjs": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@emurgo/cardano-serialization-lib-asmjs/-/cardano-serialization-lib-asmjs-14.1.2.tgz", + "integrity": "sha512-sE8y+9iz9bgWeJQKmVeuSRGc9LAXlwR0M3W9+AOa370CN4rzVhoSlpHzkK6zKQSL4iu+yQHWpz/4e1b/o95a1w==", + "license": "MIT" + }, + "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/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 + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..0926f914 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "atada-cip179-helper", + "private": true, + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "test:cip179": "node --test test/cip179.test.mjs" + }, + "dependencies": { + "cip-179": "0.2.0", + "@noble/hashes": "2.2.0", + "@emurgo/cardano-serialization-lib-asmjs": "14.1.2" + } +} diff --git a/test/cip179.test.mjs b/test/cip179.test.mjs new file mode 100644 index 00000000..36a1769b --- /dev/null +++ b/test/cip179.test.mjs @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { + parseExactJson, + fromDetailed, + terminalText, +} from "../cardano/testnet/cip179-vote.mjs"; +import { encodePayload } from "cip-179"; + +test("integer boundaries remain exact through parsing and detailed conversion", () => { + const value = parseExactJson('{"int":9223372036854775807}'); + assert.equal(value.int, 9223372036854775807n); + assert.equal(fromDetailed(value), 9223372036854775807n); + assert.throws(() => fromDetailed({ int: 18446744073709551616n })); + assert.throws(() => fromDetailed({ int: 1, string: "bad" })); + assert.throws(() => fromDetailed({ bytes: "xx" })); +}); +test("external terminal escapes and bidirectional controls are made visible", () => { + assert.equal(terminalText("\x1b[2J\u202eTitle"), "\\u001b[2J\\u202eTitle"); +}); +const detailed = (value) => + typeof value === "bigint" + ? { int: Number(value) } + : typeof value === "string" + ? { string: value } + : value instanceof Uint8Array + ? { bytes: Buffer.from(value).toString("hex") } + : Array.isArray(value) + ? { list: value.map(detailed) } + : { + map: [...value].map(([k, v]) => ({ + k: detailed(k), + v: detailed(v), + })), + }; +test("merge rejects malformed entries and checks the intended vote binding without overwriting outputs", async () => { + const dir = await mkdtemp( + join(process.env.TMPDIR ?? ".test-artifacts", "cip179-"), + ); + try { + const input = join(dir, "response.json"), + out = join(dir, "merged.json"); + const fixturePath = join(dir, "native.json"); + const built = spawnSync( + process.execPath, + ["test/native-fixture.mjs", "test/fixtures/cli-single.json", fixturePath], + { + encoding: "utf8", + env: { ...process.env, NODE_TEST_CONTEXT: undefined }, + }, + ); + assert.equal(built.status, 0, built.stderr); + const txId = built.stdout; + const native = JSON.parse(await readFile(fixturePath, "utf8"))[0]; + const response = { + specVersion: 5, + surveyRef: { txId: Buffer.from(txId, "hex"), index: 0 }, + role: 0, + credential: { type: "key", keyHash: new Uint8Array(28).fill(0x22) }, + answers: { + type: "public", + answers: [{ type: "singleChoice", questionIndex: 0, optionIndex: 1 }], + }, + }; + const sidecar = () => + JSON.stringify({ + 17: detailed( + encodePayload({ type: "responses", responses: [response] }), + ), + _cip179: { definitionCbor: native.cbor }, + }); + await writeFile(input, sidecar()); + const run = (...args) => + spawnSync( + process.execPath, + ["cardano/testnet/cip179-vote.mjs", ...args], + { + encoding: "utf8", + env: { ...process.env, NODE_TEST_CONTEXT: undefined }, + }, + ); + const verified = run("verify", input, "0", "22".repeat(28), txId, "0"); + assert.equal(verified.status, 0, JSON.stringify(verified)); + assert.notEqual( + run("verify", input, "1", "22".repeat(28), txId, "0").status, + 0, + ); + assert.notEqual( + run("verify", input, "0", "33".repeat(28), txId, "0").status, + 0, + ); + assert.notEqual( + run("verify", input, "0", "22".repeat(28), txId, "1").status, + 0, + ); + assert.equal(run("merge", out, input).status, 0); + const saved = await readFile(out, "utf8"); + assert.notEqual(run("merge", out, input).status, 0); + assert.equal(await readFile(out, "utf8"), saved); + response.answers.answers[0].optionIndex = 99; + await writeFile(input, sidecar()); + assert.notEqual( + run("merge", join(dir, "bad-option.json"), input).status, + 0, + ); + await writeFile(input, '{"17":{"list":[{"int":1},{"list":[{"int":42}]}]}}'); + assert.notEqual(run("merge", join(dir, "bad.json"), input).status, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/cip179_cli_test.py b/test/cip179_cli_test.py new file mode 100644 index 00000000..e56fea1f --- /dev/null +++ b/test/cip179_cli_test.py @@ -0,0 +1,112 @@ +import json, os, pathlib, pty, select, subprocess, tempfile, time, unittest +ROOT=pathlib.Path(__file__).resolve().parents[1] +(ROOT/'.test-artifacts').mkdir(exist_ok=True) +TX='11'*32 +class VotingHelper(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(dir=ROOT/'.test-artifacts');self.addCleanup(self.tmp.cleanup) + self.dir=pathlib.Path(self.tmp.name);self.out=self.dir/'response.json' + self.node='node';self.helper=ROOT/'cardano/testnet/cip179-vote.mjs' + def respond(self,fixture='single',prompts=(),index='0',role='0',expiry='500',fixture_path=None): + native=self.dir/'native.json' + tx_id=subprocess.check_output([str(self.node),str(ROOT/'test/native-fixture.mjs'),str(fixture_path or ROOT/f'test/fixtures/cli-{fixture}.json'),str(native)],text=True) + env={**os.environ,'AUDIT_CLI_FIXTURE':str(native),'CIP179_KOIOS_API':'https://fixture.invalid'} + master,slave=pty.openpty() + proc=subprocess.Popen([str(self.node),'--import',str(ROOT/'test/cli-fetch.mjs'),str(self.helper),'respond',tx_id,index,role,'22'*28,expiry,str(self.out)],stdin=slave,stdout=slave,stderr=slave,env=env) + os.close(slave);data=b'';offset=0;pending=list(prompts);deadline=time.monotonic()+10 + try: + while time.monotonic() { + if (String(url) !== "https://fixture.invalid/tx_cbor") + throw Error("Unexpected network request in offline test: " + url); + return new Response(readFileSync(process.env.AUDIT_CLI_FIXTURE), { + headers: { "Content-Type": "application/json" }, + }); +}; diff --git a/test/fixtures/cli-multi.json b/test/fixtures/cli-multi.json new file mode 100644 index 00000000..321f58a0 --- /dev/null +++ b/test/fixtures/cli-multi.json @@ -0,0 +1,42 @@ +[ + { + "tx_hash": "1111111111111111111111111111111111111111111111111111111111111111", + "metadata": { + "17": [ + 0, + [ + { + "0": 5, + "1": [ + 0, + "0x00000000000000000000000000000000000000000000000000000000" + ], + "2": "CLI audit", + "3": "Fixture", + "4": [ + 0, + 1, + 2 + ], + "5": 500, + "6": [ + 0 + ], + "7": [ + [ + 2, + "Choose", + [ + "A", + "B" + ], + 0, + 2 + ] + ] + } + ] + ] + } + } +] diff --git a/test/fixtures/cli-numeric.json b/test/fixtures/cli-numeric.json new file mode 100644 index 00000000..821c3136 --- /dev/null +++ b/test/fixtures/cli-numeric.json @@ -0,0 +1,42 @@ +[ + { + "tx_hash": "1111111111111111111111111111111111111111111111111111111111111111", + "metadata": { + "17": [ + 0, + [ + { + "0": 5, + "1": [ + 0, + "0x00000000000000000000000000000000000000000000000000000000" + ], + "2": "CLI audit", + "3": "Fixture", + "4": [ + 0, + 1, + 2 + ], + "5": 500, + "6": [ + 0 + ], + "7": [ + [ + 4, + "Number", + [ + -10, + 10, + 2 + ], + 1 + ] + ] + } + ] + ] + } + } +] diff --git a/test/fixtures/cli-optional.json b/test/fixtures/cli-optional.json new file mode 100644 index 00000000..14fd884b --- /dev/null +++ b/test/fixtures/cli-optional.json @@ -0,0 +1,40 @@ +[ + { + "tx_hash": "1111111111111111111111111111111111111111111111111111111111111111", + "metadata": { + "17": [ + 0, + [ + { + "0": 5, + "1": [ + 0, + "0x00000000000000000000000000000000000000000000000000000000" + ], + "2": "CLI audit", + "3": "Fixture", + "4": [ + 0, + 1, + 2 + ], + "5": 500, + "6": [ + 0 + ], + "7": [ + [ + 1, + "Choose", + [ + "A", + "B" + ] + ] + ] + } + ] + ] + } + } +] diff --git a/test/fixtures/cli-points.json b/test/fixtures/cli-points.json new file mode 100644 index 00000000..a8e19c57 --- /dev/null +++ b/test/fixtures/cli-points.json @@ -0,0 +1,42 @@ +[ + { + "tx_hash": "1111111111111111111111111111111111111111111111111111111111111111", + "metadata": { + "17": [ + 0, + [ + { + "0": 5, + "1": [ + 0, + "0x00000000000000000000000000000000000000000000000000000000" + ], + "2": "CLI audit", + "3": "Fixture", + "4": [ + 0, + 1, + 2 + ], + "5": 500, + "6": [ + 0 + ], + "7": [ + [ + 5, + "Allocate", + [ + "A", + "B" + ], + 10, + 1 + ] + ] + } + ] + ] + } + } +] diff --git a/test/fixtures/cli-ranking.json b/test/fixtures/cli-ranking.json new file mode 100644 index 00000000..490dc285 --- /dev/null +++ b/test/fixtures/cli-ranking.json @@ -0,0 +1,43 @@ +[ + { + "tx_hash": "1111111111111111111111111111111111111111111111111111111111111111", + "metadata": { + "17": [ + 0, + [ + { + "0": 5, + "1": [ + 0, + "0x00000000000000000000000000000000000000000000000000000000" + ], + "2": "CLI audit", + "3": "Fixture", + "4": [ + 0, + 1, + 2 + ], + "5": 500, + "6": [ + 0 + ], + "7": [ + [ + 3, + "Rank", + [ + "A", + "B" + ], + 1, + 2, + 1 + ] + ] + } + ] + ] + } + } +] diff --git a/test/fixtures/cli-rating.json b/test/fixtures/cli-rating.json new file mode 100644 index 00000000..690dbf21 --- /dev/null +++ b/test/fixtures/cli-rating.json @@ -0,0 +1,46 @@ +[ + { + "tx_hash": "1111111111111111111111111111111111111111111111111111111111111111", + "metadata": { + "17": [ + 0, + [ + { + "0": 5, + "1": [ + 0, + "0x00000000000000000000000000000000000000000000000000000000" + ], + "2": "CLI audit", + "3": "Fixture", + "4": [ + 0, + 1, + 2 + ], + "5": 500, + "6": [ + 0 + ], + "7": [ + [ + 6, + "Rate", + [ + "A", + "B" + ], + [ + 0, + 5 + ], + 1, + 1 + ] + ] + } + ] + ] + } + } +] diff --git a/test/fixtures/cli-single.json b/test/fixtures/cli-single.json new file mode 100644 index 00000000..73f95557 --- /dev/null +++ b/test/fixtures/cli-single.json @@ -0,0 +1,41 @@ +[ + { + "tx_hash": "1111111111111111111111111111111111111111111111111111111111111111", + "metadata": { + "17": [ + 0, + [ + { + "0": 5, + "1": [ + 0, + "0x00000000000000000000000000000000000000000000000000000000" + ], + "2": "CLI audit", + "3": "Fixture", + "4": [ + 0, + 1, + 2 + ], + "5": 500, + "6": [ + 0 + ], + "7": [ + [ + 1, + "Choose", + [ + "A", + "B" + ], + 1 + ] + ] + } + ] + ] + } + } +] diff --git a/test/fixtures/native-base.hex b/test/fixtures/native-base.hex new file mode 100644 index 00000000..2b9cb585 --- /dev/null +++ b/test/fixtures/native-base.hex @@ -0,0 +1 @@ +84a500d9010281825820333333333333333333333333333333333333333333333333333333333333333300018182581d608b218424ad74df25d35c2ea8e094a4c5c5aeb2cbb4424193315693131a05f33957021a0002a7a907582027abb40a768112b7194bc6ce42556403a35c0bdf4a4d00455d3f3e29eadb291a13a18202581c8b218424ad74df25d35c2ea8e094a4c5c5aeb2cbb442419331569313a18258204444444444444444444444444444444444444444444444444444444444444444008201f6a100d9010281825820ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c5840605c14f5cc30375d9d828e71e2c054000de973fddbc562b4a8abd919d84d65ec090dc3569a7b426f72d9a71c3b3cd20d2aab9121aa420e08bbc0df4dfefb5103f5a111820181a50005018258201111111111111111111111111111111111111111111111111111111111111111000200038200581c8b218424ad74df25d35c2ea8e094a4c5c5aeb2cbb442419331569313048183010001 diff --git a/test/koios-fixture.mjs b/test/koios-fixture.mjs new file mode 100644 index 00000000..6d4bc313 --- /dev/null +++ b/test/koios-fixture.mjs @@ -0,0 +1,27 @@ +export 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 === "bigint") return value; + 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), + ]), + ); +} diff --git a/test/native-fixture.mjs b/test/native-fixture.mjs new file mode 100644 index 00000000..b8e4a76e --- /dev/null +++ b/test/native-fixture.mjs @@ -0,0 +1,51 @@ +// Offline transport fixture: synthesize native CBOR, never submit it. +import * as CSL from "@emurgo/cardano-serialization-lib-asmjs"; +import { readFileSync, writeFileSync } from "node:fs"; +import { parseExactJson } from "../cardano/testnet/cip179-vote.mjs"; +import { koiosToMetadatum } from "./koios-fixture.mjs"; +const [input, output] = process.argv.slice(2); +const rows = parseExactJson(readFileSync(input, "utf8")); +const m = koiosToMetadatum(rows[0].metadata["17"]); +const detailed = (value) => + typeof value === "bigint" + ? { int: JSON.rawJSON(String(value)) } + : typeof value === "string" + ? { string: value } + : value instanceof Uint8Array + ? { bytes: Buffer.from(value).toString("hex") } + : Array.isArray(value) + ? { list: value.map(detailed) } + : { + map: [...value].map(([k, v]) => ({ + k: detailed(k), + v: detailed(v), + })), + }; +const metadata = CSL.GeneralTransactionMetadata.new(); +metadata.insert( + CSL.BigNum.from_str("17"), + CSL.encode_json_str_to_metadatum( + JSON.stringify(detailed(m)), + CSL.MetadataJsonSchema.DetailedSchema, + ), +); +const auxiliary = CSL.AuxiliaryData.new(); +auxiliary.set_metadata(metadata); +const old = CSL.Transaction.from_hex( + readFileSync( + new URL("./fixtures/native-base.hex", import.meta.url), + "utf8", + ).trim(), +); +const body = old.body(); +body.set_auxiliary_data_hash(CSL.hash_auxiliary_data(auxiliary)); +const tx = CSL.Transaction.new( + body, + CSL.TransactionWitnessSet.new(), + auxiliary, +); +const hash = CSL.FixedTransaction.from_hex(tx.to_hex()) + .transaction_hash() + .to_hex(); +writeFileSync(output, JSON.stringify([{ tx_hash: hash, cbor: tx.to_hex() }])); +process.stdout.write(hash);