commit 32fb4e276af0f9d84eea340f4ea877a6a98ba26a
parent 573364608293674d8752f5cd5a39bd1de8675e4c
Author: William Muli <willi.wambu@gmail.com>
Date: Tue, 25 Jul 2023 22:28:10 +0300
Added content schema and signing feature
Diffstat:
8 files changed, 278 insertions(+), 9 deletions(-)
diff --git a/package-lock.json b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@wagmi/chains": "^1.1.0",
+ "cbor": "^9.0.1",
"ethers": "^6.5.1",
"jssha": "^3.3.0",
"svelte-french-toast": "^1.0.4",
@@ -1353,6 +1354,17 @@
}
]
},
+ "node_modules/cbor": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cbor/-/cbor-9.0.1.tgz",
+ "integrity": "sha512-/TQOWyamDxvVIv+DY9cOLNuABkoyz8K/F3QE56539pGVYohx0+MEA1f4lChFTX79dBTBS7R1PF6ovH7G+VtBfQ==",
+ "dependencies": {
+ "nofilter": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/chai": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz",
@@ -2696,6 +2708,14 @@
"integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==",
"dev": true
},
+ "node_modules/nofilter": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz",
+ "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==",
+ "engines": {
+ "node": ">=12.19"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -4968,6 +4988,14 @@
"integrity": "sha512-AZ+9tFXw1sS0o0jcpJQIXvFTOB/xGiQ4OQ2t98QX3NDn2EZTSRBC801gxrsGgViuq2ak/NLkNgSNEPtCr5lfKg==",
"dev": true
},
+ "cbor": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cbor/-/cbor-9.0.1.tgz",
+ "integrity": "sha512-/TQOWyamDxvVIv+DY9cOLNuABkoyz8K/F3QE56539pGVYohx0+MEA1f4lChFTX79dBTBS7R1PF6ovH7G+VtBfQ==",
+ "requires": {
+ "nofilter": "^3.1.0"
+ }
+ },
"chai": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz",
@@ -5967,6 +5995,11 @@
"integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==",
"dev": true
},
+ "nofilter": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz",
+ "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g=="
+ },
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
diff --git a/package.json b/package.json
@@ -38,6 +38,7 @@
"type": "module",
"dependencies": {
"@wagmi/chains": "^1.1.0",
+ "cbor": "^9.0.1",
"ethers": "^6.5.1",
"jssha": "^3.3.0",
"svelte-french-toast": "^1.0.4",
diff --git a/src/components/ProposalForm.svelte b/src/components/ProposalForm.svelte
@@ -7,9 +7,12 @@
import { Routes } from '../shared/types'
import { Wala } from '$lib/wala'
import { computeHash } from '$lib/hash'
+ import { createSignedSchema, generateSchema } from '../utils/content-schema'
+
type ProposalFormData = {
- description: string
+ description: string,
+ descriptionFile?: File
targetVote: number
blockWait: number
}
@@ -21,14 +24,22 @@
}
let submitting: boolean = false
+
+ function handleFileInput(event: Event) {
+ const input = event.target as HTMLInputElement
+ const file = input.files?.[0]
+ formData.descriptionFile = file
+ }
const onSubmit = async () => {
- const { description, targetVote, blockWait } = formData
+ const { description, targetVote, blockWait, descriptionFile } = formData
if (description === '' || targetVote === 0 || blockWait === 0) return
try {
submitting = true
- await createWithoutOptions(description, blockWait, targetVote)
+ const contentSchema = generateSchema(description, descriptionFile)
+ const signedSchema = await createSignedSchema(contentSchema, $configuredChain, $connectionDetails.userAddress)
+ await createWithoutOptions(signedSchema, blockWait, targetVote)
submitting = false
goto(Routes.Home)
} catch (error) {
@@ -36,9 +47,9 @@
}
}
- 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')
+ const createWithoutOptions = async (signedSchema: string, blockWait: number, targetVote: number) => {
+ const localHash = await computeHash(signedSchema, 'SHA-256')
+ const descriptionHash = await new Wala().put(signedSchema, 'text/plain')
// compare hashes
if (localHash !== descriptionHash) {
console.error(`Computed description hash does not match wala hash. Local Hash: ${localHash}, Wala Hash: ${descriptionHash}`)
@@ -80,7 +91,12 @@
placeholder="Enter proposal description"
bind:value={formData.description}
/>
- </div>
+ <input
+ type="file"
+ class="file-input file-input-md file-input-bordered w-full my-4"
+ on:change={handleFileInput}
+ />
+ </div>
<div class="flex flex-col md:flex-row w-full gap-3 mt-3">
<div class="form-control w-full">
diff --git a/src/components/ProposalView.svelte b/src/components/ProposalView.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte"
import { formatUnits } from "viem"
- import type { Proposal } from "../shared/types"
+ import type { Content, Proposal } from "../shared/types"
import { configuredChain, connectionDetails, voteToken } from "../store"
import { copyToClipboard } from "../utils/copy-clipboard"
import { publicClient, walletClient } from "../client"
@@ -10,10 +10,12 @@
import { computeHash } from "../lib/hash"
import { PUBLIC_VOTE_CONTRACT_ADDRESS } from "$env/static/public"
import { voteContractAbi } from "../shared/token-vote-abi"
+ import { decodeCborHex, getFileNameFromContentDisposition } from "../utils/content-schema"
// Variables
export let proposal: Proposal
let description: string | undefined
+ let descriptionContent: Content | undefined
let currentProposalOptions: (string | undefined) [] = []
export let title: string | undefined = undefined
let blockNumber: bigint
@@ -83,12 +85,45 @@
}
}
+ const downloadFile = () => {
+ if(descriptionContent?.body === undefined) return
+ // Remove the data URI prefix (e.g., "data:image/png;base64,")
+ const base64WithoutPrefix = descriptionContent.body.replace(/^data:[^;]+;base64,/, '');
+
+ // Convert the base64 string to a byte array
+ const byteCharacters = atob(base64WithoutPrefix);
+ const byteArrays = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteArrays[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteArrays);
+
+ // Create a Blob from the byte array
+ const blob = new Blob([byteArray], { type: 'application/octet-stream' });
+
+ // Create a download link
+ const downloadLink = document.createElement('a');
+ downloadLink.href = URL.createObjectURL(blob);
+ downloadLink.download = getFileNameFromContentDisposition(descriptionContent["content-disposition"])
+
+ // Append the link to the body and click it to trigger the download
+ document.body.appendChild(downloadLink);
+ downloadLink.click();
+
+ // Remove the download link from the DOM
+ document.body.removeChild(downloadLink);
+ }
+
// Lifecyle callbacks
onMount(async () => {
blockNumber = await publicClient($configuredChain).getBlockNumber()
const wala = new Wala()
if(proposal.description) {
- description = await wala.get(proposal.description) as string
+ const descriptionHex = await wala.get(proposal.description) as string
+ const signedSchema = await decodeCborHex(descriptionHex)
+ const { signature, ...contentSchema } = signedSchema
+ description = contentSchema.subject
+ descriptionContent = contentSchema.content[1] as Content
}
if(proposal.options) {
@@ -108,6 +143,15 @@
{description}
</p>
+ {#if descriptionContent !== undefined && descriptionContent.body !== undefined}
+ <button
+ class="btn btn-primary text-white btn-sm normal-case my-4 w-[200px]"
+ on:click={downloadFile}
+ >
+ Download description file
+ </button>
+ {/if}
+
<p class="text-gray-500 text-sm mt-4">Options</p>
{#if currentProposalOptions.length > 0}
<ul class="list-disc list-inside">
diff --git a/src/routes/api/cbor/decode/+server.ts b/src/routes/api/cbor/decode/+server.ts
@@ -0,0 +1,17 @@
+import * as cbor from 'cbor'
+import { json, type RequestEvent } from '@sveltejs/kit'
+
+export async function POST({ request }: RequestEvent) {
+ const { hex }: { hex: string } = await request.json()
+ try {
+ const buffer = Buffer.from(hex, 'hex')
+ const content = cbor.decodeFirstSync(buffer)
+
+ return json({
+ content
+ })
+ } catch (error) {
+ console.error(error)
+ return new Response(JSON.stringify(error), { status: 500 })
+ }
+}
diff --git a/src/routes/api/cbor/encode/+server.ts b/src/routes/api/cbor/encode/+server.ts
@@ -0,0 +1,16 @@
+import * as cbor from 'cbor'
+import { json, type RequestEvent } from '@sveltejs/kit'
+
+export async function POST({ request }: RequestEvent) {
+ const { content } = await request.json()
+ try {
+ const buffer = await cbor.encodeAsync(content, { canonical: true })
+ const hex = buffer.toString('hex')
+ return json({
+ hex
+ })
+ } catch (error) {
+ console.error(error)
+ return new Response(JSON.stringify(error), { status: 500 });
+ }
+}
diff --git a/src/shared/types.ts b/src/shared/types.ts
@@ -51,3 +51,20 @@ export type NavOption = {
name: string
url: Routes
}
+
+export interface Content {
+ 'content-type': string;
+ 'content-transfer-encoding': string;
+ 'content-disposition': string;
+ body: string;
+}
+
+export interface ContentSchema {
+ subject: string;
+ date: string;
+ content: Content[];
+}
+
+export type SignedSchema = ContentSchema & {
+ signature?: string
+}
diff --git a/src/utils/content-schema.ts b/src/utils/content-schema.ts
@@ -0,0 +1,125 @@
+import type { ContentSchema, SignedSchema } from '../shared/types'
+import { walletClient, publicClient } from '../client'
+import { recoverMessageAddress, type Chain } from 'viem'
+
+export function generateSchema(
+ description: string,
+ descriptionFile: File | undefined
+): ContentSchema {
+ const currentDate = new Date().toUTCString()
+
+ const schema: ContentSchema = {
+ subject: description,
+ date: currentDate,
+ content: [
+ {
+ 'content-type': 'text/plain',
+ 'content-transfer-encoding': 'BASE64',
+ 'content-disposition': 'inline',
+ body: btoa(description)
+ }
+ ]
+ }
+
+ if (descriptionFile !== undefined) {
+ const reader = new FileReader()
+ reader.onloadend = () => {
+ const body = btoa(reader.result as string)
+ console.log(body)
+ schema.content.push({
+ 'content-type': descriptionFile.type,
+ 'content-transfer-encoding': 'BASE64',
+ 'content-disposition': `attachment; filename="${descriptionFile.name}"`,
+ body
+ })
+ }
+ reader.onerror = (error) => console.error('Error reading file: ', error)
+ reader.readAsBinaryString(descriptionFile)
+ }
+
+ return schema
+}
+
+async function cborEncodeContent(content: ContentSchema | SignedSchema): Promise<string> {
+ const response = await fetch('/api/cbor/encode', {
+ method: 'POST',
+ body: JSON.stringify({ content })
+ })
+
+ return (await response.json()).hex
+}
+
+export async function decodeCborHex(hex: string): Promise<SignedSchema> {
+ const response = await fetch('/api/cbor/decode', {
+ method: 'POST',
+ body: JSON.stringify({ hex })
+ })
+
+ return (await response.json()).content
+}
+
+export async function signMessage(account: `0x${string}`, chain: Chain, message: string) {
+ const signature = await walletClient(chain).signMessage({
+ account,
+ message
+ })
+ return signature
+}
+
+export async function createSignedSchema(
+ schema: ContentSchema,
+ chain: Chain,
+ account: `0x${string}`
+): Promise<string> {
+ // Step 1: CBORify the schema
+ const cborSchemaHex = await cborEncodeContent(schema)
+
+ // Step 2: Create a signature (Ethereum msg signature) on the CBOR itself
+ const signature = await signMessage(account, chain, cborSchemaHex)
+
+ // Step 3: Add signature to the JSON schema
+ const signedSchema: SignedSchema = {
+ ...schema,
+ signature
+ }
+
+ // Step 4: CBORify the schema again
+ const signedSchemaHex = await cborEncodeContent(signedSchema)
+ return signedSchemaHex
+}
+
+export async function verifyContentSchema(
+ contentSchema: ContentSchema,
+ signature: `0x${string}`,
+ address: `0x${string}`,
+ chain: Chain,
+ originalHex: string
+) {
+ const cborSchemaHex = await cborEncodeContent(contentSchema)
+ console.log(originalHex, cborSchemaHex)
+
+ const recoveredAddress = await recoverMessageAddress({
+ message: cborSchemaHex,
+ signature: signature
+ })
+
+ console.log('Recovered address: ', recoveredAddress)
+ const valid = await publicClient(chain).verifyMessage({
+ message: cborSchemaHex,
+ address,
+ signature
+ })
+ console.log('Message is valid', valid)
+ return valid
+}
+
+export function getFileNameFromContentDisposition(contentDisposition: string): string {
+ const filenamePattern = /filename="([^"]+)"/
+ const match = contentDisposition.match(filenamePattern)
+
+ if (match) {
+ return match[1]
+ } else {
+ return ''
+ }
+}