commit 573364608293674d8752f5cd5a39bd1de8675e4c
parent 6e8d29186fd5c55d5025aab629d43fb96de3ff10
Author: William Muli <willi.wambu@gmail.com>
Date:   Thu, 20 Jul 2023 22:22:17 +0300
Hash on frontend and check for match
Diffstat:
6 files changed, 235 insertions(+), 115 deletions(-)
diff --git a/package-lock.json b/package-lock.json
@@ -10,6 +10,7 @@
 			"dependencies": {
 				"@wagmi/chains": "^1.1.0",
 				"ethers": "^6.5.1",
+				"jssha": "^3.3.0",
 				"svelte-french-toast": "^1.0.4",
 				"viem": "^1.0.7"
 			},
@@ -2431,6 +2432,14 @@
 			"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
 			"dev": true
 		},
+		"node_modules/jssha": {
+			"version": "3.3.0",
+			"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.0.tgz",
+			"integrity": "sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==",
+			"engines": {
+				"node": "*"
+			}
+		},
 		"node_modules/kleur": {
 			"version": "4.1.5",
 			"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -5766,6 +5775,11 @@
 			"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
 			"dev": true
 		},
+		"jssha": {
+			"version": "3.3.0",
+			"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.0.tgz",
+			"integrity": "sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w=="
+		},
 		"kleur": {
 			"version": "4.1.5",
 			"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
diff --git a/package.json b/package.json
@@ -39,6 +39,7 @@
 	"dependencies": {
 		"@wagmi/chains": "^1.1.0",
 		"ethers": "^6.5.1",
+		"jssha": "^3.3.0",
 		"svelte-french-toast": "^1.0.4",
 		"viem": "^1.0.7"
 	}
diff --git a/src/components/ProposalForm.svelte b/src/components/ProposalForm.svelte
@@ -6,35 +6,29 @@
 	import { voteContractAbi } from '../shared/token-vote-abi'
 	import { Routes } from '../shared/types'
 	import { Wala } from '$lib/wala'
+  import { computeHash } from '$lib/hash'
 
   type ProposalFormData = {
     description: string
     targetVote: number
     blockWait: number
-    options: string[]
   }
 
   const formData: ProposalFormData = {
     description: '',
     targetVote: 0,
     blockWait: 0,
-    options: []
   }
 
   let submitting: boolean = false
   
 	const onSubmit = async () => {
-    const { description, targetVote, blockWait, options } = formData
+    const { description, targetVote, blockWait } = formData
     if (description === '' || targetVote === 0 || blockWait === 0) return
 
     try {
-      const optionsValid = options.some(option => option.trim() !== '') && options.length > 0
       submitting = true
-      if (optionsValid) {
-        await createWithOptions(description, blockWait, targetVote, options)
-      } else {
-        await createWithoutOptions(description, blockWait, targetVote)
-      }
+      await createWithoutOptions(description, blockWait, targetVote)
       submitting = false
       goto(Routes.Home)    
     } catch (error) {
@@ -42,8 +36,15 @@
     }
 	}
 
-  const createWithoutOptions = async(description: string, blockWait: number, targetVote: number) => {
+  const createWithoutOptions = async (description: string, blockWait: number, targetVote: number) => {
+    const localHash = await computeHash(description, 'SHA-256')
     const descriptionHash = await new Wala().put(description, 'text/plain')
+    // compare hashes
+    if (localHash !== descriptionHash) {
+      console.error(`Computed description hash does not match wala hash. Local Hash: ${localHash}, Wala Hash: ${descriptionHash}`)
+    } else {
+      console.log('Description hashes match.')
+    }
     
     const { request } = await publicClient($configuredChain).simulateContract({
         address: PUBLIC_VOTE_CONTRACT_ADDRESS as `0x${string}`,
@@ -56,38 +57,8 @@
       const receipt = await publicClient($configuredChain).waitForTransactionReceipt({
         hash
       })
-
       return receipt
   }
-
-  const createWithOptions = async(description: string, blockWait: number, targetVote: number, options: string[]) => {
-    const wala = new Wala()
-    const descriptionHash = await wala.put(description, 'text/plain')
-    const promises = options.map(option => wala.put(option, 'text/plain'))
-    const optionHashes = (await Promise.all(promises)).map(optionHash => `0x${optionHash}`)
-    const { request } = await publicClient($configuredChain).simulateContract({
-        address: PUBLIC_VOTE_CONTRACT_ADDRESS as `0x${string}`,
-        abi: voteContractAbi,
-        functionName: 'proposeMulti',
-        args: [`0x${descriptionHash}`, optionHashes as `0x${string}`[], BigInt(blockWait), targetVote],
-        account: $connectionDetails.userAddress
-      })
-      const hash = await walletClient($configuredChain).writeContract(request)
-      const receipt = await publicClient($configuredChain).waitForTransactionReceipt({
-        hash
-      })
-
-      return receipt
-  }
-  
-  const removeOption = (index: number) => {
-    const options = formData.options.filter((_option, idx) => idx !== index)
-    formData.options = [ ...options ]
-  }
-
-  const addOption = () => {
-    formData.options = [ ...formData.options, '']
-  }
 </script>
 
 <div class="flex justify-center items-center w-full py-10">
@@ -138,37 +109,6 @@
 							/>
 						</div>
 					</div>
-          <h2 class="text-gray-900 text-center w-full font-medium text-md mt-6">Options for voters to choose</h2>
-          <div class="form-control w-full">
-            {#each formData.options as option, i }
-              <div class="flex justify-between items-center gap-5">
-                <div class="w-full">
-                  <label for={`option-${i}`} class="label">
-                    <span class="label-text text-gray-800">Option {i + 1}</span>
-                  </label>
-                  <input
-                    name={`option-${i}`}
-                    type="text"
-                    placeholder="Enter option text"
-                    class="input input-bordered w-full"
-                    autocomplete="off"
-                    bind:value={formData.options[i]}
-                  />
-                </div>
-                <button on:click={() => removeOption(i)} type="button" class="btn btn-error btn-sm btn-circle mt-[35px]">
-                  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
-                    <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
-                  </svg>                  
-                </button>
-              </div>
-            {/each}
-          </div>
-          <div class="flex justify-end mt-4">
-            <button on:click={addOption} type="button" class="btn btn-primary font-light text-white btn-sm normal-case">
-              Add option
-            </button>
-          </div>
-
           <p class="text-gray-600 text-xs mt-8">* Required</p>
 
           <div class="flex justify-between">
diff --git a/src/components/ProposalView.svelte b/src/components/ProposalView.svelte
@@ -1,29 +1,91 @@
 <script lang="ts">
-	import { formatUnits, hexToString } from "viem"
+	import { createEventDispatcher, onMount } from "svelte"
+  import { formatUnits } from "viem"
 	import type { Proposal } from "../shared/types"
-	import { configuredChain, voteToken } from "../store"
+	import { configuredChain, connectionDetails, voteToken } from "../store"
 	import { copyToClipboard } from "../utils/copy-clipboard"
-	import { onMount } from "svelte"
-	import { publicClient } from "../client"
+	import { publicClient, walletClient } from "../client"
 	import { getProposalStateDescription } from "../utils/proposal"
 	import { Wala } from "../lib/wala"
-	
+	import { computeHash } from "../lib/hash"
+	import { PUBLIC_VOTE_CONTRACT_ADDRESS } from "$env/static/public"
+	import { voteContractAbi } from "../shared/token-vote-abi"
 
+  // Variables
   export let proposal: Proposal
   let description: string | undefined
-  let options: (string | undefined) [] = []
+  let currentProposalOptions: (string | undefined) [] = []
   export let title: string | undefined = undefined
   let blockNumber: bigint
+  let formOptions: string[] = [
+    'Yes',
+    'No'
+  ]
+  let optionsModalOpen: boolean = false
+  let submitting: boolean = false
+  const dispatch = createEventDispatcher<{ refreshProposal: { proposalIdx: bigint }}>()
 
   $: ({ decimals, symbol } = $voteToken)
+  $: isValidOptions = formOptions.every(option => option?.trim() !== '')
 
-  onMount(async () => {
-    blockNumber = await publicClient($configuredChain).getBlockNumber()
-  })
+  // Methods
+  const removeOption = (index: number) => {
+    const newOptions = formOptions.filter((_option, idx) => idx !== index)
+    formOptions = [ ...newOptions ]
+  }
+
+  const addOption = () => {
+    formOptions = [ ...formOptions, '']
+  }
 
   const copyAddress = async (text: string) => await copyToClipboard(text)
 
+  const onSubmit = async () => {
+    try {
+      const wala = new Wala()
+      const localHashes = await Promise.all(formOptions.map(option => computeHash(option, 'SHA-256')))
+      const walaHashes = await Promise.all(formOptions.map(option => wala.put(option, 'text/plain')))
+      const hashesMatch = localHashes.every((hash, idx) => hash === walaHashes[idx])
+      if (hashesMatch) {
+        console.log('Local hashes match hashes returned by wala server')
+      } else {
+        console.error(`Local hashes do not match those returned from wala.`)
+        console.log('Local Hashes: ', localHashes)
+        console.log('Wala Hashes: ', walaHashes)
+      }
+      
+      const simulationPromises = walaHashes.map(walaHash => {
+        return publicClient($configuredChain).simulateContract({
+          address: PUBLIC_VOTE_CONTRACT_ADDRESS as `0x${string}`,
+          abi: voteContractAbi,
+          functionName: 'addOption',
+          args: [BigInt(0), `0x${walaHash}`],
+          account: $connectionDetails.userAddress
+        })
+      })
+  
+      submitting = true
+      const simulationResult = await Promise.all(simulationPromises)
+      // transaction hashes
+      const txHashes = await Promise.all(simulationResult.map(req => {
+        return walletClient($configuredChain).writeContract(req.request)
+      }))
+      // tx receipts
+      const receipts = await Promise.all(txHashes.map(txHash =>  publicClient($configuredChain).waitForTransactionReceipt({ hash: txHash })))
+      submitting = false 
+      // close options modal
+      optionsModalOpen = false
+      dispatch('refreshProposal', {
+        proposalIdx: BigInt(0) 
+      })
+    } catch (error) {
+      console.error('Error creating options', error) 
+    }
+  }
+
+  // Lifecyle callbacks
   onMount(async () => {
+    blockNumber = await publicClient($configuredChain).getBlockNumber()
     const wala = new Wala()
     if(proposal.description) {
       description = await wala.get(proposal.description) as string
@@ -31,7 +93,7 @@
 
     if(proposal.options) {
       const promises = proposal.options.map(option => wala.get(option))
-      options = (await Promise.all(promises)) as string[]
+      currentProposalOptions = (await Promise.all(promises)) as string[]
     }
   })
 </script>
@@ -47,16 +109,21 @@
         </p>
 
         <p class="text-gray-500 text-sm mt-4">Options</p>
-        {#if options.length > 0}
+        {#if currentProposalOptions.length > 0}
           <ul class="list-disc list-inside">
-            {#each options as option}
+            {#each currentProposalOptions as option}
               <li class="text-gray-900 text-sm mt-1">
                 {option}
               </li>
             {/each}
           </ul>
         {:else}
-        <p class="text-gray-900 text-sm mt-1 italic">No voting options</p>
+          <button
+            class="btn btn-sm btn-primary text-white w-[110px] normal-case mt-4"
+            on:click={() => optionsModalOpen = true}
+          >
+          Add Options
+          </button>
         {/if}    
         
         <p class="text-gray-500 text-sm mt-4">Supply</p>
@@ -102,8 +169,67 @@
         <p class="text-gray-500 text-sm mt-4">Scan cursor</p>
         <p class="text-gray-900 text-sm mt-1">
           {proposal.scanCursor}
-        </p>
+        </p>    
       </div>
     </div>
   </div>
-{/if}
-\ No newline at end of file
+{/if}
+
+<dialog id="networkDetails" class="modal" open={optionsModalOpen}>
+  <div class="modal-box">
+    <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" on:click={() => optionsModalOpen = false}>✕</button>
+    <h3 class="font-semibold text-lg mt-[-10px]">Add Options</h3>
+    <div class="form-control w-full">
+      {#each formOptions as _, i }
+        <div class="flex justify-between items-center gap-10">
+          <div class="w-full">
+            <label for={`option-${i}`} class="label">
+              <span class="label-text text-gray-800">Option {i + 1}</span>
+            </label>
+            <input
+              name={`option-${i}`}
+              type="text"
+              placeholder="Enter option text"
+              class="input input-bordered w-full input-sm"
+              autocomplete="off"
+              bind:value={formOptions[i]}
+              required
+            />
+          </div>
+          <button on:click={() => removeOption(i)} type="button" class="btn btn-error btn-xs btn-circle mt-[35px]">
+            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
+              <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
+            </svg>                  
+          </button>
+        </div>
+      {/each}
+    </div>
+    <div class="flex justify-start mt-4">
+      <button on:click={addOption} type="button" class="btn btn-primary font-light text-white btn-xs normal-case">
+        Add option
+      </button>
+    </div>
+    {#if formOptions.length > 0}
+      <div class="flex justify-between">
+        <button 
+          type="submit" 
+          class="btn btn-sm btn-primary self-center mt-6 normal-case text-white" 
+          on:click={() => onSubmit()}
+          disabled={!isValidOptions}
+          >
+          Save Options
+          {#if submitting}
+            <span class="loading loading-spinner" />
+          {/if}
+        </button>
+        <button
+          class="btn btn-secondary btn-sm mt-6 normal-case text-white"
+          on:click={() => optionsModalOpen = false}
+        >
+        Cancel
+        </button>
+
+      </div>
+    {/if}
+  </div>
+</dialog>
+\ No newline at end of file
diff --git a/src/lib/hash.ts b/src/lib/hash.ts
@@ -0,0 +1,41 @@
+// Import the jssha library (make sure you have it installed)
+import jsSHA from 'jssha';
+
+// Define a custom type for the hashing algorithm
+type HashAlgorithm = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512';
+
+/**
+ * Hashes a string or file using the specified algorithm.
+ * @param {string | Blob} data - The string or file to be hashed.
+ * @param {HashAlgorithm} algorithm - The hashing algorithm to use (e.g., "SHA-256", "SHA-512").
+ * @returns {Promise<string>} A promise that resolves with the resulting hash.
+ */
+export function computeHash(data: string | Blob, algorithm: HashAlgorithm): Promise<string> {
+  const shaObj = new jsSHA(algorithm, 'TEXT');
+
+  if (typeof data === 'string') {
+    shaObj.update(data);
+    return Promise.resolve(shaObj.getHash('HEX'));
+  } else if (data instanceof Blob) {
+    const reader = new FileReader();
+
+    return new Promise<string>((resolve, reject) => {
+      reader.onload = function (event) {
+        if (event.target?.result) {
+          shaObj.update(event.target.result as string);
+          resolve(shaObj.getHash('HEX'));
+        } else {
+          reject(new Error('File data is empty.'));
+        }
+      };
+
+      reader.onerror = function (event) {
+        reject(new Error('Error reading file: ' + event.target?.error));
+      };
+
+      reader.readAsBinaryString(data);
+    });
+  } else {
+    return Promise.reject(new Error('Unsupported data type. Only strings and files (Blob objects) are supported.'));
+  }
+}
diff --git a/src/shared/token-vote-abi.ts b/src/shared/token-vote-abi.ts
@@ -84,6 +84,24 @@ export const voteContractAbi = [
   {
     "inputs": [
       {
+        "internalType": "uint256",
+        "name": "_proposalIdx",
+        "type": "uint256"
+      },
+      {
+        "internalType": "bytes32",
+        "name": "_optionDescription",
+        "type": "bytes32"
+      }
+    ],
+    "name": "addOption",
+    "outputs": [],
+    "stateMutability": "nonpayable",
+    "type": "function"
+  },
+  {
+    "inputs": [
+      {
         "internalType": "address",
         "name": "",
         "type": "address"
@@ -284,14 +302,8 @@ export const voteContractAbi = [
     "type": "function"
   },
   {
-    "inputs": [
-      {
-        "internalType": "uint256",
-        "name": "_proposalIdx",
-        "type": "uint256"
-      }
-    ],
-    "name": "optionCount",
+    "inputs": [],
+    "name": "lastProposalIdx",
     "outputs": [
       {
         "internalType": "uint256",
@@ -305,22 +317,12 @@ export const voteContractAbi = [
   {
     "inputs": [
       {
-        "internalType": "bytes32",
-        "name": "_description",
-        "type": "bytes32"
-      },
-      {
         "internalType": "uint256",
-        "name": "_blockWait",
+        "name": "_proposalIdx",
         "type": "uint256"
-      },
-      {
-        "internalType": "uint24",
-        "name": "_targetVotePpm",
-        "type": "uint24"
       }
     ],
-    "name": "propose",
+    "name": "optionCount",
     "outputs": [
       {
         "internalType": "uint256",
@@ -328,7 +330,7 @@ export const voteContractAbi = [
         "type": "uint256"
       }
     ],
-    "stateMutability": "nonpayable",
+    "stateMutability": "view",
     "type": "function"
   },
   {
@@ -339,11 +341,6 @@ export const voteContractAbi = [
         "type": "bytes32"
       },
       {
-        "internalType": "bytes32[]",
-        "name": "_options",
-        "type": "bytes32[]"
-      },
-      {
         "internalType": "uint256",
         "name": "_blockWait",
         "type": "uint256"
@@ -354,7 +351,7 @@ export const voteContractAbi = [
         "type": "uint24"
       }
     ],
-    "name": "proposeMulti",
+    "name": "propose",
     "outputs": [
       {
         "internalType": "uint256",
@@ -520,4 +517,4 @@ export const voteContractAbi = [
     "stateMutability": "nonpayable",
     "type": "function"
   }
-] as const
+] as const
+\ No newline at end of file