diff --git a/.env.example b/.env.example
index b513bfe..1546433 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,5 @@
-# Public wallet/app configuration
+# Public app configuration
NEXT_PUBLIC_APP_URL=http://localhost:3000
-NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
# Server-only configuration
DATABASE_URL=
@@ -12,6 +11,10 @@ ENCRYPTION_KEY=
GNOSIS_RPC_URL=
MAIN_SAFE_ADDRESS=
DAO_CONTRACT_ADDRESS=
+DAO_SHARE_TOKEN_ADDRESS=
+DAO_SHARE_THRESHOLD=100
+HATS_CONTRACT_ADDRESS=
+# Decimal or hex Hat ID values are both accepted.
ANGRY_DWARF_HAT_ID=
# External APIs
diff --git a/README.md b/README.md
index d41354f..a0f4967 100644
--- a/README.md
+++ b/README.md
@@ -53,15 +53,25 @@ The repo is public. Do not commit real treasury addresses, DAO addresses, RPC UR
Use `.env.local` for local secrets. `.env.example` should contain only placeholder keys.
Drizzle CLI commands also load `.env`, with `.env.local` overriding it when present.
-The app targets Neon in production, while Drizzle migrations use the standard `pg` driver so local Postgres databases work with `pnpm db:migrate`.
+The app targets Neon in production. At runtime it uses Neon HTTP for Neon URLs and the standard `pg` driver for localhost database URLs, so local Postgres works in both the app and migrations.
`pnpm db:reset:local` refuses non-localhost database URLs and protected database names, but it is still destructive for the selected local database.
+RaidGuild member access is checked with `DAO_SHARE_TOKEN_ADDRESS`, the DAOhaus/Baal ERC-20 shares token. `DAO_SHARE_THRESHOLD` is written as a human share amount such as `100`.
+`HATS_CONTRACT_ADDRESS` is the Hats Protocol contract address used for hats-based permissions, formatted as a `0x`-prefixed EVM address. Use the deployed Hats contract for the target network, or a local test contract address for local chain testing.
+`ANGRY_DWARF_HAT_ID` can be provided as a decimal or hex string and requires `HATS_CONTRACT_ADDRESS` to function.
+
`ENCRYPTION_KEY` must be a base64-encoded 32-byte key. Multiple-key rotation requires stable `key-id:base64-key` entries. To generate a local development key:
```bash
openssl rand -base64 32
```
+`SESSION_SECRET` must be at least 32 characters. To generate a local development value:
+
+```bash
+openssl rand -base64 32
+```
+
## Database Deployment
The `Database` GitHub Actions workflow checks generated migrations on pull requests and applies committed migrations on pushes to `main`.
diff --git a/package.json b/package.json
index 0cdbfe5..62da915 100644
--- a/package.json
+++ b/package.json
@@ -19,18 +19,23 @@
"dependencies": {
"@base-ui/react": "^1.5.0",
"@neondatabase/serverless": "^1.1.0",
+ "@tanstack/react-query": "^5.100.14",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",
+ "iron-session": "^8.0.4",
"lucide-react": "^1.17.0",
"next": "16.2.6",
"pg": "^8.18.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"server-only": "^0.0.1",
+ "siwe": "^3.0.0",
"tailwind-merge": "^3.6.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "viem": "^2.51.3",
+ "wagmi": "^3.6.16"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fc8ef1b..e16b77b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
'@neondatabase/serverless':
specifier: ^1.1.0
version: 1.1.0
+ '@tanstack/react-query':
+ specifier: ^5.100.14
+ version: 5.100.14(react@19.2.4)
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1(next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
@@ -26,6 +29,9 @@ importers:
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@neondatabase/serverless@1.1.0)(@types/pg@8.18.0)(pg@8.18.0)
+ iron-session:
+ specifier: ^8.0.4
+ version: 8.0.4
lucide-react:
specifier: ^1.17.0
version: 1.17.0(react@19.2.4)
@@ -44,12 +50,21 @@ importers:
server-only:
specifier: ^0.0.1
version: 0.0.1
+ siwe:
+ specifier: ^3.0.0
+ version: 3.0.0(ethers@6.16.0)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
+ viem:
+ specifier: ^2.51.3
+ version: 2.51.3(typescript@5.9.3)(zod@4.4.3)
+ wagmi:
+ specifier: ^3.6.16
+ version: 3.6.16(@tanstack/query-core@5.100.14)(@tanstack/react-query@5.100.14(react@19.2.4))(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(viem@2.51.3(typescript@5.9.3)(zod@4.4.3))
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@@ -87,6 +102,12 @@ importers:
packages:
+ '@adraffy/ens-normalize@1.10.1':
+ resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==}
+
+ '@adraffy/ens-normalize@1.11.1':
+ resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==}
+
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -943,6 +964,25 @@ packages:
cpu: [x64]
os: [win32]
+ '@noble/ciphers@1.3.0':
+ resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
+ engines: {node: ^14.21.3 || >=16}
+
+ '@noble/curves@1.2.0':
+ resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
+
+ '@noble/curves@1.9.1':
+ resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==}
+ engines: {node: ^14.21.3 || >=16}
+
+ '@noble/hashes@1.3.2':
+ resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
+ engines: {node: '>= 16'}
+
+ '@noble/hashes@1.8.0':
+ resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
+ engines: {node: ^14.21.3 || >=16}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -962,6 +1002,30 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+ '@scure/base@1.2.6':
+ resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==}
+
+ '@scure/bip32@1.7.0':
+ resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==}
+
+ '@scure/bip39@1.6.0':
+ resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==}
+
+ '@spruceid/siwe-parser@3.0.0':
+ resolution: {integrity: sha512-Y92k63ilw/8jH9Ry4G2e7lQd0jZAvb0d/Q7ssSD0D9mp/Zt2aCXIc3g0ny9yhplpAx1QXHsMz/JJptHK/zDGdw==}
+
+ '@stablelib/binary@1.0.1':
+ resolution: {integrity: sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==}
+
+ '@stablelib/int@1.0.1':
+ resolution: {integrity: sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==}
+
+ '@stablelib/random@1.0.2':
+ resolution: {integrity: sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==}
+
+ '@stablelib/wipe@1.0.1':
+ resolution: {integrity: sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==}
+
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -1053,6 +1117,14 @@ packages:
'@tailwindcss/postcss@4.3.0':
resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==}
+ '@tanstack/query-core@5.100.14':
+ resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==}
+
+ '@tanstack/react-query@5.100.14':
+ resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==}
+ peerDependencies:
+ react: ^18 || ^19
+
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
@@ -1068,6 +1140,9 @@ packages:
'@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
+ '@types/node@22.7.5':
+ resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==}
+
'@types/pg@8.18.0':
resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==}
@@ -1277,6 +1352,66 @@ packages:
vue-router:
optional: true
+ '@wagmi/connectors@8.0.15':
+ resolution: {integrity: sha512-LNr73YNs2KY6y6GXUWRXsDG0xSB5YpkO/9BSp3/2IAcM5XDPWcS4V1HfstXnP/ms9LARKLe2BAOCG44LajTWjw==}
+ peerDependencies:
+ '@base-org/account': ^2.5.1
+ '@coinbase/wallet-sdk': ^4.3.6
+ '@metamask/connect-evm': ^1.3.0
+ '@safe-global/safe-apps-provider': ~0.18.6
+ '@safe-global/safe-apps-sdk': ^9.1.0
+ '@wagmi/core': 3.5.0
+ '@walletconnect/ethereum-provider': ^2.21.1
+ accounts: ~0.10
+ porto: ~0.2.35
+ typescript: '>=5.9.3'
+ viem: 2.x
+ peerDependenciesMeta:
+ '@base-org/account':
+ optional: true
+ '@coinbase/wallet-sdk':
+ optional: true
+ '@metamask/connect-evm':
+ optional: true
+ '@safe-global/safe-apps-provider':
+ optional: true
+ '@safe-global/safe-apps-sdk':
+ optional: true
+ '@walletconnect/ethereum-provider':
+ optional: true
+ accounts:
+ optional: true
+ porto:
+ optional: true
+ typescript:
+ optional: true
+
+ '@wagmi/core@3.5.0':
+ resolution: {integrity: sha512-OcbdnZA6XPyDOHtY6GR8MFzRa89/EkMFCxdrWV4cgKjfsLi02NzEbMG6LQEO7VJ9ltwmLwpRQgcWnn6pBJPo7Q==}
+ peerDependencies:
+ '@tanstack/query-core': '>=5.0.0'
+ accounts: ~0.12
+ typescript: '>=5.9.3'
+ viem: 2.x
+ peerDependenciesMeta:
+ '@tanstack/query-core':
+ optional: true
+ accounts:
+ optional: true
+ typescript:
+ optional: true
+
+ abitype@1.2.3:
+ resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==}
+ peerDependencies:
+ typescript: '>=5.0.4'
+ zod: ^3.22.0 || ^4.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ zod:
+ optional: true
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1287,6 +1422,9 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ aes-js@4.0.0-beta.5:
+ resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==}
+
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
@@ -1294,6 +1432,9 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ apg-js@4.4.0:
+ resolution: {integrity: sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1429,6 +1570,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1776,6 +1921,13 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ ethers@6.16.0:
+ resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==}
+ engines: {node: '>=14.0.0'}
+
+ eventemitter3@5.0.1:
+ resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1942,6 +2094,12 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
+ iron-session@8.0.4:
+ resolution: {integrity: sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==}
+
+ iron-webcrypto@1.2.1:
+ resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
+
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -2051,6 +2209,11 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ isows@1.0.7:
+ resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==}
+ peerDependencies:
+ ws: '*'
+
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
@@ -2221,6 +2384,14 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+ mipd@0.0.7:
+ resolution: {integrity: sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==}
+ peerDependencies:
+ typescript: '>=5.0.4'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2306,6 +2477,14 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
+ ox@0.14.25:
+ resolution: {integrity: sha512-8DoibKtxE8yw63Y2jjMhlbjaURev6WCx4QR4MWLusl2/qIaeTzMJMBIYIDl1KOF45+8H1Ur6eLTdPlUoO8PlRw==}
+ peerDependencies:
+ typescript: '>=5.4.0'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@@ -2525,6 +2704,11 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ siwe@3.0.0:
+ resolution: {integrity: sha512-P2/ry7dHYJA6JJ5+veS//Gn2XDwNb3JMvuD6xiXX8L/PJ1SNVD4a3a8xqEbmANx+7kNQcD8YAh1B9bNKKvRy/g==}
+ peerDependencies:
+ ethers: ^5.6.8 || ^6.0.8
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2626,6 +2810,9 @@ packages:
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
+ tslib@2.7.0:
+ resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -2673,6 +2860,12 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
+ uncrypto@0.1.3:
+ resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
+
+ undici-types@6.19.8:
+ resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
+
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -2688,11 +2881,35 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ use-sync-external-store@1.4.0:
+ resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ viem@2.51.3:
+ resolution: {integrity: sha512-DA4EbrsvatzzLo6MwcWWiv6kI6dIr3I9HH9B6qsJaClN/s0AjIDUz5RIxl+VmGrovIUCcIvG8744yuGH7d37zw==}
+ peerDependencies:
+ typescript: '>=5.0.4'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ wagmi@3.6.16:
+ resolution: {integrity: sha512-tM6/81vwmiNrJneApCL+qJdQoAyn2Fyd6bL88dC8A0Zp13MVeK7/cp1wzia5AoY0yqJRQHjEGk2Zzqrkdht/kA==}
+ peerDependencies:
+ '@tanstack/react-query': '>=5.0.0'
+ react: '>=18'
+ typescript: '>=5.9.3'
+ viem: 2.x
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2718,6 +2935,30 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ ws@8.17.1:
+ resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ ws@8.20.1:
+ resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -2738,8 +2979,30 @@ packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+ zustand@5.0.0:
+ resolution: {integrity: sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+
snapshots:
+ '@adraffy/ens-normalize@1.10.1': {}
+
+ '@adraffy/ens-normalize@1.11.1': {}
+
'@alloc/quick-lru@5.2.0': {}
'@babel/code-frame@7.29.7':
@@ -3351,6 +3614,20 @@ snapshots:
'@next/swc-win32-x64-msvc@16.2.6':
optional: true
+ '@noble/ciphers@1.3.0': {}
+
+ '@noble/curves@1.2.0':
+ dependencies:
+ '@noble/hashes': 1.3.2
+
+ '@noble/curves@1.9.1':
+ dependencies:
+ '@noble/hashes': 1.8.0
+
+ '@noble/hashes@1.3.2': {}
+
+ '@noble/hashes@1.8.0': {}
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -3367,6 +3644,37 @@ snapshots:
'@rtsao/scc@1.1.0': {}
+ '@scure/base@1.2.6': {}
+
+ '@scure/bip32@1.7.0':
+ dependencies:
+ '@noble/curves': 1.9.1
+ '@noble/hashes': 1.8.0
+ '@scure/base': 1.2.6
+
+ '@scure/bip39@1.6.0':
+ dependencies:
+ '@noble/hashes': 1.8.0
+ '@scure/base': 1.2.6
+
+ '@spruceid/siwe-parser@3.0.0':
+ dependencies:
+ '@noble/hashes': 1.8.0
+ apg-js: 4.4.0
+
+ '@stablelib/binary@1.0.1':
+ dependencies:
+ '@stablelib/int': 1.0.1
+
+ '@stablelib/int@1.0.1': {}
+
+ '@stablelib/random@1.0.2':
+ dependencies:
+ '@stablelib/binary': 1.0.1
+ '@stablelib/wipe': 1.0.1
+
+ '@stablelib/wipe@1.0.1': {}
+
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -3440,6 +3748,13 @@ snapshots:
postcss: 8.5.15
tailwindcss: 4.3.0
+ '@tanstack/query-core@5.100.14': {}
+
+ '@tanstack/react-query@5.100.14(react@19.2.4)':
+ dependencies:
+ '@tanstack/query-core': 5.100.14
+ react: 19.2.4
+
'@tybys/wasm-util@0.10.2':
dependencies:
tslib: 2.8.1
@@ -3455,6 +3770,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/node@22.7.5':
+ dependencies:
+ undici-types: 6.19.8
+
'@types/pg@8.18.0':
dependencies:
'@types/node': 20.19.41
@@ -3635,12 +3954,41 @@ snapshots:
next: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
+ '@wagmi/connectors@8.0.15(@wagmi/core@3.5.0(@tanstack/query-core@5.100.14)(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)))(typescript@5.9.3)(viem@2.51.3(typescript@5.9.3)(zod@4.4.3))':
+ dependencies:
+ '@wagmi/core': 3.5.0(@tanstack/query-core@5.100.14)(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.51.3(typescript@5.9.3)(zod@4.4.3))
+ viem: 2.51.3(typescript@5.9.3)(zod@4.4.3)
+ optionalDependencies:
+ typescript: 5.9.3
+
+ '@wagmi/core@3.5.0(@tanstack/query-core@5.100.14)(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.51.3(typescript@5.9.3)(zod@4.4.3))':
+ dependencies:
+ eventemitter3: 5.0.1
+ mipd: 0.0.7(typescript@5.9.3)
+ viem: 2.51.3(typescript@5.9.3)(zod@4.4.3)
+ zustand: 5.0.0(@types/react@19.2.15)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4))
+ optionalDependencies:
+ '@tanstack/query-core': 5.100.14
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+ - react
+ - use-sync-external-store
+
+ abitype@1.2.3(typescript@5.9.3)(zod@4.4.3):
+ optionalDependencies:
+ typescript: 5.9.3
+ zod: 4.4.3
+
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
acorn@8.16.0: {}
+ aes-js@4.0.0-beta.5: {}
+
ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -3652,6 +4000,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ apg-js@4.4.0: {}
+
argparse@2.0.1: {}
aria-query@5.3.2: {}
@@ -3808,6 +4158,8 @@ snapshots:
convert-source-map@2.0.0: {}
+ cookie@0.7.2: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -4287,6 +4639,21 @@ snapshots:
esutils@2.0.3: {}
+ ethers@6.16.0:
+ dependencies:
+ '@adraffy/ens-normalize': 1.10.1
+ '@noble/curves': 1.2.0
+ '@noble/hashes': 1.3.2
+ '@types/node': 22.7.5
+ aes-js: 4.0.0-beta.5
+ tslib: 2.7.0
+ ws: 8.17.1
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ eventemitter3@5.0.1: {}
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.1:
@@ -4447,6 +4814,14 @@ snapshots:
hasown: 2.0.4
side-channel: 1.1.0
+ iron-session@8.0.4:
+ dependencies:
+ cookie: 0.7.2
+ iron-webcrypto: 1.2.1
+ uncrypto: 0.1.3
+
+ iron-webcrypto@1.2.1: {}
+
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.9
@@ -4563,6 +4938,10 @@ snapshots:
isexe@2.0.0: {}
+ isows@1.0.7(ws@8.20.1):
+ dependencies:
+ ws: 8.20.1
+
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
@@ -4706,6 +5085,10 @@ snapshots:
minimist@1.2.8: {}
+ mipd@0.0.7(typescript@5.9.3):
+ optionalDependencies:
+ typescript: 5.9.3
+
ms@2.1.3: {}
nanoid@3.3.12: {}
@@ -4804,6 +5187,21 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
+ ox@0.14.25(typescript@5.9.3)(zod@4.4.3):
+ dependencies:
+ '@adraffy/ens-normalize': 1.11.1
+ '@noble/ciphers': 1.3.0
+ '@noble/curves': 1.9.1
+ '@noble/hashes': 1.8.0
+ '@scure/bip32': 1.7.0
+ '@scure/bip39': 1.6.0
+ abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3)
+ eventemitter3: 5.0.1
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - zod
+
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
@@ -5064,6 +5462,12 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ siwe@3.0.0(ethers@6.16.0):
+ dependencies:
+ '@spruceid/siwe-parser': 3.0.0
+ '@stablelib/random': 1.0.2
+ ethers: 6.16.0
+
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@@ -5175,6 +5579,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
+ tslib@2.7.0: {}
+
tslib@2.8.1: {}
tsx@4.22.3:
@@ -5242,6 +5648,10 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
+ uncrypto@0.1.3: {}
+
+ undici-types@6.19.8: {}
+
undici-types@6.21.0: {}
unrs-resolver@1.12.2:
@@ -5281,10 +5691,54 @@ snapshots:
dependencies:
punycode: 2.3.1
+ use-sync-external-store@1.4.0(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+
use-sync-external-store@1.6.0(react@19.2.4):
dependencies:
react: 19.2.4
+ viem@2.51.3(typescript@5.9.3)(zod@4.4.3):
+ dependencies:
+ '@noble/curves': 1.9.1
+ '@noble/hashes': 1.8.0
+ '@scure/bip32': 1.7.0
+ '@scure/bip39': 1.6.0
+ abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3)
+ isows: 1.0.7(ws@8.20.1)
+ ox: 0.14.25(typescript@5.9.3)(zod@4.4.3)
+ ws: 8.20.1
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+ - zod
+
+ wagmi@3.6.16(@tanstack/query-core@5.100.14)(@tanstack/react-query@5.100.14(react@19.2.4))(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)):
+ dependencies:
+ '@tanstack/react-query': 5.100.14(react@19.2.4)
+ '@wagmi/connectors': 8.0.15(@wagmi/core@3.5.0(@tanstack/query-core@5.100.14)(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)))(typescript@5.9.3)(viem@2.51.3(typescript@5.9.3)(zod@4.4.3))
+ '@wagmi/core': 3.5.0(@tanstack/query-core@5.100.14)(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.51.3(typescript@5.9.3)(zod@4.4.3))
+ react: 19.2.4
+ use-sync-external-store: 1.4.0(react@19.2.4)
+ viem: 2.51.3(typescript@5.9.3)(zod@4.4.3)
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@base-org/account'
+ - '@coinbase/wallet-sdk'
+ - '@metamask/connect-evm'
+ - '@safe-global/safe-apps-provider'
+ - '@safe-global/safe-apps-sdk'
+ - '@tanstack/query-core'
+ - '@types/react'
+ - '@walletconnect/ethereum-provider'
+ - accounts
+ - immer
+ - porto
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -5332,6 +5786,10 @@ snapshots:
word-wrap@1.2.5: {}
+ ws@8.17.1: {}
+
+ ws@8.20.1: {}
+
xtend@4.0.2: {}
yallist@3.1.1: {}
@@ -5343,3 +5801,9 @@ snapshots:
zod: 4.4.3
zod@4.4.3: {}
+
+ zustand@5.0.0(@types/react@19.2.15)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)):
+ optionalDependencies:
+ '@types/react': 19.2.15
+ react: 19.2.4
+ use-sync-external-store: 1.4.0(react@19.2.4)
diff --git a/public/raidguild-full-logo.svg b/public/raidguild-full-logo.svg
new file mode 100644
index 0000000..f5a7fd2
--- /dev/null
+++ b/public/raidguild-full-logo.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..8545a6f
--- /dev/null
+++ b/src/app/api/auth/logout/route.ts
@@ -0,0 +1,19 @@
+import { NextResponse } from "next/server";
+
+import { getAuthSession } from "@/lib/auth/session";
+
+export async function POST() {
+ try {
+ const session = await getAuthSession();
+ session.destroy();
+
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ console.error("Wallet logout failed", error);
+
+ return NextResponse.json(
+ { error: "Logout failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/auth/nonce/route.ts b/src/app/api/auth/nonce/route.ts
new file mode 100644
index 0000000..717cbac
--- /dev/null
+++ b/src/app/api/auth/nonce/route.ts
@@ -0,0 +1,21 @@
+import { NextResponse } from "next/server";
+import { generateNonce } from "siwe";
+
+import { getAuthSession } from "@/lib/auth/session";
+
+export async function GET() {
+ try {
+ const session = await getAuthSession();
+ session.nonce = generateNonce();
+ await session.save();
+
+ return NextResponse.json({ nonce: session.nonce });
+ } catch (error) {
+ console.error("Wallet nonce creation failed", error);
+
+ return NextResponse.json(
+ { error: "Nonce generation failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts
new file mode 100644
index 0000000..46f8269
--- /dev/null
+++ b/src/app/api/auth/session/route.ts
@@ -0,0 +1,18 @@
+import { NextResponse } from "next/server";
+
+import { getAuthSession, serializeSession } from "@/lib/auth/session";
+
+export async function GET() {
+ try {
+ const session = await getAuthSession();
+
+ return NextResponse.json(serializeSession(session));
+ } catch (error) {
+ console.error("Wallet session lookup failed", error);
+
+ return NextResponse.json(
+ { error: "Session lookup failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts
new file mode 100644
index 0000000..1063fe1
--- /dev/null
+++ b/src/app/api/auth/verify/route.ts
@@ -0,0 +1,130 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { getAddress } from "viem";
+import { SiweMessage } from "siwe";
+
+import {
+ getAuthSession,
+ serializeSession,
+} from "@/lib/auth/session";
+import { getWalletPermissions } from "@/lib/auth/permissions";
+
+type VerifyRequestBody = {
+ message?: string;
+ signature?: string;
+};
+
+function getPublicErrorMessage(error: unknown) {
+ const message =
+ error instanceof Error ? error.message : "Wallet verification failed";
+ const lowerMessage = message.toLowerCase();
+
+ if (lowerMessage.includes("session_secret")) {
+ return "Session configuration is missing";
+ }
+
+ if (lowerMessage.includes("gnosis_rpc_url")) {
+ return "GNOSIS_RPC_URL is required for permission checks";
+ }
+
+ if (lowerMessage.includes("dao_share_token_address")) {
+ return "DAO_SHARE_TOKEN_ADDRESS is required for member access checks";
+ }
+
+ if (lowerMessage.includes("dao_share_threshold")) {
+ return "DAO_SHARE_THRESHOLD is required for member access checks";
+ }
+
+ if (lowerMessage.includes("hats_contract_address")) {
+ return "HATS_CONTRACT_ADDRESS must be a valid EVM address";
+ }
+
+ if (
+ lowerMessage.includes("execution reverted") ||
+ lowerMessage.includes("contract function")
+ ) {
+ return "Permission contract read failed";
+ }
+
+ if (lowerMessage.includes("database_url")) {
+ return "DATABASE_URL is required for permission checks";
+ }
+
+ return "Wallet verification failed";
+}
+
+function getExpectedDomain(request: NextRequest) {
+ const configuredUrl = process.env.NEXT_PUBLIC_APP_URL;
+
+ if (configuredUrl) {
+ return new URL(configuredUrl).host;
+ }
+
+ return request.headers.get("host") ?? request.nextUrl.host;
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getAuthSession();
+ const { message, signature } = (await request.json()) as VerifyRequestBody;
+
+ if (!session.nonce || !message || !signature) {
+ return NextResponse.json(
+ { error: "Missing SIWE nonce, message, or signature" },
+ { status: 400 },
+ );
+ }
+
+ let siweMessage: SiweMessage;
+
+ try {
+ siweMessage = new SiweMessage(message);
+ } catch {
+ session.destroy();
+ return NextResponse.json(
+ { error: "Invalid SIWE message" },
+ { status: 400 },
+ );
+ }
+
+ const verification = await siweMessage.verify({
+ domain: getExpectedDomain(request),
+ nonce: session.nonce,
+ signature,
+ });
+
+ if (!verification.success) {
+ session.destroy();
+ return NextResponse.json(
+ { error: "Wallet signature could not be verified" },
+ { status: 401 },
+ );
+ }
+
+ const address = getAddress(verification.data.address);
+ const permissions = await getWalletPermissions(address);
+
+ if (!permissions.canAccess) {
+ session.destroy();
+ return NextResponse.json(
+ { error: "Wallet does not have RaidGuild accounting access" },
+ { status: 403 },
+ );
+ }
+
+ session.address = address;
+ session.authenticatedAt = new Date().toISOString();
+ session.chainId = verification.data.chainId;
+ session.permissions = permissions;
+ delete session.nonce;
+ await session.save();
+
+ return NextResponse.json(serializeSession(session));
+ } catch (error) {
+ console.error("Wallet verification failed", error);
+
+ return NextResponse.json(
+ { error: getPublicErrorMessage(error) },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 2a3e7e6..85593d6 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -153,6 +153,36 @@
}
@layer utilities {
+ @keyframes toast-in {
+ from {
+ opacity: 0;
+ transform: translateY(0.5rem) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ }
+
+ @keyframes toast-out {
+ from {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(0.5rem) scale(0.98);
+ }
+ }
+
+ .toast-enter {
+ animation: toast-in 180ms ease-out both;
+ }
+
+ .animate-toast-out {
+ animation: toast-out 180ms ease-in both;
+ }
+
.container-custom {
@apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8;
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 955d95e..3feac45 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/next";
import "./globals.css";
+import { Providers } from "@/components/providers";
+
export const metadata: Metadata = {
title: "RaidGuild Accounting",
description: "Wallet-gated accounting dashboard for RaidGuild treasury reporting.",
@@ -15,7 +17,7 @@ export default function RootLayout({
return (
- {children}
+ {children}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 776c2a3..52b2887 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -4,10 +4,12 @@ import {
FileSpreadsheet,
Landmark,
LockKeyhole,
- ShieldCheck,
} from "lucide-react";
+import Image from "next/image";
+import { WalletConnect } from "@/components/auth/wallet-connect";
import { Button } from "@/components/ui/button";
+import { getAuthSession, serializeSession } from "@/lib/auth/session";
const assets = [
{ symbol: "USDC", balance: "$0.00", tone: "bg-moloch-500" },
@@ -23,7 +25,77 @@ const milestones = [
"Q1 XLSX export",
];
-export default function Home() {
+async function getSessionState() {
+ try {
+ const session = await getAuthSession();
+ return serializeSession(session);
+ } catch {
+ return {
+ address: null,
+ authenticated: false,
+ chainId: null,
+ permissions: null,
+ };
+ }
+}
+
+type SessionState = Awaited>;
+
+function PublicHome({ session }: { session: SessionState }) {
+ return (
+
+
+
+
+
+
+ Accounting Dashboard
+
+
+ Member access for treasury reporting.
+
+
+ Connect a RaidGuild member wallet to view treasury balances and
+ export-ready accounting records.
+
+
+
+
+
+
+
+
+
+
+ Wallet Required
+
+
+ Sign in with Ethereum
+
+
+
+
+ Access is checked against DAO shares, Angry Dwarf Hats, and
+ database-managed Cleric permissions.
+
+
+
+
+
+
+
+
+ );
+}
+
+function MemberHome({ session }: { session: SessionState }) {
return (
@@ -39,10 +111,7 @@ export default function Home() {
-
+
@@ -165,3 +234,13 @@ export default function Home() {
);
}
+
+export default async function Home() {
+ const session = await getSessionState();
+
+ if (!session.authenticated) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/src/components/auth/wallet-connect.tsx b/src/components/auth/wallet-connect.tsx
new file mode 100644
index 0000000..67dd446
--- /dev/null
+++ b/src/components/auth/wallet-connect.tsx
@@ -0,0 +1,326 @@
+"use client";
+
+import { LogOut, ShieldCheck, Wallet } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { SiweMessage } from "siwe";
+import { useEffect, useMemo, useState } from "react";
+import {
+ useAccount,
+ useConnect,
+ useDisconnect,
+ useSignMessage,
+} from "wagmi";
+
+import { Button } from "@/components/ui/button";
+import { useToast } from "@/components/ui/toast";
+import type { AuthPermissions } from "@/lib/auth/types";
+
+type SessionResponse = {
+ address: string | null;
+ authenticated: boolean;
+ chainId: number | null;
+ permissions: AuthPermissions | null;
+};
+
+type WalletConnectProps = {
+ initialSession?: SessionResponse;
+};
+
+const emptySession: SessionResponse = {
+ address: null,
+ authenticated: false,
+ chainId: null,
+ permissions: null,
+};
+
+function formatAddress(address: string) {
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
+}
+
+function getFriendlyError(error: unknown) {
+ const message =
+ error instanceof Error ? error.message : "Wallet sign-in failed";
+ const lowerMessage = message.toLowerCase();
+
+ if (
+ lowerMessage.includes("user rejected") ||
+ lowerMessage.includes("user denied") ||
+ lowerMessage.includes("rejected the request")
+ ) {
+ return "Request cancelled.";
+ }
+
+ if (lowerMessage.includes("no wallet connector")) {
+ return "No injected wallet found.";
+ }
+
+ if (lowerMessage.includes("chain id")) {
+ return "Could not detect the connected chain.";
+ }
+
+ if (lowerMessage.includes("session configuration")) {
+ return "Session setup is missing.";
+ }
+
+ if (lowerMessage.includes("session lookup")) {
+ return "Session lookup failed.";
+ }
+
+ if (lowerMessage.includes("nonce generation")) {
+ return "Could not start wallet sign-in.";
+ }
+
+ if (lowerMessage.includes("logout")) {
+ return "Sign out failed.";
+ }
+
+ if (lowerMessage.includes("gnosis_rpc_url")) {
+ return "Gnosis RPC URL is missing.";
+ }
+
+ if (lowerMessage.includes("dao_contract_address")) {
+ return "DAO contract address is missing.";
+ }
+
+ if (lowerMessage.includes("dao_share_token_address")) {
+ return "DAO shares token address is missing.";
+ }
+
+ if (lowerMessage.includes("dao_share_threshold")) {
+ return "DAO share threshold is missing.";
+ }
+
+ if (lowerMessage.includes("hats_contract_address")) {
+ return "Hats contract address is invalid.";
+ }
+
+ if (lowerMessage.includes("does not have raidguild accounting access")) {
+ return "This wallet does not have access.";
+ }
+
+ if (lowerMessage.includes("execution reverted")) {
+ return "Permission contract read failed.";
+ }
+
+ if (lowerMessage.includes("signature")) {
+ return "Signature verification failed.";
+ }
+
+ return "Wallet sign-in failed.";
+}
+
+export function WalletConnect({ initialSession }: WalletConnectProps) {
+ const account = useAccount();
+ const router = useRouter();
+ const { connectAsync, connectors, isPending: isConnecting } = useConnect();
+ const { disconnectAsync } = useDisconnect();
+ const { signMessageAsync, isPending: isSigning } = useSignMessage();
+ const { showToast } = useToast();
+ const [session, setSession] = useState(
+ initialSession ?? emptySession,
+ );
+ const [isLoadingSession, setIsLoadingSession] = useState(!initialSession);
+ const [isVerifying, setIsVerifying] = useState(false);
+
+ const primaryConnector = useMemo(
+ () =>
+ connectors.find((connector) => connector.type === "injected") ??
+ connectors[0],
+ [connectors],
+ );
+
+ useEffect(() => {
+ if (initialSession) {
+ return;
+ }
+
+ let isMounted = true;
+
+ fetch("/api/auth/session")
+ .then(async (response) => {
+ const payload = (await response.json()) as
+ | SessionResponse
+ | { error: string };
+
+ if (!response.ok || "error" in payload) {
+ throw new Error(
+ "error" in payload ? payload.error : "Session lookup failed",
+ );
+ }
+
+ return payload;
+ })
+ .then((nextSession) => {
+ if (isMounted) {
+ setSession(nextSession);
+ }
+ })
+ .catch(() => {
+ if (isMounted) {
+ showToast("Session setup is missing.");
+ }
+ })
+ .finally(() => {
+ if (isMounted) {
+ setIsLoadingSession(false);
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, [initialSession, showToast]);
+
+ async function signIn() {
+ setIsVerifying(true);
+
+ try {
+ if (!primaryConnector) {
+ throw new Error("No wallet connector is available");
+ }
+
+ if (!account.address && !("ethereum" in window)) {
+ throw new Error("No injected wallet found");
+ }
+
+ const connectedAccount = account.address
+ ? { address: account.address, chainId: account.chainId }
+ : await connectAsync({ connector: primaryConnector });
+ const address =
+ "address" in connectedAccount
+ ? connectedAccount.address
+ : connectedAccount.accounts[0];
+ const chainId = connectedAccount.chainId;
+
+ if (!chainId) {
+ throw new Error("Wallet chain ID is unavailable");
+ }
+
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL;
+ const domain = appUrl ? new URL(appUrl).host : window.location.host;
+ const uri = appUrl ?? window.location.origin;
+ const nonceResponse = await fetch("/api/auth/nonce");
+ const noncePayload = (await nonceResponse.json().catch(() => ({
+ error: "Could not start wallet sign-in",
+ }))) as
+ | { nonce: string }
+ | { error: string };
+
+ if (!nonceResponse.ok || "error" in noncePayload) {
+ throw new Error(
+ "error" in noncePayload
+ ? noncePayload.error
+ : "Could not start wallet sign-in",
+ );
+ }
+
+ const { nonce } = noncePayload;
+ const message = new SiweMessage({
+ address,
+ chainId,
+ domain,
+ nonce,
+ statement: "Sign in to RaidGuild Accounting.",
+ uri,
+ version: "1",
+ }).prepareMessage();
+ const signature = await signMessageAsync({ message });
+ const verifyResponse = await fetch("/api/auth/verify", {
+ body: JSON.stringify({ message, signature }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+ const nextSession = (await verifyResponse.json().catch(() => ({
+ error: "Wallet verification failed",
+ }))) as SessionResponse | { error: string };
+
+ if (!verifyResponse.ok || "error" in nextSession) {
+ throw new Error(
+ "error" in nextSession
+ ? nextSession.error
+ : "Wallet verification failed",
+ );
+ }
+
+ setSession(nextSession);
+ router.refresh();
+ } catch (nextError) {
+ showToast(
+ "ethereum" in window
+ ? getFriendlyError(nextError)
+ : "No injected wallet found.",
+ );
+ } finally {
+ setIsVerifying(false);
+ }
+ }
+
+ async function signOut() {
+ try {
+ const response = await fetch("/api/auth/logout", { method: "POST" });
+
+ if (!response.ok) {
+ throw new Error("Logout failed");
+ }
+ } catch (error) {
+ showToast(getFriendlyError(error));
+ }
+
+ try {
+ await disconnectAsync();
+ } catch (error) {
+ console.warn("Wallet disconnect failed", error);
+ } finally {
+ setSession(emptySession);
+ router.refresh();
+ }
+ }
+
+ if (session.authenticated && session.address) {
+ return (
+
+
+ {formatAddress(session.address)}
+ {session.permissions?.roles.length ? (
+
+ {session.permissions.roles.join(", ")}
+
+ ) : null}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/providers.tsx b/src/components/providers.tsx
new file mode 100644
index 0000000..9481d24
--- /dev/null
+++ b/src/components/providers.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useState, type ReactNode } from "react";
+import { WagmiProvider } from "wagmi";
+
+import { ToastProvider } from "@/components/ui/toast";
+import { wagmiConfig } from "@/lib/wagmi";
+
+export function Providers({ children }: { children: ReactNode }) {
+ const [queryClient] = useState(() => new QueryClient());
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx
new file mode 100644
index 0000000..b37a9eb
--- /dev/null
+++ b/src/components/ui/toast.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+type Toast = {
+ id: string;
+ message: string;
+ state: "entering" | "leaving";
+};
+
+type ToastContextValue = {
+ showToast: (message: string) => void;
+};
+
+const ToastContext = createContext(null);
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [toasts, setToasts] = useState([]);
+
+ const showToast = useCallback((message: string) => {
+ const id = crypto.randomUUID();
+ setToasts((currentToasts) => [
+ ...currentToasts,
+ { id, message, state: "entering" },
+ ]);
+ window.setTimeout(() => {
+ setToasts((currentToasts) =>
+ currentToasts.map((toast) =>
+ toast.id === id ? { ...toast, state: "leaving" } : toast,
+ ),
+ );
+ }, 3000);
+ window.setTimeout(() => {
+ setToasts((currentToasts) =>
+ currentToasts.filter((toast) => toast.id !== id),
+ );
+ }, 3250);
+ }, []);
+
+ const value = useMemo(() => ({ showToast }), [showToast]);
+
+ return (
+
+ {children}
+
+ {toasts.map((toast) => (
+
+ {toast.message}
+
+ ))}
+
+
+ );
+}
+
+export function useToast() {
+ const context = useContext(ToastContext);
+
+ if (!context) {
+ throw new Error("useToast must be used within ToastProvider");
+ }
+
+ return context;
+}
diff --git a/src/db/index.ts b/src/db/index.ts
index 612ba0d..1adaae1 100644
--- a/src/db/index.ts
+++ b/src/db/index.ts
@@ -2,11 +2,22 @@ import "server-only";
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
+import { drizzle as drizzleNode } from "drizzle-orm/node-postgres";
import * as schema from "@/db/schema";
let cachedDb: ReturnType | undefined;
+function isLocalDatabaseUrl(databaseUrl: string) {
+ const hostname = new URL(databaseUrl).hostname.replace(/^\[(.*)\]$/, "$1");
+
+ return (
+ hostname === "localhost" ||
+ hostname === "127.0.0.1" ||
+ hostname === "::1"
+ );
+}
+
function createDb() {
const databaseUrl = process.env.DATABASE_URL;
@@ -14,6 +25,10 @@ function createDb() {
throw new Error("DATABASE_URL is required to initialize the database");
}
+ if (isLocalDatabaseUrl(databaseUrl)) {
+ return drizzleNode(databaseUrl, { schema });
+ }
+
return drizzle(neon(databaseUrl), { schema });
}
diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts
new file mode 100644
index 0000000..ff9db13
--- /dev/null
+++ b/src/lib/auth/permissions.ts
@@ -0,0 +1,166 @@
+import "server-only";
+
+import { and, isNull, sql } from "drizzle-orm";
+import {
+ createPublicClient,
+ getAddress,
+ http,
+ isAddress,
+ parseUnits,
+} from "viem";
+import { gnosis } from "viem/chains";
+
+import { getDb } from "@/db";
+import { clericRoles } from "@/db/schema";
+import type { AuthPermissions, AuthRole } from "@/lib/auth/types";
+
+const ERC20_MEMBERSHIP_ABI = [
+ {
+ inputs: [{ internalType: "address", name: "account", type: "address" }],
+ name: "balanceOf",
+ outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "decimals",
+ outputs: [{ internalType: "uint8", name: "", type: "uint8" }],
+ stateMutability: "view",
+ type: "function",
+ },
+] as const;
+
+const HATS_ABI = [
+ {
+ inputs: [
+ { internalType: "address", name: "_wearer", type: "address" },
+ { internalType: "uint256", name: "_hatId", type: "uint256" },
+ ],
+ name: "isWearerOfHat",
+ outputs: [{ internalType: "bool", name: "wearing", type: "bool" }],
+ stateMutability: "view",
+ type: "function",
+ },
+] as const;
+
+function getGnosisClient() {
+ const rpcUrl = process.env.GNOSIS_RPC_URL;
+
+ if (!rpcUrl) {
+ throw new Error("GNOSIS_RPC_URL is required for permission checks");
+ }
+
+ return createPublicClient({
+ chain: gnosis,
+ transport: http(rpcUrl),
+ });
+}
+
+async function hasDaoShares(address: `0x${string}`) {
+ const shareTokenAddress = process.env.DAO_SHARE_TOKEN_ADDRESS;
+
+ if (!shareTokenAddress || !isAddress(shareTokenAddress)) {
+ throw new Error(
+ "DAO_SHARE_TOKEN_ADDRESS is required for member access checks",
+ );
+ }
+
+ const client = getGnosisClient();
+ const tokenAddress = getAddress(shareTokenAddress);
+ const [balance, decimals] = await Promise.all([
+ client.readContract({
+ abi: ERC20_MEMBERSHIP_ABI,
+ address: tokenAddress,
+ args: [address],
+ functionName: "balanceOf",
+ }),
+ client.readContract({
+ abi: ERC20_MEMBERSHIP_ABI,
+ address: tokenAddress,
+ functionName: "decimals",
+ }),
+ ]);
+ const thresholdValue = process.env.DAO_SHARE_THRESHOLD;
+
+ if (!thresholdValue) {
+ throw new Error("DAO_SHARE_THRESHOLD is required for member access checks");
+ }
+
+ const threshold = parseUnits(thresholdValue, decimals);
+
+ return balance >= threshold;
+}
+
+async function hasAngryDwarfHat(address: `0x${string}`) {
+ const hatsAddress = process.env.HATS_CONTRACT_ADDRESS;
+ const hatId = process.env.ANGRY_DWARF_HAT_ID;
+
+ if (!hatsAddress || !hatId) {
+ return false;
+ }
+
+ if (!isAddress(hatsAddress)) {
+ throw new Error("HATS_CONTRACT_ADDRESS must be a valid EVM address");
+ }
+
+ return getGnosisClient().readContract({
+ abi: HATS_ABI,
+ address: getAddress(hatsAddress),
+ args: [address, BigInt(hatId)],
+ functionName: "isWearerOfHat",
+ });
+}
+
+async function hasClericRole(address: `0x${string}`) {
+ const normalizedAddress = address.toLowerCase();
+ const db = getDb();
+ const role = await db
+ .select({ id: clericRoles.id })
+ .from(clericRoles)
+ .where(
+ and(
+ sql`lower(${clericRoles.walletAddress}) = ${normalizedAddress}`,
+ isNull(clericRoles.revokedAt),
+ ),
+ )
+ .limit(1);
+
+ return role.length > 0;
+}
+
+export async function getWalletPermissions(
+ walletAddress: string,
+): Promise {
+ if (!isAddress(walletAddress)) {
+ throw new Error("Invalid wallet address");
+ }
+
+ const address = getAddress(walletAddress);
+ const [isMember, isAdmin, isCleric] = await Promise.all([
+ hasDaoShares(address),
+ hasAngryDwarfHat(address),
+ hasClericRole(address),
+ ]);
+
+ const roles: AuthRole[] = [];
+
+ if (isMember) {
+ roles.push("member");
+ }
+
+ if (isAdmin) {
+ roles.push("admin");
+ }
+
+ if (isCleric) {
+ roles.push("cleric");
+ }
+
+ return {
+ canAccess: isMember || isAdmin || isCleric,
+ canAdmin: isAdmin,
+ canWriteRaidAccounting: isAdmin || isCleric,
+ roles,
+ };
+}
diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts
new file mode 100644
index 0000000..4fca393
--- /dev/null
+++ b/src/lib/auth/session.ts
@@ -0,0 +1,47 @@
+import "server-only";
+
+import { cookies } from "next/headers";
+import { getIronSession, type SessionOptions } from "iron-session";
+
+import type { AuthSessionData } from "@/lib/auth/types";
+
+const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
+
+function getSessionPassword() {
+ const password = process.env.SESSION_SECRET;
+
+ if (!password || password.length < 32) {
+ throw new Error("SESSION_SECRET must be at least 32 characters");
+ }
+
+ return password;
+}
+
+export function getSessionOptions(): SessionOptions {
+ return {
+ cookieName: "raidguild-accounting-session",
+ password: getSessionPassword(),
+ ttl: SESSION_TTL_SECONDS,
+ cookieOptions: {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: process.env.NODE_ENV === "production",
+ },
+ };
+}
+
+export async function getAuthSession() {
+ return getIronSession(
+ await cookies(),
+ getSessionOptions(),
+ );
+}
+
+export function serializeSession(session: AuthSessionData) {
+ return {
+ address: session.address ?? null,
+ authenticated: Boolean(session.address && session.permissions?.canAccess),
+ chainId: session.chainId ?? null,
+ permissions: session.permissions ?? null,
+ };
+}
diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts
new file mode 100644
index 0000000..43f1321
--- /dev/null
+++ b/src/lib/auth/types.ts
@@ -0,0 +1,16 @@
+export type AuthRole = "admin" | "cleric" | "member";
+
+export type AuthPermissions = {
+ canAccess: boolean;
+ canAdmin: boolean;
+ canWriteRaidAccounting: boolean;
+ roles: AuthRole[];
+};
+
+export type AuthSessionData = {
+ address?: `0x${string}`;
+ authenticatedAt?: string;
+ chainId?: number;
+ nonce?: string;
+ permissions?: AuthPermissions;
+};
diff --git a/src/lib/wagmi.ts b/src/lib/wagmi.ts
new file mode 100644
index 0000000..2565132
--- /dev/null
+++ b/src/lib/wagmi.ts
@@ -0,0 +1,18 @@
+"use client";
+
+import { http, createConfig } from "wagmi";
+import { arbitrum, base, gnosis, mainnet, optimism } from "wagmi/chains";
+import { injected } from "wagmi/connectors";
+
+export const wagmiConfig = createConfig({
+ chains: [gnosis, mainnet, arbitrum, optimism, base],
+ connectors: [injected()],
+ ssr: true,
+ transports: {
+ [arbitrum.id]: http(),
+ [base.id]: http(),
+ [gnosis.id]: http(),
+ [mainnet.id]: http(),
+ [optimism.id]: http(),
+ },
+});