diff --git a/.github/workflows/publish-summit-2023.yaml b/.github/workflows/publish-summit-2023.yaml new file mode 100644 index 000000000..2626f9738 --- /dev/null +++ b/.github/workflows/publish-summit-2023.yaml @@ -0,0 +1,135 @@ +name: Build and Publish Docker images (Summit 2023 Support Branch) + +on: + push: + branches: + - summit-2023 + workflow_dispatch: + +env: + PRIVATE_DOCKER_REGISTRY_URL: ${{ secrets.GITLAB_DOCKER_REGISTRY_URL }} + PRIVATE_DOCKER_REGISTRY_USER: Deploy-Token + PRIVATE_DOCKER_REGISTRY_PASS: ${{ secrets.GITLAB_PKG_REGISTRY_TOKEN }} + +jobs: + + build-version: + runs-on: self-hosted + outputs: + ARTIFACT_VERSION: ${{ steps.metadata.outputs.ARTIFACT_VERSION }} + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup dependencies + run: | + pip install yq + + - name: Set extra environment and metadata + id: metadata + run: | + CURRENT_VERSION=$(cat version.txt) + SHORT_HASH=$(echo "$GITHUB_SHA" | cut -c -5) + + if [ ${{github.event_name}} == "pull_request" ] + then + PR_NUMBER=$(echo $GITHUB_REF | awk -F/ '{ print $3 }') + echo "ARTIFACT_VERSION=${CURRENT_VERSION}-PR${PR_NUMBER}-$GITHUB_RUN_NUMBER" >> "$GITHUB_OUTPUT" + elif [ ${{github.event_name}} == "push" ] + then + echo "ARTIFACT_VERSION=${CURRENT_VERSION}-$GITHUB_RUN_NUMBER-${SHORT_HASH}" >> "$GITHUB_OUTPUT" + else + BRANCH=${GITHUB_REF##*/} + echo "BRANCH=${GITHUB_REF##*/}" >> "$GITHUB_OUTPUT" + echo "ARTIFACT_VERSION=${CURRENT_VERSION}" >> "$GITHUB_OUTPUT" + fi + + publish-voting-app: + runs-on: self-hosted + env: + APP_NAME: voting-app + needs: build-version + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up JDK + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + - name: Execute Gradle build + working-directory: backend-services/${{ env.APP_NAME }} + run: ./gradlew bootJar + + - name: Private Docker Hub Login + uses: docker/login-action@v2 + with: + registry: ${{ env.PRIVATE_DOCKER_REGISTRY_URL }} + username: ${{ env.PRIVATE_DOCKER_REGISTRY_USER }} + password: ${{ env.PRIVATE_DOCKER_REGISTRY_PASS }} + + - name: Public Docker Hub Login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_REGISTRY_USER }} + password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and Push docker image + uses: docker/build-push-action@v4 + env: + ARTIFACT_VERSION: ${{needs.build-version.outputs.ARTIFACT_VERSION}} + with: + context: backend-services/${{ env.APP_NAME }} + push: true + tags: | + ${{ env.PRIVATE_DOCKER_REGISTRY_URL }}/${{ env.APP_NAME }}:${{ env.ARTIFACT_VERSION }} + + + publish-ui-summit-2023: + runs-on: self-hosted + env: + APP_NAME: summit-2023-ui + needs: build-version + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Private Docker Hub Login + uses: docker/login-action@v2 + with: + registry: ${{ env.PRIVATE_DOCKER_REGISTRY_URL }} + username: ${{ env.PRIVATE_DOCKER_REGISTRY_USER }} + password: ${{ env.PRIVATE_DOCKER_REGISTRY_PASS }} + + - name: Public Docker Hub Login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_REGISTRY_USER }} + password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and Push docker image + uses: docker/build-push-action@v4 + env: + REACT_APP_VOTING_APP_SERVER_URL: https://api.dev.cf-summit-2023-preprod.eu-west-1.metadata.dev.cf-deployments.org + REACT_APP_VOTING_LEDGER_FOLLOWER_APP_SERVER_URL: https://follower-api.dev.cf-summit-2023-preprod.eu-west-1.metadata.dev.cf-deployments.org + REACT_APP_VOTING_VERIFICATION_APP_SERVER_URL: https://verification-api.dev.cf-summit-2023-preprod.eu-west-1.metadata.dev.cf-deployments.org + REACT_APP_USER_VERIFICATION_SERVER_URL: https://user-verification.dev.cf-summit-2023-preprod.eu-west-1.metadata.dev.cf-deployments.org + ARTIFACT_VERSION: ${{needs.build-version.outputs.ARTIFACT_VERSION}} + with: + context: ui/summit-2023 + push: true + build-args: | + "REACT_APP_VERSION=${{ env.ARTIFACT_VERSION }}" + "REACT_APP_VOTING_APP_SERVER_URL=${{ env.REACT_APP_VOTING_APP_SERVER_URL }}" + "REACT_APP_VOTING_LEDGER_FOLLOWER_APP_SERVER_URL=${{ env.REACT_APP_VOTING_LEDGER_FOLLOWER_APP_SERVER_URL }}" + "REACT_APP_VOTING_VERIFICATION_APP_SERVER_URL=${{ env.REACT_APP_VOTING_VERIFICATION_APP_SERVER_URL }}" + "REACT_APP_USER_VERIFICATION_SERVER_URL=${{ env.REACT_APP_USER_VERIFICATION_SERVER_URL }}" + tags: | + ${{ env.PRIVATE_DOCKER_REGISTRY_URL }}/${{ env.APP_NAME }}:${{ env.ARTIFACT_VERSION }} diff --git a/backend-services/hydra-tally-app/.run/HydraTallyApp (devnet--alice).run.xml b/backend-services/hydra-tally-app/.run/HydraTallyApp (devnet--alice).run.xml new file mode 100644 index 000000000..1d156d573 --- /dev/null +++ b/backend-services/hydra-tally-app/.run/HydraTallyApp (devnet--alice).run.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/backend-services/hydra-tally-app/Dockerfile b/backend-services/hydra-tally-app/Dockerfile new file mode 100644 index 000000000..dae3eec44 --- /dev/null +++ b/backend-services/hydra-tally-app/Dockerfile @@ -0,0 +1,9 @@ +FROM openjdk:21-jdk-slim AS build +WORKDIR /app +COPY . /app +RUN ./gradlew clean build + +FROM openjdk:21-jdk-slim AS runtime +WORKDIR /app +COPY --from=build /app/build/libs/*SNAPSHOT.jar /app/app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend-services/hydra-tally-app/README.md b/backend-services/hydra-tally-app/README.md new file mode 100644 index 000000000..a3f4cb0fb --- /dev/null +++ b/backend-services/hydra-tally-app/README.md @@ -0,0 +1,23 @@ +## Hydra Tally App + +# Application Description + +Hydra-Tally-App is a CLI application which contains logic to connect to Hydra network. Application demonstrates usage +of smart contracts (Aiken) to perform counting (tally) of the votes and providing result. + +The application should be run in a fedration of hydra operators. It should be used to validate and assert results, which +are provided in a centralised manner. + +# Disclaimer +Application is currently not ready to run in Byzantine environment. It should be hosted in a federated way. There are scenarios known, +in which a malicous actor could exploit the tally process, currently it serves as a Hydra / Aiken show-case. + +# Removing Federation +In order to enable Hydra-Tally-App to work in a decentralised manner, the following limitations would have to lifted / solved: +- Deduplication of votes within Smart Contract (e.g. using https://github.com/micahkendall/distributed-set) +- Preventing any Hydra Operator to close the head while tallying the votes (e.g. by forcing them to lock up in a contract and slashing in case of early fan-out) +- Prevent accumulator eUTxO fraud, any Hydra operator could commit fraudulent eUTxO to the contract address (e.g. Watch Towers to check if eUTxO is pointing to the root via a fraud proof transaction, 2 contracts idea) +- Multi-Sig for closing the head (e.g. 2 out of 3 Hydra Operators have to sign the transaction). We need to make sure that one hydra operator won't be able to spend UTxO on L1 and "rug" others + +- private votes on hydra without early results publishing to the network (no idea yet) + diff --git a/backend-services/hydra-tally-app/build.gradle.kts b/backend-services/hydra-tally-app/build.gradle.kts new file mode 100644 index 000000000..fa5017d05 --- /dev/null +++ b/backend-services/hydra-tally-app/build.gradle.kts @@ -0,0 +1,73 @@ +plugins { + java + id("io.spring.dependency-management") version "1.1.3" + id("org.graalvm.buildtools.native") version "0.9.27" + id("com.github.ben-manes.versions") version "0.48.0" + id("org.springframework.boot") version "3.1.4" +} + +group = "org.cardano.foundation" +version = "1.0.0-SNAPSHOT" +java.sourceCompatibility = JavaVersion.VERSION_17 + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +repositories { + mavenCentral() + mavenLocal() + maven { url = uri("https://repo.spring.io/milestone") } +} + +extra["springShellVersion"] = "3.1.4" + +dependencies { + implementation("org.springframework.shell:spring-shell-starter") + testImplementation("org.springframework.boot:spring-boot-starter-test") + implementation("org.springframework.boot:spring-boot-starter-reactor-netty") + + implementation("org.springframework.shell:spring-shell-starter") + + compileOnly("org.projectlombok:lombok:1.18.30") + annotationProcessor("org.projectlombok:lombok:1.18.30") + + testCompileOnly("org.projectlombok:lombok:1.18.30") + testAnnotationProcessor("org.projectlombok:lombok:1.18.30") + + implementation("org.apache.commons:commons-csv:1.10.0") + + implementation("org.cardanofoundation:cip30-data-signature-parser:0.0.11") + + implementation("com.bloxbean.cardano:cardano-client-crypto:0.5.0") + implementation("com.bloxbean.cardano:cardano-client-address:0.5.0") + implementation("com.bloxbean.cardano:cardano-client-metadata:0.5.0") + implementation("com.bloxbean.cardano:cardano-client-quicktx:0.5.0") + implementation("com.bloxbean.cardano:cardano-client-backend-blockfrost:0.5.0") + implementation("com.bloxbean.cardano:cardano-client-cip30:0.5.0") + implementation("com.bloxbean.cardano:cardano-client-core:0.5.0") + annotationProcessor("com.bloxbean.cardano:cardano-client-annotation-processor:0.5.0") + + implementation("org.cardanofoundation:hydra-java-client:0.0.10") + implementation("org.cardanofoundation:hydra-java-cardano-client-lib-adapter:0.0.10") + implementation("org.cardanofoundation:hydra-java-reactive-reactor-client:0.0.10") + +// implementation("one.util:streamex:0.8.1") + + implementation("io.vavr:vavr:0.10.4") + implementation("org.zalando:problem-spring-web-starter:0.29.1") + + implementation("com.bloxbean.cardano:aiken-java-binding:0.0.8") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.shell:spring-shell-dependencies:${property("springShellVersion")}") + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/backend-services/hydra-tally-app/gradle/wrapper/gradle-wrapper.jar b/backend-services/hydra-tally-app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..41d9927a4 Binary files /dev/null and b/backend-services/hydra-tally-app/gradle/wrapper/gradle-wrapper.jar differ diff --git a/backend-services/hydra-tally-app/gradle/wrapper/gradle-wrapper.properties b/backend-services/hydra-tally-app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..db9a6b825 --- /dev/null +++ b/backend-services/hydra-tally-app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend-services/hydra-tally-app/gradlew b/backend-services/hydra-tally-app/gradlew new file mode 100755 index 000000000..1b6c78733 --- /dev/null +++ b/backend-services/hydra-tally-app/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend-services/hydra-tally-app/gradlew.bat b/backend-services/hydra-tally-app/gradlew.bat new file mode 100644 index 000000000..ac1b06f93 --- /dev/null +++ b/backend-services/hydra-tally-app/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend-services/hydra-tally-app/scripts/devnet--alice-app-start.sh b/backend-services/hydra-tally-app/scripts/devnet--alice-app-start.sh new file mode 100755 index 000000000..21d747854 --- /dev/null +++ b/backend-services/hydra-tally-app/scripts/devnet--alice-app-start.sh @@ -0,0 +1,3 @@ +export SPRING_CONFIG_LOCATION=classpath:/application-devnet.properties,classpath:/application-devnet--alice.properties +export SPRING_PROFILES_ACTIVE=devnet--alice +./gradlew clean build && java -jar build/libs/hydra-tally-app-1.0.0-SNAPSHOT.jar diff --git a/backend-services/hydra-tally-app/scripts/devnet--bob-app-start.sh b/backend-services/hydra-tally-app/scripts/devnet--bob-app-start.sh new file mode 100755 index 000000000..ad801fd70 --- /dev/null +++ b/backend-services/hydra-tally-app/scripts/devnet--bob-app-start.sh @@ -0,0 +1,3 @@ +export SPRING_CONFIG_LOCATION=classpath:/application-devnet.properties,classpath:/application-devnet--bob.properties +export SPRING_PROFILES_ACTIVE=devnet--bob +./gradlew clean build && java -jar build/libs/hydra-tally-app-1.0.0-SNAPSHOT.jar diff --git a/backend-services/hydra-tally-app/scripts/devnet--carol-app-start.sh b/backend-services/hydra-tally-app/scripts/devnet--carol-app-start.sh new file mode 100755 index 000000000..38f66eb96 --- /dev/null +++ b/backend-services/hydra-tally-app/scripts/devnet--carol-app-start.sh @@ -0,0 +1,3 @@ +export SPRING_CONFIG_LOCATION=classpath:/application-devnet.properties,classpath:/application-devnet--carol.properties +export SPRING_PROFILES_ACTIVE=devnet--carol +./gradlew clean build && java -jar build/libs/hydra-tally-app-1.0.0-SNAPSHOT.jar diff --git a/backend-services/hydra-tally-app/scripts/preprod--orgwallet-app-start.sh b/backend-services/hydra-tally-app/scripts/preprod--orgwallet-app-start.sh new file mode 100755 index 000000000..650404a2f --- /dev/null +++ b/backend-services/hydra-tally-app/scripts/preprod--orgwallet-app-start.sh @@ -0,0 +1,5 @@ +export SPRING_CONFIG_LOCATION=classpath:/application-preprod.properties,classpath:/application-preprod--orgwallet.properties +export SPRING_PROFILES_ACTIVE=preprod--orgwallet + +#./gradlew clean build && java -jar build/libs/hydra-tally-app-1.0.0-SNAPSHOT.jar +./hydra-tally-app-1.0.0-SNAPSHOT.jar diff --git a/backend-services/hydra-tally-app/scripts/process_votes.rb b/backend-services/hydra-tally-app/scripts/process_votes.rb new file mode 100644 index 000000000..4a3137db0 --- /dev/null +++ b/backend-services/hydra-tally-app/scripts/process_votes.rb @@ -0,0 +1,36 @@ +require 'csv' + +# Check if command line arguments are provided +if ARGV.length < 2 + puts "Usage: ruby script.rb " + exit +end + +# Get event_id and organiser from command line arguments +event_id = ARGV[1] +organiser = ARGV[2] + +# Read CSV file +csv_file = 'votes.csv' # Replace with your actual CSV file path +csv_data = CSV.read(csv_file, headers: true) + +# Create a new array to hold modified rows +modified_rows = [] + +# Add two additional columns at the beginning +csv_data.each do |row| + row["event_id"] = event_id + row["organiser"] = organiser + modified_rows << row +end + +# Write modified data back to the CSV file +output_csv_file = 'processed_votes.csv' +CSV.open(output_csv_file, 'w', write_headers: true, headers: csv_data.headers) do |csv| + modified_rows.each do |row| + csv << row + end +end + + +puts "Script executed successfully. Modified CSV saved at: #{output_csv_file}" \ No newline at end of file diff --git a/backend-services/hydra-tally-app/smart-contract/.gitignore b/backend-services/hydra-tally-app/smart-contract/.gitignore new file mode 100644 index 000000000..d16386367 --- /dev/null +++ b/backend-services/hydra-tally-app/smart-contract/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/backend-services/hydra-tally-app/smart-contract/aiken.lock b/backend-services/hydra-tally-app/smart-contract/aiken.lock new file mode 100644 index 000000000..3b02658f9 --- /dev/null +++ b/backend-services/hydra-tally-app/smart-contract/aiken.lock @@ -0,0 +1,15 @@ +# This file was generated by Aiken +# You typically do not need to edit this file + +[[requirements]] +name = "Cardano-Fans/acca" +version = "038428f0a2b81c4013e69c65570b57781cae6a51" +source = "github" + +[[packages]] +name = "Cardano-Fans/acca" +version = "038428f0a2b81c4013e69c65570b57781cae6a51" +requirements = [] +source = "github" + +[etags] diff --git a/backend-services/hydra-tally-app/smart-contract/aiken.toml b/backend-services/hydra-tally-app/smart-contract/aiken.toml new file mode 100644 index 000000000..8ed3dae5a --- /dev/null +++ b/backend-services/hydra-tally-app/smart-contract/aiken.toml @@ -0,0 +1,8 @@ +name = "cardano-foundation/cf-summit2023-hydra-tally-app" +version = "0.0.1" +licences = [] +description = "CF Summit 2023 Hydra Tally App" + +dependencies = [ + { name = "Cardano-Fans/acca", version = "038428f0a2b81c4013e69c65570b57781cae6a51", source = "github" } +] \ No newline at end of file diff --git a/backend-services/hydra-tally-app/smart-contract/plutus.json b/backend-services/hydra-tally-app/smart-contract/plutus.json new file mode 100644 index 000000000..c06d56597 --- /dev/null +++ b/backend-services/hydra-tally-app/smart-contract/plutus.json @@ -0,0 +1,105 @@ +{ + "preamble": { + "title": "cardano-foundation/cf-summit2023-hydra-tally-app", + "description": "CF Summit 2023 Hydra Tally App", + "version": "0.0.1", + "plutusVersion": "v2", + "compiler": { + "name": "Aiken", + "version": "v1.0.20-alpha+49bd4ba" + } + }, + "validators": [ + { + "title": "voting.voting", + "datum": { + "title": "_voting_datum", + "schema": { + "$ref": "#/definitions/Void" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/voting~1Redeemer" + } + }, + "parameters": [ + { + "title": "_tallyNameHash", + "schema": { + "$ref": "#/definitions/ByteArray" + } + }, + { + "title": "authorised_parties_keys", + "schema": { + "$ref": "#/definitions/List$ByteArray" + } + }, + { + "title": "contract_event_id", + "schema": { + "$ref": "#/definitions/String" + } + }, + { + "title": "contract_organiser", + "schema": { + "$ref": "#/definitions/String" + } + }, + { + "title": "contract_category_id", + "schema": { + "$ref": "#/definitions/String" + } + } + ], + "compiledCode": "59082a010000323232323232323232232232232232232222325333011323253330133370e90011809000899191919191919191919191919191919191919299981319b87480000344c8c8c8c94ccc0a8ccc02c05004805854ccc0a80044cc030dd618099814180a981400d8010a5014a06600e6eb0c020c09cc050c09c0688cc028090004cdd2a4000660586ea4dcc010198161ba9373003c660586ea4dcc00e198161ba60014bd7019198008008011129998160008a5eb7bdb1804c8c8c8cccc024cc014014008cc88c8cc0040052f5bded8c044a66606600226606866ec0dd49b98005375000897adef6c6013232323253330343375e6600e012004980103d8798000133038337606ea4dcc0049ba8008005153330343372e01200426607066ec0dd49b98009375001000626607066ec0dd49b9800237500026600c00c0066eb4c0d400cdcc9bae303300230370023035001375a6062606460646064606460646064605400600e44466e95200033033375066e000080052f5c000e6e64dd718181818981898189818981898148011818001181700099805008129998139806980b1812800899299981419b8748010c09c0044c8c8c8c94ccc0b00044cdd2a40006606000697ae014c0103d87a8000533302b3372e0466e64dd7180b18148010a99981599b9701f37326eb8c0c0c0c4c0c4c0c4c0c4c0a40084cdcb8109b99375c60346052004294052819299981599b87480000044c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc0f0c0fc00852616375a607a002607a0046e64dd7181d800981d8011b99375c607200260720046eb8c0dc004c0dc008dcc9bae3035001303500237326eb8c0cc004c0cc008dcc9bae30310013029002163029001302e001302600116301030253016302500114c0103d87a8000132323232533302a33300b0140120161533302a00213300c375860266050602a6050036002294052819ba548000cc0b4dd49b980213302d37526e6007ccc0b4dd49b9801d3302d374c00497ae0330063758600e604c6026604c03246601204600266644464666002002008006444a66606000420022666006006606600466446600c0020046eacc0c8008004c8cc004004008894ccc0b000452f5c026605a6e98dd5981718179817981798139817000998010011817800a5eb7bdb18088cccc018008004888cdd2a4000660606ea0cdc0001000a5eb80010cc02804094ccc09cc034c058c0940044c94ccc0a0cdc3a4008604e002264646464a666058002266e952000330300034bd700a60103d87a8000533302b3372e0466e64dd7180b18148010a99981599b9702137326eb8c068c0a40084cdcb80f9b99375c6028605200429405281807800981700098130008b18081812980b18128008a6103d87a8000223322533302933720004002298103d8798000153330293371e0040022980103d87a800014c103d87b8000300300230030012373000244446466600200200a008444a66605a0042002264666008008606200666664444646600200200e44a66606800226606a66ec0dd49b98006375000a97adef6c6013232323253330353375e6600e014004980103d8798000133039337606ea4dcc0051ba8009005153330353372e01400426464a66606e66e1d200000113303b337606ea4dcc006181e181a8010028802981a80099980400500480089981c99bb037526e60008dd4000998030030019bad303600337326eb8c0d0008c0e0008c0d8004dcc9bae302c001375a605a00200c00a605e00444646600200200644a66605200229404c8c94ccc0a0c01400852889980200200098168011bae302b001230273028302830283028302830283028302800122323300100100322533302700114a026464a66604c66e3c00801452889980200200098158011bae302900122232323300700123253330263370e90011812800899b8f375c605660480020082c6020604660206046002646600200200844a666050002297ae0132325333027323253330293370e90010008a5114a0604e0026024604a6024604a004266056004660080080022660080080026058004605400264a66604666e1d20003022001132323253330263370e9001181280089bae302b3024001163010302330103023301430230013029001302100116323300100100422533302700114c0103d87a80001323253330263375e6022604800400a266e9520003302a0024bd7009980200200098158011814800911980199802001129998109803800899299981119b8748010c0840044c8c8c8cdd2a40006605200497ae030090013028001302000116300a301f00114c103d87a800023375e00200444646600200200644a66604800229404c8c94ccc08cc014008528899802002000981400118130009119198008008019129998118008a5eb804c8c8c8c94ccc090cdc3a400400226600c00c006266050605260440046600c00c0066044002600a004604e004604a002464a66603a66e1d2000001132323232323232325333028302b002132498c8cc004004008894ccc0a800452613233003003302e0023232375a60560046e64dd7181480098160008b1bab3029001302900237326eb8c09c004c09c008dcc9bae3025001302500237326eb8c08c004c06c00858c06c0048c8c94ccc074cdc3a40080022944528180d8009802180c800980c0059bac30013016300330160092301d301e301e0013758600260286002602800e46036002603200260220022c600260200064602e603000229309b2b19299980899b874800000454ccc050c03c00c52616153330113370e90010008a99980a18078018a4c2c2c601e0046e64dd70009b99375c0026e64dd70009bac001375c0024600a6ea80048c00cdd5000ab9a5573aaae7955cfaba05742ae881", + "hash": "1389ddd7070bd334a572825204e068506ad25ab760c725daa8c0eb94" + } + ], + "definitions": { + "ByteArray": { + "dataType": "bytes" + }, + "List$ByteArray": { + "dataType": "list", + "items": { + "$ref": "#/definitions/ByteArray" + } + }, + "String": { + "dataType": "#string" + }, + "Void": { + "title": "Unit", + "description": "The nullary constructor.", + "anyOf": [ + { + "dataType": "constructor", + "index": 0, + "fields": [] + } + ] + }, + "voting/Redeemer": { + "title": "Redeemer", + "anyOf": [ + { + "title": "CreateVoteBatch", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "ReduceVoteBatches", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + } + } +} \ No newline at end of file diff --git a/backend-services/hydra-tally-app/smart-contract/validators/voting.ak b/backend-services/hydra-tally-app/smart-contract/validators/voting.ak new file mode 100644 index 000000000..eeb7b35a4 --- /dev/null +++ b/backend-services/hydra-tally-app/smart-contract/validators/voting.ak @@ -0,0 +1,266 @@ +use acca/datums as adatums +use acca/hash.{PubKeyHash} as ahash +use acca/string as astring +use acca/validators +use aiken/dict.{Dict} +use aiken/list +use aiken/transaction.{InlineDatum, Input, Output, ScriptContext, Spend} + +type VoteId = + String + +type Category = + String + +type Proposal = + String + +type Redeemer { + CreateVoteBatch + ReduceVoteBatches +} + +type CategoryResultsDatum { + event_id: String, + organiser: String, + category_id: Category, + results: Dict, +} + +type Vote { + event_id: String, + organiser: String, + vote_id: VoteId, + voter_key: PubKeyHash, + category_id: Category, + proposal_id: Proposal, + vote_score: Int, +} + +fn compare_proposal(left: Proposal, right: Proposal) -> Ordering { + astring.compare(left, right) +} + +validator( + _tallyNameHash: ByteArray, + authorised_parties_keys: List, + contract_event_id: String, + contract_organiser: String, + contract_category_id: String, +) { + fn voting(_voting_datum: Void, redeemer: Redeemer, sc: ScriptContext) -> Bool { + expect Spend(output_reference) = sc.purpose + + let inputs: List = sc.transaction.inputs + let outputs: List = sc.transaction.outputs + + when redeemer is { + CreateVoteBatch -> { + let votes: List = + get_votes_from_inputs( + contract_event_id, + contract_organiser, + contract_category_id, + inputs, + ) + + let results: Dict = count_votes(votes) + + let on_chain_results = + CategoryResultsDatum { + event_id: contract_event_id, + organiser: contract_organiser, + category_id: contract_category_id, + results, + } + + let must_be_signed = + list.any( + sc.transaction.extra_signatories, + fn(key) { list.has(authorised_parties_keys, key) }, + ) + + and { + validators.any_output_contains_own_validator_address( + inputs, + outputs, + output_reference, + )?, + must_be_signed?, + output_contains_final_result(sc.transaction.outputs, on_chain_results)?, + } + } + ReduceVoteBatches -> { + let on_chain_results: List = + get_vote_results_from_inputs( + contract_event_id, + contract_organiser, + contract_category_id, + inputs, + ) + + let counted_on_chain_results: Dict = + count_vote_results(on_chain_results) + + let must_be_signed = + list.any( + sc.transaction.extra_signatories, + fn(key) { list.has(authorised_parties_keys, key) }, + ) + + let final_result = + CategoryResultsDatum { + event_id: contract_event_id, + organiser: contract_organiser, + category_id: contract_category_id, + results: counted_on_chain_results, + } + + and { + validators.any_output_contains_own_validator_address( + inputs, + outputs, + output_reference, + )?, + must_be_signed?, + output_contains_final_result(sc.transaction.outputs, final_result)?, + } + } + } + } +} + +fn output_contains_final_result( + outputs: List, + on_chain_results: CategoryResultsDatum, +) -> Bool { + let result_checker = + fn(r: CategoryResultsDatum) { r == on_chain_results } + + let mapper = + fn(output) { + if adatums.is_inline_datum(output) { + expect InlineDatum(results_data) = output.datum + + expect off_chain_results: CategoryResultsDatum = results_data + + Some(off_chain_results) + } else { + None + } + } + + list.filter_map(outputs, mapper) + |> list.any(result_checker) +} + +fn get_vote_results_from_inputs( + contract_event_id: String, + contract_organiser: String, + contract_category_id: String, + inputs: List, +) -> List { + list.filter_map( + inputs, + fn(input) { + if adatums.is_inline_datum(input.output) { + expect InlineDatum(category_data) = input.output.datum + expect categoryResultsDatum: CategoryResultsDatum = category_data + + let conditionsFullfilled = and { + contract_event_id == categoryResultsDatum.event_id, + contract_organiser == categoryResultsDatum.organiser, + contract_category_id == categoryResultsDatum.category_id, + } + + if conditionsFullfilled { + Some(categoryResultsDatum) + } else { + None + } + } else { + None + } + }, + ) +} + +fn get_votes_from_inputs( + contract_event_id: String, + contract_organiser: String, + contract_category_id: String, + inputs: List, +) -> List { + list.filter_map( + inputs, + fn(input) { + if adatums.is_inline_datum(input.output) { + expect InlineDatum(vote_data) = input.output.datum + expect vote: Vote = vote_data + + let conditionsFullfilled = and { + contract_event_id == vote.event_id, + contract_category_id == vote.category_id, + contract_organiser == vote.organiser, + } + + if conditionsFullfilled { + Some(vote) + } else { + None + } + } else { + None + } + }, + ) +} + +fn count_vote_results( + on_chain_results: List, +) -> Dict { + let merging_fn = + fn(r1: Dict, r2: Dict) { + dict.union_with( + left: r1, + right: r2, + with: fn(_key, a, b) { Some(a + b) }, + compare: compare_proposal, + ) + } + + let dict_list_with_results: List> = + list.map(on_chain_results, fn(rbd) { rbd.results }) + + let empty = dict.new() + list.reduce(dict_list_with_results, empty, merging_fn) +} + +fn count_votes(votes: List) -> Dict { + let empty = dict.new() + + do_count_votes(votes, empty) +} + +fn do_count_votes( + votes: List, + acc: Dict, +) -> Dict { + when votes is { + [] -> acc + [h, ..t] -> { + let p: Proposal = h.proposal_id + dict.union_with( + left: do_count_votes(t, acc), + right: dict.insert( + self: dict.new(), + key: p, + value: h.vote_score, + compare: compare_proposal, + ), + with: fn(_key, left, right) { Some(left + right) }, + compare: compare_proposal, + ) + } + } +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/HydraTallyApp.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/HydraTallyApp.java new file mode 100644 index 000000000..1771122b0 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/HydraTallyApp.java @@ -0,0 +1,61 @@ +package org.cardano.foundation.voting; + +import lombok.extern.slf4j.Slf4j; +import org.cardano.foundation.voting.domain.CardanoNetwork; +import org.cardanofoundation.hydra.reactor.HydraReactiveClient; +import org.jline.utils.AttributedString; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.event.ApplicationEventMulticaster; +import org.springframework.context.event.SimpleApplicationEventMulticaster; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.shell.command.annotation.CommandScan; +import org.springframework.shell.command.annotation.EnableCommand; +import org.springframework.shell.jline.PromptProvider; + +@SpringBootApplication +@ComponentScan(basePackages = { + "org.cardano.foundation.voting.service", + "org.cardano.foundation.voting.config", + "org.cardano.foundation.voting.client", +}) +@EnableCommand +@CommandScan(basePackages = { "org.cardano.foundation.voting.shell" }) +@Slf4j +public class HydraTallyApp implements PromptProvider { + + @Autowired + private CardanoNetwork network; + + @Value("${hydra.operator.name}") + private String actor; + + @Autowired + private HydraReactiveClient hydraReactiveClient; + + public static void main(String[] args) { + SpringApplication.run(HydraTallyApp.class, args); + } + + @Bean(name = "applicationEventMulticaster") + public ApplicationEventMulticaster simpleApplicationEventMulticaster() { + SimpleApplicationEventMulticaster eventMulticaster = + new SimpleApplicationEventMulticaster(); + + eventMulticaster.setTaskExecutor(new SimpleAsyncTaskExecutor()); + + return eventMulticaster; + } + + @Override + public AttributedString getPrompt() { + var prompt = String.format("%s:%s:%s>>", actor, hydraReactiveClient.getHydraState().toString(), network); + + return new AttributedString(prompt); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/client/ChainFollowerClient.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/client/ChainFollowerClient.java new file mode 100644 index 000000000..f7fa20f7b --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/client/ChainFollowerClient.java @@ -0,0 +1,81 @@ +package org.cardano.foundation.voting.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.vavr.control.Either; +import lombok.extern.slf4j.Slf4j; +import org.cardano.foundation.voting.domain.TallyType; +import org.cardano.foundation.voting.domain.VotingEventType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.zalando.problem.Problem; +import org.zalando.problem.spring.common.HttpStatusAdapter; + +import java.util.List; +import java.util.Optional; + +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Component +@Slf4j +public class ChainFollowerClient { + + @Autowired + private RestTemplate restTemplate; + + @Value("${ledger.follower.app.base.url}") + private String ledgerFollowerBaseUrl; + + public Either> getEventDetails(String eventId) { + var url = String.format("%s/api/reference/event/{id}", ledgerFollowerBaseUrl); + + try { + return Either.right(Optional.ofNullable(restTemplate.getForObject(url, EventDetailsResponse.class, eventId))); + } catch (HttpClientErrorException e) { + if (e.getStatusCode() == NOT_FOUND) { + return Either.right(Optional.empty()); + } + + return Either.left(Problem.builder() + .withTitle("REFERENCE_ERROR") + .withDetail("Unable to get event details from chain-tip follower service, reason:" + e.getMessage()) + .withStatus(new HttpStatusAdapter(e.getStatusCode())) + .build()); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record EventDetailsResponse(String id, + String organisers, + boolean proposalsReveal, + VotingEventType votingEventType, + List tallies) { + + public Optional findTallyByName(String name) { + return tallies.stream() + .filter(t -> t.name().equals(name)) + .findFirst(); + } + + } + + public record Tally ( + String name, + String description, + TallyType type, + HydraTallyConfig config) { + } + + public record HydraTallyConfig(String compiledScript, + String contractName, + String contractVersion, + String compiledScriptHash, + String compilerVersion, + String compilerName, + String plutusVersion, + List verificationKeys) { + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java new file mode 100644 index 000000000..33b23f2c0 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java @@ -0,0 +1,101 @@ +package org.cardano.foundation.voting.config; + +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.common.model.Network; +import com.bloxbean.cardano.client.common.model.Networks; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.cardano.foundation.voting.domain.CardanoNetwork; +import org.cardano.foundation.voting.domain.WalletType; +import org.cardanofoundation.hydra.cardano.client.lib.wallet.AccountWalletSupplier; +import org.cardanofoundation.hydra.cardano.client.lib.wallet.JsonURLWalletSupplierFactory; +import org.cardanofoundation.hydra.cardano.client.lib.wallet.Wallet; +import org.cardanofoundation.hydra.cardano.client.lib.wallet.WalletSupplier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.io.ResourceLoader; + +import java.io.IOException; + +@Configuration +@Slf4j +@AllArgsConstructor +public class CardanoConfig { + + private final Environment environment; + + private final ResourceLoader resourceLoader; + + @Bean + public CardanoNetwork network(@Value("${cardano.network:main}") CardanoNetwork network) { + log.info("Configured backend network:{}", network); + + return network; + } + + @Bean + public Network cardanoNetwork(CardanoNetwork cardanoNetwork) { + return switch(cardanoNetwork) { + case MAIN -> Networks.mainnet(); + case PREPROD -> Networks.preprod(); + case PREVIEW -> Networks.preview(); + case DEV -> Networks.testnet(); + }; + } + + @Bean + public WalletSupplier walletSupplier(@Value("${l1.operator.wallet.type}") WalletType walletType, + Network network, + ObjectMapper objectMapper) throws IOException { + return switch (walletType) { + case MNEMONIC -> { + val mnemonic = environment.getProperty("l1.operator.mnemonic"); + val mnemonicIndex = environment.getProperty("l1.operator.mnemonic.index", Integer.class, 0); + val account = new Account(network, mnemonic, mnemonicIndex.intValue()); + + yield new AccountWalletSupplier(account); + } + case CLI_JSON_FILE -> { + val signingKeyFilePath = environment.getProperty("l1.operator.signing.key.file.path"); + val verificationKeyFilePath = environment.getProperty("l1.operator.verification.key.file.path"); + log.info("L1 Signing file path: {}", signingKeyFilePath); + log.info("L1 Verification file path: {}", verificationKeyFilePath); + + assert signingKeyFilePath != null; + assert verificationKeyFilePath != null; + + val jsonFileWalletSupplierFactory = new JsonURLWalletSupplierFactory( + resourceLoader.getResource(signingKeyFilePath).getURL(), + resourceLoader.getResource(verificationKeyFilePath).getURL(), + objectMapper + ); + + yield jsonFileWalletSupplierFactory.loadWallet(); + } + }; + } + + @Bean + public Wallet l1Wallet(Network network, + WalletSupplier walletSupplier) { + val wallet = walletSupplier.getWallet(); + val verificationKey = wallet.getVerificationKey(); + val secretKey = wallet.getSecretKey(); + + log.info("L1 wallet address: {}", wallet.getBech32Address(network)); + log.info("L1 wallet verification key: {}", verificationKey.getCborHex()); + log.info("L1 wallet verification key type: {}", verificationKey.getType()); + log.info("L1 wallet verification key desc: {}", verificationKey.getDescription()); + + log.info("L1 wallet secret key: {}", secretKey.getCborHex()); + log.info("L1 wallet secret key type: {}", secretKey.getType()); + log.info("L1 wallet secret key desc: {}", secretKey.getDescription()); + + return wallet; + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/HydraConfig.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/HydraConfig.java new file mode 100644 index 000000000..48405e6a3 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/HydraConfig.java @@ -0,0 +1,62 @@ +package org.cardano.foundation.voting.config; + +import com.bloxbean.cardano.client.api.ProtocolParamsSupplier; +import com.bloxbean.cardano.client.api.UtxoSupplier; +import lombok.extern.slf4j.Slf4j; +import org.cardano.foundation.voting.service.ReactiveWebSocketHydraTxSubmissionService; +import org.cardanofoundation.hydra.cardano.client.lib.params.HydraNodeProtocolParametersAdapter; +import org.cardanofoundation.hydra.cardano.client.lib.submit.TransactionSubmissionService; +import org.cardanofoundation.hydra.cardano.client.lib.utxo.SnapshotUTxOSupplier; +import org.cardanofoundation.hydra.core.store.InMemoryUTxOStore; +import org.cardanofoundation.hydra.core.store.UTxOStore; +import org.cardanofoundation.hydra.reactor.HydraReactiveClient; +import org.cardanofoundation.hydra.reactor.HydraReactiveWebClient; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.net.http.HttpClient; +import java.time.Duration; + +@Configuration +@Slf4j +public class HydraConfig { + + @Bean + public UTxOStore uTxOStore() { + return new InMemoryUTxOStore(); + } + + @Bean + public UtxoSupplier snapshotUTxOSupplier(UTxOStore uTxOStore) { + return new SnapshotUTxOSupplier(uTxOStore); + } + + @Bean + public HydraReactiveClient hydraReactiveClient(UTxOStore uTxOStore, + @Value("${hydra.ws.url}") String hydraWsUrl) { + return new HydraReactiveClient(uTxOStore, hydraWsUrl); + } + + @Bean + public HydraReactiveWebClient hydraWebClient(HttpClient httpClient, + @Value("${hydra.http.url}") String hydraHttpUrl) { + return new HydraReactiveWebClient(httpClient, hydraHttpUrl); + } + + @Bean + public ProtocolParamsSupplier protocolParamsSupplier(HydraReactiveWebClient hydraReactiveWebClient) { + var hydraProtocolParameters = hydraReactiveWebClient.fetchProtocolParameters() + .block(Duration.ofMinutes(5)); + + return new HydraNodeProtocolParametersAdapter(hydraProtocolParameters); + } + + @Bean + @Qualifier("hydra-transaction-submission-service") + public TransactionSubmissionService hydraTransactionSubmissionService(HydraReactiveClient hydraReactiveClient) { + return new ReactiveWebSocketHydraTxSubmissionService(hydraReactiveClient); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/JsonConfig.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/JsonConfig.java new file mode 100644 index 000000000..bd03748c9 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/JsonConfig.java @@ -0,0 +1,28 @@ +package org.cardano.foundation.voting.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Slf4j +public class JsonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + + objectMapper.findAndRegisterModules(); + + log.info("Registered jackson modules:"); + objectMapper.getRegisteredModuleIds().forEach(moduleId -> { + log.info("Module: {}", moduleId); + }); + + return objectMapper; + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/PlutusConfig.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/PlutusConfig.java new file mode 100644 index 000000000..6bf18f5e2 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/PlutusConfig.java @@ -0,0 +1,33 @@ +package org.cardano.foundation.voting.config; + +import org.cardano.foundation.voting.domain.CategoryResultsDatumConverter; +import org.cardano.foundation.voting.domain.CreateVoteBatchRedeemerConverter; +import org.cardano.foundation.voting.domain.ReduceVoteBatchRedeemerConverter; +import org.cardano.foundation.voting.domain.VoteDatumConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class PlutusConfig { + + @Bean + public CategoryResultsDatumConverter categoryResultsDatumConverter() { + return new CategoryResultsDatumConverter(); + } + + @Bean + public VoteDatumConverter voteDatumConverter() { + return new VoteDatumConverter(); + } + + @Bean + public ReduceVoteBatchRedeemerConverter reduceVoteBatchRedeemerConverter() { + return new ReduceVoteBatchRedeemerConverter(); + } + + @Bean + public CreateVoteBatchRedeemerConverter createVoteBatchRedeemerConverter() { + return new CreateVoteBatchRedeemerConverter(); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/RepositoryConfig.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/RepositoryConfig.java new file mode 100644 index 000000000..8bd1dea5c --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/RepositoryConfig.java @@ -0,0 +1,23 @@ +package org.cardano.foundation.voting.config; + +import lombok.SneakyThrows; +import org.cardano.foundation.voting.repository.LocalVoteRepository; +import org.cardano.foundation.voting.repository.VoteRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ResourceLoader; + +@Configuration +public class RepositoryConfig { + + @Value("${votes.path}") + private String votesPath; + + @Bean + @SneakyThrows + public VoteRepository voteRepository(ResourceLoader resourceLoader) { + return new LocalVoteRepository(resourceLoader, votesPath); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/RestConfig.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/RestConfig.java new file mode 100644 index 000000000..3a46673ea --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/RestConfig.java @@ -0,0 +1,17 @@ +package org.cardano.foundation.voting.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .build(); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/TimeConfig.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/TimeConfig.java new file mode 100644 index 000000000..fafb81398 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/TimeConfig.java @@ -0,0 +1,16 @@ +package org.cardano.foundation.voting.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class TimeConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/WebClientConfig.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/WebClientConfig.java new file mode 100644 index 000000000..8afab8821 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/config/WebClientConfig.java @@ -0,0 +1,32 @@ +package org.cardano.foundation.voting.config; + +import org.cardanofoundation.hydra.cardano.client.lib.submit.HttpCardanoTxSubmissionService; +import org.cardanofoundation.hydra.cardano.client.lib.submit.TransactionSubmissionService; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.net.http.HttpClient; +import java.time.Duration; + +@Configuration +public class WebClientConfig { + + @Bean + public HttpClient httpClient() { + return HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(30)) + .build(); + } + + @Bean + @Qualifier("l1-transaction-submission-service") + public TransactionSubmissionService blockchainTransactionSubmissionService(HttpClient httpClient, + @Value("${cardano.tx.submit.api.url}") String blockchainApiUrl) { + return new HttpCardanoTxSubmissionService(httpClient, blockchainApiUrl); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CardanoNetwork.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CardanoNetwork.java new file mode 100644 index 000000000..3ec1adda0 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CardanoNetwork.java @@ -0,0 +1,17 @@ +package org.cardano.foundation.voting.domain; + +import java.util.Arrays; +import java.util.List; + +public enum CardanoNetwork { + + MAIN, // main-net + PREPROD, // preprod-net + PREVIEW, // preview-net + DEV; // e.g. locally hosted cardano-node + + public static List supportedNetworks() { + return Arrays.stream(CardanoNetwork.values()).map(network -> network.name().toLowerCase()).toList(); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CategoryResultsDatum.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CategoryResultsDatum.java new file mode 100644 index 000000000..931e81ebe --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CategoryResultsDatum.java @@ -0,0 +1,36 @@ +package org.cardano.foundation.voting.domain; + +import com.bloxbean.cardano.client.plutus.annotation.Constr; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.val; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Constr +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CategoryResultsDatum { + + private String eventId; + + private String organiser; + + private String categoryId; + + private Map results; + + public static CategoryResultsDatum empty(String eventId, String organiser, String categoryId) { + return new CategoryResultsDatum(eventId, organiser, categoryId, new LinkedHashMap<>()); + } + + public void add(String proposalId, long newResult) { + val existingResult = results.getOrDefault(proposalId, 0L); + + results.put(proposalId, existingResult + newResult); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CommitType.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CommitType.java new file mode 100644 index 000000000..734f5e017 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CommitType.java @@ -0,0 +1,7 @@ +package org.cardano.foundation.voting.domain; + +public enum CommitType { + + COMMIT_FUNDS, COMMIT_EMPTY + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CreateVoteBatchRedeemer.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CreateVoteBatchRedeemer.java new file mode 100644 index 000000000..78678427e --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/CreateVoteBatchRedeemer.java @@ -0,0 +1,17 @@ +package org.cardano.foundation.voting.domain; + +import com.bloxbean.cardano.client.plutus.annotation.Constr; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@Constr(alternative = 0) +public class CreateVoteBatchRedeemer { + + public static CreateVoteBatchRedeemer create() { + return new CreateVoteBatchRedeemer(); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/LocalBootstrap.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/LocalBootstrap.java new file mode 100644 index 000000000..fe2c27984 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/LocalBootstrap.java @@ -0,0 +1,7 @@ +package org.cardano.foundation.voting.domain; + +public enum LocalBootstrap { + + SHARDED, NOT_SHARDED; + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/ReduceVoteBatchRedeemer.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/ReduceVoteBatchRedeemer.java new file mode 100644 index 000000000..9d72d3d0f --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/ReduceVoteBatchRedeemer.java @@ -0,0 +1,16 @@ +package org.cardano.foundation.voting.domain; + +import com.bloxbean.cardano.client.plutus.annotation.Constr; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +@Constr(alternative = 1) +public class ReduceVoteBatchRedeemer { + + public static ReduceVoteBatchRedeemer create() { + return new ReduceVoteBatchRedeemer(); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/TallyType.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/TallyType.java new file mode 100644 index 000000000..2d2d8823b --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/TallyType.java @@ -0,0 +1,5 @@ +package org.cardano.foundation.voting.domain; + +public enum TallyType { + HYDRA, +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/UTxOCategoryResult.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/UTxOCategoryResult.java new file mode 100644 index 000000000..aba66e862 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/UTxOCategoryResult.java @@ -0,0 +1,6 @@ +package org.cardano.foundation.voting.domain; + +import com.bloxbean.cardano.client.api.model.Utxo; + +public record UTxOCategoryResult(Utxo utxo, CategoryResultsDatum categoryResultsDatum) { +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/UTxOVote.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/UTxOVote.java new file mode 100644 index 000000000..dde111261 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/UTxOVote.java @@ -0,0 +1,6 @@ +package org.cardano.foundation.voting.domain; + +import com.bloxbean.cardano.client.api.model.Utxo; + +public record UTxOVote(Utxo utxo, VoteDatum voteDatum) { +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/Vote.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/Vote.java new file mode 100644 index 000000000..aeb9ab858 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/Vote.java @@ -0,0 +1,78 @@ +package org.cardano.foundation.voting.domain; + +import io.vavr.control.Either; +import org.cardanofoundation.cip30.CIP30Verifier; +import org.zalando.problem.Problem; + +import java.util.Optional; +import java.util.UUID; + +import static org.cardano.foundation.voting.utils.MoreUUID.isUUIDv4; + +public record Vote( + UUID voteId, + String eventId, + String categoryId, + UUID proposalId, + String voterStakeAddressBech32, + byte[] voterStakeAddress, + Optional votingPower, + String coseSignature, + Optional cosePublicKey) { + + public static Either create(String voteId, + String eventId, + String categoryId, + String proposalId, + String voterStakeAddressBech32, + Optional votingPower, + String coseSignature, + Optional cosePublicKey) { + var parser = new CIP30Verifier(coseSignature, cosePublicKey); + + var result = parser.verify(); + + if (!result.isValid()) { + var problem = Problem.builder() + .withTitle("COSE_ERROR") + .withDetail("Cose signature failed for voteId:" + voteId).build(); + + return Either.left(problem); + } + + if (!isUUIDv4(voteId)) { + var problem = Problem.builder().withTitle("NO_UUID4").withDetail("voteId must be UUID4").build(); + + return Either.left(problem); + } + + if (!isUUIDv4(proposalId)) { + var problem = Problem.builder().withTitle("NO_UUID4").withDetail("Proposal must be UUID4").build(); + + return Either.left(problem); + } + + var addrAsByteArray = result.getAddress(); + + if (addrAsByteArray.isEmpty()) { + var problem = Problem.builder().withTitle("NO_UUID4").withDetail("Category must be UUID4").build(); + + return Either.left(problem); + } + + var vote = new Vote( + UUID.fromString(voteId), + eventId, + categoryId, + UUID.fromString(proposalId), + voterStakeAddressBech32, + addrAsByteArray.orElseThrow(), + votingPower.map(Long::parseLong), + coseSignature, + cosePublicKey + ); + + return Either.right(vote); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/VoteDatum.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/VoteDatum.java new file mode 100644 index 000000000..b359afabb --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/VoteDatum.java @@ -0,0 +1,30 @@ +package org.cardano.foundation.voting.domain; + +import com.bloxbean.cardano.client.plutus.annotation.Constr; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Constr +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VoteDatum { + + private String eventId; + + private String organisers; + + private String voteId; + + private byte[] voterKey; + + private String categoryId; + + private String proposalId; + + private Long voteScore; + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/VotingEventType.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/VotingEventType.java new file mode 100644 index 000000000..195591b4c --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/VotingEventType.java @@ -0,0 +1,11 @@ +package org.cardano.foundation.voting.domain; + +public enum VotingEventType { + + USER_BASED, // 1 person 1 vote + + STAKE_BASED, // 1 ADA = 1 vote but voter must stake it + + BALANCE_BASED // 1 ADA = 1 vote but voter don't have to stake it + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/WalletType.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/WalletType.java new file mode 100644 index 000000000..46647d6ac --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/domain/WalletType.java @@ -0,0 +1,7 @@ +package org.cardano.foundation.voting.domain; + +public enum WalletType { + + CLI_JSON_FILE, MNEMONIC + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/repository/LocalVoteRepository.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/repository/LocalVoteRepository.java new file mode 100644 index 000000000..c49421817 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/repository/LocalVoteRepository.java @@ -0,0 +1,105 @@ +package org.cardano.foundation.voting.repository; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.csv.CSVFormat; +import org.cardano.foundation.voting.domain.Vote; +import org.springframework.core.io.ResourceLoader; + +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Slf4j +public class LocalVoteRepository implements VoteRepository { + + private final ResourceLoader resourceLoader; + + private final String votesPath; + + @Override + @SneakyThrows + public List findAllVotes(String eventId) { + var r = resourceLoader.getResource(votesPath); + + var votes = new ArrayList(); + + var format = CSVFormat.POSTGRESQL_CSV.builder() + .setSkipHeaderRecord(true) + .setHeader( + "id", + "id_numeric_hash", + "event_id", + "category_id", + "proposal_id", + "voter_stake_address", + "cose_signature", + "cose_public_key", + "voting_power", + "voted_at_slot", + "created_at", + "updated_at" + ) + .build(); + + var votesParsed = format.parse(new InputStreamReader(r.getInputStream())); + + for (var vote : votesParsed) { + var voteId = vote.get("id"); + var voteEventId = vote.get("event_id"); + var coseSignature = vote.get("cose_signature"); + var cosePublicKey = vote.get("cose_public_key"); + var categoryId = vote.get("category_id"); + var proposalId = vote.get("proposal_id"); + var voterStakeAddress = vote.get("voter_stake_address"); + var votingPower = Optional.ofNullable(vote.get("voting_power")); + + if (!voteEventId.equals(eventId)) { + continue; + } + + var voteE = Vote.create( + voteId, + voteEventId, + categoryId, + proposalId, + voterStakeAddress, + votingPower, + coseSignature, + Optional.ofNullable(cosePublicKey) + ); + + if (voteE.isEmpty()) { + log.error("Vote creation failed, reason:{}", voteE.getLeft()); + } + + votes.add(voteE.get()); + } + + return votes; + } + + @Override + public List findAllVotes(String eventId, String categoryId) { + return findAllVotes(eventId) + .parallelStream() + .filter(v -> { + return v.categoryId().equals(categoryId); + }) + .toList(); + } + + @Override + public Set getAllUniqueCategories(String eventId) { + return findAllVotes(eventId) + .parallelStream() + .map(Vote::categoryId) + .collect(Collectors.toSet()); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/repository/VoteRepository.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/repository/VoteRepository.java new file mode 100644 index 000000000..1333e22cd --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/repository/VoteRepository.java @@ -0,0 +1,16 @@ +package org.cardano.foundation.voting.repository; + +import org.cardano.foundation.voting.domain.Vote; + +import java.util.List; +import java.util.Set; + +public interface VoteRepository { + + List findAllVotes(String eventId); + + List findAllVotes(String eventId, String categoryId); + + Set getAllUniqueCategories(String eventId); + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/HydraVoteBatchReducer.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/HydraVoteBatchReducer.java new file mode 100644 index 000000000..a4d208759 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/HydraVoteBatchReducer.java @@ -0,0 +1,247 @@ +package org.cardano.foundation.voting.service; + +import com.bloxbean.cardano.client.api.ProtocolParamsSupplier; +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.api.model.Amount; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.coinselection.impl.LargestFirstUtxoSelectionStrategy; +import com.bloxbean.cardano.client.common.model.Network; +import com.bloxbean.cardano.client.exception.CborSerializationException; +import com.bloxbean.cardano.client.function.Output; +import com.bloxbean.cardano.client.function.TxBuilderContext; +import com.bloxbean.cardano.client.function.helper.CollateralBuilders; +import com.bloxbean.cardano.client.function.helper.InputBuilders; +import com.bloxbean.cardano.client.function.helper.ScriptCallContextProviders; +import com.bloxbean.cardano.client.function.helper.model.ScriptCallContext; +import com.bloxbean.cardano.client.plutus.spec.ExUnits; +import com.bloxbean.cardano.client.plutus.spec.PlutusV2Script; +import io.vavr.control.Either; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.cardano.foundation.voting.domain.CategoryResultsDatum; +import org.cardano.foundation.voting.domain.CategoryResultsDatumConverter; +import org.cardano.foundation.voting.domain.ReduceVoteBatchRedeemer; +import org.cardano.foundation.voting.domain.UTxOCategoryResult; +import org.cardanofoundation.hydra.cardano.client.lib.submit.TransactionSubmissionService; +import org.cardanofoundation.hydra.cardano.client.lib.wallet.WalletSupplier; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.zalando.problem.Problem; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static com.bloxbean.cardano.client.common.ADAConversionUtil.adaToLovelace; +import static com.bloxbean.cardano.client.common.CardanoConstants.LOVELACE; +import static com.bloxbean.cardano.client.crypto.KeyGenUtil.getKeyHash; +import static com.bloxbean.cardano.client.plutus.spec.RedeemerTag.Spend; +import static java.util.Collections.emptySet; +import static org.cardano.foundation.voting.service.PlutusScriptLoader.evaluateExUnits; +import static org.cardano.foundation.voting.utils.BalanceUtil.balanceTx; +import static org.cardanofoundation.hydra.core.utils.HexUtils.decodeHexString; + +@Component +@Slf4j +public class HydraVoteBatchReducer { + + @Autowired + private VoteUtxoFinder voteUtxoFinder; + + @Autowired + private UtxoSupplier utxoSupplier; + + @Autowired + private ProtocolParamsSupplier protocolParamsSupplier; + + @Autowired + @Qualifier("hydra-transaction-submission-service") + private TransactionSubmissionService transactionProcessor; + + @Autowired + private WalletSupplier walletSupplier; + + @Autowired + private PlutusScriptLoader plutusScriptLoader; + + @Autowired + private Network network; + + @Autowired + private CategoryResultsDatumConverter categoryResultsDatumConverter; + + @Autowired + private org.cardano.foundation.voting.domain.ReduceVoteBatchRedeemerConverter reduceVoteBatchRedeemerConverter; + + public void batchVotesPerCategory(String contractEventId, + String contractOrganiser, + String contractCategoryId, + int batchSize) throws CborSerializationException { + val contract = plutusScriptLoader.getContract(contractCategoryId); + + Either> transactionResultE; + do { + transactionResultE = postReduceBatchTransaction(contractEventId, + contractOrganiser, + contractCategoryId, + batchSize + ); + + if (transactionResultE.isEmpty()) { + log.error("Reducing votes failed, reason:{}", transactionResultE.getLeft()); + return; + } + + val resultM = transactionResultE.get(); + + if (resultM.isEmpty()) { + //log.info("No more reducers to create within category: {}", contractCategoryId); + break; + } + + val txId = resultM.orElseThrow(); + + log.info("TxId: " + txId); + + } while (transactionResultE.isRight() && transactionResultE.get().isPresent()); + } + + private Either> postReduceBatchTransaction(String contractEventId, + String contractOrganiser, + String contractCategoryId, + int batchSize) throws CborSerializationException { + val contract = plutusScriptLoader.getContract(contractCategoryId); + val contractAddress = plutusScriptLoader.getContractAddress(contract); + + val wallet = walletSupplier.getWallet(); + val sender = wallet.getBech32Address(network); + val senderVerificationKeyBlake224 = getKeyHash(wallet.getVerificationKey()); + + val utxosWithCategoryResults = voteUtxoFinder.getUtxosWithCategoryResults(contractEventId, + contractOrganiser, + contractCategoryId, + batchSize + ); + + if (utxosWithCategoryResults.isEmpty()) { + //log.warn("No utxo found"); + + return Either.right(Optional.empty()); + } + + if (utxosWithCategoryResults.size() == 1) { + //log.info("Only final reduction left!"); + + return Either.right(Optional.empty()); + } + + val categoryResultsDatums = utxosWithCategoryResults.stream() + .map(UTxOCategoryResult::categoryResultsDatum) + .toList(); + + val reduceVoteBatchDatum = categoryResultsDatum(contractEventId, + contractOrganiser, + contractCategoryId, + categoryResultsDatums + ); + + //System.out.println("Reduction:" + JsonUtil.getPrettyJson(reduceVoteBatchDatum)); + + val utxoSelectionStrategy = new LargestFirstUtxoSelectionStrategy(utxoSupplier); + val collateralUtxos = utxoSelectionStrategy.select(sender, new Amount(LOVELACE, adaToLovelace(2)), emptySet()); + + val outputDatum = categoryResultsDatumConverter.toPlutusData(reduceVoteBatchDatum); + + val output1 = Output.builder() + .address(contractAddress) + .datum(outputDatum) + .inlineDatum(true) + .assetName(LOVELACE) + .qty(adaToLovelace(1)) + .build(); + + val scriptUtxos = utxosWithCategoryResults + .stream() + .map(UTxOCategoryResult::utxo) + .toList(); + + val extraInputs = utxoSelectionStrategy.select(sender, new Amount(LOVELACE, adaToLovelace(2)), Set.of()); + + List allInputs = new ArrayList<>(); + allInputs.addAll(scriptUtxos); + allInputs.addAll(extraInputs); + + val scriptCallContexts = scriptUtxos.stream().map(utxo -> ScriptCallContext + .builder() + .script(contract) + .utxo(utxo) + .exUnits(ExUnits.builder() // Exact exUnits will be calculated later + .mem(BigInteger.valueOf(0)) + .steps(BigInteger.valueOf(0)) + .build()) + .redeemer(reduceVoteBatchRedeemerConverter.toPlutusData(ReduceVoteBatchRedeemer.create())) + .redeemerTag(Spend).build()) + .toList(); + + var txBuilder = output1.outputBuilder() + .buildInputs(InputBuilders.createFromUtxos(allInputs, sender)) + //.andThen(output2.outputBuilder().buildInputs(InputBuilders.createFromSender(sender, sender))) + .andThen(CollateralBuilders.collateralOutputs(sender, new ArrayList<>(collateralUtxos))); // CIP-40 + + for (val scriptCallContext : scriptCallContexts) { + txBuilder = txBuilder.andThen(ScriptCallContextProviders.createFromScriptCallContext(scriptCallContext)); + } + + txBuilder = txBuilder.andThen((context, txn) -> { + val protocolParams = protocolParamsSupplier.getProtocolParams(); + val utxos = context.getUtxos(); + + txn.getBody().getRequiredSigners().add(decodeHexString(senderVerificationKeyBlake224)); + + val evaluatedExUnits = evaluateExUnits(txn, utxos, protocolParams); + + val redeemers = txn.getWitnessSet().getRedeemers(); + for (val redeemer : redeemers) { //Update costs + evaluatedExUnits.stream().filter(evalReedemer -> evalReedemer.getIndex().equals(redeemer.getIndex())) + .findFirst() + .ifPresent(evalRedeemer -> redeemer.setExUnits(evalRedeemer.getExUnits())); + } + + // Remove all scripts from witness and just add one + txn.getWitnessSet().getPlutusV2Scripts().clear(); + txn.getWitnessSet().getPlutusV2Scripts().add((PlutusV2Script) contract); + }) + .andThen(balanceTx(sender, 1)); + + val txBuilderContext = TxBuilderContext.init(utxoSupplier, protocolParamsSupplier); + val transaction = txBuilderContext.build(txBuilder); + + val result = transactionProcessor.submitTransaction(wallet.getTxSigner().sign(transaction)); + if (!result.isSuccessful()) { + return Either.left(Problem.builder() + .withTitle("Transaction failed") + .withDetail("Transaction failed. " + result.getResponse()) + .build()); + } + + return Either.right(Optional.of(result.getValue())); + } + + public static CategoryResultsDatum categoryResultsDatum(String eventId, + String contractOrganiser, + String contractCategoryId, + List categoryResultsDataList) { + val groupResultBatchDatum = CategoryResultsDatum.empty(eventId, contractOrganiser, contractCategoryId); + + for (val categoryResultsDatum : categoryResultsDataList) { + categoryResultsDatum.getResults() + .forEach(groupResultBatchDatum::add); + } + + return groupResultBatchDatum; + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/HydraVoteBatcher.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/HydraVoteBatcher.java new file mode 100644 index 000000000..e730ca542 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/HydraVoteBatcher.java @@ -0,0 +1,232 @@ +package org.cardano.foundation.voting.service; + +import com.bloxbean.cardano.client.api.ProtocolParamsSupplier; +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.api.model.Amount; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.coinselection.impl.LargestFirstUtxoSelectionStrategy; +import com.bloxbean.cardano.client.common.model.Network; +import com.bloxbean.cardano.client.exception.CborSerializationException; +import com.bloxbean.cardano.client.function.Output; +import com.bloxbean.cardano.client.function.TxBuilderContext; +import com.bloxbean.cardano.client.function.helper.CollateralBuilders; +import com.bloxbean.cardano.client.function.helper.InputBuilders; +import com.bloxbean.cardano.client.function.helper.ScriptCallContextProviders; +import com.bloxbean.cardano.client.function.helper.model.ScriptCallContext; +import com.bloxbean.cardano.client.plutus.spec.ExUnits; +import com.bloxbean.cardano.client.plutus.spec.PlutusV2Script; +import io.vavr.control.Either; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.cardano.foundation.voting.domain.CategoryResultsDatum; +import org.cardano.foundation.voting.domain.CategoryResultsDatumConverter; +import org.cardano.foundation.voting.domain.CreateVoteBatchRedeemer; +import org.cardano.foundation.voting.domain.UTxOVote; +import org.cardano.foundation.voting.utils.BalanceUtil; +import org.cardanofoundation.hydra.cardano.client.lib.submit.TransactionSubmissionService; +import org.cardanofoundation.hydra.cardano.client.lib.wallet.WalletSupplier; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.zalando.problem.Problem; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static com.bloxbean.cardano.client.common.ADAConversionUtil.adaToLovelace; +import static com.bloxbean.cardano.client.common.CardanoConstants.LOVELACE; +import static com.bloxbean.cardano.client.crypto.KeyGenUtil.getKeyHash; +import static com.bloxbean.cardano.client.plutus.spec.RedeemerTag.Spend; +import static java.util.Collections.emptySet; +import static org.cardanofoundation.hydra.core.utils.HexUtils.decodeHexString; + +@Component +@Slf4j +public class HydraVoteBatcher { + + @Autowired + private VoteUtxoFinder voteUtxoFinder; + + @Autowired + private UtxoSupplier utxoSupplier; + + @Autowired + private ProtocolParamsSupplier protocolParamsSupplier; + + @Autowired + @Qualifier("hydra-transaction-submission-service") + private TransactionSubmissionService transactionProcessor; + + @Autowired + private WalletSupplier walletSupplier; + + @Autowired + private PlutusScriptLoader plutusScriptLoader; + + @Autowired + private CategoryResultsDatumConverter categoryResultsDatumConverter; + + @Autowired + private Network network; + + @Autowired + private org.cardano.foundation.voting.domain.CreateVoteBatchRedeemerConverter createVoteBatchRedeemerConverter; + + + public void batchVotesPerCategory(String contractEventId, + String contractOrganiser, + String contractCategoryId, + int batchSize) throws CborSerializationException { + val contract = plutusScriptLoader.getContract(contractCategoryId); + val contractAddress = plutusScriptLoader.getContractAddress(contract); + + //log.info("Contract Address: {}", contractAddress); + + Either> transactionResultE; + do { + transactionResultE = createAndPostBatchTransaction(contractEventId, contractOrganiser, contractCategoryId, batchSize); + + if (transactionResultE.isEmpty()) { + log.error("Batching votes failed, reason:{}", transactionResultE.getLeft()); + return; + } + + val batchTransactionResultM = transactionResultE.get(); + + if (batchTransactionResultM.isEmpty()) { + //log.info("No more batches to create within category: {}", contractCategoryId); + break; + } + + val txId = batchTransactionResultM.orElseThrow(); + + log.info("Batched votes, txId: " + txId); + + } while (transactionResultE.isRight() && transactionResultE.get().isPresent()); + } + + private Either> createAndPostBatchTransaction( + String contractEventId, + String contractOrganiser, + String contractCategoryId, + int batchSize) + throws CborSerializationException { + + val utxosWithVotes = voteUtxoFinder.getUtxosWithVotes(contractEventId, contractOrganiser, contractCategoryId, batchSize); + + //log.info("Found votes[UTxOs]: {}", utxosWithVotes.size()); + + if (utxosWithVotes.isEmpty()) { + //log.warn("No utxo found"); + + return Either.right(Optional.empty()); + } + + val wallet = walletSupplier.getWallet(); + val sender = wallet.getBech32Address(network); + val contract = plutusScriptLoader.getContract(contractCategoryId); + val contractAddress = plutusScriptLoader.getContractAddress(contract); + val senderVerificationKeyBlake224 = getKeyHash(wallet.getVerificationKey()); + + val categoryResultsDatum = CategoryResultsDatum.empty(contractEventId, contractOrganiser, contractCategoryId); + + for (val uTxOVote : utxosWithVotes) { + val voteDatum = uTxOVote.voteDatum(); + val categoryId = voteDatum.getCategoryId(); + + if (contractCategoryId.equals(categoryId)) { + val proposalId = voteDatum.getProposalId(); + + categoryResultsDatum.add(proposalId, voteDatum.getVoteScore()); + } + } + + //System.out.println("Batch:" + JsonUtil.getPrettyJson(categoryResultsDatum)); + + val utxoSelectionStrategy = new LargestFirstUtxoSelectionStrategy(utxoSupplier); + val outputAmount = new Amount(LOVELACE, adaToLovelace(1)); + val collateralUtxos = utxoSelectionStrategy.select(sender, outputAmount, emptySet()); + + // Build the expected output + val outputDatum = categoryResultsDatumConverter.toPlutusData(categoryResultsDatum); + val output = Output.builder() + .address(contractAddress) + .datum(outputDatum) + .inlineDatum(true) + .assetName(LOVELACE) + .qty(adaToLovelace(1)) + .build(); + + val scriptUtxos = utxosWithVotes.stream() + .map(UTxOVote::utxo) + .toList(); + + val extraInputs = utxoSelectionStrategy.select(sender, new Amount(LOVELACE, adaToLovelace(2)), Set.of()); + + List allInputs = new ArrayList<>(); + allInputs.addAll(scriptUtxos); + allInputs.addAll(extraInputs); + + var txBuilder = output.outputBuilder() + .buildInputs(InputBuilders.createFromUtxos(allInputs, sender)) + .andThen(CollateralBuilders.collateralOutputs(sender, new ArrayList<>(collateralUtxos))); // CIP-40 + + val scriptCallContexts = scriptUtxos.stream().map(utxo -> ScriptCallContext + .builder() + .script(contract) + .utxo(utxo) + .exUnits(ExUnits.builder() // Exact exUnits will be calculated later + .mem(BigInteger.valueOf(0)) + .steps(BigInteger.valueOf(0)) + .build() + ) + .redeemer(createVoteBatchRedeemerConverter.toPlutusData(CreateVoteBatchRedeemer.create())) + .redeemerTag(Spend).build()) + .toList(); + + for (var scriptCallContext : scriptCallContexts) { + txBuilder = txBuilder.andThen(ScriptCallContextProviders.createFromScriptCallContext(scriptCallContext)); + } + + txBuilder = txBuilder.andThen((context, txn) -> { + val protocolParams = protocolParamsSupplier.getProtocolParams(); + val utxos = context.getUtxos(); + + txn.getBody().getRequiredSigners().add(decodeHexString(senderVerificationKeyBlake224)); + + val evalReedemers = PlutusScriptLoader.evaluateExUnits(txn, utxos, protocolParams); + + val redeemers = txn.getWitnessSet().getRedeemers(); + for (val redeemer : redeemers) { + evalReedemers.stream() + .filter(evalReedemer -> evalReedemer.getIndex().equals(redeemer.getIndex())) + .findFirst() + .ifPresent(evalRedeemer -> redeemer.setExUnits(evalRedeemer.getExUnits())); + } + + // Remove all scripts from witness and just add one + txn.getWitnessSet().getPlutusV2Scripts().clear(); + txn.getWitnessSet().getPlutusV2Scripts().add((PlutusV2Script) contract); + + }) + .andThen(BalanceUtil.balanceTx(sender, 1)); + + val txBuilderContext = TxBuilderContext.init(utxoSupplier, protocolParamsSupplier); + val transaction = txBuilderContext.build(txBuilder); + + val result = transactionProcessor.submitTransaction(wallet.getTxSigner().sign(transaction)); + + if (!result.isSuccessful()) { + return Either.left(Problem.builder() + .withTitle("Transaction failed") + .withDetail("Transaction failed. " + result.getResponse()) + .build()); + } + + return Either.right(Optional.of(result.getValue())); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/HydraVoteImporter.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/HydraVoteImporter.java new file mode 100644 index 000000000..9083a89e1 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/HydraVoteImporter.java @@ -0,0 +1,150 @@ +package org.cardano.foundation.voting.service; + +import com.bloxbean.cardano.client.api.ProtocolParamsSupplier; +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.coinselection.impl.LargestFirstUtxoSelectionStrategy; +import com.bloxbean.cardano.client.common.model.Network; +import com.bloxbean.cardano.client.function.TxBuilderContext; +import com.bloxbean.cardano.client.function.TxOutputBuilder; +import com.bloxbean.cardano.client.function.helper.BalanceTxBuilders; +import com.bloxbean.cardano.client.function.helper.InputBuilders; +import com.bloxbean.cardano.client.function.helper.MinAdaCheckers; +import com.bloxbean.cardano.client.transaction.spec.TransactionOutput; +import com.bloxbean.cardano.client.transaction.spec.Value; +import io.vavr.control.Either; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.cardano.foundation.voting.domain.Vote; +import org.cardano.foundation.voting.domain.VoteDatum; +import org.cardano.foundation.voting.domain.VoteDatumConverter; +import org.cardano.foundation.voting.domain.VotingEventType; +import org.cardanofoundation.hydra.cardano.client.lib.submit.TransactionSubmissionService; +import org.cardanofoundation.hydra.cardano.client.lib.wallet.WalletSupplier; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.zalando.problem.Problem; + +import java.util.List; + +import static com.bloxbean.cardano.client.common.ADAConversionUtil.adaToLovelace; +import static java.math.BigDecimal.ZERO; + +@Component +@Slf4j +public class HydraVoteImporter { + + @Autowired + private UtxoSupplier utxoSupplier; + + @Autowired + private ProtocolParamsSupplier protocolParamsSupplier; + + @Autowired + @Qualifier("hydra-transaction-submission-service") + private TransactionSubmissionService transactionSubmissionService; + + @Autowired + private WalletSupplier walletSupplier; + + @Autowired + private PlutusScriptLoader plutusScriptLoader; + + @Autowired + private VoteDatumConverter voteDatumConverter; + + @Autowired + private Network network; + + public Either importVotes(VotingEventType votingEventType, + List votes) throws Exception { + log.info("Importing number: {} votes", votes.size()); + + val operator = walletSupplier.getWallet(); + val sender = operator.getBech32Address(network); + + if (votes.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("No votes to import") + .withDetail("No votes to import") + .build()); + } + + val voteDatumList = votes.stream() + .map(vote -> { + val voteScore = switch (votingEventType) { + case USER_BASED: + yield vote.votingPower().orElse(1L); + case STAKE_BASED, BALANCE_BASED: + yield vote.votingPower().orElse(0L); + }; + + return VoteDatum.builder() + .eventId(vote.eventId()) + .organisers(plutusScriptLoader.getEventDetails().organisers()) + .voteId(vote.voteId().toString()) + .voterKey(vote.voterStakeAddress()) + .categoryId(vote.categoryId()) + .proposalId(vote.proposalId().toString()) + .voteScore(voteScore) + .build(); + } + ).toList(); + + // Create an empty output builder + TxOutputBuilder txOutputBuilder = (context, outputs) -> {}; + + for (val voteDatum : voteDatumList) { + val categoryId = voteDatum.getCategoryId(); + val contract = plutusScriptLoader.getContract(categoryId); + val contractAddress = plutusScriptLoader.getContractAddress(contract); + + val datum = voteDatumConverter.toPlutusData(voteDatum); + +// System.out.println("Vote:" + JsonUtil.getPrettyJson(datum)); + + txOutputBuilder = txOutputBuilder.and((context, outputs) -> { + val transactionOutput = TransactionOutput.builder() + .address(contractAddress) + .value(Value + .builder() + .coin(adaToLovelace(ZERO)) + .build() + ) + .inlineDatum(datum) + .build(); + + val additionalLoveLace = MinAdaCheckers.minAdaChecker() + .apply(context, transactionOutput); + + val value = transactionOutput.getValue() + .plus(new Value(additionalLoveLace, null)); + transactionOutput.setValue(value); + + outputs.add(transactionOutput); + }); + } + + val txBuilder = txOutputBuilder + .buildInputs(InputBuilders.createFromSender(sender, sender)) + .andThen(BalanceTxBuilders.balanceTx(sender)); + + val txBuilderContext = TxBuilderContext.init(utxoSupplier, protocolParamsSupplier); + txBuilderContext.setUtxoSelectionStrategy(new LargestFirstUtxoSelectionStrategy(utxoSupplier)); + + val transaction = txBuilderContext.build(txBuilder); + + val signedTx = operator.getTxSigner().sign(transaction); + + val result = transactionSubmissionService.submitTransaction(signedTx); + if (!result.isSuccessful()) { + return Either.left(Problem.builder() + .withTitle("Transaction submission failed") + .withDetail("Failure reason:" + result.getResponse()) + .build()); + } + + return Either.right(result.getValue()); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/PlutusScriptLoader.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/PlutusScriptLoader.java new file mode 100644 index 000000000..95394268a --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/PlutusScriptLoader.java @@ -0,0 +1,159 @@ +package org.cardano.foundation.voting.service; + +import com.bloxbean.cardano.aiken.tx.evaluator.InitialBudgetConfig; +import com.bloxbean.cardano.aiken.tx.evaluator.SlotConfig; +import com.bloxbean.cardano.aiken.tx.evaluator.TxEvaluator; +import com.bloxbean.cardano.client.api.exception.ApiRuntimeException; +import com.bloxbean.cardano.client.api.model.ProtocolParams; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.common.model.Network; +import com.bloxbean.cardano.client.plutus.spec.*; +import com.bloxbean.cardano.client.transaction.spec.Transaction; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.cardano.foundation.voting.client.ChainFollowerClient; +import org.cardano.foundation.voting.domain.TallyType; +import org.cardanofoundation.hydra.core.HydraException; +import org.cardanofoundation.hydra.core.utils.HexUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import static com.bloxbean.cardano.aiken.AikenScriptUtil.applyParamToScript; +import static com.bloxbean.cardano.client.address.AddressProvider.getEntAddress; +import static com.bloxbean.cardano.client.api.util.CostModelUtil.getCostModelFromProtocolParams; +import static com.bloxbean.cardano.client.crypto.Blake2bUtil.blake2bHash224; +import static com.bloxbean.cardano.client.plutus.blueprint.PlutusBlueprintUtil.getPlutusScriptFromCompiledCode; +import static com.bloxbean.cardano.client.plutus.blueprint.model.PlutusVersion.v2; +import static com.bloxbean.cardano.client.plutus.spec.Language.PLUTUS_V2; +import static java.nio.charset.StandardCharsets.UTF_8; + +@Component +@Slf4j +public class PlutusScriptLoader { + + @Autowired + private Network network; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ResourceLoader resourceLoader; + + @Autowired + private ChainFollowerClient chainFollowerClient; + + @Value("${ballot.event.id}") + private String eventId; + + @Value("${ballot.tally.name}") + private String tallyName; + + private String parametrisedCompiledTemplate; + + @Getter + private List verificationKeys; + + @Getter + private ChainFollowerClient.EventDetailsResponse eventDetails; + + @PostConstruct + public void init() throws IOException { + var eventDetailsE = chainFollowerClient.getEventDetails(eventId); + + if (eventDetailsE.isEmpty()) { + var issue = eventDetailsE.swap().get(); + + throw new HydraException("Error while retrieving event, issue:" + issue); + } + + var eventDetailsResponseM = eventDetailsE.get(); + + if (eventDetailsResponseM.isEmpty()) { + throw new HydraException("Event not found on ledger follower service, eventId:" + eventId); + } + + var eventDetailsResponse = eventDetailsResponseM.orElseThrow(); + + this.eventDetails = eventDetailsResponse; + + var tally = eventDetailsResponse.findTallyByName(tallyName) + .orElseThrow(() -> new HydraException("Tally not found on ledger follower service, tallyName:" + tallyName)); + + if (tally.type() != TallyType.HYDRA) { + throw new HydraException("Tally type is not HYDRA, tallyName:" + tallyName); + } + + var hydraTally = (ChainFollowerClient.HydraTallyConfig) tally.config(); + + this.parametrisedCompiledTemplate = hydraTally.compiledScript(); + var compiledScriptHash = hydraTally.compiledScriptHash(); + + log.info("Plutus contract hash: {}", compiledScriptHash); + + this.verificationKeys = hydraTally.verificationKeys(); + + log.info("Operator verification keys: {}", verificationKeys); + } + + public String getContractAddress(PlutusScript plutusScript) { + return getEntAddress(plutusScript, network).toBech32(); + } + + public PlutusScript getContract(String categoryId) { + ListPlutusData.ListPlutusDataBuilder builder = ListPlutusData.builder(); + + builder.plutusDataList(this.verificationKeys + .stream() + .map(HexUtils::decodeHexString) + .map(blake224Hash -> (PlutusData) BytesPlutusData.of(blake224Hash)) + .toList()); + + var verificationKeys = builder.build(); + + val params = ListPlutusData.of( + BytesPlutusData.of(blake2bHash224(tallyName.getBytes(UTF_8))), + verificationKeys, + BytesPlutusData.of(eventId), + BytesPlutusData.of(eventDetails.organisers()), + BytesPlutusData.of(categoryId) + ); + val compiledCode = applyParamToScript(params, parametrisedCompiledTemplate); + + return getPlutusScriptFromCompiledCode(compiledCode, v2); + } + + public static List evaluateExUnits(Transaction txn, + Set utxos, + ProtocolParams protocolParams) { + val txMem = Long.valueOf(protocolParams.getMaxTxExMem()); + val txCpu = Long.valueOf(protocolParams.getMaxTxExSteps()); + + val slot_length = 1000; + val zero_slot = 0; + val zero_time = 1660003200000L; + + try (val slotConfig = new SlotConfig(slot_length, zero_slot, zero_time); + val initialBudgetConfig = new InitialBudgetConfig(txMem, txCpu)) { + val txEvaluator = new TxEvaluator(slotConfig, initialBudgetConfig); + val costMdls = new CostMdls(); + + val costModelFromProtocolParams = getCostModelFromProtocolParams(protocolParams, PLUTUS_V2); + costModelFromProtocolParams.ifPresent(costMdls::add); + + return txEvaluator.evaluateTx(txn, utxos, costMdls); + } catch (IOException e) { + throw new ApiRuntimeException(e); + } + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/ReactiveWebSocketHydraTxSubmissionService.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/ReactiveWebSocketHydraTxSubmissionService.java new file mode 100644 index 000000000..2ca68476b --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/ReactiveWebSocketHydraTxSubmissionService.java @@ -0,0 +1,62 @@ +package org.cardano.foundation.voting.service; + +import com.bloxbean.cardano.client.api.model.Result; +import com.bloxbean.cardano.client.exception.CborSerializationException; +import com.bloxbean.cardano.client.transaction.spec.Transaction; +import com.bloxbean.cardano.client.transaction.util.TransactionUtil; +import lombok.AllArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.cardanofoundation.hydra.cardano.client.lib.submit.TransactionSubmissionService; +import org.cardanofoundation.hydra.core.utils.HexUtils; +import org.cardanofoundation.hydra.reactor.HydraReactiveClient; + +import java.time.Duration; + +import static org.cardanofoundation.hydra.core.utils.MoreBytes.humanReadableByteCountBin; + +@Slf4j +@AllArgsConstructor +public class ReactiveWebSocketHydraTxSubmissionService implements TransactionSubmissionService { + + private final HydraReactiveClient hydraClient; + + @Override + @SneakyThrows + public Result submitTransaction(byte[] txData) { + var transaction = Transaction.deserialize(txData); + var fee = transaction.getBody().getFee(); + + log.info("Transaction size: {}, fee: {} lovelaces", humanReadableByteCountBin(txData.length), fee); + + val txHash = TransactionUtil.getTxHash(transaction); + + val txResultMono = hydraClient.submitTxFullConfirmation(txHash, txData); + val txResult = txResultMono.block(Duration.ofMinutes(5)); + + return Result.create(txResult.isValid(), txResult.getReason()) + .withValue(txResult.getTxId()); + } + + public Result submitTransaction(Transaction transaction) throws CborSerializationException { + var fee = transaction.getBody().getFee(); + var txData = transaction.serialize(); + + log.info("Transaction size: {}, fee: {} lovelaces", humanReadableByteCountBin(txData.length), fee); + + val txHash = TransactionUtil.getTxHash(transaction); + + val txResultMono = hydraClient.submitTxFullConfirmation(txHash, txData); + val txResult = txResultMono.block(Duration.ofMinutes(5)); + + return Result.create(txResult.isValid(), txResult.getReason()) + .withValue(txResult.getTxId()); + } + + @Override + public Result submitTransaction(String cborHex) { + return submitTransaction(HexUtils.decodeHexString(cborHex)); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/VoteUtxoFinder.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/VoteUtxoFinder.java new file mode 100644 index 000000000..ce8ca1d5e --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/service/VoteUtxoFinder.java @@ -0,0 +1,88 @@ +package org.cardano.foundation.voting.service; + +import com.bloxbean.cardano.client.api.UtxoSupplier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.cardano.foundation.voting.domain.UTxOCategoryResult; +import org.cardano.foundation.voting.domain.UTxOVote; +import org.springframework.stereotype.Component; + +import java.util.List; + +import static com.bloxbean.cardano.client.util.HexUtil.decodeHexString; +import static org.cardano.foundation.voting.utils.MoreComparators.createCategoryResultTxHashAndTransactionIndexComparator; +import static org.cardano.foundation.voting.utils.MoreComparators.createVoteTxHashAndTransactionIndexComparator; +import static org.springframework.util.StringUtils.hasLength; + +@Component +@RequiredArgsConstructor +@Slf4j +public class VoteUtxoFinder { + + private final UtxoSupplier utxoSupplier; + private final PlutusScriptLoader plutusScriptLoader; + private final org.cardano.foundation.voting.domain.VoteDatumConverter voteDatumConverter; + private final org.cardano.foundation.voting.domain.CategoryResultsDatumConverter categoryResultsDatumConverter; + + public List getUtxosWithVotes(String contractEventId, + String contractOrganiser, + String contractCategoryId, + int batchSize) { + val contract = plutusScriptLoader.getContract(contractCategoryId); + val contractAddress = plutusScriptLoader.getContractAddress(contract); + + return utxoSupplier.getAll(contractAddress) + .parallelStream() + .filter(utxo -> hasLength(utxo.getInlineDatum())) + .map(utxo -> { + try { + val voteDatum = voteDatumConverter.deserialize(utxo.getInlineDatum()); + + return new UTxOVote(utxo, voteDatum); + } catch (Exception e) { + return new UTxOVote(utxo, null); + } + + }) + .filter(uTxOVote -> uTxOVote.voteDatum() != null) + .filter(uTxOVote -> uTxOVote.voteDatum().getEventId().equals(contractEventId)) + .filter(uTxOVote -> uTxOVote.voteDatum().getCategoryId().equals(contractCategoryId)) + .filter(uTxOVote -> uTxOVote.voteDatum().getOrganisers().equals(contractOrganiser)) + .sorted(createVoteTxHashAndTransactionIndexComparator()) + .limit(batchSize) + .toList(); + } + + public List getUtxosWithCategoryResults(String eventId, + String organiser, + String contractCategoryId, + int batchSize) { + val contract = plutusScriptLoader.getContract(contractCategoryId); + val contractAddress = plutusScriptLoader.getContractAddress(contract); + + return utxoSupplier.getAll(contractAddress) + .parallelStream() + .filter(utxo -> hasLength(utxo.getInlineDatum())) + .map(utxo -> { + val inlineDatum = utxo.getInlineDatum(); + val inlineDatumHex = decodeHexString(inlineDatum); + + try { + val categoryResultsDatum = categoryResultsDatumConverter.deserialize(inlineDatumHex); + + return new UTxOCategoryResult(utxo, categoryResultsDatum); + } catch (Exception e) { + return new UTxOCategoryResult(utxo, null); + } + }) + .filter(uTxOCategoryResult -> uTxOCategoryResult.categoryResultsDatum() != null) + .filter(uTxOCategoryResult -> uTxOCategoryResult.categoryResultsDatum().getEventId().equals(eventId)) + .filter(uTxOCategoryResult -> uTxOCategoryResult.categoryResultsDatum().getOrganiser().equals(organiser)) + .filter(uTxOCategoryResult -> uTxOCategoryResult.categoryResultsDatum().getCategoryId().equals(contractCategoryId)) + .sorted(createCategoryResultTxHashAndTransactionIndexComparator()) + .limit(batchSize) + .toList(); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/shell/HydraCommands.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/shell/HydraCommands.java new file mode 100644 index 000000000..35b51b788 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/shell/HydraCommands.java @@ -0,0 +1,402 @@ +package org.cardano.foundation.voting.shell; + +import com.bloxbean.cardano.client.util.HexUtil; +import com.bloxbean.cardano.client.util.JsonUtil; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.cardano.foundation.voting.domain.CardanoNetwork; +import org.cardano.foundation.voting.domain.CommitType; +import org.cardano.foundation.voting.domain.LocalBootstrap; +import org.cardano.foundation.voting.repository.VoteRepository; +import org.cardano.foundation.voting.service.HydraVoteBatchReducer; +import org.cardano.foundation.voting.service.HydraVoteBatcher; +import org.cardano.foundation.voting.service.HydraVoteImporter; +import org.cardano.foundation.voting.service.PlutusScriptLoader; +import org.cardanofoundation.hydra.cardano.client.lib.submit.TransactionSubmissionService; +import org.cardanofoundation.hydra.cardano.client.lib.wallet.Wallet; +import org.cardanofoundation.hydra.cardano.client.lib.wallet.WalletSupplier; +import org.cardanofoundation.hydra.core.model.UTXO; +import org.cardanofoundation.hydra.core.model.http.HeadCommitResponse; +import org.cardanofoundation.hydra.core.model.query.response.*; +import org.cardanofoundation.hydra.core.store.UTxOStore; +import org.cardanofoundation.hydra.reactor.HydraReactiveClient; +import org.cardanofoundation.hydra.reactor.HydraReactiveWebClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +import org.springframework.shell.command.annotation.Command; +import org.springframework.shell.command.annotation.Option; +import org.springframework.shell.standard.ShellOption; +import reactor.core.Disposable; +import shaded.com.google.common.collect.Lists; + +import java.math.BigInteger; +import java.time.Duration; +import java.util.Map; + +import static com.bloxbean.cardano.client.util.HexUtil.decodeHexString; +import static io.netty.util.internal.StringUtil.isNullOrEmpty; +import static org.cardanofoundation.hydra.cardano.client.lib.utils.TransactionSigningUtil.sign; +import static org.cardanofoundation.hydra.core.model.HydraState.Initializing; +import static org.cardanofoundation.hydra.core.model.HydraState.Open; + +@Slf4j +@Command(group = "hydra-tally-app") +public class HydraCommands { + + @Autowired + private CardanoNetwork network; + + @Autowired + private UTxOStore uTxOStore; + + @Autowired + private VoteRepository voteRepository; + + @Autowired + private HydraVoteImporter hydraVoteImporter; + + @Autowired + private HydraVoteBatcher hydraVoteBatcher; + + @Autowired + private HydraVoteBatchReducer hydraVoteBatchReducer; + + @Autowired + private PlutusScriptLoader plutusScriptLoader; + + @Autowired + private WalletSupplier walletSupplier; + + @Autowired + private HydraReactiveClient hydraClient; + + @Autowired + private HydraReactiveWebClient hydraReactiveWebClient; + + @Autowired + @Qualifier("l1-transaction-submission-service") + private TransactionSubmissionService l1TransactionSubmissionService; + + @Value("${hydra.ws.url}") + private String hydraWsUrl; + + @Value("${hydra.operator.name}") + private String actor; + + @Autowired + private Environment environment; + + @Value("${hydra.auto.connect:false}") + private boolean autoConnect; + + @Value("${cardano.commit.type}") + private CommitType commitType; + + @Value("${local.bootstrap}") + private LocalBootstrap localBootstrap; + + @Value("${ballot.event.id}") + private String eventId; + + @Nullable private Disposable stateQuerySubscription; + + @Nullable private Disposable responsesSubscription; + + private Wallet l1Wallet; + + private boolean tallyAllExecuted = false; + + @PostConstruct + public void init() throws Exception { + this.l1Wallet = walletSupplier.getWallet(); + if (autoConnect) { + connect(); +// initHead(); +// commitFunds(); + } + } + + @Command(command = "get-head-state", description = "gets the current hydra state.") + public String getHydraState() { + return hydraClient.getHydraState().toString(); + } + + @Command(command = "get-utxos", description = "gets current hydra's head UTxOs.") + public String getUtxOs(@ShellOption(value = "address") @Option(required = false) String address) { + GetUTxOResponse getUTxOResponse = hydraClient.getUTxOs().block(Duration.ofMinutes(5)); + + if (getUTxOResponse == null) { + return "Cannot connect, unsupported state, hydra state:" + hydraClient.getHydraState(); + } + + val sb = new StringBuilder(); + sb.append("HeadId: "); + sb.append(getUTxOResponse.getHeadId()); + sb.append("\n\n"); + + var no = 0; + var utxos = getUTxOResponse.getUtxo() + .entrySet() + .stream() + .filter(entry -> { + if (isNullOrEmpty(address)) { + return true; + } + + return entry.getValue().getAddress().equalsIgnoreCase(address); + }) + .toList(); + + for (val utxo: utxos) { + sb.append(String.format("%d. %s: %s", ++no, utxo.getKey(), utxo.getValue())); + if (utxo.getValue().getInlineDatum() != null && !utxo.getValue().getInlineDatum().asText().equals("null")) { + sb.append(JsonUtil.getPrettyJson(utxo.getValue().getInlineDatum())); + } + sb.append("\n"); + } + + return sb.toString(); + } + + @Command(command = "connect", description = "connects to the hydra network.") + public String connect() { + log.info("Connecting to the hydra network:{}", hydraWsUrl); + + GreetingsResponse greetingsResponse = hydraClient.openConnection().block(Duration.ofMinutes(5)); + + if (greetingsResponse == null) { + return "Cannot connect, unsupported state, hydra state:" + hydraClient.getHydraState(); + } + + this.stateQuerySubscription = hydraClient.getHydraStatesStream().doOnNext(hydraState -> { + System.out.printf("%n%s -> %s%n", hydraState.oldState(), hydraState.newState()); + }).subscribe(); + + this.responsesSubscription = hydraClient.getHydraResponsesStream().doOnNext(response -> { + if (response.isFailure()) { + log.error("Hydra error response: {}", response.getTag()); + } else { + if (response instanceof CommittedResponse cr) { + for (val utxo : cr.getUtxo().entrySet()) { + log.info("utxo: {}, value: {}", utxo.getKey(), utxo.getValue()); + } + } + } + }).subscribe(); + + return "Connected."; + } + + @Command(command = "disconnect", description = "disconnect from the hydra network.") + public String disconnect() throws InterruptedException { + log.info("Disconnecting from the hydra network: {}", hydraWsUrl); + + if (stateQuerySubscription != null) { + stateQuerySubscription.dispose(); + } + + if (responsesSubscription != null) { + responsesSubscription.dispose(); + } + + Boolean disconnected = hydraClient.closeConnection() + .block(Duration.ofMinutes(5)); + + if (disconnected == null) { + return "Cannot disconnect, unsupported state, hydra state: " + hydraClient.getHydraState(); + } + + return "Disconnected."; + } + + @Command(command = "abort-head", description = "aborting from the hydra network.") + public String abort() { + log.info("Aborting from the hydra network..."); + + HeadIsAbortedResponse headIsAbortedResponse = hydraClient.abortHead().block(Duration.ofMinutes(5)); + if (headIsAbortedResponse == null) { + return "Cannot abort, unsupported state, hydra state:" + hydraClient.getHydraState(); + } + + return "Aborted."; + } + + @Command(command = "init", description = "inits the hydra head.") + public String headInit() { + return initHead(); + } + + @Command(command = "head-init", description = "inits the hydra head.") + public String initHead() { + log.info("Init the head..."); + + var headIsInitializingResponse = hydraClient.initHead().block(Duration.ofMinutes(5)); + + if (headIsInitializingResponse == null) { + return "Cannot init, unsupported state, hydra state:" + hydraClient.getHydraState(); + } + + var sb = new StringBuilder(); + sb.append("HeadId: " + headIsInitializingResponse.getHeadId()); + sb.append("\n\n"); + + for (var party : headIsInitializingResponse.getParties()) { + sb.append("Party: " + party); + sb.append("\n"); + } + sb.append("\n"); + + sb.append("Head is initialized."); + + return sb.toString(); + } + + @Command(command = "head-commit-funds", description = "head commit funds.") + public String commitFunds() { + if (hydraClient.getHydraState() != Initializing) { + return "Cannot commit, unsupported state, hydra state:" + hydraClient.getHydraState(); + } + + return switch (commitType) { + case COMMIT_FUNDS -> { + val cardanoCommitAddress = environment.getProperty("cardano.commit.address"); + val cardanoCommitUtxo = environment.getProperty("cardano.commit.utxo"); + val cardanoCommitAmount = environment.getProperty("cardano.commit.amount", Long.class); + + log.info("Committing funds to the head, " + + "address: {}," + + " utxo: {}," + + " amount: {}", cardanoCommitAddress, cardanoCommitUtxo, cardanoCommitAmount); + + val utxo = new UTXO(); + + utxo.setAddress(cardanoCommitAddress); + utxo.setValue(Map.of("lovelace", BigInteger.valueOf(cardanoCommitAmount.longValue()))); + + var commitMap = Map.of(cardanoCommitUtxo, utxo); + + HeadCommitResponse committedResponse = hydraReactiveWebClient.commitRequest(commitMap) + .block(Duration.ofMinutes(5)); + + if (committedResponse == null) { + yield "Cannot commit, unsupported state, hydra state:" + hydraClient.getHydraState(); + } + + var transactionBytes = decodeHexString(committedResponse.getCborHex()); + + byte[] signedTx = sign(transactionBytes, l1Wallet.getSecretKey()); + + var txResult = l1TransactionSubmissionService.submitTransaction(HexUtil.encodeHexString(signedTx)); + + if (!txResult.isSuccessful()) { + yield "Cannot commit, transaction submission failed, reason: " + txResult.getResponse(); + } + + yield "Committed funds, L1 transactionId: " + txResult.getValue(); + } + case COMMIT_EMPTY -> { + log.info("Committing empty to the head..."); + + var commitMap = Map.of(); + + HeadCommitResponse committedResponse = hydraReactiveWebClient.commitRequest(commitMap) + .block(Duration.ofMinutes(5)); + + if (committedResponse == null) { + yield "Cannot commit, unsupported state, hydra state:" + hydraClient.getHydraState(); + } + + var transactionBytes = decodeHexString(committedResponse.getCborHex()); + + byte[] signedTx = sign(transactionBytes, l1Wallet.getSecretKey()); + + var txResult = l1TransactionSubmissionService.submitTransaction(HexUtil.encodeHexString(signedTx)); + + if (!txResult.isSuccessful()) { + yield "Cannot commit, transaction submission failed, reason: " + txResult.getResponse(); + } + + yield "Committed funds, L1 transactionId: " + txResult.getValue(); + } + }; + } + + @Command(command = "head-fan-out", description = "head fan out.") + public String fanOut() { + var headIsFinalizedResponse = hydraClient.fanOutHead() + .block(Duration.ofMinutes(5)); + + if (headIsFinalizedResponse == null) { + return "Cannot fan out, unsupported state, hydra state:" + hydraClient.getHydraState(); + } + + headIsFinalizedResponse.getUtxo().forEach((key, value) -> { + log.info("utxo: {}, value: {}", key, value); + }); + + return "Fan out completed."; + } + + @Command(command = "head-close", description = "close head.") + public String closeHead() { + HeadIsClosedResponse headIsClosedResponse = hydraClient.closeHead() + .block(Duration.ofMinutes(5)); + + if (headIsClosedResponse == null) { + return "Cannot close the head, unsupported state, hydra state:" + hydraClient.getHydraState(); + } + + return "Head is closed."; + } + + @Command(command = "tally-all", description = "tally all the votes votes.") + public String tallyAll( + @ShellOption(value = "import-batch-size", defaultValue = "25") @Option int importBatchSize, + @ShellOption(value = "create-batch-size", defaultValue = "10") @Option int createBatchSize, + @ShellOption(value = "reduce-batch-size", defaultValue = "10") @Option int reduceBatchSize + ) throws Exception { + if (tallyAllExecuted) { + return "Tallying votes already executed."; + } + + log.info("Tally all votes, importBatchSize: {}, createBatchSize: {}, reduceBatchSize: {}", + importBatchSize, createBatchSize, reduceBatchSize); + + if (hydraClient.getHydraState() != Open) { + return "Tallying votes failed, reason:" + hydraClient.getHydraState(); + } + + var allCategories = voteRepository.getAllUniqueCategories(eventId); + + var organisers = plutusScriptLoader.getEventDetails().organisers(); + var votingEventType = plutusScriptLoader.getEventDetails().votingEventType(); + + for (val categoryId : allCategories) { + log.info("Processing category: {}", categoryId); + + var allVotes = voteRepository.findAllVotes(eventId, categoryId); + var partitioned = Lists.partition(allVotes, importBatchSize); + + for (val voteBatch : partitioned) { + val txIdE = hydraVoteImporter.importVotes(votingEventType, voteBatch); + if (txIdE.isEmpty()) { + return "Importing votes failed, reason:" + txIdE.getLeft(); + } + + log.info("Imported votes voteBatch, txId: " + "{}", txIdE.get()); + + hydraVoteBatcher.batchVotesPerCategory(eventId, organisers, categoryId, createBatchSize); + hydraVoteBatchReducer.batchVotesPerCategory(eventId, organisers, categoryId, reduceBatchSize); + } + } + + this.tallyAllExecuted = true; + + return "Tallying votes done."; + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/BalanceUtil.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/BalanceUtil.java new file mode 100644 index 000000000..a39c1a698 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/BalanceUtil.java @@ -0,0 +1,23 @@ +package org.cardano.foundation.voting.utils; + +import com.bloxbean.cardano.client.function.TxBuilder; +import com.bloxbean.cardano.client.function.helper.ChangeOutputAdjustments; +import com.bloxbean.cardano.client.function.helper.CollateralBuilders; +import com.bloxbean.cardano.client.function.helper.FeeCalculators; + +//TODO -- This fix will be moved to cardano-client-lib after testing. Order of invocation for totalCollateral calculation and ChageOutputAdjustment +//changed. +public class BalanceUtil { + + public static TxBuilder balanceTx(String changeAddress, int nSigners) { + return (context, txn) -> { + FeeCalculators.feeCalculator(changeAddress, nSigners).apply(context, txn); + + ChangeOutputAdjustments.adjustChangeOutput(changeAddress, nSigners).apply(context, txn); + + if (txn.getBody().getCollateralReturn() != null) { + CollateralBuilders.balanceCollateralOutputs().apply(context, txn); + } + }; + } +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/Enums.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/Enums.java new file mode 100644 index 000000000..b32e48072 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/Enums.java @@ -0,0 +1,16 @@ +package org.cardano.foundation.voting.utils; + +import java.util.Optional; + +public final class Enums { + + public static > Optional getIfPresent(Class enumClass, String value) { + + try { + return Optional.of(Enum.valueOf(enumClass, value.toUpperCase())); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreBoolean.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreBoolean.java new file mode 100644 index 000000000..3ae951cf5 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreBoolean.java @@ -0,0 +1,15 @@ +package org.cardano.foundation.voting.utils; + +import java.math.BigInteger; + +public final class MoreBoolean { + + public static BigInteger toBigInteger(boolean value) { + return value ? BigInteger.ONE : BigInteger.ZERO; + } + + public static boolean fromBigInteger(BigInteger val) { + return val.equals(BigInteger.ONE); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreBytes.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreBytes.java new file mode 100644 index 000000000..b211686cc --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreBytes.java @@ -0,0 +1,23 @@ +package org.cardano.foundation.voting.utils; + +import java.text.CharacterIterator; +import java.text.StringCharacterIterator; + +public class MoreBytes { + + public static String humanReadableByteCountBin(long bytes) { + long absB = bytes == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(bytes); + if (absB < 1024) { + return bytes + " B"; + } + long value = absB; + CharacterIterator ci = new StringCharacterIterator("KMGTPE"); + for (int i = 40; i >= 0 && absB > 0xfffccccccccccccL >> i; i -= 10) { + value >>= 10; + ci.next(); + } + value *= Long.signum(bytes); + return String.format("%.1f %ciB", value / 1024.0, ci.current()); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreComparators.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreComparators.java new file mode 100644 index 000000000..83128cabe --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreComparators.java @@ -0,0 +1,26 @@ +package org.cardano.foundation.voting.utils; + +import org.cardano.foundation.voting.domain.UTxOCategoryResult; +import org.cardano.foundation.voting.domain.UTxOVote; +import org.cardano.foundation.voting.domain.Vote; + +import java.util.Comparator; +import java.util.function.Function; + +public class MoreComparators { + + public static Comparator createVoteTxHashAndTransactionIndexComparator() { + return Comparator.comparing((Function) t -> t.utxo().getTxHash()) + .thenComparing(t -> t.utxo().getOutputIndex()); + } + + public static Comparator createCategoryResultTxHashAndTransactionIndexComparator() { + return Comparator.comparing((Function) t -> t.utxo().getTxHash()) + .thenComparing(t -> t.utxo().getOutputIndex()); + } + + public static Comparator createVoteComparator() { + return Comparator.comparing(Vote::voteId); + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreHash.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreHash.java new file mode 100644 index 000000000..4f859bdda --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreHash.java @@ -0,0 +1,9 @@ +package org.cardano.foundation.voting.utils; + +public class MoreHash { + + public static int unsignedHash(Object o) { + return o.hashCode() & 0xFFFFFFF; + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreUUID.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreUUID.java new file mode 100644 index 000000000..bae85896d --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/MoreUUID.java @@ -0,0 +1,29 @@ +package org.cardano.foundation.voting.utils; + +import java.util.UUID; +import java.util.regex.Pattern; + +public final class MoreUUID { + + public static final String UUID_V4_STRING = + "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}"; + + public static final Pattern UUID_V4 = Pattern.compile(UUID_V4_STRING); + + public static boolean isUUIDv4(String uuid) { + return UUID_V4.matcher(uuid).matches(); + } + + public static String shortUUID(int length) { + return java.util.UUID.randomUUID().toString().substring(0, length); + } + + public static long uuidHash(String uuid) { + return UUID.fromString(uuid).hashCode() & 0xFFFFFFF; + } + + public static long uuidHash(UUID uuid) { + return uuid.hashCode() & 0xFFFFFFF; + } + +} diff --git a/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/Partitioner.java b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/Partitioner.java new file mode 100644 index 000000000..1205683b1 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/java/org/cardano/foundation/voting/utils/Partitioner.java @@ -0,0 +1,35 @@ +package org.cardano.foundation.voting.utils; + +import java.util.UUID; + +public class Partitioner { + + public static int partition(UUID uuid, int n) { + long uuidValue = uuid.getLeastSignificantBits(); + + // Ensure the value is non-negative + uuidValue = uuidValue >= 0 ? uuidValue : -uuidValue; + + // Calculate the hash and determine the partition + int partition = (int) (uuidValue % n); + + return partition; + } + + public static void main(String[] args) { + for (int i = 0; i < 1000; i++) { + // Generate a random UUID (version 4) + UUID uuid = UUID.randomUUID(); + + // Number of partitions + int n = 3; + + // Calculate the partition for the given UUID + int result = partition(uuid, n); + + System.out.println("UUID: " + uuid); + System.out.println("Partition: " + result); + } + } + +} diff --git a/backend-services/hydra-tally-app/src/main/resources/application-devnet--alice.properties b/backend-services/hydra-tally-app/src/main/resources/application-devnet--alice.properties new file mode 100644 index 000000000..3461de21f --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/application-devnet--alice.properties @@ -0,0 +1,25 @@ +spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev--devnet, dev--devnet-alice} + +# Only needed for sharded setup +#hydra.participant.number=${HYDRA_PARTICIPANT_NUMBER:0} +#hydra.participants.count=${HYDRA_PARTICIPANTS_COUNT:3} + +hydra.ws.url=ws://dev.cf-hydra-voting-poc.metadata.dev.cf-deployments.org:4001 +hydra.http.url=http://dev.cf-hydra-voting-poc.metadata.dev.cf-deployments.org:4001 + +cardano.commit.type=COMMIT_FUNDS + +# docker-compose exec cardano-node cardano-cli address build --payment-verification-key-file /devnet/credentials/alice-funds.vk --testnet-magic 42 +cardano.commit.address=addr_test1vp5cxztpc6hep9ds7fjgmle3l225tk8ske3rmwr9adu0m6qchmx5z + +# docker-compose exec cardano-node cardano-cli query utxo --address addr_test1vp5cxztpc6hep9ds7fjgmle3l225tk8ske3rmwr9adu0m6qchmx5z --testnet-magic 42 +cardano.commit.utxo=d8df0d83b5f417d84814dc13e88bbd82e8b1f3102cb1b399e56f6c28b738ab25#0 + +cardano.commit.amount=100000000 + +hydra.operator.name=alice + +l1.operator.wallet.type=CLI_JSON_FILE + +l1.operator.signing.key.file.path=classpath:secrets/alice-funds.sk +l1.operator.verification.key.file.path=classpath:secrets/alice-funds.vk diff --git a/backend-services/hydra-tally-app/src/main/resources/application-devnet--bob.properties b/backend-services/hydra-tally-app/src/main/resources/application-devnet--bob.properties new file mode 100644 index 000000000..0d7abde7f --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/application-devnet--bob.properties @@ -0,0 +1,24 @@ +spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev--devnet, dev--devnet-bob} + +# Only needed for sharded setup +#hydra.participant.number=${HYDRA_PARTICIPANT_NUMBER:1} +#hydra.participants.count=${HYDRA_PARTICIPANTS_COUNT:3} + +hydra.ws.url=ws://dev.cf-hydra-voting-poc.metadata.dev.cf-deployments.org:4002 +hydra.http.url=http://dev.cf-hydra-voting-poc.metadata.dev.cf-deployments.org:4002 + +cardano.commit.type=COMMIT_EMPTY + +# docker-compose exec cardano-node cardano-cli address build --payment-verification-key-file /devnet/credentials/bob-funds.vk --testnet-magic 42 +#cardano.commit.address=addr_test1vp0yug22dtwaxdcjdvaxr74dthlpunc57cm639578gz7algset3fh + +# docker-compose exec cardano-node cardano-cli query utxo --address addr_test1vp0yug22dtwaxdcjdvaxr74dthlpunc57cm639578gz7algset3fh --testnet-magic 42 +#cardano.commit.utxo=4ed47175de32c0d4be62d6a0792903992186c961ec82bcb24cb1e9b718d68a4b#0 + +#cardano.commit.amount=100000000 + +hydra.operator.name=bob + +l1.operator.wallet.type=CLI_JSON_FILE +l1.operator.signing.key.file.path=classpath:secrets/bob-funds.sk +l1.operator.verification.key.file.path=classpath:secrets/bob-funds.vk diff --git a/backend-services/hydra-tally-app/src/main/resources/application-devnet--carol.properties b/backend-services/hydra-tally-app/src/main/resources/application-devnet--carol.properties new file mode 100644 index 000000000..3c789ae6e --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/application-devnet--carol.properties @@ -0,0 +1,24 @@ +spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev--devnet, dev--devnet-carol} + +# Only needed for sharded setup +#hydra.participant.number=${HYDRA_PARTICIPANT_NUMBER:2} +#hydra.participants.count=${HYDRA_PARTICIPANTS_COUNT:3} + +hydra.ws.url=ws://dev.cf-hydra-voting-poc.metadata.dev.cf-deployments.org:4003 +hydra.http.url=http://dev.cf-hydra-voting-poc.metadata.dev.cf-deployments.org:4003 + +cardano.commit.type=COMMIT_EMPTY + +# docker-compose exec cardano-node cardano-cli address build --payment-verification-key-file /devnet/credentials/carol-funds.vk --testnet-magic 42 +#cardano.commit.address=addr_test1vqx5tu4nzz5cuanvac4t9an4djghrx7hkdvjnnhstqm9kegvm6g6c + +# docker-compose exec cardano-node cardano-cli query utxo --address addr_test1vqa25t3aayfmpad20elswmsj94ehmdfjnhc64yz3jg5yl6skf5cck --testnet-magic 42 +#cardano.commit.utxo=cc253327c0651429b132e0e07a27d636e73f03c259efb67ce42e7dec32372d40#0 + +#cardano.commit.amount=100000000 + +hydra.operator.name=carol + +l1.operator.wallet.type=CLI_JSON_FILE +l1.operator.signing.key.file.path=classpath:secrets/carol-funds.sk +l1.operator.verification.key.file.path=classpath:secrets/carol-funds.vk diff --git a/backend-services/hydra-tally-app/src/main/resources/application-devnet.properties b/backend-services/hydra-tally-app/src/main/resources/application-devnet.properties new file mode 100644 index 000000000..303da1c3b --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/application-devnet.properties @@ -0,0 +1,18 @@ +spring.banner.location=classpath:/banner.txt +spring.banner.charset=UTF-8 + +cardano.network=${CARDANO_NETWORK:DEV} + +local.bootstrap=${LOCAL_BOOTSTRAP:NOT_SHARDED} + +ledger.follower.app.base.url=${LEDGER_FOLLOWER_APP_BASE_URL:http://locahost:9090} +cardano.tx.submit.api.url=http://dev.cf-hydra-voting-poc.metadata.dev.cf-deployments.org:8090/api/submit/tx + +ballot.event.id=${BALLOT_EVENT_ID:CF_SUMMIT_2023_4BCC} +ballot.tally.name=${BALLOT_TALLY_NAME:Hydra Tally Experiment} + +votes.path=classpath:votes/processed_votes.csv + +hydra.auto.connect=${HYDRA_AUTO_CONNECT:true} + +spring.main.banner-mode=off \ No newline at end of file diff --git a/backend-services/hydra-tally-app/src/main/resources/application-preprod--orgwallet.properties b/backend-services/hydra-tally-app/src/main/resources/application-preprod--orgwallet.properties new file mode 100644 index 000000000..201286f69 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/application-preprod--orgwallet.properties @@ -0,0 +1,16 @@ +spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev--pre-prod, dev--pre-prod-orgwallet} + +hydra.ws.url=${HYDRA_WS_URL:ws://localhost:10001} +hydra.http.url=${HYDRA_HTTP_URL:http://localhost:10001} + +cardano.commit.address=${CARDANO_COMMIT_ADDRESS} +cardano.commit.utxo=${CARDANO_COMMIT_UTXO} +cardano.commit.amount=${CARDANO_COMMIT_AMOUNT} + +cardano.commit.type=${CARDANO_COMMIT_TYPE:COMMIT_FUNDS} + +hydra.operator.name=${HYDRA_OPERATOR_NAME:orgwallet} + +l1.operator.wallet.type=${L1_OPERATOR_WALLET_TYPE:CLI_JSON_FILE} +l1.operator.signing.key.file.path=${L1_OPERATOR_SIGNING_KEY_FILE_PATH:secrets/orgwallet.sk} +l1.operator.verification.key.file.path=${L1_OPERATOR_VERIFICATION_KEY_FILE_PATH:secrets/orgwallet.vk} diff --git a/backend-services/hydra-tally-app/src/main/resources/application-preprod.properties b/backend-services/hydra-tally-app/src/main/resources/application-preprod.properties new file mode 100644 index 000000000..ec713b688 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/application-preprod.properties @@ -0,0 +1,18 @@ +spring.banner.location=classpath:/banner.txt +spring.banner.charset=UTF-8 + +cardano.network=${CARDANO_NETWORK:PREPROD} + +local.bootstrap=${LOCAL_BOOTSTRAP:NOT_SHARDED} + +ballot.event.id=${BALLOT_EVENT_ID:CF_SUMMIT_2023_4BCC} +ballot.tally.name=${BALLOT_TALLY_NAME:Hydra Tally Experiment} + +ledger.follower.app.base.url=${LEDGER_FOLLOWER_APP_BASE_URL:https://follower-api.dev.cf-summit-2023-preprod.eu-west-1.metadata.dev.cf-deployments.org} +cardano.tx.submit.api.url=${CARDANO_TX_SUBMIT_API_URL:https://submit-api.pro.dandelion-preprod.eu-west-1.metadata.dev.cf-deployments.org/api/submit/tx} + +votes.path=${VOTES_PATH:classpath:blueprint/processed_votes.csv} + +hydra.auto.connect=${HYDRA_AUTO_CONNECT:false} + +spring.main.banner-mode=off \ No newline at end of file diff --git a/backend-services/hydra-tally-app/src/main/resources/banner.txt b/backend-services/hydra-tally-app/src/main/resources/banner.txt new file mode 100644 index 000000000..e69de29bb diff --git a/backend-services/hydra-tally-app/src/main/resources/blueprint/plutus.json b/backend-services/hydra-tally-app/src/main/resources/blueprint/plutus.json new file mode 120000 index 000000000..f84747cb7 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/blueprint/plutus.json @@ -0,0 +1 @@ +../../../../smart-contract/plutus.json \ No newline at end of file diff --git a/backend-services/hydra-tally-app/src/main/resources/secrets/alice-funds.sk b/backend-services/hydra-tally-app/src/main/resources/secrets/alice-funds.sk new file mode 100644 index 000000000..a21236f73 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/secrets/alice-funds.sk @@ -0,0 +1,5 @@ +{ + "type": "PaymentSigningKeyShelley_ed25519", + "description": "Payment Signing Key", + "cborHex": "58205f9b911a636479ed83ba601ccfcba0ab9a558269dc19fdea910d27e5cdbb5fc8" +} diff --git a/backend-services/hydra-tally-app/src/main/resources/secrets/alice-funds.vk b/backend-services/hydra-tally-app/src/main/resources/secrets/alice-funds.vk new file mode 100644 index 000000000..898ed8f49 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/secrets/alice-funds.vk @@ -0,0 +1,5 @@ +{ + "type": "PaymentVerificationKeyShelley_ed25519", + "description": "Payment Verification Key", + "cborHex": "5820f953b2d6b6f319faa9f8462257eb52ad73e33199c650f0755e279e21882399c0" +} \ No newline at end of file diff --git a/backend-services/hydra-tally-app/src/main/resources/secrets/bob-funds.sk b/backend-services/hydra-tally-app/src/main/resources/secrets/bob-funds.sk new file mode 100644 index 000000000..34a0754f5 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/secrets/bob-funds.sk @@ -0,0 +1,5 @@ +{ + "type": "PaymentSigningKeyShelley_ed25519", + "description": "Payment Signing Key", + "cborHex": "5820af9292ada4aa01db918bbba7796acf235e6d87d3ebc0d93fa44aa7e0531cf226" +} \ No newline at end of file diff --git a/backend-services/hydra-tally-app/src/main/resources/secrets/bob-funds.vk b/backend-services/hydra-tally-app/src/main/resources/secrets/bob-funds.vk new file mode 100644 index 000000000..63eb7d43d --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/secrets/bob-funds.vk @@ -0,0 +1,5 @@ +{ + "type": "PaymentVerificationKeyShelley_ed25519", + "description": "Payment Verification Key", + "cborHex": "5820aa268d154185c9ea06ea73442fd8143c34c1dd543b7142bcb132aac0d1ed6ece" +} \ No newline at end of file diff --git a/backend-services/hydra-tally-app/src/main/resources/secrets/carol-funds.sk b/backend-services/hydra-tally-app/src/main/resources/secrets/carol-funds.sk new file mode 100644 index 000000000..80bd191e8 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/secrets/carol-funds.sk @@ -0,0 +1,5 @@ +{ + "type": "PaymentSigningKeyShelley_ed25519", + "description": "Payment Signing Key", + "cborHex": "5820dd2c82563e945a5136c267f18e4dd285e2d4e754283ddbc1bed1a0e9932e0748" +} diff --git a/backend-services/hydra-tally-app/src/main/resources/secrets/carol-funds.vk b/backend-services/hydra-tally-app/src/main/resources/secrets/carol-funds.vk new file mode 100644 index 000000000..0ccfb5c7b --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/secrets/carol-funds.vk @@ -0,0 +1,5 @@ +{ + "type": "PaymentVerificationKeyShelley_ed25519", + "description": "Payment Verification Key", + "cborHex": "58200f193a88190f6dace0a3db1e0e50797a6e28cd4b6e289260dc96b5a8d7934bf8" +} \ No newline at end of file diff --git a/backend-services/hydra-tally-app/src/main/resources/votes/process_votes.rb b/backend-services/hydra-tally-app/src/main/resources/votes/process_votes.rb new file mode 100644 index 000000000..5ffd54e55 --- /dev/null +++ b/backend-services/hydra-tally-app/src/main/resources/votes/process_votes.rb @@ -0,0 +1,34 @@ +require 'csv' + +# Check if command line arguments are provided +if ARGV.length < 2 + puts "Usage: ruby script.rb " + exit +end + +# Get event_id and organiser from command line arguments +organiser = ARGV[1] + +# Read CSV file +csv_file = 'votes.csv' # Replace with your actual CSV file path +csv_data = CSV.read(csv_file, headers: true) + +# Create a new array to hold modified rows +modified_rows = [] + +# Add two additional columns at the beginning +csv_data.each do |row| + row["organiser"] = organiser + modified_rows << row +end + +# Write modified data back to the CSV file +output_csv_file = 'processed_votes.csv' +CSV.open(output_csv_file, 'w', write_headers: true, headers: csv_data.headers) do |csv| + modified_rows.each do |row| + csv << row + end +end + + +puts "Script executed successfully. Modified CSV saved at: #{output_csv_file}" diff --git a/backend-services/voting-admin-app/script/preprod--start.sh b/backend-services/voting-admin-app/script/preprod--start.sh new file mode 100755 index 000000000..c7224ef53 --- /dev/null +++ b/backend-services/voting-admin-app/script/preprod--start.sh @@ -0,0 +1,3 @@ +export SPRING_CONFIG_LOCATION=classpath:/application.properties +export SPRING_PROFILES_ACTIVE=dev--preprod +./gradlew clean build && java -jar build/libs/voting-admin-app-1.0.0-SNAPSHOT.jar diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/config/CardanoClientLibConfig.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/config/CardanoClientLibConfig.java index 512e9822b..e0233013e 100644 --- a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/config/CardanoClientLibConfig.java +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/config/CardanoClientLibConfig.java @@ -23,7 +23,7 @@ public BackendService backendService(@Value("${blockfrost.url}") String blockfro } @Bean - public Network cardanoNetwork(CardanoNetwork cardanoNetwork) { + public Network network(CardanoNetwork cardanoNetwork) { return switch(cardanoNetwork) { case MAIN -> Networks.mainnet(); case PREPROD -> Networks.preprod(); diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java index 69b809a54..2fd98889a 100644 --- a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java @@ -11,7 +11,7 @@ public class CardanoConfig { @Bean - public CardanoNetwork network(@Value("${cardano.network:main}") CardanoNetwork network) { + public CardanoNetwork cardanoNetwork(@Value("${cardano.network:main}") CardanoNetwork network) { log.info("Configured backend network:{}", network); return network; diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/CreateCategoryCommand.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/CreateCategoryCommand.java index 76914503b..a7248761d 100644 --- a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/CreateCategoryCommand.java +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/CreateCategoryCommand.java @@ -22,6 +22,6 @@ public class CreateCategoryCommand { private List proposals = List.of(); @Builder.Default - private SchemaVersion schemaVersion = SchemaVersion.V1; + private SchemaVersion schemaVersion = SchemaVersion.V11; } diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/CreateEventCommand.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/CreateEventCommand.java index e6bbb3cac..42d010ee6 100644 --- a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/CreateEventCommand.java +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/CreateEventCommand.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.ToString; +import java.util.List; import java.util.Optional; import static org.cardano.foundation.voting.domain.VotingPowerAsset.ADA; @@ -57,6 +58,9 @@ public class CreateEventCommand { private Optional proposalsRevealEpoch = Optional.empty(); @Builder.Default - private SchemaVersion schemaVersion = SchemaVersion.V1; + private List tallies = List.of(); + + @Builder.Default + private SchemaVersion schemaVersion = SchemaVersion.V11; } diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/HydraTallyConfig.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/HydraTallyConfig.java new file mode 100644 index 000000000..1ed8c5a74 --- /dev/null +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/HydraTallyConfig.java @@ -0,0 +1,27 @@ +package org.cardano.foundation.voting.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class HydraTallyConfig { + + private final String contractName; + private final String contractDescription; + private final String contractVersion; + + private final String compiledScript; + private final String compiledScriptHash; + + private final String compilerName; + private final String compilerVersion; + + // verification key hashes + private final List partiesVerificationKeys; + + private final String plutusVersion; + +} diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/SchemaVersion.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/SchemaVersion.java index 1b1bd26fb..3951a8b83 100644 --- a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/SchemaVersion.java +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/SchemaVersion.java @@ -2,7 +2,8 @@ public enum SchemaVersion { - V1("1.0.0"); + V1("1.0.0"), + V11("1.1.0"); private final String version; diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/TallyCommand.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/TallyCommand.java new file mode 100644 index 000000000..4b64bb178 --- /dev/null +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/TallyCommand.java @@ -0,0 +1,22 @@ +package org.cardano.foundation.voting.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class TallyCommand { + + private final String name; + + private final String description; + + private final TallyType type; + + private final TallyMode mode; + + private final int independentPartiesCount; + + private final Object config; + +} diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/TallyMode.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/TallyMode.java new file mode 100644 index 000000000..a16ac93c8 --- /dev/null +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/TallyMode.java @@ -0,0 +1,7 @@ +package org.cardano.foundation.voting.domain; + +public enum TallyMode { + CENTRALISED, + FEDERATED, + DECENTRALISED +} diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/TallyType.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/TallyType.java new file mode 100644 index 000000000..2d2d8823b --- /dev/null +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/domain/TallyType.java @@ -0,0 +1,5 @@ +package org.cardano.foundation.voting.domain; + +public enum TallyType { + HYDRA, +} diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/service/transaction_submit/L1SubmissionService.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/service/transaction_submit/L1SubmissionService.java index e394294fb..5c56e60d9 100644 --- a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/service/transaction_submit/L1SubmissionService.java +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/service/transaction_submit/L1SubmissionService.java @@ -3,12 +3,14 @@ import lombok.extern.slf4j.Slf4j; import org.cardano.foundation.voting.domain.CreateCategoryCommand; import org.cardano.foundation.voting.domain.CreateEventCommand; +import org.cardano.foundation.voting.domain.HydraTallyConfig; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.List; +import static org.cardano.foundation.voting.domain.TallyType.HYDRA; import static org.cardano.foundation.voting.domain.VotingEventType.*; @Service @@ -64,6 +66,26 @@ private void checkEventCorrectness(CreateEventCommand event) { if (event.getSnapshotEpoch().isPresent()) { throw new IllegalArgumentException("Event's snapshot epoch must not be specified for USER_BASED voting event!"); } + + for (var tally : event.getTallies()) { + if (tally.getIndependentPartiesCount() <= 0) { + throw new IllegalArgumentException("Tally independent parties count must be greater than 0!"); + } + + if (tally.getType() == HYDRA) { + if (tally.getConfig() == null) { + throw new IllegalArgumentException("Hydra tally config must be specified!"); + } + if (!(tally.getConfig() instanceof HydraTallyConfig)) { + throw new IllegalArgumentException("Hydra tally config must be specified!"); + } + var hydraTallyConfig = (HydraTallyConfig) tally.getConfig(); + + if (hydraTallyConfig.getPartiesVerificationKeys().isEmpty()) { + throw new IllegalArgumentException("Hydra tally config must contain at least one party verification key!"); + } + } + } } } diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/service/transaction_submit/MetadataSerialiser.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/service/transaction_submit/MetadataSerialiser.java index d937bfd28..a100d2169 100644 --- a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/service/transaction_submit/MetadataSerialiser.java +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/service/transaction_submit/MetadataSerialiser.java @@ -3,15 +3,14 @@ import com.bloxbean.cardano.client.metadata.MetadataBuilder; import com.bloxbean.cardano.client.metadata.MetadataList; import com.bloxbean.cardano.client.metadata.MetadataMap; -import org.cardano.foundation.voting.domain.CreateCategoryCommand; -import org.cardano.foundation.voting.domain.CreateEventCommand; -import org.cardano.foundation.voting.domain.OnChainEventType; +import org.cardano.foundation.voting.domain.*; import org.springframework.stereotype.Service; import java.math.BigInteger; import java.util.List; import static org.cardano.foundation.voting.domain.OnChainEventType.EVENT_REGISTRATION; +import static org.cardano.foundation.voting.domain.TallyType.HYDRA; import static org.cardano.foundation.voting.domain.VotingEventType.*; import static org.cardano.foundation.voting.utils.MoreBoolean.toBigInteger; @@ -44,11 +43,56 @@ public MetadataMap serialise(CreateEventCommand createEventCommand, map.put("proposalsRevealSlot", BigInteger.valueOf(createEventCommand.getProposalsRevealSlot().orElseThrow())); } + List tallies = createEventCommand.getTallies(); + + var tallyList = MetadataBuilder.createList(); + + for (var tally : tallies) { + var tallyBag = MetadataBuilder.createMap(); + + tallyBag.put("name", tally.getName()); + tallyBag.put("desc", tally.getDescription()); + tallyBag.put("type", tally.getType().name().toUpperCase()); + tallyBag.put("mode", tally.getMode().name().toUpperCase()); + tallyBag.put("partiesCount", BigInteger.valueOf(tally.getIndependentPartiesCount())); + + if (tally.getType() == HYDRA) { + tallyBag.put("config", createHydraTallyConfig(tally)); + } + + tallyList.add(tallyBag); + } + + map.put("tallies", tallyList); + map.put("options", createEventOptions(createEventCommand)); return map; } + private static MetadataMap createHydraTallyConfig(TallyCommand tally) { + var tallyHydra = (HydraTallyConfig) tally.getConfig(); + + var tallyHydraConfig = MetadataBuilder.createMap(); + tallyHydraConfig.put("contractName", tallyHydra.getContractName()); + tallyHydraConfig.put("contractDesc", tallyHydra.getContractDescription()); + tallyHydraConfig.put("contractVersion", tallyHydra.getContractVersion()); + + tallyHydraConfig.put("compiledScript", tallyHydra.getCompiledScript()); + tallyHydraConfig.put("compiledScriptHash", tallyHydra.getCompiledScriptHash()); + + tallyHydraConfig.put("compilerName", tallyHydra.getCompilerName()); + tallyHydraConfig.put("compilerVersion", tallyHydra.getCompilerVersion()); + tallyHydraConfig.put("plutusVersion", tallyHydra.getPlutusVersion()); + + var verificationKeys = MetadataBuilder.createList(); + tallyHydra.getPartiesVerificationKeys().forEach(verificationKeys::add); + + tallyHydraConfig.put("verificationKeys", verificationKeys); + + return tallyHydraConfig; + } + private static MetadataMap createEventOptions(CreateEventCommand createEventCommand) { var optionsMap = MetadataBuilder.createMap(); optionsMap.put("allowVoteChanging", toBigInteger(createEventCommand.isAllowVoteChanging())); diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/shell/CardanoSummit2023PreProdCommands.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/shell/CardanoSummit2023PreProdCommands.java index fe74846a4..7b98216b4 100644 --- a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/shell/CardanoSummit2023PreProdCommands.java +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/shell/CardanoSummit2023PreProdCommands.java @@ -1,11 +1,10 @@ package org.cardano.foundation.voting.shell; +import com.bloxbean.cardano.client.crypto.KeyGenUtil; +import com.bloxbean.cardano.client.crypto.VerificationKey; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.cardano.foundation.voting.domain.CardanoNetwork; -import org.cardano.foundation.voting.domain.CreateCategoryCommand; -import org.cardano.foundation.voting.domain.CreateEventCommand; -import org.cardano.foundation.voting.domain.Proposal; +import org.cardano.foundation.voting.domain.*; import org.cardano.foundation.voting.service.transaction_submit.L1SubmissionService; import org.springframework.core.annotation.Order; import org.springframework.shell.standard.ShellComponent; @@ -15,11 +14,13 @@ import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.stream.Stream; import static org.cardano.foundation.voting.domain.CardanoNetwork.PREPROD; -import static org.cardano.foundation.voting.domain.SchemaVersion.V1; +import static org.cardano.foundation.voting.domain.SchemaVersion.V11; +import static org.cardano.foundation.voting.domain.TallyMode.CENTRALISED; +import static org.cardano.foundation.voting.domain.TallyType.HYDRA; import static org.cardano.foundation.voting.domain.VotingEventType.USER_BASED; -import static org.cardano.foundation.voting.utils.MoreUUID.shortUUID; @ShellComponent @Slf4j @@ -41,22 +42,53 @@ public String createCFSummit2023Event() { log.info("Creating CF-Summit 2023 on a PRE-PROD network..."); - long startSlot = 40127992; - long endSlot = startSlot + (604800 * 2); // two weeks since 604800 is 1 week in seconds + long startSlot = 43760099; + long endSlot = startSlot + 60; + + var partiesVerificationKeys = Stream.of( + "582007b16713dd36364b22bf2f594235bba8b38e209f2e3aea80b8ec982ee8c7db35", + "5820e5549f7ee1a6b40f5bf8c1ac6c3fcb4da43be4430c7c5148e815c43353ffbd1e" + ) + .map(VerificationKey::new) + .map(KeyGenUtil::getKeyHash) + .toList(); + + var hydraTallyConfig = new HydraTallyConfig( + "cardano-foundation/hydra-tally", + "Experimental Hydra Tally Contract", + "0.0.1", + "59082a010000323232323232323232232232232232232222325333011323253330133370e90011809000899191919191919191919191919191919191919299981319b87480000344c8c8c8c94ccc0a8ccc02c05004805854ccc0a80044cc030dd618099814180a981400d8010a5014a06600e6eb0c020c09cc050c09c0688cc028090004cdd2a4000660586ea4dcc010198161ba9373003c660586ea4dcc00e198161ba60014bd7019198008008011129998160008a5eb7bdb1804c8c8c8cccc024cc014014008cc88c8cc0040052f5bded8c044a66606600226606866ec0dd49b98005375000897adef6c6013232323253330343375e6600e012004980103d8798000133038337606ea4dcc0049ba8008005153330343372e01200426607066ec0dd49b98009375001000626607066ec0dd49b9800237500026600c00c0066eb4c0d400cdcc9bae303300230370023035001375a6062606460646064606460646064605400600e44466e95200033033375066e000080052f5c000e6e64dd718181818981898189818981898148011818001181700099805008129998139806980b1812800899299981419b8748010c09c0044c8c8c8c94ccc0b00044cdd2a40006606000697ae014c0103d87a8000533302b3372e0466e64dd7180b18148010a99981599b9701f37326eb8c0c0c0c4c0c4c0c4c0c4c0a40084cdcb8109b99375c60346052004294052819299981599b87480000044c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc0f0c0fc00852616375a607a002607a0046e64dd7181d800981d8011b99375c607200260720046eb8c0dc004c0dc008dcc9bae3035001303500237326eb8c0cc004c0cc008dcc9bae30310013029002163029001302e001302600116301030253016302500114c0103d87a8000132323232533302a33300b0140120161533302a00213300c375860266050602a6050036002294052819ba548000cc0b4dd49b980213302d37526e6007ccc0b4dd49b9801d3302d374c00497ae0330063758600e604c6026604c03246601204600266644464666002002008006444a66606000420022666006006606600466446600c0020046eacc0c8008004c8cc004004008894ccc0b000452f5c026605a6e98dd5981718179817981798139817000998010011817800a5eb7bdb18088cccc018008004888cdd2a4000660606ea0cdc0001000a5eb80010cc02804094ccc09cc034c058c0940044c94ccc0a0cdc3a4008604e002264646464a666058002266e952000330300034bd700a60103d87a8000533302b3372e0466e64dd7180b18148010a99981599b9702137326eb8c068c0a40084cdcb80f9b99375c6028605200429405281807800981700098130008b18081812980b18128008a6103d87a8000223322533302933720004002298103d8798000153330293371e0040022980103d87a800014c103d87b8000300300230030012373000244446466600200200a008444a66605a0042002264666008008606200666664444646600200200e44a66606800226606a66ec0dd49b98006375000a97adef6c6013232323253330353375e6600e014004980103d8798000133039337606ea4dcc0051ba8009005153330353372e01400426464a66606e66e1d200000113303b337606ea4dcc006181e181a8010028802981a80099980400500480089981c99bb037526e60008dd4000998030030019bad303600337326eb8c0d0008c0e0008c0d8004dcc9bae302c001375a605a00200c00a605e00444646600200200644a66605200229404c8c94ccc0a0c01400852889980200200098168011bae302b001230273028302830283028302830283028302800122323300100100322533302700114a026464a66604c66e3c00801452889980200200098158011bae302900122232323300700123253330263370e90011812800899b8f375c605660480020082c6020604660206046002646600200200844a666050002297ae0132325333027323253330293370e90010008a5114a0604e0026024604a6024604a004266056004660080080022660080080026058004605400264a66604666e1d20003022001132323253330263370e9001181280089bae302b3024001163010302330103023301430230013029001302100116323300100100422533302700114c0103d87a80001323253330263375e6022604800400a266e9520003302a0024bd7009980200200098158011814800911980199802001129998109803800899299981119b8748010c0840044c8c8c8cdd2a40006605200497ae030090013028001302000116300a301f00114c103d87a800023375e00200444646600200200644a66604800229404c8c94ccc08cc014008528899802002000981400118130009119198008008019129998118008a5eb804c8c8c8c94ccc090cdc3a400400226600c00c006266050605260440046600c00c0066044002600a004604e004604a002464a66603a66e1d2000001132323232323232325333028302b002132498c8cc004004008894ccc0a800452613233003003302e0023232375a60560046e64dd7181480098160008b1bab3029001302900237326eb8c09c004c09c008dcc9bae3025001302500237326eb8c08c004c06c00858c06c0048c8c94ccc074cdc3a40080022944528180d8009802180c800980c0059bac30013016300330160092301d301e301e0013758600260286002602800e46036002603200260220022c600260200064602e603000229309b2b19299980899b874800000454ccc050c03c00c52616153330113370e90010008a99980a18078018a4c2c2c601e0046e64dd70009b99375c0026e64dd70009bac001375c0024600a6ea80048c00cdd5000ab9a5573aaae7955cfaba05742ae881", + "1389ddd7070bd334a572825204e068506ad25ab760c725daa8c0eb94", + "Aiken", + "v1.0.20-alpha+49bd4ba", + partiesVerificationKeys, + "v2" + ); + + var tallyCommand = new TallyCommand( + "Hydra_Tally_Experiment", + "", + HYDRA, + CENTRALISED, + 1, + hydraTallyConfig + ); var createEventCommand = CreateEventCommand.builder() - .id(EVENT_NAME + "_" + shortUUID(4)) + //.id(EVENT_NAME + "_" + shortUUID(4)) + .id(EVENT_NAME + "_" + "4BCC") .startSlot(Optional.of(startSlot)) .endSlot(Optional.of(endSlot)) .votingPowerAsset(Optional.empty()) .organisers("CF") .votingEventType(USER_BASED) - .schemaVersion(V1) + .schemaVersion(V11) .allowVoteChanging(false) .highLevelEventResultsWhileVoting(true) .highLevelCategoryResultsWhileVoting(true) .categoryResultsWhileVoting(false) - .proposalsRevealSlot(Optional.of(endSlot + 43200)) + .proposalsRevealSlot(Optional.of(endSlot + 1)) + .tallies(List.of(tallyCommand)) .build(); l1SubmissionService.submitEvent(createEventCommand); @@ -104,7 +136,7 @@ public String createAmbassadorCategory(@ShellOption String event) { .id("AMBASSADOR") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -156,7 +188,7 @@ public String createBlockchainForGoodCategory(@ShellOption String event) { .id("BLOCKCHAIN_FOR_GOOD") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -208,7 +240,7 @@ public String createCIPsCategory(@ShellOption String event) { .id("CIPS") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -261,7 +293,7 @@ public String createDeFiDEXCategory(@ShellOption String event) { .id("BEST_DEFI_DEX") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -313,7 +345,7 @@ public String createBestDeveloperOrDeveloperTools(@ShellOption String event) { .id("BEST_DEVELOPER_OR_DEVELOPER_TOOLS") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -366,7 +398,7 @@ public String createEducationalInfluencer(@ShellOption String event) { .id("EDUCATIONAL_INFLUENCER") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -419,7 +451,7 @@ public String createMarketPlaceCategory(@ShellOption String event) { .id("MARKETPLACE") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -471,7 +503,7 @@ public String createMostImpactfulSPOCategory(@ShellOption String event) { .id("MOST_IMPACTFUL_SSPO") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -523,7 +555,7 @@ public String createNFTProjectCategory(@ShellOption String event) { .id("NFT_PROJECT") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -571,7 +603,7 @@ public String createSSICategory(@ShellOption String event) { .id("SSI") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); diff --git a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/shell/CardanoSummit2023ProdCommands.java b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/shell/CardanoSummit2023ProdCommands.java index d5ce957dc..ada70ff94 100644 --- a/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/shell/CardanoSummit2023ProdCommands.java +++ b/backend-services/voting-admin-app/src/main/java/org/cardano/foundation/voting/shell/CardanoSummit2023ProdCommands.java @@ -1,11 +1,10 @@ package org.cardano.foundation.voting.shell; +import com.bloxbean.cardano.client.crypto.KeyGenUtil; +import com.bloxbean.cardano.client.crypto.VerificationKey; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.cardano.foundation.voting.domain.CardanoNetwork; -import org.cardano.foundation.voting.domain.CreateCategoryCommand; -import org.cardano.foundation.voting.domain.CreateEventCommand; -import org.cardano.foundation.voting.domain.Proposal; +import org.cardano.foundation.voting.domain.*; import org.cardano.foundation.voting.service.transaction_submit.L1SubmissionService; import org.springframework.core.annotation.Order; import org.springframework.shell.standard.ShellComponent; @@ -15,9 +14,12 @@ import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.stream.Stream; import static org.cardano.foundation.voting.domain.CardanoNetwork.MAIN; -import static org.cardano.foundation.voting.domain.SchemaVersion.V1; +import static org.cardano.foundation.voting.domain.SchemaVersion.V11; +import static org.cardano.foundation.voting.domain.TallyMode.CENTRALISED; +import static org.cardano.foundation.voting.domain.TallyType.HYDRA; import static org.cardano.foundation.voting.domain.VotingEventType.USER_BASED; @ShellComponent @@ -25,7 +27,7 @@ @RequiredArgsConstructor public class CardanoSummit2023ProdCommands { - private final static String EVENT_NAME = "CF_SUMMIT_2023"; + private final static String EVENT_NAME = "CARDANO_SUMMIT_AWARDS_2023"; private final L1SubmissionService l1SubmissionService; @@ -40,23 +42,52 @@ public String createCFSummit2023Event() { log.info("Creating CF-Summit 2023 on a MAIN network..."); - long startSlot = 104339709; // 28-09-2023 13:00:00 UTC - long endSlot = 105117309; // 07-10-2023 13:00:00 UTC - long proposalsRevealSlot = 105203709; // 08-10-2023 13:00:00 UTC - + long startSlot = 104865309; + long endSlot = 105502449; + long proposalsRevealSlot = 107576110; + + var partiesVerificationKeys = Stream.of("5820a9f5c4f7c861ea949bf8af7b52d9b97dc648f21eb9473a54addf202261e05644", + "5820e0546bf97a1f578091c9b42d0e4b67ec62fd342a1cedae0fb3282e37a7f88865" + ) + .map(VerificationKey::new) + .map(KeyGenUtil::getKeyHash) + .toList(); + + var hydraTallyConfig = new HydraTallyConfig( + "cardano-foundation/hydra-tally", + "Experimental Hydra Tally Contract", + "0.0.1", + "59082a010000323232323232323232232232232232232222325333011323253330133370e90011809000899191919191919191919191919191919191919299981319b87480000344c8c8c8c94ccc0a8ccc02c05004805854ccc0a80044cc030dd618099814180a981400d8010a5014a06600e6eb0c020c09cc050c09c0688cc028090004cdd2a4000660586ea4dcc010198161ba9373003c660586ea4dcc00e198161ba60014bd7019198008008011129998160008a5eb7bdb1804c8c8c8cccc024cc014014008cc88c8cc0040052f5bded8c044a66606600226606866ec0dd49b98005375000897adef6c6013232323253330343375e6600e012004980103d8798000133038337606ea4dcc0049ba8008005153330343372e01200426607066ec0dd49b98009375001000626607066ec0dd49b9800237500026600c00c0066eb4c0d400cdcc9bae303300230370023035001375a6062606460646064606460646064605400600e44466e95200033033375066e000080052f5c000e6e64dd718181818981898189818981898148011818001181700099805008129998139806980b1812800899299981419b8748010c09c0044c8c8c8c94ccc0b00044cdd2a40006606000697ae014c0103d87a8000533302b3372e0466e64dd7180b18148010a99981599b9701f37326eb8c0c0c0c4c0c4c0c4c0c4c0a40084cdcb8109b99375c60346052004294052819299981599b87480000044c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc0f0c0fc00852616375a607a002607a0046e64dd7181d800981d8011b99375c607200260720046eb8c0dc004c0dc008dcc9bae3035001303500237326eb8c0cc004c0cc008dcc9bae30310013029002163029001302e001302600116301030253016302500114c0103d87a8000132323232533302a33300b0140120161533302a00213300c375860266050602a6050036002294052819ba548000cc0b4dd49b980213302d37526e6007ccc0b4dd49b9801d3302d374c00497ae0330063758600e604c6026604c03246601204600266644464666002002008006444a66606000420022666006006606600466446600c0020046eacc0c8008004c8cc004004008894ccc0b000452f5c026605a6e98dd5981718179817981798139817000998010011817800a5eb7bdb18088cccc018008004888cdd2a4000660606ea0cdc0001000a5eb80010cc02804094ccc09cc034c058c0940044c94ccc0a0cdc3a4008604e002264646464a666058002266e952000330300034bd700a60103d87a8000533302b3372e0466e64dd7180b18148010a99981599b9702137326eb8c068c0a40084cdcb80f9b99375c6028605200429405281807800981700098130008b18081812980b18128008a6103d87a8000223322533302933720004002298103d8798000153330293371e0040022980103d87a800014c103d87b8000300300230030012373000244446466600200200a008444a66605a0042002264666008008606200666664444646600200200e44a66606800226606a66ec0dd49b98006375000a97adef6c6013232323253330353375e6600e014004980103d8798000133039337606ea4dcc0051ba8009005153330353372e01400426464a66606e66e1d200000113303b337606ea4dcc006181e181a8010028802981a80099980400500480089981c99bb037526e60008dd4000998030030019bad303600337326eb8c0d0008c0e0008c0d8004dcc9bae302c001375a605a00200c00a605e00444646600200200644a66605200229404c8c94ccc0a0c01400852889980200200098168011bae302b001230273028302830283028302830283028302800122323300100100322533302700114a026464a66604c66e3c00801452889980200200098158011bae302900122232323300700123253330263370e90011812800899b8f375c605660480020082c6020604660206046002646600200200844a666050002297ae0132325333027323253330293370e90010008a5114a0604e0026024604a6024604a004266056004660080080022660080080026058004605400264a66604666e1d20003022001132323253330263370e9001181280089bae302b3024001163010302330103023301430230013029001302100116323300100100422533302700114c0103d87a80001323253330263375e6022604800400a266e9520003302a0024bd7009980200200098158011814800911980199802001129998109803800899299981119b8748010c0840044c8c8c8cdd2a40006605200497ae030090013028001302000116300a301f00114c103d87a800023375e00200444646600200200644a66604800229404c8c94ccc08cc014008528899802002000981400118130009119198008008019129998118008a5eb804c8c8c8c94ccc090cdc3a400400226600c00c006266050605260440046600c00c0066044002600a004604e004604a002464a66603a66e1d2000001132323232323232325333028302b002132498c8cc004004008894ccc0a800452613233003003302e0023232375a60560046e64dd7181480098160008b1bab3029001302900237326eb8c09c004c09c008dcc9bae3025001302500237326eb8c08c004c06c00858c06c0048c8c94ccc074cdc3a40080022944528180d8009802180c800980c0059bac30013016300330160092301d301e301e0013758600260286002602800e46036002603200260220022c600260200064602e603000229309b2b19299980899b874800000454ccc050c03c00c52616153330113370e90010008a99980a18078018a4c2c2c601e0046e64dd70009b99375c0026e64dd70009bac001375c0024600a6ea80048c00cdd5000ab9a5573aaae7955cfaba05742ae881", + "1389ddd7070bd334a572825204e068506ad25ab760c725daa8c0eb94", + "Aiken", + "v1.0.20-alpha+49bd4ba", + partiesVerificationKeys, + "v2" + ); + + var tallyCommand = new TallyCommand( + "Hydra_Tally_Experiment", + "", + HYDRA, + CENTRALISED, + 1, + hydraTallyConfig + ); + CreateEventCommand createEventCommand = CreateEventCommand.builder() - .id(EVENT_NAME + "_" + "TEST3") + .id(EVENT_NAME) .startSlot(Optional.of(startSlot)) .endSlot(Optional.of(endSlot)) .votingPowerAsset(Optional.empty()) - .organisers("CF") + .organisers("Cardano Foundation") .votingEventType(USER_BASED) - .schemaVersion(V1) + .schemaVersion(V11) .allowVoteChanging(false) .highLevelEventResultsWhileVoting(true) .highLevelCategoryResultsWhileVoting(true) .categoryResultsWhileVoting(false) .proposalsRevealSlot(Optional.of(proposalsRevealSlot)) + .tallies(List.of(tallyCommand)) .build(); l1SubmissionService.submitEvent(createEventCommand); @@ -103,7 +134,7 @@ public String createAmbassadorCategory(@ShellOption String event) { .id("AMBASSADOR") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -154,7 +185,7 @@ public String createBlockchainForGoodCategory(@ShellOption String event) { .id("BLOCKCHAIN_FOR_GOOD") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -205,7 +236,7 @@ public String createCIPsCategory(@ShellOption String event) { .id("CIPS") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -257,7 +288,7 @@ public String createDeFiDEXCategory(@ShellOption String event) { .id("BEST_DEFI_DEX") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -308,7 +339,7 @@ public String createBestDeveloperOrDeveloperTools(@ShellOption String event) { .id("BEST_DEVELOPER_OR_DEVELOPER_TOOLS") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -360,7 +391,7 @@ public String createEducationalInfluencer(@ShellOption String event) { .id("EDUCATIONAL_INFLUENCER") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -412,7 +443,7 @@ public String createMarketPlaceCategory(@ShellOption String event) { .id("MARKETPLACE") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -463,7 +494,7 @@ public String createMostImpactfulSPOCategory(@ShellOption String event) { .id("MOST_IMPACTFUL_SSPO") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -514,7 +545,7 @@ public String createNFTProjectCategory(@ShellOption String event) { .id("NFT_PROJECT") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); @@ -558,7 +589,7 @@ public String createSSICategory(@ShellOption String event) { .id("SSI") .event(event) .gdprProtection(true) - .schemaVersion(V1) + .schemaVersion(V11) .proposals(allProposals) .build(); diff --git a/backend-services/voting-admin-app/src/main/resources/application-dev--yaci-dev-kit.properties b/backend-services/voting-admin-app/src/main/resources/application-dev--yaci-dev-kit.properties index 4e8868555..fb1235c79 100644 --- a/backend-services/voting-admin-app/src/main/resources/application-dev--yaci-dev-kit.properties +++ b/backend-services/voting-admin-app/src/main/resources/application-dev--yaci-dev-kit.properties @@ -11,4 +11,3 @@ cardano-client-lib.backend.type=YACI #spring.profiles.active=dev--yaci-dev-kit cardano.snapshot.bounds.check.enabled=false - diff --git a/backend-services/voting-app/build.gradle.kts b/backend-services/voting-app/build.gradle.kts index 5f1be9f82..e1a6b4106 100644 --- a/backend-services/voting-app/build.gradle.kts +++ b/backend-services/voting-app/build.gradle.kts @@ -30,7 +30,6 @@ configurations { repositories { mavenCentral() - mavenLocal() maven { url = uri("https://repo.spring.io/milestone") } } @@ -70,7 +69,7 @@ dependencies { implementation("com.nimbusds:nimbus-jose-jwt:9.35") implementation("com.google.crypto.tink:tink:1.11.0") - implementation("com.bloxbean.cardano:cardano-client-cip30:0.5.0") + implementation("com.bloxbean.cardano:cardano-client-cip30:0.5.0") implementation("io.blockfrost:blockfrost-java:0.1.3") diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/client/ChainFollowerClient.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/client/ChainFollowerClient.java index 9aaa2c176..b0e111049 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/client/ChainFollowerClient.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/client/ChainFollowerClient.java @@ -1,8 +1,10 @@ package org.cardano.foundation.voting.client; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import io.vavr.control.Either; import lombok.extern.slf4j.Slf4j; import org.cardano.foundation.voting.domain.CardanoNetwork; +import org.cardano.foundation.voting.domain.TallyType; import org.cardano.foundation.voting.domain.VotingEventType; import org.cardano.foundation.voting.domain.VotingPowerAsset; import org.springframework.beans.factory.annotation.Autowired; @@ -14,6 +16,7 @@ import org.zalando.problem.spring.common.HttpStatusAdapter; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.springframework.http.HttpStatus.NOT_FOUND; @@ -28,6 +31,23 @@ public class ChainFollowerClient { @Value("${ledger.follower.app.base.url}") private String ledgerFollowerBaseUrl; + public Either getVotingResults(String eventId, + String categoryId, + String tallyName + ) { + var url = String.format("%s/api/tally/voting-results/{eventId}/{categoryId}/{tallyName}", ledgerFollowerBaseUrl); + + try { + return Either.right(restTemplate.getForObject(url, L1CategoryResults.class, eventId, categoryId, tallyName)); + } catch (HttpClientErrorException e) { + return Either.left(Problem.builder() + .withTitle("CATEGORY_RESULTS_ERROR") + .withDetail("Unable to get category results from chain-tip follower service, reason:" + e.getMessage()) + .withStatus(new HttpStatusAdapter(e.getStatusCode())) + .build()); + } + } + public Either getChainTip() { var url = String.format("%s/api/blockchain/tip", ledgerFollowerBaseUrl); @@ -125,7 +145,9 @@ public record EventSummary(String id, } + @JsonIgnoreProperties(ignoreUnknown = true) public record EventDetailsResponse(String id, + String organisers, boolean finished, boolean notStarted, boolean isStarted, @@ -137,7 +159,8 @@ public record EventDetailsResponse(String id, boolean highLevelCategoryResultsWhileVoting, boolean categoryResultsWhileVoting, VotingEventType votingEventType, - List categories) { + List categories, + List tallies) { public boolean isEventInactive() { return !active; @@ -194,4 +217,17 @@ public record AccountResponse( String votingPower, VotingPowerAsset votingPowerAsset) { } + public record L1CategoryResults(String tallyName, + String tallyDescription, + TallyType tallyType, + String eventId, + String categoryId, + Map results, + Map metadata) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Tally(String name, TallyType type) { + } + } diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java index 69b809a54..2fd98889a 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java @@ -11,7 +11,7 @@ public class CardanoConfig { @Bean - public CardanoNetwork network(@Value("${cardano.network:main}") CardanoNetwork network) { + public CardanoNetwork cardanoNetwork(@Value("${cardano.network:main}") CardanoNetwork network) { log.info("Configured backend network:{}", network); return network; diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/Leaderboard.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/Leaderboard.java index a96d76775..dacc91be5 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/Leaderboard.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/Leaderboard.java @@ -13,15 +13,6 @@ @Builder public class Leaderboard { - @Builder - @Getter - public static class WinnerStats { - - private String categoryId; - private String proposalId; - - } - // per category @Builder @Getter @@ -54,6 +45,7 @@ public static class ByCategoryStats { @Getter @Builder + @AllArgsConstructor public static class Votes { private long votes; private String votingPower; diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/TallyType.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/TallyType.java new file mode 100644 index 000000000..2d2d8823b --- /dev/null +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/TallyType.java @@ -0,0 +1,5 @@ +package org.cardano.foundation.voting.domain; + +public enum TallyType { + HYDRA, +} diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/WinnerLeaderboardSource.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/WinnerLeaderboardSource.java new file mode 100644 index 000000000..a791e478e --- /dev/null +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/domain/WinnerLeaderboardSource.java @@ -0,0 +1,8 @@ +package org.cardano.foundation.voting.domain; + +public enum WinnerLeaderboardSource { + + l1, // cardano L1 + db // centralised db + +} diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/repository/CustomVoteRepository.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/repository/CustomVoteRepository.java deleted file mode 100644 index e334d5926..000000000 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/repository/CustomVoteRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.cardano.foundation.voting.repository; - -import org.cardano.foundation.voting.domain.Leaderboard; - -import java.util.List; - -public interface CustomVoteRepository { - - List getEventWinners(String eventId, List categoryIds); - -} diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/repository/DefaultCustomVoteRepository.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/repository/DefaultCustomVoteRepository.java deleted file mode 100644 index 221cae86b..000000000 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/repository/DefaultCustomVoteRepository.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.cardano.foundation.voting.repository; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import org.cardano.foundation.voting.domain.Leaderboard; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; - -@Service -public class DefaultCustomVoteRepository implements CustomVoteRepository { - - @PersistenceContext - private EntityManager entityManager; - - - // TODO optimise this in one big and more complex query for all categories? - public List getEventWinners(String eventId, List categoryIds) { - var winningProposals = new ArrayList(); - - for (var categoryId : categoryIds) { - var winningQuery = entityManager.createQuery( - "SELECT v.proposalId, SUM(v.votingPower) AS totalPower, COUNT(v.id) AS votesCount " + - "FROM Vote v " + - "WHERE v.eventId = :eventId AND v.categoryId = :categoryId " + - "GROUP BY v.proposalId " + - "ORDER BY totalPower, votesCount DESC", - Object[].class); - - winningQuery.setParameter("eventId", eventId); - winningQuery.setParameter("categoryId", categoryId); - winningQuery.setMaxResults(1); - - var result = winningQuery.getResultList(); - - if (result.isEmpty()) { - return winningProposals; - } - - var winner = result.get(0); - var proposalId = (String) winner[0]; - - var winningVote = Leaderboard.WinnerStats.builder() - .categoryId(categoryId) - .proposalId(proposalId) - .build(); - - winningProposals.add(winningVote); - } - - return winningProposals; - } - -} diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/resource/LeaderboardResource.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/resource/LeaderboardResource.java index 0075b23ff..469d8c855 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/resource/LeaderboardResource.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/resource/LeaderboardResource.java @@ -7,23 +7,27 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; -import org.cardano.foundation.voting.service.leader_board.LeaderBoardService; +import org.cardano.foundation.voting.domain.WinnerLeaderboardSource; +import org.cardano.foundation.voting.service.leader_board.HighLevelLeaderBoardService; +import org.cardano.foundation.voting.service.leader_board.LeaderboardWinnersProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.CacheControl; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.zalando.problem.Problem; import org.zalando.problem.Status; +import java.util.Optional; + import static java.util.concurrent.TimeUnit.MINUTES; +import static org.cardano.foundation.voting.domain.WinnerLeaderboardSource.db; import static org.cardano.foundation.voting.resource.Headers.XForceLeaderBoardResults; import static org.springframework.web.bind.annotation.RequestMethod.GET; import static org.springframework.web.bind.annotation.RequestMethod.HEAD; +import static org.zalando.problem.Status.NOT_FOUND; @RestController @RequestMapping("/api/leaderboard") @@ -32,12 +36,15 @@ public class LeaderboardResource { @Autowired - private LeaderBoardService leaderBoardService; + private HighLevelLeaderBoardService highLevelLeaderBoardService; + + @Autowired + private LeaderboardWinnersProvider leaderboardWinnersProvider; @Value("${leaderboard.force.results:false}") private boolean forceLeaderboardResultsAvailability; - @RequestMapping(value = "/event/{eventId}/", method = HEAD, produces = "application/json") + @RequestMapping(value = "/event/{eventId}", method = HEAD, produces = "application/json") @Timed(value = "resource.leaderboard.high.level.event.available", histogram = true) @Operation(summary = "Check availability of the high-level event leaderboard", description = "Verifies if the high-level leaderboard for a specific event is available.", @@ -49,17 +56,16 @@ public class LeaderboardResource { @ApiResponse(responseCode = "500", description = "Internal server error") } ) - public ResponseEntity isHighLevelEventLeaderBoardAvailable( - @Parameter(name = "eventId", description = "ID of the event", required = true) - @PathVariable("eventId") String eventId, - @RequestHeader(value = XForceLeaderBoardResults, required = false, defaultValue = "false") boolean forceLeaderboardResults) { - var cacheControl = CacheControl.maxAge(1, MINUTES) + public ResponseEntity isHighLevelEventLeaderBoardAvailable(@Parameter(name = "eventId", description = "ID of the event", required = true) + @PathVariable("eventId") String eventId, + @RequestHeader(value = XForceLeaderBoardResults, required = false, defaultValue = "false") boolean forceLeaderboardResults) { + var cacheControl = CacheControl.maxAge(5, MINUTES) .noTransform() .mustRevalidate(); var forceLeaderboard = forceLeaderboardResults && forceLeaderboardResultsAvailability; - var availableE = leaderBoardService.isHighLevelEventLeaderboardAvailable(eventId, forceLeaderboard); + var availableE = highLevelLeaderBoardService.isHighLevelEventLeaderboardAvailable(eventId, forceLeaderboard); return availableE.fold(problem -> { return ResponseEntity @@ -102,17 +108,16 @@ public ResponseEntity isHighLevelEventLeaderBoardAvailable( @ApiResponse(responseCode = "500", description = "Internal server error") } ) - public ResponseEntity isHighLevelCategoryLeaderBoardAvailable( - @Parameter(name = "eventId", description = "ID of the event", required = true) - @PathVariable("eventId") String eventId, - @RequestHeader(value = XForceLeaderBoardResults, required = false, defaultValue = "false") boolean forceLeaderboardResults) { - var cacheControl = CacheControl.maxAge(1, MINUTES) + public ResponseEntity isHighLevelCategoryLeaderBoardAvailable(@Parameter(name = "eventId", description = "ID of the event", required = true) + @PathVariable("eventId") String eventId, + @RequestHeader(value = XForceLeaderBoardResults, required = false, defaultValue = "false") boolean forceLeaderboardResults) { + var cacheControl = CacheControl.maxAge(5, MINUTES) .noTransform() .mustRevalidate(); var forceLeaderboard = forceLeaderboardResults && forceLeaderboardResultsAvailability; - var availableE = leaderBoardService.isHighLevelCategoryLeaderboardAvailable(eventId, forceLeaderboard); + var availableE = highLevelLeaderBoardService.isHighLevelCategoryLeaderboardAvailable(eventId, forceLeaderboard); return availableE.fold(problem -> { return ResponseEntity @@ -153,25 +158,29 @@ public ResponseEntity isHighLevelCategoryLeaderBoardAvailable( @ApiResponse(responseCode = "500", description = "Internal server error") } ) - public ResponseEntity getCategoryLeaderBoardAvailable( - @Parameter(name = "eventId", description = "ID of the event", required = true) - @PathVariable("eventId") String eventId, - @Parameter(name = "categoryId", description = "ID of the category", required = true) - @PathVariable("categoryId") String categoryId, - @RequestHeader(value = XForceLeaderBoardResults, required = false, defaultValue = "false") boolean forceLeaderboardResults) { - var cacheControl = CacheControl.maxAge(1, MINUTES) + public ResponseEntity getCategoryLeaderBoardAvailable(@Parameter(name = "eventId", description = "ID of the event", required = true) + @PathVariable("eventId") String eventId, + @Parameter(name = "categoryId", description = "ID of the category", required = true) + @PathVariable("categoryId") String categoryId, + @RequestHeader(value = XForceLeaderBoardResults, required = false, defaultValue = "false") boolean forceLeaderboardResults, + @Parameter(name = "source", description = "source of results, db or l1") + @Valid @RequestParam(name = "source") Optional winnerLeaderboardSourceM) { + var winnerLeaderboardSource = winnerLeaderboardSourceM.orElse(db); + + var cacheControl = CacheControl.maxAge(5, MINUTES) .noTransform() .mustRevalidate(); var forceLeaderboard = forceLeaderboardResults && forceLeaderboardResultsAvailability; - var categoryLeaderboardAvailableE = leaderBoardService.isCategoryLeaderboardAvailable(eventId, categoryId, forceLeaderboard); + var categoryLeaderboardAvailableE = leaderboardWinnersProvider + .getWinnerLeaderboardSource(winnerLeaderboardSource) + .isCategoryLeaderboardAvailable(eventId, categoryId, forceLeaderboard); return categoryLeaderboardAvailableE .fold(problem -> { return ResponseEntity .status(problem.getStatus().getStatusCode()) - .cacheControl(cacheControl) .body(problem); }, isAvailable -> { @@ -208,21 +217,20 @@ public ResponseEntity getCategoryLeaderBoardAvailable( } ) public ResponseEntity getEventLeaderBoard( - @Parameter(name = "eventId", description = "ID of the event", required = true) - @PathVariable("eventId") String eventId, - @RequestHeader(value = XForceLeaderBoardResults, required = false, defaultValue = "false") boolean forceLeaderboardResults) { - var cacheControl = CacheControl.maxAge(1, MINUTES) + @Parameter(name = "eventId", description = "ID of the event", required = true) + @PathVariable("eventId") String eventId, + @RequestHeader(value = XForceLeaderBoardResults, required = false, defaultValue = "false") boolean forceLeaderboardResults) { + var cacheControl = CacheControl.maxAge(5, MINUTES) .noTransform() .mustRevalidate(); var forceLeaderboard = forceLeaderboardResults && forceLeaderboardResultsAvailability; - var eventLeaderboardE = leaderBoardService.getEventLeaderboard(eventId, forceLeaderboard); + var eventLeaderboardE = highLevelLeaderBoardService.getEventLeaderboard(eventId, forceLeaderboard); return eventLeaderboardE.fold(problem -> { return ResponseEntity .status(problem.getStatus().getStatusCode()) - .cacheControl(cacheControl) .body(problem); }, response -> { @@ -246,72 +254,48 @@ public ResponseEntity getEventLeaderBoard( @ApiResponse(responseCode = "500", description = "Internal server error or other issues.") } ) - public ResponseEntity getCategoryLeaderBoard( - @Parameter(name = "eventId", description = "ID of the event", required = true) - @PathVariable("eventId") String eventId, - @Parameter(name = "categoryId", description = "ID of the category", required = true) - @PathVariable("categoryId") String categoryId, - @RequestHeader(value = XForceLeaderBoardResults, required = false, defaultValue = "false") boolean forceLeaderboardResults) { - var cacheControl = CacheControl.maxAge(1, MINUTES) + public ResponseEntity getCategoryLeaderBoard(@Parameter(name = "eventId", description = "ID of the event", required = true) + @PathVariable("eventId") String eventId, + @Parameter(name = "categoryId", description = "ID of the category", required = true) + @PathVariable("categoryId") String categoryId, + @RequestHeader(value = XForceLeaderBoardResults, required = false, defaultValue = "false") boolean forceLeaderboardResults, + @Parameter(name = "source", description = "source of results, db or l1") + @Valid @RequestParam(name = "source") Optional winnerLeaderboardSourceM) { + var winnerLeaderboardSource = winnerLeaderboardSourceM.orElse(db); + + var cacheControl = CacheControl.maxAge(5, MINUTES) .noTransform() .mustRevalidate(); var forceLeaderboard = forceLeaderboardResults && forceLeaderboardResultsAvailability; - var categoryLeaderboardE = leaderBoardService.getCategoryLeaderboard(eventId, categoryId, forceLeaderboard); + var categoryLeaderboardE = leaderboardWinnersProvider + .getWinnerLeaderboardSource(winnerLeaderboardSource) + .getCategoryLeaderboard(eventId, categoryId, forceLeaderboard); return categoryLeaderboardE .fold(problem -> { return ResponseEntity .status(problem.getStatus().getStatusCode()) - .cacheControl(cacheControl) .body(problem); }, - response -> { - return ResponseEntity - .ok() - .cacheControl(cacheControl) - .body(response); - } - ); - } - - @RequestMapping(value = "/{eventId}/winners", method = GET, produces = "application/json") - @Timed(value = "resource.leaderboard.category.winners", histogram = true) - @Operation( - summary = "Retrieve Winners for an Event", - description = "Fetches the winners for a specified event.", - responses = { - @ApiResponse(responseCode = "200", description = "Successfully retrieved winners for the event."), - @ApiResponse(responseCode = "400", description = "Bad request, incorrect event ID."), - @ApiResponse(responseCode = "403", description = "Winners not yet available for the event."), - @ApiResponse(responseCode = "500", description = "Internal server error or other issues.") - } - ) - public ResponseEntity getWinners( - @Parameter(name = "eventId", description = "ID of the event", required = true) - @PathVariable("eventId") String eventId, - @RequestHeader(value = XForceLeaderBoardResults, required = false, defaultValue = "false") boolean forceLeaderboardResults) { - var cacheControl = CacheControl.maxAge(1, MINUTES) - .noTransform() - .mustRevalidate(); - - var forceLeaderboard = forceLeaderboardResults && forceLeaderboardResultsAvailability; + proposalsInCategoryStatsM -> { + if (proposalsInCategoryStatsM.isEmpty()) { + var problem = Problem.builder() + .withTitle("VOTING_RESULTS_NOT_YET_AVAILABLE") + .withDetail("Leaderboard not yet available for event: " + eventId) + .withStatus(NOT_FOUND) + .build(); - var categoryLeaderboardE = leaderBoardService.getEventWinners(eventId, forceLeaderboard); + return ResponseEntity + .status(problem.getStatus().getStatusCode()) + .body(problem); + } - return categoryLeaderboardE - .fold(problem -> { - return ResponseEntity - .status(problem.getStatus().getStatusCode()) - .cacheControl(cacheControl) - .body(problem); - }, - response -> { return ResponseEntity .ok() .cacheControl(cacheControl) - .body(response); + .body(proposalsInCategoryStatsM.orElseThrow()); } ); } diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/AbstractWinnersService.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/AbstractWinnersService.java new file mode 100644 index 000000000..b98cd49df --- /dev/null +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/AbstractWinnersService.java @@ -0,0 +1,94 @@ +package org.cardano.foundation.voting.service.leader_board; + +import io.vavr.control.Either; +import org.cardano.foundation.voting.client.ChainFollowerClient; +import org.cardano.foundation.voting.domain.Leaderboard; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import org.zalando.problem.Problem; + +import java.util.HashMap; +import java.util.Map; + +import static org.zalando.problem.Status.BAD_REQUEST; +import static org.zalando.problem.Status.INTERNAL_SERVER_ERROR; + +public class AbstractWinnersService { + + @Autowired + protected ChainFollowerClient chainFollowerClient; + + @Transactional(readOnly = true) + public Either isCategoryLeaderboardAvailable(String event, String category, boolean forceLeaderboard) { + var eventDetailsE = chainFollowerClient.getEventDetails(event); + if (eventDetailsE.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("ERROR_GETTING_EVENT_DETAILS") + .withDetail("Unable to get event details from chain-tip follower service, event:" + event) + .withStatus(INTERNAL_SERVER_ERROR) + .build() + ); + } + var maybeEventDetails = eventDetailsE.get(); + if (maybeEventDetails.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_EVENT") + .withDetail("Unrecognised event, event:" + event) + .withStatus(BAD_REQUEST) + .build() + ); + } + var eventDetails = maybeEventDetails.orElseThrow(); + + var maybeCategory = eventDetails.categoryDetailsById(category); + + if (maybeCategory.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_CATEGORY") + .withDetail("Unrecognised category, category:" + category) + .withStatus(BAD_REQUEST) + .build() + ); + } + + return isCategoryLeaderboardAvailable(eventDetails, forceLeaderboard); + } + + protected Either isCategoryLeaderboardAvailable(ChainFollowerClient.EventDetailsResponse eventDetails, + boolean forceLeaderboard) { + if (forceLeaderboard) { + return Either.right(true); + } + + if (eventDetails.categoryResultsWhileVoting()) { + return Either.right(true); + } + + return Either.right(eventDetails.proposalsReveal()); + } + + protected static Map reInitialiseResultsToEmptyIfMissing(ChainFollowerClient.CategoryDetailsResponse categoryDetails, + Map proposalResultsMap, + ChainFollowerClient.EventDetailsResponse eventDetails) { + var categoryProposals = categoryDetails.proposals(); + + var proposalResultsMapCopy = new HashMap<>(proposalResultsMap); + + categoryProposals.forEach(proposalDetails -> { + if (!proposalResultsMap.containsKey(proposalDetails.id())) { + var b = Leaderboard.Votes.builder(); + + b.votes(0L); + + switch (eventDetails.votingEventType()) { + case BALANCE_BASED, STAKE_BASED -> b.votingPower("0"); + } + + proposalResultsMapCopy.put(proposalDetails.id(), b.build()); + } + }); + + return proposalResultsMapCopy; + } + +} diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/DBHighLevelLeaderBoardService.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/DBHighLevelLeaderBoardService.java new file mode 100644 index 000000000..2cb06acbc --- /dev/null +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/DBHighLevelLeaderBoardService.java @@ -0,0 +1,234 @@ +package org.cardano.foundation.voting.service.leader_board; + +import com.google.common.collect.Iterables; +import io.vavr.control.Either; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.cardano.foundation.voting.client.ChainFollowerClient; +import org.cardano.foundation.voting.domain.Leaderboard; +import org.cardano.foundation.voting.repository.VoteRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.zalando.problem.Problem; + +import java.util.ArrayList; +import java.util.Optional; + +import static java.util.stream.Collectors.toMap; +import static org.zalando.problem.Status.*; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DBHighLevelLeaderBoardService implements HighLevelLeaderBoardService { + + private final ChainFollowerClient chainFollowerClient; + + private final VoteRepository voteRepository; + + @Override + @Transactional(readOnly = true) + public Either isHighLevelEventLeaderboardAvailable(String event, + boolean forceLeaderboard) { + var eventDetailsE = chainFollowerClient.getEventDetails(event); + if (eventDetailsE.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("ERROR_GETTING_EVENT_DETAILS") + .withDetail("Unable to get event details from chain-tip follower service, event:" + event) + .withStatus(INTERNAL_SERVER_ERROR) + .build() + ); + } + var maybeEventDetails = eventDetailsE.get(); + if (maybeEventDetails.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_EVENT") + .withDetail("Unrecognised event, event:" + event) + .withStatus(BAD_REQUEST) + .build() + ); + } + var eventDetails = maybeEventDetails.orElseThrow(); + + return isHighLevelEventLeaderboardAvailable(eventDetails, forceLeaderboard); + } + + @Override + @Transactional(readOnly = true) + public Either isHighLevelCategoryLeaderboardAvailable(String event, boolean forceLeaderboard) { + var eventDetailsE = chainFollowerClient.getEventDetails(event); + if (eventDetailsE.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("ERROR_GETTING_EVENT_DETAILS") + .withDetail("Unable to get event details from chain-tip follower service, event:" + event) + .withStatus(INTERNAL_SERVER_ERROR) + .build() + ); + } + var maybeEventDetails = eventDetailsE.get(); + if (maybeEventDetails.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_EVENT") + .withDetail("Unrecognised event, event:" + event) + .withStatus(BAD_REQUEST) + .build() + ); + } + var eventDetails = maybeEventDetails.orElseThrow(); + + return isHighLevelCategoryLeaderboardAvailable(eventDetails, forceLeaderboard); + } + + @Override + @Transactional(readOnly = true) + public Either getEventLeaderboard(String event, boolean forceLeaderboard) { + var eventDetailsE = chainFollowerClient.getEventDetails(event); + if (eventDetailsE.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("ERROR_GETTING_EVENT_DETAILS") + .withDetail("Unable to get event details from chain-tip follower service, event:" + event) + .withStatus(INTERNAL_SERVER_ERROR) + .build() + ); + } + var maybeEventDetails = eventDetailsE.get(); + if (maybeEventDetails.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_EVENT") + .withDetail("Unrecognised event, event:" + event) + .withStatus(BAD_REQUEST) + .build() + ); + } + var eventDetails = maybeEventDetails.orElseThrow(); + + var highLevelEventLeaderboardAvailableE = isHighLevelEventLeaderboardAvailable(eventDetails, forceLeaderboard); + if (highLevelEventLeaderboardAvailableE.isEmpty()) { + return Either.left(highLevelEventLeaderboardAvailableE.getLeft()); + } + var isHighLevelEventLeaderBoardAvailable = highLevelEventLeaderboardAvailableE.get(); + + if (!isHighLevelEventLeaderBoardAvailable) { + return Either.left(Problem.builder() + .withTitle("VOTING_RESULTS_NOT_AVAILABLE") + .withDetail("Event level voting results not available until voting event finishes!") + .withStatus(FORBIDDEN) + .build() + ); + } + + var votes = voteRepository.getHighLevelEventStats(event); + if (votes.isEmpty()) { + Leaderboard.ByEventStats.ByEventStatsBuilder byEventStatsBuilder = Leaderboard.ByEventStats.builder() + .event(eventDetails.id()) + .totalVotesCount(0L); + + switch (eventDetails.votingEventType()) { + case BALANCE_BASED, STAKE_BASED -> byEventStatsBuilder.totalVotingPower("0"); + } + + return Either.right(byEventStatsBuilder + .build()); + } + + var eventVoteCount = Iterables.getOnlyElement(votes); + var voteCount = eventVoteCount.getTotalVoteCount(); + + var byEventStatsBuilder = Leaderboard.ByEventStats.builder() + .event(eventDetails.id()) + .totalVotesCount(Optional.ofNullable(voteCount).orElse(0L)); + + var votingPower = eventVoteCount.getTotalVotingPower(); + + switch (eventDetails.votingEventType()) { + case BALANCE_BASED, STAKE_BASED -> + byEventStatsBuilder.totalVotingPower(Optional.ofNullable(votingPower).map(String::valueOf).orElse("0")); + } + + var eventLeaderboardAvailableE = isHighLevelCategoryLeaderboardAvailable(eventDetails, forceLeaderboard); + if (eventLeaderboardAvailableE.isEmpty()) { + return Either.left(eventLeaderboardAvailableE.getLeft()); + } + var isEventLeaderBoardAvailable = eventLeaderboardAvailableE.get(); + + if (!isEventLeaderBoardAvailable) { + return Either.right(byEventStatsBuilder.build()); + } + + var allHighLevelCategoryStats = voteRepository.getHighLevelCategoryLevelStats(event); + + var byCategoryStats = allHighLevelCategoryStats.stream() + .map(categoryLevelStats -> { + var byCategoryStatsBuilder = Leaderboard.ByCategoryStats.builder(); + + byCategoryStatsBuilder.id(categoryLevelStats.getCategoryId()); + byCategoryStatsBuilder.votes(Optional.ofNullable(categoryLevelStats.getTotalVoteCount()).orElse(0L)); + + switch (eventDetails.votingEventType()) { + case BALANCE_BASED, STAKE_BASED -> { + var votingPowerM = Optional.ofNullable(categoryLevelStats.getTotalVotingPower()) + .map(String::valueOf) + .orElse("0"); + + byCategoryStatsBuilder.votingPower(votingPowerM); + } + } + + return byCategoryStatsBuilder.build(); + }) + .toList(); + + var byCategoryStatsMap = byCategoryStats.stream() + .collect(toMap(Leaderboard.ByCategoryStats::getId, stats -> stats)); + + var byCategoryStatsCopy = new ArrayList<>(byCategoryStats); + // pre init with empty if category not returned from db + + eventDetails.categories().forEach(categoryDetails -> { + if (!byCategoryStatsMap.containsKey(categoryDetails.id())) { + var b = Leaderboard.ByCategoryStats.builder(); + b.id(categoryDetails.id()); + + b.votes(0L); + + switch (eventDetails.votingEventType()) { + case BALANCE_BASED, STAKE_BASED -> b.votingPower("0"); + } + + byCategoryStatsCopy.add(b.build()); + } + }); + + byEventStatsBuilder.categories(byCategoryStatsCopy); + + return Either.right(byEventStatsBuilder.build()); + } + + + private Either isHighLevelEventLeaderboardAvailable(ChainFollowerClient.EventDetailsResponse eventDetails, + boolean forceLeaderboard) { + if (forceLeaderboard) { + return Either.right(true); + } + + if (eventDetails.highLevelEventResultsWhileVoting()) { + return Either.right(true); + } + + return Either.right(eventDetails.proposalsReveal()); + } + + private Either isHighLevelCategoryLeaderboardAvailable(ChainFollowerClient.EventDetailsResponse eventDetails, + boolean forceLeaderboard) { + if (forceLeaderboard) { + return Either.right(true); + } + + if (eventDetails.highLevelCategoryResultsWhileVoting()) { + return Either.right(true); + } + + return Either.right(eventDetails.proposalsReveal()); + } + +} diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/DBLeaderboardWinnersService.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/DBLeaderboardWinnersService.java new file mode 100644 index 000000000..fe164fb04 --- /dev/null +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/DBLeaderboardWinnersService.java @@ -0,0 +1,98 @@ +package org.cardano.foundation.voting.service.leader_board; + +import io.vavr.control.Either; +import org.cardano.foundation.voting.domain.Leaderboard; +import org.cardano.foundation.voting.repository.VoteRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.zalando.problem.Problem; + +import java.util.Optional; + +import static java.util.stream.Collectors.toMap; +import static org.zalando.problem.Status.*; + +@Service +@Qualifier("db_leaderboard_winners_service") +public class DBLeaderboardWinnersService extends AbstractWinnersService implements LeaderboardWinnersService { + + @Autowired + private VoteRepository voteRepository; + + @Override + @Transactional(readOnly = true) + public Either> getCategoryLeaderboard(String event, String category, boolean forceLeaderboard) { + var eventDetailsE = chainFollowerClient.getEventDetails(event); + if (eventDetailsE.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("ERROR_GETTING_EVENT_DETAILS") + .withDetail("Unable to get event details from chain-tip follower service, event:" + event) + .withStatus(INTERNAL_SERVER_ERROR) + .build() + ); + } + var maybeEventDetails = eventDetailsE.get(); + if (maybeEventDetails.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_EVENT") + .withDetail("Unrecognised event, event:" + event) + .withStatus(BAD_REQUEST) + .build() + ); + } + var eventDetails = maybeEventDetails.orElseThrow(); + + var maybeCategory = eventDetails.categoryDetailsById(category); + + if (maybeCategory.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_CATEGORY") + .withDetail("Unrecognised category, category:" + category) + .withStatus(BAD_REQUEST) + .build() + ); + } + var categoryDetails = maybeCategory.orElseThrow(); + + var categoryLeaderboardAvailableE = isCategoryLeaderboardAvailable(eventDetails, forceLeaderboard); + if (categoryLeaderboardAvailableE.isEmpty()) { + return Either.left(categoryLeaderboardAvailableE.getLeft()); + } + + var isCategoryLeaderBoardAvailable = categoryLeaderboardAvailableE.get(); + if (!isCategoryLeaderBoardAvailable) { + return Either.left(Problem.builder() + .withTitle("VOTING_RESULTS_NOT_AVAILABLE") + .withDetail("Category level voting results not available until results can be revealed!") + .withStatus(FORBIDDEN) + .build() + ); + } + + var votes = voteRepository.getCategoryLevelStats(event, categoryDetails.id()); + + var proposalResultsMap = votes.stream() + .collect(toMap(VoteRepository.CategoryLevelStats::getProposalId, v -> { + var totalVotesCount = Optional.ofNullable(v.getTotalVoteCount()).orElse(0L); + var totalVotingPower = Optional.ofNullable(v.getTotalVotingPower()).map(String::valueOf).orElse("0"); + + var b = Leaderboard.Votes.builder(); + b.votes(totalVotesCount); + + switch (eventDetails.votingEventType()) { + case BALANCE_BASED, STAKE_BASED -> b.votingPower(totalVotingPower); + } + + return b.build(); + })); + + return Either.right(Optional.of(Leaderboard.ByProposalsInCategoryStats.builder() + .category(categoryDetails.id()) + .proposals(reInitialiseResultsToEmptyIfMissing(categoryDetails, proposalResultsMap, eventDetails)) + .build()) + ); + } + +} diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/DefaultLeaderBoardService.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/DefaultLeaderBoardService.java deleted file mode 100644 index 06bb9d941..000000000 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/DefaultLeaderBoardService.java +++ /dev/null @@ -1,439 +0,0 @@ -package org.cardano.foundation.voting.service.leader_board; - -import com.google.common.collect.Iterables; -import io.vavr.control.Either; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.cardano.foundation.voting.client.ChainFollowerClient; -import org.cardano.foundation.voting.client.ChainFollowerClient.CategoryDetailsResponse; -import org.cardano.foundation.voting.client.ChainFollowerClient.EventDetailsResponse; -import org.cardano.foundation.voting.domain.Leaderboard; -import org.cardano.foundation.voting.repository.CustomVoteRepository; -import org.cardano.foundation.voting.repository.VoteRepository; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.zalando.problem.Problem; - -import java.util.*; - -import static java.util.stream.Collectors.toMap; -import static org.springframework.transaction.annotation.Isolation.SERIALIZABLE; -import static org.zalando.problem.Status.*; - -@Service -@Slf4j -@RequiredArgsConstructor -public class DefaultLeaderBoardService implements LeaderBoardService { - - private final ChainFollowerClient chainFollowerClient; - - private final VoteRepository voteRepository; - - private final CustomVoteRepository customVoteRepository; - - @Override - @Transactional(readOnly = true) - public Either isHighLevelEventLeaderboardAvailable(String event, - boolean forceLeaderboard) { - var eventDetailsE = chainFollowerClient.getEventDetails(event); - if (eventDetailsE.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("ERROR_GETTING_EVENT_DETAILS") - .withDetail("Unable to get event details from chain-tip follower service, event:" + event) - .withStatus(INTERNAL_SERVER_ERROR) - .build() - ); - } - var maybeEventDetails = eventDetailsE.get(); - if (maybeEventDetails.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("UNRECOGNISED_EVENT") - .withDetail("Unrecognised event, event:" + event) - .withStatus(BAD_REQUEST) - .build() - ); - } - var eventDetails = maybeEventDetails.orElseThrow(); - - return isHighLevelEventLeaderboardAvailable(eventDetails, forceLeaderboard); - } - - @Override - @Transactional(readOnly = true) - public Either isHighLevelCategoryLeaderboardAvailable(String event, - boolean forceLeaderboard) { - var eventDetailsE = chainFollowerClient.getEventDetails(event); - if (eventDetailsE.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("ERROR_GETTING_EVENT_DETAILS") - .withDetail("Unable to get event details from chain-tip follower service, event:" + event) - .withStatus(INTERNAL_SERVER_ERROR) - .build() - ); - } - var maybeEventDetails = eventDetailsE.get(); - if (maybeEventDetails.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("UNRECOGNISED_EVENT") - .withDetail("Unrecognised event, event:" + event) - .withStatus(BAD_REQUEST) - .build() - ); - } - var eventDetails = maybeEventDetails.orElseThrow(); - - return isHighLevelCategoryLeaderboardAvailable(eventDetails, forceLeaderboard); - } - - @Override - @Transactional(readOnly = true) - public Either getEventLeaderboard(String event, - boolean forceLeaderboard) { - var eventDetailsE = chainFollowerClient.getEventDetails(event); - if (eventDetailsE.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("ERROR_GETTING_EVENT_DETAILS") - .withDetail("Unable to get event details from chain-tip follower service, event:" + event) - .withStatus(INTERNAL_SERVER_ERROR) - .build() - ); - } - var maybeEventDetails = eventDetailsE.get(); - if (maybeEventDetails.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("UNRECOGNISED_EVENT") - .withDetail("Unrecognised event, event:" + event) - .withStatus(BAD_REQUEST) - .build() - ); - } - var eventDetails = maybeEventDetails.orElseThrow(); - - var highLevelEventLeaderboardAvailableE = isHighLevelEventLeaderboardAvailable(eventDetails, forceLeaderboard); - if (highLevelEventLeaderboardAvailableE.isEmpty()) { - return Either.left(highLevelEventLeaderboardAvailableE.getLeft()); - } - var isHighLevelEventLeaderBoardAvailable = highLevelEventLeaderboardAvailableE.get(); - - if (!isHighLevelEventLeaderBoardAvailable) { - return Either.left(Problem.builder() - .withTitle("VOTING_RESULTS_NOT_AVAILABLE") - .withDetail("Event level voting results not available until voting event finishes!") - .withStatus(FORBIDDEN) - .build() - ); - } - - var votes = voteRepository.getHighLevelEventStats(event); - if (votes.isEmpty()) { - Leaderboard.ByEventStats.ByEventStatsBuilder byEventStatsBuilder = Leaderboard.ByEventStats.builder() - .event(eventDetails.id()) - .totalVotesCount(0L); - - switch (eventDetails.votingEventType()) { - case BALANCE_BASED, STAKE_BASED -> byEventStatsBuilder.totalVotingPower("0"); - } - - return Either.right(byEventStatsBuilder - .build()); - } - - var eventVoteCount = Iterables.getOnlyElement(votes); - var voteCount = eventVoteCount.getTotalVoteCount(); - - var byEventStatsBuilder = Leaderboard.ByEventStats.builder() - .event(eventDetails.id()) - .totalVotesCount(Optional.ofNullable(voteCount).orElse(0L)); - - var votingPower = eventVoteCount.getTotalVotingPower(); - - switch (eventDetails.votingEventType()) { - case BALANCE_BASED, STAKE_BASED -> - byEventStatsBuilder.totalVotingPower(Optional.ofNullable(votingPower).map(String::valueOf).orElse("0")); - } - - var eventLeaderboardAvailableE = isHighLevelCategoryLeaderboardAvailable(eventDetails, forceLeaderboard); - if (eventLeaderboardAvailableE.isEmpty()) { - return Either.left(eventLeaderboardAvailableE.getLeft()); - } - var isEventLeaderBoardAvailable = eventLeaderboardAvailableE.get(); - - if (!isEventLeaderBoardAvailable) { - return Either.right(byEventStatsBuilder.build()); - } - - var allHighLevelCategoryStats = voteRepository.getHighLevelCategoryLevelStats(event); - - var byCategoryStats = allHighLevelCategoryStats.stream() - .map(categoryLevelStats -> { - var byCategoryStatsBuilder = Leaderboard.ByCategoryStats.builder(); - - byCategoryStatsBuilder.id(categoryLevelStats.getCategoryId()); - byCategoryStatsBuilder.votes(Optional.ofNullable(categoryLevelStats.getTotalVoteCount()).orElse(0L)); - - switch (eventDetails.votingEventType()) { - case BALANCE_BASED, STAKE_BASED -> { - var votingPowerM = Optional.ofNullable(categoryLevelStats.getTotalVotingPower()) - .map(String::valueOf) - .orElse("0"); - - byCategoryStatsBuilder.votingPower(votingPowerM); - } - } - - return byCategoryStatsBuilder.build(); - }) - .toList(); - - var byCategoryStatsMap = byCategoryStats.stream() - .collect(toMap(Leaderboard.ByCategoryStats::getId, stats -> stats)); - - var byCategoryStatsCopy = new ArrayList<>(byCategoryStats); - // pre init with empty if category not returned from db - - eventDetails.categories().forEach(categoryDetails -> { - if (!byCategoryStatsMap.containsKey(categoryDetails.id())) { - var b = Leaderboard.ByCategoryStats.builder(); - b.id(categoryDetails.id()); - - b.votes(0L); - - switch (eventDetails.votingEventType()) { - case BALANCE_BASED, STAKE_BASED -> b.votingPower("0"); - } - - byCategoryStatsCopy.add(b.build()); - } - }); - - byEventStatsBuilder.categories(byCategoryStatsCopy); - - return Either.right(byEventStatsBuilder.build()); - } - - @Override - @Transactional(readOnly = true) - public Either getCategoryLeaderboard(String event, - String category, - boolean forceLeaderboard) { - var eventDetailsE = chainFollowerClient.getEventDetails(event); - if (eventDetailsE.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("ERROR_GETTING_EVENT_DETAILS") - .withDetail("Unable to get event details from chain-tip follower service, event:" + event) - .withStatus(INTERNAL_SERVER_ERROR) - .build() - ); - } - var maybeEventDetails = eventDetailsE.get(); - if (maybeEventDetails.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("UNRECOGNISED_EVENT") - .withDetail("Unrecognised event, event:" + event) - .withStatus(BAD_REQUEST) - .build() - ); - } - var eventDetails = maybeEventDetails.orElseThrow(); - - var maybeCategory = eventDetails.categoryDetailsById(category); - - if (maybeCategory.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("UNRECOGNISED_CATEGORY") - .withDetail("Unrecognised category, category:" + category) - .withStatus(BAD_REQUEST) - .build() - ); - } - var categoryDetails = maybeCategory.orElseThrow(); - - var categoryLeaderboardAvailableE = isCategoryLeaderboardAvailable(eventDetails, forceLeaderboard); - if (categoryLeaderboardAvailableE.isEmpty()) { - return Either.left(categoryLeaderboardAvailableE.getLeft()); - } - - var isCategoryLeaderBoardAvailable = categoryLeaderboardAvailableE.get(); - if (!isCategoryLeaderBoardAvailable) { - return Either.left(Problem.builder() - .withTitle("VOTING_RESULTS_NOT_AVAILABLE") - .withDetail("Category level voting results not available until results can be revealed!") - .withStatus(FORBIDDEN) - .build() - ); - } - - var votes = voteRepository.getCategoryLevelStats(event, categoryDetails.id()); - - Map proposalResultsMap = votes.stream() - .collect(toMap(VoteRepository.CategoryLevelStats::getProposalId, v -> { - var totalVotesCount = Optional.ofNullable(v.getTotalVoteCount()).orElse(0L); - var totalVotingPower = Optional.ofNullable(v.getTotalVotingPower()).map(String::valueOf).orElse("0"); - - var b = Leaderboard.Votes.builder(); - b.votes(totalVotesCount); - - switch (eventDetails.votingEventType()) { - case BALANCE_BASED, STAKE_BASED -> b.votingPower(totalVotingPower); - } - - return b.build(); - })); - - var proposalResults = calcProposalsResults(categoryDetails, proposalResultsMap, eventDetails); - - return Either.right(Leaderboard.ByProposalsInCategoryStats.builder() - .category(categoryDetails.id()) - .proposals(proposalResults) - .build() - ); - } - - @Override - @Transactional(readOnly = true, isolation = SERIALIZABLE) - public Either> getEventWinners(String event, - boolean forceLeaderboard) { - var eventDetailsE = chainFollowerClient.getEventDetails(event); - - if (eventDetailsE.isEmpty()) { - return Either.left(eventDetailsE.getLeft()); - } - - var maybeEventDetails = eventDetailsE.get(); - - if (maybeEventDetails.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("UNRECOGNISED_EVENT") - .withDetail("Unrecognised event, event:" + event) - .withStatus(BAD_REQUEST) - .build() - ); - } - - var eventDetails = maybeEventDetails.orElseThrow(); - - var categoryLeaderboardAvailableE = isCategoryLeaderboardAvailable(eventDetails, forceLeaderboard); - if (categoryLeaderboardAvailableE.isEmpty()) { - return Either.left(categoryLeaderboardAvailableE.getLeft()); - } - - var isCategoryLeaderBoardAvailable = categoryLeaderboardAvailableE.get(); - if (!isCategoryLeaderBoardAvailable) { - return Either.left(Problem.builder() - .withTitle("VOTING_RESULTS_NOT_AVAILABLE") - .withDetail("Category level voting results not available until results can be revealed!") - .withStatus(FORBIDDEN) - .build() - ); - } - - var categoryIds = eventDetails.categories() - .stream() - .map(CategoryDetailsResponse::id) - .toList(); - - return Either.right(customVoteRepository.getEventWinners(event, categoryIds)); - } - - private static HashMap calcProposalsResults(CategoryDetailsResponse categoryDetails, - Map proposalResultsMap, - EventDetailsResponse eventDetails) { - var categoryProposals = categoryDetails.proposals(); - - var proposalResultsMapCopy = new HashMap<>(proposalResultsMap); - - categoryProposals.forEach(proposalDetails -> { - if (!proposalResultsMap.containsKey(proposalDetails.id())) { - var b = Leaderboard.Votes.builder(); - - b.votes(0L); - - switch (eventDetails.votingEventType()) { - case BALANCE_BASED, STAKE_BASED -> b.votingPower("0"); - } - - proposalResultsMapCopy.put(proposalDetails.id(), b.build()); - } - }); - return proposalResultsMapCopy; - } - - @Override - @Transactional(readOnly = true) - public Either isCategoryLeaderboardAvailable(String event, - String category, - boolean forceLeaderboard) { - var eventDetailsE = chainFollowerClient.getEventDetails(event); - if (eventDetailsE.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("ERROR_GETTING_EVENT_DETAILS") - .withDetail("Unable to get event details from chain-tip follower service, event:" + event) - .withStatus(INTERNAL_SERVER_ERROR) - .build() - ); - } - var maybeEventDetails = eventDetailsE.get(); - if (maybeEventDetails.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("UNRECOGNISED_EVENT") - .withDetail("Unrecognised event, event:" + event) - .withStatus(BAD_REQUEST) - .build() - ); - } - var eventDetails = maybeEventDetails.orElseThrow(); - - var maybeCategory = eventDetails.categoryDetailsById(category); - - if (maybeCategory.isEmpty()) { - return Either.left(Problem.builder() - .withTitle("UNRECOGNISED_CATEGORY") - .withDetail("Unrecognised category, category:" + category) - .withStatus(BAD_REQUEST) - .build() - ); - } - - return isCategoryLeaderboardAvailable(eventDetails, forceLeaderboard); - } - - private Either isHighLevelEventLeaderboardAvailable(EventDetailsResponse eventDetails, - boolean forceLeaderboard) { - if (forceLeaderboard) { - return Either.right(true); - } - - if (eventDetails.highLevelEventResultsWhileVoting()) { - return Either.right(true); - } - - return Either.right(eventDetails.proposalsReveal()); - } - - private Either isHighLevelCategoryLeaderboardAvailable(EventDetailsResponse eventDetails, - boolean forceLeaderboard) { - if (forceLeaderboard) { - return Either.right(true); - } - - if (eventDetails.highLevelCategoryResultsWhileVoting()) { - return Either.right(true); - } - - return Either.right(eventDetails.proposalsReveal()); - } - - private Either isCategoryLeaderboardAvailable(EventDetailsResponse eventDetails, - boolean forceLeaderboard) { - if (forceLeaderboard) { - return Either.right(true); - } - - if (eventDetails.categoryResultsWhileVoting()) { - return Either.right(true); - } - - return Either.right(eventDetails.proposalsReveal()); - } - -} diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/LeaderBoardService.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/HighLevelLeaderBoardService.java similarity index 54% rename from backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/LeaderBoardService.java rename to backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/HighLevelLeaderBoardService.java index 01fe8f66f..ee558e9b4 100644 --- a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/LeaderBoardService.java +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/HighLevelLeaderBoardService.java @@ -4,20 +4,12 @@ import org.cardano.foundation.voting.domain.Leaderboard; import org.zalando.problem.Problem; -import java.util.List; - -public interface LeaderBoardService { +public interface HighLevelLeaderBoardService { Either isHighLevelEventLeaderboardAvailable(String event, boolean forceLeaderboard); Either isHighLevelCategoryLeaderboardAvailable(String event, boolean forceLeaderboard); - Either isCategoryLeaderboardAvailable(String event, String category, boolean forceLeaderboard); - Either getEventLeaderboard(String event, boolean forceLeaderboard); - Either getCategoryLeaderboard(String event, String category, boolean forceLeaderboard); - - Either> getEventWinners(String event, boolean forceLeaderboard); - } diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/L1LeaderboardWinnersService.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/L1LeaderboardWinnersService.java new file mode 100644 index 000000000..05e040c63 --- /dev/null +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/L1LeaderboardWinnersService.java @@ -0,0 +1,152 @@ +package org.cardano.foundation.voting.service.leader_board; + +import io.vavr.control.Either; +import org.cardano.foundation.voting.client.ChainFollowerClient; +import org.cardano.foundation.voting.domain.Leaderboard; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.zalando.problem.Problem; + +import java.util.Map; +import java.util.Optional; + +import static java.util.stream.Collectors.toMap; +import static org.cardano.foundation.voting.domain.TallyType.HYDRA; +import static org.zalando.problem.Status.*; + +@Service +@Qualifier("l1_leaderboard_winners_service") +public class L1LeaderboardWinnersService extends AbstractWinnersService implements LeaderboardWinnersService { + + @Override + public Either> getCategoryLeaderboard(String event, + String category, + boolean forceLeaderboard) { + var eventDetailsE = chainFollowerClient.getEventDetails(event); + if (eventDetailsE.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("ERROR_GETTING_EVENT_DETAILS") + .withDetail("Unable to get event details from chain-tip follower service, event:" + event) + .withStatus(INTERNAL_SERVER_ERROR) + .build() + ); + } + var eventDetailsResponseM = eventDetailsE.get(); + if (eventDetailsResponseM.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_EVENT") + .withDetail("Unrecognised event, event:" + event) + .withStatus(BAD_REQUEST) + .build() + ); + } + var eventDetails = eventDetailsResponseM.orElseThrow(); + + var categoryM = eventDetails.categoryDetailsById(category); + if (categoryM.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_CATEGORY") + .withDetail("Unrecognised category, category:" + category) + .withStatus(BAD_REQUEST) + .build() + ); + } + var categoryDetails = categoryM.orElseThrow(); + + var categoryLeaderboardAvailableE = isCategoryLeaderboardAvailable(eventDetails, forceLeaderboard); + if (categoryLeaderboardAvailableE.isEmpty()) { + return Either.left(categoryLeaderboardAvailableE.getLeft()); + } + + var isCategoryLeaderBoardAvailable = categoryLeaderboardAvailableE.get(); + if (!isCategoryLeaderBoardAvailable) { + return Either.left(Problem.builder() + .withTitle("VOTING_RESULTS_NOT_AVAILABLE") + .withDetail("Category level voting results not available until results can be revealed!") + .withStatus(FORBIDDEN) + .build() + ); + } + + var hydraTallyNameM = findFirstHydraTallyName(eventDetails); + + if (hydraTallyNameM.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_TALLY") + .withDetail("Unrecognised tally, tally:" + "Hydra Tally Experiment") + .withStatus(NO_CONTENT) + .build() + ); + } + + ChainFollowerClient.Tally tally = hydraTallyNameM.orElseThrow(); + + var votingResultsE = chainFollowerClient.getVotingResults( + eventDetails.id(), + categoryDetails.id(), + tally.name() + ); + + if (votingResultsE.isEmpty()) { + var issue = votingResultsE.swap().get(); + + if (issue.getStatus().getStatusCode() == 404) { + return Either.right(Optional.empty()); + } + + return Either.left(Problem.builder() + .withTitle("ERROR_GETTING_VOTING_RESULTS") + .withDetail("Unable to get voting results from chain-tip follower service, event:" + event + ", category:" + category) + .withStatus(INTERNAL_SERVER_ERROR) + .build() + ); + } + + var votingResults = votingResultsE.get(); + + var byProposalsInCategoryStatsM = switch (eventDetails.votingEventType()) { + case STAKE_BASED, BALANCE_BASED -> { + var proposalResults = votingResults.results().entrySet().stream().collect(toMap(Map.Entry::getKey, e -> { + var score = e.getValue(); + + var b = Leaderboard.Votes.builder(); + b.votingPower(String.valueOf(score)); + b.votes(0); // TODO support for vote count from L1 data + + return b.build(); + })); + + yield Optional.of(Leaderboard.ByProposalsInCategoryStats.builder() + .category(category) + .proposals(reInitialiseResultsToEmptyIfMissing(categoryDetails, proposalResults, eventDetails)) + .build() + ); + } + case USER_BASED -> { + var proposalResults = votingResults.results().entrySet().stream().collect(toMap(Map.Entry::getKey, e -> { + var score = e.getValue(); + + var b = Leaderboard.Votes.builder(); + b.votes(score); + + return b.build(); + })); + + yield Optional.of(Leaderboard.ByProposalsInCategoryStats.builder() + .category(category) + .proposals(reInitialiseResultsToEmptyIfMissing(categoryDetails, proposalResults, eventDetails)) + .build() + ); + } + }; + + return Either.right(byProposalsInCategoryStatsM); + } + + private Optional findFirstHydraTallyName(ChainFollowerClient.EventDetailsResponse eventDetailsResponse) { + return eventDetailsResponse.tallies().stream() + .filter(tally -> tally.type() == HYDRA) + .findFirst(); + } + +} diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/LeaderboardWinnersProvider.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/LeaderboardWinnersProvider.java new file mode 100644 index 000000000..da73cd2f3 --- /dev/null +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/LeaderboardWinnersProvider.java @@ -0,0 +1,29 @@ +package org.cardano.foundation.voting.service.leader_board; + +import org.cardano.foundation.voting.domain.WinnerLeaderboardSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +@Service +public class LeaderboardWinnersProvider { + + @Autowired + @Qualifier("db_leaderboard_winners_service") + private LeaderboardWinnersService dbLeaderboardWinnersService; + + @Autowired + @Qualifier("l1_leaderboard_winners_service") + private LeaderboardWinnersService l1LeaderboardWinnersService; + + public LeaderboardWinnersService getWinnerLeaderboardSource(WinnerLeaderboardSource winnerLeaderboardSource) { + return switch (winnerLeaderboardSource) { + case db: + yield dbLeaderboardWinnersService; + case l1: + yield l1LeaderboardWinnersService; + }; + } + + +} diff --git a/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/LeaderboardWinnersService.java b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/LeaderboardWinnersService.java new file mode 100644 index 000000000..daf19cdba --- /dev/null +++ b/backend-services/voting-app/src/main/java/org/cardano/foundation/voting/service/leader_board/LeaderboardWinnersService.java @@ -0,0 +1,19 @@ +package org.cardano.foundation.voting.service.leader_board; + +import io.vavr.control.Either; +import org.cardano.foundation.voting.domain.Leaderboard; +import org.zalando.problem.Problem; + +import java.util.Optional; + +public interface LeaderboardWinnersService { + + Either isCategoryLeaderboardAvailable(String event, + String category, + boolean forceLeaderboard); + + Either> getCategoryLeaderboard(String event, + String category, + boolean forceLeaderboard); + +} diff --git a/backend-services/voting-app/src/main/resources/application.properties b/backend-services/voting-app/src/main/resources/application.properties index 009853d3a..47ca42c6f 100644 --- a/backend-services/voting-app/src/main/resources/application.properties +++ b/backend-services/voting-app/src/main/resources/application.properties @@ -62,4 +62,8 @@ cardano.jwt.secret=${CARDANO_JWT_SECRET:7B226B7479223A224F4B50222C22637276223A22 cardano.jwt.iss=https://cardanofoundation.org cardano.jwt.tokenValidityDurationHours=${CARDANO_JWT_TOKEN_VALIDITY_DURATION_HOURS:24} -spring.jackson.default-property-inclusion=non_null \ No newline at end of file +cardano.node.ip=${CARDANO_NODE_IP:preprod-node.world.dev.cardano.org} +cardano.node.port=${CARDANO_NODE_PORT:30000} +rollback.handling.enabled=${ROLLBACK_HANDLING_ENABLED:true} + +spring.jackson.default-property-inclusion=non_null diff --git a/backend-services/voting-ledger-follower-app/build.gradle.kts b/backend-services/voting-ledger-follower-app/build.gradle.kts index 2c852d183..c07004efd 100644 --- a/backend-services/voting-ledger-follower-app/build.gradle.kts +++ b/backend-services/voting-ledger-follower-app/build.gradle.kts @@ -69,6 +69,8 @@ dependencies { implementation("com.bloxbean.cardano:cardano-client-crypto:0.5.0") implementation("com.bloxbean.cardano:cardano-client-backend-blockfrost:0.5.0") + implementation("com.bloxbean.cardano:aiken-java-binding:0.0.8") + annotationProcessor("com.bloxbean.cardano:cardano-client-annotation-processor:0.5.0") implementation("com.bloxbean.cardano:yaci-store-spring-boot-starter:0.0.12") implementation("com.bloxbean.cardano:yaci-store-blocks-spring-boot-starter:0.0.12") diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/BlockchainDataConfig.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/BlockchainDataConfig.java index 12051517e..8a7f86ee3 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/BlockchainDataConfig.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/BlockchainDataConfig.java @@ -2,14 +2,8 @@ import com.bloxbean.cardano.client.backend.api.BackendService; import org.cardano.foundation.voting.domain.CardanoNetwork; -import org.cardano.foundation.voting.service.blockchain_state.BlockchainDataChainTipService; -import org.cardano.foundation.voting.service.blockchain_state.BlockchainDataStakePoolService; -import org.cardano.foundation.voting.service.blockchain_state.BlockchainDataTransactionDetailsService; -import org.cardano.foundation.voting.service.blockchain_state.FixedBlockchainDataStakePoolService; -import org.cardano.foundation.voting.service.blockchain_state.backend_bridge.BackendServiceBlockchainDataChainTipService; -import org.cardano.foundation.voting.service.blockchain_state.backend_bridge.BackendServiceBlockchainDataCurrentStakePoolService; -import org.cardano.foundation.voting.service.blockchain_state.backend_bridge.BackendServiceBlockchainDataStakePoolService; -import org.cardano.foundation.voting.service.blockchain_state.backend_bridge.BackendServiceBlockchainDataTransactionDetailsService; +import org.cardano.foundation.voting.service.blockchain_state.*; +import org.cardano.foundation.voting.service.blockchain_state.backend_bridge.*; import org.cardano.foundation.voting.service.chain_sync.ChainSyncService; import org.cardano.foundation.voting.service.chain_sync.DefaultChainSyncService; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/CardanoClientLibConfig.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/CardanoClientLibConfig.java index 0f858e7c6..3a8a6f228 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/CardanoClientLibConfig.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/CardanoClientLibConfig.java @@ -2,7 +2,10 @@ import com.bloxbean.cardano.client.backend.api.BackendService; import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService; +import com.bloxbean.cardano.client.common.model.Network; +import com.bloxbean.cardano.client.common.model.Networks; import lombok.extern.slf4j.Slf4j; +import org.cardano.foundation.voting.domain.CardanoNetwork; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -19,6 +22,16 @@ public BackendService orgBackendService(@Value("${blockfrost.url}") String block return new BFBackendService(blockfrostUrl, blockfrostApiKey); } + @Bean + public Network network(CardanoNetwork cardanoNetwork) { + return switch(cardanoNetwork) { + case MAIN -> Networks.mainnet(); + case PREPROD -> Networks.preprod(); + case PREVIEW -> Networks.preview(); + case DEV -> Networks.testnet(); + }; + } + @Bean @Qualifier("yaci_blockfrost") public BackendService yaciBackendService() { diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java index 69b809a54..2fd98889a 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/CardanoConfig.java @@ -11,7 +11,7 @@ public class CardanoConfig { @Bean - public CardanoNetwork network(@Value("${cardano.network:main}") CardanoNetwork network) { + public CardanoNetwork cardanoNetwork(@Value("${cardano.network:main}") CardanoNetwork network) { log.info("Configured backend network:{}", network); return network; diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/PlutusConfig.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/PlutusConfig.java new file mode 100644 index 000000000..3900d2693 --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/config/PlutusConfig.java @@ -0,0 +1,15 @@ +package org.cardano.foundation.voting.config; + +import org.cardano.foundation.voting.domain.CategoryResultsDatumConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class PlutusConfig { + + @Bean + public CategoryResultsDatumConverter categoryResultsDatumConverter() { + return new CategoryResultsDatumConverter(); + } + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/CategoryResultsDatum.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/CategoryResultsDatum.java new file mode 100644 index 000000000..82cb3a09c --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/CategoryResultsDatum.java @@ -0,0 +1,26 @@ +package org.cardano.foundation.voting.domain; + +import com.bloxbean.cardano.client.plutus.annotation.Constr; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.val; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Constr +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CategoryResultsDatum { + + private String eventId; + + private String organiser; + + private String categoryId; + + private Map results; + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/HydraTally.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/HydraTally.java new file mode 100644 index 000000000..d5a93bf4c --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/HydraTally.java @@ -0,0 +1,78 @@ +package org.cardano.foundation.voting.domain; + +import jakarta.annotation.Nullable; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.*; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +@Embeddable +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HydraTally { + + @Getter + @Setter + @Column(name = "hydra_tally_config__contract_name", nullable = false) + private String contractName; + + @Getter + @Setter + @Column(name = "hydra_tally_config__contract_description") + @Nullable + private String contractDescription; + + @Getter + @Setter + @Column(name = "hydra_tally_config__contract_version", nullable = false) + private String contractVersion; + + @Getter + @Setter + @Column(name = "hydra_tally_config__compiled_script", nullable = false, columnDefinition = "text", length = 16000) + private String compiledScript; + + @Getter + @Setter + @Column(name = "hydra_tally_config__compiled_script_hash", nullable = false) + private String compiledScriptHash; + + @Getter + @Setter + @Column(name = "hydra_tally_config__compiler_name", nullable = false) + private String compilerName; + + @Getter + @Setter + @Column(name = "hydra_tally_config__compiler_version", nullable = false) + private String compilerVersion; + + @Getter + @Setter + @Column(name = "hydra_tally_config__plutus_version", nullable = false) + private String plutusVersion; + + @Getter + @Setter + @Column(name = "hydra_tally_config__verification_key_hashes", nullable = false, columnDefinition = "text", length = 1024) + //@Column(name = "hydra_tally_config__verification_key", nullable = false, columnDefinition = "text", length = 1024) + // comma separated list of blake224 hashes of the verification keys + private String verificationKeyHashes; + + public List getVerificationKeysHashesAsList() { + return Arrays.asList(verificationKeyHashes.split(":")); + } + + public void setDescription(Optional description) { + this.contractDescription = description.orElse(null); + } + + public Optional getDescription() { + return Optional.ofNullable(contractDescription); + } + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/SchemaVersion.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/SchemaVersion.java index 4a2253ea0..b120d9b64 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/SchemaVersion.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/SchemaVersion.java @@ -5,7 +5,8 @@ public enum SchemaVersion { - V1("1.0.0"); + V1("1.0.0"), + V11("1.1.0"); private final String version; @@ -23,4 +24,12 @@ public static Optional fromText(String text) { .findFirst(); } + public boolean isGreaterThanEqual(SchemaVersion schemaVersion) { + return this.ordinal() >= schemaVersion.ordinal(); + } + + public boolean isLowerThanEqual(SchemaVersion schemaVersion) { + return this.ordinal() <= schemaVersion.ordinal(); + } + } diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/TallyResults.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/TallyResults.java new file mode 100644 index 000000000..6d8212f82 --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/TallyResults.java @@ -0,0 +1,26 @@ +package org.cardano.foundation.voting.domain; + +import lombok.Builder; +import lombok.Getter; +import org.cardano.foundation.voting.domain.entity.Tally; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Builder +@Getter +public class TallyResults { + + private String tallyName; + private Optional tallyDescription; + private Tally.TallyType tallyType; + private String eventId; + private String categoryId; + + private Map results; + + @Builder.Default + private Map metadata = new HashMap<>(); + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/Utxo.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/Utxo.java new file mode 100644 index 000000000..091155185 --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/Utxo.java @@ -0,0 +1,23 @@ +package org.cardano.foundation.voting.domain; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class Utxo { + + @JsonProperty("address") + private String address; + + @JsonProperty("tx_hash") + private String txHash; + + @JsonProperty("tx_index") + private int txIndex; + + @JsonProperty("inline_datum") + private String inlineDatum; + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Category.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Category.java index 9aae86eb4..e1fa43ef1 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Category.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Category.java @@ -15,7 +15,6 @@ @NoArgsConstructor @Entity @Table(name = "category") -@Immutable public class Category extends AbstractTimestampEntity { @Id diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Event.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Event.java index c40bf53dc..ca245117b 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Event.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Event.java @@ -5,6 +5,7 @@ import org.cardano.foundation.voting.domain.SchemaVersion; import org.cardano.foundation.voting.domain.VotingEventType; import org.cardano.foundation.voting.domain.VotingPowerAsset; +import org.hibernate.annotations.Cascade; import org.hibernate.annotations.Immutable; import javax.annotation.Nullable; @@ -12,6 +13,8 @@ import java.util.List; import java.util.Optional; +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.EAGER; import static org.cardano.foundation.voting.domain.VotingEventType.BALANCE_BASED; import static org.cardano.foundation.voting.domain.VotingEventType.STAKE_BASED; @@ -20,7 +23,6 @@ @Builder @RequiredArgsConstructor @AllArgsConstructor -@Immutable public class Event extends AbstractTimestampEntity { @Getter @@ -32,18 +34,18 @@ public class Event extends AbstractTimestampEntity { @Column(nullable = false) @Getter @Setter - private String organisers; // e.g. CF + private String organisers; @Column(name = "event_type", nullable = false) @Getter @Setter - @Enumerated(EnumType.STRING) + @Enumerated(STRING) private VotingEventType votingEventType; @Column(name = "voting_power_asset") // voting power asset is only needed for stake based voting events @Nullable - @Enumerated(EnumType.STRING) + @Enumerated(STRING) private VotingPowerAsset votingPowerAsset; @Column(name = "allow_vote_changing") @@ -104,13 +106,13 @@ public class Event extends AbstractTimestampEntity { @Column(name = "schema_version") @Getter @Setter - @Enumerated(EnumType.STRING) + @Enumerated(STRING) private SchemaVersion version; @OneToMany( mappedBy = "event", cascade = CascadeType.ALL, - fetch = FetchType.EAGER, + fetch = EAGER, orphanRemoval = true ) @Builder.Default @@ -123,8 +125,21 @@ public class Event extends AbstractTimestampEntity { @Setter private long absoluteSlot; + @Setter + @Getter + @ElementCollection(fetch = EAGER) + @CollectionTable( + name = "event_tally", + joinColumns = @JoinColumn(name = "event_id") + ) + @Builder.Default + private List tallies = new ArrayList<>(); + public Optional findCategoryByName(String categoryName) { - return categories.stream().filter(category -> category.getId().equals(categoryName)).findFirst(); + return categories + .stream() + .filter(category -> category.getId().equals(categoryName)) + .findFirst(); } public Optional getVotingPowerAsset() { diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/EventResultsCategoryResultsUtxoData.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/EventResultsCategoryResultsUtxoData.java new file mode 100644 index 000000000..326fba112 --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/EventResultsCategoryResultsUtxoData.java @@ -0,0 +1,48 @@ +package org.cardano.foundation.voting.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.*; + +import java.util.Arrays; +import java.util.List; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "event_category_result_utxo_data") +@Getter +@Setter +public class EventResultsCategoryResultsUtxoData { + + @Id + @Column(name = "id", nullable = false) + private String id; + + @Column(name = "address", nullable = false) + private String address; + + @Column(name = "tx_hash", nullable = false) + private String txHash; + + @Column(name = "index", nullable = false) + private int index; + + @Column(name = "inline_datum", nullable = false, columnDefinition = "text", length = 2048) + private String inlineDatum; + + @Column(name = "absolute_slot", nullable = false) + private long absoluteSlot; + + // blake2b 224 hashes of the verification keys of the witnesses + @Column(name = "witnesses_hashes", nullable = false, columnDefinition = "text", length = 2048) + private String witnessesHashes; + + public List getWitnessesHashes() { + return Arrays.asList(witnessesHashes.split(":")); + } + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/MerkleRootHash.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/MerkleRootHash.java index e6f532345..1e6efaa53 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/MerkleRootHash.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/MerkleRootHash.java @@ -17,7 +17,6 @@ @RequiredArgsConstructor @AllArgsConstructor @Getter -@Immutable public class MerkleRootHash extends AbstractTimestampEntity { @Id diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Proposal.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Proposal.java index b1e39a9bb..7af8fcfdc 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Proposal.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Proposal.java @@ -12,7 +12,6 @@ @Builder @Entity @Table(name = "proposal") -@Immutable public class Proposal extends AbstractTimestampEntity { @Getter diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Tally.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Tally.java new file mode 100644 index 000000000..684c945b6 --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/entity/Tally.java @@ -0,0 +1,57 @@ +package org.cardano.foundation.voting.domain.entity; + +import jakarta.annotation.Nullable; +import jakarta.persistence.*; +import lombok.*; +import org.cardano.foundation.voting.domain.HydraTally; + +import java.util.Optional; + +@Builder +@Embeddable +@AllArgsConstructor +@NoArgsConstructor +public class Tally extends AbstractTimestampEntity { + + @Getter + @Setter + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description") + @Nullable + private String description; + + @Getter + @Setter + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TallyType type; + + @Getter + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "contract_name", column = @Column(name = "hydra_tally_config__contract_name")), + @AttributeOverride(name = "contract_description", column = @Column(name = "hydra_tally_config__contract_description")), + @AttributeOverride(name = "contract_version", column = @Column(name = "hydra_tally_config__contract_version")), + @AttributeOverride(name = "compiled_script", column = @Column(name = "hydra_tally_config__compiled_script")), + @AttributeOverride(name = "compiled_script_hash", column = @Column(name = "hydra_tally_config__compiled_script_hash")), + @AttributeOverride(name = "compiler_name", column = @Column(name = "hydra_tally_config__compiler_name")), + @AttributeOverride(name = "compiler_version", column = @Column(name = "hydra_tally_config__compiler_version")), + @AttributeOverride(name = "plutus_version", column = @Column(name = "hydra_tally_config__plutus_version")), + }) + private HydraTally hydraTallyConfig; + + public enum TallyType { + HYDRA + } + + public void setDescription(Optional description) { + this.description = description.orElse(null); + } + + public Optional getDescription() { + return Optional.ofNullable(description); + } + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/presentation/EventPresentation.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/presentation/EventPresentation.java index 440bb4284..149851ef9 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/presentation/EventPresentation.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/presentation/EventPresentation.java @@ -87,4 +87,7 @@ public class EventPresentation { @Builder.Default private List categories = new ArrayList<>(); + @Builder.Default + private List tallies = new ArrayList<>(); + } diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/presentation/HydraTallyConfigPresentation.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/presentation/HydraTallyConfigPresentation.java new file mode 100644 index 000000000..5eea9abe2 --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/presentation/HydraTallyConfigPresentation.java @@ -0,0 +1,30 @@ +package org.cardano.foundation.voting.domain.presentation; + +import lombok.Builder; +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Builder +public class HydraTallyConfigPresentation { + + private String contractName; + + private String compiledScript; + + private String compiledScriptHash; + + private String contractVersion; + + private String compilerName; + + private String compilerVersion; + + private String plutusVersion; + + @Builder.Default + private List verificationKeys = new ArrayList<>(); + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/presentation/TallyPresentation.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/presentation/TallyPresentation.java new file mode 100644 index 000000000..14c75ae29 --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/presentation/TallyPresentation.java @@ -0,0 +1,21 @@ +package org.cardano.foundation.voting.domain.presentation; + +import lombok.Builder; +import lombok.Getter; +import org.cardano.foundation.voting.domain.entity.Tally; + +import java.util.Optional; + +@Builder +@Getter +public class TallyPresentation { + + private String name; + @Builder.Default + private Optional description = Optional.empty(); + private Tally.TallyType type; + + @Builder.Default + private Optional config = Optional.empty(); + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/CategoryRegistrationEnvelope.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/CategoryRegistrationEnvelope.java index eb950d8f0..0bc3e7632 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/CategoryRegistrationEnvelope.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/CategoryRegistrationEnvelope.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.ToString; import org.cardano.foundation.voting.domain.OnChainEventType; +import org.cardano.foundation.voting.domain.SchemaVersion; import java.util.List; @@ -15,7 +16,7 @@ public class CategoryRegistrationEnvelope { private OnChainEventType type; private String id; private String event; - private String schemaVersion; + private SchemaVersion schemaVersion; private long creationSlot; diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/CommitmentsEnvelope.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/CommitmentsEnvelope.java index 8e04322f3..d393ec1f9 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/CommitmentsEnvelope.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/CommitmentsEnvelope.java @@ -29,8 +29,4 @@ public Optional getCommitment(String eventId) { .flatMap(eId -> Optional.ofNullable(eId.get("hash"))); } - public boolean removeCommitment(String eventId) { - return commitments.remove(eventId) != null; - } - } diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/EventRegistrationEnvelope.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/EventRegistrationEnvelope.java index e668660ea..ab68d97a9 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/EventRegistrationEnvelope.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/EventRegistrationEnvelope.java @@ -3,9 +3,12 @@ import lombok.Builder; import lombok.Getter; import org.cardano.foundation.voting.domain.OnChainEventType; +import org.cardano.foundation.voting.domain.SchemaVersion; import org.cardano.foundation.voting.domain.VotingEventType; import org.cardano.foundation.voting.domain.VotingPowerAsset; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; @Getter @@ -15,7 +18,7 @@ public class EventRegistrationEnvelope { private OnChainEventType type; private String name; private String organisers; - private String schemaVersion; + private SchemaVersion schemaVersion; private long creationSlot; private boolean allowVoteChanging; @@ -50,4 +53,7 @@ public class EventRegistrationEnvelope { @Builder.Default private Optional proposalsRevealEpoch = Optional.empty(); + @Builder.Default + private List tallies = new ArrayList<>(); + } diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/HydraTallyRegistrationEnvelope.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/HydraTallyRegistrationEnvelope.java new file mode 100644 index 000000000..3b65b4ba9 --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/HydraTallyRegistrationEnvelope.java @@ -0,0 +1,32 @@ +package org.cardano.foundation.voting.domain.web3; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +import java.util.List; + +@Getter +@Builder +@ToString +public class HydraTallyRegistrationEnvelope { + + private String contractName; + + private String contractDesc; + + private String contractVersion; + + private String compiledScript; + + private String compiledScriptHash; + + private String compilerName; + + private String compilerVersion; + + private String plutusVersion; + + private List verificationKeys; + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/TallyRegistrationEnvelope.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/TallyRegistrationEnvelope.java new file mode 100644 index 000000000..b19c9d1f1 --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/domain/web3/TallyRegistrationEnvelope.java @@ -0,0 +1,22 @@ +package org.cardano.foundation.voting.domain.web3; + + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.cardano.foundation.voting.domain.entity.Tally; + +@Getter +@Builder +@ToString +public class TallyRegistrationEnvelope { + + private String name; + + private String description; + + private Tally.TallyType type; + + private Object config; + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/handlers/EventResultsUtxoHandler.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/handlers/EventResultsUtxoHandler.java new file mode 100644 index 000000000..81bd9eab4 --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/handlers/EventResultsUtxoHandler.java @@ -0,0 +1,101 @@ +package org.cardano.foundation.voting.handlers; + +import com.bloxbean.cardano.client.crypto.KeyGenUtil; +import com.bloxbean.cardano.client.util.HexUtil; +import com.bloxbean.cardano.yaci.core.model.VkeyWitness; +import com.bloxbean.cardano.yaci.helper.model.Utxo; +import com.bloxbean.cardano.yaci.store.events.EventMetadata; +import com.bloxbean.cardano.yaci.store.events.TransactionEvent; +import jakarta.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.cardano.foundation.voting.domain.CategoryResultsDatum; +import org.cardano.foundation.voting.domain.CategoryResultsDatumConverter; +import org.cardano.foundation.voting.domain.entity.EventResultsCategoryResultsUtxoData; +import org.cardano.foundation.voting.service.utxo.EventResultsUtxoDataService; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.StringJoiner; + +@Service +@Slf4j +@RequiredArgsConstructor +public class EventResultsUtxoHandler { + + private final CategoryResultsDatumConverter categoryResultsDatumConverter; + + private final EventResultsUtxoDataService eventResultsUtxoDataService; + + @EventListener + @Async("singleThreadExecutor") + public void processTransaction(TransactionEvent transactionEvent) { + try { + var txMetadata = transactionEvent.getMetadata(); + + for (var trx : transactionEvent.getTransactions()) { + for (var utxo : trx.getUtxos()) { + var address = utxo.getAddress(); + var utxoCategoryDatumM = parseCategoryResultsDatum(utxo.getInlineDatum()); + + if (utxoCategoryDatumM.isEmpty()) { + continue; + } + + var keyHashes = trx.getWitnesses().getVkeyWitnesses() + .stream() + .map(VkeyWitness::getKey) + .map(this::getKeyHash) + .toList(); + + var utxoCategoryResultsData = prepareUtxoCategoryResults(utxo, address, keyHashes, txMetadata); + + eventResultsUtxoDataService.storeEventResultsUtxoData(utxoCategoryResultsData); + }; + } + } catch (Exception e) { + log.warn("Error processing transaction event", e); + } +} + + @Nullable private String getKeyHash(String pubKey) { + try { + return KeyGenUtil.getKeyHash(HexUtil.decodeHexString(pubKey)); + } catch (Exception e) { + log.error("Error generating keyhash for key : " + pubKey, e); + } + return null; + } + + private static EventResultsCategoryResultsUtxoData prepareUtxoCategoryResults(Utxo utxo, + String address, + List witnesses, + EventMetadata txMetadata) { + var joiner = new StringJoiner(":"); + witnesses.forEach(joiner::add); + + log.info("Preparing utxo category results data for utxo:{}", utxo); + var utxoCategoryResultsData = new EventResultsCategoryResultsUtxoData(); + utxoCategoryResultsData.setId(utxo.getTxHash() + "#" + utxo.getIndex()); + utxoCategoryResultsData.setAddress(address); + utxoCategoryResultsData.setTxHash(utxo.getTxHash()); + utxoCategoryResultsData.setIndex(utxo.getIndex()); + utxoCategoryResultsData.setInlineDatum(utxo.getInlineDatum()); + utxoCategoryResultsData.setAbsoluteSlot(txMetadata.getSlot()); + utxoCategoryResultsData.setWitnessesHashes(joiner.toString()); + + return utxoCategoryResultsData; + } + + private Optional parseCategoryResultsDatum(String inlineDatum) { + try { + return Optional.of(categoryResultsDatumConverter.deserialize(inlineDatum)); + } catch (Exception e) { + return Optional.empty(); + } + } + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/handlers/MetadataEventHandler.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/handlers/MetadataEventHandler.java index 994e5e8aa..1bbd5122b 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/handlers/MetadataEventHandler.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/handlers/MetadataEventHandler.java @@ -32,7 +32,7 @@ public void handleMetadataEvent(TxMetadataEvent event) { } } } catch (Exception e) { - log.warn("Error processing metadata event, cause:{}", e.getMessage()); + log.warn("Error processing metadata event", e); } } diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/repository/MerkleRootHashRepository.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/repository/MerkleRootHashRepository.java index b35f5e53c..ab14c87d8 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/repository/MerkleRootHashRepository.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/repository/MerkleRootHashRepository.java @@ -17,6 +17,6 @@ public interface MerkleRootHashRepository extends JpaRepository :slot") @Modifying - void deleteAllAfterSlot(@Param("slot") long slot); + int deleteAllAfterSlot(@Param("slot") long slot); } diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/repository/UtxoCategoryResultsDataRepository.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/repository/UtxoCategoryResultsDataRepository.java new file mode 100644 index 000000000..65d4d5613 --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/repository/UtxoCategoryResultsDataRepository.java @@ -0,0 +1,21 @@ +package org.cardano.foundation.voting.repository; + +import org.cardano.foundation.voting.domain.entity.EventResultsCategoryResultsUtxoData; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface UtxoCategoryResultsDataRepository extends JpaRepository { + + List findByAddress(String contractAddress); + + @Query("DELETE FROM EventResultsCategoryResultsUtxoData u WHERE u.absoluteSlot > :slot") + @Modifying + int deleteAllAfterSlot(@Param("slot") long slot); + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/resource/BlockchainDataResource.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/resource/BlockchainDataResource.java index d36710ae1..10ae1f172 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/resource/BlockchainDataResource.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/resource/BlockchainDataResource.java @@ -55,7 +55,6 @@ public ResponseEntity tip() { .fold(problem -> { return ResponseEntity .status(problem.getStatus().getStatusCode()) - .cacheControl(cacheControl) .body(problem); }, chainTip -> { diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/resource/VotingTallyResource.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/resource/VotingTallyResource.java new file mode 100644 index 000000000..df23a1ada --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/resource/VotingTallyResource.java @@ -0,0 +1,62 @@ +package org.cardano.foundation.voting.resource; + +import io.micrometer.core.annotation.Timed; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.cardano.foundation.voting.service.results.VotingTallyService; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.zalando.problem.Problem; + +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.springframework.web.bind.annotation.RequestMethod.GET; +import static org.zalando.problem.Status.BAD_REQUEST; +import static org.zalando.problem.Status.NOT_FOUND; + +@RestController +@RequestMapping("/api/tally/voting-results") +@Slf4j +@RequiredArgsConstructor +public class VotingTallyResource { + + private final VotingTallyService votingTallyService; + + @RequestMapping(value = "/{eventId}/{categoryId}/{tallyName}", method = GET, produces = "application/json") + @Timed(value = "resource.tally.results", histogram = true) + public ResponseEntity getVoteResults(@PathVariable("eventId") String eventId, + @PathVariable("categoryId") String categoryId, + @PathVariable("tallyName") String tallyName) { + var cacheControl = CacheControl.maxAge(15, MINUTES) + .noTransform() + .mustRevalidate(); + + return votingTallyService.getVoteResults(eventId, categoryId, tallyName) + .fold(problem -> { + return ResponseEntity + .status(problem.getStatus().getStatusCode()) + .body(problem); + }, + tTallyResultsM -> { + if (tTallyResultsM.isEmpty()) { + var problem = Problem.builder() + .withTitle("NO_RESULTS_FOUND") + .withDetail("No results found for event: " + eventId + " and category: " + categoryId) + .withStatus(NOT_FOUND) + .build(); + + return ResponseEntity + .status(problem.getStatus().getStatusCode()) + .body(problem); + } + + return ResponseEntity + .ok() + .cacheControl(cacheControl) + .body(tTallyResultsM.orElseThrow()); + }); + } + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/blockchain_state/BlockchainDataUtxoStateReader.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/blockchain_state/BlockchainDataUtxoStateReader.java new file mode 100644 index 000000000..f3506df4f --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/blockchain_state/BlockchainDataUtxoStateReader.java @@ -0,0 +1,13 @@ +package org.cardano.foundation.voting.service.blockchain_state; + +import io.vavr.control.Either; +import org.cardano.foundation.voting.domain.Utxo; +import org.zalando.problem.Problem; + +import java.util.List; + +public interface BlockchainDataUtxoStateReader { + + Either> getUTxOs(String address, List verificationLKeys); + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/blockchain_state/backend_bridge/BackendServiceBlockchainDataChainTipService.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/blockchain_state/backend_bridge/BackendServiceBlockchainDataChainTipService.java index 47bc0f619..af359ffb7 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/blockchain_state/backend_bridge/BackendServiceBlockchainDataChainTipService.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/blockchain_state/backend_bridge/BackendServiceBlockchainDataChainTipService.java @@ -52,6 +52,8 @@ public Either getChainTip() { ); } catch (Exception e) { + log.error("Unable to get chain tip from backend service", e); + return Either.left(Problem.builder() .withTitle("CHAIN_TIP_NOT_FOUND") .withDetail("Unable to get chain tip from backend service, reason:" + e.getMessage()) diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/cbor/CborService.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/cbor/CborService.java index 47234cb1e..825578e21 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/cbor/CborService.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/cbor/CborService.java @@ -6,12 +6,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.cardano.foundation.voting.domain.OnChainEventType; +import org.cardano.foundation.voting.domain.SchemaVersion; import org.cardano.foundation.voting.domain.VotingEventType; import org.cardano.foundation.voting.domain.VotingPowerAsset; -import org.cardano.foundation.voting.domain.web3.CategoryRegistrationEnvelope; -import org.cardano.foundation.voting.domain.web3.CommitmentsEnvelope; -import org.cardano.foundation.voting.domain.web3.EventRegistrationEnvelope; -import org.cardano.foundation.voting.domain.web3.ProposalEnvelope; +import org.cardano.foundation.voting.domain.entity.Tally.TallyType; +import org.cardano.foundation.voting.domain.web3.*; import org.cardano.foundation.voting.utils.Enums; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -24,7 +23,10 @@ import static org.cardano.foundation.voting.domain.OnChainEventType.COMMITMENTS; import static org.cardano.foundation.voting.domain.OnChainEventType.EVENT_REGISTRATION; +import static org.cardano.foundation.voting.domain.SchemaVersion.V11; import static org.cardano.foundation.voting.domain.VotingEventType.*; +import static org.cardano.foundation.voting.domain.entity.Tally.TallyType.HYDRA; +import static org.cardano.foundation.voting.utils.ChunkedMetadataParser.deChunk; import static org.cardano.foundation.voting.utils.MoreBoolean.fromBigInteger; import static org.zalando.problem.Status.BAD_REQUEST; import static org.zalando.problem.Status.INTERNAL_SERVER_ERROR; @@ -163,6 +165,18 @@ public Either decodeCategoryRegistrationE } boolean isGdprProtection = fromBigInteger((BigInteger) options.get("gdprProtection")).orElse(false); + + var schemaVersion = SchemaVersion.fromText((String) payload.get("schemaVersion")); + + if (schemaVersion.isEmpty()) { + return Either.left( + Problem.builder() + .withTitle("INVALID_CATEGORY_REGISTRATION") + .withDetail("Invalid category registration event, missing or unrecognised schemaVersion field.") + .withStatus(BAD_REQUEST) + .build()); + } + var categoryRegistration = CategoryRegistrationEnvelope.builder() .type(maybeOnchainEventType.orElseThrow()) .id(maybeName.orElseThrow()) @@ -170,7 +184,7 @@ public Either decodeCategoryRegistrationE .creationSlot(maybeCreationSlot.orElseThrow()) .gdprProtection(isGdprProtection) .proposals(readProposalsEnvelope(maybeProposals.orElseThrow(), isGdprProtection)) - .schemaVersion((String) payload.get("schemaVersion")) + .schemaVersion(schemaVersion.orElseThrow()) .build(); return Either.right(categoryRegistration); @@ -403,7 +417,47 @@ public Either decodeEventRegistrationEnvelop eventRegistrationEnvelopeBuilder.highLevelCategoryResultsWhileVoting(fromBigInteger(((BigInteger)options.get("highLevelCategoryResultsWhileVoting"))).orElse(false)); eventRegistrationEnvelopeBuilder.categoryResultsWhileVoting(fromBigInteger(((BigInteger)options.get("categoryResultsWhileVoting"))).orElse(false)); - eventRegistrationEnvelopeBuilder.schemaVersion((String)payload.get("schemaVersion")); + var schemaVersionM = SchemaVersion.fromText((String) payload.get("schemaVersion")); + if (schemaVersionM.isEmpty()) { + return Either.left( + Problem.builder() + .withTitle("INVALID_EVENT_REGISTRATION") + .withDetail("Invalid event registration event, missing or unsupported schemaVersion field.") + .withStatus(BAD_REQUEST) + .build()); + } + + var schemaVersion = schemaVersionM.orElseThrow(); + + eventRegistrationEnvelopeBuilder.schemaVersion(schemaVersion); + + if (schemaVersion.isGreaterThanEqual(V11)) { + var talliesM = Optional.ofNullable((CBORMetadataList) payload.get("tallies")); + + if (talliesM.isPresent()) { + var tallies = talliesM.orElseThrow(); + + var tallyEnvelopeList = new ArrayList(); + for (int i = 0; i < tallies.size(); i++) { + var tallyMap = (CBORMetadataMap) tallies.getValueAt(i); + + var tallyType = Enums.getIfPresent(TallyType.class, (String) tallyMap.get("type")).orElseThrow(); + + var tallyEnvelopeBuilder = TallyRegistrationEnvelope.builder() + .name((String) tallyMap.get("name")) + .description((String) tallyMap.get("description")) + .type(tallyType); + + if (tallyType == HYDRA) { + tallyEnvelopeBuilder.config(readHydraTallyEnvelope((CBORMetadataMap) tallyMap.get("config"))); + } + + eventRegistrationEnvelopeBuilder.tallies(tallyEnvelopeList); + + tallyEnvelopeList.add(tallyEnvelopeBuilder.build()); + } + } + } return Either.right(eventRegistrationEnvelopeBuilder.build()); } catch (Exception e) { @@ -438,4 +492,42 @@ private static List readProposalsEnvelope(CBORMetadataList pro return proposals; } + private static HydraTallyRegistrationEnvelope readHydraTallyEnvelope(CBORMetadataMap hydraConfigNode) { + var compiledScriptM = deChunk(hydraConfigNode.get("compiledScript")); + + if (compiledScriptM.isEmpty()) { + throw new RuntimeException("Invalid hydra tally config. Missing compiledScript field"); + } + + var compiledScript = compiledScriptM.orElseThrow(); + + var hydraTallyRegistrationEnvelopeBuilder = HydraTallyRegistrationEnvelope.builder() + .contractName(deChunk(hydraConfigNode.get("contractName")).orElseThrow()) + .contractDesc(deChunk((hydraConfigNode.get("contractDesc"))).orElseThrow()) + .contractVersion((String) hydraConfigNode.get("contractVersion")) + .plutusVersion((String) hydraConfigNode.get("plutusVersion")) + .compiledScript(compiledScript) + .compiledScriptHash((String) hydraConfigNode.get("compiledScriptHash")) + .compilerName((String) hydraConfigNode.get("compilerName")) + .compilerVersion((String) hydraConfigNode.get("compilerVersion")) + ; + + var verificationKeys = (CBORMetadataList) hydraConfigNode.get("verificationKeys"); + + hydraTallyRegistrationEnvelopeBuilder.verificationKeys(readVerificationKeys(verificationKeys)); + + return hydraTallyRegistrationEnvelopeBuilder.build(); + } + + private static List readVerificationKeys(CBORMetadataList cborMetadataList) { + var verificationKeys = new ArrayList(); + + for (int i = 0; i < cborMetadataList.size(); i++) { + var verificationKey = deChunk(cborMetadataList.getValueAt(i)).orElseThrow(); + verificationKeys.add(verificationKey); + } + + return verificationKeys; + } + } diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/metadata/CustomMetadataProcessor.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/metadata/CustomMetadataProcessor.java index 444e9f913..0330af3b1 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/metadata/CustomMetadataProcessor.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/metadata/CustomMetadataProcessor.java @@ -5,12 +5,11 @@ import com.bloxbean.cardano.client.metadata.cbor.CBORMetadata; import com.bloxbean.cardano.client.metadata.cbor.CBORMetadataMap; import lombok.extern.slf4j.Slf4j; +import org.cardano.foundation.voting.domain.HydraTally; import org.cardano.foundation.voting.domain.OnChainEventType; -import org.cardano.foundation.voting.domain.SchemaVersion; -import org.cardano.foundation.voting.domain.entity.Category; -import org.cardano.foundation.voting.domain.entity.Event; -import org.cardano.foundation.voting.domain.entity.MerkleRootHash; -import org.cardano.foundation.voting.domain.entity.Proposal; +import org.cardano.foundation.voting.domain.entity.*; +import org.cardano.foundation.voting.domain.web3.HydraTallyRegistrationEnvelope; +import org.cardano.foundation.voting.domain.web3.TallyRegistrationEnvelope; import org.cardano.foundation.voting.service.cbor.CborService; import org.cardano.foundation.voting.service.reference_data.ReferenceDataService; import org.cardano.foundation.voting.service.vote.MerkleRootHashService; @@ -26,12 +25,15 @@ import java.math.BigInteger; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import static com.bloxbean.cardano.client.crypto.Blake2bUtil.blake2bHash224; import static com.bloxbean.cardano.client.util.HexUtil.decodeHexString; import static com.bloxbean.cardano.client.util.HexUtil.encodeHexString; import static org.cardano.foundation.voting.domain.OnChainEventType.*; +import static org.cardano.foundation.voting.domain.entity.Tally.TallyType.HYDRA; import static org.cardanofoundation.cip30.AddressFormat.TEXT; import static org.cardanofoundation.cip30.MessageFormat.HEX; @@ -119,7 +121,7 @@ private Optional processEventRegistration(long slot, var cip30Parser = new CIP30Verifier(signatureHexString, Optional.ofNullable(keyHexString)); var cip30VerificationResult = cip30Parser.verify(); if (!cip30VerificationResult.isValid()) { - log.info("Signature invalid, ignoring id:{}", id); + log.info("Event registration signature invalid, ignoring id:{}", id); return Optional.empty(); } @@ -161,11 +163,14 @@ private Optional processEventRegistration(long slot, return Optional.empty(); } - var maybeStoredEvent = referenceDataService.findEventByName(eventRegistration.getName()); - if (maybeStoredEvent.isPresent()) { - log.info("Event already found, ignoring id:{}", id); + var eventM = referenceDataService.findEventByName(eventRegistration.getName()); - return Optional.empty(); + if (eventM.isPresent()) { + var event = eventM.orElseThrow(); + + log.info("Event already found, will be removing old one and re-creating it, id:{}", event.getId()); + + referenceDataService.removeEvent(event); } var event = new Event(); @@ -176,7 +181,7 @@ private Optional processEventRegistration(long slot, event.setHighLevelEventResultsWhileVoting(Optional.of(eventRegistration.isHighLevelEventResultsWhileVoting())); event.setHighLevelCategoryResultsWhileVoting(Optional.of(eventRegistration.isHighLevelCategoryResultsWhileVoting())); event.setCategoryResultsWhileVoting(Optional.of(eventRegistration.isCategoryResultsWhileVoting())); - event.setVersion(SchemaVersion.fromText(eventRegistration.getSchemaVersion()).orElseThrow()); + event.setVersion(eventRegistration.getSchemaVersion()); event.setStartEpoch(eventRegistration.getStartEpoch()); event.setEndEpoch(eventRegistration.getEndEpoch()); @@ -193,9 +198,44 @@ private Optional processEventRegistration(long slot, event.setAbsoluteSlot(slot); + var tallies = eventRegistration.getTallies() + .stream() + .map(tally -> { + var tallyBuilder = Tally.builder() + .name(tally.getName()) + .type(tally.getType()) + .description(tally.getDescription()); + + if (tally.getType() == HYDRA) { + tallyBuilder.hydraTallyConfig(createTallyConfig(tally)); + } + + return tallyBuilder.build(); + } + ).toList(); + + event.getTallies().clear(); + event.getTallies().addAll(tallies); + return Optional.of(referenceDataService.storeEvent(event)); } + private static HydraTally createTallyConfig(TallyRegistrationEnvelope tally) { + var tallyConfig = (HydraTallyRegistrationEnvelope) tally.getConfig(); + + return HydraTally.builder() + .contractName(tallyConfig.getContractName()) + .contractDescription(tallyConfig.getContractDesc()) + .contractVersion(tallyConfig.getContractVersion()) + .compiledScript(tallyConfig.getCompiledScript()) + .compiledScriptHash(tallyConfig.getCompiledScriptHash()) + .compilerName(tallyConfig.getCompilerName()) + .compilerVersion(tallyConfig.getCompilerVersion()) + .plutusVersion(tallyConfig.getPlutusVersion()) + .verificationKeyHashes(String.join(":", tallyConfig.getVerificationKeys())) + .build(); + } + private Optional processCategoryRegistration(long slot, String signature, String key, @@ -207,7 +247,7 @@ private Optional processCategoryRegistration(long slot, var cip30Parser = new CIP30Verifier(signature, Optional.ofNullable(key)); var cip30VerificationResult = cip30Parser.verify(); if (!cip30VerificationResult.isValid()) { - log.info("Signature invalid, ignoring id: {}", id); + log.info("Category registration signature invalid, ignoring id: {}", id); return Optional.empty(); } @@ -254,31 +294,45 @@ private Optional processCategoryRegistration(long slot, return Optional.empty(); } - var event = maybeStoredEvent.orElseThrow(); - var maybeCategory = referenceDataService.findCategoryByName(categoryRegistration.getId()); - if (maybeCategory.isPresent()) { - log.info("Category already found, ignoring id: {}", categoryRegistration.getId()); + var eventM = referenceDataService.findEventByName(categoryRegistration.getEvent()); + + if (eventM.isEmpty()) { + log.warn("Category registration failed, event not found, category registration id: {}", id); return Optional.empty(); } + var categoryM = referenceDataService.findCategoryByName(categoryRegistration.getId()); + + + if (categoryM.isPresent()) { + var category = categoryM.orElseThrow(); + + log.info("Category already found, will be removing old one and re-creating it, id: {}", category.getId()); + + referenceDataService.removeCategory(category); + } + var category = new Category(); category.setId(categoryRegistration.getId()); - category.setVersion(SchemaVersion.fromText(categoryRegistration.getSchemaVersion()).orElseThrow()); + category.setVersion(categoryRegistration.getSchemaVersion()); category.setGdprProtection(categoryRegistration.isGdprProtection()); category.setAbsoluteSlot(slot); - category.setEvent(event); - - var proposals = categoryRegistration.getProposals().stream().map(proposalEnvelope -> Proposal.builder() - .id(proposalEnvelope.getId()) - .name(proposalEnvelope.getName()) - .category(category) - .absoluteSlot(slot) - .build() + category.setEvent(eventM.orElseThrow()); + + var proposals = categoryRegistration.getProposals().stream().map(proposalEnvelope -> { + return Proposal.builder() + .id(proposalEnvelope.getId()) + .name(proposalEnvelope.getName()) + .category(category) + .absoluteSlot(slot) + .build(); + } ).toList(); - category.setProposals(proposals); + category.getProposals().clear(); + category.getProposals().addAll(proposals); return Optional.of(referenceDataService.storeCategory(category)); } @@ -328,16 +382,10 @@ private Optional> processCommitments(long slot, } var commitmentsEnvelope = maybeCommitmentsEnvelope.orElseThrow(); - var merkleRootHashes = new ArrayList(); - for (var eventId : commitmentsEnvelope.getCommitments().keySet()) { - if (!bindOnEventIds.contains(eventId)) { - // we have to remove commitments which we are not actively serving / running - if (commitmentsEnvelope.removeCommitment(eventId)) { - log.info("Commitment removed for event id: {}", eventId); - } - continue; - } + var relevantCommitments = findOutRelevantEvents(commitmentsEnvelope.getCommitments()); + var merkleRootHashes = new ArrayList(); + for (var eventId : relevantCommitments.keySet()) { var maybeStoredEvent = referenceDataService.findEventByName(eventId); if (maybeStoredEvent.isEmpty()) { log.info("Event not found, ignoring commitment, id: {}", eventId); @@ -354,11 +402,12 @@ private Optional> processCommitments(long slot, .eventId(eventId) .merkleRootHash(maybeEventCommitment.orElseThrow()) .absoluteSlot(slot) - .build()); + .build() + ); } if (merkleRootHashes.isEmpty()) { - log.info("No actual commitments (merkle root hashes) found in the actual on-chain COMMITMENTS event."); + //log.info("No actual commitments (merkle root hashes) found in the actual on-chain COMMITMENTS event."); return Optional.empty(); } @@ -366,4 +415,14 @@ private Optional> processCommitments(long slot, return Optional.of(merkleRootHashService.storeCommitments(merkleRootHashes)); } + /** + * We are only interested in commitments based on events we are binding into + */ + private Map> findOutRelevantEvents(Map> commitments) { + return commitments.entrySet() + .stream() + .filter(entry -> bindOnEventIds.contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + } diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/plutus/PlutusScriptLoader.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/plutus/PlutusScriptLoader.java new file mode 100644 index 000000000..57607167c --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/plutus/PlutusScriptLoader.java @@ -0,0 +1,71 @@ +package org.cardano.foundation.voting.service.plutus; + +import com.bloxbean.cardano.client.common.model.Network; +import com.bloxbean.cardano.client.plutus.spec.BytesPlutusData; +import com.bloxbean.cardano.client.plutus.spec.ListPlutusData; +import com.bloxbean.cardano.client.plutus.spec.PlutusData; +import com.bloxbean.cardano.client.plutus.spec.PlutusScript; +import com.bloxbean.cardano.client.util.HexUtil; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.cardano.foundation.voting.domain.entity.Event; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +import static com.bloxbean.cardano.aiken.AikenScriptUtil.applyParamToScript; +import static com.bloxbean.cardano.client.address.AddressProvider.getEntAddress; +import static com.bloxbean.cardano.client.crypto.Blake2bUtil.blake2bHash224; +import static com.bloxbean.cardano.client.plutus.blueprint.PlutusBlueprintUtil.getPlutusScriptFromCompiledCode; +import static com.bloxbean.cardano.client.plutus.blueprint.model.PlutusVersion.v2; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.cardano.foundation.voting.domain.entity.Tally.TallyType.HYDRA; + +@Slf4j +@Service +public class PlutusScriptLoader { + + public Optional compileScriptBy(Event event, + String category, + String tallyName) { + ListPlutusData.ListPlutusDataBuilder builder = ListPlutusData.builder(); + + var tallyM = event.getTallies() + .stream() + .filter(tally -> tally.getName().equals(tallyName)) + .filter(tally -> tally.getType() == HYDRA) + .findFirst(); + + if (tallyM.isEmpty()) { + return Optional.empty(); + } + + var tally = tallyM.orElseThrow(); + var hydraTallyConfig = tally.getHydraTallyConfig(); + var verificationKeys = hydraTallyConfig.getVerificationKeysHashesAsList(); + + builder.plutusDataList(verificationKeys + .stream() + .map(HexUtil::decodeHexString) + .map(blake224Hash -> (PlutusData) BytesPlutusData.of(blake224Hash)) + .toList()); + + ListPlutusData verificationKeysPlutusData = builder.build(); + + val params = ListPlutusData.of( + BytesPlutusData.of(blake2bHash224(tallyName.getBytes(UTF_8))), + verificationKeysPlutusData, + BytesPlutusData.of(event.getId()), + BytesPlutusData.of(event.getOrganisers()), + BytesPlutusData.of(category) + ); + val compiledCode = applyParamToScript(params, hydraTallyConfig.getCompiledScript()); + + return Optional.of(getPlutusScriptFromCompiledCode(compiledCode, v2)); + } + + public String getContractAddress(PlutusScript plutusScript, Network network) { + return getEntAddress(plutusScript, network).toBech32(); + } + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/reference_data/ReferenceDataService.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/reference_data/ReferenceDataService.java index 4bdb6a490..2f43e0aa4 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/reference_data/ReferenceDataService.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/reference_data/ReferenceDataService.java @@ -9,6 +9,7 @@ import org.cardano.foundation.voting.domain.entity.Proposal; import org.cardano.foundation.voting.repository.CategoryRepository; import org.cardano.foundation.voting.repository.EventRepository; +import org.cardano.foundation.voting.repository.MerkleRootHashRepository; import org.cardano.foundation.voting.repository.ProposalRepository; import org.cardano.foundation.voting.service.expire.EventAdditionalInfoService; import org.springframework.stereotype.Service; @@ -21,6 +22,7 @@ @Slf4j @RequiredArgsConstructor public class ReferenceDataService { + private final MerkleRootHashRepository merkleRootHashRepository; private final EventRepository eventRepository; @@ -66,6 +68,12 @@ public Event storeEvent(Event event) { return eventRepository.saveAndFlush(event); } + @Timed(value = "service.reference.removeEvent", histogram = true) + @Transactional + public void removeEvent(Event event) { + eventRepository.delete(event); + } + @Timed(value = "service.reference.findAllValidEvents", histogram = true) @Transactional(readOnly = true) public List findAllValidEvents() { @@ -89,6 +97,12 @@ public Category storeCategory(Category category) { return categoryRepository.saveAndFlush(category); } + @Timed(value = "service.reference.removeCategory", histogram = true) + @Transactional + public void removeCategory(Category category) { + categoryRepository.delete(category); + } + @Timed(value = "service.reference.rollback", histogram = true) @Transactional public void rollbackReferenceDataAfterSlot(long slot) { diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/reference_data/ReferencePresentationService.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/reference_data/ReferencePresentationService.java index 0a548edb2..2f4c8d7af 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/reference_data/ReferencePresentationService.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/reference_data/ReferencePresentationService.java @@ -4,10 +4,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.cardano.foundation.voting.domain.EventAdditionalInfo; +import org.cardano.foundation.voting.domain.HydraTally; import org.cardano.foundation.voting.domain.entity.Event; -import org.cardano.foundation.voting.domain.presentation.CategoryPresentation; -import org.cardano.foundation.voting.domain.presentation.EventPresentation; -import org.cardano.foundation.voting.domain.presentation.ProposalPresentation; +import org.cardano.foundation.voting.domain.presentation.*; import org.cardano.foundation.voting.service.epoch.CustomEpochService; import org.cardano.foundation.voting.service.expire.EventAdditionalInfoService; import org.springframework.stereotype.Service; @@ -62,6 +61,36 @@ public Either> findEventReference(String na } var eventAdditionalInfo = eventAdditionalInfoE.get(); + var tallyPresentations = event.getTallies().stream().map( + tally -> { + var hydraTallyPresentationConfigM = switch (tally.getType()) { + case HYDRA: { + var hydraTally = (HydraTally) tally.getHydraTallyConfig(); + + yield Optional.of(HydraTallyConfigPresentation.builder() + .contractName(hydraTally.getContractName()) + .compiledScript(hydraTally.getCompiledScript()) + .compiledScriptHash(hydraTally.getCompiledScriptHash()) + .contractVersion(hydraTally.getContractVersion()) + .verificationKeys(hydraTally.getVerificationKeysHashesAsList()) + .compilerName(hydraTally.getCompilerName()) + .plutusVersion(hydraTally.getPlutusVersion()) + .compilerVersion(hydraTally.getCompilerVersion()) + .build()); + } + }; + + var tallyPresentationBuilder = TallyPresentation.builder() + .name(tally.getName()) + .type(tally.getType()) + .description(tally.getDescription()); + + tallyPresentationBuilder.config(hydraTallyPresentationConfigM); + + return tallyPresentationBuilder.build(); + } + ).toList(); + var eventBuilder = EventPresentation.builder() .id(event.getId()) .organisers(event.getOrganisers()) @@ -82,6 +111,7 @@ public Either> findEventReference(String na .isHighLevelEventResultsWhileVoting(event.getHighLevelEventResultsWhileVoting()) .isHighLevelCategoryResultsWhileVoting(event.getHighLevelEventResultsWhileVoting()) .isCategoryResultsWhileVoting(event.getCategoryResultsWhileVoting()) + .tallies(tallyPresentations) ; switch (event.getVotingEventType()) { diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/results/VotingTallyService.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/results/VotingTallyService.java new file mode 100644 index 000000000..2e80dfc5f --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/results/VotingTallyService.java @@ -0,0 +1,151 @@ +package org.cardano.foundation.voting.service.results; + +import com.bloxbean.cardano.client.common.model.Network; +import io.micrometer.core.annotation.Timed; +import io.vavr.control.Either; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.cardano.foundation.voting.domain.CategoryResultsDatumConverter; +import org.cardano.foundation.voting.domain.TallyResults; +import org.cardano.foundation.voting.service.plutus.PlutusScriptLoader; +import org.cardano.foundation.voting.service.reference_data.ReferenceDataService; +import org.cardano.foundation.voting.service.utxo.EventResultsUtxoDataService; +import org.springframework.stereotype.Service; +import org.zalando.problem.Problem; + +import java.util.Map; +import java.util.Optional; + +import static org.cardano.foundation.voting.domain.entity.Tally.TallyType.HYDRA; +import static org.zalando.problem.Status.BAD_REQUEST; + +@Service +@AllArgsConstructor +@Slf4j +public class VotingTallyService { + + private final ReferenceDataService referenceDataService; + + private final CategoryResultsDatumConverter categoryResultsDatumConverter; + + private final EventResultsUtxoDataService eventResultsUtxoDataService; + + private final PlutusScriptLoader plutusScriptLoader; + + private final Network network; + + @Timed(value = "service.vote_results.getVoteResults", histogram = true) + public Either> getVoteResults(String eventId, + String categoryId, + String tallyName) { + val eventAdditionalInfoM = referenceDataService + .findValidEventByName(eventId); + + if (eventAdditionalInfoM.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_EVENT") + .withDetail("Unrecognised event, event:" + eventId) + .withStatus(BAD_REQUEST) + .build() + ); + } + + var eventDetails = eventAdditionalInfoM.orElseThrow(); + + var isValidCategory = eventDetails.getCategories() + .stream() + .anyMatch(category -> category.getId().equals(categoryId)); + + if (!isValidCategory) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_CATEGORY") + .withDetail("Unrecognised category, category:" + categoryId) + .withStatus(BAD_REQUEST) + .build() + ); + } + + var tallyM = eventDetails.getTallies() + .stream() + .filter(tally -> tally.getName().equals(tallyName)) + .findFirst(); + + if (tallyM.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_TALLY") + .withDetail("Unrecognised tally, tally:" + tallyName) + .withStatus(BAD_REQUEST) + .build() + ); + } + + var tally = tallyM.orElseThrow(); + + if (tally.getType() != HYDRA) { + return Either.left(Problem.builder() + .withTitle("TALLY_TYPE_NOT_SUPPORTED") + .withDetail("Tally type not supported, tally:" + tally) + .withStatus(BAD_REQUEST) + .build() + ); + } + + var hydraTally = tally.getHydraTallyConfig(); + var plutusScriptM = plutusScriptLoader.compileScriptBy(eventDetails, categoryId, tallyName); + + if (plutusScriptM.isEmpty()) { + return Either.left(Problem.builder() + .withTitle("UNRECOGNISED_TALLY") + .withDetail("Unrecognised tally, tally:" + tallyName) + .withStatus(BAD_REQUEST) + .build() + ); + } + + var plutusScript = plutusScriptM.orElseThrow(); + + var contractAddress = plutusScriptLoader.getContractAddress(plutusScript, network); + + var eventResultsUtxoDataServiceAllResults = eventResultsUtxoDataService.findAllResults(contractAddress); + + if (eventResultsUtxoDataServiceAllResults.isEmpty()) { + return Either.right(Optional.empty()); + } + + var eventValidVerificationKeyHashes = tally.getHydraTallyConfig().getVerificationKeysHashesAsList(); + + var foundValidEventResultsUtxoM = eventResultsUtxoDataServiceAllResults.stream() + .filter(resultsUtxo -> resultsUtxo.getWitnessesHashes().stream().anyMatch(eventValidVerificationKeyHashes::contains)) + .findFirst(); + + if (foundValidEventResultsUtxoM.isEmpty()) { + return Either.right(Optional.empty()); + } + + var resultsUtxo = foundValidEventResultsUtxoM.orElseThrow(); + + var categoryResultsDatum = categoryResultsDatumConverter.deserialize(resultsUtxo.getInlineDatum()); + + var tallyResults = TallyResults.builder() + .tallyName(tallyName) + .tallyDescription(tally.getDescription()) + .tallyType(tally.getType()) + .eventId(categoryResultsDatum.getEventId()) + .categoryId(categoryResultsDatum.getCategoryId()) + .results(categoryResultsDatum.getResults()) + .metadata(Map.of( + "contractAddress", contractAddress, + "contractName", hydraTally.getContractName(), + "contractVersion", hydraTally.getContractVersion(), + "contractHash", hydraTally.getCompiledScriptHash(), + "compilerName", hydraTally.getCompilerName(), + "compilerVersion", hydraTally.getCompilerVersion(), + "plutusVersion", hydraTally.getPlutusVersion()) + ) + .build(); + + return Either.right(Optional.of(tallyResults)); + } + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/rollback/RollbackHandler.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/rollback/RollbackHandler.java index 5a0256246..16c483010 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/rollback/RollbackHandler.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/rollback/RollbackHandler.java @@ -1,19 +1,21 @@ package org.cardano.foundation.voting.service.rollback; import com.bloxbean.cardano.yaci.store.events.RollbackEvent; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.cardano.foundation.voting.service.reference_data.ReferenceDataService; -import org.springframework.beans.factory.annotation.Autowired; +import org.cardano.foundation.voting.service.utxo.EventResultsUtxoDataService; +import org.cardano.foundation.voting.service.vote.MerkleRootHashService; import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Slf4j -@Service +@RequiredArgsConstructor public class RollbackHandler { - @Autowired - private ReferenceDataService referenceDataService; + private final EventResultsUtxoDataService eventResultsUtxoDataService; + private final ReferenceDataService referenceDataService; + private final MerkleRootHashService merkleRootHashService; @EventListener @Transactional @@ -22,6 +24,8 @@ public void handleRollbackEvent(RollbackEvent rollbackEvent) { long rollbackToSlot = rollbackEvent.getRollbackTo().getSlot(); + eventResultsUtxoDataService.rollbackAfterSlot(rollbackToSlot); + merkleRootHashService.rollbackAfterSlot(rollbackToSlot); referenceDataService.rollbackReferenceDataAfterSlot(rollbackToSlot); log.info("Rollbacked handled."); diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/utxo/EventResultsUtxoDataService.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/utxo/EventResultsUtxoDataService.java new file mode 100644 index 000000000..d73e9cf4a --- /dev/null +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/utxo/EventResultsUtxoDataService.java @@ -0,0 +1,49 @@ +package org.cardano.foundation.voting.service.utxo; + +import io.micrometer.core.annotation.Timed; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.cardano.foundation.voting.domain.entity.EventResultsCategoryResultsUtxoData; +import org.cardano.foundation.voting.repository.UtxoCategoryResultsDataRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EventResultsUtxoDataService { + + private final UtxoCategoryResultsDataRepository utxoCategoryResultsDataRepository; + + @Transactional + @Timed(value = "service.results.store", histogram = true) + public void storeEventResultsUtxoData(EventResultsCategoryResultsUtxoData eventResultsCategoryResultsUtxoData) { + utxoCategoryResultsDataRepository.saveAndFlush(eventResultsCategoryResultsUtxoData); + } + + @Transactional + @Timed(value = "service.results.findAllResults", histogram = true) + public List findAllResults(String contractAddress) { + return utxoCategoryResultsDataRepository.findByAddress(contractAddress) + .stream() + .sorted((eventResultsCategoryResultsUtxoData1, eventResultsCategoryResultsUtxoData2) -> { + if (eventResultsCategoryResultsUtxoData1.getAbsoluteSlot() == eventResultsCategoryResultsUtxoData2.getAbsoluteSlot()) { + return 0; + } + + return (eventResultsCategoryResultsUtxoData1.getAbsoluteSlot() > eventResultsCategoryResultsUtxoData2.getAbsoluteSlot()) ? 1 : -1; + }) + .toList(); + } + + @Transactional + @Timed(value = "service.results.rollbackAfterSlot", histogram = true) + public int rollbackAfterSlot(long slot) { + log.info("Rollbacking UtxoData after slot:{}", slot); + + return utxoCategoryResultsDataRepository.deleteAllAfterSlot(slot); + } + +} diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/vote/MerkleRootHashService.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/vote/MerkleRootHashService.java index 3b7502078..a96226f4d 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/vote/MerkleRootHashService.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/vote/MerkleRootHashService.java @@ -9,6 +9,7 @@ import org.cardano.foundation.voting.domain.entity.MerkleRootHash; import org.cardano.foundation.voting.repository.MerkleRootHashRepository; import org.cardano.foundation.voting.service.reference_data.ReferenceDataService; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.zalando.problem.Problem; @@ -28,6 +29,7 @@ public class MerkleRootHashService { private final CardanoNetwork network; + @Timed(value = "service.merkle_root.isPresent", histogram = true) public Either isPresent(String event, String merkleRootHashHex) { var maybeEvent = referenceDataService.findEventByName(event); @@ -46,11 +48,19 @@ public Either isPresent(String event, String }).orElse(Either.right(new IsMerkleRootPresentResult(false, network))); } - @Timed(value = "service.reference.storeCommitments", histogram = true) + @Timed(value = "service.merkle_root.storeCommitments", histogram = true) @Transactional public List storeCommitments(List merkleRootHashes) { log.info("Storing commitments:{}", merkleRootHashes); return merkleRootHashRepository.saveAllAndFlush(merkleRootHashes); } + @Timed(value = "service.merkle_root.rollbackAfterSlot", histogram = true) + @Transactional + public int rollbackAfterSlot(@Param("slot") long slot) { + log.info("Deleting all after slot:{}", slot); + + return merkleRootHashRepository.deleteAllAfterSlot(slot); + } + } diff --git a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/vote/VotingPowerService.java b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/vote/VotingPowerService.java index 37883c99a..57ec328d4 100644 --- a/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/vote/VotingPowerService.java +++ b/backend-services/voting-ledger-follower-app/src/main/java/org/cardano/foundation/voting/service/vote/VotingPowerService.java @@ -1,10 +1,12 @@ package org.cardano.foundation.voting.service.vote; +import io.micrometer.core.annotation.Timed; import io.vavr.control.Either; import lombok.RequiredArgsConstructor; import org.cardano.foundation.voting.domain.entity.Event; import org.cardano.foundation.voting.service.blockchain_state.BlockchainDataStakePoolService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.zalando.problem.Problem; import static org.zalando.problem.Status.BAD_REQUEST; @@ -16,7 +18,9 @@ public class VotingPowerService { private final BlockchainDataStakePoolService blockchainDataStakePoolService; - public Either getVotingPower(Event event, String stakeAddress) { + @Timed(value = "service.voting_power.getVotingPower", histogram = true) + public Either getVotingPower(Event event, + String stakeAddress) { return switch (event.getVotingEventType()) { case USER_BASED -> { yield Either.left(Problem.builder() diff --git a/backend-services/voting-ledger-follower-app/src/main/resources/application.properties b/backend-services/voting-ledger-follower-app/src/main/resources/application.properties index 53ebdcc14..8c79377c9 100644 --- a/backend-services/voting-ledger-follower-app/src/main/resources/application.properties +++ b/backend-services/voting-ledger-follower-app/src/main/resources/application.properties @@ -52,7 +52,7 @@ cardano-client-lib.backend.type=${CLI_BACKEND:BLOCKFROST} organiser.account.stakeAddress=${ORGANISER_STAKE_ADDRESS:stake_test1uqwcz0754wwpuhm6xhdpda6u9enyahaj5ynlc9ay5l4mlms4pyqyg} # comma separated list of event ids that this app will be binding / serving -bind.on.event.ids=${BIND_ON_EVENT_IDS:CF_SUMMIT_2023_025E,CIP-1694_Pre_Ratification_3316,FRUITS_CF62} +bind.on.event.ids=${BIND_ON_EVENT_IDS:CF_SUMMIT_2023_4BCC} # yaci store props store.cardano.host=${CARDANO_NODE_HOST:preprod-node.world.dev.cardano.org} @@ -60,8 +60,10 @@ store.cardano.port=${CARDANO_NODE_PORT:30000} # protocol magic 1 = Cardano PreProd network store.cardano.protocol-magic=${CARDANO_NODE_PROTOCOL_MAGIC:1} -# CF Summit 2023 start block && CIP-1694 start slot +#store.cardano.sync-start-blockhash=${YACI_STORE_CARDANO_SYNC_START_BLOCK_HASH:2d5315806fb6ac1efafe266500f466d37181203b8e3a6d872ea6db02b6edb57a} store.cardano.sync-start-blockhash=${YACI_STORE_CARDANO_SYNC_START_BLOCK_HASH:3337e297121fda1262372c28de3d917bd2a60bb1e3c6326e3b7e832eb8017615} + +#store.cardano.sync-start-slot=${YACI_STORE_CARDANO_SYNC_START_SLOT:43014577} store.cardano.sync-start-slot=${YACI_STORE_CARDANO_SYNC_START_SLOT:38748711} # 1 day diff --git a/backend-services/voting-ledger-follower-app/src/main/resources/db/migration/h2/V0__voting_ledger_follower_app_init.sql b/backend-services/voting-ledger-follower-app/src/main/resources/db/migration/h2/V0__voting_ledger_follower_app_init.sql index a2eae386f..1e7640ae9 100644 --- a/backend-services/voting-ledger-follower-app/src/main/resources/db/migration/h2/V0__voting_ledger_follower_app_init.sql +++ b/backend-services/voting-ledger-follower-app/src/main/resources/db/migration/h2/V0__voting_ledger_follower_app_init.sql @@ -30,6 +30,30 @@ CREATE TABLE event ( CONSTRAINT pk_event PRIMARY KEY (id) ); +DROP TABLE IF EXISTS event_tally; + +CREATE TABLE event_tally ( + name VARCHAR(255) NOT NULL, -- human readable name, should never contain PII data + event_id VARCHAR(255) NOT NULL, + description VARCHAR(255), + type VARCHAR(255) NOT NULL, + + hydra_tally_config__contract_name VARCHAR(255) NOT NULL, + hydra_tally_config__contract_description VARCHAR(255), + hydra_tally_config__contract_version VARCHAR(255) NOT NULL, + hydra_tally_config__compiled_script TEXT NOT NULL, + hydra_tally_config__compiled_script_hash VARCHAR(255) NOT NULL, + hydra_tally_config__compiler_name VARCHAR(255) NOT NULL, + hydra_tally_config__compiler_version VARCHAR(255) NOT NULL, + hydra_tally_config__plutus_version VARCHAR(255) NOT NULL, + hydra_tally_config__verification_key_hashes TEXT NOT NULL, + + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE, + + CONSTRAINT pk_event_tally PRIMARY KEY (name) +); + DROP TABLE IF EXISTS category; CREATE TABLE category ( @@ -82,3 +106,24 @@ CREATE INDEX idx_merkle_root_hash_event_id_and_id CREATE INDEX idx_merkle_root_hash_rollback ON merkle_root_hash(absolute_slot); + + +DROP TABLE IF EXISTS utxo_category_result; + +CREATE TABLE event_category_result_utxo_data ( + id VARCHAR(255) NOT NULL, + address VARCHAR(255) NOT NULL, + tx_hash VARCHAR(255) NOT NULL, + index INT NOT NULL, + inline_datum TEXT NOT NULL, + absolute_slot BIGINT NOT NULL, + witnesses_hashes TEXT NOT NULL, + + CONSTRAINT pk_event_category_result_utxo_data PRIMARY KEY (id) +); + +CREATE INDEX idx_utxo_category_result_address + ON event_category_result_utxo_data(address); + +CREATE INDEX idx_utxo_category_result_rollback + ON event_category_result_utxo_data(absolute_slot); diff --git a/backend-services/voting-ledger-follower-app/src/main/resources/db/migration/postgresql/V0__voting_ledger_follower_app_init.sql b/backend-services/voting-ledger-follower-app/src/main/resources/db/migration/postgresql/V0__voting_ledger_follower_app_init.sql index a2eae386f..c0d635a19 100644 --- a/backend-services/voting-ledger-follower-app/src/main/resources/db/migration/postgresql/V0__voting_ledger_follower_app_init.sql +++ b/backend-services/voting-ledger-follower-app/src/main/resources/db/migration/postgresql/V0__voting_ledger_follower_app_init.sql @@ -30,6 +30,30 @@ CREATE TABLE event ( CONSTRAINT pk_event PRIMARY KEY (id) ); +DROP TABLE IF EXISTS event_tally; + +CREATE TABLE event_tally ( + name VARCHAR(255) NOT NULL, -- human readable name, should never contain PII data + event_id VARCHAR(255) NOT NULL, + description VARCHAR(255), + type VARCHAR(255) NOT NULL, + + hydra_tally_config__contract_name VARCHAR(255) NOT NULL, + hydra_tally_config__contract_description VARCHAR(255), + hydra_tally_config__contract_version VARCHAR(255) NOT NULL, + hydra_tally_config__compiled_script TEXT NOT NULL, + hydra_tally_config__compiled_script_hash VARCHAR(255) NOT NULL, + hydra_tally_config__compiler_name VARCHAR(255) NOT NULL, + hydra_tally_config__compiler_version VARCHAR(255) NOT NULL, + hydra_tally_config__plutus_version VARCHAR(255) NOT NULL, + hydra_tally_config__verification_key_hashes TEXT NOT NULL, + + created_at TIMESTAMP WITHOUT TIME ZONE, + updated_at TIMESTAMP WITHOUT TIME ZONE, + + CONSTRAINT pk_event_tally PRIMARY KEY (name) +); + DROP TABLE IF EXISTS category; CREATE TABLE category ( @@ -82,3 +106,23 @@ CREATE INDEX idx_merkle_root_hash_event_id_and_id CREATE INDEX idx_merkle_root_hash_rollback ON merkle_root_hash(absolute_slot); + +DROP TABLE IF EXISTS utxo_category_result; + +CREATE TABLE event_category_result_utxo_data ( + id VARCHAR(255) NOT NULL, + address VARCHAR(255) NOT NULL, + tx_hash VARCHAR(255) NOT NULL, + index INT NOT NULL, + inline_datum TEXT NOT NULL, + absolute_slot BIGINT NOT NULL, + witnesses_hashes TEXT NOT NULL, + + CONSTRAINT pk_event_category_result_utxo_data PRIMARY KEY (id) +); + +CREATE INDEX idx_utxo_category_result_address + ON event_category_result_utxo_data(address); + +CREATE INDEX idx_utxo_category_result_rollback + ON event_category_result_utxo_data(absolute_slot); \ No newline at end of file diff --git a/deploy/cf-cardano-ballot-voting-api/templates/deployment.yaml b/deploy/cf-cardano-ballot-voting-api/templates/deployment.yaml index 546493671..a32e9167f 100644 --- a/deploy/cf-cardano-ballot-voting-api/templates/deployment.yaml +++ b/deploy/cf-cardano-ballot-voting-api/templates/deployment.yaml @@ -44,6 +44,17 @@ spec: - name: YACI_STORE_CARDANO_SYNC_START_SLOT value: {{ .Values.yaci.startSlotNumber | quote }} + - name: CARDANO_NODE_HOST + value: {{ $.Values.yaci.cardanoNodeHost | default "preprod-node.world.dev.cardano.org" }} + - name: CARDANO_NODE_PORT + value: {{ $.Values.yaci.cardanoNodePort | default 30000 | quote }} + - name: CARDANO_NODE_PROTOCOL_MAGIC + value: {{ $.Values.yaci.cardanoNodeProtocolMagic | default 1 | quote }} + - name: YACI_STORE_CARDANO_SYNC_START_BLOCK_HASH + value: {{ $.Values.yaci.startBlockHash }} + - name: YACI_STORE_CARDANO_SYNC_START_SLOT + value: {{ $.Values.yaci.startSlotNumber | quote }} + - name: CORS_ALLOWED_ORIGINS value: {{ tpl .Values.corsAllowedOrigins . }} diff --git a/deploy/summit-2023-main-app/values-pro-mainnet.yaml b/deploy/summit-2023-main-app/values-pro-mainnet.yaml index c60d678ce..e89023a17 100644 --- a/deploy/summit-2023-main-app/values-pro-mainnet.yaml +++ b/deploy/summit-2023-main-app/values-pro-mainnet.yaml @@ -13,25 +13,20 @@ cf-summit-2023-ui: enabled: true values: frontendUrl: https://{{ .Values.domain }} - votingAppServerUrl: https://api.voting.summit.cardano.org - votingLedgerFollowerAppServerUrl: https://follower-api.voting.summit.cardano.org - votingVerificationAppServerUrl: https://verification-api.voting.summit.cardano.org - userVerificationServerUrl: https://user-verification.voting.summit.cardano.org + votingAppServerUrl: https://api.pro.cf-summit-2023-mainnet.eu-west-1.voting.summit.cardano.org + votingLedgerFollowerAppServerUrl: https://follower-api.pro.cf-summit-2023-mainnet.eu-west-1.voting.summit.cardano.org + votingVerificationAppServerUrl: https://verification-api.pro.cf-summit-2023-mainnet.eu-west-1.voting.summit.cardano.org + userVerificationServerUrl: https://user-verification.pro.cf-summit-2023-mainnet.eu-west-1.voting.summit.cardano.org targetNetwork: MAIN - eventId: CARDANO_SUMMIT_AWARDS_2023 - discordChannelUrl: https://discord.gg/FeCbA2wYF8 - discordBotUrl: https://discord.gg/65Hq3gqFwE - discordSupportChannelUrl: https://discord.gg/svAcdYjMXx + eventId: CF_SUMMIT_2023_TEST2 + discordChannelUrl: https://discord.gg/XekFHYXNmu + discordBotUrl: https://discord.com/channels/945974991718068254/1149627691310530570 image: - tag: 0.2.64-834-3fca3 - ingress: - additionalDomains: - - voting.summit.cardano.org + tag: 0.2.55 cf-summit-2023-voting-api: enabled: true values: - springProfiles: prod useJwt: true leaderboardForceResults: "false" resources: @@ -52,63 +47,51 @@ cf-summit-2023-voting-api: active: "true" voteCommitmentCronExpression: "0 30 * * * *" image: - tag: 0.2.64-783 + tag: 0.2.55 ledgerFollowerAppUrl: http://cf-summit-2023-ledger-follower-api-voting-ledger-follower-app:9090 - corsAllowedOrigins: https://voting.summit.cardano.org,https://{{ .Values.domain }} + corsAllowedOrigins: https://{{ .Values.domain }} yaci: - startBlockHash: 285f58fd09cbb95ac5ffd6f644f7907dfb3faff63155b8a86a9c7b0e99503397 - startSlotNumber: "103737886" + startBlockHash: c5255819ac8c2112b483e031cdd1b3fd69dfbe591a0dc9e46d6e2db36907ee7c + startSlotNumber: "104284818" network: main cardanoNodeHost: relays-new.cardano-mainnet.iohk.io cardanoNodePort: "3001" cardanoNodeProtocolMagic: "764824073" monitoring: alerting: - enabled: true - ingress: - additionalDomains: - - api.voting.summit.cardano.org + enabled: false cf-summit-2023-verification-api: enabled: true values: image: - tag: 0.2.64-783 - springProfiles: prod + tag: 0.2.55 ledgerFollowerAppUrl: http://cf-summit-2023-ledger-follower-api-voting-ledger-follower-app:9090 - corsAllowedOrigins: https://voting.summit.cardano.org,https://{{ .Values.domain }} - ingress: - additionalDomains: - - verification-api.voting.summit.cardano.org + corsAllowedOrigins: https://{{ .Values.domain }} cf-user-verification-service: enabled: true values: image: - tag: 0.2.64-783 - springProfiles: prod - discordBotEventIdBinding: CARDANO_SUMMIT_AWARDS_2023 + tag: 0.2.55 + discordBotEventIdBinding: CF_SUMMIT_2023_TEST2 ledgerFollowerAppUrl: http://cf-summit-2023-ledger-follower-api-voting-ledger-follower-app:9090 - corsAllowedOrigins: https://voting.summit.cardano.org,https://{{ .Values.domain }} - ingress: - additionalDomains: - - user-verification.voting.summit.cardano.org + corsAllowedOrigins: https://{{ .Values.domain }} cf-summit-2023-ledger-follower-api: enabled: true values: image: - tag: 0.2.64-783 - springProfiles: prod - bindOnEventIds: CARDANO_SUMMIT_AWARDS_2023 + tag: 0.2.55 + bindOnEventIds: CF_SUMMIT_2023_TEST1,CF_SUMMIT_2023_TEST2,CF_SUMMIT_2023_TEST3 yaci: - startBlockHash: 285f58fd09cbb95ac5ffd6f644f7907dfb3faff63155b8a86a9c7b0e99503397 - startSlotNumber: "103737886" + startBlockHash: 6bb9228e3b746366e24f2681021d619395b850b34c2db020c4928285dcf7b0c2 + startSlotNumber: "103209875" network: main cardanoNodeHost: relays-new.cardano-mainnet.iohk.io cardanoNodePort: "3001" cardanoNodeProtocolMagic: "764824073" - corsAllowedOrigins: https://voting.summit.cardano.org,https://{{ .Values.domain }} + corsAllowedOrigins: https://{{ .Values.domain }} instances: - name: follower-1 enabled: true @@ -118,22 +101,17 @@ cf-summit-2023-ledger-follower-api: enabled: true # imageTag: 0.2.37 schemaName: follower_2 - cardanoSnapshotBoundsCheckEnabled: "true" startupProbe: initialDelaySeconds: 0 periodSeconds: 30 failureThreshold: 20 - ingress: - additionalDomains: - - follower-api.voting.summit.cardano.org cf-discord-wallet-verification-bot: enabled: true values: image: tag: 11f6f15 - springProfiles: prod - backendBaseUrl: https://user-verification.voting.summit.cardano.org - frontendUrl: https://voting.summit.cardano.org + backendBaseUrl: https://user-verification.{{ .Values.domain }} + frontendUrl: https://{{ .Values.domain }} diagnosticMode: enabled: false diff --git a/scripts/witness_check.sc b/scripts/witness_check.sc new file mode 100644 index 000000000..8a79ee169 --- /dev/null +++ b/scripts/witness_check.sc @@ -0,0 +1,23 @@ +import $ivy.`com.bloxbean.cardano:cardano-client-lib:0.5.0` + +import $ivy.`org.slf4j:slf4j-simple:2.0.9` + +import com.bloxbean.cardano.client.crypto.VerificationKey +import com.bloxbean.cardano.client.util.HexUtil +import com.bloxbean.cardano.client.crypto.KeyGenUtil + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +val logger = LoggerFactory.getLogger(getClass()); + +@main +def main(vkCbor: String) = { + val vk = new VerificationKey(vkCbor); + + val key = HexUtil.encodeHexString(vk.getBytes()); + val keyHash = KeyGenUtil.getKeyHash(vk) + + println(s"key: ${key}") + println(s"keyHash (blake 224): ${keyHash}") +} diff --git a/ui/summit-2023/Dockerfile b/ui/summit-2023/Dockerfile index e1710dcf2..6b279910f 100644 --- a/ui/summit-2023/Dockerfile +++ b/ui/summit-2023/Dockerfile @@ -23,6 +23,7 @@ ENV NODE_ENV="production" \ REACT_APP_COMMIT_HASH="INJECT_COMMIT_HASH" \ REACT_APP_DISCORD_CHANNEL_URL="https://discord.gg/FeCbA2wYF8" \ REACT_APP_DISCORD_BOT_URL="https://discord.gg/65Hq3gqFwE" \ + REACT_APP_DISCORD_SUPPORT_CHANNEL_URL="https://discord.gg/svAcdYjMXx" \ REACT_APP_SUPPORTED_WALLETS="flint,eternl,nami,typhoncip30,yoroi,nufi,gerowallet,lace" # Copy built assets from builder diff --git a/ui/summit-2023/env.global.tmp.js b/ui/summit-2023/env.global.tmp.js index 77db547b8..f314102e2 100644 --- a/ui/summit-2023/env.global.tmp.js +++ b/ui/summit-2023/env.global.tmp.js @@ -6,6 +6,13 @@ window.env.REACT_APP_VOTING_VERIFICATION_APP_SERVER_URL = `$REACT_APP_VOTING_VER window.env.REACT_APP_USER_VERIFICATION_SERVER_URL = `$REACT_APP_USER_VERIFICATION_SERVER_URL`; window.env.REACT_APP_TARGET_NETWORK = `$REACT_APP_TARGET_NETWORK`; +window.env.REACT_APP_DISCORD_CHANNEL_URL = `$REACT_APP_DISCORD_CHANNEL_URL`; +window.env.REACT_APP_DISCORD_BOT_URL = `$REACT_APP_DISCORD_BOT_URL`; +window.env.REACT_APP_DISCORD_SUPPORT_CHANNEL_URL = `$REACT_APP_DISCORD_SUPPORT_CHANNEL_URL`; + window.env.REACT_APP_EVENT_ID = `$REACT_APP_EVENT_ID`; -window.env.REACT_APP_SUPPORTED_WALLETS = `$REACT_APP_SUPPORTED_WALLETS`; \ No newline at end of file +window.env.REACT_APP_SUPPORTED_WALLETS = `$REACT_APP_SUPPORTED_WALLETS`; + +window.env.REACT_APP_SHOW_WINNERS = `$REACT_APP_SHOW_WINNERS`; +window.env.REACT_APP_SHOW_HYDRA_TALLY = `$REACT_APP_SHOW_HYDRA_TALLY`; diff --git a/ui/summit-2023/package-lock.json b/ui/summit-2023/package-lock.json index 59dd75cbc..d67ca8985 100644 --- a/ui/summit-2023/package-lock.json +++ b/ui/summit-2023/package-lock.json @@ -1,12 +1,12 @@ { "name": "summit2023", - "version": "0.2.62", + "version": "0.2.63", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "summit2023", - "version": "0.2.62", + "version": "0.2.63", "dependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@cardano-foundation/cardano-connect-with-wallet": "^0.2.6", @@ -15,7 +15,9 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.11.16", + "@mui/lab": "^5.0.0-alpha.147", "@mui/material": "^5.13.0", + "@mui/system": "^5.14.13", "@reduxjs/toolkit": "^1.9.5", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", @@ -41,6 +43,7 @@ "react-dom": "^18.2.0", "react-i18next": "^12.2.0", "react-inject-env": "^2.0.0-next.17", + "react-masonry-css": "^1.0.16", "react-minimal-pie-chart": "^8.4.0", "react-qr-code": "^2.0.12", "react-redux": "^8.1.2", @@ -55,6 +58,7 @@ "sort-package-json": "^2.5.1", "start-server-and-test": "^2.0.0", "swiper": "^9.3.1", + "tss-react": "^4.9.2", "typescript": "^4.9.5", "uuid": "^9.0.0", "web-vitals": "^2.1.4" @@ -2073,9 +2077,9 @@ "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" }, "node_modules/@babel/runtime": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.15.tgz", - "integrity": "sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==", + "version": "7.23.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.1.tgz", + "integrity": "sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -4137,6 +4141,78 @@ } } }, + "node_modules/@mui/lab": { + "version": "5.0.0-alpha.147", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.147.tgz", + "integrity": "sha512-AZjDEl31/co9baYrOBHMlXI8BCrV9JTCGDE2+IswVm32HNPYL5V2gHL3wKqn+MIFeCEg+z2es+8CU/Bau0JNSQ==", + "dependencies": { + "@babel/runtime": "^7.23.1", + "@mui/base": "5.0.0-beta.18", + "@mui/system": "^5.14.12", + "@mui/types": "^7.2.5", + "@mui/utils": "^5.14.12", + "@mui/x-tree-view": "6.0.0-alpha.1", + "clsx": "^2.0.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/lab/node_modules/@mui/base": { + "version": "5.0.0-beta.18", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.18.tgz", + "integrity": "sha512-e9ZCy/ndhyt5MTshAS3qAUy/40UiO0jX+kAo6a+XirrPJE+rrQW+mKPSI0uyp+5z4Vh+z0pvNoJ2S2gSrNz3BQ==", + "dependencies": { + "@babel/runtime": "^7.23.1", + "@floating-ui/react-dom": "^2.0.2", + "@mui/types": "^7.2.5", + "@mui/utils": "^5.14.12", + "@popperjs/core": "^2.11.8", + "clsx": "^2.0.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "5.14.10", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.14.10.tgz", @@ -4182,12 +4258,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.14.10", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.14.10.tgz", - "integrity": "sha512-f67xOj3H06wWDT9xBg7hVL/HSKNF+HG1Kx0Pm23skkbEqD2Ef2Lif64e5nPdmWVv+7cISCYtSuE2aeuzrZe78w==", + "version": "5.14.13", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.14.13.tgz", + "integrity": "sha512-5EFqk4tqiSwPguj4NW/6bUf4u1qoUWXy9lrKfNh9H6oAohM+Ijv/7qSxFjnxPGBctj469/Sc5aKAR35ILBKZLQ==", "dependencies": { - "@babel/runtime": "^7.22.15", - "@mui/utils": "^5.14.10", + "@babel/runtime": "^7.23.1", + "@mui/utils": "^5.14.13", "prop-types": "^15.8.1" }, "engines": { @@ -4208,11 +4284,11 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.14.10", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.14.10.tgz", - "integrity": "sha512-EJckxmQHrsBvDbFu1trJkvjNw/1R7jfNarnqPSnL+jEQawCkQIqVELWLrlOa611TFtxSJGkdUfCFXeJC203HVg==", + "version": "5.14.13", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.14.13.tgz", + "integrity": "sha512-1ff/egFQl26hiwcUtCMKAkp4Sgqpm3qIewmXq+GN27fb44lDIACquehMFBuadOjceOFmbIXbayzbA46ZyqFYzA==", "dependencies": { - "@babel/runtime": "^7.22.15", + "@babel/runtime": "^7.23.1", "@emotion/cache": "^11.11.0", "csstype": "^3.1.2", "prop-types": "^15.8.1" @@ -4239,15 +4315,15 @@ } }, "node_modules/@mui/system": { - "version": "5.14.10", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.14.10.tgz", - "integrity": "sha512-QQmtTG/R4gjmLiL5ECQ7kRxLKDm8aKKD7seGZfbINtRVJDyFhKChA1a+K2bfqIAaBo1EMDv+6FWNT1Q5cRKjFA==", - "dependencies": { - "@babel/runtime": "^7.22.15", - "@mui/private-theming": "^5.14.10", - "@mui/styled-engine": "^5.14.10", - "@mui/types": "^7.2.4", - "@mui/utils": "^5.14.10", + "version": "5.14.13", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.14.13.tgz", + "integrity": "sha512-+5+Dx50lG4csbx2sGjrKLozXQJeCpJ4dIBZolyFLkZ+XphD1keQWouLUvJkPQ3MSglLLKuD37pp52YjMncZMEQ==", + "dependencies": { + "@babel/runtime": "^7.23.1", + "@mui/private-theming": "^5.14.13", + "@mui/styled-engine": "^5.14.13", + "@mui/types": "^7.2.6", + "@mui/utils": "^5.14.13", "clsx": "^2.0.0", "csstype": "^3.1.2", "prop-types": "^15.8.1" @@ -4278,11 +4354,11 @@ } }, "node_modules/@mui/types": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.4.tgz", - "integrity": "sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA==", + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.6.tgz", + "integrity": "sha512-7sjLQrUmBwufm/M7jw/quNiPK/oor2+pGUQP2CULRcFCArYTq78oJ3D5esTaL0UMkXKJvDqXn6Ike69yAOBQng==", "peerDependencies": { - "@types/react": "*" + "@types/react": "^17.0.0 || ^18.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -4291,12 +4367,12 @@ } }, "node_modules/@mui/utils": { - "version": "5.14.10", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.14.10.tgz", - "integrity": "sha512-Rn+vYQX7FxkcW0riDX/clNUwKuOJFH45HiULxwmpgnzQoQr3A0lb+QYwaZ+FAkZrR7qLoHKmLQlcItu6LT0y/Q==", + "version": "5.14.13", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.14.13.tgz", + "integrity": "sha512-2AFpyXWw7uDCIqRu7eU2i/EplZtks5LAMzQvIhC79sPV9IhOZU2qwOWVnPtdctRXiQJOAaXulg+A37pfhEueQw==", "dependencies": { - "@babel/runtime": "^7.22.15", - "@types/prop-types": "^15.7.5", + "@babel/runtime": "^7.23.1", + "@types/prop-types": "^15.7.7", "prop-types": "^15.8.1", "react-is": "^18.2.0" }, @@ -4317,6 +4393,35 @@ } } }, + "node_modules/@mui/x-tree-view": { + "version": "6.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-6.0.0-alpha.1.tgz", + "integrity": "sha512-JUG3HmBrmGEALbCFg1b+i7h726e1dWYZs4db3syO1j+Q++E3nbvE4Lehp5yGTFm+8esH0Tny50tuJaa4WX6VSA==", + "dependencies": { + "@babel/runtime": "^7.22.6", + "@mui/utils": "^5.14.3", + "@types/react-transition-group": "^4.4.6", + "clsx": "^2.0.0", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/base": "^5.0.0-alpha.87", + "@mui/material": "^5.8.6", + "@mui/system": "^5.8.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, "node_modules/@nicolo-ribaudo/chokidar-2": { "version": "2.1.8-no-fsevents.3", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", @@ -5541,9 +5646,9 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==" }, "node_modules/@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + "version": "15.7.8", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.8.tgz", + "integrity": "sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==" }, "node_modules/@types/q": { "version": "1.5.6", @@ -19192,6 +19297,14 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, + "node_modules/react-masonry-css": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/react-masonry-css/-/react-masonry-css-1.0.16.tgz", + "integrity": "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==", + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/react-minimal-pie-chart": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/react-minimal-pie-chart/-/react-minimal-pie-chart-8.4.0.tgz", @@ -22259,6 +22372,30 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/tss-react": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/tss-react/-/tss-react-4.9.2.tgz", + "integrity": "sha512-0qOuDpar3q3N59Jsl50oDd+Zu3wfXv2rdf4VlPzvuekH6mkAgUVobZV3j69NPH0nm3Vv5xDRACjVUqVWmaNW0g==", + "dependencies": { + "@emotion/cache": "*", + "@emotion/serialize": "*", + "@emotion/utils": "*" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/server": "^11.4.0", + "@mui/material": "^5.0.0", + "react": "^16.8.0 || ^17.0.2 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/server": { + "optional": true + }, + "@mui/material": { + "optional": true + } + } + }, "node_modules/tsutils": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", diff --git a/ui/summit-2023/package.json b/ui/summit-2023/package.json index 705168033..90c053fc0 100644 --- a/ui/summit-2023/package.json +++ b/ui/summit-2023/package.json @@ -13,7 +13,9 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.11.16", + "@mui/lab": "^5.0.0-alpha.147", "@mui/material": "^5.13.0", + "@mui/system": "^5.14.13", "@reduxjs/toolkit": "^1.9.5", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", @@ -39,6 +41,7 @@ "react-dom": "^18.2.0", "react-i18next": "^12.2.0", "react-inject-env": "^2.0.0-next.17", + "react-masonry-css": "^1.0.16", "react-minimal-pie-chart": "^8.4.0", "react-qr-code": "^2.0.12", "react-redux": "^8.1.2", @@ -53,13 +56,14 @@ "sort-package-json": "^2.5.1", "start-server-and-test": "^2.0.0", "swiper": "^9.3.1", + "tss-react": "^4.9.2", "typescript": "^4.9.5", "uuid": "^9.0.0", "web-vitals": "^2.1.4" }, "scripts": { "start": "react-scripts start", - "build": "react-scripts build", + "build": "GENERATE_SOURCEMAP=false react-scripts build", "jest-test": "react-scripts test", "cypress:open": "cypress open", "cypress:run": "cypress run", diff --git a/ui/summit-2023/public/android-chrome-192x192.png b/ui/summit-2023/public/android-chrome-192x192.png new file mode 100644 index 000000000..dd8804240 Binary files /dev/null and b/ui/summit-2023/public/android-chrome-192x192.png differ diff --git a/ui/summit-2023/public/android-chrome-512x512.png b/ui/summit-2023/public/android-chrome-512x512.png new file mode 100644 index 000000000..932cba2c1 Binary files /dev/null and b/ui/summit-2023/public/android-chrome-512x512.png differ diff --git a/ui/summit-2023/public/apple-touch-icon.png b/ui/summit-2023/public/apple-touch-icon.png new file mode 100644 index 000000000..b189d7453 Binary files /dev/null and b/ui/summit-2023/public/apple-touch-icon.png differ diff --git a/ui/summit-2023/public/favicon-16x16.png b/ui/summit-2023/public/favicon-16x16.png new file mode 100644 index 000000000..76a4f12a2 Binary files /dev/null and b/ui/summit-2023/public/favicon-16x16.png differ diff --git a/ui/summit-2023/public/favicon-256x256.png b/ui/summit-2023/public/favicon-256x256.png new file mode 100644 index 000000000..d1b81c35e Binary files /dev/null and b/ui/summit-2023/public/favicon-256x256.png differ diff --git a/ui/summit-2023/public/favicon-32x32.png b/ui/summit-2023/public/favicon-32x32.png new file mode 100644 index 000000000..3259b2f31 Binary files /dev/null and b/ui/summit-2023/public/favicon-32x32.png differ diff --git a/ui/summit-2023/public/favicon.ico b/ui/summit-2023/public/favicon.ico index 567970ecb..74b73f90f 100644 Binary files a/ui/summit-2023/public/favicon.ico and b/ui/summit-2023/public/favicon.ico differ diff --git a/ui/summit-2023/public/index.html b/ui/summit-2023/public/index.html index 6ab8a80c7..5a6b6d6d2 100644 --- a/ui/summit-2023/public/index.html +++ b/ui/summit-2023/public/index.html @@ -2,6 +2,14 @@ + + + + + - Cardano Summit 2023 + Cardano Ballot - Cardano Summit Awards Voting - + diff --git a/ui/summit-2023/public/manifest.json b/ui/summit-2023/public/manifest.json index d28f73f8e..5c244410f 100644 --- a/ui/summit-2023/public/manifest.json +++ b/ui/summit-2023/public/manifest.json @@ -1,14 +1,24 @@ { - "short_name": "Summit 2023", - "name": "Cardano Summit 2023", + "short_name": "", + "name": "Cardano Ballot - Cardano Summit Awards Voting", "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + }, { "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", + "sizes": "256x256 32x32 16x16", "type": "image/x-icon" } ], "start_url": ".", "display": "standalone", - "theme_color": "#000000" + "theme_color": "#03021f" } diff --git a/ui/summit-2023/public/static/CardanoBallot-category-1.jpg b/ui/summit-2023/public/static/CardanoBallot-category-1.jpg new file mode 100644 index 000000000..08d91dea3 Binary files /dev/null and b/ui/summit-2023/public/static/CardanoBallot-category-1.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-1.png b/ui/summit-2023/public/static/CardanoBallot-category-1.png deleted file mode 100644 index 1019fcc53..000000000 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-1.png and /dev/null differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-10.jpg b/ui/summit-2023/public/static/CardanoBallot-category-10.jpg index 9284611fe..7741eb8cb 100644 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-10.jpg and b/ui/summit-2023/public/static/CardanoBallot-category-10.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-11.jpg b/ui/summit-2023/public/static/CardanoBallot-category-11.jpg index 4df28b762..9df706570 100644 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-11.jpg and b/ui/summit-2023/public/static/CardanoBallot-category-11.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-13.jpg b/ui/summit-2023/public/static/CardanoBallot-category-13.jpg index e198c4736..dbf552fc2 100644 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-13.jpg and b/ui/summit-2023/public/static/CardanoBallot-category-13.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-14.jpg b/ui/summit-2023/public/static/CardanoBallot-category-14.jpg index 0d2734605..6f7674f19 100644 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-14.jpg and b/ui/summit-2023/public/static/CardanoBallot-category-14.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-2.jpg b/ui/summit-2023/public/static/CardanoBallot-category-2.jpg new file mode 100644 index 000000000..3d93260d2 Binary files /dev/null and b/ui/summit-2023/public/static/CardanoBallot-category-2.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-2.png b/ui/summit-2023/public/static/CardanoBallot-category-2.png deleted file mode 100644 index 408a74867..000000000 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-2.png and /dev/null differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-3.jpg b/ui/summit-2023/public/static/CardanoBallot-category-3.jpg new file mode 100644 index 000000000..2e0e44045 Binary files /dev/null and b/ui/summit-2023/public/static/CardanoBallot-category-3.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-3.png b/ui/summit-2023/public/static/CardanoBallot-category-3.png deleted file mode 100644 index 72fa86767..000000000 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-3.png and /dev/null differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-4.jpg b/ui/summit-2023/public/static/CardanoBallot-category-4.jpg new file mode 100644 index 000000000..c342872fa Binary files /dev/null and b/ui/summit-2023/public/static/CardanoBallot-category-4.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-4.png b/ui/summit-2023/public/static/CardanoBallot-category-4.png deleted file mode 100644 index 20c471e89..000000000 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-4.png and /dev/null differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-5.jpg b/ui/summit-2023/public/static/CardanoBallot-category-5.jpg new file mode 100644 index 000000000..6549f85a7 Binary files /dev/null and b/ui/summit-2023/public/static/CardanoBallot-category-5.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-5.png b/ui/summit-2023/public/static/CardanoBallot-category-5.png deleted file mode 100644 index e88ae6b96..000000000 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-5.png and /dev/null differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-6.jpg b/ui/summit-2023/public/static/CardanoBallot-category-6.jpg new file mode 100644 index 000000000..3f2f49711 Binary files /dev/null and b/ui/summit-2023/public/static/CardanoBallot-category-6.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-6.png b/ui/summit-2023/public/static/CardanoBallot-category-6.png deleted file mode 100644 index 6a8e18078..000000000 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-6.png and /dev/null differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-7.jpg b/ui/summit-2023/public/static/CardanoBallot-category-7.jpg new file mode 100644 index 000000000..614be5d4a Binary files /dev/null and b/ui/summit-2023/public/static/CardanoBallot-category-7.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-7.png b/ui/summit-2023/public/static/CardanoBallot-category-7.png deleted file mode 100644 index 02e3e99a7..000000000 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-7.png and /dev/null differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-8.jpg b/ui/summit-2023/public/static/CardanoBallot-category-8.jpg new file mode 100644 index 000000000..505914856 Binary files /dev/null and b/ui/summit-2023/public/static/CardanoBallot-category-8.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-8.png b/ui/summit-2023/public/static/CardanoBallot-category-8.png deleted file mode 100644 index 71097dc35..000000000 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-8.png and /dev/null differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-9.jpg b/ui/summit-2023/public/static/CardanoBallot-category-9.jpg new file mode 100644 index 000000000..223cabfc7 Binary files /dev/null and b/ui/summit-2023/public/static/CardanoBallot-category-9.jpg differ diff --git a/ui/summit-2023/public/static/CardanoBallot-category-9.png b/ui/summit-2023/public/static/CardanoBallot-category-9.png deleted file mode 100644 index 66f4fe3a7..000000000 Binary files a/ui/summit-2023/public/static/CardanoBallot-category-9.png and /dev/null differ diff --git a/ui/summit-2023/public/static/cardano-ballot.png b/ui/summit-2023/public/static/cardano-ballot.png index 7b4b42a92..dcffaa3b8 100644 Binary files a/ui/summit-2023/public/static/cardano-ballot.png and b/ui/summit-2023/public/static/cardano-ballot.png differ diff --git a/ui/summit-2023/public/static/cardano-summit-award.mp4 b/ui/summit-2023/public/static/cardano-summit-award.mp4 new file mode 100644 index 000000000..b93944753 Binary files /dev/null and b/ui/summit-2023/public/static/cardano-summit-award.mp4 differ diff --git a/ui/summit-2023/public/static/cardano-summit-award.png b/ui/summit-2023/public/static/cardano-summit-award.png new file mode 100644 index 000000000..783eb9290 Binary files /dev/null and b/ui/summit-2023/public/static/cardano-summit-award.png differ diff --git a/ui/summit-2023/public/static/categories.png b/ui/summit-2023/public/static/categories.png index bb6303a81..7b91d48fa 100644 Binary files a/ui/summit-2023/public/static/categories.png and b/ui/summit-2023/public/static/categories.png differ diff --git a/ui/summit-2023/public/static/categories_card.png b/ui/summit-2023/public/static/categories_card.png index 63beeaf14..43f8987d2 100644 Binary files a/ui/summit-2023/public/static/categories_card.png and b/ui/summit-2023/public/static/categories_card.png differ diff --git a/ui/summit-2023/public/static/hexFront.jpg b/ui/summit-2023/public/static/hexFront.jpg new file mode 100644 index 000000000..884659a41 Binary files /dev/null and b/ui/summit-2023/public/static/hexFront.jpg differ diff --git a/ui/summit-2023/public/static/home-graphic-bg-bottom-old.svg b/ui/summit-2023/public/static/home-graphic-bg-bottom-old.svg new file mode 100644 index 000000000..02eebdd59 --- /dev/null +++ b/ui/summit-2023/public/static/home-graphic-bg-bottom-old.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/summit-2023/public/static/home-graphic-bg-bottom.svg b/ui/summit-2023/public/static/home-graphic-bg-bottom.svg index 02eebdd59..df55e28eb 100644 --- a/ui/summit-2023/public/static/home-graphic-bg-bottom.svg +++ b/ui/summit-2023/public/static/home-graphic-bg-bottom.svg @@ -1,9 +1,9 @@ - - + + - + - + diff --git a/ui/summit-2023/public/static/hydra-aiken.png b/ui/summit-2023/public/static/hydra-aiken.png new file mode 100644 index 000000000..b555095a9 Binary files /dev/null and b/ui/summit-2023/public/static/hydra-aiken.png differ diff --git a/ui/summit-2023/public/static/list_of_wallets.png b/ui/summit-2023/public/static/list_of_wallets.png index 573ffc419..c93d98cda 100644 Binary files a/ui/summit-2023/public/static/list_of_wallets.png and b/ui/summit-2023/public/static/list_of_wallets.png differ diff --git a/ui/summit-2023/public/static/share-card.png b/ui/summit-2023/public/static/share-card.png new file mode 100644 index 000000000..1217fefd2 Binary files /dev/null and b/ui/summit-2023/public/static/share-card.png differ diff --git a/ui/summit-2023/public/static/sign_with_wallet.png b/ui/summit-2023/public/static/sign_with_wallet.png index cc5773d06..768eb9049 100644 Binary files a/ui/summit-2023/public/static/sign_with_wallet.png and b/ui/summit-2023/public/static/sign_with_wallet.png differ diff --git a/ui/summit-2023/public/static/sms_verification.png b/ui/summit-2023/public/static/sms_verification.png index 120991a20..614adee4c 100644 Binary files a/ui/summit-2023/public/static/sms_verification.png and b/ui/summit-2023/public/static/sms_verification.png differ diff --git a/ui/summit-2023/public/static/submit.png b/ui/summit-2023/public/static/submit.png index 7b818ead1..0927dbf41 100644 Binary files a/ui/summit-2023/public/static/submit.png and b/ui/summit-2023/public/static/submit.png differ diff --git a/ui/summit-2023/public/static/view_nominees.png b/ui/summit-2023/public/static/view_nominees.png index 116ee1dbe..4d5ad8daa 100644 Binary files a/ui/summit-2023/public/static/view_nominees.png and b/ui/summit-2023/public/static/view_nominees.png differ diff --git a/ui/summit-2023/public/static/vote_for_nominee.png b/ui/summit-2023/public/static/vote_for_nominee.png index 376fa055e..c7ec67d86 100644 Binary files a/ui/summit-2023/public/static/vote_for_nominee.png and b/ui/summit-2023/public/static/vote_for_nominee.png differ diff --git a/ui/summit-2023/public/static/wwcd.svg b/ui/summit-2023/public/static/wwcd.svg new file mode 100644 index 000000000..8f308321d --- /dev/null +++ b/ui/summit-2023/public/static/wwcd.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/ui/summit-2023/src/App.scss b/ui/summit-2023/src/App.scss index b438141d5..38d3e4800 100644 --- a/ui/summit-2023/src/App.scss +++ b/ui/summit-2023/src/App.scss @@ -9,14 +9,18 @@ body { background-color: #f5f9ff !important; background-image: url('./common/resources/images/home-graphic-bg-bottom.svg'); background-repeat: no-repeat; - background-position: right; - background-size: calc(100vw - 50%); - height: 100%; + background-position: bottom -24px right; + background-size: 100vh; @media only screen and (max-width: 1080px) { background-image: none; } } +a, +a:visited { + color: var(--color-primary) !important; +} + .content { margin: 2%; display: flex; diff --git a/ui/summit-2023/src/App.tsx b/ui/summit-2023/src/App.tsx index 9e656fb2e..0a46d7f5b 100644 --- a/ui/summit-2023/src/App.tsx +++ b/ui/summit-2023/src/App.tsx @@ -1,16 +1,16 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Footer } from './components/common/Footer/Footer'; -import { BrowserRouter } from 'react-router-dom'; -import './App.scss'; -import { useDispatch, useSelector } from 'react-redux'; -import { setEventData, setUserVotes, setWalletIsLoggedIn, setWalletIsVerified, setWinners } from './store/userSlice'; +import { useCardano } from '@cardano-foundation/cardano-connect-with-wallet'; import { Box, CircularProgress, Container, Grid, useMediaQuery, useTheme } from '@mui/material'; +import { useDispatch, useSelector } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; + +import { Footer } from './components/common/Footer/Footer'; +import { setEventData, setUserVotes, setWalletIsLoggedIn, setWalletIsVerified } from './store/userSlice'; import Header from './components/common/Header/Header'; import { PageRouter } from './routes'; import { env } from './common/constants/env'; import { RootState } from './store'; import { useLocalStorage } from './common/hooks/useLocalStorage'; -import { useCardano } from '@cardano-foundation/cardano-connect-with-wallet'; import { getIsVerified } from 'common/api/verificationService'; import { getEvent } from 'common/api/referenceDataService'; import { getUserInSession, tokenIsExpired } from './utils/session'; @@ -22,29 +22,79 @@ import SUMMIT2023CONTENT from 'common/resources/data/summit2023Content.json'; import { resolveCardanoNetwork } from './utils/utils'; import { parseError } from 'common/constants/errors'; import { getUserVotes } from 'common/api/voteService'; -import { getWinners } from 'common/api/leaderboardService'; +import { getCategoryLevelStats } from 'common/api/leaderboardService'; +import { ProposalContent } from 'pages/Nominees/Nominees.type'; +import { setWinners } from 'store/userSlice'; +import './App.scss'; +import { i18n } from 'i18n'; function App() { + const dispatch = useDispatch(); const theme = useTheme(); + const isBigScreen = useMediaQuery(theme.breakpoints.up('lg')); + const eventCache = useSelector((state: RootState) => state.user.event); const walletIsVerified = useSelector((state: RootState) => state.user.walletIsVerified); + const [termsAndConditionsChecked] = useLocalStorage(CB_TERMS_AND_PRIVACY, false); const [openTermDialog, setOpenTermDialog] = useState(false); - const { isConnected, stakeAddress } = useCardano({ limitNetwork: resolveCardanoNetwork(env.TARGET_NETWORK) }); + const session = getUserInSession(); const isExpired = tokenIsExpired(session?.expiresAt); - const isBigScreen = useMediaQuery(theme.breakpoints.up('lg')); - const dispatch = useDispatch(); + const { isConnected, stakeAddress } = useCardano({ limitNetwork: resolveCardanoNetwork(env.TARGET_NETWORK) }); + + async function loadWinners(filteredCategory) { + const filteredCategoryProposals: ProposalContent[] = filteredCategory?.proposals; + try { + await getCategoryLevelStats(filteredCategory?.id).then((response) => { + const updatedAwards = filteredCategoryProposals.map((proposal) => { + const id = proposal.id; + const votes = response?.proposals[id] ? response?.proposals[id].votes : 0; + const rank = 0; + return { ...proposal, votes, rank }; + }); + + updatedAwards.sort((a, b) => b.votes - a.votes); + + updatedAwards.forEach((item, index, array) => { + if (index > 0 && item.votes === array[index - 1].votes) { + item.rank = array[index - 1].rank; + } else { + item.rank = index + 1; + } + }); + + const categoryWinners = updatedAwards + .filter((winner) => (winner.rank === 1 && winner.votes > 0)) + .map((winner) => { + return { categoryId: filteredCategory.id, proposalId: winner.id }; + }); + + dispatch(setWinners({ winners: categoryWinners })); + }); + } catch (error) { + const message = `Failed to fecth Nominee stats: ${error?.message || error?.toString()}`; + if (process.env.NODE_ENV === 'development') { + console.log(message); + } + eventBus.publish('showToast', i18n.t('toast.failedToFecthNomineeStats'), 'error'); + } + } + const fetchEvent = useCallback(async () => { try { const event = await getEvent(env.EVENT_ID); + const staticCategories: CategoryContent[] = SUMMIT2023CONTENT.categories; const joinedCategories = event.categories .map((category) => { const joinedCategory = staticCategories.find((staticCategory) => staticCategory.id === category.id); if (joinedCategory) { + if ('proposalsReveal' in event && event.proposalsReveal) { + loadWinners(joinedCategory); + } return { ...category, ...joinedCategory }; } return null; @@ -65,17 +115,6 @@ function App() { } } - if ('finished' in event && event.finished) { - try { - const winners = await getWinners(); - dispatch(setWinners({ winners })); - } catch (e) { - if (process.env.NODE_ENV === 'development') { - console.log(e.message); - } - } - } - if (session) { dispatch(setWalletIsLoggedIn({ isLoggedIn: !isExpired })); if (!isExpired) { @@ -150,6 +189,7 @@ function App() { {eventCache !== undefined && eventCache?.id.length ? ( @@ -190,4 +230,4 @@ function App() { ); } -export default App; +export default App; \ No newline at end of file diff --git a/ui/summit-2023/src/common/api/leaderboardService.ts b/ui/summit-2023/src/common/api/leaderboardService.ts index efb78ffdf..44290f518 100644 --- a/ui/summit-2023/src/common/api/leaderboardService.ts +++ b/ui/summit-2023/src/common/api/leaderboardService.ts @@ -1,4 +1,4 @@ -import { ByEventStats } from 'types/voting-app-types'; +import { ByEventStats, ByProposalsInCategoryStats } from 'types/voting-app-types'; import { DEFAULT_CONTENT_TYPE_HEADERS, doRequest, HttpMethods } from '../handlers/httpHandler'; import { env } from '../constants/env'; @@ -9,9 +9,14 @@ const getStats = async () => ...DEFAULT_CONTENT_TYPE_HEADERS, }); -const getWinners = async () => - await doRequest<{ categoryId; proposalId }[]>(HttpMethods.GET, `${LEADERBOARD_URL}/${env.EVENT_ID}/winners`, { +const getCategoryLevelStats = async (categoryId) => + await doRequest(HttpMethods.GET, `${LEADERBOARD_URL}/${env.EVENT_ID}/${categoryId}`, { ...DEFAULT_CONTENT_TYPE_HEADERS, }); -export { getStats, getWinners }; +const getHydraTallyStats = async (categoryId) => + await doRequest(HttpMethods.GET, `${LEADERBOARD_URL}/${env.EVENT_ID}/${categoryId}?source=l1`, { + ...DEFAULT_CONTENT_TYPE_HEADERS, + }); + +export { getStats, getCategoryLevelStats, getHydraTallyStats }; diff --git a/ui/summit-2023/src/common/constants/env.ts b/ui/summit-2023/src/common/constants/env.ts index f114ded6f..6b5ed083b 100644 --- a/ui/summit-2023/src/common/constants/env.ts +++ b/ui/summit-2023/src/common/constants/env.ts @@ -16,6 +16,8 @@ const DISCORD_CHANNEL_URL = process.env.REACT_APP_DISCORD_CHANNEL_URL || get(window, 'env.REACT_APP_DISCORD_CHANNEL_URL'); const COMMIT_HASH = process.env.REACT_APP_COMMIT_HASH || get(window, 'env.REACT_APP_COMMIT_HASH'); const DISCORD_BOT_URL = process.env.REACT_APP_DISCORD_BOT_URL || get(window, 'env.REACT_APP_DISCORD_BOT_URL'); +const DISCORD_SUPPORT_CHANNEL_URL = + process.env.REACT_APP_DISCORD_SUPPORT_CHANNEL_URL || get(window, 'env.REACT_APP_DISCORD_SUPPORT_CHANNEL_URL'); // config vars const TARGET_NETWORK = process.env.REACT_APP_TARGET_NETWORK || get(window, 'env.REACT_APP_TARGET_NETWORK'); const EVENT_ID = process.env.REACT_APP_EVENT_ID || get(window, 'env.REACT_APP_EVENT_ID'); @@ -23,6 +25,8 @@ const APP_VERSION = process.env.REACT_APP_VERSION || get(window, 'env.REACT_APP_ const SUPPORTED_WALLETS = (process.env.REACT_APP_SUPPORTED_WALLETS || get(window, 'env.REACT_APP_SUPPORTED_WALLETS')) .split(',') .filter((w) => !!w); +const SHOW_WINNERS = process.env.REACT_APP_SHOW_WINNERS || get(window, 'env.REACT_APP_SHOW_WINNERS'); +const SHOW_HYDRA_TALLY = process.env.REACT_APP_SHOW_HYDRA_TALLY || get(window, 'env.REACT_APP_SHOW_HYDRA_TALLY'); export const env = { VOTING_APP_SERVER_URL, @@ -37,4 +41,7 @@ export const env = { COMMIT_HASH, DISCORD_CHANNEL_URL, DISCORD_BOT_URL, + DISCORD_SUPPORT_CHANNEL_URL, + SHOW_WINNERS, + SHOW_HYDRA_TALLY }; diff --git a/ui/summit-2023/src/common/handlers/httpHandler.ts b/ui/summit-2023/src/common/handlers/httpHandler.ts index 0cf1de336..265b5349b 100644 --- a/ui/summit-2023/src/common/handlers/httpHandler.ts +++ b/ui/summit-2023/src/common/handlers/httpHandler.ts @@ -159,7 +159,7 @@ async function executeRequest( if (body && (method === HttpMethods.POST || method === HttpMethods.PUT || method === HttpMethods.PATCH)) { request['body'] = body; } - + request.headers['X-Force-Leaderboard-Results'] = true; const responseHandler = responseHandlerDelegate(); return responseHandler.parse(await fetch(requestUri, request)); } diff --git a/ui/summit-2023/src/common/resources/data/categoryImages.json b/ui/summit-2023/src/common/resources/data/categoryImages.json index 85a9d0f86..93d8fe6a0 100644 --- a/ui/summit-2023/src/common/resources/data/categoryImages.json +++ b/ui/summit-2023/src/common/resources/data/categoryImages.json @@ -1,18 +1,18 @@ [ - "/static/CardanoBallot-category-1.png", - "/static/CardanoBallot-category-2.png", - "/static/CardanoBallot-category-3.png", - "/static/CardanoBallot-category-4.png", - "/static/CardanoBallot-category-5.png", - "/static/CardanoBallot-category-6.png", - "/static/CardanoBallot-category-7.png", - "/static/CardanoBallot-category-8.png", - "/static/CardanoBallot-category-9.png", - "/static/CardanoBallot-category-10.png", - "/static/CardanoBallot-category-11.png", - "/static/CardanoBallot-category-12.png", - "/static/CardanoBallot-category-13.png", - "/static/CardanoBallot-category-14.png", - "/static/CardanoBallot-category-15.png", - "/static/CardanoBallot-category-16.png" + "/static/CardanoBallot-category-1.jpg", + "/static/CardanoBallot-category-2.jpg", + "/static/CardanoBallot-category-3.jpg", + "/static/CardanoBallot-category-4.jpg", + "/static/CardanoBallot-category-5.jpg", + "/static/CardanoBallot-category-6.jpg", + "/static/CardanoBallot-category-7.jpg", + "/static/CardanoBallot-category-8.jpg", + "/static/CardanoBallot-category-9.jpg", + "/static/CardanoBallot-category-10.jpg", + "/static/CardanoBallot-category-11.jpg", + "/static/CardanoBallot-category-12.jpg", + "/static/CardanoBallot-category-13.jpg", + "/static/CardanoBallot-category-14.jpg", + "/static/CardanoBallot-category-15.jpg", + "/static/CardanoBallot-category-16.jpg" ] \ No newline at end of file diff --git a/ui/summit-2023/src/common/resources/data/leaderboardByEvent.json b/ui/summit-2023/src/common/resources/data/leaderboardByEvent.json deleted file mode 100644 index f8f78a219..000000000 --- a/ui/summit-2023/src/common/resources/data/leaderboardByEvent.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "event": "CF_SUMMIT_2023_24DC", - "totalVotesCount": 0, - "totalVotingPower": "0", - "categories": [ - { - "id": "BEST_WALLET", - "votes": 599 - }, - { - "id": "BEST_DEX", - "votes": 574 - } - ] -} diff --git a/ui/summit-2023/src/common/resources/data/summit2023Content.json b/ui/summit-2023/src/common/resources/data/summit2023Content.json index b673daccc..b5d831e89 100644 --- a/ui/summit-2023/src/common/resources/data/summit2023Content.json +++ b/ui/summit-2023/src/common/resources/data/summit2023Content.json @@ -8,37 +8,37 @@ { "id": "63123e7f-dfc3-481e-bb9d-fed1d9f6e9b9", "presentationName": "Brian Dill", - "url": "https://twitter.com/HEROsPool", + "urls": ["https://twitter.com/HEROsPool"], "desc": "As a Cardano Ambassador, Brian creates premium content that educates but provides comprehensive perspectives on emerging industry developments." }, { "id": "0299d93e-93f2-4bc8-9b40-6dd09343c443", "presentationName": "Guillermo Moratorio", - "url": "https://twitter.com/WoodlandPools", - "desc": "Guillermo is a Cardano Ambassador and Stake Pool Operator from Colorado. In addition to working as a full-stack developer for @book_io, Guillermo hosts a YouTube channel called Woodland Pools, where he covers the latest Cardano news, tutorials, and blockchain education." + "urls": ["https://twitter.com/WoodlandPools"], + "desc": "Guillermo - Woodland Pools is a Cardano Ambassador and Stake Pool Operator from Colorado. In addition to working as a full-stack developer for @book_io, Guillermo hosts a YouTube channel called Woodland Pools, where he covers the latest Cardano news, tutorials, and blockchain education." }, { "id": "fd477fac-ad16-4d2a-91a4-0a4288d3d7aa", "presentationName": "Thiago Nunes", - "url": "https://www.linkedin.com/in/thiago-nunes-72b95327/?originalSubdomain=br", + "urls": ["https://www.linkedin.com/in/thiago-nunes-72b95327/?originalSubdomain=br"], "desc": "Thiago is a Cardano Ambassador based in Brazil. In addition to serving as a pioneering Cardano Stakepool Operator at OUROS, he's a founding member of Intersect and Director at Cardano Warriors LATAM." }, { "id": "0b755eaf-a588-441f-a9dd-50c4aa478a90", "presentationName": "Tien Nguyen Anh", - "url": "https://www.linkedin.com/in/tienna/?originalSubdomain=vn", + "urls": ["https://www.linkedin.com/in/tienna/?originalSubdomain=vn"], "desc": "Tien is a dedicated Solution Architect and Enterprise Technical Specialist from Vietnam. As an active Cardano Ambassador, he's at the forefront of blockchain education in Vietnam and holds a key position in the Vietnam Blockchain Association." }, { "id": "2c94cd2e-2ad9-4425-af01-27210afca1e3", "presentationName": "Martin Lang", - "url": "https://forum.cardano.org/u/atada", + "urls": ["https://forum.cardano.org/u/atada"], "desc": "Martin is a Cardano Ambassador and Stake Pool Operator (SPO) advocate from Austria. He offers ongoing assistance by sharing scripts, guides, and support to aid fellow SPOs in establishing and managing their Stake Pools." }, { "id": "e7d4df4a-8305-4ed8-9e42-6f67442d796e", "presentationName": "Yuta Oishi", - "url": "https://forum.cardano.org/u/yuta_oishi/summary", + "urls": ["https://forum.cardano.org/u/yuta_oishi/summary"], "desc": "Yuta is a Cardano Ambassador and Stake Pool Operator (SPO) from Japan. In addition to working as a crypto-based CPA and tax accountant, Yuta is a Catalyst Proposer and ZZZ Co. (Ltd.) Community Lead." } ] @@ -51,38 +51,38 @@ { "id": "633199b6-ab4c-49bc-afd8-e8c675d145d0", "presentationName": "Cardano Impact Report 2023 - Sustainable ADA", - "url": "https://sustainableada.com/cardano-impact-report/", + "urls": ["https://sustainableada.com/cardano-impact-report/"], "desc": "The Cardano Impact Report 2023 explores how blockchain is reshaping perspectives on value, identity, and positive social or environmental changes. It envisions a future where Cardano's decentralized mechanisms can replace environmentally damaging practices, promoting cooperation and consensus for a sustainable world." }, { "id": "d4d33796-7372-410a-b640-7dde093f20e5", "presentationName": "Climate Neutral Cardano", - "url": "https://climateneutralcardano.org/", + "urls": ["https://climateneutralcardano.org/"], "desc": "Climate Neutral Cardano is an alliance of stake pools dedicated to achieving carbon-neutrality for the Cardano blockchain. It aims to mitigate the environmental impact of blockchain technology, striving to reduce carbon emissions to zero and promote sustainability within the Cardano ecosystem." }, { "id": "2e24b92f-1a34-4799-9eb4-a489be2b63c6", "presentationName": "DirectEd Development Foundation", - "url": "https://directed.dev/", + "urls": ["https://directed.dev/"], "desc": "DirectEd Development Foundation (DirectEd) is a non-profit organization dedicated to providing evidence-based, scalable, and affordable training in coding, soft skills, and entrepreneurship. They focus on empowering under-resourced, high-potential students in Kenya and Ethiopia between high school and university." }, { "id": "4cbeb976-20ba-4c20-bdc1-f21bf28c17fd", "presentationName": "Kind Stake Pool [KIND]", - "url": "https://kindstakepool.com/", + "urls": ["https://kindstakepool.com/"], "desc": "The Kind Stake Pool [KIND] is motivated by a mission to transform Cardano into a platform for charitable contributions. By participating in [KIND], you not only earn attractive staking rewards, but also actively support charitable endeavors." }, { "id": "71f4f082-4512-4e7f-adc6-6092d1b3aa14", "presentationName": "Stake Pool for Refugees [WRFGS]", - "url": "https://unrefugees.ch/en/blockchain-refugees", + "urls": ["https://unrefugees.ch/en/blockchain-refugees"], "desc": "Switzerland for UNHCR employs the Cardano blockchain to aid displaced populations, offering a novel fundraising method that bridges the blockchain and humanitarian sectors to provide meaningful assistance. By embracing Cardano, they pioneer innovative fundraising through staking to support refugees and displaced individuals." }, { "id": "07d4a8b3-dbfc-412c-9931-a5252db9082d", "presentationName": "World Mobile", - "url": "https://worldmobile.io/", - "desc": "World Mobile is the world's first community-driven mobile network, harnessing blockchain technology to achieve global connectivity by 2030\u2014even in remote areas normally deemed expensive and challenging to serve. Their core values prioritize social, economic, and environmental sustainability, highlighted by their use of off-the-shelf equipment and renewable solar energy." + "urls": ["https://worldmobile.io/"], + "desc": "World Mobile is the world's first community-driven mobile network, harnessing blockchain technology to achieve global connectivity by 2030 even in remote areas normally deemed expensive and challenging to serve. Their core values prioritize social, economic, and environmental sustainability, highlighted by their use of off-the-shelf equipment and renewable solar energy." } ] }, @@ -94,80 +94,80 @@ { "id": "4cf1ea70-87dd-45ee-85a0-2d29720725f7", "presentationName": "Adam Dean", - "url": "https://github.com/cardano-foundation/CIPs/tree/master/CIP-0027", - "desc": "Adam Dean wrote CIP-0027 and contributed to CIP-0007 as well as CIP-0083. He is also a CIP editor." + "urls": ["https://github.com/cardano-foundation/CIPs/tree/master/CIP-0027"], + "desc": "Adam Dean wrote CIP-0027 and contributed to CPS-0007 as well as CIP-0083. He is also a CIP editor." }, { "id": "d5116b52-1e82-4c7f-95e5-2a74a9f75492", "presentationName": "Andrew Westberg", - "url": "https://github.com/cardano-foundation/CIPs/tree/master/CIP-0060", + "urls": ["https://github.com/cardano-foundation/CIPs/tree/master/CIP-0060"], "desc": "Andrew Westberg wrote CIP-0060 as well as CIP-0020, CIP-0022 and CIP-0083. He has also contributed to CIP-1694." }, { "id": "69ee264f-9d02-48ef-98f1-541ffb28756f", "presentationName": "Dr. Michael Liesenfelt", - "url": "https://github.com/cardano-foundation/CIPs/tree/master/CIP-0050", + "urls": ["https://github.com/cardano-foundation/CIPs/tree/master/CIP-0050"], "desc": "Dr. Liesenfelt wrote CIP-0050 and contributed to many discussions regarding rewards and incentives." }, { "id": "70a88fb6-a87f-4ebd-8719-31c461118f3d", "presentationName": "Mike Hornan", - "url": "https://github.com/cardano-foundation/CIPs/tree/master", + "urls": ["https://github.com/cardano-foundation/CIPs/tree/master"], "desc": "Mike Hornan actively collaborated on CIP-1694, translated the proposal into French, and led efforts to educate the community about it." }, { "id": "eaafc7a1-8944-4faf-91dd-5ceefa51e8db", "presentationName": "Robert Phair", - "url": "https://github.com/cardano-foundation/CIPs/tree/master", + "urls": ["https://github.com/cardano-foundation/CIPs/tree/master"], "desc": "Robert Phair has been a CIP editor for more than 2 years." }, { "id": "9cdbde0e-ffd4-4d22-ae3c-17cb63dc89fd", "presentationName": "Ryan Williams", - "url": "https://github.com/cardano-foundation/CIPs/pull/509 / https://github.com/cardano-foundation/CIPs/pull/462/", + "urls": ["https://github.com/cardano-foundation/CIPs/pull/509", "https://github.com/cardano-foundation/CIPs/pull/462/"], "desc": "Ryan Williams is a CIP editor and also author of CIP-0090 and CIP-0095." } ] }, { "id": "BEST_DEFI_DEX", - "presentationName": "DeFi DEX", + "presentationName": "DeFi / DEX", "desc": "Decentralized finance (DeFi) or decentralized exchange (DEX) platform offering superlative solutions.", "proposals": [ { "id": "f71c438d-8247-4064-a5aa-4d21b54c2a5d", "presentationName": "Indigo", - "url": "https://indigoprotocol.io/", + "urls": ["https://indigoprotocol.io/"], "desc": "Indigo, a Cardano-based protocol, offers autonomous synthetics (iAssets) linking the real world to blockchain. iAssets mimic real asset price movements, enabling everyone to access financial opportunities without owning the actual assets." }, { "id": "a5f92606-6e15-4f91-8443-7030eb02a274", "presentationName": "Lenfi", - "url": "https://lenfi.io/", + "urls": ["https://lenfi.io/"], "desc": "Lenfi enables decentralized lending and borrowing on the Cardano blockchain. Users engage as depositors or borrowers in peer-to-peer or peer-to-pool transactions, unlocking financial potential." }, { "id": "e9d72191-bda4-437e-af4b-2f979bad5c7f", "presentationName": "Minswap", - "url": "https://minswap.org/", + "urls": ["https://minswap.org/"], "desc": "Minswap is a community-focused DEX, distributing $MIN tokens equitably without private or VC backing. The project introduced innovative concepts like the FISO model and Protocol Owned Liquidity to the Cardano ecosystem, delivering a multi-pool decentralized exchange." }, { "id": "8cf20a27-8bc8-49f3-8133-ef76e899e1c1", "presentationName": "Optim", - "url": "https://www.optim.finance/", - "desc": "Optim Finance is a Cardano-based DeFi protocol that empowers users with innovative yield products like ada staking\u2014optimizing capital allocation and facilitating participation." + "urls": ["https://www.optim.finance/"], + "desc": "Optim Finance is a Cardano-based DeFi protocol that empowers users with innovative yield products like ada staking optimizing capital allocation and facilitating participation." }, { "id": "91871e20-f9aa-422f-9213-3722ac47c1c6", "presentationName": "Spectrum Finance", - "url": "https://spectrum.fi/", + "urls": ["https://spectrum.fi/"], "desc": "Spectrum Finance is a multi-chain DEX that launched on Cardano in July 2023. In addition to multi-chain capability and security, it offers fast, trustless cross-chain swaps, liquidity provision, and mining on an open-source platform." }, { "id": "c7c7ca58-af7e-4f27-a170-070d76707580", "presentationName": "Wingriders", - "url": "https://www.wingriders.com/", + "urls": ["https://www.wingriders.com/"], "desc": "WingRiders, a Cardano-based DEX utilizing the eUTxO model, provides DeFi services encompassing ada swaps, staking, and yield farming within its Automated Market Maker (AMM) ecosystem." } ] @@ -180,38 +180,38 @@ { "id": "f4d6055f-964e-43b4-bc23-83141ca04f9f", "presentationName": "Andrew Westberg", - "url": "https://twitter.com/amw7?lang=en-GB", + "urls": ["https://twitter.com/amw7?lang=en-GB"], "desc": "Andrew is a Stake Pool Operator (SPO) for Blue Cheese St\u20b3ke House. As an active member of the Cardano community, he also runs an educational YouTube Channel called NerdOut, where he delves into technical Cardano topics." }, { "id": "a00e6d3e-1b06-48f0-b2a0-4f3784e6226c", "presentationName": "Helios", - "url": "http://github.com/hyperion-bt/helios", - "desc": "Helios is a Domain Specific Language (DSL) that compiles to Plutus-Core (i.e., Cardano on-chain validator scripts). Helios is a non-Haskell alternative to Plutus and is purely functional, strongly typed, and has a simple curly braces syntax." + "urls": ["http://github.com/hyperion-bt/helios"], + "desc": "Helios is a Domain Specific Language (DSL) that compiles to Plutus-Core (i.e., Cardano on-chain validator scripts). Helios is a non-Haskell alternative to Plutus and is purely functional, strongly typed, and has a simple curlsy braces syntax." }, { "id": "58bb4e29-5124-473b-80e1-c5c8ffa57dbb", "presentationName": "OpShin", - "url": "https://opshin.dev/", + "urls": ["https://opshin.dev/"], "desc": "OpShin is a programming language and toolchain designed for Cardano smart contract development. Opshin is a strict subset of Python, meaning developers with Python experience can get up to speed quickly, eliminating barriers to entry." }, { "id": "a30267e1-314c-4801-aa95-b03dd4d6856e", "presentationName": "Pi Lanningham", - "url": "https://www.314pool.com/", + "urls": ["https://www.314pool.com/"], "desc": "Pi (\u03c0) is a mathematician turned software engineer, best known as the CTO of SundaeSwap Labs. With a passion for education, \u03c0 operates 314pool and contributes regularly to the Cardano ecosystem via thought leadership." }, { "id": "ec34567c-2012-4e3b-94ee-8778a6e33a04", "presentationName": "Strica", - "url": "https://strica.io/", + "urls": ["https://strica.io/"], "desc": "Strica develops open-source developer tools for Cardano, including Cardanoscan, Typhon Wallet, and Flac Finance. They've also introduced Warp Transactions, which aim to transform ada token transfers on the Cardano network." }, { "id": "204a8d71-adb1-4fff-b59d-5fb391d0078d", - "presentationName": "txPipe/Santiago", - "url": "https://txpipe.io/", - "desc": "txPipe is an open-source software project dedicated to enhancing the developer experience in the Cardano blockchain ecosystem. The project focuses on developing blockchain tools that support the open-source community, accelerating blockchain adoption." + "presentationName": "TxPipe", + "urls": ["https://txpipe.io/"], + "desc": "TxPipe is an open-source software project dedicated to enhancing the developer experience in the Cardano blockchain ecosystem. The project focuses on developing blockchain tools that support the open-source community, accelerating blockchain adoption." } ] }, @@ -223,37 +223,37 @@ { "id": "88af463f-0d9c-4738-baef-bdb80f2c374e", "presentationName": "Army of Spies", - "url": "https://www.youtube.com/@ArmyofSpies/videos", + "urls": ["https://www.youtube.com/@ArmyofSpies/videos"], "desc": "Army of Spies is a YouTube channel that provides an (almost) daily rundown of all the news and rumors circulating in the Cardano ecosystem." }, { "id": "65cf347e-129a-459b-b192-55ae37e03160", "presentationName": "Cardano over Coffee", - "url": "https://linktr.ee/CardanoOverCoffee", + "urls": ["https://linktr.ee/CardanoOverCoffee"], "desc": "Cardano over Coffee is a laid-back, deep-dive conversational program (show) that delves into everything Cardano. The platform also serves as a launchpad for new project and company announcements. Active ecosystem members are also regularly interview as guests." }, { "id": "1e609753-a83e-4ff6-9cf8-dd90803f0368", "presentationName": "Cardano With Paul", - "url": "https://www.youtube.com/@CardanoWithPaul/about", + "urls": ["https://www.youtube.com/@CardanoWithPaul/about"], "desc": "Paul, a crypto enthusiast based in Ireland, has actively participated in the ecosystem since 2016. He manages the \"Cardano with Paul\" YouTube channel and blog, exclusively focusing on Cardano updates, insights, tutorials, occasional price analysis, and any valuable content he deems beneficial." }, { "id": "9b91f3ed-42be-4650-8e43-6d7a416f9591", "presentationName": "Farid - Dapp Central", - "url": "https://twitter.com/dapp_central?lang=en", + "urls": ["https://twitter.com/dapp_central?lang=en"], "desc": "Farid is the founder of DappCentral, a Stake Pool Operator [DAPP] and educational YouTube channel that aims to on-board new crypto enthusiasts into the blockchain and crypto space." }, { "id": "702efda6-ceec-413e-8f33-aa206962850c", "presentationName": "Peter Bui", - "url": "https://learncardano.io/", + "urls": ["https://learncardano.io/"], "desc": "Peter is a Cardano Ambassador, Stake Pool Operator, and \"Learn Cardano\" podcast host where anyone, from beginners to experts, can learn more about Cardano and blockchain technology." }, { "id": "e0b5f280-95f4-42aa-8ecb-00b95c2896a4", "presentationName": "The Cardano Times", - "url": "https://linktr.ee/thecardanotimes", + "urls": ["https://linktr.ee/thecardanotimes"], "desc": "The Cardano Times offers free education and news on Cardano, crypto, and Web3. The YouTube channel covers a wide range of Cardano-related topics, including NFTs, smart contracts, decentralized apps, exchanges, staking, and more." } ] @@ -266,37 +266,37 @@ { "id": "33038f64-fff9-44cc-a8e5-d4f5896c8ff6", "presentationName": "Artano", - "url": "https://artano.io/home/", + "urls": ["https://artano.io/home/"], "desc": "Artano's mission is to create a community-driven marketplace for artists, emphasizing diversity and high-quality art. Their Council of Curators, representing global talent, hails from 16 countries with diverse backgrounds. The $ARTA governance token empowers users to shape Artano's future direction through voting." }, { "id": "953c7970-6f9d-41a0-8556-b83ff7b481fe", "presentationName": "Dropspot", - "url": "https://dropspot.io/", + "urls": ["https://dropspot.io/"], "desc": "Dropspot is a curated NFT experience on Cardano that empowers creators to regain artistic sovereignty, allowing them to take complete control of their work and connecting them with new markets, global buyers, and collaborators." }, { "id": "107fc947-85f0-442e-b56f-9c10e8b5631a", "presentationName": "JamOnBread", - "url": "https://jamonbread.io/", + "urls": ["https://jamonbread.io/"], "desc": "JamOnBread is a decentralized NFT marketplace on Cardano, featuring a groundbreaking smart contract for equitable profit sharing among users, projects, and marketplaces. Its transparent approach includes on-chain affiliate rewards, NFT rarity scoring, and user profiles, aiming to attract newcomers to Cardano's NFT ecosystem." }, { "id": "0752dc99-19fa-4f4c-96c4-25ca3a66a12f", "presentationName": "JPG Store", - "url": "https://www.jpg.store/", + "urls": ["https://www.jpg.store/"], "desc": "JPG Store is the largest NFT marketplace on Cardano and supports various file formats, including JPGs, PNGs, and SVGs. The JPG Store mission aligns with Cardano's goal of delivering financial infrastructure for the unbanked." }, { "id": "5b2145cd-8740-4254-942f-889eb3671640", "presentationName": "Kreate Platform", - "url": "https://kreate.art/", + "urls": ["https://kreate.art/"], "desc": "Kreate is a comprehensive art platform on Cardano, fostering a vibrant arts culture. It empowers artists with portfolio management, social features, and innovative marketplace tools like edition support, royalties, auctions, and provenance exploration. With years of Cardano experience, Kreate delivers user-friendly, decentralized solutions, including non-custodial on-demand minting." }, { "id": "01991af2-3bc9-4818-a745-db7683c8fe37", "presentationName": "Mutant Labs", - "url": "https://labs.mutant-nft.com/", + "urls": ["https://labs.mutant-nft.com/"], "desc": "Mutant Labs provides diverse NFT project utilities on Cardano, including raffles, staking, and whitelist management. Their core mission is to inject enjoyment into the cNFT ecosystem." } ] @@ -309,37 +309,37 @@ { "id": "6e16cdae-7696-4c41-a5f2-de373a17f488", "presentationName": "ADA4Good [A4G]", - "url": "https://www.ada4good.com/", - "desc": "Run by Vahid, ADA4Good [A4G] rewards its delegators with World Mobile Tokens (WMT) and ada, while also donating 50% of margin fees to Save The Children. As a Mission Drive Stake Pool, [A4G] is part of a collective that donates a portion of block rewards to charitable causes\u2014using ada where accepted, or fiat currency." + "urls": ["https://www.ada4good.com/"], + "desc": "Run by Vahid, ADA4Good [A4G] rewards its delegators with World Mobile Tokens (WMT) and ada, while also donating 50% of margin fees to Save The Children. As a Mission Drive Stake Pool, [A4G] is part of a collective that donates a portion of block rewards to charitable causes using ada where accepted, or fiat currency." }, { "id": "46ea36b8-15b6-4d31-8c29-946342595756", "presentationName": "Blade [BLADE]", - "url": "https://bladepool.com", + "urls": ["https://bladepool.com"], "desc": "The Blade Pool [BLADE] is operated by Conrad, an InfoSec and IT infrastructure engineer from New York City. In addition to his 20 years of corporate experience, Conrad is a $adahandle co-founder and a well-respected, active community member on various social networks where he engages in discussions about Cardano and blockchain-related topics." }, { "id": "e3a130e7-45c9-47c5-a121-fbdebc6c3e9f", "presentationName": "Clay Nation [CLAY]", - "url": "https://claynation.gitbook.io/claypaper/tokenonomy/cardano-staking", + "urls": ["https://claynation.gitbook.io/claypaper/tokenonomy/cardano-staking"], "desc": "The independent, community-driven Clay Nation [CLAY] Pool has been operating for over 18 months, with over 1,000 blocks minted. CLAY offers staking benefits to encourage ada staking and long-term holding. Clay Nation stands out for its high-impact collaborations that blend technology, art, and entertainment. Through the forging of creative partnerships, the team is dedicated to accelerating adoption and amplifying the impact of Cardano projects across various markets." }, { "id": "cfe477d2-e7eb-46a7-a8ee-f721da2de399", "presentationName": "GimbalPool [GMBL]", - "url": "https://gimbalabs.com/", - "desc": "[GMBL] Stake Pool represents the community behind Gimbalabs. Dedicated to empowering the Cardano ecosystem, Gimbalabs creates community spaces, establishes robust technical infrastructure, offers educational programs, and contributes open-source code\u2014all with the ambition of ensuring Cardano's transformative impact on the world. Their emphasis on education and collaboration shines through in their Plutus Project-Based Learning program, which onboards developers into the ecosystem." + "urls": ["https://gimbalabs.com/"], + "desc": "[GMBL] Stake Pool represents the community behind Gimbalabs. Dedicated to empowering the Cardano ecosystem, Gimbalabs creates community spaces, establishes robust technical infrastructure, offers educational programs, and contributes open-source code all with the ambition of ensuring Cardano's transformative impact on the world. Their emphasis on education and collaboration shines through in their Plutus Project-Based Learning program, which onboards developers into the ecosystem." }, { "id": "6ee41116-b60c-41d2-974c-c3de31b71a83", "presentationName": "LEAD Stake Pool [LEAD]", - "url": "https://leadstakepool.com/", + "urls": ["https://leadstakepool.com/"], "desc": "The LEAD Stake Pool [LEAD] is run by a wealth manager in Sydney, Australia, with a decade of experience in financial services, specializing in tax engine programming. LEAD is dedicated to fostering communication with delegators, prioritizing long-term commitment and minimizing the need for constant delegation oversight. As a strong supporter of decentralization, LEAD participates in the Cardano Bare Metal and Single Pool Alliances." }, { "id": "e4895e4b-b25a-43d5-9bd0-1dcd23954faa", "presentationName": "SM\u20b3UG [SMAUG]", - "url": "https://smaug.pool.pm/", + "urls": ["https://smaug.pool.pm/"], "desc": "SMAUG is the creator of pool.pm, one of the most popular and widely used block explorers in the Cardano ecosystem. With the development of this block explorer, SMAUG has not only demonstrated technical excellence but also the ability to provide a solution that helps visualize transaction movements and wallet content." } ] @@ -352,37 +352,37 @@ { "id": "623405b4-a845-4130-b406-b4cf4a1a985d", "presentationName": "aeoniumsky", - "url": "https://www.aeoniumsky.io/", + "urls": ["https://www.aeoniumsky.io/"], "desc": "aeoniumsky crafts surreal animated artworks that blur reality and imagination, offering viewers a sensory journey through fantastical alternate realms." }, { "id": "9074cf60-d413-4c20-a344-a3f894d2e6c0", "presentationName": "Book.io", - "url": "https://book.io/", + "urls": ["https://book.io/"], "desc": "Book.io brings digital reading to Web3 technology, allowing readers to own eBooks and Audiobooks as Decentralized Encrypted Assets (DEAs). Unlike other licenses, these DEAs are full books living on-chain and enabling resale on secondary markets. Authors and publishers receive perpetual royalties, rewarding their creative efforts" }, { "id": "1bae816a-b943-4148-ac58-c4081ef8cac5", "presentationName": "Clay Nation", - "url": "https://www.clayspace.io/", + "urls": ["https://www.clayspace.io/"], "desc": "A collection of 10,000 characters created from hand-crafted, randomly-assembled clay traits. Each is a one-of-a-kind digital collectible, stored on the Cardano blockchain." }, { "id": "c0f06200-b04c-4e08-b00b-e050cdcc205c", "presentationName": "HOSKY", - "url": "https://hosky.io/", + "urls": ["https://hosky.io/"], "desc": "HOSKY is Cardano's inaugural meme token. Without intrinsic value, it provides a speculative thrill for crypto enthusiasts riding the dog-themed mascot trend." }, { "id": "02bd8150-91cd-499d-b94c-c0e7b5fd5dc4", "presentationName": "OREMOB", - "url": "https://oremob.io/", + "urls": ["https://oremob.io/"], "desc": "OREMOB, by Berlin artist ORE ORE ORE, is an expansion of his 2021 Web3 anime project. It's a community-driven space blending captivating narratives, unique visual identities, and pop-culture references into an artful experience." }, { "id": "50d31468-a915-4284-9e3f-e0c6f5c1c90c", "presentationName": "The Ape Society", - "url": "https://www.theapesociety.io/", + "urls": ["https://www.theapesociety.io/"], "desc": "The Ape Society is a Cardano project and community featuring 7,000 unique NFTs. Anyone can join this exclusive community by minting an ape or purchasing one on secondary markets." } ] @@ -395,25 +395,25 @@ { "id": "a910a8af-f63b-4190-90fb-1409fd110526", "presentationName": "Atala PRISM", - "url": "https://atalaprism.io/", + "urls": ["https://atalaprism.io/"], "desc": "Atala PRISM is a self-sovereign identity (SSI) platform and service suite for verifiable data and digital identity. Built on Cardano, it offers core infrastructure for issuing DIDs (Decentralized identifiers) and verifiable credentials, alongside other tools and frameworks." }, { "id": "0a70b72d-1394-4bdd-bf93-e79ceb0c40a6", "presentationName": "Blocktrust", - "url": "https://blocktrust.dev/", + "urls": ["https://blocktrust.dev/"], "desc": "Blocktrust is building on Atala PRISM, an SSI ecosystem platform for DID creation, credential issuance, identity verification, and more. With Atala PRISM, IOG has created a technical platform in the SSI ecosystem on which Blocktrust can create DIDs and issue credentials." }, { "id": "f37bf063-15fc-4959-a6a4-0349a7613ede", "presentationName": "IAMX", - "url": "https://iamx.id/", + "urls": ["https://iamx.id/"], "desc": "IAMX's self-sovereign identity (SSI) solutions blend consumer incentives and top-tier security. Their platform shapes identity management in the decentralized, consumer-driven landscape of Web3." }, { "id": "57f93799-5123-4ad0-a13f-a7c70387a756", "presentationName": "ProofSpace", - "url": "https://www.proofspace.id/", + "urls": ["https://www.proofspace.id/"], "desc": "ProofSpace is an interoperable, decentralized identity network with no-code capabilities. Their platform facilitates the issuance and verification of reusable identity credentials, empowering ecosystems." } ] diff --git a/ui/summit-2023/src/common/resources/images/github-icon.svg b/ui/summit-2023/src/common/resources/images/github-icon.svg new file mode 100644 index 000000000..37fa923df --- /dev/null +++ b/ui/summit-2023/src/common/resources/images/github-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/summit-2023/src/common/resources/images/hexFront.png b/ui/summit-2023/src/common/resources/images/hexFront.png deleted file mode 100644 index 46abbf2a1..000000000 Binary files a/ui/summit-2023/src/common/resources/images/hexFront.png and /dev/null differ diff --git a/ui/summit-2023/src/common/resources/images/hydraIcon.svg b/ui/summit-2023/src/common/resources/images/hydraIcon.svg new file mode 100644 index 000000000..96ee5d259 --- /dev/null +++ b/ui/summit-2023/src/common/resources/images/hydraIcon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ui/summit-2023/src/common/resources/images/votesIcon.svg b/ui/summit-2023/src/common/resources/images/votesIcon.svg new file mode 100644 index 000000000..e48384f02 --- /dev/null +++ b/ui/summit-2023/src/common/resources/images/votesIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/summit-2023/src/common/resources/images/winner-badge-summit-2023.svg b/ui/summit-2023/src/common/resources/images/winner-badge-summit-2023.svg new file mode 100644 index 000000000..bdded196e --- /dev/null +++ b/ui/summit-2023/src/common/resources/images/winner-badge-summit-2023.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/summit-2023/src/common/resources/images/winnersIcon.svg b/ui/summit-2023/src/common/resources/images/winnersIcon.svg new file mode 100644 index 000000000..51e725652 --- /dev/null +++ b/ui/summit-2023/src/common/resources/images/winnersIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/summit-2023/src/components/LegalOptInModal/LegalDocPreview.tsx b/ui/summit-2023/src/components/LegalOptInModal/LegalDocPreview.tsx index 126d5b99b..29e2edb9f 100644 --- a/ui/summit-2023/src/components/LegalOptInModal/LegalDocPreview.tsx +++ b/ui/summit-2023/src/components/LegalOptInModal/LegalDocPreview.tsx @@ -81,7 +81,6 @@ const LegalDocPreview = () => { })} } aria-controls="panel1a-content" id="panel1a-header" > diff --git a/ui/summit-2023/src/components/LegalOptInModal/TermsAndPrivacyOpt.tsx b/ui/summit-2023/src/components/LegalOptInModal/TermsAndPrivacyOpt.tsx index 3076186c7..513687b97 100644 --- a/ui/summit-2023/src/components/LegalOptInModal/TermsAndPrivacyOpt.tsx +++ b/ui/summit-2023/src/components/LegalOptInModal/TermsAndPrivacyOpt.tsx @@ -33,9 +33,9 @@ const TermsOptInModal = (props) => { const path = window.location.href; if ( - path.includes(ROUTES.TERMSANDCONDITIONS) || - path.includes(ROUTES.PRIVACYPOLICY) || - path.includes(ROUTES.PAGENOTFOUND) + path.includes(ROUTES.TERMS_AND_CONDITIONS) || + path.includes(ROUTES.PRIVACY_POLICY) || + path.includes(ROUTES.PAGE_NOT_FOUND) ) setOpen(false); @@ -168,7 +168,7 @@ const TermsOptInModal = (props) => { <> I have read and agree to the Cardano Ballot @@ -176,7 +176,7 @@ const TermsOptInModal = (props) => { and @@ -216,7 +216,7 @@ const TermsOptInModal = (props) => { <> I have read and agree to the Cardano Ballot @@ -224,7 +224,7 @@ const TermsOptInModal = (props) => { and diff --git a/ui/summit-2023/src/components/VerifyWallet/VerifyWallet.scss b/ui/summit-2023/src/components/VerifyWallet/VerifyWallet.scss index 6210a168c..81cc335c0 100644 --- a/ui/summit-2023/src/components/VerifyWallet/VerifyWallet.scss +++ b/ui/summit-2023/src/components/VerifyWallet/VerifyWallet.scss @@ -58,6 +58,12 @@ span { font-weight: 600 !important; } + a { + font-weight: 400; + cursor: pointer; + color: blue !important; + text-decoration: underline; + } } .secret-key-input { width: 100%; diff --git a/ui/summit-2023/src/components/VerifyWallet/VerifyWallet.tsx b/ui/summit-2023/src/components/VerifyWallet/VerifyWallet.tsx index 659467824..6db75f9fd 100644 --- a/ui/summit-2023/src/components/VerifyWallet/VerifyWallet.tsx +++ b/ui/summit-2023/src/components/VerifyWallet/VerifyWallet.tsx @@ -10,6 +10,7 @@ import { Typography, useMediaQuery, useTheme, + Box, } from '@mui/material'; import CallIcon from '@mui/icons-material/Call'; import { MuiTelInput, matchIsValidTel, MuiTelInputCountry } from 'mui-tel-input'; @@ -27,6 +28,7 @@ import { CustomButton } from '../common/Button/CustomButton'; import { getSignedMessagePromise, openNewTab, resolveCardanoNetwork } from '../../utils/utils'; import { SignedWeb3Request } from '../../types/voting-app-types'; import { parseError } from 'common/constants/errors'; +import { ErrorMessage } from '../common/ErrorMessage/ErrorMessage'; // TODO: env. const excludedCountries: MuiTelInputCountry[] | undefined = []; @@ -47,6 +49,7 @@ const VerifyWallet = (props: VerifyWalletProps) => { const [phoneCodeIsBeenSending, setPhoneCodeIsBeenSending] = useState(false); const [phoneCodeIsBeenConfirming, setPhoneCodeIsBeenConfirming] = useState(false); const [phoneCodeIsSent, setPhoneCodeIsSent] = useState(false); + const [phoneCodeShowError, setPhoneCodeShowError] = useState(false); const [checkImNotARobot, setCheckImNotARobot] = useState(false); const [isPhoneInputDisabled] = useState(false); const dispatch = useDispatch(); @@ -60,7 +63,7 @@ const VerifyWallet = (props: VerifyWalletProps) => { const queryParams = new URLSearchParams(location.search); const action = queryParams.get('action'); - const secret = queryParams.get('secret'); + const discordSecret = queryParams.get('secret'); inputRefs.current = []; @@ -70,6 +73,7 @@ const VerifyWallet = (props: VerifyWalletProps) => { function clear() { setVerifyOption(undefined); setPhoneCodeIsSent(false); + setPhoneCodeShowError(false); setPhone(''); setCodes(Array(6).fill('')); } @@ -123,21 +127,23 @@ const VerifyWallet = (props: VerifyWalletProps) => { reset(); setPhoneCodeIsBeenConfirming(false); } else { - onError('SMS code not valid'); + // onError('SMS code not valid'); + setPhoneCodeShowError(true); setPhoneCodeIsBeenConfirming(false); } }) .catch(() => { - onError('SMS code verification failed'); + // onError('SMS code verification failed'); + setPhoneCodeShowError(true); setPhoneCodeIsBeenConfirming(false); }); }; const handleVerifyDiscord = async () => { - if (action === 'verification' && secret.includes('|')) { - signMessagePromisified(secret.trim()) + if (action === 'verification' && discordSecret.includes('|')) { + signMessagePromisified(discordSecret.trim()) .then((signedMessaged: SignedWeb3Request) => { - const parsedSecret = secret.split('|')[1]; + const parsedSecret = discordSecret.split('|')[1]; verifyDiscord(env.EVENT_ID, stakeAddress, parsedSecret, signedMessaged) .then((response: { verified: boolean }) => { dispatch(setWalletIsVerified({ isVerified: response.verified })); @@ -210,6 +216,8 @@ const VerifyWallet = (props: VerifyWalletProps) => { } else if (!value && index > 0) { inputRefs.current[index]?.focus(); } + + setPhoneCodeShowError(false); }; const handleCancelConfirmChode = () => { @@ -261,10 +269,24 @@ const VerifyWallet = (props: VerifyWalletProps) => { /> ))} + + + { color: '#F6F9FF !important', } } - label="Send code" + label="Send Code" disabled={!matchIsValidTel(phone) || !checkImNotARobot || phoneCodeIsBeenSending} onClick={() => handleSendCode()} fullWidth={true} @@ -409,27 +431,16 @@ const VerifyWallet = (props: VerifyWalletProps) => { gutterBottom style={{ wordWrap: 'break-word', marginTop: '16px' }} > - 1.{' '} - openNewTab(env.DISCORD_CHANNEL_URL)} - > - Join our Discord Server - {' '} - and accept our terms and conditions by reacting with a 🚀 to the message in the verification channel. + 1. Join our openNewTab(env.DISCORD_CHANNEL_URL)}>Discord Server and accept our terms and + conditions by reacting with a 🚀 to the message in the verification channel. - 2.{' '} - openNewTab(env.DISCORD_BOT_URL)} - > - Open the Wallet Verification channel and follow the instructions in Discord. - + 2. Open the openNewTab(env.DISCORD_BOT_URL)}>Wallet Verification channel and follow the + instructions in Discord. { verification process. handleVerifyDiscord()} - disabled={!secret} + disabled={!discordSecret} fullWidth={true} /> { background: 'transparent !important', color: '#03021F', border: '1px solid #daeefb', - margin: '24px 0px', + margin: '12px 0px', }} label="Cancel" onClick={() => reset()} diff --git a/ui/summit-2023/src/components/common/Button/CustomButton.tsx b/ui/summit-2023/src/components/common/Button/CustomButton.tsx index 952ec5159..c74349a99 100644 --- a/ui/summit-2023/src/components/common/Button/CustomButton.tsx +++ b/ui/summit-2023/src/components/common/Button/CustomButton.tsx @@ -29,9 +29,13 @@ const CustomButton = (props: CustomButtonProps) => { lineHeight: 'normal', textDecoration: 'none', textTransform: 'none', + transition: 'transform 0.3s ease', '&:hover': { background: styles?.background, - boxShadow: 'none', + transition: + 'background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms', + boxShadow: + '0px 2px 4px -1px rgba(0,0,0,0.2), 0px 4px 5px 0px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12)', }, }} > diff --git a/ui/summit-2023/src/components/common/ConnectWalletButton/ConnectWalletButton.scss b/ui/summit-2023/src/components/common/ConnectWalletButton/ConnectWalletButton.scss index e2368f246..667cf8476 100644 --- a/ui/summit-2023/src/components/common/ConnectWalletButton/ConnectWalletButton.scss +++ b/ui/summit-2023/src/components/common/ConnectWalletButton/ConnectWalletButton.scss @@ -29,7 +29,6 @@ display: none; .disconnect-button { - margin-top: 4px; width: 100%; text-transform: none; } @@ -41,7 +40,6 @@ gap: 8px; border-radius: 8px; background-color: var(--color-ultra-dark-blue) !important; - transition: transform 0.3s ease; color: var(--color-light); font-size: 16px; font-style: normal; @@ -55,12 +53,11 @@ } .verify-button.MuiButton-root { width: 100%; - margin-top: 4px; display: inline-flex; padding: 14px; align-items: flex-start; gap: 8px; - border-radius: 8px; + border-radius: 4px; background-color: var(--color-light-green) !important; transition: transform 0.3s ease; color: var(--color-ultra-dark-blue); @@ -75,13 +72,9 @@ } } -.connect-button.MuiButton-root:hover { - transform: scale(1.02); -} - .connected-button.MuiButton-root { width: 210px; - border-radius: 8px; + border-radius: 4px; border: 1px solid var(--color-light-grey); background: var(--color-light-blue); display: inline-flex; @@ -90,7 +83,6 @@ align-items: center; gap: 10px; text-transform: none; - color: var(--color-dark-grey); font-size: 16px; font-style: normal; diff --git a/ui/summit-2023/src/components/common/ConnectWalletButton/ConnectWalletButton.tsx b/ui/summit-2023/src/components/common/ConnectWalletButton/ConnectWalletButton.tsx index bc20c46dc..785f22711 100644 --- a/ui/summit-2023/src/components/common/ConnectWalletButton/ConnectWalletButton.tsx +++ b/ui/summit-2023/src/components/common/ConnectWalletButton/ConnectWalletButton.tsx @@ -1,7 +1,6 @@ import { useSelector } from 'react-redux'; import { RootState } from '../../../store'; import { useCardano } from '@cardano-foundation/cardano-connect-with-wallet'; -import { eventBus } from '../../../utils/EventBus'; import { Avatar, Button } from '@mui/material'; import { addressSlice, resolveCardanoNetwork, walletIcon } from '../../../utils/utils'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; @@ -17,17 +16,18 @@ type ConnectWalletButtonProps = { disableBackdropClick?: boolean; onOpenConnectWalletModal: () => void; onOpenVerifyWalletModal: () => void; + onDisconnectWallet: () => void; onLogin: () => void; }; const ConnectWalletButton = (props: ConnectWalletButtonProps) => { - const { onOpenConnectWalletModal, onOpenVerifyWalletModal, onLogin } = props; + const { onOpenConnectWalletModal, onOpenVerifyWalletModal, onLogin, onDisconnectWallet } = props; const eventCache = useSelector((state: RootState) => state.user.event); const walletIsVerified = useSelector((state: RootState) => state.user.walletIsVerified); const session = getUserInSession(); const isExpired = tokenIsExpired(session?.expiresAt); - const { stakeAddress, isConnected, disconnect, enabledWallet } = useCardano({ + const { stakeAddress, isConnected, enabledWallet } = useCardano({ limitNetwork: resolveCardanoNetwork(env.TARGET_NETWORK), }); @@ -37,11 +37,6 @@ const ConnectWalletButton = (props: ConnectWalletButtonProps) => { } }; - const onDisconnectWallet = () => { - disconnect(); - eventBus.publish('showToast', 'Wallet disconnected successfully'); - }; - return (
{isConnected && (
- {!walletIsVerified ? ( + {!walletIsVerified && !eventCache?.finished ? ( ) : null} - {walletIsVerified || (!walletIsVerified && eventCache.finished) ? ( + {((!session || isExpired) && walletIsVerified) || (isExpired && !walletIsVerified && eventCache.finished) ? ( ) : null} + + + + ) : ( + {voted ? ( - + Already Voted { /> ) : null} + - - } - /> - + {category.presentationName} - - - {category.desc} - - - - - - - - ) : ( - - - {voted ? ( - - Already Voted - - ) : null} - + /> + - + ) : ( + + + {voted ? ( + + {i18n.t('categories.alreadyVoted')} + + ) : null} + + + } + /> + + - - {category.presentationName} - - - + + + + {category.desc} + + + + + + + )}
@@ -297,19 +374,19 @@ const Categories = () => { {voted ? ( - + Already Voted { /> } /> - + { {category.desc} - + + + + @@ -415,17 +520,23 @@ const Categories = () => { - handleListView('grid')}> + handleListView('grid')} + className={listView === 'grid' ? styles.selected : styles.unSelected} + > - handleListView('list')}> + handleListView('list')} + className={listView === 'list' ? styles.selected : styles.unSelected} + > - + {isMobile || listView === 'grid' ? renderResponsiveGrid(categories) : renderResponsiveList(categories)}
diff --git a/ui/summit-2023/src/pages/Home/Home.scss b/ui/summit-2023/src/pages/Home/Home.scss index da6c28d41..49255d69c 100644 --- a/ui/summit-2023/src/pages/Home/Home.scss +++ b/ui/summit-2023/src/pages/Home/Home.scss @@ -1,12 +1,14 @@ .hero-banner { - width: 420px; + width: 375px; height: auto; min-width: 375px; max-width: 19vw; + margin-top: -24px; @media only screen and (max-width: 600px) { width: 313px; height: auto; min-width: 313px; + margin-top: 0px; } } @@ -26,7 +28,7 @@ .hexagon-content { position: absolute; - top: 15px; + top: -20px; left: 0; right: 0; bottom: 0; @@ -82,17 +84,32 @@ font-style: normal; text-align: center; margin: 20px; - p { - margin: 0; - font-weight: 600; + @media only screen and (max-width: 600px) { + text-align: left; + margin: 0px; + } + .custom-chip-mobile { + display: flex; + align-items: center; + vertical-align: middle; + box-sizing: border-box; + height: auto; + border-radius: 8px; + margin-top: 20px; + margin-bottom: 20px; + padding: 10px; + color: #fff; + background-color: #03021f; + font-size: 0.8125rem; + transition: + background-color 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, + box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + cursor: unset; + outline: 0; gap: 5px; letter-spacing: normal; line-height: 22px; } - @media only screen and (max-width: 600px) { - text-align: left; - margin-left: 0px; - } } } diff --git a/ui/summit-2023/src/pages/Home/Home.tsx b/ui/summit-2023/src/pages/Home/Home.tsx index c46e8ab22..7baccb7f6 100644 --- a/ui/summit-2023/src/pages/Home/Home.tsx +++ b/ui/summit-2023/src/pages/Home/Home.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Typography, Grid, useTheme, useMediaQuery } from '@mui/material'; +import { Typography, Grid, Box } from '@mui/material'; import CARDANOSUMMIT2023LOGO from '../../common/resources/images/cardanosummit2023.svg'; import { Hexagon } from '../../components/common/Hexagon'; import './Home.scss'; @@ -8,22 +8,21 @@ import { NavLink } from 'react-router-dom'; import { CustomButton } from '../../components/common/Button/CustomButton'; import { useSelector } from 'react-redux'; import { RootState } from '../../store'; -import { formatUTCDate } from 'utils/dateUtils'; import Chip from '@mui/material/Chip'; import EventIcon from '@mui/icons-material/Event'; +import { Trans } from 'react-i18next'; const Home: React.FC = () => { const eventCache = useSelector((state: RootState) => state.user.event); - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const hasEventFinished = eventCache?.finished; return ( { - {i18n.t('landing.title')} + {hasEventFinished ? i18n.t('landing.eventFinishedTitle') : i18n.t('landing.title')} - {isMobile ? ( -
-

Opens on: {formatUTCDate(eventCache?.eventStartDate?.toString())}

-

Closes on: {formatUTCDate(eventCache?.eventEndDate?.toString())}

-
- ) : ( + { px: '10px', }} icon={} - label={`The Vote opens on ${formatUTCDate( - eventCache?.eventStartDate?.toString() - )}, and closes on ${formatUTCDate(eventCache?.eventEndDate?.toString())}.`} + label={hasEventFinished ? 'Voting is now closed.' : 'Voting closes 11 October 2023 23:59 UTC'} color="primary" /> - )} - + - {i18n.t('landing.description')} + }} + > - - - + + + + + + {!hasEventFinished && ( + + + + + + )}
@@ -108,7 +138,7 @@ const Home: React.FC = () => { alignItems="center" sx={{ display: 'flex', - order: '2', + order: '1', }} >
diff --git a/ui/summit-2023/src/pages/Leaderboard/Leaderboard.module.scss b/ui/summit-2023/src/pages/Leaderboard/Leaderboard.module.scss index 0d60c1022..eb2ac9c25 100644 --- a/ui/summit-2023/src/pages/Leaderboard/Leaderboard.module.scss +++ b/ui/summit-2023/src/pages/Leaderboard/Leaderboard.module.scss @@ -1,4 +1,5 @@ .leaderboard { + width: 100%; .description { max-height: 110px; margin-bottom: 40px !important; @@ -63,4 +64,23 @@ background: #daeefb; } } + + .masonryGrid { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + width: auto; + } + .masonryGridColumn { + padding-left: 16px; + background-clip: padding-box; + @media only screen and (max-width: 600px) { + padding-left: 0px; + } + } + + .masonryGridColumn > .MuiCard:root { + background: grey; + margin-bottom: 30px; + } } diff --git a/ui/summit-2023/src/pages/Leaderboard/Leaderboard.tsx b/ui/summit-2023/src/pages/Leaderboard/Leaderboard.tsx index 48ca9d2c9..8d743e766 100644 --- a/ui/summit-2023/src/pages/Leaderboard/Leaderboard.tsx +++ b/ui/summit-2023/src/pages/Leaderboard/Leaderboard.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Typography, Grid, Box } from '@mui/material'; +import { Typography, Grid, Box, styled } from '@mui/material'; import styles from './Leaderboard.module.scss'; import cn from 'classnames'; - +import { i18n } from 'i18n'; +import { makeStyles } from 'tss-react/mui'; import { PieChart } from 'react-minimal-pie-chart'; import { ByCategoryStats } from 'types/voting-app-types'; import { EventPresentation } from 'types/voting-ledger-follower-types'; @@ -14,14 +15,79 @@ import { RootState } from '../../store'; import { StatsTile } from './components/StatsTile'; import SUMMIT2023CONTENT from '../../common/resources/data/summit2023Content.json'; import { CategoryContent } from 'pages/Categories/Category.types'; -import { LeaderboardContent } from './Leaderboard.types'; import { eventBus } from '../../utils/EventBus'; +import Masonry from 'react-masonry-css'; +import { AwardsTile } from './components/AwardsTile'; +import Tab from '@mui/material/Tab'; +import TabContext from '@mui/lab/TabContext'; +import TabList from '@mui/lab/TabList'; +import TabPanel from '@mui/lab/TabPanel'; +import WinnersIcon from '@mui/icons-material/EmojiEvents'; +import VotesIcon from '@mui/icons-material/DonutLarge'; +import BarChartIcon from '@mui/icons-material/BarChart'; +import { env } from '../../common/constants/env'; +import { HydraTile } from './components/HydraTile'; +import Tooltip, { tooltipClasses, TooltipProps } from '@mui/material/Tooltip'; + +const useStyles = makeStyles()(() => ({ + customTab: { + '& .MuiTab-root': { + color: '#03021f', + textTransform: 'capitalize', + height: 3, + '&.Mui-selected': { + color: '#106593', + '&.svg': { + fill: '#106593 !important', + }, + }, + '&:hover': { + backgroundColor: '#DAEEFB', + }, + '&:active': { + backgroundColor: '#DAEEFB', + }, + '&.Mui-selected:hover': { + backgroundColor: '#DAEEFB', + }, + }, + }, + customeTabIndicator: { + '& .MuiTabs-indicator': { + width: '90px', + borderTopLeftRadius: '20px !important', + borderTopRightRadius: '20px !important', + backgroundColor: '#106593 !important', + height: '5px !important', + }, + }, +})); + +const DisableTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.arrow}`]: { + color: theme.palette.primary.main, + }, + [`& .${tooltipClasses.tooltip}`]: { + textAlign: 'center', + backgroundColor: theme.palette.primary.main, + }, +})); const Leaderboard = () => { - const event = useSelector((state: RootState) => state.user.event); + const classes = useStyles(); + const summitEvent = useSelector((state: RootState) => state.user.event); const [stats, setStats] = useState(); + const [value, setValue] = useState('2'); + const [winnersAvailable, setWinnersAvailable] = useState(Boolean); + const [hydraTallyAvailable, setHydraTallyAvailable] = useState(Boolean); + const summit2023Categories: CategoryContent[] = SUMMIT2023CONTENT.categories; - const summit2023Leaderboard: LeaderboardContent = SUMMIT2023CONTENT.leaderboard; const init = useCallback(async () => { try { @@ -29,20 +95,30 @@ const Leaderboard = () => { setStats(response.categories); }); } catch (error) { - const message = `Failed to fetch stats: ${error?.message || error?.toString()}`; + const message = `Failed to fecth stats: ${error?.message || error?.toString()}`; if (process.env.NODE_ENV === 'development') { console.log(message); } - eventBus.publish('showToast', 'Failed to fetch stats', 'error'); + eventBus.publish('showToast', i18n.t('toast.failedToFecthStats'), 'error'); } }, []); useEffect(() => { + if (env?.SHOW_WINNERS === 'true') { + setWinnersAvailable(true); + } else { + setWinnersAvailable(false); + } + if (env?.SHOW_HYDRA_TALLY === 'true') { + setHydraTallyAvailable(true); + } else { + setHydraTallyAvailable(false); + } init(); }, [init]); const statsItems: StatItem[] = - event?.categories?.map(({ id }, index) => ({ + summitEvent?.categories?.map(({ id }, index) => ({ id, label: id === summit2023Categories[index].id && summit2023Categories[index].presentationName, })) || []; @@ -56,6 +132,31 @@ const Leaderboard = () => { color: categoryColorsMap[id], })); + const handleChange = (event: React.SyntheticEvent, newValue: string) => { + setValue(newValue); + }; + + const breakpointColumnsObj = { + default: 3, + 1337: 2, + 909: 1, + }; + + const TabContextStyles = { + container: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + borderRadius: '40px', + background: '#F5F9FF', + boxShadow: '2px 2px 8px 0px rgba(67, 70, 86, 0.25)', + width: { xs: '100%', sm: '429px'}, + height: { xs: '72px', sm: '76px' }, + margin: '0 auto', + }, + }; + return (
{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', - marginTop: '20px', - marginBottom: 20, + marginBottom: 48, }} > { fontWeight: '600', }} > - Leaderboard + {i18n.t('leaderboard.title')}
- - - {summit2023Leaderboard.desc} - - - - {statsSum || placeholder}} - > - + + + + {!winnersAvailable ? ( + + + } + label={i18n.t('leaderboard.tabs.tab1.label')} + value="1" + disableRipple + disabled={!winnersAvailable} + /> + + + ) : ( + } + label={i18n.t('leaderboard.tabs.tab1.label')} + value="1" + disableRipple + /> + )} + } + label={i18n.t('leaderboard.tabs.tab2.label')} + value="2" + disableRipple + /> + {!hydraTallyAvailable ? ( + + + } + label={i18n.t('leaderboard.tabs.tab3.label')} + value="3" + disableRipple + disabled={!hydraTallyAvailable} + /> + + + ) : ( + } + label={i18n.t('leaderboard.tabs.tab3.label')} + value="3" + /> + )} + + + + {winnersAvailable ? ( + + {statsItems.map((item, index) => ( + + ))} + + ) : ( + + {i18n.t('leaderboard.tabs.tab1.tooltipText')} + + )} + + - - Category - - {statsSum || placeholder}} > - Number of votes - - - {statsItems.map(({ label, id }) => ( - -
- - {label} - - - {stats?.find((category) => category.id === id).votes || placeholder} - + + {i18n.t('leaderboard.tabs.tab2.tiles.totalVotes.tableHeadings.column1')} + + + {i18n.t('leaderboard.tabs.tab2.tiles.totalVotes.tableHeadings.column2')} + + + {statsItems.map(({ label, id }) => ( + +
+ + + {label} + + + {stats?.find((category) => category.id === id).votes || placeholder} + + + + ))} - - ))} - - - {statsSum || placeholder}} - dataTestId="votes-per-category" - > - - - 0 ? chartData : [{ title: '', value: 1, color: '#BBBBBB' }]} - /> - - - - + - {statsItems.map(({ label, id }) => ( + + 0 ? chartData : [{ title: '', value: 1, color: '#BBBBBB' }]} + /> + + + - -
- - + {statsItems.map(({ label, id }) => ( - - {label} - - - - {stats && ( - <> - - {statsSum > 0 - ? getPercentage( - stats?.find((category) => category.id === id)?.votes, - statsSum - ).toFixed(2) - : '0'}{' '} - % - - {' - '} - - {stats?.find((category) => category.id === id)?.votes} - - - )} +
+ + + + + + {label} + + + + {stats && ( + <> + + {statsSum > 0 + ? getPercentage( + stats?.find((category) => category.id === id)?.votes, + statsSum + ).toFixed(2) + : '0'}{' '} + % + + {' - '} + + {stats?.find((category) => category.id === id)?.votes} + + + )} + + + - + ))} - - ))} + + + + + + + + + + Aiken | Hydra + - - - - + + {hydraTallyAvailable ? ( + + {statsItems.map((item, index) => ( + + ))} + + ) : ( + + {i18n.t('leaderboard.tabs.tab3.tooltipText')} + + )} + + +
); }; diff --git a/ui/summit-2023/src/pages/Leaderboard/components/AwardsTile/AwardsTile.module.scss b/ui/summit-2023/src/pages/Leaderboard/components/AwardsTile/AwardsTile.module.scss new file mode 100644 index 000000000..b02179fa4 --- /dev/null +++ b/ui/summit-2023/src/pages/Leaderboard/components/AwardsTile/AwardsTile.module.scss @@ -0,0 +1,70 @@ +.awardCard { + border-radius: 16px !important; + border: 1px solid transparent !important; + box-shadow: 2px 2px 5px 0px #061d3c40 !important; + display: flex !important; + flex-direction: column !important; + margin: 16px !important; + background-color: white; + @media only screen and (max-width: 600px) { + margin: 0 0 16px 0 !important; + } + .rankCard { + border: 1px solid transparent !important; + border-radius: 8px; + background: #fff; + text-align: center; + font-size: 12px; + margin: 10px !important; + padding: 10px; + display: 'flex'; + text-align: left; + vertical-align: middle; + flex-shrink: 0; + width: 96% !important; + word-wrap: break-word; + box-shadow: 1px 2px 7px 0px rgba(67, 70, 86, 0.1); + .trophy { + display: flex; + margin: auto 6px; + } + .title { + max-width: 250px; + font-size: 20px !important; + color: var(---color-primary) !important; + font-weight: 600 !important; + } + } + + .awardTitle { + color: var(---color-primary) !important; + text-align: center; + font-size: 18px !important; + font-weight: 600 !important; + line-height: 22px !important; + letter-spacing: 0em !important; + justify-content: center; + display: flex; + background-color: transparent; + margin: 0 auto; + align-items: center; + } + + .listTitle { + color: var(---color-primary) !important; + font-size: 18px !important; + font-weight: 600 !important; + line-height: 22px !important; + letter-spacing: 0em !important; + } + + .statTitle { + font-size: 16px !important; + color: var(---color-primary) !important; + font-weight: 400 !important; + } + .divider { + height: 0.5px; + background: #f0f0f0; + } +} diff --git a/ui/summit-2023/src/pages/Leaderboard/components/AwardsTile/AwardsTile.tsx b/ui/summit-2023/src/pages/Leaderboard/components/AwardsTile/AwardsTile.tsx new file mode 100644 index 000000000..323aecc66 --- /dev/null +++ b/ui/summit-2023/src/pages/Leaderboard/components/AwardsTile/AwardsTile.tsx @@ -0,0 +1,277 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Avatar, Box, Button, CardActions, Chip, CircularProgress, Grid, Typography } from '@mui/material'; +import { Link } from 'react-router-dom'; +import Card from '@mui/material/Card'; +import { i18n } from 'i18n'; +import CardContent from '@mui/material/CardContent'; +import * as leaderboardService from '../../../../common/api/leaderboardService'; +import { eventBus } from 'utils/EventBus'; +import SUMMIT2023CONTENT from '../../../../common/resources/data/summit2023Content.json'; +import { ProposalContent } from 'pages/Nominees/Nominees.type'; +import { CategoryContent } from 'pages/Categories/Category.types'; +import styles from './AwardsTile.module.scss'; +import cn from 'classnames'; +import CATEGORY_IMAGES from '../../../../common/resources/data/categoryImages.json'; + +const AwardsTile = ({ counter, title, categoryId }) => { + const summit2023Category: CategoryContent = SUMMIT2023CONTENT.categories.find( + (category) => category.id === categoryId + ); + const summit2023Proposals: ProposalContent[] = summit2023Category.proposals; + const [awards, setAwards] = useState([]); + const [loaded, setLoaded] = useState(false); + + const init = useCallback(async () => { + try { + await leaderboardService.getCategoryLevelStats(categoryId).then((response) => { + const updatedAwards = summit2023Proposals.map((proposal) => { + const id = proposal.id; + const votes = response?.proposals[id] ? response?.proposals[id].votes : 0; + const rank = 0; + return { ...proposal, votes, rank }; + }); + + updatedAwards.sort((a, b) => b.votes - a.votes); + + updatedAwards.forEach((item, index, array) => { + if (index > 0 && item.votes === array[index - 1].votes) { + item.rank = array[index - 1].rank; + } else { + item.rank = index + 1; + } + }); + setAwards(updatedAwards); + }); + setLoaded(true); + } catch (error) { + const message = `Failed to fecth Nominee stats: ${error?.message || error?.toString()}`; + if (process.env.NODE_ENV === 'development') { + console.log(message); + } + eventBus.publish('showToast', i18n.t('toast.failedToFecthNomineeStats'), 'error'); + } + }, []); + + useEffect(() => { + init(); + }, [init]); + + return ( +
+ {loaded ? ( + + + + } + color="default" + label={title} + className={styles.awardTitle} + variant="filled" + /> + {awards.length > 0 && ( + + + {awards.slice(0, 2).map((proposal, index) => ( + + {(proposal.rank === 1 && proposal.votes > 0 ) && ( + + + + + + + + {proposal.presentationName} + + + {proposal.votes} {i18n.t('leaderboard.tabs.tab1.tile.votesLabel')} + + + + + )} + + ))} + + + )} + + + + + + {i18n.t('leaderboard.tabs.tab1.tile.tableHeadings.column1')} + + + + + {i18n.t('leaderboard.tabs.tab1.tile.tableHeadings.column2')} + + + + + {i18n.t('leaderboard.tabs.tab1.tile.tableHeadings.column3')} + + + + {awards.map((proposal, index) => ( + + {proposal.rank !== 1 && ( + + + + {proposal.rank} + + + + + {proposal.presentationName} + + + + + {proposal.votes} + + + + )} + + ))} + + + + + + + ) : ( + + + + )} +
+ ); +}; + +export { AwardsTile }; \ No newline at end of file diff --git a/ui/summit-2023/src/pages/Leaderboard/components/AwardsTile/index.ts b/ui/summit-2023/src/pages/Leaderboard/components/AwardsTile/index.ts new file mode 100644 index 000000000..a9e00d3bf --- /dev/null +++ b/ui/summit-2023/src/pages/Leaderboard/components/AwardsTile/index.ts @@ -0,0 +1 @@ +export * from './AwardsTile'; \ No newline at end of file diff --git a/ui/summit-2023/src/pages/Leaderboard/components/HydraTile/HydraTile.module.scss b/ui/summit-2023/src/pages/Leaderboard/components/HydraTile/HydraTile.module.scss new file mode 100644 index 000000000..ee98be0ca --- /dev/null +++ b/ui/summit-2023/src/pages/Leaderboard/components/HydraTile/HydraTile.module.scss @@ -0,0 +1,45 @@ +.hydraCard { + border-radius: 16px !important; + border: 1px solid transparent !important; + box-shadow: 2px 2px 5px 0px #061d3c40 !important; + display: flex !important; + flex-direction: column !important; + margin: 10px !important; + background-color: white; + @media only screen and (max-width: 600px) { + margin: 0 0 10px 0 !important; + } + + .hydraTallyTitle { + color: var(---color-primary) !important; + text-align: center; + font-size: 18px !important; + font-weight: 600 !important; + line-height: 22px !important; + letter-spacing: 0em !important; + justify-content: center; + display: flex; + background-color: transparent; + margin: 0 auto; + align-items: center; + } + + .listTitle { + color: var(---color-primary) !important; + font-size: 18px !important; + font-weight: 600 !important; + line-height: 22px !important; + letter-spacing: 0em !important; + text-align: left; + } + + .statTitle { + font-size: 16px !important; + color: var(---color-primary) !important; + font-weight: 400 !important; + } + .divider { + height: 1px; + background: #dfdfdf; + } +} diff --git a/ui/summit-2023/src/pages/Leaderboard/components/HydraTile/HydraTile.tsx b/ui/summit-2023/src/pages/Leaderboard/components/HydraTile/HydraTile.tsx new file mode 100644 index 000000000..bfebf47e1 --- /dev/null +++ b/ui/summit-2023/src/pages/Leaderboard/components/HydraTile/HydraTile.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Avatar, Box, Button, CardActions, Chip, CircularProgress, Grid, Typography } from '@mui/material'; +import { Link } from 'react-router-dom'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import * as leaderboardService from '../../../../common/api/leaderboardService'; +import { eventBus } from 'utils/EventBus'; +import SUMMIT2023CONTENT from '../../../../common/resources/data/summit2023Content.json'; +import { ProposalContent } from 'pages/Nominees/Nominees.type'; +import { CategoryContent } from 'pages/Categories/Category.types'; +import styles from './HydraTile.module.scss'; +import cn from 'classnames'; +import { i18n } from 'i18n'; +import CATEGORY_IMAGES from '../../../../common/resources/data/categoryImages.json'; + +const HydraTile = ({ counter, title, categoryId }) => { + const summit2023Category: CategoryContent = SUMMIT2023CONTENT.categories.find( + (category) => category.id === categoryId + ); + const summit2023Proposals: ProposalContent[] = summit2023Category.proposals; + const [awards, setAwards] = useState([]); + const [loaded, setLoaded] = useState(false); + + const init = useCallback(async () => { + try { + await leaderboardService.getHydraTallyStats(categoryId).then((response) => { + const updatedAwards = summit2023Proposals.map((proposal) => { + const id = proposal.id; + const votes = response?.proposals[id] ? response?.proposals[id].votes : 0; + const rank = 0; + return { ...proposal, votes, rank }; + }); + + updatedAwards.sort((a, b) => b.votes - a.votes); + updatedAwards.forEach((item, index, array) => { + if (index > 0 && item.votes === array[index - 1].votes) { + item.rank = array[index - 1].rank; + } else { + item.rank = index + 1; + } + }); + setAwards(updatedAwards); + }); + setLoaded(true); + } catch (error) { + const message = `Failed to fecth Nominee stats: ${error?.message || error?.toString()}`; + if (process.env.NODE_ENV === 'development') { + console.log(message); + } + eventBus.publish('showToast', i18n.t('toast.failedToFecthNomineeStats'), 'error'); + } + }, []); + + useEffect(() => { + init(); + }, [init]); + + return ( +
+ {loaded ? ( + + + + } + color="default" + label={title} + className={styles.hydraTallyTitle} + variant="filled" + /> + + + + + {i18n.t('leaderboard.tabs.tab3.tile.tableHeadings.column1')} + + + {i18n.t('leaderboard.tabs.tab3.tile.tableHeadings.column2')} + + + {awards.slice(0, 2).map((proposal, index) => ( + + {(proposal.rank === 1 && proposal.votes > 0 ) && ( + + + {proposal.presentationName} + + + {proposal.votes} + + + )} + + ))} + + + + + + + ) : ( + + + + )} +
+ ); +}; + +export { HydraTile }; diff --git a/ui/summit-2023/src/pages/Leaderboard/components/HydraTile/index.ts b/ui/summit-2023/src/pages/Leaderboard/components/HydraTile/index.ts new file mode 100644 index 000000000..d237e707f --- /dev/null +++ b/ui/summit-2023/src/pages/Leaderboard/components/HydraTile/index.ts @@ -0,0 +1 @@ +export * from './HydraTile'; \ No newline at end of file diff --git a/ui/summit-2023/src/pages/Leaderboard/components/StatsTile.module.scss b/ui/summit-2023/src/pages/Leaderboard/components/StatsTile/StatsTile.module.scss similarity index 100% rename from ui/summit-2023/src/pages/Leaderboard/components/StatsTile.module.scss rename to ui/summit-2023/src/pages/Leaderboard/components/StatsTile/StatsTile.module.scss diff --git a/ui/summit-2023/src/pages/Leaderboard/components/StatsTile.tsx b/ui/summit-2023/src/pages/Leaderboard/components/StatsTile/StatsTile.tsx similarity index 84% rename from ui/summit-2023/src/pages/Leaderboard/components/StatsTile.tsx rename to ui/summit-2023/src/pages/Leaderboard/components/StatsTile/StatsTile.tsx index fd9eee6c0..9892e6bc2 100644 --- a/ui/summit-2023/src/pages/Leaderboard/components/StatsTile.tsx +++ b/ui/summit-2023/src/pages/Leaderboard/components/StatsTile/StatsTile.tsx @@ -4,12 +4,12 @@ import styles from './StatsTile.module.scss'; type StatsTilePorps = { title: string | React.ReactElement; - summary: string | React.ReactElement; + summary?: string | React.ReactElement; children: React.ReactNode; dataTestId: string; }; -export const StatsTile = ({ title, summary, children, dataTestId }: StatsTilePorps) => { +const StatsTile = ({ title, summary, children, dataTestId }: StatsTilePorps) => { return ( ); }; + + +export { StatsTile }; \ No newline at end of file diff --git a/ui/summit-2023/src/pages/Leaderboard/components/StatsTile/index.ts b/ui/summit-2023/src/pages/Leaderboard/components/StatsTile/index.ts new file mode 100644 index 000000000..5ef1e85eb --- /dev/null +++ b/ui/summit-2023/src/pages/Leaderboard/components/StatsTile/index.ts @@ -0,0 +1 @@ +export * from './StatsTile'; \ No newline at end of file diff --git a/ui/summit-2023/src/pages/Leaderboard/index.ts b/ui/summit-2023/src/pages/Leaderboard/index.ts index 8adbc3e2f..4091b2433 100644 --- a/ui/summit-2023/src/pages/Leaderboard/index.ts +++ b/ui/summit-2023/src/pages/Leaderboard/index.ts @@ -1 +1 @@ -export * from './Leaderboard'; +export * from './Leaderboard'; \ No newline at end of file diff --git a/ui/summit-2023/src/pages/Legal/PrivacyPolicy/PrivacyPolicy.tsx b/ui/summit-2023/src/pages/Legal/PrivacyPolicy/PrivacyPolicy.tsx index 6370f3cce..295f8d3e6 100644 --- a/ui/summit-2023/src/pages/Legal/PrivacyPolicy/PrivacyPolicy.tsx +++ b/ui/summit-2023/src/pages/Legal/PrivacyPolicy/PrivacyPolicy.tsx @@ -10,8 +10,21 @@ const PrivacyPolicy = () => { {/* Privacy Policy */} {privacyData.title} diff --git a/ui/summit-2023/src/pages/Legal/TermsAndConditions/TermsAndConditions.tsx b/ui/summit-2023/src/pages/Legal/TermsAndConditions/TermsAndConditions.tsx index aa93c393c..f2d90bd0d 100644 --- a/ui/summit-2023/src/pages/Legal/TermsAndConditions/TermsAndConditions.tsx +++ b/ui/summit-2023/src/pages/Legal/TermsAndConditions/TermsAndConditions.tsx @@ -15,8 +15,21 @@ const TermsAndConditions = () => { {/* Terms and Conditions 1 */} {termsData.title} diff --git a/ui/summit-2023/src/pages/Nominees/Nominees.scss b/ui/summit-2023/src/pages/Nominees/Nominees.scss index 67090f7f6..ffa177065 100644 --- a/ui/summit-2023/src/pages/Nominees/Nominees.scss +++ b/ui/summit-2023/src/pages/Nominees/Nominees.scss @@ -1,3 +1,6 @@ +.nominees-page { + width: 100%; +} .nominees-page-title { color: var(--color-dark-grey); font-style: normal; @@ -20,22 +23,27 @@ } .nominee-card.MuiPaper-root { - width: 414px; - flex-shrink: 0; - border-radius: 8px; box-shadow: 2px 3px 10px 0px rgba(67, 70, 86, 0.45); + border-radius: 16px !important; + border: 1px solid transparent !important; + box-shadow: 2px 2px 5px 0px #061d3c40 !important; + display: flex !important; + flex-direction: column !important; + margin: 10px !important; + background-color: white; + @media only screen and (max-width: 600px) { + margin: 0 0 10px 0 !important; + } .nominee-title { - color: var(--color-ultra-dark-blue); - font-size: 36px; + color: var(---color-primary); font-style: normal; - font-weight: 600 !important; line-height: normal; } .nominee-description { margin-top: 20px; margin-right: 50px; - color: var(--color-medium-grey); + margin-bottom: 20px !important; font-size: 16px; font-style: normal; font-weight: 400; @@ -183,3 +191,33 @@ color: red; } } + +.selected { + background-color: #daeefb !important; + border: 2px var(--color-primary) !important; + border-radius: 8px !important; +} +.unSelected { + background-color: transparent !important; + border: 2px var(--color-primary) !important; + border-radius: 8px !important; +} + +.masonryGrid { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + width: auto; +} +.masonryGridColumn { + padding-left: 10px; + background-clip: padding-box; + @media only screen and (max-width: 600px) { + padding-left: 0px; + } +} + +.masonryGridColumn > .MuiCard:root { + background: grey; + margin-bottom: 30px; +} \ No newline at end of file diff --git a/ui/summit-2023/src/pages/Nominees/Nominees.tsx b/ui/summit-2023/src/pages/Nominees/Nominees.tsx index 399c067d0..a3813b3a6 100644 --- a/ui/summit-2023/src/pages/Nominees/Nominees.tsx +++ b/ui/summit-2023/src/pages/Nominees/Nominees.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, useMemo, ReactElement } from 'react'; import { useParams } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; import { v4 as uuidv4 } from 'uuid'; +import { i18n } from 'i18n'; import { useTheme, useMediaQuery, @@ -15,10 +17,14 @@ import { Accordion, AccordionSummary, AccordionDetails, + Button, + CardMedia, + Badge, } from '@mui/material'; import { useNavigate } from 'react-router-dom'; import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ViewListIcon from '@mui/icons-material/ViewList'; +import GppBadOutlinedIcon from '@mui/icons-material/GppBadOutlined'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; import NotificationsIcon from '@mui/icons-material/Notifications'; @@ -26,18 +32,16 @@ import QrCodeIcon from '@mui/icons-material/QrCode'; import RefreshIcon from '@mui/icons-material/Refresh'; import InfoIcon from '@mui/icons-material/Info'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; import labelVoted from '../../common/resources/images/checkmark-green.png'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; +import CloseIcon from '@mui/icons-material/Close'; import { Fade } from '@mui/material'; -import './Nominees.scss'; +import { useCardano } from '@cardano-foundation/cardano-connect-with-wallet'; +import QRCode from 'react-qr-code'; import { CategoryContent } from '../Categories/Category.types'; import SUMMIT2023CONTENT from '../../common/resources/data/summit2023Content.json'; import { eventBus } from '../../utils/EventBus'; -import { useCardano } from '@cardano-foundation/cardano-connect-with-wallet'; -import CloseIcon from '@mui/icons-material/Close'; import { ROUTES } from '../../routes'; -import { useDispatch, useSelector } from 'react-redux'; import { RootState } from '../../store'; import { buildCanonicalVoteInputJson, @@ -50,34 +54,32 @@ import { copyToClipboard, getSignedMessagePromise, resolveCardanoNetwork, shorte import { buildCanonicalLoginJson, submitLogin } from 'common/api/loginService'; import { getUserInSession, saveUserInSession, tokenIsExpired } from '../../utils/session'; import { setUserVotes, setVoteReceipt, setWalletIsLoggedIn } from '../../store/userSlice'; -import { FinalityScore } from '../../types/voting-ledger-follower-types'; import SidePage from '../../components/common/SidePage/SidePage'; import { useToggle } from 'common/hooks/useToggle'; import ReadMore from './ReadMore'; import Modal from '../../components/common/Modal/Modal'; -import QRCode from 'react-qr-code'; import { CustomButton } from '../../components/common/Button/CustomButton'; import { env } from 'common/constants/env'; import { parseError } from 'common/constants/errors'; import { categoryAlreadyVoted } from '../Categories'; import { ProposalPresentationExtended } from '../../store/types'; import { verifyVote } from 'common/api/verificationService'; +import './Nominees.scss'; +import Masonry from 'react-masonry-css'; +import { ReactComponent as WinnersIcon } from '../../common/resources/images/winner-badge-summit-2023.svg'; const Nominees = () => { + const dispatch = useDispatch(); const { categoryId } = useParams(); const navigate = useNavigate(); - const eventCache = useSelector((state: RootState) => state.user.event); - const walletIsVerified = useSelector((state: RootState) => state.user.walletIsVerified); - const walletIsLoggedIn = useSelector((state: RootState) => state.user.walletIsLoggedIn); - const receipts = useSelector((state: RootState) => state.user.receipts); + const eventCache = useSelector((state: RootState) => state.user?.event); + const walletIsVerified = useSelector((state: RootState) => state.user?.walletIsVerified); + const receipts = useSelector((state: RootState) => state.user?.receipts); const receipt = receipts && Object.keys(receipts).length && receipts[categoryId] ? receipts[categoryId] : undefined; - const userVotes = useSelector((state: RootState) => state.user.userVotes); - const winners = useSelector((state: RootState) => state.user.winners); + const userVotes = useSelector((state: RootState) => state.user?.userVotes); + const winners = useSelector((state: RootState) => state.user?.winners); const categoryVoted = categoryAlreadyVoted(categoryId, userVotes); - - const dispatch = useDispatch(); - const categories = eventCache?.categories; const summit2023Category: CategoryContent = SUMMIT2023CONTENT.categories.find( @@ -87,6 +89,7 @@ const Nominees = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isBigScreen = useMediaQuery(theme.breakpoints.down('xl')); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [isVisible, setIsVisible] = useState(true); const [isToggleReadMore, toggleReadMore] = useToggle(false); @@ -98,6 +101,7 @@ const Nominees = () => { const [nominees, setNominees] = useState([]); const session = getUserInSession(); + const isExpired = !session || tokenIsExpired(session?.expiresAt); const { isConnected, stakeAddress, signMessage } = useCardano({ limitNetwork: resolveCardanoNetwork(env.TARGET_NETWORK), @@ -107,6 +111,12 @@ const Nominees = () => { const signMessagePromisified = useMemo(() => getSignedMessagePromise(signMessage), [signMessage]); + const breakpointColumnsObj = { + default: 3, + 1337: 2, + 909: 1, + }; + const loadNominees = () => { if (categoryId) { categories?.map((category) => { @@ -115,7 +125,7 @@ const Nominees = () => { } }); } else { - navigate(ROUTES.PAGENOTFOUND); + navigate(ROUTES.PAGE_NOT_FOUND); } }; @@ -282,9 +292,9 @@ const Nominees = () => { const renderNomineeButtonLabel = () => { if (isConnected) { if (!walletIsVerified) { - return 'Verify your wallet'; + return 'Verify your Wallet'; } else { - return 'Vote for nominee'; + return 'Vote for Nominee'; } } else { return ( @@ -297,44 +307,102 @@ const Nominees = () => { const handleCopyToClipboard = (text: string) => { copyToClipboard(text) - .then(() => eventBus.publish('showToast', 'Copied to clipboard')) - .catch(() => eventBus.publish('showToast', 'Copied to clipboard failed', 'error')); + .then(() => eventBus.publish('showToast', i18n.t('toast.copy'))) + .catch(() => eventBus.publish('showToast', i18n.t('toast.copyError'), 'error')); }; - const getAssuranceTheme = () => { - // TODO + const getStatusTheme = () => { + const finalityScore = receipt?.finalityScore; - const finalityScore: FinalityScore = receipt?.finalityScore; - - switch (finalityScore) { - case 'VERY_HIGH': - return { - backgroundColor: 'rgba(16, 101, 147, 0.07)', - color: '#056122', - }; - case 'HIGH': - return { - backgroundColor: 'rgba(16, 101, 147, 0.07)', - color: '#056122', - }; - case 'MEDIUM': - return { - backgroundColor: 'rgba(16, 101, 147, 0.07)', - color: '#652701', - }; - case 'LOW': - return { - backgroundColor: 'rgba(16, 101, 147, 0.07)', - }; - case 'FINAL': - return { - backgroundColor: 'rgba(16, 101, 147, 0.07)', - color: '#056122', - }; - default: - return { - backgroundColor: 'rgba(16, 101, 147, 0.07)', - color: '#106593', - }; + if (receipt?.status === 'FULL') { + switch (finalityScore) { + case 'VERY_HIGH': + return { + label: 'VERY HIGH', + backgroundColor: 'rgba(16, 101, 147, 0.07)', + border: 'border: 1px solid #106593', + color: '#056122', + icon: , + description: i18n.t('nominees.receipt.status.FULL.VERY_HIGH.description'), + status: 'FULL', + }; + case 'HIGH': + return { + label: 'HIGH', + backgroundColor: 'rgba(16, 101, 147, 0.07)', + border: 'border: 1px solid #106593', + color: '#056122', + icon: , + description: i18n.t('nominees.receipt.status.FULL.HIGH.description'), + status: 'FULL', + }; + case 'MEDIUM': + return { + label: 'MEDIUM', + backgroundColor: 'rgba(16, 101, 147, 0.07)', + border: 'border: 1px solid #106593', + color: '#652701', + icon: , + description: i18n.t('nominees.receipt.status.FULL.MEDIUM.description'), + status: 'FULL', + }; + case 'LOW': + return { + label: 'LOW', + backgroundColor: 'rgba(16, 101, 147, 0.07)', + border: 'border: 1px solid #106593', + color: '#C20024', + icon: , + description: i18n.t('nominees.receipt.status.FULL.LOW.description'), + status: 'FULL', + }; + case 'FINAL': + return { + label: 'FINAL', + backgroundColor: 'rgba(5, 97, 34, 0.07)', + border: '1px solid #056122', + icon: , + description: '', + status: 'FULL', + }; + default: + return { + backgroundColor: 'rgba(16, 101, 147, 0.07)', + color: '#24262E', + icon: , + description: i18n.t('nominees.receipt.status.FULL.DEFAULT.description'), + status: 'FULL', + }; + } + } else if (receipt?.status === 'PARTIAL') { + return { + label: 'Vote in progress', + backgroundColor: 'rgba(253, 135, 60, 0.07)', + border: '1px solid #FD873C', + color: '#24262E', + icon: , + description: i18n.t('nominees.receipt.status.PARTIAL.description'), + status: 'PARTIAL', + }; + } else if (receipt?.status === 'ROLLBACK') { + return { + label: 'There’s been a rollback', + backgroundColor: 'rgba(194, 0, 36, 0.07)', + border: '1px solid #C20024', + color: '#24262E', + icon: , + description: i18n.t('nominees.receipt.status.ROLLBACK.description'), + status: 'ROLLBACK', + }; + } else { + // BASIC + return { + label: 'Vote not ready for verification', + backgroundColor: 'rgba(16, 101, 147, 0.07)', + border: '1px solid #106593', + color: '#24262E', + icon: , + description: i18n.t('nominees.receipt.status.BASIC.description'), + }; } }; @@ -344,7 +412,7 @@ const Nominees = () => { }; const handleViewVoteReceipt = () => { - if (isConnected && walletIsLoggedIn && !tokenIsExpired(session?.expiresAt)) { + if (isConnected && !isExpired) { viewVoteReceipt(true, true); } else { login(); @@ -404,9 +472,9 @@ const Nominees = () => { verifyVote(body) .then((result) => { if ('verified' in result && result.verified) { - eventBus.publish('showToast', 'Vote proof verified', 'verified'); + eventBus.publish('showToast', i18n.t('toast.voteVerified'), 'verified'); } else { - eventBus.publish('showToast', 'Vote proof not verified', 'error'); + eventBus.publish('showToast', i18n.t('toast.voteNotVerified'), 'error'); } }) .catch((e) => { @@ -433,23 +501,213 @@ const Nominees = () => { key={nominee.id} > + } + invisible={!isWinner} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + > + + + + {voted ? ( + + {i18n.t('nominees.alreadyVoted')} + + ) : null} + + + + + + + {nominee.presentationName} + + + + + {shortenString(nominee.desc, 210)} + + + {!eventCache?.finished && !categoryVoted ? ( + + handleNomineeButton(nominee)} + /> + + ) : null} + + + {isWinner ? ( + + + + ) : null} + + + handleReadMore(nominee)} + /> + + + + + + + ); + })} + + + ); + }; + const renderResponsiveGrid = (): ReactElement => { + return ( + <> +
+ + {sortNominees(nominees).map((nominee) => { + const voted = nomineeAlreadyVoted(nominee); + const isWinner = nomineeIsWinner(nominee); + + return ( + } + invisible={!isWinner} + key={nominee.id} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + > - - - {voted ? ( - + + {voted ? ( + + Already Voted { }} /> - ) : null} - + + ) : null} {nominee.presentationName} - {isWinner ? ( - - - - ) : null} + {isWinner ? ( + + ) : null} - {shortenString(nominee.desc, 210)} + {shortenString(nominee.desc, 200)} - {!eventCache?.finished && !categoryVoted ? ( - + + handleReadMore(nominee)} + fullWidth={true} + /> + + {!eventCache?.finished && !categoryVoted ? ( handleNomineeButton(nominee)} + fullWidth={true} /> - - ) : null} + ) : null} + - - handleReadMore(nominee)} - fullWidth={true} - /> - - - ); - })} - - - ); - }; - const renderResponsiveGrid = (): ReactElement => { - return ( - <> -
- - {sortNominees(nominees).map((nominee) => { - const voted = nomineeAlreadyVoted(nominee); - const isWinner = nomineeIsWinner(nominee); - - return ( - -
- - - - {voted ? ( - - Already Voted - - ) : null} - - - {nominee.presentationName} - {isWinner ? ( - - - - ) : null} - - - - - {shortenString(nominee.desc, 210)} - - - - - handleReadMore(nominee)} - fullWidth={true} - /> - - {!eventCache?.finished && !categoryVoted ? ( - handleNomineeButton(nominee)} - fullWidth={true} - /> - ) : null} - - -
-
+ ); })} -
+
); }; + const showBanner = isConnected && (isExpired || (!isExpired && categoryVoted)); + return ( <>
{ {!isMobile && (
- handleListView('grid')}> + handleListView('grid')} + className={viewMode === 'grid' ? 'selected' : 'un-selected'} + > - handleListView('list')}> + handleListView('list')} + className={viewMode === 'list' ? 'selected' : 'un-selected'} + >
@@ -716,25 +882,23 @@ const Nominees = () => { {summit2023Category.desc} - {isConnected && (categoryVoted || eventCache?.finished || (receipt && categoryId === receipt?.category)) ? ( + {showBanner ? (
- {!tokenIsExpired(session?.expiresAt) ? ( + {!isExpired ? ( ) : ( @@ -750,9 +914,9 @@ const Nominees = () => { lineHeight: '22px', }} > - {!tokenIsExpired(session?.expiresAt) - ? `You have successfully cast a vote for ${votedNominee?.presentationName} in the ${summit2023Category.presentationName} category.` - : 'To see you vote receipt, please sign with your wallet'} + {!isExpired + ? `${i18n.t('nominees.successfullyVoteCast')} ${summit2023Category.presentationName} category.` + : `${i18n.t('nominees.signIn')}`}
{ color: '#F6F9FF', width: 'auto', }} - label={!tokenIsExpired(session?.expiresAt) ? 'View vote receipt' : 'Login with wallet'} + label={!isExpired ? i18n.t('nominees.viewReceipt') : i18n.t('nominees.loginWithWallet')} onClick={() => handleViewVoteReceipt()} fullWidth={true} /> @@ -824,7 +988,7 @@ const Nominees = () => { lineHeight: '36px', }} > - Vote Receipt + {i18n.t('nominees.receipt.voteReceipt')} {receipt?.finalityScore === 'FINAL' ? ( @@ -856,8 +1020,8 @@ const Nominees = () => { lineHeight: '22px', }} > - Verified: - + {i18n.t('nominees.receipt.verified')}: + { maxWidth: '406px', }} > - Your vote has been successfully submitted. You might have to wait up to 30 minutes for this to be - visible on chain. Please check back later to verify your vote. + {i18n.t('nominees.receipt.status.FINAL.description')}
) : ( @@ -913,16 +1076,16 @@ const Nominees = () => { alignItems: 'center', padding: '10px 20px', borderRadius: '8px', - border: '1px solid #106593', - color: 'white', + border: getStatusTheme()?.border, + color: getStatusTheme()?.color, width: '100%', marginBottom: '20px', - backgroundColor: getAssuranceTheme()?.backgroundColor, + backgroundColor: getStatusTheme()?.backgroundColor, }} >
- + {getStatusTheme()?.icon} { > {receipt?.finalityScore ? ( <> - Assurance: {receipt?.finalityScore} + Assurance: {getStatusTheme()?.label} ) : ( - 'Vote not ready for verification' + <>{getStatusTheme()?.label} )} - + { maxWidth: '406px', }} > - Your vote has been successfully submitted. You might have to wait up to 30 minutes for this to be - visible on chain. Please check back later to verify your vote. + {getStatusTheme()?.description} )} @@ -1007,20 +1169,8 @@ const Nominees = () => { lineHeight: '22px', }} > - Event + {i18n.t('nominees.receipt.event')}: - - -
{ lineHeight: '22px', }} > - Proposal + {i18n.t('nominees.receipt.nominee')}: - + { lineHeight: '22px', }} > - Voter Staking Address + {i18n.t('nominees.receipt.stakingAddress')}: - + { lineHeight: '22px', }} > - Status + {i18n.t('nominees.receipt.statusTitle')}: - + { lineHeight: 'normal', }} > - Show Advanced Information + {i18n.t('nominees.receipt.showAdvancedInfo')}: @@ -1208,7 +1358,7 @@ const Nominees = () => { > ID - + { lineHeight: '22px', }} > - Voted at Slot + {i18n.t('nominees.receipt.votedAtSlot')}: - + { lineHeight: '22px', }} > - Vote Proof + {i18n.t('nominees.receipt.voteProof')}: - + { variant="body2" sx={{ pointer: 'cursor' }} > - {receipt?.merkleProof ? JSON.stringify(receipt?.merkleProof || '', null, 4) : 'Not available yet'} + {receipt?.merkleProof + ? JSON.stringify(receipt?.merkleProof || '', null, 4) + : i18n.t('nominees.notAvailable')} {receipt?.merkleProof ? ( @@ -1334,7 +1486,7 @@ const Nominees = () => { color: '#03021F', width: 'auto', }} - label="Verify vote proof" + label={i18n.t('nominees.verifyVoteProof')} onClick={verifyVoteProof} /> ) : null} @@ -1347,7 +1499,7 @@ const Nominees = () => { { lineHeight: '22px', }} > - Your vote has been successfully verified. Click the link or scan the QR code to view the transaction. + {i18n.t('nominees.successfullyVerified')} -
+
+ handleCopyToClipboard( + `https://beta.explorer.cardano.org/en/transaction/${receipt?.merkleProof?.transactionHash}` + ) + } + style={{ display: 'flex', justifyContent: 'center', width: '100%', marginTop: '24px', cursor: 'pointer' }} + >
@@ -1378,7 +1537,7 @@ const Nominees = () => { color: '#03021F', width: 'auto', }} - label="Done" + label={i18n.t('nominees.done')} onClick={toggleViewFinalReceipt} /> @@ -1386,28 +1545,70 @@ const Nominees = () => { - handleVoteNomineeButton()} - /> - + > + {i18n.t('nominees.confirmVoteFor')} {votedNominee?.presentationName} [{selectedNomineeToVote?.id}] + + + + + ); diff --git a/ui/summit-2023/src/pages/Nominees/ReadMore.tsx b/ui/summit-2023/src/pages/Nominees/ReadMore.tsx index 79e7e4468..7447f7ff6 100644 --- a/ui/summit-2023/src/pages/Nominees/ReadMore.tsx +++ b/ui/summit-2023/src/pages/Nominees/ReadMore.tsx @@ -4,6 +4,7 @@ import { Typography, Button, Container, IconButton } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import xIcon from '../../common/resources/images/x-icon.svg'; import linkedinIcon from '../../common/resources/images/linkedin-icon.svg'; +import gitHubIcon from '../../common/resources/images/github-icon.svg'; const ReadMore = (props) => { const { nominee, closeSidePage } = props; @@ -12,7 +13,9 @@ const ReadMore = (props) => { closeSidePage(false); }; - const shouldDisplayGrid = nominee.url.includes('twitter.com') || nominee.url.includes('linkedin.com'); + const shouldDisplayGrid = nominee.urls.some( + (url) => url.includes('twitter.com') || url.includes('linkedin.com') || url.includes('github.com') + ); return ( <> @@ -54,36 +57,58 @@ const ReadMore = (props) => { marginTop={1} marginBottom={2} > - {nominee.url.includes('twitter.com') ? ( - - - X - - - ) : null} - {nominee.url.includes('linkedin.com') ? ( - - - Linkedin - + {nominee.urls.map((url, index) => ( + + {url.includes('twitter.com') ? ( + + + X + + + ) : null} + {url.includes('linkedin.com') ? ( + + + Linkedin + + + ) : null} + {url.includes('github.com') ? ( + + + GitHub + + + ) : null} - ) : null} + ))} ) : null} @@ -97,13 +122,18 @@ const ReadMore = (props) => { {!shouldDisplayGrid ? ( - + <> + {nominee.urls.map((url, index) => ( + + ))} + ) : null} diff --git a/ui/summit-2023/src/pages/UserGuide/UserGuide.module.scss b/ui/summit-2023/src/pages/UserGuide/UserGuide.module.scss index da010bc28..d13e08507 100644 --- a/ui/summit-2023/src/pages/UserGuide/UserGuide.module.scss +++ b/ui/summit-2023/src/pages/UserGuide/UserGuide.module.scss @@ -1,5 +1,4 @@ .userguide { - margin-left: 20px; .sectionTitle { max-height: 110px; margin-top: 40px !important; diff --git a/ui/summit-2023/src/pages/UserGuide/UserGuide.tsx b/ui/summit-2023/src/pages/UserGuide/UserGuide.tsx index f8836c259..d728e51aa 100644 --- a/ui/summit-2023/src/pages/UserGuide/UserGuide.tsx +++ b/ui/summit-2023/src/pages/UserGuide/UserGuide.tsx @@ -1,8 +1,8 @@ import { Grid, Typography, useMediaQuery, useTheme } from '@mui/material'; import React, { useEffect, useState } from 'react'; +import { i18n } from 'i18n'; import { GuideTile } from './components/GuideTile'; import styles from './UserGuide.module.scss'; -import { i18n } from 'i18n'; import SvgIcon from '@mui/material/SvgIcon'; import { ReactComponent as StepOneIcon } from '../../common/resources/images/step1.svg'; import { ReactComponent as StepTwoIcon } from '../../common/resources/images/step2.svg'; @@ -15,18 +15,19 @@ import Modal from '../../components/common/Modal/Modal'; import SupportedWalletsList from '../../components/SupportedWalletsList/SupportedWalletsList'; const UserGuide = () => { - const [openSupportedWalletsModal, setOpenSupportedWalletsModal] = useState(false); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const [openSupportedWalletsModal, setOpenSupportedWalletsModal] = useState(false); + useEffect(() => { - const openConnectWalletModal = () => { + const openSupportedWalletModal = () => { setOpenSupportedWalletsModal(true); }; - eventBus.subscribe('openSupportedWalletModal', openConnectWalletModal); + eventBus.subscribe('openSupportedWalletModal', openSupportedWalletModal); return () => { - eventBus.unsubscribe('openSupportedWalletModal', openConnectWalletModal); + eventBus.unsubscribe('openSupportedWalletModal', openSupportedWalletModal); }; }, []); diff --git a/ui/summit-2023/src/pages/UserGuide/components/GuideTile.module.scss b/ui/summit-2023/src/pages/UserGuide/components/GuideTile.module.scss index ced5832ef..2ceec2552 100644 --- a/ui/summit-2023/src/pages/UserGuide/components/GuideTile.module.scss +++ b/ui/summit-2023/src/pages/UserGuide/components/GuideTile.module.scss @@ -3,7 +3,7 @@ box-shadow: 2px 2px 5px 0px #061d3c40 !important; display: flex !important; flex-direction: column !important; - margin: 20px !important; + margin: 0px 10px 10px 0px !important; max-width: 630px; background-color: white; diff --git a/ui/summit-2023/src/routes/index.tsx b/ui/summit-2023/src/routes/index.tsx index c2ac183f2..7ce35fef9 100644 --- a/ui/summit-2023/src/routes/index.tsx +++ b/ui/summit-2023/src/routes/index.tsx @@ -16,11 +16,11 @@ export const ROUTES = { CATEGORIES: `${PAGE_PATH}categories`, NOMINEES: `${PAGE_PATH}nominees`, LEADERBOARD: `${PAGE_PATH}leaderboard`, - USERGUIDE: `${PAGE_PATH}user-guide`, - TERMSANDCONDITIONS: `${PAGE_PATH}termsandconditions`, - PRIVACYPOLICY: `${PAGE_PATH}privacypolicy`, + USER_GUIDE: `${PAGE_PATH}user-guide`, + TERMS_AND_CONDITIONS: `${PAGE_PATH}terms-and-conditions`, + PRIVACY_POLICY: `${PAGE_PATH}privacy-policy`, NOMINEES_BY_ID: `${PAGE_PATH}nominees/:categoryId`, - PAGENOTFOUND: `${PAGE_PATH}404`, + PAGE_NOT_FOUND: `${PAGE_PATH}404`, }; const PageRouter = () => { @@ -48,19 +48,19 @@ const PageRouter = () => { element={} /> } /> } /> } /> } /> ) => { - state.connectedWallet = action.payload.wallet; - }, setConnectedPeerWallet: (state, action: PayloadAction<{ peerWallet: boolean }>) => { state.connectedPeerWallet = action.payload.peerWallet; }, @@ -61,6 +57,10 @@ export const userSlice = createSlice({ state.walletIsLoggedIn = action.payload.isLoggedIn; }, setVoteReceipt: (state, action: PayloadAction<{ categoryId: string; receipt: VoteReceipt }>) => { + if (!action.payload.categoryId.length) { + state.receipts = {}; + return; + } state.receipts = { ...state.receipts, [action.payload.categoryId]: action.payload.receipt, @@ -79,7 +79,11 @@ export const userSlice = createSlice({ state.event = action.payload.event; }, setWinners: (state, action: PayloadAction<{ winners: { categoryId: string; proposalId: string }[] }>) => { - state.winners = action.payload.winners; + let filteredWinners = state.winners.filter( + (oldWinner) => !action.payload.winners.some((winner) => winner.categoryId === oldWinner.categoryId) + ); + filteredWinners = [...filteredWinners, ...action.payload.winners]; + state.winners = filteredWinners; }, setUserStartsVerification: ( state, diff --git a/ui/summit-2023/src/utils/dateUtils.ts b/ui/summit-2023/src/utils/dateUtils.ts index 9ebb246b8..807d469ae 100644 --- a/ui/summit-2023/src/utils/dateUtils.ts +++ b/ui/summit-2023/src/utils/dateUtils.ts @@ -1,9 +1,3 @@ -export const formatUTCDate = (date: string) => { - if (!date) return ''; - const isoDate = new Date(date).toISOString(); - return `${isoDate.substring(0, 10)} ${isoDate.substring(11, 16)} UTC`; -}; - export const monthNames = [ 'January', 'February', @@ -19,6 +13,16 @@ export const monthNames = [ 'December', ]; +export const formatUTCDate = (date: string) => { + if (!date) return ''; + + const parsedDate = new Date(date); + const monthName = monthNames[parsedDate.getUTCMonth()]; + + const isoDate = parsedDate.toISOString(); + return `${isoDate.substring(0, 4)} ${monthName} ${isoDate.substring(8, 10)}th ${isoDate.substring(11, 16)} UTC`; +}; + export const getMonthName = (index: number) => monthNames[index]; export const getDateAndMonth = (date: string) => { diff --git a/ui/summit-2023/src/utils/session.ts b/ui/summit-2023/src/utils/session.ts index 7f03999ed..f4766b689 100644 --- a/ui/summit-2023/src/utils/session.ts +++ b/ui/summit-2023/src/utils/session.ts @@ -14,6 +14,7 @@ const clearUserInSessionStorage = () => { }; const tokenIsExpired = (expiresAt: string) => { + if (!expiresAt) return true; const currentDate = new Date(); const givenDate = new Date(expiresAt); return givenDate < currentDate; diff --git a/ui/summit-2023/tsconfig.json b/ui/summit-2023/tsconfig.json index ed78914d4..36112286a 100644 --- a/ui/summit-2023/tsconfig.json +++ b/ui/summit-2023/tsconfig.json @@ -19,7 +19,7 @@ "noUnusedLocals": true, "removeComments": true, "preserveConstEnums": true, - "sourceMap": true, + "sourceMap": false, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true,