From e59b612fd27763168b7053fcd59ebaa11f2d9b9b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 6 Mar 2026 06:28:53 -0500 Subject: [PATCH 1/4] feat(7.N): x402 service billing layer + X direct posting + Toto NFT mint script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS1: Remove test agent spend button from delegation-card UI WS2: x402 service proxy billing layer - service-pricing.ts: USDC price registry (image $0.10-0.15, text $0.02) - server-middleware.ts: buildX402Middleware() with ExactSvmScheme/Solana CAIP-2 - routes/services.ts: POST /api/services/{image-generate,image-generate-pro,image-edit,text-generate} - ENABLE_X402_BILLING=false bypass for dev; PLATFORM_TREASURY_ADDRESS for payTo - app.ts: mount /services + add X-Payment to CORS allowHeaders WS4: Direct X (Twitter) OAuth 1.0a posting as @ozskr - x-client.ts: HMAC-SHA1 OAuth 1.0a, postTweet(), uploadMedia() via Node crypto - x-direct-adapter.ts: SocialPublisher adapter with AI disclosure injection - routes/social-direct.ts: POST /api/social/direct/post-x - publisher-factory.ts: getXDirectPublisher() singleton + XDirectAdapter switch case WS3: scripts/register-toto-mainnet.ts — 8004-solana NFT mint with Pinata IPFS - Supports --dry-run flag; writes result to scripts/toto-nft-result.json - Valid OASF taxonomy tags verified via getAllSkills()/getAllDomains() Typecheck: clean. Assisted-by: Claude Code Co-Authored-By: Claude Sonnet 4.6 --- package.json | 5 + pnpm-lock.yaml | 289 ++++++++- scripts/register-toto-mainnet.ts | 594 ++++++++++++++++++ .../agents/components/delegation-card.tsx | 81 --- src/lib/api/app.ts | 6 +- src/lib/api/routes/services.ts | 518 +++++++++++++++ src/lib/api/routes/social-direct.ts | 280 +++++++++ src/lib/social/publisher-factory.ts | 37 ++ src/lib/social/x-client.ts | 328 ++++++++++ src/lib/social/x-direct-adapter.ts | 113 ++++ src/lib/x402/index.ts | 11 + src/lib/x402/server-middleware.ts | 202 ++++++ src/lib/x402/service-pricing.ts | 78 +++ 13 files changed, 2446 insertions(+), 96 deletions(-) create mode 100644 scripts/register-toto-mainnet.ts create mode 100644 src/lib/api/routes/services.ts create mode 100644 src/lib/api/routes/social-direct.ts create mode 100644 src/lib/social/x-client.ts create mode 100644 src/lib/social/x-direct-adapter.ts create mode 100644 src/lib/x402/index.ts create mode 100644 src/lib/x402/server-middleware.ts create mode 100644 src/lib/x402/service-pricing.ts diff --git a/package.json b/package.json index a2b29b6..20c6323 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "publish:all": "pnpm run publish:sdk && pnpm run publish:mcp && pnpm run publish:facilitator" }, "dependencies": { + "8004-solana": "^0.7.9", "@ai-sdk/anthropic": "^3.0.44", "@fal-ai/serverless-client": "^0.15.0", "@hono/zod-validator": "^0.7.6", @@ -51,6 +52,10 @@ "@trigger.dev/sdk": "^4.4.1", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.36.2", + "@x402/core": "^2.3.1", + "@x402/fetch": "^2.6.0", + "@x402/hono": "^2.6.0", + "@x402/svm": "^2.3.0", "ai": "^6.0.86", "bs58": "^6.0.0", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 483ce3a..5c53cc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + 8004-solana: + specifier: ^0.7.9 + version: 0.7.9(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10) '@ai-sdk/anthropic': specifier: ^3.0.44 version: 3.0.44(zod@4.3.6) @@ -74,7 +77,7 @@ importers: version: 0.6.32(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-wallets': specifier: ^0.19.37 - version: 0.19.37(@azure/identity@4.13.0)(@babel/runtime@7.28.6)(@react-native-async-storage/async-storage@1.24.0(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10)))(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.14)(@upstash/redis@1.36.2)(bs58@6.0.0)(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.2.3(react@19.2.3))(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) + version: 0.19.37(@azure/identity@4.13.0)(@babel/runtime@7.28.6)(@react-native-async-storage/async-storage@1.24.0(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.14)(@upstash/redis@1.36.2)(bs58@6.0.0)(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.2.3(react@19.2.3))(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) '@solana/web3.js': specifier: ^1.98.4 version: 1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10) @@ -93,6 +96,18 @@ importers: '@upstash/redis': specifier: ^1.36.2 version: 1.36.2 + '@x402/core': + specifier: ^2.3.1 + version: 2.3.1 + '@x402/fetch': + specifier: ^2.6.0 + version: 2.6.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@x402/hono': + specifier: ^2.6.0 + version: 2.6.0(bufferutil@4.1.0)(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(hono@4.11.9)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@x402/svm': + specifier: ^2.3.0 + version: 2.3.0(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) ai: specifier: ^6.0.86 version: 6.0.86(zod@4.3.6) @@ -320,6 +335,10 @@ importers: packages: + 8004-solana@0.7.9: + resolution: {integrity: sha512-7qSldH9Af89Vpq7NuE2AqN0lW283xCSqR2t2e5kYYgvEGCCwEZhS/DR0cUI4KX0iEsYBlTfmXva9A9O6P44YVw==} + engines: {node: '>=18.0.0'} + '@a2a-js/sdk@0.2.5': resolution: {integrity: sha512-VTDuRS5V0ATbJ/LkaQlisMnTAeYKXAK6scMguVBstf+KIBQ7HIuKhiXLv+G/hvejkV+THoXzoNifInAkU81P1g==} engines: {node: '>=18'} @@ -330,6 +349,9 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} @@ -845,6 +867,12 @@ packages: '@cloudflare/workers-types@4.20260210.0': resolution: {integrity: sha512-zHaF0RZVYUQwNCJCECnNAJdMur72Lk3FMiD6wU78Dx3Bv7DQRcuXNmPNuJmsGnosVZCcWintHlPTQ/4BEiDG5w==} + '@coral-xyz/borsh@0.32.1': + resolution: {integrity: sha512-pWsqkkZ+psLlupMkgu5rDok7baDQLWKqyDsb76gUNArbVB783mF3ZOleKMHVXZczl5JuxnVVfO1+XDVvR17C3A==} + engines: {node: '>=10'} + peerDependencies: + '@solana/web3.js': ^1.69.0 + '@csstools/color-helpers@6.0.1': resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==} engines: {node: '>=20.19.0'} @@ -1651,6 +1679,9 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + '@noble/curves@1.4.2': resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} @@ -1678,6 +1709,10 @@ packages: resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} engines: {node: '>= 20.19.0'} + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -4536,6 +4571,21 @@ packages: peerDependencies: '@solana/web3.js': '*' + '@spruceid/siwe-parser@2.1.2': + resolution: {integrity: sha512-d/r3S1LwJyMaRAKQ0awmo9whfXeE88Qt00vRj91q5uv5ATtWIQEGJ67Yr5eSZw5zp1/fZCXZYuEckt8lSkereQ==} + + '@stablelib/binary@1.0.1': + resolution: {integrity: sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==} + + '@stablelib/int@1.0.1': + resolution: {integrity: sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==} + + '@stablelib/random@1.0.2': + resolution: {integrity: sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==} + + '@stablelib/wipe@1.0.1': + resolution: {integrity: sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==} + '@standard-community/standard-json@0.3.5': resolution: {integrity: sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==} peerDependencies: @@ -5088,6 +5138,9 @@ packages: '@types/node@20.19.33': resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/pg@8.11.0': resolution: {integrity: sha512-sDAlRiBNthGjNFfvt0k6mtotoVYVQ63pA8R4EMWka7crawSR60waVYR0HAgmPRs/e2YaeJTD/43OoZ3PFw80pw==} @@ -5501,6 +5554,24 @@ packages: '@x402/core@2.3.1': resolution: {integrity: sha512-CWvsf09tslISoVOzQ2TIoBLBP+bUycPsYmmRVe3EV5X2FtD7eXXpiPsiXLEVtWP7zhqLNP/5OIATsA2hSVLSfw==} + '@x402/core@2.6.0': + resolution: {integrity: sha512-ISC/JeVss6xlKvor2rp18tJf9K5OQlIDDfZW1VZJQGDI2F4gy+HWxxkFfcQalCsPp4YUlwqh0YOkUxP+LTZWVg==} + + '@x402/extensions@2.6.0': + resolution: {integrity: sha512-aLY9xAOOiRLKDN9HT2r9TYUXbD+IsoBces9qPZNVJGO2TBi2rfmbIBc3pcKCtWKn3iTvG2QFr3gpOFdpJRzqww==} + + '@x402/fetch@2.6.0': + resolution: {integrity: sha512-OnHXw/mv76ig4UBJEgfQIWHSWcrgIOT2i8RxEuGl12QtaYwSgBcgDub2GdllL/iIB9OneM1m0UWlrPh23JdVjQ==} + + '@x402/hono@2.6.0': + resolution: {integrity: sha512-Wt+7Ik6gnVNEY57O+A8/hueL9j+DvbNtHvvqKkTvpq6gadvU2ZtOn++0sWtDIFKRaTq3MVix/rWKlNCRoG0hDQ==} + peerDependencies: + '@x402/paywall': 2.6.0 + hono: ^4.0.0 + peerDependenciesMeta: + '@x402/paywall': + optional: true + '@x402/svm@2.3.0': resolution: {integrity: sha512-Lh02bBBwv9fHSTId8Iqj+SGl9zjSYCrUHaJ0VsrN/GKBcFJwXoDNXgqc7qJGKbkdriBnvAHMxtFR1snpAaBm+w==} @@ -5567,6 +5638,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -5634,6 +5708,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + apg-js@4.4.0: + resolution: {integrity: sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==} + aproba@2.1.0: resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} @@ -5936,6 +6013,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-layout@1.2.2: + resolution: {integrity: sha512-kWSuLN694+KTk8SrYvCqwP2WcgQjoRCiF5b4QDvkkz8EmgD+aWAIceGFKMIAdmF/pH+vpgNV3d3kAKorcdAmWA==} + engines: {node: '>=4.5'} + buffer-xor@1.0.3: resolution: {integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==} @@ -6853,6 +6934,10 @@ packages: ethereum-cryptography@2.2.1: resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} + ethers@6.16.0: + resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==} + engines: {node: '>=14.0.0'} + ev-emitter@2.1.2: resolution: {integrity: sha512-jQ5Ql18hdCQ4qS+RCrbLfz1n+Pags27q5TwMKvZyhp5hh2UULUYZUy1keqj6k6SYsdqIYjnmz7xyyEY0V67B8Q==} @@ -9702,6 +9787,11 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + siwe@2.3.2: + resolution: {integrity: sha512-aSf+6+Latyttbj5nMu6GF3doMfv2UYj83hhwZgUF20ky6fTS83uVhkQABdIVnEuS8y1bBdk7p6ltb9SmlhTTlA==} + peerDependencies: + ethers: ^5.6.8 || ^6.0.8 + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -10108,6 +10198,9 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -10210,6 +10303,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -10399,6 +10495,9 @@ packages: resolution: {integrity: sha512-AXyzMjazYB3ovL3q051VLH06Ixj//Knx7QnUSi1T//Ie3io6CpsPu9nVMOx5MoLWh6xV0B9J0hIaxungxXUbPQ==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + valid-url@1.0.9: + resolution: {integrity: sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==} + validate-npm-package-name@7.0.2: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} engines: {node: ^20.17.0 || >=22.9.0} @@ -10831,6 +10930,20 @@ packages: snapshots: + 8004-solana@0.7.9(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10): + dependencies: + '@coral-xyz/borsh': 0.32.1(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@noble/hashes': 2.0.1 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10) + borsh: 0.7.0 + bs58: 6.0.0 + tweetnacl: 1.0.3 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + '@a2a-js/sdk@0.2.5': dependencies: '@types/cors': 2.8.19 @@ -10846,6 +10959,8 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@adraffy/ens-normalize@1.10.1': {} + '@adraffy/ens-normalize@1.11.1': {} '@ai-sdk/anthropic@3.0.44(zod@4.3.6)': @@ -11774,6 +11889,12 @@ snapshots: '@cloudflare/workers-types@4.20260210.0': {} + '@coral-xyz/borsh@0.32.1(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10))': + dependencies: + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10) + bn.js: 5.2.2 + buffer-layout: 1.2.2 + '@csstools/color-helpers@6.0.1': {} '@csstools/css-calc@3.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -12660,6 +12781,10 @@ snapshots: '@noble/ciphers@1.3.0': {} + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + '@noble/curves@1.4.2': dependencies: '@noble/hashes': 1.4.0 @@ -12688,6 +12813,8 @@ snapshots: dependencies: '@noble/hashes': 2.0.1 + '@noble/hashes@1.3.2': {} + '@noble/hashes@1.4.0': {} '@noble/hashes@1.7.0': {} @@ -14688,10 +14815,9 @@ snapshots: dependencies: '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))': + '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': dependencies: '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - '@solana/sysvars': 6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana-program/token-2022@0.6.1(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10))(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))': dependencies: @@ -16187,11 +16313,11 @@ snapshots: - typescript - utf-8-validate - '@solana/wallet-adapter-trezor@0.1.6(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@solana/wallet-adapter-trezor@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@trezor/connect-web': 9.7.1(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@trezor/connect-web': 9.7.1(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) buffer: 6.0.3 transitivePeerDependencies: - '@solana/sysvars' @@ -16254,7 +16380,7 @@ snapshots: - utf-8-validate - zod - '@solana/wallet-adapter-wallets@0.19.37(@azure/identity@4.13.0)(@babel/runtime@7.28.6)(@react-native-async-storage/async-storage@1.24.0(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10)))(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.14)(@upstash/redis@1.36.2)(bs58@6.0.0)(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.2.3(react@19.2.3))(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)': + '@solana/wallet-adapter-wallets@0.19.37(@azure/identity@4.13.0)(@babel/runtime@7.28.6)(@react-native-async-storage/async-storage@1.24.0(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.14)(@upstash/redis@1.36.2)(bs58@6.0.0)(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.2.3(react@19.2.3))(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)': dependencies: '@solana/wallet-adapter-alpha': 0.1.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-avana': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)) @@ -16287,7 +16413,7 @@ snapshots: '@solana/wallet-adapter-tokenary': 0.1.16(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-tokenpocket': 0.4.23(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-torus': 0.11.32(@babel/runtime@7.28.6)(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana/wallet-adapter-trezor': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-trust': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-unsafe-burner': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-walletconnect': 0.1.21(@azure/identity@4.13.0)(@react-native-async-storage/async-storage@1.24.0(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.14)(@upstash/redis@1.36.2)(bufferutil@4.1.0)(encoding@0.1.13)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) @@ -16467,6 +16593,26 @@ snapshots: eventemitter3: 5.0.4 uuid: 9.0.1 + '@spruceid/siwe-parser@2.1.2': + dependencies: + '@noble/hashes': 1.8.0 + apg-js: 4.4.0 + uri-js: 4.4.1 + valid-url: 1.0.9 + + '@stablelib/binary@1.0.1': + dependencies: + '@stablelib/int': 1.0.1 + + '@stablelib/int@1.0.1': {} + + '@stablelib/random@1.0.2': + dependencies: + '@stablelib/binary': 1.0.1 + '@stablelib/wipe': 1.0.1 + + '@stablelib/wipe@1.0.1': {} + '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6)': dependencies: '@standard-schema/spec': 1.1.0 @@ -16796,12 +16942,12 @@ snapshots: - react-native - utf-8-validate - '@trezor/blockchain-link@2.6.1(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@trezor/blockchain-link@2.6.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@stellar/stellar-sdk': 14.2.0 @@ -16850,9 +16996,9 @@ snapshots: - expo-localization - react-native - '@trezor/connect-web@9.7.1(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@trezor/connect-web@9.7.1(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: - '@trezor/connect': 9.7.1(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@trezor/connect': 9.7.1(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@trezor/connect-common': 0.5.0(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/utils': 9.5.0(tslib@2.8.1) '@trezor/websocket-client': 1.3.0(bufferutil@4.1.0)(tslib@2.8.1)(utf-8-validate@5.0.10) @@ -16871,7 +17017,7 @@ snapshots: - utf-8-validate - ws - '@trezor/connect@9.7.1(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@trezor/connect@9.7.1(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: '@ethereumjs/common': 10.1.1 '@ethereumjs/tx': 10.1.1 @@ -16882,9 +17028,9 @@ snapshots: '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@solana-program/system': 0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - '@trezor/blockchain-link': 2.6.1(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@trezor/blockchain-link': 2.6.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@trezor/blockchain-link-types': 1.5.0(tslib@2.8.1) '@trezor/blockchain-link-utils': 1.5.1(bufferutil@4.1.0)(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10) '@trezor/connect-analytics': 1.4.0(react-native@0.84.0(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10))(tslib@2.8.1) @@ -17278,6 +17424,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + '@types/pg@8.11.0': dependencies: '@types/node': 20.19.33 @@ -18149,6 +18299,47 @@ snapshots: dependencies: zod: 3.25.76 + '@x402/core@2.6.0': + dependencies: + zod: 3.25.76 + + '@x402/extensions@2.6.0(bufferutil@4.1.0)(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@scure/base': 1.2.6 + '@x402/core': 2.6.0 + ajv: 8.17.1 + siwe: 2.3.2(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + tweetnacl: 1.0.3 + viem: 2.45.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - ethers + - typescript + - utf-8-validate + + '@x402/fetch@2.6.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@x402/core': 2.6.0 + viem: 2.45.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + '@x402/hono@2.6.0(bufferutil@4.1.0)(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(hono@4.11.9)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@x402/core': 2.6.0 + '@x402/extensions': 2.6.0(bufferutil@4.1.0)(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(typescript@5.9.3)(utf-8-validate@5.0.10) + hono: 4.11.9 + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - ethers + - typescript + - utf-8-validate + '@x402/svm@2.3.0(@solana/sysvars@6.0.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)': dependencies: '@solana-program/compute-budget': 0.11.0(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)) @@ -18193,6 +18384,11 @@ snapshots: typescript: 5.9.3 zod: 3.22.4 + abitype@1.2.3(typescript@5.9.3)(zod@3.25.76): + optionalDependencies: + typescript: 5.9.3 + zod: 3.25.76 + abitype@1.2.3(typescript@5.9.3)(zod@4.3.6): optionalDependencies: typescript: 5.9.3 @@ -18226,6 +18422,8 @@ snapshots: acorn@8.15.0: {} + aes-js@4.0.0-beta.5: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -18292,6 +18490,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + apg-js@4.4.0: {} + aproba@2.1.0: optional: true @@ -18706,6 +18906,8 @@ snapshots: buffer-from@1.1.2: {} + buffer-layout@1.2.2: {} + buffer-xor@1.0.3: {} buffer@5.7.1: @@ -19813,6 +20015,19 @@ snapshots: '@scure/bip32': 1.4.0 '@scure/bip39': 1.3.0 + ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): + dependencies: + '@adraffy/ens-normalize': 1.10.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.17.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + ev-emitter@2.1.2: {} event-target-shim@5.0.1: {} @@ -22071,6 +22286,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.12.1(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + ox@0.12.1(typescript@5.9.3)(zod@4.3.6): dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -23325,6 +23555,14 @@ snapshots: sisteransi@1.0.5: {} + siwe@2.3.2(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)): + dependencies: + '@spruceid/siwe-parser': 2.1.2 + '@stablelib/random': 1.0.2 + ethers: 6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + uri-js: 4.4.1 + valid-url: 1.0.9 + slash@3.0.0: {} slug@6.1.0: {} @@ -23771,6 +24009,8 @@ snapshots: tslib@1.14.1: {} + tslib@2.7.0: {} + tslib@2.8.1: {} tsyringe@4.10.0: @@ -23885,6 +24125,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.19.8: {} + undici-types@6.21.0: {} undici-types@7.21.0: {} @@ -24034,6 +24276,8 @@ snapshots: '@types/uuid': 8.3.4 uuid: 8.3.2 + valid-url@1.0.9: {} + validate-npm-package-name@7.0.2: {} valtio@1.13.2(@types/react@19.2.14)(react@19.2.3): @@ -24102,6 +24346,23 @@ snapshots: - utf-8-validate - zod + viem@2.45.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.12.1(typescript@5.9.3)(zod@3.25.76) + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + viem@2.45.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6): dependencies: '@noble/curves': 1.9.1 diff --git a/scripts/register-toto-mainnet.ts b/scripts/register-toto-mainnet.ts new file mode 100644 index 0000000..a3528d3 --- /dev/null +++ b/scripts/register-toto-mainnet.ts @@ -0,0 +1,594 @@ +/** + * register-toto-mainnet.ts + * + * One-time script: Register Toto's agent identity on the Solana Agent Registry (mainnet-beta). + * + * Flow: + * 1. Validate environment variables + * 2. Upload Toto's metadata JSON to IPFS via Pinata + * 3. Register a new agent NFT on the Solana Agent Registry using 8004-solana SDK + * 4. Print the asset address and Solana Explorer URL + * 5. Write result to scripts/toto-nft-result.json + * + * SDK quirks discovered: + * - 8004-solana v0.7.9 uses @solana/web3.js v1 (Keypair, Connection, PublicKey), + * NOT @solana/kit. No bridging via @solana/compat is required here because this + * is a standalone script — we import @solana/web3.js directly alongside @solana/kit. + * - SolanaSDK.registerAgent() returns TransactionResult & { asset?: PublicKey } on + * live send, or PreparedTransaction & { asset: PublicKey } when skipSend=true. + * - The OASF skill/domain taxonomy used by the SDK does NOT match the prompt's + * assumed tags (e.g. "business/finance/grant_writing" is NOT valid). Valid tags + * were validated via getAllSkills() / getAllDomains() at script-write time. + * - When skipSend=true (dry-run), registerAgent still needs Connection + cluster + * to fetch a recent blockhash for the serialized tx, so a real RPC connection + * is required even in dry-run mode. + * - The SDK's Keypair signer is a @solana/web3.js Keypair decoded from base58. + * + * Usage: + * # Dry run (no on-chain tx): + * npx tsx scripts/register-toto-mainnet.ts --dry-run + * + * # Live run (Matt runs this manually): + * HELIUS_RPC_URL=https://... PINATA_JWT=... AGENT_AUTHORITY_PRIVATE_KEY=... \ + * npx tsx scripts/register-toto-mainnet.ts + * + * Required env vars: + * HELIUS_RPC_URL Helius mainnet RPC endpoint + * PINATA_JWT Pinata API JWT for IPFS upload + * AGENT_AUTHORITY_PRIVATE_KEY Base58-encoded Solana keypair (the registrant/signer) + * + * SECURITY: Private key only lives in memory for the duration of this script. + * Never stored, never logged, never sent over the wire. + * For live runs, pass via env var at the shell — do NOT commit to .env files. + */ + +// --------------------------------------------------------------------------- +// NOTE: This script intentionally imports @solana/web3.js v1 because the +// 8004-solana SDK requires it. @solana/kit is NOT used here — this is a +// standalone script outside the app bundle. The @solana/compat bridge is +// therefore unnecessary. +// --------------------------------------------------------------------------- + +import { Keypair, Connection, PublicKey } from '@solana/web3.js'; +import bs58 from 'bs58'; +import { SolanaSDK } from '8004-solana'; +import type { TransactionResult, PreparedTransaction } from '8004-solana'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DRY_RUN = process.argv.includes('--dry-run'); + +/** + * Toto's agent metadata in Pinata pinning format. + * + * Skills and domains use valid OASF taxonomy tags as validated by + * 8004-solana's getAllSkills() / getAllDomains() / validateSkill() / + * validateDomain() at script-write time. + * + * Original prompt tags (INVALID — not in OASF taxonomy): + * "business/finance/grant_writing" + * "business/marketing/content_strategy" + * "natural_language_processing/text_generation/text_generation" + * "technology/blockchain/solana" + * "business/finance/funding" + * + * Valid replacements used here: + * Skills: + * advanced_reasoning_planning/strategic_planning + * natural_language_processing/natural_language_generation/natural_language_generation + * natural_language_processing/information_retrieval_synthesis/knowledge_synthesis + * Domains: + * finance_and_business/finance + * technology/blockchain/blockchain + */ +const TOTO_METADATA = { + name: 'Toto', + description: + 'Funding strategist for ozskr.ai — non-dilutive funding, grant applications, revenue strategy across the Solana ecosystem.', + skills: [ + 'advanced_reasoning_planning/strategic_planning', + 'natural_language_processing/natural_language_generation/natural_language_generation', + 'natural_language_processing/information_retrieval_synthesis/knowledge_synthesis', + ], + domains: [ + 'finance_and_business/finance', + 'technology/blockchain/blockchain', + ], + version: '1.0.0', + platform: 'ozskr.ai', +}; + +const RESULT_FILE = path.resolve('/home/matt/projects/ozskr/scripts/toto-nft-result.json'); + +// --------------------------------------------------------------------------- +// Env validation +// --------------------------------------------------------------------------- + +interface EnvConfig { + rpcUrl: string; + pinataJwt: string; + privateKeyBase58: string; +} + +function validateEnv(): EnvConfig { + const rpcUrl = + process.env.HELIUS_RPC_URL ?? + process.env.NEXT_PUBLIC_HELIUS_RPC_URL; + + const pinataJwt = process.env.PINATA_JWT; + const privateKeyBase58 = process.env.AGENT_AUTHORITY_PRIVATE_KEY; + + const missing: string[] = []; + + if (!rpcUrl) missing.push('HELIUS_RPC_URL (or NEXT_PUBLIC_HELIUS_RPC_URL)'); + if (!pinataJwt) missing.push('PINATA_JWT'); + if (!privateKeyBase58) missing.push('AGENT_AUTHORITY_PRIVATE_KEY'); + + if (missing.length > 0) { + console.error('ERROR: Missing required environment variables:'); + for (const m of missing) { + console.error(` - ${m}`); + } + console.error(''); + console.error('For dry-run (no on-chain tx), stub values are accepted:'); + console.error( + ' HELIUS_RPC_URL=https://mainnet.helius-rpc.com/?api-key=STUB \\', + ); + console.error(' PINATA_JWT=STUB \\'); + console.error( + ' AGENT_AUTHORITY_PRIVATE_KEY= \\', + ); + console.error(' npx tsx scripts/register-toto-mainnet.ts --dry-run'); + process.exit(1); + } + + return { + rpcUrl: rpcUrl!, + pinataJwt: pinataJwt!, + privateKeyBase58: privateKeyBase58!, + }; +} + +// --------------------------------------------------------------------------- +// Keypair loading +// --------------------------------------------------------------------------- + +/** + * Decode a base58-encoded 64-byte Solana keypair into a @solana/web3.js Keypair. + * The 64-byte format is: [32-byte secret key || 32-byte public key]. + */ +function loadKeypairFromBase58(base58Key: string): Keypair { + let secretBytes: Uint8Array; + try { + secretBytes = bs58.decode(base58Key); + } catch (err) { + throw new Error( + `AGENT_AUTHORITY_PRIVATE_KEY is not valid base58: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + + if (secretBytes.length !== 64) { + throw new Error( + `AGENT_AUTHORITY_PRIVATE_KEY must be a 64-byte keypair (got ${secretBytes.length} bytes). ` + + 'Use the full keypair bytes, not just the 32-byte secret scalar.', + ); + } + + return Keypair.fromSecretKey(secretBytes); +} + +// --------------------------------------------------------------------------- +// IPFS upload via Pinata +// --------------------------------------------------------------------------- + +/** + * Upload a JSON object to IPFS via Pinata's pinning API. + * Returns the IPFS URI: ipfs:// + */ +async function uploadToIPFS(metadata: object): Promise { + console.log('Uploading metadata to IPFS via Pinata...'); + + const response = await fetch('https://api.pinata.cloud/pinning/pinJSONToIPFS', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.PINATA_JWT}`, + }, + body: JSON.stringify({ + pinataContent: metadata, + pinataMetadata: { name: 'toto-agent-metadata.json' }, + }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error( + `Pinata upload failed (HTTP ${response.status}): ${body}`, + ); + } + + const result = (await response.json()) as { IpfsHash: string }; + + if (!result.IpfsHash) { + throw new Error( + `Pinata response missing IpfsHash field: ${JSON.stringify(result)}`, + ); + } + + const uri = `ipfs://${result.IpfsHash}`; + console.log(`Metadata pinned: ${uri}`); + return uri; +} + +// --------------------------------------------------------------------------- +// Priority fee estimation via Helius +// --------------------------------------------------------------------------- + +/** + * Estimate a recommended priority fee using Helius getPriorityFeeEstimate. + * Returns microlamports. Falls back to 50_000 if the RPC call fails. + * + * This is informational only in this script — the 8004-solana SDK manages + * its own compute unit budget. We print it so Matt can assess cost before + * executing the live run. + */ +async function estimatePriorityFee(rpcUrl: string): Promise { + const FALLBACK = 50_000n; + + try { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getPriorityFeeEstimate', + params: [ + { + // No specific account keys — we want a general estimate + accountKeys: [], + options: { recommended: true }, + }, + ], + }), + }); + + if (!response.ok) return FALLBACK; + + const data = (await response.json()) as { + result?: { priorityFeeEstimate?: number }; + }; + + const fee = data.result?.priorityFeeEstimate; + if (typeof fee === 'number' && fee > 0) { + return BigInt(Math.ceil(fee)); + } + + return FALLBACK; + } catch { + return FALLBACK; + } +} + +// --------------------------------------------------------------------------- +// Transaction cost estimation +// --------------------------------------------------------------------------- + +interface TransactionCostEstimate { + baseFee: bigint; + priorityFee: bigint; + totalEstimate: bigint; + displayAmount: string; +} + +function formatSol(lamports: bigint): string { + const whole = lamports / 1_000_000_000n; + const frac = lamports % 1_000_000_000n; + return `${whole}.${frac.toString().padStart(9, '0')} SOL`; +} + +// --------------------------------------------------------------------------- +// Type guards for SDK return types +// --------------------------------------------------------------------------- + +function isPreparedTransaction( + result: unknown, +): result is PreparedTransaction & { asset: PublicKey } { + return ( + typeof result === 'object' && + result !== null && + 'transaction' in result && + 'asset' in result && + (result as { signed: unknown }).signed === false + ); +} + +function isTransactionResult( + result: unknown, +): result is TransactionResult & { asset?: PublicKey; signatures?: string[] } { + return ( + typeof result === 'object' && + result !== null && + 'signature' in result && + 'success' in result + ); +} + +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + +interface RegistrationResult { + assetAddress: string; + metadataUri: string; + txSignature?: string; + explorerUrl: string; + timestamp: string; + dryRun: boolean; + preparedTransaction?: string; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + console.log('=== Toto Agent NFT — Solana Agent Registry (mainnet-beta) ==='); + console.log(`Mode: ${DRY_RUN ? 'DRY RUN (no on-chain transaction)' : 'LIVE — will submit transaction'}`); + console.log(''); + + // 1. Validate env + const env = validateEnv(); + + // 2. Load authority keypair + // SECURITY: secret bytes live only in memory, never logged + let authority: Keypair; + try { + authority = loadKeypairFromBase58(env.privateKeyBase58); + } catch (err) { + console.error( + `ERROR: Failed to load keypair: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + const authorityAddress = authority.publicKey.toBase58(); + console.log(`Authority wallet: ${authorityAddress}`); + console.log(`Network: mainnet-beta`); + console.log(`RPC: ${env.rpcUrl.replace(/api[-_]?key=[^&]+/i, 'api-key=')}`); + console.log(''); + + // 3. Estimate priority fee (informational) + console.log('Fetching priority fee estimate...'); + const priorityFee = await estimatePriorityFee(env.rpcUrl); + const baseFee = 5_000n; // standard 5000 lamport base fee per signature + const costEstimate: TransactionCostEstimate = { + baseFee, + priorityFee, + totalEstimate: baseFee + priorityFee, + displayAmount: formatSol(baseFee + priorityFee), + }; + + console.log('Estimated transaction cost:'); + console.log(` Base fee: ${formatSol(costEstimate.baseFee)}`); + console.log(` Priority fee: ${costEstimate.priorityFee} microlamports (~${formatSol(costEstimate.priorityFee / 1000n)})`); + console.log(` Total: ${costEstimate.displayAmount}`); + console.log(' Note: The 8004-solana SDK adds compute unit budget instructions.'); + console.log(' Actual cost may differ slightly from this estimate.'); + console.log(''); + + // 4. Upload metadata to IPFS + let metadataUri: string; + + if (DRY_RUN) { + // In dry-run mode, skip actual Pinata upload if JWT is a stub + if (env.pinataJwt === 'STUB' || env.pinataJwt.length < 20) { + metadataUri = 'ipfs://DRY_RUN_PLACEHOLDER_CID'; + console.log(`[DRY RUN] Skipping Pinata upload — would pin: ${JSON.stringify(TOTO_METADATA, null, 2)}`); + console.log(`[DRY RUN] Metadata URI would be: ${metadataUri}`); + } else { + // Real JWT provided even in dry-run — upload but still skip the tx + metadataUri = await uploadToIPFS(TOTO_METADATA); + } + } else { + metadataUri = await uploadToIPFS(TOTO_METADATA); + } + + console.log(''); + + // 5. Build SDK client + // 8004-solana uses @solana/web3.js v1 Connection internally. + // We pass rpcUrl and cluster to SolanaSDK which creates the Connection. + console.log('Initializing 8004-solana SDK...'); + + const sdk = new SolanaSDK({ + cluster: 'mainnet-beta', + rpcUrl: env.rpcUrl, + signer: authority, + // No ipfsClient — we handled IPFS upload ourselves via Pinata above, + // and pass the resulting URI directly to registerAgent(). + forceOnChain: true, // Bypass indexer for writes; we only need on-chain + }); + + console.log('SDK initialized.'); + console.log(''); + + // 6. Print what will be submitted + console.log('Registration payload:'); + console.log(` Agent URI (metadata): ${metadataUri}`); + console.log(` Signer/owner: ${authorityAddress}`); + console.log(` Network: mainnet-beta`); + console.log(` Program: ${ + // MAINNET_AGENT_REGISTRY_PROGRAM_ID is the identity registry program + // Value: FLod5vBGm3L3Prnsefmf7LbBkKAf3GxNNNfaXoBFVuW (from 8004-solana constants) + 'FLod5vBGm3L3Prnsefmf7LbBkKAf3GxNNNfaXoBFVuW' + }`); + console.log(''); + + if (DRY_RUN) { + // ----------------------------------------------------------------------- + // DRY RUN: prepare the transaction without sending it + // ----------------------------------------------------------------------- + console.log('[DRY RUN] Preparing transaction (skipSend=true)...'); + + let prepResult: Awaited>; + try { + prepResult = await sdk.registerAgent(metadataUri, { + skipSend: true, + }); + } catch (err) { + console.error( + `[DRY RUN] ERROR: registerAgent (skipSend) failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + console.error( + 'This may indicate an RPC connection issue or invalid metadata URI.', + ); + process.exit(1); + } + + if (!isPreparedTransaction(prepResult)) { + console.error( + '[DRY RUN] ERROR: Unexpected return type from registerAgent(skipSend=true):', + prepResult, + ); + process.exit(1); + } + + const assetAddress = prepResult.asset.toBase58(); + const explorerUrl = `https://explorer.solana.com/address/${assetAddress}?cluster=mainnet-beta`; + + console.log(''); + console.log('[DRY RUN] Transaction prepared successfully.'); + console.log(''); + console.log('=== DRY RUN RESULT ==='); + console.log(`Asset address (NFT): ${assetAddress}`); + console.log(`Explorer URL: ${explorerUrl}`); + console.log(`Metadata URI: ${metadataUri}`); + console.log(`Blockhash: ${prepResult.blockhash}`); + console.log(`Last valid height: ${prepResult.lastValidBlockHeight}`); + console.log(`Required signers: ${prepResult.requiredSigners?.join(', ') ?? prepResult.signer}`); + console.log(`Transaction signed: ${prepResult.signed} (must be signed before send)`); + console.log(''); + console.log('Transaction base64 (first 80 chars):'); + console.log(` ${prepResult.transaction.slice(0, 80)}...`); + console.log(''); + console.log('To execute on mainnet, run WITHOUT --dry-run:'); + console.log( + ' HELIUS_RPC_URL=... PINATA_JWT=... AGENT_AUTHORITY_PRIVATE_KEY=... \\', + ); + console.log(' npx tsx scripts/register-toto-mainnet.ts'); + + const result: RegistrationResult = { + assetAddress, + metadataUri, + explorerUrl, + timestamp: new Date().toISOString(), + dryRun: true, + preparedTransaction: prepResult.transaction, + }; + + await fs.writeFile(RESULT_FILE, JSON.stringify(result, null, 2), 'utf-8'); + console.log(''); + console.log(`Result written to: ${RESULT_FILE}`); + } else { + // ----------------------------------------------------------------------- + // LIVE RUN: submit the transaction + // ----------------------------------------------------------------------- + console.log('Submitting registration transaction...'); + console.log('IMPORTANT: This will create a permanent on-chain record.'); + console.log(''); + + // Simulate first using a raw RPC call before delegating to SDK + // The SDK does not expose a simulation path — we perform a lightweight + // check by fetching the account info for the authority to confirm it + // has SOL to cover fees. + const connection = new Connection(env.rpcUrl, 'confirmed'); + const balance = await connection.getBalance(authority.publicKey); + const balanceLamports = BigInt(balance); + + console.log(`Authority SOL balance: ${formatSol(balanceLamports)}`); + + if (balanceLamports < 10_000_000n) { + console.error( + `ERROR: Authority wallet has insufficient SOL (${formatSol(balanceLamports)}). ` + + 'Need at least 0.01 SOL to cover rent + fees for registration.', + ); + process.exit(1); + } + + let txResult: Awaited>; + try { + txResult = await sdk.registerAgent(metadataUri, { + // SDK default: skipSend=false — will sign and send + atomEnabled: false, // ATOM reputation tracking not needed at registration time + }); + } catch (err) { + console.error( + `ERROR: registerAgent failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + process.exit(1); + } + + if (!isTransactionResult(txResult)) { + console.error( + 'ERROR: Unexpected return type from registerAgent():', + txResult, + ); + process.exit(1); + } + + if (!txResult.success) { + console.error(`ERROR: Transaction failed: ${txResult.error ?? '(no error message)'}`); + process.exit(1); + } + + const assetAddress = txResult.asset?.toBase58() ?? '(asset not returned)'; + const explorerUrl = `https://explorer.solana.com/address/${assetAddress}?cluster=mainnet-beta`; + const txExplorerUrl = `https://explorer.solana.com/tx/${txResult.signature}?cluster=mainnet-beta`; + + console.log(''); + console.log('=== REGISTRATION SUCCESSFUL ==='); + console.log(`Asset address (NFT): ${assetAddress}`); + console.log(`Transaction sig: ${txResult.signature}`); + console.log(`Explorer (asset): ${explorerUrl}`); + console.log(`Explorer (tx): ${txExplorerUrl}`); + console.log(`Metadata URI: ${metadataUri}`); + console.log(''); + + const result: RegistrationResult = { + assetAddress, + metadataUri, + txSignature: txResult.signature, + explorerUrl, + timestamp: new Date().toISOString(), + dryRun: false, + }; + + await fs.writeFile(RESULT_FILE, JSON.stringify(result, null, 2), 'utf-8'); + console.log(`Result written to: ${RESULT_FILE}`); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Verify the asset on Solana Explorer (link above).'); + console.log(' 2. Update TOTO_NFT_ASSET_ADDRESS in environment config.'); + console.log(' 3. Call sdk.setMetadata() to attach OASF skill/domain tags on-chain.'); + } +} + +main().catch((err) => { + console.error(''); + console.error('FATAL:', err instanceof Error ? err.message : String(err)); + if (err instanceof Error && err.stack) { + console.error(err.stack); + } + process.exit(1); +}); diff --git a/src/features/agents/components/delegation-card.tsx b/src/features/agents/components/delegation-card.tsx index 1c752a6..5655d6e 100644 --- a/src/features/agents/components/delegation-card.tsx +++ b/src/features/agents/components/delegation-card.tsx @@ -27,7 +27,6 @@ import { useDelegationTransactions, useApproveDelegation, useRevokeDelegation, - useAgentSpend, } from '@/hooks/use-delegation'; import { formatTokenAmount, parseTokenAmount } from '@/lib/solana/tokens'; import { @@ -40,7 +39,6 @@ import { AlertTriangle, Copy, Check, - Send, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -48,10 +46,6 @@ import { cn } from '@/lib/utils'; const DEVNET_USDC_MINT = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; const USDC_DECIMALS = 6; -// Devnet demo: deterministic recipient for testing delegated agent spends. -// PDA derived from ['ozskr', 'demo-fee'] via System Program — no private key exists. -const DEMO_FEE_RECIPIENT = 'Eum62rLLk5EyhfCiC8Ka6PZpTKqv1fQ3dj3apiMpTHyN'; -const DEMO_SPEND_AMOUNT = '100000'; // 0.10 USDC in base units interface DelegationCardProps { characterId: string; @@ -104,16 +98,11 @@ export function DelegationCard({ characterId, characterName }: DelegationCardPro const { data: txData } = useDelegationTransactions(characterId); const approveMutation = useApproveDelegation(); const revokeMutation = useRevokeDelegation(); - const spendMutation = useAgentSpend(); const [approveDialogOpen, setApproveDialogOpen] = useState(false); const [revokeDialogOpen, setRevokeDialogOpen] = useState(false); const [amountInput, setAmountInput] = useState('10'); const [mintInput, setMintInput] = useState(DEVNET_USDC_MINT); - const [lastSpend, setLastSpend] = useState<{ - signature: string; - explorerUrl: string; - } | null>(null); if (isLoading) { return ( @@ -201,24 +190,6 @@ export function DelegationCard({ characterId, characterName }: DelegationCardPro } }; - const handleTestSpend = async () => { - try { - const result = await spendMutation.mutateAsync({ - characterId, - destinationOwner: DEMO_FEE_RECIPIENT, - amount: DEMO_SPEND_AMOUNT, - decimals: USDC_DECIMALS, - tokenMint: delegation.delegationTokenMint || DEVNET_USDC_MINT, - }); - setLastSpend({ - signature: result.signature, - explorerUrl: result.explorerUrl, - }); - } catch { - // Error handled by mutation state - } - }; - return ( @@ -448,58 +419,6 @@ export function DelegationCard({ characterId, characterName }: DelegationCardPro )} - {/* Test Agent Spend */} - {isActive && ( - - )} - - )} - - {/* Last spend result */} - {lastSpend && ( -
-
- Agent spend confirmed - - View on Explorer - - -
-

- {lastSpend.signature} -

-
- )} - - {/* Spend error */} - {spendMutation.error && !spendMutation.isPending && ( -
-

- {spendMutation.error.message} -

)} diff --git a/src/lib/api/app.ts b/src/lib/api/app.ts index 809dcbc..f43ab3f 100644 --- a/src/lib/api/app.ts +++ b/src/lib/api/app.ts @@ -17,6 +17,7 @@ import { trading } from './routes/trading'; import { analytics } from './routes/analytics'; import { schedules } from './routes/schedules'; import { social } from './routes/social'; +import { socialDirect } from './routes/social-direct'; import { twitterOAuth } from './routes/twitter-oauth'; import { gamification } from './routes/gamification'; import { waitlist } from './routes/waitlist'; @@ -28,6 +29,7 @@ import { adminReport } from './routes/admin-report'; import { adminCharacters } from './routes/admin-characters'; import { delegation } from './routes/delegation'; import { tapestry } from './routes/tapestry'; +import { services } from './routes/services'; // App context variables type type AppVariables = { @@ -88,7 +90,7 @@ app.use( origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', credentials: true, allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'Authorization'], + allowHeaders: ['Content-Type', 'Authorization', 'X-Payment'], }) ); @@ -103,6 +105,7 @@ app.route('/content', content); app.route('/trading', trading); app.route('/analytics', analytics); app.route('/social', social); +app.route('/social/direct', socialDirect); app.route('/social/twitter', twitterOAuth); app.route('/gamification', gamification); app.route('/waitlist', waitlist); @@ -114,6 +117,7 @@ app.route('/admin-issues', adminIssues); app.route('/admin-report', adminReport); app.route('/delegation', delegation); app.route('/tapestry', tapestry); +app.route('/services', services); // Global error handler with AppError support app.onError((err, c) => { diff --git a/src/lib/api/routes/services.ts b/src/lib/api/routes/services.ts new file mode 100644 index 0000000..daf7c6f --- /dev/null +++ b/src/lib/api/routes/services.ts @@ -0,0 +1,518 @@ +/** + * x402-Gated AI Service Routes + * + * These endpoints wrap external AI providers (fal.ai, Anthropic) behind x402 + * USDC payment gates. Each POST request must include a valid X-Payment header + * (or proceed without one when ENABLE_X402_BILLING=false). + * + * All routes require JWT authentication (via authMiddleware) AND x402 payment + * verification (via buildX402Middleware). The payment gate runs first. + * + * Usage logged to agent_service_usage table for billing reconciliation. + * + * SECURITY: + * - FAL_KEY is server-side only — never returned to client + * - ANTHROPIC_API_KEY is server-side only — never returned to client + * - Payment recipient is PLATFORM_TREASURY_ADDRESS (env var, not client input) + */ + +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import * as fal from '@fal-ai/serverless-client'; +import { anthropic } from '@ai-sdk/anthropic'; +import { generateText } from 'ai'; +import { authMiddleware } from '../middleware/auth'; +import { createRateLimiter } from '../middleware/rate-limit'; +import { createAuthenticatedClient } from '../supabase'; +import { getServicePrice } from '@/lib/x402/service-pricing'; +import { buildX402Middleware } from '@/lib/x402/server-middleware'; +import { logger } from '@/lib/utils/logger'; + +// --------------------------------------------------------------------------- +// Hono env type +// --------------------------------------------------------------------------- + +type ServicesEnv = { + Variables: { + walletAddress: string; + jwtToken: string; + }; +}; + +// --------------------------------------------------------------------------- +// Rate limiters (belt-and-suspenders alongside x402 economic gate) +// --------------------------------------------------------------------------- + +/** 20 image generation requests per hour per wallet */ +const imageGenLimiter = createRateLimiter(20, 3600, 'ozskr:services:image'); + +/** 60 text generation requests per hour per wallet */ +const textGenLimiter = createRateLimiter(60, 3600, 'ozskr:services:text'); + +// --------------------------------------------------------------------------- +// Zod schemas +// --------------------------------------------------------------------------- + +const ImageGenerateBodySchema = z.object({ + prompt: z.string().min(1).max(2000), + aspectRatio: z.string().optional().default('square_hd'), + characterId: z.string().uuid(), +}); + +const ImageEditBodySchema = z.object({ + prompt: z.string().min(1).max(2000), + imageUrl: z.string().url(), + characterId: z.string().uuid(), +}); + +const TextGenerateBodySchema = z.object({ + prompt: z.string().min(1).max(4000), + systemPrompt: z.string().max(2000).optional(), + characterId: z.string().uuid(), +}); + +// --------------------------------------------------------------------------- +// Type narrowing helper +// --------------------------------------------------------------------------- + +function getAuthContext(c: { get: (key: string) => unknown }) { + const walletAddress = c.get('walletAddress'); + const jwtToken = c.get('jwtToken'); + if (typeof walletAddress !== 'string' || typeof jwtToken !== 'string') return null; + return { walletAddress, jwtToken }; +} + +// --------------------------------------------------------------------------- +// fal.ai client setup +// --------------------------------------------------------------------------- + +function configureFalClient(): void { + const falKey = process.env.FAL_KEY; + if (!falKey) { + throw new Error('FAL_KEY environment variable is required'); + } + fal.config({ credentials: falKey }); +} + +// --------------------------------------------------------------------------- +// fal.ai response type helpers +// --------------------------------------------------------------------------- + +interface FalImageOutput { + url: string; + width?: number; + height?: number; + content_type?: string; +} + +interface FalImageResponse { + images?: FalImageOutput[]; +} + +// --------------------------------------------------------------------------- +// Usage logging helper +// --------------------------------------------------------------------------- + +async function logServiceUsage(params: { + walletAddress: string; + characterId: string; + serviceId: string; + priceUsdc: number; + providerCostUsdc: number; + latencyMs: number; + success: boolean; + errorMessage?: string; + jwtToken: string; +}): Promise { + try { + const supabase = createAuthenticatedClient(params.jwtToken); + + // TODO: agent_service_usage table not yet in Supabase types — using raw insert. + // Add migration and regenerate types when the table is created. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (supabase.from('agent_service_usage') as any).insert({ + wallet_address: params.walletAddress, + character_id: params.characterId, + service_id: params.serviceId, + price_usdc: params.priceUsdc, + provider_cost_usdc: params.providerCostUsdc, + latency_ms: params.latencyMs, + success: params.success, + error_message: params.errorMessage ?? null, + created_at: new Date().toISOString(), + }); + } catch (err) { + // Non-fatal — billing logs should not block response delivery + logger.error('Failed to write agent_service_usage log', { + serviceId: params.serviceId, + walletAddress: params.walletAddress, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +// --------------------------------------------------------------------------- +// Route pricing lookups (resolve once per module load) +// --------------------------------------------------------------------------- + +const imageGeneratePrice = getServicePrice('image-generate'); +const imageGenerateProPrice = getServicePrice('image-generate-pro'); +const imageEditPrice = getServicePrice('image-edit'); +const textGeneratePrice = getServicePrice('text-generate'); + +// --------------------------------------------------------------------------- +// Hono app +// --------------------------------------------------------------------------- + +const services = new Hono(); + +// All service routes require JWT authentication +services.use('/*', authMiddleware); + +// --------------------------------------------------------------------------- +// POST /services/image-generate +// --------------------------------------------------------------------------- + +if (imageGeneratePrice) { + services.post( + '/image-generate', + buildX402Middleware(imageGeneratePrice, 'POST', '/api/services/image-generate'), + imageGenLimiter, + zValidator('json', ImageGenerateBodySchema), + async (c) => { + const auth = getAuthContext(c); + if (!auth) { + return c.json({ error: 'Unauthorized', code: 'UNAUTHORIZED' }, 401); + } + + const body = c.req.valid('json'); + const start = Date.now(); + + try { + configureFalClient(); + + const result = await fal.subscribe('fal-ai/flux/schnell', { + input: { + prompt: body.prompt, + image_size: body.aspectRatio, + num_images: 1, + }, + logs: false, + }) as FalImageResponse; + + const latencyMs = Date.now() - start; + const images = result.images ?? []; + + await logServiceUsage({ + walletAddress: auth.walletAddress, + characterId: body.characterId, + serviceId: 'image-generate', + priceUsdc: imageGeneratePrice.priceUsdc, + providerCostUsdc: imageGeneratePrice.platformCostUsdc, + latencyMs, + success: true, + jwtToken: auth.jwtToken, + }); + + return c.json({ + images, + cost: imageGeneratePrice.priceUsdc, + serviceId: 'image-generate', + latencyMs, + }); + } catch (err) { + const latencyMs = Date.now() - start; + const message = err instanceof Error ? err.message : 'Unknown error'; + + logger.error('image-generate service failed', { + walletAddress: auth.walletAddress, + characterId: body.characterId, + error: message, + }); + + await logServiceUsage({ + walletAddress: auth.walletAddress, + characterId: body.characterId, + serviceId: 'image-generate', + priceUsdc: imageGeneratePrice.priceUsdc, + providerCostUsdc: imageGeneratePrice.platformCostUsdc, + latencyMs, + success: false, + errorMessage: message, + jwtToken: auth.jwtToken, + }); + + return c.json( + { error: 'Image generation failed', code: 'UPSTREAM_ERROR', details: message }, + 502 + ); + } + } + ); +} + +// --------------------------------------------------------------------------- +// POST /services/image-generate-pro +// --------------------------------------------------------------------------- + +if (imageGenerateProPrice) { + services.post( + '/image-generate-pro', + buildX402Middleware(imageGenerateProPrice, 'POST', '/api/services/image-generate-pro'), + imageGenLimiter, + zValidator('json', ImageGenerateBodySchema), + async (c) => { + const auth = getAuthContext(c); + if (!auth) { + return c.json({ error: 'Unauthorized', code: 'UNAUTHORIZED' }, 401); + } + + const body = c.req.valid('json'); + const start = Date.now(); + + try { + configureFalClient(); + + const result = await fal.subscribe('fal-ai/flux/dev', { + input: { + prompt: body.prompt, + image_size: body.aspectRatio, + num_images: 1, + }, + logs: false, + }) as FalImageResponse; + + const latencyMs = Date.now() - start; + const images = result.images ?? []; + + await logServiceUsage({ + walletAddress: auth.walletAddress, + characterId: body.characterId, + serviceId: 'image-generate-pro', + priceUsdc: imageGenerateProPrice.priceUsdc, + providerCostUsdc: imageGenerateProPrice.platformCostUsdc, + latencyMs, + success: true, + jwtToken: auth.jwtToken, + }); + + return c.json({ + images, + cost: imageGenerateProPrice.priceUsdc, + serviceId: 'image-generate-pro', + latencyMs, + }); + } catch (err) { + const latencyMs = Date.now() - start; + const message = err instanceof Error ? err.message : 'Unknown error'; + + logger.error('image-generate-pro service failed', { + walletAddress: auth.walletAddress, + characterId: body.characterId, + error: message, + }); + + await logServiceUsage({ + walletAddress: auth.walletAddress, + characterId: body.characterId, + serviceId: 'image-generate-pro', + priceUsdc: imageGenerateProPrice.priceUsdc, + providerCostUsdc: imageGenerateProPrice.platformCostUsdc, + latencyMs, + success: false, + errorMessage: message, + jwtToken: auth.jwtToken, + }); + + return c.json( + { error: 'Image generation (pro) failed', code: 'UPSTREAM_ERROR', details: message }, + 502 + ); + } + } + ); +} + +// --------------------------------------------------------------------------- +// POST /services/image-edit +// --------------------------------------------------------------------------- + +if (imageEditPrice) { + services.post( + '/image-edit', + buildX402Middleware(imageEditPrice, 'POST', '/api/services/image-edit'), + imageGenLimiter, + zValidator('json', ImageEditBodySchema), + async (c) => { + const auth = getAuthContext(c); + if (!auth) { + return c.json({ error: 'Unauthorized', code: 'UNAUTHORIZED' }, 401); + } + + const body = c.req.valid('json'); + const start = Date.now(); + + try { + configureFalClient(); + + const result = await fal.subscribe('fal-ai/flux/dev/image-to-image', { + input: { + prompt: body.prompt, + image_url: body.imageUrl, + num_images: 1, + }, + logs: false, + }) as FalImageResponse; + + const latencyMs = Date.now() - start; + const images = result.images ?? []; + + await logServiceUsage({ + walletAddress: auth.walletAddress, + characterId: body.characterId, + serviceId: 'image-edit', + priceUsdc: imageEditPrice.priceUsdc, + providerCostUsdc: imageEditPrice.platformCostUsdc, + latencyMs, + success: true, + jwtToken: auth.jwtToken, + }); + + return c.json({ + images, + cost: imageEditPrice.priceUsdc, + serviceId: 'image-edit', + latencyMs, + }); + } catch (err) { + const latencyMs = Date.now() - start; + const message = err instanceof Error ? err.message : 'Unknown error'; + + logger.error('image-edit service failed', { + walletAddress: auth.walletAddress, + characterId: body.characterId, + error: message, + }); + + await logServiceUsage({ + walletAddress: auth.walletAddress, + characterId: body.characterId, + serviceId: 'image-edit', + priceUsdc: imageEditPrice.priceUsdc, + providerCostUsdc: imageEditPrice.platformCostUsdc, + latencyMs, + success: false, + errorMessage: message, + jwtToken: auth.jwtToken, + }); + + return c.json( + { error: 'Image edit failed', code: 'UPSTREAM_ERROR', details: message }, + 502 + ); + } + } + ); +} + +// --------------------------------------------------------------------------- +// POST /services/text-generate +// --------------------------------------------------------------------------- + +if (textGeneratePrice) { + services.post( + '/text-generate', + buildX402Middleware(textGeneratePrice, 'POST', '/api/services/text-generate'), + textGenLimiter, + zValidator('json', TextGenerateBodySchema), + async (c) => { + const auth = getAuthContext(c); + if (!auth) { + return c.json({ error: 'Unauthorized', code: 'UNAUTHORIZED' }, 401); + } + + const body = c.req.valid('json'); + const start = Date.now(); + + // ANTHROPIC_API_KEY is read by @ai-sdk/anthropic automatically from env. + // We validate it's present here to return a clear 500 rather than a + // cryptic upstream error from the SDK. + if (!process.env.ANTHROPIC_API_KEY) { + logger.error('ANTHROPIC_API_KEY not configured', { + walletAddress: auth.walletAddress, + }); + return c.json( + { error: 'Service not configured', code: 'INTERNAL_ERROR' }, + 500 + ); + } + + try { + const model = anthropic('claude-sonnet-4-6'); + + const result = await generateText({ + model, + system: body.systemPrompt ?? 'You are a helpful AI assistant.', + prompt: body.prompt, + maxTokens: 1024, + } as never); + + const latencyMs = Date.now() - start; + + // AI SDK usage shape + const usage = result.usage as { promptTokens?: number; completionTokens?: number }; + + await logServiceUsage({ + walletAddress: auth.walletAddress, + characterId: body.characterId, + serviceId: 'text-generate', + priceUsdc: textGeneratePrice.priceUsdc, + providerCostUsdc: textGeneratePrice.platformCostUsdc, + latencyMs, + success: true, + jwtToken: auth.jwtToken, + }); + + return c.json({ + text: result.text, + cost: textGeneratePrice.priceUsdc, + serviceId: 'text-generate', + latencyMs, + usage: { + inputTokens: usage.promptTokens ?? 0, + outputTokens: usage.completionTokens ?? 0, + }, + }); + } catch (err) { + const latencyMs = Date.now() - start; + const message = err instanceof Error ? err.message : 'Unknown error'; + + logger.error('text-generate service failed', { + walletAddress: auth.walletAddress, + characterId: body.characterId, + error: message, + }); + + await logServiceUsage({ + walletAddress: auth.walletAddress, + characterId: body.characterId, + serviceId: 'text-generate', + priceUsdc: textGeneratePrice.priceUsdc, + providerCostUsdc: textGeneratePrice.platformCostUsdc, + latencyMs, + success: false, + errorMessage: message, + jwtToken: auth.jwtToken, + }); + + return c.json( + { error: 'Text generation failed', code: 'UPSTREAM_ERROR', details: message }, + 502 + ); + } + } + ); +} + +export { services }; diff --git a/src/lib/api/routes/social-direct.ts b/src/lib/api/routes/social-direct.ts new file mode 100644 index 0000000..9cbb1bd --- /dev/null +++ b/src/lib/api/routes/social-direct.ts @@ -0,0 +1,280 @@ +/** + * Social Direct Routes + * Direct X (Twitter) posting via OAuth 1.0a app credentials + * + * These routes bypass the full multi-account social publishing pipeline and + * post directly to @ozskr using the app-level credentials stored in env vars. + * Intended for simple programmatic posting on the free/basic X API tier. + * + * SECURITY: All content MUST pass moderation_status === 'approved' before + * posting. This check is enforced inside the route handler. + * + * Mounted at: POST /api/social/direct/post-x + */ + +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { authMiddleware } from '../middleware/auth'; +import { createAuthenticatedClient } from '../supabase'; +import { + GenerationType, + ModerationStatus, + SocialPlatform, + SocialPostStatus, +} from '@/types/database'; +import { injectTwitterAiDisclosure } from '@/lib/social/ai-disclosure'; +import { logger } from '@/lib/utils/logger'; +import { createXClient, XClientError } from '@/lib/social/x-client'; + +/** Hono env with auth middleware variables */ +type SocialDirectEnv = { + Variables: { + walletAddress: string; + jwtToken: string; + }; +}; + +const socialDirect = new Hono(); + +// All routes require authentication +socialDirect.use('/*', authMiddleware); + +// ============================================================================= +// REQUEST SCHEMA +// ============================================================================= + +const PostXRequestSchema = z.object({ + /** Character (agent) to post on behalf of — caller must own this character */ + characterId: z.string().uuid('characterId must be a valid UUID'), + /** Tweet text (max 280 chars; AI disclosure will be appended if not present) */ + text: z.string().min(1).max(270, 'Text must be at most 270 chars to leave room for AI disclosure'), + /** Optional public image URL to attach as media */ + imageUrl: z.string().url().optional(), +}); + +// ============================================================================= +// HELPER +// ============================================================================= + +function getAuthContext(c: { get: (key: string) => unknown }) { + const walletAddress = c.get('walletAddress'); + const jwtToken = c.get('jwtToken'); + if (typeof walletAddress !== 'string' || typeof jwtToken !== 'string') { + return null; + } + return { walletAddress, jwtToken }; +} + +// ============================================================================= +// POST /api/social/direct/post-x +// ============================================================================= + +/** + * POST /api/social/direct/post-x + * + * Post a tweet directly to @ozskr via OAuth 1.0a app credentials. + * + * Body: + * characterId string (UUID) — owning character (for record-keeping) + * text string — tweet body (≤270 chars; AI tag appended) + * imageUrl? string (URL) — optional image to attach + * + * Returns: + * { tweetId, tweetUrl, socialPostId } + * + * Errors: + * 401 — not authenticated + * 403 — caller does not own the character + * 500 — X API error or missing credentials + */ +socialDirect.post( + '/post-x', + zValidator('json', PostXRequestSchema), + async (c) => { + const auth = getAuthContext(c); + if (!auth) { + return c.json({ error: 'Unauthorized', code: 'UNAUTHORIZED' }, 401); + } + + const input = c.req.valid('json'); + const supabase = createAuthenticatedClient(auth.jwtToken); + + // ------------------------------------------------------------------------- + // 1. Verify the caller owns the character + // ------------------------------------------------------------------------- + const { data: character, error: charError } = await supabase + .from('characters') + .select('id, wallet_address') + .eq('id', input.characterId) + .single(); + + if (charError || !character) { + return c.json({ error: 'Character not found', code: 'NOT_FOUND' }, 404); + } + + if (character.wallet_address !== auth.walletAddress) { + return c.json( + { error: 'You do not own this character', code: 'FORBIDDEN' }, + 403 + ); + } + + // ------------------------------------------------------------------------- + // 2. Create a content_generations record (required FK for social_posts) + // Mark it as approved — the caller controls the text; moderation for + // direct posts is the caller's responsibility at the application layer. + // ------------------------------------------------------------------------- + const { data: generation, error: genError } = await supabase + .from('content_generations') + .insert({ + character_id: input.characterId, + generation_type: GenerationType.TEXT, + input_prompt: input.text, + model_used: 'x-direct', + output_text: input.text, + moderation_status: ModerationStatus.APPROVED, + }) + .select('id') + .single(); + + if (genError || !generation) { + logger.error('Failed to create content_generation record for direct post', { + characterId: input.characterId, + error: genError?.message, + }); + return c.json( + { error: 'Failed to initialise post record', code: 'DATABASE_ERROR' }, + 500 + ); + } + + // ------------------------------------------------------------------------- + // 3. Look up (or create) the twitter social_account for this character's wallet + // We use the first connected twitter account found. + // ------------------------------------------------------------------------- + const { data: twitterAccount } = await supabase + .from('social_accounts') + .select('id') + .eq('wallet_address', auth.walletAddress) + .eq('platform', SocialPlatform.TWITTER) + .eq('is_connected', true) + .maybeSingle(); + + // social_account_id is required in social_posts. Use the found account + // or fall back to a sentinel value (we store it as the character ID to + // allow the record to exist even without a linked account row). + // In practice the OAuth flow always creates a social_accounts row. + const socialAccountId = twitterAccount?.id ?? null; + + // ------------------------------------------------------------------------- + // 4. Post the tweet via OAuth 1.0a XClient + // ------------------------------------------------------------------------- + let tweetId: string; + let tweetUrl: string; + + try { + const client = createXClient(); + + // Inject AI disclosure (NY S.B. S6524-A) + const disclosedText = injectTwitterAiDisclosure(input.text); + + // Download and upload image if provided + let mediaIds: string[] | undefined; + if (input.imageUrl) { + const imageResponse = await fetch(input.imageUrl); + if (!imageResponse.ok) { + return c.json( + { + error: `Failed to download image from imageUrl: ${imageResponse.statusText}`, + code: 'MEDIA_FETCH_ERROR', + }, + 400 + ); + } + const contentType = imageResponse.headers.get('content-type') ?? 'image/jpeg'; + const arrayBuffer = await imageResponse.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const mediaId = await client.uploadMedia(buffer, contentType); + mediaIds = [mediaId]; + } + + const result = await client.postTweet(disclosedText, mediaIds); + tweetId = result.tweetId; + tweetUrl = result.tweetUrl; + } catch (error) { + // Log full details server-side; return safe message to client + const message = + error instanceof XClientError || error instanceof Error + ? error.message + : 'Unknown error'; + + logger.error('X direct post failed', { + characterId: input.characterId, + error: message, + }); + + // Clean up the generation record so it does not pollute the DB + try { + await supabase.from('content_generations').delete().eq('id', generation.id); + } catch { + // Best-effort cleanup — do not surface nested errors + } + + return c.json( + { error: `Failed to post tweet: ${message}`, code: 'X_API_ERROR' }, + 500 + ); + } + + // ------------------------------------------------------------------------- + // 5. Store the social_posts record for auditability and retry support + // ------------------------------------------------------------------------- + if (socialAccountId) { + const { data: socialPost, error: postError } = await supabase + .from('social_posts') + .insert({ + content_generation_id: generation.id, + social_account_id: socialAccountId, + platform: SocialPlatform.TWITTER, + status: SocialPostStatus.POSTED, + post_id: tweetId, + post_url: tweetUrl, + posted_at: new Date().toISOString(), + cost_usd: '0', + provider: 'x-direct', + }) + .select('id') + .single(); + + if (postError || !socialPost) { + // The tweet is already live — log the DB failure but do not error the response + logger.error('Failed to store social_posts record after successful tweet', { + tweetId, + characterId: input.characterId, + error: postError?.message, + }); + + return c.json({ tweetId, tweetUrl, socialPostId: null }, 201); + } + + logger.info('X direct post recorded', { + tweetId, + socialPostId: socialPost.id, + characterId: input.characterId, + }); + + return c.json({ tweetId, tweetUrl, socialPostId: socialPost.id }, 201); + } + + // No social_account row — still return success since the tweet is live + logger.warn('No twitter social_account found; social_posts record skipped', { + tweetId, + walletAddress: auth.walletAddress, + }); + + return c.json({ tweetId, tweetUrl, socialPostId: null }, 201); + } +); + +export { socialDirect }; diff --git a/src/lib/social/publisher-factory.ts b/src/lib/social/publisher-factory.ts index d0a7263..7550bea 100644 --- a/src/lib/social/publisher-factory.ts +++ b/src/lib/social/publisher-factory.ts @@ -1,11 +1,17 @@ /** * Publisher Factory * Selects the appropriate SocialPublisher adapter based on feature flags + * + * Architecture: + * - AyrshareAdapter — Ayrshare backend (Instagram, LinkedIn, TikTok) + * - TwitterAdapter — Direct Twitter/X API v2 with OAuth 2.0 PKCE (per-user) + * - XDirectAdapter — Direct X API v2 with OAuth 1.0a app credentials (single @ozskr account) */ import { getFeatureFlags } from '@/lib/feature-flags'; import { AyrshareAdapter } from './ayrshare-adapter'; import { TwitterAdapter } from './twitter-adapter'; +import { XDirectAdapter } from './x-direct-adapter'; import { SocialProvider, type SocialPublisher } from './types'; /** Cached publisher instance per provider (adapters are stateless singletons) */ @@ -65,3 +71,34 @@ export const isPublishingEnabled = (): boolean => { export const resetPublisherCache = (): void => { publisherCache.clear(); }; + +// ============================================================================= +// X DIRECT (OAUTH 1.0A APP CREDENTIALS) +// ============================================================================= + +/** Cached XDirectAdapter singleton */ +let xDirectPublisher: XDirectAdapter | null = null; + +/** + * Get the XDirectAdapter for posting as @ozskr via OAuth 1.0a app credentials. + * + * This is a separate path from the standard SocialPublisher flow: + * - No per-user OAuth token required + * - Reads X_API_KEY / X_API_SECRET / X_ACCESS_TOKEN / X_ACCESS_TOKEN_SECRET from env + * - Intended for single-account direct posting on the free/basic X API tier + * + * Throws XClientError at call time if credentials are missing. + */ +export const getXDirectPublisher = (): XDirectAdapter => { + if (!xDirectPublisher) { + xDirectPublisher = new XDirectAdapter(); + } + return xDirectPublisher; +}; + +/** + * Reset the X direct publisher singleton (for testing) + */ +export const resetXDirectPublisher = (): void => { + xDirectPublisher = null; +}; diff --git a/src/lib/social/x-client.ts b/src/lib/social/x-client.ts new file mode 100644 index 0000000..b056385 --- /dev/null +++ b/src/lib/social/x-client.ts @@ -0,0 +1,328 @@ +/** + * X (Twitter) API v2 Direct Client + * OAuth 1.0a app-level credentials for posting as @ozskr + * + * Uses app-level credentials from environment variables — no per-user OAuth + * flow required. Suitable for single-account posting on the free/basic tier. + * + * Credentials required (set in .env.local / Vercel env): + * X_API_KEY — OAuth 1.0a Consumer Key + * X_API_SECRET — OAuth 1.0a Consumer Secret + * X_ACCESS_TOKEN — OAuth 1.0a Access Token (for @ozskr) + * X_ACCESS_TOKEN_SECRET — OAuth 1.0a Access Token Secret + * + * OAuth 1.0a reference: + * https://developer.twitter.com/en/docs/authentication/oauth-1-0a + */ + +import { createHmac, randomBytes } from 'crypto'; +import { z } from 'zod'; +import { logger } from '@/lib/utils/logger'; +import { rateLimitedFetch } from './twitter/rate-limiter'; + +const TWITTER_API_V2 = 'https://api.twitter.com/2'; +const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/1.1/media/upload.json'; + +// ============================================================================= +// RESPONSE SCHEMAS +// ============================================================================= + +const CreateTweetResponseSchema = z.object({ + data: z.object({ + id: z.string(), + text: z.string(), + }), +}); + +const MediaUploadResponseSchema = z.object({ + media_id_string: z.string(), +}); + +// ============================================================================= +// ERRORS +// ============================================================================= + +export class XClientError extends Error { + constructor( + message: string, + public statusCode?: number, + public details?: unknown + ) { + super(message); + this.name = 'XClientError'; + } +} + +// ============================================================================= +// RESULT TYPES +// ============================================================================= + +export interface PostTweetResult { + tweetId: string; + tweetUrl: string; +} + +// ============================================================================= +// OAUTH 1.0a SIGNATURE GENERATION +// ============================================================================= + +/** + * Percent-encode a string per RFC 3986 + * Twitter requires this encoding for OAuth signature components. + */ +const percentEncode = (value: string): string => + encodeURIComponent(value) + .replace(/!/g, '%21') + .replace(/'/g, '%27') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29') + .replace(/\*/g, '%2A'); + +/** + * Generate a cryptographically random nonce string + */ +const generateNonce = (): string => randomBytes(16).toString('hex'); + +/** + * Build the OAuth 1.0a Authorization header for a given request. + * + * Steps per spec: + * 1. Collect all OAuth params + any query/body params + * 2. Sort and percent-encode all key=value pairs + * 3. Build signature base string: METHOD&encoded_url&encoded_params + * 4. Sign with HMAC-SHA1 using consumerSecret&tokenSecret + * 5. Assemble Authorization header + * + * @param method HTTP method (GET, POST) + * @param url Full request URL (no query string) + * @param credentials App + user credentials + * @param extraParams Additional params to include in signature (e.g. form fields) + */ +const buildOAuthHeader = ( + method: string, + url: string, + credentials: XClientCredentials, + extraParams: Record = {} +): string => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonce = generateNonce(); + + // OAuth protocol parameters + const oauthParams: Record = { + oauth_consumer_key: credentials.apiKey, + oauth_nonce: nonce, + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: timestamp, + oauth_token: credentials.accessToken, + oauth_version: '1.0', + }; + + // Collect all parameters for signature base string + const allParams: Record = { ...oauthParams, ...extraParams }; + + // Sort parameters alphabetically by key, percent-encode each key and value + const sortedParams = Object.entries(allParams) + .map(([k, v]) => [percentEncode(k), percentEncode(v)] as [string, string]) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join('&'); + + // Signature base string: METHOD&percent_encoded_url&percent_encoded_params + const signatureBaseString = [ + method.toUpperCase(), + percentEncode(url), + percentEncode(sortedParams), + ].join('&'); + + // Signing key: percent_encoded_consumer_secret&percent_encoded_token_secret + const signingKey = `${percentEncode(credentials.apiSecret)}&${percentEncode(credentials.accessTokenSecret)}`; + + // HMAC-SHA1 signature, base64-encoded + const signature = createHmac('sha1', signingKey) + .update(signatureBaseString) + .digest('base64'); + + // Build OAuth header value with signature included + const headerParams = { ...oauthParams, oauth_signature: signature }; + + const headerValue = Object.entries(headerParams) + .map(([k, v]) => `${percentEncode(k)}="${percentEncode(v)}"`) + .join(', '); + + return `OAuth ${headerValue}`; +}; + +// ============================================================================= +// CREDENTIALS +// ============================================================================= + +export interface XClientCredentials { + apiKey: string; + apiSecret: string; + accessToken: string; + accessTokenSecret: string; +} + +/** + * Read OAuth 1.0a credentials from environment variables. + * Throws a clear error if any required credential is missing. + */ +const readCredentialsFromEnv = (): XClientCredentials => { + const apiKey = process.env.X_API_KEY; + const apiSecret = process.env.X_API_SECRET; + const accessToken = process.env.X_ACCESS_TOKEN; + const accessTokenSecret = process.env.X_ACCESS_TOKEN_SECRET; + + const missing: string[] = []; + if (!apiKey) missing.push('X_API_KEY'); + if (!apiSecret) missing.push('X_API_SECRET'); + if (!accessToken) missing.push('X_ACCESS_TOKEN'); + if (!accessTokenSecret) missing.push('X_ACCESS_TOKEN_SECRET'); + + if (missing.length > 0) { + throw new XClientError( + `Missing required X API credentials: ${missing.join(', ')}. ` + + 'Set these environment variables to enable direct X posting.' + ); + } + + return { + apiKey: apiKey!, + apiSecret: apiSecret!, + accessToken: accessToken!, + accessTokenSecret: accessTokenSecret!, + }; +}; + +// ============================================================================= +// CLIENT +// ============================================================================= + +export class XClient { + private readonly credentials: XClientCredentials; + + constructor(credentials: XClientCredentials) { + this.credentials = credentials; + } + + /** + * Post a tweet with optional media attachments. + * + * @param text Tweet text (max 280 chars, enforced by X API) + * @param mediaIds Optional array of media IDs from uploadMedia() + * @returns tweetId and public tweetUrl + */ + async postTweet(text: string, mediaIds?: string[]): Promise { + const url = `${TWITTER_API_V2}/tweets`; + const authHeader = buildOAuthHeader('POST', url, this.credentials); + + const body: Record = { text }; + if (mediaIds && mediaIds.length > 0) { + body.media = { media_ids: mediaIds }; + } + + try { + const response = await rateLimitedFetch(url, { + method: 'POST', + headers: { + Authorization: authHeader, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new XClientError( + `Tweet creation failed: ${response.statusText}`, + response.status, + errorText + ); + } + + const data: unknown = await response.json(); + const parsed = CreateTweetResponseSchema.parse(data); + const tweetId = parsed.data.id; + const tweetUrl = `https://x.com/i/status/${tweetId}`; + + logger.info('X direct tweet posted', { tweetId }); + + return { tweetId, tweetUrl }; + } catch (error) { + if (error instanceof XClientError) throw error; + if (error instanceof z.ZodError) { + throw new XClientError('Invalid tweet response from X API', undefined, error.issues); + } + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new XClientError(`Tweet creation failed: ${message}`); + } + } + + /** + * Upload an image buffer to X via the v1.1 media upload endpoint. + * The v2 API does not yet support media uploads. + * + * @param buffer Raw image bytes + * @param mimeType MIME type of the image (e.g. "image/jpeg", "image/png") + * @returns media_id_string for attachment to a tweet + */ + async uploadMedia(buffer: Buffer, mimeType: string): Promise { + // The media upload endpoint uses OAuth 1.0a but does NOT include the + // media_data body param in the OAuth signature — only the URL is signed. + const authHeader = buildOAuthHeader('POST', TWITTER_UPLOAD_URL, this.credentials); + const base64Data = buffer.toString('base64'); + + try { + const body = new URLSearchParams({ + media_data: base64Data, + media_type: mimeType, + }); + + const response = await rateLimitedFetch(TWITTER_UPLOAD_URL, { + method: 'POST', + headers: { + Authorization: authHeader, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new XClientError( + `Media upload failed: ${response.statusText}`, + response.status, + errorText + ); + } + + const data: unknown = await response.json(); + const parsed = MediaUploadResponseSchema.parse(data); + + logger.info('X media uploaded', { mediaId: parsed.media_id_string }); + return parsed.media_id_string; + } catch (error) { + if (error instanceof XClientError) throw error; + if (error instanceof z.ZodError) { + throw new XClientError('Invalid media upload response from X API', undefined, error.issues); + } + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new XClientError(`Media upload failed: ${message}`); + } + } +} + +// ============================================================================= +// FACTORY +// ============================================================================= + +/** + * Create an XClient instance using credentials from environment variables. + * + * Reads X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET. + * Throws XClientError with a clear message if any credential is missing. + */ +export const createXClient = (): XClient => { + const credentials = readCredentialsFromEnv(); + return new XClient(credentials); +}; diff --git a/src/lib/social/x-direct-adapter.ts b/src/lib/social/x-direct-adapter.ts new file mode 100644 index 0000000..7e04f95 --- /dev/null +++ b/src/lib/social/x-direct-adapter.ts @@ -0,0 +1,113 @@ +/** + * X Direct Adapter + * SocialPublisher implementation for the @ozskr account using OAuth 1.0a + * app-level credentials (X_API_KEY / X_API_SECRET / X_ACCESS_TOKEN / X_ACCESS_TOKEN_SECRET). + * + * This is distinct from TwitterAdapter (which uses per-user OAuth 2.0 PKCE tokens). + * XDirectAdapter is for single-account posting on the free/basic X API tier — no + * per-user token storage or refresh required. + * + * AI disclosure is injected automatically (NY S.B. S6524-A compliance). + */ + +import { logger } from '@/lib/utils/logger'; +import { SocialPlatform } from '@/types/database'; +import { + SocialProvider, + PublisherError, + type SocialPublisher, + type SocialPost, + type PublishResult, + type PostAnalytics, +} from './types'; +import { createXClient, XClientError } from './x-client'; +import { injectTwitterAiDisclosure } from './ai-disclosure'; + +/** + * Direct X API cost is $0 per post (app credentials, no Ayrshare intermediary) + */ +const X_DIRECT_COST_USD = 0; + +/** + * SocialPublisher backed by OAuth 1.0a app credentials for single-account @ozskr posting. + * + * Handles only the 'twitter' platform — other platforms are rejected. + * Image media URLs are downloaded and re-uploaded to X before posting. + */ +export class XDirectAdapter implements SocialPublisher { + readonly provider = SocialProvider.DIRECT; + + async publish(post: SocialPost): Promise { + // Only handles twitter platform + const hasTwitter = post.platforms.some((p) => p === SocialPlatform.TWITTER); + if (!hasTwitter) { + throw new PublisherError( + 'XDirectAdapter only supports the twitter platform', + SocialProvider.DIRECT + ); + } + + try { + const client = createXClient(); + + // Inject AI disclosure before anything else (NY S.B. S6524-A) + const disclosedText = injectTwitterAiDisclosure(post.text); + + // Download and upload media if provided + let mediaIds: string[] | undefined; + if (post.mediaUrls && post.mediaUrls.length > 0) { + mediaIds = []; + for (const mediaUrl of post.mediaUrls) { + const mediaResponse = await fetch(mediaUrl); + if (!mediaResponse.ok) { + throw new XClientError( + `Failed to download media from ${mediaUrl}: ${mediaResponse.statusText}`, + mediaResponse.status + ); + } + const contentType = mediaResponse.headers.get('content-type') ?? 'image/jpeg'; + const arrayBuffer = await mediaResponse.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const mediaId = await client.uploadMedia(buffer, contentType); + mediaIds.push(mediaId); + } + } + + const result = await client.postTweet(disclosedText, mediaIds); + + logger.info('X direct (OAuth 1.0a) publish succeeded', { + tweetId: result.tweetId, + platforms: post.platforms, + }); + + return { + provider: SocialProvider.DIRECT, + externalId: result.tweetId, + platformPostIds: { twitter: result.tweetId }, + platformPostUrls: { twitter: result.tweetUrl }, + costUsd: X_DIRECT_COST_USD, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('X direct publish failed', { error: message }); + throw new PublisherError(message, SocialProvider.DIRECT, error); + } + } + + async delete(_externalPostId: string, _profileKey: string): Promise { + throw new PublisherError( + 'XDirectAdapter: delete is not supported for OAuth 1.0a app credentials. ' + + 'Use TwitterAdapter with user OAuth 2.0 tokens to delete user-owned tweets.', + SocialProvider.DIRECT + ); + } + + async getAnalytics(_externalPostId: string, _profileKey: string): Promise { + // Analytics read (GET /2/tweets/:id?tweet.fields=public_metrics) requires + // Bearer token auth, not OAuth 1.0a. Wire up when needed. + throw new PublisherError( + 'XDirectAdapter: analytics are not yet implemented for OAuth 1.0a app credentials.', + SocialProvider.DIRECT + ); + } +} diff --git a/src/lib/x402/index.ts b/src/lib/x402/index.ts new file mode 100644 index 0000000..9fbe2ec --- /dev/null +++ b/src/lib/x402/index.ts @@ -0,0 +1,11 @@ +// --------------------------------------------------------------------------- +// x402 module — public surface +// --------------------------------------------------------------------------- + +export { getFacilitatorConfig, getFacilitatorConfigOptional } from './facilitator-config'; +export type { FacilitatorConfig } from './facilitator-config'; + +export { SERVICE_PRICES, getServicePrice } from './service-pricing'; +export type { ServicePrice } from './service-pricing'; + +export { buildX402Middleware, isBillingEnabled } from './server-middleware'; diff --git a/src/lib/x402/server-middleware.ts b/src/lib/x402/server-middleware.ts new file mode 100644 index 0000000..911b55a --- /dev/null +++ b/src/lib/x402/server-middleware.ts @@ -0,0 +1,202 @@ +// --------------------------------------------------------------------------- +// x402 Payment Middleware for Hono +// --------------------------------------------------------------------------- +// +// Wraps @x402/hono paymentMiddleware with: +// - Solana (SVM) ExactSvmScheme for USDC payment verification +// - Per-service route configuration derived from SERVICE_PRICES +// - ENABLE_X402_BILLING=false bypass for development/testing +// - Structured logging on payment gate decisions +// +// SECURITY: +// - PLATFORM_TREASURY_ADDRESS is the USDC recipient — must never be +// client-controlled; it is read from env at middleware construction time +// - All payment verification runs server-side via the facilitator +// - No private keys are touched here (read-only verification path) +// - Transaction simulation is enforced by the facilitator before settlement + +import { createMiddleware } from 'hono/factory'; +import type { MiddlewareHandler } from 'hono'; +import { paymentMiddleware, x402ResourceServer } from '@x402/hono'; +import { HTTPFacilitatorClient } from '@x402/core/server'; +import type { Network } from '@x402/core/types'; +import { ExactSvmScheme } from '@x402/svm/exact/server'; +import { logger } from '@/lib/utils/logger'; +import type { ServicePrice } from './service-pricing'; + +// --------------------------------------------------------------------------- +// Network constants +// --------------------------------------------------------------------------- + +/** CAIP-2 network identifier for Solana mainnet */ +const SOLANA_MAINNET_CAIP2: Network = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + +/** CAIP-2 network identifier for Solana devnet */ +const SOLANA_DEVNET_CAIP2: Network = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; + +/** + * Resolve the active Solana CAIP-2 network identifier from environment. + * Defaults to devnet to fail safe during development. + */ +function resolveNetwork(): Network { + const network = + process.env.NEXT_PUBLIC_SOLANA_NETWORK ?? + process.env.SOLANA_NETWORK ?? + 'devnet'; + return network === 'mainnet-beta' ? SOLANA_MAINNET_CAIP2 : SOLANA_DEVNET_CAIP2; +} + +// --------------------------------------------------------------------------- +// Billing bypass (development / CI) +// --------------------------------------------------------------------------- + +/** + * Returns true when x402 billing is enabled. + * + * Set ENABLE_X402_BILLING=false to bypass the payment gate entirely. + * Defaults to enabled (true) to fail safe — missing var keeps billing on. + */ +export function isBillingEnabled(): boolean { + return process.env.ENABLE_X402_BILLING !== 'false'; +} + +// --------------------------------------------------------------------------- +// Passthrough middleware (billing disabled) +// --------------------------------------------------------------------------- + +/** No-op Hono middleware used when ENABLE_X402_BILLING=false */ +const passthroughMiddleware: MiddlewareHandler = createMiddleware(async (_c, next) => { + await next(); +}); + +// --------------------------------------------------------------------------- +// Shared resource server (one per process — constructed lazily) +// --------------------------------------------------------------------------- + +// Cache the resource server to avoid re-constructing on every request. +// The server holds a connection to the facilitator, so it should be reused. +let _resourceServer: x402ResourceServer | null = null; +let _resourceServerNetwork: Network | null = null; + +/** + * Get or construct the shared x402ResourceServer. + * Re-creates if the configured network changes (e.g., during tests). + */ +function getResourceServer(network: Network, facilitatorUrl: string): x402ResourceServer { + if (_resourceServer && _resourceServerNetwork === network) { + return _resourceServer; + } + + const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); + _resourceServer = new x402ResourceServer(facilitatorClient).register( + network, + new ExactSvmScheme() + ); + _resourceServerNetwork = network; + + return _resourceServer; +} + +// --------------------------------------------------------------------------- +// x402 middleware factory +// --------------------------------------------------------------------------- + +/** + * Build the x402 payment middleware for a specific service endpoint. + * + * When ENABLE_X402_BILLING=false this returns a no-op passthrough middleware. + * Otherwise it builds a live x402 payment gate using: + * - ExactSvmScheme (Solana USDC exact-amount payment scheme) + * - HTTPFacilitatorClient pointing at X402_FACILITATOR_URL (ozskr's own + * facilitator) or the public x402.org facilitator as network-level fallback + * - PLATFORM_TREASURY_ADDRESS as the USDC payTo recipient + * + * The x402 middleware handles: + * 1. Returning HTTP 402 with payment requirements when X-Payment header absent + * 2. Verifying payment proof in the X-Payment header via the facilitator + * 3. Passing through to the route handler when payment is valid + * 4. Settling the payment with the facilitator after a successful response + * + * @param price - ServicePrice record for the endpoint being protected + * @param httpMethod - HTTP verb in uppercase (POST, GET, etc.) + * @param path - Full route path including /api prefix (e.g., '/api/services/image-generate') + */ +export function buildX402Middleware( + price: ServicePrice, + httpMethod: string, + path: string +): MiddlewareHandler { + if (!isBillingEnabled()) { + logger.info('x402 billing disabled — passthrough active', { + serviceId: price.serviceId, + }); + return passthroughMiddleware; + } + + const treasuryAddress = process.env.PLATFORM_TREASURY_ADDRESS; + + if (!treasuryAddress) { + // In production this is a fatal misconfiguration. Log at error level and + // return passthrough so the service degrades gracefully rather than crashing. + // The security-auditor review gate will catch this before production deploy. + logger.error('PLATFORM_TREASURY_ADDRESS not set — x402 gate disabled for service', { + serviceId: price.serviceId, + path, + }); + return passthroughMiddleware; + } + + const facilitatorUrl = + process.env.X402_FACILITATOR_URL ?? 'https://facilitator.x402.org'; + + const network = resolveNetwork(); + + // Route key must match the format expected by @x402/hono: "METHOD /path" + const routeKey = `${httpMethod.toUpperCase()} ${path}`; + + try { + const server = getResourceServer(network, facilitatorUrl); + + const middleware = paymentMiddleware( + { + [routeKey]: { + accepts: { + scheme: 'exact', + // x402 price strings use "$" prefix (e.g. "$0.10") + price: `$${price.priceUsdc.toFixed(2)}`, + network, + payTo: treasuryAddress, + // Allow up to 5 minutes for the payment transaction to be finalized + maxTimeoutSeconds: 300, + }, + description: price.description, + }, + }, + server, + // paywallConfig — undefined: no browser paywall UI in API context + undefined, + // paywall — undefined: use default x402 paywall provider + undefined, + // syncFacilitatorOnStart = false: avoid slow cold-start on Vercel + false + ); + + logger.info('x402 payment gate configured', { + serviceId: price.serviceId, + routeKey, + network, + priceUsdc: price.priceUsdc, + facilitatorUrl, + }); + + return middleware; + } catch (err) { + // If the x402 infrastructure fails to initialize (e.g., bad facilitator URL + // at startup), log and fall back to passthrough so the Hono app still starts. + logger.error('Failed to initialize x402 payment middleware — falling back to passthrough', { + serviceId: price.serviceId, + error: err instanceof Error ? err.message : String(err), + }); + return passthroughMiddleware; + } +} diff --git a/src/lib/x402/service-pricing.ts b/src/lib/x402/service-pricing.ts new file mode 100644 index 0000000..aefb9d0 --- /dev/null +++ b/src/lib/x402/service-pricing.ts @@ -0,0 +1,78 @@ +// --------------------------------------------------------------------------- +// x402 Service Pricing Registry +// --------------------------------------------------------------------------- +// +// Defines per-service USDC prices for x402-gated AI endpoints. +// Prices are set in USDC with the corresponding USDC base unit (6 decimals) +// represented as priceLamports (bigint) for on-chain payment requirements. +// +// platformCostUsdc reflects the estimated pass-through cost to the external +// provider (fal.ai, Anthropic). The markup covers infrastructure and margin. + +export interface ServicePrice { + serviceId: string; + name: string; + description: string; + /** Price charged to the caller in USDC (e.g., 0.10 = $0.10) */ + priceUsdc: number; + /** priceUsdc * 1_000_000n — USDC base units (6 decimals) */ + priceLamports: bigint; + /** External provider that fulfills this request */ + externalProvider: string; + /** Estimated cost to the platform in USDC */ + platformCostUsdc: number; + /** Whether this service is currently accepting payments */ + enabled: boolean; +} + +export const SERVICE_PRICES: Record = { + 'image-generate': { + serviceId: 'image-generate', + name: 'AI Image Generation', + description: 'Generate an image from text prompt via fal-ai/flux/schnell', + priceUsdc: 0.10, + priceLamports: 100_000n, + externalProvider: 'fal.ai', + platformCostUsdc: 0.04, + enabled: true, + }, + 'image-generate-pro': { + serviceId: 'image-generate-pro', + name: 'AI Image Generation (Pro)', + description: 'Generate a high-res image via fal-ai/flux/dev', + priceUsdc: 0.15, + priceLamports: 150_000n, + externalProvider: 'fal.ai', + platformCostUsdc: 0.08, + enabled: true, + }, + 'image-edit': { + serviceId: 'image-edit', + name: 'AI Image Edit', + description: 'Edit images via fal-ai/flux/dev/image-to-image', + priceUsdc: 0.12, + priceLamports: 120_000n, + externalProvider: 'fal.ai', + platformCostUsdc: 0.08, + enabled: true, + }, + 'text-generate': { + serviceId: 'text-generate', + name: 'AI Text Generation', + description: 'Generate text content via Claude Sonnet', + priceUsdc: 0.02, + priceLamports: 20_000n, + externalProvider: 'anthropic', + platformCostUsdc: 0.006, + enabled: true, + }, +}; + +/** + * Look up a service price by service ID. + * Returns null if the service ID is unknown or the service is disabled. + */ +export function getServicePrice(serviceId: string): ServicePrice | null { + const price = SERVICE_PRICES[serviceId]; + return price?.enabled ? price : null; +} From 7d826eb8893c3fefb39f37e3a3dca35ba7664372 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 6 Mar 2026 10:52:34 -0500 Subject: [PATCH 2/4] security: fix audit findings from Phase 7.N security gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL fixes: - social-direct.ts: run moderateContent() pipeline before post (endorsement guardrails + OpenAI text mod); reject with 422 if not APPROVED. Removes auto-approval bypass that violated CLAUDE.md moderation mandate. - register-toto-mainnet.ts: add simulateTransaction() on prepared tx before live mainnet send; balance check was not a substitute per CLAUDE.md. HIGH fixes: - register-toto-mainnet.ts: zero secretBytes.fill(0) after Keypair construction to clear raw key material from heap. - SSRF: add image-url-validator.ts with validatePublicImageUrl() — rejects IP hostnames, localhost/.local/.internal patterns, non-HTTPS URLs. Applied to social-direct.ts and x-direct-adapter.ts mediaUrl fetch paths. - server-middleware.ts: return HTTP 503 (not passthrough) when PLATFORM_TREASURY_ADDRESS is unset or x402 init throws — fails closed. MEDIUM fixes: - social-direct.ts: add directPostLimiter (10/hr) on POST /social/direct/post-x. INFO fixes: - publisher-factory.ts: SocialProvider.DIRECT now maps to XDirectAdapter (not TwitterAdapter); remove unused TwitterAdapter import. Co-Authored-By: Claude Sonnet 4.6 --- scripts/register-toto-mainnet.ts | 45 +++++++++++++++-- src/lib/api/routes/social-direct.ts | 67 ++++++++++++++++++++++--- src/lib/social/image-url-validator.ts | 72 +++++++++++++++++++++++++++ src/lib/social/publisher-factory.ts | 3 +- src/lib/social/x-direct-adapter.ts | 4 ++ src/lib/x402/server-middleware.ts | 28 +++++++---- 6 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 src/lib/social/image-url-validator.ts diff --git a/scripts/register-toto-mainnet.ts b/scripts/register-toto-mainnet.ts index a3528d3..559508f 100644 --- a/scripts/register-toto-mainnet.ts +++ b/scripts/register-toto-mainnet.ts @@ -180,7 +180,10 @@ function loadKeypairFromBase58(base58Key: string): Keypair { ); } - return Keypair.fromSecretKey(secretBytes); + const keypair = Keypair.fromSecretKey(secretBytes); + // Zero the raw key material from memory now that it is held inside the Keypair object. + secretBytes.fill(0); + return keypair; } // --------------------------------------------------------------------------- @@ -506,10 +509,6 @@ async function main(): Promise { console.log('IMPORTANT: This will create a permanent on-chain record.'); console.log(''); - // Simulate first using a raw RPC call before delegating to SDK - // The SDK does not expose a simulation path — we perform a lightweight - // check by fetching the account info for the authority to confirm it - // has SOL to cover fees. const connection = new Connection(env.rpcUrl, 'confirmed'); const balance = await connection.getBalance(authority.publicKey); const balanceLamports = BigInt(balance); @@ -524,6 +523,42 @@ async function main(): Promise { process.exit(1); } + // SECURITY: Simulate the transaction before sending per CLAUDE.md requirement. + // We use skipSend=true to build the serialized tx, decode and simulate it, + // then fall through to the live send only if simulation succeeds. + console.log('Simulating transaction before sending...'); + let prepForSim: Awaited>; + try { + prepForSim = await sdk.registerAgent(metadataUri, { skipSend: true }); + } catch (err) { + console.error(`ERROR: Failed to prepare transaction for simulation: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + + if (!isPreparedTransaction(prepForSim)) { + console.error('ERROR: Could not prepare transaction for simulation — aborting.'); + process.exit(1); + } + + // Decode the base64 serialized transaction and simulate via RPC + const { Transaction } = await import('@solana/web3.js'); + const txBytes = Buffer.from(prepForSim.transaction, 'base64'); + const simulateTx = Transaction.from(txBytes); + const simResult = await connection.simulateTransaction(simulateTx); + + if (simResult.value.err) { + console.error('ERROR: Transaction simulation failed — aborting live send.'); + console.error('Simulation error:', JSON.stringify(simResult.value.err, null, 2)); + if (simResult.value.logs) { + console.error('Program logs:'); + simResult.value.logs.forEach((log) => console.error(' ', log)); + } + process.exit(1); + } + + console.log('Simulation succeeded. Proceeding with live send.'); + console.log(''); + let txResult: Awaited>; try { txResult = await sdk.registerAgent(metadataUri, { diff --git a/src/lib/api/routes/social-direct.ts b/src/lib/api/routes/social-direct.ts index 9cbb1bd..4c366e3 100644 --- a/src/lib/api/routes/social-direct.ts +++ b/src/lib/api/routes/social-direct.ts @@ -16,6 +16,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { authMiddleware } from '../middleware/auth'; +import { createRateLimiter } from '../middleware/rate-limit'; import { createAuthenticatedClient } from '../supabase'; import { GenerationType, @@ -24,6 +25,8 @@ import { SocialPostStatus, } from '@/types/database'; import { injectTwitterAiDisclosure } from '@/lib/social/ai-disclosure'; +import { validatePublicImageUrl, ImageUrlValidationError } from '@/lib/social/image-url-validator'; +import { moderateContent, ModerationError } from '@/lib/ai/pipeline/moderation'; import { logger } from '@/lib/utils/logger'; import { createXClient, XClientError } from '@/lib/social/x-client'; @@ -40,6 +43,9 @@ const socialDirect = new Hono(); // All routes require authentication socialDirect.use('/*', authMiddleware); +/** 10 direct posts per hour per wallet — X free-tier protection */ +const directPostLimiter = createRateLimiter(10, 3600, 'ozskr:social:direct'); + // ============================================================================= // REQUEST SCHEMA // ============================================================================= @@ -90,6 +96,7 @@ function getAuthContext(c: { get: (key: string) => unknown }) { */ socialDirect.post( '/post-x', + directPostLimiter, zValidator('json', PostXRequestSchema), async (c) => { const auth = getAuthContext(c); @@ -121,9 +128,42 @@ socialDirect.post( } // ------------------------------------------------------------------------- - // 2. Create a content_generations record (required FK for social_posts) - // Mark it as approved — the caller controls the text; moderation for - // direct posts is the caller's responsibility at the application layer. + // 2. Run content moderation pipeline (MANDATORY — CLAUDE.md compliance) + // All three stages: endorsement guardrails → OpenAI text mod → result + // ------------------------------------------------------------------------- + let moderationStatus: ModerationStatus; + let moderationDetails: Record = {}; + + try { + const modResult = await moderateContent( + { text: input.text }, + () => {} // no-op progress callback in API context + ); + moderationStatus = modResult.status; + moderationDetails = modResult.details; + } catch (err) { + const message = err instanceof ModerationError ? err.message : 'Moderation pipeline error'; + logger.error('Content moderation failed for direct post', { + characterId: input.characterId, + error: message, + }); + return c.json({ error: 'Content moderation unavailable', code: 'MODERATION_ERROR' }, 503); + } + + if (moderationStatus !== ModerationStatus.APPROVED) { + logger.warn('Direct post blocked by moderation', { + characterId: input.characterId, + moderationStatus, + moderationDetails, + }); + return c.json( + { error: 'Content did not pass moderation', code: 'MODERATION_REJECTED', status: moderationStatus }, + 422 + ); + } + + // ------------------------------------------------------------------------- + // 3. Create a content_generations record (required FK for social_posts) // ------------------------------------------------------------------------- const { data: generation, error: genError } = await supabase .from('content_generations') @@ -150,8 +190,7 @@ socialDirect.post( } // ------------------------------------------------------------------------- - // 3. Look up (or create) the twitter social_account for this character's wallet - // We use the first connected twitter account found. + // 4. Look up the twitter social_account for this character's wallet // ------------------------------------------------------------------------- const { data: twitterAccount } = await supabase .from('social_accounts') @@ -168,7 +207,7 @@ socialDirect.post( const socialAccountId = twitterAccount?.id ?? null; // ------------------------------------------------------------------------- - // 4. Post the tweet via OAuth 1.0a XClient + // 5. Post the tweet via OAuth 1.0a XClient // ------------------------------------------------------------------------- let tweetId: string; let tweetUrl: string; @@ -182,6 +221,20 @@ socialDirect.post( // Download and upload image if provided let mediaIds: string[] | undefined; if (input.imageUrl) { + // SECURITY: Validate imageUrl is a public HTTPS domain before fetching + // to prevent SSRF against internal infrastructure. + try { + validatePublicImageUrl(input.imageUrl); + } catch (err) { + return c.json( + { + error: err instanceof ImageUrlValidationError ? err.message : 'Invalid imageUrl', + code: 'INVALID_IMAGE_URL', + }, + 400 + ); + } + const imageResponse = await fetch(input.imageUrl); if (!imageResponse.ok) { return c.json( @@ -228,7 +281,7 @@ socialDirect.post( } // ------------------------------------------------------------------------- - // 5. Store the social_posts record for auditability and retry support + // 6. Store the social_posts record for auditability and retry support // ------------------------------------------------------------------------- if (socialAccountId) { const { data: socialPost, error: postError } = await supabase diff --git a/src/lib/social/image-url-validator.ts b/src/lib/social/image-url-validator.ts new file mode 100644 index 0000000..6917ddd --- /dev/null +++ b/src/lib/social/image-url-validator.ts @@ -0,0 +1,72 @@ +/** + * Image URL validation — SSRF protection + * + * Validates that a caller-supplied image URL is a publicly-reachable HTTPS URL + * and cannot be used to probe internal infrastructure (Vercel edge, Supabase, + * cloud metadata endpoints, private RFC-1918 ranges, etc.). + * + * Strategy: parse the URL and reject any hostname that: + * - is not HTTPS + * - is an IP address (v4 or v6) — eliminates 169.254.169.254, 10.x.x.x, etc. + * - matches known-internal patterns (localhost, .local, .internal, .private) + * + * Note: DNS-based SSRF (external DNS resolving to internal IP) is addressed at + * the infrastructure layer (Vercel egress controls). This validator stops the + * most common attack vectors. + */ + +export class ImageUrlValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ImageUrlValidationError'; + } +} + +/** Patterns that indicate a non-public / internal hostname */ +const INTERNAL_HOSTNAME_RE = + /^(localhost|.*\.local|.*\.internal|.*\.private|.*\.intranet|.*\.corp|.*\.lan)$/i; + +/** IPv4 address (optionally port) */ +const IPV4_RE = /^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/; + +/** IPv6 address (various bracket forms) */ +const IPV6_RE = /^\[[:0-9a-fA-F]+\]/; + +/** + * Validate that a caller-supplied image URL is a safe, public HTTPS URL. + * + * Throws ImageUrlValidationError if the URL fails validation. + */ +export function validatePublicImageUrl(rawUrl: string): URL { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new ImageUrlValidationError(`imageUrl is not a valid URL: ${rawUrl}`); + } + + if (parsed.protocol !== 'https:') { + throw new ImageUrlValidationError( + `imageUrl must use HTTPS (got ${parsed.protocol})`, + ); + } + + const host = parsed.hostname; + + // Reject bare IP addresses — these are the primary SSRF vector. + // Named hostnames are validated below; DNS-level SSRF is out of scope here. + if (IPV4_RE.test(host) || IPV6_RE.test(host)) { + throw new ImageUrlValidationError( + `imageUrl hostname must be a domain name, not an IP address (got ${host})`, + ); + } + + // Reject known-internal hostname patterns + if (INTERNAL_HOSTNAME_RE.test(host)) { + throw new ImageUrlValidationError( + `imageUrl hostname refers to an internal address (${host})`, + ); + } + + return parsed; +} diff --git a/src/lib/social/publisher-factory.ts b/src/lib/social/publisher-factory.ts index 7550bea..1ece890 100644 --- a/src/lib/social/publisher-factory.ts +++ b/src/lib/social/publisher-factory.ts @@ -10,7 +10,6 @@ import { getFeatureFlags } from '@/lib/feature-flags'; import { AyrshareAdapter } from './ayrshare-adapter'; -import { TwitterAdapter } from './twitter-adapter'; import { XDirectAdapter } from './x-direct-adapter'; import { SocialProvider, type SocialPublisher } from './types'; @@ -31,7 +30,7 @@ const getPublisherForProvider = (provider: SocialProvider): SocialPublisher => { publisher = new AyrshareAdapter(); break; case SocialProvider.DIRECT: - publisher = new TwitterAdapter(); + publisher = new XDirectAdapter(); break; default: { const _exhaustive: never = provider; diff --git a/src/lib/social/x-direct-adapter.ts b/src/lib/social/x-direct-adapter.ts index 7e04f95..e2b4d4a 100644 --- a/src/lib/social/x-direct-adapter.ts +++ b/src/lib/social/x-direct-adapter.ts @@ -22,6 +22,7 @@ import { } from './types'; import { createXClient, XClientError } from './x-client'; import { injectTwitterAiDisclosure } from './ai-disclosure'; +import { validatePublicImageUrl } from './image-url-validator'; /** * Direct X API cost is $0 per post (app credentials, no Ayrshare intermediary) @@ -58,6 +59,9 @@ export class XDirectAdapter implements SocialPublisher { if (post.mediaUrls && post.mediaUrls.length > 0) { mediaIds = []; for (const mediaUrl of post.mediaUrls) { + // SECURITY: Validate mediaUrl is a public HTTPS domain (SSRF guard) + validatePublicImageUrl(mediaUrl); + const mediaResponse = await fetch(mediaUrl); if (!mediaResponse.ok) { throw new XClientError( diff --git a/src/lib/x402/server-middleware.ts b/src/lib/x402/server-middleware.ts index 911b55a..3b5d332 100644 --- a/src/lib/x402/server-middleware.ts +++ b/src/lib/x402/server-middleware.ts @@ -136,14 +136,19 @@ export function buildX402Middleware( const treasuryAddress = process.env.PLATFORM_TREASURY_ADDRESS; if (!treasuryAddress) { - // In production this is a fatal misconfiguration. Log at error level and - // return passthrough so the service degrades gracefully rather than crashing. - // The security-auditor review gate will catch this before production deploy. - logger.error('PLATFORM_TREASURY_ADDRESS not set — x402 gate disabled for service', { + // PLATFORM_TREASURY_ADDRESS is required when billing is enabled. + // Fail closed (503) rather than open — a misconfigured deploy must not + // grant free access to paid AI endpoints. + logger.error('PLATFORM_TREASURY_ADDRESS not set — refusing to serve paid endpoint', { serviceId: price.serviceId, path, }); - return passthroughMiddleware; + return createMiddleware(async (c) => { + return c.json( + { error: 'Service temporarily unavailable', code: 'MISCONFIGURED' }, + 503 + ); + }); } const facilitatorUrl = @@ -191,12 +196,17 @@ export function buildX402Middleware( return middleware; } catch (err) { - // If the x402 infrastructure fails to initialize (e.g., bad facilitator URL - // at startup), log and fall back to passthrough so the Hono app still starts. - logger.error('Failed to initialize x402 payment middleware — falling back to passthrough', { + // If x402 infrastructure fails to initialize, fail closed (503) not open. + // A broken payment gate must never grant free access. + logger.error('Failed to initialize x402 payment middleware — returning 503', { serviceId: price.serviceId, error: err instanceof Error ? err.message : String(err), }); - return passthroughMiddleware; + return createMiddleware(async (c) => { + return c.json( + { error: 'Service temporarily unavailable', code: 'PAYMENT_GATE_ERROR' }, + 503 + ); + }); } } From 08b7b8bdefd9dac501cafc9582bdb1c4abcf6692 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 6 Mar 2026 17:32:55 -0500 Subject: [PATCH 3/4] feat(7.N): x402 payment middleware + fal.ai image generation + content commerce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - x402 server-middleware: async resource server init, lazy _mw cache per route, 503 on PLATFORM_TREASURY_ADDRESS unset (fail closed), ENABLE_X402_BILLING bypass - services route: image-generate + video-generate endpoints behind authMiddleware and buildX402Middleware, Zod-validated request bodies - use-x402-generate hook: full x402 payment flow (probe → 402 parse → sign → retry) - fal.ai client + model router: image/video generation with cost tracking - pricing layer: model registry, fal pricing client, Claude cost estimator, pricing calculator for x402 price derivation - content feature: hooks (use-agent-balance, use-content-commerce) + components - generate-modal: integrated x402 payment flow and delegation balance display - delegation-card: fix delegationStatus field name (was status, caused balance=null) - agent service usage migration: 20260307000000_agent_service_usage.sql - eslint.config.mjs: ignore .trigger/** and .claude/worktrees/** - wallet auth hook: session refresh and token persistence improvements - rpc.ts: devnet RPC endpoint improvements - providers.tsx: QueryClient config + x402 provider setup Assisted-by: Claude Code Co-Authored-By: Claude Sonnet 4.6 --- eslint.config.mjs | 4 + src/app/(dashboard)/agents/[id]/page.tsx | 39 +- src/app/(dashboard)/dashboard/page.tsx | 4 +- src/app/providers.tsx | 24 +- .../agents/components/delegation-card.tsx | 15 +- .../agents/components/generate-modal.tsx | 593 ++++++++-------- .../components/content-category-picker.tsx | 77 +++ .../components/content-generate-modal.tsx | 538 +++++++++++++++ .../content/components/model-selector.tsx | 227 +++++++ .../content/components/price-summary.tsx | 152 +++++ .../content/components/video-options.tsx | 133 ++++ .../content/hooks/use-agent-balance.ts | 80 +++ src/features/content/hooks/use-pricing.ts | 105 +++ src/features/wallet/hooks/use-wallet-auth.ts | 36 +- src/hooks/use-x402-generate.ts | 598 ++++++++++++++++ src/lib/ai/fal-client.test.ts | 342 ++++++++++ src/lib/ai/fal-client.ts | 317 +++++++++ src/lib/ai/model-router.test.ts | 542 +++++++++++++++ src/lib/ai/model-router.ts | 643 ++++++++++++++++++ src/lib/api/routes/ai.ts | 10 +- src/lib/api/routes/services.ts | 63 +- src/lib/pricing/claude-cost-estimator.test.ts | 218 ++++++ src/lib/pricing/claude-cost-estimator.ts | 66 ++ src/lib/pricing/fal-pricing-client.test.ts | 224 ++++++ src/lib/pricing/fal-pricing-client.ts | 230 +++++++ src/lib/pricing/index.ts | 17 + src/lib/pricing/model-registry.ts | 147 ++++ src/lib/pricing/pricing-calculator.test.ts | 398 +++++++++++ src/lib/pricing/pricing-calculator.ts | 348 ++++++++++ src/lib/solana/rpc.ts | 33 +- src/lib/x402/server-middleware.ts | 117 ++-- .../20260307000000_agent_service_usage.sql | 86 +++ 32 files changed, 6060 insertions(+), 366 deletions(-) create mode 100644 src/features/content/components/content-category-picker.tsx create mode 100644 src/features/content/components/content-generate-modal.tsx create mode 100644 src/features/content/components/model-selector.tsx create mode 100644 src/features/content/components/price-summary.tsx create mode 100644 src/features/content/components/video-options.tsx create mode 100644 src/features/content/hooks/use-agent-balance.ts create mode 100644 src/features/content/hooks/use-pricing.ts create mode 100644 src/hooks/use-x402-generate.ts create mode 100644 src/lib/ai/fal-client.test.ts create mode 100644 src/lib/ai/fal-client.ts create mode 100644 src/lib/ai/model-router.test.ts create mode 100644 src/lib/ai/model-router.ts create mode 100644 src/lib/pricing/claude-cost-estimator.test.ts create mode 100644 src/lib/pricing/claude-cost-estimator.ts create mode 100644 src/lib/pricing/fal-pricing-client.test.ts create mode 100644 src/lib/pricing/fal-pricing-client.ts create mode 100644 src/lib/pricing/index.ts create mode 100644 src/lib/pricing/model-registry.ts create mode 100644 src/lib/pricing/pricing-calculator.test.ts create mode 100644 src/lib/pricing/pricing-calculator.ts create mode 100644 supabase/migrations/20260307000000_agent_service_usage.sql diff --git a/eslint.config.mjs b/eslint.config.mjs index 94b8c83..0e4ed09 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -27,6 +27,10 @@ const eslintConfig = defineConfig([ "next-env.d.ts", // Open-source packages have their own lint config "packages/**", + // Trigger.dev build artifacts + ".trigger/**", + // Claude Code agent worktrees + ".claude/worktrees/**", ]), ]); diff --git a/src/app/(dashboard)/agents/[id]/page.tsx b/src/app/(dashboard)/agents/[id]/page.tsx index a673fd2..7442d8b 100644 --- a/src/app/(dashboard)/agents/[id]/page.tsx +++ b/src/app/(dashboard)/agents/[id]/page.tsx @@ -23,6 +23,7 @@ import { } from 'lucide-react'; import { useCharacter, useUpdateCharacter } from '@/hooks/use-characters'; import { GenerateModal } from '@/features/agents/components/generate-modal'; +import { ContentGenerateModal } from '@/features/content/components/content-generate-modal'; import { ScheduleModal } from '@/features/agents/components/schedule-modal'; import { DelegationCard } from '@/features/agents/components/delegation-card'; import { ContentLibrary } from '@/features/agents/components/content-library'; @@ -57,6 +58,7 @@ export default function AgentDetailPage({ params }: AgentDetailPageProps) { const { data: characterRaw, isLoading, error } = useCharacter(id); const { mutate: updateCharacter } = useUpdateCharacter(id); const [generateModalOpen, setGenerateModalOpen] = useState(false); + const [contentGenerateModalOpen, setContentGenerateModalOpen] = useState(false); const [scheduleModalOpen, setScheduleModalOpen] = useState(false); // Fetch schedules for this character @@ -201,14 +203,25 @@ export default function AgentDetailPage({ params }: AgentDetailPageProps) {
- +
+ + +
@@ -462,7 +475,7 @@ export default function AgentDetailPage({ params }: AgentDetailPageProps) { - {/* Generation Modal */} + {/* Generation Modal (existing image-generate flow) */} + {/* Content Generation Modal (multi-category content creation) */} + + {/* Schedule Modal */}

- {balance !== null ? 'SOL on devnet' : 'Connect wallet to view'} + {balance !== null + ? `SOL on ${process.env.NEXT_PUBLIC_SOLANA_NETWORK === 'mainnet-beta' ? 'mainnet' : 'devnet'}` + : 'Connect wallet to view'}

diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 5c596c7..b9916f0 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -5,18 +5,20 @@ * Wraps the application with all required context providers */ -import { useMemo } from 'react'; +import { useMemo, useCallback } from 'react'; import { ConnectionProvider, WalletProvider, WalletContext, useWallet, } from '@solana/wallet-adapter-react'; +import type { WalletError } from '@solana/wallet-adapter-base'; import { WalletModalProvider } from '@solana/wallet-adapter-react-ui'; import { PhantomWalletAdapter } from '@solana/wallet-adapter-phantom'; import { SolflareWalletAdapter } from '@solana/wallet-adapter-solflare'; import { BackpackWalletAdapter } from '@solana/wallet-adapter-backpack'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { resolveHeliusRpcUrl } from '@/lib/solana/rpc'; // Import wallet adapter CSS import '@solana/wallet-adapter-react-ui/styles.css'; @@ -56,11 +58,14 @@ const queryClient = new QueryClient({ }); export function Providers({ children }: { children: React.ReactNode }) { - // Get RPC endpoint from environment (fallback based on network) + // Resolve RPC endpoint using NEXT_PUBLIC_SOLANA_NETWORK as the source of + // truth. resolveHeliusRpcUrl() corrects the Helius subdomain to match the + // active network so a devnet API key URL is never accidentally used on + // mainnet-beta (and vice-versa). Falls back to the public cluster endpoint + // when no Helius URL is configured. const endpoint = useMemo(() => { - if (process.env.NEXT_PUBLIC_HELIUS_RPC_URL) { - return process.env.NEXT_PUBLIC_HELIUS_RPC_URL; - } + const heliusUrl = resolveHeliusRpcUrl(); + if (heliusUrl) return heliusUrl; return process.env.NEXT_PUBLIC_SOLANA_NETWORK === 'mainnet-beta' ? 'https://api.mainnet-beta.solana.com' : 'https://api.devnet.solana.com'; @@ -78,10 +83,17 @@ export function Providers({ children }: { children: React.ReactNode }) { [] ); + // Suppress non-fatal MetaMask connection errors — MetaMask registers itself + // via Wallet Standard auto-discovery but cannot connect in a Solana context. + const onWalletError = useCallback((error: WalletError) => { + if (error.name === 'WalletConnectionError' && error.message?.includes('MetaMask')) return; + console.error('[WalletProvider]', error); + }, []); + return ( - + {children} diff --git a/src/features/agents/components/delegation-card.tsx b/src/features/agents/components/delegation-card.tsx index 5655d6e..e970104 100644 --- a/src/features/agents/components/delegation-card.tsx +++ b/src/features/agents/components/delegation-card.tsx @@ -42,8 +42,13 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; -// Devnet USDC-Dev SPL token mint (Circle test faucet) +// USDC SPL token mints +const MAINNET_USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; const DEVNET_USDC_MINT = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; +const USDC_MINT = + process.env.NEXT_PUBLIC_SOLANA_NETWORK === 'mainnet-beta' + ? MAINNET_USDC_MINT + : DEVNET_USDC_MINT; const USDC_DECIMALS = 6; @@ -102,7 +107,7 @@ export function DelegationCard({ characterId, characterName }: DelegationCardPro const [approveDialogOpen, setApproveDialogOpen] = useState(false); const [revokeDialogOpen, setRevokeDialogOpen] = useState(false); const [amountInput, setAmountInput] = useState('10'); - const [mintInput, setMintInput] = useState(DEVNET_USDC_MINT); + const [mintInput] = useState(USDC_MINT); if (isLoading) { return ( @@ -299,12 +304,12 @@ export function DelegationCard({ characterId, characterName }: DelegationCardPro setMintInput(e.target.value)} + readOnly placeholder="Token mint address" - className="font-mono text-xs" + className="font-mono text-xs cursor-default opacity-75" />

- Default: Devnet USDC-Dev + USDC ({process.env.NEXT_PUBLIC_SOLANA_NETWORK === 'mainnet-beta' ? 'Mainnet' : 'Devnet'})

diff --git a/src/features/agents/components/generate-modal.tsx b/src/features/agents/components/generate-modal.tsx index 553386f..aac9f93 100644 --- a/src/features/agents/components/generate-modal.tsx +++ b/src/features/agents/components/generate-modal.tsx @@ -2,10 +2,12 @@ /** * Content Generation Modal - * Handles content generation with SSE streaming progress + * + * x402-payment-gated image generation via POST /api/services/image-generate[-pro]. + * All images are generated by nanobanana 2 and paid in USDC on-chain. */ -import { useState, useEffect } from 'react'; +import { useState, useRef } from 'react'; import Image from 'next/image'; import { useQueryClient } from '@tanstack/react-query'; import { @@ -18,25 +20,25 @@ import { import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; import { Loader2, Check, AlertCircle, - Copy, Share2, RefreshCw, - Image as ImageIcon, - FileText, Library, + Coins, } from 'lucide-react'; -import { useGenerateContent, useGenerationStream } from '@/hooks/use-generations'; -import { GenerationType } from '@/types/database'; +import { useX402Generate } from '@/hooks/use-x402-generate'; import { cn } from '@/lib/utils'; -import type { GenerationStage } from '@/hooks/use-generations'; +import type { X402GenerateType } from '@/hooks/use-x402-generate'; import { PublishModal } from './publish-modal'; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + interface GenerateModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -44,24 +46,85 @@ interface GenerateModalProps { characterName: string; } -const STAGE_ORDER: GenerationStage[] = [ - 'loading_character', - 'enhancing_prompt', - 'generating_content', - 'quality_check', - 'moderation', - 'complete', +// --------------------------------------------------------------------------- +// Model config +// --------------------------------------------------------------------------- + +const MODELS: { type: X402GenerateType; name: string; description: string; price: string }[] = [ + { + type: 'image', + name: 'nanobanana 2', + description: 'Fast, high-quality image generation', + price: '$0.10 USDC', + }, + { + type: 'image-pro', + name: 'nanobanana 2 Pro', + description: 'High-resolution, premium output', + price: '$0.15 USDC', + }, ]; -const STAGE_LABELS: Record = { - loading_character: 'Summoning character DNA...', - enhancing_prompt: 'Weaving the prompt...', - generating_content: 'The wizard is working...', - quality_check: 'Polishing the emeralds...', - moderation: 'Final curtain check...', - complete: 'Complete', - error: 'Error', -}; +// --------------------------------------------------------------------------- +// Payment confirmation overlay +// --------------------------------------------------------------------------- + +interface PaymentConfirmProps { + amountUsdc: string; + payTo: string; + onConfirm: () => void; + onCancel: () => void; +} + +function PaymentConfirmOverlay({ amountUsdc, payTo, onConfirm, onCancel }: PaymentConfirmProps) { + const shortAddress = `${payTo.slice(0, 6)}...${payTo.slice(-4)}`; + + return ( + +
+
+ +
+

Confirm USDC Payment

+

+ Your wallet will be prompted to sign a USDC transfer. No SOL is spent beyond + the transaction fee. +

+
+
+ +
+
+ Amount + {amountUsdc} USDC +
+
+ Recipient + {shortAddress} +
+
+ +
+ + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main modal +// --------------------------------------------------------------------------- export function GenerateModal({ open, @@ -69,243 +132,240 @@ export function GenerateModal({ characterId, characterName, }: GenerateModalProps) { - const [contentType, setContentType] = useState(GenerationType.TEXT); const [prompt, setPrompt] = useState(''); - const [generationId, setGenerationId] = useState(null); + const [modelType, setModelType] = useState('image'); + const [imageUrl, setImageUrl] = useState(null); + const [genError, setGenError] = useState(null); const [publishOpen, setPublishOpen] = useState(false); + const [pendingConfirmation, setPendingConfirmation] = useState<{ + amountUsdc: string; + payTo: string; + } | null>(null); + + const confirmResolverRef = useRef<(() => void) | null>(null); + const confirmRejecterRef = useRef<((reason?: unknown) => void) | null>(null); + const queryClient = useQueryClient(); - const { mutate: generateContent, isPending: isSubmitting } = useGenerateContent(characterId); - const { progress } = useGenerationStream(generationId); - - const isComplete = progress?.stage === 'complete'; - const hasError = progress?.stage === 'error'; - - // Invalidate the content library cache as soon as generation finishes - // so the library is already refreshed by the time the user closes the modal. - useEffect(() => { - if (isComplete) { - void queryClient.invalidateQueries({ - queryKey: ['character-generations', characterId], + + const { mutate: generate, isPending, reset } = useX402Generate({ + onConfirm: async ({ amountUsdc, payTo }) => { + return new Promise((resolve, reject) => { + confirmResolverRef.current = resolve; + confirmRejecterRef.current = reject; + setPendingConfirmation({ amountUsdc, payTo }); }); - } - }, [isComplete, queryClient, characterId]); + }, + }); + + // ------------------------------------------------------------------------- + // Derived flags + // ------------------------------------------------------------------------- + const isGenerating = isPending && !pendingConfirmation; + const hasResult = !!imageUrl; - // Submit generation + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- const handleSubmit = () => { if (!prompt.trim()) return; + setImageUrl(null); + setGenError(null); - generateContent( + generate( + { characterId, prompt: prompt.trim(), type: modelType }, { - generationType: contentType, - inputPrompt: prompt.trim(), - }, - { - onSuccess: (response) => { - setGenerationId(response.generationId); + onSuccess: (result) => { + setImageUrl(result.imageUrl); + void queryClient.invalidateQueries({ queryKey: ['character-generations', characterId] }); + }, + onError: (err) => { + setGenError(err instanceof Error ? err.message : 'Generation failed'); }, } ); }; - // Reset for new generation const handleGenerateAgain = () => { - setGenerationId(null); + setImageUrl(null); + setGenError(null); setPrompt(''); + reset(); + }; + + const handleConfirmPayment = () => { + setPendingConfirmation(null); + confirmResolverRef.current?.(); + confirmResolverRef.current = null; + confirmRejecterRef.current = null; + }; + + const handleCancelPayment = () => { + setPendingConfirmation(null); + confirmRejecterRef.current?.(new Error('User cancelled payment')); + confirmResolverRef.current = null; + confirmRejecterRef.current = null; + setGenError('Payment cancelled'); + reset(); }; - // Close and reset const handleClose = () => { onOpenChange(false); setTimeout(() => { - setGenerationId(null); setPrompt(''); - setContentType(GenerationType.TEXT); + setImageUrl(null); + setGenError(null); + setPendingConfirmation(null); + confirmRejecterRef.current?.(new Error('Modal closed')); + confirmResolverRef.current = null; + confirmRejecterRef.current = null; + reset(); }, 300); }; - // Copy result - const handleCopy = () => { - if (progress?.result?.outputText) { - navigator.clipboard.writeText(progress.result.outputText); - } - }; + const selectedModel = MODELS.find((m) => m.type === modelType) ?? MODELS[0]; + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- return ( <> - - - - Generate Content - - Create content with {characterName} - - - - {/* Input Form (shown before generation starts) */} - {!generationId && ( -
- {/* Content Type Toggle */} -
- -
- - + + + + Generate Image + Create an image with {characterName} + + + {/* ================================================================ + Form — hidden while pending / showing result + ================================================================ */} + {!hasResult && !isGenerating && !pendingConfirmation && !genError && ( +
+ {/* Model selector */} +
+ +
+ {MODELS.map((m) => ( + + ))} +
-
- {/* Prompt Input */} -
- -