diff --git a/.gitignore b/.gitignore index 090a1f0..dfd263a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .idea .DS_Store +node_modules/ + +.test-artifacts/ diff --git a/CIP179-VALIDATION.md b/CIP179-VALIDATION.md new file mode 100644 index 0000000..8f1344f --- /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/00_common.sh b/cardano/mainnet/00_common.sh index c2b7332..8ba59f3 100755 --- a/cardano/mainnet/00_common.sh +++ b/cardano/mainnet/00_common.sh @@ -1194,7 +1194,7 @@ convert_actionUTXO2Bech() { local govActionID="${1}" # local govActionUTXO=$(trimString "${1%%#*}"); govActionUTXO=${govActionUTXO,,} #takes the part before the # separator # local govActionIdx=$(trimString "${1#*#}"); #takes the part after the # separator - if [[ "${govActionID}" =~ ^([[:xdigit:]]{64}+#[[:digit:]]{1,})$ ]]; then + if [[ "${govActionID}" =~ ^([[:xdigit:]]{64}#[[:digit:]]{1,})$ ]]; then local govActionUTXO=${govActionID:0:64}; govActionUTXO=${govActionUTXO,,} #make sure its lower case local govActionIdx=$(( ${govActionID:65} + 0 )) #make sure to have single digits if provided like #00 #01 #02... local govActionIdxHex="00$(bc <<< "obase=16;ibase=10;${govActionIdx}")"; govActionIdxHex=${govActionIdxHex: -$(( (${#govActionIdxHex}-1)/2*2 ))} #make sure its with a leading zero and always in pairs like 03, 04af diff --git a/cardano/mainnet/24a_genVote.sh b/cardano/mainnet/24a_genVote.sh index b24fac3..7c68ec0 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 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" + 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}")" --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 echo -e "\e[0mCreated the Vote-Certificate file: \e[32m${votingFile}\e[90m" diff --git a/cardano/mainnet/24b_regVote.sh b/cardano/mainnet/24b_regVote.sh index 05aa678..b2e3df1 100755 --- a/cardano/mainnet/24b_regVote.sh +++ b/cardano/mainnet/24b_regVote.sh @@ -154,8 +154,9 @@ echo #Setting default variables -metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; +metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; metadataJsonFile=""; metadataCborFile=""; votefileParameter=""; actionIdCollector=""; voterHashCollector=""; voteCounter=0; +cip179ResponseFiles=() #Check all optional parameters about there types and set the corresponding variables #Starting with the 3th parameter (index=2) up to the last parameter @@ -175,11 +176,13 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) metadatum=$(jq -r "keys_unsorted[0]" "${metafile}" 2> /dev/null) if [[ $? -ne 0 ]]; then echo -e "\n\e[35mERROR - '${metafile}' is not a valid JSON file!\n\e[0m"; exit 1; fi #Check if it is null, a number, lower then zero, higher then 65535, otherwise exit with an error - if [ "${metadatum}" == null ] || [ -z "${metadatum##*[!0-9]*}" ] || [ "${metadatum}" -lt 0 ] || [ "${metadatum}" -gt 65535 ]; then + if [ "${metadatum}" == null ] || [ -z "${metadatum##*[!0-9]*}" ] || [ "${metadatum}" -lt 0 ] || [ "${metadatum}" -gt 65535 ]; then echo -e "\n\e[35mERROR - MetaDatum Value '${metadatum}' in '${metafile}' must be in the range of 0..65535!\n\e[0m"; exit 1; fi - metafileParameter+="--metadata-json-file ${metafile} "; metafileList+="'${metafile}' " + metadataJsonFile="${metafile}" + metafileParameter+="--metadata-json-file ${metafile} "; metafileList+="'${metafile}' " elif [[ -f "${metafile}" && "${metafileExt^^}" == "CBOR" ]]; then #its a cbor file + metadataCborFile="${metafile}" metafileParameter+="--metadata-cbor-file ${metafile} "; metafileList+="'${metafile}' " elif [[ -f "${metafile}" && "${metafileExt^^}" == "VOTE" ]]; then #its a vote file @@ -199,6 +202,12 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) #Additionally read the description from the voting file voteActionDescription=$(jq -r '.description // "-"' 2> /dev/null "${metafile}"); + cip179ResponseFile=$(jq -r '.cip179Response // empty' 2> /dev/null "${metafile}") + if [[ "${cip179ResponseFile}" != "" ]]; then + if [[ "${cip179ResponseFile}" != /* ]]; then cip179ResponseFile="$(dirname "${metafile}")/${cip179ResponseFile}"; fi + if [[ ! -f "${cip179ResponseFile}" ]]; then echo -e "\n\e[35mERROR - CIP-179 response file '${cip179ResponseFile}' referenced by '${metafile}' does not exist.\e[0m\n"; exit 1; fi + cip179ResponseFiles+=("${cip179ResponseFile}") + fi #Show the Description echo -e "\e[0m Description: \e[33m${voteActionDescription}\e[0m"; @@ -217,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} @@ -286,6 +302,17 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) done +if [[ ${#cip179ResponseFiles[@]} -gt 0 ]]; then + if [[ "${metadataJsonFile}" != "" ]]; then echo -e "\n\e[35mERROR - JSON metadata '${metadataJsonFile}' cannot be combined with CIP-179 response sidecars because they use different cardano-cli JSON schemas.\e[0m\n"; exit 1; fi + if [[ "${metadataCborFile}" != "" ]]; then echo -e "\n\e[35mERROR - CBOR metadata '${metadataCborFile}' cannot be safely checked for a label 17 collision with CIP-179 response sidecars.\e[0m\n"; exit 1; fi + if ! exists node || [[ ! -f "${scriptDir}/cip179-vote.mjs" ]]; then echo -e "\n\e[35mERROR - Node.js and '${scriptDir}/cip179-vote.mjs' are required to merge CIP-179 responses.\e[0m\n"; exit 1; fi + 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}' " +fi + #Check if there is only one vote included if also a hardware wallet is used (limitation by the hardware wallet firmware) if [[ ${voteCounter} -gt 1 ]] && [[ -f "${fromAddr}.hwsfile" || "${voterSigningFile}" == *".hwsfile" ]]; then echo -e "\n\e[91mPlease include only one vote-file in case a hardware-wallet is involved in the transaction.\nThis is a limitation of the hardware-wallet firmware!\n\e[0m"; exit 1; fi @@ -312,6 +339,11 @@ if [[ ! "${transactionMessage}" == "{}" ]]; then echo -e "\n\e[35mERROR - The given encryption mode '${encryption,,}' is not on the supported list of encryption methods. Only 'basic' from CIP-0083 is currently supported\n\n\e[0m"; exit 1; fi + if [[ ${#cip179ResponseFiles[@]} -gt 0 ]]; then + tmp=$(jq 'with_entries(.value |= {map: [to_entries[] | {k: {string: .key}, v: (if (.value | type) == "array" then {list: [.value[] | {string: .}]} else {string: .value} end)}]})' <<< "${tmp}") + checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi + fi + echo "${tmp}" > ${transactionMessageMetadataFile}; metafileParameter="${metafileParameter}--metadata-json-file ${transactionMessageMetadataFile} "; #add it to the list of metadata.jsons to attach else diff --git a/cardano/mainnet/24c_queryVote.sh b/cardano/mainnet/24c_queryVote.sh index af0a758..0559baf 100755 --- a/cardano/mainnet/24c_queryVote.sh +++ b/cardano/mainnet/24c_queryVote.sh @@ -87,7 +87,7 @@ for (( tmpCnt=0; tmpCnt<${paramCnt}; tmpCnt++ )) paramValue=${allParameters[$tmpCnt]} #Check if its a Governance Action-ID - if [[ "${paramValue,,}" =~ ^([[:xdigit:]]{64}+#[[:digit:]]{1,})$ ]]; then + if [[ "${paramValue,,}" =~ ^([[:xdigit:]]{64}#[[:digit:]]{1,})$ ]]; then if [[ "${govActionID}" != "" ]]; then echo -e "\n\e[91mERROR - Only one Action-ID is allowed as parameter!\e[0m\n"; exit 1; fi govActionID="${paramValue,,}" echo -e "\e[0mUsing Governance Action-ID:\e[32m ${govActionID}\e[0m\n" diff --git a/cardano/mainnet/25a_genAction.sh b/cardano/mainnet/25a_genAction.sh index c311d8f..7104a7c 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 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 + echo -e "\n\e[91mERROR - The CIP-179-linked anchor does not contain a valid positive body.onChain.deposit and body.onChain.reward_account.\n\e[0m"; exit 1; + fi + fi + contentHASH=$(b2sum -l 256 "${tmpAnchorContent}" 2> /dev/null | cut -d' ' -f 1) checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi echo -e "\e[0mAnchor-URL(HASH):\e[32m ${anchorURL} \e[0m(\e[94m${contentHASH}\e[0m)" @@ -261,6 +273,18 @@ if [[ ${protocolVersionMajor} -lt 9 ]]; then if [[ ${actionDepositFee} -lt 0 ]]; then echo -e "\n\e[91mERROR - Could not query the current Action-Deposit fee amount!\n\e[0m"; exit 1; fi +# A linked survey anchor must describe the deposit and return account this +# script will use. Never silently correct stale, already-signed metadata. +if [[ "${cip179AnchorDeposit}" != "" ]]; then + if [[ "${cip179AnchorDeposit}" != "${actionDepositFee}" ]]; then + echo -e "\n\e[91mERROR - The CIP-179-linked anchor declares a governance action deposit of ${cip179AnchorDeposit} lovelace, but the current network requires ${actionDepositFee}. Regenerate and re-sign the anchor metadata; no action file was created.\n\e[0m"; exit 1; + fi + if [[ "${cip179AnchorRewardAccount}" != "${stakeAddr}" ]]; then + echo -e "\n\e[91mERROR - The CIP-179-linked anchor reward account does not match the selected deposit-return stake address. Regenerate and re-sign the anchor metadata; no action file was created.\n\e[0m"; exit 1; + fi + echo -e "\e[0mCIP-179 Anchor: \e[32m deposit and reward account match the action\e[0m" +fi + echo -e "\e[0mAction-Deposit Fee:\e[32m $(convertToADA ${actionDepositFee}) ADA / ${actionDepositFee} lovelaces\n\e[0m" if [[ ${committeeMaxTermLength} -lt 0 ]]; then @@ -810,5 +834,3 @@ echo -e "\"./25b_regAction.sh myWallet ${actionFile}\"\e[0m" echo echo -e "\e[0m" - - diff --git a/cardano/mainnet/cip179-vote.mjs b/cardano/mainnet/cip179-vote.mjs new file mode 100755 index 0000000..03f9972 --- /dev/null +++ b/cardano/mainnet/cip179-vote.mjs @@ -0,0 +1,719 @@ +#!/usr/bin/env node + +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 = () => { + 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( + `${terminalText(label)} must be ${bytes * 2} hexadecimal characters`, + ); + } + return Uint8Array.from(Buffer.from(value, "hex")); +}; + +function detailed(value) { + if (typeof value === "bigint") { + 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), + })), + }; + } + throw new Error("Unsupported metadata value"); +} + +// 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 Buffer.concat(chunks, length); + } finally { + await reader.cancel(); + } +} + +async function loadPackage() { + 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 { + throw new Error( + "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 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_cbor`, { + method: "POST", + headers, + body: JSON.stringify({ _tx_hashes: [txId] }), + signal: AbortSignal.timeout(30_000), + }); + 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, 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; + 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 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})`, + ); + } + if (hex(blake2b(bytes, { dkLen: 32 })) !== hex(survey.contentAnchor.hash)) { + throw new Error( + "Survey presentation hash does not match its content anchor", + ); + } + try { + 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"); + } +} + +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}. ${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", + ); + 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}: ${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+)$/)); + 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, 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); + 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: ${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], + ); + if (answer) answers.push(answer); + } + 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; + 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 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) + responses.push(...(await readResponses(input, cip179))); + const payload = cip179.encodePayload({ type: "responses", responses }); + await writeExclusive( + output, + `${stringifyExact({ 17: detailed(payload) })}\n`, + ); +} + +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 63a0752..0658123 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 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. Lets do an example: @@ -1155,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/00_common.sh b/cardano/testnet/00_common.sh index c2b7332..8ba59f3 100755 --- a/cardano/testnet/00_common.sh +++ b/cardano/testnet/00_common.sh @@ -1194,7 +1194,7 @@ convert_actionUTXO2Bech() { local govActionID="${1}" # local govActionUTXO=$(trimString "${1%%#*}"); govActionUTXO=${govActionUTXO,,} #takes the part before the # separator # local govActionIdx=$(trimString "${1#*#}"); #takes the part after the # separator - if [[ "${govActionID}" =~ ^([[:xdigit:]]{64}+#[[:digit:]]{1,})$ ]]; then + if [[ "${govActionID}" =~ ^([[:xdigit:]]{64}#[[:digit:]]{1,})$ ]]; then local govActionUTXO=${govActionID:0:64}; govActionUTXO=${govActionUTXO,,} #make sure its lower case local govActionIdx=$(( ${govActionID:65} + 0 )) #make sure to have single digits if provided like #00 #01 #02... local govActionIdxHex="00$(bc <<< "obase=16;ibase=10;${govActionIdx}")"; govActionIdxHex=${govActionIdxHex: -$(( (${#govActionIdxHex}-1)/2*2 ))} #make sure its with a leading zero and always in pairs like 03, 04af diff --git a/cardano/testnet/24a_genVote.sh b/cardano/testnet/24a_genVote.sh index b24fac3..7c68ec0 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 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" + 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}")" --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 echo -e "\e[0mCreated the Vote-Certificate file: \e[32m${votingFile}\e[90m" diff --git a/cardano/testnet/24b_regVote.sh b/cardano/testnet/24b_regVote.sh index 05aa678..b2e3df1 100755 --- a/cardano/testnet/24b_regVote.sh +++ b/cardano/testnet/24b_regVote.sh @@ -154,8 +154,9 @@ echo #Setting default variables -metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; +metafileParameter=""; metafile=""; transactionMessage="{}"; enc=""; passphrase="cardano"; metadataJsonFile=""; metadataCborFile=""; votefileParameter=""; actionIdCollector=""; voterHashCollector=""; voteCounter=0; +cip179ResponseFiles=() #Check all optional parameters about there types and set the corresponding variables #Starting with the 3th parameter (index=2) up to the last parameter @@ -175,11 +176,13 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) metadatum=$(jq -r "keys_unsorted[0]" "${metafile}" 2> /dev/null) if [[ $? -ne 0 ]]; then echo -e "\n\e[35mERROR - '${metafile}' is not a valid JSON file!\n\e[0m"; exit 1; fi #Check if it is null, a number, lower then zero, higher then 65535, otherwise exit with an error - if [ "${metadatum}" == null ] || [ -z "${metadatum##*[!0-9]*}" ] || [ "${metadatum}" -lt 0 ] || [ "${metadatum}" -gt 65535 ]; then + if [ "${metadatum}" == null ] || [ -z "${metadatum##*[!0-9]*}" ] || [ "${metadatum}" -lt 0 ] || [ "${metadatum}" -gt 65535 ]; then echo -e "\n\e[35mERROR - MetaDatum Value '${metadatum}' in '${metafile}' must be in the range of 0..65535!\n\e[0m"; exit 1; fi - metafileParameter+="--metadata-json-file ${metafile} "; metafileList+="'${metafile}' " + metadataJsonFile="${metafile}" + metafileParameter+="--metadata-json-file ${metafile} "; metafileList+="'${metafile}' " elif [[ -f "${metafile}" && "${metafileExt^^}" == "CBOR" ]]; then #its a cbor file + metadataCborFile="${metafile}" metafileParameter+="--metadata-cbor-file ${metafile} "; metafileList+="'${metafile}' " elif [[ -f "${metafile}" && "${metafileExt^^}" == "VOTE" ]]; then #its a vote file @@ -199,6 +202,12 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) #Additionally read the description from the voting file voteActionDescription=$(jq -r '.description // "-"' 2> /dev/null "${metafile}"); + cip179ResponseFile=$(jq -r '.cip179Response // empty' 2> /dev/null "${metafile}") + if [[ "${cip179ResponseFile}" != "" ]]; then + if [[ "${cip179ResponseFile}" != /* ]]; then cip179ResponseFile="$(dirname "${metafile}")/${cip179ResponseFile}"; fi + if [[ ! -f "${cip179ResponseFile}" ]]; then echo -e "\n\e[35mERROR - CIP-179 response file '${cip179ResponseFile}' referenced by '${metafile}' does not exist.\e[0m\n"; exit 1; fi + cip179ResponseFiles+=("${cip179ResponseFile}") + fi #Show the Description echo -e "\e[0m Description: \e[33m${voteActionDescription}\e[0m"; @@ -217,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} @@ -286,6 +302,17 @@ for (( tmpCnt=2; tmpCnt<${paramCnt}; tmpCnt++ )) done +if [[ ${#cip179ResponseFiles[@]} -gt 0 ]]; then + if [[ "${metadataJsonFile}" != "" ]]; then echo -e "\n\e[35mERROR - JSON metadata '${metadataJsonFile}' cannot be combined with CIP-179 response sidecars because they use different cardano-cli JSON schemas.\e[0m\n"; exit 1; fi + if [[ "${metadataCborFile}" != "" ]]; then echo -e "\n\e[35mERROR - CBOR metadata '${metadataCborFile}' cannot be safely checked for a label 17 collision with CIP-179 response sidecars.\e[0m\n"; exit 1; fi + if ! exists node || [[ ! -f "${scriptDir}/cip179-vote.mjs" ]]; then echo -e "\n\e[35mERROR - Node.js and '${scriptDir}/cip179-vote.mjs' are required to merge CIP-179 responses.\e[0m\n"; exit 1; fi + 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}' " +fi + #Check if there is only one vote included if also a hardware wallet is used (limitation by the hardware wallet firmware) if [[ ${voteCounter} -gt 1 ]] && [[ -f "${fromAddr}.hwsfile" || "${voterSigningFile}" == *".hwsfile" ]]; then echo -e "\n\e[91mPlease include only one vote-file in case a hardware-wallet is involved in the transaction.\nThis is a limitation of the hardware-wallet firmware!\n\e[0m"; exit 1; fi @@ -312,6 +339,11 @@ if [[ ! "${transactionMessage}" == "{}" ]]; then echo -e "\n\e[35mERROR - The given encryption mode '${encryption,,}' is not on the supported list of encryption methods. Only 'basic' from CIP-0083 is currently supported\n\n\e[0m"; exit 1; fi + if [[ ${#cip179ResponseFiles[@]} -gt 0 ]]; then + tmp=$(jq 'with_entries(.value |= {map: [to_entries[] | {k: {string: .key}, v: (if (.value | type) == "array" then {list: [.value[] | {string: .}]} else {string: .value} end)}]})' <<< "${tmp}") + checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi + fi + echo "${tmp}" > ${transactionMessageMetadataFile}; metafileParameter="${metafileParameter}--metadata-json-file ${transactionMessageMetadataFile} "; #add it to the list of metadata.jsons to attach else diff --git a/cardano/testnet/24c_queryVote.sh b/cardano/testnet/24c_queryVote.sh index af0a758..0559baf 100755 --- a/cardano/testnet/24c_queryVote.sh +++ b/cardano/testnet/24c_queryVote.sh @@ -87,7 +87,7 @@ for (( tmpCnt=0; tmpCnt<${paramCnt}; tmpCnt++ )) paramValue=${allParameters[$tmpCnt]} #Check if its a Governance Action-ID - if [[ "${paramValue,,}" =~ ^([[:xdigit:]]{64}+#[[:digit:]]{1,})$ ]]; then + if [[ "${paramValue,,}" =~ ^([[:xdigit:]]{64}#[[:digit:]]{1,})$ ]]; then if [[ "${govActionID}" != "" ]]; then echo -e "\n\e[91mERROR - Only one Action-ID is allowed as parameter!\e[0m\n"; exit 1; fi govActionID="${paramValue,,}" echo -e "\e[0mUsing Governance Action-ID:\e[32m ${govActionID}\e[0m\n" diff --git a/cardano/testnet/25a_genAction.sh b/cardano/testnet/25a_genAction.sh index c311d8f..7104a7c 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 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 + echo -e "\n\e[91mERROR - The CIP-179-linked anchor does not contain a valid positive body.onChain.deposit and body.onChain.reward_account.\n\e[0m"; exit 1; + fi + fi + contentHASH=$(b2sum -l 256 "${tmpAnchorContent}" 2> /dev/null | cut -d' ' -f 1) checkError "$?"; if [ $? -ne 0 ]; then exit $?; fi echo -e "\e[0mAnchor-URL(HASH):\e[32m ${anchorURL} \e[0m(\e[94m${contentHASH}\e[0m)" @@ -261,6 +273,18 @@ if [[ ${protocolVersionMajor} -lt 9 ]]; then if [[ ${actionDepositFee} -lt 0 ]]; then echo -e "\n\e[91mERROR - Could not query the current Action-Deposit fee amount!\n\e[0m"; exit 1; fi +# A linked survey anchor must describe the deposit and return account this +# script will use. Never silently correct stale, already-signed metadata. +if [[ "${cip179AnchorDeposit}" != "" ]]; then + if [[ "${cip179AnchorDeposit}" != "${actionDepositFee}" ]]; then + echo -e "\n\e[91mERROR - The CIP-179-linked anchor declares a governance action deposit of ${cip179AnchorDeposit} lovelace, but the current network requires ${actionDepositFee}. Regenerate and re-sign the anchor metadata; no action file was created.\n\e[0m"; exit 1; + fi + if [[ "${cip179AnchorRewardAccount}" != "${stakeAddr}" ]]; then + echo -e "\n\e[91mERROR - The CIP-179-linked anchor reward account does not match the selected deposit-return stake address. Regenerate and re-sign the anchor metadata; no action file was created.\n\e[0m"; exit 1; + fi + echo -e "\e[0mCIP-179 Anchor: \e[32m deposit and reward account match the action\e[0m" +fi + echo -e "\e[0mAction-Deposit Fee:\e[32m $(convertToADA ${actionDepositFee}) ADA / ${actionDepositFee} lovelaces\n\e[0m" if [[ ${committeeMaxTermLength} -lt 0 ]]; then @@ -810,5 +834,3 @@ echo -e "\"./25b_regAction.sh myWallet ${actionFile}\"\e[0m" echo echo -e "\e[0m" - - diff --git a/cardano/testnet/cip179-vote.mjs b/cardano/testnet/cip179-vote.mjs new file mode 100755 index 0000000..03f9972 --- /dev/null +++ b/cardano/testnet/cip179-vote.mjs @@ -0,0 +1,719 @@ +#!/usr/bin/env node + +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 = () => { + 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( + `${terminalText(label)} must be ${bytes * 2} hexadecimal characters`, + ); + } + return Uint8Array.from(Buffer.from(value, "hex")); +}; + +function detailed(value) { + if (typeof value === "bigint") { + 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), + })), + }; + } + throw new Error("Unsupported metadata value"); +} + +// 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 Buffer.concat(chunks, length); + } finally { + await reader.cancel(); + } +} + +async function loadPackage() { + 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 { + throw new Error( + "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 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_cbor`, { + method: "POST", + headers, + body: JSON.stringify({ _tx_hashes: [txId] }), + signal: AbortSignal.timeout(30_000), + }); + 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, 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; + 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 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})`, + ); + } + if (hex(blake2b(bytes, { dkLen: 32 })) !== hex(survey.contentAnchor.hash)) { + throw new Error( + "Survey presentation hash does not match its content anchor", + ); + } + try { + 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"); + } +} + +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}. ${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", + ); + 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}: ${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+)$/)); + 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, 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); + 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: ${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], + ); + if (answer) answers.push(answer); + } + 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; + 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 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) + responses.push(...(await readResponses(input, cip179))); + const payload = cip179.encodePayload({ type: "responses", responses }); + await writeExclusive( + output, + `${stringifyExact({ 17: detailed(payload) })}\n`, + ); +} + +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 63a0752..0658123 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 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. Lets do an example: @@ -1155,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 0000000..c3168ec --- /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 0000000..0926f91 --- /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 0000000..36a1769 --- /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 0000000..e56fea1 --- /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 0000000..321f58a --- /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 0000000..821c313 --- /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 0000000..14fd884 --- /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 0000000..a8e19c5 --- /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 0000000..490dc28 --- /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 0000000..690dbf2 --- /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 0000000..73f9555 --- /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 0000000..2b9cb58 --- /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 0000000..6d4bc31 --- /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 0000000..b8e4a76 --- /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);