Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Deploy primodium interface

on:
workflow_dispatch:
# pull_request:

jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout code
uses: actions/checkout@v4.2.0

- uses: pnpm/action-setup@v4

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1

- name: Build code
run: |
cat > .env <<-EOF
${{vars.PRIMODIUM_ENV_FILE}}
EOF
pnpm install
pnpm build:client-core

- name: Copy files to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_KEY }}
port: ${{ secrets.SERVER_PORT }}
source: "packages/client/dist"
debug: true
target: /home/deployer/primodium-interface
strip_components: 3 # Remove the first 3 levels of directories from the source path to copy only the files inside dist
rm: true

- name: Deploy primodium interface to server
uses: appleboy/ssh-action@v1.1.0
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_KEY }}
port: ${{ secrets.SERVER_PORT }}
debug: true
script_stop: true
script: |
chmod -R o+rX /home/deployer/primodium-interface
mv /home/deployer/primodium-interface /tmp
sudo -u primodium_interface bash -c '
rm -rf /var/www/primodium-interface/*
cp -r /tmp/primodium-interface/* /var/www/primodium-interface
'
rm -rf /tmp/primodium-interface


3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ jobs:

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: "v0.3.0"


- name: Build client bundle
env:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ This monorepo contains the entire stack for running Primodium, including the Rea

### Installation

[![Test suites for client and contracts](https://github.com/HappyChainDevs/primodium/actions/workflows/test.yml/badge.svg)](https://github.com/HappyChainDevs/primodium/actions/workflows/test.yml)

#### Prerequisites

There are a few CLI tools to install to be compatible with the entire monorepo.
Expand Down
4 changes: 3 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"lint": "eslint ./src --ext .js,.ts,.tsx",
"lint:fix": "eslint ./src --ext .js,.ts,.tsx --fix",
"preview": "vite preview",
"test": "vitest"
"test": "vitest --environment jsdom"
},
"dependencies": {
"@amplitude/analytics-browser": "^2.1.3",
Expand All @@ -38,6 +38,7 @@
"comlink": "^4.4.1",
"contracts": "workspace:*",
"framer-motion": "^10.12.18",
"jsdom": "^24.1.0",
"lodash": "^4.17.21",
"phaser": "^3.80.1",
"react": "^18.2.0",
Expand All @@ -56,6 +57,7 @@
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.3.0",
"@types/lodash": "^4.14.195",
"@types/node": "^22.15.17",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@types/react-virtualized-auto-sizer": "^1.0.3",
Expand Down
132 changes: 26 additions & 106 deletions packages/client/src/components/transfer/Authorize.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,36 @@
import { useEffect, useState } from "react";
import { FaClipboard, FaExclamationCircle, FaEye, FaEyeSlash, FaInfoCircle, FaTimes, FaUnlink } from "react-icons/fa";
import { Address, Hex } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { requestSessionKey } from "@happy.tech/core";
import { useState } from "react";
import { FaInfoCircle, FaTimes } from "react-icons/fa";

import { STORAGE_PREFIX } from "@primodiumxyz/core";
import { useAccountClient } from "@primodiumxyz/core/react";
import { defaultEntity } from "@primodiumxyz/reactive-tables";
import { Button } from "@/components/core/Button";
import { SecondaryCard } from "@/components/core/Card";
import { TransactionQueueMask } from "@/components/shared/TransactionQueueMask";
import { useContractCalls } from "@/hooks/useContractCalls";
import { copyToClipboard } from "@/util/clipboard";
import { findEntriesWithPrefix } from "@/util/localStorage";
import { HAPPY_STORAGE_PREFIX, isHappySessionKeyRegistered } from "@/util/localStorage";

const sessionWalletTooltip =
"Bypass annoying confirmation popups by authorizing a session account. This allows you to securely perform certain actions without external confirmation.";
const sessionWalletTooltip = (
<>
Bypass confirmation popups by authorizing a session key. Powered by{" "}
<span className="text-yellow-400">Happy Wallet</span>, this lets you securely perform actions without repeated
approvals.
</>
);

export function Authorize() {
const { sessionAccount } = useAccountClient();
const { grantAccessWithSignature, revokeAccess } = useContractCalls();
const [showDetails, setShowDetails] = useState(false);
const {
playerAccount: { address: playerAddress, worldContract },
} = useAccountClient();
const [showHelp, setShowHelp] = useState(!localStorage.getItem("hideHelp"));

useEffect(() => {
setShowDetails(false);
}, [sessionAccount]);
const weAreHappySponsored = isHappySessionKeyRegistered(playerAddress);

// Function to handle private key validation and connection
const sessionAddress = sessionAccount?.address;
const handleHappySessionKeyRegister = async () => {
if (!isHappySessionKeyRegistered(playerAddress)) {
await requestSessionKey(worldContract.address);

const submitPrivateKey = async (privateKey: Hex) => {
// Validate the private key format here
// This is a basic example, adjust the validation according to your requirements
const isValid = /^0x[a-fA-F0-9]{64}$/.test(privateKey);
if (!isValid) return;
const account = privateKeyToAccount(privateKey as Hex);

if (sessionAddress && sessionAddress === account.address) return;
else await grantAccessWithSignature(privateKey, { id: defaultEntity });
};

const handleRandomPress = () => {
const storedKeys = findEntriesWithPrefix();
const privateKey = storedKeys.length > 0 ? storedKeys[0].privateKey : generatePrivateKey();

const account = privateKeyToAccount(privateKey as Hex);
localStorage.setItem(STORAGE_PREFIX + account.address, privateKey);

return privateKey;
};

const removeSessionKey = async (publicKey: Address) => {
await revokeAccess(publicKey);
localStorage.removeItem(STORAGE_PREFIX + publicKey);
localStorage.setItem(HAPPY_STORAGE_PREFIX + playerAddress, "true");
} else return;
};

const hideHelp = () => {
Expand All @@ -73,75 +51,17 @@ export function Authorize() {
)}

<TransactionQueueMask queueItemId={defaultEntity}>
{sessionAddress ? (
{weAreHappySponsored ? (
<div className="w-full flex flex-col">
<div className="w-full flex items-center justify-center p-4">
<p className="uppercase font-bold text-success w-full flex justify-center text-sm">AUTHORIZING</p>
<div className="absolute right-2 flex gap-1">
<Button
onClick={async () => {
setShowDetails(false);
revokeAccess(sessionAddress);
removeSessionKey(sessionAddress);
}}
tooltip="stop authorizing"
tooltipDirection="top"
className="btn-sm btn-primary"
>
<FaUnlink />
</Button>
<Button
tooltip={`${showDetails ? "Hide" : "See"} details`}
onClick={() => setShowDetails((prev) => !prev)}
tooltipDirection="top"
className="btn-sm btn-primary"
>
{showDetails ? <FaEyeSlash /> : <FaEye />}
</Button>
</div>
<p className="uppercase font-bold text-success w-full flex justify-center text-sm">
🤠 SESSION KEY REGISTERED 🤠
</p>
</div>
{showDetails && (
<SecondaryCard className="flex flex-col gap-2 p-3 w-full animate-slide-down bg-base-800">
<div className="text-sm flex justify-between items-center">
<div className="flex flex-col gap-2">
<div className="flex gap-1 items-center">
<span className="font-bold">Session Address: </span>
<Button
size="xs"
variant="primary"
tooltip="Copy address"
onClick={() => copyToClipboard(sessionAddress, "address")}
tooltipDirection="top"
>
<FaClipboard />
</Button>
<Button
size="xs"
variant="error"
tooltip={`Copy private key`}
onClick={() => copyToClipboard(sessionAccount?.privateKey, "private key")}
tooltipDirection="top"
>
<FaExclamationCircle />
</Button>
</div>
<p className="text-xs">{sessionAddress}</p>
</div>
</div>
</SecondaryCard>
)}
</div>
) : (
<Button
variant="primary"
size="md"
className="w-full"
onClick={() => {
const key = handleRandomPress();
submitPrivateKey(key);
}}
>
CLICK TO AUTHORIZE SESSION ACCOUNT
<Button variant="primary" size="md" className="w-full" onClick={handleHappySessionKeyRegister}>
CLICK TO AUTHORIZE SESSION KEY
</Button>
)}
</TransactionQueueMask>
Expand Down
31 changes: 18 additions & 13 deletions packages/client/src/screens/Enter.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
import { requestSessionKey } from "@happy.tech/core";
import { useEffect, useState } from "react";
import { FaExclamationTriangle, FaInfoCircle } from "react-icons/fa";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";

import { STORAGE_PREFIX } from "@primodiumxyz/core";
import { useAccountClient, useCore } from "@primodiumxyz/core/react";
import { defaultEntity } from "@primodiumxyz/reactive-tables";
import { Tooltip } from "@/components/core/Tooltip";
import { TransactionQueueMask } from "@/components/shared/TransactionQueueMask";
import { useContractCalls } from "@/hooks/useContractCalls";
import { findEntriesWithPrefix } from "@/util/localStorage";
import { HAPPY_STORAGE_PREFIX, isHappySessionKeyRegistered } from "@/util/localStorage";

import { Landing } from "./Landing";

export const Enter: React.FC = () => {
const { tables } = useCore();
const {
playerAccount: { entity: playerEntity },
playerAccount: { address: playerAddress, worldContract, entity: playerEntity },
sessionAccount,
} = useAccountClient();

const { grantAccessWithSignature, spawn } = useContractCalls();
const { spawn } = useContractCalls();
const navigate = useNavigate();
const location = useLocation();
const [showingToast, setShowingToast] = useState(false);

const [state, setState] = useState<"loading" | "delegate" | "play">("loading");

const confirmSkip = async () => {
toast.dismiss();
if (showingToast) await new Promise((resolve) => setTimeout(resolve, 500));
Expand All @@ -37,6 +37,9 @@ export const Enter: React.FC = () => {
<div className="flex flex-col text-center justify-center items-center gap-2 w-full">
<FaExclamationTriangle size={24} className="text-warning" />
Are you sure you want to skip? You will need to confirm every action with your external wallet.
<br />
<br />
You can still enable a session key within the game settings.
</div>

<div className="flex justify-center w-full gap-2">
Expand Down Expand Up @@ -72,8 +75,9 @@ export const Enter: React.FC = () => {
},
);
};

useEffect(() => {
if (!sessionAccount) {
if (!isHappySessionKeyRegistered(playerAddress)) {
setState("delegate");
} else {
setState("play");
Expand All @@ -93,13 +97,14 @@ export const Enter: React.FC = () => {
navigate("/game" + location.search);
};

const handleDelegate = async () => {
const storedKeys = findEntriesWithPrefix();
const privateKey = storedKeys.length > 0 ? storedKeys[0].privateKey : generatePrivateKey();
const account = privateKeyToAccount(privateKey);
localStorage.setItem(STORAGE_PREFIX + account.address, privateKey);
const handleHappySessionKeyRegister = async () => {
if (!isHappySessionKeyRegistered(playerAddress)) {
// once this resolves, write a status into local storage that this has been done
await requestSessionKey(worldContract.address);

await grantAccessWithSignature(privateKey, { id: defaultEntity });
localStorage.setItem(HAPPY_STORAGE_PREFIX + playerAddress, "true");
setState("play");
} else return;
};

return (
Expand All @@ -108,7 +113,7 @@ export const Enter: React.FC = () => {
{state === "delegate" && (
<div className="grid grid-cols-7 gap-2 items-center pointer-events-auto">
<button
onClick={handleDelegate}
onClick={handleHappySessionKeyRegister}
className="relative btn col-span-6 font-bold outline-none h-fit btn-secondary w-full star-background hover:scale-105"
>
<Tooltip
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/systems/setupSessionAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const setupSessionAccount = async (core: Core, account: AccountClient) =>
return true;
}

// this is fully for mud's delegate system - we may need none of this for happy session keys
const potentialAuthorizeds = query({ with: [tables.UserDelegationControl] }).reduce((prev, entity) => {
const key = decodeEntity(tables.UserDelegationControl.metadata.abiKeySchema, entity) as {
delegator: Address;
Expand Down
9 changes: 8 additions & 1 deletion packages/client/src/util/localStorage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Hex } from "viem";
import { Address, Hex } from "viem";

import { STORAGE_PREFIX } from "@primodiumxyz/core";

Expand Down Expand Up @@ -35,3 +35,10 @@ export function getPrivateKey(publicKey: Hex): Hex | undefined {
if (!entry) return;
return entry as Hex;
}

// [HAPPY_PRIM] storage helpers for session keys
export const HAPPY_STORAGE_PREFIX = "[HAPPY_PRIM] sessionKeyRegistered:";

export const isHappySessionKeyRegistered = (address: Address): boolean => {
return localStorage.getItem(HAPPY_STORAGE_PREFIX + address) === "true";
};
Loading