mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-07-30 19:22:28 +00:00
Crypto
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+505
-4
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 */
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}>;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user