This commit is contained in:
iamlukethedev
2026-03-21 20:49:29 -05:00
parent c2cbdeec44
commit 7228643131
20 changed files with 4397 additions and 13 deletions
+3
View File
@@ -15,3 +15,6 @@ DEBUG=true
# ELEVENLABS_API_KEY=
# ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
# ELEVENLABS_MODEL_ID=eleven_flash_v2_5
# Optional: Solana RPC for the crypto room (free tier from Helius, Alchemy, etc.)
# NEXT_PUBLIC_SOLANA_RPC_URL=https://mainnet.helius-rpc.com/?api-key=YOUR_KEY
+505 -4
View File
@@ -12,6 +12,7 @@
"@noble/ed25519": "^3.0.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@solana/web3.js": "^1.98.4",
"@vercel/otel": "^2.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -1896,6 +1897,21 @@
"node": ">= 10"
}
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/ed25519": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz",
@@ -1905,6 +1921,18 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2551,6 +2579,103 @@
"dev": true,
"license": "MIT"
},
"node_modules/@solana/buffer-layout": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz",
"integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==",
"license": "MIT",
"dependencies": {
"buffer": "~6.0.3"
},
"engines": {
"node": ">=5.10"
}
},
"node_modules/@solana/codecs-core": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz",
"integrity": "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==",
"license": "MIT",
"dependencies": {
"@solana/errors": "2.3.0"
},
"engines": {
"node": ">=20.18.0"
},
"peerDependencies": {
"typescript": ">=5.3.3"
}
},
"node_modules/@solana/codecs-numbers": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz",
"integrity": "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==",
"license": "MIT",
"dependencies": {
"@solana/codecs-core": "2.3.0",
"@solana/errors": "2.3.0"
},
"engines": {
"node": ">=20.18.0"
},
"peerDependencies": {
"typescript": ">=5.3.3"
}
},
"node_modules/@solana/errors": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz",
"integrity": "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==",
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",
"commander": "^14.0.0"
},
"bin": {
"errors": "bin/cli.mjs"
},
"engines": {
"node": ">=20.18.0"
},
"peerDependencies": {
"typescript": ">=5.3.3"
}
},
"node_modules/@solana/errors/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@solana/web3.js": {
"version": "1.98.4",
"resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz",
"integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.0",
"@noble/curves": "^1.4.2",
"@noble/hashes": "^1.4.0",
"@solana/buffer-layout": "^4.0.1",
"@solana/codecs-numbers": "^2.1.0",
"agentkeepalive": "^4.5.0",
"bn.js": "^5.2.1",
"borsh": "^0.7.0",
"bs58": "^4.0.1",
"buffer": "6.0.3",
"fast-stable-stringify": "^1.0.0",
"jayson": "^4.1.1",
"node-fetch": "^2.7.0",
"rpc-websockets": "^9.0.2",
"superstruct": "^2.0.2"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2961,6 +3086,15 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -3040,7 +3174,6 @@
"version": "20.19.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -3107,6 +3240,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
@@ -3117,7 +3256,6 @@
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -3862,6 +4000,18 @@
"node": ">= 14"
}
},
"node_modules/agentkeepalive": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
"license": "MIT",
"dependencies": {
"humanize-ms": "^1.2.1"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4163,6 +4313,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/base-x": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz",
"integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -4201,6 +4360,23 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/bn.js": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz",
"integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==",
"license": "MIT"
},
"node_modules/borsh": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz",
"integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==",
"license": "Apache-2.0",
"dependencies": {
"bn.js": "^5.2.0",
"bs58": "^4.0.0",
"text-encoding-utf-8": "^1.0.2"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -4259,6 +4435,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bs58": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz",
"integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==",
"license": "MIT",
"dependencies": {
"base-x": "^3.0.2"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -4283,6 +4468,20 @@
"ieee754": "^1.2.1"
}
},
"node_modules/bufferutil": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
"integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -4517,6 +4716,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4781,6 +4989,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delay": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz",
"integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -5089,6 +5309,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
"license": "MIT"
},
"node_modules/es6-promisify": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
"integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==",
"license": "MIT",
"dependencies": {
"es6-promise": "^4.0.3"
}
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -5636,6 +5871,14 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/eyes": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz",
"integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==",
"engines": {
"node": "> 0.1.90"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5687,6 +5930,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-stable-stringify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz",
"integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==",
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -6206,6 +6455,15 @@
"node": ">= 14"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.0.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -6810,6 +7068,15 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/isomorphic-ws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz",
"integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==",
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -6840,6 +7107,74 @@
"react": "^19.0.0"
}
},
"node_modules/jayson": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz",
"integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==",
"license": "MIT",
"dependencies": {
"@types/connect": "^3.4.33",
"@types/node": "^12.12.54",
"@types/ws": "^7.4.4",
"commander": "^2.20.3",
"delay": "^5.0.0",
"es6-promisify": "^5.0.0",
"eyes": "^0.1.8",
"isomorphic-ws": "^4.0.1",
"json-stringify-safe": "^5.0.1",
"stream-json": "^1.9.1",
"uuid": "^8.3.2",
"ws": "^7.5.10"
},
"bin": {
"jayson": "bin/jayson.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jayson/node_modules/@types/node": {
"version": "12.20.55",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
"license": "MIT"
},
"node_modules/jayson/node_modules/@types/ws": {
"version": "7.4.7",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
"integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/jayson/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/jayson/node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -6944,6 +7279,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -8462,6 +8803,60 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"optional": true,
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -9301,6 +9696,42 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rpc-websockets": {
"version": "9.3.6",
"resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.6.tgz",
"integrity": "sha512-RzuOQDGd+EtR/cBYQAH/0jjaBzhyvXXGROhxigGJPf+q3XKyvtelZCucylzxiq5MaGlfBx1075djTsxFsFDgrA==",
"license": "LGPL-3.0-only",
"dependencies": {
"@swc/helpers": "^0.5.11",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.2.2",
"buffer": "^6.0.3",
"eventemitter3": "^5.0.1",
"uuid": "^11.0.0",
"ws": "^8.5.0"
},
"funding": {
"type": "paypal",
"url": "https://paypal.me/kozjak"
},
"optionalDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^6.0.0"
}
},
"node_modules/rpc-websockets/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -9345,6 +9776,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -9700,6 +10151,21 @@
"node": ">= 0.4"
}
},
"node_modules/stream-chain": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
"integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
"license": "BSD-3-Clause"
},
"node_modules/stream-json": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
"integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
"license": "BSD-3-Clause",
"dependencies": {
"stream-chain": "^2.2.5"
}
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -9904,6 +10370,15 @@
}
}
},
"node_modules/superstruct": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz",
"integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -9977,6 +10452,11 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/text-encoding-utf-8": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz",
"integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg=="
},
"node_modules/three": {
"version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
@@ -10386,7 +10866,6 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -10443,7 +10922,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unified": {
@@ -10618,6 +11096,20 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utf-8-validate": {
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz",
"integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/utility-types": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
@@ -10627,6 +11119,15 @@
"node": ">= 4"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+1
View File
@@ -20,6 +20,7 @@
"@noble/ed25519": "^3.0.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@solana/web3.js": "^1.98.4",
"@vercel/otel": "^2.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+149
View File
@@ -0,0 +1,149 @@
import { NextResponse } from "next/server";
const PAIR_ID_RE = /^[a-zA-Z0-9]{16,128}$/;
type DexScreenerPair = {
chainId?: string;
pairAddress?: string;
dexId?: string;
url?: string;
priceUsd?: string;
priceNative?: string;
fdv?: number;
marketCap?: number;
liquidity?: {
usd?: number;
};
volume?: {
h24?: number;
};
txns?: {
h24?: {
buys?: number;
sells?: number;
};
};
baseToken?: {
address?: string;
symbol?: string;
name?: string;
};
quoteToken?: {
address?: string;
symbol?: string;
name?: string;
};
priceChange?: {
m5?: number;
h1?: number;
h6?: number;
h24?: number;
};
info?: {
imageUrl?: string;
};
};
const toResponsePair = (pair: DexScreenerPair) => ({
pairAddress: pair.pairAddress!,
dexId: pair.dexId ?? null,
url: pair.url ?? `https://dexscreener.com/solana/${pair.pairAddress}`,
priceUsd: Number(pair.priceUsd ?? "0"),
priceNative: pair.priceNative ? Number(pair.priceNative) : null,
fdv: pair.fdv ?? null,
marketCap: pair.marketCap ?? null,
liquidityUsd: pair.liquidity?.usd ?? null,
volume24hUsd: pair.volume?.h24 ?? null,
buys24h: pair.txns?.h24?.buys ?? null,
sells24h: pair.txns?.h24?.sells ?? null,
pairLabel: `${pair.baseToken?.symbol ?? "TOKEN"}/${pair.quoteToken?.symbol ?? "TOKEN"}`,
chainId: pair.chainId ?? "solana",
quoteToken: {
address: pair.quoteToken!.address!,
symbol: pair.quoteToken?.symbol ?? "TOKEN",
name: pair.quoteToken?.name ?? pair.quoteToken?.symbol ?? "Token",
},
baseToken: {
address: pair.baseToken!.address!,
symbol: pair.baseToken?.symbol ?? "TOKEN",
name: pair.baseToken?.name ?? pair.baseToken?.symbol ?? "Token",
},
priceChangePct: {
m5: pair.priceChange?.m5 ?? null,
h1: pair.priceChange?.h1 ?? null,
h6: pair.priceChange?.h6 ?? null,
h24: pair.priceChange?.h24 ?? null,
},
imageUrl: pair.info?.imageUrl ?? null,
loadedAt: Date.now(),
});
const isCompletePair = (pair: DexScreenerPair | undefined): pair is DexScreenerPair =>
Boolean(pair?.pairAddress && pair.baseToken?.address && pair.quoteToken?.address);
const getBestPair = (pairs: DexScreenerPair[]) =>
pairs
.filter(isCompletePair)
.sort((left, right) => (right.liquidity?.usd ?? 0) - (left.liquidity?.usd ?? 0))[0];
export async function GET(
_request: Request,
context: { params: Promise<{ pairId: string }> },
) {
const { pairId } = await context.params;
if (!PAIR_ID_RE.test(pairId)) {
return NextResponse.json({ error: "Invalid pair id." }, { status: 400 });
}
try {
const requestOptions = {
headers: {
accept: "application/json",
},
cache: "no-store" as const,
};
const pairUpstream = await fetch(
`https://api.dexscreener.com/latest/dex/pairs/solana/${pairId}`,
requestOptions,
);
let pair: DexScreenerPair | undefined;
if (pairUpstream.ok) {
const payload = (await pairUpstream.json()) as {
pairs?: DexScreenerPair[];
};
pair = getBestPair(payload.pairs ?? []);
}
if (!pair) {
const tokenUpstream = await fetch(
`https://api.dexscreener.com/tokens/v1/solana/${pairId}`,
requestOptions,
);
if (!tokenUpstream.ok) {
throw new Error(`DexScreener request failed with ${tokenUpstream.status}.`);
}
const tokenPayload = (await tokenUpstream.json()) as DexScreenerPair[];
pair = getBestPair(tokenPayload);
}
if (!pair) {
return NextResponse.json({ error: "Pair data was not available." }, { status: 404 });
}
return NextResponse.json({
pair: toResponsePair(pair),
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Unable to load the requested pair.",
},
{ status: 502 },
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
const MINT_RE = /^[1-9A-HJ-NP-Za-km-z]{32,64}$/;
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const inputMint = searchParams.get("inputMint")?.trim() ?? "";
const outputMint = searchParams.get("outputMint")?.trim() ?? "";
const amount = searchParams.get("amount")?.trim() ?? "";
const slippageBps = Number(searchParams.get("slippageBps") ?? "150");
if (!MINT_RE.test(inputMint) || !MINT_RE.test(outputMint)) {
return NextResponse.json({ error: "Invalid token mint." }, { status: 400 });
}
if (!/^\d+$/.test(amount) || Number(amount) <= 0) {
return NextResponse.json({ error: "Invalid swap amount." }, { status: 400 });
}
if (!Number.isFinite(slippageBps) || slippageBps < 10 || slippageBps > 5_000) {
return NextResponse.json({ error: "Slippage must be between 10 and 5000 bps." }, { status: 400 });
}
try {
const query = new URLSearchParams({
inputMint,
outputMint,
amount,
slippageBps: String(slippageBps),
restrictIntermediateTokens: "true",
});
const upstream = await fetch(`https://api.jup.ag/swap/v1/quote?${query.toString()}`, {
headers: {
accept: "application/json",
},
cache: "no-store",
});
const payload = await upstream.json();
if (!upstream.ok) {
throw new Error(
typeof payload?.error === "string"
? payload.error
: "Quote request failed.",
);
}
return NextResponse.json({ quote: payload });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Unable to request a Jupiter quote.",
},
{ status: 502 },
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
const PUBLIC_KEY_RE = /^[1-9A-HJ-NP-Za-km-z]{32,64}$/;
export async function POST(request: Request) {
try {
const payload = (await request.json()) as {
quoteResponse?: unknown;
userPublicKey?: string;
};
const userPublicKey = payload.userPublicKey?.trim() ?? "";
if (!PUBLIC_KEY_RE.test(userPublicKey)) {
return NextResponse.json({ error: "Invalid wallet public key." }, { status: 400 });
}
if (!payload.quoteResponse || typeof payload.quoteResponse !== "object") {
return NextResponse.json({ error: "Missing quote payload." }, { status: 400 });
}
const upstream = await fetch("https://api.jup.ag/swap/v1/swap", {
method: "POST",
headers: {
"Content-Type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
quoteResponse: payload.quoteResponse,
userPublicKey,
dynamicComputeUnitLimit: true,
dynamicSlippage: true,
prioritizationFeeLamports: {
priorityLevelWithMaxLamports: {
maxLamports: 1_000_000,
priorityLevel: "veryHigh",
},
},
}),
cache: "no-store",
});
const upstreamPayload = await upstream.json();
if (!upstream.ok || typeof upstreamPayload?.swapTransaction !== "string") {
throw new Error(
typeof upstreamPayload?.error === "string"
? upstreamPayload.error
: "Unable to build a swap transaction.",
);
}
return NextResponse.json({
swapTransaction: upstreamPayload.swapTransaction,
lastValidBlockHeight:
typeof upstreamPayload.lastValidBlockHeight === "number"
? upstreamPayload.lastValidBlockHeight
: null,
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Unable to prepare the Solana swap transaction.",
},
{ status: 502 },
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,724 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CRYPTO_ROOM_AGENT_LOOP_MS, CRYPTO_ROOM_APPROVAL_TTL_MS, CRYPTO_ROOM_PAIR_ADDRESS, SOL_MINT } from "@/features/crypto/lib/constants";
import { buildCryptoReportSnapshot } from "@/features/crypto/lib/pnl";
import {
deserializeSwapTransaction,
enrichHoldingsMetadata,
fetchTokenDecimals,
fetchWalletSnapshot,
getPhantomProvider,
getSolanaConnection,
getTrackedToken,
quotePreviewFromResponse,
rawAmountFromUi,
} from "@/features/crypto/lib/solana";
import {
buildInitialCryptoRoomState,
loadCryptoRoomState,
saveCryptoRoomState,
} from "@/features/crypto/lib/storage";
import type {
CryptoAgentSetting,
CryptoApprovalRequest,
CryptoQuotePreview,
CryptoRoomSettings,
CryptoTradeRecord,
CryptoTradeSide,
CryptoTrackedPair,
CryptoWalletSnapshot,
} from "@/features/crypto/types";
import type { OfficeAgent } from "@/features/retro-office/core/types";
const buildId = () =>
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `crypto_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const emptyWallet: CryptoWalletSnapshot = {
publicKey: null,
connected: false,
solBalance: 0,
tokenHoldings: [],
trackedTokenBalance: 0,
trackedTokenDecimals: 9,
lastUpdatedAt: null,
};
const resolveApprovalSide = (params: {
pair: CryptoTrackedPair;
wallet: CryptoWalletSnapshot;
setting: CryptoAgentSetting;
}): CryptoTradeSide | null => {
const { pair, wallet, setting } = params;
const h1 = pair.priceChangePct.h1 ?? 0;
const h24 = pair.priceChangePct.h24 ?? 0;
if (h1 <= -6 || h24 <= -15) return "buy";
if (setting.allowSell && wallet.trackedTokenBalance > 0 && (h1 >= 10 || h24 >= 20)) {
return "sell";
}
return null;
};
const resolveApprovalAmount = (params: {
setting: CryptoAgentSetting;
wallet: CryptoWalletSnapshot;
side: CryptoTradeSide;
}): number => {
if (params.side === "buy") {
return Math.min(params.setting.maxTradeSol, Math.max(0.05, params.setting.maxTradeSol * 0.75));
}
return Number((params.wallet.trackedTokenBalance * 0.25).toFixed(4));
};
const buildAgentRationale = (params: {
pair: CryptoTrackedPair;
side: CryptoTradeSide;
amountUi: number;
setting: CryptoAgentSetting;
}) => {
const h1 = params.pair.priceChangePct.h1 ?? 0;
const h24 = params.pair.priceChangePct.h24 ?? 0;
if (params.side === "buy") {
return `${params.setting.agentName} spotted a ${Math.abs(h1).toFixed(1)}% 1H pullback and wants to scale into ${params.pair.baseToken.symbol} with ${params.amountUi.toFixed(3)} SOL.`;
}
return `${params.setting.agentName} wants to trim exposure after a ${Math.max(h1, h24).toFixed(1)}% breakout and de-risk 25% of the tracked bag.`;
};
export function useCryptoRoomState(agents: OfficeAgent[]) {
const initialState = useMemo(() => loadCryptoRoomState(agents), [agents]);
const [settings, setSettings] = useState<CryptoRoomSettings>(initialState.settings);
const [ledger, setLedger] = useState<CryptoTradeRecord[]>(initialState.ledger);
const [approvals, setApprovals] = useState<CryptoApprovalRequest[]>(initialState.approvals);
const [pair, setPair] = useState<CryptoTrackedPair | null>(null);
const [pairLoading, setPairLoading] = useState(true);
const [pairError, setPairError] = useState<string | null>(null);
const [trackedTokenDecimals, setTrackedTokenDecimals] = useState(9);
const [wallet, setWallet] = useState<CryptoWalletSnapshot>(emptyWallet);
const [walletError, setWalletError] = useState<string | null>(null);
const [walletLoading, setWalletLoading] = useState(false);
const [tradeSide, setTradeSide] = useState<CryptoTradeSide>("buy");
const [tradeAmountUi, setTradeAmountUi] = useState("0.10");
const [tradeSlippageBps, setTradeSlippageBps] = useState(settings.defaultSlippageBps);
const [quote, setQuote] = useState<CryptoQuotePreview | null>(null);
const [quoteLoading, setQuoteLoading] = useState(false);
const [quoteError, setQuoteError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [swapError, setSwapError] = useState<string | null>(null);
const [selectedApprovalId, setSelectedApprovalId] = useState<string | null>(null);
useEffect(() => {
setTradeSlippageBps(settings.defaultSlippageBps);
}, [settings.defaultSlippageBps]);
useEffect(() => {
saveCryptoRoomState({
version: 1,
settings,
ledger,
approvals,
});
}, [approvals, ledger, settings]);
useEffect(() => {
let cancelled = false;
const loadPair = async () => {
setPairLoading(true);
setPairError(null);
try {
const response = await fetch(`/api/crypto/pair/${settings.pairAddress || CRYPTO_ROOM_PAIR_ADDRESS}`, {
cache: "no-store",
});
const payload = (await response.json()) as { pair?: CryptoTrackedPair; error?: string };
if (!response.ok || !payload.pair) {
throw new Error(payload.error?.trim() || "Unable to load Solana market data.");
}
if (cancelled) return;
setPair(payload.pair);
} catch (error) {
if (cancelled) return;
setPair(null);
setPairError(error instanceof Error ? error.message : "Unable to load Solana market data.");
} finally {
if (!cancelled) setPairLoading(false);
}
};
void loadPair();
return () => {
cancelled = true;
};
}, [settings.pairAddress]);
useEffect(() => {
if (!pair) return;
let cancelled = false;
const trackedToken = getTrackedToken(pair);
void fetchTokenDecimals(trackedToken.address)
.then((decimals) => {
if (!cancelled) {
setTrackedTokenDecimals(decimals);
setWallet((current) => ({
...current,
trackedTokenDecimals: decimals,
}));
}
})
.catch(() => {
if (!cancelled) {
setTrackedTokenDecimals(9);
}
});
return () => {
cancelled = true;
};
}, [pair]);
const refreshWallet = useCallback(async () => {
const publicKey = wallet.publicKey;
if (!publicKey) return;
const trackedMint = pair ? getTrackedToken(pair).address : undefined;
try {
setWalletLoading(true);
const snapshot = await fetchWalletSnapshot({
publicKey,
trackedTokenMint: trackedMint,
trackedTokenDecimals,
});
snapshot.tokenHoldings = await enrichHoldingsMetadata(snapshot.tokenHoldings);
setWallet(snapshot);
setWalletError(null);
} catch (error) {
setWalletError(
error instanceof Error ? error.message : "Unable to refresh the connected wallet.",
);
} finally {
setWalletLoading(false);
}
}, [pair, trackedTokenDecimals, wallet.publicKey]);
const connectWallet = useCallback(async () => {
const provider = getPhantomProvider();
if (!provider) {
setWalletError("Phantom was not detected in this browser.");
return;
}
try {
const connected = await provider.connect();
const publicKey = connected.publicKey.toBase58();
setWallet({
...emptyWallet,
connected: true,
publicKey,
});
setWalletError(null);
setWalletLoading(true);
const trackedMint = pair ? getTrackedToken(pair).address : undefined;
const snapshot = await fetchWalletSnapshot({
publicKey,
trackedTokenMint: trackedMint,
trackedTokenDecimals,
});
snapshot.tokenHoldings = await enrichHoldingsMetadata(snapshot.tokenHoldings);
setWallet(snapshot);
setWalletError(null);
setWalletLoading(false);
} catch (error) {
setWalletError(error instanceof Error ? error.message : "Wallet connection was cancelled.");
setWalletLoading(false);
}
}, [pair, trackedTokenDecimals]);
const disconnectWallet = useCallback(async () => {
try {
await getPhantomProvider()?.disconnect();
} catch {
/* ignore */
}
setWallet(emptyWallet);
setQuote(null);
}, []);
const revokeWallet = useCallback(async () => {
const provider = getPhantomProvider();
try {
await provider?.disconnect();
} catch {
/* ignore */
}
setWallet(emptyWallet);
setQuote(null);
setWalletError(null);
}, []);
useEffect(() => {
if (!wallet.connected || !wallet.publicKey) return;
void refreshWallet();
}, [pair, refreshWallet, wallet.connected, wallet.publicKey]);
useEffect(() => {
const now = Date.now();
setApprovals((current) =>
current.map((approval) =>
approval.status === "pending" && approval.expiresAt < now
? { ...approval, status: "expired" }
: approval,
),
);
}, []);
const trackedToken = pair ? getTrackedToken(pair) : null;
const report = useMemo(
() =>
buildCryptoReportSnapshot({
trades: ledger,
approvals,
currentTokenPriceUsd: pair?.priceUsd ?? 0,
trackedTokenSymbol: trackedToken?.symbol ?? "TOKEN",
}),
[approvals, ledger, pair?.priceUsd, trackedToken?.symbol],
);
const requestQuote = useCallback(
async (params?: {
side?: CryptoTradeSide;
amountUi?: number;
slippageBps?: number;
}) => {
if (!pair || !trackedToken) {
setQuoteError("Pair metadata is still loading.");
return null;
}
const side = params?.side ?? tradeSide;
const amountUi = params?.amountUi ?? Number(tradeAmountUi);
const slippageBps = params?.slippageBps ?? tradeSlippageBps;
if (!Number.isFinite(amountUi) || amountUi <= 0) {
setQuoteError("Enter a trade size greater than zero.");
return null;
}
const inputMint = side === "buy" ? SOL_MINT : trackedToken.address;
const outputMint = side === "buy" ? trackedToken.address : SOL_MINT;
const inputDecimals = side === "buy" ? 9 : trackedTokenDecimals;
const outputDecimals = side === "buy" ? trackedTokenDecimals : 9;
setQuoteLoading(true);
setQuoteError(null);
try {
const query = new URLSearchParams({
inputMint,
outputMint,
amount: rawAmountFromUi(amountUi, inputDecimals),
slippageBps: String(slippageBps),
});
const response = await fetch(`/api/crypto/quote?${query.toString()}`, {
cache: "no-store",
});
const payload = (await response.json()) as {
quote?: {
inAmount: string;
outAmount: string;
priceImpactPct?: string;
routePlan?: Array<{
swapInfo?: {
label?: string;
};
}>;
};
error?: string;
};
if (!response.ok || !payload.quote) {
throw new Error(payload.error?.trim() || "Unable to load a Jupiter quote.");
}
const nextQuote = quotePreviewFromResponse({
response: payload.quote,
inputMint,
outputMint,
inputDecimals,
outputDecimals,
slippageBps,
});
setQuote(nextQuote);
return nextQuote;
} catch (error) {
const message =
error instanceof Error ? error.message : "Unable to load a Jupiter quote.";
setQuote(null);
setQuoteError(message);
return null;
} finally {
setQuoteLoading(false);
}
},
[pair, trackedToken, trackedTokenDecimals, tradeAmountUi, tradeSide, tradeSlippageBps],
);
const submitSwap = useCallback(async () => {
if (!quote || !pair || !trackedToken) {
setSwapError("Request a fresh quote before signing a trade.");
return;
}
if (!wallet.publicKey) {
setSwapError("Connect Phantom before signing a trade.");
return;
}
const provider = getPhantomProvider();
if (!provider) {
setSwapError("Phantom is not available in this browser.");
return;
}
const tradeAgeMs = Date.now() - quote.createdAt;
if (tradeAgeMs > 60_000) {
setSwapError("Quote expired. Refresh the quote before signing.");
return;
}
setSubmitting(true);
setSwapError(null);
try {
const response = await fetch("/api/crypto/swap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
quoteResponse: quote.raw,
userPublicKey: wallet.publicKey,
}),
});
const payload = (await response.json()) as {
swapTransaction?: string;
error?: string;
};
if (!response.ok || !payload.swapTransaction) {
throw new Error(payload.error?.trim() || "Unable to build the Jupiter swap transaction.");
}
const transaction = deserializeSwapTransaction(payload.swapTransaction);
const signed = await provider.signTransaction(transaction);
const connection = getSolanaConnection();
const signature = await connection.sendRawTransaction(signed.serialize(), {
skipPreflight: false,
maxRetries: 3,
});
await connection.confirmTransaction(signature, "confirmed");
const inputAmountUi = quote.inputAmountUi;
const outputAmountUi = quote.outputAmountUi;
const side = tradeSide;
const notionalUsd =
side === "buy"
? outputAmountUi * (pair.priceUsd ?? 0)
: inputAmountUi * (pair.priceUsd ?? 0);
const nextRecord: CryptoTradeRecord = {
id: buildId(),
source: selectedApprovalId ? "agent" : "user",
status: "confirmed",
agentId:
selectedApprovalId
? approvals.find((approval) => approval.id === selectedApprovalId)?.agentId ?? null
: null,
agentName:
selectedApprovalId
? approvals.find((approval) => approval.id === selectedApprovalId)?.agentName ?? null
: null,
pairAddress: pair.pairAddress,
tokenMint: trackedToken.address,
tokenSymbol: trackedToken.symbol,
side,
walletPublicKey: wallet.publicKey,
inputMint: quote.inputMint,
outputMint: quote.outputMint,
inputAmountUi,
outputAmountUi,
tokenDelta: side === "buy" ? outputAmountUi : -inputAmountUi,
notionalUsd,
executionPriceUsd: pair.priceUsd,
slippageBps: quote.slippageBps,
quoteCreatedAt: quote.createdAt,
txSignature: signature,
error: null,
rationale:
selectedApprovalId
? approvals.find((approval) => approval.id === selectedApprovalId)?.rationale ?? null
: "Manual trade from the crypto room.",
createdAt: Date.now(),
submittedAt: Date.now(),
confirmedAt: Date.now(),
};
setLedger((current) => [nextRecord, ...current].slice(0, 250));
if (selectedApprovalId) {
setApprovals((current) =>
current.map((approval) =>
approval.id === selectedApprovalId
? { ...approval, status: "approved" }
: approval,
),
);
setSelectedApprovalId(null);
}
setQuote(null);
await refreshWallet();
} catch (error) {
const message =
error instanceof Error ? error.message : "The trade could not be completed.";
setSwapError(message);
const failedRecord: CryptoTradeRecord = {
id: buildId(),
source: selectedApprovalId ? "agent" : "user",
status: "failed",
agentId:
selectedApprovalId
? approvals.find((approval) => approval.id === selectedApprovalId)?.agentId ?? null
: null,
agentName:
selectedApprovalId
? approvals.find((approval) => approval.id === selectedApprovalId)?.agentName ?? null
: null,
pairAddress: pair?.pairAddress ?? CRYPTO_ROOM_PAIR_ADDRESS,
tokenMint: trackedToken?.address ?? "",
tokenSymbol: trackedToken?.symbol ?? "TOKEN",
side: tradeSide,
walletPublicKey: wallet.publicKey,
inputMint: quote?.inputMint ?? SOL_MINT,
outputMint: quote?.outputMint ?? "",
inputAmountUi: quote?.inputAmountUi ?? Number(tradeAmountUi),
outputAmountUi: quote?.outputAmountUi ?? 0,
tokenDelta: tradeSide === "buy" ? quote?.outputAmountUi ?? 0 : -(quote?.inputAmountUi ?? 0),
notionalUsd:
tradeSide === "buy"
? (quote?.outputAmountUi ?? 0) * (pair?.priceUsd ?? 0)
: (quote?.inputAmountUi ?? 0) * (pair?.priceUsd ?? 0),
executionPriceUsd: pair?.priceUsd ?? 0,
slippageBps: quote?.slippageBps ?? tradeSlippageBps,
quoteCreatedAt: quote?.createdAt ?? Date.now(),
txSignature: null,
error: message,
rationale:
selectedApprovalId
? approvals.find((approval) => approval.id === selectedApprovalId)?.rationale ?? null
: "Manual trade from the crypto room.",
createdAt: Date.now(),
submittedAt: null,
confirmedAt: null,
};
setLedger((current) => [
failedRecord,
...current,
].slice(0, 250));
} finally {
setSubmitting(false);
}
}, [
approvals,
pair,
quote,
refreshWallet,
selectedApprovalId,
trackedToken,
tradeAmountUi,
tradeSide,
tradeSlippageBps,
wallet.publicKey,
]);
const updateAgentSetting = useCallback(
(agentId: string, patch: Partial<CryptoAgentSetting>) => {
setSettings((current) => ({
...current,
agentSettings: current.agentSettings.map((entry) =>
entry.agentId === agentId ? { ...entry, ...patch } : entry,
),
}));
},
[],
);
const loadApprovalIntoTrade = useCallback(
(approvalId: string) => {
const approval = approvals.find((entry) => entry.id === approvalId);
if (!approval) return;
setTradeSide(approval.side);
setTradeAmountUi(approval.proposedInputAmountUi.toFixed(approval.side === "buy" ? 3 : 4));
setTradeSlippageBps(approval.slippageBps);
setSelectedApprovalId(approvalId);
setQuote(null);
setQuoteError(null);
setSwapError(null);
},
[approvals],
);
const rejectApproval = useCallback((approvalId: string) => {
setApprovals((current) =>
current.map((approval) =>
approval.id === approvalId ? { ...approval, status: "rejected" } : approval,
),
);
if (selectedApprovalId === approvalId) {
setSelectedApprovalId(null);
setQuote(null);
}
}, [selectedApprovalId]);
const runAgentCycle = useCallback(() => {
if (!pair || !trackedToken) return;
const now = Date.now();
const nextApprovals: CryptoApprovalRequest[] = [];
const nextLedgers: CryptoTradeRecord[] = [];
setSettings((current) => ({
...current,
agentSettings: current.agentSettings.map((setting) => {
const minutesSinceLastSignal =
setting.lastSignalAt ? (now - setting.lastSignalAt) / 60_000 : Number.POSITIVE_INFINITY;
if (minutesSinceLastSignal < setting.cooldownMinutes) {
return setting;
}
const mode = setting.mode;
const dailyLossHit = report.totalPnlUsd <= -Math.abs(setting.dailyLossLimitUsd);
if (dailyLossHit) {
return {
...setting,
lastSignalAt: now,
lastSignalSummary: "Paused after hitting the daily loss guard.",
};
}
const side = resolveApprovalSide({ pair, wallet, setting });
if (!side) {
return {
...setting,
lastSignalAt: now,
lastSignalSummary: "Holding fire while the pair is inside the risk band.",
};
}
const proposedInputAmountUi = resolveApprovalAmount({ setting, wallet, side });
if (!Number.isFinite(proposedInputAmountUi) || proposedInputAmountUi <= 0) {
return {
...setting,
lastSignalAt: now,
lastSignalSummary: "Skipped because there was no safe size available.",
};
}
const rationale = buildAgentRationale({
pair,
side,
amountUi: proposedInputAmountUi,
setting,
});
if (mode === "suggest_only" || !wallet.connected) {
nextLedgers.push({
id: buildId(),
source: "agent",
status: "draft",
agentId: setting.agentId,
agentName: setting.agentName,
pairAddress: pair.pairAddress,
tokenMint: trackedToken.address,
tokenSymbol: trackedToken.symbol,
side,
walletPublicKey: wallet.publicKey,
inputMint: side === "buy" ? SOL_MINT : trackedToken.address,
outputMint: side === "buy" ? trackedToken.address : SOL_MINT,
inputAmountUi: proposedInputAmountUi,
outputAmountUi: 0,
tokenDelta: 0,
notionalUsd: proposedInputAmountUi * (side === "buy" ? pair.priceUsd : pair.priceUsd),
executionPriceUsd: pair.priceUsd,
slippageBps: setting.slippageBps,
quoteCreatedAt: now,
txSignature: null,
error: null,
rationale,
createdAt: now,
submittedAt: null,
confirmedAt: null,
});
} else {
nextApprovals.push({
id: buildId(),
agentId: setting.agentId,
agentName: setting.agentName,
pairAddress: pair.pairAddress,
side,
maxTradeSol: setting.maxTradeSol,
slippageBps: setting.slippageBps,
rationale,
proposedInputAmountUi,
createdAt: now,
expiresAt: now + CRYPTO_ROOM_APPROVAL_TTL_MS,
status: "pending",
});
}
return {
...setting,
lastSignalAt: now,
lastSignalSummary: rationale,
};
}),
}));
if (nextApprovals.length > 0) {
setApprovals((current) => [...nextApprovals, ...current].slice(0, 100));
}
if (nextLedgers.length > 0) {
setLedger((current) => [...nextLedgers, ...current].slice(0, 250));
}
}, [pair, report.totalPnlUsd, trackedToken, wallet]);
useEffect(() => {
if (!settings.autoStrategyEnabled) return;
if (!settings.agentSettings.some((setting) => setting.mode === "auto_strategy")) return;
const intervalId = window.setInterval(() => {
runAgentCycle();
}, CRYPTO_ROOM_AGENT_LOOP_MS);
return () => {
window.clearInterval(intervalId);
};
}, [runAgentCycle, settings.agentSettings, settings.autoStrategyEnabled]);
const resetRoom = useCallback(() => {
const next = buildInitialCryptoRoomState(agents);
setSettings(next.settings);
setLedger(next.ledger);
setApprovals(next.approvals);
setQuote(null);
setSelectedApprovalId(null);
}, [agents]);
return {
pair,
pairLoading,
pairError,
trackedToken,
trackedTokenDecimals,
wallet,
walletLoading,
walletError,
connectWallet,
disconnectWallet,
revokeWallet,
refreshWallet,
tradeSide,
setTradeSide,
tradeAmountUi,
setTradeAmountUi,
tradeSlippageBps,
setTradeSlippageBps,
quote,
quoteLoading,
quoteError,
requestQuote,
submitting,
submitSwap,
swapError,
settings,
setSettings,
updateAgentSetting,
ledger,
approvals,
report,
loadApprovalIntoTrade,
rejectApproval,
runAgentCycle,
resetRoom,
selectedApprovalId,
};
}
+21
View File
@@ -0,0 +1,21 @@
export const CRYPTO_ROOM_PAIR_ADDRESS = "6rpwhgzzdvsbn1lty58h9krjfqsezkpnbeitngmyvsvc";
export const CRYPTO_ROOM_DEXSCREENER_URL =
`https://dexscreener.com/solana/${CRYPTO_ROOM_PAIR_ADDRESS}`;
export const SOL_MINT = "So11111111111111111111111111111111111111112";
export const SOL_DECIMALS = 9;
export const TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
export const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
export const SOLANA_MAINNET_RPC: string = process.env.NEXT_PUBLIC_SOLANA_RPC_URL ?? "";
export const SOLANA_RPC_ENDPOINTS: readonly string[] = [
...(SOLANA_MAINNET_RPC ? [SOLANA_MAINNET_RPC] : []),
"https://rpc.ankr.com/solana",
"https://solana-rpc.publicnode.com",
"https://api.mainnet-beta.solana.com",
];
export const CRYPTO_ROOM_STORAGE_KEY = "openclaw-crypto-room-v1";
export const CRYPTO_ROOM_APPROVAL_TTL_MS = 15 * 60 * 1000;
export const CRYPTO_ROOM_AGENT_LOOP_MS = 45 * 1000;
+93
View File
@@ -0,0 +1,93 @@
import type {
CryptoApprovalRequest,
CryptoReportSnapshot,
CryptoTradeRecord,
} from "@/features/crypto/types";
type SourceAggregate = {
id: string;
label: string;
realizedPnlUsd: number;
totalVolumeUsd: number;
tradeCount: number;
};
export const buildCryptoReportSnapshot = (params: {
trades: CryptoTradeRecord[];
approvals: CryptoApprovalRequest[];
currentTokenPriceUsd: number;
trackedTokenSymbol: string;
}): CryptoReportSnapshot => {
const confirmedTrades = params.trades.filter((trade) => trade.status === "confirmed");
let inventoryQty = 0;
let inventoryCostUsd = 0;
let realizedPnlUsd = 0;
let feesPaidUsd = 0;
let wins = 0;
let losses = 0;
let biggestWinnerUsd = 0;
let biggestLoserUsd = 0;
const sourceMap = new Map<string, SourceAggregate>();
const pushSourceAggregate = (trade: CryptoTradeRecord, realizedDeltaUsd: number) => {
const id = trade.agentId ?? trade.source;
const label = trade.agentName ?? (trade.source === "agent" ? "Agent" : "User");
const current = sourceMap.get(id) ?? {
id,
label,
realizedPnlUsd: 0,
totalVolumeUsd: 0,
tradeCount: 0,
};
current.realizedPnlUsd += realizedDeltaUsd;
current.totalVolumeUsd += trade.notionalUsd;
current.tradeCount += 1;
sourceMap.set(id, current);
};
for (const trade of confirmedTrades) {
const tokenQty = Math.abs(trade.tokenDelta);
const tradeFeeUsd = trade.notionalUsd * 0.003;
feesPaidUsd += tradeFeeUsd;
if (trade.tokenDelta > 0) {
inventoryQty += trade.tokenDelta;
inventoryCostUsd += trade.notionalUsd;
pushSourceAggregate(trade, 0);
continue;
}
const quantitySold = Math.min(inventoryQty, tokenQty);
const averageEntryUsd = inventoryQty > 0 ? inventoryCostUsd / inventoryQty : 0;
const realizedDeltaUsd = quantitySold * trade.executionPriceUsd - quantitySold * averageEntryUsd;
realizedPnlUsd += realizedDeltaUsd;
inventoryQty = Math.max(0, inventoryQty - quantitySold);
inventoryCostUsd = Math.max(0, inventoryCostUsd - quantitySold * averageEntryUsd);
if (realizedDeltaUsd > 0) wins += 1;
if (realizedDeltaUsd < 0) losses += 1;
biggestWinnerUsd = Math.max(biggestWinnerUsd, realizedDeltaUsd);
biggestLoserUsd = Math.min(biggestLoserUsd, realizedDeltaUsd);
pushSourceAggregate(trade, realizedDeltaUsd);
}
const openTokenValueUsd = inventoryQty * params.currentTokenPriceUsd;
const unrealizedPnlUsd = openTokenValueUsd - inventoryCostUsd;
const resolvedTrades = wins + losses;
return {
trackedTokenSymbol: params.trackedTokenSymbol,
tradeCount: params.trades.length,
confirmedTradeCount: confirmedTrades.length,
pendingApprovals: params.approvals.filter((approval) => approval.status === "pending").length,
totalVolumeUsd: params.trades.reduce((sum, trade) => sum + trade.notionalUsd, 0),
realizedPnlUsd,
unrealizedPnlUsd,
totalPnlUsd: realizedPnlUsd + unrealizedPnlUsd,
winRatePct: resolvedTrades > 0 ? (wins / resolvedTrades) * 100 : 0,
feesPaidUsd,
openTokenQuantity: inventoryQty,
averageEntryUsd: inventoryQty > 0 ? inventoryCostUsd / inventoryQty : 0,
biggestWinnerUsd,
biggestLoserUsd,
bySource: [...sourceMap.values()].sort((left, right) => right.totalVolumeUsd - left.totalVolumeUsd),
};
};
+314
View File
@@ -0,0 +1,314 @@
import {
Connection,
LAMPORTS_PER_SOL,
PublicKey,
VersionedTransaction,
} from "@solana/web3.js";
import {
SOLANA_RPC_ENDPOINTS,
SOL_DECIMALS,
SOL_MINT,
TOKEN_2022_PROGRAM_ID,
TOKEN_PROGRAM_ID,
} from "@/features/crypto/lib/constants";
import type { CryptoQuotePreview, CryptoTokenHolding, CryptoTrackedPair, CryptoWalletSnapshot } from "@/features/crypto/types";
type PhantomProvider = {
isPhantom?: boolean;
publicKey?: PublicKey;
connect: (options?: { onlyIfTrusted?: boolean }) => Promise<{ publicKey: PublicKey }>;
disconnect: () => Promise<void>;
signTransaction: (
transaction: VersionedTransaction,
) => Promise<VersionedTransaction>;
};
declare global {
interface Window {
phantom?: {
solana?: PhantomProvider;
};
solana?: PhantomProvider;
}
}
let activeRpcIndex = 0;
export const getSolanaConnection = () =>
new Connection(SOLANA_RPC_ENDPOINTS[activeRpcIndex] ?? SOLANA_RPC_ENDPOINTS[0], "confirmed");
const advanceRpc = () => {
activeRpcIndex = (activeRpcIndex + 1) % SOLANA_RPC_ENDPOINTS.length;
};
const isForbiddenError = (error: unknown): boolean => {
const msg = error instanceof Error ? error.message : String(error);
return msg.includes("403") || msg.includes("Access forbidden");
};
const withRpcFallback = async <T>(fn: (connection: Connection) => Promise<T>): Promise<T> => {
const attempts = SOLANA_RPC_ENDPOINTS.length;
for (let i = 0; i < attempts; i++) {
try {
return await fn(getSolanaConnection());
} catch (error) {
if (isForbiddenError(error) && i < attempts - 1) {
advanceRpc();
continue;
}
throw error;
}
}
throw new Error("All Solana RPC endpoints returned errors.");
};
export const getPhantomProvider = (): PhantomProvider | null => {
if (typeof window === "undefined") return null;
const provider = window.phantom?.solana ?? window.solana ?? null;
if (!provider?.isPhantom) return null;
return provider;
};
export const fetchTokenDecimals = async (mint: string): Promise<number> => {
if (mint === SOL_MINT) return SOL_DECIMALS;
return withRpcFallback(async (connection) => {
const accountInfo = await connection.getParsedAccountInfo(new PublicKey(mint));
const parsed = accountInfo.value?.data;
if (
parsed &&
typeof parsed === "object" &&
"parsed" in parsed &&
typeof parsed.parsed === "object" &&
parsed.parsed &&
"info" in parsed.parsed &&
typeof parsed.parsed.info === "object" &&
parsed.parsed.info &&
"decimals" in parsed.parsed.info &&
typeof parsed.parsed.info.decimals === "number"
) {
return parsed.parsed.info.decimals;
}
return 9;
});
};
export const fetchWalletSnapshot = async (params: {
publicKey: string;
trackedTokenMint?: string;
trackedTokenDecimals?: number;
}): Promise<CryptoWalletSnapshot> => {
const owner = new PublicKey(params.publicKey);
const [solBalance, tokenAccountsLegacy, tokenAccounts2022] = await withRpcFallback(
async (connection) =>
Promise.all([
connection.getBalance(owner),
connection.getParsedTokenAccountsByOwner(owner, {
programId: new PublicKey(TOKEN_PROGRAM_ID),
}),
connection.getParsedTokenAccountsByOwner(owner, {
programId: new PublicKey(TOKEN_2022_PROGRAM_ID),
}),
]),
);
const tokenAccounts = [...tokenAccountsLegacy.value, ...tokenAccounts2022.value];
const holdingsMap = new Map<string, CryptoTokenHolding>();
for (const entry of tokenAccounts) {
const parsed = entry.account.data.parsed;
const info =
parsed && typeof parsed === "object" && "info" in parsed
? (parsed.info as Record<string, unknown>)
: null;
const mint = typeof info?.mint === "string" ? info.mint : "";
if (!mint) continue;
const tokenAmount =
typeof info?.tokenAmount === "object" && info.tokenAmount
? (info.tokenAmount as Record<string, unknown>)
: null;
const uiAmountString =
typeof tokenAmount?.uiAmountString === "string"
? tokenAmount.uiAmountString
: "0";
const decimals =
typeof tokenAmount?.decimals === "number" ? tokenAmount.decimals : 9;
const balance = Number(uiAmountString || "0");
const existing = holdingsMap.get(mint);
if (existing) {
existing.balance += balance;
existing.uiAmountString = existing.balance.toString();
} else {
holdingsMap.set(mint, {
mint,
symbol: "",
name: "",
imageUrl: "",
balance,
decimals,
uiAmountString,
});
}
}
const tokenHoldings = Array.from(holdingsMap.values()).filter(
(h) => h.balance > 0,
);
let trackedTokenBalance = 0;
const trackedMint = params.trackedTokenMint;
if (trackedMint) {
const match = holdingsMap.get(trackedMint);
trackedTokenBalance = match?.balance ?? 0;
}
return {
publicKey: params.publicKey,
connected: true,
solBalance: solBalance / LAMPORTS_PER_SOL,
tokenHoldings,
trackedTokenBalance,
trackedTokenDecimals: params.trackedTokenDecimals ?? 9,
lastUpdatedAt: Date.now(),
};
};
type TokenMeta = { symbol: string; name: string; imageUrl: string };
const tokenMetadataCache = new Map<string, TokenMeta>();
let jupiterLoaded = false;
let jupiterLoadPromise: Promise<void> | null = null;
const loadJupiterTokenList = async () => {
if (jupiterLoaded) return;
if (jupiterLoadPromise) {
await jupiterLoadPromise;
return;
}
jupiterLoadPromise = (async () => {
try {
const response = await fetch("https://tokens.jup.ag/tokens", {
signal: AbortSignal.timeout(12000),
});
if (!response.ok) throw new Error("Token list fetch failed.");
const tokens = (await response.json()) as Array<{
address: string;
symbol?: string;
name?: string;
logoURI?: string;
}>;
for (const t of tokens) {
if (t.address && (t.symbol || t.name)) {
tokenMetadataCache.set(t.address, {
symbol: t.symbol ?? "",
name: t.name ?? "",
imageUrl: t.logoURI ?? "",
});
}
}
} catch {
// Jupiter unavailable; DexScreener fallback will handle unknowns.
}
jupiterLoaded = true;
})();
await jupiterLoadPromise;
};
const fetchDexScreenerTokenMeta = async (mints: string[]) => {
const batchSize = 30;
for (let i = 0; i < mints.length; i += batchSize) {
const batch = mints.slice(i, i + batchSize).join(",");
try {
const response = await fetch(
`https://api.dexscreener.com/tokens/v1/solana/${batch}`,
{ signal: AbortSignal.timeout(8000) },
);
if (!response.ok) continue;
const pairs = (await response.json()) as Array<{
baseToken?: { address?: string; symbol?: string; name?: string };
quoteToken?: { address?: string; symbol?: string; name?: string };
info?: { imageUrl?: string };
}>;
for (const p of pairs) {
for (const tok of [p.baseToken, p.quoteToken]) {
if (tok?.address && (tok.symbol || tok.name) && !tokenMetadataCache.has(tok.address)) {
tokenMetadataCache.set(tok.address, {
symbol: tok.symbol ?? "",
name: tok.name ?? "",
imageUrl: p.info?.imageUrl ?? "",
});
}
}
}
} catch {
// Silently skip failed batch.
}
}
};
export const enrichHoldingsMetadata = async (
holdings: CryptoTokenHolding[],
): Promise<CryptoTokenHolding[]> => {
await loadJupiterTokenList();
const unknownMints = holdings
.filter((h) => !tokenMetadataCache.has(h.mint))
.map((h) => h.mint);
if (unknownMints.length > 0) {
await fetchDexScreenerTokenMeta(unknownMints);
}
return holdings.map((h) => {
const meta = tokenMetadataCache.get(h.mint);
if (meta) {
return {
...h,
symbol: meta.symbol || h.symbol,
name: meta.name || h.name,
imageUrl: meta.imageUrl || h.imageUrl,
};
}
return h;
});
};
export const rawAmountFromUi = (amountUi: number, decimals: number): string => {
const normalized = Number.isFinite(amountUi) ? Math.max(0, amountUi) : 0;
return Math.round(normalized * 10 ** decimals).toString();
};
export const quotePreviewFromResponse = (params: {
response: {
inAmount: string;
outAmount: string;
priceImpactPct?: string;
routePlan?: Array<{
swapInfo?: {
label?: string;
};
}>;
};
inputMint: string;
outputMint: string;
inputDecimals: number;
outputDecimals: number;
slippageBps: number;
}): CryptoQuotePreview => ({
inputMint: params.inputMint,
outputMint: params.outputMint,
inAmountRaw: params.response.inAmount,
outAmountRaw: params.response.outAmount,
inputAmountUi: Number(params.response.inAmount) / 10 ** params.inputDecimals,
outputAmountUi: Number(params.response.outAmount) / 10 ** params.outputDecimals,
priceImpactPct: Number(params.response.priceImpactPct ?? "0"),
slippageBps: params.slippageBps,
routeLabel:
params.response.routePlan?.[0]?.swapInfo?.label?.trim() || "Jupiter route",
raw: params.response,
createdAt: Date.now(),
});
export const deserializeSwapTransaction = (base64Transaction: string) =>
VersionedTransaction.deserialize(
Uint8Array.from(atob(base64Transaction), (char) => char.charCodeAt(0)),
);
export const getTrackedToken = (pair: CryptoTrackedPair) =>
pair.baseToken.address === SOL_MINT ? pair.quoteToken : pair.baseToken;
+111
View File
@@ -0,0 +1,111 @@
import type {
CryptoApprovalRequest,
CryptoRoomSettings,
CryptoTradeRecord,
} from "@/features/crypto/types";
import { CRYPTO_ROOM_PAIR_ADDRESS, CRYPTO_ROOM_STORAGE_KEY } from "@/features/crypto/lib/constants";
import type { OfficeAgent } from "@/features/retro-office/core/types";
type CryptoRoomPersistedState = {
version: 1;
settings: CryptoRoomSettings;
ledger: CryptoTradeRecord[];
approvals: CryptoApprovalRequest[];
};
const buildDefaultSettings = (agents: OfficeAgent[]): CryptoRoomSettings => ({
pairAddress: CRYPTO_ROOM_PAIR_ADDRESS,
defaultSlippageBps: 150,
maxDailyLossUsd: 250,
autoStrategyEnabled: false,
reportCurrency: "USD",
agentSettings: agents.map((agent, index) => ({
agentId: agent.id,
agentName: agent.name,
mode: index === 0 ? "prepare_for_approval" : "suggest_only",
maxTradeSol: 0.2,
slippageBps: 150,
cooldownMinutes: 30,
dailyLossLimitUsd: 50,
allowSell: true,
lastSignalAt: null,
lastSignalSummary: null,
})),
});
export const buildInitialCryptoRoomState = (agents: OfficeAgent[]): CryptoRoomPersistedState => ({
version: 1,
settings: buildDefaultSettings(agents),
ledger: [],
approvals: [],
});
const mergeSettings = (
current: CryptoRoomSettings,
agents: OfficeAgent[],
): CryptoRoomSettings => {
const byAgentId = new Map(current.agentSettings.map((entry) => [entry.agentId, entry]));
return {
...current,
pairAddress: current.pairAddress || CRYPTO_ROOM_PAIR_ADDRESS,
reportCurrency: "USD",
agentSettings: agents.map((agent, index) => {
const existing = byAgentId.get(agent.id);
if (existing) {
return {
...existing,
agentName: agent.name,
};
}
return {
agentId: agent.id,
agentName: agent.name,
mode: index === 0 ? "prepare_for_approval" : "suggest_only",
maxTradeSol: 0.2,
slippageBps: current.defaultSlippageBps || 150,
cooldownMinutes: 30,
dailyLossLimitUsd: 50,
allowSell: true,
lastSignalAt: null,
lastSignalSummary: null,
};
}),
};
};
export const loadCryptoRoomState = (agents: OfficeAgent[]): CryptoRoomPersistedState => {
if (typeof window === "undefined") {
return buildInitialCryptoRoomState(agents);
}
try {
const raw = window.localStorage.getItem(CRYPTO_ROOM_STORAGE_KEY);
if (!raw) return buildInitialCryptoRoomState(agents);
const parsed = JSON.parse(raw) as Partial<CryptoRoomPersistedState> | null;
if (!parsed || typeof parsed !== "object") {
return buildInitialCryptoRoomState(agents);
}
const fallback = buildInitialCryptoRoomState(agents);
return {
version: 1,
settings: mergeSettings(
parsed.settings && typeof parsed.settings === "object"
? { ...fallback.settings, ...parsed.settings }
: fallback.settings,
agents,
),
ledger: Array.isArray(parsed.ledger) ? parsed.ledger : [],
approvals: Array.isArray(parsed.approvals) ? parsed.approvals : [],
};
} catch {
return buildInitialCryptoRoomState(agents);
}
};
export const saveCryptoRoomState = (state: CryptoRoomPersistedState) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(CRYPTO_ROOM_STORAGE_KEY, JSON.stringify(state));
} catch {
/* ignore */
}
};
+174
View File
@@ -0,0 +1,174 @@
export type CryptoTradeIntentSource = "user" | "agent";
export type CryptoAgentTradeMode =
| "suggest_only"
| "prepare_for_approval"
| "auto_strategy";
export type CryptoTradeSide = "buy" | "sell";
export type CryptoTradeStatus =
| "draft"
| "approval_pending"
| "ready_to_sign"
| "submitted"
| "confirmed"
| "rejected"
| "failed";
export type CryptoTrackedPair = {
pairAddress: string;
dexId: string | null;
url: string;
priceUsd: number;
priceNative: number | null;
fdv: number | null;
marketCap: number | null;
liquidityUsd: number | null;
volume24hUsd: number | null;
buys24h: number | null;
sells24h: number | null;
pairLabel: string;
chainId: string;
quoteToken: {
address: string;
symbol: string;
name: string;
};
baseToken: {
address: string;
symbol: string;
name: string;
};
priceChangePct: {
m5: number | null;
h1: number | null;
h6: number | null;
h24: number | null;
};
imageUrl: string | null;
loadedAt: number;
};
export type CryptoQuotePreview = {
inputMint: string;
outputMint: string;
inAmountRaw: string;
outAmountRaw: string;
inputAmountUi: number;
outputAmountUi: number;
priceImpactPct: number;
slippageBps: number;
routeLabel: string;
raw: unknown;
createdAt: number;
};
export type CryptoTokenHolding = {
mint: string;
symbol: string;
name: string;
imageUrl: string;
balance: number;
decimals: number;
uiAmountString: string;
};
export type CryptoWalletSnapshot = {
publicKey: string | null;
connected: boolean;
solBalance: number;
tokenHoldings: CryptoTokenHolding[];
trackedTokenBalance: number;
trackedTokenDecimals: number;
lastUpdatedAt: number | null;
};
export type CryptoTradeRecord = {
id: string;
source: CryptoTradeIntentSource;
status: CryptoTradeStatus;
agentId: string | null;
agentName: string | null;
pairAddress: string;
tokenMint: string;
tokenSymbol: string;
side: CryptoTradeSide;
walletPublicKey: string | null;
inputMint: string;
outputMint: string;
inputAmountUi: number;
outputAmountUi: number;
tokenDelta: number;
notionalUsd: number;
executionPriceUsd: number;
slippageBps: number;
quoteCreatedAt: number;
txSignature: string | null;
error: string | null;
rationale: string | null;
createdAt: number;
submittedAt: number | null;
confirmedAt: number | null;
};
export type CryptoApprovalRequest = {
id: string;
agentId: string;
agentName: string;
pairAddress: string;
side: CryptoTradeSide;
maxTradeSol: number;
slippageBps: number;
rationale: string;
proposedInputAmountUi: number;
createdAt: number;
expiresAt: number;
status: "pending" | "approved" | "rejected" | "expired";
};
export type CryptoAgentSetting = {
agentId: string;
agentName: string;
mode: CryptoAgentTradeMode;
maxTradeSol: number;
slippageBps: number;
cooldownMinutes: number;
dailyLossLimitUsd: number;
allowSell: boolean;
lastSignalAt: number | null;
lastSignalSummary: string | null;
};
export type CryptoRoomSettings = {
pairAddress: string;
defaultSlippageBps: number;
maxDailyLossUsd: number;
autoStrategyEnabled: boolean;
reportCurrency: "USD";
agentSettings: CryptoAgentSetting[];
};
export type CryptoReportSnapshot = {
trackedTokenSymbol: string;
tradeCount: number;
confirmedTradeCount: number;
pendingApprovals: number;
totalVolumeUsd: number;
realizedPnlUsd: number;
unrealizedPnlUsd: number;
totalPnlUsd: number;
winRatePct: number;
feesPaidUsd: number;
openTokenQuantity: number;
averageEntryUsd: number;
biggestWinnerUsd: number;
biggestLoserUsd: number;
bySource: Array<{
id: string;
label: string;
realizedPnlUsd: number;
totalVolumeUsd: number;
tradeCount: number;
}>;
};
+219 -5
View File
@@ -24,6 +24,7 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { Environment, OrbitControls } from "@react-three/drei";
import * as THREE from "three";
import { SettingsPanel } from "@/features/office/components/panels/SettingsPanel";
import { CryptoImmersiveScreen } from "@/features/crypto/components/CryptoImmersiveScreen";
import { AtmImmersiveScreen } from "@/features/office/screens/AtmImmersiveScreen";
import { GithubImmersiveScreen } from "@/features/office/screens/GithubImmersiveScreen";
import {
@@ -68,6 +69,7 @@ import {
} from "@/features/retro-office/core/constants";
import {
ensureOfficeAtm,
ensureOfficeCryptoRoom,
ensureOfficeGymRoom,
ensureOfficePhoneBooth,
ensureOfficePingPongTable,
@@ -115,6 +117,7 @@ import {
import {
loadFurniture,
markAtmMigrationApplied,
markCryptoRoomMigrationApplied,
markGymRoomMigrationApplied,
markPhoneBoothMigrationApplied,
markQaLabMigrationApplied,
@@ -147,6 +150,8 @@ import {
} from "@/features/retro-office/objects/kitchen";
import {
AtmMachineModel as InteractiveAtmMachineModel,
CryptoBoardModel as InteractiveCryptoBoardModel,
CryptoTerminalModel as InteractiveCryptoTerminalModel,
DeviceRackModel as InteractiveDeviceRackModel,
DumbbellRackModel as InteractiveDumbbellRackModel,
ExerciseBikeModel as InteractiveExerciseBikeModel,
@@ -1999,14 +2004,16 @@ export function RetroOffice3D({
ensureOfficeServerRoom(
ensureOfficePhoneBooth(
ensureOfficeSmsBooth(
ensureOfficeAtm(
ensureOfficePingPongTable(
(loadFurniture() ?? materializeDefaults()).filter(
(item) => !isRetiredPingPongLamp(item),
ensureOfficeAtm(
ensureOfficeCryptoRoom(
ensureOfficePingPongTable(
(loadFurniture() ?? materializeDefaults()).filter(
(item) => !isRetiredPingPongLamp(item),
),
),
),
),
),
),
),
),
),
@@ -2098,6 +2105,7 @@ export function RetroOffice3D({
const followAgentIdRef = useRef<string | null>(null);
const prevMonitorAgentIdRef = useRef<string | null>(null);
const prevAtmUidRef = useRef<string | null>(null);
const prevCryptoViewRef = useRef<string | null>(null);
const prevSmsBoothViewRef = useRef<string | null>(null);
const prevPhoneBoothViewRef = useRef<string | null>(null);
const prevGithubViewRef = useRef<string | null>(null);
@@ -2105,6 +2113,8 @@ export function RetroOffice3D({
const [monitorImmersiveReady, setMonitorImmersiveReady] = useState(false);
const [activeAtmUid, setActiveAtmUid] = useState<string | null>(null);
const [atmImmersiveReady, setAtmImmersiveReady] = useState(false);
const [activeCryptoTerminalUid, setActiveCryptoTerminalUid] = useState<string | null>(null);
const [cryptoImmersiveReady, setCryptoImmersiveReady] = useState(false);
const [phoneBoothCommandArrived, setPhoneBoothCommandArrived] = useState(false);
const [phoneBoothImmersiveReady, setPhoneBoothImmersiveReady] = useState(false);
const [phoneBoothDoorOpen, setPhoneBoothDoorOpen] = useState(false);
@@ -2154,6 +2164,10 @@ export function RetroOffice3D({
markAtmMigrationApplied();
}, []);
useEffect(() => {
markCryptoRoomMigrationApplied();
}, []);
useEffect(() => {
markPhoneBoothMigrationApplied();
}, []);
@@ -2367,6 +2381,18 @@ export function RetroOffice3D({
[activeAtmUid, furniture],
);
const atmImmersive = Boolean(activeAtm && atmImmersiveReady);
const activeCryptoTerminal = useMemo(
() =>
activeCryptoTerminalUid
? (furniture.find(
(item) =>
item._uid === activeCryptoTerminalUid &&
(item.type === "crypto_board" || item.type === "crypto_terminal"),
) ?? null)
: null,
[activeCryptoTerminalUid, furniture],
);
const cryptoImmersive = Boolean(activeCryptoTerminal && cryptoImmersiveReady);
const activeSmsBooth = useMemo(
() => furniture.find((item) => item.type === "sms_booth") ?? null,
[furniture],
@@ -2469,6 +2495,7 @@ export function RetroOffice3D({
const immersiveOverlayActive =
monitorImmersive ||
atmImmersive ||
cryptoImmersive ||
smsBoothImmersive ||
phoneBoothImmersive ||
githubImmersive ||
@@ -2624,6 +2651,7 @@ export function RetroOffice3D({
!followAgentId &&
!monitorAgentId &&
!activeAtmUid &&
!activeCryptoTerminalUid &&
!activeGithubTerminalUid &&
!activeQaTerminalUid
) {
@@ -2631,6 +2659,7 @@ export function RetroOffice3D({
}
}, [
activeAtmUid,
activeCryptoTerminalUid,
activeGithubTerminalUid,
activeQaTerminalUid,
followAgentId,
@@ -2820,6 +2849,17 @@ export function RetroOffice3D({
}
}, [activeAtmUid, monitorAgentId]);
useEffect(() => {
if (monitorAgentId && activeCryptoTerminalUid) {
const timer = window.setTimeout(() => {
setActiveCryptoTerminalUid(null);
}, 0);
return () => {
window.clearTimeout(timer);
};
}
}, [activeCryptoTerminalUid, monitorAgentId]);
useEffect(() => {
if (monitorAgentId && activeGithubTerminalUid) {
const timer = window.setTimeout(() => {
@@ -2853,6 +2893,17 @@ export function RetroOffice3D({
}
}, [activeAtmUid, activeGithubTerminalUid]);
useEffect(() => {
if (activeAtmUid && activeCryptoTerminalUid) {
const timer = window.setTimeout(() => {
setActiveCryptoTerminalUid(null);
}, 0);
return () => {
window.clearTimeout(timer);
};
}
}, [activeAtmUid, activeCryptoTerminalUid]);
useEffect(() => {
if (activeAtmUid && activeQaTerminalUid) {
const timer = window.setTimeout(() => {
@@ -2864,12 +2915,37 @@ export function RetroOffice3D({
}
}, [activeAtmUid, activeQaTerminalUid]);
useEffect(() => {
if (activeGithubTerminalUid && activeCryptoTerminalUid) {
const timer = window.setTimeout(() => {
setActiveCryptoTerminalUid(null);
}, 0);
return () => {
window.clearTimeout(timer);
};
}
}, [activeCryptoTerminalUid, activeGithubTerminalUid]);
useEffect(() => {
if (activeQaTerminalUid && activeCryptoTerminalUid) {
const timer = window.setTimeout(() => {
setActiveCryptoTerminalUid(null);
}, 0);
return () => {
window.clearTimeout(timer);
};
}
}, [activeCryptoTerminalUid, activeQaTerminalUid]);
useEffect(() => {
if (!smsBoothAgentId) return;
const timer = window.setTimeout(() => {
if (activeAtmUid) {
setActiveAtmUid(null);
}
if (activeCryptoTerminalUid) {
setActiveCryptoTerminalUid(null);
}
if (activeGithubTerminalUid) {
setActiveGithubTerminalUid(null);
}
@@ -2885,6 +2961,7 @@ export function RetroOffice3D({
};
}, [
activeAtmUid,
activeCryptoTerminalUid,
activeGithubTerminalUid,
activeQaTerminalUid,
monitorAgentId,
@@ -2898,6 +2975,9 @@ export function RetroOffice3D({
if (activeAtmUid) {
setActiveAtmUid(null);
}
if (activeCryptoTerminalUid) {
setActiveCryptoTerminalUid(null);
}
if (activeGithubTerminalUid) {
setActiveGithubTerminalUid(null);
}
@@ -2913,6 +2993,7 @@ export function RetroOffice3D({
};
}, [
activeAtmUid,
activeCryptoTerminalUid,
activeGithubTerminalUid,
activeQaTerminalUid,
monitorAgentId,
@@ -3442,6 +3523,24 @@ export function RetroOffice3D({
};
}, [activeAtmUid]);
useEffect(() => {
const resetTimer = window.setTimeout(() => {
setCryptoImmersiveReady(false);
}, 0);
if (!activeCryptoTerminalUid) {
return () => {
window.clearTimeout(resetTimer);
};
}
const timer = window.setTimeout(() => {
setCryptoImmersiveReady(true);
}, 900);
return () => {
window.clearTimeout(resetTimer);
window.clearTimeout(timer);
};
}, [activeCryptoTerminalUid]);
useEffect(() => {
const resetTimer = window.setTimeout(() => {
setGithubImmersiveReady(false);
@@ -3558,6 +3657,17 @@ export function RetroOffice3D({
}
}, [activeAtm, activeAtmUid]);
useEffect(() => {
if (activeCryptoTerminalUid && !activeCryptoTerminal) {
const timer = window.setTimeout(() => {
setActiveCryptoTerminalUid(null);
}, 0);
return () => {
window.clearTimeout(timer);
};
}
}, [activeCryptoTerminal, activeCryptoTerminalUid]);
useEffect(() => {
if (activeGithubTerminalUid && !activeGithubTerminal) {
const timer = window.setTimeout(() => {
@@ -3605,6 +3715,31 @@ export function RetroOffice3D({
prevAtmUidRef.current = activeAtmUid;
}, [activeAtm, activeAtmUid]);
useEffect(() => {
if (!activeCryptoTerminalUid && prevCryptoViewRef.current) {
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
}
if (!activeCryptoTerminalUid || !activeCryptoTerminal) {
prevCryptoViewRef.current = activeCryptoTerminalUid;
return;
}
const { width, height } = getItemBaseSize(activeCryptoTerminal);
const [wx, , wz] = toWorld(
activeCryptoTerminal.x + width / 2,
activeCryptoTerminal.y + height / 2,
);
const frontVector = new THREE.Vector3(0, 0, 1).applyAxisAngle(
new THREE.Vector3(0, 1, 0),
getItemRotationRadians(activeCryptoTerminal),
);
cameraPresetRef.current = {
pos: [wx + frontVector.x * 0.88, 1.24, wz + frontVector.z * 0.88],
target: [wx + frontVector.x * 0.03, 1.08, wz + frontVector.z * 0.03],
zoom: 215,
};
prevCryptoViewRef.current = activeCryptoTerminalUid;
}, [activeCryptoTerminal, activeCryptoTerminalUid]);
useEffect(() => {
const activeViewKey = activeGithubTerminalUid
? `manual:${activeGithubTerminalUid}`
@@ -3724,6 +3859,7 @@ export function RetroOffice3D({
);
setFollowAgentId(null);
setActiveAtmUid(null);
setActiveCryptoTerminalUid(null);
onMonitorSelect?.(null);
cameraPresetRef.current = {
pos: [tableWx + 2.4, 2.8, tableWz + 2.1],
@@ -3801,15 +3937,26 @@ export function RetroOffice3D({
}
if (item.type === "atm") {
setFollowAgentId(null);
setActiveCryptoTerminalUid(null);
setActiveGithubTerminalUid(null);
setActiveQaTerminalUid(null);
onMonitorSelect?.(null);
setActiveAtmUid(uid);
return;
}
if (item.type === "crypto_board" || item.type === "crypto_terminal") {
setFollowAgentId(null);
setActiveAtmUid(null);
setActiveGithubTerminalUid(null);
setActiveQaTerminalUid(null);
onMonitorSelect?.(null);
setActiveCryptoTerminalUid(uid);
return;
}
if (item.type === "sms_booth") {
setFollowAgentId(null);
setActiveAtmUid(null);
setActiveCryptoTerminalUid(null);
setActiveGithubTerminalUid(null);
setActiveQaTerminalUid(null);
onMonitorSelect?.(null);
@@ -3827,6 +3974,7 @@ export function RetroOffice3D({
if (item.type === "phone_booth") {
setFollowAgentId(null);
setActiveAtmUid(null);
setActiveCryptoTerminalUid(null);
setActiveGithubTerminalUid(null);
setActiveQaTerminalUid(null);
onMonitorSelect?.(null);
@@ -3848,6 +3996,7 @@ export function RetroOffice3D({
if (item.type === "server_terminal") {
setFollowAgentId(null);
setActiveAtmUid(null);
setActiveCryptoTerminalUid(null);
setActiveQaTerminalUid(null);
onMonitorSelect?.(null);
setActiveGithubTerminalUid(uid);
@@ -3856,6 +4005,7 @@ export function RetroOffice3D({
if (item.type === "server_rack") {
setFollowAgentId(null);
setActiveAtmUid(null);
setActiveCryptoTerminalUid(null);
setActiveQaTerminalUid(null);
onMonitorSelect?.(null);
setActiveGithubTerminalUid(serverTerminal?._uid ?? uid);
@@ -3868,6 +4018,7 @@ export function RetroOffice3D({
) {
setFollowAgentId(null);
setActiveAtmUid(null);
setActiveCryptoTerminalUid(null);
setActiveGithubTerminalUid(null);
onMonitorSelect?.(null);
setActiveQaTerminalUid(
@@ -3891,6 +4042,7 @@ export function RetroOffice3D({
setActiveGithubTerminalUid(null);
setActiveQaTerminalUid(null);
setActiveAtmUid(null);
setActiveCryptoTerminalUid(null);
onMonitorSelect?.(agentId);
return;
}
@@ -3898,6 +4050,7 @@ export function RetroOffice3D({
setActiveGithubTerminalUid(null);
setActiveQaTerminalUid(null);
setActiveAtmUid(null);
setActiveCryptoTerminalUid(null);
const agent = renderAgentLookupRef.current.get(agentId);
if (!agent) return;
const tx = item.x + 40;
@@ -3969,6 +4122,8 @@ export function RetroOffice3D({
document.body.style.cursor =
hoveredItem?.type === "pingpong" ||
hoveredItem?.type === "atm" ||
hoveredItem?.type === "crypto_board" ||
hoveredItem?.type === "crypto_terminal" ||
hoveredItem?.type === "sms_booth" ||
hoveredItem?.type === "phone_booth" ||
hoveredItem?.type === "server_rack" ||
@@ -4184,6 +4339,11 @@ export function RetroOffice3D({
setActiveAtmUid(null);
return;
}
if (cryptoImmersive) {
e.preventDefault();
setActiveCryptoTerminalUid(null);
return;
}
}
if (!editMode) return;
if (e.key === "Escape") {
@@ -4242,6 +4402,7 @@ export function RetroOffice3D({
activeQaTerminalUid,
activeGithubTerminalUid,
atmImmersive,
cryptoImmersive,
editMode,
drag,
githubImmersive,
@@ -4252,6 +4413,7 @@ export function RetroOffice3D({
onMonitorSelect,
onQaLabDismiss,
qaImmersive,
setActiveCryptoTerminalUid,
rotateSelectedItem,
selectedUid,
]);
@@ -4593,6 +4755,30 @@ export function RetroOffice3D({
onPointerOut={handleFurniturePointerOut}
onClick={handleDeskClick}
/>
) : item.type === "crypto_board" ? (
<InteractiveCryptoBoardModel
key={item._uid}
item={item}
isSelected={item._uid === selectedUid}
isHovered={item._uid === hoverUid}
editMode={editMode}
onPointerDown={handleFurniturePointerDown}
onPointerOver={handleFurniturePointerOver}
onPointerOut={handleFurniturePointerOut}
onClick={handleDeskClick}
/>
) : item.type === "crypto_terminal" ? (
<InteractiveCryptoTerminalModel
key={item._uid}
item={item}
isSelected={item._uid === selectedUid}
isHovered={item._uid === hoverUid}
editMode={editMode}
onPointerDown={handleFurniturePointerDown}
onPointerOver={handleFurniturePointerOver}
onPointerOut={handleFurniturePointerOut}
onClick={handleDeskClick}
/>
) : item.type === "sms_booth" ? (
<InteractiveSmsBoothModel
key={item._uid}
@@ -5433,6 +5619,34 @@ export function RetroOffice3D({
</div>
) : null}
{cryptoImmersive ? (
<div className="pointer-events-none absolute inset-0 z-20 overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.12),rgba(0,0,0,0.92))]" />
<div className="absolute inset-x-0 top-0 h-[9vh] bg-black" />
<div className="absolute inset-x-0 bottom-0 h-[12vh] bg-black" />
<div className="absolute inset-y-0 left-0 w-[5vw] bg-black" />
<div className="absolute inset-y-0 right-0 w-[5vw] bg-black" />
<div className="absolute inset-[4.8vh_4.8vw_7.2vh_4.8vw] rounded-[30px] border border-cyan-300/16 bg-[#020812] shadow-[0_0_0_18px_rgba(1,5,16,0.96),0_0_0_22px_rgba(34,211,238,0.12),0_30px_120px_rgba(0,0,0,0.78)]" />
<div className="absolute inset-[5.6vh_5.6vw_8vh_5.6vw] rounded-[24px] border border-cyan-300/12 bg-[#030c14]" />
<div className="pointer-events-auto absolute inset-[5.8vh_5.8vw_8.2vh_5.8vw] overflow-hidden rounded-[22px] bg-[#01060a]">
<CryptoImmersiveScreen agents={agents} />
</div>
<div className="pointer-events-auto absolute right-[5.8vw] top-[4.2vh] flex items-center gap-3 rounded-full border border-cyan-300/18 bg-[#04111b]/88 px-4 py-2 backdrop-blur-sm">
<div className="h-2 w-2 rounded-full bg-emerald-400" />
<div className="text-[11px] font-semibold uppercase tracking-[0.28em] text-cyan-100/90">
Crypto Room
</div>
<button
type="button"
onClick={() => setActiveCryptoTerminalUid(null)}
className="rounded-full border border-white/10 px-3 py-1 text-[11px] text-white/70 transition-colors hover:border-white/20 hover:text-white"
>
Exit
</button>
</div>
</div>
) : null}
{standupImmersive && standupMeeting ? (
<StandupImmersiveScreen
meeting={standupMeeting}
@@ -8,6 +8,7 @@ export const GYM_ROOM_MIGRATION_KEY = "openclaw-office-gym-room-migration-v3";
export const QA_LAB_MIGRATION_KEY = "openclaw-office-qa-lab-migration-v3";
export const PHONE_BOOTH_MIGRATION_KEY = "openclaw-office-phone-booth-migration-v1";
export const SMS_BOOTH_MIGRATION_KEY = "openclaw-office-sms-booth-migration-v1";
export const CRYPTO_ROOM_MIGRATION_KEY = "openclaw-office-crypto-room-migration-v2";
export const ROTATION_STEP_DEG = 15;
export const WALL_THICKNESS = 8;
export const DOOR_THICKNESS = 8;
@@ -13,6 +13,7 @@ import {
import { nextUid } from "@/features/retro-office/core/geometry";
import {
hasAtmMigrationApplied,
hasCryptoRoomMigrationApplied,
hasGymRoomMigrationApplied,
hasPhoneBoothMigrationApplied,
hasQaLabMigrationApplied,
@@ -392,7 +393,7 @@ const DEFAULT_QA_LAB_ITEMS: FurnitureSeed[] = [
{ type: "plant", x: 1496, y: 622 },
];
const DEFAULT_ART_ROOM_ITEMS: FurnitureSeed[] = [
const PREVIOUS_ART_ROOM_ITEMS: FurnitureSeed[] = [
{ type: "wall", x: 260, y: 40, w: 8, h: 230 },
{ type: "wall", x: 260, y: 40, w: 178, h: 8 },
{ type: "wall", x: 260, y: 262, w: 178, h: 8 },
@@ -405,6 +406,52 @@ const DEFAULT_ART_ROOM_ITEMS: FurnitureSeed[] = [
{ type: "plant", x: 280, y: 240 },
];
const DEFAULT_CRYPTO_ROOM_ITEMS: FurnitureSeed[] = [
{ type: "wall", x: 260, y: 40, w: 8, h: 230 },
{ type: "wall", x: 260, y: 40, w: 178, h: 8 },
{ type: "wall", x: 260, y: 262, w: 178, h: 8 },
{ type: "wall", x: 430, y: 40, w: 8, h: 90 },
{ type: "door", x: 420, y: 150, w: 40, h: 8, facing: 90 },
{ type: "wall", x: 430, y: 170, w: 8, h: 100 },
{ type: "crypto_board", x: 330, y: 50, facing: 90 },
{ type: "crypto_board", x: 330, y: 190, facing: 90 },
{ type: "crypto_terminal", x: 270, y: 140, facing: 90 },
];
const LEGACY_CRYPTO_ROOM_ITEMS: FurnitureSeed[] = [
{ type: "wall", x: 260, y: 40, w: 8, h: 230 },
{ type: "wall", x: 260, y: 40, w: 178, h: 8 },
{ type: "wall", x: 260, y: 262, w: 178, h: 8 },
{ type: "wall", x: 430, y: 40, w: 8, h: 90 },
{ type: "door", x: 420, y: 150, w: 40, h: 8, facing: 90 },
{ type: "wall", x: 430, y: 170, w: 8, h: 100 },
{ type: "crypto_board", x: 282, y: 66, facing: 90 },
{ type: "crypto_board", x: 314, y: 66, facing: 90 },
{ type: "crypto_board", x: 346, y: 66, facing: 90 },
{ type: "server_rack", x: 278, y: 162, facing: 90 },
{ type: "server_rack", x: 338, y: 162, facing: 90 },
{ type: "crypto_terminal", x: 288, y: 220, facing: 0 },
{ type: "plant", x: 284, y: 242 },
{ type: "plant", x: 392, y: 242 },
];
const PREVIOUS_CRYPTO_ROOM_ITEMS: FurnitureSeed[] = [
{ type: "wall", x: 260, y: 40, w: 8, h: 230 },
{ type: "wall", x: 260, y: 40, w: 178, h: 8 },
{ type: "wall", x: 260, y: 262, w: 178, h: 8 },
{ type: "wall", x: 430, y: 40, w: 8, h: 90 },
{ type: "door", x: 420, y: 150, w: 40, h: 8, facing: 90 },
{ type: "wall", x: 430, y: 170, w: 8, h: 100 },
{ type: "crypto_board", x: 282, y: 66, facing: 90 },
{ type: "crypto_board", x: 314, y: 66, facing: 90 },
{ type: "crypto_board", x: 346, y: 66, facing: 90 },
{ type: "crypto_terminal", x: 278, y: 152, facing: 90 },
{ type: "crypto_terminal", x: 344, y: 152, facing: 90 },
{ type: "crypto_terminal", x: 288, y: 220, facing: 0 },
{ type: "plant", x: 284, y: 242 },
{ type: "plant", x: 392, y: 242 },
];
const DEFAULT_FURNITURE: FurnitureSeed[] = [
{ type: "round_table", x: 50, y: 50, r: 90 },
{ type: "chair", x: 130, y: 50, facing: 0 },
@@ -416,7 +463,7 @@ const DEFAULT_FURNITURE: FurnitureSeed[] = [
{ type: "executive_desk", x: 420, y: 60, w: 130, h: 65 },
{ type: "chair", x: 540, y: 60, facing: 0 },
{ type: "bookshelf", x: 500, y: 30, w: 80, h: 120 },
{ type: "couch", x: 270, y: 90, w: 40, h: 80, vertical: true, facing: 180 },
{ type: "couch", x: 290, y: 110, w: 40, h: 80, vertical: true, facing: 180 },
{ type: "fridge", x: 1050, y: 20, w: 40, h: 80 },
{ type: "stove", x: 920, y: 20 },
{ type: "cabinet", x: 980, y: 30, w: 40, h: 40 },
@@ -500,7 +547,7 @@ const DEFAULT_FURNITURE: FurnitureSeed[] = [
...DEFAULT_SERVER_ROOM_ITEMS,
...DEFAULT_GYM_ITEMS,
...DEFAULT_QA_LAB_ITEMS,
...DEFAULT_ART_ROOM_ITEMS,
...DEFAULT_CRYPTO_ROOM_ITEMS,
DEFAULT_SMS_BOOTH,
{ type: "chair", x: 100, y: 200, facing: 180 },
];
@@ -557,6 +604,18 @@ const PREVIOUS_QA_LAB_SIGNATURES = new Set(
const QA_LAB_SIGNATURES = new Set(
DEFAULT_QA_LAB_ITEMS.map(createFurnitureSignature),
);
const PREVIOUS_ART_ROOM_SIGNATURES = new Set(
PREVIOUS_ART_ROOM_ITEMS.map(createFurnitureSignature),
);
const LEGACY_CRYPTO_ROOM_SIGNATURES = new Set(
LEGACY_CRYPTO_ROOM_ITEMS.map(createFurnitureSignature),
);
const PREVIOUS_CRYPTO_ROOM_SIGNATURES = new Set(
PREVIOUS_CRYPTO_ROOM_ITEMS.map(createFurnitureSignature),
);
const CRYPTO_ROOM_SIGNATURES = new Set(
DEFAULT_CRYPTO_ROOM_ITEMS.map(createFurnitureSignature),
);
const hasSignature = (
items: FurnitureItem[],
@@ -734,3 +793,48 @@ export const ensureOfficeQaLab = (items: FurnitureItem[]): FurnitureItem[] => {
];
};
export const ensureOfficeCryptoRoom = (
items: FurnitureItem[],
): FurnitureItem[] => {
const hasCurrentCryptoRoom = hasSignature(items, CRYPTO_ROOM_SIGNATURES);
if (hasCurrentCryptoRoom) return items;
const hasPreviousCryptoRoom = hasAllSignatures(items, PREVIOUS_CRYPTO_ROOM_SIGNATURES);
if (hasPreviousCryptoRoom) {
return [
...replaceBySignatureSet(items, PREVIOUS_CRYPTO_ROOM_SIGNATURES),
...DEFAULT_CRYPTO_ROOM_ITEMS.map((item) => ({ ...item, _uid: nextUid() })),
];
}
const hasLegacyCryptoRoom = hasAllSignatures(items, LEGACY_CRYPTO_ROOM_SIGNATURES);
if (hasLegacyCryptoRoom) {
return [
...replaceBySignatureSet(items, LEGACY_CRYPTO_ROOM_SIGNATURES),
...DEFAULT_CRYPTO_ROOM_ITEMS.map((item) => ({ ...item, _uid: nextUid() })),
];
}
const hasPreviousArtRoom = hasAllSignatures(items, PREVIOUS_ART_ROOM_SIGNATURES);
if (hasPreviousArtRoom) {
return [
...replaceBySignatureSet(items, PREVIOUS_ART_ROOM_SIGNATURES),
...DEFAULT_CRYPTO_ROOM_ITEMS.map((item) => ({ ...item, _uid: nextUid() })),
];
}
const hasLegacyArtProps = items.some((item) => item.type === "easel");
if (hasLegacyArtProps) {
return [
...items.filter((item) => item.type !== "easel"),
...DEFAULT_CRYPTO_ROOM_ITEMS.map((item) => ({ ...item, _uid: nextUid() })),
];
}
if (hasCryptoRoomMigrationApplied()) return items;
return [
...items,
...DEFAULT_CRYPTO_ROOM_ITEMS.map((item) => ({ ...item, _uid: nextUid() })),
];
};
@@ -69,6 +69,8 @@ export const ITEM_FOOTPRINT: Record<string, [number, number]> = {
qa_terminal: [54, 38],
device_rack: [70, 36],
test_bench: [90, 42],
crypto_board: [22, 84],
crypto_terminal: [72, 34],
treadmill: [70, 35],
weight_bench: [90, 45],
dumbbell_rack: [80, 28],
@@ -150,6 +152,8 @@ export const ITEM_METADATA: Record<string, { blocksNavigation: boolean }> = {
qa_terminal: { blocksNavigation: true },
device_rack: { blocksNavigation: true },
test_bench: { blocksNavigation: true },
crypto_board: { blocksNavigation: false },
crypto_terminal: { blocksNavigation: true },
// ── gym ───────────────────────────────────────────────────────────────────
treadmill: { blocksNavigation: true },
weight_bench: { blocksNavigation: true },
@@ -1,5 +1,6 @@
import {
ATM_MIGRATION_KEY,
CRYPTO_ROOM_MIGRATION_KEY,
GYM_ROOM_MIGRATION_KEY,
PHONE_BOOTH_MIGRATION_KEY,
QA_LAB_MIGRATION_KEY,
@@ -86,3 +87,10 @@ export const hasSmsBoothMigrationApplied = () =>
export const markSmsBoothMigrationApplied = () => {
markStorageFlag(SMS_BOOTH_MIGRATION_KEY);
};
export const hasCryptoRoomMigrationApplied = () =>
hasStorageFlag(CRYPTO_ROOM_MIGRATION_KEY);
export const markCryptoRoomMigrationApplied = () => {
markStorageFlag(CRYPTO_ROOM_MIGRATION_KEY);
};
+383 -1
View File
@@ -1,5 +1,6 @@
import { Text } from "@react-three/drei";
import { useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { SCALE } from "@/features/retro-office/core/constants";
import {
@@ -9,6 +10,163 @@ import {
} from "@/features/retro-office/core/geometry";
import { InteractiveFurnitureModelProps } from "@/features/retro-office/objects/types";
type ChartVariant = "line" | "candle" | "bars";
function useAnimatedChartTexture(
w: number,
h: number,
variant: ChartVariant = "line",
seed = 0,
) {
const stateRef = useRef({
data: [] as number[],
elapsed: 0,
tick: 0,
});
const { canvas, texture } = useMemo(() => {
const c = document.createElement("canvas");
c.width = w;
c.height = h;
const t = new THREE.CanvasTexture(c);
t.minFilter = THREE.LinearFilter;
t.magFilter = THREE.LinearFilter;
t.colorSpace = THREE.SRGBColorSpace;
return { canvas: c, texture: t };
}, [w, h]);
useEffect(() => {
const s = stateRef.current;
if (s.data.length > 0) return;
let v = 0.45 + ((seed * 17) % 37) * 0.008;
for (let i = 0; i < 64; i++) {
v = Math.max(0.08, Math.min(0.92, v + (Math.random() - 0.48) * 0.04));
s.data.push(v);
}
}, [seed]);
useFrame((_, delta) => {
const s = stateRef.current;
s.elapsed += delta;
if (s.elapsed < 0.25) return;
s.elapsed = 0;
s.tick++;
const last = s.data[s.data.length - 1] ?? 0.5;
const drift = variant === "candle" ? 0.06 : 0.035;
const next = Math.max(
0.08,
Math.min(0.92, last + (Math.random() - 0.48) * drift),
);
s.data.push(next);
if (s.data.length > 64) s.data.shift();
const ctx = canvas.getContext("2d");
if (!ctx) return;
drawChart(ctx, w, h, s.data, variant, s.tick);
texture.needsUpdate = true;
});
return texture;
}
const CHART_COLORS: Record<ChartVariant, { line: string; fill: string }> = {
line: { line: "#22d3ee", fill: "rgba(34,211,238,0.18)" },
candle: { line: "#34d399", fill: "rgba(52,211,153,0.18)" },
bars: { line: "#a78bfa", fill: "rgba(167,139,250,0.15)" },
};
function drawChart(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
data: number[],
variant: ChartVariant,
_tick: number,
) {
ctx.fillStyle = "#06101a";
ctx.fillRect(0, 0, w, h);
const pad = 4;
const cw = w - pad * 2;
const ch = h - pad * 2;
ctx.strokeStyle = "rgba(56,189,248,0.08)";
ctx.lineWidth = 1;
for (let i = 1; i < 5; i++) {
const gy = pad + (ch * i) / 5;
ctx.beginPath();
ctx.moveTo(pad, gy);
ctx.lineTo(pad + cw, gy);
ctx.stroke();
}
for (let i = 1; i < 8; i++) {
const gx = pad + (cw * i) / 8;
ctx.beginPath();
ctx.moveTo(gx, pad);
ctx.lineTo(gx, pad + ch);
ctx.stroke();
}
const len = data.length;
if (len < 2) return;
const dx = cw / (len - 1);
const colors = CHART_COLORS[variant];
if (variant === "candle") {
const barW = Math.max(2, dx * 0.55);
for (let i = 0; i < len; i++) {
const prev = data[Math.max(0, i - 1)];
const cur = data[i];
const x = pad + i * dx;
const open = pad + ch - prev * ch;
const close = pad + ch - cur * ch;
const up = cur >= prev;
ctx.fillStyle = up ? "#34d399" : "#f87171";
ctx.fillRect(x - barW / 2, Math.min(open, close), barW, Math.max(2, Math.abs(close - open)));
ctx.fillRect(x - 0.5, Math.min(open, close) - 3, 1, Math.abs(close - open) + 6);
}
return;
}
if (variant === "bars") {
const barW = Math.max(2, dx * 0.6);
for (let i = 0; i < len; i++) {
const val = data[i];
const x = pad + i * dx;
const barH = val * ch;
const up = i === 0 || data[i] >= data[i - 1];
ctx.fillStyle = up ? "rgba(167,139,250,0.7)" : "rgba(248,113,113,0.6)";
ctx.fillRect(x - barW / 2, pad + ch - barH, barW, barH);
}
return;
}
ctx.beginPath();
ctx.moveTo(pad, pad + ch - data[0] * ch);
for (let i = 1; i < len; i++) {
ctx.lineTo(pad + i * dx, pad + ch - data[i] * ch);
}
ctx.strokeStyle = colors.line;
ctx.lineWidth = 2;
ctx.stroke();
ctx.lineTo(pad + (len - 1) * dx, pad + ch);
ctx.lineTo(pad, pad + ch);
ctx.closePath();
const grad = ctx.createLinearGradient(0, pad, 0, pad + ch);
grad.addColorStop(0, colors.fill);
grad.addColorStop(1, "rgba(6,16,26,0)");
ctx.fillStyle = grad;
ctx.fill();
const lastVal = data[len - 1];
const ly = pad + ch - lastVal * ch;
ctx.beginPath();
ctx.arc(pad + (len - 1) * dx, ly, 3, 0, Math.PI * 2);
ctx.fillStyle = colors.line;
ctx.fill();
}
export function AtmMachineModel({
item,
isSelected,
@@ -190,6 +348,230 @@ export function AtmMachineModel({
);
}
export function CryptoBoardModel({
item,
isSelected,
isHovered,
onPointerDown,
onPointerOver,
onPointerOut,
onClick,
}: InteractiveFurnitureModelProps) {
const [wx, , wz] = toWorld(item.x, item.y);
const { width, height } = getItemBaseSize(item);
const widthWorld = width * SCALE;
const depthWorld = height * SCALE;
const rotY = getItemRotationRadians(item);
const glowColor = isSelected ? "#fbbf24" : isHovered ? "#22d3ee" : "#0f172a";
const glowIntensity = isSelected ? 0.35 : isHovered ? 0.22 : 0.04;
const uidSeed = useMemo(
() =>
item._uid
.split("")
.reduce((acc, c) => acc + c.charCodeAt(0), 0),
[item._uid],
);
const variant: ChartVariant = uidSeed % 2 === 0 ? "candle" : "line";
const screenTex = useAnimatedChartTexture(256, 192, variant, uidSeed);
return (
<group
position={[wx, 0, wz]}
onPointerDown={(event) => {
event.stopPropagation();
onPointerDown(item._uid);
}}
onPointerOver={(event) => {
event.stopPropagation();
onPointerOver(item._uid);
}}
onPointerOut={(event) => {
event.stopPropagation();
onPointerOut();
}}
onClick={(event) => {
event.stopPropagation();
onClick?.(item._uid);
}}
>
<group position={[widthWorld / 2, 0, depthWorld / 2]} rotation={[0, rotY, 0]}>
<mesh position={[0, 1.36, 0]} castShadow receiveShadow>
<boxGeometry args={[0.08, 1.6, depthWorld]} />
<meshStandardMaterial color="#111827" roughness={0.5} metalness={0.22} />
</mesh>
<mesh position={[0, 1.36, -0.03]}>
<boxGeometry args={[0.03, 1.46, depthWorld * 0.86]} />
<meshStandardMaterial color="#020617" />
</mesh>
<mesh position={[0.03, 1.36, -0.03]}>
<planeGeometry args={[depthWorld * 0.78, 1.32]} />
<meshStandardMaterial
color="#051018"
emissive={glowColor}
emissiveIntensity={glowIntensity}
/>
</mesh>
<mesh position={[0.032, 1.36, -0.03]}>
<planeGeometry args={[depthWorld * 0.72, 1.2]} />
<meshStandardMaterial
map={screenTex}
emissive="#38bdf8"
emissiveIntensity={0.6}
emissiveMap={screenTex}
toneMapped={false}
/>
</mesh>
<Text
position={[0.035, 1.88, -0.03]}
rotation={[0, Math.PI / 2, 0]}
fontSize={0.06}
color="#e0f2fe"
anchorX="center"
anchorY="middle"
>
CRYPTO
</Text>
<Text
position={[0.035, 0.88, -0.03]}
rotation={[0, Math.PI / 2, 0]}
fontSize={0.045}
color="#bbf7d0"
anchorX="center"
anchorY="middle"
>
SOL ROOM
</Text>
</group>
</group>
);
}
const TERMINAL_SCREEN_VARIANTS: ChartVariant[] = ["candle", "line", "bars"];
export function CryptoTerminalModel({
item,
isSelected,
isHovered,
onPointerDown,
onPointerOver,
onPointerOut,
onClick,
}: InteractiveFurnitureModelProps) {
const [wx, , wz] = toWorld(item.x, item.y);
const { width, height } = getItemBaseSize(item);
const widthWorld = width * SCALE;
const depthWorld = height * SCALE;
const rotY = getItemRotationRadians(item);
const glowColor = isSelected ? "#fbbf24" : isHovered ? "#34d399" : "#000000";
const glowIntensity = isSelected ? 0.28 : isHovered ? 0.18 : 0;
const screenTex0 = useAnimatedChartTexture(128, 96, "candle", 10);
const screenTex1 = useAnimatedChartTexture(128, 96, "line", 20);
const screenTex2 = useAnimatedChartTexture(128, 96, "bars", 30);
const mainScreenTex = useAnimatedChartTexture(256, 128, "line", 50);
const screenTextures = useMemo(
() => [screenTex0, screenTex1, screenTex2],
[screenTex0, screenTex1, screenTex2],
);
return (
<group
position={[wx, 0, wz]}
onPointerDown={(event) => {
event.stopPropagation();
onPointerDown(item._uid);
}}
onPointerOver={(event) => {
event.stopPropagation();
onPointerOver(item._uid);
}}
onPointerOut={(event) => {
event.stopPropagation();
onPointerOut();
}}
onClick={(event) => {
event.stopPropagation();
onClick?.(item._uid);
}}
>
<group position={[widthWorld / 2, 0, depthWorld / 2]} rotation={[0, rotY, 0]}>
<mesh position={[0, 0.44, 0]} castShadow receiveShadow>
<boxGeometry args={[widthWorld * 0.92, 0.12, depthWorld * 0.92]} />
<meshStandardMaterial color="#111827" roughness={0.55} metalness={0.12} />
</mesh>
<mesh position={[0, 0.82, 0]} castShadow receiveShadow>
<boxGeometry args={[widthWorld * 0.8, 0.6, depthWorld * 0.72]} />
<meshStandardMaterial
color="#1f2937"
roughness={0.42}
metalness={0.16}
emissive={glowColor}
emissiveIntensity={glowIntensity}
/>
</mesh>
{/* Flat monitor on top of the terminal. */}
<mesh position={[0, 1.52, -depthWorld * 0.08]}>
<boxGeometry args={[widthWorld * 0.68, 0.5, 0.03]} />
<meshStandardMaterial color="#0a0a0a" roughness={0.3} metalness={0.5} />
</mesh>
<mesh position={[0, 1.52, -depthWorld * 0.08 + 0.017]}>
<planeGeometry args={[widthWorld * 0.62, 0.42]} />
<meshStandardMaterial
map={mainScreenTex}
emissive="#22d3ee"
emissiveIntensity={0.35}
emissiveMap={mainScreenTex}
toneMapped={false}
/>
</mesh>
{/* Monitor stand. */}
<mesh position={[0, 1.2, -depthWorld * 0.08]}>
<boxGeometry args={[0.04, 0.16, 0.04]} />
<meshStandardMaterial color="#111827" metalness={0.4} roughness={0.5} />
</mesh>
<mesh position={[0, 1.18, -depthWorld * 0.18]}>
<boxGeometry args={[widthWorld * 0.72, 0.05, 0.16]} />
<meshStandardMaterial color="#0f172a" />
</mesh>
{[-0.28, 0, 0.28].map((offset, index) => (
<group key={index} position={[offset, 1.14, -depthWorld * 0.12]}>
<mesh>
<boxGeometry args={[0.28, 0.18, 0.04]} />
<meshStandardMaterial color="#020617" />
</mesh>
<mesh position={[0, 0, 0.022]}>
<planeGeometry args={[0.24, 0.14]} />
<meshStandardMaterial
map={screenTextures[index]}
emissive={
TERMINAL_SCREEN_VARIANTS[index] === "line"
? "#22d3ee"
: "#34d399"
}
emissiveIntensity={0.35}
emissiveMap={screenTextures[index]}
toneMapped={false}
/>
</mesh>
</group>
))}
<mesh position={[0, 0.89, depthWorld * 0.22]} rotation={[-0.22, 0, 0]}>
<boxGeometry args={[widthWorld * 0.7, 0.03, 0.14]} />
<meshStandardMaterial color="#0f172a" />
</mesh>
<Text
position={[0, 1.82, -depthWorld * 0.06]}
fontSize={0.045}
color="#dbeafe"
anchorX="center"
anchorY="middle"
>
SOL TRADING CONSOLE
</Text>
</group>
</group>
);
}
export function PhoneBoothModel({
item,
isSelected,
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { buildCryptoReportSnapshot } from "@/features/crypto/lib/pnl";
import type { CryptoTradeRecord } from "@/features/crypto/types";
const buyTrade = (overrides: Partial<CryptoTradeRecord> = {}): CryptoTradeRecord => ({
id: "buy-1",
source: "user",
status: "confirmed",
agentId: null,
agentName: null,
pairAddress: "pair",
tokenMint: "mint",
tokenSymbol: "MEME",
side: "buy",
walletPublicKey: "wallet",
inputMint: "sol",
outputMint: "mint",
inputAmountUi: 1,
outputAmountUi: 100,
tokenDelta: 100,
notionalUsd: 100,
executionPriceUsd: 1,
slippageBps: 150,
quoteCreatedAt: 1,
txSignature: "sig",
error: null,
rationale: null,
createdAt: 1,
submittedAt: 1,
confirmedAt: 1,
...overrides,
});
describe("buildCryptoReportSnapshot", () => {
it("tracks open inventory and unrealized pnl", () => {
const report = buildCryptoReportSnapshot({
trades: [buyTrade()],
approvals: [],
currentTokenPriceUsd: 1.5,
trackedTokenSymbol: "MEME",
});
expect(report.openTokenQuantity).toBe(100);
expect(report.averageEntryUsd).toBe(1);
expect(report.unrealizedPnlUsd).toBeCloseTo(50);
expect(report.realizedPnlUsd).toBeCloseTo(0);
});
it("realizes pnl on sells using weighted average cost", () => {
const report = buildCryptoReportSnapshot({
trades: [
buyTrade(),
buyTrade({
id: "buy-2",
inputAmountUi: 0.5,
outputAmountUi: 50,
tokenDelta: 50,
notionalUsd: 75,
executionPriceUsd: 1.5,
}),
buyTrade({
id: "sell-1",
source: "agent",
agentId: "agent-1",
agentName: "Agent One",
side: "sell",
inputMint: "mint",
outputMint: "sol",
inputAmountUi: 60,
outputAmountUi: 0.9,
tokenDelta: -60,
notionalUsd: 108,
executionPriceUsd: 1.8,
}),
],
approvals: [],
currentTokenPriceUsd: 2,
trackedTokenSymbol: "MEME",
});
expect(report.realizedPnlUsd).toBeCloseTo(38);
expect(report.openTokenQuantity).toBe(90);
expect(report.unrealizedPnlUsd).toBeCloseTo(75);
expect(report.bySource[0]?.label).toBe("User");
expect(report.bySource[1]?.label).toBe("Agent One");
});
});