diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cc662fe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +Dockerfile + +node_modules +.yarn/* +!.yarn/patches diff --git a/.gitignore b/.gitignore index 1d5abae..551c75e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .direnv/ node_modules/ -.yarn/ +.yarn/* +!.yarn/patches/ +!.yarn/patches/** diff --git a/.yarn/patches/bitcore-lib-npm-8.25.10-2b2055eaf2.patch b/.yarn/patches/bitcore-lib-npm-8.25.10-2b2055eaf2.patch new file mode 100644 index 0000000..286711e --- /dev/null +++ b/.yarn/patches/bitcore-lib-npm-8.25.10-2b2055eaf2.patch @@ -0,0 +1,12 @@ +diff --git a/index.js b/index.js +index 4cbe6bf2ac69202558e0cfb8457fec21c2d48017..98cad3a403bacc0ebd2d1223d3c17adc23a53bc7 100644 +--- a/index.js ++++ b/index.js +@@ -5,6 +5,7 @@ var bitcore = module.exports; + // module information + bitcore.version = 'v' + require('./package.json').version; + bitcore.versionGuard = function(version) { ++ return; + if (version !== undefined) { + var message = 'More than one instance of bitcore-lib found. ' + + 'Please make sure to require bitcore-lib and check that submodules do' + diff --git a/.yarn/patches/bitcore-lib-npm-8.25.47-62066b9183.patch b/.yarn/patches/bitcore-lib-npm-8.25.47-62066b9183.patch new file mode 100644 index 0000000..286711e --- /dev/null +++ b/.yarn/patches/bitcore-lib-npm-8.25.47-62066b9183.patch @@ -0,0 +1,12 @@ +diff --git a/index.js b/index.js +index 4cbe6bf2ac69202558e0cfb8457fec21c2d48017..98cad3a403bacc0ebd2d1223d3c17adc23a53bc7 100644 +--- a/index.js ++++ b/index.js +@@ -5,6 +5,7 @@ var bitcore = module.exports; + // module information + bitcore.version = 'v' + require('./package.json').version; + bitcore.versionGuard = function(version) { ++ return; + if (version !== undefined) { + var message = 'More than one instance of bitcore-lib found. ' + + 'Please make sure to require bitcore-lib and check that submodules do' + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8fe1014 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,66 @@ +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS builder +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* .yarnrc.yml ./ +COPY packages/bet-dapp/package.json ./packages/bet-dapp/ + +COPY .yarn/patches .yarn/patches/ + +RUN corepack enable +RUN yarn set version 4.2.2 + +RUN \ + yarn install + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +ENV NEXT_TELEMETRY_DISABLED=1 + +ENV NEXT_PUBLIC_URL=https://betting.hathor.network/ +ENV NEXT_PUBLIC_BASE_PATH=/public + +COPY packages/bet-dapp ./packages/bet-dapp +RUN cp ./packages/bet-dapp/next.config.production.mjs ./packages/bet-dapp/next.config.mjs + +RUN \ + yarn workspace bet-dapp run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +# Uncomment the following line in case you want to disable telemetry during runtime. +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# # Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/packages/bet-dapp/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/packages/bet-dapp/public ./packages/bet-dapp/public/public +COPY --from=builder --chown=nextjs:nodejs /app/packages/bet-dapp/.next/static ./packages/bet-dapp/.next/static +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/classic-level ./node_modules/classic-level + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +ENV HOSTNAME="0.0.0.0" +CMD ["node", "packages/bet-dapp/server.js"] diff --git a/SOP.md b/SOP.md new file mode 100644 index 0000000..4b0e313 --- /dev/null +++ b/SOP.md @@ -0,0 +1,179 @@ +# Bet-dapp - SOP + +## Pre-requisites + +### Tools + +- Docker +- AWS CLI +- Terraform (Only if you need to change the infra. Not necessary to deploy new versions in the instance) + +### AWS Access + +You must configure access to the `Nano-testnet` account in AWS. + +Run the following command: + +```bash +aws configure sso +``` + +Set the following values when asked: + +- SSO Session Name: `nano-testnet` +- SSO Start URL: `https://hathorlabs.awsapps.com/start` +- SSO Region: `us-east-1` +- SSO Registration Scopes: `sso:account:access` +- In the accounts list, select `Nano-testnet` +- CLI default client region: `us-east-1` +- CLI default output format: `None` +- CLI profile name: `nano-testnet` + +Login to the account using the following command: + +```bash +aws sso login --profile nano-testnet +``` +## Deploying new versions + +### Building and pushing the image + +```bash +aws ecr get-login-password --region ap-southeast-1 --profile nano-testnet | docker login --username AWS --password-stdin 471112952246.dkr.ecr.ap-southeast-1.amazonaws.com + +docker build -t 471112952246.dkr.ecr.ap-southeast-1.amazonaws.com/bet-dapp:latest . + +docker push 471112952246.dkr.ecr.ap-southeast-1.amazonaws.com/bet-dapp:latest +``` + +### Accessing the instance + +```bash +# Get the instance IP from its name tag +aws ec2 describe-instances --profile nano-testnet --filters "Name=tag:Name,Values=bet-dapp" --query "Reservations[*].Instances[*].PublicIpAddress" --output text --region ap-southeast-1 + +ssh ec2-user@ +``` + +### Login to Docker in the instance + +```bash +aws ecr get-login-password --region ap-southeast-1 | sudo docker login --username AWS --password-stdin 471112952246.dkr.ecr.ap-southeast-1.amazonaws.com +``` + +### Pulling the latest image + +The following should be run inside the EC2 instance + +```bash +# Inside the home directory (/home/ec2-user) +sudo docker compose pull +``` + +### Restarting the service + +The following should be run inside the EC2 instance + +```bash +# Inside the home directory (/home/ec2-user) +# Running this will pick up any changes in the configuration and recreate the container +sudo docker compose up -d +``` + +## Other operations in the instance + +### Checking logs + +The following should be run inside the EC2 instance + +```bash +# Inside the home directory (/home/ec2-user) +sudo docker compose logs bet-dapp -f +``` + +### Stopping the service + +The following should be run inside the EC2 instance + +```bash +# Inside the home directory (/home/ec2-user) +sudo docker compose down +``` + +### Starting the service + +The following should be run inside the EC2 instance + +```bash +# Inside the home directory (/home/ec2-user) +sudo docker compose up -d +``` + +## Inspecting the DynamoDB table in AWS + +Go to https://hathorlabs.awsapps.com/start and login to the `Nano-testnet` account. + +You can find the table at: +- https://ap-southeast-1.console.aws.amazon.com/dynamodbv2/home?region=ap-southeast-1#table?name=NanoContracts + +Click `Explore table items` to see the items in the table. + +## Infra changes + +The infra is currently defined in https://github.com/HathorNetwork/ops-tools/tree/master/terraform/bet-dapp + +Make sure to run `make terraform-init` to setup the Terraform environment. + +### Updating the DybamoDB table definition + +The table is defined in the file `terraform/bet-dapp/dynamodb.tf` + +Refer to the [Terraform documentation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table) for more information on how to define the table. + +Apply the changes by running: + +```bash +make apply +``` + +You'll be prompted to review the changes before they are applied. + +### Changing the instance security group, instance type or other configurations + +The instance is defined in the file `terraform/bet-dapp/ec2.tf` + +Refer to the Terraform documentation on the following resources for more information on how to define the instance: +- [aws_instance](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/instance) +- [aws_security_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) +- [aws_iam_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) +- [aws_iam_instance_profile](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_instance_profile) +- [aws_iam_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) + +Apply the changes by running: + +```bash +make apply +``` + +You'll be prompted to review the changes before they are applied. + +### Changing the instance provisioning script + +The provisioning script is defined in the file `terraform/bet-dapp/scripts/provision-instance.yaml.tftpl` + +It's a cloud-init script that runs when the instance is created. + +If changes are needed inside the instance, ideally they should be written in this script and the instance should be recreated by running `make apply`. + +This will generate a downtime in the service, so it should be done with caution. To avoid this, you could create a new instance first, test the changes and then switch the Elastic IP to the new instance. Or just make the changes in the running instance, if you're in a hurry, then update the script later. + +The Elastic IP is defined in the file `terraform/bet-dapp/ec2.tf` + +### Changing the Cloudfront configuration + +We had to add a rule in the Cloudfront distribution we use to serve `hathor.network`, in order to have the bet-dapp served under https://hathor.network/betting2024 + +To update this, you should: + +- Login to the `Hathor-website` account through https://hathorlabs.awsapps.com/start +- Access the Cloudfront distribution at https://us-east-1.console.aws.amazon.com/cloudfront/v4/home?region=eu-central-1#/distributions/E2AT2JAIYAKNGG diff --git a/package.json b/package.json index 85a418a..24acab0 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,9 @@ "packageManager": "yarn@4.2.2", "workspaces": [ "packages/bet-dapp" - ] + ], + "resolutions": { + "bitcore-lib@npm:8.25.10": "patch:bitcore-lib@npm%3A8.25.47#~/.yarn/patches/bitcore-lib-npm-8.25.47-62066b9183.patch", + "bitcore-lib@npm:^8.25.10": "patch:bitcore-lib@npm%3A8.25.47#~/.yarn/patches/bitcore-lib-npm-8.25.47-62066b9183.patch" + } } diff --git a/packages/bet-dapp/.gitignore b/packages/bet-dapp/.gitignore index fd3dbb5..8e8c536 100644 --- a/packages/bet-dapp/.gitignore +++ b/packages/bet-dapp/.gitignore @@ -34,3 +34,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +dynamodb_data/ diff --git a/packages/bet-dapp/docker-compose.yml b/packages/bet-dapp/docker-compose.yml new file mode 100644 index 0000000..ad63f21 --- /dev/null +++ b/packages/bet-dapp/docker-compose.yml @@ -0,0 +1,11 @@ +version: '3.8' + +services: + dynamodb: + image: amazon/dynamodb-local + container_name: dapp-dynamodb + ports: + - "8000:8000" + volumes: + - ./dynamodb_data:/home/dynamodblocal/data + command: -jar DynamoDBLocal.jar -sharedDb -dbPath /home/dynamodblocal/data --cors "http://localhost:3000" diff --git a/packages/bet-dapp/next.config.mjs b/packages/bet-dapp/next.config.mjs index 4678774..2070f9f 100644 --- a/packages/bet-dapp/next.config.mjs +++ b/packages/bet-dapp/next.config.mjs @@ -1,4 +1,16 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + reactStrictMode: false, + webpack: config => { + config.externals.push('pino-pretty', 'lokijs', 'encoding'); + + return { + ...config, + node: { + __dirname: true, + }, + }; + }, +}; export default nextConfig; diff --git a/packages/bet-dapp/next.config.production.mjs b/packages/bet-dapp/next.config.production.mjs new file mode 100644 index 0000000..99693a8 --- /dev/null +++ b/packages/bet-dapp/next.config.production.mjs @@ -0,0 +1,18 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", + basePath: "", + assetPrefix: "https://betting.hathor.network/", + webpack: config => { + config.externals.push('pino-pretty', 'lokijs', 'encoding'); + + return { + ...config, + node: { + __dirname: true, + }, + }; + }, +}; + +export default nextConfig; diff --git a/packages/bet-dapp/package.json b/packages/bet-dapp/package.json index 43bbe5c..7e208ef 100644 --- a/packages/bet-dapp/package.json +++ b/packages/bet-dapp/package.json @@ -3,32 +3,61 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbo", - "build": "next build", + "dev": "next dev", + "build": "next build --experimental-build-mode compile", "start": "next start", "lint": "next lint" }, "dependencies": { + "@aws-sdk/client-dynamodb": "^3.637.0", + "@aws-sdk/lib-dynamodb": "^3.637.0", + "@hookform/resolvers": "^3.9.0", "@radix-ui/react-aspect-ratio": "^1.1.0", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@tanstack/react-table": "^8.20.5", + "@walletconnect/core": "^2.15.1", + "@walletconnect/modal": "^2.7.0", + "@walletconnect/sign-client": "^2.15.1", + "@walletconnect/types": "^2.15.1", + "@web3modal/standalone": "^2.4.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "hathor-rpc-handler-test": "0.0.42", + "highlight.js": "^11.10.0", + "lodash": "^4.17.21", "lucide-react": "^0.435.0", - "next": "14.2.6", + "marked": "^14.1.0", + "next": "^14.2.7", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.52.2", + "react-syntax-highlighter": "^15.5.0", + "sharp": "^0.33.5", "tailwind-merge": "^2.5.2", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8" }, "devDependencies": { + "@types/lodash": "^4", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/react-syntax-highlighter": "^15", "eslint": "^8", "eslint-config-next": "14.2.6", + "node-pre-gyp": "^0.17.0", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" + }, + "peerDependencies": { + "@hathor/wallet-lib": "1.11.0" } } diff --git a/packages/bet-dapp/public/11_Opening-Screen_Nomad.png b/packages/bet-dapp/public/11_Opening-Screen_Nomad.png new file mode 100644 index 0000000..db8f391 Binary files /dev/null and b/packages/bet-dapp/public/11_Opening-Screen_Nomad.png differ diff --git a/packages/bet-dapp/public/12_Opening-Screen_Builder.png b/packages/bet-dapp/public/12_Opening-Screen_Builder.png new file mode 100644 index 0000000..040e6bc Binary files /dev/null and b/packages/bet-dapp/public/12_Opening-Screen_Builder.png differ diff --git a/packages/bet-dapp/public/13_Opening-Screen_Scribe.png b/packages/bet-dapp/public/13_Opening-Screen_Scribe.png new file mode 100644 index 0000000..fdc26a8 Binary files /dev/null and b/packages/bet-dapp/public/13_Opening-Screen_Scribe.png differ diff --git a/packages/bet-dapp/public/14_Opening-Screen_Surveyor.png b/packages/bet-dapp/public/14_Opening-Screen_Surveyor.png new file mode 100644 index 0000000..08e97b1 Binary files /dev/null and b/packages/bet-dapp/public/14_Opening-Screen_Surveyor.png differ diff --git a/packages/bet-dapp/public/background.png b/packages/bet-dapp/public/background.png new file mode 100644 index 0000000..07f59ba Binary files /dev/null and b/packages/bet-dapp/public/background.png differ diff --git a/packages/bet-dapp/public/fonts/KuenstlerGrotesk.ttf b/packages/bet-dapp/public/fonts/KuenstlerGrotesk.ttf new file mode 100644 index 0000000..e8bf4cf Binary files /dev/null and b/packages/bet-dapp/public/fonts/KuenstlerGrotesk.ttf differ diff --git a/packages/bet-dapp/public/lettering-nanos.png b/packages/bet-dapp/public/lettering-nanos.png new file mode 100644 index 0000000..8879d21 Binary files /dev/null and b/packages/bet-dapp/public/lettering-nanos.png differ diff --git a/packages/bet-dapp/public/lettering-pharaohs-quest.png b/packages/bet-dapp/public/lettering-pharaohs-quest.png new file mode 100644 index 0000000..62cbf94 Binary files /dev/null and b/packages/bet-dapp/public/lettering-pharaohs-quest.png differ diff --git a/packages/bet-dapp/public/logo.svg b/packages/bet-dapp/public/logo.svg new file mode 100644 index 0000000..5e72bad --- /dev/null +++ b/packages/bet-dapp/public/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/bet-dapp/public/logo_white.svg b/packages/bet-dapp/public/logo_white.svg new file mode 100644 index 0000000..6f3e2e4 --- /dev/null +++ b/packages/bet-dapp/public/logo_white.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/bet-dapp/public/open-screen-background.png b/packages/bet-dapp/public/open-screen-background.png new file mode 100644 index 0000000..7bbf6cc Binary files /dev/null and b/packages/bet-dapp/public/open-screen-background.png differ diff --git a/packages/bet-dapp/public/opening_screen_guilds_1.png b/packages/bet-dapp/public/opening_screen_guilds_1.png new file mode 100644 index 0000000..689d02e Binary files /dev/null and b/packages/bet-dapp/public/opening_screen_guilds_1.png differ diff --git a/packages/bet-dapp/public/opening_screen_guilds_2.png b/packages/bet-dapp/public/opening_screen_guilds_2.png new file mode 100644 index 0000000..1fd494c Binary files /dev/null and b/packages/bet-dapp/public/opening_screen_guilds_2.png differ diff --git a/packages/bet-dapp/public/opening_screen_guilds_3.png b/packages/bet-dapp/public/opening_screen_guilds_3.png new file mode 100644 index 0000000..633aba3 Binary files /dev/null and b/packages/bet-dapp/public/opening_screen_guilds_3.png differ diff --git a/packages/bet-dapp/public/opening_screen_guilds_4.png b/packages/bet-dapp/public/opening_screen_guilds_4.png new file mode 100644 index 0000000..6b8a8e1 Binary files /dev/null and b/packages/bet-dapp/public/opening_screen_guilds_4.png differ diff --git a/packages/bet-dapp/public/opening_screen_guilds_5.png b/packages/bet-dapp/public/opening_screen_guilds_5.png new file mode 100644 index 0000000..9c90d47 Binary files /dev/null and b/packages/bet-dapp/public/opening_screen_guilds_5.png differ diff --git a/packages/bet-dapp/public/papyrus-background.png b/packages/bet-dapp/public/papyrus-background.png new file mode 100644 index 0000000..aa0d855 Binary files /dev/null and b/packages/bet-dapp/public/papyrus-background.png differ diff --git a/packages/bet-dapp/public/pharaohs_quest.png b/packages/bet-dapp/public/pharaohs_quest.png new file mode 100644 index 0000000..84efc24 Binary files /dev/null and b/packages/bet-dapp/public/pharaohs_quest.png differ diff --git a/packages/bet-dapp/public/qr_code.png b/packages/bet-dapp/public/qr_code.png new file mode 100644 index 0000000..4cb3fed Binary files /dev/null and b/packages/bet-dapp/public/qr_code.png differ diff --git a/packages/bet-dapp/public/qr_code_old.png b/packages/bet-dapp/public/qr_code_old.png new file mode 100644 index 0000000..727df5d Binary files /dev/null and b/packages/bet-dapp/public/qr_code_old.png differ diff --git a/packages/bet-dapp/public/visual-elements/03_Opening-Screen_Icon-God.png b/packages/bet-dapp/public/visual-elements/03_Opening-Screen_Icon-God.png new file mode 100644 index 0000000..ee3df32 Binary files /dev/null and b/packages/bet-dapp/public/visual-elements/03_Opening-Screen_Icon-God.png differ diff --git a/packages/bet-dapp/public/visual-elements/04_Opening-Screen_Icon-Pharao.png b/packages/bet-dapp/public/visual-elements/04_Opening-Screen_Icon-Pharao.png new file mode 100644 index 0000000..25e4f4b Binary files /dev/null and b/packages/bet-dapp/public/visual-elements/04_Opening-Screen_Icon-Pharao.png differ diff --git a/packages/bet-dapp/public/visual-elements/05_Opening-Screen_Icon-Priest.png b/packages/bet-dapp/public/visual-elements/05_Opening-Screen_Icon-Priest.png new file mode 100644 index 0000000..b110b8e Binary files /dev/null and b/packages/bet-dapp/public/visual-elements/05_Opening-Screen_Icon-Priest.png differ diff --git a/packages/bet-dapp/public/visual-elements/06_Opening-Screen_Icon-Noble.png b/packages/bet-dapp/public/visual-elements/06_Opening-Screen_Icon-Noble.png new file mode 100644 index 0000000..4d74ba3 Binary files /dev/null and b/packages/bet-dapp/public/visual-elements/06_Opening-Screen_Icon-Noble.png differ diff --git a/packages/bet-dapp/public/visual-elements/07_Opening-Screen_Icon-Artisan.png b/packages/bet-dapp/public/visual-elements/07_Opening-Screen_Icon-Artisan.png new file mode 100644 index 0000000..c23a5d2 Binary files /dev/null and b/packages/bet-dapp/public/visual-elements/07_Opening-Screen_Icon-Artisan.png differ diff --git a/packages/bet-dapp/public/visual-elements/07_Opening-Screen_Icon-Scribe.png b/packages/bet-dapp/public/visual-elements/07_Opening-Screen_Icon-Scribe.png new file mode 100644 index 0000000..6376322 Binary files /dev/null and b/packages/bet-dapp/public/visual-elements/07_Opening-Screen_Icon-Scribe.png differ diff --git a/packages/bet-dapp/public/visual-elements/09_Opening-Screen_Icon-Builder.png b/packages/bet-dapp/public/visual-elements/09_Opening-Screen_Icon-Builder.png new file mode 100644 index 0000000..986240e Binary files /dev/null and b/packages/bet-dapp/public/visual-elements/09_Opening-Screen_Icon-Builder.png differ diff --git a/packages/bet-dapp/public/visual-elements/10_Opening-Screen_Icon-Servent.png b/packages/bet-dapp/public/visual-elements/10_Opening-Screen_Icon-Servent.png new file mode 100644 index 0000000..5b2e2c6 Binary files /dev/null and b/packages/bet-dapp/public/visual-elements/10_Opening-Screen_Icon-Servent.png differ diff --git a/packages/bet-dapp/src/app/all_bets/layout.tsx b/packages/bet-dapp/src/app/all_bets/layout.tsx new file mode 100644 index 0000000..be9a1a5 --- /dev/null +++ b/packages/bet-dapp/src/app/all_bets/layout.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/bet-dapp/src/app/all_bets/page.tsx b/packages/bet-dapp/src/app/all_bets/page.tsx new file mode 100644 index 0000000..3eb8597 --- /dev/null +++ b/packages/bet-dapp/src/app/all_bets/page.tsx @@ -0,0 +1,43 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Header } from '@/components/header'; +import { NcHistoryItem, columns } from '@/components/nc-history/columns'; +import { DataTable } from '@/components/nc-history/data-table'; +import Link from 'next/link'; +import Image from 'next/image'; +import { getNanoContracts } from '@/lib/api/getNanoContracts'; +import { orderBy } from 'lodash'; +import { BASE_PATH } from '@/constants'; + +export default function AllBetsPage() { + const [data, setData] = useState([]); + + useEffect(() => { + (async () => { + try { + const nanoContracts = await getNanoContracts(); + setData(orderBy(nanoContracts, 'createdAt', ['desc'])); + } catch (e) { + } + })(); + }, []); + + return ( +
+
+ + +

See all bets

+

Choose the existing bet you want to see details

+ + +
+
+ + Hathor + +
+ ); +} diff --git a/packages/bet-dapp/src/app/all_bets/success/[id]/nano-contract.template.tsx b/packages/bet-dapp/src/app/all_bets/success/[id]/nano-contract.template.tsx new file mode 100644 index 0000000..c7221c8 --- /dev/null +++ b/packages/bet-dapp/src/app/all_bets/success/[id]/nano-contract.template.tsx @@ -0,0 +1,203 @@ +export const ncCode = ` +from math import floor +from typing import Optional + +from hathor.nanocontracts.blueprint import Blueprint +from hathor.nanocontracts.exception import NCFail +from hathor.nanocontracts.types import Context, NCAction, NCActionType, SignedData, public +from hathor.types import Address, Amount, Timestamp, TokenUid, TxOutputScript + +Result = str + + +class InvalidToken(NCFail): + pass + + +class ResultAlreadySet(NCFail): + pass + + +class ResultNotAvailable(NCFail): + pass + + +class WithdrawalNotAllowed(NCFail): + pass + + +class DepositNotAllowed(NCFail): + pass + + +class TooManyActions(NCFail): + pass + + +class TooLate(NCFail): + pass + + +class InsufficientBalance(NCFail): + pass + + +class InvalidOracleSignature(NCFail): + pass + + +class Bet(Blueprint): + """Bet blueprint with final result provided by an oracle. + + The life cycle of contracts using this blueprint is the following: + + 1. [Owner ] Create a contract. + 2. [User 1] \`bet(...)\` on result A. + 3. [User 2] \`bet(...)\` on result A. + 4. [User 3] \`bet(...)\` on result B. + 5. [Oracle] \`set_result(...)\` as result A. + 6. [User 1] \`withdraw(...)\` + 7. [User 2] \`withdraw(...)\` + + Notice that, in the example above, users 1 and 2 won. + """ + + # Total bets per result. + bets_total: dict[Result, Amount] + + # Total bets per (result, address). + bets_address: dict[tuple[Result, Address], Amount] + + # Bets grouped by address. + address_details: dict[Address, dict[Result, Amount]] + + # Amount that has already been withdrawn per address. + withdrawals: dict[Address, Amount] + + # Total bets. + total: Amount + + # Final result. + final_result: Optional[Result] + + # Oracle script to set the final result. + oracle_script: TxOutputScript + + # Maximum timestamp to make a bet. + date_last_bet: Timestamp + + # Token for this bet. + token_uid: TokenUid + + @public + def initialize(self, ctx: Context, oracle_script: TxOutputScript, token_uid: TokenUid, + date_last_bet: Timestamp) -> None: + if len(ctx.actions) != 0: + raise NCFail('must be a single call') + self.oracle_script = oracle_script + self.token_uid = token_uid + self.date_last_bet = date_last_bet + self.final_result = None + self.total = 0 + + def has_result(self) -> bool: + """Return True if the final result has already been set.""" + return bool(self.final_result is not None) + + def fail_if_result_is_available(self) -> None: + """Fail the execution if the final result has already been set.""" + if self.has_result(): + raise ResultAlreadySet + + def fail_if_result_is_not_available(self) -> None: + """Fail the execution if the final result is not available yet.""" + if not self.has_result(): + raise ResultNotAvailable + + def fail_if_invalid_token(self, action: NCAction) -> None: + """Fail the execution if the token is invalid.""" + if action.token_uid != self.token_uid: + token1 = self.token_uid.hex() if self.token_uid else None + token2 = action.token_uid.hex() if action.token_uid else None + raise InvalidToken(f'invalid token ({token1} != {token2})') + + def _get_action(self, ctx: Context) -> NCAction: + """Return the only action available; fails otherwise.""" + if len(ctx.actions) != 1: + raise TooManyActions('only one action supported') + if self.token_uid not in ctx.actions: + raise InvalidToken(f'token different from {self.token_uid.hex()}') + return ctx.actions[self.token_uid] + + @public + def bet(self, ctx: Context, address: Address, score: str) -> None: + """Make a bet.""" + action = self._get_action(ctx) + if action.type != NCActionType.DEPOSIT: + raise WithdrawalNotAllowed('must be deposit') + self.fail_if_result_is_available() + self.fail_if_invalid_token(action) + if ctx.timestamp > self.date_last_bet: + raise TooLate(f'cannot place bets after {self.date_last_bet}') + amount = action.amount + self.total += amount + if score not in self.bets_total: + self.bets_total[score] = amount + else: + self.bets_total[score] += amount + key = (score, address) + if key not in self.bets_address: + self.bets_address[key] = amount + else: + self.bets_address[key] += amount + + # Update dict indexed by address + partial = self.address_details.get(address, {}) + partial.update({ + score: self.bets_address[key] + }) + + self.address_details[address] = partial + + @public + def set_result(self, ctx: Context, result: SignedData[Result]) -> None: + """Set final result. This method is called by the oracle.""" + self.fail_if_result_is_available() + if not result.checksig(self.oracle_script): + raise InvalidOracleSignature + self.final_result = result.data + + @public + def withdraw(self, ctx: Context) -> None: + """Withdraw tokens after the final result is set.""" + action = self._get_action(ctx) + if action.type != NCActionType.WITHDRAWAL: + raise DepositNotAllowed('action must be withdrawal') + self.fail_if_result_is_not_available() + self.fail_if_invalid_token(action) + allowed = self.get_max_withdrawal(ctx.address) + if action.amount > allowed: + raise InsufficientBalance(f'withdrawal amount is greater than available (max: {allowed})') + if ctx.address not in self.withdrawals: + self.withdrawals[ctx.address] = action.amount + else: + self.withdrawals[ctx.address] += action.amount + + def get_max_withdrawal(self, address: Address) -> int: + """Return the maximum amount available for withdrawal.""" + total = self.get_winner_amount(address) + withdrawals = self.withdrawals.get(address, 0) + return total - withdrawals + + def get_winner_amount(self, address: Address) -> Amount: + """Return how much an address has won.""" + self.fail_if_result_is_not_available() + if self.final_result not in self.bets_total: + return 0 + result_total = self.bets_total[self.final_result] + if result_total == 0: + return 0 + address_total = self.bets_address.get((self.final_result, address), 0) + percentage = address_total / result_total + return floor(percentage * self.total) +`; diff --git a/packages/bet-dapp/src/app/all_bets/success/[id]/page.tsx b/packages/bet-dapp/src/app/all_bets/success/[id]/page.tsx new file mode 100644 index 0000000..bb49feb --- /dev/null +++ b/packages/bet-dapp/src/app/all_bets/success/[id]/page.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { HathorGradient } from '@/components/hathor-gradient'; +import { Card, CardContent } from '@/components/ui/card'; +import React, { useCallback } from 'react'; +import SyntaxHighlighter from 'react-syntax-highlighter'; +import solarized from 'react-syntax-highlighter/dist/esm/styles/hljs/stackoverflow-dark'; +import { ncCode } from './nano-contract.template'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; +import { Button } from '@/components/ui/button'; +import { useParams, useRouter } from 'next/navigation'; + +export default function CreateSuccess() { + const params = useParams(); + const router = useRouter(); + + const placeBet = useCallback(() => { + if (!params || !params.id) { + return; + } + + router.push(`/bet/${params.id}`); + }, [params, router]); + + return ( + <> +
+ + +
+ Congratulations!)} /> + 🎉 +
+ +

+ Nano Contract created in just a few minutes!
+ Scroll down to see all the hassle, time and money that you saved! +

+ +
+
+ + + {ncCode} + + + +
+ +

+ Now it's time: place your bet and have fun.🥳 +

+ + + +
+
+
+ + ); +} diff --git a/packages/bet-dapp/src/app/bet/[id]/createBet.tsx b/packages/bet-dapp/src/app/bet/[id]/createBet.tsx new file mode 100644 index 0000000..a8b01dc --- /dev/null +++ b/packages/bet-dapp/src/app/bet/[id]/createBet.tsx @@ -0,0 +1,34 @@ +import { IHathorRpc } from '@/contexts/JsonRpcContext'; +import { SendNanoContractRpcRequest, sendNanoContractTxRpcRequest } from 'hathor-rpc-handler-test'; +import { SendNanoContractTxResponse } from 'hathor-rpc-handler-test'; +import { BET_BLUEPRINT, EVENT_TOKEN } from '@/constants'; +import { NanoContractActionType } from '@hathor/wallet-lib/lib/nano_contracts/types'; +import { getAddressHex } from '@/lib/utils'; + +export const createBet = async ( + hathorRpc: IHathorRpc, + address: string, + ncId: string, + result: string, + amount: number, +) => { + const ncTxRpcReq: SendNanoContractRpcRequest = sendNanoContractTxRpcRequest( + 'bet', + BET_BLUEPRINT, [{ + type: NanoContractActionType.DEPOSIT, + token: EVENT_TOKEN, + amount: Math.round(amount * 100), + address: null, + changeAddress: address, + }], [ + address, + result, + ], + true, + ncId, + ); + + const rpcResponse: SendNanoContractTxResponse = await hathorRpc.sendNanoContractTx(ncTxRpcReq); + + return rpcResponse; +}; diff --git a/packages/bet-dapp/src/app/bet/[id]/page.tsx b/packages/bet-dapp/src/app/bet/[id]/page.tsx new file mode 100644 index 0000000..c449feb --- /dev/null +++ b/packages/bet-dapp/src/app/bet/[id]/page.tsx @@ -0,0 +1,304 @@ +'use client'; + +import React, { useCallback, useEffect, useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Header } from '@/components/header'; +import { z } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import Image from 'next/image'; +import { TotalBets } from '@/components/total-bets'; +import { useParams, useRouter } from 'next/navigation'; +import { getNanoContractById } from '@/lib/api/getNanoContractById'; +import { NanoContract } from '@/lib/dynamodb/nano-contract'; +import { useWalletConnectClient } from '@/contexts/WalletConnectClientContext'; +import { useJsonRpc } from '@/contexts/JsonRpcContext'; +import { createBet } from './createBet'; +import { ResultError } from '@/components/result-error'; +import { WaitInput } from '@/components/wait-input'; +import { EVENT_TOKEN_SYMBOL } from '@/constants'; +import { getFullnodeNanoContractById } from '@/lib/api/getFullnodeNanoContractById'; +import { NanoContractStateAPIResponse } from '@hathor/wallet-lib/lib/nano_contracts/types'; +import { get } from 'lodash'; +import { getFullnodeNanoContractHistoryById } from '@/lib/api/getFullnodeNanoContractHistoryById'; +import { extractDataFromHistory, waitForTransactionConfirmation } from '@/lib/utils'; +import { Transaction } from '@hathor/wallet-lib'; +import { IHistoryTx } from '@hathor/wallet-lib/lib/types'; +import { BASE_PATH } from '@/constants'; + +const formSchema = z.object({ + bet: z.string().min(2), + amount: z.coerce.number(), +}); + +export default function BetPage() { + const router = useRouter(); + const params = useParams(); + const [waitingApproval, setWaitingApproval] = useState(false); + const [waitingConfirmation, setWaitingConfirmation] = useState(false); + const [history, setHistory] = useState<{ + type: string, + amount: string, + bet: string, + id: string, + timestamp: Date, + }[]>([]); + const [bet, setBet] = useState(null); + const [error, setError] = useState(false); + const [nanoContract, setNanoContract] = useState(null); + const [fullnodeNanoContract, setFullnodeNanoContract] = useState(null); + const { session, connect, getFirstAddress } = useWalletConnectClient(); + + const updateNcData = useCallback(async (ncId: string) => { + const firstAddress = getFirstAddress(); + + const nc = await getNanoContractById(ncId); + setNanoContract(nc); + + // State on fullnode: + const fullnodeNc: NanoContractStateAPIResponse = await getFullnodeNanoContractById(nc.id, firstAddress); + setFullnodeNanoContract(fullnodeNc); + + // History on fullnode: + const history = await getFullnodeNanoContractHistoryById(nc.id) + const [_totalInBets, data] = await extractDataFromHistory(history); + setHistory(data); + }, [getFirstAddress]); + + useEffect(() => { + if (!params || !params.id) { + return; + } + + const ncId = params.id as string; + updateNcData(ncId); + }, [params, updateNcData]); + + // Poll for result to check if it was already set. + useEffect(() => { + if (!nanoContract) { + return; + } + + let interval: ReturnType; + const fetchValue = async () => { + const firstAddress = getFirstAddress(); + const fullnodeNc: NanoContractStateAPIResponse = await getFullnodeNanoContractById(nanoContract.id, firstAddress); + const fullnodeHistory: IHistoryTx[] = await getFullnodeNanoContractHistoryById(nanoContract.id); + const [_, data] = await extractDataFromHistory(fullnodeHistory); + + setHistory(data); + + const result = get(fullnodeNc, 'fields.final_result.value', null); + if (result) { + // @ts-ignore + clearInterval(interval); + + // Navigate to result + router.replace(`/results/${nanoContract.id}`); + } + }; + + fetchValue(); + interval = setInterval(fetchValue, 3000); + + return () => { + clearInterval(interval); + }; + }, [nanoContract, getFirstAddress, router]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: 'onChange', + defaultValues: { + bet: '', + }, + }); + + const { hathorRpc } = useJsonRpc(); + + const onSubmit = useCallback(async (values: z.infer) => { + await connect(); + if (!nanoContract) { + return; + } + setWaitingApproval(true); + + try { + const firstAddress = getFirstAddress(); + const tx = await createBet( + hathorRpc, + firstAddress, + nanoContract.id, + values.bet, + values.amount + ); + + setWaitingApproval(false); + setWaitingConfirmation(true); + await waitForTransactionConfirmation((tx.response as unknown as Transaction).hash as string); + + setBet({ + amount: values.amount, + bet: values.bet + }); + } catch (e) { + setError(true); + } finally { + setWaitingApproval(false); + setWaitingConfirmation(false); + } + }, [getFirstAddress, hathorRpc, connect, nanoContract ]); + + const onConnect = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + + connect(); + }, [connect]); + + const onTryAgain = useCallback(() => { + const values = form.getValues(); + setError(false); + onSubmit(values); + }, [form, onSubmit]); + + const onCancel = useCallback(() => { + setError(false); + }, []); + + const onSetResult = useCallback(async () => { + if (!params || !params.id) { + return; + } + + router.push(`/set_result/${params.id}`); + }, [params, router]); + + const connected = !!session; + + if (!nanoContract) { + return null; + } + + const oracleAddress = nanoContract.oracle; + const result = get(fullnodeNanoContract, 'fields.final_result.value', null); + const lastBet: number = get(fullnodeNanoContract, 'fields.date_last_bet.value', 0); + const address = getFirstAddress(); + + const now = Math.ceil(new Date().getTime() / 1000); + + const canPlaceABet = () => { + return session + && !result + && lastBet >= now; + }; + + const canSetResult = () => { + return (session != null) + && history.length > 0 + && address === oracleAddress + && !result; + }; + + return ( +
+ { error && ( + + )} + { waitingApproval && ( + + )} + + { waitingConfirmation && ( + + )} + { (!error && !waitingApproval && !waitingConfirmation) && ( + <> +
+ + +
+ + ( + + Your bet + + <> + {!bet && ()} + { bet &&

{bet.bet}

} + +
+ +
+ )} + /> + ( + + Amount + + <> + {!bet && } + { bet &&

{bet.amount} {EVENT_TOKEN_SYMBOL}

} + +
+ +
+ )} + /> + +
+ { connected ? ( + + ) : ( + + )} +
+ + +
+ + + + + +
+
+ + )} + + Hathor + +
+ ); +} diff --git a/packages/bet-dapp/src/app/bet/layout.tsx b/packages/bet-dapp/src/app/bet/layout.tsx new file mode 100644 index 0000000..be9a1a5 --- /dev/null +++ b/packages/bet-dapp/src/app/bet/layout.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/bet-dapp/src/app/create/createNc.ts b/packages/bet-dapp/src/app/create/createNc.ts new file mode 100644 index 0000000..7cfa12a --- /dev/null +++ b/packages/bet-dapp/src/app/create/createNc.ts @@ -0,0 +1,44 @@ +import { IHathorRpc } from '@/contexts/JsonRpcContext'; +import { SendNanoContractRpcRequest, SendNanoContractTxResponse, sendNanoContractTxRpcRequest } from 'hathor-rpc-handler-test'; +import { BET_BLUEPRINT } from '@/constants'; +import { getOracleBuffer, waitForTransactionConfirmation } from '@/lib/utils'; +import NanoContract from '@hathor/wallet-lib/lib/nano_contracts/nano_contract'; +import { createNanoContractTx } from '@/lib/api/createNanoContractTx'; + +export const createNc = async ( + hathorRpc: IHathorRpc, + title: string, + description: string, + oracleType: string, + oracle: string, + timestamp: number, + token: string, + creatorAddress: string, +): Promise => { + const ncTxRpcReq: SendNanoContractRpcRequest = sendNanoContractTxRpcRequest( + 'initialize', + BET_BLUEPRINT, + [], + [ + getOracleBuffer(oracle), + token, + timestamp, + ], + true, + null, + ); + + const result: SendNanoContractTxResponse = await hathorRpc.sendNanoContractTx(ncTxRpcReq); + console.log('Got result from rpc', result); + const nanoContract = result.response as unknown as NanoContract; + + if (!nanoContract.timestamp) { + throw new Error('No timestamp received in transaction'); + } + + console.log('Will create tx in dynamodb'); + await createNanoContractTx(nanoContract, title, description, oracleType, oracle, timestamp, creatorAddress, nanoContract.timestamp); + console.log('done'); + + return nanoContract; +}; diff --git a/packages/bet-dapp/src/app/create/page.tsx b/packages/bet-dapp/src/app/create/page.tsx index 7dfa12e..23759a0 100644 --- a/packages/bet-dapp/src/app/create/page.tsx +++ b/packages/bet-dapp/src/app/create/page.tsx @@ -1,15 +1,277 @@ -import React from 'react'; +'use client'; + +import React, { useCallback, useState } from 'react'; import { Card, CardContent } from '@/components/ui/card'; import { Header } from '@/components/header'; +import { z } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { format, addHours } from 'date-fns'; +import { useWalletConnectClient } from '@/contexts/WalletConnectClientContext'; +import { createNc } from './createNc'; +import { useJsonRpc } from '@/contexts/JsonRpcContext'; +import { WaitInput } from '@/components/wait-input'; +import Link from 'next/link'; +import Image from 'next/image'; +import { ResultError } from '@/components/result-error'; +import { useRouter } from 'next/navigation'; +import { waitForTransactionConfirmation } from '@/lib/utils'; +import { EVENT_TOKEN, EVENT_TOKEN_SYMBOL } from '@/constants'; +import { BASE_PATH } from '@/constants'; + +function formatLocalDateTime(date: Date): string { + return format(date, 'yyyy-MM-dd\'T\'HH:mm'); +} + +const formSchema = z.object({ + name: z.string().min(2), + description: z.string().optional(), + oracleType: z.enum(['publicKey', 'random']), // Ensure oracleType is either 'publicKey' or 'random' + oracle: z.string().optional(), + lastBetAt: z.date(), +}).refine((data) => { + // Enforce that 'oracle' is required when 'oracleType' is 'publicKey' + if (data.oracleType === 'publicKey') { + return !!data.oracle; + } + return true; +}, { + path: ['oracle'], // Specify the path of the error + message: 'Public Key is required when Oracle Type is Public Key', +}); + +export default function CreateNanoContractPage() { + const [waitingApproval, setWaitingApproval] = useState(false); + const [waitingConfirmation, setWaitingConfirmation] = useState(false); + const [error, setError] = useState(false); + + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: 'onChange', + defaultValues: { + name: '', + description: '', + oracleType: 'random', + lastBetAt: addHours(new Date(), 2), + }, + }); + + const { hathorRpc } = useJsonRpc(); + + const { session, connect, getFirstAddress } = useWalletConnectClient(); + + const onSubmit = async (values: z.infer) => { + setWaitingApproval(true); + + const firstAddress = getFirstAddress(); + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + const nc = await createNc( + hathorRpc, + values.name, + values.description || '', + values.oracleType, + values.oracleType === 'random' ? firstAddress : values.oracle as string, + Math.ceil(values.lastBetAt.getTime() / 1000), + EVENT_TOKEN, + firstAddress, + ); + + setWaitingApproval(false); + setWaitingConfirmation(true); + await waitForTransactionConfirmation(nc.hash as string); + router.push(`/create/success/${nc.hash}`); + } catch (e) { + setError(true); + } finally { + setWaitingApproval(false); + setWaitingConfirmation(false); + } + }; + + const handleNotConnected = async (event: React.MouseEvent) => { + event.preventDefault(); + await connect(); + }; + + const onTryAgain = () => { + const values = form.getValues(); + setError(false); + onSubmit(values); + }; + + const onCancel = useCallback(() => { + router.replace('/'); + }, [router]); + + const oracleTypeValue = form.watch('oracleType'); -export default function CreateNanoContractLayout() { return ( -
-
- - - - +
+
+ { error && ( + + )} + { waitingConfirmation && ( + + )} + { waitingApproval && ( + + )} + { (!error && !waitingApproval && !waitingConfirmation) && ( + + +

Create your Nano Contract

+

Create your own betting contracts and claim your spot in the sands of Hathor!

+ +
+ + ( + + Name of the Main Event + + + + + + )} + /> + + ( + + Description + +