From 11680ec620c609050bfa26d5e2576f8b8864517a Mon Sep 17 00:00:00 2001 From: Thaddeus Diamond Date: Thu, 13 Oct 2022 00:18:34 -0500 Subject: [PATCH 1/8] CIP-0071(?): Non-Fungible Token (NFT) Proxy Voting Standard This is the initial commit for a proposed Cardano Improvement Proposal (CIP) that would enable NFT projects that did not want to use a governance or other native fungible asset to perform verifiable on-chain votes. [ Documentation: README.md, example/ ] --- CIP-0071/README.md | 99 ++++++++ CIP-0071/example/voting.html | 149 ++++++++++++ CIP-0071/example/voting.js | 454 +++++++++++++++++++++++++++++++++++ 3 files changed, 702 insertions(+) create mode 100644 CIP-0071/README.md create mode 100644 CIP-0071/example/voting.html create mode 100644 CIP-0071/example/voting.js diff --git a/CIP-0071/README.md b/CIP-0071/README.md new file mode 100644 index 000000000..426aac8b7 --- /dev/null +++ b/CIP-0071/README.md @@ -0,0 +1,99 @@ +--- +CIP: 71 +Title: Non-Fungible Token (NFT) Proxy Voting Standard +Authors: Thaddeus Diamond +Comments-URI: +Status: Proposed +Type: Informational +Created: 2022-10-11 +Post-History: +License: CC-BY-4.0 +--- + +## Abstract + +This proposal defines a standard mechanism for performing verifiable on-chain voting in NFT projects that do not want a governance token using inline datums, plutus minting policies, and smart contracts. + +## Motivation + +This proposal is intended to provide a standard mechanism for non-fungible token (NFT) projects to perform on-chain verifiable votes using only their NFT assets. There are several proposed solutions for governance that involve using either a service provider (e.g., Summon) with native assets or the issuance of proprietary native assets. However, there are several issues with these approaches: +- Airdrops of governance tokens require minUTxO ADA attached, costing the NFT project ADA out of pocket +- Fungible tokens do not have a 1:1 mechanism for tracking votes against specific assets with voting power +- Sale of the underlying NFT is not tied to sale of the governance token, creating separate asset classes and leaving voting power potentially with those who are no longer holders of the NFT + +This standard provides a simple solution for this by using the underlying NFT to mint a one-time "ballot" token that can be used only for a specific voting topic. Projects that adopt this standard will be able to integrate with web viewers that track projects' governance votes and will also receive the benefits of verifiable on-chain voting without requiring issuance of a new native token. + +It is not intended for complex voting applications or for governance against fungible tokens that cannot be labeled individually. + +## Specification + +### A Simple Analogy + +The basic analogy here is that of a traditional state or federal vote. Imagine a citizen who has a state ID (e.g., Driver's License) and wants to vote. That citizen would: +1. Go to a precinct and show their ID to the appropriate authority +2. Receive a ballot with choices for the current vote +3. Mark their selections on the ballot +4. Sign their ballot with their name +5. Submit their ballot into a single "ballot box" +6. Authorized vote counting authorities process the vote after polls close +7. Await the election results through a trusted news outlet + +This specification follows the same process, but using tokens: +1. A holder of a project validates their NFT by sending it to self +2. The user signs a Plutus minting policy to create a proxy NFT that represents the current ballot +3. The holder marks their vote selections off-chain +4. The holder signs a new transaction that sends the proxy "ballot" NFT to a smart contract ("the ballot box") with a datum representing the vote +5. Authorized vote counting wallets process the UTxOs' datum in the "ballot box" smart contract after the polls close +6. Project creators report the results in a human-readable off-chain format to their holders. + +Because of the efficient UTxO model Cardano employs, steps #1, #2, and #4 can occur in a single transaction. + +### The Vote Casting Process + +#### "Ballot" -> Plutus Minting Policy + +[ ] TODO: Describe the mechanism for creating the ballot and verifying ownership + +#### "Vote" -> Inline Datum + +[ ] TODO: Describe and diagram the format for vote casting + +#### "Ballot Box" -> Smart Contract + +[ ] TODO: Describe the logic required in the "ballot box" smart contract and potential for extension + +### The Vote Counting Process + +#### "Ballot Counter" -> Authorized Wallet + +[ ] TODO: Describe the mechanism for creating the ballot and verifying ownership + +### Reclaiming ADA Locked by the Ballot NFTs + +[ ] TODO: Describe how/why ballot minting policy allows any user to burn the asset + +## Potential Disadvantages + +[ ] TODO: Token proliferation +[ ] TODO: Lack of easy way to determine vote after it is cast but before counted +[ ] TODO: Non-secret nature of the ballot if that is desired + +## Backward Compatibility + +Due to the nature of Plutus minting policies and smart contracts, which derive policy identifiers and payment addresses from the actual source code, once a vote has been started it cannot change versions or code implementations. However, because the mechanism we propose here is just a reference architecture, between votes projects are free to change either the "ballot" Plutus minting policy or the "ballot box" smart contract as they see fit. There are no prior CIPs with which to conform with for backward interoperability. + +## Path to Active + +- Considerations for ranked-choice voting if projects wish to have it +- Minimal reference implementation making use of [Lucid](https://github.com/spacebudz/lucid) (off-chain), [Plutus Core](https://github.com/input-output-hk/plutus) [using Helios](https://github.com/Hyperion-BT/Helios) (on-chain): [Implementation](./example/) +- Open-source implementations from other NFT projects that make use of this CIP + +## References + +- [CIP-0025](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0025): NFT Metadata Standard +- [CIP-0030](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0030): Cardano dApp-Wallet Web Bridge +- [CIP-0068](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068): Datum Metadata Standard + +## Copyright + +This CIP is licensed under [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/legalcode). diff --git a/CIP-0071/example/voting.html b/CIP-0071/example/voting.html new file mode 100644 index 000000000..31edf020a --- /dev/null +++ b/CIP-0071/example/voting.html @@ -0,0 +1,149 @@ + + + + + + + + + + + + + +
+
+ +
+

Connect your wallet to display eligible votes...

+
+ +
+
+ Please vote on the question posed below (1 answer only) +

If you were to choose only one letter from the first 5 in the alphabet, what would it be?

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+

Administrative Use Only

+ + +
+
+ +
+ Vote results will appear here... +
+ +
+
+

Done With Your Ballots? Want to Reclaim Your ADA?

+ +
+
+
+ + + + + diff --git a/CIP-0071/example/voting.js b/CIP-0071/example/voting.js new file mode 100644 index 000000000..250a25480 --- /dev/null +++ b/CIP-0071/example/voting.js @@ -0,0 +1,454 @@ +import * as helios from '@hyperionbt/helios'; + +import {Data, fromHex, toHex, getAddressDetails} from 'lucid-cardano'; + +// These modules are imported from https://github.com/thaddeusdiamond/cardano-nft-mint-frontend +// but elided in the source code of the CIP for space +import * as CardanoDAppJs from '../third-party/cardano-dapp-js.js'; +import * as LucidInst from '../third-party/lucid-inst.js'; +import * as NftPolicy from "../nft-toolkit/nft-policy.js"; +import {shortToast} from '../third-party/toastify-utils.js'; +import {validate, validated} from '../nft-toolkit/utils.js'; + +const BURN_REDEEMER = 'd87a80'; +const MAX_NFTS_TO_MINT = 20; +const MAX_ATTEMPTS = 12; +const OPTIMIZE_HELIOS = true; +const SINGLE_NFT = 1n; +const TEN_MINS = 600000; +const TXN_WAIT_TIMEOUT = 15000; + +function getVoteCounterSourceCode(pubKeyHash) { + return ` + spending vote_counter + + const EXPECTED_SIGNER: PubKeyHash = PubKeyHash::new(#${pubKeyHash}) + + func main(ctx: ScriptContext) -> Bool { + ctx.tx.is_signed_by(EXPECTED_SIGNER) + } + `; +} + +function getBallotSourceCodeStr(referencePolicyId, pollsClose, pubKeyHash, ballotPrefix) { + return ` + minting voting_ballot + + const BALLOT_BOX_PUBKEY: ValidatorHash = ValidatorHash::new(#${pubKeyHash}) + const BALLOT_NAME_PREFIX: ByteArray = #${ballotPrefix} + const POLLS_CLOSE: Time = Time::new(${pollsClose}) + const REFERENCE_POLICY_HASH: MintingPolicyHash = MintingPolicyHash::new(#${referencePolicyId}) + + enum Redeemer { + Mint + Burn + } + + func assets_locked_in_script(tx: Tx, minted_assets: Value) -> Bool { + //print(tx.value_sent_to(BALLOT_BOX_PUBKEY).serialize().show()); + //print(minted_assets.serialize().show()); + ballots_sent: Value = tx.value_locked_by(BALLOT_BOX_PUBKEY); + assets_locked: Bool = ballots_sent.contains(minted_assets); + if (assets_locked) { + true + } else { + print("Minted ballots (" + minted_assets.serialize().show() + ") were not correctly locked in the script: " + ballots_sent.serialize().show()); + false + } + } + + func assets_were_spent(minted: Value, policy: MintingPolicyHash, outputs: []TxOutput) -> Bool { + minted_assets: Map[ByteArray]Int = minted.get_policy(policy); + reference_assets_names: Map[ByteArray]Int = minted_assets.map_keys((asset_id: ByteArray) -> ByteArray { + asset_id.slice(BALLOT_NAME_PREFIX.length, asset_id.length) + }); + reference_assets: Map[MintingPolicyHash]Map[ByteArray]Int = Map[MintingPolicyHash]Map[ByteArray]Int { + REFERENCE_POLICY_HASH: reference_assets_names + }; + tx_sends_to_self: Bool = outputs.head.value.contains(Value::from_map(reference_assets)); + if (tx_sends_to_self) { + true + } else { + print("The NFTs with voting power (" + REFERENCE_POLICY_HASH.serialize().show() + ") for the ballots were never sent-to-self"); + false + } + } + + func polls_are_still_open(time_range: TimeRange) -> Bool { + tx_during_polls_open: Bool = time_range.is_before(POLLS_CLOSE); + if (tx_during_polls_open) { + true + } else { + print("Invalid time range: " + time_range.serialize().show() + " (polls close at " + POLLS_CLOSE.serialize().show() + ")"); + false + } + } + + func main(redeemer: Redeemer, ctx: ScriptContext) -> Bool { + tx: Tx = ctx.tx; + minted_policy: MintingPolicyHash = ctx.get_current_minting_policy_hash(); + redeemer.switch { + Mint => { + polls_are_still_open(tx.time_range) + && assets_were_spent(tx.minted, minted_policy, tx.outputs) + && assets_locked_in_script(tx, tx.minted) + }, + Burn => { + tx.minted.get_policy(minted_policy).all((asset_id: ByteArray, amount: Int) -> Bool { + if (amount > 0) { + print(asset_id.show() + " asset ID was minted not burned (quantity " + amount.show() + ")"); + false + } else { + true + } + }) + } + } + } + `; +} + +function getCompiledCode(mintingSourceCode) { + return helios.Program.new(mintingSourceCode).compile(OPTIMIZE_HELIOS); +} + +function getLucidScript(compiledCode) { + return { + type: "PlutusV2", + script: JSON.parse(compiledCode.serialize()).cborHex + } +} + +function getBallotSelection(ballotDomName) { + return document.querySelector(`input[name=${ballotDomName}]:checked`)?.value; +} + +async function waitForTxn(lucid, blockfrostKey, txHash) { + for (var attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const result = await fetch(`${lucid.provider.data.url}/txs/${txHash}`, { + headers: { project_id: blockfrostKey } + }).then(res => res.json()); + if (result && !result.error) { + return; + } + + if (attempt < (MAX_ATTEMPTS - 1)) { + await new Promise(resolve => setTimeout(resolve, TXN_WAIT_TIMEOUT)); + } + } + throw `Could not retrieve voting txn after ${MAX_ATTEMPTS} attempts`; +} + + +export async function mintBallot(blockfrostKey, pubKeyHash, policyId, pollsClose, ballotDomName, ballotPrefix, ballotMetadata) { + try { + const cardanoDApp = CardanoDAppJs.getCardanoDAppInstance(); + validate(cardanoDApp.isWalletConnected(), 'Please connect a wallet before voting using "Connect Wallet" button'); + const wallet = await cardanoDApp.getConnectedWallet(); + + const lucid = validated(await LucidInst.getLucidInstance(blockfrostKey), 'Please validate that your wallet is on the correct network'); + lucid.selectWallet(wallet); + const voter = await lucid.wallet.address(); + + const voteCounterSourceCode = getVoteCounterSourceCode(pubKeyHash); + const voteCounterCompiledCode = getCompiledCode(voteCounterSourceCode); + const voteCounterScript = getLucidScript(voteCounterCompiledCode) + const voteCounter = lucid.utils.validatorToAddress(voteCounterScript); + const voteCounterPkh = getAddressDetails(voteCounter).paymentCredential.hash; + + const ballotPrefixHex = toHex(new TextEncoder().encode(ballotPrefix)); + const mintingSourceCode = getBallotSourceCodeStr(policyId, pollsClose, voteCounterPkh, ballotPrefixHex); + const mintingCompiledCode = getCompiledCode(mintingSourceCode); + const voteMintingPolicy = getLucidScript(mintingCompiledCode); + const voteMintingPolicyId = mintingCompiledCode.mintingPolicyHash.hex; + + const vote = validated(getBallotSelection(ballotDomName), 'Please select your ballot choice!'); + const voteDatum = { + inline: Data.to(Data.fromJson({ voter: voter, vote: vote })) + }; + + const votingAssets = await getVotingAssets([policyId], [], lucid); + const assetIds = Object.keys(votingAssets.assets); + var assetIdsChunked = []; + for (var i = 0; i < assetIds.length; i += MAX_NFTS_TO_MINT) { + assetIdsChunked.push(assetIds.slice(i, i + MAX_NFTS_TO_MINT)); + } + if (assetIdsChunked.length > 1) { + validate( + confirm(`We will have to split your votes into ${assetIdsChunked.length} different transactions due to blockchain size limits, should we proceed?`), + "Did not agree to submit multiple voting transactions" + ); + } + + for (var i = 0; i < assetIdsChunked.length; i++) { + var mintAssets = {}; + var referenceAssets = {}; + var mintingMetadata = { [voteMintingPolicyId]: {}, version: NftPolicy.CIP0025_VERSION } + for (const assetId of assetIdsChunked[i]) { + const assetName = assetId.slice(56); + const ballotNameHex = `${ballotPrefixHex}${assetName}`; + const ballotName = new TextDecoder().decode(fromHex(ballotNameHex)); + mintAssets[`${voteMintingPolicyId}${ballotNameHex}`] = SINGLE_NFT; + referenceAssets[`${policyId}${assetName}`] = SINGLE_NFT; + mintingMetadata[voteMintingPolicyId][ballotName] = Object.assign({}, ballotMetadata); + mintingMetadata[voteMintingPolicyId][ballotName].name = ballotName; + mintingMetadata[voteMintingPolicyId][ballotName].vote = vote; + } + + const txBuilder = lucid.newTx() + .addSigner(voter) + .mintAssets(mintAssets, Data.empty()) + .attachMintingPolicy(voteMintingPolicy) + .attachMetadata(NftPolicy.METADATA_KEY, mintingMetadata) + .payToAddress(voter, referenceAssets) + .payToContract(voteCounter, voteDatum, mintAssets) + .validTo(new Date().getTime() + TEN_MINS); + + const txComplete = await txBuilder.complete({ nativeUplc: false }); + const txSigned = await txComplete.sign().complete(); + const txHash = await txSigned.submit(); + shortToast(`[${i + 1}/${assetIdsChunked.length}] Successfully voted in Tx ${txHash}`); + if (i < (assetIdsChunked.length - 1)) { + shortToast('Waiting for prior transaction to finish, please wait for pop-ups to complete your vote!'); + await waitForTxn(lucid, blockfrostKey, txHash); + } else { + shortToast('Your vote(s) have been successfully recorded!'); + } + } + return true; + } catch (err) { + shortToast(JSON.stringify(err)); + } + return false; +} + +async function getVotingAssets(votingPolicies, exclusions, lucid) { + if (votingPolicies === undefined || votingPolicies === []) { + return {}; + } + const votingAssets = {}; + const utxos = []; + for (const utxo of await lucid.wallet.getUtxos()) { + var found = false; + for (const assetName in utxo.assets) { + if (!votingPolicies.includes(assetName.slice(0, 56))) { + continue; + } + if (exclusions.includes(assetName)) { + continue; + } + if (votingAssets[assetName] === undefined) { + votingAssets[assetName] = 0n; + } + votingAssets[assetName] += utxo.assets[assetName]; + found = true; + } + if (found) { + utxos.push(utxo); + } + } + return { assets: votingAssets, utxos: utxos }; +} + +async function walletVotingAssets(blockfrostKey, votingPolicies, exclusions) { + var cardanoDApp = CardanoDAppJs.getCardanoDAppInstance(); + if (!cardanoDApp.isWalletConnected()) { + return {}; + } + + try { + const wallet = await cardanoDApp.getConnectedWallet(); + const lucidInst = validated(LucidInst.getLucidInstance(blockfrostKey), 'Unable to initialize Lucid, network mismatch detected'); + + const lucid = validated(await lucidInst, 'Unable to initialize Lucid, network mismatch detected'); + lucid.selectWallet(wallet); + return await getVotingAssets(votingPolicies, exclusions, lucid); + } catch (err) { + const msg = (typeof(err) === 'string') ? err : JSON.stringify(err); + shortToast(`Voting power retrieval error occurred: ${msg}`); + return {}; + } +} + +export async function votingAssetsAvailable(blockfrostKey, votingPolicies, exclusions) { + const votingAssets = await walletVotingAssets(blockfrostKey, votingPolicies, exclusions); + if (votingAssets.assets) { + const remainingVotingBigInt = + Object.values(votingAssets.assets) + .reduce((partialSum, a) => partialSum + a, 0n); + return Number(remainingVotingBigInt); + } + return -1; +} + +export async function countBallots(blockfrostKey, pubKeyHash, policyId, pollsClose, voteOutputDom, ballotPrefix) { + try { + const cardanoDApp = CardanoDAppJs.getCardanoDAppInstance(); + validate(cardanoDApp.isWalletConnected(), 'Please connect a wallet before voting using "Connect Wallet" button'); + const wallet = await cardanoDApp.getConnectedWallet(); + + const lucid = validated(await LucidInst.getLucidInstance(blockfrostKey), 'Please validate that your wallet is on the correct network'); + lucid.selectWallet(wallet); + const oracle = await lucid.wallet.address(); + + const voteCounterSourceCode = getVoteCounterSourceCode(pubKeyHash); + const voteCounterCompiledCode = getCompiledCode(voteCounterSourceCode); + const voteCounterScript = getLucidScript(voteCounterCompiledCode) + const voteCounter = lucid.utils.validatorToAddress(voteCounterScript); + const voteCounterPkh = getAddressDetails(voteCounter).paymentCredential.hash; + + const ballotPrefixHex = toHex(new TextEncoder().encode(ballotPrefix)); + const mintingSourceCode = getBallotSourceCodeStr(policyId, pollsClose, voteCounterPkh, ballotPrefixHex); + const mintingCompiledCode = getCompiledCode(mintingSourceCode); + const mintingPolicyId = mintingCompiledCode.mintingPolicyHash.hex; + + var voteAssets = {}; + const votes = await lucid.utxosAt(voteCounter); + for (const vote of votes) { + const voteResult = Data.toJson(Data.from(vote.datum)); + for (const unit in vote.assets) { + if (!unit.startsWith(mintingPolicyId)) { + continue; + } + const voteCount = Number(vote.assets[unit]); + voteAssets[unit] = { + voter: voteResult.voter, + vote: voteResult.vote, + count: voteCount + } + } + } + + const votePrinted = JSON.stringify(voteAssets, undefined, 4); + document.getElementById(voteOutputDom).innerHTML = + `
${JSON.stringify(voteAssets, undefined, 4)}
`; + return votePrinted; + } catch (err) { + shortToast(JSON.stringify(err)); + } +} + +export async function redeemBallots(blockfrostKey, pubKeyHash, policyId, pollsClose, voteOutputDom, ballotPrefix) { + try { + const cardanoDApp = CardanoDAppJs.getCardanoDAppInstance(); + validate(cardanoDApp.isWalletConnected(), 'Please connect a wallet before voting using "Connect Wallet" button'); + const wallet = await cardanoDApp.getConnectedWallet(); + + const lucid = validated(await LucidInst.getLucidInstance(blockfrostKey), 'Please validate that your wallet is on the correct network'); + lucid.selectWallet(wallet); + const oracle = await lucid.wallet.address(); + + const voteCounterSourceCode = getVoteCounterSourceCode(pubKeyHash); + const voteCounterCompiledCode = getCompiledCode(voteCounterSourceCode); + const voteCounterScript = getLucidScript(voteCounterCompiledCode) + const voteCounter = lucid.utils.validatorToAddress(voteCounterScript); + const voteCounterPkh = getAddressDetails(voteCounter).paymentCredential.hash; + + const ballotPrefixHex = toHex(new TextEncoder().encode(ballotPrefix)); + const mintingSourceCode = getBallotSourceCodeStr(policyId, pollsClose, voteCounterPkh, ballotPrefixHex); + const mintingCompiledCode = getCompiledCode(mintingSourceCode); + const mintingPolicyId = mintingCompiledCode.mintingPolicyHash.hex; + + var voterRepayments = {}; + const votesToCollect = []; + const votes = await lucid.utxosAt(voteCounter); + for (const vote of votes) { + const voteResult = Data.toJson(Data.from(vote.datum)); + var hasVote = false; + for (const unit in vote.assets) { + if (!unit.startsWith(mintingPolicyId)) { + continue; + } + hasVote = true; + const voteCount = Number(vote.assets[unit]); + if (!(voteResult.voter in voterRepayments)) { + voterRepayments[voteResult.voter] = {} + } + voterRepayments[voteResult.voter][unit] = voteCount; + } + + if (hasVote) { + votesToCollect.push(vote); + } + } + + const txBuilder = lucid.newTx() + .addSigner(oracle) + .collectFrom(votesToCollect, Data.empty()) + .attachSpendingValidator(voteCounterScript); + for (const voter in voterRepayments) { + txBuilder.payToAddress(voter, voterRepayments[voter]); + } + const txComplete = await txBuilder.complete({ nativeUplc: false }); + const txSigned = await txComplete.sign().complete(); + const txHash = await txSigned.submit(); + shortToast(`Successfully counted ballots in ${txHash}`); + } catch (err) { + shortToast(JSON.stringify(err)); + } +} + +export async function burnBallots(blockfrostKey, pubKeyHash, policyId, pollsClose, ballotPrefix) { + try { + const cardanoDApp = CardanoDAppJs.getCardanoDAppInstance(); + validate(cardanoDApp.isWalletConnected(), 'Please connect a wallet before voting using "Connect Wallet" button'); + const wallet = await cardanoDApp.getConnectedWallet(); + + const lucid = validated(await LucidInst.getLucidInstance(blockfrostKey), 'Please validate that your wallet is on the correct network'); + lucid.selectWallet(wallet); + const voter = await lucid.wallet.address(); + + const voteCounterSourceCode = getVoteCounterSourceCode(pubKeyHash); + const voteCounterCompiledCode = getCompiledCode(voteCounterSourceCode); + const voteCounterScript = getLucidScript(voteCounterCompiledCode) + const voteCounter = lucid.utils.validatorToAddress(voteCounterScript); + const voteCounterPkh = getAddressDetails(voteCounter).paymentCredential.hash; + + const ballotPrefixHex = toHex(new TextEncoder().encode(ballotPrefix)); + const mintingSourceCode = getBallotSourceCodeStr(policyId, pollsClose, voteCounterPkh, ballotPrefixHex); + const mintingCompiledCode = getCompiledCode(mintingSourceCode); + const mintingPolicy = getLucidScript(mintingCompiledCode); + const mintingPolicyId = mintingCompiledCode.mintingPolicyHash.hex; + + const utxos = await lucid.wallet.getUtxos(); + const utxosToCollect = []; + const mintAssets = {}; + var hasAlerted = false; + for (const utxo of utxos) { + var foundAsset = false; + for (const unit in utxo.assets) { + if (unit.startsWith(mintingPolicyId)) { + if (Object.keys(mintAssets).length >= MAX_NFTS_TO_MINT) { + if (!hasAlerted) { + alert(`Can only burn ${MAX_NFTS_TO_MINT} ballots to burn at a time. Start with that, then click this button again.`); + hasAlerted = true; + } + break; + } + foundAsset = true; + if (!(unit in mintAssets)) { + mintAssets[unit] = 0n; + } + mintAssets[unit] -= utxo.assets[unit]; + } + } + + if (foundAsset) { + utxosToCollect.push(utxo); + } + } + + const txBuilder = lucid.newTx() + .addSigner(voter) + .collectFrom(utxosToCollect) + .mintAssets(mintAssets, BURN_REDEEMER) + .attachMintingPolicy(mintingPolicy) + .validTo(new Date().getTime() + TEN_MINS); + const txComplete = await txBuilder.complete({ nativeUplc: false }); + const txSigned = await txComplete.sign().complete(); + const txHash = await txSigned.submit(); + shortToast(`Successfully burned your ballots in ${txHash}`); + } catch (err) { + shortToast(JSON.stringify(err)); + } +} From 2ae31c636573846a7f3530a700bc3ed01f0abd0a Mon Sep 17 00:00:00 2001 From: Thaddeus Diamond Date: Thu, 13 Oct 2022 16:07:36 -0500 Subject: [PATCH 2/8] CIP-0071: Draft revision See changes in README.md --- CIP-0071/README.md | 194 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 171 insertions(+), 23 deletions(-) diff --git a/CIP-0071/README.md b/CIP-0071/README.md index 426aac8b7..9e47d33b1 100644 --- a/CIP-0071/README.md +++ b/CIP-0071/README.md @@ -12,7 +12,7 @@ License: CC-BY-4.0 ## Abstract -This proposal defines a standard mechanism for performing verifiable on-chain voting in NFT projects that do not want a governance token using inline datums, plutus minting policies, and smart contracts. +This proposal defines a standard mechanism for performing verifiable on-chain voting in NFT projects that do not have a governance token by using datums, plutus minting policies, and smart contracts. ## Motivation @@ -29,22 +29,22 @@ It is not intended for complex voting applications or for governance against fun ### A Simple Analogy -The basic analogy here is that of a traditional state or federal vote. Imagine a citizen who has a state ID (e.g., Driver's License) and wants to vote. That citizen would: -1. Go to a precinct and show their ID to the appropriate authority -2. Receive a ballot with choices for the current vote -3. Mark their selections on the ballot -4. Sign their ballot with their name -5. Submit their ballot into a single "ballot box" -6. Authorized vote counting authorities process the vote after polls close -7. Await the election results through a trusted news outlet +The basic analogy here is that of a traditional state or federal vote. Imagine a citizen who has a state ID (e.g., Driver's License) and wants to vote, as well as a central voting authority that counts all the ballots. +1. Citizens go to to a precinct and show their ID to the appropriate authority +2. Citizens receive a ballot with choices for the current vote +3. Citizens mark their selections on the ballot +4. Citizens sign their ballot with their name +5. Citizens submit their ballot into a single "ballot box" +6. Central voting authorities process the vote after polls close +7. Citizens await the election results through a trusted news outlet This specification follows the same process, but using tokens: 1. A holder of a project validates their NFT by sending it to self -2. The user signs a Plutus minting policy to create a proxy NFT that represents the current ballot -3. The holder marks their vote selections off-chain -4. The holder signs a new transaction that sends the proxy "ballot" NFT to a smart contract ("the ballot box") with a datum representing the vote -5. Authorized vote counting wallets process the UTxOs' datum in the "ballot box" smart contract after the polls close -6. Project creators report the results in a human-readable off-chain format to their holders. +2. The holder signs a Plutus minting policy to create a "ballot" NFT linked to their unique NFT +3. The holder marks their desired vote selections off-chain +4. The holder signs a tx that sends the "ballot" NFT to a "ballot box" (smart contract) with their "vote" (datum) +5. Authorized vote counting wallets process UTxOs and their datums in the "ballot box" smart contract after polls close +6. Authorized vote counters report the results in a human-readable off-chain format to holders Because of the efficient UTxO model Cardano employs, steps #1, #2, and #4 can occur in a single transaction. @@ -52,31 +52,178 @@ Because of the efficient UTxO model Cardano employs, steps #1, #2, and #4 can oc #### "Ballot" -> Plutus Minting Policy -[ ] TODO: Describe the mechanism for creating the ballot and verifying ownership +Every holder that participates in the vote will have their project NFT in a wallet that can be spent from (either hardware or software, and typically via [CIP-30](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0030)). To create a ballot, the voting authority will create a Plutus minting policy with a specific combination of: -#### "Vote" -> Inline Datum +```ts +type BallotMintingPolicy = { + referencePolicyId: MintingPolicyHash, // Reference policy ID of the original NFT project + pollsClose: Time, // Polls close (as a Unix timestamp in milliseconds) + assetNameMapping: func(ByteArray) -> ByteArray // Some function (potentially identity) to map reference NFT name 1-for-1 to ballot NFT name +}; +``` -[ ] TODO: Describe and diagram the format for vote casting +This Plutus minting policy will perform the following checks: +1. Polls are still open during the Tx validFrom/validTo interval +2. The reference NFT assets were spent (typically, sent-to-self) for each ballot being minted +3. The minted assets are sent directly to the ballot box smart contract in the minting transaction (see [the potential attack below](#Creation-of-Ballot-Without-Casting-a-Vote)) + +For the voter, each vote they wish to cast will require creating a separate "ballot" NFT. In the process, their reference NFT never leaves the original wallet. Sample [Helios language](https://github.com/Hyperion-BT/Helios) pseudocode (functions elided for space) is as follows: + +```ts +func main(redeemer: Redeemer, ctx: ScriptContext) -> Bool { + tx: Tx = ctx.tx; + minted_policy: MintingPolicyHash = ctx.get_current_minting_policy_hash(); + redeemer.switch { + Mint => { + polls_are_still_open(tx.time_range) + && assets_were_spent(tx.minted, minted_policy, tx.outputs) + && assets_locked_in_script(tx, tx.minted) + }, + // Burn code elided for space... + } +} +``` + +Note the open burning policy (see ["Reclaiming ADA Locked by the Ballot NFTs"](#Reclaiming-ADA-Locked-by-the-Ballot-NFTs) for more details). + +#### "Vote" -> eUTxO Datum + +To cast the vote, the user sends the ballot NFT just created to a "ballot box". Note that for reasons specified in [the "attacks" section below](#Creation-of-Ballot-Without-Casting-a-Vote) this needs to occur during the same transaction that the ballot was minted in. + +The datum is a simple object representing the voter who cast the vote and the vote itself: + +```ts +type VoteDatum = { + voter: PubKeyHash, + vote: object +}; +``` + +The `voter` element is extremely important in this datum so that we know who minted the ballot NFT and who we should return it to. At the end of the ballot counting process, this user will receive their ballot NFT back. + +Note that we are trying to avoid being overly prescriptive here with the specific vote type as we want the only limitations on the vote type to be those imposed by Cardano. Further iterations of this standard should discuss the potential for how to implement ranked-choice voting (RCV) inside of this `vote` object, support multiple-choice vote selection, and more. #### "Ballot Box" -> Smart Contract -[ ] TODO: Describe the logic required in the "ballot box" smart contract and potential for extension +Essentially, the "ballot box" is a smart contract with arbitrary logic decided upon by the authorized vote counter. Some samples include: + +1. A ballot box that can be redeemed at any time by a tx signed by the authorized vote counter +2. A ballot box that can be redeemed only after polls close +2. A ballot box that can be redeemed once a majority of voters have sent in a ballot (using datums) +3. A ballot box that can be redeemed only by the specific wallet specified in the `voter` datum of each UTxO + +Because the ballot creation and vote casting process has already occurred on-chain we want to provide the maximum flexibility in the protocol here so that each project can decide what is best for their own community. Helios code for the simple case enumerated as #1 above would be: + +```ts +const EXPECTED_SIGNER: PubKeyHash = PubKeyHash::new(#${pubKeyHash}) + +func main(ctx: ScriptContext) -> Bool { + ctx.tx.is_signed_by(EXPECTED_SIGNER) +} +``` ### The Vote Counting Process #### "Ballot Counter" -> Authorized Wallet -[ ] TODO: Describe the mechanism for creating the ballot and verifying ownership +Given the flexible nature of the ["ballot box" smart contract](#"Ballot-Box"-->-Smart-Contract) enumerated above, we propose a simple algorithm for counting votes and returning the ballots to the user: + +1. Ensure the polls are closed (can be either on or off-chain) +2. Iterate through all UTxOs in forward-time-order locked in the "ballot box" and for each + * Determine which assets are inside of that UTxO + * Mark their most recent vote to match the `vote` object in the UTxOs datum +3. Ensure any required quorums or thresholds were reached +4. Report on the final ballot outcome + +Javascript-like pseudocode using the [Lucid library](https://github.com/spacebudz/lucid) for the above algorithm would be as follows: + +```js +function countVotes(ballotPolicyId, ballotBox) { + var votesByAsset = {}; + const votes = await lucid.utxosAt(ballotBox); + for (const vote of votes) { + const voteResult = Data.toJson(Data.from(vote.datum)); + for (const unit in vote.assets) { + if (!unit.startsWith(ballotPolicyId)) { + continue; + } + const voteCount = Number(vote.assets[unit]); // Should always be 1 + votesByAsset[unit] = { + voter: voteResult.voter, + vote: voteResult.vote, + count: voteCount + } + } + } + return votesByAsset; +} +``` + +There is no requirement that the "ballot counter" redeem all "ballots" from the "ballot box" and send them back to the respective voters, but we anticipate that this is what will happen in practice. We encourage further open-sourced code versions that enforce this requirement at the smart contract level. ### Reclaiming ADA Locked by the Ballot NFTs -[ ] TODO: Describe how/why ballot minting policy allows any user to burn the asset +Even if the ballot NFT is returned to the user, this will leave users with ADA locked alongside these newly created assets, which can impose a financial hardship for certain project users. + +We can add burn-specific code to our Plutus minting policy so that ballot creation does not impose a major financial burden on users: + +```ts +func main(redeemer: Redeemer, ctx: ScriptContext) -> Bool { + tx: Tx = ctx.tx; + minted_policy: MintingPolicyHash = ctx.get_current_minting_policy_hash(); + redeemer.switch { + // Minting code elided for space... + Burn => { + tx.minted.get_policy(minted_policy).all((asset_id: ByteArray, amount: Int) -> Bool { + if (amount > 0) { + print(asset_id.show() + " asset ID was minted not burned (quantity " + amount.show() + ")"); + false + } else { + true + } + }) + } + } +} +``` + +The Helios code above simply checks that during a burn (as indicated by the Plutus minting policy's `redeemer`), the user is not attempting to mint a positive number of any assets. With this code, *any Cardano wallet* can burn *any ballot* minted as part of this protocol. Why so permissive? We want to ensure that each vote is bringing the minimal costs possible to the user. In providing this native burning mechanism we can free up the minUTxO that had been locked with the ballot, and enable the user to potentially participate in more votes they might not have otherwise. In addition, users who really do not like the specific commemorative NFTs or projects that choose to skip the "commemorative" aspect of ballot creation now have an easy way to dispose of "junk" assets. + +## Potential Attacks and Mitigations + +### Creation of Ballot Without Casting a Vote + +Imagine a user who decides to create ballots for the current vote, but not actually cast the vote by sending it to the ballot box. According to checks #1 and #2 in [the Plutus Minting Policy](#"Ballot"-->-Plutus-Minting-Policy), this would be possible. After the ballot was created, the user could sell the reference NFT and wait until just before the polls close to surreptitiously cast a vote over the wishes of the new project owner. Check #3 in the minting policy during the mint transaction itself prevents this attack. + +### Double Voting + +There are two potential ways that a user could double vote in this standard. + +First, the user could wait until their first vote casting transaction completes, then create more ballots and resubmit to the ballot box. The result would be the creation of more assets that count toward the ultimate vote. However, Cardano helps us here by referencing token supply based on the concatenation of policy ID and asset identifier. So long as the mapping function in the [Plutus minting policy for ballots](#"Ballot"-->-Plutus-Minting-Policy) is idempotent, each subsequent time the user votes the policy will create an additional fungible token with the same asset identifier. Then, the ballot counter can ignore any prior votes based on each unique asset identifier to avoid duplicate votes (see ["'Ballot Counter' -> Authorized Wallet"](#"Ballot-Counter"-->-Authorized-Wallet)). + +Secondly, the user could attempt to create multiple ballots of the same name for a given reference NFT. If the reference NFT is actually a fungible token and not an NFT, then our assumptions will have been broken and this is an unsupported use case. But if our assumption that this is an NFT project are correct, then simply checking that the quantity minted is equal to the quantity spent inside of the Plutus minting policy will prevent this. The [example code](./example/voting.js) attached does just that. + +### Returning the "Ballot" NFTs to the Wrong User + +During the construction of the ballot NFTs we allow the user to specify their vote alongside a `voter` field indicating where their "ballot" NFT should be returned to once the vote is fully counted. Unfortunately, this is not enforced inside the Plutus minting policy's code (largely due to CPU/memory constraints). So, we rely on the user to provide an accurate return address, which means that there is the potential for someone who has not actually voted to receive a commemorative NFT. This does not impact the protocol though, as the "ballot" NFT was legally minted, just returned to the incorrect location. That user actually received a gift, as they can now burn the ballot and receive some small amount of dust. ## Potential Disadvantages -[ ] TODO: Token proliferation -[ ] TODO: Lack of easy way to determine vote after it is cast but before counted -[ ] TODO: Non-secret nature of the ballot if that is desired +There are several potential disadvantages to this proposal that may be avoided by the use of a native token or other voting mechanism. We enumerate some here explicitly so projects can understand where this protocol may or may not be appropriate to use: + +- Projects concerned with token proliferation and confusing their user base with the creation of multiple new assets might want to avoid this standard as it requires one new token policy per vote/initiative +- Projects wishing to create a "secret ballot" that will not be revealed until after polls close should use this because the datum votes appear on-chain (and typically inline) + - Performing an encrypted vote on-chain with verifiable post-vote results is an exercise left to the standard's implementer +- Projects wishing for anonymity in their votes should not use this standard as each vote can be traced to a reference asset + +## Optional Recommendations + +In no particular order, we recommend the following implementation details that do not impact the protocol, but may impact user experience: + +- The mapping function described in the [Plutus minting policy for ballots](#"Ballot"-->-Plutus-Minting-Policy) should likely be some sort of prefixing or suffixing (e.g., "Ballot #1 - "), and NOT the identity function. Although the asset will be different than the reference NFT due to its differing policy ID, users are likely to be confused when viewing these assets in a token viewer. +- The "ballot NFT" should have some sort of unique metadata (if using [CIP-25](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0025)), datum (if using [CIP-68](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068)) or other identification so that the users can engage with the ballot in a fun, exciting way and to ensure there is no confusion with the reference NFT. +- The "vote" represented by a datum will be easier to debug and analyze in real-time if it uses the new "inline datum" feature from Vasil, but the protocol will still work on Alonzo era transactions. +- The "ballot box" smart contract should likely encode that the datum's "voter" field is respected when returning the ballots to users after voting has ended to provide greater transparency and trust for project participants (though it will not impact the protocol). ## Backward Compatibility @@ -93,6 +240,7 @@ Due to the nature of Plutus minting policies and smart contracts, which derive p - [CIP-0025](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0025): NFT Metadata Standard - [CIP-0030](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0030): Cardano dApp-Wallet Web Bridge - [CIP-0068](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068): Datum Metadata Standard +- [Helios Language](https://github.com/Hyperion-BT/Helios): On-Chain Cardano Smart Contract language used in example code ## Copyright From 7482f4683932909766a339ea87a021853308b93f Mon Sep 17 00:00:00 2001 From: Thaddeus Diamond Date: Thu, 13 Oct 2022 16:09:57 -0500 Subject: [PATCH 3/8] CIP-0071: Fix inline markdown anchors --- CIP-0071/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CIP-0071/README.md b/CIP-0071/README.md index 9e47d33b1..0e629d6f1 100644 --- a/CIP-0071/README.md +++ b/CIP-0071/README.md @@ -126,7 +126,7 @@ func main(ctx: ScriptContext) -> Bool { #### "Ballot Counter" -> Authorized Wallet -Given the flexible nature of the ["ballot box" smart contract](#"Ballot-Box"-->-Smart-Contract) enumerated above, we propose a simple algorithm for counting votes and returning the ballots to the user: +Given the flexible nature of the ["ballot box" smart contract](#Ballot-Box---Smart-Contract) enumerated above, we propose a simple algorithm for counting votes and returning the ballots to the user: 1. Ensure the polls are closed (can be either on or off-chain) 2. Iterate through all UTxOs in forward-time-order locked in the "ballot box" and for each @@ -193,13 +193,13 @@ The Helios code above simply checks that during a burn (as indicated by the Plut ### Creation of Ballot Without Casting a Vote -Imagine a user who decides to create ballots for the current vote, but not actually cast the vote by sending it to the ballot box. According to checks #1 and #2 in [the Plutus Minting Policy](#"Ballot"-->-Plutus-Minting-Policy), this would be possible. After the ballot was created, the user could sell the reference NFT and wait until just before the polls close to surreptitiously cast a vote over the wishes of the new project owner. Check #3 in the minting policy during the mint transaction itself prevents this attack. +Imagine a user who decides to create ballots for the current vote, but not actually cast the vote by sending it to the ballot box. According to checks #1 and #2 in [the Plutus Minting Policy](#Ballot---Plutus-Minting-Policy), this would be possible. After the ballot was created, the user could sell the reference NFT and wait until just before the polls close to surreptitiously cast a vote over the wishes of the new project owner. Check #3 in the minting policy during the mint transaction itself prevents this attack. ### Double Voting There are two potential ways that a user could double vote in this standard. -First, the user could wait until their first vote casting transaction completes, then create more ballots and resubmit to the ballot box. The result would be the creation of more assets that count toward the ultimate vote. However, Cardano helps us here by referencing token supply based on the concatenation of policy ID and asset identifier. So long as the mapping function in the [Plutus minting policy for ballots](#"Ballot"-->-Plutus-Minting-Policy) is idempotent, each subsequent time the user votes the policy will create an additional fungible token with the same asset identifier. Then, the ballot counter can ignore any prior votes based on each unique asset identifier to avoid duplicate votes (see ["'Ballot Counter' -> Authorized Wallet"](#"Ballot-Counter"-->-Authorized-Wallet)). +First, the user could wait until their first vote casting transaction completes, then create more ballots and resubmit to the ballot box. The result would be the creation of more assets that count toward the ultimate vote. However, Cardano helps us here by referencing token supply based on the concatenation of policy ID and asset identifier. So long as the mapping function in the [Plutus minting policy for ballots](#Ballot---Plutus-Minting-Policy) is idempotent, each subsequent time the user votes the policy will create an additional fungible token with the same asset identifier. Then, the ballot counter can ignore any prior votes based on each unique asset identifier to avoid duplicate votes (see ["'Ballot Counter' -> Authorized Wallet"](#Ballot-Counter---Authorized-Wallet)). Secondly, the user could attempt to create multiple ballots of the same name for a given reference NFT. If the reference NFT is actually a fungible token and not an NFT, then our assumptions will have been broken and this is an unsupported use case. But if our assumption that this is an NFT project are correct, then simply checking that the quantity minted is equal to the quantity spent inside of the Plutus minting policy will prevent this. The [example code](./example/voting.js) attached does just that. @@ -220,7 +220,7 @@ There are several potential disadvantages to this proposal that may be avoided b In no particular order, we recommend the following implementation details that do not impact the protocol, but may impact user experience: -- The mapping function described in the [Plutus minting policy for ballots](#"Ballot"-->-Plutus-Minting-Policy) should likely be some sort of prefixing or suffixing (e.g., "Ballot #1 - "), and NOT the identity function. Although the asset will be different than the reference NFT due to its differing policy ID, users are likely to be confused when viewing these assets in a token viewer. +- The mapping function described in the [Plutus minting policy for ballots](#Ballot---Plutus-Minting-Policy) should likely be some sort of prefixing or suffixing (e.g., "Ballot #1 - "), and NOT the identity function. Although the asset will be different than the reference NFT due to its differing policy ID, users are likely to be confused when viewing these assets in a token viewer. - The "ballot NFT" should have some sort of unique metadata (if using [CIP-25](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0025)), datum (if using [CIP-68](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068)) or other identification so that the users can engage with the ballot in a fun, exciting way and to ensure there is no confusion with the reference NFT. - The "vote" represented by a datum will be easier to debug and analyze in real-time if it uses the new "inline datum" feature from Vasil, but the protocol will still work on Alonzo era transactions. - The "ballot box" smart contract should likely encode that the datum's "voter" field is respected when returning the ballots to users after voting has ended to provide greater transparency and trust for project participants (though it will not impact the protocol). From f622cf354035349a9469ed5b41a3e3c70913fdc2 Mon Sep 17 00:00:00 2001 From: Thaddeus Diamond Date: Thu, 13 Oct 2022 16:19:17 -0500 Subject: [PATCH 4/8] CIP-0071: Proofreading edits [TDiamond] --- CIP-0071/README.md | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/CIP-0071/README.md b/CIP-0071/README.md index 0e629d6f1..e0d7c91ef 100644 --- a/CIP-0071/README.md +++ b/CIP-0071/README.md @@ -52,7 +52,7 @@ Because of the efficient UTxO model Cardano employs, steps #1, #2, and #4 can oc #### "Ballot" -> Plutus Minting Policy -Every holder that participates in the vote will have their project NFT in a wallet that can be spent from (either hardware or software, and typically via [CIP-30](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0030)). To create a ballot, the voting authority will create a Plutus minting policy with a specific combination of: +Every holder that participates in the vote will have their project NFT in a wallet that can be spent from (either hardware or software, typically accessed via [CIP-30](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0030)). To create a ballot, the voting authority will create a Plutus minting policy with a specific combination of: ```ts type BallotMintingPolicy = { @@ -84,8 +84,6 @@ func main(redeemer: Redeemer, ctx: ScriptContext) -> Bool { } ``` -Note the open burning policy (see ["Reclaiming ADA Locked by the Ballot NFTs"](#Reclaiming-ADA-Locked-by-the-Ballot-NFTs) for more details). - #### "Vote" -> eUTxO Datum To cast the vote, the user sends the ballot NFT just created to a "ballot box". Note that for reasons specified in [the "attacks" section below](#Creation-of-Ballot-Without-Casting-a-Vote) this needs to occur during the same transaction that the ballot was minted in. @@ -105,17 +103,17 @@ Note that we are trying to avoid being overly prescriptive here with the specifi #### "Ballot Box" -> Smart Contract -Essentially, the "ballot box" is a smart contract with arbitrary logic decided upon by the authorized vote counter. Some samples include: +Essentially, the "ballot box" is a smart contract with arbitrary logic decided upon by the authorized vote counter. Some examples include: 1. A ballot box that can be redeemed at any time by a tx signed by the authorized vote counter 2. A ballot box that can be redeemed only after polls close -2. A ballot box that can be redeemed once a majority of voters have sent in a ballot (using datums) +2. A ballot box that can be redeemed once a majority of voters have sent in a ballot 3. A ballot box that can be redeemed only by the specific wallet specified in the `voter` datum of each UTxO Because the ballot creation and vote casting process has already occurred on-chain we want to provide the maximum flexibility in the protocol here so that each project can decide what is best for their own community. Helios code for the simple case enumerated as #1 above would be: ```ts -const EXPECTED_SIGNER: PubKeyHash = PubKeyHash::new(#${pubKeyHash}) +const EXPECTED_SIGNER: PubKeyHash = PubKeyHash::new(#0123456789abcdef) func main(ctx: ScriptContext) -> Bool { ctx.tx.is_signed_by(EXPECTED_SIGNER) @@ -129,7 +127,7 @@ func main(ctx: ScriptContext) -> Bool { Given the flexible nature of the ["ballot box" smart contract](#Ballot-Box---Smart-Contract) enumerated above, we propose a simple algorithm for counting votes and returning the ballots to the user: 1. Ensure the polls are closed (can be either on or off-chain) -2. Iterate through all UTxOs in forward-time-order locked in the "ballot box" and for each +2. Iterate through all UTxOs in forward-time-order locked in the "ballot box" and for each: * Determine which assets are inside of that UTxO * Mark their most recent vote to match the `vote` object in the UTxOs datum 3. Ensure any required quorums or thresholds were reached @@ -195,17 +193,17 @@ The Helios code above simply checks that during a burn (as indicated by the Plut Imagine a user who decides to create ballots for the current vote, but not actually cast the vote by sending it to the ballot box. According to checks #1 and #2 in [the Plutus Minting Policy](#Ballot---Plutus-Minting-Policy), this would be possible. After the ballot was created, the user could sell the reference NFT and wait until just before the polls close to surreptitiously cast a vote over the wishes of the new project owner. Check #3 in the minting policy during the mint transaction itself prevents this attack. -### Double Voting +### Double Voting in Multiple Transactions -There are two potential ways that a user could double vote in this standard. +A user could wait until their first vote casting transaction completes, then create more ballots and resubmit to the ballot box. The result would be the creation of more assets that count toward the ultimate vote. However, Cardano helps us here by identifying tokens based on the concatenation of policy ID and asset identifier. So long as the mapping function in the [Plutus minting policy for ballots](#Ballot---Plutus-Minting-Policy) is idempotent, each subsequent time the user votes the policy will create an additional fungible token with the same asset identifier. Then, the ballot counter can ignore any prior votes based on each unique asset identifier to avoid duplicate votes (see ["'Ballot Counter' -> Authorized Wallet"](#Ballot-Counter---Authorized-Wallet)). -First, the user could wait until their first vote casting transaction completes, then create more ballots and resubmit to the ballot box. The result would be the creation of more assets that count toward the ultimate vote. However, Cardano helps us here by referencing token supply based on the concatenation of policy ID and asset identifier. So long as the mapping function in the [Plutus minting policy for ballots](#Ballot---Plutus-Minting-Policy) is idempotent, each subsequent time the user votes the policy will create an additional fungible token with the same asset identifier. Then, the ballot counter can ignore any prior votes based on each unique asset identifier to avoid duplicate votes (see ["'Ballot Counter' -> Authorized Wallet"](#Ballot-Counter---Authorized-Wallet)). +### Double Creation of the Same Ballot -Secondly, the user could attempt to create multiple ballots of the same name for a given reference NFT. If the reference NFT is actually a fungible token and not an NFT, then our assumptions will have been broken and this is an unsupported use case. But if our assumption that this is an NFT project are correct, then simply checking that the quantity minted is equal to the quantity spent inside of the Plutus minting policy will prevent this. The [example code](./example/voting.js) attached does just that. +A user could attempt to create multiple ballots of the same name for a given reference NFT. If the reference NFT is actually a fungible token and not an NFT, then our assumptions will have been broken and this is an unsupported use case. But if our assumption that this is an NFT project are correct, then simply checking that the quantity minted is equal to the quantity spent inside of the Plutus minting policy will prevent this. The [example code](./example/voting.js) attached does just that. ### Returning the "Ballot" NFTs to the Wrong User -During the construction of the ballot NFTs we allow the user to specify their vote alongside a `voter` field indicating where their "ballot" NFT should be returned to once the vote is fully counted. Unfortunately, this is not enforced inside the Plutus minting policy's code (largely due to CPU/memory constraints). So, we rely on the user to provide an accurate return address, which means that there is the potential for someone who has not actually voted to receive a commemorative NFT. This does not impact the protocol though, as the "ballot" NFT was legally minted, just returned to the incorrect location. That user actually received a gift, as they can now burn the ballot and receive some small amount of dust. +During the construction of the ballot NFTs we allow the user to specify their vote alongside a `voter` field indicating where their "ballot" NFT should be returned to once the vote is fully counted. Unfortunately, this is not strictly checked inside the Plutus minting policy's code (largely due to CPU/memory constraints). So, we rely on the user to provide an accurate return address, which means that there is the potential for someone who has not actually voted to receive a commemorative NFT. This does not impact the protocol though, as the "ballot" NFT was legally minted, just returned to the incorrect location. That user actually received a gift, as they can now burn the ballot and receive some small amount of dust. ## Potential Disadvantages @@ -221,9 +219,9 @@ There are several potential disadvantages to this proposal that may be avoided b In no particular order, we recommend the following implementation details that do not impact the protocol, but may impact user experience: - The mapping function described in the [Plutus minting policy for ballots](#Ballot---Plutus-Minting-Policy) should likely be some sort of prefixing or suffixing (e.g., "Ballot #1 - "), and NOT the identity function. Although the asset will be different than the reference NFT due to its differing policy ID, users are likely to be confused when viewing these assets in a token viewer. -- The "ballot NFT" should have some sort of unique metadata (if using [CIP-25](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0025)), datum (if using [CIP-68](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068)) or other identification so that the users can engage with the ballot in a fun, exciting way and to ensure there is no confusion with the reference NFT. +- The "ballot" NFT should have some sort of unique metadata (if using [CIP-25](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0025)), datum (if using [CIP-68](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068)) or other identification so that the users can engage with the ballot in a fun, exciting way and to ensure there is no confusion with the reference NFT. - The "vote" represented by a datum will be easier to debug and analyze in real-time if it uses the new "inline datum" feature from Vasil, but the protocol will still work on Alonzo era transactions. -- The "ballot box" smart contract should likely encode that the datum's "voter" field is respected when returning the ballots to users after voting has ended to provide greater transparency and trust for project participants (though it will not impact the protocol). +- The "ballot box" smart contract should likely enforce that the datum's "voter" field is respected when returning the ballots to users after voting has ended to provide greater transparency and trust for project participants. ## Backward Compatibility @@ -241,6 +239,7 @@ Due to the nature of Plutus minting policies and smart contracts, which derive p - [CIP-0030](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0030): Cardano dApp-Wallet Web Bridge - [CIP-0068](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068): Datum Metadata Standard - [Helios Language](https://github.com/Hyperion-BT/Helios): On-Chain Cardano Smart Contract language used in example code +- [Lucid](https://github.com/spacebudz/lucid): Transaction construction library used in code samples and pseudocode ## Copyright From c242db0b318882f08b572d0cd7ad2a2108d0ff80 Mon Sep 17 00:00:00 2001 From: Thaddeus Diamond Date: Thu, 13 Oct 2022 16:19:17 -0500 Subject: [PATCH 5/8] CIP-0071: Community reviewer edits [TDiamond] [See rendered Markdown](CIP-0071/README.md) --- CIP-0071/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CIP-0071/README.md b/CIP-0071/README.md index e0d7c91ef..dfbd658a3 100644 --- a/CIP-0071/README.md +++ b/CIP-0071/README.md @@ -4,7 +4,7 @@ Title: Non-Fungible Token (NFT) Proxy Voting Standard Authors: Thaddeus Diamond Comments-URI: Status: Proposed -Type: Informational +Type: Process Created: 2022-10-11 Post-History: License: CC-BY-4.0 @@ -107,8 +107,9 @@ Essentially, the "ballot box" is a smart contract with arbitrary logic decided u 1. A ballot box that can be redeemed at any time by a tx signed by the authorized vote counter 2. A ballot box that can be redeemed only after polls close -2. A ballot box that can be redeemed once a majority of voters have sent in a ballot -3. A ballot box that can be redeemed only by the specific wallet specified in the `voter` datum of each UTxO +3. A ballot box that can be redeemed once a majority of voters have sent in a ballot +4. A ballot box that can be redeemed only by the specific wallet specified in the `voter` datum of each UTxO +5. A ballot box that can be redeemed only after polls close, has to burn the ballots it redeems, and has to send the minUTxO back to the voter address Because the ballot creation and vote casting process has already occurred on-chain we want to provide the maximum flexibility in the protocol here so that each project can decide what is best for their own community. Helios code for the simple case enumerated as #1 above would be: @@ -210,7 +211,7 @@ During the construction of the ballot NFTs we allow the user to specify their vo There are several potential disadvantages to this proposal that may be avoided by the use of a native token or other voting mechanism. We enumerate some here explicitly so projects can understand where this protocol may or may not be appropriate to use: - Projects concerned with token proliferation and confusing their user base with the creation of multiple new assets might want to avoid this standard as it requires one new token policy per vote/initiative -- Projects wishing to create a "secret ballot" that will not be revealed until after polls close should use this because the datum votes appear on-chain (and typically inline) +- Projects wishing to create a "secret ballot" that will not be revealed until after polls close should not use this because the datum votes appear on-chain (and typically inline) - Performing an encrypted vote on-chain with verifiable post-vote results is an exercise left to the standard's implementer - Projects wishing for anonymity in their votes should not use this standard as each vote can be traced to a reference asset From 67d429d6ea695361109ac14b149b53b9800c2d73 Mon Sep 17 00:00:00 2001 From: Thaddeus Diamond Date: Thu, 13 Oct 2022 16:19:17 -0500 Subject: [PATCH 6/8] CIP-0071: ktorz revision edits [TDiamond] [See rendered Markdown](CIP-0071/README.md) --- CIP-0071/README.md | 45 +++++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/CIP-0071/README.md b/CIP-0071/README.md index dfbd658a3..b15d3ada1 100644 --- a/CIP-0071/README.md +++ b/CIP-0071/README.md @@ -12,7 +12,7 @@ License: CC-BY-4.0 ## Abstract -This proposal defines a standard mechanism for performing verifiable on-chain voting in NFT projects that do not have a governance token by using datums, plutus minting policies, and smart contracts. +This proposal uses plutus minting policies to create valid "ballots" that are sent alongside datum "votes" to a centralized smart contract "ballot box" in order to perform verifiable on-chain voting in NFT projects that do not have a governance token. ## Motivation @@ -23,7 +23,13 @@ This proposal is intended to provide a standard mechanism for non-fungible token This standard provides a simple solution for this by using the underlying NFT to mint a one-time "ballot" token that can be used only for a specific voting topic. Projects that adopt this standard will be able to integrate with web viewers that track projects' governance votes and will also receive the benefits of verifiable on-chain voting without requiring issuance of a new native token. -It is not intended for complex voting applications or for governance against fungible tokens that cannot be labeled individually. +We anticipate some potential use cases: +- Enforcing an exact 1:1 vote based on a user's existing NFT project holdings +- Enforcing vote validity by rejecting invalid vote options (e.g., disallowing write-ins) +- Creating "super-votes" based on an NFT serial number (e.g., rare NFTs in the 9,000-10,000 serial range get 2x votes) + +> **Warning** +> This specification is not intended for for governance against fungible tokens that cannot be labeled individually. ## Specification @@ -41,12 +47,13 @@ The basic analogy here is that of a traditional state or federal vote. Imagine This specification follows the same process, but using tokens: 1. A holder of a project validates their NFT by sending it to self 2. The holder signs a Plutus minting policy to create a "ballot" NFT linked to their unique NFT -3. The holder marks their desired vote selections off-chain +3. The holder marks their desired vote selections on the ballot 4. The holder signs a tx that sends the "ballot" NFT to a "ballot box" (smart contract) with their "vote" (datum) 5. Authorized vote counting wallets process UTxOs and their datums in the "ballot box" smart contract after polls close 6. Authorized vote counters report the results in a human-readable off-chain format to holders -Because of the efficient UTxO model Cardano employs, steps #1, #2, and #4 can occur in a single transaction. +> **Note** +> Because of the efficient UTxO model Cardano employs, steps #1 through #4 occur in a single transaction. ### The Vote Casting Process @@ -64,7 +71,7 @@ type BallotMintingPolicy = { This Plutus minting policy will perform the following checks: 1. Polls are still open during the Tx validFrom/validTo interval -2. The reference NFT assets were spent (typically, sent-to-self) for each ballot being minted +2. The ballot NFTs were validly minted (at the least, the user sent-to-self the reference assets and the vote weight/choices are correct) 3. The minted assets are sent directly to the ballot box smart contract in the minting transaction (see [the potential attack below](#Creation-of-Ballot-Without-Casting-a-Vote)) For the voter, each vote they wish to cast will require creating a separate "ballot" NFT. In the process, their reference NFT never leaves the original wallet. Sample [Helios language](https://github.com/Hyperion-BT/Helios) pseudocode (functions elided for space) is as follows: @@ -76,14 +83,17 @@ func main(redeemer: Redeemer, ctx: ScriptContext) -> Bool { redeemer.switch { Mint => { polls_are_still_open(tx.time_range) - && assets_were_spent(tx.minted, minted_policy, tx.outputs) - && assets_locked_in_script(tx, tx.minted) + && ballots_are_validly_minted(tx.minted, minted_policy, tx.outputs) + && assets_locked_in_ballot_box(tx, tx.minted) }, // Burn code elided for space... } } ``` +> **Note** +> `ballots_are_validly_minted()` includes all required and custom checks (e.g., the holder has sent the reference NFT to themselves in `tx.outputs`) to validate newly minted ballots + #### "Vote" -> eUTxO Datum To cast the vote, the user sends the ballot NFT just created to a "ballot box". Note that for reasons specified in [the "attacks" section below](#Creation-of-Ballot-Without-Casting-a-Vote) this needs to occur during the same transaction that the ballot was minted in. @@ -188,21 +198,31 @@ func main(redeemer: Redeemer, ctx: ScriptContext) -> Bool { The Helios code above simply checks that during a burn (as indicated by the Plutus minting policy's `redeemer`), the user is not attempting to mint a positive number of any assets. With this code, *any Cardano wallet* can burn *any ballot* minted as part of this protocol. Why so permissive? We want to ensure that each vote is bringing the minimal costs possible to the user. In providing this native burning mechanism we can free up the minUTxO that had been locked with the ballot, and enable the user to potentially participate in more votes they might not have otherwise. In addition, users who really do not like the specific commemorative NFTs or projects that choose to skip the "commemorative" aspect of ballot creation now have an easy way to dispose of "junk" assets. -## Potential Attacks and Mitigations +## Rationale + +### Using Inline Datums (On-Chain) Instead of Metadata (Off-Chain) + +There are several existing open-source protocols (e.g., [VoteAire](https://github.com/voteaire/voteaire-onchain-spec)) that use metadata to record votes in Cardano transactions without requiring any additional minting or smart contracts. However, since the vote counting occurs off-chain by validating metadata the vote counter is the ultimate arbiter of what is a "valid" vote. In this specification, the validity of the vote is ensured in the Ballot creation process, so that any vote in the ballot box is guaranteed to be valid. We strongly believe that moving the entire process into flexible on-chain logic will improve the transparency of the voting process and meet the needs of the use cases discussed in ["Motivation"](#Motivation) and ["Ballot Box"](#Ballot-Box---Smart-Contract). + +### Commemorative NFTs with Optional Token Burning + +There is a question as to whether we should enforce the requirement that votes be burned when they are counted by the vote counter. However, we do not want that to be a standard as many users of NFT communities have expressed an interest in receiving commemorative NFTs (similar to an "I Voted" sticker). Instead, we propose that the ballot Plutus minting policy be burn-able by anyone who holds the NFT in their wallet. This way, locked ADA can be reclaimed if the user has no further use for the commemorative NFT (see an example of this in the [Implementation](./example/)). + +### Potential Attacks and Mitigations -### Creation of Ballot Without Casting a Vote +#### Creation of Ballot Without Casting a Vote Imagine a user who decides to create ballots for the current vote, but not actually cast the vote by sending it to the ballot box. According to checks #1 and #2 in [the Plutus Minting Policy](#Ballot---Plutus-Minting-Policy), this would be possible. After the ballot was created, the user could sell the reference NFT and wait until just before the polls close to surreptitiously cast a vote over the wishes of the new project owner. Check #3 in the minting policy during the mint transaction itself prevents this attack. -### Double Voting in Multiple Transactions +#### Double Voting in Multiple Transactions A user could wait until their first vote casting transaction completes, then create more ballots and resubmit to the ballot box. The result would be the creation of more assets that count toward the ultimate vote. However, Cardano helps us here by identifying tokens based on the concatenation of policy ID and asset identifier. So long as the mapping function in the [Plutus minting policy for ballots](#Ballot---Plutus-Minting-Policy) is idempotent, each subsequent time the user votes the policy will create an additional fungible token with the same asset identifier. Then, the ballot counter can ignore any prior votes based on each unique asset identifier to avoid duplicate votes (see ["'Ballot Counter' -> Authorized Wallet"](#Ballot-Counter---Authorized-Wallet)). -### Double Creation of the Same Ballot +#### Double Creation of the Same Ballot A user could attempt to create multiple ballots of the same name for a given reference NFT. If the reference NFT is actually a fungible token and not an NFT, then our assumptions will have been broken and this is an unsupported use case. But if our assumption that this is an NFT project are correct, then simply checking that the quantity minted is equal to the quantity spent inside of the Plutus minting policy will prevent this. The [example code](./example/voting.js) attached does just that. -### Returning the "Ballot" NFTs to the Wrong User +#### Returning the "Ballot" NFTs to the Wrong User During the construction of the ballot NFTs we allow the user to specify their vote alongside a `voter` field indicating where their "ballot" NFT should be returned to once the vote is fully counted. Unfortunately, this is not strictly checked inside the Plutus minting policy's code (largely due to CPU/memory constraints). So, we rely on the user to provide an accurate return address, which means that there is the potential for someone who has not actually voted to receive a commemorative NFT. This does not impact the protocol though, as the "ballot" NFT was legally minted, just returned to the incorrect location. That user actually received a gift, as they can now burn the ballot and receive some small amount of dust. @@ -241,6 +261,7 @@ Due to the nature of Plutus minting policies and smart contracts, which derive p - [CIP-0068](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0068): Datum Metadata Standard - [Helios Language](https://github.com/Hyperion-BT/Helios): On-Chain Cardano Smart Contract language used in example code - [Lucid](https://github.com/spacebudz/lucid): Transaction construction library used in code samples and pseudocode +- [VoteAire Specification](https://github.com/voteaire/voteaire-onchain-spec): Open-source voting specification using metadata off-chain ## Copyright From 2945e449fb2fa5eca02443aeffa4b12149ad5bfd Mon Sep 17 00:00:00 2001 From: Thaddeus Diamond Date: Wed, 9 Nov 2022 10:58:19 -0600 Subject: [PATCH 7/8] CIP-0071: Update ADA label to ada per docs --- CIP-0071/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CIP-0071/README.md b/CIP-0071/README.md index b15d3ada1..ef84d0e13 100644 --- a/CIP-0071/README.md +++ b/CIP-0071/README.md @@ -17,7 +17,7 @@ This proposal uses plutus minting policies to create valid "ballots" that are se ## Motivation This proposal is intended to provide a standard mechanism for non-fungible token (NFT) projects to perform on-chain verifiable votes using only their NFT assets. There are several proposed solutions for governance that involve using either a service provider (e.g., Summon) with native assets or the issuance of proprietary native assets. However, there are several issues with these approaches: -- Airdrops of governance tokens require minUTxO ADA attached, costing the NFT project ADA out of pocket +- Airdrops of governance tokens require minUTxO ada attached, costing the NFT project ada out of pocket - Fungible tokens do not have a 1:1 mechanism for tracking votes against specific assets with voting power - Sale of the underlying NFT is not tied to sale of the governance token, creating separate asset classes and leaving voting power potentially with those who are no longer holders of the NFT @@ -170,9 +170,9 @@ function countVotes(ballotPolicyId, ballotBox) { There is no requirement that the "ballot counter" redeem all "ballots" from the "ballot box" and send them back to the respective voters, but we anticipate that this is what will happen in practice. We encourage further open-sourced code versions that enforce this requirement at the smart contract level. -### Reclaiming ADA Locked by the Ballot NFTs +### Reclaiming Ada Locked by the Ballot NFTs -Even if the ballot NFT is returned to the user, this will leave users with ADA locked alongside these newly created assets, which can impose a financial hardship for certain project users. +Even if the ballot NFT is returned to the user, this will leave users with ada locked alongside these newly created assets, which can impose a financial hardship for certain project users. We can add burn-specific code to our Plutus minting policy so that ballot creation does not impose a major financial burden on users: @@ -206,7 +206,7 @@ There are several existing open-source protocols (e.g., [VoteAire](https://githu ### Commemorative NFTs with Optional Token Burning -There is a question as to whether we should enforce the requirement that votes be burned when they are counted by the vote counter. However, we do not want that to be a standard as many users of NFT communities have expressed an interest in receiving commemorative NFTs (similar to an "I Voted" sticker). Instead, we propose that the ballot Plutus minting policy be burn-able by anyone who holds the NFT in their wallet. This way, locked ADA can be reclaimed if the user has no further use for the commemorative NFT (see an example of this in the [Implementation](./example/)). +There is a question as to whether we should enforce the requirement that votes be burned when they are counted by the vote counter. However, we do not want that to be a standard as many users of NFT communities have expressed an interest in receiving commemorative NFTs (similar to an "I Voted" sticker). Instead, we propose that the ballot Plutus minting policy be burn-able by anyone who holds the NFT in their wallet. This way, locked ada can be reclaimed if the user has no further use for the commemorative NFT (see an example of this in the [Implementation](./example/)). ### Potential Attacks and Mitigations From 225fbb1165a3960cae8244d1c08da830ee95f068 Mon Sep 17 00:00:00 2001 From: Robert Phair Date: Wed, 9 Nov 2022 23:08:09 +0530 Subject: [PATCH 8/8] Update CIP-0071/example/voting.html one final update for ada vs. ADA typographical convention --- CIP-0071/example/voting.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CIP-0071/example/voting.html b/CIP-0071/example/voting.html index 31edf020a..2fbbaa086 100644 --- a/CIP-0071/example/voting.html +++ b/CIP-0071/example/voting.html @@ -70,7 +70,7 @@

Administrative Use Only

-

Done With Your Ballots? Want to Reclaim Your ADA?

+

Done With Your Ballots? Want to Reclaim Your Ada?