├── vite.config.ts ├── src ├── constants.ts ├── Button.tsx ├── Countdown.tsx ├── pixels.ts ├── style.css ├── Mint.tsx ├── Withdraw.tsx ├── icons.tsx ├── Canvas.tsx └── index.tsx ├── index.html ├── .gitignore ├── package.json ├── tsconfig.json ├── .github └── workflows │ └── publish.yaml ├── public └── vite.svg └── README.md /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import preact from "@preact/preset-vite"; 3 | import { viteSingleFile } from "vite-plugin-singlefile"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [preact(), viteSingleFile()], 8 | }); 9 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import "./style.css"; 2 | 3 | export const BASEPAINT_ADDRESS = "0xBa5e05cb26b78eDa3A2f8e3b3814726305dcAc83"; 4 | export const BRUSH_ADDRESS = "0xD68fe5b53e7E1AbeB5A4d0A6660667791f39263a"; 5 | export const METADATA_ADDRESS = "0x5104482a2Ef3a03b6270D3e931eac890b86FaD01"; 6 | -------------------------------------------------------------------------------- /src/Button.tsx: -------------------------------------------------------------------------------- 1 | export default function Button({ 2 | onClick, 3 | children, 4 | }: { 5 | onClick: () => void; 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | BasePaint Mini 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "preview": "vite preview" 8 | }, 9 | "dependencies": { 10 | "preact": "^10.22.1", 11 | "viem": "^2.21.39", 12 | "vite-plugin-singlefile": "^2.0.2" 13 | }, 14 | "devDependencies": { 15 | "@preact/preset-vite": "^2.9.0", 16 | "typescript": "^5.6.3", 17 | "vite": "^5.3.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "noEmit": true, 7 | "allowJs": true, 8 | "checkJs": true, 9 | "strict": true, 10 | 11 | /* Preact Config */ 12 | "jsx": "react-jsx", 13 | "jsxImportSource": "preact", 14 | "skipLibCheck": true, 15 | "paths": { 16 | "react": ["./node_modules/preact/compat/"], 17 | "react-dom": ["./node_modules/preact/compat/"] 18 | } 19 | }, 20 | "include": ["node_modules/vite/client.d.ts", "**/*"] 21 | } 22 | -------------------------------------------------------------------------------- /src/Countdown.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "preact/hooks"; 2 | 3 | function useTimestamp() { 4 | const [timestamp, setTimestamp] = useState(() => BigInt(Date.now()) / 1000n); 5 | useEffect(() => { 6 | const interval = setInterval( 7 | () => setTimestamp(BigInt(Date.now()) / 1000n), 8 | 1000 9 | ); 10 | return () => clearInterval(interval); 11 | }, []); 12 | 13 | return timestamp; 14 | } 15 | 16 | export function getSecondsLeft({ 17 | timestamp, 18 | startedAt, 19 | epochDuration, 20 | }: { 21 | timestamp: bigint; 22 | startedAt: bigint; 23 | epochDuration: bigint; 24 | }) { 25 | const difference = epochDuration - ((timestamp - startedAt) % epochDuration); 26 | return Number(difference); 27 | } 28 | 29 | export default function Countdown({ 30 | startedAt, 31 | epochDuration, 32 | }: { 33 | startedAt: bigint; 34 | epochDuration: bigint; 35 | }) { 36 | const timestamp = useTimestamp(); 37 | 38 | const seconds = getSecondsLeft({ timestamp, startedAt, epochDuration }); 39 | const minutes = Math.floor(seconds / 60); 40 | const hours = Math.floor(minutes / 60); 41 | const days = Math.floor(hours / 24); 42 | 43 | return ( 44 | 45 | {(hours % 24).toString().padStart(2, "0")}: 46 | {(minutes % 60).toString().padStart(2, "0")}: 47 | {Math.floor(seconds % 60) 48 | .toString() 49 | .padStart(2, "0")} 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | - name: Set up Node 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 22 37 | cache: "npm" 38 | - name: Install dependencies 39 | run: npm ci 40 | - name: Build 41 | run: npm run build 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v4 44 | - name: Upload artifact 45 | uses: actions/upload-pages-artifact@v3 46 | with: 47 | # Upload dist folder 48 | path: "./dist" 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v4 52 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/pixels.ts: -------------------------------------------------------------------------------- 1 | type FlatPoint = number; 2 | type Point2D = { x: number; y: number }; 3 | 4 | function flatPoint({ x, y }: Point2D) { 5 | return x + y * 10_000; 6 | } 7 | 8 | function point2D(n: FlatPoint): Point2D { 9 | return { x: n % 10_000, y: Math.floor(n / 10_000) }; 10 | } 11 | 12 | function toPaddedHex(n: number) { 13 | return n.toString(16).padStart(2, "0"); 14 | } 15 | 16 | export default class Pixels { 17 | storage: Map; 18 | 19 | constructor(data?: Map) { 20 | this.storage = data || new Map(); 21 | } 22 | 23 | set(x: number, y: number, color: number | null) { 24 | const map = new Map(this.storage); 25 | if (color === null) { 26 | map.delete(flatPoint({ x, y })); 27 | } else { 28 | map.set(flatPoint({ x, y }), color); 29 | } 30 | return new Pixels(map); 31 | } 32 | 33 | *[Symbol.iterator]() { 34 | for (const [flat, color] of this.storage) { 35 | const { x, y } = point2D(flat); 36 | yield { x, y, color }; 37 | } 38 | } 39 | 40 | toString() { 41 | let result = ""; 42 | for (const { x, y, color } of this) { 43 | result += toPaddedHex(x) + toPaddedHex(y) + toPaddedHex(color); 44 | } 45 | return result; 46 | } 47 | 48 | static fromString(data: string) { 49 | const map = new Map(); 50 | for (const [pixel] of data.matchAll(/.{6}/g)) { 51 | const [x, y, color] = [...pixel.matchAll(/.{2}/g)].map(([n]) => 52 | parseInt(n, 16) 53 | ); 54 | map.set(flatPoint({ x, y }), color); 55 | } 56 | 57 | return new Pixels(map); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | min-height: 100vh; 9 | color: white; 10 | background-color: #222; 11 | } 12 | 13 | a { 14 | color: #0042DF; 15 | } 16 | 17 | .toolbar { 18 | display: flex; 19 | justify-content: space-between; 20 | align-items: center; 21 | padding: 0.5rem; 22 | background: black; 23 | } 24 | 25 | .toolbar button { 26 | background: none; 27 | color: white; 28 | width: 2rem; 29 | height: 2rem; 30 | display: inline-flex; 31 | align-items: center; 32 | justify-content: center; 33 | } 34 | 35 | .theme-name { 36 | flex: 1; 37 | color: white; 38 | font-size: 14px; 39 | font-weight: medium; 40 | } 41 | 42 | .countdown { 43 | font-size: 12px; 44 | opacity: 0.5; 45 | font-variant: tabular-nums; 46 | } 47 | 48 | .color-button { 49 | display: inline-block; 50 | border-width: 3px; 51 | } 52 | 53 | .hero { 54 | text-align: center; 55 | } 56 | 57 | .main { 58 | display: flex; 59 | flex-direction: column; 60 | flex: 1; 61 | height: 100vh; 62 | } 63 | 64 | .container { 65 | flex: 1; 66 | overflow: auto; 67 | } 68 | 69 | .fullscreen { 70 | display: flex; 71 | flex-direction: column; 72 | justify-content: center; 73 | align-items: center; 74 | height: 100vh; 75 | } 76 | 77 | .menu { 78 | display: flex; 79 | flex-direction: column; 80 | gap: 4px; 81 | align-items: stretch; 82 | width: 320px; 83 | max-width: 100%; 84 | } 85 | 86 | .price { 87 | display: flex; 88 | justify-content: space-between; 89 | } 90 | 91 | button.big { 92 | padding: 1rem; 93 | font-size: 1.5rem; 94 | cursor: pointer; 95 | } 96 | 97 | .draw-mode-toggle { 98 | display: none !important; 99 | } 100 | 101 | @media (hover: none) and (pointer: coarse) { 102 | .draw-mode-toggle { 103 | display: inline-flex !important; 104 | } 105 | } 106 | 107 | .eraser-mode-toggle.active { 108 | background-color: #e0e0e0; 109 | border: 2px solid #000; 110 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BasePaint Mini 2 | 3 | > [!WARNING] 4 | > Work in progress, please report bugs! 5 | 6 | Minimalistic implementation of [BasePaint](https://basepaint.xyz/) dApp. 7 | 8 | Principles: 9 | 10 | - Compiles down to a self-contained HTML file that can be hosted on IPFS 11 | - Doesn't depend on external RPC providers or any other services 12 | 13 | Supported features (work-in-progress): 14 | 15 | - [x] Basic wallet connection (via injected EIP-1193) 16 | - [x] Painting on today's canvas 17 | - [x] Minting the previous day's canvas 18 | - [x] Withdrawing earnings 19 | 20 | Out of scope: 21 | 22 | - Minting brushes 23 | - Browsing previous days 24 | - Theme voting 25 | - Chat, live cursors, WIP 26 | - Animations 27 | 28 | ## Getting Started 29 | 30 | - `npm run dev` - Starts a dev server at http://localhost:5173/ 31 | 32 | - `npm run build` - Builds for production, emitting to `dist/` 33 | 34 | - `npm run preview` - Starts a server at http://localhost:4173/ to test production build locally 35 | 36 | ## Contributing 37 | 38 | At this time, we can't promise we'll respond to issues or pull requests. 39 | 40 | # License 41 | 42 | While BasePaint artwork is distributed as CC0, the code in this repository is licensed under the MIT license. We welcome BasePaint meme proliferation, but please don't forget to include the attribution in your forks, modifications and distributions. 43 | 44 | ``` 45 | MIT License 46 | 47 | Copyright (c) 2024 BasePaint Team 48 | 49 | Permission is hereby granted, free of charge, to any person obtaining a copy 50 | of this software and associated documentation files (the "Software"), to deal 51 | in the Software without restriction, including without limitation the rights 52 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 53 | copies of the Software, and to permit persons to whom the Software is 54 | furnished to do so, subject to the following conditions: 55 | 56 | The above copyright notice and this permission notice shall be included in all 57 | copies or substantial portions of the Software. 58 | 59 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 60 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 61 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 62 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 63 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 64 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 65 | SOFTWARE. 66 | ``` 67 | -------------------------------------------------------------------------------- /src/Mint.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; 2 | import Pixels from "./pixels"; 3 | import Button from "./Button"; 4 | import { Address, formatEther, parseAbi } from "viem"; 5 | import { BASEPAINT_ADDRESS } from "./constants"; 6 | import { Client } from "."; 7 | 8 | export default function Mint({ 9 | client, 10 | address, 11 | day, 12 | theme, 13 | palette, 14 | size, 15 | pixels, 16 | price, 17 | }: { 18 | client: Client; 19 | address: Address; 20 | day: number; 21 | theme: string; 22 | palette: string[]; 23 | size: number; 24 | pixels: string; 25 | price: bigint; 26 | }) { 27 | const [count, setCount] = useState(1); 28 | const canvasRef = useRef(null); 29 | const background = useMemo(() => Pixels.fromString(pixels), [pixels]); 30 | const PIXEL_SIZE = 3; 31 | 32 | async function mint() { 33 | const chainId = await client.getChainId(); 34 | if (chainId !== client.chain.id) { 35 | await client.switchChain(client.chain); 36 | } 37 | 38 | await client.writeContract({ 39 | account: address, 40 | abi: parseAbi([ 41 | "function mint(uint256 day, uint256 count) public payable", 42 | ]), 43 | functionName: "mint", 44 | address: BASEPAINT_ADDRESS, 45 | args: [BigInt(day), BigInt(count)], 46 | value: price * BigInt(count), 47 | }); 48 | } 49 | 50 | useLayoutEffect(() => { 51 | const ctx = canvasRef.current?.getContext("2d"); 52 | if (!ctx) { 53 | return; 54 | } 55 | ctx.clearRect(0, 0, size * PIXEL_SIZE, size * PIXEL_SIZE); 56 | ctx.imageSmoothingEnabled = false; 57 | 58 | ctx.fillStyle = palette[0]; 59 | ctx.fillRect(0, 0, size * PIXEL_SIZE, size * PIXEL_SIZE); 60 | 61 | for (const { x, y, color } of background) { 62 | if (palette[color]) { 63 | ctx.fillStyle = palette[color]; 64 | ctx.fillRect(x * PIXEL_SIZE, y * PIXEL_SIZE, PIXEL_SIZE, PIXEL_SIZE); 65 | } 66 | } 67 | }, [background, palette, PIXEL_SIZE, size, background]); 68 | 69 | return ( 70 |
71 |
72 |
73 | Day {day}: {theme} 74 |
75 |
76 | {palette.map((color, i) => ( 77 |
86 | ))} 87 |
88 | 93 |
94 | setCount(+e.currentTarget.value)} 100 | /> 101 | {formatEther(price * BigInt(count))} ETH 102 |
103 | 104 |
105 |
106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /src/Withdraw.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "preact/hooks"; 2 | import { BASEPAINT_ADDRESS } from "./constants"; 3 | import { Address, formatEther, parseAbi } from "viem"; 4 | import { keccak256, toHex } from "viem"; 5 | import Button from "./Button"; 6 | import { Client } from "."; 7 | 8 | async function getEarningsForDay( 9 | client: Client, 10 | address: Address, 11 | day: number 12 | ) { 13 | const slot = getSlot(day, address); 14 | const result = await client.getStorageAt({ 15 | address: BASEPAINT_ADDRESS, 16 | slot, 17 | }); 18 | return BigInt(result ?? 0); 19 | } 20 | 21 | function getSlot(day: number, address: Address) { 22 | const canvasesSlot = 5; // forge inspect BasePaint storage-layout --pretty 23 | const canvasesHex = toHex(canvasesSlot, { size: 32 }); 24 | const dayHex = toHex(day, { size: 32 }); 25 | const canvasesDaySlot = keccak256( 26 | `0x${dayHex.slice(2) + canvasesHex.slice(2)}` 27 | ); 28 | const contributionsSlot = toHex(BigInt(canvasesDaySlot) + 2n); 29 | const addressContributionsSlot = keccak256( 30 | `0x${address.slice(2).padStart(64, "0") + contributionsSlot.slice(2)}` 31 | ); 32 | return addressContributionsSlot; 33 | } 34 | 35 | export default function Withdraw({ 36 | client, 37 | today, 38 | address, 39 | }: { 40 | client: Client; 41 | today: number; 42 | address: Address; 43 | }) { 44 | const stop = useRef(false); 45 | const [progress, setProgress] = useState(0); 46 | const [unclaimedDays, setUnclaimedDays] = useState([]); 47 | 48 | useEffect(() => { 49 | async function findDays() { 50 | for (let day = today - 2; day > 0; day--) { 51 | if (stop.current) return; 52 | const earnings = await getEarningsForDay(client, address, day); 53 | 54 | if (earnings > 0n) { 55 | setUnclaimedDays((days) => [...days, day]); 56 | console.log( 57 | `Day ${day} has unclaimed earnings: ${formatEther(earnings)}` 58 | ); 59 | } 60 | 61 | setProgress(Math.round((100 * (today - day)) / today)); 62 | } 63 | } 64 | 65 | setProgress(0); 66 | setUnclaimedDays([]); 67 | findDays(); 68 | 69 | return () => (stop.current = true); 70 | }, [client, address, today]); 71 | 72 | async function withdraw() { 73 | stop.current = true; 74 | const chainId = await client.getChainId(); 75 | if (chainId !== client.chain.id) { 76 | await client.switchChain(client.chain); 77 | } 78 | 79 | if (!unclaimedDays.length) { 80 | alert("No unclaimed days found"); 81 | return; 82 | } 83 | 84 | await client.writeContract({ 85 | account: address, 86 | address: BASEPAINT_ADDRESS, 87 | functionName: "authorWithdraw", 88 | abi: parseAbi(["function authorWithdraw(uint256[] calldata indexes)"]), 89 | args: [unclaimedDays.map((day) => BigInt(day))], 90 | }); 91 | 92 | setUnclaimedDays([]); 93 | } 94 | 95 | return ( 96 |
97 | {!stop.current &&
Looking for unclaimed days: {progress}%
} 98 |
99 | {unclaimedDays.length 100 | ? "Unclaimed earnings from: " + unclaimedDays.join(", ") 101 | : "No unclaimed days found yet"} 102 |
103 |
104 | 105 |
106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/icons.tsx: -------------------------------------------------------------------------------- 1 | export function MagnifyingGlassPlus() { 2 | return ( 3 | 10 | 15 | 16 | ); 17 | } 18 | 19 | export function MagnifyingGlassMinus() { 20 | return ( 21 | 28 | 33 | 34 | ); 35 | } 36 | 37 | export function Trash() { 38 | return ( 39 | 46 | 51 | 52 | ); 53 | } 54 | 55 | export function ArrowUpCircle() { 56 | return ( 57 | 64 | 69 | 70 | ); 71 | } 72 | 73 | export function PencilIcon() { 74 | return ( 75 | 82 | 87 | 88 | ); 89 | } 90 | 91 | export function HandIcon() { 92 | return ( 93 | 100 | 105 | 106 | ); 107 | } 108 | 109 | export function EraserIcon() { 110 | return ( 111 | 118 | 123 | 128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /src/Canvas.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "preact/compat"; 2 | import { useEffect, useMemo, useReducer, useRef } from "preact/hooks"; 3 | import Pixels from "./pixels"; 4 | import { 5 | ArrowUpCircle, 6 | MagnifyingGlassMinus, 7 | MagnifyingGlassPlus, 8 | Trash, 9 | HandIcon, 10 | PencilIcon, 11 | EraserIcon, 12 | } from "./icons"; 13 | import { BASEPAINT_ADDRESS, BRUSH_ADDRESS } from "./constants"; 14 | import { Address, parseAbi } from "viem"; 15 | import { Client } from "."; 16 | import Countdown, { getSecondsLeft } from "./Countdown"; 17 | 18 | function Canvas({ 19 | client, 20 | day, 21 | epochDuration, 22 | startedAt, 23 | theme, 24 | palette, 25 | size, 26 | pixels, 27 | address, 28 | brushes, 29 | }: { 30 | client: Client; 31 | day: number; 32 | epochDuration: bigint; 33 | startedAt: bigint; 34 | theme: string; 35 | palette: string[]; 36 | size: number; 37 | pixels: string; 38 | address: Address; 39 | brushes: { id: bigint; strength: bigint }[]; 40 | }) { 41 | const [state, dispatch] = useReducer(reducer, initialState); 42 | const canvasRef = useRef(null); 43 | const PIXEL_SIZE = state.pixelSize; 44 | 45 | const background = useMemo(() => Pixels.fromString(pixels), [pixels]); 46 | 47 | async function save() { 48 | const chainId = await client.getChainId(); 49 | if (chainId !== client.chain.id) { 50 | await client.switchChain(client.chain); 51 | } 52 | 53 | const agreedToRules = confirm(RULES); 54 | if (!agreedToRules) { 55 | return; 56 | } 57 | 58 | const response = prompt( 59 | "What brush token ID do you want to use?", 60 | brushes[0]?.id.toString() ?? "0" 61 | ); 62 | if (!response) { 63 | return; 64 | } 65 | 66 | const brushId = BigInt(response); 67 | 68 | const owner = await client.readContract({ 69 | account: address, 70 | abi: parseAbi(["function ownerOf(uint256) returns (address)"]), 71 | functionName: "ownerOf", 72 | address: BRUSH_ADDRESS, 73 | args: [brushId], 74 | }); 75 | 76 | if (owner !== address) { 77 | alert("You do not own this brush, the owner is " + owner); 78 | return; 79 | } 80 | 81 | const strength = await client.readContract({ 82 | account: address, 83 | abi: parseAbi(["function strengths(uint256) returns (uint256)"]), 84 | functionName: "strengths", 85 | address: BRUSH_ADDRESS, 86 | args: [brushId], 87 | }); 88 | 89 | const secondsToFinalize = 30 * 60; 90 | const secondsLeft = getSecondsLeft({ 91 | timestamp: BigInt(Date.now()) / 1000n, 92 | startedAt, 93 | epochDuration, 94 | }); 95 | 96 | if (strength < 100_000n && secondsLeft < secondsToFinalize) { 97 | alert(`The last ${secondsToFinalize} seconds are for cleanup crew only.`); 98 | return; 99 | } 100 | 101 | await client.writeContract({ 102 | account: address, 103 | abi: parseAbi([ 104 | "function paint(uint256 day, uint256 tokenId, bytes calldata pixels)", 105 | ]), 106 | functionName: "paint", 107 | address: BASEPAINT_ADDRESS, 108 | args: [BigInt(day), brushId, `0x${state.pixels}`], 109 | }); 110 | } 111 | 112 | useEffect(() => { 113 | const ctx = canvasRef.current?.getContext("2d"); 114 | if (!ctx) { 115 | return; 116 | } 117 | ctx.clearRect(0, 0, size * PIXEL_SIZE, size * PIXEL_SIZE); 118 | ctx.imageSmoothingEnabled = false; 119 | 120 | ctx.fillStyle = palette[0]; 121 | ctx.fillRect(0, 0, size * PIXEL_SIZE, size * PIXEL_SIZE); 122 | 123 | for (const { x, y, color } of background) { 124 | if (palette[color]) { 125 | ctx.fillStyle = palette[color]; 126 | ctx.fillRect(x * PIXEL_SIZE, y * PIXEL_SIZE, PIXEL_SIZE, PIXEL_SIZE); 127 | } 128 | } 129 | 130 | ctx.beginPath(); 131 | ctx.strokeStyle = "rgba(0,0,0,0.3)"; 132 | ctx.lineWidth = 1; 133 | 134 | for (let x = 0; x <= size; x++) { 135 | ctx.moveTo(x * PIXEL_SIZE, 0); 136 | ctx.lineTo(x * PIXEL_SIZE, size * PIXEL_SIZE); 137 | } 138 | for (let y = 0; y <= size; y++) { 139 | ctx.moveTo(0, y * PIXEL_SIZE); 140 | ctx.lineTo(size * PIXEL_SIZE, y * PIXEL_SIZE); 141 | } 142 | ctx.stroke(); 143 | 144 | for (const { x, y, color } of state.pixels) { 145 | if (palette[color]) { 146 | ctx.fillStyle = palette[color]; 147 | ctx.fillRect(x * PIXEL_SIZE, y * PIXEL_SIZE, PIXEL_SIZE, PIXEL_SIZE); 148 | } 149 | } 150 | }, [background, palette, PIXEL_SIZE, size, state.pixels]); 151 | 152 | const locate = (e: React.MouseEvent | React.TouchEvent) => { 153 | const rect = e.currentTarget.getBoundingClientRect(); 154 | const canvasSize = rect.width; 155 | 156 | // Determine coordinates based on event type 157 | const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; 158 | const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; 159 | 160 | // Calculate the actual size of each pixel 161 | const actualPixelSize = canvasSize / size; 162 | 163 | // Calculate the position relative to the canvas 164 | const relativeX = clientX - rect.left; 165 | const relativeY = clientY - rect.top; 166 | 167 | // Convert to grid coordinates 168 | const x = Math.floor(relativeX / actualPixelSize); 169 | const y = Math.floor(relativeY / actualPixelSize); 170 | 171 | // Ensure the coordinates are within the canvas bounds 172 | const boundedX = Math.max(0, Math.min(x, size - 1)); 173 | const boundedY = Math.max(0, Math.min(y, size - 1)); 174 | 175 | return { x: boundedX, y: boundedY }; 176 | }; 177 | 178 | return ( 179 |
180 | 192 |
193 | { 196 | if (state.drawMode) { 197 | e.preventDefault(); 198 | dispatch({ type: "down", where: locate(e), erase: state.eraserMode }); 199 | } 200 | }} 201 | onTouchMove={(e) => { 202 | if (state.drawMode) { 203 | e.preventDefault(); 204 | dispatch({ type: "move", where: locate(e) }); 205 | } 206 | }} 207 | onTouchEnd={(e) => { 208 | if (state.drawMode) { 209 | e.preventDefault(); 210 | dispatch({ type: "up" }); 211 | } 212 | }} 213 | onMouseDown={(e) => 214 | dispatch({ type: "down", where: locate(e), erase: e.button === 2 }) 215 | } 216 | onMouseMove={(e) => dispatch({ type: "move", where: locate(e) })} 217 | onMouseUp={() => dispatch({ type: "up" })} 218 | onMouseLeave={() => dispatch({ type: "leave" })} 219 | onContextMenu={(e) => e.preventDefault()} 220 | width={size * PIXEL_SIZE} 221 | height={size * PIXEL_SIZE} 222 | style={{ 223 | touchAction: state.drawMode ? "none" : "auto" 224 | }} 225 | /> 226 |
227 |
228 | ); 229 | } 230 | 231 | function Toolbar({ 232 | day, 233 | startedAt, 234 | epochDuration, 235 | theme, 236 | palette, 237 | colorIndex, 238 | dispatch, 239 | onSave, 240 | drawMode, 241 | eraserMode, 242 | }: { 243 | day: number; 244 | startedAt: bigint; 245 | epochDuration: bigint; 246 | theme: string; 247 | palette: string[]; 248 | colorIndex: number; 249 | dispatch: (action: Action) => void; 250 | onSave: () => void; 251 | drawMode: boolean; 252 | eraserMode: boolean; 253 | }) { 254 | return ( 255 |
256 |
257 |
258 | Day {day}: {theme} 259 |
260 |
261 | Canvas flips in{" "} 262 | 263 |
264 |
265 | 268 | 271 | 274 | 277 | 283 | 290 |
291 | {palette.map((color, index) => ( 292 | 301 | ))} 302 |
303 |
304 | ); 305 | } 306 | 307 | type Point2D = { x: number; y: number }; 308 | 309 | type State = { 310 | size: number; 311 | down: boolean; 312 | erasing: boolean; 313 | pixelSize: number; 314 | colorIndex: number; 315 | pixels: Pixels; 316 | drawMode: boolean; 317 | eraserMode: boolean; 318 | }; 319 | 320 | const initialState: State = { 321 | size: 256, 322 | down: false, 323 | erasing: false, 324 | pixelSize: 3, 325 | colorIndex: 0, 326 | pixels: new Pixels(), 327 | drawMode: false, 328 | eraserMode: false, 329 | }; 330 | 331 | type Action = 332 | | { type: "init"; size: number } 333 | | { type: "pick"; index: number } 334 | | { type: "down"; where: Point2D; erase: boolean } 335 | | { type: "move"; where: Point2D } 336 | | { type: "up" } 337 | | { type: "leave" } 338 | | { type: "zoom-in" } 339 | | { type: "zoom-out" } 340 | | { type: "reset" } 341 | | { type: "toggle-draw-mode" } 342 | | { type: "toggle-eraser-mode" }; 343 | 344 | function reducer(state: State, action: Action): State { 345 | switch (action.type) { 346 | case "pick": 347 | return { 348 | ...state, 349 | colorIndex: action.index, 350 | }; 351 | 352 | case "down": 353 | case "move": 354 | if ( 355 | action.where.x < 0 || 356 | action.where.x >= state.size || 357 | action.where.y < 0 || 358 | action.where.y >= state.size 359 | ) { 360 | return state; 361 | } 362 | 363 | if (action.type === "move" && !state.down) { 364 | return state; 365 | } 366 | 367 | const erasing = action.type === "down" ? action.erase : state.erasing; 368 | 369 | return { 370 | ...state, 371 | erasing, 372 | down: true, 373 | pixels: state.pixels.set( 374 | action.where.x, 375 | action.where.y, 376 | erasing ? null : state.colorIndex 377 | ), 378 | }; 379 | 380 | case "up": 381 | case "leave": 382 | return { ...state, down: false, erasing: false }; 383 | 384 | case "reset": 385 | return { ...state, pixels: new Pixels() }; 386 | 387 | case "zoom-in": 388 | return { ...state, pixelSize: Math.min(20, state.pixelSize + 1) }; 389 | 390 | case "zoom-out": 391 | return { ...state, pixelSize: Math.max(1, state.pixelSize - 1) }; 392 | 393 | case "toggle-draw-mode": 394 | return { ...state, drawMode: !state.drawMode }; 395 | 396 | case "toggle-eraser-mode": 397 | return { ...state, eraserMode: !state.eraserMode }; 398 | 399 | default: 400 | return state; 401 | } 402 | } 403 | 404 | const RULES = `\ 405 | BasePaint Rules: 406 | 407 | 😊 Be Kind: Be patient with each other. We're all here to learn and create together. 408 | 🖌️ Be Original: Don't copy another artist's pixel artwork. 409 | 🥸 Be Yourself: One brush per painter. Use your brush invites on new artists! 410 | 🧠 Be Creative: Help others but don't trace or spam unnecessary pixels (blobs, checkers or borders). 411 | ⚠️ Keep It Clean: No QR Codes, project names, logos, offensive symbols, etc. 412 | 🎨 CC0: Your artwork on this canvas will be released under a CC0 license in the public domain. 413 | `; 414 | 415 | export default memo(Canvas); 416 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import "./style.css"; 2 | import { render } from "preact"; 3 | import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; 4 | import { 5 | createWalletClient, 6 | custom, 7 | parseAbi, 8 | parseAbiItem, 9 | publicActions, 10 | } from "viem"; 11 | import { Address } from "viem"; 12 | import Canvas from "./Canvas"; 13 | import { 14 | BASEPAINT_ADDRESS, 15 | BRUSH_ADDRESS, 16 | METADATA_ADDRESS, 17 | } from "./constants"; 18 | import Withdraw from "./Withdraw"; 19 | import Mint from "./Mint"; 20 | import Button from "./Button"; 21 | import { base } from "viem/chains"; 22 | 23 | export type Client = NonNullable>; 24 | 25 | function useClient() { 26 | const [ethereum] = useState(() => (window as any).ethereum); 27 | 28 | if (!ethereum) { 29 | return null; 30 | } 31 | 32 | const client = useMemo( 33 | () => 34 | createWalletClient({ 35 | chain: base, 36 | transport: custom(ethereum), 37 | }).extend(publicActions), 38 | [ethereum] 39 | ); 40 | 41 | return client; 42 | } 43 | 44 | function usePromise(promise: () => Promise, deps: any[] = []): T | null { 45 | const [value, setValue] = useState(null); 46 | 47 | useEffect(() => { 48 | let isMounted = true; 49 | promise().then((v) => { 50 | if (isMounted) { 51 | setValue(v); 52 | } 53 | }); 54 | 55 | return () => { 56 | isMounted = false; 57 | }; 58 | }, deps); 59 | 60 | return value; 61 | } 62 | 63 | async function initialFetch(client: Client) { 64 | const [startedAt, epochDuration] = await Promise.all([ 65 | client.readContract({ 66 | abi: parseAbi(["function startedAt() view returns (uint256)"]), 67 | functionName: "startedAt", 68 | address: BASEPAINT_ADDRESS, 69 | }), 70 | client.readContract({ 71 | abi: parseAbi(["function epochDuration() view returns (uint256)"]), 72 | functionName: "epochDuration", 73 | address: BASEPAINT_ADDRESS, 74 | }), 75 | ]); 76 | return { startedAt, epochDuration }; 77 | } 78 | 79 | async function fetchThemeFromBasepaint(day: number) { 80 | const request = await fetch(`https://basepaint.xyz/api/theme/${day}`); 81 | return (await request.json()) as { 82 | theme: string; 83 | palette: string[]; 84 | size: number; 85 | }; 86 | } 87 | 88 | async function fetchThemeFromBlockchain(client: Client, day: number) { 89 | const metadata = await client.readContract({ 90 | address: METADATA_ADDRESS, 91 | abi: parseAbi([ 92 | "function getMetadata(uint256 id) public view returns ((string name, uint24[] palette, uint96 size, address proposer))", 93 | ]), 94 | functionName: "getMetadata", 95 | args: [BigInt(day)], 96 | }); 97 | 98 | if (!metadata.name) { 99 | throw new Error(`No theme found for day ${day} onchain`); 100 | } 101 | 102 | return { 103 | theme: metadata.name, 104 | palette: metadata.palette.map( 105 | (color) => `#${color.toString(16).padStart(6, "0")}` 106 | ), 107 | size: Number(metadata.size), 108 | }; 109 | } 110 | 111 | async function fetchBrushes(client: Client, address: Address) { 112 | const events = await client.getContractEvents({ 113 | abi: parseAbi([ 114 | "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)", 115 | ]), 116 | address: BRUSH_ADDRESS, 117 | eventName: "Transfer", 118 | args: { to: address }, 119 | strict: true, 120 | fromBlock: 0n, 121 | }); 122 | 123 | const tokenIds = events.map((e) => e.args.tokenId); 124 | const owners = await Promise.all( 125 | tokenIds.map((id) => 126 | client.readContract({ 127 | abi: parseAbi(["function ownerOf(uint256) view returns (address)"]), 128 | functionName: "ownerOf", 129 | address: BRUSH_ADDRESS, 130 | args: [id], 131 | }) 132 | ) 133 | ); 134 | 135 | const ownedTokenIds = tokenIds.filter((_, i) => owners[i] === address); 136 | const strengths = await Promise.all( 137 | ownedTokenIds.map((id) => 138 | client.readContract({ 139 | abi: parseAbi(["function strengths(uint256) view returns (uint256)"]), 140 | functionName: "strengths", 141 | address: BRUSH_ADDRESS, 142 | args: [id], 143 | }) 144 | ) 145 | ); 146 | 147 | return ownedTokenIds 148 | .map((id, i) => ({ id, strength: strengths[i] })) 149 | .sort((a, b) => Number(b.strength - a.strength)); 150 | } 151 | 152 | function useToday(client: Client) { 153 | const info = usePromise(() => initialFetch(client), [client]); 154 | const [day, setDay] = useState(null); 155 | 156 | if (!info) { 157 | return null; 158 | } 159 | 160 | useEffect(() => { 161 | function computeDay() { 162 | if (!info) { 163 | return; 164 | } 165 | 166 | setDay( 167 | Number( 168 | (BigInt(Date.now()) / 1000n - info.startedAt) / info.epochDuration + 169 | 1n 170 | ) 171 | ); 172 | } 173 | 174 | computeDay(); // Initial value 175 | 176 | const interval = setInterval(computeDay, 1000); 177 | return () => clearInterval(interval); 178 | }, [info]); 179 | 180 | if (!day) { 181 | return null; 182 | } 183 | 184 | return { day, ...info }; 185 | } 186 | 187 | async function getStrokesFromLogs( 188 | client: Client, 189 | day: number, 190 | onNewPixels: (pixels: string) => void, 191 | ac: AbortController 192 | ) { 193 | let latestBlock = await client.getBlockNumber(); 194 | let logs: { day: number; pixels: string }[] = []; 195 | const BATCH_SIZE = 10_000n; 196 | for (let toBlock = latestBlock; toBlock > BATCH_SIZE; toBlock -= BATCH_SIZE) { 197 | const fromBlock = toBlock - BATCH_SIZE + 1n; 198 | console.log("Fetching logs from Ethereum", { fromBlock, toBlock }); 199 | 200 | const batchLogs = await client.getLogs({ 201 | address: BASEPAINT_ADDRESS, 202 | event: parseAbiItem( 203 | "event Painted(uint256 indexed day, uint256 tokenId, address author, bytes pixels)" 204 | ), 205 | fromBlock, 206 | toBlock, 207 | strict: true, 208 | }); 209 | 210 | logs = [ 211 | ...batchLogs.map((log) => ({ 212 | day: Number(log.args.day), 213 | pixels: log.args.pixels, 214 | })), 215 | ...logs, 216 | ]; 217 | 218 | if (logs[0].day < day) { 219 | break; 220 | } 221 | } 222 | 223 | async function poll() { 224 | const fromBlock = latestBlock + 1n; 225 | const toBlock = await client.getBlockNumber(); 226 | console.log("Polling logs from Ethereum", { fromBlock, toBlock }); 227 | 228 | const batchLogs = await client.getLogs({ 229 | address: BASEPAINT_ADDRESS, 230 | event: parseAbiItem( 231 | "event Painted(uint256 indexed day, uint256 tokenId, address author, bytes pixels)" 232 | ), 233 | args: { day: BigInt(day) }, 234 | fromBlock, 235 | toBlock, 236 | strict: true, 237 | }); 238 | console.log(`Got ${batchLogs.length} new logs`); 239 | 240 | latestBlock = toBlock; 241 | const pixels = batchLogs 242 | .map((log) => log.args.pixels.replace(/^0x/, "")) 243 | .join(""); 244 | onNewPixels(pixels); 245 | } 246 | 247 | let interval = setInterval(() => { 248 | if (ac.signal.aborted) { 249 | clearInterval(interval); 250 | } else { 251 | poll(); 252 | } 253 | }, 15_000); 254 | 255 | return logs 256 | .filter((log) => log.day === day) 257 | .map((log) => log.pixels.replace(/^0x/, "")) 258 | .join(""); 259 | } 260 | 261 | function usePaintedPixels(client: Client, day: number) { 262 | const [pixels, setPixels] = useState(null); 263 | 264 | useEffect(() => { 265 | const ac = new AbortController(); 266 | 267 | getStrokesFromLogs( 268 | client, 269 | day, 270 | (morePixels) => setPixels((old) => old + morePixels), 271 | ac 272 | ).then(setPixels); 273 | 274 | return () => ac.abort(); 275 | }, [client]); 276 | 277 | return pixels; 278 | } 279 | 280 | function useWallet(client: Client) { 281 | const [address, setAddress] = useState
(null); 282 | const connect = useCallback(() => { 283 | client 284 | .requestAddresses() 285 | .then((addresses) => addresses.length > 0 && setAddress(addresses[0])); 286 | }, [client]); 287 | 288 | useEffect(() => { 289 | client 290 | .getAddresses() 291 | .then((addresses) => addresses.length > 0 && setAddress(addresses[0])); 292 | }, [client]); 293 | 294 | return { address, connect }; 295 | } 296 | 297 | function useCurrentChainId(client: Client) { 298 | const [currentChainId, setCurrentChainId] = useState(null); 299 | 300 | useEffect(() => { 301 | client.getChainId().then(setCurrentChainId); 302 | }, [client]); 303 | 304 | const switchChain = useCallback( 305 | (id: number) => { 306 | client.switchChain({ id }).then(() => setCurrentChainId(id)); 307 | }, 308 | [client] 309 | ); 310 | 311 | return { currentChainId, switchChain }; 312 | } 313 | 314 | function useTheme(client: Client, day: number) { 315 | return usePromise( 316 | () => 317 | fetchThemeFromBlockchain(client, day).catch(() => 318 | fetchThemeFromBasepaint(day) 319 | ), 320 | [day] 321 | ); 322 | } 323 | 324 | function useBrushes(client: Client, address: Address) { 325 | return usePromise( 326 | () => fetchBrushes(client, address).catch(() => []), 327 | [address] 328 | ); 329 | } 330 | 331 | function usePrice(client: Client) { 332 | return usePromise( 333 | () => 334 | client.readContract({ 335 | abi: parseAbi(["function openEditionPrice() view returns (uint256)"]), 336 | functionName: "openEditionPrice", 337 | address: BASEPAINT_ADDRESS, 338 | }), 339 | [] 340 | ); 341 | } 342 | 343 | export function App() { 344 | const client = useClient(); 345 | if (!client) { 346 | return ( 347 |
348 | 349 | Please install MetaMask or similar Ethereum wallet extension. 350 |
351 | ); 352 | } 353 | 354 | const { address, connect } = useWallet(client); 355 | if (!address) { 356 | return ( 357 |
358 | 359 |
360 | 361 |
362 |
363 | ); 364 | } 365 | 366 | const { currentChainId, switchChain } = useCurrentChainId(client); 367 | if (currentChainId !== client.chain.id) { 368 | return ( 369 |
370 | 371 |
372 | 375 |
376 |
377 | ); 378 | } 379 | 380 | const [ui, setUI] = useState<"paint" | "mint" | "withdraw" | null>(null); 381 | 382 | if (!ui) { 383 | return ( 384 |
385 | 386 |
387 | 388 | 389 | 390 |
391 |
392 | ); 393 | } 394 | 395 | const today = useToday(client); 396 | if (!today) { 397 | return ; 398 | } 399 | 400 | if (ui === "withdraw") { 401 | return ; 402 | } 403 | 404 | let day = ui === "mint" ? today.day - 1 : today.day; 405 | 406 | const theme = useTheme(client, day); 407 | if (!theme) { 408 | return ; 409 | } 410 | 411 | const pixels = usePaintedPixels(client, day); 412 | if (pixels === null) { 413 | return ; 414 | } 415 | 416 | if (ui === "mint") { 417 | const price = usePrice(client); 418 | if (!price) { 419 | return ; 420 | } 421 | 422 | return ( 423 | 433 | ); 434 | } 435 | 436 | const brushes = useBrushes(client, address); 437 | 438 | return ( 439 | 451 | ); 452 | } 453 | 454 | function Loading({ what }: { what: string }) { 455 | return
Loading {what}…
; 456 | } 457 | 458 | function BasePaintHero() { 459 | return ( 460 |
461 | 467 | 473 | 474 |

BasePaint Mini

475 |

476 | Tiny implementation of the{" "} 477 | 478 | BasePaint 479 | {" "} 480 | dApp with zero external dependencies. 481 |

482 |

483 | Press{" "} 484 | {navigator.userAgent.toLowerCase().indexOf("mac") !== -1 485 | ? "Cmd" 486 | : "Ctrl"} 487 | +D to bookmark this page. 488 |

489 |
490 | ); 491 | } 492 | 493 | render(, document.getElementById("app")!); 494 | --------------------------------------------------------------------------------