diff --git a/CIP-0071/README.md b/CIP-0071/README.md new file mode 100644 index 000000000..ef84d0e13 --- /dev/null +++ b/CIP-0071/README.md @@ -0,0 +1,268 @@ +--- +CIP: 71 +Title: Non-Fungible Token (NFT) Proxy Voting Standard +Authors: Thaddeus Diamond +Comments-URI: +Status: Proposed +Type: Process +Created: 2022-10-11 +Post-History: +License: CC-BY-4.0 +--- + +## Abstract + +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 + +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. + +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 + +### 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, 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 holder signs a Plutus minting policy to create a "ballot" NFT linked to their unique NFT +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 + +> **Note** +> Because of the efficient UTxO model Cardano employs, steps #1 through #4 occur in a single transaction. + +### The Vote Casting Process + +#### "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, 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 = { + 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 +}; +``` + +This Plutus minting policy will perform the following checks: +1. Polls are still open during the Tx validFrom/validTo interval +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: + +```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) + && 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. + +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 + +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 +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: + +```ts +const EXPECTED_SIGNER: PubKeyHash = PubKeyHash::new(#0123456789abcdef) + +func main(ctx: ScriptContext) -> Bool { + ctx.tx.is_signed_by(EXPECTED_SIGNER) +} +``` + +### The Vote Counting Process + +#### "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: + +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 + +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. + +## 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 + +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 + +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 + +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 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 + +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 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 + +## 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 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 + +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 +- [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 + +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..2fbbaa086 --- /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)); + } +}