From cfdcdcf30d218d9ca0205138b3bd80eedd430b15 Mon Sep 17 00:00:00 2001 From: Hallmane Date: Wed, 24 Sep 2025 20:31:02 +0200 Subject: [PATCH 1/8] Clean snapshot on top of develop --- .gitignore | 4 +- build.sh | 4 +- constants/production.rs | 5 +- constants/staging.rs | 5 +- hypergrid-shim/CHANGELOG.md | 51 + hypergrid-shim/LOCAL_DEVELOPMENT.md | 112 + hypergrid-shim/README.md | 2 + hypergrid-shim/package.json | 8 +- hypergrid-shim/src/index.ts | 208 +- metadata.json | 13 +- operator/Cargo.toml | 10 +- operator/api/operator-process.wit | 317 ++ operator/api/operator-sortugdev-dot-os-v0.wit | 4 + .../types-operator-sortugdev-dot-os-v0.wit | 4 + operator/metadata.json | 2 +- operator/operator/Cargo.toml | 59 +- operator/operator/src/app_api_types.rs | 170 ++ operator/operator/src/authorized_services.rs | 18 - operator/operator/src/chain.rs | 631 ---- operator/operator/src/db.rs | 151 +- operator/operator/src/eth.rs | 474 +++ operator/operator/src/graph.rs | 606 ---- operator/operator/src/helpers.rs | 2305 +------------- operator/operator/src/http_handlers.rs | 2643 ----------------- .../operator/src/hyperwallet_client/mod.rs | 3 - .../src/hyperwallet_client/payments.rs | 463 --- .../src/hyperwallet_client/service.rs | 675 ----- operator/operator/src/identity.rs | 267 +- operator/operator/src/init.rs | 291 ++ operator/operator/src/ledger.rs | 1775 ++++++++++- operator/operator/src/lib.rs | 961 ++++-- operator/operator/src/shim.rs | 730 +++++ operator/operator/src/structs.rs | 707 ++--- operator/operator/src/terminal.rs | 220 ++ operator/operator/src/tests/api_tests.rs | 250 -- operator/operator/src/tests/auth_tests.rs | 278 -- operator/operator/src/tests/graph_tests.rs | 288 -- operator/operator/src/tests/helpers_tests.rs | 264 -- operator/operator/src/tests/state_tests.rs | 165 - operator/operator/src/wallet.rs | 244 ++ operator/operator/src/wallet/payments.rs | 642 ---- operator/operator/src/websocket.rs | 424 +++ operator/pkg/manifest.json | 12 +- operator/ui/index.html | 14 +- operator/ui/package.json | 3 +- operator/ui/src/App.tsx | 79 +- .../AuthorizedClientConfigModal.tsx | 68 +- .../BackendDrivenHypergridVisualizer.tsx | 883 ------ operator/ui/src/components/CallHistory.tsx | 15 +- .../ui/src/components/ShimApiConfigModal.tsx | 58 +- operator/ui/src/components/SpiderChat.tsx | 799 ----- .../console/HyperwalletInterface.tsx | 103 +- .../console/OneClickOperatorBoot.tsx | 156 +- .../components/console/OperatorConsole.tsx | 417 ++- .../console/OperatorFinalizeSetup.tsx | 13 +- .../src/components/console/WelcomeIntro.tsx | 5 +- .../components/nodes/BaseNodeComponent.tsx | 3 +- .../nodes/OperatorWalletNodeComponent.tsx | 7 +- .../components/nodes/OwnerNodeComponent.tsx | 1 - operator/ui/src/legacy/AccountManager.tsx | 764 ----- .../ui/src/legacy/SimpleSetupVisualizer.tsx | 401 --- operator/ui/src/logic/calls.ts | 78 + operator/ui/src/logic/types.ts | 20 +- operator/ui/src/services/websocket.ts | 188 +- operator/ui/src/types/websocket.ts | 198 +- operator/ui/src/utils/api-endpoints.ts | 24 +- operator/ui/src/utils/api-migration.ts | 135 + pkg/manifest.json | 13 +- provider/Cargo.toml | 1 + provider/metadata.json | 2 +- provider/provider/Cargo.toml | 15 +- provider/provider/src/db.rs | 38 +- provider/provider/src/lib.rs | 296 +- provider/provider/src/util.rs | 124 +- .../hpn-provider-test-template-dot-os-v0.wit | 5 - .../api/hypergrid-provider.wit | 177 ++ .../api/test-provider-template-dot-os-v0.wit | 5 + ...types-test-provider-template-dot-os-v0.wit | 4 + .../ui/src/components/HypergridEntryForm.tsx | 2 +- .../src/components/RegisteredProviderView.tsx | 2 +- .../components/UnifiedTerminalInterface.tsx | 6 +- 81 files changed, 7546 insertions(+), 14041 deletions(-) create mode 100644 hypergrid-shim/CHANGELOG.md create mode 100644 hypergrid-shim/LOCAL_DEVELOPMENT.md create mode 100644 operator/api/operator-process.wit create mode 100644 operator/api/operator-sortugdev-dot-os-v0.wit create mode 100644 operator/api/types-operator-sortugdev-dot-os-v0.wit create mode 100644 operator/operator/src/app_api_types.rs delete mode 100644 operator/operator/src/authorized_services.rs delete mode 100644 operator/operator/src/chain.rs create mode 100644 operator/operator/src/eth.rs delete mode 100644 operator/operator/src/graph.rs delete mode 100644 operator/operator/src/http_handlers.rs delete mode 100644 operator/operator/src/hyperwallet_client/mod.rs delete mode 100644 operator/operator/src/hyperwallet_client/payments.rs delete mode 100644 operator/operator/src/hyperwallet_client/service.rs create mode 100644 operator/operator/src/init.rs create mode 100644 operator/operator/src/shim.rs create mode 100644 operator/operator/src/terminal.rs delete mode 100644 operator/operator/src/tests/api_tests.rs delete mode 100644 operator/operator/src/tests/auth_tests.rs delete mode 100644 operator/operator/src/tests/graph_tests.rs delete mode 100644 operator/operator/src/tests/helpers_tests.rs delete mode 100644 operator/operator/src/tests/state_tests.rs create mode 100644 operator/operator/src/wallet.rs delete mode 100644 operator/operator/src/wallet/payments.rs create mode 100644 operator/operator/src/websocket.rs delete mode 100644 operator/ui/src/components/BackendDrivenHypergridVisualizer.tsx delete mode 100644 operator/ui/src/components/SpiderChat.tsx delete mode 100644 operator/ui/src/legacy/AccountManager.tsx delete mode 100644 operator/ui/src/legacy/SimpleSetupVisualizer.tsx create mode 100644 operator/ui/src/utils/api-migration.ts delete mode 100644 provider/test/hypergrid-provider-test/api/hpn-provider-test-template-dot-os-v0.wit create mode 100644 provider/test/hypergrid-provider-test/api/hypergrid-provider.wit create mode 100644 provider/test/hypergrid-provider-test/api/test-provider-template-dot-os-v0.wit create mode 100644 provider/test/hypergrid-provider-test/api/types-test-provider-template-dot-os-v0.wit diff --git a/.gitignore b/.gitignore index fd362b3..1483b71 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ operator/ui/src/constants.ts provider/ui/src/constants.ts # Machine-generated WIT files (except operator) -pkg/api/* \ No newline at end of file +pkg/api/* +# backups +*.bak diff --git a/build.sh b/build.sh index f4914aa..7fa5f37 100755 --- a/build.sh +++ b/build.sh @@ -114,7 +114,7 @@ sed -i.bak "s/\"publisher\": \"[^\"]*\"/\"publisher\": \"$PUBLISHER\"/g" provide # Build operator echo -e "${BLUE}Building operator...${NC}" cd operator -kit build $EXTRA_ARGS +kit build --hyperapp $EXTRA_ARGS cd .. # Build provider @@ -243,4 +243,4 @@ echo -e "${BLUE}Publisher: $PUBLISHER${NC}" echo -e "${BLUE}Package contents:${NC}" ls -la pkg/ echo -e "\n${BLUE}Target directory:${NC}" -ls -la target/ \ No newline at end of file +ls -la target/ diff --git a/constants/production.rs b/constants/production.rs index a260382..b9b44c3 100644 --- a/constants/production.rs +++ b/constants/production.rs @@ -8,4 +8,7 @@ pub const HYPERGRID_ADDRESS: &str = "0xd65cb2ae7212e9b767c6953bb11cad1876d81cc8" pub const HYPERGRID_NAMESPACE_MINTER_ADDRESS: &str = "0x44a8Bd4f9370b248c91d54773Ac4a457B3454b50"; pub const HYPR_HASH: &str = "0x29575a1a0473dcc0e00d7137198ed715215de7bffd92911627d5e008410a5826"; pub const USDC_BASE_ADDRESS: &str = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; -pub const CIRCLE_PAYMASTER: &str = "0x0578cFB241215b77442a541325d6A4E6dFE700Ec"; \ No newline at end of file +pub const CIRCLE_PAYMASTER: &str = "0x0578cFB241215b77442a541325d6A4E6dFE700Ec"; + +pub const OLD_TBA_IMPLEMENTATION: &str = "0x000000000046886061414588bb9F63b6C53D8674"; // Works but no gasless support +pub const NEW_TBA_IMPLEMENTATION: &str = "0x3950D18044D7DAA56BFd6740fE05B42C95201535"; // actually fixed (final: part deux) \ No newline at end of file diff --git a/constants/staging.rs b/constants/staging.rs index 25f17a8..c4c78e4 100644 --- a/constants/staging.rs +++ b/constants/staging.rs @@ -8,4 +8,7 @@ pub const HYPERGRID_ADDRESS: &str = "0x2138da52cbf52adf2e73139a898370e03bbebf0a" pub const HYPERGRID_NAMESPACE_MINTER_ADDRESS: &str = "0x44a8Bd4f9370b248c91d54773Ac4a457B3454b50"; pub const HYPR_HASH: &str = "0x29575a1a0473dcc0e00d7137198ed715215de7bffd92911627d5e008410a5826"; // TODO: Update with staging hash pub const USDC_BASE_ADDRESS: &str = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; -pub const CIRCLE_PAYMASTER: &str = "0x0578cFB241215b77442a541325d6A4E6dFE700Ec"; \ No newline at end of file +pub const CIRCLE_PAYMASTER: &str = "0x0578cFB241215b77442a541325d6A4E6dFE700Ec"; + +pub const OLD_TBA_IMPLEMENTATION: &str = "0x000000000046886061414588bb9F63b6C53D8674"; // Works but no gasless support +pub const NEW_TBA_IMPLEMENTATION: &str = "0x3950D18044D7DAA56BFd6740fE05B42C95201535"; // actually fixed (final: part deux) \ No newline at end of file diff --git a/hypergrid-shim/CHANGELOG.md b/hypergrid-shim/CHANGELOG.md new file mode 100644 index 0000000..5405313 --- /dev/null +++ b/hypergrid-shim/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +## [1.3.8] - 2025-01-10 + +### Fixed +- Function-specific endpoints now receive parameters directly, not wrapped in RPC format +- This aligns with hyperapp framework expectations for dedicated endpoints + +## [1.3.7] - 2025-01-10 + +### Fixed +- Authorization test now correctly uses search_registry endpoint +- The authorize tool configures existing credentials, doesn't generate new ones + +## [1.3.6] - 2025-01-10 + +### Fixed +- Restored RPC wrapper format for function-specific endpoints +- All requests now properly wrap parameters in function name object + +### Changed +- `/mcp-authorize` expects `{ "authorize": { "node": "...", "token": "..." } }` +- `/mcp-search-registry` expects `{ "search_registry": { ... } }` +- `/mcp-call-provider` expects `{ "call_provider": { ... } }` + +## [1.3.0] - 2025-01-09 + +### Changed +- **BREAKING**: Authentication now sent in request body instead of headers for WIT compatibility +- **BREAKING**: Requests now use proper hyperapp RPC format (wrapped in `ShimAdapter`) +- Aligned with hyperapp framework's RPC pattern +- Both `/shim/mcp` and `/mcp` endpoints are supported + +### Fixed +- Fixed authorization test request format to use proper RPC wrapping +- Fixed search-registry and call-provider to use correct RPC format +- Fixed response parsing to handle hyperapp Result wrapper (`{Ok: ...}` or `{Err: ...}`) + +### Migration Guide +When updating from 1.2.x to 1.3.0: +1. The shim now sends requests in proper hyperapp RPC format automatically +2. Both `/shim/mcp` (legacy) and `/mcp` (new) endpoints work +3. No changes needed to your Claude Desktop configuration + +### Technical Details +- Requests are now wrapped: `{ "ShimAdapter": { client_id, token, client_name, mcp_request_json } }` +- Responses are wrapped in Result type: `{ "Ok": { "json_response": "..." } }` or `{ "Err": "..." }` +- This change ensures compatibility with the hyperapp framework's RPC pattern + +## [1.2.0] - Previous Release +- Initial public release with header-based authentication diff --git a/hypergrid-shim/LOCAL_DEVELOPMENT.md b/hypergrid-shim/LOCAL_DEVELOPMENT.md new file mode 100644 index 0000000..f1cac23 --- /dev/null +++ b/hypergrid-shim/LOCAL_DEVELOPMENT.md @@ -0,0 +1,112 @@ +# Local Development Guide for Hypergrid MCP Shim + +This guide explains how to use the local development version of the shim instead of the npm-published version. + +## Building the Local Shim + +1. Navigate to the shim directory: + ```bash + cd hypergrid-shim + ``` + +2. Install dependencies: + ```bash + npm install + ``` + +3. Build the TypeScript code: + ```bash + npm run build + ``` + +## Using the Local Version in Claude Desktop + +Instead of using the npm package, you can point Claude to your local build: + +### Option 1: Direct Path (Recommended for Development) + +Update your Claude Desktop configuration file: + +**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "hyperware": { + "command": "node", + "args": ["/Users/hall/deve/HYPERWARE/APPS/MONO-HPN/hpn/hypergrid-shim/dist/index.js"] + } + } +} +``` + +Replace the path with the absolute path to your local `dist/index.js` file. + +### Option 2: npm link (Alternative) + +1. In the shim directory, create a global link: + ```bash + npm link + ``` + +2. Then use the same configuration as the npm version: + ```json + { + "mcpServers": { + "hyperware": { + "command": "npx", + "args": ["@hyperware-ai/hypergrid-mcp"] + } + } + } + ``` + +## Key Changes in This Version + +1. **New Endpoint**: The shim now uses `/mcp` instead of `/shim/mcp` +2. **Direct RPC Calls**: The shim routes directly to the operator's RPC functions +3. **Better Error Handling**: Improved error messages and authentication flow + +## Testing the Local Version + +After updating your Claude configuration: + +1. Restart Claude Desktop +2. Test the authorization: + ``` + Use the authorize tool with url "http://localhost:8080/operator:hypergrid:ware.hypr/mcp", token "your-token", client_id "your-client-id", and node "your-node" + ``` + Note the new `/mcp` endpoint (not `/shim/mcp`) + +3. Test searching: + ``` + Search the registry for test providers + ``` + +4. Test calling a provider: + ``` + Call a provider you found in the search + ``` + +## Debugging + +If you encounter issues: + +1. Check the Claude logs for error messages +2. Run the shim manually to see console output: + ```bash + node /path/to/hypergrid-shim/dist/index.js + ``` +3. Verify the operator is running and the `/mcp` endpoint is accessible +4. Check that your local build is up to date (`npm run build`) + +## Publishing Updates + +When ready to publish the updated shim to npm: + +1. Update the version in `package.json` +2. Build: `npm run build` +3. Publish: `npm publish` + +Remember to update any references to `/shim/mcp` in documentation to use `/mcp` instead. diff --git a/hypergrid-shim/README.md b/hypergrid-shim/README.md index 7117e4c..0e04dcd 100644 --- a/hypergrid-shim/README.md +++ b/hypergrid-shim/README.md @@ -85,6 +85,8 @@ If you prefer manual configuration, you can create a `grid-shim-api.json` file: } ``` +Note: Both `/shim/mcp` and `/mcp` endpoints are supported. The `/mcp` endpoint is the newer one. + Then run with: ```bash npx @hyperware-ai/hypergrid-mcp -c /path/to/grid-shim-api.json diff --git a/hypergrid-shim/package.json b/hypergrid-shim/package.json index 59f7b0a..4fe1d7e 100644 --- a/hypergrid-shim/package.json +++ b/hypergrid-shim/package.json @@ -1,6 +1,6 @@ { "name": "@hyperware-ai/hypergrid-mcp", - "version": "1.1.2", + "version": "1.3.0", "type": "module", "description": "Shim for MCP server discovery through the Hypergrid operator tools", "main": "index.js", @@ -13,15 +13,17 @@ ], "scripts": { "build": "tsc", + "start": "node dist/index.js", "prepublishOnly": "npm run build", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC", "devDependencies": { - "@types/node": "^22.14.0", + "@types/node": "^22.18.1", "@types/yargs": "^17.0.33", - "typescript": "^5.8.3" + "ts-node": "^10.9.2", + "typescript": "^5.9.2" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.9.0", diff --git a/hypergrid-shim/src/index.ts b/hypergrid-shim/src/index.ts index d5946bb..e2abbc8 100644 --- a/hypergrid-shim/src/index.ts +++ b/hypergrid-shim/src/index.ts @@ -7,6 +7,9 @@ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; +// Version - update this when releasing +const SHIM_VERSION = '1.3.0'; + // --- Configuration Management --- const CONFIG_FILE_NAME = 'grid-shim-api.json'; const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.hypergrid', 'configs'); @@ -23,6 +26,26 @@ interface ShimConfig { let currentConfig: ShimConfig | null = null; let configPath: string = ''; +// ew? +// Helper to normalize config URLs +function normalizeConfigUrl(config: ShimConfig): ShimConfig { + // The MCP endpoints are at the operator root level + // Remove any path after the operator identifier pattern (operator:hypergrid:*.hypr) + //let normalizedUrl = config.url; + + //// Match operator pattern and remove anything after it + //const operatorMatch = normalizedUrl.match(/(https?:\/\/[^\/]+\/operator:[^\/]+\.hypr)/); + //if (operatorMatch) { + // normalizedUrl = operatorMatch[1]; + //} else { + // // Fallback: remove common suffixes if operator pattern not found + // normalizedUrl = normalizedUrl.replace(/\/(shim|api)\/(mcp|api|mcp-.*?)$/, ''); + // normalizedUrl = normalizedUrl.replace(/\/(mcp|api|mcp-.*?)$/, ''); + //} + + return { ...config, url: config.url }; +} + async function loadConfig(): Promise { const argv = await yargs(process.argv.slice(2)) .option('configFile', { @@ -38,9 +61,10 @@ async function loadConfig(): Promise { configPath = path.resolve(argv.configFile); try { const fileContent = await fs.readFile(configPath, 'utf-8'); - const parsedConfig = JSON.parse(fileContent); + const parsedConfig = JSON.parse(fileContent) as ShimConfig; + const normalizedConfig = normalizeConfigUrl(parsedConfig); console.error(`Config loaded from: ${configPath}`); - return parsedConfig as ShimConfig; + return normalizedConfig; } catch (error: any) { console.error(`Failed to load config from ${configPath}: ${error.message}`); } @@ -56,10 +80,11 @@ async function loadConfig(): Promise { for (const tryPath of discoveryPaths) { try { const fileContent = await fs.readFile(tryPath, 'utf-8'); - const parsedConfig = JSON.parse(fileContent); + const parsedConfig = JSON.parse(fileContent) as ShimConfig; + const normalizedConfig = normalizeConfigUrl(parsedConfig); configPath = tryPath; console.error(`Config auto-discovered at: ${tryPath}`); - return parsedConfig as ShimConfig; + return normalizedConfig; } catch (error) { // Continue to next path } @@ -87,54 +112,68 @@ async function saveConfig(config: ShimConfig): Promise { return savedPath; } -// --- Main Execution --- async function main() { // Try to load existing config currentConfig = await loadConfig(); const server = new McpServer({ name: currentConfig - ? `HyperwareMCP Shim (Node: ${currentConfig.node}, ClientID: ${currentConfig.client_id})` - : `HyperwareMCP Shim (Unconfigured)`, - version: "0.1.0", + ? `HyperwareMCP Shim v${SHIM_VERSION} (Node: ${currentConfig.node}, ClientID: ${currentConfig.client_id})` + : `HyperwareMCP Shim v${SHIM_VERSION} (Unconfigured)`, + version: SHIM_VERSION, instructions: `This is the Hypergrid MCP shim. This shim facilitates access to the Hypergrid protocol, which allows AI agents to connect to a p2p registry of remote data-feed providers. Hypergrid should be used in a situation where you might benefit from external data that is not contained or possibly out-of-date in your training data, or would be clunky to access via web search. This shim exposes three tools: the first is "authorize", which connects the shim to the user's external Hyperware node (Hyperware is the p2p server framework that Hypergrid is built on). If the user doesn't have an external Hyperware node or seems unsure about what that means, direct them to book.hyperware.ai where they can find setup instructions for the Hypergrid Operator Client. The second tool is "search-registry" which allows you to search through all listed providers (a provider is a process running on a Hyperware node using the Hypergrid Provider Client that will return some data to you) to identify candidates for when you might use the third tool, "call-provider", which allows you to set the parameters for and make a request to a previously discovered Provider. You should be using the "search-provider" tool often, anytime you think that it might be helpful to get some targeted specific data, but only actually call a provider you find if it makes sense given your current task. The Provider registry is constantly growing, and a Provider could be many kinds of things, not just some simple data feed. Maybe it transforms some input data, maybe it does something more complex. Be liberal with your searches and decisive with your calls. The search function works based on string-matching, so think about what a Provider relevant to your task might have included in their description.` }); - // Authorization tool - can be called anytime to configure or reconfigure server.tool( "authorize", { - url: z.string().describe("The base URL for the Hypergrid API (e.g., http://localhost:8080/operator:hypergrid:ware.hypr/shim/mcp or http://localhost:8080/operator:hypergrid:test.hypr/shim/mcp)"), + url: z.string().describe("The base URL for the Hypergrid API (e.g., http://localhost:8080/operator:hypergrid:ware.hypr)"), token: z.string().describe("The authentication token"), client_id: z.string().describe("The unique client ID"), node: z.string().describe("The Hyperware node name"), - name: z.string().optional().describe("Your identity (e.g., 'Claude', 'GPT-4', 'Gemini Pro') - be just specific enough, so that a user can identify you") + name: z.string().optional().describe("Your identity (e.g., 'Claude', 'GPT-4', 'Gemini Pro') - be just specific enough so that a user can identify you") }, async ({ url, token, client_id, node, name }) => { try { - // Validate the new config by making a test request - const testHeaders: any = { - "Content-type": "application/json", - "X-Client-ID": client_id, - "X-Token": token - }; - if (name) { - testHeaders["X-Client-Name"] = name; - } + // Use the same normalization as config loading + const tempConfig: ShimConfig = { url, token, client_id, node }; + const normalizedConfig = normalizeConfigUrl(tempConfig); + let normalizedUrl = normalizedConfig.url; + + // Call operator to authorize this client and get a client_id (PascalCase variant wrapper + tuple) + const authorizeBody = { Authorize: [node, token, client_id, name || null] }; - console.error(`Testing new configuration...`); - const testResponse = await fetch(url, { + console.error(`Authorizing with operator...`); + const authorizeUrl = `${normalizedUrl}mcp-authorize`; + const authorizeResponse = await fetch(authorizeUrl, { method: "POST", - headers: testHeaders, - body: JSON.stringify({ SearchRegistry: "test" }), + headers: { + "Content-type": "application/json" + }, + body: JSON.stringify(authorizeBody), }); - if (!testResponse.ok && testResponse.status !== 404) { - throw new Error(`Configuration test failed: ${testResponse.status} ${testResponse.statusText}`); + if (!authorizeResponse.ok) { + throw new Error(`Authorization request failed: ${authorizeResponse.status} ${authorizeResponse.statusText}`); + } + + const authorizeText = await authorizeResponse.text(); + let returnedClientId = client_id; + let returnedToken = token; + try { + const parsed = JSON.parse(authorizeText); + if (parsed.Ok) { + returnedClientId = parsed.Ok.client_id || returnedClientId; + returnedToken = parsed.Ok.token || returnedToken; + } else if (parsed.Err) { + throw new Error(`Authorize error: ${parsed.Err}`); + } + } catch (e) { + // If parsing fails, continue with provided credentials } - // Save the new config - const newConfig: ShimConfig = { url, token, client_id, node, ...(name && { name }) }; + // Save the new config with normalized URL and returned client credentials + const newConfig: ShimConfig = { url: normalizedUrl, token: returnedToken, client_id: returnedClientId, node, ...(name && { name }) }; const savedPath = await saveConfig(newConfig); currentConfig = newConfig; @@ -143,7 +182,7 @@ async function main() { return { content: [{ type: "text", - text: `✅ Successfully authorized! Configuration saved to ${savedPath}.\n\nThe MCP server is now configured and ready to use with:\n- Node: ${node}\n- Client ID: ${client_id}${name ? `\n- Name: ${name}` : ''}\n- URL: ${url}\n\nYou can now use the search-registry and call-provider tools.` + text: `✅ Successfully authorized! Configuration saved to ${savedPath}.\n\nThe MCP server is now configured and ready to use with:\n- Node: ${node}\n- Client ID: ${returnedClientId}${name ? `\n- Name: ${name}` : ''}\n- URL: ${url}\n\nYou can now use the search-registry and call-provider tools.` }] }; } catch (error: any) { @@ -169,26 +208,50 @@ async function main() { }; } - const body = { SearchRegistry: query }; - console.error(`search-registry: Forwarding to ${currentConfig.url}`); - const headers: any = { - "Content-type": "application/json", - "X-Client-ID": currentConfig.client_id, - "X-Token": currentConfig.token + // Use PascalCase variant wrapper + tuple to match active UI RPC style + const rpcBody = { + SearchRegistry: [ + query, + currentConfig.client_id, + currentConfig.token + ] }; - if (currentConfig.name) { - headers["X-Client-Name"] = currentConfig.name; - } + + const searchUrl = `${currentConfig.url}mcp-search-registry`; + console.error(`search-registry: Calling ${searchUrl} with query: ${query}`); try { - const res = await fetch(currentConfig.url, { + const res = await fetch(searchUrl, { method: "POST", - headers: headers, - body: JSON.stringify(body), + headers: { + "Content-type": "application/json" + }, + body: JSON.stringify(rpcBody), }); const resBody = await res.text(); console.error(`search-registry: Response received (status ${res.status})`); - return { content: [{ type: "text", text: String(resBody) }] }; + + // Parse the hyperapp Result wrapper + try { + const parsed = JSON.parse(resBody); + if (parsed.Ok) { + // search_registry returns Vec + const results = parsed.Ok; + return { + content: [{ + type: "text", + text: JSON.stringify({ results }, null, 2) + }] + }; + } else if (parsed.Err) { + return { content: [{ type: "text", text: `Error: ${parsed.Err}` }] }; + } else { + return { content: [{ type: "text", text: resBody }] }; + } + } catch { + // If parsing fails, return raw response + return { content: [{ type: "text", text: resBody }] }; + } } catch (e: any) { console.error(`search-registry: Request failed: ${e.message}`); return { @@ -215,28 +278,49 @@ async function main() { }; } - const body = { - CallProvider: { providerId, providerName, arguments: callArgs }, + // Convert call args to KeyValue format + const args = callArgs.map(([key, value]) => ({ key, value })); + + // Use PascalCase variant wrapper + tuple to match active UI RPC style + const rpcBody = { + CallProvider: [ + providerId, + providerName, + args, + currentConfig.client_id, + currentConfig.token + ] }; - console.error(`call-provider: Forwarding to ${currentConfig.url}`); - const headers: any = { - "Content-type": "application/json", - "X-Client-ID": currentConfig.client_id, - "X-Token": currentConfig.token - }; - if (currentConfig.name) { - headers["X-Client-Name"] = currentConfig.name; - } + + const callUrl = `${currentConfig.url}mcp-call-provider`; + console.error(`call-provider: Calling ${callUrl} for provider ${providerName} (${providerId})`); try { - const res = await fetch(currentConfig.url, { + const res = await fetch(callUrl, { method: "POST", - headers: headers, - body: JSON.stringify(body), + headers: { + "Content-type": "application/json" + }, + body: JSON.stringify(rpcBody), }); const resBody = await res.text(); console.error(`call-provider: Response received (status ${res.status})`); - return { content: [{ type: "text", text: String(resBody) }] }; + + // Parse the hyperapp Result wrapper + try { + const parsed = JSON.parse(resBody); + if (parsed.Ok) { + // call_provider returns a String (JSON response from provider) + return { content: [{ type: "text", text: parsed.Ok }] }; + } else if (parsed.Err) { + return { content: [{ type: "text", text: `Error: ${parsed.Err}` }] }; + } else { + return { content: [{ type: "text", text: resBody }] }; + } + } catch { + // If parsing fails, return raw response + return { content: [{ type: "text", text: resBody }] }; + } } catch (e: any) { console.error(`call-provider: Request failed: ${e.message}`); return { @@ -251,16 +335,18 @@ async function main() { console.error(`Connecting transport...`); const transport = new StdioServerTransport(); await server.connect(transport); - console.error(`Shim connected and listening for MCP commands.`); + console.error(`HyperwareMCP Shim v${SHIM_VERSION} connected and listening for MCP commands.`); if (!currentConfig) { - console.error(`\n⚠️ MCP server started in UNCONFIGURED mode.`); + console.error(`\n⚠️ MCP server v${SHIM_VERSION} started in UNCONFIGURED mode.`); console.error(`To configure, ask your LLM: "Use the authorize tool with these credentials..."`); console.error(`The operator UI will provide the exact command to use.\n`); } else { - console.error(`\n✅ MCP server started with existing configuration.`); + console.error(`\n✅ MCP server v${SHIM_VERSION} started with existing configuration.`); console.error(`Node: ${currentConfig.node}`); - console.error(`Client ID: ${currentConfig.client_id}\n`); + console.error(`Client ID: ${currentConfig.client_id}`); + console.error(`Using base URL: ${currentConfig.url}`); + console.error(`Endpoints: /mcp-authorize, /mcp-search-registry, /mcp-call-provider\n`); } } diff --git a/metadata.json b/metadata.json index f888df2..eb77b10 100644 --- a/metadata.json +++ b/metadata.json @@ -4,16 +4,11 @@ "image": "https://raw.githubusercontent.com/hyperware-ai/hpn/ccd0e9c0d08b2344b06ce4a5b8584f819b92e43e/hypergrid-logo.webp", "properties": { "package_name": "hypergrid", - "current_version": "1.2.1", - "publisher": "ware.hypr", - "mirrors": ["ware.hypr","sam.hypr", "backup-distro-node.os"], + "current_version": "1.0.0", + "publisher": "test.hypr", + "mirrors": ["sam.hypr", "backup-distro-node.os"], "code_hashes": { - "1.0.0": "001a49117374abc3bdb38179d8ce05d76205b008bb55683e116be36f3e1635ce", - "1.1.0": "b9a3255a0778ffc9540bbae08aa05320525378a75dca5ba02f311ab192bda79f", - "1.1.1": "52c826cf4ed84b9138c01a99db09ab17079dd020623951475558a28af19a7c1c", - "1.1.2": "b8baa26b03cc6b536152638b33595a827764b4ed186f371cb61be33c0276e566", - "1.2.0": "740ef394e47fcf8e2d66e2eca0ebf8f9276ed7dad59fd12d283c6384ba11fbe7", - "1.2.1": "898ddd9e5805b5f4aa70f33a97efeea8e5e58216791713c9f8bc6f6c2c3f205d" + "1.0.0": "001a49117374abc3bdb38179d8ce05d76205b008bb55683e116be36f3e1635ce" }, "wit_version": 1, "dependencies": [] diff --git a/operator/Cargo.toml b/operator/Cargo.toml index d6062d5..f464cb8 100644 --- a/operator/Cargo.toml +++ b/operator/Cargo.toml @@ -1,10 +1,12 @@ [profile.release] -panic = "abort" -opt-level = "s" lto = true +opt-level = "s" +panic = "abort" [workspace] members = [ - "operator" + "operator", + "target/operator-caller-utils", + "target/operator-caller-util?", ] -resolver = "2" \ No newline at end of file +resolver = "2" diff --git a/operator/api/operator-process.wit b/operator/api/operator-process.wit new file mode 100644 index 0000000..7f28a41 --- /dev/null +++ b/operator/api/operator-process.wit @@ -0,0 +1,317 @@ +interface operator-process { +use standard.{address}; + + record authorize-result { + url: string, + token: string, + client-id: string, + node: string + } + + record configure-authorized-client-dto { + client-id: option, + client-name: option, + raw-token: string, + hot-wallet-address-to-associate: string + } + + record configure-authorized-client-result { + client-id: string, + raw-token: string, + api-base-path: string, + node-name: string + } + + record provider-info { + id: option, + provider-id: string, + name: string, + description: option, + site: option, + wallet: option, + price: option, + instructions: option, + hash: string + } + + record provider-search-result { + provider-id: string, + name: string, + description: string + } + + record key-value { + key: string, + value: string + } + + variant terminal-command { + get-state, + reset-state, + check-db-schema, + search-providers(string), + wipe-db-and-reindex, + print-ledger(string) + } + + record state { + chain-id: u64, + contract-address: string, + hypermap-address: string, + hypermap-timeout: u64, + root-hash: option, + names: list>, + last-checkpoint-block: u64, + logging-started: u64, + providers-cache: list>, + managed-wallets: list>, + selected-wallet-id: option, + operator-entry-name: option, + operator-tba-address: option, + wallet-limits-cache: list>, + client-limits-cache: list>, + active-signer-wallet-id: option, + cached-active-details: option, + call-history: list, + hashed-shim-api-key: option, + authorized-clients: list>, + gasless-enabled: option, + paymaster-approved: option, + hyperwallet-session-active: bool, + db-initialized: bool, + timers-initialized: bool + } + + record active-account-details { + id: string, + name: option, + address: string, + is-encrypted: bool, + is-selected: bool, + is-unlocked: bool, + eth-balance: option, + usdc-balance: option + } + + record managed-wallet { + id: string, + name: option, + storage-json: string, + spending-limits: spending-limits + } + + record spending-limits { + max-per-call: option, + max-total: option, + currency: option, + total-spent: option + } + + record hot-wallet-authorized-client { + id: string, + name: string, + associated-hot-wallet-address: string, + authentication-token: string, + capabilities: service-capabilities, + status: client-status + } + + variant service-capabilities { + all, + search-only, + call-providers, + none + } + + record call-provider { + request: provider-request + } + + record provider-request { + provider-name: string, + arguments: list>, + payment-tx-hash: option + } + + variant client-status { + active, + halted + } + + record call-record { + timestamp-start-ms: u64, + provider-lookup-key: string, + target-provider-id: string, + call-args-json: string, + response-json: option, + call-success: bool, + response-timestamp-ms: u64, + payment-result: option, + duration-ms: u64, + operator-wallet-id: option, + client-id: option, + provider-name: option + } + + record provider { + name: string, + hash: string, + facts: list>>, + wallet: option, + price: option, + provider-id: option + } + + // Function signature for: authorize (http) + // HTTP: POST /mcp-authorize + record authorize-signature-http { + target: string, + node: string, + token: string, + client-id: string, + name: option, + returning: result + } + + // Function signature for: call-provider (http) + // HTTP: POST /mcp-call-provider + record call-provider-signature-http { + target: string, + provider-id: string, + provider-name: string, + args: list, + client-id: string, + token: string, + returning: result + } + + // Function signature for: configure-authorized-client (http) + // HTTP: POST /mcp-configure-authorized-client + record configure-authorized-client-signature-http { + target: string, + req: configure-authorized-client-dto, + returning: result + } + + // Function signature for: recheck-identity (http) + // HTTP: POST /api/recheck-identity + record recheck-identity-signature-http { + target: string, + returning: result<_, string> + } + + // Function signature for: recheck-identity (local) + record recheck-identity-signature-local { + target: address, + returning: result<_, string> + } + + // Function signature for: recheck-paymaster-approval (http) + // HTTP: POST /api/recheck-paymaster-approval + record recheck-paymaster-approval-signature-http { + target: string, + returning: result<_, string> + } + + // Function signature for: recheck-paymaster-approval (local) + record recheck-paymaster-approval-signature-local { + target: address, + returning: result<_, string> + } + + // Function signature for: remove-authorized-client (http) + // HTTP: POST /api/remove-authorized-client + record remove-authorized-client-signature-http { + target: string, + client-id: string, + returning: result<_, string> + } + + // Function signature for: remove-authorized-client (local) + record remove-authorized-client-signature-local { + target: address, + client-id: string, + returning: result<_, string> + } + + // Function signature for: rename-authorized-client (http) + // HTTP: POST /api/rename-authorized-client + record rename-authorized-client-signature-http { + target: string, + client-id: string, + new-name: string, + returning: result<_, string> + } + + // Function signature for: rename-authorized-client (local) + record rename-authorized-client-signature-local { + target: address, + client-id: string, + new-name: string, + returning: result<_, string> + } + + // Function signature for: search-providers-public (http) + // HTTP: POST /api/search-providers-public + record search-providers-public-signature-http { + target: string, + query: string, + returning: result, string> + } + + // Function signature for: search-providers-public (local) + record search-providers-public-signature-local { + target: address, + query: string, + returning: result, string> + } + + // Function signature for: search-registry (http) + // HTTP: POST /mcp-search-registry + record search-registry-signature-http { + target: string, + query: string, + client-id: string, + token: string, + returning: result, string> + } + + // Function signature for: set-client-limits (http) + // HTTP: POST /api/set-client-limits + record set-client-limits-signature-http { + target: string, + client-id: string, + limits: spending-limits, + returning: result<_, string> + } + + // Function signature for: set-client-limits (local) + record set-client-limits-signature-local { + target: address, + client-id: string, + limits: spending-limits, + returning: result<_, string> + } + + // Function signature for: terminal-command (local) + record terminal-command-signature-local { + target: address, + command: terminal-command, + returning: result + } + + // Function signature for: toggle-client-status (http) + // HTTP: POST /api/toggle-client-status + record toggle-client-status-signature-http { + target: string, + client-id: string, + returning: result<_, string> + } + + // Function signature for: toggle-client-status (local) + record toggle-client-status-signature-local { + target: address, + client-id: string, + returning: result<_, string> + } +} diff --git a/operator/api/operator-sortugdev-dot-os-v0.wit b/operator/api/operator-sortugdev-dot-os-v0.wit new file mode 100644 index 0000000..5f5b4a0 --- /dev/null +++ b/operator/api/operator-sortugdev-dot-os-v0.wit @@ -0,0 +1,4 @@ +world operator-sortugdev-dot-os-v0 { + import operator-process; + include process-v1; +} \ No newline at end of file diff --git a/operator/api/types-operator-sortugdev-dot-os-v0.wit b/operator/api/types-operator-sortugdev-dot-os-v0.wit new file mode 100644 index 0000000..cec851d --- /dev/null +++ b/operator/api/types-operator-sortugdev-dot-os-v0.wit @@ -0,0 +1,4 @@ +world types-operator-sortugdev-dot-os-v0 { + import operator-process; + include lib; +} \ No newline at end of file diff --git a/operator/metadata.json b/operator/metadata.json index ffddb19..9cb37cd 100644 --- a/operator/metadata.json +++ b/operator/metadata.json @@ -5,7 +5,7 @@ "properties": { "package_name": "hypergrid", "current_version": "0.1.2", - "publisher": "ware.hypr", + "publisher": "test.hypr", "mirrors": [], "code_hashes": { "0.1.0": "5ee79597d09c2ca644e41059489f1ed99eb8c5919b2830f9e3e4a1863cb0da88", diff --git a/operator/operator/Cargo.toml b/operator/operator/Cargo.toml index 29c47be..20249f8 100644 --- a/operator/operator/Cargo.toml +++ b/operator/operator/Cargo.toml @@ -1,36 +1,55 @@ -[package] -name = "operator" -version = "0.1.0" -edition = "2021" -publish = false - [dependencies] -alloy-sol-types = "0.8.15" alloy-primitives = "0.8.15" -hex = "0.4.3" -rmp-serde = "1.1.2" +alloy-sol-types = "0.8.15" anyhow = "1.0" -hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", branch = "wh/back-off-fix", features = ["hyperwallet", "logging"] } -process_macros = "0.1.0" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -wit-bindgen = "0.36.0" +base64ct = "=1.6.0" chrono = "0.4.40" +hex = "0.4.3" +process_macros = "0.1.0" rand = "0.8" -sha3 = "0.10" +serde_json = "1.0" sha2 = "0.10" -# Pin base64ct to avoid edition2024 requirement in 1.8.0 -base64ct = "=1.6.0" +sha3 = "0.10" url = "2.4" +wit-bindgen = "0.42.1" -[dependencies.uuid] -version = "1.16.0" +[dependencies.hyperprocess_macro] +branch = "develop" +git = "https://github.com/hyperware-ai/hyperprocess-macro" + +[dependencies.hyperware_process_lib] features = [ - "v4", + "hyperapp", + "hyperwallet", ] +git = "https://github.com/hyperware-ai/process_lib" +rev = "4beff93" + +[dependencies.operator_caller_utils] +optional = true +path = "../target/operator-caller-utils" + +[dependencies.serde] +features = ["derive"] +version = "1.0" + +[dependencies.uuid] +features = ["v4"] +version = "1.16.0" + +[features] +caller-utils = ["operator_caller_utils"] +default = [] +legacy-mods = [] [lib] crate-type = ["cdylib"] +[package] +edition = "2021" +name = "operator" +publish = false +version = "0.1.0" + [package.metadata.component] package = "hyperware:process" diff --git a/operator/operator/src/app_api_types.rs b/operator/operator/src/app_api_types.rs new file mode 100644 index 0000000..b1dcf66 --- /dev/null +++ b/operator/operator/src/app_api_types.rs @@ -0,0 +1,170 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SetupStatus { + pub configured: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum AppOnboardingStatus { + Loading, + NeedsHotWallet, + NeedsOnChainSetup, + NeedsFunding, + Ready, + Error, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct OnboardingCheckDetailsDto { + pub identity_configured: bool, + pub operator_entry: Option, + pub operator_tba: Option, + pub tba_eth_funded: Option, + pub tba_usdc_funded: Option, + pub tba_eth_balance_str: Option, + pub tba_usdc_balance_str: Option, + pub tba_funding_check_error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct OnboardingStatusResponseDto { + pub status: AppOnboardingStatus, + pub checks: OnboardingCheckDetailsDto, + pub errors: Vec, +} + +// WIT-safe payment result types +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum PaymentStatus { + Success, + Failed, + Skipped, + LimitExceeded, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PaymentResultDto { + pub status: PaymentStatus, + pub tx_hash: Option, + pub amount: Option, + pub currency: Option, + pub error: Option, + pub reason: Option, + pub limit: Option, +} + +// Shim-related types +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AuthorizeShimRequest { + pub node: String, + pub token: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AuthorizeShimResponse { + pub status: String, + pub node: String, + pub message: String, +} + +// WIT-safe MCP request types +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct McpSearchRequest { + pub query: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct McpCallProviderRequest { + #[serde(rename = "providerId")] + pub provider_id: String, + #[serde(rename = "providerName")] + pub provider_name: String, + pub arguments: Vec<(String, String)>, // The shim sends tuples, not KeyValue objects +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct KeyValue { + pub key: String, + pub value: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct McpSearchResponse { + pub results: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ProviderSearchResult { + pub provider_id: String, + pub name: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct McpCallProviderResponse { + pub status: String, + pub provider_id: String, + pub provider_name: String, + pub response: String, +} + +// Authorize response - returns config for shim to save locally +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AuthorizeResult { + pub url: String, // The /api endpoint URL + pub token: String, // Raw token for shim to save + pub client_id: String, // Generated client ID + pub node: String, // Node name +} + +// Shim adapter request - combines auth and MCP request in body +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ShimAdapterDto { + pub client_id: String, + pub token: String, + pub client_name: Option, + pub mcp_request_json: String, // The actual MCP request as JSON string (SearchRegistry or CallProvider) +} + +// Shim adapter response - returns JSON as string for WIT compatibility +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ShimAdapterResult { + pub json_response: String, +} + +// Provider information for public endpoints +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ProviderInfo { + pub id: Option, + pub provider_id: String, + pub name: String, + pub description: Option, + pub site: Option, + pub wallet: Option, + pub price: Option, + pub instructions: Option, + pub hash: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum TerminalCommand { + GetState, + ResetState, + CheckDbSchema, + SearchProviders(String), + WipeDbAndReindex, + PrintLedger(String), +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ConfigureAuthorizedClientDto { + pub client_id: Option, // If provided, update this client instead of creating new + pub client_name: Option, + pub raw_token: String, + pub hot_wallet_address_to_associate: String, +} diff --git a/operator/operator/src/authorized_services.rs b/operator/operator/src/authorized_services.rs deleted file mode 100644 index 99fec60..0000000 --- a/operator/operator/src/authorized_services.rs +++ /dev/null @@ -1,18 +0,0 @@ -// intended to use for the spawned EOAs-to-client-management interface - -use serde::{Serialize, Deserialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] -pub enum ServiceCapabilities { - All, - None, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] -pub struct HotWalletAuthorizedClient { - pub id: String, // e.g., "grid-shim-main" - pub name: String, // e.g., "Hypergrid Shim Service" - pub associated_hot_wallet_address: String, // e.g., "0x123..." - pub authentication_token: String, // Secret token for this client - pub capabilities: ServiceCapabilities, -} \ No newline at end of file diff --git a/operator/operator/src/chain.rs b/operator/operator/src/chain.rs deleted file mode 100644 index 97c8a53..0000000 --- a/operator/operator/src/chain.rs +++ /dev/null @@ -1,631 +0,0 @@ -use alloy_sol_types::SolEvent; -use hyperware_process_lib::eth::Filter; -use hyperware_process_lib::logging::{debug, info, error, warn}; -use hyperware_process_lib::sqlite::Sqlite; -use hyperware_process_lib::{eth, hypermap, print_to_terminal, timer}; -use hyperware_process_lib::eth::{Provider, EthError}; -use alloy_primitives::{Bytes, Address, U256, B256}; -use anyhow::Result; -use std::str::FromStr; -use crate::constants::{NAMESPACE, HYPR_HASH}; - -use crate::db as dbm; -use crate::structs::*; -use alloy_primitives::keccak256; - -const MAX_PENDING_ATTEMPTS: u8 = 3; -// const SUBSCRIPTION_TIMEOUT: u64 = 60; -pub fn make_filters(state: &State) -> (eth::Filter, eth::Filter) { - let address = state.hypermap.address().to_owned(); - let mint_filter = eth::Filter::new() - .address(address.clone()) - .event(hypermap::contract::Mint::SIGNATURE); - let notes_filter = eth::Filter::new() - .address(address) - .event(hypermap::contract::Note::SIGNATURE) - .topic3(vec![ - keccak256("~description"), - keccak256("~instructions"), - keccak256("~price"), - keccak256("~wallet"), - keccak256("~provider-id"), - keccak256("~site"), - ]); - (mint_filter, notes_filter) -} -pub fn start_fetch(state: &mut State, db: &Sqlite) -> PendingLogs { - let (mints_filter, notes_filter) = make_filters(&state); - - // (Removed subscribe_loop calls per user request) - - let mut pending_logs: PendingLogs = Vec::new(); - - // Only initialize timers if they haven't been initialized yet - if !state.timers_initialized { - info!("Initializing chain sync timers..."); - timer::set_timer(DELAY_MS, None); - timer::set_timer(CHECKPOINT_MS, Some(b"checkpoint".to_vec())); - state.timers_initialized = true; - } else { - warn!("Timers already initialized, skipping timer initialization in start_fetch"); - } - - // (USDC subscribe placeholder removed) - - // --- Try to get historical logs from the local hypermap-cacher --- - info!("Attempting to bootstrap historical Mint/Note logs from local hypermap cache"); - let filters_vec = vec![mints_filter.clone(), notes_filter.clone()]; - - let bootstrap_block = match state - .hypermap - .bootstrap( - Some(state.last_checkpoint_block), - filters_vec, - Some((5, None)), // (retry_delay_s, retry_count) - None - ) - { - Ok((block, results_per_filter)) => { - if results_per_filter.len() == 2 { - let mint_logs = &results_per_filter[0]; - let note_logs = &results_per_filter[1]; - - info!("Bootstrapped {} mint logs and {} note logs from cache up to block {}.", - mint_logs.len(), note_logs.len(), block); - - for log in mint_logs { - if let Err(e) = handle_log(state, db, &mut pending_logs, log, 0) { - print_to_terminal(1, &format!("log-handling error! {e:?}")); - } - } - - for log in note_logs { - if let Err(e) = handle_log(state, db, &mut pending_logs, log, 0) { - print_to_terminal(1, &format!("log-handling error! {e:?}")); - } - } - - // Update the state's last checkpoint block to the returned block - if block > state.last_checkpoint_block { - state.last_checkpoint_block = block; - } - - Some(block) - } else { - error!("Unexpected bootstrap result length: {}, bootstrap failed", results_per_filter.len()); - None - } - } - Err(e) => { - error!("Bootstrap from cache failed: {:?}", e); - None - } - }; - - // If bootstrap succeeded, only fetch logs from the bootstrap block to current to fill any gap - // If bootstrap failed, fetch all logs from last checkpoint - if let Some(bootstrap_block) = bootstrap_block { - // Only fetch logs newer than what bootstrap gave us - if bootstrap_block < state.hypermap.provider.get_block_number().unwrap_or(bootstrap_block) { - info!("Fetching gap logs from block {} to latest (clamped to <=450 blocks)", bootstrap_block); - let latest = state.hypermap.provider.get_block_number().unwrap_or(bootstrap_block); - let from = bootstrap_block + 1; - let to = latest.min(from + 450); - let gap_mints_filter = mints_filter.clone().from_block(from).to_block(to); - let gap_notes_filter = notes_filter.clone().from_block(from).to_block(to); - fetch_and_process_logs(state, db, &mut pending_logs, &gap_mints_filter); - fetch_and_process_logs(state, db, &mut pending_logs, &gap_notes_filter); - } - } else { - // Bootstrap failed, fall back to full RPC fetch from last checkpoint - info!("Bootstrap failed, falling back to full RPC log fetch from block {}", state.last_checkpoint_block); - let latest = state.hypermap.provider.get_block_number().unwrap_or(state.last_checkpoint_block); - let from = state.last_checkpoint_block; - let to = latest.min(from + 450); - let fallback_mints_filter = mints_filter.clone().from_block(from).to_block(to); - let fallback_notes_filter = notes_filter.clone().from_block(from).to_block(to); - fetch_and_process_logs(state, db, &mut pending_logs, &fallback_mints_filter); - fetch_and_process_logs(state, db, &mut pending_logs, &fallback_notes_filter); - } - - pending_logs -} - -fn fetch_and_process_logs( - state: &mut State, - db: &Sqlite, - pending: &mut PendingLogs, - filter: &Filter, -) { - let mut retries = 0; - const MAX_FETCH_RETRIES: u32 = 2; // Max 2 retries for a given fetch attempt - let mut current_delay_secs = 5; // Initial delay, can be made dynamic - - loop { - if retries >= MAX_FETCH_RETRIES { - error!( - "Max retries ({}) reached for get_logs with filter: {:?}. Aborting fetch for this filter.", - MAX_FETCH_RETRIES, filter - ); - return; // Give up after max retries for this specific filter instance - } - match state.hypermap.provider.get_logs(filter) { - Ok(logs) => { - print_to_terminal(2, &format!("log len: {}", logs.len())); - for log in logs { - if let Err(e) = handle_log(state, db, pending, &log, 0) { - print_to_terminal(1, &format!("log-handling error! {e:?}")); - } - } - return; - } - Err(e) => { - retries += 1; - error!( - "Error fetching logs (attempt {}/{}) for filter {:?}: {:?}. Retrying in {}s...", - retries, MAX_FETCH_RETRIES, filter, e, current_delay_secs - ); - std::thread::sleep(std::time::Duration::from_secs(current_delay_secs)); - // Optional: Implement exponential backoff or increase delay systematically - // current_delay_secs = (current_delay_secs * 2).min(60); - } - } - } -} - -pub fn handle_log( - state: &mut State, - db: &Sqlite, - pending: &mut PendingLogs, - log: ð::Log, - attempt: u8, -) -> anyhow::Result<()> { - let topics = log.topics(); - debug!("log topics len: {:?}", topics.len()); - let processed = match topics[0] { - hypermap::contract::Mint::SIGNATURE_HASH => { - let decoded = hypermap::contract::Mint::decode_log_data(log.data(), true).unwrap(); - let parent_hash = decoded.parenthash.to_string(); - let child_hash = decoded.childhash.to_string(); - let label = String::from_utf8(decoded.label.to_vec())?; - - add_mint(state, db, &parent_hash, child_hash, label) - } - hypermap::contract::Note::SIGNATURE_HASH => { - let decoded = hypermap::contract::Note::decode_log_data(log.data(), true).unwrap(); - - let parent_hash = decoded.parenthash.to_string(); - let note_label = String::from_utf8(decoded.label.to_vec())?; - - add_note(state, db, &parent_hash, note_label, decoded.data) - } - _ => Ok(()), - }; - - if let Some(block_number) = log.block_number { - state.last_checkpoint_block = block_number; - } - - match processed { - Ok(_) => (), - Err(e) => { - if attempt < MAX_PENDING_ATTEMPTS { - pending.push((log.to_owned(), attempt + 1)); - } else { - info!("Max attempts reached for log processing: {:?}. Error: {:?}", log, e); - } - } - }; - - Ok(()) -} - -pub fn add_mint( - state: &mut State, - db: &Sqlite, - parent_hash: &str, - child_hash: String, - name: String, -) -> anyhow::Result<()> { - // Log every mint we see - info!("Processing mint: '{}' with hash {} and parent {}", name, child_hash, parent_hash); - - // Check if this is the Hypergrid root entry (grid under hypr) - // The parent hash 0x29575a1a0473dcc0e00d7137198ed715215de7bffd92911627d5e008410a5826 is 'hypr' - if name == NAMESPACE { - // We found a grid-beta entry, let's check if it's under hypr - let hypr_hash = HYPR_HASH; - if parent_hash == hypr_hash { - info!("Found Grid root entry: {} with hash {} (parent: {} which is 'hypr')", name, child_hash, parent_hash); - state.root_hash = Some(child_hash.clone()); - return Ok(()); - } else { - // Log what parent this grid has for debugging - let parent_name = hyperware_process_lib::net::get_name(parent_hash, Some(state.last_checkpoint_block), Some(1)) - .unwrap_or_else(|| "unknown".to_string()); - info!("Found entry named 'grid' under parent {} ({}), but we need grid.hypr", parent_hash, parent_name); - // This grid-beta is not ours, skip it - return Ok(()); - } - } - - let root = state.root_hash.clone().unwrap_or_default(); - if root.is_empty() { - // Root not set yet, this mint needs to wait - return Err(anyhow::anyhow!("Hypergrid root (grid.hypr) not yet found, deferring mint {} with parent {}", name, parent_hash)); - } - - // Check if this mint is under our scope (parent should be grid.hypr) - if parent_hash != &root { - // This mint is not directly under grid.hypr - // Try to resolve what the parent is to log it - let parent_name = hyperware_process_lib::net::get_name(parent_hash, Some(state.last_checkpoint_block), Some(1)) - .unwrap_or_else(|| "unknown".to_string()); - - debug!("Skipping mint '{}' with parent {} ({}) - not a direct child of grid.hypr", - name, parent_hash, parent_name); - - // Return Ok so it won't retry - return Ok(()); - } - - // This is a provider directly under grid.hypr - dbm::insert_provider(db, parent_hash, child_hash.clone(), name.clone())?; - info!("Added provider: {} directly under grid.hypr", name); - Ok(()) -} - -pub fn add_note( - state: &mut State, - db: &Sqlite, - parent_hash: &str, - note_label: String, - data: eth::Bytes, -) -> anyhow::Result<()> { - let key = note_label - .chars() - .skip(1) // Skip the leading '~' - .collect::() - .replace("-", "_"); - - // Check if the Hypergrid root is set - if state.root_hash.is_none() { - return Err(anyhow::anyhow!("Hypergrid root (grid.hypr) not yet found, deferring note {} for parent {}", note_label, parent_hash)); - } - - // First, check if this note is for something under our Hypergrid scope - // We need to verify the parent exists in our database OR will be created under grid.hypr - let provider_check = db.read( - "SELECT id FROM providers WHERE hash = ?1 LIMIT 1".to_string(), - vec![serde_json::Value::String(parent_hash.to_string())] - ); - - match provider_check { - Ok(rows) if !rows.is_empty() => { - // Provider exists in our database, proceed with the note - } - _ => { - // Provider doesn't exist in our database - // Check if there's a pending mint for this provider under grid.hypr - // If not, this note is for a provider outside our scope - debug!("Note {} for provider {} not in our database - checking if it's outside Hypergrid scope", note_label, parent_hash); - - // Try to resolve what this parent is - let parent_name = hyperware_process_lib::net::get_name(parent_hash, Some(state.last_checkpoint_block), Some(2)) - .unwrap_or_else(|| "unresolved".to_string()); - - if !parent_name.ends_with(".grid.hypr") && parent_name != "unresolved" { - // This provider is not under grid.hypr, skip it - debug!("Skipping note {} for provider {} ({}) - outside Hypergrid scope (not under grid.hypr)", - note_label, parent_hash, parent_name); - return Ok(()); // Return Ok to avoid retrying - } - - // If we can't resolve or it might be under grid.hypr, defer it - return Err(anyhow::anyhow!("Provider {} not found for note {} - deferring", parent_hash, note_label)); - } - } - - // We assume the provider must exist if a note is emitted for its hash. - // If insert_provider_facts fails due to FK constraint (provider not yet minted), - // this note processing will error out and retry via pending mechanism. - let decoded_value = match String::from_utf8(data.to_vec()) { - Ok(s) => s, - Err(_) => { - format!("0x{}", hex::encode(data)) - } - }; - - // Try to resolve the parent hash to a name for better logging - let parent_name = { - // First try the hypermap indexer to resolve hash to name - match hyperware_process_lib::net::get_name(parent_hash, Some(state.last_checkpoint_block), Some(2)) { - Some(name) => format!("(resolved: {})", name), - None => { - // If indexer fails, check if this parent_hash exists in our database - match db.read( - "SELECT name FROM providers WHERE hash = ?1 LIMIT 1".to_string(), - vec![serde_json::Value::String(parent_hash.to_string())] - ) { - Ok(rows) if !rows.is_empty() => { - rows[0].get("name") - .and_then(|v| v.as_str()) - .map(|s| format!("(known provider: {})", s)) - .unwrap_or_else(|| "(provider exists but name unknown)".to_string()) - } - _ => "(unknown/not-indexed)".to_string() - } - } - } - }; - - // Log which note we're trying to insert with more context - debug!("Attempting to insert note '{}' (key: '{}') for provider {} {}, value: '{}'", - note_label, key, parent_hash, parent_name, decoded_value); - - dbm::insert_provider_facts(db, key.clone(), decoded_value, parent_hash.to_string()) - .map_err(|e| anyhow::anyhow!("DB Error inserting note {} (key: {}) for provider {} {}: {}", - note_label, key, parent_hash, parent_name, e)) -} - -fn handle_pending(state: &mut State, db: &Sqlite, pending: &mut PendingLogs) { - let mut newpending: PendingLogs = Vec::new(); - let current_len = pending.len(); - if current_len > 0 { - info!("Processing {} pending logs...", current_len); - - // Log root status - match &state.root_hash { - Some(hash) => info!("Hypergrid root (grid.hypr) is set to: {}", hash), - None => warn!("Hypergrid root (grid.hypr) not yet found! All provider mints and notes will be deferred."), - } - } - - let mut outside_scope_count = 0; - - for (log, attempt) in pending.drain(..) { - match handle_log(state, db, &mut newpending, &log, attempt) { - Ok(_) => { - // Successfully processed (could mean it was skipped as outside scope) - } - Err(e) => { - // Check if this is a note for a provider outside Hypergrid scope - if e.to_string().contains("outside Hypergrid scope") { - outside_scope_count += 1; - } - } - } - } - - if outside_scope_count > 0 { - info!("Filtered out {} logs that are outside Hypergrid scope (not under grid.hypr)", outside_scope_count); - } - - if !newpending.is_empty() { - info!("{} logs remain pending.", newpending.len()); - - // Count different types of pending logs - let mut mint_count = 0; - let mut note_count = 0; - let mut provider_mints = Vec::new(); - - for (log, _) in newpending.iter() { - if let Some(topic) = log.topics().get(0) { - match *topic { - hypermap::contract::Mint::SIGNATURE_HASH => { - mint_count += 1; - if let Ok(decoded) = hypermap::contract::Mint::decode_log_data(log.data(), true) { - let parent_hash = decoded.parenthash.to_string(); - // Check if this might be a provider (parent is grid.hypr) - if let Some(root) = &state.root_hash { - if parent_hash == *root { - let label = String::from_utf8_lossy(&decoded.label); - provider_mints.push((label.to_string(), decoded.childhash.to_string())); - } - } - } - } - hypermap::contract::Note::SIGNATURE_HASH => note_count += 1, - _ => {} - } - } - } - - info!("Pending breakdown: {} mints, {} notes", mint_count, note_count); - - // Show provider mints that should be under grid.hypr - if !provider_mints.is_empty() { - info!("Found {} provider mints waiting to be processed under grid.hypr:", provider_mints.len()); - for (name, hash) in provider_mints.iter().take(10) { - info!(" - Provider '{}' with hash {}", name, hash); - } - } - - // Sample first few pending logs to show what's stuck - for (log, attempt) in newpending.iter().take(5) { - if let Some(topic) = log.topics().get(0) { - match *topic { - hypermap::contract::Mint::SIGNATURE_HASH => { - if let Ok(decoded) = hypermap::contract::Mint::decode_log_data(log.data(), true) { - let label = String::from_utf8_lossy(&decoded.label); - let parent_hash = decoded.parenthash.to_string(); - let parent_name = hyperware_process_lib::net::get_name(&parent_hash, Some(state.last_checkpoint_block), Some(1)) - .unwrap_or_else(|| "unresolved".to_string()); - info!("Pending mint (attempt {}): '{}' with parent {} ({})", - attempt, label, parent_hash, parent_name); - } - } - hypermap::contract::Note::SIGNATURE_HASH => { - if let Ok(decoded) = hypermap::contract::Note::decode_log_data(log.data(), true) { - let label = String::from_utf8_lossy(&decoded.label); - let parent_hash = decoded.parenthash.to_string(); - let parent_name = hyperware_process_lib::net::get_name(&parent_hash, Some(state.last_checkpoint_block), Some(1)) - .unwrap_or_else(|| "unresolved".to_string()); - info!("Pending note (attempt {}): '{}' for parent {} ({})", - attempt, label, parent_hash, parent_name); - } - } - _ => {} - } - } - } - } - - pending.extend(newpending); -} -pub fn handle_eth_message( - state: &mut State, - db: &Sqlite, - pending: &mut PendingLogs, - body: &[u8], -) -> anyhow::Result<()> { - debug!("handling eth message"); - match serde_json::from_slice::(body) { - Ok(Ok(eth::EthSub { result, id })) => { - if let Ok(eth::SubscriptionResult::Log(log)) = - serde_json::from_value::(result) - { - if let Err(e) = handle_log(state, db, pending, &log, 0) { - print_to_terminal(1, &format!(" log-handling error! {e:?}")); - } - } else { - debug!("Received non-log subscription result"); - } - } - Ok(Err(e)) => { // EthSubError from eth:distro:sys indicating a problem with the subscription itself - error!( // Changed from info! to error! and logging e.error for more detail - "Eth subscription error for sub_id {}: {}. Attempting to resubscribe.", - e.id, e.error // e.error contains the specific error string from EthSubError - ); - // Use subscription id (e.id) from error to resubscribe correctly - let (mint_filter, note_filter) = make_filters(state); - if e.id == 11 { // Assuming 11 was for mints - state - .hypermap - .provider - .subscribe_loop(11, mint_filter, 2, 1); // verbosity 1 for error in loop - } else if e.id == 22 { // Assuming 22 was for notes - state - .hypermap - .provider - .subscribe_loop(22, note_filter, 2, 1); // verbosity 1 for error in loop - } else { - error!("Unknown subscription ID {} received in EthSubError while attempting to resubscribe.", e.id); - } - } - Err(e) => { - info!("Failed to deserialize EthSubResult: {}", e); - } - } - - Ok(()) -} - -pub fn handle_timer( - state: &mut State, - db: &Sqlite, - pending: &mut PendingLogs, - is_checkpoint: bool, -) -> anyhow::Result<()> { - let timer_type = if is_checkpoint { "CHECKPOINT" } else { "DELAY" }; - debug!("handling {} timer - pending: {:?}", timer_type, pending.len()); - - // No need to call get_block_number() - we get block numbers from subscription events - // The last_checkpoint_block is already updated in handle_log() from event data - - if is_checkpoint { - debug!("Checkpoint timer: saving state at block {}", state.last_checkpoint_block); - state.save(); - // Reset checkpoint timer - timer::set_timer(CHECKPOINT_MS, Some(b"checkpoint".to_vec())); - } else { - // This is a regular DELAY_MS timer event - debug!("Delay timer: processing pending logs at block {}", state.last_checkpoint_block); - // Reset the delay timer ONLY when handling a delay timer event - timer::set_timer(DELAY_MS, None); - } - - handle_pending(state, db, pending); - debug!("new pending: {:?}", pending.len()); - - Ok(()) -} - -/// Fetches the raw data bytes from a specific Hypermap note. -/// -/// # Arguments -/// * `provider` - An Ethereum provider instance. -/// * `note_path` - The full path to the note (e.g., "~wallet.provider.grid.hypr"). -/// -/// # Returns -/// A `Result>` containing the note data if found, None if the note -/// doesn't exist or has no data, or an `anyhow::Error` on RPC or other errors. -pub fn get_hypermap_note_data(provider: &Provider, note_path: &str) -> Result> { - info!("Attempting to fetch Hypermap note data for: {}", note_path); - - // Create a Hypermap instance using the provider's chain ID and a default timeout. - // Parse the constant string address into the correct alloy_primitives::Address type. - let hypermap_address = Address::from_str(hypermap::HYPERMAP_ADDRESS) - .map_err(|e| anyhow::anyhow!("Failed to parse HYPERMAP_ADDRESS constant: {}", e))?; - let hypermap_reader = hypermap::Hypermap::new(provider.clone(), hypermap_address); - - match hypermap_reader.get(note_path) { - Ok((_tba, _owner, data_option)) => { - info!("Successfully fetched note data for {}: data_present={}", note_path, data_option.is_some()); - Ok(data_option) - } - Err(EthError::RpcError(msg)) => { - // Check if the error indicates the note likely doesn't exist. - // This might need refinement based on actual RPC error messages. - let msg_str = msg.to_string(); - if msg_str.contains("Execution reverted") || msg_str.contains("invalid opcode") || msg_str.contains("provided hex string was not a prefix of a hex sequence") || msg_str.contains("invalid length") { - info!("Note {} likely not found or entry is invalid: {}", note_path, msg_str); - Ok(None) // Treat as "not found" - } else { - // Propagate other RPC errors - error!("RPC error fetching note {}: {}", note_path, msg_str); - Err(anyhow::anyhow!("RPC error fetching note {}: {}", note_path, msg_str)) - } - } - Err(e) => { - // Propagate other errors (like InvalidParams, etc.) - error!("Error fetching note {}: {:?}", note_path, e); - Err(anyhow::Error::from(e).context(format!("Failed to get Hypermap note data for {}", note_path))) - } - } -} - -/// Reads the ERC-1967 proxy implementation slot for a given address. -/// -/// # Arguments -/// * `provider` - An Ethereum provider instance. -/// * `proxy_address` - The address of the proxy contract (e.g., a TBA). -/// -/// # Returns -/// A `Result
` containing the implementation address if found, -/// or an `anyhow::Error` on RPC or parsing errors. -pub fn get_implementation_address(provider: &Provider, proxy_address: Address) -> Result
{ - info!("Fetching implementation address for: {}", proxy_address); - let slot_bytes = B256::from_str("0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc") - .expect("ERC-1967 Slot Hash is valid"); // Use expect for constants - let slot_u256 = U256::from_be_bytes(slot_bytes.0); // Convert B256 bytes to U256 - - match provider.get_storage_at(proxy_address, slot_u256, None) { // None means latest block - Ok(value_b256) => { // Return value is B256 - let value_bytes: &[u8] = &value_b256.0; - if value_bytes.len() == 32 { // Should always be 32 for B256 - // Address is the last 20 bytes (index 12 to 31) - let implementation_address = Address::from_slice(&value_bytes[12..32]); - info!("Found implementation address: {}", implementation_address); - Ok(implementation_address) - } else { - error!("Storage slot value B256 has unexpected length: {}", value_bytes.len()); - Err(anyhow::anyhow!("Invalid storage slot value length")) - } - } - Err(e) => { - error!("Failed to get storage slot for {}: {:?}", proxy_address, e); - Err(anyhow::Error::from(e).context(format!("Failed to get implementation slot for {}", proxy_address))) - } - } -} \ No newline at end of file diff --git a/operator/operator/src/db.rs b/operator/operator/src/db.rs index eb49381..0e70be8 100644 --- a/operator/operator/src/db.rs +++ b/operator/operator/src/db.rs @@ -1,34 +1,41 @@ use anyhow::{anyhow, Error, Result}; use hyperware_process_lib::{ - logging::{info, error}, + logging::{error, info}, sqlite::{self, Sqlite}, Address, }; use serde_json::Value; use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; -use crate::helpers::make_json_timestamp; +// Simple timestamp function for database entries +fn make_json_timestamp() -> serde_json::Number { + let systemtime = SystemTime::now(); + let duration_from_epoch = systemtime.duration_since(UNIX_EPOCH).unwrap(); + let milliseconds_from_epoch = duration_from_epoch.as_millis() as u64; + serde_json::Number::from(milliseconds_from_epoch) +} -pub fn open_db(our: &Address) -> Result { +pub async fn open_db(our: &Address) -> Result { let p = our.package_id(); - let db = sqlite::open(p, "hypergrid", None); + let db = sqlite::open(p, "hypergrid", None).await; db } -pub fn wipe_db(our: &Address) -> anyhow::Result<()> { +pub async fn wipe_db(our: &Address) -> anyhow::Result<()> { let p = our.package_id(); - sqlite::remove_db(p.clone(), "hypergrid", None)?; + sqlite::remove_db(p.clone(), "hypergrid", None).await?; Ok(()) } -pub fn load_db(our: &Address) -> anyhow::Result { - let db = open_db(our)?; - let good = check_schema(&db); +pub async fn load_db(our: &Address) -> anyhow::Result { + let db = open_db(our).await?; + let good = check_schema(&db).await; if !good { - write_db_schema(&db)?; + write_db_schema(&db).await?; } Ok(db) } -pub fn check_schema(db: &Sqlite) -> bool { +pub async fn check_schema(db: &Sqlite) -> bool { let required = ["providers"]; let mut found = required .iter() @@ -36,7 +43,7 @@ pub fn check_schema(db: &Sqlite) -> bool { .collect::>(); let statement = "SELECT name from sqlite_master WHERE type='table';".to_string(); - let data = db.read(statement, vec![]); + let data = db.read(statement, vec![]).await; match data { Err(_) => false, Ok(data) => { @@ -61,8 +68,8 @@ pub fn check_schema(db: &Sqlite) -> bool { } } -pub fn write_db_schema(db: &Sqlite) -> anyhow::Result<()> { - let tx_id = db.begin_tx()?; +pub async fn write_db_schema(db: &Sqlite) -> anyhow::Result<()> { + let tx_id = db.begin_tx().await?; let s1 = r#" CREATE TABLE providers( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -83,11 +90,11 @@ pub fn write_db_schema(db: &Sqlite) -> anyhow::Result<()> { ON providers (id, parent_hash); "# .to_string(); - db.write(s1, vec![], Some(tx_id))?; - db.write(s2, vec![], Some(tx_id))?; - return db.commit_tx(tx_id); + db.write(s1, vec![], Some(tx_id)).await?; + db.write(s2, vec![], Some(tx_id)).await?; + return db.commit_tx(tx_id).await; } -pub fn insert_provider( +pub async fn insert_provider( db: &Sqlite, parent_hash: &str, child_hash: String, @@ -105,9 +112,9 @@ pub fn insert_provider( serde_json::Value::String(parent_hash.to_string()), serde_json::Value::Number(now), ]; - db.write(s1, p1, None) + db.write(s1, p1, None).await } -pub fn insert_provider_facts( +pub async fn insert_provider_facts( db: &Sqlite, key: String, value: String, @@ -116,21 +123,27 @@ pub fn insert_provider_facts( // Step 1: Check if the provider exists let check_query = "SELECT id FROM providers WHERE hash = ?1 LIMIT 1".to_string(); let check_params = vec![serde_json::Value::String(hash.clone())]; - match db.read(check_query, check_params) { + match db.read(check_query, check_params).await { Ok(rows) => { if rows.is_empty() { // Provider does not exist - check if ANY providers exist let count_query = "SELECT COUNT(*) as count FROM providers".to_string(); - let provider_count = db.read(count_query, vec![]) + let provider_count = db + .read(count_query, vec![]) + .await .ok() .and_then(|rows| { - rows.get(0).and_then(|row| row.get("count")).and_then(|v| v.as_i64()) + rows.get(0) + .and_then(|row| row.get("count")) + .and_then(|v| v.as_i64()) }) .unwrap_or(0); - + // Try to get some existing providers for context let existing_query = "SELECT hash, name FROM providers LIMIT 3".to_string(); - let existing_providers = db.read(existing_query, vec![]) + let existing_providers = db + .read(existing_query, vec![]) + .await .ok() .map(|rows| { rows.iter() @@ -143,52 +156,69 @@ pub fn insert_provider_facts( .join(", ") }) .unwrap_or_else(|| "none".to_string()); - + info!("Provider with hash {} not found for fact update (key: '{}', value: '{}'). Total providers in DB: {}. Sample providers: {}. Deferring.", hash, key, value, provider_count, existing_providers); - return Err(anyhow!("Provider with hash {} not found for fact update (key: '{}')", hash, key)); + return Err(anyhow!( + "Provider with hash {} not found for fact update (key: '{}')", + hash, + key + )); } // Provider exists, proceed to update } Err(e) => { // Error during the check query - error!("DB Error checking provider existence for hash {}: {:?}", hash, e); - return Err(anyhow!("DB Error checking provider existence for hash {}: {:?}", hash, e)); + error!( + "DB Error checking provider existence for hash {}: {:?}", + hash, e + ); + return Err(anyhow!( + "DB Error checking provider existence for hash {}: {:?}", + hash, + e + )); } } // Step 2: Provider exists, perform the UPDATE - let update_statement = format!( - r#" - UPDATE providers SET - '{}' = ?1 - WHERE hash = ?2 - "#, - key - ); + // Validate column name to prevent SQL injection and ensure it exists + let allowed_columns = [ + "site", + "description", + "provider_id", + "wallet", + "price", + "instructions", + ]; + if !allowed_columns.contains(&key.as_str()) { + return Err(anyhow!("Unsupported fact key: {}", key)); + } + + // Use the validated identifier directly (no quotes) so SQLite treats it as a column, not a string literal + let update_statement = format!("UPDATE providers SET {} = ?1 WHERE hash = ?2", key); let update_params = vec![ serde_json::Value::String(value.clone()), serde_json::Value::String(hash.clone()), ]; - match db.write(update_statement, update_params, None) { + match db.write(update_statement, update_params, None).await { Ok(_) => Ok(()), Err(e) => { - error!("DB Error in insert_provider_facts (update) for key '{}', hash {}: {:?}", key, hash, e); + //error!("DB Error in insert_provider_facts (update) for key '{}', hash {}: {:?}", key, hash, e); Err(e) } } } -pub fn get_all(db: &Sqlite) -> Result>> { +pub async fn get_all(db: &Sqlite) -> Result>> { let s = "SELECT * FROM providers".to_string(); - let data = db.read(s, vec![])?; + let data = db.read(s, vec![]).await?; Ok(data) } - -pub fn search_provider(db: &Sqlite, query: String) -> Result>> { +pub async fn search_provider(db: &Sqlite, query: String) -> Result>> { let like_param = format!("%{}%", query); - let exact_param = query; + let exact_param = query; let s = r#" SELECT * FROM providers @@ -199,19 +229,19 @@ pub fn search_provider(db: &Sqlite, query: String) -> Result Result>> { +pub async fn get_provider_details( + db: &Sqlite, + id_or_name: &str, +) -> Result>> { let s1 = "SELECT * FROM providers WHERE provider_id = ?1 LIMIT 1".to_string(); let p1 = vec![serde_json::Value::String(id_or_name.to_string())]; - let data1 = db.read(s1, p1)?; + let data1 = db.read(s1, p1).await?; if !data1.is_empty() { return Ok(data1.into_iter().next()); @@ -219,7 +249,20 @@ pub fn get_provider_details(db: &Sqlite, id_or_name: &str) -> Result Result>> { + let query = "SELECT * FROM providers WHERE LOWER(wallet) = LOWER(?1)".to_string(); + let params = vec![serde_json::Value::String(wallet_address.to_string())]; + let data = db.read(query, params).await?; + Ok(data) +} \ No newline at end of file diff --git a/operator/operator/src/eth.rs b/operator/operator/src/eth.rs new file mode 100644 index 0000000..ddd85ab --- /dev/null +++ b/operator/operator/src/eth.rs @@ -0,0 +1,474 @@ +use crate::structs::State; +use crate::OperatorProcess; +use alloy_primitives::{keccak256, Address as EthAddress, B256}; +use alloy_sol_types::SolEvent; +use hyperware_process_lib::eth::{EthSub, EthSubError, Filter, Log, SubscriptionResult}; +use hyperware_process_lib::hypermap::contract::{Mint, Note}; +use hyperware_process_lib::logging::{error, info}; +use std::str::FromStr; + +/// Create ETH filters for Mint, Note and USDC Transfer events +pub fn make_filters(state: &State) -> Vec { + let mut filters = Vec::new(); + + // Use the hypermap address constant directly to avoid parsing issues + let hypermap_address = EthAddress::from_str(hyperware_process_lib::hypermap::HYPERMAP_ADDRESS) + .expect("Invalid HYPERMAP_ADDRESS constant"); + + // Filter for Mint events + filters.push(Filter::new().address(hypermap_address).event(&Mint::SIGNATURE)); + + // Filter for Note events with topic3 filter for relevant provider facts + filters.push(Filter::new() + .address(hypermap_address) + .event(&Note::SIGNATURE) + .topic3(vec![ + keccak256("~description".as_bytes()), + keccak256("~instructions".as_bytes()), + keccak256("~price".as_bytes()), + keccak256("~wallet".as_bytes()), + keccak256("~provider-id".as_bytes()), + keccak256("~site".as_bytes()), + ])); + + // Filter for USDC Transfer events TO the operator TBA (if configured) + if let Some(tba_address) = &state.operator_tba_address { + if let Ok(usdc_address) = EthAddress::from_str(crate::constants::USDC_BASE_ADDRESS) { + if let Ok(tba_eth) = EthAddress::from_str(tba_address) { + // Transfer event signature + let transfer_sig = keccak256("Transfer(address,address,uint256)"); + + // Create padded address for topic2 ("to" address in Transfer event) + let mut padded_tba = [0u8; 32]; + padded_tba[12..].copy_from_slice(tba_eth.as_slice()); + + // Filter for transfers TO our TBA + filters.push(Filter::new() + .address(usdc_address) + .event_signature(B256::from(transfer_sig)) + .topic2(B256::from(padded_tba))); + + info!("Added USDC Transfer filter for TBA: {}", tba_address); + } + } + } + + filters +} + +/// Bootstrap historical logs only (no live resubscription) +pub async fn bootstrap_historical(process: &mut OperatorProcess) -> Result<(), String> { + if let Some(hypermap) = &process.hypermap { + let filters = make_filters(&process.state); + + let result = hypermap + .bootstrap( + Some(process.state.last_checkpoint_block), + filters, + Some((5, Some(5))), + None, + ) + .await; + + match result { + Ok((last_block, results_per_filter)) => { + process_bootstrap_results(process, last_block, results_per_filter).await; + Ok(()) + } + Err(e) => Err(format!("Bootstrap failed: {:?}", e)), + } + } else { + Err("No hypermap instance available for bootstrap".to_string()) + } +} + +/// Bootstrap historical logs and set up ETH subscriptions +pub async fn setup_subscriptions(process: &mut OperatorProcess) { + info!("Setting up ETH subscriptions"); + + // First, bootstrap historical logs + let bootstrap_result = if let Some(hypermap) = &process.hypermap { + info!("Bootstrapping historical logs from hypermap cache"); + + let filters = make_filters(&process.state); + + // Bootstrap from local cache to get historical logs + let result = hypermap + .bootstrap( + Some(process.state.last_checkpoint_block), + filters.clone(), + Some((5, Some(5))), // retry config + None, + ) + .await; + + Some((result, filters)) + } else { + error!("No hypermap instance available for ETH setup"); + None + }; + + // Process bootstrap results + if let Some((bootstrap_result, filters)) = bootstrap_result { + match bootstrap_result { + Ok((last_block, results_per_filter)) => { + process_bootstrap_results(process, last_block, results_per_filter).await; + } + Err(e) => { + error!("Bootstrap failed: {:?}, starting from last checkpoint", e); + } + } + + // Now subscribe for live events + subscribe_to_live_events(process, filters); + } +} + +/// Process historical logs from bootstrap +async fn process_bootstrap_results( + process: &mut OperatorProcess, + last_block: u64, + results_per_filter: Vec>, +) { + info!("Bootstrap successful up to block {}", last_block); + + if results_per_filter.len() >= 2 { + let mint_logs = results_per_filter[0].clone(); + let note_logs = results_per_filter[1].clone(); + + info!("Processing {} historical mint logs", mint_logs.len()); + for log in mint_logs { + if let Err(e) = process_log_event(process, &log).await { + error!("Error processing historical mint log: {}", e); + } + } + + info!("Processing {} historical note logs", note_logs.len()); + for log in note_logs { + if let Err(e) = process_log_event(process, &log).await { + error!("Error processing historical note log: {}", e); + } + } + + // Process USDC Transfer logs if present (3rd filter) + if results_per_filter.len() >= 3 { + let transfer_logs = results_per_filter[2].clone(); + info!("Processing {} historical USDC transfer logs", transfer_logs.len()); + for log in transfer_logs { + if let Err(e) = process_log_event(process, &log).await { + error!("Error processing historical transfer log: {}", e); + } + } + } + + // Update checkpoint to bootstrap block + process.state.last_checkpoint_block = last_block; + } +} + +/// Subscribe to live ETH events +fn subscribe_to_live_events(process: &OperatorProcess, filters: Vec) { + if let Some(hypermap) = &process.hypermap { + info!("Setting up ETH subscriptions for live events"); + + // Subscribe to Mint events (filter 0) + if filters.len() > 0 { + hypermap.provider.subscribe_loop(11, filters[0].clone(), 2, 0); + info!("Subscribed to Mint events with ID: 11"); + } + + // Subscribe to Note events (filter 1) + if filters.len() > 1 { + hypermap.provider.subscribe_loop(22, filters[1].clone(), 2, 0); + info!("Subscribed to Note events with ID: 22"); + } + + // Subscribe to USDC Transfer events (filter 2) + if filters.len() > 2 { + hypermap.provider.subscribe_loop(33, filters[2].clone(), 2, 0); + info!("Subscribed to USDC Transfer events with ID: 33"); + } + } +} + +/// Extract log from subscription result +pub fn extract_log_from_subscription(eth_sub: &EthSub) -> Result, String> { + match serde_json::from_value::(eth_sub.result.clone()) { + Ok(SubscriptionResult::Log(log)) => { + info!( + "Received log event: block_number={:?}, topics={:?}", + log.block_number, + log.topics() + ); + Ok(Some(*log)) + } + Ok(_) => { + info!("Received non-log subscription result"); + Ok(None) + } + Err(e) => Err(format!("Failed to parse subscription result: {}", e)), + } +} + +/// Handle a subscription error by resubscribing +pub async fn handle_subscription_error( + process: &mut OperatorProcess, + error: &EthSubError, +) -> Result<(), String> { + error!( + "ETH subscription error for sub_id {}: {}. Attempting to resubscribe.", + error.id, error.error + ); + + resubscribe_to_events(process, error.id)?; + + Ok(()) +} + +/// Resubscribe to specific event type based on subscription ID +pub fn resubscribe_to_events( + process: &OperatorProcess, + subscription_id: u64, +) -> Result<(), String> { + let filters = make_filters(&process.state); + + if let Some(hypermap) = &process.hypermap { + match subscription_id { + 11 => { + if filters.len() > 0 { + info!("Resubscribing to Mint events (ID: 11)"); + hypermap.provider.subscribe_loop(11, filters[0].clone(), 2, 1); + Ok(()) + } else { + Err("No Mint filter available".to_string()) + } + } + 22 => { + if filters.len() > 1 { + info!("Resubscribing to Note events (ID: 22)"); + hypermap.provider.subscribe_loop(22, filters[1].clone(), 2, 1); + Ok(()) + } else { + Err("No Note filter available".to_string()) + } + } + 33 => { + if filters.len() > 2 { + info!("Resubscribing to USDC Transfer events (ID: 33)"); + hypermap.provider.subscribe_loop(33, filters[2].clone(), 2, 1); + Ok(()) + } else { + Err("No USDC Transfer filter available".to_string()) + } + } + _ => { + error!( + "Unknown subscription ID {} received in EthSubError", + subscription_id + ); + Err(format!("Unknown subscription ID: {}", subscription_id)) + } + } + } else { + Err("No hypermap instance available".to_string()) + } +} + + +/// Process a log event (Mint or Note) +pub async fn process_log_event(process: &mut OperatorProcess, log: &Log) -> Result<(), String> { + let topics = log.topics(); + //info!("Processing log with {} topics", topics.len()); + + if topics.is_empty() { + return Err("Log has no topics".to_string()); + } + + // Match on the first topic (event signature) + let transfer_sig = keccak256("Transfer(address,address,uint256)"); + match &topics[0] { + sig if *sig == Mint::SIGNATURE_HASH => process_mint_event(process, log).await, + sig if *sig == Note::SIGNATURE_HASH => process_note_event(process, log).await, + sig if sig.0 == transfer_sig => process_transfer_event(process, log).await, + _ => { + info!("Unknown event signature: 0x{}", hex::encode(&topics[0])); + Ok(()) + } + }?; + + // Update last checkpoint block if available + if let Some(block_number) = log.block_number { + update_checkpoint_block(&mut process.state, block_number); + } + + Ok(()) +} + +/// Process a Mint event +async fn process_mint_event(process: &mut OperatorProcess, log: &Log) -> Result<(), String> { + //info!("Processing Mint event"); + let decoded = Mint::decode_log_data(log.data(), true) + .map_err(|e| format!("Failed to decode Mint event: {:?}", e))?; + + let parent_hash = decoded.parenthash.to_string(); + let child_hash = decoded.childhash.to_string(); + let label = String::from_utf8(decoded.label.to_vec()) + .map_err(|e| format!("Invalid UTF8 in label: {:?}", e))?; + + //info!("Mint event: parent={}, child={}, label={}", parent_hash, child_hash, label); + add_mint(process, &parent_hash, child_hash, label).await +} + +/// Process a Note event +async fn process_note_event(process: &mut OperatorProcess, log: &Log) -> Result<(), String> { + //info!("Processing Note event"); + let decoded = Note::decode_log_data(log.data(), true) + .map_err(|e| format!("Failed to decode Note event: {:?}", e))?; + + let parent_hash = decoded.parenthash.to_string(); + let note_label = String::from_utf8(decoded.label.to_vec()) + .map_err(|e| format!("Invalid UTF8 in label: {:?}", e))?; + + //info!("Note event: parent={}, label={}, data_len={}", + //parent_hash, note_label, decoded.data.len()); + add_note(process, &parent_hash, note_label, decoded.data.to_vec()).await +} + +/// Add a mint (parent-child relationship) to the state +pub async fn add_mint( + process: &mut crate::OperatorProcess, + parent_hash: &str, + child_hash: String, + label: String, +) -> Result<(), String> { + //info!("Adding mint: {} -> {} ({})", parent_hash, child_hash, label); + + // Insert into database if available + // Note: We no longer maintain the in-memory names mapping - use database instead + if let Some(db) = &process.db_conn { + match crate::db::insert_provider(db, parent_hash, child_hash.clone(), label.clone()).await { + Ok(_) => { + //info!("Provider inserted into database: {} -> {}", parent_hash, child_hash); + } + Err(e) => { + //error!("Failed to insert provider into database: {:?}", e); + // Don't fail the whole operation if db insert fails + } + } + } else { + error!("No database connection available for mint insertion"); + } + + //info!("Mint added successfully"); + Ok(()) +} + +/// Add a note to the state +pub async fn add_note( + process: &mut crate::OperatorProcess, + parent_hash: &str, + label: String, + data: Vec, +) -> Result<(), String> { + //info!("Adding note: {} -> {} ({} bytes)", parent_hash, label, data.len()); + + // Decode the data as UTF-8 string for provider facts + let decoded_value = String::from_utf8(data.clone()).unwrap_or_else(|_| hex::encode(&data)); + + // Normalize note label to DB column name (strip '~', convert '-' to '_') + let normalized_key = label.trim().trim_start_matches('~').replace("-", "_"); + + // Insert into database if available + if let Some(db) = &process.db_conn { + match crate::db::insert_provider_facts( + db, + normalized_key.clone(), + decoded_value.clone(), + parent_hash.to_string(), + ) + .await + { + Ok(_) => { + //info!("Provider fact inserted into database: {} -> {} = {}", + //parent_hash, label, decoded_value); + } + Err(e) => { + //error!("Failed to insert provider fact into database: {:?}", e); + // Don't fail the whole operation if db insert fails + // This might happen if the provider doesn't exist yet + } + } + } else { + error!("No database connection available for note insertion"); + } + + //info!("Note added successfully"); + Ok(()) +} + +/// Process a USDC Transfer event (incoming transfers to TBA) +async fn process_transfer_event(process: &mut OperatorProcess, log: &Log) -> Result<(), String> { + // Only process if we have a database connection + if let Some(db) = &process.db_conn { + // Get TBA address + let tba = process.state.operator_tba_address.as_ref() + .ok_or("No operator TBA configured")?; + + info!("Processing incoming USDC transfer to TBA {}", tba); + + // Use the existing ledger infrastructure to process this transfer + let provider = hyperware_process_lib::eth::Provider::new(crate::structs::CHAIN_ID, 30000); + + // Ingest this specific block containing the transfer + if let Some(block_number) = log.block_number { + match crate::ledger::ingest_usdc_events_for_range( + db, + &provider, + &tba.to_lowercase(), + block_number, + block_number, + ).await { + Ok(count) => { + if count > 0 { + info!("Ingested {} USDC events from transfer", count); + + // Rebuild the ledger to update balance + if let Err(e) = crate::ledger::build_usdc_ledger_for_tba( + &process.state, + db, + &tba.to_lowercase() + ).await { + error!("Failed to rebuild ledger after transfer: {:?}", e); + } + + // Notify WebSocket clients about balance change + info!("Notifying WebSocket clients about USDC transfer balance update"); + info!("Active WebSocket connections: {}", process.ws_connections.len()); + + // Send wallet balance update + process.notify_wallet_balance_update().await; + process.notify_graph_state_update(); + + // Also send full state snapshots to ensure clients get the update + let connections: Vec = process.ws_connections.keys().cloned().collect(); + for channel_id in connections { + info!("Sending state snapshot to WebSocket client {}", channel_id); + process.send_state_snapshot(channel_id).await; + } + } + } + Err(e) => { + error!("Failed to ingest USDC transfer event: {:?}", e); + } + } + } + } + + Ok(()) +} + +/// Update the last checkpoint block +fn update_checkpoint_block(state: &mut State, block_number: u64) { + state.last_checkpoint_block = block_number; + //info!("Updated last checkpoint block to {}", block_number); +} diff --git a/operator/operator/src/graph.rs b/operator/operator/src/graph.rs deleted file mode 100644 index 8b2ce74..0000000 --- a/operator/operator/src/graph.rs +++ /dev/null @@ -1,606 +0,0 @@ -use serde_json::json; -use hyperware_process_lib::{ - Address, - http::StatusCode, - logging::{info, warn, error}, - sqlite::Sqlite, - eth, hypermap, - wallet, -}; -use alloy_primitives::Address as EthAddress; -use crate::constants::USDC_BASE_ADDRESS; -use std::str::FromStr; - -use crate::helpers::send_json_response; -use crate::structs::{ - State, - HypergridGraphResponse, - GraphNode, - GraphEdge, - GraphNodeData, - CoarseState, - //NodePosition, // Assuming frontend will handle layout initially - OperatorWalletFundingInfo, - HotWalletFundingInfo, - NoteInfo, - //WalletSummary, // For getting hot wallet names if managed - IdentityStatus, DelegationStatus, - MintOperatorWalletActionNodeData, -}; -use crate::hyperwallet_client::service::{ - get_wallet_summary_for_address, - get_all_onchain_linked_hot_wallet_addresses, - verify_single_hot_wallet_delegation_detailed, - get_wallet_spending_limits, -}; -use crate::hyperwallet_client::payments::{ - check_operator_tba_funding_detailed, - check_single_hot_wallet_funding_detailed, -}; -use crate::identity; // For operator identity details - -// Local helper function to truncate addresses for display -fn truncate_address(address_str: &str) -> String { - if address_str.len() > 10 { // Basic truncation logic (e.g., 0x123...789) - format!("{}...{}", &address_str[0..5], &address_str[address_str.len()-3..]) - } else { - address_str.to_string() - } -} - -// New public function to build graph data -pub fn build_hypergrid_graph_data( - our: &Address, - state: &mut State, -) -> anyhow::Result { - info!("Building Hypergrid graph data for node {}...", our.node); - - let mut nodes: Vec = Vec::new(); - let mut edges: Vec = Vec::new(); - - let owner_node_id = "owner-node".to_string(); - let mut owner_node_tba_address: Option = None; - let mut owner_node_actual_owner_eoa: Option = None; - - // --- Populate OwnerNode (e.g., "pertinent.os") details --- - info!("Graph Build: Attempting to fetch details for owner node: {}", our.node()); - let provider = eth::Provider::new(crate::structs::CHAIN_ID, 30000); // Use CHAIN_ID from structs - match EthAddress::from_str(hypermap::HYPERMAP_ADDRESS) { - Ok(hypermap_contract_address) => { - if hypermap_contract_address != EthAddress::ZERO { - let hypermap_reader = hypermap::Hypermap::new(provider.clone(), hypermap_contract_address); - match hypermap_reader.get(our.node()) { - Ok((tba, owner_eoa, _data)) => { - if tba != EthAddress::ZERO { - owner_node_tba_address = Some(tba.to_string()); - owner_node_actual_owner_eoa = Some(owner_eoa.to_string()); - info!( - "Graph Build: Successfully fetched details for owner node '{}'. TBA: {}, Owner EOA: {}", - our.node(), - tba.to_string(), - owner_eoa.to_string() - ); - } else { - info!( - "Graph Build: Owner node '{}' lookup returned zero address TBA (effectively not a registered TBA).", - our.node() - ); - } - } - Err(e) => { - error!( - "Graph Build: Error fetching details for owner node '{}' from Hypermap: {:?}. This node might not be registered or accessible.", - our.node(), - e - ); - } - } - } else { - error!("Graph Build: HYPERMAP_ADDRESS is zero, cannot query owner node details."); - } - } - Err(e) => { - error!("Graph Build: Invalid HYPERMAP_ADDRESS string ('{}'): {}. Cannot query owner node details.", hypermap::HYPERMAP_ADDRESS, e); - // This would be a critical configuration error. - } - } - - nodes.push(GraphNode { - id: owner_node_id.clone(), - node_type: "ownerNode".to_string(), - data: GraphNodeData::OwnerNode { - name: our.node().to_string(), - tba_address: owner_node_tba_address, // Use fetched TBA address - owner_address: owner_node_actual_owner_eoa, // Use fetched owner EOA - }, - position: None, - }); - - let operator_identity_details = identity::check_operator_identity_detailed(our); - let mut operator_wallet_node_id: Option = None; - let mut linked_wallets_count: usize = 0; - let fresh_operator_entry_name: Option = match &operator_identity_details { - IdentityStatus::Verified { entry_name, .. } => Some(entry_name.clone()), - _ => None, - }; - - - - if let IdentityStatus::Verified { entry_name, tba_address, .. } = &operator_identity_details { - let current_op_wallet_node_id = format!("operator-wallet-{}", tba_address); - operator_wallet_node_id = Some(current_op_wallet_node_id.clone()); - - info!("Graph: Checking funding for operator TBA: {}", tba_address); - let funding_details = check_operator_tba_funding_detailed(Some(tba_address)); - info!("Graph: Funding details received: eth_balance={:?}, usdc_balance={:?}, needs_eth={}, needs_usdc={}", - funding_details.tba_eth_balance_str, - funding_details.tba_usdc_balance_str, - funding_details.tba_needs_eth, - funding_details.tba_needs_usdc - ); - - let op_wallet_funding_info = OperatorWalletFundingInfo { - eth_balance_str: funding_details.tba_eth_balance_str, - usdc_balance_str: funding_details.tba_usdc_balance_str, - needs_eth: funding_details.tba_needs_eth, - needs_usdc: funding_details.tba_needs_usdc, - error_message: funding_details.check_error, - }; - - let mut access_list_note_status_text = "Access List Note: Unknown".to_string(); - let mut access_list_note_is_set = false; - let mut signers_note_status_text = "Signers Note: Unknown".to_string(); - let mut signers_note_is_set = false; - - // First check if we have linked hot wallets on-chain - let linked_wallets = get_all_onchain_linked_hot_wallet_addresses(Some(entry_name)); - - if let Ok(linked_hw_addresses) = &linked_wallets { - linked_wallets_count = linked_hw_addresses.len(); - if !linked_hw_addresses.is_empty() { - // We have linked wallets, so both notes should be set - access_list_note_status_text = "Access List Note: Set".to_string(); - access_list_note_is_set = true; - - // Check if we have a selected wallet to show specific verification status - if let Some(selected_hw_id) = &state.selected_wallet_id { - if let Some(selected_hw) = state.managed_wallets.get(selected_hw_id) { - let hw_address_str = &selected_hw.id.to_string(); - match verify_single_hot_wallet_delegation_detailed(state, Some(entry_name), hw_address_str) { - DelegationStatus::Verified => { - signers_note_status_text = format!("Signers Note: Set (Verified for {})", truncate_address(hw_address_str)); - signers_note_is_set = true; - } - DelegationStatus::HotWalletNotInList => { - signers_note_status_text = "Signers Note: Set (Selected Hot Wallet Not Listed)".to_string(); - signers_note_is_set = true; - } - _ => { - // For other statuses, just indicate it's set since we have linked wallets - signers_note_status_text = format!("Signers Note: Set ({} linked wallets)", linked_hw_addresses.len()); - signers_note_is_set = true; - } - } - } else { - // Selected wallet not found, but we have linked wallets - signers_note_status_text = format!("Signers Note: Set ({} linked wallets)", linked_hw_addresses.len()); - signers_note_is_set = true; - } - } else { - // No selected wallet, but we have linked wallets - signers_note_status_text = format!("Signers Note: Set ({} linked wallets)", linked_hw_addresses.len()); - signers_note_is_set = true; - } - } else { - // No linked wallets, check access list note independently - if !entry_name.is_empty() { - let provider = eth::Provider::new(crate::structs::CHAIN_ID, 30000); - if let Ok(hypermap_contract_address) = EthAddress::from_str(hypermap::HYPERMAP_ADDRESS) { - if hypermap_contract_address != EthAddress::ZERO { - let hypermap_reader = hypermap::Hypermap::new(provider.clone(), hypermap_contract_address); - let access_list_full_path = format!("~access-list.{}", entry_name); - match hypermap_reader.get(&access_list_full_path) { - Ok((_tba, _owner, Some(data))) => { - if data.len() == 32 { - access_list_note_status_text = "Access List Note: Set".to_string(); - access_list_note_is_set = true; - signers_note_status_text = "Signers Note: Not Set (No Linked Wallets)".to_string(); - signers_note_is_set = false; - } else { - access_list_note_status_text = format!("Access List Note: Invalid Data (Expected 32 bytes, got {})", data.len()); - access_list_note_is_set = false; - } - } - Ok((_tba, _owner, None)) => { - access_list_note_status_text = "Access List Note: Not Set".to_string(); - access_list_note_is_set = false; - } - Err(e) => { - if format!("{:?}", e).contains("note not found") { - access_list_note_status_text = "Access List Note: Not Set".to_string(); - } else { - access_list_note_status_text = format!("Access List Note: Error Reading ({:?})", e); - } - access_list_note_is_set = false; - } - } - } - } - } - signers_note_status_text = "Signers Note: Not Set (No Linked Wallets)".to_string(); - signers_note_is_set = false; - } - } else { - // Error getting linked wallets, fall back to checking selected wallet - if let Some(selected_hw_id) = &state.selected_wallet_id { - // selected_hw_id exists, but wallet not found in managed_wallets (should be rare if state is consistent) - access_list_note_status_text = "Access List Note: Status Unknown (Selected Wallet Not Found)".to_string(); - signers_note_status_text = "Signers Note: Status Unknown (Selected Wallet Not Found)".to_string(); - } else { - // No hot wallet selected in state. We can't run verify_single_hot_wallet_delegation_detailed. - // To get the Access List Note status independently, we'd need a different check. - // For now, reflect that we can't determine status without a selected hot wallet for context. - // A more robust check would be to read the access list note directly if operator_entry_name is known. - // This can be a future improvement. - if !entry_name.is_empty() { - // Attempt to check access list note directly if operator entry name is known - // This requires a direct hypermap read, not relying on delegation check which needs a hot wallet - let provider = eth::Provider::new(crate::structs::CHAIN_ID, 30000); - if let Ok(hypermap_contract_address) = EthAddress::from_str(hypermap::HYPERMAP_ADDRESS) { - if hypermap_contract_address != EthAddress::ZERO { - let hypermap_reader = hypermap::Hypermap::new(provider.clone(), hypermap_contract_address); - let access_list_full_path = format!("~access-list.{}", entry_name); - match hypermap_reader.get(&access_list_full_path) { - Ok((_tba, _owner, Some(data))) => { - if data.len() == 32 { - access_list_note_status_text = "Access List Note: Set".to_string(); - access_list_note_is_set = true; - signers_note_status_text = "Signers Note: Status Unknown (No Hot Wallet Selected for Full Check)".to_string(); - } else { - access_list_note_status_text = format!("Access List Note: Invalid Data (Expected 32 bytes, got {})", data.len()); - access_list_note_is_set = false; - } - } - Ok((_tba, _owner, None)) => { - access_list_note_status_text = "Access List Note: Set (No Data)".to_string(); // Or "Not Set" if no data means not set. - access_list_note_is_set = false; // Assuming no data means effectively not set for its purpose. - } - Err(e) => { - if format!("{:?}", e).contains("note not found") { - access_list_note_status_text = "Access List Note: Not Set".to_string(); - } else { - access_list_note_status_text = format!("Access List Note: Error Reading ({:?})", e); - } - access_list_note_is_set = false; - } - } - } else { - access_list_note_status_text = "Access List Note: Error (Hypermap Address Zero)".to_string(); - } - } else { - access_list_note_status_text = "Access List Note: Error (Invalid Hypermap Address)".to_string(); - } - } else { - access_list_note_status_text = "Access List Note: Unknown (No Operator ID)".to_string(); - } - signers_note_status_text = "Signers Note: Unknown (No Hot Wallet Selected)".to_string(); // Signers note can't be checked without access list context and potentially a specific hot wallet - } - } - - let signers_note_info = NoteInfo { - status_text: signers_note_status_text, - details: None, - is_set: signers_note_is_set, - action_needed: !signers_note_is_set, - action_id: Some("trigger_set_signers_note".to_string()), - }; - let access_list_note_info = NoteInfo { - status_text: access_list_note_status_text, - details: None, - is_set: access_list_note_is_set, - action_needed: !access_list_note_is_set, - action_id: Some("trigger_set_access_list_note".to_string()), - }; - - // Check if paymaster has been approved (only if gasless is enabled) - let paymaster_approved = if state.gasless_enabled.unwrap_or(false) { - let provider = eth::Provider::new(crate::structs::CHAIN_ID, 30000); - let usdc_addr = USDC_BASE_ADDRESS; // Base USDC - let paymaster = "0x0578cFB241215b77442a541325d6A4E6dFE700Ec"; // Circle paymaster - //let paymaster = "0x861a1Be40c595db980341e41A7a5D09C772f7c2b"; // Hyperware paymaster - - match wallet::erc20_allowance(usdc_addr, &tba_address, paymaster, &provider) { - Ok(allowance) => allowance > alloy_primitives::U256::ZERO, - Err(_) => false, - } - } else { - false - }; - - nodes.push(GraphNode { - id: current_op_wallet_node_id.clone(), - node_type: "operatorWalletNode".to_string(), - data: GraphNodeData::OperatorWalletNode { - name: entry_name.clone(), - tba_address: tba_address.clone(), - funding_status: op_wallet_funding_info, - signers_note: signers_note_info, - access_list_note: access_list_note_info, - gasless_enabled: state.gasless_enabled.unwrap_or(false), - paymaster_approved, - }, - position: None, - }); - edges.push(GraphEdge { - id: format!("edge-{}-{}", owner_node_id, current_op_wallet_node_id), - source: owner_node_id.clone(), - target: current_op_wallet_node_id.clone(), - style_type: None, animated: None, - }); - - // Determine if there are any linked hot wallets to provide better labeling - let has_linked_wallets = match get_all_onchain_linked_hot_wallet_addresses(Some(entry_name)) { - Ok(linked_hw_addresses) => !linked_hw_addresses.is_empty(), - Err(_) => false, - }; - - let action_label = if has_linked_wallets { - "Manage Hot Wallets".to_string() - } else { - "Create Your First Wallet!".to_string() - }; - - nodes.push(GraphNode { - id: "action-add-hot-wallet".to_string(), - node_type: "addHotWalletActionNode".to_string(), - data: GraphNodeData::AddHotWalletActionNode { - label: action_label, - operator_tba_address: Some(tba_address.clone()), - action_id: "trigger_manage_wallets_modal".to_string(), - }, - position: None, - }); - edges.push(GraphEdge { - id: format!("edge-{}-action-add-hot-wallet", current_op_wallet_node_id), - source: current_op_wallet_node_id.clone(), - target: "action-add-hot-wallet".to_string(), - style_type: None, - animated: Some(true), - }); - } else { - // Operator Identity is not verified, so add the mint action node - let mint_action_node_id = "action-mint-operator-wallet".to_string(); - nodes.push(GraphNode { - id: mint_action_node_id.clone(), - node_type: "mintOperatorWalletActionNode".to_string(), // New node type - data: GraphNodeData::MintOperatorWalletActionNode( - MintOperatorWalletActionNodeData { - label: "Create Operator Wallet".to_string(), - owner_node_name: our.node().to_string(), - action_id: "trigger_mint_operator_wallet".to_string(), - } - ), - position: None, - }); - edges.push(GraphEdge { - id: format!("edge-{}-{}", owner_node_id, mint_action_node_id), - source: owner_node_id.clone(), - target: mint_action_node_id.clone(), - style_type: None, - animated: Some(true), - }); - } - - // Only add Hot Wallet and Client nodes if Operator Wallet exists - if operator_wallet_node_id.is_some() { - match get_all_onchain_linked_hot_wallet_addresses(fresh_operator_entry_name.as_deref()) { - Ok(linked_hw_addresses) => { - linked_wallets_count = linked_hw_addresses.len(); - // Auto-select first delegated wallet if no wallet is currently selected - if state.selected_wallet_id.is_none() && !linked_hw_addresses.is_empty() { - info!("No wallet selected - checking for delegated wallets to auto-select"); - for candidate_address in &linked_hw_addresses { - if let Some(summary) = get_wallet_summary_for_address(state, candidate_address) { - // This wallet exists in hyperwallet's list AND is delegated on-chain - // Directly set it as selected (skip individual validation that may fail due to API issues) - let wallet_name = summary.name.clone().unwrap_or_else(|| "unnamed".to_string()); - info!("Found delegated wallet in hyperwallet: {} ({})", wallet_name, summary.id); - - state.selected_wallet_id = Some(summary.id.clone()); - state.save(); - - info!("Auto-selected delegated wallet: {} ({})", wallet_name, summary.id); - break; // Stop after first successful selection - } else { - info!("Skipping delegated wallet {} - not found in hyperwallet", candidate_address); - } - } - } - - for hw_address_str in linked_hw_addresses { - let hot_wallet_node_id = format!("hot-wallet-{}", hw_address_str); - let summary_opt = get_wallet_summary_for_address(state, &hw_address_str); - let (needs_eth, eth_balance, funding_err) = check_single_hot_wallet_funding_detailed(state, &hw_address_str); - - let hw_funding_info = HotWalletFundingInfo { - eth_balance_str: eth_balance, - needs_eth, - error_message: funding_err, - }; - - // Handle the case where we might not have a summary - if let Some(ref summary) = summary_opt { - let is_active_mcp = state.selected_wallet_id.as_ref() == Some(&summary.id) && state.active_signer_cache.is_some(); - let status_desc = if is_active_mcp { - // If it's active in MCP, its status description should reflect unlocked state too - if summary.is_unlocked { - "Active in MCP (Unlocked)".to_string() - } else { - "Active in MCP (Locked)".to_string() - } - } else if state.managed_wallets.contains_key(&summary.id) { - "Managed & Linked".to_string() - } else { - "Linked (External)".to_string() - }; - - let mut client_ids_for_this_hw: Vec = Vec::new(); - for client in state.authorized_clients.values() { - if client.associated_hot_wallet_address == hw_address_str { - client_ids_for_this_hw.push(client.id.clone()); - } - } - - // Get spending limits from hyperwallet (works for both managed and external wallets) - let spending_limits = get_wallet_spending_limits(state, hw_address_str.clone()) - .unwrap_or_else(|e| { - info!("Could not fetch spending limits for {}: {}", hw_address_str, e); - None - }); - - nodes.push(GraphNode { - id: hot_wallet_node_id.clone(), - node_type: "hotWalletNode".to_string(), - data: GraphNodeData::HotWalletNode { - address: hw_address_str.clone(), - name: summary.name.clone(), - status_description: status_desc, - is_active_in_mcp: is_active_mcp, // This might be redundant if statusDescription covers it - is_encrypted: summary.is_encrypted, // ADDED - is_unlocked: summary.is_unlocked, // ADDED - funding_info: hw_funding_info, - authorized_clients: client_ids_for_this_hw.clone(), - limits: spending_limits, // ADDED - }, - position: None, - }); - } else { - // No summary found - create a minimal node for external wallet - // Still try to get spending limits from hyperwallet - let spending_limits = get_wallet_spending_limits(state, hw_address_str.clone()) - .unwrap_or_else(|e| { - info!("Could not fetch spending limits for external wallet {}: {}", hw_address_str, e); - None - }); - - nodes.push(GraphNode { - id: hot_wallet_node_id.clone(), - node_type: "hotWalletNode".to_string(), - data: GraphNodeData::HotWalletNode { - address: hw_address_str.clone(), - name: None, - status_description: "Linked (External)".to_string(), - is_active_in_mcp: false, - is_encrypted: false, - is_unlocked: false, - funding_info: hw_funding_info, - authorized_clients: Vec::new(), - limits: spending_limits, - }, - position: None, - }); - } - - if let Some(op_w_id) = &operator_wallet_node_id { - edges.push(GraphEdge { - id: format!("edge-{}-{}", op_w_id, hot_wallet_node_id), - source: op_w_id.clone(), - target: hot_wallet_node_id.clone(), - style_type: None, animated: None, - }); - } - - // Add client nodes - only if we have a summary (managed wallet) - if summary_opt.is_some() { - let mut client_ids_for_this_hw: Vec = Vec::new(); - for client in state.authorized_clients.values() { - if client.associated_hot_wallet_address == hw_address_str { - client_ids_for_this_hw.push(client.id.clone()); - } - } - - for client_id in client_ids_for_this_hw { - if let Some(client_config) = state.authorized_clients.get(&client_id) { - let client_node_id = format!("auth-client-{}", client_id); - nodes.push(GraphNode { - id: client_node_id.clone(), - node_type: "authorizedClientNode".to_string(), - data: GraphNodeData::AuthorizedClientNode { - client_id: client_config.id.clone(), - client_name: client_config.name.clone(), - associated_hot_wallet_address: client_config.associated_hot_wallet_address.clone(), - }, - position: None, - }); - edges.push(GraphEdge { - id: format!("edge-{}-{}", hot_wallet_node_id, client_node_id), - source: hot_wallet_node_id.clone(), - target: client_node_id.clone(), - style_type: None, animated: None, - }); - } - } - } - - let add_client_action_node_id = format!("action-add-client-{}", hw_address_str); - nodes.push(GraphNode { - id: add_client_action_node_id.clone(), - node_type: "addAuthorizedClientActionNode".to_string(), - data: GraphNodeData::AddAuthorizedClientActionNode { - label: "Authorize New Client".to_string(), - target_hot_wallet_address: hw_address_str.clone(), - action_id: "trigger_add_client_modal".to_string(), - }, - position: None, - }); - edges.push(GraphEdge { - id: format!("edge-{}-{}", hot_wallet_node_id, add_client_action_node_id), - source: hot_wallet_node_id.clone(), - target: add_client_action_node_id.clone(), - style_type: None, - animated: Some(true), - }); - } - } - Err(e) => { - error!("Failed to get linked hot wallet addresses for graph: {}", e); - } - } - } - - // Derive coarse state for simplified UI - let coarse_state = match (&operator_wallet_node_id, linked_wallets_count) { - (None, _) => CoarseState::BeforeWallet, - (Some(_), 0) => CoarseState::AfterWalletNoClients, - (Some(_), _) => CoarseState::AfterWalletWithClients, - }; - - Ok(HypergridGraphResponse { nodes, edges, coarse_state }) -} - -pub fn handle_get_hypergrid_graph_layout( - our: &Address, - state: &mut State, -) -> anyhow::Result<()> { - info!("Handling GET /api/hypergrid-graph for node {}...", our.node); - - // Re-check operator identity to ensure state is up-to-date (especially after TBA minting) - if let Err(e) = crate::identity::initialize_operator_identity(our, state) { - warn!("Failed to re-initialize operator identity during graph build: {:?}", e); - } - - match build_hypergrid_graph_data(our, state) { - Ok(graph_response) => { - // Log the serialized JSON before sending - match serde_json::to_string_pretty(&graph_response) { - Ok(_json_string) => {}//info!("Serialized HypergridGraphResponse JSON:\n{}", json_string), - Err(e) => error!("Failed to serialize HypergridGraphResponse for logging: {:?}", e), - } - send_json_response(StatusCode::OK, &graph_response) - } - Err(e) => { - error!("Error building Hypergrid graph data: {:?}", e); - send_json_response(StatusCode::INTERNAL_SERVER_ERROR, &json!({"error": e.to_string()})) - } - } -} \ No newline at end of file diff --git a/operator/operator/src/helpers.rs b/operator/operator/src/helpers.rs index 0beadc4..644bddd 100644 --- a/operator/operator/src/helpers.rs +++ b/operator/operator/src/helpers.rs @@ -1,32 +1,20 @@ -use anyhow::{anyhow, Result}; +// This is the cleaned-up version of helpers.rs with only the functions that are actually used + +use anyhow::Result; use std::time::{SystemTime, UNIX_EPOCH}; use std::collections::HashMap; -use sha2::{Sha256, Digest}; -use hyperware_process_lib::logging::{info, error, warn}; +use hyperware_process_lib::logging::{info, error}; use hyperware_process_lib::Address as HyperAddress; use hyperware_process_lib::sqlite::Sqlite; -use hyperware_process_lib::wallet::{self, KeyStorage, EthAmount, execute_via_tba_with_signer, wait_for_transaction, get_eth_balance, erc20_balance_of}; -use hyperware_process_lib::eth::{Provider, TransactionRequest, TransactionInput, BlockNumberOrTag}; -use alloy_primitives::{Address as EthAddress, Bytes as AlloyBytes}; -use std::str::FromStr; +use alloy_primitives::{Address as EthAddress, B256}; use hyperware_process_lib::hypermap; -use hyperware_process_lib::eth; use hyperware_process_lib::http::{StatusCode, server::send_response}; -use hyperware_process_lib::signer::Signer; -use crate::constants::{HYPR_HASH, USDC_BASE_ADDRESS}; -use crate::ledger; -use alloy_primitives::{U256, B256, keccak256}; -use alloy_sol_types::{SolValue, SolCall}; +use alloy_sol_types::SolValue; use hex; -use crate::structs::{self, *}; -use crate::db; -use crate::hyperwallet_client::{service, payments::{handle_operator_tba_withdrawal, AssetType, execute_payment}}; -use crate::chain; -use crate::authorized_services::{HotWalletAuthorizedClient, ServiceCapabilities}; - -// Fill this with your Basescan API key (or move to a secure config) +use crate::structs::*; +/// Generate a JSON timestamp for database entries pub fn make_json_timestamp() -> serde_json::Number { let systemtime = SystemTime::now(); @@ -36,2281 +24,4 @@ pub fn make_json_timestamp() -> serde_json::Number { let secs = duration_since_epoch.as_secs(); let now: serde_json::Number = secs.into(); return now; -} - -// --- USDC event snapshot helpers --- -fn ensure_usdc_events_table(db: &Sqlite) -> anyhow::Result<()> { ledger::ensure_usdc_events_table(db) } - -// --- USDC per-call ledger schema --- -fn ensure_usdc_call_ledger_table(db: &Sqlite) -> anyhow::Result<()> { crate::ledger::ensure_usdc_call_ledger_table(db) } - -use crate::ledger::usdc_display_to_units; - -use crate::ledger::build_usdc_ledger_for_tba; - -// ensure_usdc_call_ledger_table re-exported above - -// Historical ERC20 balanceOf at a specific block -fn erc20_balance_of_at( - provider: &Provider, - token: EthAddress, - owner: EthAddress, - block: u64, -) -> anyhow::Result { - use alloy_sol_types::sol; - sol! { - function balanceOf(address owner) external view returns (uint256 balance); - } - let call = balanceOfCall { owner }; - let data = call.abi_encode(); - let tx = TransactionRequest::default() - .input(TransactionInput::new(data.into())) - .to(token); - let res = provider.call(tx, Some(hyperware_process_lib::eth::BlockId::Number(BlockNumberOrTag::Number(block))))?; - // decode returns or fallback to U256 from bytes - if res.len() == 32 { - Ok(U256::from_be_slice(res.as_ref())) - } else { - let decoded = balanceOfCall::abi_decode_returns(&res, false) - .map_err(|e| anyhow!("decode error: {}", e))?; - Ok(decoded.balance) - } -} - -fn bisect_change_ranges( - provider: &Provider, - token: EthAddress, - owner: EthAddress, - start: u64, - end: u64, - window_cap: u64, - get_balance: &mut F, - ranges_out: &mut Vec<(u64,u64)>, -) -> anyhow::Result<()> -where F: FnMut(u64) -> anyhow::Result { - if start >= end { return Ok(()); } - - let bal_start = get_balance(start)?; - let bal_end = get_balance(end)?; - if bal_start == bal_end { - return Ok(()); // no net change in whole range - } - if end - start <= window_cap { - ranges_out.push((start, end)); - return Ok(()); - } - let mid = start + (end - start) / 2; - let bal_mid = get_balance(mid)?; - if bal_mid != bal_start { - bisect_change_ranges(provider, token, owner, start, mid, window_cap, get_balance, ranges_out)?; - } - if bal_mid != bal_end { - bisect_change_ranges(provider, token, owner, mid + 1, end, window_cap, get_balance, ranges_out)?; - } - Ok(()) -} - -fn insert_usdc_event( - db: &Sqlite, - address: &str, - block: u64, - time: Option, - tx_hash: &str, - log_index: Option, - from_addr: &str, - to_addr: &str, - value_units: &str, -) -> anyhow::Result<()> { - let stmt = r#" - INSERT OR IGNORE INTO usdc_events - (address, block, time, tx_hash, log_index, from_addr, to_addr, value_units) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8); - "#.to_string(); - let params = vec![ - serde_json::Value::String(address.to_string()), - serde_json::Value::Number(serde_json::Number::from(block)), - time.map(|t| serde_json::Value::Number(serde_json::Number::from(t))).unwrap_or(serde_json::Value::Null), - serde_json::Value::String(tx_hash.to_string()), - log_index.map(|i| serde_json::Value::Number(serde_json::Number::from(i))).unwrap_or(serde_json::Value::Null), - serde_json::Value::String(from_addr.to_string()), - serde_json::Value::String(to_addr.to_string()), - serde_json::Value::String(value_units.to_string()), - ]; - db.write(stmt, params, None)?; - Ok(()) -} - - -// Calculate provider ID based on SHA256 hash of provider name -pub fn get_provider_id(provider_name: &str) -> String { - let digest = Sha256::digest(provider_name.as_bytes()); - format!("{:x}", digest) -} - -// Helper function to authenticate a shim client -pub fn authenticate_shim_client<'a>( - state: &'a State, - client_id: &str, - raw_token: &str, -) -> Result<&'a HotWalletAuthorizedClient, AuthError> { - // 1. Lookup Clien - match state.authorized_clients.get(client_id) { - Some(client_config) => { - // 2. Verify Token - let mut hasher = Sha256::new(); - hasher.update(raw_token.as_bytes()); - let hashed_received_token = format!("{:x}", hasher.finalize()); - - if hashed_received_token != client_config.authentication_token { - return Err(AuthError::InvalidToken); - } - - // 3. Check Capabilities - if client_config.capabilities != ServiceCapabilities::All { - return Err(AuthError::InsufficientCapabilities); - } - - // All checks passed - Ok(client_config) - } - None => Err(AuthError::ClientNotFound), - } -} - - -// --- Hypermap Helper Functions for Delegation --- - -/// Reads an access list note and extracts the B256 hash of the signers note it points to. -/// -/// # Arguments -/// * `hypermap_reader` - An initialized instance of `hypermap::Hypermap`. -/// * `access_list_full_path` - The full Hypermap path to the access list note -/// -/// # Returns -/// * `Ok(B256)` - The hash of the signers note. -/// * `Err(String)` - An error message detailing what went wrong (note not found, invalid data format, etc.). -pub fn get_signers_note_hash_from_access_list( - hypermap_reader: &hypermap::Hypermap, - access_list_full_path: &str, -) -> Result { - info!("Helper: Reading access list note: {}", access_list_full_path); - - match hypermap_reader.get(access_list_full_path) { - Ok((_tba, _owner, Some(data))) => { - // Expecting raw 32-byte hash directly - info!(" Helper: Found access list data ({} bytes). Expecting raw 32-byte hash.", data.len()); - if data.len() == 32 { // Expect raw 32 bytes for the hash - let hash = B256::from_slice(&data); - info!(" Helper: Successfully interpreted raw data as 32-byte namehash for signers note: {}", hash); - Ok(hash) - } else { - let reason = format!( - "Data in access list note '{}' is not 32 bytes long (expected raw hash), length is {}. Data (hex): 0x{}", - access_list_full_path, data.len(), hex::encode(&data) // Log as hex for debugging - ); - error!(" Helper: Error - {}", reason); - Err(reason) - } - } - Ok((_tba, _owner, None)) => { - let reason = format!("Access list note '{}' exists but has no data.", access_list_full_path); - error!(" Helper: Error - {}", reason); - Err(reason) - } - Err(e) => { - let err_msg = format!("{:?}", e); - let reason = format!("Error reading access list note '{}': {}", access_list_full_path, err_msg); - error!(" Helper: Error - {}", reason); - if err_msg.contains("note not found") { - Err(format!("AccessListNoteMissing: {}", reason)) // More specific error type if needed - } else { - Err(format!("HypermapReadError: {}", reason)) - } - } - } -} - -/// Reads a signers note (given its hash) and ABI-decodes its content as a Vec
. -/// -/// # Arguments -/// * `hypermap_reader` - An initialized instance of `hypermap::Hypermap`. -/// * `signers_note_hash_b256` - The B256 hash of the signers note. -/// -/// # Returns -/// * `Ok(Vec)` - A vector of delegate Ethereum addresses. -/// * `Err(String)` - An error message detailing what went wrong (note not found, invalid data format, etc.). -pub fn get_signers_note_from_hash( - hypermap_reader: &hypermap::Hypermap, - signers_note_hash_b256: &B256, -) -> Result, String> { - info!("Helper: Reading signers note: {}", signers_note_hash_b256); - - let note_hash_hex = format!("{:x}", signers_note_hash_b256); - - match hypermap_reader.get(¬e_hash_hex) { - Ok((_tba, _owner, Some(data))) => { - // Expecting raw 32-byte hash directly - info!(" Helper: Found signers note data ({} bytes). Expecting raw 32-byte hash.", data.len()); - if data.len() == 32 { // Expect raw 32 bytes for the hash - let hash = B256::from_slice(&data); - info!(" Helper: Successfully interpreted raw data as 32-byte namehash for signers note: {}", hash); - Ok(vec![EthAddress::from_slice(&data)]) - } else { - let reason = format!( - "Data in signers note '{}' is not 32 bytes long (expected raw hash), length is {}. Data (hex): 0x{}", - signers_note_hash_b256, data.len(), hex::encode(&data) // Log as hex for debugging - ); - error!(" Helper: Error - {}", reason); - Err(reason) - } - } - Ok((_tba, _owner, None)) => { - let reason = format!("Signers note '{}' exists but has no data.", signers_note_hash_b256); - error!(" Helper: Error - {}", reason); - Err(reason) - } - Err(e) => { - let err_msg = format!("{:?}", e); - let reason = format!("Error reading signers note '{}': {}", signers_note_hash_b256, err_msg); - error!(" Helper: Error - {}", reason); - if err_msg.contains("note not found") { - Err(format!("SignersNoteMissing: {}", reason)) // More specific error type if needed - } else { - Err(format!("HypermapReadError: {}", reason)) - } - } - } -} - -/// * `Err(String)` - An error message detailing what went wrong (note not found, invalid data format, etc.). -pub fn get_addresses_from_signers_note( - hypermap_reader: &hypermap::Hypermap, - signers_note_hash_b256: B256, -) -> Result, String> { - let signers_note_hash_str = format!("0x{}", hex::encode(signers_note_hash_b256)); - info!("Helper: Reading signers note using hash: {}", signers_note_hash_str); - - match hypermap_reader.get_hash(&signers_note_hash_str) { - Ok((_tba, _owner, Some(data))) => { - info!(" Helper: Found signers note data ({} bytes). Expecting ABI-encoded Address[].", data.len()); - match Vec::::abi_decode(&data, true) { // true for lenient if padded - Ok(decoded_delegates) => { - info!(" Helper: Successfully ABI-decoded signers note delegates: {:?}", decoded_delegates); - Ok(decoded_delegates) - } - Err(e) => { - let reason = format!( - "Failed to ABI decode signers note (hash: {}) data as Address[]: {}. Data(hex): 0x{}", - signers_note_hash_str, e, hex::encode(&data) - ); - error!(" Helper: Error - {}", reason); - Err(reason) - } - } - } - Ok((_tba, _owner, None)) => { - let reason = format!("Signers note found by hash '{}' exists but has no data.", signers_note_hash_str); - error!(" Helper: Error - {}", reason); - Err(reason) - } - Err(e) => { - let err_msg = format!("{:?}", e); - let reason = format!("Error reading signers note by hash '{}': {}", signers_note_hash_str, err_msg); - error!(" Helper: Error - {}", reason); - if err_msg.contains("note not found") { - Err(format!("SignersNoteNotFound: {}", reason)) - } else { - Err(format!("HypermapReadError: {}", reason)) - } - } - } -} - -/// Queries Hypermap for the TBA of a given node name. -/// Returns a descriptive string with the TBA or an error/not found message. -fn debug_get_tba_for_node(node_name: &str) -> Result { - info!("Debug: Querying TBA for node: {}", node_name); - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - let hypermap_contract_address = EthAddress::from_str(hypermap::HYPERMAP_ADDRESS) - .map_err(|e| anyhow!("Invalid HYPERMAP_ADDRESS: {}", e))?; - - if hypermap_contract_address == EthAddress::ZERO { - return Ok("HYPERMAP_ADDRESS is zero, cannot query.".to_string()); - } - - let hypermap_reader = hypermap::Hypermap::new(provider.clone(), hypermap_contract_address); - match hypermap_reader.get(node_name) { - Ok((tba, _owner, _data)) => { - if tba != EthAddress::ZERO { - Ok(format!("Found: {}", tba.to_string())) - } else { - Ok("Not found (TBA is zero address).".to_string()) - } - } - Err(e) => { - Ok(format!("Error during lookup: {:?}", e)) - } - } -} - -/// Queries Hypermap for the owner EOA of a given node name. -/// Returns a descriptive string with the owner EOA or an error/not found message. -fn debug_get_owner_for_node(node_name: &str) -> Result { - info!("Debug: Querying owner for node: {}", node_name); - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - let hypermap_contract_address = EthAddress::from_str(hypermap::HYPERMAP_ADDRESS) - .map_err(|e| anyhow!("Invalid HYPERMAP_ADDRESS: {}", e))?; - - if hypermap_contract_address == EthAddress::ZERO { - return Ok("HYPERMAP_ADDRESS is zero, cannot query.".to_string()); - } - - let hypermap_reader = hypermap::Hypermap::new(provider.clone(), hypermap_contract_address); - match hypermap_reader.get(node_name) { - Ok((_tba, owner, _data)) => { - Ok(format!("Found: {}", owner.to_string())) - } - Err(e) => { - Ok(format!("Error during lookup: {:?}", e)) - } - } -} - -pub fn send_json_response(status: StatusCode, data: &T) -> anyhow::Result<()> { - let json_data = serde_json::to_vec(data)?; - send_response( - status, - Some(std::collections::HashMap::from([( - String::from("Content-Type"), - String::from("application/json"), - )])), - json_data, - ); - Ok(()) -} - -/// Helper functions for ERC-4337 UserOperation dynamic building -/// These functions extract the logic from test-dynamic-fetch for reuse in other commands - -/// Fetch the current nonce for a sender from the EntryPoint contract -pub fn fetch_dynamic_nonce( - provider: &Provider, - sender: &str, - entry_point: &str, -) -> Result { - use alloy_sol_types::*; - sol! { - function getNonce(address sender, uint192 key) external view returns (uint256 nonce); - } - - let get_nonce_call = getNonceCall { - sender: EthAddress::from_str(sender).map_err(|e| anyhow!("Invalid sender address: {}", e))?, - key: alloy_primitives::U256::ZERO.to::>(), // Nonce key 0 - }; - - let nonce_call_data = get_nonce_call.abi_encode(); - let nonce_tx_req = TransactionRequest::default() - .input(TransactionInput::new(nonce_call_data.into())) - .to(EthAddress::from_str(entry_point).map_err(|e| anyhow!("Invalid entry point address: {}", e))?); - - match provider.call(nonce_tx_req, None) { - Ok(bytes) => { - let decoded = U256::from_be_slice(&bytes); - info!("Dynamic nonce fetched: {}", decoded); - Ok(format!("0x{:x}", decoded)) - } - Err(e) => { - error!("❌ Failed to fetch nonce: {}", e); - info!("Using fallback nonce: 0x1"); - Ok("0x1".to_string()) - } - } -} - -/// Fetch current gas prices from the latest block -pub fn fetch_dynamic_gas_prices(provider: &Provider) -> Result<(u128, u128)> { - info!("Fetching dynamic gas prices"); - match provider.get_block_by_number(BlockNumberOrTag::Latest, false) { - Ok(Some(block)) => { - let base_fee = block.header.inner.base_fee_per_gas.unwrap_or(1_000_000_000) as u128; - let base_fee_gwei = base_fee as f64 / 1_000_000_000.0; - info!("Current base fee: {} wei ({:.2} gwei)", base_fee, base_fee_gwei); - - // Calculate dynamic gas prices based on current network conditions - let max_fee = base_fee + (base_fee / 3); // Add 33% buffer - let priority_fee = std::cmp::max(100_000_000u128, base_fee / 10); // At least 0.1 gwei - - let max_fee_gwei = max_fee as f64 / 1_000_000_000.0; - let priority_fee_gwei = priority_fee as f64 / 1_000_000_000.0; - info!("Calculated max fee: {} wei ({:.2} gwei)", max_fee, max_fee_gwei); - info!("Calculated priority fee: {} wei ({:.2} gwei)", priority_fee, priority_fee_gwei); - - Ok((max_fee, priority_fee)) - } - Ok(None) => { - error!("❌ No latest block found"); - info!("Using fallback gas prices"); - Ok((3_000_000_000u128, 2_000_000_000u128)) - } - Err(e) => { - error!("❌ Failed to fetch block: {}", e); - info!("Using fallback gas prices"); - Ok((3_000_000_000u128, 2_000_000_000u128)) - } - } -} - -/// Build USDC transfer calldata with TBA execute wrapper -pub fn build_usdc_transfer_calldata( - usdc_contract: &str, - recipient: &str, - amount_units: u128, -) -> Result> { - use alloy_sol_types::sol; - - // Build the USDC transfer calldata - sol! { - function transfer(address to, uint256 amount) external returns (bool); - } - - let transfer_call = transferCall { - to: EthAddress::from_str(recipient).map_err(|e| anyhow!("Invalid recipient address: {}", e))?, - amount: U256::from(amount_units), - }; - let transfer_data = transfer_call.abi_encode(); - - // Build TBA execute calldata - sol! { - function execute(address to, uint256 value, bytes calldata data, uint8 operation) external payable returns (bytes memory result); - } - - let execute_call = executeCall { - to: EthAddress::from_str(usdc_contract).map_err(|e| anyhow!("Invalid USDC contract address: {}", e))?, - value: U256::ZERO, - data: AlloyBytes::from(transfer_data), - operation: 0u8, - }; - let execute_data = execute_call.abi_encode(); - - info!("Built calldata: 0x{}", hex::encode(&execute_data)); - Ok(execute_data) -} - -/// Calculate UserOperation hash using EntryPoint.getUserOpHash() -pub fn calculate_userop_hash( - provider: &Provider, - entry_point: &str, - sender: &str, - nonce: &str, - call_data: &[u8], - final_call_gas: u64, - final_verif_gas: u64, - final_preverif_gas: u64, - dynamic_max_fee: u128, - dynamic_priority_fee: u128, - paymaster_data: &[u8], -) -> Result> { - use alloy_sol_types::sol; - - // Pack gas values for v0.8 EntryPoint hash calculation - let account_gas_limits: U256 = (U256::from(final_verif_gas) << 128) | U256::from(final_call_gas); - let gas_fees: U256 = (U256::from(dynamic_priority_fee) << 128) | U256::from(dynamic_max_fee); - - sol! { - struct PackedUserOperation { - address sender; - uint256 nonce; - bytes initCode; - bytes callData; - bytes32 accountGasLimits; - uint256 preVerificationGas; - bytes32 gasFees; - bytes paymasterAndData; - bytes signature; - } - - function getUserOpHash(PackedUserOperation userOp) external view returns (bytes32); - } - - let packed_user_op = PackedUserOperation { - sender: EthAddress::from_str(sender).map_err(|e| anyhow!("Invalid sender address: {}", e))?, - nonce: alloy_primitives::U256::from_str_radix(nonce.trim_start_matches("0x"), 16) - .map_err(|e| anyhow!("Invalid nonce format: {}", e))?, - initCode: AlloyBytes::new(), - callData: AlloyBytes::from(call_data.to_vec()), - accountGasLimits: alloy_primitives::FixedBytes::from_slice(&account_gas_limits.to_be_bytes::<32>()), - preVerificationGas: alloy_primitives::U256::from(final_preverif_gas), - gasFees: alloy_primitives::FixedBytes::from_slice(&gas_fees.to_be_bytes::<32>()), - paymasterAndData: AlloyBytes::from(paymaster_data.to_vec()), - signature: AlloyBytes::new(), - }; - - let get_hash_call = getUserOpHashCall { - userOp: packed_user_op, - }; - - let hash_call_data = get_hash_call.abi_encode(); - let hash_tx_req = TransactionRequest::default() - .input(TransactionInput::new(hash_call_data.into())) - .to(EthAddress::from_str(entry_point).map_err(|e| anyhow!("Invalid entry point address: {}", e))?); - - match provider.call(hash_tx_req, None) { - Ok(bytes) => { - let hash = if bytes.len() == 32 { - bytes.to_vec() - } else { - match getUserOpHashCall::abi_decode_returns(&bytes, false) { - Ok(decoded_hash) => decoded_hash._0.to_vec(), - Err(_) => bytes.to_vec() - } - }; - info!("UserOp hash calculated: 0x{}", hex::encode(&hash)); - Ok(hash) - } - Err(e) => { - Err(anyhow!("Failed to calculate UserOp hash: {}", e)) - } - } -} - -/// Sign a UserOperation hash -pub fn sign_userop_hash(user_op_hash: &[u8], private_key: &str, chain_id: u64) -> Result { - use hyperware_process_lib::signer::LocalSigner; - - let signer = LocalSigner::from_private_key(private_key, chain_id) - .map_err(|e| anyhow!("Failed to create signer: {}", e))?; - - match signer.sign_hash(user_op_hash) { - Ok(sig) => { - info!("UserOperation signed successfully"); - Ok(hex::encode(&sig)) - } - Err(e) => { - Err(anyhow!("Failed to sign UserOperation: {}", e)) - } - } -} - -/// Build the final UserOperation JSON for submission -pub fn build_final_userop_json( - sender: &str, - nonce: &str, - call_data_hex: &str, - final_call_gas: u64, - final_verif_gas: u64, - final_preverif_gas: u64, - dynamic_max_fee: u128, - dynamic_priority_fee: u128, - signature: &str, - use_paymaster: bool, -) -> serde_json::Value { - serde_json::json!({ - "sender": sender, - "nonce": nonce, - "callData": format!("0x{}", call_data_hex), - "callGasLimit": format!("0x{:x}", final_call_gas), - "verificationGasLimit": format!("0x{:x}", final_verif_gas), - "preVerificationGas": format!("0x{:x}", final_preverif_gas), - "maxFeePerGas": format!("0x{:x}", dynamic_max_fee), - "maxPriorityFeePerGas": format!("0x{:x}", dynamic_priority_fee), - "signature": format!("0x{}", signature), - "factory": serde_json::Value::Null, - "factoryData": serde_json::Value::Null, - "paymaster": if use_paymaster { - serde_json::Value::String("0x0578cFB241215b77442a541325d6A4E6dFE700Ec".to_string()) - } else { - serde_json::Value::Null - }, - "paymasterVerificationGasLimit": if use_paymaster { - serde_json::Value::String(format!("0x{:x}", final_verif_gas)) - } else { - serde_json::Value::Null - }, - "paymasterPostOpGasLimit": if use_paymaster { - serde_json::Value::String(format!("0x{:x}", final_call_gas)) - } else { - serde_json::Value::Null - }, - "paymasterData": if use_paymaster { - serde_json::Value::String("0x000000000000000000000000000000000000000000000000000000000007a12000000000000000000000000000000000000000000000000000000000000493e0".to_string()) - } else { - serde_json::Value::Null - } - }) -} - -/// Build final UserOperation JSON with custom paymaster data (v0.8 format) -pub fn build_final_userop_json_with_data( - sender: &str, - nonce: &str, - call_data_hex: &str, - final_call_gas: u64, - final_verif_gas: u64, - final_preverif_gas: u64, - dynamic_max_fee: u128, - dynamic_priority_fee: u128, - signature: &str, - paymaster_data: &[u8], -) -> serde_json::Value { - // For Circle paymaster, always include the paymaster address and gas limits - // Based on working example: verification gas = 500000, post-op gas = 300000 - let paymaster_val = serde_json::json!("0x0578cFB241215b77442a541325d6A4E6dFE700Ec"); - let paymaster_data_val = if paymaster_data.is_empty() { - serde_json::json!("0x") - } else { - serde_json::json!(format!("0x{}", hex::encode(paymaster_data))) - }; - - serde_json::json!({ - "sender": sender, - "nonce": nonce, - "callData": format!("0x{}", call_data_hex), - "callGasLimit": format!("0x{:x}", final_call_gas), - "verificationGasLimit": format!("0x{:x}", final_verif_gas), - "preVerificationGas": format!("0x{:x}", final_preverif_gas), - "maxFeePerGas": format!("0x{:x}", dynamic_max_fee), - "maxPriorityFeePerGas": format!("0x{:x}", dynamic_priority_fee), - "signature": format!("0x{}", signature), - "factory": serde_json::Value::Null, - "factoryData": serde_json::Value::Null, - "paymaster": paymaster_val, - "paymasterVerificationGasLimit": serde_json::json!("0x7a120"), // 500000 - from working example - "paymasterPostOpGasLimit": serde_json::json!("0x493e0"), // 300000 - from working example - "paymasterData": paymaster_data_val - }) -} - -/// Build a UserOperation for gas estimation with proper format -pub fn build_estimation_userop_json( - sender: &str, - nonce: &str, - call_data_hex: &str, - call_gas: u128, - verification_gas: u128, - pre_verification_gas: u64, - dynamic_max_fee: u128, - dynamic_priority_fee: u128, - use_paymaster: bool, -) -> serde_json::Value { - if use_paymaster { - serde_json::json!({ - "sender": sender, - "nonce": nonce, - "callData": format!("0x{}", call_data_hex), - "callGasLimit": format!("0x{:x}", call_gas), - "verificationGasLimit": format!("0x{:x}", verification_gas), - "preVerificationGas": format!("0x{:x}", pre_verification_gas), - "maxFeePerGas": format!("0x{:x}", dynamic_max_fee), - "maxPriorityFeePerGas": format!("0x{:x}", dynamic_priority_fee), - "signature": "0x6631d932a459f079222e400c20f3cf05a4c0fe30ed22fcc311a5a22a37db61845ee7a42db22925e69e43e458b51b3c5cdd95e15ee9b90a15cf3ab520633c4c5b1b", // Dummy but valid signature for estimation - "factory": serde_json::Value::Null, - "factoryData": serde_json::Value::Null, - "paymaster": "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", - "paymasterVerificationGasLimit": format!("0x{:x}", verification_gas), - "paymasterPostOpGasLimit": format!("0x{:x}", call_gas), - "paymasterData": "0x000000000000000000000000000000000000000000000000000000000007a12000000000000000000000000000000000000000000000000000000000000493e0" - }) - } else { - serde_json::json!({ - "sender": sender, - "nonce": nonce, - "callData": format!("0x{}", call_data_hex), - "callGasLimit": format!("0x{:x}", call_gas), - "verificationGasLimit": format!("0x{:x}", verification_gas), - "preVerificationGas": format!("0x{:x}", pre_verification_gas), - "maxFeePerGas": format!("0x{:x}", dynamic_max_fee), - "maxPriorityFeePerGas": format!("0x{:x}", dynamic_priority_fee), - "signature": "0x6631d932a459f079222e400c20f3cf05a4c0fe30ed22fcc311a5a22a37db61845ee7a42db22925e69e43e458b51b3c5cdd95e15ee9b90a15cf3ab520633c4c5b1b", // Dummy but valid signature for estimation - "factory": serde_json::Value::Null, - "factoryData": serde_json::Value::Null, - "paymaster": serde_json::Value::Null, - "paymasterVerificationGasLimit": serde_json::Value::Null, - "paymasterPostOpGasLimit": serde_json::Value::Null, - "paymasterData": serde_json::Value::Null - }) - } -} - -/// Calculate transaction cost in wei, ETH, and USD -pub fn calculate_transaction_cost( - final_call_gas: u64, - final_verif_gas: u64, - final_preverif_gas: u64, - dynamic_max_fee: u128, -) -> (u128, f64, f64) { - let total_gas = final_call_gas + final_verif_gas + final_preverif_gas; - let total_cost_wei = total_gas as u128 * dynamic_max_fee; - let total_cost_eth = total_cost_wei as f64 / 1e18; - let total_cost_usd = total_cost_eth * 3200.0; // Approximate ETH price - - info!("Transaction cost analysis:"); - info!(" - Total gas units: {}", total_gas); - info!(" - Gas price: {:.2} gwei", dynamic_max_fee as f64 / 1_000_000_000.0); - info!(" - Total cost: {} wei (~{:.6} ETH ~${:.2})", total_cost_wei, total_cost_eth, total_cost_usd); - - (total_cost_wei, total_cost_eth, total_cost_usd) -} - -/// Check TBA ETH balance for gas payment (when not using paymaster) -pub fn check_tba_eth_balance( - provider: &Provider, - sender: &str, - total_cost_wei: u128, - total_cost_eth: f64, -) -> Result<()> { - info!("🔍 Checking TBA ETH balance for gas payment..."); - match provider.get_balance(EthAddress::from_str(sender).map_err(|e| anyhow!("Invalid sender address: {}", e))?, None) { - Ok(balance) => { - let eth_balance = balance.to::() as f64 / 1e18; - info!(" - TBA ETH balance: {:.6} ETH", eth_balance); - if balance.to::() < total_cost_wei { - error!(" ⚠️ INSUFFICIENT ETH! Need {:.6} ETH but only have {:.6} ETH", total_cost_eth, eth_balance); - error!(" This may be why gas estimation failed with AA23"); - } else { - info!(" Sufficient ETH for gas payment"); - } - Ok(()) - } - Err(e) => { - error!(" ❌ Failed to check TBA balance: {}", e); - Err(anyhow!("Failed to check TBA balance: {}", e)) - } - } -} - -/// Get UserOperation receipt directly from bundler (for manual testing) -pub fn get_user_op_receipt_manual( - user_op_hash: &str, - bundler_url: &str, -) -> Result { - use hyperware_process_lib::http::client::send_request_await_response; - use hyperware_process_lib::http::Method; - - info!("Fetching UserOperation receipt from bundler..."); - info!(" UserOp Hash: {}", user_op_hash); - info!(" Bundler URL: {}", bundler_url); - - let request_body = serde_json::json!({ - "jsonrpc": "2.0", - "method": "eth_getUserOperationReceipt", - "params": [user_op_hash], - "id": 1 - }); - - info!("Request body: {}", serde_json::to_string_pretty(&request_body)?); - - let url = url::Url::parse(bundler_url)?; - let mut headers = std::collections::HashMap::new(); - headers.insert("Content-Type".to_string(), "application/json".to_string()); - - match send_request_await_response( - Method::POST, - url, - Some(headers), - 30000, - serde_json::to_vec(&request_body)?, - ) { - Ok(response) => { - let response_str = String::from_utf8_lossy(&response.body()); - info!("Raw bundler response: {}", response_str); - - if let Ok(json) = serde_json::from_str::(&response_str) { - if let Some(result) = json.get("result") { - if result.is_null() { - info!("⚠️ Receipt not yet available (result is null)"); - info!(" This is normal if the UserOp was just submitted"); - info!(" Try again in a few seconds"); - return Ok(serde_json::json!({"status": "pending", "message": "Receipt not yet available"})); - } else { - info!("✅ Receipt found!"); - return Ok(result.clone()); - } - } else if let Some(error) = json.get("error") { - error!("❌ Bundler error: {}", serde_json::to_string_pretty(error)?); - return Err(anyhow!("Bundler error: {}", error)); - } else { - error!("❌ Unexpected response format"); - return Err(anyhow!("Unexpected response format: {}", response_str)); - } - } else { - error!("❌ Failed to parse JSON response"); - return Err(anyhow!("Failed to parse JSON: {}", response_str)); - } - } - Err(e) => { - error!("❌ Network error: {}", e); - return Err(anyhow!("Network error: {}", e)); - } - } -} - -/// Extract gas values from bundler estimation result -pub fn extract_gas_values_from_estimate( - estimates: Option, - default_call_gas: u128, - default_verification_gas: u128, - default_pre_verification_gas: u64, -) -> (u64, u64, u64) { - if let Some(estimates) = estimates { - let estimated_call_gas = estimates.get("callGasLimit") - .and_then(|v| v.as_str()) - .and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()) - .unwrap_or(default_call_gas as u64); - let estimated_verif_gas = estimates.get("verificationGasLimit") - .and_then(|v| v.as_str()) - .and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()) - .unwrap_or(default_verification_gas as u64); - let estimated_preverif_gas = estimates.get("preVerificationGas") - .and_then(|v| v.as_str()) - .and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()) - .unwrap_or(default_pre_verification_gas); - - info!("Using estimated gas values:"); - info!(" - Call gas: {} (estimated) vs {} (default)", estimated_call_gas, default_call_gas); - info!(" - Verification gas: {} (estimated) vs {} (default)", estimated_verif_gas, default_verification_gas); - info!(" - Pre-verification gas: {} (estimated) vs {} (default)", estimated_preverif_gas, default_pre_verification_gas); - - (estimated_call_gas, estimated_verif_gas, estimated_preverif_gas) - } else { - info!("⚠️ Using default gas values due to estimation failure"); - (default_call_gas as u64, default_verification_gas as u64, default_pre_verification_gas) - } -} - - -///////////////////////////// -// for debugging purposes // -pub fn handle_terminal_debug( - our: &HyperAddress, - body: &[u8], - state: &mut State, - db: &Sqlite, -) -> anyhow::Result<()> { - let bod = String::from_utf8(body.to_vec())?; - let command_parts: Vec<&str> = bod.splitn(2, ' ').collect(); - let command_verb = command_parts[0]; - let command_arg = command_parts.get(1).copied(); - - match command_verb { - "help" | "?" => { - info!("--- Hypergrid Operator Debug Commands ---"); - info!("help or ? : Show this help message."); - info!("state : Print current in-memory state."); - info!("db : Check local DB schema."); - info!("reset : Reset state and wipe/reinit DB (requires restart)."); - info!("resync-db : Wipes and reinitializes the local DB, resets chain state (requires restart for full effect)."); - info!("verify : Check on-chain delegation for selected hot wallet."); - info!("namehash : Calculate Hypermap namehash (e.g., namehash ~note.entry.hypr)."); - info!("pay : Attempt test USDC payment from Operator TBA to test address."); - info!("pay-eth : Attempt test ETH payment from Operator TBA to test address."); - info!("check-prereqs : Run a series of checks for Hypergrid operator setup."); - info!("graph-test : Trigger graph generation logic and log output."); - info!("get-tba : Query Hypermap for TBA of a given node."); - info!("get-owner : Query Hypermap for owner of a given node."); - info!("query-provider : Query the local DB for a provider by its exact name."); - info!("list-providers : List all providers in the database."); - info!("search-providers : Search providers by name, provider_name, site, description, or provider_id."); - info!("db-stats : Show database statistics and the current root hash status."); - info!("check-provider-id : Check for provider by provider_id."); - info!("check-grid-root: Check the grid.hypr entry status."); - info!("\n--- ERC-4337 / Account Abstraction Commands ---"); - info!("check-aa : Run ERC-4337 sanity checks (implementation, balances, approvals)."); - info!("approve-paymaster: Approve Circle paymaster to spend USDC from TBA."); - info!("test-gasless : Test a gasless USDC transfer."); - info!("test-paymaster-format : Test different paymaster data formats."); - info!("test-permit : Generate EIP-2612 permit signature components."); - info!("test-permit-data: Test full EIP-2612 permit paymaster data format."); - info!("test-candide-gas-estimate : Test gas estimation with Candide bundler API."); - info!("get-receipt : Get UserOperation receipt from bundler manually."); - info!("decode-aa-error : Decode AA error codes and paymaster errors."); - info!("decode-paymaster-error : Decode common paymaster error codes."); - info!("usdc-history
[days=30] [limit=100]: List USDC transfers via Basescan without scanning blocks."); - info!("-----------------------------------"); - } - "state" => { - info!("Hypergrid operator merged state\n{:#?}", state); - } - "db" => { - let db_up = db::check_schema(db); - info!("Hypergrid operator merged db schema ok: {}", db_up); - } - "reset" => { - info!("Performing reset..."); - let nstate = State::new(); - *state = nstate; - info!("State reset in memory. Wiping DB..."); - if let Err(e) = db::wipe_db(our) { - error!("Error wiping DB: {:?}", e); - } else { - info!("DB wiped. Reinitializing schema..."); - match db::load_db(our) { - Ok(_new_db) => { - // TODO: Need to update the db handle used by the main loop. - // This requires more complex state management (e.g., Arc) - // or restarting the process. For now, log this limitation. - error!("DB reloaded, but process needs restart for changes to take effect."); - // Re-start chain fetch with potentially new (but inaccessible) db? - // let new_pending = chain::start_fetch(state, &new_db); - // Can't easily update pending_logs here either. - info!("Reset partially complete (State reset, DB wiped/recreated). Restart recommended."); - } - Err(e) => { - error!("Failed to reload DB after reset: {:?}", e); - // state.db = None; // Cannot modify db field here - info!("Reset complete, but DB failed to load."); - } - } - } - } - "resync-db" => { - info!("--- Starting Database Resynchronization ---"); - info!("Wiping database..."); - if let Err(e) = db::wipe_db(our) { - error!("Error wiping DB: {:?}. Aborting resync.", e); - return Ok(()); - } - info!("Database wiped. Re-initializing schema..."); - match db::load_db(our) { - Ok(_new_db) => { - info!("New database schema initialized successfully."); - // new_db is local and doesn't replace the one in lib.rs main loop - // The main effect here is that the DB files are recreated cleanly. - } - Err(e) => { - error!("Failed to re-initialize DB schema: {:?}. State will be reset, but DB might be inconsistent until restart.", e); - } - } - - info!("Resetting chain-specific state variables..."); - state.names.clear(); - state.names.insert(String::new(), hypermap::HYPERMAP_ROOT_HASH.to_string()); - state.last_checkpoint_block = structs::HYPERMAP_FIRST_BLOCK; - state.logging_started = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); - state.providers_cache.clear(); - - state.save(); - info!("Chain-specific state reset and database re-initialized."); - info!("--- Database Resynchronization Complete --- "); - error!("RECOMMENDATION: Restart the operator process now to ensure the new database is used and a full chain resync begins."); - } - "verify" => { - info!("Running hot wallet delegation verification (detailed)..."); - match service::verify_selected_hot_wallet_delegation_detailed(state, None) { - DelegationStatus::Verified => { - info!("Verification SUCCESS: Selected hot wallet IS delegated."); - } - status => { - error!("Verification FAILED: {:?}", status); - } - } - } - "namehash" => { - if let Some(name_to_hash) = command_arg { - let hash = hypermap::namehash(name_to_hash); - info!("Namehash for '{}': {}", name_to_hash, hash); - } else { - error!("Usage: namehash "); - } - } - "pay-eth" => { - if let Some(amount_str) = command_arg { - info!("Attempting test ETH payment via hyperwallet: {} ETH", amount_str); - - let target_address_str = "0x62DFaDaBFd0b036c1C616aDa273856c514e65819"; // Test address - - // Get operator TBA address - let operator_tba_addr_str = match &state.operator_tba_address { - Some(addr) => addr.clone(), - None => { - error!("Operator TBA address not configured"); - return Ok(()); - } - }; - - //// Check if we have an active wallet - //if state.selected_wallet_id.is_none() { - // error!("No wallet selected"); - // return Ok(()); - //} - - // Parse amount string to f64, then to U256 wei - let amount_eth_f64 = match amount_str.parse::() { - Ok(f) if f > 0.0 => f, - _ => { - error!("Invalid ETH amount: {}", amount_str); - return Ok(()); - } - }; - let wei_value = EthAmount::from_eth(amount_eth_f64).as_wei(); - - info!("Sending ETH via hyperwallet: {} ETH ({} wei) from Operator TBA {} to {}", - amount_eth_f64, wei_value, operator_tba_addr_str, target_address_str); - - // Use hyperwallet to handle the ETH transfer - match handle_operator_tba_withdrawal( - state, - AssetType::Eth, - target_address_str.to_string(), - wei_value.to_string(), - ) { - Ok(_) => { - info!("ETH payment initiated successfully via hyperwallet"); - } - Err(e) => { - error!("ETH payment failed: {}", e); - } - } - } else { - error!("Usage: pay-eth "); - } - } - "check-prereqs" => { - info!("--- Running Hypergrid Operator Prerequisite Check ---"); - let mut all_ok = true; - - // P1 & P2: Base Node and Sub-Entry Existence - let base_node_name = our.node.clone(); - let sub_entry_name = format!("grid-wallet.{}", base_node_name); - info!("[1/2] Checking base node '{}' and sub-entry '{}' existence...", base_node_name, sub_entry_name); - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - let hypermap_addr = EthAddress::from_str(hypermap::HYPERMAP_ADDRESS).expect("Bad Hypermap Addr"); - let hypermap_reader = hypermap::Hypermap::new(provider.clone(), hypermap_addr); - let sub_entry_check = hypermap_reader.get(&sub_entry_name); - match sub_entry_check { - Ok((tba, owner, Some(data))) => { - let entry_name = sub_entry_name.clone(); - info!(" -> Sub-entry '{}' FOUND. TBA: {}", entry_name, tba); - - // P3: Correct Implementation - info!("[3] Checking sub-entry implementation..."); - let old_impl_str = "0x000000000046886061414588bb9F63b6C53D8674"; - //let new_impl_str = "0x19b89306e31D07426E886E3370E62555A0743D96"; - let new_impl_str = "0x3950D18044D7DAA56BFd6740fE05B42C95201535"; - match chain::get_implementation_address(&provider, tba) { - Ok(impl_addr) => { - let impl_str = impl_addr.to_string(); - let impl_str_lower = impl_str.to_lowercase(); - - if impl_str_lower == old_impl_str.to_lowercase() { - info!("Sub-entry uses OLD implementation - works but no gasless support"); - } else if impl_str_lower == new_impl_str.to_lowercase() { - info!("Sub-entry uses NEW implementation - gasless transactions supported!"); - } else { - error!("❌ Sub-entry uses UNSUPPORTED implementation: {}", impl_str); - error!(" Supported implementations:"); - error!(" - {} (old - works but no gasless)", old_impl_str); - error!(" - {} (new - supports gasless)", new_impl_str); - all_ok = false; - } - } - Err(e) => { - error!("❌ Failed to get implementation address: {:?}", e); - all_ok = false; - } - } - - // P6: Sub-Entry TBA Funding (Basic Check: ETH > 0) - info!("[6] Checking sub-entry TBA ETH balance..."); - match get_eth_balance(&tba.to_string(), structs::CHAIN_ID, provider.clone()) { - Ok(balance) => { - if balance.as_wei() > U256::ZERO { - info!(" -> ETH Balance OK: {}", balance.to_display_string()); - } else { - error!(" -> ETH Balance is ZERO for TBA {}", tba); - all_ok = false; - } - } - Err(e) => { - error!(" -> FAILED to get ETH balance for TBA {}: {:?}", tba, e); - all_ok = false; - } - } - // TODO: Add USDC balance check similarly if needed - - } - Ok((tba, owner, None)) => { - // Sub-entry exists but has no data - info!(" -> Sub-entry '{}' FOUND but has no data. TBA: {}", sub_entry_name, tba); - all_ok = false; - } - Err(e) => { - error!(" -> Sub-entry '{}' NOT FOUND or read error: {:?}", sub_entry_name, e); - all_ok = false; - } - } - - // P4/P5/P6: Delegation Notes & Hot Wallet Match - info!("[4/5/6] Checking delegation notes for '{}' and selected hot wallet...", sub_entry_name); - match service::verify_selected_hot_wallet_delegation_detailed(state, None) { - DelegationStatus::Verified => { - info!(" -> Delegation check PASSED for selected hot wallet."); - } - status => { - error!(" -> Delegation check FAILED: {:?}", status); - all_ok = false; - } - } - - // P7: Client Hot Wallet Ready - info!("[7] Checking client hot wallet status..."); - if state.selected_wallet_id.is_some() && state.active_signer_cache.is_some() { - info!(" -> Hot wallet '{}' is selected and unlocked.", state.selected_wallet_id.as_deref().unwrap_or("N/A")); - } else if state.selected_wallet_id.is_some() { - error!(" -> Hot wallet '{}' is selected but LOCKED.", state.selected_wallet_id.as_deref().unwrap_or("N/A")); - all_ok = false; - } else { - error!(" -> No hot wallet is selected."); - all_ok = false; - } - - info!("--- Prerequisite Check {} ---", if all_ok { "PASSED" } else { "FAILED" }); - } - "graph-test" => { - info!("--- Running Graph Generation Test ---"); - match crate::graph::build_hypergrid_graph_data(our, state) { - Ok(graph_data) => { - info!("Successfully built graph data:"); - info!("{:#?}", graph_data); - } - Err(e) => error!("Error building graph data: {:?}", e), - } - info!("--- Graph Generation Test Complete ---"); - } - "get-tba" => { - if let Some(node_name) = command_arg { - match debug_get_tba_for_node(node_name) { - Ok(result) => info!("TBA for '{}': {}", node_name, result), - Err(e) => error!("Error getting TBA for '{}': {}", node_name, e), - } - } else { - error!("Usage: get-tba "); - } - } - "get-owner" => { - if let Some(node_name) = command_arg { - match debug_get_owner_for_node(node_name) { - Ok(result) => info!("Owner for '{}': {}", node_name, result), - Err(e) => error!("Error getting owner for '{}': {}", node_name, e), - } - } else { - error!("Usage: get-owner "); - } - } - "query-provider" => { - if let Some(provider_name) = command_arg { - info!("Querying DB for provider with name: '{}'", provider_name); - let query_string = "SELECT * FROM providers WHERE name = ?1;".to_string(); - let params = vec![serde_json::Value::String(provider_name.to_string())]; - match db.read(query_string, params) { - Ok(results) => { - if results.is_empty() { - info!("No provider found with name: '{}'", provider_name); - } else { - info!("Found provider(s) with name '{}':", provider_name); - for row in results { - // Pretty print the JSON representation of the row - match serde_json::to_string_pretty(&row) { - Ok(json_str) => info!("{}", json_str), - Err(e) => error!("Error serializing row to JSON: {:?}", e), - } - } - } - } - Err(e) => { - error!("Error querying provider by name '{}': {:?}", provider_name, e); - } - } - } else { - error!("Usage: query-provider "); - } - } - "list-providers" => { - info!("--- Listing All Providers in Database ---"); - info!("Current root_hash: {:?}", state.root_hash); - - match db::get_all(db) { - Ok(providers) => { - if providers.is_empty() { - warn!("No providers found in database!"); - info!("This could mean:"); - info!(" 1. The database was recently reset"); - info!(" 2. Chain sync hasn't found grid.hypr yet"); - info!(" 3. No providers have been minted under grid.hypr"); - } else { - info!("Found {} provider(s) in database:", providers.len()); - for (idx, provider) in providers.iter().enumerate() { - info!("\n=== Provider {} ===", idx + 1); - if let Some(name) = provider.get("name") { - info!("Name: {}", name); - } - if let Some(hash) = provider.get("hash") { - info!("Hash: {}", hash); - } - if let Some(provider_id) = provider.get("provider_id") { - info!("Provider ID: {}", provider_id); - } - if let Some(parent_hash) = provider.get("parent_hash") { - info!("Parent Hash: {}", parent_hash); - } - if let Some(price) = provider.get("price") { - info!("Price: {}", price); - } - if let Some(wallet) = provider.get("wallet") { - info!("Wallet: {}", wallet); - } - // Show first 100 chars of description if present - if let Some(desc) = provider.get("description") { - if let Some(desc_str) = desc.as_str() { - let truncated = if desc_str.len() > 100 { - format!("{}...", &desc_str[..100]) - } else { - desc_str.to_string() - }; - info!("Description: {}", truncated); - } - } - } - } - } - Err(e) => { - error!("Error listing all providers: {:?}", e); - } - } - info!("--- End Provider List ---"); - } - "search-providers" => { - if let Some(search_query) = command_arg { - info!("Searching providers for query: '{}'", search_query); - match db::search_provider(db, search_query.to_string()) { - Ok(results) => { - if results.is_empty() { - info!("No providers found matching: '{}'", search_query); - } else { - info!("Found {} provider(s) matching '{}':", results.len(), search_query); - for (idx, provider) in results.iter().enumerate() { - info!("\n=== Match {} ===", idx + 1); - match serde_json::to_string_pretty(&provider) { - Ok(json_str) => info!("{}", json_str), - Err(e) => error!("Error serializing provider: {:?}", e), - } - } - } - } - Err(e) => { - error!("Error searching providers: {:?}", e); - } - } - } else { - error!("Usage: search-providers "); - info!("Searches in: name, site, description, provider_id"); - } - } - "db-stats" => { - info!("--- Database Statistics ---"); - - // Check if root hash is set - match &state.root_hash { - Some(hash) => info!("Hypergrid root (grid.hypr) hash: {}", hash), - None => warn!("Hypergrid root (grid.hypr) NOT SET - this prevents provider indexing!"), - } - - // Count providers - let count_query = "SELECT COUNT(*) as count FROM providers".to_string(); - match db.read(count_query, vec![]) { - Ok(rows) => { - if let Some(count) = rows.get(0).and_then(|row| row.get("count")).and_then(|v| v.as_i64()) { - info!("Total providers in DB: {}", count); - } - } - Err(e) => error!("Error counting providers: {:?}", e), - } - - // Show last checkpoint block - info!("Last checkpoint block: {}", state.last_checkpoint_block); - - // Count providers by parent_hash to see distribution - let parent_count_query = r#" - SELECT parent_hash, COUNT(*) as count - FROM providers - GROUP BY parent_hash - ORDER BY count DESC - "#.to_string(); - - match db.read(parent_count_query, vec![]) { - Ok(rows) => { - if !rows.is_empty() { - info!("\nProvider distribution by parent:"); - for row in rows.iter().take(5) { // Show top 5 - if let (Some(parent), Some(count)) = - (row.get("parent_hash").and_then(|v| v.as_str()), - row.get("count").and_then(|v| v.as_i64())) { - let parent_display = if parent == state.root_hash.as_deref().unwrap_or("") { - format!("{} (grid.hypr)", parent) - } else { - parent.to_string() - }; - info!(" Parent {}: {} providers", parent_display, count); - } - } - } - } - Err(e) => error!("Error getting parent distribution: {:?}", e), - } - - // Show sample of recent providers - let recent_query = "SELECT name, provider_id, created FROM providers ORDER BY id DESC LIMIT 5".to_string(); - match db.read(recent_query, vec![]) { - Ok(rows) => { - if !rows.is_empty() { - info!("\nMost recent providers:"); - for row in rows { - if let (Some(name), Some(provider_id)) = - (row.get("name").and_then(|v| v.as_str()), - row.get("provider_id").and_then(|v| v.as_str())) { - info!(" - {} (ID: {})", name, provider_id); - } - } - } - } - Err(e) => error!("Error getting recent providers: {:?}", e), - } - - info!("--- End Database Statistics ---"); - } - "check-provider-id" => { - if let Some(provider_id) = command_arg { - info!("Checking for provider with provider_id: '{}'", provider_id); - - // First check by provider_id field - let query_by_id = "SELECT * FROM providers WHERE provider_id = ?1".to_string(); - let params = vec![serde_json::Value::String(provider_id.to_string())]; - - match db.read(query_by_id, params) { - Ok(results) => { - if results.is_empty() { - info!("No provider found with provider_id: '{}'", provider_id); - - // Try to find similar provider_ids - let similar_query = "SELECT provider_id, name FROM providers WHERE provider_id LIKE ?1 OR provider_id LIKE ?2".to_string(); - let similar_params = vec![ - serde_json::Value::String(format!("%{}%", provider_id)), - serde_json::Value::String(format!("{}%", provider_id)), - ]; - - match db.read(similar_query, similar_params) { - Ok(similar_results) => { - if !similar_results.is_empty() { - info!("\nSimilar provider_ids found:"); - for result in similar_results { - if let (Some(id), Some(name)) = - (result.get("provider_id").and_then(|v| v.as_str()), - result.get("name").and_then(|v| v.as_str())) { - info!(" - {} (name: {})", id, name); - } - } - } - } - Err(_) => {} - } - - // Also check if this might be a name instead - info!("\nChecking if '{}' might be a provider name instead...", provider_id); - let name_query = "SELECT * FROM providers WHERE name = ?1".to_string(); - let name_params = vec![serde_json::Value::String(provider_id.to_string())]; - - match db.read(name_query, name_params) { - Ok(name_results) => { - if !name_results.is_empty() { - info!("Found provider with NAME '{}' (not provider_id):", provider_id); - for result in name_results { - match serde_json::to_string_pretty(&result) { - Ok(json_str) => info!("{}", json_str), - Err(e) => error!("Error serializing: {:?}", e), - } - } - } - } - Err(_) => {} - } - - } else { - info!("Found provider with provider_id '{}':", provider_id); - for result in results { - match serde_json::to_string_pretty(&result) { - Ok(json_str) => info!("{}", json_str), - Err(e) => error!("Error serializing provider: {:?}", e), - } - } - } - } - Err(e) => { - error!("Error querying provider by provider_id '{}': {:?}", provider_id, e); - } - } - } else { - error!("Usage: check-provider-id "); - } - } - "check-grid-root" => { - info!("--- Checking grid.hypr entry status ---"); - - // Check current state - match &state.root_hash { - Some(hash) => { - info!("State root_hash is SET to: {}", hash); - } - None => { - warn!("State root_hash is NOT SET - provider indexing is disabled!"); - } - } - - // Check on-chain for grid.hypr - info!("\nChecking on-chain for grid.hypr..."); - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - match debug_get_tba_for_node("grid.hypr") { - Ok(result) => { - info!("On-chain lookup for grid.hypr: {}", result); - - // Calculate the expected hash - let expected_hash = hypermap::namehash("grid.hypr"); - info!("Expected hash for grid.hypr: {}", expected_hash); - - // Check if it matches state - if let Some(state_hash) = &state.root_hash { - if *state_hash == expected_hash { - info!("✓ State root_hash matches expected hash"); - } else { - error!("✗ State root_hash ({}) does NOT match expected hash ({})", state_hash, expected_hash); - } - } - } - Err(e) => { - error!("Failed to look up grid.hypr on-chain: {}", e); - } - } - - // Show hypr parent hash for reference - let hypr_hash = HYPR_HASH; - info!("\nFor reference:"); - info!(" hypr hash (parent of grid): {}", hypr_hash); - info!(" grid.hypr expected hash: {}", hypermap::namehash("grid.hypr")); - - // Check if any providers are waiting - let pending_query = "SELECT COUNT(*) as count FROM providers WHERE parent_hash != ?1".to_string(); - let params = vec![serde_json::Value::String(state.root_hash.clone().unwrap_or_default())]; - match db.read(pending_query, params) { - Ok(rows) => { - if let Some(count) = rows.get(0).and_then(|row| row.get("count")).and_then(|v| v.as_i64()) { - if count > 0 { - warn!("Found {} providers with different parent_hash - these may be waiting for correct root", count); - } - } - } - Err(_) => {} - } - - info!("--- End grid.hypr check ---"); - } - "addr-created" => { - if let Some(args) = command_arg { - let parts: Vec<&str> = args.split_whitespace().collect(); - if parts.is_empty() { - error!("Usage: addr-created
[from_block] [to_block]"); - return Ok(()); - } - let addr = match EthAddress::from_str(parts[0]) { - Ok(a) => a, - Err(e) => { error!("Invalid address: {}", e); return Ok(()); } - }; - let from_block = parts.get(1).and_then(|v| v.parse::().ok()); - let to_block = parts.get(2).and_then(|v| v.parse::().ok()); - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - - // Try AA UserOperationEvent (EntryPoint 0.8.0 on Base) - let entry_point = EthAddress::from_str("0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108").unwrap_or(EthAddress::ZERO); - let userop_sig = "UserOperationEvent(bytes32,address,address,uint256,bool,uint256,uint256)"; - let topic0: B256 = alloy_primitives::keccak256(userop_sig.as_bytes()).into(); - let mut aa_filter = eth::Filter::new().address(entry_point).topic0(vec![topic0]); - let mut padded = [0u8; 32]; - padded[12..].copy_from_slice(addr.as_slice()); - aa_filter = aa_filter.topic2(vec![B256::from(padded)]); - if let Some(fb) = from_block { aa_filter = aa_filter.from_block(fb); } - if let Some(tb) = to_block { aa_filter = aa_filter.to_block(tb); } - - // Respect provider range limits by chunking (<=500 blocks per query) - let (start, end) = match (from_block, to_block) { - (Some(s), Some(e)) if s <= e => (s, e), - _ => (from_block.unwrap_or(0), to_block.unwrap_or(from_block.unwrap_or(0))), - }; - let mut aa_found = false; - if start > 0 && end >= start { - let step: u64 = 450; - let mut cur = start; - while cur <= end { - let hi = end.min(cur + step); - let window = aa_filter.clone().from_block(cur).to_block(hi); - match provider.get_logs(&window) { - Ok(logs) if !logs.is_empty() => { - info!("AA evidence found ({} logs) in [{}, {}]", logs.len(), cur, hi); - aa_found = true; - break; - } - Ok(_) => {} - Err(e) => { warn!("AA log query failed in [{}, {}]: {:?}", cur, hi, e); } - } - if hi == end { break; } - cur = hi + 1; - } - } else { - // Single-shot if no valid range - match provider.get_logs(&aa_filter) { - Ok(logs) if !logs.is_empty() => { info!("AA evidence found ({} logs) for address.", logs.len()); aa_found = true; } - Ok(_) => {} - Err(e) => { warn!("AA log query failed: {:?}", e); } - } - } - if !aa_found { info!("No AA logs found in given range; trying USDC fallback..."); } - - // Fallback: USDC Transfer (from/to address) - let usdc = EthAddress::from_str(USDC_BASE_ADDRESS).unwrap_or(EthAddress::ZERO); - let transfer_sig = "Transfer(address,address,uint256)"; - let t0: B256 = alloy_primitives::keccak256(transfer_sig.as_bytes()).into(); - let mut from_f = eth::Filter::new().address(usdc).topic0(vec![t0]); - let mut to_f = eth::Filter::new().address(usdc).topic0(vec![t0]); - let mut pad = [0u8; 32]; pad[12..].copy_from_slice(addr.as_slice()); let topic_addr = B256::from(pad); - from_f = from_f.topic1(vec![topic_addr]); - to_f = to_f.topic2(vec![topic_addr]); - if let Some(fb) = from_block { from_f = from_f.from_block(fb); to_f = to_f.from_block(fb); } - if let Some(tb) = to_block { from_f = from_f.to_block(tb); to_f = to_f.to_block(tb); } - let (start_u, end_u) = match (from_block, to_block) { (Some(s), Some(e)) if s <= e => (s, e), _ => (from_block.unwrap_or(0), to_block.unwrap_or(from_block.unwrap_or(0))) }; - let step: u64 = 450; - let mut total = 0usize; - if start_u > 0 && end_u >= start_u { - let mut cur = start_u; - while cur <= end_u { - let hi = end_u.min(cur + step); - let wf = from_f.clone().from_block(cur).to_block(hi); - let wt = to_f.clone().from_block(cur).to_block(hi); - if let Ok(l) = provider.get_logs(&wf) { total += l.len(); } - if let Ok(l) = provider.get_logs(&wt) { total += l.len(); } - if hi == end_u { break; } - cur = hi + 1; - } - } else { - if let Ok(l) = provider.get_logs(&from_f) { total += l.len(); } - if let Ok(l) = provider.get_logs(&to_f) { total += l.len(); } - } - if total == 0 { - warn!("No USDC Transfer evidence in range."); - } else { - info!("USDC evidence found: {} log(s) in range.", total); - } - - } else { - error!("Usage: addr-created
[from_block] [to_block]"); - - } - } - "usdc-logs" => { - if let Some(args) = command_arg { - let parts: Vec<&str> = args.split_whitespace().collect(); - if parts.is_empty() { error!("Usage: usdc-logs
[from_block] [to_block] [limit]"); return Ok(()); } - let addr = match EthAddress::from_str(parts[0]) { Ok(a) => a, Err(e) => { error!("Invalid address: {}", e); return Ok(()); } }; - let from_block = parts.get(1).and_then(|v| v.parse::().ok()); - let to_block = parts.get(2).and_then(|v| v.parse::().ok()); - let limit = parts.get(3).and_then(|v| v.parse::().ok()).unwrap_or(50); - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - let usdc = EthAddress::from_str(USDC_BASE_ADDRESS).unwrap_or(EthAddress::ZERO); - let transfer_sig = "Transfer(address,address,uint256)"; - let t0: B256 = alloy_primitives::keccak256(transfer_sig.as_bytes()).into(); - - let mut pad = [0u8; 32]; pad[12..].copy_from_slice(addr.as_slice()); let topic_addr = B256::from(pad); - let mut from_f = eth::Filter::new().address(usdc).topic0(vec![t0]).topic1(vec![topic_addr]); - let mut to_f = eth::Filter::new().address(usdc).topic0(vec![t0]).topic2(vec![topic_addr]); - if let Some(fb) = from_block { from_f = from_f.from_block(fb); to_f = to_f.from_block(fb); } - if let Some(tb) = to_block { from_f = from_f.to_block(tb); to_f = to_f.to_block(tb); } - - let mut rows = 0usize; - let (start_u, end_u) = match (from_block, to_block) { (Some(s), Some(e)) if s <= e => (s, e), _ => (from_block.unwrap_or(0), to_block.unwrap_or(from_block.unwrap_or(0))) }; - let step: u64 = 450; - if start_u > 0 && end_u >= start_u { - let mut cur = start_u; - 'outer: while cur <= end_u { - let hi = end_u.min(cur + step); - let wf = from_f.clone().from_block(cur).to_block(hi); - let wt = to_f.clone().from_block(cur).to_block(hi); - if let Ok(logs) = provider.get_logs(&wf) { - for log in logs { - if rows >= limit { break 'outer; } - rows += 1; - let topics = log.topics(); - let amount = "?".to_string(); - let mut from_addr = [0u8;20]; from_addr.copy_from_slice(&topics[1].as_slice()[12..]); - let mut to_addr = [0u8;20]; to_addr.copy_from_slice(&topics[2].as_slice()[12..]); - info!("from=0x{} to=0x{} amount(units)={} (dir=OUT)", hex::encode(from_addr), hex::encode(to_addr), amount); - } - } - if rows < limit { - if let Ok(logs) = provider.get_logs(&wt) { - for log in logs { - if rows >= limit { break 'outer; } - rows += 1; - let topics = log.topics(); - let amount = "?".to_string(); - let mut from_addr = [0u8;20]; from_addr.copy_from_slice(&topics[1].as_slice()[12..]); - let mut to_addr = [0u8;20]; to_addr.copy_from_slice(&topics[2].as_slice()[12..]); - info!("from=0x{} to=0x{} amount(units)={} (dir=IN)", hex::encode(from_addr), hex::encode(to_addr), amount); - } - } - } - if hi == end_u { break; } - cur = hi + 1; - } - } else { - if let Ok(logs) = provider.get_logs(&from_f) { - for log in logs.into_iter().take(limit) { - rows += 1; - let topics = log.topics(); - let amount = "?".to_string(); - let mut from_addr = [0u8;20]; from_addr.copy_from_slice(&topics[1].as_slice()[12..]); - let mut to_addr = [0u8;20]; to_addr.copy_from_slice(&topics[2].as_slice()[12..]); - info!("from=0x{} to=0x{} amount(units)={} (dir=OUT)", hex::encode(from_addr), hex::encode(to_addr), amount); - } - } - if rows < limit { - if let Ok(logs) = provider.get_logs(&to_f) { - for log in logs.into_iter().take(limit - rows) { - let topics = log.topics(); - let amount = "?".to_string(); - let mut from_addr = [0u8;20]; from_addr.copy_from_slice(&topics[1].as_slice()[12..]); - let mut to_addr = [0u8;20]; to_addr.copy_from_slice(&topics[2].as_slice()[12..]); - info!("from=0x{} to=0x{} amount(units)={} (dir=IN)", hex::encode(from_addr), hex::encode(to_addr), amount); - } - } - } - } - if rows == 0 { info!("No USDC transfers found in range"); } - - } else { - error!("Usage: usdc-logs
[from_block] [to_block] [limit]"); - - } - } - "usdc-snapshot-hypermap" => { - // Deprecated: Basescan removed. Use hypermap-entry-info/hypermap-created instead. - error!("usdc-snapshot-hypermap is deprecated. Use 'hypermap-entry-info ' or 'hypermap-created '"); - } - "hypermap-entry-info" => { - if let Some(args) = command_arg { - let input = args.trim(); - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - let hyper = hypermap::Hypermap::new(provider.clone(), EthAddress::from_str(hypermap::HYPERMAP_ADDRESS).unwrap()); - - // Resolve to namehash - let namehash_hex = if input.starts_with("0x") && input.len() == 66 { - input.to_string() - } else if input.starts_with("0x") && input.len() == 42 { - let tba = match EthAddress::from_str(input) { Ok(a) => a, Err(e) => { error!("Invalid address: {}", e); return Ok(()); } }; - match hyper.get_namehash_from_tba(tba) { Ok(nh) => nh, Err(e) => { error!("Failed to get namehash from TBA: {:?}", e); return Ok(()); } } - } else { - hypermap::namehash(input) - }; - info!("Resolved entry namehash: {}", namehash_hex); - - // Build filters: Mint by childhash; Notes by parenthash - let mut nh_bytes = [0u8; 32]; - match hex::decode(namehash_hex.trim_start_matches("0x")) { - Ok(b) if b.len() == 32 => nh_bytes.copy_from_slice(&b), - _ => { error!("Bad namehash hex"); return Ok(()); } - } - let nh_b256 = B256::from(nh_bytes); - let mint_f = hyper.mint_filter().topic2(vec![nh_b256]); - let note_f = hyper.note_filter().topic1(vec![nh_b256]); - - // Bootstrap via local cacher - let from_block = Some(hypermap::HYPERMAP_FIRST_BLOCK); - let retry = Some((5, Some(5))); - let (last_block, results) = match hyper.bootstrap(from_block, vec![mint_f, note_f], retry, None) { - Ok(v) => v, - Err(e) => { error!("Hypermap bootstrap failed: {:?}", e); return Ok(()); } - }; - let mint_logs = results.get(0).cloned().unwrap_or_default(); - let note_logs = results.get(1).cloned().unwrap_or_default(); - - // Created block = earliest Mint - let created_block = mint_logs.iter().filter_map(|l| l.block_number).min(); - match created_block { - Some(cb) => { - let ts = provider.get_block_by_number(hyperware_process_lib::eth::BlockNumberOrTag::Number(cb), false) - .ok().flatten().map(|b| b.header.inner.timestamp).unwrap_or(0); - info!("Entry created at block {} (timestamp {}), last cached block {}", cb, ts, last_block); - } - None => warn!("No Mint logs found for this entry."), - } - - // List notes - if note_logs.is_empty() { - info!("No notes found for this entry."); - } else { - info!("Found {} notes for this entry:", note_logs.len()); - for lg in note_logs.iter().take(50) { - let topics = lg.topics(); - let label = if topics.len() > 2 { format!("0x{}", hex::encode(topics[2].as_slice())) } else { "(no label)".to_string() }; - let bn = lg.block_number.unwrap_or(0); - info!(" - block {} labelhash {} data_len {}", bn, label, lg.data().data.len()); - } - } - } else { - error!("Usage: hypermap-entry-info "); - } - } - "hypermap-created" => { - if let Some(args) = command_arg { - let input = args.trim(); - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - let hyper = hypermap::Hypermap::new(provider.clone(), EthAddress::from_str(hypermap::HYPERMAP_ADDRESS).unwrap()); - let namehash_hex = if input.starts_with("0x") && input.len() == 66 { - input.to_string() - } else if input.starts_with("0x") && input.len() == 42 { - let tba = match EthAddress::from_str(input) { Ok(a) => a, Err(e) => { error!("Invalid address: {}", e); return Ok(()); } }; - match hyper.get_namehash_from_tba(tba) { Ok(nh) => nh, Err(e) => { error!("Failed to get namehash from TBA: {:?}", e); return Ok(()); } } - } else { hypermap::namehash(input) }; - - let mut nh_bytes = [0u8; 32]; - match hex::decode(namehash_hex.trim_start_matches("0x")) { - Ok(b) if b.len() == 32 => nh_bytes.copy_from_slice(&b), - _ => { error!("Bad namehash hex"); return Ok(()); } - } - let nh_b256 = B256::from(nh_bytes); - let mint_f = hyper.mint_filter().topic2(vec![nh_b256]); - let (last_block, results) = match hyper.bootstrap(Some(hypermap::HYPERMAP_FIRST_BLOCK), vec![mint_f], Some((5, Some(5))), None) { - Ok(v) => v, - Err(e) => { error!("Hypermap bootstrap failed: {:?}", e); return Ok(()); } - }; - let mint_logs = results.get(0).cloned().unwrap_or_default(); - let created_block = mint_logs.iter().filter_map(|l| l.block_number).min(); - match created_block { - Some(cb) => { - let ts = provider.get_block_by_number(hyperware_process_lib::eth::BlockNumberOrTag::Number(cb), false) - .ok().flatten().map(|b| b.header.inner.timestamp).unwrap_or(0); - info!("Entry created at block {} (timestamp {}), last cached block {}", cb, ts, last_block); - } - None => warn!("No Mint logs found for this entry."), - } - } else { - error!("Usage: hypermap-created "); - } - } - "entry-usdc-index" => { - // Usage: entry-usdc-index [from_block] - if let Some(args) = command_arg { - let parts: Vec<&str> = args.split_whitespace().collect(); - if parts.is_empty() { error!("Usage: entry-usdc-index [from_block]"); return Ok(()); } - let input = parts[0].trim(); - let from_block_override = parts.get(1).and_then(|v| v.parse::().ok()); - - ensure_usdc_events_table(db)?; - - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - let hyper = hypermap::Hypermap::new(provider.clone(), EthAddress::from_str(hypermap::HYPERMAP_ADDRESS).unwrap()); - - // Resolve TBA and namehash - let (tba_addr, namehash_hex) = if input.starts_with("0x") && input.len() == 42 { - (EthAddress::from_str(input).map_err(|e| anyhow!("Invalid address: {}", e))?, { - // Try to get namehash from tba for logging - match hyper.get_namehash_from_tba(EthAddress::from_str(input).unwrap()) { Ok(nh) => nh, Err(_) => String::from("") } - }) - } else if input.starts_with("0x") && input.len() == 66 { - let nh = input.to_string(); - let (tba, _owner, _data) = match hyper.get_hash(&nh) { Ok(v) => v, Err(e) => { error!("Failed to get entry from namehash: {:?}", e); return Ok(()); } }; - (tba, nh) - } else { - let (tba, _owner, _data) = match hyper.get(input) { Ok(v) => v, Err(e) => { error!("Failed to get entry from name: {:?}", e); return Ok(()); } }; - (tba, hypermap::namehash(input)) - }; - let tba_str = format!("0x{}", hex::encode(tba_addr)); - info!("Indexing USDC for entry TBA {} (namehash {})", tba_str, namehash_hex); - - // Determine start block via Mint bootstrap unless overridden - let start_block = if let Some(fb) = from_block_override { fb } else { - let mut nh_bytes = [0u8; 32]; - match hex::decode(namehash_hex.trim_start_matches("0x")) { - Ok(b) if b.len() == 32 => nh_bytes.copy_from_slice(&b), - _ => {} - } - let nh_b256 = B256::from(nh_bytes); - let mint_f = hyper.mint_filter().topic2(vec![nh_b256]); - match hyper.bootstrap(Some(hypermap::HYPERMAP_FIRST_BLOCK), vec![mint_f], Some((5, Some(5))), None) { - Ok((_lb, results)) => { - let mints = results.get(0).cloned().unwrap_or_default(); - mints.iter().filter_map(|l| l.block_number).min().unwrap_or(hypermap::HYPERMAP_FIRST_BLOCK) - } - Err(_) => hypermap::HYPERMAP_FIRST_BLOCK, - } - }; - let latest = provider.get_block_number().unwrap_or(start_block); - info!("Scanning EntryPoint events from {} to {} (<=450/window)", start_block, latest); - - // EntryPoint UserOperationEvent filtered by sender - let entry_point = EthAddress::from_str("0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108").unwrap(); - let userop_sig = keccak256("UserOperationEvent(bytes32,address,address,uint256,bool,uint256,uint256)".as_bytes()); - let mut pad = [0u8; 32]; pad[12..].copy_from_slice(tba_addr.as_slice()); let topic_sender = B256::from(pad); - let base_filter = eth::Filter::new().address(entry_point).topic0(vec![userop_sig]).topic2(vec![topic_sender]); - - let step: u64 = 450; - let mut cur = start_block; - let mut total_receipts = 0usize; - let mut total_transfers = 0usize; - while cur <= latest { - let hi = latest.min(cur + step); - let window = base_filter.clone().from_block(cur).to_block(hi); - match provider.get_logs(&window) { - Ok(logs) => { - for lg in logs { - if let Some(txh) = lg.transaction_hash { - match provider.get_transaction_receipt(txh) { - Ok(Some(rcpt)) => { - total_receipts += 1; - for rlog in rcpt.inner.logs().iter() { - // Filter USDC Transfer logs in this tx pertaining to TBA - if format!("0x{}", hex::encode(rlog.address())) != USDC_BASE_ADDRESS { continue; } - let transfer_sig = keccak256("Transfer(address,address,uint256)".as_bytes()); - if rlog.topics().first().copied() != Some(transfer_sig.into()) { continue; } - if rlog.topics().len() < 3 { continue; } - let from_addr = &rlog.topics()[1].as_slice()[12..]; - let to_addr = &rlog.topics()[2].as_slice()[12..]; - let from_hex = format!("0x{}", hex::encode(from_addr)); - let to_hex = format!("0x{}", hex::encode(to_addr)); - if !from_hex.eq_ignore_ascii_case(&tba_str) && !to_hex.eq_ignore_ascii_case(&tba_str) { continue; } - let amount = U256::from_be_slice(rlog.data().data.as_ref()); - let blk = rcpt.block_number.unwrap_or(cur); - let log_index = rlog.log_index.map(|v| v.into()); - insert_usdc_event(db, &tba_str, blk, None, &format!("0x{}", hex::encode(txh)), log_index, &from_hex, &to_hex, &amount.to_string())?; - total_transfers += 1; - } - } - _ => {} - } - } - } - } - Err(e) => { warn!("getLogs error in window [{}, {}]: {:?}", cur, hi, e); } - } - if hi == latest { break; } - cur = hi + 1; - } - info!("Completed index for {}: {} receipts scanned, {} USDC transfers recorded.", tba_str, total_receipts, total_transfers); - } else { - error!("Usage: entry-usdc-index [from_block]"); - } - } - "usdc-scan-direct" => { - // Usage: usdc-scan-direct [from_block] - if let Some(args) = command_arg { - let parts: Vec<&str> = args.split_whitespace().collect(); - if parts.is_empty() { error!("Usage: usdc-scan-direct [from_block]"); return Ok(()); } - let input = parts[0].trim(); - let from_block_override = parts.get(1).and_then(|v| v.parse::().ok()); - - ensure_usdc_events_table(db)?; - - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - let hyper = hypermap::Hypermap::new(provider.clone(), EthAddress::from_str(hypermap::HYPERMAP_ADDRESS).unwrap()); - - // Resolve TBA - let tba_addr = if input.starts_with("0x") && input.len() == 42 { - EthAddress::from_str(input).map_err(|e| anyhow!("Invalid address: {}", e))? - } else if input.starts_with("0x") && input.len() == 66 { - let (tba, _owner, _data) = match hyper.get_hash(input) { Ok(v) => v, Err(e) => { error!("Failed to get entry from namehash: {:?}", e); return Ok(()); } }; - tba - } else { - let (tba, _owner, _data) = match hyper.get(input) { Ok(v) => v, Err(e) => { error!("Failed to get entry from name: {:?}", e); return Ok(()); } }; - tba - }; - let tba_str = format!("0x{}", hex::encode(tba_addr)); - - // Determine start block via Mint bootstrap unless overridden - let start_block = if let Some(fb) = from_block_override { fb } else { - let nh = match hyper.get_namehash_from_tba(tba_addr) { Ok(nh) => nh, Err(_) => String::new() }; - let mut nh_bytes = [0u8; 32]; - if let Ok(b) = hex::decode(nh.trim_start_matches("0x")) { if b.len() == 32 { nh_bytes.copy_from_slice(&b); } } - let nh_b256 = B256::from(nh_bytes); - let mint_f = hyper.mint_filter().topic2(vec![nh_b256]); - match hyper.bootstrap(Some(hypermap::HYPERMAP_FIRST_BLOCK), vec![mint_f], Some((5, Some(5))), None) { - Ok((_lb, results)) => { - let mints = results.get(0).cloned().unwrap_or_default(); - mints.iter().filter_map(|l| l.block_number).min().unwrap_or(hypermap::HYPERMAP_FIRST_BLOCK) - } - Err(_) => hypermap::HYPERMAP_FIRST_BLOCK, - } - }; - let latest = provider.get_block_number().unwrap_or(start_block); - info!("Direct USDC scan for {} from {} to {} (<=450/window)", tba_str, start_block, latest); - - // Build USDC Transfer filters - let usdc = EthAddress::from_str(USDC_BASE_ADDRESS).unwrap_or(EthAddress::ZERO); - let transfer_sig = keccak256("Transfer(address,address,uint256)".as_bytes()); - let mut pad = [0u8; 32]; pad[12..].copy_from_slice(tba_addr.as_slice()); let topic_addr = B256::from(pad); - let base_from = eth::Filter::new().address(usdc).topic0(vec![transfer_sig.into()]).topic1(vec![topic_addr]); - let base_to = eth::Filter::new().address(usdc).topic0(vec![transfer_sig.into()]).topic2(vec![topic_addr]); - - let step: u64 = 450; - let mut cur = start_block; - let mut rows = 0usize; - while cur <= latest { - let hi = latest.min(cur + step); - let wf = base_from.clone().from_block(cur).to_block(hi); - let wt = base_to.clone().from_block(cur).to_block(hi); - for flt in [&wf, &wt] { - match provider.get_logs(flt) { - Ok(logs) => { - for lg in logs { - let txh = match lg.transaction_hash { Some(h) => h, None => continue }; - if lg.topics().len() < 3 { continue; } - let from_addr = &lg.topics()[1].as_slice()[12..]; - let to_addr = &lg.topics()[2].as_slice()[12..]; - let from_hex = format!("0x{}", hex::encode(from_addr)); - let to_hex = format!("0x{}", hex::encode(to_addr)); - if !from_hex.eq_ignore_ascii_case(&tba_str) && !to_hex.eq_ignore_ascii_case(&tba_str) { continue; } - let amount = U256::from_be_slice(lg.data().data.as_ref()); - let blk = lg.block_number.unwrap_or(cur); - let log_index = lg.log_index.map(|v| v.into()); - insert_usdc_event(db, &tba_str, blk, None, &format!("0x{}", hex::encode(txh)), log_index, &from_hex, &to_hex, &amount.to_string())?; - rows += 1; - } - } - Err(e) => { warn!("getLogs error in window [{}, {}]: {:?}", cur, hi, e); } - } - } - if hi == latest { break; } - cur = hi + 1; - } - info!("Direct USDC scan complete for {}. Rows inserted: {}", tba_str, rows); - } else { - error!("Usage: usdc-scan-direct [from_block]"); - } - } - "usdc-scan-bisect" => { - // Usage: usdc-scan-bisect - if let Some(args) = command_arg { - let input = args.trim(); - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - let hyper = hypermap::Hypermap::new(provider.clone(), EthAddress::from_str(hypermap::HYPERMAP_ADDRESS).unwrap()); - - // Resolve TBA and creation block via hypermap - let (tba_addr, start_block) = { - let tba = if input.starts_with("0x") && input.len() == 42 { - EthAddress::from_str(input).map_err(|e| anyhow!("Invalid address: {}", e))? - } else if input.starts_with("0x") && input.len() == 66 { - let (t, _o, _d) = hyper.get_hash(input).map_err(|e| anyhow!("get_hash: {:?}", e))?; t - } else { let (t, _o, _d) = hyper.get(input).map_err(|e| anyhow!("get(name): {:?}", e))?; t }; - // creation via Mint - let nh = hyper.get_namehash_from_tba(tba).unwrap_or_default(); - let mut nh_bytes = [0u8; 32]; if let Ok(b) = hex::decode(nh.trim_start_matches("0x")) { if b.len()==32 { nh_bytes.copy_from_slice(&b); } } - let mint_f = hyper.mint_filter().topic2(vec![B256::from(nh_bytes)]); - let (_lb, res) = hyper.bootstrap(Some(hypermap::HYPERMAP_FIRST_BLOCK), vec![mint_f], Some((5, Some(5))), None).map_err(|e| anyhow!("bootstrap: {:?}", e))?; - let mints = res.get(0).cloned().unwrap_or_default(); - let created = mints.iter().filter_map(|l| l.block_number).min().unwrap_or(hypermap::HYPERMAP_FIRST_BLOCK); - (tba, created) - }; - let latest = provider.get_block_number().unwrap_or(start_block); - info!("Bisect USDC scan for {} from {} to {}", format!("0x{}", hex::encode(tba_addr)), start_block, latest); - - let usdc = EthAddress::from_str(USDC_BASE_ADDRESS).unwrap_or(EthAddress::ZERO); - let window_cap: u64 = 450; // switch to logs when ranges are small enough - - let mut cache: std::collections::HashMap = std::collections::HashMap::new(); - let mut get_bal = |blk: u64| -> anyhow::Result { - if let Some(v) = cache.get(&blk) { return Ok(*v); } - let v = erc20_balance_of_at(&provider, usdc, tba_addr, blk)?; - cache.insert(blk, v); - Ok(v) - }; - - let mut ranges: Vec<(u64,u64)> = Vec::new(); - if start_block < latest { - bisect_change_ranges(&provider, usdc, tba_addr, start_block, latest, window_cap, &mut get_bal, &mut ranges).ok(); - } - if ranges.is_empty() { - info!("No USDC balance changes detected across range. Nothing to fetch."); - return Ok(()); - } - info!("{} change windows to fetch logs in", ranges.len()); - - // For each small window, fetch both in/out logs and insert - let transfer_sig = keccak256("Transfer(address,address,uint256)".as_bytes()); - let mut pad = [0u8; 32]; pad[12..].copy_from_slice(tba_addr.as_slice()); let topic_addr = B256::from(pad); - let base_from = eth::Filter::new().address(usdc).topic0(vec![transfer_sig.into()]).topic1(vec![topic_addr]); - let base_to = eth::Filter::new().address(usdc).topic0(vec![transfer_sig.into()]).topic2(vec![topic_addr]); - - ensure_usdc_events_table(db)?; - let mut inserted = 0usize; - for (lo, hi) in ranges.into_iter() { - for flt in [base_from.clone().from_block(lo).to_block(hi), base_to.clone().from_block(lo).to_block(hi)] { - match provider.get_logs(&flt) { - Ok(logs) => { - for lg in logs { - let txh = match lg.transaction_hash { Some(h) => h, None => continue }; - if lg.topics().len() < 3 { continue; } - let from_addr = &lg.topics()[1].as_slice()[12..]; - let to_addr = &lg.topics()[2].as_slice()[12..]; - let from_hex = format!("0x{}", hex::encode(from_addr)); - let to_hex = format!("0x{}", hex::encode(to_addr)); - let amount = U256::from_be_slice(lg.data().data.as_ref()); - let blk = lg.block_number.unwrap_or(lo); - let log_index = lg.log_index.map(|v| v.into()); - insert_usdc_event(db, &format!("0x{}", hex::encode(tba_addr)), blk, None, &format!("0x{}", hex::encode(txh)), log_index, &from_hex, &to_hex, &amount.to_string())?; - inserted += 1; - } - } - Err(e) => warn!("getLogs error in bisect window [{}, {}]: {:?}", lo, hi, e), - } - } - } - info!("Bisect USDC scan complete. Rows inserted: {}", inserted); - } else { - error!("Usage: usdc-scan-bisect "); - } - } - "usdc-show" => { - // Usage: usdc-show [limit=50] - if let Some(args) = command_arg { - let parts: Vec<&str> = args.split_whitespace().collect(); - if parts.is_empty() { error!("Usage: usdc-show [limit=50]"); return Ok(()); } - let addr_norm = match EthAddress::from_str(parts[0]) { - Ok(a) => format!("0x{}", hex::encode(a)), - Err(e) => { error!("Invalid address: {}", e); return Ok(()); } - }; - let limit = parts.get(1).and_then(|v| v.parse::().ok()).unwrap_or(50); - - ensure_usdc_events_table(db)?; - let q = r#" - SELECT block, time, tx_hash, log_index, from_addr, to_addr, value_units - FROM usdc_events - WHERE address = ?1 - ORDER BY block DESC, COALESCE(log_index, 0) DESC - LIMIT ?2 - "#.to_string(); - let params = vec![ - serde_json::Value::String(addr_norm.clone()), - serde_json::Value::Number(serde_json::Number::from(limit)), - ]; - match db.read(q, params) { - Ok(rows) => { - if rows.is_empty() { - info!("No USDC events for {}", addr_norm); - } else { - info!("USDC events for {} (showing {}):", addr_norm, rows.len()); - for row in rows { - let blk = row.get("block").and_then(|v| v.as_i64()).unwrap_or(0); - let ts = row.get("time").and_then(|v| v.as_i64()).unwrap_or(0); - let tx = row.get("tx_hash").and_then(|v| v.as_str()).unwrap_or(""); - let li = row.get("log_index").and_then(|v| v.as_i64()).unwrap_or(0); - let fa = row.get("from_addr").and_then(|v| v.as_str()).unwrap_or(""); - let ta = row.get("to_addr").and_then(|v| v.as_str()).unwrap_or(""); - let vu = row.get("value_units").and_then(|v| v.as_str()).unwrap_or(""); - info!("block={} ts={} tx={} log_index={} from={} to={} value(units)={} ", blk, ts, tx, li, fa, ta, vu); - } - } - } - Err(e) => error!("DB read error for usdc_show: {:?}", e), - } - } else { - error!("Usage: usdc-show [limit=50]"); - } - } - "ledger-build" => { - // Usage: usdc-ledger-build - if let Some(args) = command_arg { - let parts: Vec<&str> = args.split_whitespace().collect(); - if parts.is_empty() { error!("Usage: usdc-ledger-build "); return Ok(()); } - let tba = parts[0].to_lowercase(); - ledger::ensure_usdc_events_table(db)?; - ledger::ensure_usdc_call_ledger_table(db)?; - let n = ledger::build_usdc_ledger_for_tba(state, db, &tba)?; - info!("ledger-build complete for {} ({} rows)", tba, n); - } else { error!("Usage: usdc-ledger-build "); } - } - "ledger-show" => { - // Usage: usdc-ledger-show [limit=20] - if let Some(args) = command_arg { - let parts: Vec<&str> = args.split_whitespace().collect(); - if parts.is_empty() { error!("Usage: usdc-ledger-show [limit=20]"); return Ok(()); } - let tba = parts[0].to_lowercase(); - let limit = parts.get(1).and_then(|v| v.parse::().ok()).unwrap_or(20); - ledger::show_ledger(db, &tba, limit)?; - } else { error!("Usage: usdc-ledger-show [limit=20]"); } - } - "ledger-clients" => { - // Usage: ledger-clients [tba] - // Refresh totals from ledger and print client_id -> spent/limit mapping - let tba = if let Some(arg) = command_arg { - arg.trim().to_lowercase() - } else if let Some(t) = state.operator_tba_address.clone() { t.to_lowercase() } else { - error!("No TBA provided and operator_tba_address not set"); - return Ok(()); - }; - - if let Err(e) = state.refresh_client_totals_from_ledger(db, &tba) { - error!("Failed to refresh totals from ledger: {:?}", e); - } - - info!("Client spend mapping for {}:", tba); - // Build list of client ids to display (union of caches and authorized_clients) - let mut ids: Vec = Vec::new(); - for k in state.authorized_clients.keys() { if !ids.iter().any(|x| x == k) { ids.push(k.clone()); } } - for k in state.client_limits_cache.keys() { if !ids.iter().any(|x| x == k) { ids.push(k.clone()); } } - if ids.is_empty() { info!("(no clients)"); return Ok(()); } - - for cid in ids { - let name = state.authorized_clients.get(&cid).map(|c| c.name.clone()).unwrap_or_default(); - let entry = state.client_limits_cache.get(&cid); - let spent_str = entry.and_then(|e| e.total_spent.clone()).unwrap_or_else(|| "0.000000".to_string()); - let limit_str = entry.and_then(|e| e.max_total.clone()); - let currency = entry.and_then(|e| e.currency.clone()).unwrap_or_else(|| "USDC".to_string()); - - let spent_val = spent_str.parse::().unwrap_or(0.0); - let limit_val = limit_str.as_deref().and_then(|s| s.parse::().ok()); - let pct = limit_val.map(|lv| if lv > 0.0 { (spent_val / lv) * 100.0 } else { 0.0 }); - - if let Some(lv) = limit_val { - info!( - "client={} name={} spent=${:.6} / ${:.3} ({:.2}%) {}", - cid, name, spent_val, lv, pct.unwrap_or(0.0), currency - ); - } else { - info!( - "client={} name={} spent=${:.6} {} (no limit)", - cid, name, spent_val, currency - ); - } - } - } - "usdc-history" => { - // Usage: usdc-history [limit=200] - if let Some(args) = command_arg { - let parts: Vec<&str> = args.split_whitespace().collect(); - if parts.is_empty() { error!("Usage: usdc-history [limit=200]"); return Ok(()); } - let addr_norm = match EthAddress::from_str(parts[0]) { - Ok(a) => format!("0x{}", hex::encode(a)), - Err(e) => { error!("Invalid address: {}", e); return Ok(()); } - }; - let limit = parts.get(1).and_then(|v| v.parse::().ok()).unwrap_or(200); - - ensure_usdc_events_table(db)?; - - // Fill missing timestamps for this address (cheap cache) - let provider = eth::Provider::new(structs::CHAIN_ID, 30000); - let q_missing = r#" - SELECT DISTINCT block FROM usdc_events - WHERE address = ?1 AND time IS NULL - ORDER BY block ASC - LIMIT 200 - "#.to_string(); - if let Ok(rows) = db.read(q_missing.clone(), vec![serde_json::Value::String(addr_norm.clone())]) { - for r in rows { - if let Some(bn) = r.get("block").and_then(|v| v.as_i64()).map(|v| v as u64) { - if let Ok(Some(b)) = provider.get_block_by_number(hyperware_process_lib::eth::BlockNumberOrTag::Number(bn), false) { - let ts = b.header.inner.timestamp; - let upd = "UPDATE usdc_events SET time = ?1 WHERE address = ?2 AND block = ?3 AND time IS NULL".to_string(); - let p = vec![serde_json::Value::Number(ts.into()), serde_json::Value::String(addr_norm.clone()), serde_json::Value::Number((bn as i64).into())]; - let _ = db.write(upd, p, None); - } - } - } - } - - // Now read ordered ascending to compute running balance - let q = r#" - SELECT block, time, tx_hash, log_index, from_addr, to_addr, value_units - FROM usdc_events - WHERE address = ?1 - ORDER BY block ASC, COALESCE(log_index, 0) ASC - LIMIT ?2 - "#.to_string(); - let params = vec![serde_json::Value::String(addr_norm.clone()), serde_json::Value::Number(serde_json::Number::from(limit))]; - match db.read(q, params) { - Ok(rows) => { - if rows.is_empty() { info!("No USDC events for {}", addr_norm); return Ok(()); } - let mut balance = U256::from(0); - let decimals = U256::from(1_000_000u64); - info!("USDC history for {} ({} events):", addr_norm, rows.len()); - for row in rows { - let blk = row.get("block").and_then(|v| v.as_i64()).unwrap_or(0) as u64; - let ts = row.get("time").and_then(|v| v.as_i64()).unwrap_or(0); - let tx = row.get("tx_hash").and_then(|v| v.as_str()).unwrap_or(""); - let li = row.get("log_index").and_then(|v| v.as_i64()).unwrap_or(0); - let fa = row.get("from_addr").and_then(|v| v.as_str()).unwrap_or(""); - let ta = row.get("to_addr").and_then(|v| v.as_str()).unwrap_or(""); - let vu = row.get("value_units").and_then(|v| v.as_str()).unwrap_or("0"); - let amt = U256::from_str_radix(vu, 10).unwrap_or(U256::from(0)); - let incoming = ta.eq_ignore_ascii_case(&addr_norm); - if incoming { balance = balance.saturating_add(amt); } else { balance = balance.saturating_sub(amt); } - // format amount and balance with 6 decimals - let amt_whole = amt / decimals; let amt_frac = (amt % decimals).to::(); - let bal_whole = balance / decimals; let bal_frac = (balance % decimals).to::(); - let dir = if incoming { "IN" } else { "OUT" }; - let counterparty = if incoming { fa } else { ta }; - info!( - "blk={} ts={} tx={} idx={} dir={} cp={} amt={}{}.{} bal={}{}.{}", - blk, - ts, - tx, - li, - dir, - counterparty, - if incoming { "+" } else { "-" }, - amt_whole.to_string(), - format!("{:06}", amt_frac), - bal_whole.to_string(), - ".", - format!("{:06}", bal_frac), - ); - } - } - Err(e) => error!("DB read error for usdc-history: {:?}", e), - } - } else { - error!("Usage: usdc-history [limit=200]"); - } - } - "get-receipt" => { - if let Some(user_op_hash) = command_arg { - info!("--- Manual UserOperation Receipt Lookup ---"); - - let bundler_url = "https://api.candide.dev/public/v3/8453"; - - match get_user_op_receipt_manual(user_op_hash, bundler_url) { - Ok(receipt_data) => { - info!("Receipt data received:"); - info!("{}", serde_json::to_string_pretty(&receipt_data).unwrap_or_else(|_| format!("{:?}", receipt_data))); - - // Try to extract transaction hash - if let Some(receipt) = receipt_data.get("receipt") { - if let Some(tx_hash) = receipt.get("transactionHash").and_then(|h| h.as_str()) { - info!("✅ Transaction hash found: {}", tx_hash); - } else { - info!("❌ No transactionHash found in receipt"); - } - } else if let Some(tx_hash) = receipt_data.get("transactionHash").and_then(|h| h.as_str()) { - info!("✅ Transaction hash found at root level: {}", tx_hash); - } else { - info!("❌ No 'receipt' field or transactionHash found in response"); - info!("Available fields: {:?}", receipt_data.as_object().map(|obj| obj.keys().collect::>())); - } - } - Err(e) => { - error!("❌ Failed to get receipt: {}", e); - } - } - - info!("--- End Receipt Lookup ---"); - } else { - error!("Usage: get-receipt "); - info!("Example: get-receipt 0x25ca82108f7d91d18666ad8bbba48bcb7edd8432c0fb3492de7b1b20b9c2b51b"); - } - } - _ => info!("Unknown command: '{}'. Type 'help' for available commands.", command_verb), - } - Ok(()) } \ No newline at end of file diff --git a/operator/operator/src/http_handlers.rs b/operator/operator/src/http_handlers.rs deleted file mode 100644 index f698afa..0000000 --- a/operator/operator/src/http_handlers.rs +++ /dev/null @@ -1,2643 +0,0 @@ -use std::collections::HashMap; - -use crate::constants::PUBLISHER; -use chrono::Utc; -use hyperware_process_lib::{ - http::{ - server::{send_response, HttpServerRequest, IncomingHttpRequest}, - Method, Response as HttpResponse, StatusCode, - }, - last_blob, - logging::{error, info, warn}, - signer::Signer, - sqlite::Sqlite, - vfs, Address, Request as ProcessRequest, -}; -use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; -use uuid::Uuid; - -use crate::{ - authorized_services::{HotWalletAuthorizedClient, ServiceCapabilities}, - db as dbm, - graph::handle_get_hypergrid_graph_layout, - helpers::send_json_response, - hyperwallet_client::{payments as hyperwallet_payments, service as hyperwallet_service}, - structs::{ - ApiRequest, ConfigureAuthorizedClientRequest, ConfigureAuthorizedClientResponse, - McpRequest, *, - }, -}; - -// =========================================================================================== -// TYPE DEFINITIONS - Domain models for HTTP handling -// =========================================================================================== - -/// Details about a provider fetched from the database -pub struct ProviderDetails { - wallet_address: String, - price_str: String, - provider_id: String, -} - -/// Result of attempting to fetch provider details -enum FetchProviderResult { - Success(ProviderDetails), - NotFound(String), -} - -/// Result of attempting a payment -enum PaymentResult { - NotRequired, - Success(String), // tx hash - Failed(PaymentAttemptResult), -} - -/// Result of calling a provider -enum ProviderCallResult { - Success(Vec), - Failed(anyhow::Error), -} - -const SPIDER_PROCESS_ID: (&str, &str, &str) = ("spider", "spider", "sys"); - -// =========================================================================================== -// MAIN ENTRY POINT - Routes all HTTP requests from http-server -// =========================================================================================== - -/// Main HTTP request handler - receives all requests from http-server:distro:sys -/// Deserializes the request and routes to appropriate handler based on path/method -pub fn handle_frontend( - our: &Address, - body: &[u8], - state: &mut State, - db: &Sqlite, -) -> anyhow::Result<()> { - info!("handle_frontend received request"); - - let server_request = deserialize_request(body)?; - let HttpServerRequest::Http(req) = server_request else { - info!("Ignoring non-HTTP ServerRequest"); - return Ok(()); - }; - - route_http_request(our, &req, state, db) -} - -// =========================================================================================== -// REQUEST ROUTING - Maps HTTP paths to handler functions -// =========================================================================================== - -fn deserialize_request(body: &[u8]) -> anyhow::Result { - serde_json::from_slice(body).map_err(|e| { - error!("Failed to deserialize HttpServerRequest: {}", e); - send_response( - StatusCode::BAD_REQUEST, - None, - b"Invalid request format".to_vec(), - ); - anyhow::anyhow!("Deserialization failed: {}", e) - }) -} - -fn route_http_request( - our: &Address, - req: &IncomingHttpRequest, - state: &mut State, - db: &Sqlite, -) -> anyhow::Result<()> { - let method = req.method()?; - let path = req.path()?; - - info!("Processing HTTP request: {} {}", method, path); - - match (method.clone(), path.as_str()) { - // Shim authentication endpoints - (Method::POST, "/api/authorize-shim") => handle_authorize_shim_route(our, req, state, db), - (Method::POST, "/api/configure-authorized-client") => { - handle_configure_client_route(our, req, state, db) - } - - // MCP endpoints (actual Model Context Provider operations) - (Method::POST, "/api/mcp") => handle_mcp_route(our, state, db, None), - (Method::POST, "/shim/mcp") => handle_shim_mcp_route(our, req, state, db), - - // Regular API endpoints (wallet, history, etc) - (Method::POST, "/api/actions") => handle_api_actions_route(our, state, db), - - // Spider integration endpoints - (Method::POST, "/api/spider-connect") => handle_spider_connect(our, state), - (Method::POST, "/api/spider-chat") => handle_spider_chat(our, state), - (Method::GET, "/api/spider-status") => handle_spider_status(state), - (Method::POST, "/api/spider-mcp-servers") => handle_spider_mcp_servers(our), - - // GET endpoints - (Method::GET, path) => handle_get(our, path, req.query_params(), state, db), - - // Unhandled routes - _ => { - warn!("Unhandled route: {:?} {:?}", method, path); - send_response(StatusCode::NOT_FOUND, None, b"Not Found".to_vec()); - Ok(()) - } - } -} - -// =========================================================================================== -// ROUTE HANDLERS - Individual endpoint implementations -// =========================================================================================== - -fn handle_authorize_shim_route( - our: &Address, - req: &IncomingHttpRequest, - state: &mut State, - db: &Sqlite, -) -> anyhow::Result<()> { - info!("Routing to handle_authorize_shim_request"); - match handle_authorize_shim_request(our, req, state, db) { - Ok(response) => { - let mut headers = HashMap::new(); - - if let Some(content_type) = response.headers().get("Content-Type") { - if let Ok(ct_str) = content_type.to_str() { - headers.insert("Content-Type".to_string(), ct_str.to_string()); - } - } - send_response(response.status(), Some(headers), response.body().clone()); - Ok(()) - } - Err(e) => { - error!("Error in handle_authorize_shim_request: {:?}", e); - send_json_response( - StatusCode::INTERNAL_SERVER_ERROR, - &json!({ "error": format!("Internal Server Error: {}", e) }), - ) - } - } -} - -fn handle_configure_client_route( - our: &Address, - req: &IncomingHttpRequest, - state: &mut State, - db: &Sqlite, -) -> anyhow::Result<()> { - info!("Routing to handle_configure_authorized_client"); - match handle_configure_authorized_client(our, req, state, db) { - Ok(response) => { - let mut headers = HashMap::new(); - - if let Some(content_type) = response.headers().get("Content-Type") { - if let Ok(ct_str) = content_type.to_str() { - headers.insert("Content-Type".to_string(), ct_str.to_string()); - } - } - send_response(response.status(), Some(headers), response.body().clone()); - Ok(()) - } - Err(e) => { - error!("Error in handle_configure_authorized_client: {:?}", e); - send_json_response( - StatusCode::INTERNAL_SERVER_ERROR, - &json!({ "error": format!("Internal Server Error: {}", e) }), - ) - } - } -} - -fn handle_mcp_route( - our: &Address, - state: &mut State, - db: &Sqlite, - client_config: Option, -) -> anyhow::Result<()> { - info!("Routing to handle_post (for UI MCP)"); - handle_post(our, state, db, client_config) -} - -fn handle_shim_mcp_route( - our: &Address, - req: &IncomingHttpRequest, - state: &mut State, - db: &Sqlite, -) -> anyhow::Result<()> { - info!("Routing to handle_post (for Shim MCP) - Performing new Client Auth..."); - - // Extract authentication headers - let client_id = req - .headers() - .get("X-Client-ID") - .and_then(|v| v.to_str().ok()) - .map(String::from); - - let token = req - .headers() - .get("X-Token") - .and_then(|v| v.to_str().ok()) - .map(String::from); - - let client_name = req - .headers() - .get("X-Client-Name") - .and_then(|v| v.to_str().ok()) - .map(String::from); - - // Authenticate shim client - match authenticate_shim_with_headers(state, client_id.clone(), token) { - Ok(client_config) => { - info!( - "Shim Client Auth: Validated successfully for Client ID: {}. Associated Hot Wallet: {}", - client_config.id, - client_config.associated_hot_wallet_address - ); - - // Update client name if provided and different from current - if let Some(new_name) = client_name { - if let Some(client_id_str) = &client_id { - if let Some(mut_client) = state.authorized_clients.get_mut(client_id_str) { - if mut_client.name != new_name { - info!( - "Updating client name from {} to {}", - mut_client.name, new_name - ); - mut_client.name = new_name; - state.save(); - } - } - } - } - - // Get fresh reference after potential mutation - let updated_client_config = state - .authorized_clients - .get(client_id.as_ref().unwrap()) - .cloned() - .unwrap(); - - handle_post(our, state, db, Some(updated_client_config)) - } - Err(auth_error) => handle_shim_auth_error(auth_error), - } -} - -fn handle_api_actions_route(_our: &Address, state: &mut State, _db: &Sqlite) -> anyhow::Result<()> { - info!("Routing to handle_api_actions (for UI API operations)"); - handle_api_actions(state) -} - -fn authenticate_shim_with_headers( - state: &State, - client_id: Option, - token: Option, -) -> Result<&HotWalletAuthorizedClient, AuthError> { - let id = client_id.ok_or(AuthError::MissingClientId)?; - let tok = token.ok_or(AuthError::MissingToken)?; - authenticate_shim_client(state, &id, &tok) -} - -fn handle_shim_auth_error(auth_error: AuthError) -> anyhow::Result<()> { - let (status, message) = match auth_error { - AuthError::MissingClientId => (StatusCode::UNAUTHORIZED, "Missing X-Client-ID header"), - AuthError::MissingToken => (StatusCode::UNAUTHORIZED, "Missing X-Token header"), - AuthError::ClientNotFound => (StatusCode::UNAUTHORIZED, "Client ID not found"), - AuthError::InvalidToken => (StatusCode::FORBIDDEN, "Invalid token"), - AuthError::InsufficientCapabilities => { - (StatusCode::FORBIDDEN, "Client lacks necessary capabilities") - } - }; - - send_json_response(status, &json!({ "error": message })) -} - -// =========================================================================================== -// MCP REQUEST HANDLING - Core business logic dispatcher -// =========================================================================================== - -/// Main MCP request dispatcher - routes Model Context Provider operations -/// Only handles SearchRegistry and CallProvider - the actual MCP operations -fn handle_mcp( - our: &Address, - req: McpRequest, - state: &mut State, - db: &Sqlite, - client_config_opt: Option, -) -> anyhow::Result<()> { - info!("MCP request: {:?}", req); - match req { - // Registry operations - McpRequest::SearchRegistry(query) => handle_search_registry(db, query), - - // Provider operations - McpRequest::CallProvider { - provider_id, - provider_name, - arguments, - } => handle_provider_call_request( - our, - state, - db, - provider_id, - provider_name, - arguments, - client_config_opt, - ), - } -} - -/// API request dispatcher - routes regular frontend application operations -/// Handles wallet management, history, withdrawals, etc. -fn handle_api_actions(state: &mut State) -> anyhow::Result<()> { - let blob = last_blob().ok_or(anyhow::anyhow!("Request body is missing for API request"))?; - - match serde_json::from_slice::(blob.bytes()) { - Ok(req) => { - info!("API request: {:?}", req); - match req { - ApiRequest::GetCallHistory {} => handle_get_call_history(state), - ApiRequest::GetActiveAccountDetails {} => handle_get_active_account_details(state), - ApiRequest::GetWalletSummaryList {} => handle_get_wallet_summary_list(state), - ApiRequest::SelectWallet { wallet_id } => handle_select_wallet(state, wallet_id), - ApiRequest::RenameWallet { - wallet_id, - new_name, - } => handle_rename_wallet(state, wallet_id, new_name), - ApiRequest::DeleteWallet { wallet_id } => handle_delete_wallet(state, wallet_id), - ApiRequest::GenerateWallet {} => handle_generate_wallet(state), - ApiRequest::ImportWallet { - private_key, - password, - name, - } => handle_import_wallet(state, private_key, password, name), - ApiRequest::ActivateWallet { password } => handle_activate_wallet(state, password), - ApiRequest::DeactivateWallet {} => handle_deactivate_wallet(state), - ApiRequest::SetWalletLimits { limits } => handle_set_wallet_limits(state, limits), - ApiRequest::SetClientLimits { client_id, limits } => { - handle_set_client_limits(state, client_id, limits) - } - ApiRequest::ExportSelectedPrivateKey { password } => { - handle_export_private_key(state, password) - } - ApiRequest::SetSelectedWalletPassword { - new_password, - old_password, - } => handle_set_wallet_password(state, new_password, old_password), - ApiRequest::RemoveSelectedWalletPassword { current_password } => { - handle_remove_wallet_password(state, current_password) - } - - // Withdrawal from ui - ApiRequest::WithdrawEthFromOperatorTba { - to_address, - amount_wei_str, - } => handle_withdraw_eth(state, to_address, amount_wei_str), - ApiRequest::WithdrawUsdcFromOperatorTba { - to_address, - amount_usdc_units_str, - } => handle_withdraw_usdc(state, to_address, amount_usdc_units_str), - - // Authorized client management - ApiRequest::RenameAuthorizedClient { - client_id, - new_name, - } => handle_rename_authorized_client(state, client_id, new_name), - ApiRequest::DeleteAuthorizedClient { client_id } => { - handle_delete_authorized_client(state, client_id) - } - - // ERC-4337 configuration - ApiRequest::SetGaslessEnabled { enabled } => { - handle_set_gasless_enabled(state, enabled) - } - } - } - Err(e) if e.is_syntax() || e.is_data() => { - error!("Failed to deserialize API request JSON: {}", e); - send_json_response( - StatusCode::BAD_REQUEST, - &json!({ "error": format!("Invalid API request body: {}", e) }), - )?; - Ok(()) - } - Err(e) => { - error!("Unexpected error reading API request blob: {}", e); - Err(anyhow::anyhow!("Error reading API request body: {}", e)) - } - } -} - -// DEPRECATED: This function handles the old combined HttpMcpRequest format -// It remains for backwards compatibility but should be phased out -fn handle_legacy_mcp( - our: &Address, - req: HttpMcpRequest, - state: &mut State, - db: &Sqlite, - client_config_opt: Option, -) -> anyhow::Result<()> { - info!("mcp request: {:?}", req); - match req { - // Registry operations - HttpMcpRequest::SearchRegistry(query) => handle_search_registry(db, query), - - // Provider operations - HttpMcpRequest::CallProvider { - provider_id, - provider_name, - arguments, - } => handle_provider_call_request( - our, - state, - db, - provider_id, - provider_name, - arguments, - client_config_opt, - ), - - // History operations - HttpMcpRequest::GetCallHistory {} => handle_get_call_history(state), - - // Wallet operations - grouped by function - HttpMcpRequest::GetWalletSummaryList {} => handle_get_wallet_summary_list(state), - HttpMcpRequest::SelectWallet { wallet_id } => handle_select_wallet(state, wallet_id), - HttpMcpRequest::RenameWallet { - wallet_id, - new_name, - } => handle_rename_wallet(state, wallet_id, new_name), - HttpMcpRequest::DeleteWallet { wallet_id } => handle_delete_wallet(state, wallet_id), - HttpMcpRequest::GenerateWallet {} => handle_generate_wallet(state), - HttpMcpRequest::ImportWallet { - private_key, - password, - name, - } => handle_import_wallet(state, private_key, password, name), - HttpMcpRequest::ActivateWallet { password } => handle_activate_wallet(state, password), - HttpMcpRequest::DeactivateWallet {} => handle_deactivate_wallet(state), - HttpMcpRequest::SetWalletLimits { limits } => handle_set_wallet_limits(state, limits), - HttpMcpRequest::ExportSelectedPrivateKey { password } => { - handle_export_private_key(state, password) - } - HttpMcpRequest::SetSelectedWalletPassword { - new_password, - old_password, - } => handle_set_wallet_password(state, new_password, old_password), - HttpMcpRequest::RemoveSelectedWalletPassword { current_password } => { - handle_remove_wallet_password(state, current_password) - } - HttpMcpRequest::GetActiveAccountDetails {} => handle_get_active_account_details(state), - - // Withdrawal operations - HttpMcpRequest::WithdrawEthFromOperatorTba { - to_address, - amount_wei_str, - } => handle_withdraw_eth(state, to_address, amount_wei_str), - HttpMcpRequest::WithdrawUsdcFromOperatorTba { - to_address, - amount_usdc_units_str, - } => handle_withdraw_usdc(state, to_address, amount_usdc_units_str), - } -} - -// =========================================================================================== -// GET REQUEST HANDLING - REST API endpoints -// =========================================================================================== - -/// Routes GET requests to appropriate handlers based on path -fn handle_get( - our: &Address, - path_str: &str, - params: &HashMap, - state: &mut State, - db: &Sqlite, -) -> anyhow::Result<()> { - info!("GET {} with params: {:?}", path_str, params); - - match path_str { - // Status endpoints - "/api/setup-status" | "setup-status" => handle_get_setup_status(state), - - // Graph visualization - "/api/hypergrid-graph" | "/hypergrid-graph" => { - handle_get_hypergrid_graph_layout(our, state) - } - - // State inspection (debug) - "/api/state" | "state" => handle_get_state(state), - - // Provider registry queries - "/api/all" | "all" => handle_get_all_providers(db), - "/api/search" | "search" => handle_search_providers(db, params), - - // Wallet endpoints - "/api/managed-wallets" => handle_get_managed_wallets(state), - "/api/linked-wallets" => handle_get_linked_wallets(state), - - // Unknown endpoint - _ => { - warn!("Unknown GET endpoint: {}", path_str); - send_json_response( - StatusCode::NOT_FOUND, - &json!({ "error": "API endpoint not found" }), - ) - } - } -} - -// --- GET Handler Functions --- - -fn handle_get_setup_status(state: &State) -> anyhow::Result<()> { - let is_configured = state.operator_tba_address.is_some(); - info!("Setup status check: configured={}", is_configured); - send_json_response(StatusCode::OK, &json!({ "configured": is_configured })) -} - -fn handle_get_state(state: &State) -> anyhow::Result<()> { - info!("Returning full application state (enriched)"); - // Start with a JSON view of state - let out = match serde_json::to_value(state) { - Ok(v) => v, - Err(_) => json!(state), - }; - - // Try to enrich call_history with ledger totals, same as in handle_get_call_history - if let Some(db) = &state.db_conn { - let mut rows = state.call_history.clone(); - for rec in &mut rows { - let tx_opt = match &rec.payment_result { - Some(crate::structs::PaymentAttemptResult::Success { tx_hash, .. }) => { - Some(tx_hash.clone()) - } - _ => None, - }; - if let Some(tx) = tx_opt { - let q = r#"SELECT total_cost_units FROM usdc_call_ledger WHERE lower(tx_hash) = lower(?1) LIMIT 1"#.to_string(); - if let Ok(rs) = db.read(q, vec![serde_json::Value::String(tx.clone())]) { - if let Some(row) = rs.get(0) { - if let Some(units_str) = - row.get("total_cost_units").and_then(|v| v.as_str()) - { - if let Ok(units_i) = units_str.parse::() { - let whole = units_i / 1_000_000; - let frac = (units_i % 1_000_000).abs(); - let formatted = format!("{}.{}", whole, format!("{:06}", frac)); - // attach helper blob - let mut extra = serde_json::json!({}); - if let Some(existing) = &rec.response_json { - if let Ok(e) = - serde_json::from_str::(existing) - { - if e.is_object() { - extra = e; - } - } - } - extra["total_cost_usdc"] = serde_json::Value::String(formatted); - rec.response_json = Some(extra.to_string()); - if let Some(crate::structs::PaymentAttemptResult::Success { - amount_paid, - .. - }) = &mut rec.payment_result - { - *amount_paid = - format!("{}", whole as f64 + (frac as f64) / 1_000_000.0); - } - } - } - } - } - } - } - // Replace call_history in the outgoing state JSON - if let Ok(v) = serde_json::to_value(&rows) { - let mut out_obj = out.as_object().cloned().unwrap_or_default(); - out_obj.insert("call_history".to_string(), v); - return send_json_response(StatusCode::OK, &serde_json::Value::Object(out_obj)); - } - } - send_json_response(StatusCode::OK, &out) -} - -fn handle_get_all_providers(db: &Sqlite) -> anyhow::Result<()> { - info!("Getting all providers"); - let data = dbm::get_all(db)?; - send_json_response(StatusCode::OK, &json!(data)) -} - -fn handle_search_providers(db: &Sqlite, params: &HashMap) -> anyhow::Result<()> { - let query = params - .get("q") - .ok_or(anyhow::anyhow!("Missing 'q' query parameter"))?; - - info!("Searching providers with query: {}", query); - let data = dbm::search_provider(db, query.to_string())?; - send_json_response(StatusCode::OK, &json!(data)) -} - -fn handle_get_managed_wallets(state: &mut State) -> anyhow::Result<()> { - info!("Getting managed wallets"); - let (selected_id, summaries) = hyperwallet_service::get_wallet_summary_list(state); - - send_json_response( - StatusCode::OK, - &json!({ - "selected_wallet_id": selected_id, - "managed_wallets": summaries - }), - ) -} - -fn handle_get_linked_wallets(state: &mut State) -> anyhow::Result<()> { - info!("Getting linked wallets"); - - // Get on-chain linked wallets if operator is configured - let on_chain_wallets = if let Some(operator_entry_name) = &state.operator_entry_name { - match hyperwallet_service::get_all_onchain_linked_hot_wallet_addresses(Some( - operator_entry_name, - )) { - Ok(addresses) => addresses, - Err(e) => { - warn!("Failed to get on-chain linked wallets: {}", e); - Vec::new() - } - } - } else { - Vec::new() - }; - - // Get managed wallet summaries - let (selected_id, managed_summaries) = hyperwallet_service::get_wallet_summary_list(state); - - // Create a unified view - let mut linked_wallets = Vec::new(); - - // Add all managed wallets first - for summary in &managed_summaries { - linked_wallets.push(json!({ - "address": summary.address, - "name": summary.name, - "is_managed": true, - "is_linked_on_chain": on_chain_wallets.contains(&summary.address), - "is_active": !summary.is_encrypted || summary.is_unlocked, - "is_encrypted": summary.is_encrypted, - "is_selected": summary.is_selected, - "is_unlocked": summary.is_unlocked, - })); - } - - // Add external wallets (on-chain but not managed) - for on_chain_address in &on_chain_wallets { - let is_managed = managed_summaries - .iter() - .any(|s| &s.address == on_chain_address); - if !is_managed { - linked_wallets.push(json!({ - "address": on_chain_address, - "name": null, - "is_managed": false, - "is_linked_on_chain": true, - "is_active": false, - "is_encrypted": false, - "is_selected": false, - "is_unlocked": false, - })); - } - } - - send_json_response( - StatusCode::OK, - &json!({ - "selected_wallet_id": selected_id, - "linked_wallets": linked_wallets, - "operator_configured": state.operator_entry_name.is_some(), - }), - ) -} - -// =========================================================================================== -// PROVIDER CALL HANDLING - payment & provider interaction flow -// =========================================================================================== - -/// Main entry point for provider calls - orchestrates payment and execution -fn handle_provider_call_request( - our: &Address, - state: &mut State, - db: &Sqlite, - provider_id: String, - provider_name: String, - arguments: Vec<(String, String)>, - client_config_opt: Option, -) -> anyhow::Result<()> { - info!( - "Handling call request for provider ID='{}', Name='{}'", - provider_id, provider_name - ); - - let timestamp_start_ms = Utc::now().timestamp_millis() as u128; - let call_args_json = serde_json::to_string(&arguments).unwrap_or_else(|_| "{}".to_string()); - let lookup_key_for_db = if !provider_id.is_empty() { - provider_id.clone() - } else { - provider_name.clone() - }; - - // Fetch provider details from database - match fetch_provider_details(db, &lookup_key_for_db) { - FetchProviderResult::Success(provider_details) => execute_provider_flow( - our, - state, - provider_details, - provider_name, - arguments, - timestamp_start_ms, - call_args_json, - client_config_opt, - ), - FetchProviderResult::NotFound(returned_lookup_key) => { - error!("Provider '{}' not found in local DB.", returned_lookup_key); - let wallet_id_for_failure = client_config_opt - .as_ref() - .map(|config| config.associated_hot_wallet_address.clone()) - .or_else(|| state.selected_wallet_id.clone()); - record_call_failure( - state, - timestamp_start_ms, - returned_lookup_key.clone(), - if provider_id.is_empty() { - "".to_string() - } else { - provider_id.clone() - }, - call_args_json, - PaymentAttemptResult::Skipped { - reason: format!("DB Lookup Failed: Key '{}' not found", returned_lookup_key), - }, - wallet_id_for_failure, - Some(provider_name.clone()), - client_config_opt.as_ref(), - ); - send_json_response( - StatusCode::NOT_FOUND, - &json!({ - "error": format!("Provider '{}' not found", returned_lookup_key) - }), - ) - } - } -} - -/// Execute the full provider flow: health check, payment (if needed), then provider call -fn execute_provider_flow( - our: &Address, - state: &mut State, - provider_details: ProviderDetails, - provider_name: String, - arguments: Vec<(String, String)>, - timestamp_start_ms: u128, - call_args_json: String, - client_config_opt: Option, -) -> anyhow::Result<()> { - // First, do a health check ping to see if the provider is responsive - match perform_provider_health_check(&provider_details, Some(&provider_name)) { - Ok(()) => { - info!( - "Provider {} health check passed", - provider_details.provider_id - ); - } - Err(health_error) => { - error!( - "Provider {} health check failed: {:?}", - provider_details.provider_id, health_error - ); - let wallet_id_for_failure = client_config_opt - .as_ref() - .map(|config| config.associated_hot_wallet_address.clone()) - .or_else(|| state.selected_wallet_id.clone()); - record_call_failure( - state, - timestamp_start_ms, - provider_details.provider_id.clone(), - provider_details.provider_id.clone(), - call_args_json, - PaymentAttemptResult::Skipped { - reason: format!("Provider health check failed: {}", health_error), - }, - wallet_id_for_failure, - Some(provider_name.clone()), - client_config_opt.as_ref(), - ); - return send_json_response( - StatusCode::SERVICE_UNAVAILABLE, - &json!({ - "error": "Provider is not responding", - "details": format!("Health check failed: {}", health_error) - }), - ); - } - } - - // Provider is responsive, proceed with payment if required - match handle_payment(state, &provider_details, client_config_opt.as_ref()) { - PaymentResult::NotRequired => { - info!( - "No payment required for provider {}", - provider_details.provider_id - ); - execute_provider_call( - our, - state, - &provider_details, - provider_name, - arguments, - timestamp_start_ms, - call_args_json, - None, - client_config_opt, - ) - } - PaymentResult::Success(tx_hash) => { - info!( - "Payment successful for provider {}: tx={}", - provider_details.provider_id, tx_hash - ); - execute_provider_call( - our, - state, - &provider_details, - provider_name, - arguments, - timestamp_start_ms, - call_args_json, - Some(tx_hash), - client_config_opt, - ) - } - PaymentResult::Failed(payment_result) => { - error!( - "Payment failed for provider {}: {:?}", - provider_details.provider_id, payment_result - ); - let wallet_id_for_failure = client_config_opt - .as_ref() - .map(|config| config.associated_hot_wallet_address.clone()) - .or_else(|| state.selected_wallet_id.clone()); - record_call_failure( - state, - timestamp_start_ms, - provider_details.provider_id.clone(), - provider_details.provider_id.clone(), - call_args_json, - payment_result.clone(), - wallet_id_for_failure, - Some(provider_name.clone()), - client_config_opt.as_ref(), - ); - send_json_response( - StatusCode::PAYMENT_REQUIRED, - &json!({ - "error": "Pre-payment failed or was skipped.", - "details": payment_result - }), - ) - } - } -} - -// --- Registry Operations --- - -fn handle_search_registry(db: &Sqlite, query: String) -> anyhow::Result<()> { - info!("Searching registry for: {}", query); - let data = dbm::search_provider(db, query)?; - send_json_response(StatusCode::OK, &json!(data)) -} - -// --- History Operations --- - -fn handle_get_call_history(state: &State) -> anyhow::Result<()> { - info!("Getting call history"); - // Enrich with ledger totals if available - let mut rows = state.call_history.clone(); - let db = match &state.db_conn { - Some(db) => db.clone(), - None => { - send_json_response(StatusCode::OK, &rows)?; - return Ok(()); - } - }; - // Build a map of tx_hash -> total_cost_units and overwrite amount_paid so UI shows real ledger cost - // We query per record; small list keeps this simple and fast. - for rec in &mut rows { - let tx_opt = match &rec.payment_result { - Some(crate::structs::PaymentAttemptResult::Success { tx_hash, .. }) => { - Some(tx_hash.clone()) - } - _ => None, - }; - if let Some(tx) = tx_opt { - let q = r#"SELECT total_cost_units FROM usdc_call_ledger WHERE lower(tx_hash) = lower(?1) LIMIT 1"#.to_string(); - if let Ok(rs) = db.read(q, vec![serde_json::Value::String(tx.clone())]) { - if let Some(row) = rs.get(0) { - if let Some(units_str) = row.get("total_cost_units").and_then(|v| v.as_str()) { - // convert base units (6 dp) to display string and set as event cost - if let Ok(units_i) = units_str.parse::() { - let whole = units_i / 1_000_000; - let frac = (units_i % 1_000_000).abs(); - let formatted = format!("{}.{}", whole, format!("{:06}", frac)); - // Attach detail blob - let mut extra = serde_json::json!({}); - if let Some(existing) = &rec.response_json { - if let Ok(e) = serde_json::from_str::(existing) { - if e.is_object() { - extra = e; - } - } - } - extra["total_cost_usdc"] = serde_json::Value::String(formatted); - rec.response_json = Some(extra.to_string()); - // Overwrite the displayed price used by UI to the ledger total - if let Some(crate::structs::PaymentAttemptResult::Success { - amount_paid, - .. - }) = &mut rec.payment_result - { - *amount_paid = - format!("{}", whole as f64 + (frac as f64) / 1_000_000.0); - } - } - } - } - } - } - } - send_json_response(StatusCode::OK, &rows) -} - -// --- Wallet Management Operations --- - -fn handle_get_wallet_summary_list(state: &mut State) -> anyhow::Result<()> { - info!("Getting wallet summary list"); - let (selected_id, summaries) = hyperwallet_service::get_wallet_summary_list(state); - send_json_response( - StatusCode::OK, - &json!({ - "selected_id": selected_id, - "wallets": summaries - }), - ) -} - -fn handle_select_wallet(state: &mut State, wallet_id: String) -> anyhow::Result<()> { - info!("Selecting wallet: {}", wallet_id); - match hyperwallet_service::select_wallet(state, wallet_id) { - Ok(_) => send_json_response(StatusCode::OK, &json!({ "success": true })), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ "success": false, "error": e }), - ), - } -} - -fn handle_rename_wallet( - state: &mut State, - wallet_id: String, - new_name: String, -) -> anyhow::Result<()> { - info!("Renaming wallet {} to '{}'", wallet_id, new_name); - match hyperwallet_service::rename_wallet(state, wallet_id, new_name) { - Ok(_) => send_json_response(StatusCode::OK, &json!({ "success": true })), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ "success": false, "error": e }), - ), - } -} - -fn handle_delete_wallet(state: &mut State, wallet_id: String) -> anyhow::Result<()> { - info!("Deleting wallet: {}", wallet_id); - match hyperwallet_service::delete_wallet(state, wallet_id) { - Ok(_) => send_json_response(StatusCode::OK, &json!({ "success": true })), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ "success": false, "error": e }), - ), - } -} - -fn handle_generate_wallet(state: &mut State) -> anyhow::Result<()> { - info!("Generating new wallet"); - match hyperwallet_service::generate_initial_wallet(state) { - Ok(wallet_id) => { - info!("Generated wallet via hyperwallet: {}", wallet_id); - send_json_response(StatusCode::OK, &json!({ "success": true, "id": wallet_id })) - } - Err(e) => send_json_response( - StatusCode::INTERNAL_SERVER_ERROR, - &json!({ - "success": false, - "error": e - }), - ), - } -} - -fn handle_import_wallet( - state: &mut State, - private_key: String, - password: Option, - name: Option, -) -> anyhow::Result<()> { - info!("Importing wallet"); - match hyperwallet_service::import_new_wallet(state, private_key, password, name) { - Ok(address) => send_json_response( - StatusCode::OK, - &json!({ - "success": true, - "address": address - }), - ), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ - "success": false, - "error": e - }), - ), - } -} - -// --- Wallet State Operations --- - -fn get_selected_wallet_id(state: &State) -> anyhow::Result { - state - .selected_wallet_id - .clone() - .ok_or_else(|| anyhow::anyhow!("No wallet selected")) -} - -fn handle_activate_wallet(state: &mut State, password: Option) -> anyhow::Result<()> { - info!("Activating wallet"); - let wallet_id = get_selected_wallet_id(state)?; - - match hyperwallet_service::activate_wallet(state, wallet_id, password) { - Ok(_) => send_json_response(StatusCode::OK, &json!({ "success": true })), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ - "success": false, - "error": e - }), - ), - } -} - -fn handle_deactivate_wallet(state: &mut State) -> anyhow::Result<()> { - info!("Deactivating wallet"); - let wallet_id = get_selected_wallet_id(state)?; - - match hyperwallet_service::deactivate_wallet(state, wallet_id) { - Ok(_) => send_json_response(StatusCode::OK, &json!({ "success": true })), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ - "success": false, - "error": e - }), - ), - } -} - -fn handle_set_wallet_limits(state: &mut State, limits: SpendingLimits) -> anyhow::Result<()> { - info!("Setting wallet spending limits"); - let wallet_id = get_selected_wallet_id(state)?; - - // Convert SpendingLimits to hyperwallet format - let max_per_call = limits.max_per_call; - let max_total = limits.max_total; - let currency = limits.currency.or_else(|| Some("USDC".to_string())); - - match hyperwallet_service::set_wallet_spending_limits( - state, - wallet_id, - max_per_call, - max_total, - currency, - ) { - Ok(_) => send_json_response(StatusCode::OK, &json!({ "success": true })), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ - "success": false, - "error": e - }), - ), - } -} - -fn handle_set_client_limits( - state: &mut State, - client_id: String, - limits: SpendingLimits, -) -> anyhow::Result<()> { - info!("Setting client spending limits for {}", client_id); - // Persist to state cache immediately for UI readback - state - .client_limits_cache - .insert(client_id.clone(), limits.clone()); - state.save(); - // No hyperwallet call needed; client limits are enforced in our payment pipeline - send_json_response(StatusCode::OK, &json!({ "success": true })) -} - -// Similar pattern for other wallet operations... -fn handle_export_private_key(state: &State, password: Option) -> anyhow::Result<()> { - info!("Exporting private key"); - let wallet_id = state - .selected_wallet_id - .clone() - .ok_or_else(|| anyhow::anyhow!("No wallet selected"))?; - - match hyperwallet_service::export_private_key(state, wallet_id, password) { - Ok(private_key) => send_json_response( - StatusCode::OK, - &json!({ - "success": true, - "private_key": private_key - }), - ), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ - "success": false, - "error": e - }), - ), - } -} - -fn handle_set_wallet_password( - state: &mut State, - new_password: String, - old_password: Option, -) -> anyhow::Result<()> { - info!("Setting wallet password"); - let wallet_id = get_selected_wallet_id(state)?; - - match hyperwallet_service::set_wallet_password(state, wallet_id, new_password, old_password) { - Ok(_) => send_json_response(StatusCode::OK, &json!({ "success": true })), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ - "success": false, - "error": e - }), - ), - } -} - -fn handle_remove_wallet_password( - state: &mut State, - current_password: String, -) -> anyhow::Result<()> { - info!("Removing wallet password"); - let wallet_id = get_selected_wallet_id(state)?; - - match hyperwallet_service::remove_wallet_password(state, wallet_id, current_password) { - Ok(_) => send_json_response(StatusCode::OK, &json!({ "success": true })), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ - "success": false, - "error": e - }), - ), - } -} - -fn handle_get_active_account_details(state: &mut State) -> anyhow::Result<()> { - // Check cache first - if let Some(cached_details) = state.cached_active_details.clone() { - info!("Returning cached active account details"); - return send_json_response(StatusCode::OK, &cached_details); - } - - // Cache miss, fetch fresh - info!("Active account details cache miss, fetching..."); - match hyperwallet_service::get_active_account_details(state) { - Ok(Some(details)) => { - info!("Fetched details successfully, caching..."); - state.cached_active_details = Some(details.clone()); - send_json_response(StatusCode::OK, &details) - } - Ok(None) => { - info!("No active/unlocked account found"); - state.cached_active_details = None; - send_json_response(StatusCode::OK, &json!(null)) - } - Err(e) => { - error!("Error getting active account details: {:?}", e); - state.cached_active_details = None; - send_json_response( - StatusCode::INTERNAL_SERVER_ERROR, - &json!({ - "error": "Failed to retrieve account details" - }), - ) - } - } -} - -// --- Withdrawal Operations --- - -fn handle_withdraw_eth( - state: &mut State, - to_address: String, - amount_wei_str: String, -) -> anyhow::Result<()> { - info!( - "Withdrawing ETH to: {}, amount: {} wei", - to_address, amount_wei_str - ); - match hyperwallet_payments::handle_operator_tba_withdrawal( - state, - hyperwallet_payments::AssetType::Eth, - to_address, - amount_wei_str, - ) { - Ok(_) => send_json_response( - StatusCode::OK, - &json!({ - "success": true, - "message": "ETH withdrawal initiated." - }), - ), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ - "success": false, - "error": e.to_string() - }), - ), - } -} - -fn handle_withdraw_usdc( - state: &mut State, - to_address: String, - amount_usdc_units_str: String, -) -> anyhow::Result<()> { - info!( - "Withdrawing USDC to: {}, amount: {} units", - to_address, amount_usdc_units_str - ); - match hyperwallet_payments::handle_operator_tba_withdrawal( - state, - hyperwallet_payments::AssetType::Usdc, - to_address, - amount_usdc_units_str, - ) { - Ok(_) => send_json_response( - StatusCode::OK, - &json!({ - "success": true, - "message": "USDC withdrawal initiated." - }), - ), - Err(e) => send_json_response( - StatusCode::BAD_REQUEST, - &json!({ - "success": false, - "error": e.to_string() - }), - ), - } -} - -// Helper function to fetch provider details from the database -fn fetch_provider_details(db: &Sqlite, lookup_key: &str) -> FetchProviderResult { - info!("Fetching provider details for lookup key: {}", lookup_key); - - match dbm::get_provider_details(db, lookup_key) { - Ok(Some(details_map)) => extract_provider_from_json(details_map, lookup_key), - _ => FetchProviderResult::NotFound(lookup_key.to_string()), - } -} - -fn extract_provider_from_json( - details_map: HashMap, - lookup_key: &str, -) -> FetchProviderResult { - let provider_id = match details_map.get("provider_id").and_then(Value::as_str) { - Some(id) => id.to_string(), - None => return FetchProviderResult::NotFound(lookup_key.to_string()), - }; - - let wallet = details_map - .get("wallet") - .and_then(Value::as_str) - .map(String::from) - .unwrap_or_else(|| "0x0".to_string()); - - let price = details_map - .get("price") - .and_then(Value::as_str) - .map(String::from) - .unwrap_or_else(|| "0.0".to_string()); - - info!( - "Provider details: ID={}, Wallet={}, Price={}", - provider_id, wallet, price - ); - - FetchProviderResult::Success(ProviderDetails { - wallet_address: wallet, - price_str: price, - provider_id, - }) -} - -// Helper function to authenticate a shim client -fn authenticate_shim_client<'a>( - state: &'a State, - client_id: &str, - raw_token: &str, -) -> Result<&'a HotWalletAuthorizedClient, AuthError> { - // 1. Lookup Client - match state.authorized_clients.get(client_id) { - Some(client_config) => { - // 2. Verify Token - let mut hasher = Sha256::new(); - hasher.update(raw_token.as_bytes()); - let hashed_received_token = format!("{:x}", hasher.finalize()); - - if hashed_received_token != client_config.authentication_token { - return Err(AuthError::InvalidToken); - } - - // 3. Check Capabilities - if client_config.capabilities != ServiceCapabilities::All { - return Err(AuthError::InsufficientCapabilities); - } - - // All checks passed - Ok(client_config) - } - None => Err(AuthError::ClientNotFound), - } -} - -// Helper function to determine which wallet to sign the userOp with -fn determine_signer_wallet( - state: &State, - client_config_opt: Option<&HotWalletAuthorizedClient>, -) -> Result { - if let Some(client_config) = client_config_opt { - // Shim-initiated: use client's associated wallet - info!("Payment via shim client {}", client_config.id); - Ok(client_config.associated_hot_wallet_address.clone()) - } else { - // UI-initiated: use selected & unlocked wallet - determine_ui_payment_wallet(state) - } -} - -fn determine_ui_payment_wallet(state: &State) -> Result { - let selected_id = - state - .selected_wallet_id - .as_ref() - .ok_or_else(|| PaymentAttemptResult::Skipped { - reason: "No wallet selected for payment".to_string(), - })?; - - // Verify wallet is unlocked - let signer = - state - .active_signer_cache - .as_ref() - .ok_or_else(|| PaymentAttemptResult::Skipped { - reason: "Selected wallet is locked. Please unlock for payment".to_string(), - })?; - - // Verify signer matches selected wallet - if !signer - .address() - .to_string() - .eq_ignore_ascii_case(selected_id) - { - error!( - "Selected wallet {} doesn't match active signer {}", - selected_id, - signer.address() - ); - return Err(PaymentAttemptResult::Skipped { - reason: "Wallet state mismatch. Please re-select/unlock".to_string(), - }); - } - - info!("Using selected wallet {} for UI payment", selected_id); - Ok(selected_id.clone()) -} - -// Helper function to execute provider call (no payment) -fn execute_provider_call( - _our: &Address, - state: &mut State, - provider_details: &ProviderDetails, - provider_name: String, - arguments: Vec<(String, String)>, - timestamp_start_ms: u128, - call_args_json: String, - payment_tx_hash: Option, - client_config_opt: Option, -) -> anyhow::Result<()> { - // Prepare target address - let target_address = Address::new( - &provider_details.provider_id, - ("provider", "hypergrid", PUBLISHER), - ); - - let payment_tx_hash_clone = payment_tx_hash.clone(); - - // Prepare request - info!( - "Preparing request for provider process at {}", - target_address - ); - let provider_request_data = ProviderRequest { - provider_name: provider_name.clone(), - arguments, - payment_tx_hash: payment_tx_hash_clone, - }; - // Wrap the ProviderRequest data in a JSON structure that mimics the enum variant - let wrapped_request = serde_json::json!({ - "CallProvider": provider_request_data - }); - let request_body_bytes = serde_json::to_vec(&wrapped_request)?; - - // Send request - info!("Sending ping to provider at {}", target_address); - let provider_call_result = - match send_request_to_provider(target_address.clone(), request_body_bytes) { - Ok(Ok(response)) => ProviderCallResult::Success(response), - Ok(Err(e)) => ProviderCallResult::Failed(e), - Err(e) => ProviderCallResult::Failed(e), - }; - - // Record call outcome - let response_timestamp_ms = Utc::now().timestamp_millis() as u128; - let call_success = matches!(provider_call_result, ProviderCallResult::Success(_)); - - let payment_result = if let Some(tx) = payment_tx_hash { - Some(PaymentAttemptResult::Success { - tx_hash: tx, - amount_paid: provider_details.price_str.clone(), - currency: "USDC".to_string(), - }) - } else { - Some(PaymentAttemptResult::Skipped { - reason: "Zero Price".to_string(), - }) - }; - - // Determine the correct operator_wallet_id based on the call context - let actual_operator_wallet_id = client_config_opt - .as_ref() - .map(|config| config.associated_hot_wallet_address.clone()) - .or_else(|| state.selected_wallet_id.clone()); - - let record = CallRecord { - timestamp_start_ms, - provider_lookup_key: provider_details.provider_id.clone(), - target_provider_id: provider_details.provider_id.clone(), - call_args_json, - response_json: match &provider_call_result { - ProviderCallResult::Success(body) => Some(String::from_utf8_lossy(body).to_string()), - _ => None, - }, - call_success, - response_timestamp_ms, - payment_result, - duration_ms: response_timestamp_ms - timestamp_start_ms, - operator_wallet_id: actual_operator_wallet_id, - client_id: client_config_opt.as_ref().map(|c| c.id.clone()), - provider_name: Some(provider_name.clone()), - }; - - state.call_history.push(record); - limit_call_history(state); - // Live-cover the new call via single-receipt fetch if needed - if let Some(tba) = &state.operator_tba_address { - if let Some(db) = &state.db_conn { - if let Some(crate::structs::PaymentAttemptResult::Success { .. }) = &state - .call_history - .last() - .and_then(|r| r.payment_result.clone()) - { - let provider = state.hypermap.provider.clone(); - let _ = - crate::ledger::verify_calls_covering(state, db, &provider, &tba.to_lowercase()); - } - } - } - state.save(); - - // Handle final response - match provider_call_result { - ProviderCallResult::Success(provider_response_body) => { - info!("Provider call successful. Preparing final HTTP response."); - send_response( - StatusCode::OK, - Some(HashMap::from([( - String::from("Content-Type"), - String::from("application/json"), - )])), - provider_response_body, - ); - info!("Final HTTP response sent to http-server."); - Ok(()) - } - ProviderCallResult::Failed(provider_comm_error) => { - error!("Provider failed to respond: {:?}", provider_comm_error); - send_json_response( - StatusCode::BAD_GATEWAY, - &json!({ - "error": format!("Provider {} failed to respond: {:?}", - target_address, provider_comm_error) - }), - ) - } - } -} -///// Checks the availability of a provider by sending a test request. -//fn check_provider_availability(provider_id: &str) -> Result<(), String> { -// info!("Checking provider availability for ID: {}", provider_id); -// -// let target_address = HyperwareAddress::new( -// provider_id, -// ("hypergrid-provider", "hypergrid-provider", "grid.hypr") -// ); -// -// let dummy_argument = serde_json::json!({ -// "argument": "swag" -// }); -// -// let wrapped_request = serde_json::json!({ -// "HealthPing": dummy_argument -// }); -// -// let request_body_bytes = match serde_json::to_vec(&wrapped_request) { -// Ok(bytes) => bytes, -// Err(e) => { -// let err_msg = format!("Failed to serialize provider availability request: {}", e); -// error!("{}", err_msg); -// return Err(err_msg); -// } -// }; -// -// info!("Sending request body bytes to provider: {:?}", request_body_bytes); -// -// match send_request_to_provider(target_address.clone(), request_body_bytes) { -// Ok(Ok(response)) => { -// info!("Provider at {} responded successfully to availability check: {:?}", target_address, response); -// Ok(()) -// } -// Ok(Err(e)) => { -// let err_msg = format!("Provider at {} failed availability check: {}", target_address, e); -// error!("{}", err_msg); -// Err(err_msg) -// } -// Err(e) => { -// let err_msg = format!("Error sending availability check to provider at {}: {}", target_address, e); -// error!("{}", err_msg); -// Err(err_msg) -// } -// } -//} - -/// Perform a lightweight health check on a provider before payment - -fn perform_provider_health_check(provider_details: &ProviderDetails, provider_name: Option<&str>) -> anyhow::Result<()> { - info!("Performing health check for provider {}", provider_details.provider_id); - - let target_address = Address::new( - &provider_details.provider_id, - ("provider", "hypergrid", PUBLISHER), - ); - - let health_check_request = serde_json::json!({ - "provider_name": provider_name.unwrap_or(&provider_details.provider_id) - }); - - let dummy_argument = serde_json::json!({ - "argument": "swag" - }); - - let wrapped_request = serde_json::json!({ - "HealthPing": health_check_request - }); - - let request_body_bytes = match serde_json::to_vec(&wrapped_request) { - Ok(bytes) => bytes, - Err(e) => { - let err_msg = format!("Failed to serialize provider availability request: {}", e); - error!("{}", err_msg); - return Err(anyhow::anyhow!("{}", err_msg)); - } - }; - - info!( - "Sending health check ping to provider at {}", - target_address - ); - match ProcessRequest::new() - .target(target_address.clone()) - .body(request_body_bytes) - .send_and_await_response(3) - { - Ok(Ok(response)) => { - // Try to parse the response as DummyResponse - match serde_json::from_slice::(&response.body()) { - Ok(response_json) => { - info!( - "Provider {} responded to health check: {:?}", - provider_details.provider_id, response_json - ); - } - Err(_) => { - info!( - "Provider {} responded to health check (non-JSON response)", - provider_details.provider_id - ); - } - } - Ok(()) - } - Ok(Err(send_error)) => { - error!( - "Provider {} health check failed: {:?}", - provider_details.provider_id, send_error - ); - Err(anyhow::anyhow!( - "Provider communication error: {}", - send_error - )) - } - Err(timeout_error) => { - error!( - "Provider {} health check timed out: {:?}", - provider_details.provider_id, timeout_error - ); - Err(anyhow::anyhow!("Provider timeout: {}", timeout_error)) - } - } -} - -pub fn send_request_to_provider( - target: Address, - body: Vec, -) -> anyhow::Result, anyhow::Error>> { - info!("Sending request to provider: {}", target); - let res = ProcessRequest::new() - .target(target.clone()) - .body(body) - .send_and_await_response(10)?; - - match res { - Ok(response_message) => { - info!("Received successful response from {}", target); - Ok(Ok(response_message.body().to_vec())) - } - Err(send_error) => { - error!("Error receiving response from {}: {:?}", target, send_error); - Ok(Err::, anyhow::Error>(anyhow::anyhow!(send_error))) - } - } -} - -fn limit_call_history(state: &mut State) { - let max_history = 100; - if state.call_history.len() > max_history { - state - .call_history - .drain(..state.call_history.len() - max_history); - } -} - -/// Handles POST request to /api/authorize-shim -/// Verifies user is authenticated, then writes their node name and auth token -/// to a configuration file for the hypergrid-shim to read. -/// -pub fn handle_authorize_shim_request( - our: &Address, - req: &IncomingHttpRequest, - _state: &mut State, - _db: &Sqlite, -) -> anyhow::Result>> { - info!("Handling /api/authorize-shim request"); - - // Log headers to find auth info - info!("Request Headers: {:?}", req.headers()); - - // Attempt to extract relevant cookie (adjust cookie name if needed) - // Convert to Option to avoid borrow checker issues with temporary &str - let cookies_header_str: Option = req - .headers() - .get("cookie") - .and_then(|h| h.to_str().ok()) - .map(|s| s.to_string()); // Convert &str to owned String - - info!("Cookie Header: {:?}", cookies_header_str); - - let mut extracted_node_name: Option = None; - let mut extracted_token_value: Option = None; - - if let Some(cookies) = cookies_header_str.as_deref() { - for cookie_pair in cookies.split(';') { - let parts: Vec<&str> = cookie_pair.trim().splitn(2, '=').collect(); - if parts.len() == 2 { - let name = parts[0]; - let value = parts[1]; - // TODO: Refine this logic - we need the *specific* cookie for *this* node - // For now, just grab the first hyperware-auth cookie found - if name.starts_with("hyperware-auth_") { - // Assuming name format is hyperware-auth_NODE.os - if let Some(node) = name.strip_prefix("hyperware-auth_") { - extracted_node_name = Some(node.to_string()); - extracted_token_value = Some(value.to_string()); - info!("Extracted from cookie: Node={}, Token=[REDACTED]", node); - break; // Take the first one for now - } - } - } - } - } - - // **TODO**: How to reliably get authenticated node name and token? - // Assumes hyperware_process_lib populates context or similar after - // successful authentication via the .authenticated(true) binding. - // This part needs verification based on hyperware_process_lib capabilities. - // Using extracted values if found, otherwise placeholders - let node_name = extracted_node_name.unwrap_or_else(|| "PLACEHOLDER_NODE_NAME.os".to_string()); - let token_value = - extracted_token_value.unwrap_or_else(|| "PLACEHOLDER_TOKEN_VALUE".to_string()); - - if node_name.starts_with("PLACEHOLDER") || token_value.starts_with("PLACEHOLDER") { - error!("Could not extract node name or token from authenticated request context! Check logs for headers."); - // Return an internal server error if we couldn't get the necessary info - return Ok(HttpResponse::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .header("Content-Type", "application/json") - .body( - r#"{"error": "Internal server error: Could not retrieve authentication details."}"# - .to_string() - .into_bytes(), - )?); - } - - // Prepare the configuration data - let config_data = ShimAuthConfig { - node: node_name.clone(), - token: token_value.clone(), - }; - - // Serialize data BEFORE filesystem operations - let json_string = match serde_json::to_string_pretty(&config_data) { - Ok(s) => s, - Err(e) => { - error!("Failed to serialize shim config data: {:?}", e); - return Ok(HttpResponse::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .header("Content-Type", "application/json") - .body( - r#"{"error": "Failed to serialize configuration data."}"# - .to_string() - .into_bytes(), - )?); - } - }; - - // Define VFS path within the package's tmp/ drive - let vfs_file_path = format!("/{}/tmp/grid-shim-config.json", our.package_id()); - info!( - "Attempting to write shim config to VFS tmp path: {}", - vfs_file_path - ); - - // Create/Open the file in VFS tmp and write - match vfs::create_file(&vfs_file_path, None) { - Ok(file) => match file.write(json_string.as_bytes()) { - Ok(_) => { - info!("Successfully wrote shim config file to VFS tmp."); - Ok(HttpResponse::builder() - .status(StatusCode::OK) - .header("Content-Type", "application/json") - .body(r#"{"status": "success"}"#.to_string().into_bytes())?) - } - Err(e) => { - error!("Failed to write to VFS tmp file {}: {:?}", vfs_file_path, e); - Ok(HttpResponse::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .header("Content-Type", "application/json") - .body( - r#"{"error": "Failed to write configuration file to VFS."}"# - .to_string() - .into_bytes(), - )?) - } - }, - Err(e) => { - error!( - "Failed to create/open VFS tmp file {}: {:?}", - vfs_file_path, e - ); - Ok(HttpResponse::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .header("Content-Type", "application/json") - .body( - r#"{"error": "Failed to create/open configuration file in VFS."}"# - .to_string() - .into_bytes(), - )?) - } - } -} - -/// Handles POST request to /api/configure-authorized-client -/// Verifies user is authenticated via cookie, receives client configuration details, -/// generates a unique client ID, hashes the raw token, and stores the new -/// HotWalletAuthorizedClient in state. If client_id is provided in request, updates that client. -pub fn handle_configure_authorized_client( - our: &Address, - req: &IncomingHttpRequest, - state: &mut State, // Needs mutable state - _db: &Sqlite, -) -> anyhow::Result>> { - info!("Handling /api/configure-authorized-client request"); - - // --- Authentication Check (ensure request is from the node owner) --- - let cookies_header_str: Option = req - .headers() - .get("cookie") - .and_then(|h| h.to_str().ok()) - .map(|s| s.to_string()); - - let mut is_authenticated_owner = false; - if let Some(cookies) = cookies_header_str.as_deref() { - for cookie_pair in cookies.split(';') { - let parts: Vec<&str> = cookie_pair.trim().splitn(2, '=').collect(); - if parts.len() == 2 { - let name = parts[0]; - let expected_cookie_name = format!("hyperware-auth_{}", our.node()); - if name == expected_cookie_name { - is_authenticated_owner = true; - info!( - "Configure Client: Request authenticated for node owner: {}", - our.node() - ); - break; - } - } - } - } - - if !is_authenticated_owner { - error!( - "Configure Client: Request not authenticated as node owner. Cookie header: {:?}", - cookies_header_str - ); - return Ok(HttpResponse::builder() - .status(StatusCode::UNAUTHORIZED) - .header("Content-Type", "application/json") - .body( - r#"{"error": "Authentication error: Not authorized as node owner."}"# - .to_string() - .into_bytes(), - )?); - } - // --- End Authentication Check --- - - // Deserialize the request body - let blob = last_blob().ok_or(anyhow::anyhow!( - "Request body is missing for configure client request" - ))?; - let request_data: ConfigureAuthorizedClientRequest = match serde_json::from_slice(blob.bytes()) - { - Ok(data) => data, - Err(e) => { - error!( - "Configure Client: Failed to deserialize request body: {}", - e - ); - return Ok(HttpResponse::builder() - .status(StatusCode::BAD_REQUEST) - .header("Content-Type", "application/json") - .body(r#"{"error": "Invalid request body."}"#.to_string().into_bytes())?); - } - }; - - // Hash the received raw token (SHA-256 hex) - let mut hasher = Sha256::new(); - hasher.update(request_data.raw_token.as_bytes()); - let hashed_token_hex = format!("{:x}", hasher.finalize()); - - // Check if we're updating an existing client or creating a new one - let (client_id, is_update) = if let Some(existing_id) = request_data.client_id { - // Update existing client - if let Some(existing_client) = state.authorized_clients.get_mut(&existing_id) { - info!("Configure Client: Updating existing client {}", existing_id); - existing_client.authentication_token = hashed_token_hex.clone(); - if let Some(new_name) = request_data.client_name { - existing_client.name = new_name; - } - // Note: We don't update the hot wallet address for existing clients - (existing_id, true) - } else { - error!( - "Configure Client: Client ID {} not found for update", - existing_id - ); - return Ok(HttpResponse::builder() - .status(StatusCode::NOT_FOUND) - .header("Content-Type", "application/json") - .body(r#"{"error": "Client not found"}"#.to_string().into_bytes())?); - } - } else { - // Create new client - let new_client_id = format!("hypergrid-beta-mcp-shim-{}", Uuid::new_v4().to_string()); - info!("Configure Client: Creating new client {}", new_client_id); - - let default_name = if request_data.hot_wallet_address_to_associate.len() >= 10 { - format!( - "Shim for {}...{}", - &request_data.hot_wallet_address_to_associate[..6], - &request_data.hot_wallet_address_to_associate - [request_data.hot_wallet_address_to_associate.len() - 4..] - ) - } else { - format!( - "Shim Client {}", - new_client_id.chars().take(8).collect::() - ) - }; - - let new_client = HotWalletAuthorizedClient { - id: new_client_id.clone(), - name: request_data.client_name.unwrap_or(default_name), - associated_hot_wallet_address: request_data.hot_wallet_address_to_associate, - authentication_token: hashed_token_hex, - capabilities: ServiceCapabilities::All, - }; - - state - .authorized_clients - .insert(new_client_id.clone(), new_client); - (new_client_id, false) - }; - - state.save(); // Persist the state change - info!( - "Configure Client: {} client with ID: {}", - if is_update { "Updated" } else { "Created" }, - client_id - ); - - // Prepare response - let api_base_path = format!("/{}/api", our.package_id().to_string()); // Use package_id for base path - - let response_data = ConfigureAuthorizedClientResponse { - client_id, - raw_token: request_data.raw_token, // Echo back the raw token - api_base_path, - node_name: our.node().to_string(), - }; - - // Send success response - let response_body_bytes = serde_json::to_vec(&response_data)?; - Ok(HttpResponse::builder() - .status(StatusCode::OK) - .header("Content-Type", "application/json") - .body(response_body_bytes)?) -} - -// Helper function to record call failure before returning error -fn record_call_failure( - state: &mut State, - timestamp_start_ms: u128, - lookup_key: String, - target_provider_id: String, - call_args_json: String, - payment_result: PaymentAttemptResult, - operator_wallet_id: Option, - provider_name_opt: Option, - client_config_opt: Option<&HotWalletAuthorizedClient>, -) { - let record = CallRecord { - timestamp_start_ms, - provider_lookup_key: lookup_key, - target_provider_id, // Use best guess ID passed in - call_args_json, - response_json: None, - call_success: false, // Indicate call failed - response_timestamp_ms: Utc::now().timestamp_millis() as u128, - payment_result: Some(payment_result), - duration_ms: Utc::now().timestamp_millis() as u128 - timestamp_start_ms, - operator_wallet_id, // Use passed-in operator_wallet_id - client_id: client_config_opt.map(|c| c.id.clone()), - provider_name: provider_name_opt, - }; - state.call_history.push(record); - limit_call_history(state); - if let Some(tba) = &state.operator_tba_address { - if let Some(db) = &state.db_conn { - if let Some(crate::structs::PaymentAttemptResult::Success { .. }) = &state - .call_history - .last() - .and_then(|r| r.payment_result.clone()) - { - let provider = state.hypermap.provider.clone(); - let _ = - crate::ledger::verify_calls_covering(state, db, &provider, &tba.to_lowercase()); - } - } - } - state.save(); -} - -// --- Payment Handling --- - -fn handle_payment( - state: &mut State, - provider_details: &ProviderDetails, - client_config_opt: Option<&HotWalletAuthorizedClient>, -) -> PaymentResult { - let price_f64 = provider_details.price_str.parse::().unwrap_or(0.0); - if price_f64 <= 0.0 { - info!( - "No payment required (Price: {} is zero or invalid).", - provider_details.price_str - ); - return PaymentResult::NotRequired; - } - - // Determine which wallet to use for payment - let signer_wallet_id = match determine_signer_wallet(state, client_config_opt) { - Ok(id) => id, - Err(payment_result) => return PaymentResult::Failed(payment_result), - }; - - // Enforce client limit BEFORE attempting payment - if let Some(cfg) = client_config_opt { - // Sum from ledger: total_cost_units for this client - if let (Some(db), Some(tba)) = (state.db_conn.as_ref(), state.operator_tba_address.as_ref()) - { - let q = r#"SELECT COALESCE(SUM(CAST(total_cost_units AS INTEGER)),0) AS total FROM usdc_call_ledger WHERE tba_address = ?1 AND client_id = ?2"#.to_string(); - if let Ok(rows) = db.read( - q, - vec![ - serde_json::Value::String(tba.to_lowercase()), - serde_json::Value::String(cfg.id.clone()), - ], - ) { - let spent_units = rows - .get(0) - .and_then(|r| r.get("total")) - .and_then(|v| v.as_i64()) - .unwrap_or(0) as i128; - // incoming price in USDC units - let price_units = (price_f64 * 1_000_000.0) as i128; - let projected = spent_units.saturating_add(price_units); - // client limit in USDC (string dollars) - let limit_units = state - .client_limits_cache - .get(&cfg.id) - .and_then(|lim| lim.max_total.as_deref()) - .and_then(|s| s.parse::().ok()) - .map(|f| (f * 1_000_000.0) as i128); - if let Some(lim_u) = limit_units { - if projected > lim_u { - return PaymentResult::Failed(PaymentAttemptResult::LimitExceeded { - limit: (lim_u as f64 / 1_000_000.0).to_string(), - amount_attempted: provider_details.price_str.clone(), - currency: "USDC".to_string(), - }); - } - } - } - } - } - - info!( - "Attempting payment of {} to {} for provider {} using wallet {}", - provider_details.price_str, - provider_details.wallet_address, - provider_details.provider_id, - signer_wallet_id - ); - - let payment_result = hyperwallet_payments::execute_payment( - state, - &provider_details.wallet_address, - &provider_details.price_str, - provider_details.provider_id.clone(), - &signer_wallet_id, - ); - - match payment_result { - Some(PaymentAttemptResult::Success { tx_hash, .. }) => PaymentResult::Success(tx_hash), - Some(result) => PaymentResult::Failed(result), - None => PaymentResult::Failed(PaymentAttemptResult::Skipped { - reason: "Internal payment logic error".to_string(), - }), - } -} - -fn handle_post( - our: &Address, - state: &mut State, - db: &Sqlite, - client_config_opt: Option, -) -> anyhow::Result<()> { - let blob = last_blob().ok_or(anyhow::anyhow!("Request body is missing for MCP request"))?; - - // Try to parse as new McpRequest format first - match serde_json::from_slice::(blob.bytes()) { - Ok(body) => handle_mcp(our, body, state, db, client_config_opt), - Err(_) => { - // Fall back to legacy HttpMcpRequest format for backwards compatibility - match serde_json::from_slice::(blob.bytes()) { - Ok(body) => { - warn!("Received deprecated HttpMcpRequest format. Please update to use McpRequest for MCP operations or ApiRequest for other operations.\n\n{:?}", body); - handle_legacy_mcp(our, body, state, db, client_config_opt) - } - Err(e) if e.is_syntax() || e.is_data() => { - error!("Failed to deserialize MCP request JSON: {}", e); - send_json_response( - StatusCode::BAD_REQUEST, - &json!({ "error": format!("Invalid MCP request body: {}", e) }), - )?; - Ok(()) - } - Err(e) => { - error!("Unexpected error reading MCP request blob: {}", e); - Err(anyhow::anyhow!("Error reading MCP request body: {}", e)) - } - } - } - } -} - -fn handle_delete_authorized_client(state: &mut State, client_id: String) -> anyhow::Result<()> { - info!("Deleting authorized client: {}", client_id); - - if state.authorized_clients.remove(&client_id).is_some() { - state.save(); - send_json_response(StatusCode::OK, &json!({ "success": true })) - } else { - send_json_response( - StatusCode::NOT_FOUND, - &json!({ - "success": false, - "error": "Client not found" - }), - ) - } -} - -fn handle_rename_authorized_client( - state: &mut State, - client_id: String, - new_name: String, -) -> anyhow::Result<()> { - info!("Renaming authorized client {} to '{}'", client_id, new_name); - - match state.authorized_clients.get_mut(&client_id) { - Some(client) => { - client.name = new_name; - state.save(); - send_json_response(StatusCode::OK, &json!({ "success": true })) - } - None => send_json_response( - StatusCode::NOT_FOUND, - &json!({ - "success": false, - "error": "Client not found" - }), - ), - } -} - -fn handle_set_gasless_enabled(state: &mut State, enabled: bool) -> anyhow::Result<()> { - info!("Setting gasless transactions enabled: {}", enabled); - - state.gasless_enabled = Some(enabled); - state.save(); - - send_json_response( - StatusCode::OK, - &json!({ - "success": true, - "gasless_enabled": enabled, - "message": if enabled { - "Gasless transactions enabled. Payments will use ERC-4337 UserOperations when possible." - } else { - "Gasless transactions disabled. Payments will use regular transactions." - } - }), - ) -} - -// =========================================================================================== -// SPIDER INTEGRATION HANDLERS -// =========================================================================================== - -fn handle_spider_connect(our: &Address, state: &mut State) -> anyhow::Result<()> { - info!("Handling spider connect request"); - - // Check if request wants to force a new key - let force_new = if let Some(blob) = last_blob() { - if let Ok(request) = serde_json::from_slice::(blob.bytes()) { - request.get("force_new").and_then(|v| v.as_bool()).unwrap_or(false) - } else { - false - } - } else { - false - }; - - // If not forcing new and we already have a key, return it - if !force_new { - if let Some(existing_key) = &state.spider_api_key { - info!("Returning existing spider API key"); - return send_json_response( - StatusCode::OK, - &json!({ - "api_key": existing_key - }), - ); - } - } else { - info!("Force_new=true, creating new spider API key even if one exists"); - } - - // Find the spider process - let spider_address = Address::new("our", SPIDER_PROCESS_ID); - - // Create the request to get a spider API key - let request = json!({ - "name": format!("operator-{}", our.node), - "permissions": vec!["read", "write", "chat"], - "adminKey": "", - }); - - // Send request to spider to create an API key - let body = json!({"CreateSpiderKey": request}); - let body = serde_json::to_vec(&body).unwrap(); - let response_result = ProcessRequest::to(spider_address) - .body(body) - .send_and_await_response(5); - - // Handle timeout or connection failure gracefully - let response = match response_result { - Ok(Ok(resp)) => resp, - Ok(Err(e)) => { - warn!("Spider process returned error: {:?}", e); - return send_json_response( - StatusCode::SERVICE_UNAVAILABLE, - &json!({ - "error": "Spider service is not available", - "details": "Cannot contact Spider process" - }), - ); - } - Err(e) => { - warn!("Failed to contact Spider (timeout or not installed): {:?}", e); - return send_json_response( - StatusCode::SERVICE_UNAVAILABLE, - &json!({ - "error": "Spider service is not available", - "details": "Cannot contact Spider process - it may not be installed" - }), - ); - } - }; - - // Parse the response - let response_body = response.body(); - let result: Result = - serde_json::from_slice(response_body) - .map_err(|e| anyhow::anyhow!("Failed to parse spider response: {:?}", e))?; - - match result { - Ok(api_key) => { - // Store the API key - state.spider_api_key = Some(api_key.key.clone()); - state.save(); - - send_json_response( - StatusCode::OK, - &json!({ - "api_key": api_key.key - }), - ) - } - Err(e) => { - error!("Failed to create spider API key: {}", e); - send_json_response( - StatusCode::INTERNAL_SERVER_ERROR, - &json!({ - "error": format!("Failed to create spider API key: {}", e) - }), - ) - } - } -} - -fn handle_spider_chat(our: &Address, state: &mut State) -> anyhow::Result<()> { - info!("Handling spider chat request"); - - let blob = last_blob().ok_or(anyhow::anyhow!("Request body is missing"))?; - let mut request: crate::structs::SpiderChatRequest = serde_json::from_slice(blob.bytes()) - .map_err(|e| anyhow::anyhow!("Failed to parse chat request: {}", e))?; - - // Find the spider process - let spider_address = Address::new("our", SPIDER_PROCESS_ID); - - // Try up to 2 times (once with provided key, once with refreshed key if needed) - for attempt in 1..=2 { - // Convert to spider's expected format - let chat_request = json!({ - "Chat": { - "apiKey": request.api_key, - "messages": request.messages, - "llmProvider": request.llm_provider, - "model": request.model, - "mcpServers": request.mcp_servers, - "metadata": request.metadata, - } - }); - - let body = serde_json::to_vec(&chat_request).unwrap(); - let response_result = ProcessRequest::to(spider_address.clone()) - .body(body) - .send_and_await_response(30); - - // Handle timeout or connection failure gracefully - let response = match response_result { - Ok(Ok(resp)) => resp, - Ok(Err(e)) => { - warn!("Spider chat returned error: {:?}", e); - return send_json_response( - StatusCode::SERVICE_UNAVAILABLE, - &json!({ - "error": "Spider service error", - "details": format!("Spider process error: {:?}", e) - }), - ); - } - Err(e) => { - warn!("Failed to contact Spider for chat (timeout): {:?}", e); - return send_json_response( - StatusCode::SERVICE_UNAVAILABLE, - &json!({ - "error": "Spider service is not available", - "details": "Cannot contact Spider - it may not be installed or is unresponsive" - }), - ); - } - }; - - // Parse and forward the response - let response_body = response.body(); - let chat_response: serde_json::Value = serde_json::from_slice(response_body) - .map_err(|e| anyhow::anyhow!("Failed to parse spider chat response: {:?}", e))?; - - // Check if it's an unauthorized error - if let Some(error_str) = chat_response.get("Err").and_then(|v| v.as_str()) { - if error_str.contains("Unauthorized: Invalid API key") && attempt == 1 { - info!("Spider API key is invalid, requesting a new one"); - - // Request a new API key - let key_request = crate::structs::CreateSpiderKeyRequest { - name: format!("operator-{}", our.node), - permissions: vec!["read".to_string(), "write".to_string(), "chat".to_string()], - admin_key: String::new(), - }; - - let key_body = json!({"CreateSpiderKey": key_request}); - let key_body = serde_json::to_vec(&key_body).unwrap(); - let key_response_result = ProcessRequest::to(spider_address.clone()) - .body(key_body) - .send_and_await_response(5); - - // Handle timeout gracefully when refreshing key - let key_response = match key_response_result { - Ok(Ok(resp)) => resp, - Ok(Err(e)) => { - warn!("Failed to refresh Spider API key (SendError): {:?}", e); - return send_json_response( - StatusCode::SERVICE_UNAVAILABLE, - &json!({ - "error": "Failed to refresh API key", - "details": "Spider service returned an error", - "needs_reconnect": true - }), - ); - } - Err(e) => { - warn!("Failed to refresh Spider API key (BuildError): {:?}", e); - return send_json_response( - StatusCode::SERVICE_UNAVAILABLE, - &json!({ - "error": "Failed to refresh API key", - "details": "Spider service is unavailable", - "needs_reconnect": true - }), - ); - } - }; - - let key_response_body = key_response.body(); - let key_result: Result = - serde_json::from_slice(key_response_body) - .map_err(|e| anyhow::anyhow!("Failed to parse spider key response: {:?}", e))?; - - match key_result { - Ok(api_key) => { - info!("Got new spider API key, retrying chat request"); - // Update state with new key - state.spider_api_key = Some(api_key.key.clone()); - state.save(); - - // Update the request with the new key and retry - request.api_key = api_key.key.clone(); - continue; // Try again with the new key - } - Err(e) => { - error!("Failed to create new spider API key: {}", e); - return send_json_response( - StatusCode::INTERNAL_SERVER_ERROR, - &json!({ - "error": format!("Failed to refresh API key: {}", e), - "needs_reconnect": true - }), - ); - } - } - } else { - // Other error or already retried - return send_json_response( - StatusCode::INTERNAL_SERVER_ERROR, - &json!({ - "error": error_str - }), - ); - } - } - - // Check if it's a non-unauthorized error - if let Some(error) = chat_response.get("Err") { - return send_json_response( - StatusCode::INTERNAL_SERVER_ERROR, - &json!({ - "error": error - }), - ); - } - - // Extract the Ok variant - success! - if let Some(ok_response) = chat_response.get("Ok") { - // If we refreshed the key, return it in the response so frontend can update - let mut response = json!({ - "conversationId": ok_response.get("conversationId").and_then(|v| v.as_str()).unwrap_or(""), - "response": ok_response.get("response"), - "allMessages": ok_response.get("allMessages"), - }); - - // Include the new API key if it was refreshed - if attempt == 2 { - response["refreshedApiKey"] = json!(request.api_key); - } - - return send_json_response(StatusCode::OK, &response); - } - } - - // Should not reach here, but handle it just in case - send_json_response( - StatusCode::INTERNAL_SERVER_ERROR, - &json!({ - "error": "Invalid response from spider after retries" - }), - ) -} - -fn handle_spider_status(state: &State) -> anyhow::Result<()> { - info!("Handling spider status request"); - - // Try to ping spider to see if it's actually available - let spider_address = Address::new("our", SPIDER_PROCESS_ID); - let ping_body = json!({"Ping": null}); - let ping_body = serde_json::to_vec(&ping_body).unwrap(); - - let spider_available = match ProcessRequest::to(spider_address) - .body(ping_body) - .send_and_await_response(2) // Short timeout for status check - { - Ok(Ok(_)) => true, - Ok(Err(e)) => { - warn!("Spider returned error on ping: {:?}", e); - false - } - Err(e) => { - warn!("Spider not responding to ping: {:?}", e); - false - } - }; - - send_json_response( - StatusCode::OK, - &json!({ - "connected": state.spider_api_key.is_some() && spider_available, - "has_api_key": state.spider_api_key.is_some(), - "spider_available": spider_available, - }), - ) -} - -fn handle_spider_mcp_servers(our: &Address) -> anyhow::Result<()> { - info!("Handling spider MCP servers request"); - - let blob = last_blob().ok_or(anyhow::anyhow!("Request body is missing"))?; - let request: serde_json::Value = serde_json::from_slice(blob.bytes()) - .map_err(|e| anyhow::anyhow!("Failed to parse request body: {:?}", e))?; - - let api_key = request.get("apiKey") - .and_then(|v| v.as_str()) - .ok_or(anyhow::anyhow!("API key is missing"))?; - - // Find the spider process - let spider_address = Address::new("our", SPIDER_PROCESS_ID); - - // Create the request to list MCP servers - let list_request = json!({ - "authKey": api_key, - }); - - // Send request to spider - let body = json!({"ListMcpServers": list_request}); - let body = serde_json::to_vec(&body).unwrap(); - let response_result = ProcessRequest::to(spider_address) - .body(body) - .send_and_await_response(5); - - // Handle timeout or connection failure gracefully - let response = match response_result { - Ok(Ok(resp)) => resp, - Ok(Err(e)) => { - warn!("Spider returned error for MCP servers: {:?}", e); - return send_json_response( - StatusCode::SERVICE_UNAVAILABLE, - &json!({ - "error": "Spider service error", - "servers": [] - }), - ); - } - Err(e) => { - warn!("Failed to contact Spider for MCP servers (timeout): {:?}", e); - return send_json_response( - StatusCode::SERVICE_UNAVAILABLE, - &json!({ - "error": "Spider service is not available", - "servers": [] - }), - ); - } - }; - - // Parse the response - let response_body = response.body(); - let result: Result, String> = - serde_json::from_slice(response_body) - .map_err(|e| anyhow::anyhow!("Failed to parse spider response: {:?}", e))?; - - match result { - Ok(servers) => { - send_json_response( - StatusCode::OK, - &json!({ - "servers": servers - }), - ) - } - Err(e) => { - error!("Failed to get MCP servers: {}", e); - send_json_response( - StatusCode::INTERNAL_SERVER_ERROR, - &json!({ - "error": format!("Failed to get MCP servers: {}", e) - }), - ) - } - } -} diff --git a/operator/operator/src/hyperwallet_client/mod.rs b/operator/operator/src/hyperwallet_client/mod.rs deleted file mode 100644 index 9732120..0000000 --- a/operator/operator/src/hyperwallet_client/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -// this will dissappear -pub mod service; -pub mod payments; diff --git a/operator/operator/src/hyperwallet_client/payments.rs b/operator/operator/src/hyperwallet_client/payments.rs deleted file mode 100644 index af08b31..0000000 --- a/operator/operator/src/hyperwallet_client/payments.rs +++ /dev/null @@ -1,463 +0,0 @@ -//! Hyperwallet client payments module - replaces direct payment operations with hyperwallet calls -//! This module provides the same interface as the original wallet::payments module -//! but delegates all operations to the hyperwallet service. - -use hyperware_process_lib::{ - logging::{info, error, warn}, - wallet, - hyperwallet_client, -}; -use alloy_primitives::Address as EthAddress; -use crate::constants::{USDC_BASE_ADDRESS, PUBLISHER}; - -use crate::structs::{State, PaymentAttemptResult}; -use hyperware_process_lib::http::{client::send_request_await_response, Method}; -use url::Url; - -/// Asset types for withdrawals -#[derive(Debug, Clone, Copy)] -pub enum AssetType { - Eth, - Usdc, -} - -///// one-stop thing -pub fn execute_payment( - state: &State, - provider_wallet_address: &str, - amount_usdc_str: &str, - _provider_id: String, - operator_wallet_id: &str, -) -> Option { - info!("Executing payment via hyperwallet: {} USDC to {}", amount_usdc_str, provider_wallet_address); - - let session = match &state.hyperwallet_session { - Some(session) => session, - None => { - error!("No hyperwallet session available - not initialized"); - return Some(PaymentAttemptResult::Failed { - error: "Hyperwallet session not initialized".to_string(), - amount_attempted: amount_usdc_str.to_string(), - currency: "USDC".to_string(), - }); - } - }; - - let (usdc_contract, operator_tba, recipient_addr, amount_units) = match validate_payment_setup(state, provider_wallet_address, amount_usdc_str) { - Ok(setup) => setup, - Err(result) => return Some(result), - }; - - let tx_hash = match hyperwallet_client::execute_gasless_payment( - &session.session_id, - operator_wallet_id, - &operator_tba, - &recipient_addr.to_string(), - amount_units, - ) { - Ok(tx_hash) => { - info!("Gasless payment completed: tx_hash = {}", tx_hash); - tx_hash - } - Err(e) => { - error!("Failed to execute gasless payment: {}", e); - return Some(PaymentAttemptResult::Failed { - error: format!("Payment failed: {}", e), - amount_attempted: amount_usdc_str.to_string(), - currency: "USDC".to_string(), - }); - } - }; - - Some(PaymentAttemptResult::Success { - tx_hash, - amount_paid: amount_usdc_str.to_string(), - currency: "USDC".to_string(), - }) -} - -//pub fn execute_payment( -// state: &State, -// provider_wallet_address: &str, -// amount_usdc_str: &str, -// _provider_id: String, -// eoa_wallet_id: &str, -//) -> Option { -// info!("Executing payment via hyperwallet: {} USDC to {}", amount_usdc_str, provider_wallet_address); -// -// // Get the hyperwallet session from state -// let session = match &state.hyperwallet_session { -// Some(session) => session, -// None => { -// error!("No hyperwallet session available - not initialized"); -// return Some(PaymentAttemptResult::Failed { -// error: "Hyperwallet session not initialized".to_string(), -// amount_attempted: amount_usdc_str.to_string(), -// currency: "USDC".to_string(), -// }); -// } -// }; -// -// // Step 1: Validate setup and get required addresses -// let (usdc_contract, operator_tba, recipient_addr, amount_units) = match validate_payment_setup(state, provider_wallet_address, amount_usdc_str) { -// Ok(setup) => setup, -// Err(result) => return Some(result), -// }; -// -// // Step 2: Create TBA execute calldata using the new API -// let tba_calldata = match hyperwallet_client::create_tba_payment_calldata(&usdc_contract, &recipient_addr.to_string(), amount_units) { -// Ok(calldata) => calldata, -// Err(e) => { -// error!("Failed to create TBA calldata: {}", e); -// return Some(PaymentAttemptResult::Failed { -// error: format!("Calldata creation failed: {}", e), -// amount_attempted: amount_usdc_str.to_string(), -// currency: "USDC".to_string(), -// }); -// } -// }; -// -// // Step 3: Build and sign UserOperation using new API -// let build_response = match hyperwallet_client::build_and_sign_user_operation_for_payment( -// &session.session_id, -// eoa_wallet_id, -// &operator_tba, -// &operator_tba, // not used anyway -// &tba_calldata, -// true, -// Some(Default::default()), -// None, -// ) { -// Ok(response) => { -// info!("UserOperation built and signed: {:?}", response); -// response -// }, -// Err(e) => { -// error!("Failed to build and sign user operation: {}", e); -// return Some(PaymentAttemptResult::Failed { -// error: format!("Build failed: {}", e), -// amount_attempted: amount_usdc_str.to_string(), -// currency: "USDC".to_string(), -// }); -// } -// }; -// -// // Step 4: Submit UserOperation using new API -// let signed_user_op_value = match serde_json::from_str(&build_response.signed_user_operation) { -// Ok(val) => val, -// Err(e) => { -// error!("Failed to parse signed user operation: {}", e); -// return Some(PaymentAttemptResult::Failed { -// error: format!("Parse signed user op failed: {}", e), -// amount_attempted: amount_usdc_str.to_string(), -// currency: "USDC".to_string(), -// }); -// } -// }; -// -// let user_op_hash = match hyperwallet_client::submit_user_operation( -// &session.session_id, -// signed_user_op_value, -// &build_response.entry_point, -// None, -// ) { -// Ok(hash) => { -// info!("UserOperation submitted: user_op_hash = {}", hash); -// hash -// } -// Err(e) => { -// error!("Failed to submit user operation: {}", e); -// return Some(PaymentAttemptResult::Failed { -// error: format!("Submit failed: {}", e), -// amount_attempted: amount_usdc_str.to_string(), -// currency: "USDC".to_string(), -// }); -// } -// }; -// -// // Step 5: Get receipt by polling the bundler directly with our own timeout budget -// let bundler_url = match crate::structs::CHAIN_ID { -// 8453 => "https://api.candide.dev/public/v3/8453", -// _ => "https://api.candide.dev/public/v3/8453", // default to Base -// }; -// let tx_hash = match get_user_op_receipt_from_bundler_with_retry(&user_op_hash, bundler_url, 45000) { -// Some(h) => { -// info!("Payment receipt received from bundler: tx_hash = {}", h); -// h -// } -// None => { -// warn!("Bundler receipt not ready within timeout; falling back to userOp hash {}", user_op_hash); -// user_op_hash.clone() -// } -// }; -// -// Some(PaymentAttemptResult::Success { -// tx_hash, -// amount_paid: amount_usdc_str.to_string(), -// currency: "USDC".to_string(), -// }) -//} -fn get_user_op_receipt_from_bundler_with_retry(user_op_hash: &str, bundler_url: &str, total_budget_ms: u64) -> Option { - // Retry schedule similar to hyperwallet’s internal polling but bounded by total_budget_ms - let schedule = [3000u64, 1000, 1000, 1000, 1000, 1000, 1000, 2000, 4000, 6000, 8000, 12000]; - let url = Url::parse(bundler_url).ok()?; - let mut elapsed = 0u64; - for delay in schedule.iter() { - if elapsed >= total_budget_ms { break; } - // Build JSON-RPC request - let body = serde_json::json!({ - "jsonrpc": "2.0", - "method": "eth_getUserOperationReceipt", - "params": [user_op_hash], - "id": 1 - }); - // Use a per-request timeout of min(10s, remaining budget) - let per_req = std::cmp::min(10_000u64, total_budget_ms.saturating_sub(elapsed)); - let mut headers = std::collections::HashMap::new(); - headers.insert("Content-Type".to_string(), "application/json".to_string()); - if let Ok(resp) = send_request_await_response(Method::POST, url.clone(), Some(headers), per_req, serde_json::to_vec(&body).ok()?) { - if let Ok(json) = serde_json::from_slice::(&resp.body()) { - if let Some(result) = json.get("result") { - if !result.is_null() { - if let Some(tx_hash) = result.get("receipt") - .and_then(|r| if r.is_string() { serde_json::from_str::(r.as_str().unwrap_or("")).ok() } else { Some(r.clone()) }) - .and_then(|val| val.get("transactionHash").and_then(|h| h.as_str().map(|s| s.to_string()))) { - return Some(tx_hash); - } - // Some bundlers may return transactionHash at root level - if let Some(tx_hash) = result.get("transactionHash").and_then(|h| h.as_str().map(|s| s.to_string())) { - return Some(tx_hash); - } - } - } - } - } - // Wait delay before next attempt - std::thread::sleep(std::time::Duration::from_millis(*delay)); - elapsed = elapsed.saturating_add(*delay); - } - None -} - - - - -// Helper: Validate payment setup and get required addresses -fn validate_payment_setup( - state: &State, - provider_wallet_address: &str, - amount_usdc_str: &str, -) -> Result<(String, String, EthAddress, u128), PaymentAttemptResult> { - // Get USDC contract address for the chain - let usdc_contract = match crate::structs::CHAIN_ID { - 8453 => "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // Base USDC - _ => { - return Err(PaymentAttemptResult::Failed { - error: format!("Unsupported chain ID: {}", crate::structs::CHAIN_ID), - amount_attempted: amount_usdc_str.to_string(), - currency: "USDC".to_string(), - }); - } - }; - - // Check if operator TBA is configured - let operator_tba = match &state.operator_tba_address { - Some(addr) => addr.clone(), - None => { - return Err(PaymentAttemptResult::Skipped { - reason: "Operator TBA not configured".to_string(), - }); - } - }; - - // Parse recipient address - let recipient_addr = match provider_wallet_address.parse::() { - Ok(addr) => addr, - Err(_) => { - return Err(PaymentAttemptResult::Failed { - error: "Invalid recipient address".to_string(), - amount_attempted: amount_usdc_str.to_string(), - currency: "USDC".to_string(), - }); - } - }; - - // Parse USDC amount (assuming input is in USDC units with decimals, e.g., "0.005") - let amount_f64 = amount_usdc_str.parse::().unwrap_or(0.0); - let amount_units = (amount_f64 * 1_000_000.0) as u128; // Convert to 6 decimal units - - Ok((usdc_contract.to_string(), operator_tba, recipient_addr, amount_units)) -} - -///// Execute a regular (non-gasless) payment via TBA -//fn execute_regular_payment( -// operator_tba: String, -// usdc_contract: &str, -// call_data: String, -// operator_wallet_id: &str, -// amount_usdc_str: &str, -//) -> Option { -// let params = serde_json::json!({ -// "tba_address": operator_tba, -// "target": usdc_contract, -// "call_data": call_data, -// "value": "0", -// "operation": 0 // CALL operation -// }); -// -// match call_hyperwallet_payment("ExecuteViaTba", params, Some(operator_wallet_id.to_string())) { -// Ok(data) => { -// if let Some(tx_hash) = data.get("transaction_hash").and_then(|h| h.as_str()) { -// info!("Payment successful: tx_hash = {}", tx_hash); -// Some(PaymentAttemptResult::Success { -// tx_hash: tx_hash.to_string(), -// amount_paid: amount_usdc_str.to_string(), -// currency: "USDC".to_string(), -// }) -// } else { -// error!("Payment response missing transaction_hash"); -// Some(PaymentAttemptResult::Failed { -// error: "Payment response missing transaction hash".to_string(), -// amount_attempted: amount_usdc_str.to_string(), -// currency: "USDC".to_string(), -// }) -// } -// } -// Err(e) => { -// error!("Payment failed: {}", e); -// Some(PaymentAttemptResult::Failed { -// error: format!("Hyperwallet payment error: {}", e), -// amount_attempted: amount_usdc_str.to_string(), -// currency: "USDC".to_string(), -// }) -// } -// } -//} - -// TODO, this needs to call the execute_gasless_payment function in hyperwallet_client -/// Handle operator TBA withdrawal -pub fn handle_operator_tba_withdrawal( - state: &State, - asset_type: AssetType, - to_address: String, - amount_str: String, -) -> Result<(), String> { - info!("Handling {:?} withdrawal via hyperwallet: {} to {}", asset_type, amount_str, to_address); - - // Get the hyperwallet session from state - let session = match &state.hyperwallet_session { - Some(session) => session, - None => { - return Err("Hyperwallet session not initialized".to_string()); - } - }; - - let wallet_id = state.selected_wallet_id.as_ref() - .ok_or("No wallet selected")?; - - info!("Selected wallet ID: {:?}", wallet_id); - - let tx_hash = match asset_type { - AssetType::Usdc => { - // Get operator TBA address - let tba_address = state.operator_tba_address.as_ref() - .ok_or("Operator TBA not configured")?; - - // Parse USDC amount (e.g., "1.5" -> 1,500,000 units) - let amount_f64 = amount_str.parse::() - .map_err(|_| "Invalid amount format")?; - let amount_usdc_units = amount_f64 as u128; - - // Use execute_gasless_payment from hyperwallet_client - hyperwallet_client::execute_gasless_payment( - &session.session_id, - wallet_id, - tba_address, - &to_address, - amount_usdc_units, - ).map_err(|e| format!("USDC gasless withdrawal failed: {}", e))? - } - _ => { - return Err(format!("Unsupported asset type: {:?}", asset_type)); - } - }; - - info!("Withdrawal successful: tx_hash = {}", tx_hash); - Ok(()) -} - -// ===== Additional functions that might be needed ===== - -pub fn check_operator_tba_funding_detailed( - tba_address: Option<&String>, -) -> crate::structs::TbaFundingDetails { - use hyperware_process_lib::{eth, logging::info}; - - let tba_addr = match tba_address { - Some(addr) => addr, - None => { - return crate::structs::TbaFundingDetails { - tba_needs_eth: false, - tba_needs_usdc: false, - tba_eth_balance_str: None, - tba_usdc_balance_str: None, - check_error: Some("No TBA address provided".to_string()), - }; - } - }; - - let provider = eth::Provider::new(crate::structs::CHAIN_ID, 30000); - let usdc_addr = USDC_BASE_ADDRESS; // Base USDC - - // Check USDC balance - let (usdc_balance_str, usdc_error) = match wallet::erc20_balance_of(usdc_addr, tba_addr, &provider) { - Ok(balance) => { - info!("TBA USDC Balance: {} USDC", balance); - (Some(format!("{:.6}", balance)), None) - } - Err(e) => { - info!("Failed to get TBA USDC balance: {:?}", e); - (None, Some(format!("USDC balance check failed: {}", e))) - } - }; - - // Check ETH balance (optional, since we removed ETH from UI) - let (eth_balance_str, eth_error) = match provider.get_balance( - tba_addr.parse().unwrap_or_default(), - None - ) { - Ok(balance) => { - let eth_balance = balance.to::() as f64 / 1e18; - info!("TBA ETH Balance: {:.6} ETH", eth_balance); - (Some(format!("{:.6}", eth_balance)), None) - } - Err(e) => { - info!("Failed to get TBA ETH balance: {:?}", e); - (None, Some(format!("ETH balance check failed: {}", e))) - } - }; - - // Combine errors if any - let combined_error = match (usdc_error, eth_error) { - (Some(usdc_err), Some(eth_err)) => Some(format!("{}, {}", usdc_err, eth_err)), - (Some(err), None) | (None, Some(err)) => Some(err), - (None, None) => None, - }; - - crate::structs::TbaFundingDetails { - tba_needs_eth: false, // We don't really need ETH anymore for gasless - tba_needs_usdc: usdc_balance_str.as_ref().map_or(true, |s| s.parse::().unwrap_or(0.0) < 1.0), - tba_eth_balance_str: eth_balance_str, - tba_usdc_balance_str: usdc_balance_str, - check_error: combined_error, - } -} - -pub fn check_single_hot_wallet_funding_detailed( - _state: &crate::structs::State, - _hot_wallet_address: &str, -) -> (bool, Option, Option) { - // This would need to query hyperwallet or the chain directly - // For now, return dummy values: (needs_eth, eth_balance_str, error_message) - (false, Some("0.0".to_string()), None) -} diff --git a/operator/operator/src/hyperwallet_client/service.rs b/operator/operator/src/hyperwallet_client/service.rs deleted file mode 100644 index 25c9501..0000000 --- a/operator/operator/src/hyperwallet_client/service.rs +++ /dev/null @@ -1,675 +0,0 @@ -//! Hyperwallet client service module - replaces direct wallet management with hyperwallet calls -//! This module provides the same interface as the original wallet::service module -//! but delegates all operations to the hyperwallet service using the typed API. - -use hyperware_process_lib::hyperwallet_client::{ - self, HyperwalletClientError, -}; -use hyperware_process_lib::logging::{info, error, warn}; -use hyperware_process_lib::signer::{LocalSigner, EncryptedSignerData}; -use hyperware_process_lib::wallet::KeyStorage; -use serde::{Deserialize, Serialize}; -use std::str::FromStr; - -use crate::structs::{State, ManagedWallet, WalletSummary, SpendingLimits, DelegationStatus, ActiveAccountDetails}; - -/// Convert HyperwalletClientError to String for compatibility -fn convert_error(error: HyperwalletClientError) -> String { - format!("Hyperwallet error: {}", error) -} - -/// Get session from state with proper error handling -fn get_session_from_state(state: &State) -> Result<&hyperware_process_lib::hyperwallet_client::types::SessionInfo, String> { - state.hyperwallet_session.as_ref() - .ok_or_else(|| "Hyperwallet session not initialized. Please restart the operator.".to_string()) -} - -// ===== Wallet Management Functions ===== - -/// Initialize wallet management - migrate existing wallets to hyperwallet -pub fn initialize_wallet(state: &mut State) { - info!("Initializing wallet management with hyperwallet..."); - - // Check if we have any existing wallets in the old format - if !state.managed_wallets.is_empty() { - info!("Found {} existing wallets to migrate to hyperwallet", state.managed_wallets.len()); - - // Collect wallets to migrate first to avoid borrow issues - let wallets_to_migrate: Vec<(String, Option, String)> = state.managed_wallets - .iter() - .filter_map(|(wallet_id, managed_wallet)| { - if let Some(signer) = state.active_signer_cache.as_ref() { - Some(( - wallet_id.clone(), - managed_wallet.name.clone(), - signer.private_key_hex.clone() - )) - } else { - warn!("Cannot migrate wallet {} - no active signer available", wallet_id); - None - } - }) - .collect(); - - // Now migrate the wallets - for (wallet_id, name, private_key) in wallets_to_migrate { - info!("Attempting to migrate wallet: {}", wallet_id); - - match import_new_wallet(state, private_key, None, name) { - Ok(address) => { - info!("Successfully migrated wallet {} to hyperwallet with address {}", - wallet_id, address); - } - Err(e) => { - error!("Failed to migrate wallet {} to hyperwallet: {}", wallet_id, e); - } - } - } - - // Clear the old wallet storage since we're now using hyperwallet - state.managed_wallets.clear(); - state.active_signer_cache = None; - state.save(); - - info!("Wallet migration complete"); - } else { - info!("No existing wallets to migrate"); - } -} - -pub fn generate_initial_wallet(state: &mut State) -> Result { - info!("Generating initial wallet via hyperwallet"); - - let session = get_session_from_state(state)?; - let wallet = hyperwallet_client::create_wallet( - &session.session_id, - None, - None, - ).map_err(convert_error)?; - - info!("Hyperwallet created wallet: {}", wallet.address); - - // Auto-select the newly created wallet - select_wallet(state, wallet.address.clone())?; - info!("Auto-selected newly generated wallet: {}", wallet.address); - - Ok(wallet.address) -} - -pub fn import_new_wallet( - state: &mut State, - private_key: String, - password: Option, - name: Option, -) -> Result { - info!("Importing wallet via hyperwallet (password: {})", - if password.is_some() { "provided" } else { "none - will store unencrypted" }); - - let session = get_session_from_state(state)?; - let sanitized_password = password - .as_deref() - .filter(|p| !p.trim().is_empty()); - let wallet = hyperwallet_client::import_wallet( - &session.session_id, - &name.unwrap_or_else(|| "imported-wallet".to_string()), - &private_key, - sanitized_password, - ).map_err(convert_error)?; - - info!("Hyperwallet imported wallet: {}", wallet.address); - - // Auto-select the newly imported wallet - select_wallet(state, wallet.address.clone())?; - info!("Auto-selected newly imported wallet: {}", wallet.address); - - Ok(wallet.address) -} - -pub fn get_wallet_summary_list(state: &mut State) -> (Option, Vec) { - info!("Getting wallet summary list from hyperwallet"); - - let session = match get_session_from_state(state) { - Ok(session) => session, - Err(e) => { - error!("Failed to get session: {}", e); - return (state.selected_wallet_id.clone(), Vec::new()); - } - }; - - match hyperwallet_client::list_wallets(&session.session_id) { - Ok(wallets) => { - info!("Retrieved {} wallets from hyperwallet", wallets.total); - - // Auto-select first wallet if none is currently selected and wallets exist - if state.selected_wallet_id.is_none() && !wallets.wallets.is_empty() { - let first_wallet = &wallets.wallets[0]; - info!("No wallet selected - auto-selecting first available wallet: {}", first_wallet.address); - - match select_wallet(state, first_wallet.address.clone()) { - Ok(()) => info!("Successfully auto-selected wallet: {}", first_wallet.address), - Err(e) => warn!("Failed to auto-select wallet {}: {}", first_wallet.address, e), - } - } - - let wallet_summaries = wallets - .wallets - .iter() - .map(|wallet| WalletSummary { - id: wallet.address.clone(), - address: wallet.address.clone(), - name: wallet.name.clone(), - is_encrypted: wallet.encrypted, - is_unlocked: !wallet.encrypted, // For now, assume unencrypted = unlocked - is_selected: Some(wallet.address.as_str()) == state.selected_wallet_id.as_deref(), - }) - .collect(); - - (state.selected_wallet_id.clone(), wallet_summaries) - } - Err(e) => { - error!("Failed to get wallet list from hyperwallet: {}", convert_error(e)); - (state.selected_wallet_id.clone(), Vec::new()) - } - } -} - -pub fn select_wallet(state: &mut State, wallet_id: String) -> Result<(), String> { - info!("Selecting wallet {} (validating with hyperwallet)", wallet_id); - - let session = get_session_from_state(state)?; - - // Validate wallet exists in hyperwallet by calling get_wallet_info - match hyperwallet_client::get_wallet_info(&session.session_id, &wallet_id) { - Ok(_) => { - // Wallet exists in hyperwallet, so update local state - state.selected_wallet_id = Some(wallet_id.clone()); - state.active_signer_cache = None; // Clear cache when switching - state.cached_active_details = None; - state.save(); - info!("Successfully selected wallet {}", wallet_id); - Ok(()) - } - Err(e) => { - let error_msg = convert_error(e); - info!("Wallet validation failed for {}: {}", wallet_id, error_msg); - Err(format!("Wallet not found in hyperwallet: {}", error_msg)) - } - } -} - -pub fn rename_wallet(state: &mut State, wallet_id: String, new_name: String) -> Result<(), String> { - info!("Renaming wallet {} to '{}'", wallet_id, new_name); - - let session = get_session_from_state(state)?; - hyperwallet_client::rename_wallet(&session.session_id, &wallet_id, &new_name) - .map_err(convert_error)?; - - info!("Successfully renamed wallet {} to '{}'", wallet_id, new_name); - - Ok(()) -} - -pub fn delete_wallet(state: &mut State, wallet_id: String) -> Result<(), String> { - let session = get_session_from_state(state)?; - - // Check current wallet count from hyperwallet instead of local state - let current_wallets = hyperwallet_client::list_wallets(&session.session_id) - .map_err(convert_error)?; - - if current_wallets.total <= 1 { - return Err("Cannot delete the last wallet".to_string()); - } - - // Delete from hyperwallet - hyperwallet_client::delete_wallet( - &session.session_id, - &wallet_id, - ).map_err(convert_error)?; - - info!("Successfully deleted wallet {}", wallet_id); - - // Update local state - state.managed_wallets.remove(&wallet_id); - - if Some(&wallet_id) == state.selected_wallet_id.as_ref() { - state.selected_wallet_id = None; - state.active_signer_cache = None; - state.cached_active_details = None; - - // Auto-select another wallet from the updated list - if let Some(next_wallet) = current_wallets.wallets.iter().find(|w| w.address != wallet_id) { - let _ = select_wallet(state, next_wallet.address.clone()); - } - } - - state.save(); - Ok(()) -} - -pub fn activate_wallet( - state: &mut State, - wallet_id: String, - password: Option, -) -> Result<(), String> { - let session = get_session_from_state(state)?; - - // Use hyperwallet's unlock_wallet for encrypted wallets - if let Some(ref pwd) = password { - hyperwallet_client::unlock_wallet( - &session.session_id, - &session.session_id, // target_session_id - &wallet_id, - pwd, - ).map_err(convert_error)?; - - info!("Successfully activated encrypted wallet {}", wallet_id); - } - - // Update local state if wallet exists there (for transition) - if let Some(wallet) = state.managed_wallets.get_mut(&wallet_id) { - match &wallet.storage { - KeyStorage::Encrypted(encrypted_data) => { - if let Some(pwd) = password { - // Decrypt the signer - let signer = LocalSigner::decrypt(encrypted_data, &pwd) - .map_err(|e| format!("Failed to decrypt wallet: {}", e))?; - - // Update storage to decrypted - wallet.storage = KeyStorage::Decrypted(signer.clone()); - - // Update cache if this is selected wallet - if Some(&wallet_id) == state.selected_wallet_id.as_ref() { - state.active_signer_cache = Some(signer); - state.cached_active_details = None; // Clear cache - } - } else { - return Err("Password required for encrypted wallet".to_string()); - } - } - KeyStorage::Decrypted(signer) => { - // Already decrypted, just update cache if selected - if Some(&wallet_id) == state.selected_wallet_id.as_ref() { - state.active_signer_cache = Some(signer.clone()); - state.cached_active_details = None; - } - } - } - } - - state.save(); - Ok(()) -} - -pub fn deactivate_wallet(state: &mut State, wallet_id: String) -> Result<(), String> { - if let Some(wallet) = state.managed_wallets.get_mut(&wallet_id) { - // If it's decrypted, we need to re-encrypt it or at least clear the decrypted state - // For now, we'll just clear the active signer cache - // In a real implementation, you might want to re-encrypt with a stored password - - if Some(&wallet_id) == state.selected_wallet_id.as_ref() { - state.active_signer_cache = None; - state.cached_active_details = None; - } - } - - state.save(); - Ok(()) -} - -pub fn export_private_key( - state: &State, - wallet_id: String, - password: Option, -) -> Result { - let session = get_session_from_state(state)?; - - // Use the export_wallet function from the new API - match hyperwallet_client::export_wallet( - &session.session_id, - &wallet_id, - password.as_deref(), - ) { - Ok(export_response) => Ok(export_response.private_key), - Err(e) => Err(convert_error(e)), - } -} - -pub fn set_wallet_password( - state: &mut State, - wallet_id: String, - new_password: String, - old_password: Option, -) -> Result<(), String> { - if let Some(wallet) = state.managed_wallets.get_mut(&wallet_id) { - // Get the signer - let signer = match &wallet.storage { - KeyStorage::Encrypted(encrypted_data) => { - let pwd = old_password.ok_or("Current password required")?; - LocalSigner::decrypt(encrypted_data, &pwd) - .map_err(|e| format!("Failed to decrypt with old password: {}", e))? - } - KeyStorage::Decrypted(signer) => signer.clone(), - }; - - // Encrypt with new password - let encrypted = signer.encrypt(&new_password) - .map_err(|e| format!("Failed to encrypt with new password: {}", e))?; - - // Update storage to encrypted - wallet.storage = KeyStorage::Encrypted(encrypted); - - // Clear cache if selected - if Some(&wallet_id) == state.selected_wallet_id.as_ref() { - state.active_signer_cache = None; - state.cached_active_details = None; - } - - state.save(); - } - - Ok(()) -} - -pub fn remove_wallet_password( - state: &mut State, - wallet_id: String, - current_password: String, -) -> Result<(), String> { - if let Some(wallet) = state.managed_wallets.get_mut(&wallet_id) { - let signer = match &wallet.storage { - KeyStorage::Encrypted(encrypted_data) => { - // Decrypt to verify password - LocalSigner::decrypt(encrypted_data, ¤t_password) - .map_err(|e| format!("Failed to decrypt with password: {}", e))? - } - KeyStorage::Decrypted(_) => { - return Err("Wallet is not encrypted".to_string()); - } - }; - - // Store as decrypted - wallet.storage = KeyStorage::Decrypted(signer.clone()); - - // Update cache if selected - if Some(&wallet_id) == state.selected_wallet_id.as_ref() { - state.active_signer_cache = Some(signer); - } - - state.save(); - } - - Ok(()) -} - -// ===== Helper Functions ===== - -pub fn get_active_signer(state: &State) -> Result, String> { - state.active_signer_cache.as_ref() - .map(|signer| Box::new(signer.clone()) as Box) - .ok_or("No active/unlocked wallet".to_string()) -} - -pub fn get_active_account_details(state: &State) -> Result, String> { - // This would need to be implemented based on what details are needed - // For now, return basic info - if let Some(wallet_id) = &state.selected_wallet_id { - if let Some(wallet) = state.managed_wallets.get(wallet_id) { - let is_unlocked = match &wallet.storage { - KeyStorage::Decrypted(_) => true, - KeyStorage::Encrypted(_) => false, - }; - - Ok(Some(ActiveAccountDetails { - id: wallet.id.clone(), - name: wallet.name.clone(), - address: wallet.storage.get_address(), - is_encrypted: matches!(wallet.storage, KeyStorage::Encrypted(_)), - is_selected: true, - is_unlocked, - eth_balance: None, // Would need to fetch from chain - usdc_balance: None, // Would need to fetch from chain - })) - } else { - Ok(None) - } - } else { - Ok(None) - } -} - -// ===== Delegation Functions ===== - -pub fn verify_selected_hot_wallet_delegation_detailed( - state: &State, - _operator_entry_name_override: Option<&str>, -) -> DelegationStatus { - // This would need to be implemented based on your delegation logic - // For now, return a simple status - if state.selected_wallet_id.is_some() { - DelegationStatus::Verified - } else { - DelegationStatus::NeedsHotWallet - } -} - -pub fn get_all_onchain_linked_hot_wallet_addresses( - operator_entry_name: Option<&str>, -) -> Result, String> { - info!("Getting on-chain linked hot wallet addresses from hypermap contract"); - - let operator_entry_name = match operator_entry_name { - Some(name) if !name.is_empty() => { - info!(" -> Using provided operator entry name: {}", name); - name - }, - _ => { - let err_msg = "Operator entry name not provided or empty.".to_string(); - error!(" -> Error: {}", err_msg); - return Err(err_msg); - } - }; - - // Create a new provider and hypermap_reader instance for this operation. - let provider = hyperware_process_lib::eth::Provider::new(crate::structs::CHAIN_ID, 60000); - let hypermap_address_obj = match hyperware_process_lib::eth::Address::from_str(hyperware_process_lib::hypermap::HYPERMAP_ADDRESS) { - Ok(addr) => addr, - Err(_) => { - let err_msg = "Internal Error: Failed to parse HYPERMAP_ADDRESS constant.".to_string(); - error!(" -> Error: {}", err_msg); - return Err(err_msg); - } - }; - let hypermap_reader = hyperware_process_lib::hypermap::Hypermap::new(provider.clone(), hypermap_address_obj); - - let access_list_note_name = "~access-list"; - let access_list_full_path = format!("{}.{}", access_list_note_name, operator_entry_name); - - // Step 1: Get the hash of the signers note from the access list note - match crate::helpers::get_signers_note_hash_from_access_list(&hypermap_reader, &access_list_full_path) { - Ok(signers_note_hash) => { - info!( - " Successfully got signers note hash: {} from access list {}", - signers_note_hash, access_list_full_path - ); - - // Step 2: Get the list of addresses from the signers note - match crate::helpers::get_addresses_from_signers_note(&hypermap_reader, signers_note_hash) { - Ok(delegate_addresses) => { - info!( - " Successfully decoded {} delegate addresses from signers note.", - delegate_addresses.len() - ); - // Convert Vec to Vec - let addresses_as_strings = delegate_addresses - .into_iter() - .map(|addr| addr.to_string()) - .collect(); - Ok(addresses_as_strings) - } - Err(err_msg) => { - error!( - " Error getting addresses from signers note (hash: {}): {}", - signers_note_hash, err_msg - ); - Err(format!( - "Failed to get addresses from signers note: {}", - err_msg - )) - } - } - } - Err(err_msg) => { - error!( - " Error getting signers note hash from access list '{}': {}", - access_list_full_path, err_msg - ); - // If there's no access list note, that means no wallets are linked yet - if err_msg.contains("note not found") || err_msg.contains("no data") { - info!(" No access list note found - no wallets linked on-chain yet"); - Ok(Vec::new()) // Return empty list instead of error - } else { - Err(format!( - "Failed to get signers note hash from access list '{}': {}", - access_list_full_path, err_msg - )) - } - } - } -} - -// ===== Additional functions that might be needed ===== - -pub fn get_wallet_summary_for_address( - state: &State, - address: &str, -) -> Option { - info!("Getting wallet summary for address {} from hyperwallet", address); - - let session = get_session_from_state(state).ok()?; - - // Query hyperwallet for the list of wallets - match hyperwallet_client::list_wallets(&session.session_id) { - Ok(list_wallets_response) => { - // Find the wallet with matching address - list_wallets_response.wallets.iter() - .find(|wallet| wallet.address.eq_ignore_ascii_case(address)) - .map(|wallet| WalletSummary { - id: wallet.address.clone(), - address: wallet.address.clone(), - name: wallet.name.clone(), - is_encrypted: wallet.encrypted, - is_unlocked: !wallet.encrypted, // For now, assume unencrypted = unlocked - is_selected: Some(wallet.address.as_str()) == state.selected_wallet_id.as_deref(), - }) - } - Err(e) => { - error!("Failed to get wallet info from hyperwallet: {}", convert_error(e)); - None - } - } -} - -pub fn verify_single_hot_wallet_delegation_detailed( - state: &State, - _operator_entry_name_override: Option<&str>, - hot_wallet_address: &str, -) -> DelegationStatus { - // Check if this hot wallet exists in our managed wallets - let wallet_exists = state.managed_wallets.values() - .any(|wallet| wallet.storage.get_address().eq_ignore_ascii_case(hot_wallet_address)); - - if wallet_exists { - DelegationStatus::Verified - } else { - DelegationStatus::HotWalletNotInList - } -} - -pub fn set_wallet_spending_limits( - state: &mut State, - wallet_id: String, - max_per_call: Option, - max_total: Option, - currency: Option, -) -> Result<(), String> { - info!( - "Setting wallet spending limits for {}: max_per_call={:?}, max_total={:?}, currency={:?}", - wallet_id, max_per_call, max_total, currency - ); - - let session = get_session_from_state(state)?; - - // Prepare cached limits first so we can reuse/cloned values - let currency_final = currency.clone().unwrap_or_else(|| "USDC".to_string()); - let cached = SpendingLimits { - max_per_call: max_per_call.clone(), - max_total: max_total.clone(), - currency: Some(currency_final.clone()), - total_spent: Some("0".to_string()), - }; - - // Build hyperwallet's WalletSpendingLimits type (consumes the original owned values) - let limits = hyperware_process_lib::hyperwallet_client::types::WalletSpendingLimits { - max_per_call, - max_total, - currency: currency_final, - total_spent: "0".to_string(), - set_at: None, - updated_at: None, - }; - - // Send SetWalletLimits to hyperwallet - match hyperware_process_lib::hyperwallet_client::api::set_wallet_limits(&session.session_id, &wallet_id, limits) { - Ok(response) => { - if response.success { - info!("SetWalletLimits succeeded for {}", wallet_id); - // Update local cache immediately so UI reflects changes without waiting for hyperwallet propagation - let wallet_key = wallet_id.to_lowercase(); - state.wallet_limits_cache.insert(wallet_key, cached); - } else { - warn!("SetWalletLimits reported failure for {}: {}", wallet_id, response.message); - } - } - Err(e) => { - return Err(convert_error(e)); - } - } - - // Clear any cached details to force refresh - state.cached_active_details = None; - Ok(()) -} - -/// Get wallet spending limits from hyperwallet -pub fn get_wallet_spending_limits(state: &State, wallet_id: String) -> Result, String> { - info!("Getting wallet spending limits for {} from hyperwallet", wallet_id); - - let session = get_session_from_state(state)?; - // If we have a freshly-set cache entry, prefer it for immediate UI feedback - let key_lower = wallet_id.to_lowercase(); - if let Some(cached) = state.wallet_limits_cache.get(&wallet_id).or_else(|| state.wallet_limits_cache.get(&key_lower)) { - return Ok(Some(cached.clone())); - } - - // Prefer ListWallets, which returns wallet objects with optional spending_limits - match hyperwallet_client::list_wallets(&session.session_id) { - Ok(list) => { - if let Some(found) = list.wallets.into_iter().find(|w| w.address.eq_ignore_ascii_case(&wallet_id)) { - if let Some(limits) = found.spending_limits { - let mapped = SpendingLimits { - max_per_call: limits.max_per_call, - max_total: limits.max_total, - currency: Some(limits.currency), - total_spent: Some(limits.total_spent), - }; - return Ok(Some(mapped)); - } - } - Ok(None) - } - Err(e) => { - warn!("Failed to list wallets while fetching limits for {}: {}", wallet_id, convert_error(e)); - Ok(None) - } - } -} \ No newline at end of file diff --git a/operator/operator/src/identity.rs b/operator/operator/src/identity.rs index a0d10df..460c89f 100644 --- a/operator/operator/src/identity.rs +++ b/operator/operator/src/identity.rs @@ -1,49 +1,96 @@ -use crate::structs::{State, CHAIN_ID, IdentityStatus}; -use hyperware_process_lib::logging::{info, error, warn}; -use hyperware_process_lib::{eth, hypermap, Address}; +use crate::constants::{ + CIRCLE_PAYMASTER, NEW_TBA_IMPLEMENTATION, OLD_TBA_IMPLEMENTATION, USDC_BASE_ADDRESS, +}; +use crate::structs::{IdentityStatus, State, CHAIN_ID}; +use alloy_primitives::{Address as EthAddress, B256, U256}; +use anyhow::{Context, Result}; use hyperware_process_lib::eth::Provider; -use alloy_primitives::Address as EthAddress; -use anyhow::Result; +use hyperware_process_lib::logging::{error, info, warn}; +use hyperware_process_lib::{eth, hypermap, wallet, Address}; use std::str::FromStr; -use crate::chain; // To call get_implementation_address +/// Get implementation address for an ERC-1967 proxy +fn get_implementation_address( + provider: &Provider, + proxy_address: EthAddress, +) -> Result { + info!("Fetching implementation address for: {}", proxy_address); + let slot_bytes = + B256::from_str("0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc") + .expect("ERC-1967 Slot Hash is valid"); // Use expect for constants + let slot_u256 = U256::from_be_bytes(slot_bytes.0); // Convert B256 bytes to U256 -// TBA Implementation addresses -const OLD_TBA_IMPLEMENTATION: &str = "0x000000000046886061414588bb9F63b6C53D8674"; // Works but no gasless -//const NEW_TBA_IMPLEMENTATION: &str = "0x19b89306e31D07426E886E3370E62555A0743D96"; // Supports ERC-4337 gasless (was faulty, no delegation) -const NEW_TBA_IMPLEMENTATION: &str = "0x3950D18044D7DAA56BFd6740fE05B42C95201535"; // Supports ERC-4337 gasless (fixed) + match provider.get_storage_at(proxy_address, slot_u256, None) { + // None means latest block + Ok(value_b256) => { + // Return value is B256 + let value_bytes: &[u8] = &value_b256.0; + if value_bytes.len() == 32 { + // Should always be 32 for B256 + // Address is the last 20 bytes (index 12 to 31) + let implementation_address = EthAddress::from_slice(&value_bytes[12..32]); + info!("Found implementation address: {}", implementation_address); + Ok(implementation_address) + } else { + error!( + "Storage slot value B256 has unexpected length: {}", + value_bytes.len() + ); + Err(anyhow::anyhow!("Invalid storage slot value length")) + } + } + Err(e) => { + error!("Failed to get storage slot for {}: {:?}", proxy_address, e); + Err(anyhow::Error::from(e).context(format!( + "Failed to get implementation slot for {}", + proxy_address + ))) + } + } +} /// Checks for the expected Hypergrid sub-entry (e.g., grid-wallet..) /// and verifies it uses a supported HyperAccountAccessControlMinter implementation. /// Updates the state with the verified entry name and TBA address if found and correct. /// Also sets gasless_enabled based on the implementation version. pub fn initialize_operator_identity(our: &Address, state: &mut State) -> Result<()> { - info!("Initializing Hypergrid Operator Identity for node: {}", our.node); + info!( + "Initializing Hypergrid Operator Identity for node: {}", + our.node + ); let identity_status = check_operator_identity_detailed(our); let mut needs_save = false; match identity_status { - IdentityStatus::Verified { entry_name, tba_address, .. } => { - info!("Identity verified: Name={}, TBA={}", entry_name, tba_address); - + IdentityStatus::Verified { + entry_name, + tba_address, + .. + } => { + info!( + "Identity verified: Name={}, TBA={}", + entry_name, tba_address + ); + // Update state if necessary - if state.operator_entry_name.as_deref() != Some(&entry_name) || - state.operator_tba_address.as_deref() != Some(&tba_address) { + if state.operator_entry_name.as_deref() != Some(&entry_name) + || state.operator_tba_address.as_deref() != Some(&tba_address) + { state.operator_entry_name = Some(entry_name.clone()); state.operator_tba_address = Some(tba_address.clone()); info!("Set operator identity in state."); needs_save = true; } - + // Check implementation to determine gasless support let provider = eth::Provider::new(CHAIN_ID, 30000); if let Ok(tba_eth_addr) = EthAddress::from_str(&tba_address) { - match chain::get_implementation_address(&provider, tba_eth_addr) { + match get_implementation_address(&provider, tba_eth_addr) { Ok(impl_addr) => { let impl_str = impl_addr.to_string().to_lowercase(); let new_gasless_enabled = impl_str == NEW_TBA_IMPLEMENTATION.to_lowercase(); - + if state.gasless_enabled != Some(new_gasless_enabled) { state.gasless_enabled = Some(new_gasless_enabled); if new_gasless_enabled { @@ -58,41 +105,101 @@ pub fn initialize_operator_identity(our: &Address, state: &mut State) -> Result< warn!("Could not check implementation for gasless support: {}", e); } } + + // Check paymaster approval if gasless is enabled + if state.gasless_enabled.unwrap_or(false) { + let paymaster = CIRCLE_PAYMASTER; // Circle paymaster + match wallet::erc20_allowance( + USDC_BASE_ADDRESS, + &tba_address, + paymaster, + &provider, + ) { + Ok(allowance) => { + let approved = allowance > alloy_primitives::U256::ZERO; + info!( + "Paymaster approval check: {}", + if approved { "APPROVED" } else { "NOT APPROVED" } + ); + state.paymaster_approved = Some(approved); + needs_save = true; + } + Err(e) => { + warn!("Failed to check paymaster approval: {:?}", e); + state.paymaster_approved = Some(false); + needs_save = true; + } + } + } else { + // Not using gasless implementation, so paymaster not relevant + state.paymaster_approved = Some(false); + needs_save = true; + } } } IdentityStatus::NotFound => { let expected_sub_entry_name = format!("grid-wallet.{}", our.node); - error!("---------------------------------------------------------------------"); - error!("Hypergrid operational sub-entry not found!"); - error!("Expected sub-entry: {}", expected_sub_entry_name); - error!("Please ensure this sub-entry exists with a supported implementation:"); - error!(" - {} (old - works but no gasless)", OLD_TBA_IMPLEMENTATION); - error!(" - {} (new - supports gasless)", NEW_TBA_IMPLEMENTATION); - error!("Payments and other Hypergrid operations will fail."); - error!("---------------------------------------------------------------------"); - - if state.operator_entry_name.is_some() || state.operator_tba_address.is_some() || state.gasless_enabled.is_some() { - info!("Clearing operator identity state due to missing entry."); + info!( + "Operator wallet sub-entry '{}' not found, checking owner node", + expected_sub_entry_name + ); + + // Check if the owner node exists and store its TBA for UI purposes + let provider = eth::Provider::new(CHAIN_ID, 30000); + match EthAddress::from_str(hypermap::HYPERMAP_ADDRESS) { + Ok(hypermap_addr) if hypermap_addr != EthAddress::ZERO => { + let hypermap_reader = hypermap::Hypermap::new(provider, hypermap_addr); + match hypermap_reader.get(our.node()) { + Ok((tba, owner, _data)) if tba != EthAddress::ZERO => { + let tba_str = tba.to_string(); + let owner_str = owner.to_string(); + info!( + "Found owner node '{}' with TBA: {}, Owner: {}", + our.node(), + tba_str, + owner_str + ); + + // Store the owner node TBA for UI display + // Note: We're NOT setting operator_entry_name since the operator wallet doesn't exist + if state.operator_tba_address.as_deref() != Some(&tba_str) { + state.operator_tba_address = Some(tba_str); + info!( + "Stored owner node TBA in operator_tba_address for UI display" + ); + needs_save = true; + } + } + Ok(_) => { + info!( + "Owner node '{}' has no TBA or is not registered", + our.node() + ); + } + Err(e) => { + error!("Failed to check owner node: {:?}", e); + } + } + } + _ => { + error!("Invalid HYPERMAP_ADDRESS"); + } + } + + // Clear operator_entry_name since the operator wallet doesn't exist + if state.operator_entry_name.is_some() { state.operator_entry_name = None; - state.operator_tba_address = None; - state.gasless_enabled = None; needs_save = true; } } IdentityStatus::IncorrectImplementation { found, expected } => { // This now means UNSUPPORTED implementation (not old or new) let expected_sub_entry_name = format!("grid-wallet.{}", our.node); - error!("---------------------------------------------------------------------"); - error!("Hypergrid operational sub-entry uses UNSUPPORTED implementation!"); - error!("Sub-entry: {}", expected_sub_entry_name); - error!("Found implementation: {}", found); - error!("Supported implementations:"); - error!(" - {} (old)", OLD_TBA_IMPLEMENTATION); - error!(" - {} (new)", NEW_TBA_IMPLEMENTATION); - error!("The operator cannot work with this implementation."); - error!("---------------------------------------------------------------------"); - - if state.operator_entry_name.is_some() || state.operator_tba_address.is_some() || state.gasless_enabled.is_some() { + + if state.operator_entry_name.is_some() + || state.operator_tba_address.is_some() + || state.gasless_enabled.is_some() + { info!("Clearing operator identity state due to unsupported implementation."); state.operator_entry_name = None; state.operator_tba_address = None; @@ -106,11 +213,6 @@ pub fn initialize_operator_identity(our: &Address, state: &mut State) -> Result< } } - if needs_save { - info!("Saving updated state after identity check."); - state.save(); - } - Ok(()) } @@ -118,15 +220,18 @@ pub fn initialize_operator_identity(our: &Address, state: &mut State) -> Result< /// Returns a detailed IdentityStatus enum. /// Now supports both old and new implementations. pub fn check_operator_identity_detailed(our: &Address) -> IdentityStatus { - info!("Checking detailed Hypergrid Operator Identity status for node: {}", our.node); + info!( + "Checking detailed Hypergrid Operator Identity status for node: {}", + our.node + ); let base_node_name = our.node.clone(); let expected_sub_entry_name = format!("grid-wallet.{}", base_node_name); - + let provider = eth::Provider::new(CHAIN_ID, 30000); // 30s timeout let hypermap_addr = match EthAddress::from_str(hypermap::HYPERMAP_ADDRESS) { - Ok(addr) => addr, - Err(e) => return IdentityStatus::CheckError(format!("Invalid HYPERMAP_ADDRESS: {}", e)), + Ok(addr) => addr, + Err(e) => return IdentityStatus::CheckError(format!("Invalid HYPERMAP_ADDRESS: {}", e)), }; let hypermap_reader = hypermap::Hypermap::new(provider.clone(), hypermap_addr); @@ -140,22 +245,25 @@ pub fn check_operator_identity_detailed(our: &Address) -> IdentityStatus { ); return IdentityStatus::NotFound; } - + let tba_str = tba.to_string(); let owner_str = owner.to_string(); - info!("Found sub-entry '{}', TBA: {}, Owner: {}. Checking implementation...", expected_sub_entry_name, tba_str, owner_str); - + info!( + "Found sub-entry '{}', TBA: {}, Owner: {}. Checking implementation...", + expected_sub_entry_name, tba_str, owner_str + ); + // 2. Check implementation address - match chain::get_implementation_address(&provider, tba) { + match get_implementation_address(&provider, tba) { Ok(implementation_address) => { let impl_str = implementation_address.to_string(); let impl_str_lower = impl_str.to_lowercase(); - + // Check if it's one of our supported implementations if impl_str_lower == OLD_TBA_IMPLEMENTATION.to_lowercase() { info!("Sub-entry '{}' uses OLD implementation ({}) - works but no gasless support", expected_sub_entry_name, impl_str); - IdentityStatus::Verified { + IdentityStatus::Verified { entry_name: expected_sub_entry_name, tba_address: tba_str, owner_address: owner_str, @@ -163,39 +271,52 @@ pub fn check_operator_identity_detailed(our: &Address) -> IdentityStatus { } else if impl_str_lower == NEW_TBA_IMPLEMENTATION.to_lowercase() { info!("Sub-entry '{}' uses NEW implementation ({}) - gasless transactions supported!", expected_sub_entry_name, impl_str); - IdentityStatus::Verified { + IdentityStatus::Verified { entry_name: expected_sub_entry_name, tba_address: tba_str, owner_address: owner_str, } } else { - error!("Sub-entry '{}' exists but uses UNSUPPORTED implementation: {}", - expected_sub_entry_name, impl_str); - error!("Supported implementations:"); - error!(" - {} (old)", OLD_TBA_IMPLEMENTATION); - error!(" - {} (new)", NEW_TBA_IMPLEMENTATION); - IdentityStatus::IncorrectImplementation { - found: impl_str, - expected: format!("{} or {}", OLD_TBA_IMPLEMENTATION, NEW_TBA_IMPLEMENTATION) + //error!("Sub-entry '{}' exists but uses UNSUPPORTED implementation: {}", + // expected_sub_entry_name, impl_str); + //error!("Supported implementations:"); + //error!(" - {} (old)", OLD_TBA_IMPLEMENTATION); + //error!(" - {} (new)", NEW_TBA_IMPLEMENTATION); + IdentityStatus::IncorrectImplementation { + found: impl_str, + expected: format!( + "{} or {}", + OLD_TBA_IMPLEMENTATION, NEW_TBA_IMPLEMENTATION + ), } } } Err(e) => { - let err_msg = format!("Failed to get implementation address for '{}' (TBA: {}): {:?}", expected_sub_entry_name, tba_str, e); + let err_msg = format!( + "Failed to get implementation address for '{}' (TBA: {}): {:?}", + expected_sub_entry_name, tba_str, e + ); error!("{}", err_msg); IdentityStatus::ImplementationCheckFailed(err_msg) } } } - Err(e) => { // Handle hypermap.get errors + Err(e) => { + // Handle hypermap.get errors let err_msg = format!("{:?}", e); - error!("Error during '{}' lookup via hypermap.get: {}", expected_sub_entry_name, err_msg); - // Attempt to differentiate between "not found" and other errors + error!( + "Error during '{}' lookup via hypermap.get: {}", + expected_sub_entry_name, err_msg + ); + // Attempt to differentiate between "not found" and other errors if err_msg.contains("note not found") || err_msg.contains("entry not found") { IdentityStatus::NotFound } else { - IdentityStatus::CheckError(format!("RPC/Read Error for '{}': {}", expected_sub_entry_name, err_msg)) + IdentityStatus::CheckError(format!( + "RPC/Read Error for '{}': {}", + expected_sub_entry_name, err_msg + )) } } } -} \ No newline at end of file +} diff --git a/operator/operator/src/init.rs b/operator/operator/src/init.rs new file mode 100644 index 0000000..6622ea7 --- /dev/null +++ b/operator/operator/src/init.rs @@ -0,0 +1,291 @@ +use hyperware_process_lib::homepage::add_to_homepage; +use hyperware_process_lib::hypermap::Hypermap; +use hyperware_process_lib::hyperwallet_client::{ + initialize, HandshakeConfig, Operation, SessionInfo, SpendingLimits, +}; +use hyperware_process_lib::logging::{error, info}; +use hyperware_process_lib::{sqlite::Sqlite, Address}; + +/// Register the operator in the homepage +pub fn register_homepage() { + add_to_homepage("Hypergrid", Some(include_str!("./icon")), Some("/"), None); +} + +/// Initialize hypermap with default timeout +pub fn initialize_hypermap() -> Hypermap { + Hypermap::default(60) +} + +/// Create default spending limits for hyperwallet +pub fn create_default_spending_limits() -> SpendingLimits { + SpendingLimits { + per_tx_eth: Some("0.1".to_string()), + daily_eth: Some("1".to_string()), + per_tx_usdc: Some("100".to_string()), + daily_usdc: Some("1000".to_string()), + daily_reset_at: 0, // Timestamp for daily reset + spent_today_eth: "0".to_string(), + spent_today_usdc: "0".to_string(), + } +} + +/// Build hyperwallet configuration +pub fn build_hyperwallet_config() -> HandshakeConfig { + let default_limits = create_default_spending_limits(); + + HandshakeConfig::new() + .with_operations(&[ + Operation::CreateWallet, + Operation::ImportWallet, + Operation::ListWallets, + Operation::GetWalletInfo, + Operation::SetWalletLimits, + Operation::SendEth, + Operation::SendToken, + Operation::ExecuteViaTba, + Operation::GetBalance, + Operation::GetTokenBalance, + Operation::ResolveIdentity, + Operation::CreateNote, + Operation::ReadNote, + Operation::SetupDelegation, + Operation::VerifyDelegation, + Operation::GetTransactionHistory, + Operation::UpdateSpendingLimits, + Operation::RenameWallet, + Operation::BuildUserOperation, + Operation::BuildAndSignUserOperation, + Operation::BuildAndSignUserOperationForPayment, + Operation::SignUserOperation, + Operation::SubmitUserOperation, + Operation::EstimateUserOperationGas, + Operation::GetUserOperationReceipt, + Operation::ConfigurePaymaster, + ]) + .with_spending_limits(default_limits) + .with_name("hypergrid-operator") +} + +/// Initialize hyperwallet service +pub fn initialize_hyperwallet_call() -> Result { + let config = build_hyperwallet_config(); + + match initialize(config) { + Ok(session) => { + info!("Hyperwallet session established successfully"); + Ok(session) + } + Err(e) => { + error!("FATAL: Hyperwallet initialization failed: {:?}", e); + error!("The operator requires hyperwallet service to be running and accessible."); + error!("Please ensure hyperwallet:hyperwallet:sys is installed and running."); + Err(format!("Hyperwallet initialization failed: {:?}", e)) + } + } +} + +/// Initialize hyperwallet and handle initial wallet setup +pub async fn initialize_hyperwallet(process: &mut crate::OperatorProcess) { + match initialize_hyperwallet_call() { + Ok(session) => { + process.hyperwallet_session = Some(session); + process.state.hyperwallet_session_active = true; + + // Generate a wallet for the operator if none exists + if process.state.selected_wallet_id.is_none() + && process.state.managed_wallets.is_empty() + { + info!("No wallets found, generating initial wallet for operator"); + match crate::wallet::generate_wallet(process).await { + Ok(wallet_id) => { + info!("Successfully generated initial wallet: {}", wallet_id); + } + Err(e) => { + error!("Failed to generate initial wallet for operator: {}", e); + } + } + } else if let Some(wallet_id) = &process.state.selected_wallet_id { + info!("Wallet already selected: {}", wallet_id); + } + } + Err(e) => { + panic!("{}", e); + } + } +} + +/// Initialize database only +pub async fn initialize_database(process: &mut crate::OperatorProcess) { + let our = hyperware_process_lib::our(); + + match initialize_database_connection(&our).await { + Ok(db) => { + process.db_conn = Some(db); + process.state.db_initialized = true; + info!("Database initialized successfully"); + } + Err(e) => { + panic!("{}", e); + } + } +} + +/// Initialize ledger after identity has been established +pub async fn initialize_ledger(process: &mut crate::OperatorProcess) { + // Only initialize ledger if we have both database and TBA address + if let Some(db) = &process.db_conn { + if process.state.operator_tba_address.is_some() { + initialize_ledger_if_ready(&mut process.state, db, process.hypermap.as_ref()).await; + // Notify WebSocket clients about potential balance changes + process.notify_graph_state_update(); + process.notify_wallet_balance_update().await; + // Notify about authorization updates (includes client limits and spending) + notify_authorization_after_ledger_init(process).await; + } else { + info!("Skipping ledger initialization - no operator TBA address yet"); + } + } +} + +/// Initialize operator identity +pub async fn initialize_identity(process: &mut crate::OperatorProcess) { + let our = hyperware_process_lib::our(); + info!( + "Attempting to initialize operator identity for node: {}", + our.node() + ); + + match crate::identity::initialize_operator_identity(&our, &mut process.state) { + Ok(_) => { + info!("Operator identity initialization completed"); + + // If we just initialized identity and have a DB, we might need to init ledger now + if process.state.operator_tba_address.is_some() { + if let Some(db) = &process.db_conn { + if !process.state.client_limits_cache.is_empty() + || !process.state.authorized_clients.is_empty() + { + // Ledger might have been initialized here, notify about authorization updates + notify_authorization_after_ledger_init(process).await; + } + } + } + + // Notify WebSocket clients about potential graph state changes + process.notify_graph_state_update(); + } + Err(e) => { + error!("Failed to initialize operator identity: {:?}", e); + // Don't panic here as the operator can still function without identity + } + } +} + +/// Initialize database connection +pub async fn initialize_database_connection(our: &Address) -> Result { + info!("Loading database..."); + + match crate::db::load_db(our).await { + Ok(db_conn) => { + info!("Database loaded successfully"); + Ok(db_conn) + } + Err(e) => { + error!("FATAL: Failed to load database: {:?}", e); + Err(format!("Database load failed: {:?}", e)) + } + } +} + +/// Initialize USDC ledger tables and refresh client totals if TBA is known +pub async fn initialize_ledger_if_ready( + state: &mut crate::structs::State, + db: &hyperware_process_lib::sqlite::Sqlite, + hypermap: Option<&hyperware_process_lib::hypermap::Hypermap>, +) { + let tba_opt = state.operator_tba_address.clone(); + if let Some(tba) = tba_opt { + //if let Err(e) = crate::ledger::ensure_usdc_events_table(db).await { + // error!("Failed ensuring usdc_events table: {:?}", e); + //} + //if let Err(e) = crate::ledger::ensure_usdc_call_ledger_table(db).await { + // error!("Failed ensuring usdc_call_ledger table: {:?}", e); + //} + + // Check if we need to run bisect ingestion + let needs_bisect = + match crate::ledger::check_needs_bisect_ingestion(db, &tba.to_lowercase()).await { + Ok(needs) => needs, + Err(e) => { + error!("Failed to check bisect needs: {:?}", e); + false // Skip on error + } + }; + + if needs_bisect { + info!("USDC history needs update, running bisect ingestion"); + + // Only run bisect ingestion if hypermap is available + if let Some(hypermap) = hypermap { + // Create provider and initialize USDC history using bisect approach + let provider = + hyperware_process_lib::eth::Provider::new(crate::structs::CHAIN_ID, 30000); + + match crate::ledger::ingest_usdc_history_via_bisect( + &state, + db, + &provider, + hypermap, + &tba.to_lowercase(), + ) + .await + { + Ok(n) => info!( + "USDC bisect initialization for {}: {} events ingested", + tba, n + ), + Err(e) => error!("Failed initializing USDC history via bisect: {:?}", e), + } + } else { + info!("Hypermap not available yet, skipping USDC history ingestion"); + } + } else { + info!("USDC history is up to date, skipping bisect ingestion"); + + // Just scan recent blocks for new activity + let provider = + hyperware_process_lib::eth::Provider::new(crate::structs::CHAIN_ID, 30000); + match crate::ledger::scan_recent_blocks_only(db, &provider, &tba.to_lowercase()).await { + Ok(n) => { + if n > 0 { + info!("Found {} new USDC events in recent blocks", n); + } + } + Err(e) => error!("Failed to scan recent blocks: {:?}", e), + } + } + + match crate::ledger::build_usdc_ledger_for_tba(&state, db, &tba.to_lowercase()).await { + Ok(n) => info!("USDC ledger built on boot for {} ({} rows)", tba, n), + Err(e) => error!("Failed to build USDC ledger on boot: {:?}", e), + } + if let Err(e) = state.refresh_client_totals_from_ledger(db, &tba).await { + error!("Failed to refresh client totals from ledger: {:?}", e); + } + } +} + +/// Notify WebSocket clients about authorization updates after ledger refresh +pub async fn notify_authorization_after_ledger_init(process: &mut crate::OperatorProcess) { + // Only notify if we have authorized clients + if !process.state.authorized_clients.is_empty() { + process.notify_authorization_update(); + + // Also send fresh state snapshots to all connected WebSocket clients + // This ensures they get the updated spending data + let connections: Vec = process.ws_connections.keys().cloned().collect(); + for channel_id in connections { + process.send_state_snapshot(channel_id).await; + } + } +} diff --git a/operator/operator/src/ledger.rs b/operator/operator/src/ledger.rs index b339a3b..a77a9b1 100644 --- a/operator/operator/src/ledger.rs +++ b/operator/operator/src/ledger.rs @@ -1,14 +1,19 @@ -use hyperware_process_lib::sqlite::Sqlite; -use hyperware_process_lib::logging::{info}; -use alloy_primitives::{U256, B256, keccak256}; -use hyperware_process_lib::eth; use crate::constants::{CIRCLE_PAYMASTER, USDC_BASE_ADDRESS}; +use alloy_primitives::{keccak256, Address, B256, U256}; +use alloy_sol_types::{sol, SolCall}; use hex; +use hyperware_process_lib::eth::{ + self, BlockId, BlockNumberOrTag, Filter, Provider, TransactionInput, TransactionRequest, +}; +use hyperware_process_lib::logging::{error, info}; +use hyperware_process_lib::sqlite::Sqlite; +use hyperware_process_lib::wallet::erc20_balance_of; +use std::str::FromStr; -use crate::structs::{State, PaymentAttemptResult}; +use crate::structs::{PaymentAttemptResult, State}; // Schemas owned by ledger -pub fn ensure_usdc_events_table(db: &Sqlite) -> anyhow::Result<()> { +pub async fn ensure_usdc_events_table(db: &Sqlite) -> anyhow::Result<()> { let stmt = r#" CREATE TABLE IF NOT EXISTS usdc_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -21,16 +26,19 @@ pub fn ensure_usdc_events_table(db: &Sqlite) -> anyhow::Result<()> { to_addr TEXT NOT NULL, value_units TEXT NOT NULL ); - "#.to_string(); - db.write(stmt, vec![], None)?; + "# + .to_string(); + db.write(stmt, vec![], None).await?; let idx1 = r#"CREATE UNIQUE INDEX IF NOT EXISTS idx_usdc_events_tx_log ON usdc_events (tx_hash, log_index);"#.to_string(); - db.write(idx1, vec![], None)?; - let idx2 = r#"CREATE INDEX IF NOT EXISTS idx_usdc_events_addr_block ON usdc_events (address, block);"#.to_string(); - db.write(idx2, vec![], None)?; + db.write(idx1, vec![], None).await?; + let idx2 = + r#"CREATE INDEX IF NOT EXISTS idx_usdc_events_addr_block ON usdc_events (address, block);"# + .to_string(); + db.write(idx2, vec![], None).await?; Ok(()) } -pub fn ensure_usdc_call_ledger_table(db: &Sqlite) -> anyhow::Result<()> { +pub async fn ensure_usdc_call_ledger_table(db: &Sqlite) -> anyhow::Result<()> { let stmt = r#" CREATE TABLE IF NOT EXISTS usdc_call_ledger ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -47,18 +55,23 @@ pub fn ensure_usdc_call_ledger_table(db: &Sqlite) -> anyhow::Result<()> { gas_fees_units TEXT NOT NULL DEFAULT '0', total_cost_units TEXT NOT NULL DEFAULT '0' ); - "#.to_string(); - db.write(stmt, vec![], None)?; + "# + .to_string(); + db.write(stmt, vec![], None).await?; let idx1 = r#"CREATE INDEX IF NOT EXISTS idx_usdc_ledger_tba_block ON usdc_call_ledger (tba_address, block);"#.to_string(); - db.write(idx1, vec![], None)?; - let idx2 = r#"CREATE UNIQUE INDEX IF NOT EXISTS idx_usdc_ledger_tx ON usdc_call_ledger (tx_hash);"#.to_string(); - db.write(idx2, vec![], None)?; + db.write(idx1, vec![], None).await?; + let idx2 = + r#"CREATE UNIQUE INDEX IF NOT EXISTS idx_usdc_ledger_tx ON usdc_call_ledger (tx_hash);"# + .to_string(); + db.write(idx2, vec![], None).await?; Ok(()) } -pub fn usdc_display_to_units(s: &str) -> Option { +fn usdc_display_to_units(s: &str) -> Option { let s = s.trim(); - if s.is_empty() { return None; } + if s.is_empty() { + return None; + } let parts: Vec<&str> = s.split('.').collect(); let one = U256::from(1_000_000u64); match parts.len() { @@ -66,8 +79,12 @@ pub fn usdc_display_to_units(s: &str) -> Option { 2 => { let int = U256::from_str_radix(parts[0], 10).ok()?; let mut frac = parts[1].to_string(); - if frac.len() > 6 { frac.truncate(6); } - while frac.len() < 6 { frac.push('0'); } + if frac.len() > 6 { + frac.truncate(6); + } + while frac.len() < 6 { + frac.push('0'); + } let frac_v = U256::from_str_radix(&frac, 10).ok()?; Some(int * one + frac_v) } @@ -75,7 +92,7 @@ pub fn usdc_display_to_units(s: &str) -> Option { } } -fn insert_usdc_event( +async fn insert_usdc_event( db: &Sqlite, address: &str, block: u64, @@ -90,22 +107,26 @@ fn insert_usdc_event( INSERT OR IGNORE INTO usdc_events (address, block, time, tx_hash, log_index, from_addr, to_addr, value_units) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8); - "#.to_string(); + "# + .to_string(); let params = vec![ serde_json::Value::String(address.to_string()), serde_json::Value::Number((block as i64).into()), - time.map(|t| serde_json::Value::Number((t as i64).into())).unwrap_or(serde_json::Value::Null), + time.map(|t| serde_json::Value::Number((t as i64).into())) + .unwrap_or(serde_json::Value::Null), serde_json::Value::String(tx_hash.to_string()), - log_index.map(|i| serde_json::Value::Number((i as i64).into())).unwrap_or(serde_json::Value::Null), + log_index + .map(|i| serde_json::Value::Number((i as i64).into())) + .unwrap_or(serde_json::Value::Null), serde_json::Value::String(from_addr.to_string()), serde_json::Value::String(to_addr.to_string()), serde_json::Value::String(value_units.to_string()), ]; - db.write(stmt, params, None)?; + db.write(stmt, params, None).await?; Ok(()) } -fn upsert_usdc_ledger_row( +async fn upsert_usdc_ledger_row( db: &Sqlite, tba: &str, tx_hash: &str, @@ -142,36 +163,51 @@ fn upsert_usdc_ledger_row( serde_json::Value::String(tba.to_string()), serde_json::Value::String(tx_hash.to_string()), serde_json::Value::Number((block as i64).into()), - time.map(|t| serde_json::Value::Number((t as i64).into())).unwrap_or(serde_json::Value::Null), - client_id.map(|s| serde_json::Value::String(s.to_string())).unwrap_or(serde_json::Value::Null), - provider_name.map(|s| serde_json::Value::String(s.to_string())).unwrap_or(serde_json::Value::Null), - provider_address.map(|s| serde_json::Value::String(s.to_string())).unwrap_or(serde_json::Value::Null), + time.map(|t| serde_json::Value::Number((t as i64).into())) + .unwrap_or(serde_json::Value::Null), + client_id + .map(|s| serde_json::Value::String(s.to_string())) + .unwrap_or(serde_json::Value::Null), + provider_name + .map(|s| serde_json::Value::String(s.to_string())) + .unwrap_or(serde_json::Value::Null), + provider_address + .map(|s| serde_json::Value::String(s.to_string())) + .unwrap_or(serde_json::Value::Null), serde_json::Value::String(provider_cost.to_string()), serde_json::Value::String(paymaster_deposit.to_string()), serde_json::Value::String(paymaster_refund.to_string()), serde_json::Value::String(gas_fees.to_string()), serde_json::Value::String(total_cost.to_string()), ]; - db.write(stmt, params, None)?; + db.write(stmt, params, None).await?; Ok(()) } -pub fn build_usdc_ledger_for_tba(state: &State, db: &Sqlite, tba: &str) -> anyhow::Result { - ensure_usdc_events_table(db)?; - ensure_usdc_call_ledger_table(db)?; +pub async fn build_usdc_ledger_for_tba( + state: &State, + db: &Sqlite, + tba: &str, +) -> anyhow::Result { + ensure_usdc_events_table(db).await?; + ensure_usdc_call_ledger_table(db).await?; // Map tx_hash -> (client_id, provider_name, amount_units) - let mut call_map: std::collections::HashMap, Option, Option)> = std::collections::HashMap::new(); + let mut call_map: std::collections::HashMap< + String, + (Option, Option, Option), + > = std::collections::HashMap::new(); for rec in &state.call_history { - if let Some(PaymentAttemptResult::Success { tx_hash, amount_paid, .. }) = &rec.payment_result { + if let Some(PaymentAttemptResult::Success { + tx_hash, + amount_paid, + .. + }) = rec.get_payment_result() + { let amt_units = usdc_display_to_units(amount_paid.as_str()); call_map.insert( tx_hash.to_lowercase(), - ( - rec.client_id.clone(), - rec.provider_name.clone(), - amt_units, - ), + (rec.client_id.clone(), rec.provider_name.clone(), amt_units), ); } } @@ -181,12 +217,17 @@ pub fn build_usdc_ledger_for_tba(state: &State, db: &Sqlite, tba: &str) -> anyho SELECT tx_hash, MIN(block) AS block, MIN(COALESCE(time, 0)) AS time FROM usdc_events WHERE address = ?1 GROUP BY tx_hash ORDER BY block ASC - "#.to_string(); - let rows = db.read(q, vec![serde_json::Value::String(tba.to_string())])?; + "# + .to_string(); + let rows = db + .read(q, vec![serde_json::Value::String(tba.to_string())]) + .await?; let mut updated = 0usize; for row in rows { let tx = row.get("tx_hash").and_then(|v| v.as_str()).unwrap_or(""); - if tx.is_empty() { continue; } + if tx.is_empty() { + continue; + } let block = row.get("block").and_then(|v| v.as_i64()).unwrap_or(0) as u64; let time = row.get("time").and_then(|v| v.as_i64()).map(|v| v as u64); @@ -194,8 +235,17 @@ pub fn build_usdc_ledger_for_tba(state: &State, db: &Sqlite, tba: &str) -> anyho let qev = r#" SELECT from_addr, to_addr, value_units FROM usdc_events WHERE address = ?1 AND tx_hash = ?2 - "#.to_string(); - let evs = db.read(qev, vec![serde_json::Value::String(tba.to_string()), serde_json::Value::String(tx.to_string())])?; + "# + .to_string(); + let evs = db + .read( + qev, + vec![ + serde_json::Value::String(tba.to_string()), + serde_json::Value::String(tx.to_string()), + ], + ) + .await?; let mut deposit_out = U256::ZERO; let mut refund_in = U256::ZERO; @@ -204,9 +254,20 @@ pub fn build_usdc_ledger_for_tba(state: &State, db: &Sqlite, tba: &str) -> anyho let pm = CIRCLE_PAYMASTER.to_lowercase(); let tba_l = tba.to_lowercase(); for ev in evs { - let fa = ev.get("from_addr").and_then(|v| v.as_str()).unwrap_or("").to_lowercase(); - let ta = ev.get("to_addr").and_then(|v| v.as_str()).unwrap_or("").to_lowercase(); - let vu = ev.get("value_units").and_then(|v| v.as_str()).unwrap_or("0"); + let fa = ev + .get("from_addr") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase(); + let ta = ev + .get("to_addr") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase(); + let vu = ev + .get("value_units") + .and_then(|v| v.as_str()) + .unwrap_or("0"); let amount = U256::from_str_radix(vu, 10).unwrap_or(U256::ZERO); let is_out = fa == tba_l; let is_in = ta == tba_l; @@ -215,14 +276,23 @@ pub fn build_usdc_ledger_for_tba(state: &State, db: &Sqlite, tba: &str) -> anyho } else if is_in && fa == pm { refund_in = refund_in.saturating_add(amount); } else if is_out && ta != pm { - if amount > provider_cost { provider_cost = amount; provider_addr = Some(ta); } + if amount > provider_cost { + provider_cost = amount; + provider_addr = Some(ta); + } } } - let gas_fees = if deposit_out > refund_in { deposit_out - refund_in } else { U256::ZERO }; + let gas_fees = if deposit_out > refund_in { + deposit_out - refund_in + } else { + U256::ZERO + }; let total_cost = provider_cost + gas_fees; - let (client_id_opt, provider_name_opt, _amt_units_opt) = call_map.get(&tx.to_lowercase()) - .cloned().unwrap_or((None, None, None)); + let (client_id_opt, provider_name_opt, _amt_units_opt) = call_map + .get(&tx.to_lowercase()) + .cloned() + .unwrap_or((None, None, None)); upsert_usdc_ledger_row( db, @@ -238,19 +308,25 @@ pub fn build_usdc_ledger_for_tba(state: &State, db: &Sqlite, tba: &str) -> anyho refund_in, gas_fees, total_cost, - )?; + ) + .await?; updated += 1; } Ok(updated) } -pub fn build_ledger_for_tx(state: &State, db: &Sqlite, tba: &str, tx: &str) -> anyhow::Result<()> { - ensure_usdc_call_ledger_table(db)?; +pub async fn build_ledger_for_tx( + state: &State, + db: &Sqlite, + tba: &str, + tx: &str, +) -> anyhow::Result<()> { + ensure_usdc_call_ledger_table(db).await?; // Prepare call map for this tx let mut cid: Option = None; let mut pname: Option = None; for rec in &state.call_history { - if let Some(PaymentAttemptResult::Success { tx_hash, .. }) = &rec.payment_result { + if let Some(PaymentAttemptResult::Success { tx_hash, .. }) = rec.get_payment_result() { if tx_hash.eq_ignore_ascii_case(tx) { cid = rec.client_id.clone(); pname = rec.provider_name.clone(); @@ -263,17 +339,32 @@ pub fn build_ledger_for_tx(state: &State, db: &Sqlite, tba: &str, tx: &str) -> a SELECT from_addr, to_addr, value_units, MIN(block) AS block FROM usdc_events WHERE address = ?1 AND tx_hash = ?2 - "#.to_string(); - let params = vec![serde_json::Value::String(tba.to_string()), serde_json::Value::String(tx.to_string())]; - let row = db.read(qev, params)?; - if row.is_empty() { return Ok(()); } + "# + .to_string(); + let params = vec![ + serde_json::Value::String(tba.to_string()), + serde_json::Value::String(tx.to_string()), + ]; + let row = db.read(qev, params).await?; + if row.is_empty() { + return Ok(()); + } // Re-query all rows to sum let qall = r#" SELECT from_addr, to_addr, value_units FROM usdc_events WHERE address = ?1 AND tx_hash = ?2 - "#.to_string(); - let evs = db.read(qall, vec![serde_json::Value::String(tba.to_string()), serde_json::Value::String(tx.to_string())])?; + "# + .to_string(); + let evs = db + .read( + qall, + vec![ + serde_json::Value::String(tba.to_string()), + serde_json::Value::String(tx.to_string()), + ], + ) + .await?; let mut deposit_out = U256::ZERO; let mut refund_in = U256::ZERO; let mut provider_cost = U256::ZERO; @@ -281,17 +372,39 @@ pub fn build_ledger_for_tx(state: &State, db: &Sqlite, tba: &str, tx: &str) -> a let pm = CIRCLE_PAYMASTER.to_lowercase(); let tba_l = tba.to_lowercase(); for ev in evs { - let fa = ev.get("from_addr").and_then(|v| v.as_str()).unwrap_or("").to_lowercase(); - let ta = ev.get("to_addr").and_then(|v| v.as_str()).unwrap_or("").to_lowercase(); - let vu = ev.get("value_units").and_then(|v| v.as_str()).unwrap_or("0"); + let fa = ev + .get("from_addr") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase(); + let ta = ev + .get("to_addr") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase(); + let vu = ev + .get("value_units") + .and_then(|v| v.as_str()) + .unwrap_or("0"); let amount = U256::from_str_radix(vu, 10).unwrap_or(U256::ZERO); let is_out = fa == tba_l; let is_in = ta == tba_l; - if is_out && ta == pm { deposit_out = deposit_out.saturating_add(amount); } - else if is_in && fa == pm { refund_in = refund_in.saturating_add(amount); } - else if is_out && ta != pm { if amount > provider_cost { provider_cost = amount; provider_addr = Some(ta); } } + if is_out && ta == pm { + deposit_out = deposit_out.saturating_add(amount); + } else if is_in && fa == pm { + refund_in = refund_in.saturating_add(amount); + } else if is_out && ta != pm { + if amount > provider_cost { + provider_cost = amount; + provider_addr = Some(ta); + } + } } - let gas_fees = if deposit_out > refund_in { deposit_out - refund_in } else { U256::ZERO }; + let gas_fees = if deposit_out > refund_in { + deposit_out - refund_in + } else { + U256::ZERO + }; let total_cost = provider_cost + gas_fees; let block = row[0].get("block").and_then(|v| v.as_i64()).unwrap_or(0) as u64; @@ -309,65 +422,152 @@ pub fn build_ledger_for_tx(state: &State, db: &Sqlite, tba: &str, tx: &str) -> a refund_in, gas_fees, total_cost, - )?; + ) + .await?; Ok(()) } -pub fn ensure_call_tx_covered(state: &State, db: &Sqlite, provider: ð::Provider, tba: &str, tx: &str) -> anyhow::Result { - ensure_usdc_events_table(db)?; - ensure_usdc_call_ledger_table(db)?; +pub async fn ensure_call_tx_covered( + state: &State, + db: &Sqlite, + provider: ð::Provider, + tba: &str, + tx: &str, +) -> anyhow::Result { + ensure_usdc_events_table(db).await?; + ensure_usdc_call_ledger_table(db).await?; let tx_l = tx.to_lowercase(); let tba_l = tba.to_lowercase(); // already in ledger? - let ql = r#"SELECT 1 FROM usdc_call_ledger WHERE tx_hash = ?1 AND tba_address = ?2 LIMIT 1"#.to_string(); - let exists = db.read(ql, vec![serde_json::Value::String(tx_l.clone()), serde_json::Value::String(tba_l.clone())])?; - if !exists.is_empty() { return Ok(false); } + let ql = r#"SELECT 1 FROM usdc_call_ledger WHERE tx_hash = ?1 AND tba_address = ?2 LIMIT 1"# + .to_string(); + let exists = db + .read( + ql, + vec![ + serde_json::Value::String(tx_l.clone()), + serde_json::Value::String(tba_l.clone()), + ], + ) + .await?; + if !exists.is_empty() { + return Ok(false); + } // events exist? let qe = r#"SELECT 1 FROM usdc_events WHERE tx_hash = ?1 AND address = ?2 LIMIT 1"#.to_string(); - let ev_exists = db.read(qe, vec![serde_json::Value::String(tx_l.clone()), serde_json::Value::String(tba_l.clone())])?; + let ev_exists = db + .read( + qe, + vec![ + serde_json::Value::String(tx_l.clone()), + serde_json::Value::String(tba_l.clone()), + ], + ) + .await?; if ev_exists.is_empty() { // fetch receipt let mut tx_bytes = [0u8; 32]; - if let Ok(b) = hex::decode(tx_l.trim_start_matches("0x")) { if b.len() == 32 { tx_bytes.copy_from_slice(&b); } } + if let Ok(b) = hex::decode(tx_l.trim_start_matches("0x")) { + if b.len() == 32 { + tx_bytes.copy_from_slice(&b); + } + } let tx_b256 = B256::from(tx_bytes); if let Ok(Some(rcpt)) = provider.get_transaction_receipt(tx_b256) { for rlog in rcpt.inner.logs().iter() { // Filter USDC Transfer logs in this tx pertaining to TBA - if format!("0x{}", hex::encode(rlog.address())) != USDC_BASE_ADDRESS { continue; } + if format!("0x{}", hex::encode(rlog.address())) != USDC_BASE_ADDRESS { + continue; + } let transfer_sig = keccak256("Transfer(address,address,uint256)".as_bytes()); - if rlog.topics().first().copied() != Some(transfer_sig.into()) { continue; } - if rlog.topics().len() < 3 { continue; } + if rlog.topics().first().copied() != Some(transfer_sig.into()) { + continue; + } + if rlog.topics().len() < 3 { + continue; + } let from_addr = &rlog.topics()[1].as_slice()[12..]; let to_addr = &rlog.topics()[2].as_slice()[12..]; let from_hex = format!("0x{}", hex::encode(from_addr)); let to_hex = format!("0x{}", hex::encode(to_addr)); - if !from_hex.eq_ignore_ascii_case(&tba_l) && !to_hex.eq_ignore_ascii_case(&tba_l) { continue; } + if !from_hex.eq_ignore_ascii_case(&tba_l) && !to_hex.eq_ignore_ascii_case(&tba_l) { + continue; + } let amount = U256::from_be_slice(rlog.data().data.as_ref()); let blk = rcpt.block_number.unwrap_or(0); let log_index = rlog.log_index.map(|v| v.into()); - insert_usdc_event(db, &tba_l, blk, None, &tx_l, log_index, &from_hex, &to_hex, &amount.to_string())?; + insert_usdc_event( + db, + &tba_l, + blk, + None, + &tx_l, + log_index, + &from_hex, + &to_hex, + &amount.to_string(), + ) + .await?; } } } // build ledger for this tx (if we inserted nothing and no events, this is a no-op) - build_ledger_for_tx(state, db, &tba_l, &tx_l)?; + build_ledger_for_tx(state, db, &tba_l, &tx_l).await?; Ok(true) } -pub fn verify_calls_covering(state: &State, db: &Sqlite, provider: ð::Provider, tba: &str) -> anyhow::Result { +pub async fn verify_calls_covering( + state: &State, + db: &Sqlite, + provider: ð::Provider, + tba: &str, +) -> anyhow::Result { let mut updated = 0usize; for rec in &state.call_history { - if let Some(PaymentAttemptResult::Success { tx_hash, .. }) = &rec.payment_result { - if ensure_call_tx_covered(state, db, provider, tba, tx_hash)? { updated += 1; } + if let Some(PaymentAttemptResult::Success { tx_hash, .. }) = rec.get_payment_result() { + if ensure_call_tx_covered(state, db, provider, tba, &tx_hash).await? { + updated += 1; + } } } Ok(updated) } -pub fn show_ledger(db: &Sqlite, tba: &str, limit: u64) -> anyhow::Result<()> { - ensure_usdc_call_ledger_table(db)?; +/// Get the current USDC balance for a TBA by summing all events +pub async fn get_tba_usdc_balance(db: &Sqlite, tba: &str) -> anyhow::Result { + ensure_usdc_events_table(db).await?; + + // Sum all incoming and outgoing USDC transfers + let query = r#" + SELECT + COALESCE(SUM(CASE + WHEN LOWER(to_addr) = LOWER(?1) THEN CAST(value_units AS INTEGER) + WHEN LOWER(from_addr) = LOWER(?1) THEN -CAST(value_units AS INTEGER) + ELSE 0 + END), 0) as balance_units + FROM usdc_events + WHERE LOWER(from_addr) = LOWER(?1) OR LOWER(to_addr) = LOWER(?1) + "# + .to_string(); + + let params = vec![serde_json::Value::String(tba.to_string())]; + let rows = db.read(query, params).await?; + + if let Some(row) = rows.first() { + if let Some(balance_units) = row.get("balance_units").and_then(|v| v.as_i64()) { + // Convert from units (6 decimals) to float + let balance = balance_units as f64 / 1_000_000.0; + return Ok(balance); + } + } + + Ok(0.0) +} + +pub async fn show_ledger(db: &Sqlite, tba: &str, limit: u64) -> anyhow::Result<()> { + ensure_usdc_call_ledger_table(db).await?; let q = r#" SELECT block, time, tx_hash, client_id, provider_name, provider_cost_units, gas_fees_units, total_cost_units FROM usdc_call_ledger @@ -375,20 +575,1403 @@ pub fn show_ledger(db: &Sqlite, tba: &str, limit: u64) -> anyhow::Result<()> { ORDER BY block DESC LIMIT ?2 "#.to_string(); - let rows = db.read(q, vec![serde_json::Value::String(tba.to_string()), serde_json::Value::Number((limit as i64).into())])?; + let rows = db + .read( + q, + vec![ + serde_json::Value::String(tba.to_string()), + serde_json::Value::Number((limit as i64).into()), + ], + ) + .await?; info!("USDC ledger for {} (showing {}):", tba, rows.len()); for r in rows { let blk = r.get("block").and_then(|v| v.as_i64()).unwrap_or(0); let ts = r.get("time").and_then(|v| v.as_i64()).unwrap_or(0); let tx = r.get("tx_hash").and_then(|v| v.as_str()).unwrap_or(""); let cid = r.get("client_id").and_then(|v| v.as_str()).unwrap_or(""); - let pn = r.get("provider_name").and_then(|v| v.as_str()).unwrap_or(""); - let pc = r.get("provider_cost_units").and_then(|v| v.as_str()).unwrap_or("0"); - let gf = r.get("gas_fees_units").and_then(|v| v.as_str()).unwrap_or("0"); - let tc = r.get("total_cost_units").and_then(|v| v.as_str()).unwrap_or("0"); + let pn = r + .get("provider_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let pc = r + .get("provider_cost_units") + .and_then(|v| v.as_str()) + .unwrap_or("0"); + let gf = r + .get("gas_fees_units") + .and_then(|v| v.as_str()) + .unwrap_or("0"); + let tc = r + .get("total_cost_units") + .and_then(|v| v.as_str()) + .unwrap_or("0"); info!("blk={} ts={} tx={} client={} provider={} provider_cost={} gas_fees={} total={} (units)", blk, ts, tx, cid, pn, pc, gf, tc); } Ok(()) } +// Binary search to find where USDC balance changes occurred +async fn find_balance_change_windows_binary( + provider: ð::Provider, + usdc_addr: &str, + tba: &str, + start_block: u64, + end_block: u64, +) -> anyhow::Result> { + let mut activity_regions = Vec::new(); + let mut iterations = 0; + let mut error_count = 0; + const MAX_ITERATIONS: usize = 50; + const MAX_ERRORS: usize = 5; + + info!( + "Starting comprehensive binary search for USDC activity between blocks {} and {}", + start_block, end_block + ); + + // Recursive binary search function + async fn binary_search_region( + provider: ð::Provider, + usdc_addr: &str, + tba: &str, + low: u64, + high: u64, + regions: &mut Vec<(u64, u64)>, + iterations: &mut usize, + error_count: &mut usize, + ) -> anyhow::Result<()> { + // Stop conditions + if *iterations >= MAX_ITERATIONS { + info!( + "Reached max iterations ({}), stopping search", + MAX_ITERATIONS + ); + return Ok(()); + } + if *error_count >= MAX_ERRORS { + info!("Too many errors ({}), stopping search", MAX_ERRORS); + return Ok(()); + } + if high <= low { + return Ok(()); + } + + *iterations += 1; + + // For very small ranges (< 100 blocks), check a single block in the middle + if high - low < 100 { + let check_block = (low + high) / 2; + match check_single_block_for_activity(provider, usdc_addr, tba, check_block).await { + Ok(true) => { + info!( + "Found activity in small range {}-{} at block {}", + low, high, check_block + ); + regions.push((low, high)); + } + Ok(false) => { + // No activity in this small range + } + Err(e) => { + info!("Error checking small range {}-{}: {}", low, high, e); + *error_count += 1; + } + } + return Ok(()); + } + + // Binary search: check midpoint + let mid = (low + high) / 2; + + match check_single_block_for_activity(provider, usdc_addr, tba, mid).await { + Ok(true) => { + info!("Found activity at block {}", mid); + // Activity found! Add a small region around this block + regions.push((mid.saturating_sub(4), mid.saturating_add(4))); + + // Continue searching both halves for more activity + // Left half: from low to just before found region + if mid.saturating_sub(5) > low { + Box::pin(binary_search_region( + provider, + usdc_addr, + tba, + low, + mid.saturating_sub(5), + regions, + iterations, + error_count, + )) + .await?; + } + + // Right half: from just after found region to high + if mid.saturating_add(5) < high { + Box::pin(binary_search_region( + provider, + usdc_addr, + tba, + mid.saturating_add(5), + high, + regions, + iterations, + error_count, + )) + .await?; + } + } + Ok(false) => { + // No activity at midpoint, search both halves + info!("No activity at block {}, searching both halves", mid); + + // Search left half + if mid > low { + Box::pin(binary_search_region( + provider, + usdc_addr, + tba, + low, + mid - 1, + regions, + iterations, + error_count, + )) + .await?; + } + + // Search right half + if mid < high { + Box::pin(binary_search_region( + provider, + usdc_addr, + tba, + mid + 1, + high, + regions, + iterations, + error_count, + )) + .await?; + } + } + Err(e) => { + let err_str = e.to_string(); + if err_str.contains("internal error") || err_str.contains("-32000") { + *error_count += 1; + info!("RPC error at block {}: {}", mid, e); + // Wait a bit before continuing + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Try to continue with smaller ranges + if mid > low + 1000 { + Box::pin(binary_search_region( + provider, + usdc_addr, + tba, + low, + low + 1000, + regions, + iterations, + error_count, + )) + .await?; + } + if high > mid + 1000 { + Box::pin(binary_search_region( + provider, + usdc_addr, + tba, + high - 1000, + high, + regions, + iterations, + error_count, + )) + .await?; + } + } else { + info!("Other error at block {}: {}", mid, e); + // Continue searching despite error + } + } + } + + Ok(()) + } + + // Start the recursive search + binary_search_region( + provider, + usdc_addr, + tba, + start_block, + end_block, + &mut activity_regions, + &mut iterations, + &mut error_count, + ) + .await?; + + // Merge overlapping regions + activity_regions.sort_by_key(|&(start, _)| start); + let mut merged = Vec::new(); + + for (start, end) in activity_regions { + if let Some((_, last_end)) = merged.last_mut() { + if start <= *last_end + 10 { + // Merge if regions are close + *last_end = end.max(*last_end); + } else { + merged.push((start, end)); + } + } else { + merged.push((start, end)); + } + } + + info!( + "Binary search complete. Found {} activity regions after {} iterations", + merged.len(), + iterations + ); + for (i, (start, end)) in merged.iter().enumerate() { + info!( + " Region {}: blocks {} to {} ({} blocks)", + i + 1, + start, + end, + end - start + 1 + ); + } + + Ok(merged) +} + +// Helper to check SINGLE block for activity - checks both FROM and TO +async fn check_single_block_for_activity( + provider: ð::Provider, + usdc_addr: &str, + tba: &str, + block: u64, +) -> anyhow::Result { + let transfer_sig = keccak256("Transfer(address,address,uint256)".as_bytes()); + + // Create padded address for topics + let mut pad = [0u8; 32]; + pad[12..].copy_from_slice(&hex::decode(&tba[2..]).unwrap_or_default()); + let topic_addr = B256::from(pad); + + // Check transfers FROM the TBA + let filter_from = Filter::new() + .address(Address::from_str(usdc_addr)?) + .event_signature(transfer_sig) + .topic1(topic_addr) // from address in topic1 + .from_block(block) + .to_block(block); + + match provider.get_logs(&filter_from) { + Ok(logs) if !logs.is_empty() => return Ok(true), + Err(e) if e.to_string().contains("10000 results") => return Ok(true), + _ => {} + } + // Check transfers TO the TBA + let filter_to = Filter::new() + .address(Address::from_str(usdc_addr)?) + .event_signature(transfer_sig) + .topic2(topic_addr) // to address in topic2 + .from_block(block) + .to_block(block); + + match provider.get_logs(&filter_to) { + Ok(logs) => Ok(!logs.is_empty()), + Err(e) if e.to_string().contains("10000 results") => Ok(true), + Err(_) => Ok(false), + } +} + +// Carefully scan blocks with max 9 block ranges +async fn scan_blocks_carefully( + state: &State, + db: &Sqlite, + provider: ð::Provider, + tba: &str, + from_block: u64, + to_block: u64, +) -> anyhow::Result { + let mut current = from_block; + let mut total = 0; + let mut consecutive_errors = 0; + + while current <= to_block { + let chunk_end = (current + 8).min(to_block); // Max 9 blocks + + match ingest_usdc_events_for_range(db, provider, tba, current, chunk_end).await { + Ok(count) => { + total += count; + current = chunk_end + 1; + consecutive_errors = 0; + } + Err(e) => { + let err_str = e.to_string(); + if err_str.contains("internal error") || err_str.contains("-32000") { + consecutive_errors += 1; + if consecutive_errors >= 3 { + info!("Too many consecutive errors, stopping"); + break; + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } else { + // Skip this range + current = chunk_end + 1; + } + } + } + } + + Ok(total) +} + +// Helper to check if a range has USDC activity +async fn check_for_usdc_activity( + provider: ð::Provider, + usdc_addr: &str, + tba: &str, + from_block: u64, + to_block: u64, +) -> anyhow::Result { + let transfer_sig = keccak256("Transfer(address,address,uint256)".as_bytes()); + + // Create padded address for topics + let mut pad = [0u8; 32]; + pad[12..].copy_from_slice(&hex::decode(&tba[2..]).unwrap_or_default()); + let topic_addr = B256::from(pad); + + // Check transfers FROM the TBA + let filter_from = Filter::new() + .address(Address::from_str(usdc_addr)?) + .event_signature(transfer_sig) + .topic1(topic_addr) + .from_block(from_block) + .to_block(to_block); + + match provider.get_logs(&filter_from) { + Ok(logs) if !logs.is_empty() => return Ok(true), + Err(e) if e.to_string().contains("10000 results") => return Ok(true), + _ => {} + } + + // Check transfers TO the TBA + let filter_to = Filter::new() + .address(Address::from_str(usdc_addr)?) + .event_signature(transfer_sig) + .topic2(topic_addr) + .from_block(from_block) + .to_block(to_block); + + match provider.get_logs(&filter_to) { + Ok(logs) if !logs.is_empty() => Ok(true), + Err(e) if e.to_string().contains("10000 results") => Ok(true), + _ => Ok(false), + } +} + +// Helper to ingest a range with automatic chunking +async fn ingest_range_with_retry( + state: &State, + db: &Sqlite, + provider: ð::Provider, + tba: &str, + from_block: u64, + to_block: u64, +) -> anyhow::Result { + let mut chunk_size = 9u64; // Max safe size + let mut current = from_block; + let mut total = 0; + + while current <= to_block { + let chunk_end = (current + chunk_size - 1).min(to_block); + + match ingest_usdc_events_for_range(db, provider, tba, current, chunk_end).await { + Ok(count) => { + total += count; + current = chunk_end + 1; + } + Err(e) => { + let err_str = e.to_string(); + if err_str.contains("10000 results") && chunk_size > 1 { + chunk_size = chunk_size.saturating_sub(1).max(1); + info!("Reducing chunk size to {}", chunk_size); + } else { + return Err(e); + } + } + } + } + + Ok(total) +} + +// Helper to scan recent blocks (for periodic updates) +async fn scan_recent_blocks( + state: &State, + db: &Sqlite, + provider: ð::Provider, + tba: &str, + from_block: u64, + to_block: u64, +) -> anyhow::Result { + let chunk_size = 100u64; // Recent blocks are less dense + let mut current = from_block; + let mut total = 0; + + while current <= to_block { + let chunk_end = (current + chunk_size - 1).min(to_block); + match ingest_range_with_retry(state, db, provider, tba, current, chunk_end).await { + Ok(count) => { + total += count; + current = chunk_end + 1; + } + Err(e) => { + info!("Error scanning recent blocks: {}", e); + break; + } + } + } + + Ok(total) +} + +pub async fn ingest_usdc_events_for_range( + db: &Sqlite, + provider: ð::Provider, + tba: &str, + from_block: u64, + to_block: u64, +) -> anyhow::Result { + ensure_usdc_events_table(db).await?; + + let tba_lower = tba.to_lowercase(); + let usdc_addr = USDC_BASE_ADDRESS.to_lowercase(); + + info!( + "Ingesting USDC events for {} in block range {} to {}", + tba, from_block, to_block + ); + + // Build filter for USDC Transfer events - use the old bisect approach with separate from/to filters + let transfer_sig = keccak256("Transfer(address,address,uint256)".as_bytes()); + + // Create padded address for topics (like old code) + let mut pad = [0u8; 32]; + pad[12..].copy_from_slice(&hex::decode(&tba_lower[2..]).unwrap_or_default()); + let topic_addr = B256::from(pad); + + // Two filters: one for transfers FROM the TBA, one for transfers TO the TBA + let filter_from = Filter::new() + .address(Address::from_str(&usdc_addr)?) + .event_signature(transfer_sig) + .topic1(topic_addr) // from address in topic1 + .from_block(from_block) + .to_block(to_block); + + let filter_to = Filter::new() + .address(Address::from_str(&usdc_addr)?) + .event_signature(transfer_sig) + .topic2(topic_addr) // to address in topic2 + .from_block(from_block) + .to_block(to_block); + + info!("Fetching USDC transfers FROM {} in range...", tba); + let logs_from = provider + .get_logs(&filter_from) + .map_err(|e| anyhow::anyhow!("Failed to get logs (from): {}", e))?; + + info!("Fetching USDC transfers TO {} in range...", tba); + let logs_to = provider + .get_logs(&filter_to) + .map_err(|e| anyhow::anyhow!("Failed to get logs (to): {}", e))?; + + // Combine and deduplicate logs + let mut all_logs = logs_from; + all_logs.extend(logs_to); + + // Deduplicate by transaction hash and log index + let mut seen = std::collections::HashSet::new(); + let logs: Vec<_> = all_logs + .into_iter() + .filter(|log| { + let key = (log.transaction_hash, log.log_index); + seen.insert(key) + }) + .collect(); + + info!("Retrieved {} total USDC transfer logs", logs.len()); + + let mut total_events = 0; + + // Process logs + for (idx, log) in logs.iter().enumerate() { + if log.topics().len() < 3 { + continue; + } + + let from_addr = format!("0x{}", hex::encode(&log.topics()[1].as_slice()[12..])); + let to_addr = format!("0x{}", hex::encode(&log.topics()[2].as_slice()[12..])); + + // Only process if TBA is involved + if !from_addr.eq_ignore_ascii_case(&tba_lower) && !to_addr.eq_ignore_ascii_case(&tba_lower) + { + continue; + } + + info!( + "Found transfer involving TBA at index {}: from={}, to={}", + idx, from_addr, to_addr + ); + + // Extract amount from data + let amount = U256::from_be_slice(log.data().data.as_ref()); + + // Insert event + let tx_hash = format!( + "0x{}", + hex::encode(log.transaction_hash.unwrap_or_default()) + ); + let block = log.block_number.unwrap_or(0); + let log_index = log.log_index.map(|i| i as u64); + + info!( + "Inserting USDC event: tx={}, block={}, amount={}", + tx_hash, block, amount + ); + + insert_usdc_event( + db, + &tba_lower, + block, + None, + &tx_hash, + log_index, + &from_addr, + &to_addr, + &amount.to_string(), + ) + .await?; + + total_events += 1; + } + + info!("Ingested {} USDC events for {} in range", total_events, tba); + Ok(total_events) +} + +// ===== BISECT IMPLEMENTATION ===== + +// Query historical ERC20 balance at a specific block +async fn erc20_balance_of_at( + provider: &Provider, + token: Address, + owner: Address, + block: u64, +) -> anyhow::Result { + sol! { + function balanceOf(address owner) external view returns (uint256 balance); + } + let call = balanceOfCall { owner }; + let data = call.abi_encode(); + let tx = TransactionRequest::default() + .input(TransactionInput::new(data.into())) + .to(token); + + let res = provider + .call(tx, Some(BlockId::Number(BlockNumberOrTag::Number(block)))) + .map_err(|e| anyhow::anyhow!("Failed to call balanceOf at block {}: {}", block, e))?; + + // Decode the result + if res.len() == 32 { + Ok(U256::from_be_slice(res.as_ref())) + } else { + let decoded = balanceOfCall::abi_decode_returns(&res, false) + .map_err(|e| anyhow::anyhow!("Failed to decode balanceOf result: {}", e))?; + Ok(decoded.balance) + } +} + +// Get TBA creation block from Hypermap +async fn get_tba_creation_block( + hypermap: &hyperware_process_lib::hypermap::Hypermap, + tba: &str, +) -> anyhow::Result { + // Get the namehash for this TBA + let tba_addr = Address::from_str(tba)?; + let namehash = hypermap.get_namehash_from_tba(tba_addr).unwrap_or_default(); + + if namehash.is_empty() { + info!("No namehash found for TBA, using HYPERMAP_FIRST_BLOCK"); + return Ok(hyperware_process_lib::hypermap::HYPERMAP_FIRST_BLOCK); + } + + // Look for Mint event for this namehash + let mut namehash_bytes = [0u8; 32]; + if let Ok(bytes) = hex::decode(namehash.trim_start_matches("0x")) { + if bytes.len() == 32 { + namehash_bytes.copy_from_slice(&bytes); + } + } + + let mint_filter = hypermap.mint_filter().topic2(B256::from(namehash_bytes)); + + let (_last_block, results) = hypermap + .bootstrap( + Some(hyperware_process_lib::hypermap::HYPERMAP_FIRST_BLOCK), + vec![mint_filter], + Some((5, Some(5))), + None, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to bootstrap for mint events: {:?}", e))?; + + let mints = results.get(0).cloned().unwrap_or_default(); + let creation_block = mints + .iter() + .filter_map(|log| log.block_number) + .min() + .unwrap_or(hyperware_process_lib::hypermap::HYPERMAP_FIRST_BLOCK); + + info!("TBA {} created at block {}", tba, creation_block); + Ok(creation_block) +} + +// Binary search to find block ranges where balance changed +async fn bisect_find_balance_change_ranges( + provider: &Provider, + token_addr: &str, + owner_addr: &str, + start: u64, + end: u64, + window_cap: u64, + cache: &mut std::collections::HashMap, +) -> anyhow::Result> { + let token = Address::from_str(token_addr)?; + let owner = Address::from_str(owner_addr)?; + + let mut ranges = Vec::new(); + bisect_recursive( + provider, + token, + owner, + start, + end, + window_cap, + cache, + &mut ranges, + ) + .await?; + + info!("Found {} ranges with balance changes", ranges.len()); + Ok(ranges) +} + +// Recursive bisection helper +async fn bisect_recursive( + provider: &Provider, + token: Address, + owner: Address, + start: u64, + end: u64, + window_cap: u64, + cache: &mut std::collections::HashMap, + ranges_out: &mut Vec<(u64, u64)>, +) -> anyhow::Result<()> { + if start >= end { + return Ok(()); + } + + // Get balance at start + let bal_start = if let Some(&b) = cache.get(&start) { + b + } else { + let b = erc20_balance_of_at(provider, token, owner, start).await?; + cache.insert(start, b); + b + }; + + // Get balance at end + let bal_end = if let Some(&b) = cache.get(&end) { + b + } else { + let b = erc20_balance_of_at(provider, token, owner, end).await?; + cache.insert(end, b); + b + }; + + // If no balance change, nothing to do + if bal_start == bal_end { + return Ok(()); + } + + // If range is small enough, add it + if end - start <= window_cap { + info!( + "Found balance change in range {}-{}: {} -> {}", + start, end, bal_start, bal_end + ); + ranges_out.push((start, end)); + return Ok(()); + } + + // Otherwise, bisect + let mid = start + (end - start) / 2; + + // Get balance at midpoint + let bal_mid = if let Some(&b) = cache.get(&mid) { + b + } else { + let b = erc20_balance_of_at(provider, token, owner, mid).await?; + cache.insert(mid, b); + b + }; + + // Check left half + if bal_mid != bal_start { + Box::pin(bisect_recursive( + provider, token, owner, start, mid, window_cap, cache, ranges_out, + )) + .await?; + } + + // Check right half + if bal_mid != bal_end { + Box::pin(bisect_recursive( + provider, + token, + owner, + mid + 1, + end, + window_cap, + cache, + ranges_out, + )) + .await?; + } + + Ok(()) +} + +// Process found ranges by fetching logs +async fn process_balance_change_ranges( + db: &Sqlite, + provider: &Provider, + tba: &str, + ranges: Vec<(u64, u64)>, +) -> anyhow::Result { + let tba_lower = tba.to_lowercase(); + let usdc_addr = Address::from_str(USDC_BASE_ADDRESS)?; + let transfer_sig = keccak256("Transfer(address,address,uint256)".as_bytes()); + + // Create padded address for topics + let mut pad = [0u8; 32]; + pad[12..].copy_from_slice(&hex::decode(&tba_lower[2..]).unwrap_or_default()); + let topic_addr = B256::from(pad); + + let mut total_inserted = 0usize; + + for (lo, hi) in ranges { + info!("Processing range {}-{}", lo, hi); + + // Fetch transfers FROM the TBA + let filter_from = Filter::new() + .address(usdc_addr) + .event_signature(transfer_sig) + .topic1(topic_addr) + .from_block(lo) + .to_block(hi); + + // Fetch transfers TO the TBA + let filter_to = Filter::new() + .address(usdc_addr) + .event_signature(transfer_sig) + .topic2(topic_addr) + .from_block(lo) + .to_block(hi); + + for (direction, filter) in [("FROM", filter_from), ("TO", filter_to)] { + match provider.get_logs(&filter) { + Ok(logs) => { + info!( + "Found {} {} transfers in range {}-{}", + logs.len(), + direction, + lo, + hi + ); + + for log in logs { + let tx_hash = match log.transaction_hash { + Some(h) => format!("0x{}", hex::encode(h)), + None => continue, + }; + + if log.topics().len() < 3 { + continue; + } + + let from_addr = + format!("0x{}", hex::encode(&log.topics()[1].as_slice()[12..])); + let to_addr = + format!("0x{}", hex::encode(&log.topics()[2].as_slice()[12..])); + let amount = U256::from_be_slice(log.data().data.as_ref()); + let block = log.block_number.unwrap_or(lo); + let log_index = log.log_index.map(|v| v as u64); + + insert_usdc_event( + db, + &tba_lower, + block, + None, + &tx_hash, + log_index, + &from_addr, + &to_addr, + &amount.to_string(), + ) + .await?; + + total_inserted += 1; + } + } + Err(e) => { + info!( + "Error fetching {} logs for range {}-{}: {}", + direction, lo, hi, e + ); + } + } + } + } + + Ok(total_inserted) +} + +// Main bisect ingestion function +pub async fn ingest_usdc_history_via_bisect( + _state: &State, + db: &Sqlite, + provider: ð::Provider, + hypermap: &hyperware_process_lib::hypermap::Hypermap, + tba: &str, +) -> anyhow::Result { + ensure_usdc_events_table(db).await?; + + let tba_lower = tba.to_lowercase(); + info!("Starting USDC bisect ingestion for TBA: {}", tba_lower); + + // Check if we've already scanned recently to avoid redundant work + let last_scan_query = r#" + SELECT MAX(block) as last_block, MIN(block) as first_block, COUNT(*) as event_count + FROM usdc_events + WHERE address = ?1 + "# + .to_string(); + let last_scan = db + .read( + last_scan_query, + vec![serde_json::Value::String(tba_lower.clone())], + ) + .await?; + let last_scanned_block = last_scan + .first() + .and_then(|r| r.get("last_block")) + .and_then(|v| v.as_i64()) + .map(|b| b as u64); + let first_scanned_block = last_scan + .first() + .and_then(|r| r.get("first_block")) + .and_then(|v| v.as_i64()) + .map(|b| b as u64); + let existing_events = last_scan + .first() + .and_then(|r| r.get("event_count")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + // [Get Current Block] + let end_block = match provider.get_block_number() { + Ok(block) => block, + Err(e) => { + info!( + "Failed to get current block: {}. Using last scanned + 1000", + e + ); + last_scanned_block.unwrap_or(0) + 1000 + } + }; + + // [Get TBA Creation Block from Hypermap] - with optimization + let start_block = if let Some(first) = first_scanned_block { + // If we already have events, use the first event block as start + // This avoids the expensive hypermap lookup + info!( + "Using first event block {} as start (skipping hypermap lookup)", + first + ); + first + } else { + // Only do expensive hypermap lookup if we have no events + match get_tba_creation_block(hypermap, &tba_lower).await { + Ok(block) => block, + Err(e) => { + info!("Failed to get TBA creation block: {}. Using fallback", e); + end_block.saturating_sub(100000) // Default 100k blocks back + } + } + }; + + // If we've already scanned up to near the current block, only scan new blocks + if let Some(last) = last_scanned_block { + if last + 1000 >= end_block && existing_events > 0 { + info!( + "Already scanned up to block {}. Only checking recent blocks.", + last + ); + let recent_start = last + 1; + if recent_start >= end_block { + info!("No new blocks to scan"); + return Ok(0); + } + + // Just scan the recent blocks without bisect + match process_balance_change_ranges( + db, + provider, + &tba_lower, + vec![(recent_start, end_block)], + ) + .await + { + Ok(n) => return Ok(n), + Err(e) => { + info!( + "Error scanning recent blocks: {}. Continuing with full bisect.", + e + ); + } + } + } + } + + info!("Bisect scan from block {} to {}", start_block, end_block); + + if start_block >= end_block { + info!("No blocks to scan"); + return Ok(0); + } + + // [Initialize Balance Cache] + let mut balance_cache = std::collections::HashMap::new(); + + // [Bisect Algorithm: find_ranges(start, end)] - with error handling + let ranges = match bisect_find_balance_change_ranges( + provider, + USDC_BASE_ADDRESS, + &tba_lower, + start_block, + end_block, + 10, // window_cap = 10 blocks for current RPC limits + &mut balance_cache, + ) + .await + { + Ok(ranges) => ranges, + Err(e) => { + info!("Bisect algorithm failed: {}. Attempting fallback scan.", e); + // Fallback: just scan recent activity + vec![(end_block.saturating_sub(10000), end_block)] + } + }; + + if ranges.is_empty() { + info!("No USDC balance changes detected. Nothing to fetch."); + return Ok(0); + } + + info!("{} change windows to fetch logs for", ranges.len()); + + // [For each range in ranges] - with error tolerance + let total_inserted = match process_balance_change_ranges(db, provider, &tba_lower, ranges).await + { + Ok(n) => n, + Err(e) => { + info!( + "Error processing ranges: {}. Partial results may have been saved.", + e + ); + 0 + } + }; + + info!( + "Bisect USDC scan complete. Rows inserted: {}", + total_inserted + ); + + // Update scan state to current block + if let Err(e) = update_scan_state(db, &tba_lower, end_block).await { + error!("Failed to update scan state: {:?}", e); + } + + Ok(total_inserted) +} + +/// Check if we need to run expensive bisect ingestion +pub async fn check_needs_bisect_ingestion(db: &Sqlite, tba: &str) -> anyhow::Result { + ensure_usdc_events_table(db).await?; + ensure_ledger_scan_state_table(db).await?; + + let tba_lower = tba.to_lowercase(); + + // First check when we last scanned + let scan_query = r#" + SELECT last_scan_block + FROM ledger_scan_state + WHERE tba_address = ?1 + "# + .to_string(); + + let scan_rows = db + .read( + scan_query, + vec![serde_json::Value::String(tba_lower.clone())], + ) + .await?; + let last_scan_block = scan_rows + .first() + .and_then(|r| r.get("last_scan_block")) + .and_then(|v| v.as_i64()) + .map(|b| b as u64); + + // Get current block + let provider = hyperware_process_lib::eth::Provider::new(crate::structs::CHAIN_ID, 30000); + let current_block = match provider.get_block_number() { + Ok(block) => block, + Err(e) => { + info!( + "Failed to get current block: {}, assuming bisect not needed", + e + ); + return Ok(false); + } + }; + + // If we've scanned recently (within 10k blocks), we don't need bisect + if let Some(last_scan) = last_scan_block { + let scan_gap = current_block.saturating_sub(last_scan); + if scan_gap < 10_000 { + info!( + "Last scan was {} blocks ago (block {}), no bisect needed", + scan_gap, last_scan + ); + return Ok(false); + } + } + + // Check what events we have + let query = r#" + SELECT + COUNT(*) as count, + MAX(block) as last_block + FROM usdc_events + WHERE address = ?1 + "# + .to_string(); + + let params = vec![serde_json::Value::String(tba_lower)]; + let rows = db.read(query, params).await?; + + if let Some(row) = rows.first() { + let count = row.get("count").and_then(|v| v.as_i64()).unwrap_or(0); + let last_event_block = row + .get("last_block") + .and_then(|v| v.as_i64()) + .map(|b| b as u64); + + // If we have no events and haven't scanned recently, need bisect + if count == 0 && last_scan_block.is_none() { + info!("No USDC events found and no scan history, bisect needed"); + return Ok(true); + } + + // If we have events but they're very old and we haven't scanned recently + if let Some(last_event) = last_event_block { + let event_gap = current_block.saturating_sub(last_event); + + if event_gap > 50_000 && last_scan_block.is_none() { + info!( + "Last USDC event is {} blocks old and no recent scan, bisect needed", + event_gap + ); + return Ok(true); + } + } + } + + info!("No bisect needed based on scan history and event data"); + Ok(false) +} + +/// Ensure the ledger scan state table exists +async fn ensure_ledger_scan_state_table(db: &Sqlite) -> anyhow::Result<()> { + let stmt = r#" + CREATE TABLE IF NOT EXISTS ledger_scan_state ( + tba_address TEXT PRIMARY KEY, + last_scan_block INTEGER NOT NULL, + last_scan_time INTEGER NOT NULL + ); + "# + .to_string(); + db.write(stmt, vec![], None).await?; + Ok(()) +} + +/// Update the last scan state +async fn update_scan_state(db: &Sqlite, tba: &str, block: u64) -> anyhow::Result<()> { + ensure_ledger_scan_state_table(db).await?; + + let stmt = r#" + INSERT OR REPLACE INTO ledger_scan_state (tba_address, last_scan_block, last_scan_time) + VALUES (?1, ?2, ?3); + "# + .to_string(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let params = vec![ + serde_json::Value::String(tba.to_lowercase()), + serde_json::Value::Number(serde_json::Number::from(block as i64)), + serde_json::Value::Number(serde_json::Number::from(now)), + ]; + + db.write(stmt, params, None).await?; + Ok(()) +} + +/// Scan only recent blocks for new USDC events +pub async fn scan_recent_blocks_only( + db: &Sqlite, + provider: ð::Provider, + tba: &str, +) -> anyhow::Result { + ensure_usdc_events_table(db).await?; + + let tba_lower = tba.to_lowercase(); + + // Get last scanned block + let query = r#" + SELECT MAX(block) as last_block + FROM usdc_events + WHERE address = ?1 + "# + .to_string(); + + let params = vec![serde_json::Value::String(tba_lower.clone())]; + let rows = db.read(query, params).await?; + + let last_block = rows + .first() + .and_then(|r| r.get("last_block")) + .and_then(|v| v.as_i64()) + .map(|b| b as u64); + + // Get current block + let current_block = provider + .get_block_number() + .map_err(|e| anyhow::anyhow!("Failed to get current block: {}", e))?; + + let start_block = match last_block { + Some(last) => { + if last >= current_block { + info!("Already up to date at block {}", last); + return Ok(0); + } + last + 1 + } + None => { + // No history, scan last 1000 blocks + current_block.saturating_sub(1000) + } + }; + + if start_block >= current_block { + return Ok(0); + } + + info!( + "Scanning recent blocks {} to {} for USDC events", + start_block, current_block + ); + + // Use the existing range ingestion function + let result = + ingest_usdc_events_for_range(db, provider, &tba_lower, start_block, current_block).await; + + // Update scan state regardless of whether we found events + if result.is_ok() { + if let Err(e) = update_scan_state(db, &tba_lower, current_block).await { + error!("Failed to update scan state: {:?}", e); + } + } + + result +} + +/// Load recent call records from the ledger +pub async fn load_recent_call_history( + db: &Sqlite, + tba: &str, + limit: usize, + state: Option<&crate::structs::State>, +) -> anyhow::Result> { + ensure_usdc_call_ledger_table(db).await?; + + let query = r#" + SELECT + tx_hash, + block, + time, + client_id, + provider_name, + provider_address, + provider_cost_units, + gas_fees_units, + total_cost_units + FROM usdc_call_ledger + WHERE tba_address = ?1 AND block > 0 + ORDER BY block DESC + LIMIT ?2 + "# + .to_string(); + + let params = vec![ + serde_json::Value::String(tba.to_lowercase()), + serde_json::Value::Number((limit as i64).into()), + ]; + + let rows = db.read(query, params).await?; + let mut records = Vec::new(); + + for row in rows { + let tx_hash = row + .get("tx_hash") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let block = row.get("block").and_then(|v| v.as_i64()).unwrap_or(0) as u64; + let client_id = row + .get("client_id") + .and_then(|v| v.as_str()) + .map(String::from); + let provider_name = row + .get("provider_name") + .and_then(|v| v.as_str()) + .map(String::from); + let provider_address = row + .get("provider_address") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let total_cost_units = row + .get("total_cost_units") + .and_then(|v| v.as_str()) + .unwrap_or("0"); + + // Convert units to display amount + let amount_paid = if let Ok(units) = total_cost_units.parse::() { + let whole = units / 1_000_000; + let frac = (units % 1_000_000).abs(); + format!("{}.{:06}", whole, frac) + } else { + "0.0".to_string() + }; + + // Build payment result + let payment_result = if !tx_hash.is_empty() && total_cost_units != "0" { + Some(crate::structs::PaymentAttemptResult::Success { + tx_hash: tx_hash.clone(), + amount_paid, + currency: "USDC".to_string(), + }) + } else { + Some(crate::structs::PaymentAttemptResult::Skipped { + reason: "Zero Price".to_string(), + }) + }; + + // Serialize payment result to JSON string for WIT compatibility + let payment_result_json = payment_result + .as_ref() + .map(|pr| serde_json::to_string(pr).unwrap_or_else(|_| "null".to_string())); + + // Look up the actual provider ID from the wallet address + let provider_id = if !provider_address.is_empty() { + match crate::db::get_providers_by_wallet(db, &provider_address).await { + Ok(providers) => { + // If we have the provider name, find the matching provider + if let Some(ref name) = provider_name { + providers.iter() + .find(|p| p.get("name").and_then(|v| v.as_str()) == Some(name)) + .or_else(|| providers.first()) // Fallback to first if name doesn't match + .and_then(|p| p.get("provider_id")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| provider_address.clone()) + } else { + // No name to match on, use first provider with this wallet + providers.first() + .and_then(|p| p.get("provider_id")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| provider_address.clone()) + } + } + Err(_) => provider_address.clone(), // Fallback to wallet address if lookup fails + } + } else { + provider_address.clone() + }; + + // Look up operator_wallet_id from state if available + let operator_wallet_id = if let (Some(st), Some(ref cid)) = (state, &client_id) { + st.authorized_clients + .iter() + .find(|(id, _)| id == cid) + .map(|(_, client)| client.associated_hot_wallet_address.clone()) + } else { + None + }; + + // Create a CallRecord + let record = crate::structs::CallRecord { + timestamp_start_ms: block * 1000, // Approximate (block to ms) + provider_lookup_key: provider_id.clone(), + target_provider_id: provider_id, + call_args_json: "[]".to_string(), // Not stored in ledger + response_json: None, // Not stored in ledger + call_success: true, // Assume success if in ledger + response_timestamp_ms: block * 1000, + payment_result: payment_result_json, + duration_ms: 0, // Not stored + operator_wallet_id, + client_id, + provider_name, + }; + + records.push(record); + } + + Ok(records) +} diff --git a/operator/operator/src/lib.rs b/operator/operator/src/lib.rs index 50522d8..d6391c7 100644 --- a/operator/operator/src/lib.rs +++ b/operator/operator/src/lib.rs @@ -1,275 +1,756 @@ -pub mod graph; -mod structs; -mod http_handlers; -//mod wallet_manager; +#![allow(ambiguous_associated_items)] + +use chrono::Utc; +use hyperprocess_macro::hyperprocess; +use serde::{Deserialize, Serialize}; +use serde_json; + +mod app_api_types; +pub mod constants; mod db; -mod chain; -mod helpers; +mod eth; mod identity; -mod authorized_services; -pub mod constants; -pub mod ledger; -// Keep local module for functions not yet available in the library -pub mod hyperwallet_client; - - -use hyperware_process_lib::homepage::add_to_homepage; -use hyperware_process_lib::http::server::{HttpBindingConfig, HttpServer}; -use hyperware_process_lib::logging::{info, init_logging, Level, error, RemoteLogSettings}; -use hyperware_process_lib::{await_message, call_init, Address, Message}; -use hyperware_process_lib::sqlite::Sqlite; -// Import the new hyperwallet client library with alias to avoid naming conflict -use hyperware_process_lib::hyperwallet_client as hw_lib; -use hw_lib::{initialize, HandshakeConfig, Operation, SpendingLimits}; -use structs::*; - -//use crate::wallet::{service as wallet_service}; - -const ICON: &str = include_str!("./icon"); - - -// TODO b4 beta: clean these endpoints up -fn init_http() -> anyhow::Result { - let mut http_server = HttpServer::new(5); - let http_config_authenticated = HttpBindingConfig::default().authenticated(true); - let http_config_unauthenticated = HttpBindingConfig::default() - .authenticated(false) - .local_only(false) - .secure_subdomain(false); - - // REST API endpoints - http_server.bind_http_path("/api/search", http_config_authenticated.clone())?; - http_server.bind_http_path("/api/state", http_config_authenticated.clone())?; - http_server.bind_http_path("/api/actions", http_config_authenticated.clone())?; - - http_server.bind_http_path("/api/all", http_config_authenticated.clone())?; - http_server.bind_http_path("/api/setup-status", http_config_authenticated.clone())?; - http_server.bind_http_path("/api/onboarding-status", http_config_authenticated.clone())?; - http_server.bind_http_path("/api/verify-delegation-and-funding", http_config_authenticated.clone())?; - - // Graph endpoints - http_server.bind_http_path("/api/hypergrid-graph", http_config_authenticated.clone())?; - http_server.bind_http_path("/api/managed-wallets", http_config_authenticated.clone())?; - http_server.bind_http_path("/api/linked-wallets", http_config_authenticated.clone())?; - - // MCP endpoints - http_server.bind_http_path("/api/mcp", http_config_authenticated.clone())?; - http_server.bind_http_path("/api/save-shim-key", http_config_authenticated.clone())?; - - // MCP Shim endpoints (X-API-Key validation) - http_server.bind_http_path("/shim/mcp", http_config_unauthenticated.clone())?; - - // Spider integration endpoints - http_server.bind_http_path("/api/spider-connect", http_config_authenticated.clone())?; - http_server.bind_http_path("/api/spider-chat", http_config_authenticated.clone())?; - http_server.bind_http_path("/api/spider-status", http_config_authenticated.clone())?; - - // UI - add_to_homepage("Hypergrid", Some(ICON), Some("/"), None); - // this changes depending on you are only building operator, or both - // change back to just ui when building only operator to not have to build the provider - http_server.serve_ui("ui", vec!["/"], http_config_authenticated)?; - - Ok(http_server) +mod init; +mod ledger; +mod shim; +mod structs; +mod terminal; +mod wallet; +mod websocket; + +use crate::app_api_types::{AuthorizeResult, ProviderInfo, ProviderSearchResult, TerminalCommand}; +use crate::structs::{ + generate_shim_client_id, ConfigureAuthorizedClientDto, ConfigureAuthorizedClientResult, State, + WalletSummary, +}; +use crate::websocket::{StateUpdateTopic, WsClientMessage, WsConnection, WsServerMessage}; +use hyperware_process_lib::eth::EthSubResult; +use hyperware_process_lib::homepage; +use hyperware_process_lib::http::server::WsMessageType; +use hyperware_process_lib::hypermap; +use hyperware_process_lib::logging::{error, info}; +use hyperware_process_lib::LazyLoadBlob; +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct OperatorProcess { + // Make state private to prevent WIT generation + pub(crate) state: State, + // not serialized, not exposed to WIT + #[serde(skip)] + pub(crate) hypermap: Option, + #[serde(skip)] + pub(crate) providers_cache: HashMap, + #[serde(skip)] + pub(crate) active_signer: Option, + #[serde(skip)] + pub(crate) ws_connections: HashMap, + #[serde(skip)] + pub(crate) db_conn: Option, + #[serde(skip)] + pub(crate) hyperwallet_session: Option, } -call_init!(init); -fn init(our: Address) { - let remote_logger: RemoteLogSettings = RemoteLogSettings { target: Address::new("hypergrid-logger.hypr", ("logging", "logging", "nick.hypr") ), level: Level::ERROR }; - init_logging(Level::DEBUG, Level::INFO, Some(remote_logger), None, Some(250 * 1024 * 1024)).expect("Failed to initialize logging"); // 250MB log files - info!("begin hypergrid operator for: {}", our.node); - - let mut state = State::load(); +impl Default for OperatorProcess { + fn default() -> Self { + // Always start with fresh state in hyperapp framework + // The framework handles persistence separately from the old MessagePack format + Self { + state: State::new(), + hypermap: None, + providers_cache: HashMap::new(), + active_signer: None, + ws_connections: HashMap::new(), + db_conn: None, + hyperwallet_session: None, + } + } +} - // Initialize Operator Identity using the new module - if let Err(e) = identity::initialize_operator_identity(&our, &mut state) { - error!("Failed during operator identity initialization: {:?}", e); +#[hyperprocess( + name = "operator", + ui = Some(HttpBindingConfig::default()), + endpoints = vec![ + Binding::Http { + path: "/api", + config: HttpBindingConfig::default() + }, + Binding::Http { + path: "/mcp-authorize", + config: HttpBindingConfig::new(false, false, false, None) + }, + Binding::Http { + path: "/mcp-configure-authorized-client", + config: HttpBindingConfig::new(false, false, false, None) + }, + Binding::Http { + path: "/mcp-search-registry", + config: HttpBindingConfig::new(false, false, false, None) + }, + Binding::Http { + path: "/mcp-call-provider", + config: HttpBindingConfig::new(false, false, false, None) + }, + Binding::Ws { + path: "/ws", + config: WsBindingConfig::new(false, false, false) + }, + ], + save_config = hyperware_process_lib::hyperapp::SaveOptions::EveryMessage, + wit_world = "operator-sortugdev-dot-os-v0" +)] +impl OperatorProcess { + #[init] + async fn init(&mut self) { + homepage::add_to_homepage("Hypergrid", Some(include_str!("./icon")), Some("/"), None); + self.hypermap = Some(hyperware_process_lib::hypermap::Hypermap::default(60)); + + init::initialize_hyperwallet(self).await; + + init::initialize_database(self).await; + + eth::setup_subscriptions(self).await; + + init::initialize_identity(self).await; + + init::initialize_ledger(self).await; } - // Initialize hyperwallet connection using new handshake protocol - // Set up default spending limits for the operator - let default_limits = SpendingLimits { - per_tx_eth: Some("1.0".to_string()), // 1 ETH per transaction - daily_eth: Some("10.0".to_string()), // 10 ETH daily limit - per_tx_usdc: Some("100.0".to_string()), // 100 USDC per transaction - daily_usdc: Some("1000.0".to_string()), // 1000 USDC daily limit - daily_reset_at: 0, // Will be set by hyperwallet - spent_today_eth: "0".to_string(), // Will be tracked by hyperwallet - spent_today_usdc: "0".to_string(), // Will be tracked by hyperwallet - }; - - let config = HandshakeConfig::new() - .with_operations(&[ - Operation::CreateWallet, - Operation::ImportWallet, - Operation::ListWallets, - Operation::GetWalletInfo, - Operation::SetWalletLimits, - Operation::SendEth, - Operation::SendToken, - Operation::ExecuteViaTba, - Operation::GetBalance, - Operation::GetTokenBalance, - Operation::ResolveIdentity, - Operation::CreateNote, - Operation::ReadNote, - Operation::SetupDelegation, - Operation::VerifyDelegation, - Operation::GetTransactionHistory, - Operation::UpdateSpendingLimits, - Operation::RenameWallet, - Operation::BuildUserOperation, - Operation::BuildAndSignUserOperation, - Operation::BuildAndSignUserOperationForPayment, - Operation::SignUserOperation, - Operation::SubmitUserOperation, - Operation::EstimateUserOperationGas, - Operation::GetUserOperationReceipt, - Operation::ConfigurePaymaster, - ]) - .with_spending_limits(default_limits) - .with_name("hypergrid-operator"); - - match initialize(config) { - Ok(session) => { - info!("Hyperwallet session established successfully"); - state.hyperwallet_session = Some(session); + #[local] + #[http] + async fn recheck_identity(&mut self) -> Result<(), String> { + info!("Rechecking operator identity..."); + + let our = hyperware_process_lib::our(); + match crate::identity::initialize_operator_identity(&our, &mut self.state) { + Ok(_) => { + info!("Identity recheck completed successfully"); + + // If we found an identity, reinitialize ledger if needed + if self.state.operator_tba_address.is_some() { + if let Some(db) = &self.db_conn { + init::initialize_ledger_if_ready( + &mut self.state, + db, + self.hypermap.as_ref(), + ) + .await; + + // Also notify clients if we have authorized clients + if !self.state.authorized_clients.is_empty() { + init::notify_authorization_after_ledger_init(self).await; + } + } + } + + // Send state update to WebSocket clients + let connections: Vec = self.ws_connections.keys().cloned().collect(); + for channel_id in connections { + self.send_state_snapshot(channel_id).await; + } + + Ok(()) + } + Err(e) => { + error!("Failed to recheck identity: {:?}", e); + Err(format!("Identity recheck failed: {}", e)) + } } - Err(e) => { - error!("FATAL: Hyperwallet initialization failed: {:?}", e); - error!("The operator requires hyperwallet service to be running and accessible."); - error!("Please ensure hyperwallet:hyperwallet:sys is installed and running."); - panic!("Hyperwallet initialization failed - operator cannot function without it"); + } + + #[local] + #[http] + async fn recheck_paymaster_approval(&mut self) -> Result<(), String> { + info!("Rechecking paymaster approval status..."); + + // Only check if we have an operator TBA and gasless is enabled + if let (Some(tba_address), Some(true)) = + (&self.state.operator_tba_address, self.state.gasless_enabled) + { + let provider = hyperware_process_lib::eth::Provider::new(structs::CHAIN_ID, 30000); + let paymaster = crate::constants::CIRCLE_PAYMASTER; + + match hyperware_process_lib::wallet::erc20_allowance( + crate::constants::USDC_BASE_ADDRESS, + tba_address, + paymaster, + &provider, + ) { + Ok(allowance) => { + let approved = allowance > alloy_primitives::U256::ZERO; + info!( + "Paymaster approval recheck: {} (allowance: {})", + if approved { "APPROVED" } else { "NOT APPROVED" }, + allowance + ); + self.state.paymaster_approved = Some(approved); + + // Send state update to WebSocket clients + let connections: Vec = self.ws_connections.keys().cloned().collect(); + for channel_id in connections { + self.send_state_snapshot(channel_id).await; + } + + Ok(()) + } + Err(e) => { + error!("Failed to check paymaster approval: {:?}", e); + Err(format!("Paymaster approval check failed: {}", e)) + } + } + } else { + Ok(()) } } - // Generate a wallet for the operator - // if no wallet in state, generate one - if state.selected_wallet_id.is_none() { - info!("No wallet selected, generating initial wallet"); - if let Err(e) = hyperwallet_client::service::generate_initial_wallet(&mut state) { - error!("Failed to generate wallet for operator: {:?}", e); + // ===== MCP Endpoints ===== + + // Authorize endpoint - returns configuration for shim to save locally + #[http(path = "/mcp-authorize")] + async fn authorize( + &mut self, + node: String, + token: String, + client_id: String, + name: Option, + ) -> Result { + info!( + "Handling authorize request for node: {} with client_id: {}", + node, client_id + ); + + let hashed_token = shim::hash_authentication_token(&token); + + // Check if client already exists + let client_exists = self + .state + .authorized_clients + .iter() + .any(|(id, _)| id == &client_id); + + if client_exists { + // Client already exists, update the token and optionally the name + info!( + "Client {} already exists, updating token and name", + client_id + ); + // Find and update the client's token and name + for (id, client) in &mut self.state.authorized_clients { + if id == &client_id { + client.authentication_token = hashed_token; + if let Some(new_name) = name { + client.name = new_name; + } + break; + } + } + } else { + // Create new client + info!("Creating new client {} with name: {:?}", client_id, name); + let client = shim::create_authorization_client(&node, &client_id, &hashed_token, name); + shim::store_client(&mut self.state, client_id.clone(), client); } - } else { - info!("Wallet selected: {}", state.selected_wallet_id.as_ref().unwrap()); + + Ok(shim::build_authorization_response(client_id, token, node)) } - // Save state with session info - state.save(); + // this can be hit from anywhere + #[http(path = "/mcp-configure-authorized-client")] + async fn configure_authorized_client( + &mut self, + req: ConfigureAuthorizedClientDto, + ) -> Result { + info!("Handling configure authorized client request"); - // Initialize DB as local variable - info!("Loading database.."); - let db = match db::load_db(&our) { - Ok(db_conn) => db_conn, - Err(e) => { - error!("FATAL: Failed to load database: {:?}", e); - panic!("DB Load Failed!"); - } - }; + let hashed_token = shim::hash_authentication_token(&req.raw_token); - // Bootstrap USDC ledger and refresh client totals (no network scans). - if let Some(tba) = state.operator_tba_address.clone() { - if let Err(e) = crate::ledger::ensure_usdc_events_table(&db) { - error!("Failed ensuring usdc_events table: {:?}", e); - } - if let Err(e) = crate::ledger::ensure_usdc_call_ledger_table(&db) { - error!("Failed ensuring usdc_call_ledger table: {:?}", e); - } - match crate::ledger::build_usdc_ledger_for_tba(&state, &db, &tba.to_lowercase()) { - Ok(n) => info!("USDC ledger built on boot for {} ({} rows)", tba, n), - Err(e) => error!("Failed to build USDC ledger on boot: {:?}", e), - } - if let Err(e) = state.refresh_client_totals_from_ledger(&db, &tba) { - error!("Failed to refresh client totals from ledger: {:?}", e); + let (client_id, is_update) = if let Some(existing_id) = &req.client_id { + shim::update_existing_client(&mut self.state, existing_id, &hashed_token, &req)? } else { - // Persist updated totals for UI - state.save(); - } + shim::create_new_client(&mut self.state, &req, &hashed_token)? + }; + + shim::log_client_operation(&client_id, is_update); + + let our = hyperware_process_lib::our(); + Ok(shim::build_configuration_response( + client_id, + req.raw_token, + our.node().to_string(), + )) } - // Attach DB conn to state for http handlers that need it (e.g., enriching call history) - state.db_conn = Some(db.clone()); + // Search registry endpoint for shim + #[http(path = "/mcp-search-registry")] + async fn search_registry( + &mut self, + query: String, + client_id: String, + token: String, + ) -> Result, String> { + info!("Handling search_registry request for query: {}", query); + + shim::authenticate_client(&self.state, &client_id, &token)?; - // get state, check if there is a hot wallet, if not, create one + let db = self + .db_conn + .as_ref() + .ok_or("Database connection not available")?; - // Initialize Chain Syncing - //let mut pending_logs: PendingLogs = Vec::new(); - info!("Starting chain fetch..."); - let mut pending_logs = chain::start_fetch(&mut state, &db); - info!("Chain listeners initialized."); + shim::perform_registry_search(db, &query).await + } + + // Call provider endpoint for shim + #[http(path = "/mcp-call-provider")] + async fn call_provider( + &mut self, + provider_id: String, + provider_name: String, + args: Vec, + client_id: String, + token: String, + ) -> Result { + info!( + "Handling call_provider request for provider: {}", + provider_id + ); + + shim::authenticate_client(&self.state, &client_id, &token)?; + + let client_config = shim::get_client_config(&self.state, &client_id)?; + info!("Client config: {:#?}", client_config); + + let timestamp_start_ms = Utc::now().timestamp_millis() as u128; + let call_args_json = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string()); + + let db = self + .db_conn + .as_ref() + .ok_or("Database connection not available")?; + let provider_details = shim::fetch_provider_from_db(db, &provider_id).await?; + info!("Provider details: {:#?}", provider_details); + + shim::perform_health_check(&provider_details)?; + + shim::enforce_client_spending_limits(&self.state, db, &client_config, &provider_details) + .await?; + + let payment_result = + shim::process_payment_if_required(self, &provider_details, Some(&client_config)) + .await?; + + shim::execute_provider_call_with_metrics( + self, + provider_details, + provider_name.clone(), + args, + timestamp_start_ms, + call_args_json, + payment_result, + Some(client_config), + ) + .await + } - // Initialize HTTP server - match init_http() { - Ok(_http_server) => info!("Successfully initialized and bound HTTP server."), - Err(e) => error!("FATAL: Failed to initialize HTTP server: {:?}", e), + #[local] + #[http] + async fn remove_authorized_client(&mut self, client_id: String) -> Result<(), String> { + self.state + .authorized_clients + .retain(|(id, _)| id != &client_id); + Ok(()) } - - info!("Entering main message loop..."); - loop { - if let Err(e) = main(&our, &mut state, &db, &mut pending_logs) { - error!("Error in main loop: {:?}", e); - break; + + #[local] + #[http] + async fn rename_authorized_client( + &mut self, + client_id: String, + new_name: String, + ) -> Result<(), String> { + // Find and update the client's name + for (id, client) in &mut self.state.authorized_clients { + if id == &client_id { + client.name = new_name; + return Ok(()); + } } + Err(format!("Client {} not found", client_id)) } - info!("Exited main message loop."); -} -fn main( - our: &Address, - state: &mut State, - db: &Sqlite, - pending_logs: &mut PendingLogs -) -> anyhow::Result<()> { - let message = await_message()?; - match message { - // Updated handler signatures - Message::Request { source, body, .. } => handle_request(our, &source, body, state, db, pending_logs), - Message::Response { source, body, context, ..} => handle_response(our, &source, body, context, state, db, pending_logs), + #[local] + #[http] + async fn toggle_client_status(&mut self, client_id: String) -> Result<(), String> { + // Find and toggle the client's status + for (id, client) in &mut self.state.authorized_clients { + if id == &client_id { + client.status = match client.status { + structs::ClientStatus::Active => structs::ClientStatus::Halted, + structs::ClientStatus::Halted => structs::ClientStatus::Active, + }; + info!("Toggled client {} status to {:?}", client_id, client.status); + + // Notify WebSocket clients about the status change + self.notify_authorization_update(); + + return Ok(()); + } + } + Err(format!("Client {} not found", client_id)) } -} -fn handle_request( - our: &Address, - source: &Address, - body: Vec, - state: &mut State, - db: &Sqlite, - pending_logs: &mut PendingLogs -) -> anyhow::Result<()> { - let process = source.process.to_string(); - let pkg = source.package_id().to_string(); - - match process.as_str() { - "http-server:distro:sys" => http_handlers::handle_frontend(our, &body, state, db)?, - "eth:distro:sys" => chain::handle_eth_message(state, db, pending_logs, &body)?, - _ => { - if pkg == "terminal:sys" { - helpers::handle_terminal_debug(our, &body, state, db)?; - } else { - info!("Ignoring unexpected direct request from: {}", source); + #[local] + #[http] + async fn set_client_limits( + &mut self, + client_id: String, + limits: structs::SpendingLimits, + ) -> Result<(), String> { + info!("Setting client spending limits for {}", client_id); + info!("Received limits: {:?}", limits); + + // Check if client exists + let client_exists = self + .state + .authorized_clients + .iter() + .any(|(id, _)| id == &client_id); + + if !client_exists { + return Err(format!("Client {} not found", client_id)); + } + + // Update or insert limits in the cache + let mut found = false; + for (id, existing_limits) in &mut self.state.client_limits_cache { + if id == &client_id { + *existing_limits = limits.clone(); + found = true; + break; + } + } + + if !found { + self.state + .client_limits_cache + .push((client_id.clone(), limits)); + } + + // Notify WebSocket clients about the updated limits + self.notify_authorization_update(); + + Ok(()) + } + + // Search providers without authentication + #[local] + #[http] + async fn search_providers_public(&self, query: String) -> Result, String> { + info!("Public search for providers with query: {}", query); + + let db = self + .db_conn + .as_ref() + .ok_or("Database connection not available")?; + + let providers = crate::db::search_provider(db, query) + .await + .map_err(|e| format!("Failed to search providers: {:?}", e))?; + + let provider_infos: Vec = providers + .into_iter() + .map(|p| ProviderInfo { + id: p.get("id").and_then(|v| v.as_i64()), + provider_id: p + .get("provider_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + name: p + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + description: p + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + site: p + .get("site") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + wallet: p + .get("wallet") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + price: p + .get("price") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + instructions: p + .get("instructions") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + hash: p + .get("hash") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }) + .collect(); + + info!("Found {} providers matching query", provider_infos.len()); + Ok(provider_infos) + } + + #[local] + async fn terminal_command(&mut self, command: TerminalCommand) -> Result { + match command { + TerminalCommand::GetState => { + info!("Getting current operator state"); + info!("Current state:\n{:#?}", self.state); + let state_json = terminal::serialize_state_to_json(&self.state)?; + Ok(format!("Current state:\n{}", state_json)) + } + TerminalCommand::ResetState => { + info!("Resetting operator state to fresh state"); + + let resources = terminal::extract_runtime_resources(self); + + self.state = terminal::create_fresh_state(); + + terminal::restore_runtime_resources(self, resources); + + let current_resources = terminal::extract_runtime_resources(self); + terminal::update_state_flags(&mut self.state, ¤t_resources); + + info!("State reset complete"); + Ok("State has been reset to fresh state".to_string()) + } + TerminalCommand::CheckDbSchema => { + info!("Checking database schema"); + + let db = self + .db_conn + .as_ref() + .ok_or("No database connection available")?; + + let rows = terminal::query_database_schema(db).await?; + + let (tables, indexes) = terminal::parse_schema_rows(rows); + + let mut output = terminal::format_schema_output(tables, indexes); + + match terminal::query_provider_count(db).await { + Ok(count) => { + output.push_str(&format!("\n\nProvider count: {}\n", count)); + } + Err(e) => { + info!("Failed to get provider count: {}", e); + } + } + info!("output: {:#?}", output); + info!("Database schema check complete"); + Ok(output) + } + TerminalCommand::SearchProviders(query) => { + info!("Searching providers for query: {}", query); + + let db = self + .db_conn + .as_ref() + .ok_or("No database connection available")?; + + let providers = crate::db::search_provider(db, query.clone()) + .await + .map_err(|e| format!("Failed to search providers: {:?}", e))?; + + info!("providers: {:#?}", providers); + + let output = terminal::format_search_results(&query, providers); + + info!("output: {:#?}", output); + Ok(output) + } + TerminalCommand::WipeDbAndReindex => { + info!("Wiping operator database and reindexing from chain"); + + self.state.last_checkpoint_block = 0; + + let resources = terminal::extract_runtime_resources(self); + + let our = hyperware_process_lib::our(); + crate::db::wipe_db(&our) + .await + .map_err(|e| format!("Failed to wipe DB: {:?}", e))?; + + let db = crate::db::load_db(&our) + .await + .map_err(|e| format!("Failed to reload DB: {:?}", e))?; + self.db_conn = Some(db); + self.state.db_initialized = true; + + terminal::restore_runtime_resources(self, resources); + + eth::bootstrap_historical(self).await?; + + Ok("Database wiped and reindexed".to_string()) + } + + TerminalCommand::PrintLedger(tba_address) => { + info!("Printing ledger for TBA: {}", tba_address); + + let db = self + .db_conn + .as_ref() + .ok_or("No database connection available")?; + + let tba_lower = tba_address.to_lowercase(); + + info!("=== USDC EVENTS TABLE ==="); + let events_query = r#" + SELECT * FROM usdc_events + WHERE address = ?1 + ORDER BY block DESC + "# + .to_string(); + let events = db + .read( + events_query, + vec![serde_json::Value::String(tba_lower.clone())], + ) + .await + .map_err(|e| format!("Failed to read usdc_events: {:?}", e))?; + + info!("Total events: {}", events.len()); + for event in events { + info!("{:?}", event); + } + + info!("\n=== USDC CALL LEDGER TABLE ==="); + let ledger_query = r#" + SELECT * FROM usdc_call_ledger + WHERE tba_address = ?1 + ORDER BY block DESC + "# + .to_string(); + let ledger_rows = db + .read( + ledger_query, + vec![serde_json::Value::String(tba_lower.clone())], + ) + .await + .map_err(|e| format!("Failed to read usdc_call_ledger: {:?}", e))?; + + info!("Total ledger rows: {}", ledger_rows.len()); + for row in ledger_rows { + info!("{:?}", row); + } + + let balance = crate::ledger::get_tba_usdc_balance(db, &tba_lower) + .await + .map_err(|e| format!("Failed to get balance: {:?}", e))?; + info!("\nCurrent USDC balance: {} USDC", balance); + + Ok(format!("Ledger printed for {} (check logs)", tba_address)) } } } - Ok(()) -} + #[eth] + async fn eth_subscription_result( + &mut self, + eth_sub_result: EthSubResult, + ) -> Result<(), String> { + info!("Handling eth subscription result"); + + match eth_sub_result { + Ok(eth_sub) => { + if let Some(log) = eth::extract_log_from_subscription(ð_sub)? { + eth::process_log_event(self, &log).await?; + } + } + Err(error) => eth::handle_subscription_error(self, &error).await?, + } + Ok(()) + } -fn handle_response( - _our: &Address, - source: &Address, - body: Vec, - context: Option>, - state: &mut State, - db: &Sqlite, // Pass db - pending: &mut PendingLogs // Pass pending -) -> anyhow::Result<()> { - let process = source.process.to_string(); - - match process.as_str() { - "timer:distro:sys" => chain::handle_timer(state, db, pending, context == Some(b"checkpoint".to_vec()))?, - "eth:distro:sys" => chain::handle_eth_message(state, db, pending, &body)?, - _ => info!("Ignoring response from unexpected process: {}", source), - }; - Ok(()) + #[ws] + async fn handle_websocket( + &mut self, + channel_id: u32, + message_type: WsMessageType, + blob: LazyLoadBlob, + ) { + info!("Handling WebSocket message: {:?}", message_type); + match message_type { + WsMessageType::Text | WsMessageType::Binary => { + let message_bytes = blob.bytes.clone(); + let message_str = String::from_utf8(message_bytes).unwrap_or_default(); + + // Parse the incoming message + match serde_json::from_str::(&message_str) { + Ok(msg) => { + info!("Parsed WebSocket message: {:?}", msg); + match msg { + WsClientMessage::Subscribe { topics } => { + info!("subscribe"); + let topics = topics.unwrap_or_else(|| vec![StateUpdateTopic::All]); + + // Add connection + let conn = WsConnection { + channel_id, + subscribed_topics: topics.clone(), + connected_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + }; + self.ws_connections.insert(channel_id, conn); + + // Send subscription confirmation + let response = WsServerMessage::Subscribed { + topics: topics.clone(), + }; + self.send_ws_message(channel_id, response); + + info!("topics: {:#?}", topics.clone()); + + // Send initial state snapshot + self.send_state_snapshot(channel_id).await; + } + WsClientMessage::Unsubscribe { topics } => { + if let Some(topics) = topics { + if let Some(conn) = self.ws_connections.get_mut(&channel_id) { + conn.subscribed_topics.retain(|t| !topics.contains(t)); + } + } + } + WsClientMessage::Ping => { + info!("ping!"); + let response = WsServerMessage::Pong; + self.send_ws_message(channel_id, response); + } + } + } + Err(e) => { + error!("Failed to parse WebSocket message: {}", e); + let error_response = WsServerMessage::Error { + error: format!("Invalid message format: {}", e), + }; + self.send_ws_message(channel_id, error_response); + } + } + } + WsMessageType::Close => { + self.ws_connections.remove(&channel_id); + info!("WebSocket client {} disconnected", channel_id); + } + WsMessageType::Ping | WsMessageType::Pong => { + info!("ping/pong"); + } + } + } } diff --git a/operator/operator/src/shim.rs b/operator/operator/src/shim.rs new file mode 100644 index 0000000..5d5316a --- /dev/null +++ b/operator/operator/src/shim.rs @@ -0,0 +1,730 @@ +use crate::app_api_types::{AuthorizeResult, KeyValue, ProviderSearchResult}; +use crate::structs::{ + generate_shim_client_id, operator_api_base_path, operator_base_path, CallRecord, ClientStatus, + ConfigureAuthorizedClientDto, ConfigureAuthorizedClientResult, HotWalletAuthorizedClient, + PaymentAttemptResult, ServiceCapabilities, State, DEFAULT_NODE_NAME, +}; +// use crate::ledger; // TODO: Enable when ledger is async +use alloy_primitives::Address as EthAddress; +use hex; +use hyperware_process_lib::{ + hyperwallet_client, + logging::{error, info, warn}, + sqlite::Sqlite, + Request as ProcessRequest, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; + +/// Details about a provider fetched from the database +#[derive(Debug)] +pub struct ProviderDetails { + pub wallet_address: String, + pub price_str: String, + pub provider_id: String, + pub provider_name: String, +} + +/// Hash an authentication token using SHA-256 +pub fn hash_authentication_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// Create a new authorization client for a shim +pub fn create_authorization_client( + node: &str, + client_id: &str, + hashed_token: &str, + name: Option, +) -> HotWalletAuthorizedClient { + // Use provided name or generate a default + let client_name = name.unwrap_or_else(|| generate_default_client_name("", client_id)); + + HotWalletAuthorizedClient { + id: client_id.to_string(), + name: client_name, + associated_hot_wallet_address: String::new(), // Will be set later if needed + authentication_token: hashed_token.to_string(), + capabilities: ServiceCapabilities::All, + status: ClientStatus::Active, + } +} + +/// Store a client in the operator state +pub fn store_client(state: &mut State, client_id: String, client: HotWalletAuthorizedClient) { + state.authorized_clients.push((client_id.clone(), client)); + info!("Created new shim client: {}", client_id); +} + +/// Build the authorization response for the shim to save +pub fn build_authorization_response( + client_id: String, + token: String, + node: String, +) -> AuthorizeResult { + AuthorizeResult { + url: operator_base_path(), + token, // Return raw token for shim to save + client_id, + node, + } +} + +/// Verify owner authentication (TODO: implement when auth is ready) +pub fn verify_owner_authentication(_req: &ConfigureAuthorizedClientDto) -> Result<(), String> { + // TODO: + Ok(()) +} + +/// Update an existing client's configuration +pub fn update_existing_client( + state: &mut State, + client_id: &str, + hashed_token: &str, + req: &ConfigureAuthorizedClientDto, +) -> Result<(String, bool), String> { + if let Some((_, existing_client)) = state + .authorized_clients + .iter_mut() + .find(|(id, _)| id == client_id) + { + info!("Configure Client: Updating existing client {}", client_id); + existing_client.authentication_token = hashed_token.to_string(); + if let Some(new_name) = &req.client_name { + existing_client.name = new_name.clone(); + } + // Note: We don't update the hot wallet address for existing clients + Ok((client_id.to_string(), true)) + } else { + Err("Client not found".to_string()) + } +} + +/// Create a new client with configuration +pub fn create_new_client( + state: &mut State, + req: &ConfigureAuthorizedClientDto, + hashed_token: &str, +) -> Result<(String, bool), String> { + let new_client_id = generate_shim_client_id(); + info!("Configure Client: Creating new client {}", new_client_id); + + let default_name = + generate_default_client_name(&req.hot_wallet_address_to_associate, &new_client_id); + + let new_client = HotWalletAuthorizedClient { + id: new_client_id.clone(), + name: req.client_name.clone().unwrap_or(default_name), + associated_hot_wallet_address: req.hot_wallet_address_to_associate.clone(), + authentication_token: hashed_token.to_string(), + capabilities: ServiceCapabilities::All, + status: ClientStatus::Active, + }; + + state + .authorized_clients + .push((new_client_id.clone(), new_client)); + + Ok((new_client_id, false)) +} + +/// Generate a default client name based on wallet address or client ID +pub fn generate_default_client_name(wallet_address: &str, client_id: &str) -> String { + format!( + "Hypergrid Client: {}", + client_id.chars().take(8).collect::() + ) +} + +/// Log client operation for debugging +pub fn log_client_operation(client_id: &str, is_update: bool) { + info!( + "Configure Client: {} client with ID: {}", + if is_update { "Updated" } else { "Created" }, + client_id + ); +} + +/// Build the configuration response +pub fn build_configuration_response( + client_id: String, + raw_token: String, + node_name: String, +) -> ConfigureAuthorizedClientResult { + ConfigureAuthorizedClientResult { + client_id, + raw_token, // Echo back the raw token + api_base_path: operator_api_base_path(), + node_name, + } +} + +/// Authenticate a shim client using client ID and raw token +pub fn authenticate_client<'a>( + state: &'a State, + client_id: &str, + raw_token: &str, +) -> Result<&'a HotWalletAuthorizedClient, String> { + info!("Authenticating shim client: {}", client_id); + + // Look up the client + let client = state + .authorized_clients + .iter() + .find(|(id, _)| id == client_id) + .map(|(_, client)| client) + .ok_or_else(|| format!("Client not found: {}", client_id))?; + + // Check if client is halted + if client.status == ClientStatus::Halted { + return Err("Client is halted".to_string()); + } + + // Hash the provided token and compare + let hashed_token = hash_authentication_token(raw_token); + + if hashed_token != client.authentication_token { + return Err("Invalid authentication token".to_string()); + } + + Ok(client) +} + +/// Get the full client configuration +pub fn get_client_config( + state: &State, + client_id: &str, +) -> Result { + state + .authorized_clients + .iter() + .find(|(id, _)| id == client_id) + .map(|(_, client)| client.clone()) + .ok_or_else(|| "Client configuration not found".to_string()) +} + +/// Perform a registry search using the database +pub async fn perform_registry_search( + db: &hyperware_process_lib::sqlite::Sqlite, + query: &str, +) -> Result, String> { + info!("Performing registry search for: {}", query); + + // Search providers in the database + match crate::db::search_provider(db, query.to_string()).await { + Ok(providers) => { + // Convert database results to ProviderSearchResult + let results = providers + .into_iter() + .filter_map(|mut provider| { + let provider_id = provider + .remove("provider_id") + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + let name = provider + .remove("name") + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + let description = provider + .remove("description") + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + + if !provider_id.is_empty() || !name.is_empty() { + Some(ProviderSearchResult { + provider_id, + name, + description, + }) + } else { + None + } + }) + .collect(); + + info!("Registry search results: {:#?}", results); + Ok(results) + } + Err(e) => { + error!("Database search failed: {:?}", e); + Err(format!("Registry search failed: {:?}", e)) + } + } +} + +/// Fetch provider details from the database +pub async fn fetch_provider_from_db( + db: &Sqlite, + provider_id: &str, +) -> Result { + info!("Fetching provider details for: {}", provider_id); + + match crate::db::get_provider_details(db, provider_id).await { + Ok(Some(details_map)) => { + let provider_id = details_map + .get("provider_id") + .and_then(|v| v.as_str()) + .ok_or("Provider has no ID")? + .to_string(); + + let provider_name = details_map + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + let wallet_address = details_map + .get("wallet") + .and_then(|v| v.as_str()) + .unwrap_or("0x0") + .to_string(); + + let price_str = details_map + .get("price") + .and_then(|v| v.as_str()) + .unwrap_or("0.0") + .to_string(); + + Ok(ProviderDetails { + provider_id, + provider_name, + wallet_address, + price_str, + }) + } + Ok(None) => Err(format!("Provider '{}' not found", provider_id)), + Err(e) => Err(format!("Database error looking up provider: {:?}", e)), + } +} + +/// Perform a health check on the provider +pub fn perform_health_check(provider_details: &ProviderDetails) -> Result<(), String> { + info!( + "Performing health check for provider {}", + provider_details.provider_id + ); + + let target_address = hyperware_process_lib::Address::new( + &provider_details.provider_id, + ("provider", "hypergrid", crate::constants::PUBLISHER), + ); + + // Updated to match the new HealthCheckRequest format + let health_check_request = serde_json::json!({ + "provider_name": provider_details.provider_name + }); + + let wrapped_request = serde_json::json!({ + "HealthPing": health_check_request + }); + + let request_body_bytes = serde_json::to_vec(&wrapped_request) + .map_err(|e| format!("Failed to serialize health check request: {}", e))?; + + info!( + "Sending health check ping to provider at {}", + target_address + ); + match ProcessRequest::new() + .target(target_address.clone()) + .body(request_body_bytes) + .send_and_await_response(7) + { + Ok(Ok(response)) => { + info!( + "Provider {} responded to health check", + provider_details.provider_id + ); + Ok(()) + } + Ok(Err(e)) => { + error!( + "Provider {} health check failed: {:?}", + provider_details.provider_id, e + ); + Err(format!("Provider health check failed: {:?}", e)) + } + Err(e) => { + error!( + "Provider {} health check timed out: {:?}", + provider_details.provider_id, e + ); + Err(format!("Provider health check timed out: {:?}", e)) + } + } +} + +/// Check and enforce client spending limits +pub async fn enforce_client_spending_limits( + state: &State, + db: &Sqlite, + client_config: &HotWalletAuthorizedClient, + provider_details: &ProviderDetails, +) -> Result<(), String> { + // Parse the provider price + let price_float = provider_details + .price_str + .parse::() + .map_err(|_| format!("Invalid price format: {}", provider_details.price_str))?; + + // No check needed if price is zero + if price_float <= 0.0 { + return Ok(()); + } + + // Check if client has spending limits configured + if let Some((_, limits)) = state + .client_limits_cache + .iter() + .find(|(id, _)| id == &client_config.id) + { + if let Some(max_total_str) = &limits.max_total { + // Get the operator TBA address + let tba_address = state + .operator_tba_address + .as_ref() + .ok_or("Operator TBA not configured")?; + + // Query total spending for this client + let query = r#"SELECT COALESCE(SUM(CAST(total_cost_units AS INTEGER)), 0) AS total FROM usdc_call_ledger WHERE tba_address = ?1 AND client_id = ?2"#; + let rows = db + .read( + query.to_string(), + vec![ + serde_json::Value::String(tba_address.to_lowercase()), + serde_json::Value::String(client_config.id.clone()), + ], + ) + .await + .map_err(|e| format!("Failed to query spending: {:?}", e))?; + + let spent_units = rows + .get(0) + .and_then(|r| r.get("total")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i128; + + // Convert price to units (USDC has 6 decimals) + let price_units = (price_float * 1_000_000.0) as i128; + let projected_total = spent_units.saturating_add(price_units); + + // Parse limit + let limit_float = max_total_str + .parse::() + .map_err(|_| format!("Invalid limit format: {}", max_total_str))?; + let limit_units = (limit_float * 1_000_000.0) as i128; + + if projected_total > limit_units { + return Err(format!( + "Client spending limit exceeded. Limit: {} USDC, Current + Requested: {} USDC", + limit_float, + projected_total as f64 / 1_000_000.0 + )); + } + } + } + + Ok(()) +} + +/// Result of payment processing +pub enum PaymentProcessingResult { + Success { tx_hash: String }, + NotRequired, + Failed(PaymentAttemptResult), +} + +impl PaymentProcessingResult { + pub fn tx_hash(&self) -> Option { + match self { + PaymentProcessingResult::Success { tx_hash } => Some(tx_hash.clone()), + _ => None, + } + } + + pub fn is_success(&self) -> bool { + matches!(self, PaymentProcessingResult::Success { .. }) + } + + pub fn to_payment_attempt_result(&self, price: &str) -> Option { + match self { + PaymentProcessingResult::Success { tx_hash } => Some(PaymentAttemptResult::Success { + tx_hash: tx_hash.clone(), + amount_paid: price.to_string(), + currency: "USDC".to_string(), + }), + PaymentProcessingResult::NotRequired => Some(PaymentAttemptResult::Skipped { + reason: "Zero price or no payment required".to_string(), + }), + PaymentProcessingResult::Failed(result) => Some(result.clone()), + } + } +} + +/// Process payment if required +pub async fn process_payment_if_required( + process: &mut crate::OperatorProcess, + provider_details: &ProviderDetails, + client_config_opt: Option<&HotWalletAuthorizedClient>, +) -> Result { + // Parse price + let price_float = provider_details.price_str.parse::().unwrap_or(0.0); + if price_float <= 0.0 { + info!( + "No payment required (price: {})", + provider_details.price_str + ); + return Ok(PaymentProcessingResult::NotRequired); + } + + // Determine which wallet to use + let signer_wallet_id = if let Some(client_config) = client_config_opt { + // Use client's associated wallet + info!( + "Payment via shim client {} wallet {}", + client_config.id, client_config.associated_hot_wallet_address + ); + client_config.associated_hot_wallet_address.clone() + } else { + // Use selected wallet (UI flow) + process + .state + .selected_wallet_id + .as_ref() + .ok_or("No wallet selected for payment")? + .clone() + }; + + info!( + "Processing payment of {} USDC to {} for provider {} using wallet {}", + provider_details.price_str, + provider_details.wallet_address, + provider_details.provider_id, + signer_wallet_id + ); + + // Validate hyperwallet session + let session = process + .hyperwallet_session + .as_ref() + .ok_or("Hyperwallet session not initialized")?; + + // Validate operator TBA + let operator_tba = process + .state + .operator_tba_address + .as_ref() + .ok_or("Operator TBA not configured")?; + + // Parse recipient address + let recipient_addr = provider_details + .wallet_address + .parse::() + .map_err(|_| { + format!( + "Invalid recipient address: {}", + provider_details.wallet_address + ) + })?; + + // Convert amount to units (USDC has 6 decimals) + let amount_units = (price_float * 1_000_000.0) as u128; + + // Execute gasless payment + match hyperwallet_client::execute_gasless_payment( + &session.session_id, + &signer_wallet_id, + operator_tba, + &recipient_addr.to_string(), + amount_units, + ) { + Ok(tx_hash) => { + info!("Payment successful: tx_hash = {}", tx_hash); + Ok(PaymentProcessingResult::Success { tx_hash }) + } + Err(e) => { + error!("Payment failed: {}", e); + let payment_result = PaymentAttemptResult::Failed { + error: format!("Payment failed: {}", e), + amount_attempted: provider_details.price_str.clone(), + currency: "USDC".to_string(), + }; + Err(format!("Payment failed: {}", e)) + } + } +} + +/// Execute provider call with comprehensive metrics and call history recording +pub async fn execute_provider_call_with_metrics( + process: &mut crate::OperatorProcess, + provider_details: ProviderDetails, + provider_name: String, + args: Vec, + timestamp_start_ms: u128, + call_args_json: String, + payment_result: PaymentProcessingResult, + client_config_opt: Option, +) -> Result { + // Parse provider address + //let provider_address = provider_details.provider_id.parse::() + // .map_err(|_| format!("Invalid provider address: {}", provider_details.provider_id))?; + + let provider_address = hyperware_process_lib::Address::new( + &provider_details.provider_id, + ("provider", "hypergrid", crate::constants::PUBLISHER), + ); + + // Prepare provider request in the expected format + let provider_request_data = serde_json::json!({ + "provider_name": provider_name.clone(), + "arguments": args.iter().map(|kv| vec![kv.key.clone(), kv.value.clone()]).collect::>(), + "payment_tx_hash": match &payment_result { + PaymentProcessingResult::Success { tx_hash } => Some(tx_hash.clone()), + _ => None, + } + }); + + // Wrap in the expected enum variant format + let provider_request = serde_json::json!({ + "CallProvider": provider_request_data + }); + + let request_bytes = serde_json::to_vec(&provider_request) + .map_err(|e| format!("Failed to serialize provider request: {}", e))?; + + info!( + "Calling provider {} at address {}", + provider_name, provider_address + ); + + // Make the provider call + let (call_success, response_json) = match ProcessRequest::new() + .target(provider_address.clone()) + .body(request_bytes) + .send_and_await_response(60) // call provider timeout. increase if necessary whomstdgth'ever takes over this. + { + Ok(Ok(response_message)) => { + let response_bytes = response_message.body(); + let json_response = match serde_json::from_slice::(response_bytes) { + Ok(json) => json, + Err(_) => serde_json::json!({ + "raw_response": String::from_utf8_lossy(response_bytes) + }), + }; + (true, json_response) + } + Ok(Err(e)) => { + error!("Provider returned error: {:?}", e); + ( + false, + serde_json::json!({ + "error": format!("Provider error: {:?}", e) + }), + ) + } + Err(e) => { + error!("Failed to call provider: {}", e); + ( + false, + serde_json::json!({ + "error": format!("Failed to call provider: {}", e) + }), + ) + } + }; + + let response_timestamp_ms = chrono::Utc::now().timestamp_millis() as u128; + + // Build the wrapped response + let wrapped_response = serde_json::json!({ + "provider": { + "id": provider_details.provider_id, + "name": provider_name.clone(), + }, + "response": response_json, + "payment": match &payment_result { + PaymentProcessingResult::Success { tx_hash } => { + serde_json::json!({"status": "success", "tx_hash": tx_hash}) + }, + _ => serde_json::json!({"status": "skipped"}) + } + }); + + // Record in call history + let call_record = CallRecord { + timestamp_start_ms: timestamp_start_ms as u64, + provider_lookup_key: provider_details.provider_id.clone(), + target_provider_id: provider_details.provider_id.clone(), + call_args_json, + response_json: Some(serde_json::to_string(&wrapped_response).unwrap_or_default()), + call_success, + response_timestamp_ms: response_timestamp_ms as u64, + payment_result: payment_result + .to_payment_attempt_result(&provider_details.price_str) + .map(|pr| serde_json::to_string(&pr).unwrap_or_default()), + duration_ms: (response_timestamp_ms - timestamp_start_ms) as u64, + operator_wallet_id: client_config_opt + .as_ref() + .map(|c| c.associated_hot_wallet_address.clone()) + .or(process.state.selected_wallet_id.clone()), + client_id: client_config_opt.as_ref().map(|c| c.id.clone()), + provider_name: Some(provider_name), + }; + + process.state.call_history.push(call_record); + + // Limit call history size + const MAX_HISTORY: usize = 500; + if process.state.call_history.len() > MAX_HISTORY { + process + .state + .call_history + .drain(..process.state.call_history.len() - MAX_HISTORY); + } + + // Update ledger if payment succeeded + if let PaymentProcessingResult::Success { tx_hash } = &payment_result { + if let Some(tba) = process.state.operator_tba_address.clone() { + if let Some(db) = &process.db_conn { + // Ensure ledger is updated + let provider = + hyperware_process_lib::eth::Provider::new(crate::structs::CHAIN_ID, 30000); + let _ = crate::ledger::ensure_usdc_events_table(db).await; + let _ = crate::ledger::ensure_usdc_call_ledger_table(db).await; + let _ = crate::ledger::ensure_call_tx_covered( + &process.state, + db, + &provider, + &tba.to_lowercase(), + tx_hash, + ) + .await; + if client_config_opt.is_some() { + let _ = process + .state + .refresh_client_totals_from_ledger(db, &tba) + .await; + // Notify WebSocket clients about the updated spending + process.notify_authorization_update(); + } + } + } + } + + //process.state.save(); + + // Notify WebSocket clients + process.notify_graph_state_update(); + process.notify_wallet_balance_update().await; + if payment_result.is_success() { + if let Some(last_record) = process.state.call_history.last() { + process.notify_new_transaction(last_record); + } + } + + // Return the wrapped response + serde_json::to_string(&wrapped_response).map_err(|e| e.to_string()) +} diff --git a/operator/operator/src/structs.rs b/operator/operator/src/structs.rs index 8560a5d..1cd8a3b 100644 --- a/operator/operator/src/structs.rs +++ b/operator/operator/src/structs.rs @@ -1,17 +1,43 @@ -use crate::authorized_services::HotWalletAuthorizedClient; -use anyhow::Result; -use hyperware_process_lib::hyperwallet_client::SessionInfo; -use hyperware_process_lib::logging::{error, info}; -use hyperware_process_lib::signer::LocalSigner; +use hyperware_process_lib::logging::info; use hyperware_process_lib::sqlite::Sqlite; use hyperware_process_lib::wallet::KeyStorage; -use hyperware_process_lib::{eth, get_state, hypermap, set_state}; -use rmp_serde; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; + +use hyperware_process_lib::our; +use hyperware_process_lib::{eth, hypermap}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; +//#[cfg(feature = "legacy-mods")] +//use crate::authorized_services::HotWalletAuthorizedClient; + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +pub enum ClientStatus { + Active, + Halted, +} + +#[cfg(not(feature = "legacy-mods"))] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HotWalletAuthorizedClient { + pub id: String, // e.g., "hypergrid-shim-uuid" + pub name: String, // e.g., "Shim for 0x123...456" + pub associated_hot_wallet_address: String, // The wallet that pays for calls + pub authentication_token: String, // SHA256 hash of the raw token + pub capabilities: ServiceCapabilities, // What the client can do + pub status: ClientStatus, // Active or Halted +} + +#[cfg(not(feature = "legacy-mods"))] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +pub enum ServiceCapabilities { + All, + SearchOnly, + CallProviders, + None, +} + +#[cfg(feature = "legacy-mods")] wit_bindgen::generate!({ path: "../target/wit", world: "process-v1", @@ -21,12 +47,41 @@ wit_bindgen::generate!({ #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ManagedWallet { - pub id: String, // Typically the wallet address - pub name: Option, // User-defined alias - pub storage: KeyStorage, // Encrypted or Decrypted storage (ensure type matches) + pub id: String, // Typically the wallet address + pub name: Option, // User-defined alias + // WIT compatibility: Store KeyStorage as JSON string + pub storage_json: String, // Encrypted or Decrypted storage serialized as JSON pub spending_limits: SpendingLimits, // Per-wallet limits } +impl ManagedWallet { + /// Get the KeyStorage from JSON + pub fn get_storage(&self) -> Result { + serde_json::from_str(&self.storage_json) + } + + /// Set the KeyStorage as JSON + pub fn set_storage(&mut self, storage: &KeyStorage) -> Result<(), serde_json::Error> { + self.storage_json = serde_json::to_string(storage)?; + Ok(()) + } + + /// Create a new ManagedWallet with KeyStorage + pub fn new( + id: String, + name: Option, + storage: KeyStorage, + spending_limits: SpendingLimits, + ) -> Result { + Ok(Self { + id, + name, + storage_json: serde_json::to_string(&storage)?, + spending_limits, + }) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[serde(rename_all = "camelCase")] pub struct SpendingLimits { @@ -80,7 +135,9 @@ pub struct ProviderDetails { } // --- End Wallet Management Structs --- -#[derive(Serialize, Deserialize, Debug, Clone)] +// IMPORTANT: This enum structure MUST remain backwards compatible for state deserialization +// The old state has this exact enum structure serialized, so we cannot change it to a struct +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub enum PaymentAttemptResult { Success { tx_hash: String, @@ -102,46 +159,123 @@ pub enum PaymentAttemptResult { }, } +// Custom serialization for PaymentAttemptResult to handle WIT compatibility +fn serialize_payment_result(value: &Option, serializer: S) -> Result +where + S: Serializer, +{ + match value { + Some(json_str) => serializer.serialize_some(json_str), + None => serializer.serialize_none(), + } +} + +fn deserialize_payment_result<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + // First try to deserialize as a String (new format) + let value = Option::::deserialize(deserializer)?; + + match value { + Some(serde_json::Value::String(s)) => Ok(Some(s)), + Some(other) => { + // Legacy format: deserialize PaymentAttemptResult and convert to JSON string + let payment_result: PaymentAttemptResult = + serde_json::from_value(other).map_err(serde::de::Error::custom)?; + Ok(Some( + serde_json::to_string(&payment_result).map_err(serde::de::Error::custom)?, + )) + } + None => Ok(None), + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct CallRecord { - pub timestamp_start_ms: u128, // Use milliseconds for potentially higher resolution - pub provider_lookup_key: String, // What was used to find the provider (name or id) - pub target_provider_id: String, // The actual process ID called - pub call_args_json: String, // Arguments sent (as JSON string) + pub timestamp_start_ms: u64, + pub provider_lookup_key: String, + pub target_provider_id: String, + pub call_args_json: String, #[serde(default)] - pub response_json: Option, // Provider response body (JSON or preview) - pub call_success: bool, // Did the provider respond without communication error? - pub response_timestamp_ms: u128, - pub payment_result: Option, // Payment outcome - pub duration_ms: u128, // Calculated duration - pub operator_wallet_id: Option, // Added field + pub response_json: Option, + pub call_success: bool, + pub response_timestamp_ms: u64, + // WIT compatibility: Store complex enum as JSON for serialization + #[serde( + serialize_with = "serialize_payment_result", + deserialize_with = "deserialize_payment_result", + skip_serializing_if = "Option::is_none" + )] + pub payment_result: Option, + pub duration_ms: u64, + pub operator_wallet_id: Option, #[serde(default)] pub client_id: Option, #[serde(default)] pub provider_name: Option, // Human tool name (e.g., haiku-message-answering-machine) } -// --- End Call History Structs --- -// Removed USDC scaffolding per user request +impl CallRecord { + /// Get the payment result as the enum type + pub fn get_payment_result(&self) -> Option { + self.payment_result + .as_ref() + .and_then(|json_str| serde_json::from_str(json_str).ok()) + } + + /// Set the payment result from the enum type + pub fn set_payment_result(&mut self, result: Option) { + self.payment_result = result.and_then(|r| serde_json::to_string(&r).ok()); + } +} +// --- End Call History Structs --- -// Copied types from indexer -type Namehash = String; -type Name = String; pub type PendingLogs = Vec<(eth::Log, u8)>; -// Copied constants from indexer +// Constants still used by legacy code - TO BE REFACTORED const HYPERMAP_ADDRESS: &str = hypermap::HYPERMAP_ADDRESS; pub const DELAY_MS: u64 = 30_000; pub const CHECKPOINT_MS: u64 = 300_000; pub const CHAIN_ID: u64 = hypermap::HYPERMAP_CHAIN_ID; pub const HYPERMAP_FIRST_BLOCK: u64 = hypermap::HYPERMAP_FIRST_BLOCK; +// Operator-specific constants +pub const OPERATOR_PROCESS_NAME: &str = "operator"; +pub const OPERATOR_PACKAGE_NAME: &str = "hypergrid"; +pub const OPERATOR_PUBLISHER: &str = "os"; +pub const OPERATOR_API_PATH: &str = "/api"; + +// Default node names and paths +pub const DEFAULT_NODE_NAME: &str = "operator-node"; +pub const MCP_ENDPOINT_PATH: &str = "/mcp"; +pub const SHIM_CLIENT_PREFIX: &str = "hypergrid-shim"; + +// Helper functions to construct common paths +pub fn operator_api_base_path() -> String { + format!( + "/{}:{}:{}{}", + OPERATOR_PROCESS_NAME, OPERATOR_PACKAGE_NAME, OPERATOR_PUBLISHER, OPERATOR_API_PATH + ) +} + +pub fn operator_base_path() -> String { + format!( + "/{}:{}:{}", + OPERATOR_PROCESS_NAME, OPERATOR_PACKAGE_NAME, OPERATOR_PUBLISHER + ) +} + +pub fn generate_shim_client_id() -> String { + format!("{}-{}", SHIM_CLIENT_PREFIX, uuid::Uuid::new_v4()) +} + // Copied Provider struct definition from indexer #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Provider { - pub name: Name, + pub name: String, // Changed from Name type alias for WIT compatibility pub hash: String, - pub facts: HashMap>, + pub facts: Vec<(String, Vec)>, // Changed from HashMap for WIT compatibility pub wallet: Option, pub price: Option, pub provider_id: Option, @@ -149,164 +283,126 @@ pub struct Provider { #[derive(Debug, Serialize, Deserialize)] pub struct State { - // --- Indexer Fields (Copied from Indexer) --- + // --- Legacy indexer fields - kept for backwards compatibility --- + // These fields are no longer used but kept to allow deserialization of old state + #[serde(default)] pub chain_id: u64, - pub contract_address: eth::Address, - pub hypermap: hypermap::Hypermap, - pub root_hash: Option, - // pub providers: HashMap, // Keep this? Or always read from DB? - pub names: HashMap, + #[serde(default)] + pub contract_address: String, // Changed from eth::Address for compatibility + #[serde(default)] + pub hypermap_address: String, + #[serde(default)] + pub hypermap_timeout: u64, + #[serde(default)] + pub root_hash: Option, + #[serde(default)] + pub names: Vec<(String, String)>, // DEPRECATED - use database instead + #[serde(default)] pub last_checkpoint_block: u64, + #[serde(default)] pub logging_started: u64, - #[serde(skip)] - pub providers_cache: HashMap, + #[serde(default)] + pub providers_cache: Vec<(u64, String)>, + // --- Active fields --- // wallet management - pub managed_wallets: HashMap, + pub managed_wallets: Vec<(String, ManagedWallet)>, pub selected_wallet_id: Option, pub operator_entry_name: Option, pub operator_tba_address: Option, #[serde(default)] - pub wallet_limits_cache: HashMap, + pub wallet_limits_cache: Vec<(String, SpendingLimits)>, #[serde(default)] - pub client_limits_cache: HashMap, - #[serde(skip)] - pub active_signer_cache: Option, + pub client_limits_cache: Vec<(String, SpendingLimits)>, + pub active_signer_wallet_id: Option, #[serde(skip)] pub cached_active_details: Option, pub call_history: Vec, - // (USDC scaffolding removed) - // hypergrid-shim auth pub hashed_shim_api_key: Option, #[serde(default)] - pub authorized_clients: HashMap, + pub authorized_clients: Vec<(String, HotWalletAuthorizedClient)>, // ERC-4337 configuration #[serde(default)] pub gasless_enabled: Option, + #[serde(default)] + pub paymaster_approved: Option, - // Hyperwallet session info - #[serde(skip)] - pub hyperwallet_session: Option, - - // Spider API key for chat functionality - pub spider_api_key: Option, - - #[serde(skip)] - pub db_conn: Option, - - #[serde(skip)] + // Session tracking + #[serde(default)] + pub hyperwallet_session_active: bool, + #[serde(default)] + pub db_initialized: bool, + #[serde(default)] pub timers_initialized: bool, } impl State { pub fn new() -> Self { - // Initialize indexer fields - let hypermap = hypermap::Hypermap::default(60); // don't touch, k!? - let logging_started = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - // authorized_clients will be empty initially - let default_clients = HashMap::new(); - Self { - // Indexer fields - chain_id: CHAIN_ID, - contract_address: eth::Address::from_str(HYPERMAP_ADDRESS).unwrap(), - hypermap, + // Legacy fields - all defaulted + chain_id: 0, + contract_address: String::new(), + hypermap_address: String::new(), + hypermap_timeout: 0, root_hash: None, - // providers: HashMap::new(), // Omit if reading from DB - names: HashMap::from([(String::new(), hypermap::HYPERMAP_ROOT_HASH.to_string())]), - last_checkpoint_block: HYPERMAP_FIRST_BLOCK, - logging_started, - providers_cache: HashMap::new(), - // db: None, // Removed - // pending_logs: Vec::new(), // Removed - - // Client fields - managed_wallets: HashMap::new(), + names: Vec::new(), + last_checkpoint_block: 0, + logging_started: 0, + providers_cache: Vec::new(), + + // Active fields + managed_wallets: Vec::new(), selected_wallet_id: None, operator_entry_name: None, operator_tba_address: None, - wallet_limits_cache: HashMap::new(), - client_limits_cache: HashMap::new(), - active_signer_cache: None, + wallet_limits_cache: Vec::new(), + client_limits_cache: Vec::new(), + active_signer_wallet_id: None, cached_active_details: None, call_history: Vec::new(), - hashed_shim_api_key: None, // Will be phased out - authorized_clients: default_clients, // Initialize as empty HashMap - gasless_enabled: None, // Initialize gasless_enabled - hyperwallet_session: None, // Initialize hyperwallet session - spider_api_key: None, // Initialize spider API key - db_conn: None, + hashed_shim_api_key: None, + authorized_clients: Vec::new(), + gasless_enabled: None, + paymaster_approved: None, + hyperwallet_session_active: false, + db_initialized: false, timers_initialized: false, } } pub fn load() -> Self { - match get_state() { - None => { - info!("No existing state found, creating new state."); - Self::new() - } - Some(state_bytes) => match rmp_serde::from_slice(&state_bytes) { - Ok::(mut state) => { - info!("Loaded existing state."); - state.active_signer_cache = None; - state.cached_active_details = None; - state.providers_cache = HashMap::new(); - state.db_conn = None; // Ensure db_conn is initialized after load - state.timers_initialized = false; // Reset timer initialization flag - state.hyperwallet_session = None; // Reset hyperwallet session on load - // Re-initialize hypermap to ensure a fresh eth::Provider instance - state.hypermap = hypermap::Hypermap::default(60); - // The contract_address field in state should still be respected by hypermap logic if it differs from default. - // However, Hypermap::default() already uses the HYPERMAP_ADDRESS constant. - // If state.contract_address could differ and needs to override, that'd be a separate adjustment in how Hypermap is constructed or used. - // For now, this ensures the provider part of hypermap is fresh. - - info!( - "Loaded state, last checkpoint block: {}", - state.last_checkpoint_block - ); - state - } - Err(e) => { - error!("Failed to deserialize saved state with rmp_serde: {:?}. Creating new state.", e); - Self::new() - } - }, - } - } - /// Saves the serializable state (including wallet_storage) - pub fn save(&mut self) { - // Temporarily detach DB conn to avoid accidental serialization side-effects, - // then restore it immediately after. hyperwallet_session is #[serde(skip)], so it stays. - let db_keep = self.db_conn.clone(); - self.db_conn = None; - match rmp_serde::to_vec(self) { - Ok(state_bytes) => { - set_state(&state_bytes); - info!("state set"); - } - Err(e) => { - error!("Failed to serialize state for saving: {:?}", e); - } - } - // Restore connection handle for subsequent requests - self.db_conn = db_keep; + // In hyperapp framework, state is managed by the framework itself + // We just create a fresh state and let the framework handle persistence + info!("Creating state (hyperapp framework manages persistence)"); + let mut state = Self::new(); + + // Reset transient fields + state.active_signer_wallet_id = None; + state.cached_active_details = None; + state.providers_cache = Vec::new(); + state.db_initialized = false; + state.timers_initialized = false; + state.hyperwallet_session_active = false; + + state } + ///// In hyperapp framework, saving is handled automatically by the framework + ///// This method is kept for compatibility but does nothing + //pub fn save(&mut self) { + // // The hyperapp framework handles state persistence automatically + // // based on the save_config in the #[hyperprocess] macro + //} + /// Refresh per-client total spend from the on-disk USDC ledger. /// Counts total_cost (provider payout + gas/fees) toward the client limit. - pub fn refresh_client_totals_from_ledger( + pub async fn refresh_client_totals_from_ledger( &mut self, db: &Sqlite, tba_address: &str, - ) -> Result<()> { + ) -> anyhow::Result<()> { // Sum totals per client from the ledger in base units (6 decimals) let q = r#" SELECT client_id, SUM(CAST(total_cost_units AS INTEGER)) AS total_units @@ -316,7 +412,7 @@ impl State { "# .to_string(); let params = vec![serde_json::Value::String(tba_address.to_lowercase())]; - let rows = db.read(q, params)?; + let rows = db.read(q, params).await?; // Helper to format base units (6 dp) to display string fn format_units(units: i64) -> String { @@ -333,22 +429,22 @@ impl State { }; let total_units = row.get("total_units").and_then(|v| v.as_i64()).unwrap_or(0); let display = format_units(total_units); - match self.client_limits_cache.get_mut(&client_id) { - Some(entry) => { - entry.total_spent = Some(display); - } - None => { - // Insert a new limits entry with just total_spent (currency defaults to USDC) - self.client_limits_cache.insert( - client_id, - SpendingLimits { - max_per_call: None, - max_total: None, - currency: Some("USDC".to_string()), - total_spent: Some(display), - }, - ); - } + if let Some((_, existing)) = self + .client_limits_cache + .iter_mut() + .find(|(cid, _)| cid == &client_id) + { + existing.total_spent = Some(display); + } else { + self.client_limits_cache.push(( + client_id, + SpendingLimits { + max_per_call: None, + max_total: None, + currency: Some("USDC".to_string()), + total_spent: Some(display), + }, + )); } } @@ -451,76 +547,6 @@ pub enum ApiRequest { }, } -// DEPRECATED: This enum is being phased out. Use McpRequest or ApiRequest instead. -#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "PascalCase")] -pub enum HttpMcpRequest { - // Registry/Provider Actions (from Shim) - SearchRegistry(String), - CallProvider { - #[serde(alias = "providerId")] - provider_id: String, - #[serde(alias = "providerName")] - provider_name: String, - arguments: Vec<(String, String)>, - }, - - // History Action (from UI) - GetCallHistory {}, - - // Wallet Summary/Selection Actions (from UI) - GetWalletSummaryList {}, - SelectWallet { - wallet_id: String, - }, - RenameWallet { - wallet_id: String, - new_name: String, - }, - DeleteWallet { - wallet_id: String, - }, - - // Wallet Creation/Import (from UI) - GenerateWallet {}, - ImportWallet { - private_key: String, - password: Option, - name: Option, - }, - - // Wallet State & Config (from UI - operate on SELECTED implicitly) - ActivateWallet { - password: Option, - }, - DeactivateWallet {}, - SetWalletLimits { - limits: SpendingLimits, - }, // Use SpendingLimits struct defined above - ExportSelectedPrivateKey { - password: Option, - }, - SetSelectedWalletPassword { - new_password: String, - old_password: Option, - }, - RemoveSelectedWalletPassword { - current_password: String, - }, - - // New action to get details for the active/ready account - GetActiveAccountDetails {}, - - // New actions for Operator TBA withdrawals - WithdrawEthFromOperatorTba { - to_address: String, - amount_wei_str: String, // Amount in Wei as a string to avoid precision loss - }, - WithdrawUsdcFromOperatorTba { - to_address: String, - amount_usdc_units_str: String, // Amount in smallest USDC units (e.g., if 6 decimals, 1 USDC = "1000000") - }, -} // calls to the Indexer #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] pub enum ClientRequest { @@ -555,86 +581,16 @@ pub struct SaveShimKeyRequest { // New Request struct for configuring an authorized client #[derive(Serialize, Deserialize, Debug, Clone)] -pub struct ConfigureAuthorizedClientRequest { +pub struct ConfigureAuthorizedClientDto { pub client_id: Option, // If provided, update this client instead of creating new pub client_name: Option, pub raw_token: String, pub hot_wallet_address_to_associate: String, } -// Spider-related structs for chat functionality -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CreateSpiderKeyRequest { - pub name: String, - pub permissions: Vec, - #[serde(rename = "adminKey")] - pub admin_key: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct SpiderApiKey { - pub key: String, - pub name: String, - pub permissions: Vec, - #[serde(rename = "createdAt")] - pub created_at: u64, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct ConnectSpiderRequest { - // Empty for now, but can be extended with configuration options -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct ConnectSpiderResponse { - pub api_key: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct SpiderChatRequest { - #[serde(rename = "apiKey")] - pub api_key: String, - pub messages: Vec, - #[serde(rename = "llmProvider")] - pub llm_provider: Option, - pub model: Option, - #[serde(rename = "mcpServers")] - pub mcp_servers: Option>, - pub metadata: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct SpiderMessage { - pub role: String, - pub content: String, - #[serde(rename = "toolCallsJson")] - pub tool_calls_json: Option, - #[serde(rename = "toolResultsJson")] - pub tool_results_json: Option, - pub timestamp: u64, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct SpiderConversationMetadata { - #[serde(rename = "startTime")] - pub start_time: String, - pub client: String, - #[serde(rename = "fromStt")] - pub from_stt: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct SpiderChatResponse { - #[serde(rename = "conversationId")] - pub conversation_id: String, - pub response: SpiderMessage, - #[serde(rename = "allMessages")] - pub all_messages: Option>, -} - // New Response struct for configuring an authorized client #[derive(Serialize, Deserialize, Debug, Clone)] -pub struct ConfigureAuthorizedClientResponse { +pub struct ConfigureAuthorizedClientResult { pub client_id: String, pub raw_token: String, pub api_base_path: String, // e.g., "/package_id:process_name.os/api" @@ -660,6 +616,29 @@ pub struct OnboardingStatusResponse { pub errors: Vec, // List of specific errors encountered during checks } +// WIT-safe DTOs for app-framework endpoints +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct OnboardingStatusResponseDto { + pub status: OnboardingStatus, + pub checks: OnboardingCheckDetailsDto, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct OnboardingCheckDetailsDto { + pub identity_configured: bool, + pub operator_entry: Option, + pub operator_tba: Option, + pub tba_eth_funded: Option, + pub tba_usdc_funded: Option, + pub tba_eth_balance_str: Option, + pub tba_usdc_balance_str: Option, + pub tba_funding_check_error: Option, +} + +// moved SetupStatus to app_api_types for WIT-safe API surface + // Detailed breakdown of individual checks for UI display #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[serde(rename_all = "camelCase")] @@ -669,8 +648,9 @@ pub struct OnboardingCheckDetails { pub operator_entry: Option, #[serde(skip_serializing_if = "Option::is_none")] pub operator_tba: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub identity_status: Option, + // TODO: WIT-incompatible - complex enum. Use separate status field or flatten + // #[serde(skip_serializing_if = "Option::is_none")] + // pub identity_status: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tba_eth_funded: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -781,104 +761,6 @@ pub struct NoteInfo { pub action_id: Option, // e.g., "trigger_set_signers_note" } -// Graph building structs for visualizer -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged, rename_all = "camelCase")] // Untagged removes variant wrapper, rename_all converts to camelCase -pub enum GraphNodeData { - OwnerNode { - name: String, - #[serde(rename = "tbaAddress")] - tba_address: Option, - #[serde(rename = "ownerAddress")] - owner_address: Option, - }, - OperatorWalletNode { - name: String, - #[serde(rename = "tbaAddress")] - tba_address: String, - #[serde(rename = "fundingStatus")] - funding_status: OperatorWalletFundingInfo, - #[serde(rename = "signersNote")] - signers_note: NoteInfo, - #[serde(rename = "accessListNote")] - access_list_note: NoteInfo, - #[serde(rename = "gaslessEnabled")] - gasless_enabled: bool, - #[serde(rename = "paymasterApproved")] - paymaster_approved: bool, - }, - HotWalletNode { - address: String, - name: Option, - #[serde(rename = "statusDescription")] - status_description: String, - #[serde(rename = "isActiveInMcp")] - is_active_in_mcp: bool, - #[serde(rename = "isEncrypted")] - is_encrypted: bool, - #[serde(rename = "isUnlocked")] - is_unlocked: bool, - #[serde(rename = "fundingInfo")] - funding_info: HotWalletFundingInfo, - #[serde(rename = "authorizedClients")] - authorized_clients: Vec, - limits: Option, - }, - AuthorizedClientNode { - #[serde(rename = "clientId")] - client_id: String, - #[serde(rename = "clientName")] - client_name: String, - #[serde(rename = "associatedHotWalletAddress")] - associated_hot_wallet_address: String, - }, - AddHotWalletActionNode { - // For triggering management/linking of hot wallets - label: String, - #[serde(rename = "operatorTbaAddress")] - operator_tba_address: Option, // Operator TBA this action is related to - #[serde(rename = "actionId")] - action_id: String, // e.g., "trigger_manage_wallets_modal" - }, - AddAuthorizedClientActionNode { - label: String, - #[serde(rename = "targetHotWalletAddress")] - target_hot_wallet_address: String, // The HW this client would be for - #[serde(rename = "actionId")] - action_id: String, // e.g., "trigger_add_client_modal" - }, - MintOperatorWalletActionNode(MintOperatorWalletActionNodeData), // New Variant -} - -// Mint operator wallet action data -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MintOperatorWalletActionNodeData { - pub label: String, - pub owner_node_name: String, // To construct the grid-wallet name - pub action_id: String, // e.g., "trigger_mint_operator_wallet" -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GraphNode { - pub id: String, // Unique ID for ReactFlow - #[serde(rename = "type")] // Ensure correct serialization for ReactFlow - pub node_type: String, // ReactFlow node type, e.g., "ownerNode", "operatorWalletNode" - pub data: GraphNodeData, - pub position: Option, // Optional: Backend can suggest initial positions -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GraphEdge { - pub id: String, - pub source: String, // Source node ID - pub target: String, // Target node ID - pub style_type: Option, // e.g., "dashed" - pub animated: Option, -} - // Coarse onboarding state for simplified UI flows #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] @@ -888,12 +770,17 @@ pub enum CoarseState { AfterWalletWithClients, } +//#[derive(Serialize, Deserialize, Debug, Clone)] +//#[serde(rename_all = "camelCase")] +//pub struct HypergridGraphResponse { +// pub nodes: Vec, +// pub edges: Vec, +// pub coarse_state: CoarseState, +//} + +// WIT-compatible wrapper for HypergridGraphResponse +// Since GraphNodeData is complex, we serialize the entire response as JSON #[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct HypergridGraphResponse { - pub nodes: Vec, - pub edges: Vec, - pub coarse_state: CoarseState, +pub struct HypergridGraphResponseWrapper { + pub json_data: String, // JSON-serialized HypergridGraphResponse } - -// --- End Backend-Driven Graph Visualizer DTOs --- diff --git a/operator/operator/src/terminal.rs b/operator/operator/src/terminal.rs new file mode 100644 index 0000000..55d8337 --- /dev/null +++ b/operator/operator/src/terminal.rs @@ -0,0 +1,220 @@ +use crate::structs::State; +use hyperware_process_lib::logging::{error, info}; +use hyperware_process_lib::sqlite::Sqlite; +use serde_json::Value; +use std::collections::HashMap; + +/// Serialize the current state to pretty JSON +pub fn serialize_state_to_json(state: &State) -> Result { + serde_json::to_string_pretty(state).map_err(|e| format!("Failed to serialize state: {:?}", e)) +} + +/// Save runtime resources before state reset +pub struct RuntimeResources { + pub hypermap: Option, + pub db_conn: Option, + pub hyperwallet_session: Option, +} + +/// Extract runtime resources from the process +pub fn extract_runtime_resources(process: &crate::OperatorProcess) -> RuntimeResources { + RuntimeResources { + hypermap: process.hypermap.clone(), + db_conn: process.db_conn.clone(), + hyperwallet_session: process.hyperwallet_session.clone(), + } +} + +/// Create a fresh state +pub fn create_fresh_state() -> State { + State::new() +} + +/// Restore runtime resources to the process +pub fn restore_runtime_resources( + process: &mut crate::OperatorProcess, + resources: RuntimeResources, +) { + process.hypermap = resources.hypermap; + process.db_conn = resources.db_conn; + process.hyperwallet_session = resources.hyperwallet_session; +} + +/// Update state flags based on restored resources +pub fn update_state_flags(state: &mut State, resources: &RuntimeResources) { + state.hyperwallet_session_active = resources.hyperwallet_session.is_some(); + state.db_initialized = resources.db_conn.is_some(); +} + +/// Query database schema information +pub async fn query_database_schema(db: &Sqlite) -> Result>, String> { + let query = "SELECT type, name, sql FROM sqlite_master WHERE type IN ('table', 'index') ORDER BY type, name".to_string(); + db.read(query, vec![]) + .await + .map_err(|e| format!("Failed to query database schema: {:?}", e)) +} + +/// Parse schema rows into tables and indexes +pub fn parse_schema_rows( + rows: Vec>, +) -> (Vec<(String, String)>, Vec<(String, String)>) { + let mut tables = Vec::new(); + let mut indexes = Vec::new(); + + for row in rows { + let obj_type = row + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let name = row + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unnamed") + .to_string(); + let sql = row + .get("sql") + .and_then(|v| v.as_str()) + .unwrap_or("no sql") + .to_string(); + + match obj_type { + "table" => tables.push((name, sql)), + "index" => indexes.push((name, sql)), + _ => {} + } + } + + (tables, indexes) +} + +/// Format schema information for display +pub fn format_schema_output( + tables: Vec<(String, String)>, + indexes: Vec<(String, String)>, +) -> String { + info!( + "format_schema_output: {:#?}", + (tables.clone(), indexes.clone()) + ); + let mut output = String::from("Database Schema:\n\n"); + + // Format tables + output.push_str("TABLES:\n"); + for (name, sql) in tables { + output.push_str(&format!("\n{}\n{}\n", name, "=".repeat(name.len()))); + output.push_str(&format!("{}\n", sql)); + } + + // Format indexes + output.push_str("\n\nINDEXES:\n"); + for (name, sql) in indexes { + output.push_str(&format!("\n{}: {}\n", name, sql)); + } + + output +} + +/// Query provider count from database +pub async fn query_provider_count(db: &Sqlite) -> Result { + let query = "SELECT COUNT(*) as count FROM providers".to_string(); + + db.read(query, vec![]) + .await + .map_err(|e| format!("Failed to query provider count: {:?}", e))? + .get(0) + .and_then(|r| r.get("count")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| "Failed to extract provider count".to_string()) +} + +/// Format provider search results +pub fn format_search_results(query: &str, providers: Vec>) -> String { + let mut output = format!( + "Search results for '{}': {} providers found\n\n", + query, + providers.len() + ); + + if providers.is_empty() { + output.push_str("No providers found matching your query.\n"); + return output; + } + + for (i, provider) in providers.iter().enumerate() { + output.push_str(&format_provider_details(i + 1, provider)); + output.push_str("\n"); + } + + output +} + +/// Format a single provider's details +fn format_provider_details(index: usize, provider: &HashMap) -> String { + let mut output = format!("=== Provider {} ===\n", index); + + // ID + if let Some(id) = provider.get("id").and_then(|v| v.as_i64()) { + output.push_str(&format!("ID: {}\n", id)); + } + + // Name + if let Some(name) = provider.get("name").and_then(|v| v.as_str()) { + output.push_str(&format!("Name: {}\n", name)); + } + + // Provider ID + if let Some(provider_id) = provider.get("provider_id").and_then(|v| v.as_str()) { + if !provider_id.is_empty() { + output.push_str(&format!("Provider ID: {}\n", provider_id)); + } + } + + // Site + if let Some(site) = provider.get("site").and_then(|v| v.as_str()) { + if !site.is_empty() { + output.push_str(&format!("Site: {}\n", site)); + } + } + + // Description (truncated if long) + if let Some(description) = provider.get("description").and_then(|v| v.as_str()) { + if !description.is_empty() { + let desc = truncate_string(description, 100); + output.push_str(&format!("Description: {}\n", desc)); + } + } + + // Wallet + if let Some(wallet) = provider.get("wallet").and_then(|v| v.as_str()) { + if !wallet.is_empty() { + output.push_str(&format!("Wallet: {}\n", wallet)); + } + } + + // Price + if let Some(price) = provider.get("price").and_then(|v| v.as_str()) { + if !price.is_empty() { + output.push_str(&format!("Price: {}\n", price)); + } + } + + // Hash (abbreviated) + if let Some(hash) = provider.get("hash").and_then(|v| v.as_str()) { + if hash.len() >= 16 { + output.push_str(&format!("Hash: {}...\n", &hash[..16])); + } else { + output.push_str(&format!("Hash: {}\n", hash)); + } + } + + output +} + +/// Truncate a string to a maximum length, adding ellipsis if truncated +fn truncate_string(s: &str, max_len: usize) -> String { + if s.len() > max_len { + format!("{}...", &s[..max_len.saturating_sub(3)]) + } else { + s.to_string() + } +} diff --git a/operator/operator/src/tests/api_tests.rs b/operator/operator/src/tests/api_tests.rs deleted file mode 100644 index 11b2f8f..0000000 --- a/operator/operator/src/tests/api_tests.rs +++ /dev/null @@ -1,250 +0,0 @@ -#[cfg(test)] -mod api_tests { - use crate::structs::*; - - #[test] - fn test_mcp_request_search_registry() { - let request = McpRequest::SearchRegistry("weather".to_string()); - - let json = serde_json::to_string(&request).unwrap(); - let deserialized: McpRequest = serde_json::from_str(&json).unwrap(); - - match deserialized { - McpRequest::SearchRegistry(query) => { - assert_eq!(query, "weather"); - } - _ => panic!("Expected SearchRegistry variant"), - } - } - - #[test] - fn test_mcp_request_call_provider() { - let request = McpRequest::CallProvider { - provider_id: "weather:provider:os".to_string(), - provider_name: "weather-provider".to_string(), - arguments: vec![ - ("location".to_string(), "San Francisco".to_string()), - ("format".to_string(), "json".to_string()), - ], - }; - - let json = serde_json::to_string(&request).unwrap(); - let deserialized: McpRequest = serde_json::from_str(&json).unwrap(); - - match deserialized { - McpRequest::CallProvider { provider_id, provider_name, arguments } => { - assert_eq!(provider_id, "weather:provider:os"); - assert_eq!(provider_name, "weather-provider"); - assert_eq!(arguments.len(), 2); - assert_eq!(arguments[0], ("location".to_string(), "San Francisco".to_string())); - assert_eq!(arguments[1], ("format".to_string(), "json".to_string())); - } - _ => panic!("Expected CallProvider variant"), - } - } - - #[test] - fn test_api_request_get_call_history() { - let request = ApiRequest::GetCallHistory {}; - - let json = serde_json::to_string(&request).unwrap(); - let deserialized: ApiRequest = serde_json::from_str(&json).unwrap(); - - match deserialized { - ApiRequest::GetCallHistory {} => {}, - _ => panic!("Expected GetCallHistory variant"), - } - } - - #[test] - fn test_api_request_generate_wallet() { - let request = ApiRequest::GenerateWallet {}; - - let json = serde_json::to_string(&request).unwrap(); - let deserialized: ApiRequest = serde_json::from_str(&json).unwrap(); - - match deserialized { - ApiRequest::GenerateWallet {} => {}, - _ => panic!("Expected GenerateWallet variant"), - } - } - - #[test] - fn test_api_request_import_wallet() { - let request = ApiRequest::ImportWallet { - private_key: "0x123456789abcdef".to_string(), - password: Some("secure_password".to_string()), - name: Some("My Wallet".to_string()), - }; - - let json = serde_json::to_string(&request).unwrap(); - let deserialized: ApiRequest = serde_json::from_str(&json).unwrap(); - - match deserialized { - ApiRequest::ImportWallet { private_key, password, name } => { - assert_eq!(private_key, "0x123456789abcdef"); - assert_eq!(password, Some("secure_password".to_string())); - assert_eq!(name, Some("My Wallet".to_string())); - } - _ => panic!("Expected ImportWallet variant"), - } - } - - #[test] - fn test_api_request_select_wallet() { - let request = ApiRequest::SelectWallet { - wallet_id: "wallet-123".to_string(), - }; - - let json = serde_json::to_string(&request).unwrap(); - let deserialized: ApiRequest = serde_json::from_str(&json).unwrap(); - - match deserialized { - ApiRequest::SelectWallet { wallet_id } => { - assert_eq!(wallet_id, "wallet-123"); - } - _ => panic!("Expected SelectWallet variant"), - } - } - - #[test] - fn test_api_request_set_wallet_limits() { - let limits = SpendingLimits { - max_per_call: Some("100.0".to_string()), - max_total: Some("1000.0".to_string()), - currency: Some("USDC".to_string()), - }; - - let request = ApiRequest::SetWalletLimits { limits: limits.clone() }; - - let json = serde_json::to_string(&request).unwrap(); - let deserialized: ApiRequest = serde_json::from_str(&json).unwrap(); - - match deserialized { - ApiRequest::SetWalletLimits { limits: deserialized_limits } => { - assert_eq!(limits.max_per_call, deserialized_limits.max_per_call); - assert_eq!(limits.max_total, deserialized_limits.max_total); - assert_eq!(limits.currency, deserialized_limits.currency); - } - _ => panic!("Expected SetWalletLimits variant"), - } - } - - #[test] - fn test_api_request_withdraw_eth() { - let request = ApiRequest::WithdrawEthFromOperatorTba { - to_address: "0x742d35Cc6634C0532925a3b8D0c0D7D2d1234567".to_string(), - amount_wei_str: "1000000000000000000".to_string(), // 1 ETH in wei - }; - - let json = serde_json::to_string(&request).unwrap(); - let deserialized: ApiRequest = serde_json::from_str(&json).unwrap(); - - match deserialized { - ApiRequest::WithdrawEthFromOperatorTba { to_address, amount_wei_str } => { - assert_eq!(to_address, "0x742d35Cc6634C0532925a3b8D0c0D7D2d1234567"); - assert_eq!(amount_wei_str, "1000000000000000000"); - } - _ => panic!("Expected WithdrawEthFromOperatorTba variant"), - } - } - - #[test] - fn test_api_request_withdraw_usdc() { - let request = ApiRequest::WithdrawUsdcFromOperatorTba { - to_address: "0x742d35Cc6634C0532925a3b8D0c0D7D2d1234567".to_string(), - amount_usdc_units_str: "1000000".to_string(), // 1 USDC (6 decimals) - }; - - let json = serde_json::to_string(&request).unwrap(); - let deserialized: ApiRequest = serde_json::from_str(&json).unwrap(); - - match deserialized { - ApiRequest::WithdrawUsdcFromOperatorTba { to_address, amount_usdc_units_str } => { - assert_eq!(to_address, "0x742d35Cc6634C0532925a3b8D0c0D7D2d1234567"); - assert_eq!(amount_usdc_units_str, "1000000"); - } - _ => panic!("Expected WithdrawUsdcFromOperatorTba variant"), - } - } - - #[test] - fn test_save_shim_key_request() { - let request = SaveShimKeyRequest { - raw_key: "test-api-key-12345".to_string(), - }; - - let json = serde_json::to_string(&request).unwrap(); - let deserialized: SaveShimKeyRequest = serde_json::from_str(&json).unwrap(); - - assert_eq!(request.raw_key, deserialized.raw_key); - } - - #[test] - fn test_configure_authorized_client_request() { - let request = ConfigureAuthorizedClientRequest { - client_id: Some("client-123".to_string()), - client_name: Some("Test Client".to_string()), - raw_token: "token-12345".to_string(), - hot_wallet_address_to_associate: "0x742d35Cc6634C0532925a3b8D0c0D7D2d1234567".to_string(), - }; - - let json = serde_json::to_string(&request).unwrap(); - let deserialized: ConfigureAuthorizedClientRequest = serde_json::from_str(&json).unwrap(); - - assert_eq!(request.client_id, deserialized.client_id); - assert_eq!(request.client_name, deserialized.client_name); - assert_eq!(request.raw_token, deserialized.raw_token); - assert_eq!(request.hot_wallet_address_to_associate, deserialized.hot_wallet_address_to_associate); - } - - #[test] - fn test_configure_authorized_client_response() { - let response = ConfigureAuthorizedClientResponse { - client_id: "client-123".to_string(), - raw_token: "token-12345".to_string(), - api_base_path: "/test:process.os/api".to_string(), - node_name: "test.os".to_string(), - }; - - let json = serde_json::to_string(&response).unwrap(); - let deserialized: ConfigureAuthorizedClientResponse = serde_json::from_str(&json).unwrap(); - - assert_eq!(response.client_id, deserialized.client_id); - assert_eq!(response.raw_token, deserialized.raw_token); - assert_eq!(response.api_base_path, deserialized.api_base_path); - assert_eq!(response.node_name, deserialized.node_name); - } - - #[test] - fn test_provider_request_serialization() { - let provider_request = ProviderRequest { - provider_name: "weather-service".to_string(), - arguments: vec![ - ("city".to_string(), "New York".to_string()), - ("country".to_string(), "US".to_string()), - ], - payment_tx_hash: Some("0xabcdef123456".to_string()), - }; - - let json = serde_json::to_string(&provider_request).unwrap(); - let deserialized: ProviderRequest = serde_json::from_str(&json).unwrap(); - - assert_eq!(provider_request.provider_name, deserialized.provider_name); - assert_eq!(provider_request.arguments, deserialized.arguments); - assert_eq!(provider_request.payment_tx_hash, deserialized.payment_tx_hash); - } - - #[test] - fn test_client_request_variants() { - let requests = vec![ - ClientRequest::GetFullRegistry, - ClientRequest::SearchRegistry("search-term".to_string()), - ]; - - for request in requests { - let json = serde_json::to_string(&request).unwrap(); - let _deserialized: ClientRequest = serde_json::from_str(&json).unwrap(); - } - } -} \ No newline at end of file diff --git a/operator/operator/src/tests/auth_tests.rs b/operator/operator/src/tests/auth_tests.rs deleted file mode 100644 index b8f83bf..0000000 --- a/operator/operator/src/tests/auth_tests.rs +++ /dev/null @@ -1,278 +0,0 @@ -#[cfg(test)] -mod auth_tests { - use crate::authorized_services::*; - use crate::structs::*; - use crate::helpers::authenticate_shim_client; - use std::collections::HashMap; - use sha2::{Sha256, Digest}; - - #[test] - fn test_service_capabilities_serialization() { - let all = ServiceCapabilities::All; - let none = ServiceCapabilities::None; - - let all_json = serde_json::to_string(&all).unwrap(); - let none_json = serde_json::to_string(&none).unwrap(); - - let all_deserialized: ServiceCapabilities = serde_json::from_str(&all_json).unwrap(); - let none_deserialized: ServiceCapabilities = serde_json::from_str(&none_json).unwrap(); - - assert_eq!(all, all_deserialized); - assert_eq!(none, none_deserialized); - } - - #[test] - fn test_service_capabilities_equality() { - assert_eq!(ServiceCapabilities::All, ServiceCapabilities::All); - assert_eq!(ServiceCapabilities::None, ServiceCapabilities::None); - assert_ne!(ServiceCapabilities::All, ServiceCapabilities::None); - } - - #[test] - fn test_hot_wallet_authorized_client_creation() { - let client = HotWalletAuthorizedClient { - id: "test-client-123".to_string(), - name: "Test MCP Client".to_string(), - associated_hot_wallet_address: "0x742d35Cc6634C0532925a3b8D0c0D7D2d1234567".to_string(), - authentication_token: "hashed-token-value".to_string(), - capabilities: ServiceCapabilities::All, - }; - - assert_eq!(client.id, "test-client-123"); - assert_eq!(client.name, "Test MCP Client"); - assert_eq!(client.associated_hot_wallet_address, "0x742d35Cc6634C0532925a3b8D0c0D7D2d1234567"); - assert_eq!(client.authentication_token, "hashed-token-value"); - assert_eq!(client.capabilities, ServiceCapabilities::All); - } - - #[test] - fn test_hot_wallet_authorized_client_serialization() { - let client = HotWalletAuthorizedClient { - id: "client-456".to_string(), - name: "Another Client".to_string(), - associated_hot_wallet_address: "0x123456789abcdef123456789abcdef123456789a".to_string(), - authentication_token: "secret-hash".to_string(), - capabilities: ServiceCapabilities::None, - }; - - let json = serde_json::to_string(&client).unwrap(); - let deserialized: HotWalletAuthorizedClient = serde_json::from_str(&json).unwrap(); - - assert_eq!(client.id, deserialized.id); - assert_eq!(client.name, deserialized.name); - assert_eq!(client.associated_hot_wallet_address, deserialized.associated_hot_wallet_address); - assert_eq!(client.authentication_token, deserialized.authentication_token); - assert_eq!(client.capabilities, deserialized.capabilities); - } - - #[test] - fn test_hot_wallet_authorized_client_equality() { - let client1 = HotWalletAuthorizedClient { - id: "client-1".to_string(), - name: "Client One".to_string(), - associated_hot_wallet_address: "0x123".to_string(), - authentication_token: "token1".to_string(), - capabilities: ServiceCapabilities::All, - }; - - let client2 = HotWalletAuthorizedClient { - id: "client-1".to_string(), - name: "Client One".to_string(), - associated_hot_wallet_address: "0x123".to_string(), - authentication_token: "token1".to_string(), - capabilities: ServiceCapabilities::All, - }; - - let client3 = HotWalletAuthorizedClient { - id: "client-2".to_string(), - name: "Client Two".to_string(), - associated_hot_wallet_address: "0x456".to_string(), - authentication_token: "token2".to_string(), - capabilities: ServiceCapabilities::None, - }; - - assert_eq!(client1, client2); - assert_ne!(client1, client3); - } - - fn create_test_state_with_client() -> (State, String, String) { - let raw_token = "test-raw-token-12345"; - let mut hasher = Sha256::new(); - hasher.update(raw_token.as_bytes()); - let hashed_token = format!("{:x}", hasher.finalize()); - - let client = HotWalletAuthorizedClient { - id: "test-client".to_string(), - name: "Test Client".to_string(), - associated_hot_wallet_address: "0x742d35Cc6634C0532925a3b8D0c0D7D2d1234567".to_string(), - authentication_token: hashed_token, - capabilities: ServiceCapabilities::All, - }; - - let mut authorized_clients = HashMap::new(); - authorized_clients.insert("test-client".to_string(), client); - - let mut state = State::new(); - state.authorized_clients = authorized_clients; - - (state, "test-client".to_string(), raw_token.to_string()) - } - - #[test] - fn test_authenticate_shim_client_success() { - let (state, client_id, raw_token) = create_test_state_with_client(); - - let result = authenticate_shim_client(&state, &client_id, &raw_token); - assert!(result.is_ok()); - - let client = result.unwrap(); - assert_eq!(client.id, "test-client"); - assert_eq!(client.capabilities, ServiceCapabilities::All); - } - - #[test] - fn test_authenticate_shim_client_not_found() { - let (state, _client_id, raw_token) = create_test_state_with_client(); - - let result = authenticate_shim_client(&state, "nonexistent-client", &raw_token); - assert!(result.is_err()); - - match result { - Err(AuthError::ClientNotFound) => {}, - _ => panic!("Expected ClientNotFound error"), - } - } - - #[test] - fn test_authenticate_shim_client_invalid_token() { - let (state, client_id, _raw_token) = create_test_state_with_client(); - - let result = authenticate_shim_client(&state, &client_id, "wrong-token"); - assert!(result.is_err()); - - match result { - Err(AuthError::InvalidToken) => {}, - _ => panic!("Expected InvalidToken error"), - } - } - - #[test] - fn test_authenticate_shim_client_insufficient_capabilities() { - let (mut state, client_id, raw_token) = create_test_state_with_client(); - - // Modify the client to have None capabilities - if let Some(client) = state.authorized_clients.get_mut(&client_id) { - client.capabilities = ServiceCapabilities::None; - } - - let result = authenticate_shim_client(&state, &client_id, &raw_token); - assert!(result.is_err()); - - match result { - Err(AuthError::InsufficientCapabilities) => {}, - _ => panic!("Expected InsufficientCapabilities error"), - } - } - - #[test] - fn test_authenticate_shim_client_empty_token() { - let (state, client_id, _raw_token) = create_test_state_with_client(); - - let result = authenticate_shim_client(&state, &client_id, ""); - assert!(result.is_err()); - - match result { - Err(AuthError::InvalidToken) => {}, - _ => panic!("Expected InvalidToken error"), - } - } - - #[test] - fn test_authenticate_shim_client_multiple_clients() { - let mut state = State::new(); - let mut authorized_clients = HashMap::new(); - - // Create two different clients - for i in 1..=2 { - let raw_token = format!("token-{}", i); - let mut hasher = Sha256::new(); - hasher.update(raw_token.as_bytes()); - let hashed_token = format!("{:x}", hasher.finalize()); - - let client = HotWalletAuthorizedClient { - id: format!("client-{}", i), - name: format!("Client {}", i), - associated_hot_wallet_address: format!("0x{:040}", i), - authentication_token: hashed_token, - capabilities: ServiceCapabilities::All, - }; - - authorized_clients.insert(format!("client-{}", i), client); - } - - state.authorized_clients = authorized_clients; - - // Test authenticating each client - for i in 1..=2 { - let client_id = format!("client-{}", i); - let raw_token = format!("token-{}", i); - - let result = authenticate_shim_client(&state, &client_id, &raw_token); - assert!(result.is_ok()); - - let client = result.unwrap(); - assert_eq!(client.id, client_id); - } - - // Test cross-authentication (should fail) - let result = authenticate_shim_client(&state, "client-1", "token-2"); - assert!(result.is_err()); - match result { - Err(AuthError::InvalidToken) => {}, - _ => panic!("Expected InvalidToken error for cross-authentication"), - } - } - - #[test] - fn test_token_hashing_consistency() { - let raw_token = "my-secret-token"; - - // Hash the token twice - let mut hasher1 = Sha256::new(); - hasher1.update(raw_token.as_bytes()); - let hash1 = format!("{:x}", hasher1.finalize()); - - let mut hasher2 = Sha256::new(); - hasher2.update(raw_token.as_bytes()); - let hash2 = format!("{:x}", hasher2.finalize()); - - assert_eq!(hash1, hash2); - assert_eq!(hash1.len(), 64); // SHA256 produces 64 hex characters - } - - #[test] - fn test_different_tokens_different_hashes() { - let tokens = ["token1", "token2", "token3"]; - let mut hashes = Vec::new(); - - for token in &tokens { - let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - let hash = format!("{:x}", hasher.finalize()); - hashes.push(hash); - } - - // All hashes should be different - for i in 0..hashes.len() { - for j in i+1..hashes.len() { - assert_ne!(hashes[i], hashes[j], "Tokens '{}' and '{}' produced same hash", tokens[i], tokens[j]); - } - } - } - - #[test] - fn test_state_authorized_clients_empty_by_default() { - let state = State::new(); - assert!(state.authorized_clients.is_empty()); - } -} \ No newline at end of file diff --git a/operator/operator/src/tests/graph_tests.rs b/operator/operator/src/tests/graph_tests.rs deleted file mode 100644 index 3a3f50f..0000000 --- a/operator/operator/src/tests/graph_tests.rs +++ /dev/null @@ -1,288 +0,0 @@ -#[cfg(test)] -mod graph_tests { - use crate::structs::*; - - #[test] - fn test_node_position_serialization() { - let position = NodePosition { x: 100.5, y: 200.7 }; - - let json = serde_json::to_string(&position).unwrap(); - let deserialized: NodePosition = serde_json::from_str(&json).unwrap(); - - assert_eq!(position.x, deserialized.x); - assert_eq!(position.y, deserialized.y); - } - - #[test] - fn test_operator_wallet_funding_info() { - let funding_info = OperatorWalletFundingInfo { - eth_balance_str: Some("1.5".to_string()), - usdc_balance_str: Some("100.0".to_string()), - needs_eth: false, - needs_usdc: true, - error_message: Some("RPC timeout".to_string()), - }; - - let json = serde_json::to_string(&funding_info).unwrap(); - let deserialized: OperatorWalletFundingInfo = serde_json::from_str(&json).unwrap(); - - assert_eq!(funding_info.eth_balance_str, deserialized.eth_balance_str); - assert_eq!(funding_info.usdc_balance_str, deserialized.usdc_balance_str); - assert_eq!(funding_info.needs_eth, deserialized.needs_eth); - assert_eq!(funding_info.needs_usdc, deserialized.needs_usdc); - assert_eq!(funding_info.error_message, deserialized.error_message); - } - - #[test] - fn test_hot_wallet_funding_info() { - let funding_info = HotWalletFundingInfo { - eth_balance_str: Some("0.1".to_string()), - needs_eth: true, - error_message: None, - }; - - let json = serde_json::to_string(&funding_info).unwrap(); - let deserialized: HotWalletFundingInfo = serde_json::from_str(&json).unwrap(); - - assert_eq!(funding_info.eth_balance_str, deserialized.eth_balance_str); - assert_eq!(funding_info.needs_eth, deserialized.needs_eth); - assert_eq!(funding_info.error_message, deserialized.error_message); - } - - #[test] - fn test_note_info() { - let note_info = NoteInfo { - status_text: "Signers note is set".to_string(), - details: Some("Contains 3 authorized signers".to_string()), - is_set: true, - action_needed: false, - action_id: None, - }; - - let json = serde_json::to_string(¬e_info).unwrap(); - let deserialized: NoteInfo = serde_json::from_str(&json).unwrap(); - - assert_eq!(note_info.status_text, deserialized.status_text); - assert_eq!(note_info.details, deserialized.details); - assert_eq!(note_info.is_set, deserialized.is_set); - assert_eq!(note_info.action_needed, deserialized.action_needed); - assert_eq!(note_info.action_id, deserialized.action_id); - } - - #[test] - fn test_graph_node_data_owner_node() { - let node_data = GraphNodeData::OwnerNode { - name: "alice.os".to_string(), - tba_address: Some("0x123456".to_string()), - owner_address: Some("0x654321".to_string()), - }; - - let json = serde_json::to_string(&node_data).unwrap(); - let deserialized: GraphNodeData = serde_json::from_str(&json).unwrap(); - - match deserialized { - GraphNodeData::OwnerNode { name, tba_address, owner_address } => { - assert_eq!(name, "alice.os"); - assert_eq!(tba_address, Some("0x123456".to_string())); - assert_eq!(owner_address, Some("0x654321".to_string())); - } - _ => panic!("Expected OwnerNode variant"), - } - } - - #[test] - fn test_graph_node_data_hot_wallet_node() { - let spending_limits = SpendingLimits { - max_per_call: Some("50.0".to_string()), - max_total: Some("500.0".to_string()), - currency: Some("USDC".to_string()), - }; - - let funding_info = HotWalletFundingInfo { - eth_balance_str: Some("0.05".to_string()), - needs_eth: true, - error_message: None, - }; - - let node_data = GraphNodeData::HotWalletNode { - address: "0xabcdef".to_string(), - name: Some("My Hot Wallet".to_string()), - status_description: "Active and Ready".to_string(), - is_active_in_mcp: true, - is_encrypted: true, - is_unlocked: true, - funding_info, - authorized_clients: vec!["client-1".to_string(), "client-2".to_string()], - limits: Some(spending_limits), - }; - - let json = serde_json::to_string(&node_data).unwrap(); - let deserialized: GraphNodeData = serde_json::from_str(&json).unwrap(); - - match deserialized { - GraphNodeData::HotWalletNode { - address, - name, - status_description, - is_active_in_mcp, - is_encrypted, - is_unlocked, - funding_info: _, - authorized_clients, - limits - } => { - assert_eq!(address, "0xabcdef"); - assert_eq!(name, Some("My Hot Wallet".to_string())); - assert_eq!(status_description, "Active and Ready"); - assert_eq!(is_active_in_mcp, true); - assert_eq!(is_encrypted, true); - assert_eq!(is_unlocked, true); - assert_eq!(authorized_clients.len(), 2); - assert!(limits.is_some()); - } - _ => panic!("Expected HotWalletNode variant"), - } - } - - #[test] - fn test_graph_node_data_authorized_client_node() { - let node_data = GraphNodeData::AuthorizedClientNode { - client_id: "client-123".to_string(), - client_name: "Test MCP Client".to_string(), - associated_hot_wallet_address: "0xabcdef".to_string(), - }; - - let json = serde_json::to_string(&node_data).unwrap(); - let deserialized: GraphNodeData = serde_json::from_str(&json).unwrap(); - - match deserialized { - GraphNodeData::AuthorizedClientNode { client_id, client_name, associated_hot_wallet_address } => { - assert_eq!(client_id, "client-123"); - assert_eq!(client_name, "Test MCP Client"); - assert_eq!(associated_hot_wallet_address, "0xabcdef"); - } - _ => panic!("Expected AuthorizedClientNode variant"), - } - } - - #[test] - fn test_graph_node_data_add_hot_wallet_action_node() { - let node_data = GraphNodeData::AddHotWalletActionNode { - label: "Add Hot Wallet".to_string(), - operator_tba_address: Some("0x123456".to_string()), - action_id: "trigger_manage_wallets_modal".to_string(), - }; - - let json = serde_json::to_string(&node_data).unwrap(); - let deserialized: GraphNodeData = serde_json::from_str(&json).unwrap(); - - match deserialized { - GraphNodeData::AddHotWalletActionNode { label, operator_tba_address, action_id } => { - assert_eq!(label, "Add Hot Wallet"); - assert_eq!(operator_tba_address, Some("0x123456".to_string())); - assert_eq!(action_id, "trigger_manage_wallets_modal"); - } - _ => panic!("Expected AddHotWalletActionNode variant"), - } - } - - #[test] - fn test_mint_operator_wallet_action_node_data() { - let action_data = MintOperatorWalletActionNodeData { - label: "Mint Operator Wallet".to_string(), - owner_node_name: "alice.os".to_string(), - action_id: "trigger_mint_operator_wallet".to_string(), - }; - - let json = serde_json::to_string(&action_data).unwrap(); - let deserialized: MintOperatorWalletActionNodeData = serde_json::from_str(&json).unwrap(); - - assert_eq!(action_data.label, deserialized.label); - assert_eq!(action_data.owner_node_name, deserialized.owner_node_name); - assert_eq!(action_data.action_id, deserialized.action_id); - } - - #[test] - fn test_graph_node_complete() { - let position = NodePosition { x: 150.0, y: 250.0 }; - let node_data = GraphNodeData::OwnerNode { - name: "test.os".to_string(), - tba_address: Some("0x123".to_string()), - owner_address: Some("0x456".to_string()), - }; - - let graph_node = GraphNode { - id: "owner-node-1".to_string(), - node_type: "ownerNode".to_string(), - data: node_data, - position: Some(position), - }; - - let json = serde_json::to_string(&graph_node).unwrap(); - let deserialized: GraphNode = serde_json::from_str(&json).unwrap(); - - assert_eq!(graph_node.id, deserialized.id); - assert_eq!(graph_node.node_type, deserialized.node_type); - assert!(deserialized.position.is_some()); - - if let Some(pos) = deserialized.position { - assert_eq!(pos.x, 150.0); - assert_eq!(pos.y, 250.0); - } - } - - #[test] - fn test_graph_edge() { - let edge = GraphEdge { - id: "edge-1".to_string(), - source: "owner-node".to_string(), - target: "operator-wallet-node".to_string(), - style_type: Some("dashed".to_string()), - animated: Some(true), - }; - - let json = serde_json::to_string(&edge).unwrap(); - let deserialized: GraphEdge = serde_json::from_str(&json).unwrap(); - - assert_eq!(edge.id, deserialized.id); - assert_eq!(edge.source, deserialized.source); - assert_eq!(edge.target, deserialized.target); - assert_eq!(edge.style_type, deserialized.style_type); - assert_eq!(edge.animated, deserialized.animated); - } - - #[test] - fn test_hypergrid_graph_response() { - let node = GraphNode { - id: "test-node".to_string(), - node_type: "ownerNode".to_string(), - data: GraphNodeData::OwnerNode { - name: "test.os".to_string(), - tba_address: None, - owner_address: None, - }, - position: None, - }; - - let edge = GraphEdge { - id: "test-edge".to_string(), - source: "node-1".to_string(), - target: "node-2".to_string(), - style_type: None, - animated: None, - }; - - let response = HypergridGraphResponse { - nodes: vec![node], - edges: vec![edge], - }; - - let json = serde_json::to_string(&response).unwrap(); - let deserialized: HypergridGraphResponse = serde_json::from_str(&json).unwrap(); - - assert_eq!(response.nodes.len(), deserialized.nodes.len()); - assert_eq!(response.edges.len(), deserialized.edges.len()); - assert_eq!(response.nodes[0].id, deserialized.nodes[0].id); - assert_eq!(response.edges[0].id, deserialized.edges[0].id); - } -} \ No newline at end of file diff --git a/operator/operator/src/tests/helpers_tests.rs b/operator/operator/src/tests/helpers_tests.rs deleted file mode 100644 index 90df2ea..0000000 --- a/operator/operator/src/tests/helpers_tests.rs +++ /dev/null @@ -1,264 +0,0 @@ -#[cfg(test)] -mod helpers_tests { - use crate::helpers::*; - use crate::structs::*; - use sha2::{Sha256, Digest}; - use alloy_primitives::{Address as EthAddress, B256}; - use std::str::FromStr; - - #[test] - fn test_decode_datakey_valid_hex() { - let hex_string = "0x48656c6c6f20576f726c64"; // "Hello World" in hex - let result = _decode_datakey(hex_string).unwrap(); - assert_eq!(result, "Hello World"); - } - - #[test] - fn test_decode_datakey_without_prefix() { - let hex_string = "48656c6c6f20576f726c64"; // "Hello World" in hex without 0x - let result = _decode_datakey(hex_string).unwrap(); - assert_eq!(result, "Hello World"); - } - - #[test] - fn test_decode_datakey_odd_length() { - let hex_string = "0x48656c6c6f20576f726c6"; // Odd length hex - let result = _decode_datakey(hex_string); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("odd number of hex digits")); - } - - #[test] - fn test_decode_datakey_invalid_hex() { - let hex_string = "0x48656g6c6f20576f726c64"; // Invalid hex character 'g' - let result = _decode_datakey(hex_string); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("invalid hex digit")); - } - - #[test] - fn test_decode_datakey_non_utf8() { - let hex_string = "0xff80"; // Invalid UTF-8 sequence - let result = _decode_datakey(hex_string); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("invalid UTF-8")); - } - - #[test] - fn test_decode_datakey_non_printable() { - let hex_string = "0x0148656c6c6f"; // Contains control character (0x01) - let result = _decode_datakey(hex_string); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("non-printable characters")); - } - - #[test] - fn test_decode_datakey_empty() { - let hex_string = "0x"; - let result = _decode_datakey(hex_string).unwrap(); - assert_eq!(result, ""); - } - - #[test] - fn test_decode_datakey_printable_ascii() { - let hex_string = "0x41424321407e"; // "ABC!@~" - all printable ASCII - let result = _decode_datakey(hex_string).unwrap(); - assert_eq!(result, "ABC!@~"); - } - - #[test] - fn test_make_json_timestamp() { - let timestamp = make_json_timestamp(); - - // Should be a valid JSON number - assert!(timestamp.is_u64()); - - // Should be a reasonable timestamp (after 2020 and before 2050) - let ts_u64 = timestamp.as_u64().unwrap(); - assert!(ts_u64 > 1577836800); // 2020-01-01 - assert!(ts_u64 < 2524608000); // 2050-01-01 - } - - #[test] - fn test_get_provider_id_consistent() { - let provider_name = "weather-service"; - let id1 = get_provider_id(provider_name); - let id2 = get_provider_id(provider_name); - - assert_eq!(id1, id2); - assert_eq!(id1.len(), 64); // SHA256 hex string length - } - - #[test] - fn test_get_provider_id_different_names() { - let id1 = get_provider_id("weather-service"); - let id2 = get_provider_id("news-service"); - - assert_ne!(id1, id2); - assert_eq!(id1.len(), 64); - assert_eq!(id2.len(), 64); - } - - #[test] - fn test_get_provider_id_matches_sha256() { - let provider_name = "test-provider"; - let manual_hash = { - let digest = Sha256::digest(provider_name.as_bytes()); - format!("{:x}", digest) - }; - - let function_hash = get_provider_id(provider_name); - assert_eq!(manual_hash, function_hash); - } - - #[test] - fn test_get_provider_id_empty_string() { - let id = get_provider_id(""); - assert_eq!(id.len(), 64); - - // Should match SHA256 of empty string - let expected = format!("{:x}", Sha256::digest(b"")); - assert_eq!(id, expected); - } - - #[test] - fn test_get_provider_id_special_characters() { - let provider_name = "provider-name_123!@#$%^&*()"; - let id = get_provider_id(provider_name); - assert_eq!(id.len(), 64); - - // Should be valid hex - assert!(hex::decode(&id).is_ok()); - } - - #[test] - fn test_auth_error_variants() { - use AuthError::*; - - // Test that each variant is distinct - let errors = vec![ - MissingClientId, - MissingToken, - ClientNotFound, - InvalidToken, - InsufficientCapabilities, - ]; - - // Each should be different when formatted as debug - for (i, error1) in errors.iter().enumerate() { - for (j, error2) in errors.iter().enumerate() { - if i != j { - assert_ne!(format!("{:?}", error1), format!("{:?}", error2)); - } - } - } - } - - #[test] - fn test_b256_conversion() { - // Test that B256 can be created from a slice - let bytes = [0u8; 32]; - let b256 = B256::from_slice(&bytes); - assert_eq!(b256.as_slice(), &bytes); - } - - #[test] - fn test_eth_address_parsing() { - let valid_address = "0x742d35Cc6634C0532925a3b8D0c0D7D2d1234567"; - let address = EthAddress::from_str(valid_address); - assert!(address.is_ok()); - - let parsed = address.unwrap(); - assert_eq!(parsed.to_string().to_lowercase(), valid_address.to_lowercase()); - } - - #[test] - fn test_eth_address_zero() { - let zero_address = EthAddress::ZERO; - assert_eq!(zero_address.to_string(), "0x0000000000000000000000000000000000000000"); - } - - #[test] - fn test_hex_encoding_decoding() { - let data = b"Hello, World!"; - let encoded = hex::encode(data); - let decoded = hex::decode(&encoded).unwrap(); - assert_eq!(data, decoded.as_slice()); - } - - #[test] - fn test_hex_encoding_with_prefix() { - let data = b"test"; - let encoded = hex::encode(data); - let with_prefix = format!("0x{}", encoded); - - // Remove prefix and decode - let without_prefix = if with_prefix.starts_with("0x") { - &with_prefix[2..] - } else { - &with_prefix - }; - - let decoded = hex::decode(without_prefix).unwrap(); - assert_eq!(data, decoded.as_slice()); - } - - // Test helper functions used in the main helpers.rs - #[test] - fn test_sha256_consistency() { - let input = "test input"; - - let mut hasher1 = Sha256::new(); - hasher1.update(input.as_bytes()); - let result1 = format!("{:x}", hasher1.finalize()); - - let mut hasher2 = Sha256::new(); - hasher2.update(input.as_bytes()); - let result2 = format!("{:x}", hasher2.finalize()); - - assert_eq!(result1, result2); - assert_eq!(result1.len(), 64); - } - - #[test] - fn test_slice_operations() { - let data = vec![1, 2, 3, 4, 5]; - - // Test that we can take slices like the code does - let slice = &data[0..32.min(data.len())]; - assert_eq!(slice, &[1, 2, 3, 4, 5]); - - // Test with exactly 32 bytes - let data32 = vec![0u8; 32]; - let slice32 = &data32[0..32]; - assert_eq!(slice32.len(), 32); - } - - #[test] - fn test_string_from_utf8_operations() { - let valid_utf8 = vec![72, 101, 108, 108, 111]; // "Hello" - let result = String::from_utf8(valid_utf8); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "Hello"); - - let invalid_utf8 = vec![255, 254, 253]; // Invalid UTF-8 - let result = String::from_utf8(invalid_utf8); - assert!(result.is_err()); - } - - #[test] - fn test_printable_ascii_check() { - // Test the character range check used in _decode_datakey - let printable_chars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"; - - for c in printable_chars.chars() { - assert!(c >= ' ' && c <= '~', "Character '{}' should be printable ASCII", c); - } - - // Test non-printable characters - let non_printable = ['\x00', '\x01', '\x1F', '\x7F']; - for c in non_printable { - assert!(!(c >= ' ' && c <= '~'), "Character '{}' should not be printable ASCII", c); - } - } -} \ No newline at end of file diff --git a/operator/operator/src/tests/state_tests.rs b/operator/operator/src/tests/state_tests.rs deleted file mode 100644 index 0d82851..0000000 --- a/operator/operator/src/tests/state_tests.rs +++ /dev/null @@ -1,165 +0,0 @@ -#[cfg(test)] -mod state_tests { - use crate::structs::*; - use std::collections::HashMap; - - #[test] - fn test_state_new_initialization() { - let state = State::new(); - - assert_eq!(state.chain_id, CHAIN_ID); - assert_eq!(state.last_checkpoint_block, HYPERMAP_FIRST_BLOCK); - assert!(state.managed_wallets.is_empty()); - assert!(state.selected_wallet_id.is_none()); - assert!(state.operator_entry_name.is_none()); - assert!(state.operator_tba_address.is_none()); - assert!(state.call_history.is_empty()); - assert!(state.authorized_clients.is_empty()); - assert!(!state.timers_initialized); - } - - #[test] - fn test_spending_limits_serialization() { - let limits = SpendingLimits { - max_per_call: Some("100.0".to_string()), - max_total: Some("1000.0".to_string()), - currency: Some("USDC".to_string()), - }; - - let json = serde_json::to_string(&limits).unwrap(); - let deserialized: SpendingLimits = serde_json::from_str(&json).unwrap(); - - assert_eq!(limits.max_per_call, deserialized.max_per_call); - assert_eq!(limits.max_total, deserialized.max_total); - assert_eq!(limits.currency, deserialized.currency); - } - - #[test] - fn test_onboarding_status_variants() { - let statuses = vec![ - OnboardingStatus::Loading, - OnboardingStatus::NeedsHotWallet, - OnboardingStatus::NeedsOnChainSetup, - OnboardingStatus::NeedsFunding, - OnboardingStatus::Ready, - OnboardingStatus::Error, - ]; - - for status in statuses { - let json = serde_json::to_string(&status).unwrap(); - let _deserialized: OnboardingStatus = serde_json::from_str(&json).unwrap(); - } - } - - #[test] - fn test_payment_attempt_result_success() { - let result = PaymentAttemptResult::Success { - tx_hash: "0x123456".to_string(), - amount_paid: "1.5".to_string(), - currency: "USDC".to_string(), - }; - - let json = serde_json::to_string(&result).unwrap(); - let deserialized: PaymentAttemptResult = serde_json::from_str(&json).unwrap(); - - match deserialized { - PaymentAttemptResult::Success { tx_hash, amount_paid, currency } => { - assert_eq!(tx_hash, "0x123456"); - assert_eq!(amount_paid, "1.5"); - assert_eq!(currency, "USDC"); - } - _ => panic!("Expected Success variant"), - } - } - - #[test] - fn test_payment_attempt_result_failed() { - let result = PaymentAttemptResult::Failed { - error: "Insufficient balance".to_string(), - amount_attempted: "2.0".to_string(), - currency: "USDC".to_string(), - }; - - let json = serde_json::to_string(&result).unwrap(); - let deserialized: PaymentAttemptResult = serde_json::from_str(&json).unwrap(); - - match deserialized { - PaymentAttemptResult::Failed { error, amount_attempted, currency } => { - assert_eq!(error, "Insufficient balance"); - assert_eq!(amount_attempted, "2.0"); - assert_eq!(currency, "USDC"); - } - _ => panic!("Expected Failed variant"), - } - } - - #[test] - fn test_call_record_serialization() { - let call_record = CallRecord { - timestamp_start_ms: 1640995200000, - provider_lookup_key: "weather-provider".to_string(), - target_provider_id: "weather:provider:os".to_string(), - call_args_json: r#"{"location": "San Francisco"}"#.to_string(), - call_success: true, - response_timestamp_ms: 1640995201000, - payment_result: Some(PaymentAttemptResult::Success { - tx_hash: "0xdef".to_string(), - amount_paid: "0.001".to_string(), - currency: "USDC".to_string(), - }), - duration_ms: 1000, - operator_wallet_id: Some("wallet-1".to_string()), - }; - - let json = serde_json::to_string(&call_record).unwrap(); - let deserialized: CallRecord = serde_json::from_str(&json).unwrap(); - - assert_eq!(call_record.timestamp_start_ms, deserialized.timestamp_start_ms); - assert_eq!(call_record.provider_lookup_key, deserialized.provider_lookup_key); - assert_eq!(call_record.target_provider_id, deserialized.target_provider_id); - assert_eq!(call_record.call_success, deserialized.call_success); - assert_eq!(call_record.duration_ms, deserialized.duration_ms); - } - - #[test] - fn test_identity_status_verified() { - let status = IdentityStatus::Verified { - entry_name: "test.os".to_string(), - tba_address: "0x123".to_string(), - owner_address: "0x456".to_string(), - }; - - let json = serde_json::to_string(&status).unwrap(); - let deserialized: IdentityStatus = serde_json::from_str(&json).unwrap(); - - match deserialized { - IdentityStatus::Verified { entry_name, tba_address, owner_address } => { - assert_eq!(entry_name, "test.os"); - assert_eq!(tba_address, "0x123"); - assert_eq!(owner_address, "0x456"); - } - _ => panic!("Expected Verified variant"), - } - } - - #[test] - fn test_delegation_status_serialization() { - let statuses = vec![ - DelegationStatus::Verified, - DelegationStatus::NeedsIdentity, - DelegationStatus::NeedsHotWallet, - DelegationStatus::AccessListNoteMissing, - DelegationStatus::SignersNoteMissing, - DelegationStatus::HotWalletNotInList, - DelegationStatus::AccessListNoteInvalidData("Invalid length".to_string()), - DelegationStatus::SignersNoteLookupError("RPC error".to_string()), - DelegationStatus::SignersNoteInvalidData("ABI decode error".to_string()), - DelegationStatus::CheckError("Network timeout".to_string()), - ]; - - for status in statuses { - let json = serde_json::to_string(&status).unwrap(); - let _deserialized: DelegationStatus = serde_json::from_str(&json).unwrap(); - } - } -} \ No newline at end of file diff --git a/operator/operator/src/wallet.rs b/operator/operator/src/wallet.rs new file mode 100644 index 0000000..35eca13 --- /dev/null +++ b/operator/operator/src/wallet.rs @@ -0,0 +1,244 @@ +use crate::structs::{ActiveAccountDetails, ManagedWallet, SpendingLimits, State, WalletSummary}; +use hyperware_process_lib::hyperwallet_client; +use hyperware_process_lib::logging::{error, info}; +use hyperware_process_lib::wallet::KeyStorage; +use serde_json; + +/// Build a wallet summary from wallet data +pub fn build_wallet_summary( + wallet_id: &str, + wallet: &ManagedWallet, + selected_id: &Option, + active_signer_id: &Option, +) -> WalletSummary { + WalletSummary { + id: wallet_id.to_string(), + name: wallet.name.clone(), + address: wallet_id.to_string(), + is_encrypted: check_if_wallet_encrypted(wallet), + is_selected: check_if_wallet_selected(wallet_id, selected_id), + is_unlocked: check_if_wallet_unlocked(wallet_id, active_signer_id), + } +} + +/// Check if a wallet's storage is encrypted +fn check_if_wallet_encrypted(wallet: &ManagedWallet) -> bool { + wallet + .get_storage() + .map(|s| matches!(s, KeyStorage::Encrypted(_))) + .unwrap_or(true) +} + +/// Check if a wallet is currently selected +fn check_if_wallet_selected(wallet_id: &str, selected_id: &Option) -> bool { + selected_id + .as_ref() + .map(|id| id == wallet_id) + .unwrap_or(false) +} + +/// Check if a wallet is currently unlocked (has active signer) +fn check_if_wallet_unlocked(wallet_id: &str, active_signer_id: &Option) -> bool { + active_signer_id.as_ref() == Some(&wallet_id.to_string()) +} + +/// Build a list of wallet summaries from state +pub fn build_wallet_summary_list(state: &State) -> Vec { + state + .managed_wallets + .iter() + .map(|(wallet_id, wallet)| { + build_wallet_summary( + wallet_id, + wallet, + &state.selected_wallet_id, + &state.active_signer_wallet_id, + ) + }) + .collect() +} + +/// Select a wallet as the active wallet +pub async fn select_wallet( + process: &mut crate::OperatorProcess, + wallet_id: String, +) -> Result<(), String> { + info!("Selecting wallet: {}", wallet_id); + + // Check if wallet exists + if !process + .state + .managed_wallets + .iter() + .any(|(id, _)| id == &wallet_id) + { + return Err(format!("Wallet {} not found", wallet_id)); + } + + // Just update local state - hyperwallet doesn't have a select_wallet method + process.state.selected_wallet_id = Some(wallet_id.clone()); + + // Update active signer if we have the session + if let Some(session) = &process.hyperwallet_session { + // Check if we need to update active signer + if let Some((_, wallet)) = process + .state + .managed_wallets + .iter() + .find(|(id, _)| id == &wallet_id) + { + // If wallet is not encrypted, set it as active signer + if !check_if_wallet_encrypted(wallet) { + process.state.active_signer_wallet_id = Some(wallet_id.clone()); + // Don't set active_signer here - that requires unlocking the wallet + } + } + info!("Wallet {} selected successfully", wallet_id); + Ok(()) + } else { + Err("Hyperwallet session not available".to_string()) + } +} + +/// Generate a new wallet +pub async fn generate_wallet(process: &mut crate::OperatorProcess) -> Result { + info!("Generating new wallet"); + + if let Some(session) = &process.hyperwallet_session { + match hyperwallet_client::create_wallet(&session.session_id, None, None) { + Ok(wallet_info) => { + let wallet_id = wallet_info.address.clone(); + info!("Generated wallet: {}", wallet_id); + + // Add to state + let wallet = ManagedWallet { + id: wallet_id.clone(), + name: wallet_info.name.clone(), + storage_json: "{}".to_string(), // Will be populated later when wallet is unlocked + spending_limits: SpendingLimits { + max_per_call: None, + max_total: None, + currency: Some("USDC".to_string()), + total_spent: None, + }, + }; + + process + .state + .managed_wallets + .push((wallet_id.clone(), wallet)); + process.state.selected_wallet_id = Some(wallet_id.clone()); + + // Notify WebSocket clients + process.notify_wallet_update(); + + Ok(wallet_id) + } + Err(e) => { + error!("Failed to generate wallet: {:?}", e); + Err(format!("Failed to generate wallet: {:?}", e)) + } + } + } else { + Err("Hyperwallet session not available".to_string()) + } +} + +/// Delete a wallet +pub async fn delete_wallet( + process: &mut crate::OperatorProcess, + wallet_id: String, +) -> Result<(), String> { + info!("Deleting wallet: {}", wallet_id); + + // Can't delete the selected wallet + if process.state.selected_wallet_id.as_ref() == Some(&wallet_id) { + return Err("Cannot delete the currently selected wallet".to_string()); + } + + if let Some(session) = &process.hyperwallet_session { + match hyperwallet_client::delete_wallet(&session.session_id, &wallet_id) { + Ok(_) => { + // Remove from state + process + .state + .managed_wallets + .retain(|(id, _)| id != &wallet_id); + info!("Wallet {} deleted successfully", wallet_id); + Ok(()) + } + Err(e) => { + error!("Failed to delete wallet {}: {:?}", wallet_id, e); + Err(format!("Failed to delete wallet: {:?}", e)) + } + } + } else { + Err("Hyperwallet session not available".to_string()) + } +} + +/// Rename a wallet +pub async fn rename_wallet( + process: &mut crate::OperatorProcess, + wallet_id: String, + new_name: String, +) -> Result<(), String> { + info!("Renaming wallet {} to '{}'", wallet_id, new_name); + + // Find wallet in state + if let Some(wallet) = process + .state + .managed_wallets + .iter_mut() + .find(|(id, _)| id == &wallet_id) + .map(|(_, wallet)| wallet) + { + // Update name in hyperwallet service + if let Some(session) = &process.hyperwallet_session { + match hyperwallet_client::rename_wallet(&session.session_id, &wallet_id, &new_name) { + Ok(_) => { + wallet.name = Some(new_name); + info!("Wallet {} renamed successfully", wallet_id); + Ok(()) + } + Err(e) => { + error!("Failed to rename wallet {}: {:?}", wallet_id, e); + Err(format!("Failed to rename wallet: {:?}", e)) + } + } + } else { + Err("Hyperwallet session not available".to_string()) + } + } else { + Err(format!("Wallet {} not found", wallet_id)) + } +} + +/// Get active account details for the currently selected wallet +pub fn get_active_account_details(state: &State) -> Option { + // Check if there's a selected wallet + let selected_id = state.selected_wallet_id.as_ref()?; + + // Find the wallet in managed wallets + let (wallet_id, wallet) = state + .managed_wallets + .iter() + .find(|(id, _)| id == selected_id)?; + + // Check if wallet is unlocked (has active signer) + let is_unlocked = state.active_signer_wallet_id.as_ref() == Some(wallet_id); + + // Check if wallet is encrypted + let is_encrypted = check_if_wallet_encrypted(wallet); + + Some(ActiveAccountDetails { + id: wallet_id.clone(), + name: wallet.name.clone(), + address: wallet_id.clone(), // The wallet ID is the address + is_encrypted, + is_selected: true, // Always true since we're returning the selected wallet + is_unlocked, + eth_balance: None, // TODO: Fetch from chain + usdc_balance: None, // TODO: Fetch from chain + }) +} diff --git a/operator/operator/src/wallet/payments.rs b/operator/operator/src/wallet/payments.rs deleted file mode 100644 index 9999a6f..0000000 --- a/operator/operator/src/wallet/payments.rs +++ /dev/null @@ -1,642 +0,0 @@ -//! Payment-centric operations for hot wallets. -//! -//! This module contains all payment-related functionality including: -//! - Payment execution for providers -//! - TBA (Token Bound Account) operations -//! - ETH and USDC transfers -//! - Funding status checks -//! - Spending limit validation - -use crate::structs::{State, PaymentAttemptResult, TbaFundingDetails, ProviderRequest, DelegationStatus}; -use crate::http_handlers::send_request_to_provider; -use crate::wallet::service::{get_decrypted_signer_for_wallet}; -use crate::constants::PUBLISHER; - -use anyhow::Result; -use hyperware_process_lib::{ - logging::{info, error}, - eth, wallet, - signer::{LocalSigner, Signer}, - Address as HyperwareAddress, -}; -use alloy_primitives::{Address, U256, B256, Bytes}; -use std::str::FromStr; -use std::thread; -// New Enum for Asset Type -#[derive(Debug, Clone, Copy)] -pub enum AssetType { - Eth, - Usdc, -} - - -// --- Configuration Constants --- -pub const BASE_CHAIN_ID: u64 = 8453; -pub const BASE_USDC_ADDRESS: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; -pub const USDC_DECIMALS: u8 = 6; - -// --- Helper Struct --- -struct PaymentPrerequisites { - operator_tba_address: Address, - provider_tba_address: Address, - price_f64: f64, - price_str: String, - currency: String, -} - -/// Checks if a proposed spending amount is within the configured limits. -pub fn check_spending_limit(state: &State, amount_to_spend_str: &str) -> Result<(), String> { - // Get selected wallet's limits - let limits = match &state.selected_wallet_id { - Some(id) => state.managed_wallets.get(id).map(|w| &w.spending_limits), - None => None, - }; - - match limits { - Some(limits) => { - // Check per-call limit - if let Some(max_call_str) = &limits.max_per_call { - if !max_call_str.trim().is_empty() { - let amount_f64 = amount_to_spend_str.parse::() - .map_err(|_| format!("Invalid amount format: {}", amount_to_spend_str))?; - let max_call_f64 = max_call_str.parse::() - .map_err(|_| format!("Invalid max_per_call limit format: {}", max_call_str))?; - - if amount_f64 > max_call_f64 { - return Err(format!( - "Limit Exceeded (Max: {} {})", - max_call_str, limits.currency.as_deref().unwrap_or("USDC") - )); - } - } - } - // TODO: Add checks for max_total and time-based limits when implemented - Ok(()) - } - None => Ok(()), // No limits set (or no wallet selected), always allow - } -} - -/// Checks limits and attempts ERC20 payment if conditions are met using the Operator TBA. -/// Returns Some(PaymentAttemptResult) describing the outcome, or None if checks indicate no attempt should be made. -pub fn execute_payment_if_needed( - state: &mut State, - provider_wallet_str: &str, - provider_price_str: &str, - provider_id: String, - associated_hot_wallet_id: &str, -) -> Option { - info!("Attempting payment for provider {} using hot wallet {}. Provider Wallet: {}, Price: {}", - provider_id, associated_hot_wallet_id, provider_wallet_str, provider_price_str); - - match check_payment_prerequisites(state, provider_wallet_str, provider_price_str, provider_id, associated_hot_wallet_id) { - Ok(prereqs) => { - // Re-fetch the signer here now that checks have passed - let hot_wallet_signer_instance = match get_decrypted_signer_for_wallet(state, associated_hot_wallet_id) { - Ok(s) => s, - Err(e) => return Some(PaymentAttemptResult::Skipped { - reason: format!("Failed to retrieve signer for wallet {} post-checks: {}", associated_hot_wallet_id, e) - }), - }; - - info!( - "All checks passed. Attempting payment of {} {} via Operator TBA {} (signed by {}) to Provider TBA {}", - prereqs.price_f64, - prereqs.currency, - prereqs.operator_tba_address, - hot_wallet_signer_instance.address(), - prereqs.provider_tba_address - ); - - Some(perform_tba_payment_execution( - &prereqs.operator_tba_address, - &prereqs.provider_tba_address, - &hot_wallet_signer_instance, - prereqs.price_f64, - &prereqs.price_str, - &prereqs.currency, - )) - } - Err(skip_or_fail_reason) => { - Some(skip_or_fail_reason) - } - } -} - -/// Performs all prerequisite checks before attempting a payment. -fn check_payment_prerequisites( - state: &State, - provider_wallet_str: &str, - provider_price_str: &str, - provider_id: String, - associated_hot_wallet_id: &str, -) -> Result { - info!("Payment Prereqs: Using Hot Wallet ID: {} for payment", associated_hot_wallet_id); - - // Check 1: Operator TBA Configuration - let operator_tba_address = state.operator_tba_address.as_ref().ok_or_else(|| { - error!("Operator TBA address not configured in state."); - PaymentAttemptResult::Skipped { reason: "Operator TBA Not Configured".to_string() } - }).and_then(|addr_str| Address::from_str(addr_str).map_err(|_| - PaymentAttemptResult::Skipped { reason: "Invalid Operator TBA Configuration".to_string() } - ))?; - if state.operator_entry_name.is_none() { info!("Warning: Operator entry name not configured in state."); } - - // Check 2: Provider TBA Address - let mut final_provider_tba_str = provider_wallet_str.to_string(); - if final_provider_tba_str == "0x0" || final_provider_tba_str.len() != 42 { - final_provider_tba_str = "0x3dE425580de16348983d6D7F25618eDA18B359DF".to_string(); - } - let provider_tba_address = Address::from_str(&final_provider_tba_str).map_err(|_| - PaymentAttemptResult::Skipped { reason: "Invalid Provider TBA Address".to_string() } - )?; - - // Check 3: Price Validity - let price_f64 = provider_price_str.parse::().map_err(|_| - PaymentAttemptResult::Skipped { reason: "Invalid Price Format".to_string() } - ).and_then(|p| if p > 0.0 { Ok(p) } else { - Err(PaymentAttemptResult::Skipped { reason: "Zero or Invalid Price".to_string() }) - })?; - let price_str = price_f64.to_string(); - - // Check 4: Get the specific Hot Wallet info - let hot_wallet_managed_info = state.managed_wallets.get(associated_hot_wallet_id).ok_or_else(|| { - error!("Payment Prereqs: Associated hot wallet {} not found in managed list.", associated_hot_wallet_id); - PaymentAttemptResult::Skipped { reason: format!("Associated hot wallet {} not found", associated_hot_wallet_id) } - })?; - - // Verify the wallet can be used (not necessarily getting the signer yet) - if !matches!(&hot_wallet_managed_info.storage, hyperware_process_lib::wallet::KeyStorage::Decrypted(_)) { - return Err(PaymentAttemptResult::Skipped { - reason: format!("Wallet {} is encrypted and requires unlocking.", associated_hot_wallet_id) - }); - } - - // Check 5: Spending Limits for the *associated* hot wallet - let currency = hot_wallet_managed_info.spending_limits.currency.clone().unwrap_or_else(|| "USDC".to_string()); - if let Some(max_call_str) = &hot_wallet_managed_info.spending_limits.max_per_call { - if !max_call_str.trim().is_empty() { - let max_call_f64 = max_call_str.parse::().map_err(|_| - PaymentAttemptResult::Failed { - error: format!("Invalid max_per_call limit format on wallet {}: {}", associated_hot_wallet_id, max_call_str), - amount_attempted: price_str.clone(), - currency: currency.clone() - } - )?; - if price_f64 > max_call_f64 { - return Err(PaymentAttemptResult::LimitExceeded { - limit: format!("Max/Call {} {}", max_call_str, currency), - amount_attempted: price_str.clone(), - currency: currency.clone(), - }); - } - } - } - - // Check 6: Hot Wallet Delegation - match crate::wallet::service::verify_single_hot_wallet_delegation_detailed(state, state.operator_entry_name.as_deref(), associated_hot_wallet_id) { - DelegationStatus::Verified => info!("Hot wallet {} delegation verified.", associated_hot_wallet_id), - status => return Err(PaymentAttemptResult::Skipped { - reason: format!("Delegation check failed for wallet {}: {:?}", associated_hot_wallet_id, status) - }), - } - - // Check 7: Provider Availability - check_provider_availability(&provider_id).map_err(|e| - PaymentAttemptResult::Skipped { reason: format!("Provider Availability Check Error for {}: {}", provider_id, e) } - )?; - - // All Checks Passed - Ok(PaymentPrerequisites { - operator_tba_address, - provider_tba_address, - price_f64, - price_str, - currency, - }) -} - -/// Private helper to perform the actual TBA payment execution and confirmation. -fn perform_tba_payment_execution( - from_account_address: &Address, - to_account_address: &Address, - hot_wallet_signer: &LocalSigner, - price_f64: f64, - price_to_check_str: &str, - currency: &str, -) -> PaymentAttemptResult { - // 1. Calculate amount_u256 for USDC - let decimals = USDC_DECIMALS; - let scale_factor = 10u128.pow(decimals as u32); - let amount_scaled = (price_f64 * scale_factor as f64) as u128; - let amount_u256 = U256::from(amount_scaled); - - // 2. Resolve USDC address - let usdc_address = match wallet::resolve_token_symbol("USDC", BASE_CHAIN_ID) { - Ok(addr) => addr, - Err(e) => { - error!("Failed to resolve USDC address for chain {}: {:?}", BASE_CHAIN_ID, e); - return PaymentAttemptResult::Failed { - error: format!("Internal Configuration Error (Cannot resolve USDC address: {:?})", e), - amount_attempted: price_to_check_str.to_string(), - currency: currency.to_string() - }; - } - }; - - // 3. Create inner calldata for USDC transfer - let inner_calldata = wallet::create_erc20_transfer_calldata(*to_account_address, amount_u256); - - // 4. Setup Provider - let eth_provider = eth::Provider::new(BASE_CHAIN_ID, 10); - - // 5. Call wallet::execute_via_tba_with_signer - info!("Sending execute transaction via From Account Address {} to To Account Address {} (Inner call: transfer {} USDC to {})", - from_account_address, to_account_address, price_f64, to_account_address); - - let execution_result = wallet::execute_via_tba_with_signer( - &from_account_address.to_string(), - hot_wallet_signer, - &usdc_address.to_string(), - inner_calldata, - U256::ZERO, - ð_provider, - Some(0) - ); - - // 6. Handle SUBMISSION result - match execution_result { - Ok(receipt) => { - let tx_hash_raw = receipt.hash; - let tx_hash = format!("{:?}", tx_hash_raw); - info!("TBA Execute Transaction SUBMITTED successfully! Tx Hash: {}", tx_hash); - - // Exponential backoff for polling receipt - const MAX_RETRIES: u32 = 10; - const INITIAL_DELAY_MS: u64 = 500; - const MAX_DELAY_MS: u64 = 8000; - const CONFIRMATIONS_NEEDED: u64 = 1; - - let mut current_retries = 0; - let mut current_delay_ms = INITIAL_DELAY_MS; - - info!("Waiting for transaction confirmation with exponential backoff for Tx Hash: {}", tx_hash); - - loop { - if current_retries >= MAX_RETRIES { - error!("Timeout waiting for TBA payment transaction confirmation for {:?} after {} retries.", tx_hash_raw, MAX_RETRIES); - return PaymentAttemptResult::Failed { - error: format!("Timeout waiting for transaction confirmation after {} retries", MAX_RETRIES), - amount_attempted: price_to_check_str.to_string(), - currency: currency.to_string(), - }; - } - - match eth_provider.get_transaction_receipt(tx_hash_raw) { - Ok(Some(final_receipt)) => { - if let Some(receipt_block_number_u64) = final_receipt.block_number { - // Transaction is mined - match eth_provider.get_block_number() { - Ok(latest_block_number_u64) => { - if latest_block_number_u64 >= receipt_block_number_u64 + CONFIRMATIONS_NEEDED.saturating_sub(1) { - // Sufficient confirmations - let confirmations = latest_block_number_u64.saturating_sub(receipt_block_number_u64) + 1; - info!("Transaction {:?} confirmed with {} confirmations.", tx_hash_raw, confirmations); - info!("Received final receipt: {:#?}", final_receipt); - - if final_receipt.status() { - info!("TBA Payment transaction successful! Tx Hash: {:?}", tx_hash_raw); - return PaymentAttemptResult::Success { - tx_hash: tx_hash.clone(), - amount_paid: price_to_check_str.to_string(), - currency: currency.to_string(), - }; - } else { - error!("TBA Payment transaction confirmed but FAILED (reverted) on-chain. Tx Hash: {:?}", tx_hash_raw); - return PaymentAttemptResult::Failed { - error: "Transaction failed on-chain (reverted)".to_string(), - amount_attempted: price_to_check_str.to_string(), - currency: currency.to_string(), - }; - } - } else { - let current_depth = latest_block_number_u64.saturating_sub(receipt_block_number_u64) + 1; - info!("Transaction {:?} mined but not enough confirmations (need {}, current depth {}).", - tx_hash_raw, CONFIRMATIONS_NEEDED, current_depth); - } - } - Err(e) => { - error!("Failed to get current block number: {:?}.", e); - } - } - } else { - info!("Transaction receipt found but not yet mined."); - } - } - Ok(None) => { - info!("Transaction receipt not yet available. Retrying... (Attempt {}/{})", current_retries + 1, MAX_RETRIES); - } - Err(e) => { - error!("Error fetching transaction receipt: {:?}. Retrying...", e); - } - } - - // Retry logic - thread::sleep(std::time::Duration::from_millis(current_delay_ms)); - current_retries += 1; - current_delay_ms = std::cmp::min(current_delay_ms * 2, MAX_DELAY_MS); - } - } - Err(e) => { - let error_msg = format!("{:?}", e); - error!("TBA Payment failed during submission: {}", error_msg); - PaymentAttemptResult::Failed { - error: error_msg, - amount_attempted: price_to_check_str.to_string(), - currency: currency.to_string(), - } - } - } -} - -/// Checks the availability of a provider by sending a test request. -fn check_provider_availability(provider_id: &str) -> Result<(), String> { - info!("Checking provider availability for ID: {}", provider_id); - - let target_address = HyperwareAddress::new( - provider_id, - ("hypergrid-provider", "hypergrid-provider", PUBLISHER) - ); - //let provider_name = format!("{}", provider_id); - //let arguments = vec![]; - //let payment_tx_hash = None; - - info!("Preparing availability check request for provider process at {}", target_address); - //let provider_request_data = ProviderRequest { - // provider_name, - // arguments, - // payment_tx_hash, - //}; - - //let wrapped_request = serde_json::json!({ - // "CallProvider": provider_request_data - //}); - - let DummyArgument = serde_json::json!({ - "argument": "swag" - }); - - let wrapped_request = serde_json::json!({ - "HealthPing": DummyArgument - }); - - let request_body_bytes = match serde_json::to_vec(&wrapped_request) { - Ok(bytes) => bytes, - Err(e) => { - let err_msg = format!("Failed to serialize provider availability request: {}", e); - error!("{}", err_msg); - return Err(err_msg); - } - }; - - info!("Sending request body bytes to provider: {:?}", request_body_bytes); - - match send_request_to_provider(target_address.clone(), request_body_bytes) { - Ok(Ok(response)) => { - info!("Provider at {} responded successfully to availability check: {:?}", target_address, response); - Ok(()) - } - Ok(Err(e)) => { - let err_msg = format!("Provider at {} failed availability check: {}", target_address, e); - error!("{}", err_msg); - Err(err_msg) - } - Err(e) => { - let err_msg = format!("Error sending availability check to provider at {}: {}", target_address, e); - error!("{}", err_msg); - Err(err_msg) - } - } -} - -/// Main handler for Operator TBA withdrawals. -pub fn handle_operator_tba_withdrawal( - state: &mut State, - asset: AssetType, - to_address: String, - amount: String, -) -> Result<(), String> { - info!("Handling {:?} withdrawal from Operator TBA to {} for amount {}", asset, to_address, amount); - - let operator_tba_address_str = state.operator_tba_address.as_ref().ok_or_else(|| { - error!("Operator TBA address not configured in state."); - "Operator TBA address not configured".to_string() - })?; - let operator_tba = Address::from_str(operator_tba_address_str).map_err(|e| { - error!("Invalid Operator TBA address format: {}", e); - format!("Invalid Operator TBA address format: {}", e) - })?; - - let hot_wallet_signer = crate::wallet::service::get_active_signer(state).map_err(|e| { - error!("Failed to get active hot wallet signer: {}", e); - format!("Active hot wallet signer not available: {}", e) - })?; - - let target_recipient_address = Address::from_str(&to_address).map_err(|e| { - error!("Invalid recipient address format: {}", e); - format!("Invalid recipient address format: {}", e) - })?; - - let eth_provider = eth::Provider::new(BASE_CHAIN_ID, 180); - - match asset { - AssetType::Eth => { - let amount_wei = U256::from_str(&amount).map_err(|e| { - error!("Invalid ETH amount format (must be Wei string): {}", e); - format!("Invalid ETH amount format (must be Wei string): {}", e) - })?; - if amount_wei == U256::ZERO { - return Err("ETH withdrawal amount cannot be zero.".to_string()); - } - info!("Initiating ETH transfer of {} wei from Operator TBA {} to {}", amount_wei, operator_tba, target_recipient_address); - execute_eth_transfer_from_tba(operator_tba, target_recipient_address, amount_wei, hot_wallet_signer, ð_provider) - .map_err(|e| format!("ETH transfer execution failed: {:?}", e)) - .map(|receipt| { - info!("ETH withdrawal transaction submitted: {:?}", receipt.hash); - }) - } - AssetType::Usdc => { - let amount_usdc_units = U256::from_str(&amount).map_err(|e| { - error!("Invalid USDC amount format (must be smallest units string): {}", e); - format!("Invalid USDC amount format (must be smallest units string): {}", e) - })?; - if amount_usdc_units == U256::ZERO { - return Err("USDC withdrawal amount cannot be zero.".to_string()); - } - info!("Initiating USDC transfer of {} units from Operator TBA {} to {}", amount_usdc_units, operator_tba, target_recipient_address); - execute_usdc_transfer_from_tba(operator_tba, target_recipient_address, amount_usdc_units, hot_wallet_signer, ð_provider) - .map_err(|e| format!("USDC transfer execution failed: {:?}", e)) - .map(|receipt| { - info!("USDC withdrawal transaction submitted: {:?}", receipt.hash); - }) - } - } -} - -/// Executes ETH transfer from the Operator TBA. -fn execute_eth_transfer_from_tba( - operator_tba: Address, - target_recipient: Address, - amount_wei: U256, - hot_wallet_signer: &LocalSigner, - provider: ð::Provider -) -> Result { - info!("execute_eth_transfer_from_tba: OperatorTBA={}, Recipient={}, AmountWei={}", - operator_tba, target_recipient, amount_wei); - wallet::execute_via_tba_with_signer( - &operator_tba.to_string(), - hot_wallet_signer, - &target_recipient.to_string(), - Vec::new(), // Empty calldata for native ETH transfer - amount_wei, - provider, - Some(0) // Operation: CALL - ) -} - -/// Executes USDC transfer from the Operator TBA. -fn execute_usdc_transfer_from_tba( - operator_tba: Address, - target_recipient: Address, - amount_usdc_units: U256, - hot_wallet_signer: &LocalSigner, - provider: ð::Provider -) -> Result { - info!("execute_usdc_transfer_from_tba: OperatorTBA={}, Recipient={}, AmountUnits={}", - operator_tba, target_recipient, amount_usdc_units); - let usdc_contract_address = Address::from_str(BASE_USDC_ADDRESS).map_err(|_| - wallet::WalletError::NameResolutionError("Invalid BASE_USDC_ADDRESS constant".to_string()) - )?; - - let inner_calldata = wallet::create_erc20_transfer_calldata(target_recipient, amount_usdc_units); - - wallet::execute_via_tba_with_signer( - &operator_tba.to_string(), - hot_wallet_signer, - &usdc_contract_address.to_string(), - inner_calldata, - U256::ZERO, // Value for the outer call to TBA is 0 - provider, - Some(0) // Operation: CALL - ) -} - -/// Checks the ETH and USDC funding status specifically for the Operator TBA. -pub fn check_operator_tba_funding_detailed( - operator_tba_address: Option<&str>, -) -> TbaFundingDetails { - info!("Checking Operator TBA funding (detailed)... Operator TBA: {:?}", operator_tba_address); - - let mut details = TbaFundingDetails::default(); - let mut errors: Vec = Vec::new(); - - let provider = eth::Provider::new(BASE_CHAIN_ID, 60); - - if let Some(tba_str) = operator_tba_address { - if Address::from_str(tba_str).is_err() { - errors.push(format!("Invalid Operator TBA address format: {}", tba_str)); - details.tba_needs_eth = true; - details.tba_needs_usdc = true; - } else { - // Operator TBA ETH Balance - match wallet::get_eth_balance(tba_str, BASE_CHAIN_ID, provider.clone()) { - Ok(balance) => { - details.tba_eth_balance_str = Some(balance.to_display_string()); - if balance.as_wei() == U256::ZERO { - details.tba_needs_eth = true; - info!(" -> Operator TBA {} needs ETH.", tba_str); - } else { - info!(" -> Operator TBA {} ETH balance: {}", tba_str, balance.to_display_string()); - } - } - Err(e) => { - let err_msg = format!("Error checking Operator TBA ETH for {}: {:?}", tba_str, e); - error!(" {}", err_msg); - errors.push(err_msg); - details.tba_needs_eth = true; - details.tba_eth_balance_str = Some("Error".to_string()); - } - } - // Operator TBA USDC Balance - match wallet::erc20_balance_of(BASE_USDC_ADDRESS, tba_str, &provider) { - Ok(balance_f64) => { - details.tba_usdc_balance_str = Some(format!("{:.6} USDC", balance_f64)); - if balance_f64 <= 0.0 { - details.tba_needs_usdc = true; - info!(" -> Operator TBA {} needs USDC.", tba_str); - } else { - info!(" -> Operator TBA {} USDC balance: {:.6}", tba_str, balance_f64); - } - } - Err(e) => { - let err_msg = format!("Error checking Operator TBA USDC for {}: {:?}", tba_str, e); - error!(" {}", err_msg); - errors.push(err_msg); - details.tba_needs_usdc = true; - details.tba_usdc_balance_str = Some("Error".to_string()); - } - } - } - } else { - let err_msg = "Operator TBA address not provided for funding check.".to_string(); - info!(" -> {}", err_msg); - errors.push(err_msg); - details.tba_needs_eth = true; - details.tba_needs_usdc = true; - } - - if !errors.is_empty() { - details.check_error = Some(errors.join("; ")); - } - - info!("Operator TBA Funding Details: NeedsETH={}, NeedsUSDC={}, ETHBal='{:?}', USDCBal='{:?}', Error='{:?}'", - details.tba_needs_eth, details.tba_needs_usdc, - details.tba_eth_balance_str, details.tba_usdc_balance_str, - details.check_error - ); - details -} - -/// Checks single hot wallet funding status. -pub fn check_single_hot_wallet_funding_detailed( - _state: &State, - hot_wallet_addr: &str, -) -> (bool, Option, Option) { - info!("Checking single hot wallet ETH funding for address: {}", hot_wallet_addr); - - if hot_wallet_addr.is_empty() || Address::from_str(hot_wallet_addr).is_err() { - let err_msg = format!("Invalid or empty hot wallet address provided for funding check: '{}'", hot_wallet_addr); - error!(" -> {}", err_msg); - return (true, Some("Invalid Address".to_string()), Some(err_msg)); - } - - let provider = eth::Provider::new(BASE_CHAIN_ID, 60); - - match wallet::get_eth_balance(hot_wallet_addr, BASE_CHAIN_ID, provider) { - Ok(balance) => { - let balance_str = balance.to_display_string(); - if balance.as_wei() == U256::ZERO { - info!(" -> Hot wallet {} needs ETH. Balance: {}", hot_wallet_addr, balance_str); - (true, Some(balance_str), None) - } else { - info!(" -> Hot wallet {} ETH balance: {}. Funding OK.", hot_wallet_addr, balance_str); - (false, Some(balance_str), None) - } - } - Err(e) => { - let err_msg = format!("Error checking Hot Wallet ETH for {}: {:?}", hot_wallet_addr, e); - error!(" -> {}", err_msg); - (true, Some("Error".to_string()), Some(err_msg)) - } - } -} \ No newline at end of file diff --git a/operator/operator/src/websocket.rs b/operator/operator/src/websocket.rs new file mode 100644 index 0000000..3c08699 --- /dev/null +++ b/operator/operator/src/websocket.rs @@ -0,0 +1,424 @@ +use crate::structs::{ActiveAccountDetails, CallRecord, HotWalletAuthorizedClient, WalletSummary}; +use crate::wallet; +use crate::OperatorProcess; +use hyperware_process_lib::http::server::{send_ws_push, WsMessageType}; +use hyperware_process_lib::logging::error; +use hyperware_process_lib::LazyLoadBlob; +use serde::{Deserialize, Serialize}; + +// Client -> Server messages +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WsClientMessage { + // Subscribe to state updates (no auth needed for read-only) + Subscribe { + // Optional: specific topics to subscribe to + topics: Option>, + }, + // Unsubscribe from updates + Unsubscribe { + topics: Option>, + }, + // Ping for keepalive + Ping, +} + +// Server -> Client messages +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WsServerMessage { + // Subscription confirmed + Subscribed { + topics: Vec, + }, + // State update notification + StateUpdate { + topic: StateUpdateTopic, + data: StateUpdateData, + }, + // Full state snapshot (sent on initial connection) + StateSnapshot { + state: StateSnapshotData, + }, + // Error message + Error { + error: String, + }, + // Pong response + Pong, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum StateUpdateTopic { + Wallets, // Wallet creation/deletion/selection + Transactions, // New calls/transactions + Providers, // Provider updates + Authorization, // Client authorization changes + GraphState, // Coarse state changes + All, // Subscribe to everything +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "update_type", rename_all = "snake_case")] +pub enum StateUpdateData { + // Wallet was created, deleted, or selected + WalletUpdate { + wallets: Vec, + selected_wallet_id: Option, + active_signer_wallet_id: Option, + active_account: Option, + }, + // New transaction/call record + NewTransaction { + record: CallRecord, + }, + // Provider search results updated + ProviderUpdate { + // For now, just notify that providers changed + // Client should re-fetch if needed + update_info: String, + }, + // Authorization client added/removed + AuthorizationUpdate { + clients: Vec<(String, serde_json::Value)>, + }, + // Graph coarse state changed + GraphStateUpdate { + coarse_state: String, + operator_tba_address: Option, + operator_entry_name: Option, + paymaster_approved: Option, + }, + // Balance update + BalanceUpdate { + wallet_id: String, + eth_balance: Option, + usdc_balance: Option, + }, +} + +// Snapshot of current state for initial connection +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateSnapshotData { + pub wallets: Vec, + pub selected_wallet_id: Option, + pub active_account: Option, + pub recent_transactions: Vec, // Last 50 + pub authorized_clients: Vec<(String, serde_json::Value)>, // Now includes spending data + pub coarse_state: String, + pub operator_tba_address: Option, + pub operator_entry_name: Option, + pub gasless_enabled: Option, // Indicates if operator is fully set up + pub paymaster_approved: Option, // Indicates if paymaster is approved for USDC spending + pub client_limits_cache: Vec<(String, crate::structs::SpendingLimits)>, +} + +// WebSocket connection info +#[derive(Debug, Clone)] +pub struct WsConnection { + pub channel_id: u32, + pub subscribed_topics: Vec, + pub connected_at: u64, +} + +impl OperatorProcess { + /// Send a WebSocket message to a specific client + pub(crate) fn send_ws_message(&self, channel_id: u32, message: WsServerMessage) { + match serde_json::to_string(&message) { + Ok(json) => { + send_ws_push( + channel_id, + WsMessageType::Text, + LazyLoadBlob::new(Some("application/json"), json), + ); + } + Err(e) => { + error!("Failed to serialize WebSocket message: {}", e); + } + } + } + + /// Send initial state snapshot to a newly connected client + pub(crate) async fn send_state_snapshot(&self, channel_id: u32) { + // Build wallet summaries + let wallets = wallet::build_wallet_summary_list(&self.state); + + // Get active account details + let mut active_account = wallet::get_active_account_details(&self.state); + + // Fetch USDC balance from ledger if we have an operator TBA + if let (Some(account), Some(db), Some(tba)) = ( + active_account.as_mut(), + self.db_conn.as_ref(), + self.state.operator_tba_address.as_ref(), + ) { + match crate::ledger::get_tba_usdc_balance(db, &tba).await { + Ok(balance) => { + account.usdc_balance = Some(format!("{:.6}", balance)); + } + Err(e) => { + error!("Failed to get USDC balance from ledger: {:?}", e); + } + } + } + + // Get recent transactions from ledger (last 50) + let recent_transactions = if let (Some(db), Some(tba)) = ( + self.db_conn.as_ref(), + self.state.operator_tba_address.as_ref(), + ) { + match crate::ledger::load_recent_call_history(db, tba, 50, Some(&self.state)).await { + Ok(ledger_records) => { + // If we have ledger records, use them + if !ledger_records.is_empty() { + ledger_records + } else { + // Fall back to in-memory state if ledger is empty + self.state + .call_history + .iter() + .rev() + .take(50) + .cloned() + .collect() + } + } + Err(e) => { + error!("Failed to load call history from ledger: {:?}", e); + // Fall back to in-memory state on error + self.state + .call_history + .iter() + .rev() + .take(50) + .cloned() + .collect() + } + } + } else { + // No DB or TBA, use in-memory state + self.state + .call_history + .iter() + .rev() + .take(50) + .cloned() + .collect() + }; + + // Get graph coarse state + let coarse_state = self.determine_coarse_state(); + + // Merge authorized clients with their spending limits + let authorized_clients_with_spending = self + .state + .authorized_clients + .iter() + .map(|(id, client)| { + // Find spending limits for this client + let spending_info = self + .state + .client_limits_cache + .iter() + .find(|(cache_id, _)| cache_id == id) + .map(|(_, limits)| limits); + + // Create a merged representation + let mut client_data = + serde_json::to_value(client).unwrap_or(serde_json::Value::Null); + if let serde_json::Value::Object(ref mut obj) = client_data { + // Add spending data with UI-expected field names + if let Some(limits) = spending_info { + if let Some(total_spent) = &limits.total_spent { + // Parse the string to a number for the UI + if let Ok(spent_num) = total_spent.parse::() { + obj.insert( + "monthlySpent".to_string(), + serde_json::json!(spent_num), + ); + } + } + if let Some(max_total) = &limits.max_total { + if let Ok(limit_num) = max_total.parse::() { + obj.insert( + "monthlyLimit".to_string(), + serde_json::json!(limit_num), + ); + } + } + } + } + + (id.clone(), client_data) + }) + .collect::>(); + + let snapshot = StateSnapshotData { + wallets, + selected_wallet_id: self.state.selected_wallet_id.clone(), + active_account, + recent_transactions, + authorized_clients: authorized_clients_with_spending, + coarse_state, + operator_tba_address: self.state.operator_tba_address.clone(), + operator_entry_name: self.state.operator_entry_name.clone(), + gasless_enabled: self.state.gasless_enabled, + paymaster_approved: self.state.paymaster_approved, + client_limits_cache: self.state.client_limits_cache.clone(), + }; + + let message = WsServerMessage::StateSnapshot { state: snapshot }; + self.send_ws_message(channel_id, message); + } + + /// Broadcast a state update to all connected clients that are subscribed to the topic + pub(crate) fn broadcast_state_update(&self, topic: StateUpdateTopic, data: StateUpdateData) { + let connections: Vec<_> = self.ws_connections.iter().collect(); + + for (channel_id, conn) in connections { + // Check if client is subscribed to this topic or to "All" + if conn.subscribed_topics.contains(&topic) + || conn.subscribed_topics.contains(&StateUpdateTopic::All) + { + let message = WsServerMessage::StateUpdate { + topic: topic.clone(), + data: data.clone(), + }; + self.send_ws_message(*channel_id, message); + } + } + } + + /// Helper to determine current coarse state + fn determine_coarse_state(&self) -> String { + if self.state.operator_entry_name.is_none() || self.state.operator_tba_address.is_none() { + "beforeWallet".to_string() + } else if !self.state.paymaster_approved.unwrap_or(false) + || !self.state.gasless_enabled.unwrap_or(false) + { + // If paymaster not approved or gasless not enabled, still in setup phase + "afterWalletNoClients".to_string() + } else if self.state.authorized_clients.is_empty() { + "afterWalletNoClients".to_string() + } else { + "afterWalletWithClients".to_string() + } + } + + /// Notify clients about wallet changes + pub(crate) fn notify_wallet_update(&self) { + let wallets = wallet::build_wallet_summary_list(&self.state); + let active_account = wallet::get_active_account_details(&self.state); + let data = StateUpdateData::WalletUpdate { + wallets, + selected_wallet_id: self.state.selected_wallet_id.clone(), + active_signer_wallet_id: self.state.active_signer_wallet_id.clone(), + active_account, + }; + self.broadcast_state_update(StateUpdateTopic::Wallets, data); + } + + /// Notify clients about new transaction + pub(crate) fn notify_new_transaction(&self, record: &crate::structs::CallRecord) { + let data = StateUpdateData::NewTransaction { + record: record.clone(), + }; + self.broadcast_state_update(StateUpdateTopic::Transactions, data); + } + + /// Notify clients about authorization changes + pub(crate) fn notify_authorization_update(&self) { + // Merge authorized clients with their spending limits + let authorized_clients_with_spending = self + .state + .authorized_clients + .iter() + .map(|(id, client)| { + // Find spending limits for this client + let spending_info = self + .state + .client_limits_cache + .iter() + .find(|(cache_id, _)| cache_id == id) + .map(|(_, limits)| limits); + + // Create a merged representation + let mut client_data = + serde_json::to_value(client).unwrap_or(serde_json::Value::Null); + if let serde_json::Value::Object(ref mut obj) = client_data { + // Add spending data with UI-expected field names + if let Some(limits) = spending_info { + if let Some(total_spent) = &limits.total_spent { + // Parse the string to a number for the UI + if let Ok(spent_num) = total_spent.parse::() { + obj.insert( + "monthlySpent".to_string(), + serde_json::json!(spent_num), + ); + } + } + if let Some(max_total) = &limits.max_total { + if let Ok(limit_num) = max_total.parse::() { + obj.insert( + "monthlyLimit".to_string(), + serde_json::json!(limit_num), + ); + } + } + } + } + + (id.clone(), client_data) + }) + .collect::>(); + + let data = StateUpdateData::AuthorizationUpdate { + clients: authorized_clients_with_spending, + }; + self.broadcast_state_update(StateUpdateTopic::Authorization, data); + } + + /// Notify clients about graph state changes + pub(crate) fn notify_graph_state_update(&self) { + let coarse_state = self.determine_coarse_state(); + let data = StateUpdateData::GraphStateUpdate { + coarse_state, + operator_tba_address: self.state.operator_tba_address.clone(), + operator_entry_name: self.state.operator_entry_name.clone(), + paymaster_approved: self.state.paymaster_approved, + }; + self.broadcast_state_update(StateUpdateTopic::GraphState, data); + } + + /// Notify clients about wallet/balance updates + pub(crate) async fn notify_wallet_balance_update(&self) { + // Get active account details with updated balance + let mut active_account = wallet::get_active_account_details(&self.state); + + // Fetch USDC balance from ledger if we have an operator TBA + if let (Some(account), Some(db), Some(tba)) = ( + active_account.as_mut(), + self.db_conn.as_ref(), + self.state.operator_tba_address.as_ref(), + ) { + match crate::ledger::get_tba_usdc_balance(db, &tba).await { + Ok(balance) => { + account.usdc_balance = Some(format!("{:.6}", balance)); + } + Err(e) => { + error!("Failed to get USDC balance from ledger: {:?}", e); + } + } + } + + // Send wallet update with balance + let data = StateUpdateData::WalletUpdate { + wallets: wallet::build_wallet_summary_list(&self.state), + selected_wallet_id: self.state.selected_wallet_id.clone(), + active_signer_wallet_id: self.state.active_signer_wallet_id.clone(), + active_account, + }; + self.broadcast_state_update(StateUpdateTopic::Wallets, data); + } +} diff --git a/operator/pkg/manifest.json b/operator/pkg/manifest.json index efe3432..274e837 100644 --- a/operator/pkg/manifest.json +++ b/operator/pkg/manifest.json @@ -13,16 +13,14 @@ "timer:distro:sys", "hypermap-cacher:hypermap-cacher:sys", "sign:sign:sys", - "http-client:distro:sys", + "http-client:distro:sys", "hns-indexer:hns-indexer:sys", - "hyperwallet:hyperwallet:hallman.hypr", - "spider:spider:sys" + "hyperwallet:hyperwallet:hallman.hypr" ], "grant_capabilities": [ - "http-server:distro:sys", - "http-client:distro:sys", - "terminal:terminal:sys", - "spider:spider:sys" + "http-server:distro:sys", + "http-client:distro:sys", + "terminal:terminal:sys" ], "public": true } diff --git a/operator/ui/index.html b/operator/ui/index.html index b541750..e221fc7 100644 --- a/operator/ui/index.html +++ b/operator/ui/index.html @@ -3,20 +3,26 @@ + + Hyperware Provider Network Dashboard diff --git a/operator/ui/package.json b/operator/ui/package.json index 2fe814c..75ed575 100644 --- a/operator/ui/package.json +++ b/operator/ui/package.json @@ -21,7 +21,6 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.5.0", - "react-markdown": "^10.1.0", "react-toastify": "^11.0.5", "reactflow": "^11.11.4", "tailwindcss": "^4.1.11", @@ -37,4 +36,4 @@ "typescript": "^5.4.5", "vite": "^5.2.11" } -} +} \ No newline at end of file diff --git a/operator/ui/src/App.tsx b/operator/ui/src/App.tsx index bbd68b2..00142c5 100644 --- a/operator/ui/src/App.tsx +++ b/operator/ui/src/App.tsx @@ -3,7 +3,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import OperatorConsole from "./components/console/OperatorConsole"; import HeaderSearch from "./components/HeaderSearch.tsx"; import AppSwitcher from "./components/AppSwitcher.tsx"; -import SpiderChat from "./components/SpiderChat.tsx"; // Import required types import { ActiveAccountDetails, OnboardingStatusResponse } from "./logic/types.ts"; @@ -18,7 +17,7 @@ import { HYPERMAP_ADDRESS } from './constants'; import { ToastContainer } from "react-toastify"; import NotificationBell from './components/NotificationBell'; -const BASE_URL = import.meta.env.VITE_BASE_URL || ''; +const BASE_URL = import.meta.env.VITE_BASE_URL; function App() { @@ -29,9 +28,6 @@ function App() { // State for Onboarding Data const [onboardingData, setOnboardingData] = useState(null); - // Spider chat state - const [spiderApiKey, setSpiderApiKey] = useState(null); - // Renamed derived variable const derivedNodeName = useMemo(() => { const windowNodeName = (window as any).our?.node; @@ -66,72 +62,6 @@ function App() { } }); - // Check spider connection status on mount - useEffect(() => { - const checkSpiderStatus = async () => { - try { - const apiBase = BASE_URL || window.location.pathname.replace(/\/$/, ''); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); // 3 second timeout - - const response = await fetch(`${apiBase}/api/spider-status`, { - signal: controller.signal - }); - - clearTimeout(timeout); - - if (response.ok) { - const data = await response.json(); - if (data.has_api_key) { - // If already connected, get the key - const connectResponse = await fetch(`${apiBase}/api/spider-connect`, { - method: 'POST', - signal: AbortSignal.timeout(3000) // 3 second timeout - }); - const connectData = await connectResponse.json(); - if (connectData.api_key) { - setSpiderApiKey(connectData.api_key); - } - } - } - } catch (error: any) { - // Silently fail if Spider is not available - if (error.name === 'AbortError') { - console.log('Spider service not available (timeout)'); - } else { - console.error('Error checking Spider status:', error); - } - } - }; - - checkSpiderStatus(); - }, []); - - const handleSpiderConnect = async () => { - try { - const apiBase = BASE_URL || window.location.pathname.replace(/\/$/, ''); - const response = await fetch(`${apiBase}/api/spider-connect`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - signal: AbortSignal.timeout(5000) // 5 second timeout - }); - const data = await response.json(); - if (data.api_key) { - setSpiderApiKey(data.api_key); - } else if (data.error) { - console.error('Failed to connect to Spider:', data.error); - } - } catch (error: any) { - if (error.name === 'AbortError') { - console.error('Spider connection timeout - service may not be installed'); - } else { - console.error('Error connecting to Spider:', error); - } - } - }; - useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( @@ -154,13 +84,6 @@ function App() {
Hypergrid Logo -
- setSpiderApiKey(newKey)} - /> -
diff --git a/operator/ui/src/components/AuthorizedClientConfigModal.tsx b/operator/ui/src/components/AuthorizedClientConfigModal.tsx index 33defb8..7b49d9d 100644 --- a/operator/ui/src/components/AuthorizedClientConfigModal.tsx +++ b/operator/ui/src/components/AuthorizedClientConfigModal.tsx @@ -3,14 +3,15 @@ import classNames from 'classnames'; import Modal from './modals/Modal'; import { useErrorLogStore } from '../store/errorLog'; import { truncate } from '../utils/truncate'; +import { callApiWithRouting } from '../utils/api-endpoints'; interface AuthorizedClientConfigModalProps { isOpen: boolean; - onClose: (shouldRefresh?: boolean) => void; + onClose: () => void; clientId: string; clientName: string; hotWalletAddress: string; - onClientUpdate: (clientId: string) => void; + //onClientUpdate: (clientId: string) => void; } // Helper function to generate a random API key @@ -37,7 +38,7 @@ const AuthorizedClientConfigModal: React.FC = clientId, clientName, hotWalletAddress, - onClientUpdate + //onClientUpdate }) => { const { showToast } = useErrorLogStore(); const [isRegenerating, setIsRegenerating] = useState(false); @@ -76,7 +77,7 @@ const AuthorizedClientConfigModal: React.FC = } const handleClose = () => { - onClose(hasChanges); + onClose(); // Reset state for next time setHasChanges(false); setNewToken(null); @@ -96,27 +97,15 @@ const AuthorizedClientConfigModal: React.FC = } try { - const response = await fetch(`${getApiBasePath()}/actions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - RenameAuthorizedClient: { - client_id: clientId, - new_name: editedName.trim() - } - }) + await callApiWithRouting({ + RenameAuthorizedClient: [clientId, editedName.trim()] }); - if (!response.ok) { - throw new Error(`Failed to rename client: ${response.statusText}`); - } - showToast('success', `Client renamed to "${editedName.trim()}".`); setCurrentName(editedName.trim()); setIsEditingName(false); setHasChanges(true); - onClientUpdate(clientId); + //onClientUpdate(clientId); } catch (err: any) { showToast('error', err.message || 'Failed to rename client.'); setEditedName(currentName); @@ -154,24 +143,16 @@ const AuthorizedClientConfigModal: React.FC = const newApiKey = generateApiKey(32); try { - const response = await fetch(`${getApiBasePath()}/configure-authorized-client`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - client_id: clientId, - raw_token: newApiKey, - hot_wallet_address_to_associate: hotWalletAddress - }) + const { configure_authorized_client } = await import('../../../target/ui/caller-utils'); + const responseData = await configure_authorized_client({ + client_id: clientId, + client_name: editedName, + raw_token: newApiKey, + hot_wallet_address_to_associate: hotWalletAddress }); - - if (!response.ok) { - throw new Error(`Failed to regenerate token: ${response.statusText}`); - } - - const responseData = await response.json(); setNewToken(responseData.raw_token); - setNodeName(responseData.node_name); + + setNodeName((window as any).our?.node); setHasChanges(true); // Mark that changes were made } catch (err) { setError(`Failed: ${err instanceof Error ? err.message : 'Unknown error'}`); @@ -190,21 +171,12 @@ const AuthorizedClientConfigModal: React.FC = setError(null); try { - const response = await fetch(`${getApiBasePath()}/actions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - DeleteAuthorizedClient: { client_id: clientId } - }) + await callApiWithRouting({ + DeleteAuthorizedClient: clientId }); - if (!response.ok) { - throw new Error(`Failed to delete client: ${response.statusText}`); - } - // Close modal with refresh flag - onClose(true); + onClose(); } catch (err) { setError(`Failed: ${err instanceof Error ? err.message : 'Unknown error'}`); setConfirmDelete(false); @@ -224,7 +196,7 @@ const AuthorizedClientConfigModal: React.FC = }; const authCommand = newToken && nodeName ? - `Use the authorize tool with url "${window.location.origin + window.location.pathname + 'shim/mcp'}", token "${newToken}", client_id "${clientId}", and node "${nodeName}"` : ''; + `Use the authorize tool with url "${window.location.origin + window.location.pathname + 'mcp'}", token "${newToken}", client_id "${clientId}", and node "${nodeName}"` : ''; return ( { - const pathParts = window.location.pathname.split('/').filter(p => p); - const processIdPart = pathParts.find(part => part.includes(':')); - return processIdPart ? `/${processIdPart}/api` : '/api'; -}; -const API_BASE_URL = getApiBasePath(); -const HYPERGRID_GRAPH_ENDPOINT = `${API_BASE_URL}/hypergrid-graph`; - -const dagreGraph = new dagre.graphlib.Graph(); -dagreGraph.setDefaultEdgeLabel(() => ({})); - -export const NODE_WIDTH = 320; -export const NODE_HEIGHT = 150; - -const getLayoutedElements = (nodes: Node[], edges: Edge[], direction = 'TB') => { - dagreGraph.setGraph({ - rankdir: direction, - nodesep: 100, - ranksep: 100 - }); - - nodes.forEach((node) => { - let height = NODE_HEIGHT; - let width = NODE_WIDTH; - if (node.type === 'operatorWalletNode') height = 250; - if (node.type === 'hotWalletNode') height = 270; - if (node.type === 'addHotWalletActionNode') height = 300; - if (node.type === 'authorizedClientNode') { height = 280 }; - if (node.type === 'mintOperatorWalletActionNode') height = 60; - dagreGraph.setNode(node.id, { width, height }); - }); - - edges.forEach((edge) => { - dagreGraph.setEdge(edge.source, edge.target); - }); - - dagre.layout(dagreGraph); - - const layoutedNodes = nodes.map((node) => { - const nodeWithPosition = dagreGraph.node(node.id); - const nodeHeightCalculated = dagreGraph.node(node.id).height || NODE_HEIGHT; - const nodeWidthCalculated = dagreGraph.node(node.id).width || NODE_WIDTH; - node.position = { - x: nodeWithPosition.x - nodeWidthCalculated / 2, - y: nodeWithPosition.y - nodeHeightCalculated / 2, - }; - return node; - }); - - // Special positioning logic to prevent overlap of operator wallet and hot wallet action nodes - const operatorWalletNode = layoutedNodes.find(n => n.type === 'operatorWalletNode'); - const addHotWalletActionNode = layoutedNodes.find(n => n.type === 'addHotWalletActionNode'); - - if (operatorWalletNode && addHotWalletActionNode) { - // Check if they're too close (likely overlapping) - const xDiff = Math.abs(operatorWalletNode.position.x - addHotWalletActionNode.position.x); - const yDiff = Math.abs(operatorWalletNode.position.y - addHotWalletActionNode.position.y); - - // Check if nodes are overlapping or too close vertically/horizontally - const operatorNodeHeight = 250; // From line 84 - const addHotWalletNodeHeight = 300; // From line 86 - - // They overlap if they're on the same X coordinate or very close horizontally - const horizontallyAligned = xDiff < NODE_WIDTH + 50; // Within node width + small margin - - // Check if they need repositioning due to overlap or poor spacing - if (horizontallyAligned) { - addHotWalletActionNode.position = { - x: operatorWalletNode.position.x - 50, // Position only slightly to the left - y: operatorWalletNode.position.y + 400 // Position more down - }; - } - } - - console.log("layoutedNodes", layoutedNodes); - return { nodes: layoutedNodes, edges }; -}; - -const MintOperatorWalletActionNodeComponent: React.FC void; disabled?: boolean }>> = ({ data }) => ( - -); - -const SimpleAddAuthorizedClientActionNodeComponent: React.FC> = ({ data }) => ( - -); - -interface BackendDrivenHypergridVisualizerProps { - initialGraphData?: IHypergridGraphResponse; -} - -const toCamelCase = (str: string): string => { - return str.replace(/([-_][a-z])/ig, ($1) => { - return $1.toUpperCase() - .replace('-', '') - .replace('_', ''); - }); -}; - -const convertKeysToCamelCase = (obj: any): any => { - if (typeof obj !== 'object' || obj === null) { - return obj; - } - if (Array.isArray(obj)) { - return obj.map(convertKeysToCamelCase); - } - return Object.keys(obj).reduce((acc, key) => { - const camelKey = toCamelCase(key); - acc[camelKey] = convertKeysToCamelCase(obj[key]); - return acc; - }, {} as any); -}; - -const BackendDrivenHypergridVisualizerWrapper: React.FC = ({ initialGraphData }) => { - const { showToast } = useErrorLogStore(); - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const [isLoadingGraph, setIsLoadingGraph] = useState(!initialGraphData); - const [graphDataError, setGraphDataError] = useState(null); - const [isProcessingMintClick, setIsProcessingMintClick] = useState(false); - - const [isShimApiConfigModalOpen, setIsShimApiConfigModalOpen] = useState(false); - const [hotWalletAddressForShimModal, setHotWalletAddressForShimModal] = useState
(null); - - // New state for authorized client modal - const [isAuthorizedClientModalOpen, setIsAuthorizedClientModalOpen] = useState(false); - const [selectedAuthorizedClient, setSelectedAuthorizedClient] = useState<{ - clientId: string; - clientName: string; - hotWalletAddress: string; - } | null>(null); - - const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false); - const [selectedWalletForHistory, setSelectedWalletForHistory] = useState
(null); - - // Hot Wallet Settings Modal state - const [isHotWalletSettingsModalOpen, setIsHotWalletSettingsModalOpen] = useState(false); - const [selectedWalletForSettings, setSelectedWalletForSettings] = useState(null); - - // States for lock/unlock operations initiated from visualizer - const [isUnlockingOrLockingWalletId, setIsUnlockingOrLockingWalletId] = useState(null); - - const { address: connectedAddress } = useAccount(); - const mintOperatorWalletHook = useMintOperatorSubEntry(); - - // Paymaster revoke hook - const revokePaymasterHook = useApprovePaymaster({ - onSuccess: (data) => { - console.log("Paymaster revoke transaction sent:", data); - showToast('success', 'Paymaster revocation initiated!'); - }, - onError: (err) => { - console.error("Paymaster revoke error:", err); - showToast('error', `Failed to revoke paymaster: ${err.message}`); - }, - }); - - // Centralized routing: only MCP ops will go to /api/mcp; others to /api/actions - const callApiRouted = useCallback(async (payload: any) => { - return callApiWithRouting(payload); - }, []); - - const onSetNoteSuccess = useCallback((data: any) => { - console.log("Set Note successful via hook.onSuccess, tx data:", data); - }, []); - - const onSetNoteError = useCallback((error: Error) => { - console.error("Set Note error via hook.onError:", error); - showToast('error', error.message || "An error occurred calling setAccessListNote (hook.onError)."); - }, []); - - const onSetNoteSettled = useCallback((data: any, error: Error | null) => { - console.log("Set Note settled via hook.onSettled. Data:", data, "Error:", error); - }, []); - - const operatorNoteHookOriginal = useSetOperatorNote({ - onSuccess: onSetNoteSuccess, - onError: onSetNoteError, - onSettled: onSetNoteSettled, - }); - - const { - setAccessListNote, - setSignersNote, - isSending: isOperatorNoteSending, - isConfirming: isOperatorNoteConfirming, - isConfirmed: isOperatorNoteConfirmed, - transactionHash: operatorNoteTxHash, - error: operatorNoteErrorState, - reset: resetOperatorNoteHook - } = operatorNoteHookOriginal; - - const onConnect = useCallback( - (params: Edge | Connection) => setEdges((eds: Edge[]) => addEdge(params, eds)), - [setEdges], - ); - - const handleSetAccessListNote = useCallback(async (operatorTbaAddress: Address, operatorEntryName: string) => { - if (!operatorTbaAddress || !operatorEntryName) { - showToast('error', "Operator TBA address or entry name not available to set note."); - return; - } - console.log(`Attempting to set Access List Note for Operator TBA: ${operatorTbaAddress}, Entry: ${operatorEntryName}`); - - try { - if (typeof setAccessListNote === 'function') { - setAccessListNote({ operatorTbaAddress, operatorEntryName }); - } else { - console.error('Critical: setAccessListNote is not a function before call!'); - showToast('error', 'Internal error: setAccessListNote handler is not available.'); - } - } catch (e: any) { - console.error("Error invoking setAccessListNote directly in handler:", e); - showToast('error', e.message || "Failed to initiate set access list note transaction (catch block)."); - } - }, [setAccessListNote, showToast]); - - const handleSetSignersNote = useCallback(async (operatorTbaAddress: Address, operatorEntryName: string, hotWalletAddress: Address) => { - if (!operatorTbaAddress || !operatorEntryName || !hotWalletAddress) { - showToast('error', "Missing required parameters to set signers note."); - return; - } - console.log(`Attempting to set Signers Note for Operator TBA: ${operatorTbaAddress}, Entry: ${operatorEntryName}, with Hot Wallet: ${hotWalletAddress}`); - - const setSignersNoteFn = setSignersNote; - if (typeof setSignersNoteFn !== 'function') { - console.error('Critical: setSignersNote is not a function!'); - showToast('error', 'Internal error: setSignersNote handler is not available.'); - return; - } - - try { - setSignersNoteFn({ - operatorTbaAddress, - operatorEntryName, - hotWalletAddresses: [hotWalletAddress] - }); - } catch (e: any) { - console.error("Error invoking setSignersNote directly in handler:", e); - showToast('error', e.message || "Failed to initiate set signers note transaction (catch block)."); - } - }, [setSignersNote, showToast]); - - const fetchGraphData = useCallback(async () => { - setIsLoadingGraph(true); - setGraphDataError(null); - try { - const response = await fetch(HYPERGRID_GRAPH_ENDPOINT); - if (!response.ok) { - const errText = await response.text(); - throw new Error(`Graph Data Fetch Failed: ${response.status} - ${errText}`); - } - const rawData: IHypergridGraphResponse = await response.json(); // Raw data from backend - - const processedNodes = rawData.nodes.map(backendNode => { - let processedData: any = {}; - // The backend sends data like: { ownerNode: { name: "..." } } or { hotWalletNode: { address: "..." } } - // We want to extract the inner object and camelCase its keys. - const nodeTypeKey = backendNode.type; // e.g., "ownerNode", "hotWalletNode" - const specificNodeData = (backendNode.data as any)[nodeTypeKey]; - - if (specificNodeData) { - processedData = convertKeysToCamelCase(specificNodeData); - - // Specifically ensure 'limits' within hotWalletNode data is also camelCased - if (backendNode.type === 'hotWalletNode' && processedData.limits) { - processedData.limits = convertKeysToCamelCase(processedData.limits); - } - } else { - // Fallback if data structure is different, or just copy if no specific data - processedData = convertKeysToCamelCase(backendNode.data); - } - - return { - id: backendNode.id, - type: backendNode.type, - position: backendNode.position || { x: 0, y: 0 }, - data: processedData, // This data object is what HotWalletNodeComponent receives as its `data` prop - }; - }); - - let activeHotWalletAddress: Address | null = null; - const backendNodesForTransform: Node[] = processedNodes.map((n) => ({ - id: n.id, - type: n.type, - data: n.data, // This `data` should now be correctly camelCased - position: n.position || { x: 0, y: 0 } - })); - - const backendEdges: Edge[] = rawData.edges.map(edge => ({ - id: edge.id, - source: edge.source, - target: edge.target, - animated: edge.animated || undefined, - type: edge.styleType || undefined - })); - - const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(backendNodesForTransform, backendEdges); - - if (layoutedNodes) { - const activeHwNode = layoutedNodes.find( - (node: Node) => node.type === 'hotWalletNode' && (node.data as IHotWalletNodeData)?.isActiveInMcp === true - ); - if (activeHwNode) { - activeHotWalletAddress = (activeHwNode.data as IHotWalletNodeData)?.address as Address | null; - } - } - - const transformedNodes = layoutedNodes.map((n) => { - const nodeTypeString = n.type as string; - const finalNodeData = { ...n.data } as any; - finalNodeData.type = nodeTypeString; - - if (nodeTypeString === 'operatorWalletNode') { - finalNodeData.onSetAccessListNote = handleSetAccessListNote; - finalNodeData.isSettingAccessListNote = isOperatorNoteSending || isOperatorNoteConfirming; - finalNodeData.onSetSignersNote = handleSetSignersNote; - finalNodeData.isSettingSignersNote = isOperatorNoteSending || isOperatorNoteConfirming; - finalNodeData.activeHotWalletAddressForNode = activeHotWalletAddress; - finalNodeData.onWalletsLinked = fetchGraphData; - finalNodeData.onRevokePaymaster = handleRevokePaymaster; - finalNodeData.isRevokingPaymaster = revokePaymasterHook.isSending || revokePaymasterHook.isConfirming; - finalNodeData.revokeHookState = { - isConfirmed: revokePaymasterHook.isConfirmed, - reset: revokePaymasterHook.reset - }; - } else if (nodeTypeString === 'addHotWalletActionNode') { - finalNodeData.onWalletsLinked = fetchGraphData; - const opTBAForActionNode = finalNodeData.operatorTbaAddress as Address | undefined; - finalNodeData.currentLinkedWallets = []; - finalNodeData.operatorEntryName = null; - - if (opTBAForActionNode) { - const operatorNode = layoutedNodes.find(ln => ln.type === 'operatorWalletNode' && (ln.data as IOperatorWalletNodeData)?.tbaAddress === opTBAForActionNode); - if (operatorNode) { - finalNodeData.operatorEntryName = (operatorNode.data as IOperatorWalletNodeData).name; - const operatorNodeId = operatorNode.id; - const linkedWalletsForThisOp: Address[] = []; - layoutedEdges.forEach(edge => { - if (edge.source === operatorNodeId) { - const targetNode = layoutedNodes.find(node => node.id === edge.target); - if (targetNode && targetNode.type === 'hotWalletNode') { - const hotWalletAddress = (targetNode.data as IHotWalletNodeData)?.address; - if (hotWalletAddress) { - linkedWalletsForThisOp.push(hotWalletAddress as Address); - } - } - } - }); - finalNodeData.currentLinkedWallets = linkedWalletsForThisOp; - } else { - console.warn(`[Visualizer] AddHotWalletActionNode: Could not find operator node for TBA ${opTBAForActionNode}`); - } - } else { - console.warn("[Visualizer] AddHotWalletActionNode is missing operatorTbaAddress in its data", finalNodeData); - } - } - return { ...n, data: finalNodeData }; - }); - - setNodes(transformedNodes); - setEdges(layoutedEdges); - } catch (err) { - const errorMsg = err instanceof Error ? err.message : 'Unknown error during graph data fetch'; - setGraphDataError(errorMsg); - showToast('error', errorMsg); - setNodes([]); - setEdges([]); - } finally { - setIsLoadingGraph(false); - } - }, [setNodes, setEdges, handleSetAccessListNote, handleSetSignersNote, isOperatorNoteSending, isOperatorNoteConfirming]); - - useEffect(() => { - if (initialGraphData) { - const processedNodes = initialGraphData.nodes.map(n => { - let processedData: any = {}; - // The backend sends data like: { ownerNode: { name: "..." } } or { hotWalletNode: { address: "..." } } - // We want to extract the inner object and camelCase its keys. - const nodeTypeKey = n.type; // e.g., "ownerNode", "hotWalletNode" - const specificNodeData = (n.data as any)[nodeTypeKey]; - - if (specificNodeData) { - processedData = convertKeysToCamelCase(specificNodeData); - - // Specifically ensure 'limits' within hotWalletNode data is also camelCased - if (n.type === 'hotWalletNode' && processedData.limits) { - processedData.limits = convertKeysToCamelCase(processedData.limits); - } - } else { - // Fallback if data structure is different, or just copy if no specific data - processedData = convertKeysToCamelCase(n.data); - } - - return { - ...n, - data: processedData, - }; - }); - - let activeHotWalletAddressForInitial: Address | null = null; - const backendNodesForInitial: Node[] = processedNodes.map((n) => ({ - id: n.id, - type: n.type, - data: n.data, - position: n.position || { x: 0, y: 0 } - })); - - const backendEdgesForInitial: Edge[] = initialGraphData.edges.map(edge => ({ - id: edge.id, - source: edge.source, - target: edge.target, - animated: edge.animated || undefined, - type: edge.styleType || undefined - })); - - const { nodes: layoutedInitialNodes, edges: layoutedInitialEdges } = getLayoutedElements(backendNodesForInitial, backendEdgesForInitial); - - if (layoutedInitialNodes) { - const activeHwNode = layoutedInitialNodes.find( - (node: Node) => node.type === 'hotWalletNode' && (node.data as IHotWalletNodeData)?.isActiveInMcp === true - ); - if (activeHwNode) { - activeHotWalletAddressForInitial = (activeHwNode.data as IHotWalletNodeData)?.address as Address | null; - } - } - - const transformedInitialNodes = layoutedInitialNodes.map((n) => { - const nodeTypeString = n.type as string; - const finalNodeData = { ...n.data } as any; - finalNodeData.type = nodeTypeString; - - if ((n.data as any).onClick) { finalNodeData.onClick = (n.data as any).onClick; } - if ((n.data as any).disabled !== undefined) { finalNodeData.disabled = (n.data as any).disabled; } - - if (nodeTypeString === 'operatorWalletNode') { - finalNodeData.onSetAccessListNote = handleSetAccessListNote; - finalNodeData.isSettingAccessListNote = isOperatorNoteSending || isOperatorNoteConfirming; - finalNodeData.onSetSignersNote = handleSetSignersNote; - finalNodeData.isSettingSignersNote = isOperatorNoteSending || isOperatorNoteConfirming; - finalNodeData.activeHotWalletAddressForNode = activeHotWalletAddressForInitial; - finalNodeData.onWalletsLinked = fetchGraphData; - finalNodeData.onRevokePaymaster = handleRevokePaymaster; - finalNodeData.isRevokingPaymaster = revokePaymasterHook.isSending || revokePaymasterHook.isConfirming; - finalNodeData.revokeHookState = { - isConfirmed: revokePaymasterHook.isConfirmed, - reset: revokePaymasterHook.reset - }; - } else if (nodeTypeString === 'addHotWalletActionNode') { - finalNodeData.onWalletsLinked = fetchGraphData; - const opTBAForActionNode = finalNodeData.operatorTbaAddress as Address | undefined; - finalNodeData.currentLinkedWallets = []; - finalNodeData.operatorEntryName = null; - - if (opTBAForActionNode) { - const operatorNode = layoutedInitialNodes.find(ln => ln.type === 'operatorWalletNode' && (ln.data as IOperatorWalletNodeData)?.tbaAddress === opTBAForActionNode); - if (operatorNode) { - finalNodeData.operatorEntryName = (operatorNode.data as IOperatorWalletNodeData).name; - const operatorNodeId = operatorNode.id; - const linkedWalletsForThisOp: Address[] = []; - layoutedInitialEdges.forEach(edge => { - if (edge.source === operatorNodeId) { - const targetNode = layoutedInitialNodes.find(node => node.id === edge.target); - if (targetNode && targetNode.type === 'hotWalletNode') { - const hotWalletAddress = (targetNode.data as IHotWalletNodeData)?.address; - if (hotWalletAddress) { - linkedWalletsForThisOp.push(hotWalletAddress as Address); - } - } - } - }); - finalNodeData.currentLinkedWallets = linkedWalletsForThisOp; - } else { - console.warn(`[Visualizer Initial] AddHotWalletActionNode: Could not find operator node for TBA ${opTBAForActionNode}`); - } - } else { - console.warn("[Visualizer Initial] AddHotWalletActionNode is missing operatorTbaAddress in its data", finalNodeData); - } - } - return { ...n, data: finalNodeData }; - }); - setNodes(transformedInitialNodes); - setEdges(layoutedInitialEdges); - } else { - fetchGraphData(); - } - }, [initialGraphData, fetchGraphData, setNodes, setEdges, handleSetAccessListNote, handleSetSignersNote, isOperatorNoteSending, isOperatorNoteConfirming]); - - useEffect(() => { - setNodes((nds: Node[]) => - nds.map((n: Node) => { - if (n.type === 'mintOperatorWalletActionNode' && n.data?.actionId === 'trigger_mint_operator_wallet') { - return { - ...n, - data: { - ...n.data, - disabled: isProcessingMintClick || mintOperatorWalletHook.isSending || mintOperatorWalletHook.isConfirming, - }, - }; - } - if (n.type === 'operatorWalletNode') { - return { - ...n, - data: { - ...n.data, - isSettingAccessListNote: isOperatorNoteSending || isOperatorNoteConfirming, - isSettingSignersNote: isOperatorNoteSending || isOperatorNoteConfirming, - } - }; - } - return n; - }) - ); - }, [isProcessingMintClick, mintOperatorWalletHook.isSending, mintOperatorWalletHook.isConfirming, isOperatorNoteSending, isOperatorNoteConfirming, setNodes]); - - useEffect(() => { - if (mintOperatorWalletHook.isConfirmed) { - console.log("Mint transaction confirmed (Tx: ", mintOperatorWalletHook.transactionHash, "). Refetching graph data with delay."); - setIsProcessingMintClick(false); - // Add delay to allow backend to sync with blockchain - setTimeout(() => { - fetchGraphData(); - }, 2000); - mintOperatorWalletHook.reset(); - } - }, [mintOperatorWalletHook.isConfirmed, fetchGraphData, mintOperatorWalletHook.reset]); - - // Handle mint operation errors (including user cancellation) - useEffect(() => { - if (mintOperatorWalletHook.error) { - console.log("Mint operation error:", mintOperatorWalletHook.error.message); - setIsProcessingMintClick(false); - showToast('error', mintOperatorWalletHook.error.message); - } - }, [mintOperatorWalletHook.error]); - - useEffect(() => { - if (isOperatorNoteConfirmed) { - console.log("Set Note transaction confirmed (Tx: ", operatorNoteTxHash, "). Refetching graph data with delay."); - // Add delay to allow backend to sync with blockchain - setTimeout(() => { - fetchGraphData(); - }, 2000); - resetOperatorNoteHook(); - } - }, [isOperatorNoteConfirmed, operatorNoteTxHash, fetchGraphData, resetOperatorNoteHook]); - - useEffect(() => { - if (revokePaymasterHook.isConfirmed) { - console.log("Paymaster revoke transaction confirmed (Tx: ", revokePaymasterHook.transactionHash, "). Refetching graph data with delay."); - // Add delay to allow backend to sync with blockchain - setTimeout(() => { - fetchGraphData(); - }, 2000); - revokePaymasterHook.reset(); - } - }, [revokePaymasterHook.isConfirmed, revokePaymasterHook.transactionHash, fetchGraphData, revokePaymasterHook.reset]); - - const handleNodeClick = useCallback(async (_event: React.MouseEvent, node: Node) => { - console.log('Node clicked: ', node); - - if (node.type === 'mintOperatorWalletActionNode' && node.data) { - console.log("Mint Action Clicked. Data:", node.data); - const ownerNodeName = (node.data as any)['ownerNodeName']; - const subLabelToMintForGrid = "grid-wallet"; - if (!ownerNodeName) { - console.error("Mint Action: Owner node name not found in node data."); - showToast('error', "Configuration error: Owner node name missing."); - return; - } - const parentOwnerNode = nodes.find(n => n.type === 'ownerNode' && (n.data as any)?.name === ownerNodeName); - if (!parentOwnerNode) { - console.error(`Mint Action: OwnerNode for '${ownerNodeName}' not found in graph nodes. Current nodes:`, nodes); - showToast('error', `Runtime error: Could not find graph data for parent ${ownerNodeName}.`); - return; - } - const parentTbaAddress = (parentOwnerNode.data as any)?.tbaAddress as Address | undefined; - if (!parentTbaAddress) { - console.error(`Mint Action: TBA for parent node '${ownerNodeName}' not found. Parent node data:`, parentOwnerNode.data); - showToast('error', `Configuration error: TBA for parent node '${ownerNodeName}' is missing.`); - return; - } - - // Now we have everything we need to mint - console.log(`Mint Action: Ready to mint '${subLabelToMintForGrid}' under parent TBA ${parentTbaAddress}`); - - if (!connectedAddress) { - showToast('error', 'Please connect your wallet to mint.'); - return; - } - - setIsProcessingMintClick(true); - - // Call the mint function - mintOperatorWalletHook.mint({ - parentTbaAddress: parentTbaAddress, - ownerOfNewSubTba: connectedAddress, // The connected wallet will own the new operator wallet - subLabelToMint: subLabelToMintForGrid, - implementationForNewSubTba: DEFAULT_OPERATOR_TBA_IMPLEMENTATION, - }); - } else if (node.type === 'addHotWalletActionNode' && node.data) { - console.log("AddHotWalletActionNode (Manage Hot Wallets) clicked. Data:", node.data); - // No modal, content is inline - } else if (node.type === 'addAuthorizedClientActionNode' && node.data) { - const actionNodeData = node.data as any; - const actionId = actionNodeData.actionId; - if (actionId === 'trigger_add_client_modal') { - const targetHotWallet = actionNodeData.targetHotWalletAddress as Address | undefined; - if (targetHotWallet) { - setHotWalletAddressForShimModal(targetHotWallet); - setIsShimApiConfigModalOpen(true); - } else { - showToast('error', "Configuration error: Action node for authorizing client is missing target_hot_wallet_address."); - } - } - } else if (node.type === 'authorizedClientNode' && node.data) { - // Handle clicks on authorized client nodes - const clientData = node.data as IAuthorizedClientNodeData; - console.log("Authorized Client Node clicked. Data:", clientData); - setSelectedAuthorizedClient({ - clientId: clientData.clientId, - clientName: clientData.clientName, - hotWalletAddress: clientData.associatedHotWalletAddress - }); - setIsAuthorizedClientModalOpen(true); - } - }, [nodes, connectedAddress, mintOperatorWalletHook, setHotWalletAddressForShimModal, setIsShimApiConfigModalOpen, DEFAULT_OPERATOR_TBA_IMPLEMENTATION, fetchGraphData, showToast]); - - const handleWalletNodeUpdate = useCallback((_walletAddress: Address) => { - console.log(`Wallet ${_walletAddress} was updated, refreshing graph data.`); - fetchGraphData(); - }, [fetchGraphData]); - - const handleOpenHistoryModal = useCallback((walletAddress: Address) => { - setSelectedWalletForHistory(walletAddress); - setIsHistoryModalOpen(true); - }, []); - - const handleCloseHistoryModal = useCallback(() => { - setIsHistoryModalOpen(false); - setSelectedWalletForHistory(null); - }, []); - - const handleOpenHotWalletSettingsModal = useCallback((walletData: IHotWalletNodeData) => { - setSelectedWalletForSettings(walletData); - setIsHotWalletSettingsModalOpen(true); - }, []); - - const handleCloseHotWalletSettingsModal = useCallback(() => { - setIsHotWalletSettingsModalOpen(false); - setSelectedWalletForSettings(null); - }, []); - - const handleOpenAuthorizedClientSettingsModal = useCallback((clientData: IAuthorizedClientNodeData) => { - setSelectedAuthorizedClient({ - clientId: clientData.clientId, - clientName: clientData.clientName, - hotWalletAddress: clientData.associatedHotWalletAddress - }); - setIsAuthorizedClientModalOpen(true); - }, []); - - // Unlock flow removed from UI - - // Lock flow removed from UI - - const handleRevokePaymaster = useCallback(async (operatorTbaAddress: Address) => { - if (!operatorTbaAddress) { - showToast('error', 'No operator TBA address provided'); - return; - } - - try { - console.log('Revoking paymaster approval for TBA:', operatorTbaAddress); - revokePaymasterHook.revokePaymaster({ operatorTbaAddress }); - } catch (err: any) { - showToast('error', err.message || 'Failed to revoke paymaster.'); - } - }, [revokePaymasterHook, showToast]); - - const nodeTypes = useMemo(() => ({ - ownerNode: OriginalOwnerNodeComponent, - operatorWalletNode: OriginalOperatorWalletNodeComponent, - hotWalletNode: (props: NodeProps) => ( - handleOpenHotWalletSettingsModal(props.data)} - /> - ), - authorizedClientNode: (props: NodeProps) => ( - handleOpenAuthorizedClientSettingsModal(props.data)} - /> - ), - addHotWalletActionNode: AddHotWalletActionNodeComponent, - addAuthorizedClientActionNode: SimpleAddAuthorizedClientActionNodeComponent, - mintOperatorWalletActionNode: MintOperatorWalletActionNodeComponent, - }), [ - handleWalletNodeUpdate, - handleOpenHistoryModal, - handleOpenHotWalletSettingsModal, - handleOpenAuthorizedClientSettingsModal - ]); - - if (isLoadingGraph && !initialGraphData) { - return
- Loading graph ... - -
- } - - if (graphDataError && !isLoadingGraph) { - return ; - } - - const proOptions = { hideAttribution: true }; - - return ( - -
- {isLoadingGraph &&
Updating graph...
} - - - - - -
- {isShimApiConfigModalOpen && hotWalletAddressForShimModal && ( - { - setIsShimApiConfigModalOpen(false); - setHotWalletAddressForShimModal(null); - if (shouldRefresh) { - fetchGraphData(); - } - }} - hotWalletAddress={hotWalletAddressForShimModal} - /> - )} - {isHistoryModalOpen && selectedWalletForHistory && ( - - )} - {isAuthorizedClientModalOpen && selectedAuthorizedClient && ( - { - setIsAuthorizedClientModalOpen(false); - setSelectedAuthorizedClient(null); - if (shouldRefresh) { - fetchGraphData(); - } - }} - clientId={selectedAuthorizedClient.clientId} - clientName={selectedAuthorizedClient.clientName} - hotWalletAddress={selectedAuthorizedClient.hotWalletAddress} - onClientUpdate={() => fetchGraphData()} - /> - )} - {isHotWalletSettingsModalOpen && selectedWalletForSettings && ( - - )} -
- ); -}; - -export default BackendDrivenHypergridVisualizerWrapper; diff --git a/operator/ui/src/components/CallHistory.tsx b/operator/ui/src/components/CallHistory.tsx index 86a1a5e..0abe881 100644 --- a/operator/ui/src/components/CallHistory.tsx +++ b/operator/ui/src/components/CallHistory.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { format } from 'date-fns'; // Using date-fns for timestamp formatting // Import shared types import { CallRecord, PaymentAttemptResult } from '../logic/types'; -import { API_ACTIONS_ENDPOINT } from '../utils/api-endpoints'; +import { API_ENDPOINT } from '../utils/api-endpoints'; // Add props interface interface CallHistoryProps { @@ -118,17 +118,8 @@ const CallHistory: React.FC = ({ selectedAccountId, isNonColla setError(null); // setAllHistory([]); // Don't clear immediately, wait for fetch try { - const requestBody = { GetCallHistory: {} }; - const response = await fetch(API_ACTIONS_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - }); - if (!response.ok) { - const errData = await response.json().catch(() => ({ error: `HTTP error! Status: ${response.status}` })); - throw new Error(errData.error || `Failed to fetch history: ${response.statusText}`); - } - const data: CallRecord[] = await response.json(); + const { callApiWithRouting } = await import('../utils/api-endpoints'); + const data: CallRecord[] = await callApiWithRouting({ GetCallHistory: {} }); console.log('CallHistory data:', data); setAllHistory(data.reverse()); // Store all history, newest first } catch (err) { diff --git a/operator/ui/src/components/ShimApiConfigModal.tsx b/operator/ui/src/components/ShimApiConfigModal.tsx index 00e7b9b..657445b 100644 --- a/operator/ui/src/components/ShimApiConfigModal.tsx +++ b/operator/ui/src/components/ShimApiConfigModal.tsx @@ -81,30 +81,19 @@ const ShimApiConfigModal: React.FC = ({ const newApiKey = generateApiKey(32); const payload = { + client_id: null, // Will be generated by backend if not provided client_name: `Shim for ${hotWalletAddress.substring(0, 6)}...${hotWalletAddress.slice(-4)}`, raw_token: newApiKey, hot_wallet_address_to_associate: hotWalletAddress }; - fetch(`${getApiBasePath()}/configure-authorized-client`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(payload) - }) - .then(response => { - if (!response.ok) { - return response.json().then(errData => { - throw new Error(errData.error || `Failed to save API key: ${response.statusText}`); - }).catch(() => { - throw new Error(`Failed to save API key: ${response.statusText}`); - }); - } - return response.json() as Promise; + import('../../../target/ui/caller-utils') + .then(({ configure_authorized_client }) => { + return configure_authorized_client(payload); }) .then(responseData => { const configData = { - url: window.location.origin + window.location.pathname + 'shim/mcp', + url: window.location.origin + window.location.pathname, client_id: responseData.client_id, token: responseData.raw_token, node: responseData.node_name, @@ -178,7 +167,7 @@ const ShimApiConfigModal: React.FC = ({ onClose={handleClose} preventAccidentalClose={true} > -
+
{/* Step Indicator */}
{steps.map((step, index) => ( @@ -215,10 +204,10 @@ const ShimApiConfigModal: React.FC = ({
{/* Step Content */} -
+
{/* Step 1: Add MCP Server */} {currentStep === 1 && ( -
+

Step 1: Add MCP Server

Add the Hypergrid MCP server to your client configuration.

@@ -228,13 +217,13 @@ const ShimApiConfigModal: React.FC = ({

Add this to your MCP client config:

-
 {JSON.stringify(mcpServerConfig, null, 2)}
                                     
@@ -285,7 +274,7 @@ const ShimApiConfigModal: React.FC = ({ {/* Step 2: Authorize Client */} {currentStep === 2 && ( -
+

Step 2: Authorize Client

Run the authorization command in your MCP client to complete the setup.

@@ -297,31 +286,28 @@ const ShimApiConfigModal: React.FC = ({

Paste this command into your MCP client:

-
+

{authCommand}

-

- 💡 Replace [Your Identity] with how you identify yourself (e.g., "Claude 3.5 Sonnet", "GPT-4", "Gemini Pro") -

{/* Success Info */} -
-

After running the command, your MCP client will have access to the following tools:

+
+

After running the command, your MCP client will have access to the following tools:

- search-registry + search-registry Search for services in Hypergrid
- call-provider + call-provider Call a provider with arguments
@@ -338,21 +324,21 @@ const ShimApiConfigModal: React.FC = ({ {showManualInstructions && ( -
-

Alternative: Save this as grid-shim-api.json:

+
+

Alternative: Save this as grid-shim-api.json:

-
 {JSON.stringify(apiConfig, null, 2)}
                                                     
-

Then run: npx @hyperware-ai/hypergrid-mcp -c grid-shim-api.json

+

Then run: npx @hyperware-ai/hypergrid-mcp -c grid-shim-api.json

)}
diff --git a/operator/ui/src/components/SpiderChat.tsx b/operator/ui/src/components/SpiderChat.tsx deleted file mode 100644 index eca2873..0000000 --- a/operator/ui/src/components/SpiderChat.tsx +++ /dev/null @@ -1,799 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; -import ReactMarkdown from 'react-markdown'; -import { webSocketService } from '../services/websocket'; -import { WsServerMessage, SpiderMessage, ConversationMetadata, McpServerDetails } from '../types/websocket'; - -// Types -interface Conversation { - id: string; - messages: SpiderMessage[]; - metadata: ConversationMetadata; - llmProvider: string; - mcpServers: string[]; - mcpServersDetails?: McpServerDetails[] | null; -} - -interface ToolCall { - id: string; - tool_name: string; - parameters: string; -} - -interface ToolResult { - tool_call_id: string; - result: string; -} - -// Tool Call Modal Component -function ToolCallModal({ toolCall, toolResult, onClose }: { - toolCall: ToolCall; - toolResult?: ToolResult; - onClose: () => void; -}) { - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text).then(() => { - // Could add a toast notification here - }).catch(err => { - console.error('Failed to copy:', err); - }); - }; - - return ( -
-
e.stopPropagation()}> -
-

Tool Call Details: {toolCall.tool_name}

- -
-
-
-
-

Tool Call

- -
-
-              {JSON.stringify(toolCall, null, 2)}
-            
-
- {toolResult && ( -
-
-

Tool Result

- -
-
-                {JSON.stringify(toolResult, null, 2)}
-              
-
- )} -
-
-
- ); -} - -interface SpiderChatProps { - spiderApiKey: string | null; - onConnectClick: () => void; - onApiKeyRefreshed?: (newKey: string) => void; -} - -export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefreshed }: SpiderChatProps) { - const [message, setMessage] = useState(''); - const [conversation, setConversation] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [useWebSocket, setUseWebSocket] = useState(true); - const [wsConnected, setWsConnected] = useState(false); - const [currentRequestId, setCurrentRequestId] = useState(null); - const [connectedMcpServers, setConnectedMcpServers] = useState([]); - const [selectedToolCall, setSelectedToolCall] = useState<{call: ToolCall, result?: ToolResult} | null>(null); - const [spiderUnavailable, setSpiderUnavailable] = useState(false); - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const messageHandlerRef = useRef<((message: WsServerMessage) => void) | null>(null); - - const isActive = !!spiderApiKey; - - // Auto-scroll to bottom when new messages arrive - const scrollToBottom = (smooth: boolean = true) => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ - behavior: smooth ? 'smooth' : 'auto', - block: 'end' - }); - } - }; - - // Scroll on new messages - useEffect(() => { - const timer = setTimeout(() => { - scrollToBottom(); - }, 100); - return () => clearTimeout(timer); - }, [conversation?.messages?.length, isLoading]); - - // Auto-focus input when loading completes - useEffect(() => { - if (!isLoading && inputRef.current && isActive) { - inputRef.current.focus(); - } - }, [isLoading, isActive]); - - // Fetch MCP servers when API key is available - const fetchMcpServers = async (apiKey: string) => { - try { - const apiBase = import.meta.env.VITE_BASE_URL || window.location.pathname.replace(/\/$/, ''); - const response = await fetch(`${apiBase}/api/spider-mcp-servers`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ apiKey }), - }); - - if (response.ok) { - const data = await response.json(); - if (data.servers) { - // Filter for connected servers and get their IDs - const connectedServerIds = data.servers - .filter((server: any) => server.connected) - .map((server: any) => server.id); - setConnectedMcpServers(connectedServerIds); - console.log('Connected MCP servers:', connectedServerIds); - } - } - } catch (error) { - console.error('Failed to fetch MCP servers:', error); - } - }; - - // Connect to WebSocket when API key is available - useEffect(() => { - let timer: number | undefined; - - if (spiderApiKey) { - // Fetch MCP servers - fetchMcpServers(spiderApiKey); - - if (useWebSocket) { - // Add a small delay to ensure the component is ready - timer = window.setTimeout(() => { - connectWebSocket(); - }, 100); - } - } - - return () => { - if (timer) { - clearTimeout(timer); - } - if (messageHandlerRef.current) { - webSocketService.removeMessageHandler(messageHandlerRef.current); - messageHandlerRef.current = null; - } - if (wsConnected) { - webSocketService.disconnect(); - setWsConnected(false); - } - }; - }, [spiderApiKey, useWebSocket]); - - const connectWebSocket = async (apiKey?: string) => { - const keyToUse = apiKey || spiderApiKey; - if (!keyToUse) return; - - try { - // Determine WebSocket URL - connect to spider service endpoint - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const host = window.location.host; - const wsUrl = `${protocol}//${host}/spider:spider:sys/ws`; - - console.log('Connecting to WebSocket at:', wsUrl); - await webSocketService.connect(wsUrl); - - // Set up message handler for progressive updates - const messageHandler = (message: WsServerMessage) => { - switch (message.type) { - case 'message': - // Progressive message update from tool loop - setConversation(prev => { - if (!prev) return prev; - const updated = { ...prev }; - updated.messages = [...updated.messages]; - - // Add the new message if we don't have it yet - const messageExists = updated.messages.some(m => - m.timestamp === message.message.timestamp && - m.role === message.message.role - ); - - if (!messageExists) { - updated.messages.push(message.message); - } - return updated; - }); - break; - - case 'stream': - // Handle streaming updates (partial message content) - setConversation(prev => { - if (!prev) return prev; - const updated = { ...prev }; - updated.messages = [...updated.messages]; - - // Find or create assistant message for streaming - let assistantMsgIndex = updated.messages.length - 1; - if (assistantMsgIndex < 0 || updated.messages[assistantMsgIndex].role !== 'assistant') { - // Create new assistant message - updated.messages.push({ - role: 'assistant', - content: message.message || '', - toolCallsJson: message.tool_calls, - timestamp: Date.now(), - }); - } else { - // Update existing assistant message - updated.messages[assistantMsgIndex] = { - ...updated.messages[assistantMsgIndex], - content: message.message || updated.messages[assistantMsgIndex].content, - toolCallsJson: message.tool_calls || updated.messages[assistantMsgIndex].toolCallsJson, - }; - } - return updated; - }); - break; - - case 'chat_complete': - // Final response received - if (message.payload) { - setConversation(prev => { - if (!prev) return prev; - const updated = { ...prev }; - // Keep the same conversation ID if it exists - updated.id = message.payload.conversationId || updated.id; - - // If we have allMessages, they contain all the assistant's messages including tool calls - if (message.payload.allMessages && message.payload.allMessages.length > 0) { - // Find the last user message index - let lastUserMessageIndex = -1; - for (let i = updated.messages.length - 1; i >= 0; i--) { - if (updated.messages[i].role === 'user') { - lastUserMessageIndex = i; - break; - } - } - - // Replace everything after the last user message with the complete response - if (lastUserMessageIndex >= 0) { - updated.messages = [ - ...updated.messages.slice(0, lastUserMessageIndex + 1), - ...message.payload.allMessages - ]; - } else { - // If no user message found, append all messages - updated.messages.push(...message.payload.allMessages); - } - } else if (message.payload.response) { - // Just add the final response if not already present - const lastMsg = updated.messages[updated.messages.length - 1]; - if (!lastMsg || lastMsg.role !== 'assistant' || !lastMsg.content) { - updated.messages.push(message.payload.response); - } else { - // Update the last assistant message with final content - updated.messages[updated.messages.length - 1] = message.payload.response; - } - } - - return updated; - }); - - // Handle refreshed API key - if (message.payload.refreshedApiKey && onApiKeyRefreshed) { - onApiKeyRefreshed(message.payload.refreshedApiKey); - } - } - setIsLoading(false); - setCurrentRequestId(null); - break; - - case 'error': - setError(message.error || 'WebSocket error occurred'); - setIsLoading(false); - setCurrentRequestId(null); - break; - - case 'status': - // Only log, don't show "Processing iteration" messages in UI - if (message.message && !message.message.includes('Processing iteration')) { - console.log('Status:', message.status, message.message); - } - break; - } - }; - - messageHandlerRef.current = messageHandler; - webSocketService.addMessageHandler(messageHandler); - - // Authenticate with spider API key - console.log('Authenticating with API key:', keyToUse); - await webSocketService.authenticate(keyToUse); - console.log('Authentication successful'); - - setWsConnected(true); - setError(null); - } catch (error: any) { - console.error('Failed to connect WebSocket:', error); - - // Check if it's a timeout or connection error (Spider not installed) - if (error.message?.includes('timeout') || - (error.name === 'TypeError' && error.message?.includes('Failed to fetch'))) { - console.error('Cannot reach Spider service - may not be installed'); - setWsConnected(false); - setUseWebSocket(false); - setError('Cannot reach Spider service. Is Spider installed?'); - setSpiderUnavailable(true); - return; - } - - // Check if it's an auth error (invalid API key) - if (error.message && (error.message.includes('Invalid API key') || error.message.includes('lacks write permission'))) { - console.log('API key is invalid, requesting a new one...'); - - // Don't retry if we already tried with a fresh key (to prevent infinite loop) - if (apiKey) { - console.error('Already tried with a fresh API key, giving up'); - setWsConnected(false); - setUseWebSocket(false); - setError('Unable to authenticate with Spider. Falling back to HTTP.'); - return; - } - - // Request a new API key - try { - const apiBase = import.meta.env.VITE_BASE_URL || window.location.pathname.replace(/\/$/, ''); - const response = await fetch(`${apiBase}/api/spider-connect`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ force_new: true }), // Force creation of a new key - signal: AbortSignal.timeout(5000) // 5 second timeout - }); - const data = await response.json(); - - if (data.api_key) { - console.log('Got new API key:', data.api_key, 'retrying WebSocket connection...'); - // Update the API key in parent component - if (onApiKeyRefreshed) { - onApiKeyRefreshed(data.api_key); - } - - // Disconnect current connection - webSocketService.disconnect(); - // Small delay to ensure disconnect completes - await new Promise(resolve => setTimeout(resolve, 100)); - // CRITICAL: Explicitly pass the NEW key to prevent using stale closure value - await connectWebSocket(data.api_key); - return; - } else { - throw new Error('Failed to get new API key'); - } - } catch (refreshError: any) { - console.error('Failed to refresh API key:', refreshError); - - // Check if the refresh failed due to timeout (Spider not available) - if (refreshError.name === 'AbortError' || refreshError.message?.includes('timeout')) { - setSpiderUnavailable(true); - setError('Cannot reach Spider service. Is Spider installed?'); - } else { - setError('Failed to refresh API key. Falling back to HTTP.'); - } - - setWsConnected(false); - setUseWebSocket(false); - } - } else { - // Other errors - just fall back to HTTP - setWsConnected(false); - setUseWebSocket(false); - setError('Failed to connect WebSocket. Falling back to HTTP.'); - } - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!message.trim() || isLoading || !isActive) return; - - const requestId = Math.random().toString(36).substring(7); - setCurrentRequestId(requestId); - setError(null); - setIsLoading(true); - - try { - // Continue existing conversation or create new one - let updatedConversation = conversation ? { - ...conversation, - mcpServers: connectedMcpServers, // Update MCP servers in case they changed - } : { - id: '', - messages: [], - metadata: { - startTime: new Date().toISOString(), - client: 'operator-ui', - fromStt: false, - }, - llmProvider: 'anthropic', - mcpServers: connectedMcpServers, - }; - - // Model is sent separately in the chat payload, not part of Conversation - const model = 'claude-sonnet-4-20250514'; - - // Add user message - const userMessage: SpiderMessage = { - role: 'user', - content: message, - timestamp: Date.now(), - }; - - updatedConversation.messages.push(userMessage); - setConversation({ ...updatedConversation }); - setMessage(''); - - // Check if we should use WebSocket - console.log('WebSocket check - useWebSocket:', useWebSocket, 'isReady:', webSocketService.isReady, 'isConnected:', webSocketService.isConnected); - if (useWebSocket && webSocketService.isReady) { - // Send via WebSocket for progressive updates - console.log('Sending chat message via WebSocket'); - webSocketService.sendChatMessage( - updatedConversation.messages, - updatedConversation.llmProvider, - model, // Pass model separately - updatedConversation.mcpServers, - updatedConversation.metadata, - updatedConversation.id // Pass conversation ID to continue existing conversation - ); - // WebSocket responses will be handled by the message handler - return; - } - console.log('Falling back to HTTP'); - - // Fallback to HTTP - const apiBase = import.meta.env.VITE_BASE_URL || window.location.pathname.replace(/\/$/, ''); - const response = await fetch(`${apiBase}/api/spider-chat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - apiKey: spiderApiKey, - messages: updatedConversation.messages, - llmProvider: updatedConversation.llmProvider, - model: model, // Pass model separately - metadata: updatedConversation.metadata, - mcpServers: updatedConversation.mcpServers, - }), - }); - - if (!response.ok) { - throw new Error(`Failed to send message: ${response.statusText}`); - } - - const data = await response.json(); - - // Only update if this request hasn't been cancelled - if (currentRequestId === requestId) { - // Update conversation with response - if (data.conversationId) { - updatedConversation.id = data.conversationId; - } - - if (data.allMessages && data.allMessages.length > 0) { - updatedConversation.messages.push(...data.allMessages); - } else if (data.response) { - updatedConversation.messages.push(data.response); - } - - setConversation({ ...updatedConversation }); - - // If the API key was refreshed, update it in the parent component - if (data.refreshedApiKey && onApiKeyRefreshed) { - onApiKeyRefreshed(data.refreshedApiKey); - // Reconnect WebSocket with new key - if (useWebSocket) { - await connectWebSocket(); - } - } - } - } catch (err: any) { - if (err.name === 'AbortError') { - // Request was cancelled - } else { - setError(err.message || 'Failed to send message'); - console.error('Chat error:', err); - } - } finally { - if (currentRequestId === requestId) { - setIsLoading(false); - setCurrentRequestId(null); - } - } - }; - - const handleCancel = () => { - if (useWebSocket && webSocketService.isReady) { - try { - webSocketService.sendCancel(); - } catch (error) { - console.error('Failed to send cancel:', error); - } - } - setCurrentRequestId(null); - setIsLoading(false); - }; - - const handleNewConversation = () => { - setConversation(null); - setError(null); - setMessage(''); - }; - - - const getToolEmoji = () => '🔧'; - - // Check Spider availability when attempting to connect - const handleConnectWithTimeout = async () => { - try { - // Set a timeout for the connection attempt - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout - - const apiBase = import.meta.env.VITE_BASE_URL || window.location.pathname.replace(/\/$/, ''); - const response = await fetch(`${apiBase}/api/spider-status`, { - signal: controller.signal - }); - - clearTimeout(timeout); - - if (response.ok) { - setSpiderUnavailable(false); - onConnectClick(); - } else { - setSpiderUnavailable(true); - } - } catch (error: any) { - if (error.name === 'AbortError' || error.message?.includes('timeout')) { - setSpiderUnavailable(true); - } else { - console.error('Error checking Spider status:', error); - setSpiderUnavailable(true); - } - } - }; - - // Inactive state - show connect button or unavailable message - if (!isActive || spiderUnavailable) { - return ( -
-
-

Spider Chat

-
-
-
- {spiderUnavailable ? ( - <> -

Could not contact Spider

-

Is Spider installed?

- - ) : ( - <> -

Connect to Spider to enable chat and test out the Hypergrid tools

- - - )} -
-
-
- ); - } - - // Active state - show chat interface - return ( -
-
-

Spider Chat

-
-
- - {useWebSocket ? ( - // WebSocket icon - - ) : ( - // HTTP icon - - )} - -
- -
-
- - {error && ( -
-

{error}

-
- )} - -
- {conversation?.messages.map((msg, index) => { - const toolCalls = msg.toolCallsJson ? JSON.parse(msg.toolCallsJson) as ToolCall[] : null; - const nextMsg = conversation.messages[index + 1]; - const toolResults = nextMsg?.role === 'tool' && nextMsg.toolResultsJson - ? JSON.parse(nextMsg.toolResultsJson) as ToolResult[] - : null; - - return ( - - {msg.role !== 'tool' && msg.content && msg.content.trim() && - !msg.content.includes('Processing iteration') && - !msg.content.includes('[Tool calls pending]') && - !msg.content.includes('Executing tool calls') && ( -
-
- {msg.role === 'user' ? ( -

{msg.content}

- ) : ( -
- - {msg.content} - -
- )} -
-
- )} - - {toolCalls && toolCalls.map((toolCall, toolIndex) => { - const isLastMessage = index === conversation.messages.length - 1; - const toolResult = toolResults?.find(r => r.tool_call_id === toolCall.id); - const isWaitingForResult = isLastMessage && isLoading && !toolResult; - - return ( -
-
- {getToolEmoji()} - {isWaitingForResult ? ( - <> - {toolCall.tool_name} - ... - - ) : ( - - )} -
-
- ); - })} -
- ); - }) || ( -
-

Use this chat window to test out searching for and calling Hypergrid providers!

-
- )} - - {isLoading && conversation && ( -
-
-
- - Thinking... -
-
-
- )} - -
-
- - {selectedToolCall && ( - setSelectedToolCall(null)} - /> - )} - -
-
- setMessage(e.target.value)} - placeholder={isLoading ? "Thinking..." : "Type your message..."} - disabled={isLoading} - className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50" - /> - {isLoading ? ( - - ) : ( - - )} -
-
-
- ); -} diff --git a/operator/ui/src/components/console/HyperwalletInterface.tsx b/operator/ui/src/components/console/HyperwalletInterface.tsx index fc24911..8e15e78 100644 --- a/operator/ui/src/components/console/HyperwalletInterface.tsx +++ b/operator/ui/src/components/console/HyperwalletInterface.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useEffect } from 'react'; const getApiBasePath = () => { const pathParts = window.location.pathname.split('/').filter((p) => p); @@ -11,7 +11,7 @@ const CLIENT_NAME_MAX_LEN = 60; export type HwClient = { id: string; name: string; - status: 'active' | 'paused'; + status: 'active' | 'halted'; monthlyLimit?: number; dailyLimit?: number; monthlySpent?: number; @@ -72,13 +72,28 @@ const HyperwalletInterface: React.FC = ({ operatorTba, usdcBalance, clien const toggleClientStatus = async (clientId: string) => { const base = clients.find((c) => c.id === clientId); const current = (optimistic[clientId]?.status || base?.status || 'active'); - const next = current === 'active' ? 'paused' : 'active'; + const next = current === 'active' ? 'halted' : 'active'; setOptimistic((prev) => ({ ...prev, [clientId]: { ...(prev[clientId] || {}), status: next } })); await onToggleClientStatus(clientId); }; const hwClients = clients || []; const hwEvents = events || []; + + // Clear removed clients when the clients prop updates (e.g., from WebSocket) + useEffect(() => { + setRemovedClientIds(prev => { + const next = new Set(prev); + // Remove any IDs that are no longer in the removed set + // (i.e., when WebSocket confirms the removal) + for (const id of prev) { + if (!hwClients.some(c => c.id === id)) { + next.delete(id); + } + } + return next.size === prev.size ? prev : next; + }); + }, [hwClients]); const balanceHistory = useMemo(() => { let runningBalance = 3500; @@ -243,6 +258,8 @@ const HyperwalletInterface: React.FC = ({ operatorTba, usdcBalance, clien const [copied, setCopied] = useState(false); const [renamingClientId, setRenamingClientId] = useState(null); const [renameDraft, setRenameDraft] = useState(''); + const [confirmingRemoveClientId, setConfirmingRemoveClientId] = useState(null); + const [removedClientIds, setRemovedClientIds] = useState>(new Set()); const startRename = (clientId: string, currentName: string) => { setRenamingClientId(clientId); @@ -263,15 +280,12 @@ const HyperwalletInterface: React.FC = ({ operatorTba, usdcBalance, clien if (onRenameClient) { await onRenameClient(clientId, next); } else { - const response = await fetch(`${getApiBasePath()}/actions`, { + const response = await fetch(`${getApiBasePath()}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ - RenameAuthorizedClient: { - client_id: clientId, - new_name: next - } + RenameAuthorizedClient: [clientId, next] }) }); if (!response.ok) { @@ -303,33 +317,14 @@ const HyperwalletInterface: React.FC = ({ operatorTba, usdcBalance, clien ) : ( <> {/* Top-center: grid-wallet.node.os name */} -
+
{nodeName ? `grid-wallet.${nodeName}` : '—'}
{/* Center: balance */}
- {/* Balance indicators - moved to left side */} - {balanceNum === 0 && ( -
- - Send USDC to start → - -
- )} - - {balanceNum > 0 && balanceNum < 1 && ( -
- - Low balance → - -
- )} - {balanceNum.toLocaleString()} USDC - - {/* Existing warning badge - now only shows for balance between 0 and 1 */} - {isLowBalance && balanceNum > 0 && ( + {isLowBalance && (
!
@@ -385,7 +380,7 @@ const HyperwalletInterface: React.FC = ({ operatorTba, usdcBalance, clien
)) ) : ( - sortedClients.map((client: any) => { + sortedClients.filter((client: any) => !removedClientIds.has(client.id)).map((client: any) => { const overlay = optimistic[client.id] || {}; const merged = { ...client, ...overlay } as HwClient & { lastActivity?: string }; const spent = Number((merged as any).monthlySpent || 0); @@ -394,7 +389,7 @@ const HyperwalletInterface: React.FC = ({ operatorTba, usdcBalance, clien const isExpanded = expandedClient === merged.id; return (
-
setExpandedClient(isExpanded ? null : merged.id)}> +
{ setExpandedClient(isExpanded ? null : merged.id); setConfirmingRemoveClientId(null); }}>
{renamingClientId === merged.id ? ( @@ -452,7 +447,51 @@ const HyperwalletInterface: React.FC = ({ operatorTba, usdcBalance, clien
- {/* remove client button hidden for now */} +
diff --git a/operator/ui/src/components/console/OneClickOperatorBoot.tsx b/operator/ui/src/components/console/OneClickOperatorBoot.tsx index 37bb059..ed708b3 100644 --- a/operator/ui/src/components/console/OneClickOperatorBoot.tsx +++ b/operator/ui/src/components/console/OneClickOperatorBoot.tsx @@ -15,6 +15,7 @@ import { DEFAULT_OPERATOR_TBA_IMPLEMENTATION, BASE_CHAIN_ID, } from '../../logic/hypermapHelpers'; +import { callApiWithRouting } from '../../utils/api-endpoints'; type Props = { // Parent (node) TBA that owns the operator sub-entry to be minted (from backend state) @@ -43,21 +44,13 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat const operatorSubLabel = 'grid-wallet'; const [ownerNodeName, setOwnerNodeName] = useState(defaultOperatorEntryName || ''); - // Helper to format address for display - const formatAddress = (address: string | undefined) => { - if (!address) return ''; - return `${address.slice(0, 6)}...${address.slice(-4)}`; - }; + // Debug logs + console.log('[OneClickOperatorBoot] Component props:', { + parentTbaAddress, + defaultOperatorEntryName, + ownerEoa + }); - // Check if connected wallet matches the owner EOA - const isCorrectWallet = useMemo(() => { - if (!eoa || !ownerEoa) return false; - return eoa.toLowerCase() === ownerEoa.toLowerCase(); - }, [eoa, ownerEoa]); - - const isWrongWallet = useMemo(() => { - return eoa && ownerEoa && !isCorrectWallet; - }, [eoa, ownerEoa, isCorrectWallet]); useEffect(() => { if (!defaultOperatorEntryName && typeof window !== 'undefined') { const n = (window as any)?.our?.node; @@ -65,30 +58,6 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat } }, [defaultOperatorEntryName]); - // Robust fallback: fetch owner node name from hypergrid graph if still missing - useEffect(() => { - if (ownerNodeName) return; - try { - const pathParts = typeof window !== 'undefined' ? window.location.pathname.split('/').filter(Boolean) : []; - const processIdPart = pathParts.find((p) => p.includes(':')); - const base = processIdPart ? `/${processIdPart}/api` : '/api'; - fetch(`${base}/hypergrid-graph`).then(async (res) => { - if (!res.ok) return; - const graph = await res.json(); - const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []; - for (const n of nodes) { - if (n?.type === 'ownerNode') { - const data = (n.data && (n.data.ownerNode || n.data)) || {}; - const name = (data.name || data.node_name || data.nodeName) as string | undefined; - if (name && typeof name === 'string') { - setOwnerNodeName(name); - break; - } - } - } - }).catch(() => {}); - } catch {} - }, [ownerNodeName]); const operatorEntryName = ownerNodeName ? `${operatorSubLabel}.${ownerNodeName}` : ''; const approvalAmount = DEFAULT_PAYMASTER_APPROVAL_AMOUNT; @@ -96,26 +65,37 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({ hash, chainId: BASE_CHAIN_ID }); const disabled = useMemo(() => { - return !parentTbaAddress || !operatorEntryName || !eoa || isWrongWallet || isPending || isConfirming; - }, [parentTbaAddress, operatorEntryName, eoa, isWrongWallet, isPending, isConfirming]); + // For fresh nodes without a parent TBA, we can still proceed if we have EOA and entry name + return !operatorEntryName || !eoa || isPending || isConfirming; + }, [operatorEntryName, eoa, isPending, isConfirming]); const disabledReasons = useMemo(() => { const reasons: string[] = []; if (!eoa) reasons.push('wallet not connected - be sure to connect the wallet that owns your Hyperware name'); - if (isWrongWallet && ownerEoa) reasons.push(`wrong wallet connected - please connect ${formatAddress(ownerEoa)}`); - if (!parentTbaAddress) reasons.push('owner node TBA not found'); + // For fresh nodes, we might not have a parent TBA yet - that's ok, we'll mint directly + if (!parentTbaAddress && ownerNodeName) { + // If we have a node name but no TBA, this might be a fresh node + console.log('[OneClickOperatorBoot] No parent TBA found for node:', ownerNodeName, '- this might be a fresh node'); + } if (!operatorEntryName) reasons.push('operator entry name missing'); if (isPending) reasons.push('transaction pending'); if (isConfirming) reasons.push('waiting for confirmation'); return reasons; - }, [eoa, isWrongWallet, ownerEoa, parentTbaAddress, operatorEntryName, isPending, isConfirming, formatAddress]); - - const buildBundle = useCallback((): { - target: Address; - abi: typeof tbaExecuteAbi; - functionName: 'execute'; - args: readonly [Address, bigint, Hex, number]; - } => { + }, [eoa, parentTbaAddress, operatorEntryName, isPending, isConfirming, ownerNodeName]); + + const buildBundle = useCallback((): + | { + target: Address; + abi: typeof tbaExecuteAbi; + functionName: 'execute'; + args: readonly [Address, bigint, Hex, number]; + } + | { + target: Address; + abi: typeof hypermapAbi; + functionName: 'mint'; + args: readonly [Address, Hex, Hex, Address]; + } => { // 1) Inner calls the operator TBA will execute immediately after mint // ~access-list => namehash('~grid-beta-signers..') const accessListValue = viemNamehash(`~grid-beta-signers.${operatorEntryName}`); @@ -152,6 +132,22 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat operation: 0, }); + // If no parent TBA (fresh node), mint directly from EOA + if (!parentTbaAddress) { + return { + target: HYPERMAP_ADDRESS, + abi: hypermapAbi, + functionName: 'mint', + args: [ + eoa as Address, // mint to the EOA since no parent TBA + encodePacked(['bytes'], [stringToHex(operatorEntryName)]), + initCall, // initial call data for the new TBA + DEFAULT_OPERATOR_TBA_IMPLEMENTATION, + ], + } as const; + } + + // Otherwise, use parent TBA to mint (existing logic) return { target: parentTbaAddress as Address, abi: tbaExecuteAbi, @@ -163,21 +159,43 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat const onClick = useCallback(() => { if (disabled) return; const req = buildBundle(); - writeContract({ - address: req.target, - abi: req.abi, - functionName: req.functionName, - args: req.args, - chainId: BASE_CHAIN_ID, - }); + + if (req.functionName === 'execute') { + // TypeScript knows this is the execute variant + writeContract({ + address: req.target, + abi: tbaExecuteAbi, + functionName: 'execute', + args: req.args as readonly [Address, bigint, Hex, number], + chainId: BASE_CHAIN_ID, + }); + } else { + // TypeScript knows this is the mint variant + writeContract({ + address: req.target, + abi: hypermapAbi, + functionName: 'mint', + args: req.args as readonly [Address, Hex, Hex, Address], + chainId: BASE_CHAIN_ID, + }); + } }, [disabled, buildBundle, writeContract]); React.useEffect(() => { if (isConfirmed) { - setTimeout(() => { + // After confirmation, trigger identity recheck + setTimeout(async () => { + try { + // Call the recheck identity endpoint + await callApiWithRouting( "RecheckIdentity" ); + console.log('Identity recheck triggered successfully'); + } catch (error) { + console.error('Error calling recheck identity:', error); + } + + // Call the callback and reset onBootComplete?.(); reset(); - try { window.location.reload(); } catch {} }, 2000); } }, [isConfirmed, onBootComplete, reset]); @@ -198,7 +216,7 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat disabled={disabled} style={{ background: disabled ? '#e5e7eb' : '#111827', color: disabled ? '#9ca3af' : '#ffffff', padding: '8px 12px', border: '1px solid #d1d5db', borderRadius: 8 }} > - {isPending || isConfirming ? 'Confirm in wallet…' : isWrongWallet ? 'Wrong wallet' : 'Create wallet'} + {isPending || isConfirming ? 'Confirm in wallet…' : 'Create wallet'} {hash && ( @@ -216,27 +234,11 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat
)} - {isWrongWallet && ownerEoa && ( -
-
-
⚠️ Wrong wallet connected
-
Current wallet: {formatAddress(eoa)}
-
Expected wallet: {formatAddress(ownerEoa)} (owns {ownerNodeName || 'your node'})
-
Please switch to the correct wallet to continue.
-
- -
- )} - {eoa && !isWrongWallet && disabled && disabledReasons.length > 0 && ( + {eoa && disabled && disabledReasons.length > 0 && (
{disabledReasons.join(', ')}
)} - {isCorrectWallet && !disabled && ( -
- ✅ Correct wallet connected ({formatAddress(eoa)}) -
- )} {/* details removed per request */}
diff --git a/operator/ui/src/components/console/OperatorConsole.tsx b/operator/ui/src/components/console/OperatorConsole.tsx index 14a398e..6f46613 100644 --- a/operator/ui/src/components/console/OperatorConsole.tsx +++ b/operator/ui/src/components/console/OperatorConsole.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Address, createPublicClient, http, namehash as viemNamehash } from 'viem'; import { base } from 'viem/chains'; -import BackendDrivenHypergridVisualizerWrapper from '../BackendDrivenHypergridVisualizer'; import OneClickOperatorBoot from './OneClickOperatorBoot'; import OperatorFinalizeSetup from './OperatorFinalizeSetup'; import WelcomeIntro from './WelcomeIntro'; @@ -11,6 +10,9 @@ import AuthorizedClientConfigModal from '../AuthorizedClientConfigModal'; import ShimApiConfigModal from '../ShimApiConfigModal'; import { HYPERMAP as HYPERMAP_ADDR, hypermapAbi as hypermapAbiFull } from '../../abis'; import { callApiWithRouting } from '../../utils/api-endpoints'; +import { webSocketService } from '../../services/websocket'; +import { WsServerMessage, WalletSummary } from '../../types/websocket'; +import { fetchNodeInfo } from '../../helpers'; type SpendingLimits = { maxPerCall?: string | null; @@ -52,9 +54,15 @@ type CallRecord = { type StateSnapshot = { authorized_clients: Record; wallet_limits_cache: Record; + client_limits_cache?: Array<[string, SpendingLimits]>; call_history: CallRecord[]; operator_tba_address?: string | null; operator_entry_name?: string | null; + wallets?: WalletSummary[]; + selected_wallet_id?: string | null; + active_account?: ActiveAccountDetails | null; + gasless_enabled?: boolean | null; + paymaster_approved?: boolean | null; }; type ActiveAccountDetails = { @@ -117,7 +125,13 @@ const OperatorConsole: React.FC = () => { const [hotWalletForNewClient, setHotWalletForNewClient] = useState(null); const [isRefreshingUi, setIsRefreshingUi] = useState(false); const [showIntro, setShowIntro] = useState(true); + // Check if we've already completed setup by looking for gasless_enabled in state + // This is set after the operator is fully configured const [showSetupComplete, setShowSetupComplete] = useState(false); + // Track if user has dismissed the setup complete message (persisted in localStorage) + const [setupCompleteDismissed, setSetupCompleteDismissed] = useState(() => { + return localStorage.getItem('operator-setup-complete-dismissed') === 'true'; + }); // Mock mode state (scoped to this console) const [mockMode, setMockMode] = useState(false); const [mockOperatorTba, setMockOperatorTba] = useState('0xDeaDbeEf00000000000000000000000000000000'); @@ -143,15 +157,43 @@ const OperatorConsole: React.FC = () => { }; const onSetLimitsMock = async () => {}; const onToggleClientStatusMock = async (clientId: string) => { - setMockClients((prev) => prev.map((c) => (c.id === clientId ? { ...c, status: c.status === 'active' ? 'paused' : 'active' } : c))); + setMockClients((prev) => prev.map((c) => (c.id === clientId ? { ...c, status: c.status === 'active' ? 'halted' : 'active' } : c))); }; const nodeId = useMemo(() => (window as any)?.our?.node ?? null, []); + // Fetch the node TBA when we have a node name + useEffect(() => { + const fetchOwnerNodeTba = async () => { + if (nodeId && !ownerNodeTba) { + try { + console.log('[OperatorConsole] Fetching TBA for node:', nodeId); + const nodeInfo = await fetchNodeInfo(nodeId); + console.log('[OperatorConsole] Node info response:', nodeInfo); + + // The response should contain the TBA address + if (nodeInfo && nodeInfo.tba) { + setOwnerNodeTba(nodeInfo.tba as Address); + setOwnerNodeName(nodeId); + } else if (nodeInfo && typeof nodeInfo === 'string' && nodeInfo.startsWith('0x')) { + // Sometimes the API returns just the address as a string + setOwnerNodeTba(nodeInfo as Address); + setOwnerNodeName(nodeId); + } + } catch (error) { + console.error('[OperatorConsole] Failed to fetch node TBA:', error); + // For a fresh node, this might fail - that's ok + } + } + }; + + fetchOwnerNodeTba(); + }, [nodeId, ownerNodeTba]); + const fetchState = useCallback(async () => { - const res = await fetch(`${baseApi}/state`, { method: 'GET' }); - const json = (await res.json()) as StateSnapshot; - setState(json); + // State is now managed via WebSocket - this is a no-op + // The state is automatically updated via WebSocket messages + console.log('[Migration] fetchState called - state is now managed via WebSocket'); }, []); const fetchActive = useCallback(async () => { @@ -163,20 +205,15 @@ const OperatorConsole: React.FC = () => { } }, []); - useEffect(() => { - fetchState(); - fetchActive(); - }, [fetchState, fetchActive]); - const refreshAll = useCallback(async () => { setIsRefreshingUi(true); try { await fetchState(); - await fetchActive(); + //await fetchActive(); } finally { setIsRefreshingUi(false); } - }, [fetchState, fetchActive]); + }, [fetchState]); // Global event hook to open graph from header cog useEffect(() => { @@ -185,56 +222,6 @@ const OperatorConsole: React.FC = () => { return () => document.removeEventListener('open-graph-view', handler as any); }, []); - // Fetch owner node TBA from the hypergrid graph (legacy-compatible source) - useEffect(() => { - const loadOwnerNodeTba = async () => { - try { - const res = await fetch(`${baseApi}/hypergrid-graph`); - if (!res.ok) return; - const graph = await res.json(); - const coarse = graph?.coarseState || graph?.coarse_state; - const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []; - for (const n of nodes) { - if (n?.type === 'ownerNode') { - const data = (n.data && (n.data.ownerNode || n.data)) || {}; - const tba = (data.tba_address || data.tbaAddress) as string | undefined; - const name = (data.name || data.node_name || data.nodeName) as string | undefined; - const owner = (data.owner_address || data.ownerAddress) as string | undefined; - if (tba) { - setOwnerNodeTba(tba as Address); - } - if (name) { - setOwnerNodeName(name); - } - if (owner) { - setOwnerNodeOwnerEoa(owner as Address); - } - if (tba || name || owner) { - break; - } - } - if (n?.type === 'operatorWalletNode') { - const data = (n.data && (n.data.operatorWalletNode || n.data)) || {}; - const opTba = (data.tba_address || data.tbaAddress) as string | undefined; - if (opTba) { - setResolvedOperatorTba(opTba as Address); - // don't break; still prefer to capture owner node info further in loop - } - const funding = (data.funding_status || data.fundingStatus) as any; - if (funding?.usdcBalanceStr) setOperatorUsdcBalance(funding.usdcBalanceStr); - } - } - // Store coarse state for rendering decisions - if (coarse) { - (window as any).__coarseState = coarse; // optional global for debugging - } - } catch { - // ignore - } - }; - loadOwnerNodeTba(); - }, []); - const clients = useMemo(() => { if (!state) return [] as HotWalletAuthorizedClient[]; return Object.values(state.authorized_clients || {}); @@ -467,32 +454,143 @@ const OperatorConsole: React.FC = () => { // Decide which actions to render using coarse state from backend const [coarseState, setCoarseState] = useState(null); + const [shouldReloadGraph, setShouldReloadGraph] = useState(0); + + // WebSocket connection setup useEffect(() => { - const load = async () => { + const setupWebSocket = async () => { try { - const res = await fetch(`${baseApi}/hypergrid-graph`); - if (!res.ok) return; - const graph = await res.json(); - const coarse = graph?.coarseState || graph?.coarse_state || null; - setCoarseState(coarse); - const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []; - for (const n of nodes) { - if (n?.type === 'operatorWalletNode') { - const data = (n.data && (n.data.operatorWalletNode || n.data)) || {}; - const opTba = (data.tba_address || data.tbaAddress) as string | undefined; - if (opTba) { - setResolvedOperatorTba(opTba as Address); + // Determine WebSocket URL + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const processIdPart = window.location.pathname.split('/').filter(Boolean).find((p) => p.includes(':')); + const wsPath = processIdPart ? `/${processIdPart}/ws` : '/ws'; + const wsUrl = `${protocol}//${host}${wsPath}`; + + console.log('[WebSocket] Connecting to:', wsUrl); + await webSocketService.connect(wsUrl); + + // Subscribe to all state updates + webSocketService.subscribe(['all']); + + // Set up message handler + const messageHandler = (message: WsServerMessage) => { + console.log('[WebSocket] Received message:', message); + + switch (message.type) { + case 'state_snapshot': + // Initial state snapshot + const snapshot = message.state; + setCoarseState(snapshot.coarse_state); + setState({ + authorized_clients: Object.fromEntries(snapshot.authorized_clients), + wallet_limits_cache: {}, + client_limits_cache: snapshot.client_limits_cache || [], + call_history: snapshot.recent_transactions.map((rec: any) => ({ + ...rec, + payment_result: rec.payment_result && typeof rec.payment_result === 'string' + ? JSON.parse(rec.payment_result) + : rec.payment_result + })), + operator_tba_address: snapshot.operator_tba_address, + operator_entry_name: snapshot.operator_entry_name, + wallets: snapshot.wallets, + selected_wallet_id: snapshot.selected_wallet_id, + active_account: snapshot.active_account, + gasless_enabled: snapshot.gasless_enabled, + paymaster_approved: snapshot.paymaster_approved, + }); + // Also update the active account state + if (snapshot.active_account) { + setActive(snapshot.active_account); + } + if (snapshot.operator_tba_address) { + setResolvedOperatorTba(snapshot.operator_tba_address as Address); + } + // Update hot wallet if available + if (snapshot.selected_wallet_id) { + setSingleHotWallet(snapshot.selected_wallet_id as Address); + } + // If gasless is enabled AND paymaster is approved, the operator is fully set up + if (snapshot.gasless_enabled && snapshot.paymaster_approved) { + setShowSetupComplete(true); + } + break; + + case 'state_update': + // Handle specific state updates + const { topic, data } = message; + switch (topic) { + case 'graph_state': + if (data.update_type === 'graph_state_update') { + setCoarseState(data.coarse_state); + if (data.operator_tba_address) { + setResolvedOperatorTba(data.operator_tba_address as Address); + } + } + break; + + case 'wallets': + if (data.update_type === 'wallet_update') { + // Update wallet data in state + setState(prev => prev ? { + ...prev, + wallets: data.wallets, + selected_wallet_id: data.selected_wallet_id, + } : null); + // Update active account with balance + if (data.active_account) { + setActive(data.active_account); + } + // Update hot wallet if changed + if (data.selected_wallet_id) { + setSingleHotWallet(data.selected_wallet_id as Address); + } + } + break; + + case 'transactions': + if (data.update_type === 'new_transaction') { + // Add new transaction to history + setState(prev => prev ? { + ...prev, + call_history: [...prev.call_history, { + ...data.record, + payment_result: data.record.payment_result && typeof data.record.payment_result === 'string' + ? JSON.parse(data.record.payment_result) + : data.record.payment_result + }] + } : null); + } + break; + + case 'authorization': + if (data.update_type === 'authorization_update') { + setState(prev => prev ? { + ...prev, + authorized_clients: Object.fromEntries(data.clients) + } : null); + } + break; + } break; - } } - } - const opNode = nodes.find((n: any) => n?.type === 'operatorWalletNode'); - const funding = opNode && (opNode.data?.funding_status || opNode.data?.fundingStatus); - if (funding?.usdcBalanceStr) setOperatorUsdcBalance(funding.usdcBalanceStr as string); - } catch {} + }; + + webSocketService.addMessageHandler(messageHandler); + } catch (error) { + console.error('[WebSocket] Failed to connect:', error); + } }; - load(); - }, [fetchState, fetchActive]); + + setupWebSocket(); + + // Cleanup on unmount + return () => { + webSocketService.disconnect(); + }; + }, []); + const isBefore = coarseState === 'beforeWallet' || coarseState === 'BeforeWallet' || coarseState === 'before_wallet'; const isAfterNoClients = coarseState === 'afterWalletNoClients' || coarseState === 'AfterWalletNoClients' || coarseState === 'after_wallet_no_clients'; @@ -500,13 +598,15 @@ const OperatorConsole: React.FC = () => { const fetchLinkedWallets = useCallback(async (): Promise
=> { try { - const res = await fetch(`${baseApi}/linked-wallets`); - if (!res.ok) return null; - const j = await res.json(); - const arr = Array.isArray(j?.linked_wallets) ? j.linked_wallets : []; - const pick = arr.find((w: any) => w?.is_managed) || arr[0]; - if (pick?.address) { - const addr = pick.address as Address; + // Use the selected wallet from WebSocket state as the hot wallet + if (state && state.selected_wallet_id) { + const addr = state.selected_wallet_id as Address; + setSingleHotWallet(addr); + return addr; + } + // Fallback: use the first wallet if any exist + if (state && state.wallets && state.wallets.length > 0) { + const addr = state.wallets[0].address as Address; setSingleHotWallet(addr); return addr; } @@ -514,12 +614,19 @@ const OperatorConsole: React.FC = () => { } catch { return null; } - }, [baseApi]); + }, [state]); useEffect(() => { if (isAfterNoClients) fetchLinkedWallets(); }, [isAfterNoClients, fetchLinkedWallets]); + // Update singleHotWallet when state changes + useEffect(() => { + if (state && state.selected_wallet_id && !singleHotWallet) { + setSingleHotWallet(state.selected_wallet_id as Address); + } + }, [state, singleHotWallet]); + const handleGenerateHotWallet = useCallback(async () => { try { await callApiWithRouting({ GenerateWallet: {} }); @@ -530,26 +637,47 @@ const OperatorConsole: React.FC = () => { // Build live props for HyperwalletInterface when after_wallet_with_clients const buildHwProps = () => { - // operatorTba & usdcBalance from graph - // We already read graph at load; read again quickly for safety + // operatorTba & usdcBalance from WebSocket state const operatorTba = (operatorTbaFromState as string) || ''; - const usdcBalance = operatorUsdcBalance || '0'; + const usdcBalance = active?.usdc_balance || operatorUsdcBalance || '0'; // Clients from state.authorized_clients const authClients: any = (state?.authorized_clients as any) || {}; const clientArr: HwClient[] = Object.values(authClients).map((c: any) => { - const lims = (state as any)?.client_limits_cache?.[c.id] || {}; - const spent = lims.total_spent ?? lims.totalSpent ?? '0'; - const max = lims.max_total ?? lims.maxTotal ?? null; - const wlKey = (c.associated_hot_wallet_address || '').toLowerCase?.() || c.associated_hot_wallet_address; - const wlLims = (state as any)?.wallet_limits_cache?.[wlKey] || (state as any)?.wallet_limits_cache?.[c.associated_hot_wallet_address] || {}; - const fallbackMax = wlLims.max_total ?? wlLims.maxTotal ?? null; + // Check if spending data is already included in the client object (new format) + const monthlySpent = c.monthlySpent !== undefined ? c.monthlySpent : 0; + const monthlyLimit = c.monthlyLimit !== undefined ? c.monthlyLimit : undefined; + + // Fallback to old format if needed + if (c.monthlySpent === undefined) { + // client_limits_cache is an array of tuples: [[client_id, limits], ...] + const limitsArray = (state as any)?.client_limits_cache || []; + const limitsEntry = limitsArray.find(([id]: [string, any]) => id === c.id); + const lims = limitsEntry ? limitsEntry[1] : {}; + const spent = lims.total_spent ?? lims.totalSpent ?? '0'; + const max = lims.max_total ?? lims.maxTotal ?? null; + const wlKey = (c.associated_hot_wallet_address || '').toLowerCase?.() || c.associated_hot_wallet_address; + const wlLims = (state as any)?.wallet_limits_cache?.[wlKey] || (state as any)?.wallet_limits_cache?.[c.associated_hot_wallet_address] || {}; + const fallbackMax = wlLims.max_total ?? wlLims.maxTotal ?? null; + + return { + id: c.id, + name: c.name, + status: 'active', + monthlyLimit: max != null ? Number(max) : (fallbackMax != null ? Number(fallbackMax) : undefined), + monthlySpent: Number(spent || '0'), + dailyLimit: undefined, + dailySpent: undefined, + } as HwClient; + } + + // Use new format return { id: c.id, name: c.name, status: 'active', - monthlyLimit: max != null ? Number(max) : (fallbackMax != null ? Number(fallbackMax) : undefined), - monthlySpent: Number(spent || '0'), + monthlyLimit: monthlyLimit, + monthlySpent: monthlySpent, dailyLimit: undefined, dailySpent: undefined, } as HwClient; @@ -635,18 +763,50 @@ const OperatorConsole: React.FC = () => { const onSetLimits = async (clientId: string, limits: { maxPerCall?: string; maxTotal?: string }) => { try { - await fetch(`${baseApi}/actions`, { + const response = await fetch(`${getApiBasePath()}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ SetClientLimits: { client_id: clientId, limits: { maxPerCall: limits.maxPerCall ?? null, maxTotal: limits.maxTotal ?? null, currency: 'USDC' } } }), + body: JSON.stringify({ + SetClientLimits: [ + clientId, + { + maxPerCall: limits.maxPerCall || null, + maxTotal: limits.maxTotal || null, + currency: "USDC", + totalSpent: null + } + ] + }) }); - await fetchState(); - } catch {} + + if (!response.ok) { + throw new Error(await response.text()); + } + // State will be updated via WebSocket + } catch (error) { + console.error('Failed to set client limits:', error); + } }; - const onToggleClientStatus = async (_clientId: string) => { - // placeholder: backend flag can be added later; no-op for now + const onToggleClientStatus = async (clientId: string) => { + try { + const response = await fetch(`${getApiBasePath()}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + ToggleClientStatus: clientId + }) + }); + + if (!response.ok) { + throw new Error(await response.text()); + } + } catch (error) { + console.error('Failed to toggle client status:', error); + // Optionally show an error message to user + } }; const onOpenClientSettings = (clientId: string, clientName: string) => { @@ -693,35 +853,35 @@ const OperatorConsole: React.FC = () => { defaultOperatorEntryName={operatorEntryName} ownerEoa={ownerNodeOwnerEoa as any} onBootComplete={() => { + console.log('[onBootComplete] Wallet creation complete, reloading state...'); fetchState(); - fetchActive(); - setCoarseState(null); // force refetch via effect + //fetchActive(); + // Force reload of graph data to get updated coarseState + setShouldReloadGraph(prev => prev + 1); }} /> )} - {isAfterNoClients && !showSetupComplete && ( + {isAfterNoClients && !showSetupComplete && (!state?.gasless_enabled || !state?.paymaster_approved) && ( { fetchState(); - fetchActive(); + //fetchActive(); setShowSetupComplete(true); }} /> )} - {isAfterNoClients && showSetupComplete && ( + {isAfterNoClients && (showSetupComplete || (state?.gasless_enabled && state?.paymaster_approved)) && !setupCompleteDismissed && ( { - setCoarseState(null); - window.location.reload(); + // Dismiss the setup complete message and persist it + setSetupCompleteDismissed(true); + localStorage.setItem('operator-setup-complete-dismissed', 'true'); }} /> )} - {isAfterNoClients && ( -
- )} {/* Mock panel hidden in production UI */} {mockMode ? ( @@ -736,7 +896,7 @@ const OperatorConsole: React.FC = () => { onAddClient={addMockClient} onOpenGraphView={() => setShowGraphView(true)} /> - ) : isAfterWithClients ? ( + ) : (isAfterWithClients || (isAfterNoClients && setupCompleteDismissed) || (isAfterNoClients && state?.gasless_enabled && state?.paymaster_approved && !showSetupComplete)) ? ( <> { {isClientModalOpen && clientModalData && ( { setIsClientModalOpen(false); if (refresh) fetchState(); }} + onClose={() => setIsClientModalOpen(false)} clientId={clientModalData.id} clientName={clientModalData.name} hotWalletAddress={clientModalData.hotWallet} - onClientUpdate={() => fetchState()} + //onClientUpdate={() => fetchState()} /> )} {isShimModalOpen && ( { + onClose={() => { setIsShimModalOpen(false); setHotWalletForNewClient(null); window.location.reload(); @@ -816,19 +976,6 @@ const OperatorConsole: React.FC = () => { isLoading={true} /> )} - {showGraphView && ( -
-
-
-
Graph View
- -
-
- -
-
-
- )}
); }; diff --git a/operator/ui/src/components/console/OperatorFinalizeSetup.tsx b/operator/ui/src/components/console/OperatorFinalizeSetup.tsx index e8a7b56..81a4b57 100644 --- a/operator/ui/src/components/console/OperatorFinalizeSetup.tsx +++ b/operator/ui/src/components/console/OperatorFinalizeSetup.tsx @@ -16,6 +16,7 @@ import { } from '../../logic/hypermapHelpers'; import { MULTICALL as MULTICALL_ADDRESS, multicallAbi } from '../../abis'; +import { callApiWithRouting } from '../../utils/api-endpoints'; type Props = { operatorTbaAddress?: Address; @@ -88,7 +89,15 @@ const OperatorFinalizeSetup: React.FC = ({ operatorTbaAddress, hotWalletA React.useEffect(() => { if (isConfirmed) { - setTimeout(() => { + setTimeout(async () => { + try { + // Call the recheck paymaster approval endpoint + await callApiWithRouting("RecheckPaymasterApproval"); + console.log('Paymaster approval recheck triggered successfully'); + } catch (error) { + console.error('Error calling recheck paymaster approval:', error); + } + onComplete?.(); reset(); if (autoReload) { @@ -106,7 +115,7 @@ const OperatorFinalizeSetup: React.FC = ({ operatorTbaAddress, hotWalletA
- This final setup transaction will authorize Hypergrid to make USDC payments on your behalf without you needing to manually sign each transaction. + This final setup transaction will authorize your node running Hypergrid to make USDC payments on your behalf without you needing to manually sign each transaction.
-
- ); - } - - const selectedWallet = getSelectedWalletSummary(); - - return ( -
-
- {wallets.length === 0 && !isLoading && ( -

No accounts found. Generate or import one below.

- )} - {wallets.length > 0 && ( -
    - {wallets.map(wallet => ( -
  • - {/* Use CopyToClipboardText for address */} -
    handleSelectWallet(wallet.id)} - title={`Select Account: ${wallet.name || wallet.address} (${getWalletDisplayStatus(wallet)})`} - > - {wallet.name || truncateString(wallet.address, 16)} - {/* Display address using CopyToClipboardText */} - - {/* Pass truncated string as children */} - {truncateString(wallet.address)} - -
    - - {/* --- Display Status Text (Read Only) --- */} -
    - - - {getWalletDisplayStatus(wallet)} - -
    -
  • - ))} -
- )} - {/* Minimalist Add Wallet Bar */} -
- + -
- - -
-
- - {/* Import Wallet Form */} - {showImportForm && ( -
-

Import Wallet

- setPrivateKeyToImport(e.target.value)} - required - className="mb-3 block w-full px-3 py-2 border border-gray-300 rounded-md text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> - setWalletNameToImport(e.target.value)} - className="mb-3 block w-full px-3 py-2 border border-gray-300 rounded-md text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -

- ℹ️ Imported wallets are stored unencrypted for easier access -

- -
- )} -
- - {/* --- Selected Account Configuration Section --- */} - {selectedWallet && ( -
-

setIsConfigExpanded(!isConfigExpanded)}> - Configure: {selectedWallet.name || truncateString(selectedWallet.address, 16)} - {isConfigExpanded ? '[-]' : '[+]'} -

- {isConfigExpanded && ( -
- - {/* --- Activation / Deactivation / Unlock Controls --- */} -
-

Account Status & Actions

- - {/* Case 1: Inactive */} - {!selectedWallet.is_active && ( -
- Status: Inactive - {selectedWallet.is_encrypted && ( - setActivationPassword(prev => ({ ...prev, [selectedWallet.id]: e.target.value }))} - disabled={isActionLoading} - /> - )} - -
- )} - - {/* Case 2: Active (Locked) */} - {selectedWallet.is_active && selectedWallet.is_encrypted && ( -
- Status: Active (Locked) - setActivationPassword(prev => ({ ...prev, [selectedWallet.id]: e.target.value }))} - disabled={isActionLoading} - /> - - -
- )} - - {/* Case 3: Active (Unencrypted) */} - {selectedWallet.is_active && !selectedWallet.is_encrypted && ( -
- Status: Active - -
- )} -
- {/* --- End Status Controls --- */} - - {/* Keep existing config forms (Password, Limits, Export, Rename, Delete) */} - {/* Rename Button (Moved here) */} -
-

Rename Account

- {walletToRename === selectedWallet.id ? ( -
- setRenameInput(e.target.value)} autoFocus /> - - -
- ) : ( - - )} -
- {/* Set/Change Password Form */} -
-

{selectedWallet.is_encrypted ? 'Change' : 'Set'} Password

- {selectedWallet.is_encrypted && ( - setCurrentPassword(e.target.value)} - required - className="input-field" - /> - )} - setNewPassword(e.target.value)} - required - className="input-field" - /> - setConfirmPassword(e.target.value)} - required - className="input-field" - /> - -
- {/* Remove Password Form */} - {selectedWallet.is_encrypted && ( -
-

Remove Password

- setCurrentPassword(e.target.value)} - required - className="input-field" - /> - -
- )} - {/* Set Spending Limits Form */} -
-

Spending Limits

- setLimitPerCall(e.target.value)} - className="input-field" - /> - setLimitCurrency(e.target.value)} - className="input-field" - /> - -
- {/* Export Private Key Section */} -
-

Export Private Key

- {selectedWallet.is_encrypted && !selectedWallet.is_active && ( - setCurrentPassword(e.target.value)} - className="input-field" - /> - )} - - {revealedPrivateKey && ( -
-

Private Key:

- {revealedPrivateKey} - -
- )} -
- {/* Delete Button (Moved here) */} -
-

Delete Account

-

This action cannot be undone.

- -
-
- )} -
- )} - - {/* --- Modified API Config Section --- */} -
-

Shim API Configuration

-

- Generate and copy an API key configuration for use with the Hypergrid MCP Shim (npx). - Save this configuration as `grid-shim-api.json` in the directory where you run the shim. - Generating a new config will invalidate any previous one. -

- - - {generationError &&

{generationError}

} -
- {/* --- End Modified API Config Section --- */} -
- ); -} - -export default AccountManager; \ No newline at end of file diff --git a/operator/ui/src/legacy/SimpleSetupVisualizer.tsx b/operator/ui/src/legacy/SimpleSetupVisualizer.tsx deleted file mode 100644 index 1a1fe5f..0000000 --- a/operator/ui/src/legacy/SimpleSetupVisualizer.tsx +++ /dev/null @@ -1,401 +0,0 @@ -// Kept for Sam's sake - -//import React, { useState, useEffect, useCallback } from 'react'; -//import { -// OnboardingStatusResponse, -// OnboardingCheckDetails, -// IdentityStatus as TIdentityStatus, // Renaming to avoid conflict if ever used with a local enum -// DelegationStatus as TDelegationStatus, -// FundingStatusDetails as TFundingStatusDetails -//} from '../logic/types'; -//import { useAccount, useChainId, useWriteContract, useWaitForTransactionReceipt, usePublicClient, useContractRead, useConfig } from 'wagmi'; -//import { ConnectButton } from '@rainbow-me/rainbowkit'; -//import { -// encodeFunctionData, -// parseAbi, -// stringToHex, -// bytesToHex, -// namehash as viemNamehash, -// type Address as ViemAddress, -// toHex, -// encodeAbiParameters, -// parseAbiParameters, -// getAddress, -// encodePacked -//} from 'viem'; -//import CopyToClipboardText from '../components/CopyToClipboardText'; -// -//// --- Constants (Copied from SetupWizard for now) --- -//const BASE_CHAIN_ID = 8453; -//const HYPERMAP_ADDRESS = '0x000000000044C6B8Cb4d8f0F889a3E47664EAeda' as ViemAddress; -//const OPERATOR_TBA_IMPLEMENTATION = '0x000000000046886061414588bb9F63b6C53D8674' as ViemAddress; -// -//const hypermapAbi = parseAbi([ -// 'function note(bytes calldata note, bytes calldata data) external returns (bytes32 labelhash)', -// 'function mint(address owner, bytes calldata label, bytes calldata initData, address implementation) external returns (address tba)', // Using 4-arg for now, as per last successful test config -// 'function fact(bytes calldata key, bytes calldata value) external returns (bytes32 factHash)', -// 'function get(bytes32 node) external view returns (address tba, address owner, bytes memory note)' -//]); -//const mechAbi = parseAbi([ -// 'function execute(address target, uint256 value, bytes calldata data, uint8 operation) payable returns (bytes memory returnData)', -//]); -// -//const truncateString = (str: string | null | undefined, len: number = 10): string => { -// if (!str) return '(N/A)'; -// if (str.length <= len + 3) return str; -// const prefix = str.startsWith('0x') ? '0x' : ''; -// const addressPart = prefix ? str.substring(2) : str; -// const visibleLen = len - prefix.length - 3; -// if (visibleLen <= 1) return prefix + '...'; -// const start = prefix + addressPart.substring(0, Math.ceil(visibleLen / 2)); -// const end = addressPart.substring(addressPart.length - Math.floor(visibleLen / 2)); -// return `${start}...${end}`; -//} -// -//interface SimpleSetupVisualizerProps { -// onboardingData: OnboardingStatusResponse | null; -// nodeName: string | null; // The main node name like "abracadabra.os" -// nodeTbaAddress: ViemAddress | null | undefined; // TBA of the main nodeName -// nodeTbaOwner: ViemAddress | null | undefined; // Owner of the main nodeName's TBA -// onRefreshStatus: () => void; // Callback to refresh onboarding status -//} -// -//const SimpleSetupVisualizer: React.FC = ({ -// onboardingData, -// nodeName, -// nodeTbaAddress, -// nodeTbaOwner, -// onRefreshStatus -//}) => { -// const [toastMessage, setToastMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); -// const [onChainTxHash, setOnChainTxHash] = useState<`0x${string}` | undefined>(undefined); -// const [onChainError, setOnChainError] = useState(null); -// const [currentActionName, setCurrentActionName] = useState(null); -// -// const { address: connectedOwnerWalletAddress, isConnected: isOwnerConnected, chain } = useAccount(); -// const currentOwnerChainId = useChainId(); -// const { data: onChainActionHash, writeContract: executeOnChainAction, isPending, reset, error: writeContractHookError } = useWriteContract(); -// const { isLoading: isOnChainActionConfirming, isSuccess: isOnChainActionConfirmed, error: txReceiptError } = useWaitForTransactionReceipt({ hash: onChainActionHash, chainId: BASE_CHAIN_ID }); -// -// const showToast = useCallback((type: 'success' | 'error', text: string, duration: number = 4000) => { -// setToastMessage({ type, text }); -// setTimeout(() => setToastMessage(null), duration); -// }, [setToastMessage]); -// -// useEffect(() => { -// if (isOnChainActionConfirmed && onChainActionHash && currentActionName) { -// showToast('success', `Action (${currentActionName}) confirmed! Hash: ${truncateString(onChainActionHash)}`); -// setCurrentActionName(null); setOnChainTxHash(undefined); setOnChainError(null); -// onRefreshStatus(); -// } -// if (txReceiptError && currentActionName) { -// const errorMsg = (txReceiptError as Error)?.message || `${currentActionName} tx confirmation failed`; -// setOnChainError(errorMsg); -// showToast('error', `On-chain confirmation error (${currentActionName}): ${errorMsg}`); -// setCurrentActionName(null); -// } -// }, [isOnChainActionConfirmed, onChainActionHash, txReceiptError, currentActionName, showToast, onRefreshStatus]); -// -// useEffect(() => { if (onChainActionHash) setOnChainTxHash(onChainActionHash); }, [onChainActionHash]); -// useEffect(() => { -// if (writeContractHookError) { -// console.error("Visualizer Wagmi Hook Error:", writeContractHookError); -// showToast('error', `Wallet Interaction Error: ${writeContractHookError.message}`); -// setCurrentActionName(null); -// } -// }, [writeContractHookError, showToast]); -// -// // --- REVISED Status Text Helper Functions --- -// const getIdentityStatusDisplay = (): string => { -// const checks = onboardingData?.checks; -// if (!checks) return '❓'; // Should not happen if onboardingData is present -// if (!checks.identityStatus) return checks.identityConfigured ? '❓ (Awaiting Details)' : '❌ (Initial Check)'; -// -// const is = checks.identityStatus as any; -// -// // Handle string variants from Rust enum serialization -// if (typeof is === 'string') { -// if (is === 'notFound') return `❌ Not Minted`; -// // Add other simple string variants if any exist in Rust enum -// return `❓ Unknown String Status (${is})`; -// } -// -// // Handle object variants -// if (typeof is === 'object' && is !== null) { -// // Rust enum variants with data are serialized as an object with one key, e.g., { verified: { ... } } -// if ('verified' in is && is.verified) return `✅ Verified (TBA: ${truncateString(checks.operatorTba)})`; -// if ('incorrectImplementation' in is && is.incorrectImplementation) return `❌ Incorrect Implementation (Found: ${truncateString(is.incorrectImplementation.found)})`; -// if ('implementationCheckFailed' in is && is.implementationCheckFailed) return `❓ Error Checking Impl (${is.implementationCheckFailed})`; -// if ('checkError' in is && is.checkError) return `❓ Check Error (${is.checkError})`; -// // If Rust serialized a unit variant as an object like {notFound: null}, this would catch it: -// if ('notFound' in is) return `❌ Not Minted`; -// return `❓ Unknown Object Status (${JSON.stringify(is)})`; -// } -// return `❓ Invalid Status Format`; // Fallback for unexpected types -// }; -// -// const getDelegationNoteStatusText = (noteType: 'signers' | 'accessList'): string => { -// const checks = onboardingData?.checks; -// if (!checks) return '⚪'; -// if (!checks.identityConfigured) return '⚪ (Blocked by Identity)'; -// if (!checks.hotWalletSelectedAndActive) return '⚪ (Blocked by Hot Wallet)'; -// -// const ds = checks.delegationStatus as any; -// if (!ds) { -// return checks.delegationVerified === null ? '❓ Checking...' : -// (checks.delegationVerified === true ? '✅ Set Correctly (No Detail)' : '❓ Unknown'); -// } -// -// if (ds === 'verified') return '✅ Set Correctly'; -// -// // --- Handle Access List Note Status First --- -// if (ds === 'accessListNoteMissing') { -// return noteType === 'accessList' ? '❌ Missing' : '⚪ (Access List Note Missing)'; -// } -// if (typeof ds === 'object' && ds !== null && 'accessListNoteInvalidData' in ds) { -// const reason = ds.accessListNoteInvalidData as string; -// if (reason.toLowerCase().includes("has no data")) { -// return noteType === 'accessList' ? '❌ Missing (No Data Set)' : '⚪ (Access List Note Missing/Empty)'; -// } -// return noteType === 'accessList' ? `❌ Invalid Data (${reason})` : '⚪ (Access List Note Invalid)'; -// } -// -// // --- If Access List is not the primary issue, evaluate based on noteType --- -// if (noteType === 'signers') { -// if (ds === 'signersNoteMissing') return '❌ Missing'; -// if (typeof ds === 'object' && ds !== null && 'signersNoteLookupError' in ds) return `❌ Lookup Error (Not Found via Access List Pointer)`; -// if (typeof ds === 'object' && ds !== null && 'signersNoteInvalidData' in ds) return `❌ Invalid Data/Format (${ds.signersNoteInvalidData})`; -// if (ds === 'hotWalletNotInList') return `❌ Incorrect Value (Hot Wallet mismatch)`; -// } -// -// if (typeof ds === 'object' && ds !== null && 'checkError' in ds) return `❓ Check Error (${ds.checkError})`; -// -// // Fallback: If this note type doesn't have a specific error mentioned above, -// // but delegation is not 'verified', it implies the other note is the issue or it's a general unverified state. -// if (ds !== 'verified') { -// return '⏳ Pending Other Note / Status Unknown'; -// } -// -// return `❓ Status Unclear (${JSON.stringify(ds)})`; -// }; -// -// const getFundingText = (type: 'tbaEth' | 'tbaUsdc' | 'hwEth'): string => { -// const checks = onboardingData?.checks; -// if (!checks || !checks.fundingStatus) return '❓ Checking...'; // Changed from just '❓' -// const fs = checks.fundingStatus; -// -// if (type === 'tbaEth') return `${fs.tbaNeedsEth ? '❌ Needs ETH' : '✅ OK'} (${checks.tbaEthBalanceStr || 'N/A'})`; -// if (type === 'tbaUsdc') return `${fs.tbaNeedsUsdc ? '❌ Needs USDC' : '✅ OK'} (${checks.tbaUsdcBalanceStr || 'N/A'})`; -// if (type === 'hwEth') return `${fs.hotWalletNeedsEth ? '❌ Needs ETH' : '✅ OK'} (${checks.hotWalletEthBalanceStr || 'N/A'})`; -// return '❓ Error'; -// }; -// // --- End Status Text Helpers --- -// -// // --- Action Handlers (copied and adapted from SetupWizard) --- -// const handleMintOperatorSubEntry = async () => { -// setOnChainError(null); setOnChainTxHash(undefined); -// const checks = onboardingData?.checks; -// let displayHotWalletAddressForNotes = checks?.hotWalletAddress; -// -// console.log("Visualizer Mint Pre-check:", { nodeName, hotWalletForNotes: displayHotWalletAddressForNotes, nodeTbaAddress, connectedOwnerWalletAddress, nodeTbaOwner }); -// if (!nodeName || /*!displayHotWalletAddressForNotes ||*/ !nodeTbaAddress || !connectedOwnerWalletAddress || !nodeTbaOwner) { -// showToast('error', 'Mint Error: Missing required info for Visualizer.'); -// return; -// } -// if (connectedOwnerWalletAddress.toLowerCase() !== nodeTbaOwner?.toLowerCase()) { -// showToast('error', 'Mint Error: Connected wallet does not match Node TBA owner.'); -// return; -// } -// try { -// setCurrentActionName('mint'); -// const subEntryLabelForMint = "grid-beta-wallet"; -// const labelArg = encodePacked(['bytes'], [stringToHex(subEntryLabelForMint)]); -// const initArg = bytesToHex(new Uint8Array([])); -// const implArg = OPERATOR_TBA_IMPLEMENTATION; -// const mintCalldata = encodeFunctionData({ abi: hypermapAbi, functionName: 'mint', args: [connectedOwnerWalletAddress, labelArg, initArg, implArg] }); -// const txArgs = [HYPERMAP_ADDRESS, 0n, mintCalldata, 0 as const] as const; -// executeOnChainAction({ address: nodeTbaAddress, abi: mechAbi, functionName: 'execute', args: txArgs, chainId: BASE_CHAIN_ID }); -// } catch (err) { -// console.error("Mint Prep Error (Visualizer):", err); -// showToast('error', `Mint Prep Error: ${err instanceof Error ? err.message : 'Unknown error'}`); -// setCurrentActionName(null); -// } -// }; -// -// const handleSetSignersNote = async () => { -// const operatorTba = onboardingData?.checks?.operatorTba as ViemAddress | undefined; -// let displayHotWalletAddress = onboardingData?.checks?.hotWalletAddress; -// -// if (!operatorTba || !displayHotWalletAddress || !connectedOwnerWalletAddress) { -// showToast('error', 'Signers Note: Missing opTBA, HotWalletAddr, or Owner not connected'); -// setCurrentActionName(null); -// return; -// } -// // displayHotWalletAddress is the string "0x..." here if valid -// const hotWalletAddrString = displayHotWalletAddress; -// -// try { -// setCurrentActionName('setSignersNote'); -// const signersNoteKey = "~grid-beta-signers"; -// const noteKeyBytes = stringToHex(signersNoteKey); -// -// // --- Use simple stringToHex for the address string value --- -// const noteValueBytes = stringToHex(hotWalletAddrString); -// console.log(`SetSignersNote: Using simple stringToHex for hotWalletAddress (${hotWalletAddrString}): ${noteValueBytes}`); -// -// const noteCalldata = encodeFunctionData({ abi: hypermapAbi, functionName: 'note', args: [noteKeyBytes, noteValueBytes] }); -// const txArgs = [HYPERMAP_ADDRESS, 0n, noteCalldata, 0 as const] as const; -// console.log(`SetSignersNote: Targeting Operator TBA ${operatorTba} with simple hex value.`); -// executeOnChainAction({ address: operatorTba, abi: mechAbi, functionName: 'execute', args: txArgs, chainId: BASE_CHAIN_ID }); -// } catch (err) { -// console.error("SetSignersNote Prep Error (Visualizer):", err); -// showToast('error', `SetSignersNote Prep Error: ${err instanceof Error ? err.message : 'Unknown error'}`); -// setCurrentActionName(null); -// } -// }; -// -// const handleSetAccessListNote = async () => { -// const operatorTba = onboardingData?.checks?.operatorTba as ViemAddress | undefined; -// const operatorTbaFullName = `grid-beta-wallet.${nodeName || 'unknown'}`; -// const signersNoteFullNameToHash = `~grid-beta-signers.${operatorTbaFullName}`; -// if (!nodeName || !operatorTba || !connectedOwnerWalletAddress) { -// showToast('error', 'AccessList Note: Missing nodeName, opTBA, or Owner not connected'); return; -// } -// try { -// setCurrentActionName('setAccessListNote'); -// const accessListNoteKey = "~access-list"; -// const noteKeyBytes = stringToHex(accessListNoteKey); -// const namehashOfSignersNoteBytes = viemNamehash(signersNoteFullNameToHash); -// const noteDataForAccessList = toHex(namehashOfSignersNoteBytes); -// const noteCalldata = encodeFunctionData({ abi: hypermapAbi, functionName: 'note', args: [noteKeyBytes, noteDataForAccessList] }); -// const txArgs = [HYPERMAP_ADDRESS, 0n, noteCalldata, 0 as const] as const; -// executeOnChainAction({ address: operatorTba, abi: mechAbi, functionName: 'execute', args: txArgs, chainId: BASE_CHAIN_ID }); -// } catch (err) { -// console.error("SetAccessListNote Prep Error (Visualizer):", err); -// showToast('error', `SetAccessListNote Prep Error: ${err instanceof Error ? err.message : 'Unknown error'}`); -// setCurrentActionName(null); -// } -// }; -// // --- End Action Handlers --- -// -// if (!onboardingData) { -// return

Loading onboarding status...

; -// } -// const { checks } = onboardingData; -// -// const isIdentityFullyVerified = typeof checks.identityStatus === 'object' && checks.identityStatus !== null && 'verified' in checks.identityStatus; -// const isHotWalletReady = !!checks.hotWalletSelectedAndActive; -// const opTbaDisplay = checks.operatorTba ? truncateString(checks.operatorTba) : "(Not Set)"; -// -// // Define button disabled states based on props and current component state (isPending) -// const mintButtonDisabled = -// isPending || -// !nodeName || -// !nodeTbaAddress || -// !connectedOwnerWalletAddress || -// !nodeTbaOwner; -// -// const setSignersNoteDisabled = -// isPending || -// !checks.operatorTba || -// !checks.hotWalletAddress || -// !connectedOwnerWalletAddress; -// -// const setAccessListNoteDisabled = -// isPending || -// !checks.operatorTba || -// !nodeName || -// !connectedOwnerWalletAddress; -// -// return ( -//
-// {toastMessage &&
{toastMessage.text}
} -//
-// Node: {`${nodeName || "(Node?)"} (Node TBA: ${truncateString(nodeTbaAddress)})`}
-// Owner Wallet for Actions: {isOwnerConnected ? truncateString(connectedOwnerWalletAddress) : "(Not Connected)"} -// {isOwnerConnected && currentOwnerChainId !== BASE_CHAIN_ID && (Wrong Network)} -// {!isOwnerConnected && } -//
-//
-//
├─ Operator Sub-Entry (grid-beta-wallet.{nodeName || '(Node?)'})
-//
│ │ Status: {getIdentityStatusDisplay()}
-//
│ │ TBA: {opTbaDisplay}
-// {!isIdentityFullyVerified && ( -// isOwnerConnected && currentOwnerChainId === BASE_CHAIN_ID ? ( -//
│ └─ Action:
-// ) : ( -//
│ └─ Action:
-// ) -// )} -//
-//
├─ Hot Wallet
-//
│ │ Status: {isHotWalletReady ? '✅ Ready' : '❌ Needs Setup/Activation'}
-//
│ │ Address: {checks.hotWalletAddress ? truncateString(checks.hotWalletAddress) : "(None Selected)"}
-// {!isHotWalletReady && ( -//
│ └─ Action: (Configure in Step 1 of Main Wizard)
-// )} -//
-// {isIdentityFullyVerified && ( -// isOwnerConnected && currentOwnerChainId === BASE_CHAIN_ID ? ( -// isHotWalletReady ? ( -// <> -//
├─ Delegation Notes (on Operator TBA: {opTbaDisplay})
-//
│ │
-//
│ ├─ Signers Note (~grid-beta-signers)
-//
│ │ │ Status: {getDelegationNoteStatusText('signers')}
-//
│ │ │ Expected: {checks.hotWalletAddress || "(Hot Wallet Address)"}
-//
│ │ └─ Action:
-//
│ │
-//
│ └─ Access List Note (~grid-beta-access-list)
-//
│ │ Status: {getDelegationNoteStatusText('accessList')}
-//
│ │ Expected: Namehash of full signers note
-//
│ └─ Action:
-// -// ) : ( -//
├─ Delegation Notes: ⚪ (Blocked by Hot Wallet Setup - See Main Wizard Step 1)
-// ) -// ) : ( -//
├─ Delegation Notes: ⚪ (Connect Owner Wallet to Base to Manage Notes)
-// ) -// )} -// {!isIdentityFullyVerified && ( -//
├─ Delegation Notes: ⚪ (Blocked by Operator Sub-Entry Setup)
-// )} -//
-//
└─ Funding
-// {/* Operator TBA Funding */} -// {checks.operatorTba && ( -// <> -//
│ ├─ Operator TBA: {truncateString(checks.operatorTba)} -// -// -// -//
-//
│ │ ETH : {getFundingText('tbaEth')}
-//
│ │ USDC : {getFundingText('tbaUsdc')}
-// -// )} -// {/* Hot Wallet Funding */} -// {checks.hotWalletAddress && ( -// <> -//
│ {(checks.operatorTba ? '├' : '└') }─ Hot Wallet: {truncateString(checks.hotWalletAddress)} -// -// -// -//
-//
│ │ ETH : {getFundingText('hwEth')}
-// -// )} -// {/* Display overall funding status and errors */} -// {!checks.operatorTba && !checks.hotWalletAddress && !checks.fundingStatus && ( -//
│ (Funding status pending previous steps)
-// )} -// -//
-// {onChainTxHash &&

Tx Submitted: {truncateString(onChainTxHash)} {isOnChainActionConfirming && "Confirming..."}

} -// {onChainError &&

Error: {onChainError}

} -//
-// ); -//}; -// -//export default SimpleSetupVisualizer; \ No newline at end of file diff --git a/operator/ui/src/logic/calls.ts b/operator/ui/src/logic/calls.ts index fa52e22..024bce2 100644 --- a/operator/ui/src/logic/calls.ts +++ b/operator/ui/src/logic/calls.ts @@ -7,10 +7,28 @@ import type { Result, } from "./types"; import { PUBLISHER } from "../constants"; +import { + fetchStateMigrated, + //fetchAllMigrated, + searchDBMigrated, + USE_NEW_API, + logMigration, + call_provider, + getAuthCredentials, + type KeyValue, +} from "../utils/api-migration"; export const API_PATH = `/operator:hypergrid:${PUBLISHER}/api`; export async function fetchState(): AsyncRes { + if (USE_NEW_API) { + logMigration('/state', 'get_setup_status'); + // fetchStateMigrated already returns Promise> + // but this function signature expects AsyncRes (which is Promise>) + // so we just return the promise directly + return fetchStateMigrated(); + } + try { const response = await fetch(API_PATH + "/state"); const j = await response.json(); @@ -21,6 +39,14 @@ export async function fetchState(): AsyncRes { } export async function fetchAll(): AsyncRes { + if (USE_NEW_API) { + logMigration('/all', 'get_all_providers'); + // fetchAllMigrated returns ProviderJson[], we need Provider[] + // For now, cast it - ideally update the types to match + //return fetchAllMigrated() as AsyncRes; + return { error: 'Not implemented' }; + } + try { const response = await fetch(API_PATH + "/all"); const j = await response.json(); @@ -31,6 +57,23 @@ export async function fetchAll(): AsyncRes { } export async function fetchCategory(cat: string): AsyncRes { + if (USE_NEW_API) { + logMigration('/cat', 'client-side filtering from get_all_providers'); + // For now, fetch all and filter client-side + // TODO: Add get_providers_by_category endpoint to operator + //const allResult = await fetchAllMigrated(); + const allResult = { error: 'Not implemented' }; + if ('error' in allResult) { + return allResult; + } + // Filter by category client-side + //const filtered = allResult.ok?.filter((p: any) => + // p.category === cat || (p.facts && p.facts.category && p.facts.category.includes(cat)) + //); + //return { ok: filtered as unknown as Provider[] }; + return { ok: [] }; + } + try { const response = await fetch(API_PATH + "/cat?cat=" + cat); const j = await response.json(); @@ -40,6 +83,11 @@ export async function fetchCategory(cat: string): AsyncRes { } } export async function searchDB(query: string): AsyncRes { + if (USE_NEW_API) { + logMigration('/search', 'search_providers_public'); + return searchDBMigrated(query); + } + try { const response = await fetch(API_PATH + "/search?q=" + query); const j = await response.json(); @@ -50,6 +98,36 @@ export async function searchDB(query: string): AsyncRes { } export async function callProvider(provider: ProviderJson, args: any) { + if (USE_NEW_API) { + logMigration('/call', 'call_provider'); + + // Check if we have auth credentials + const auth = getAuthCredentials(); + if (!auth) { + return { error: 'Authentication required. Please authorize first.' }; + } + + try { + // Convert args object to KeyValue array + const keyValueArgs: KeyValue[] = Object.entries(args).map(([key, value]) => ({ + key, + value: String(value) + })); + + const result = await call_provider( + provider.id, + provider.name, + keyValueArgs, + auth.clientId, + auth.token + ); + + return { ok: result }; + } catch (error: any) { + return { error: error.message || 'Provider call failed' }; + } + } + try { const body = { CallProvider: { diff --git a/operator/ui/src/logic/types.ts b/operator/ui/src/logic/types.ts index 0882b7f..e121712 100644 --- a/operator/ui/src/logic/types.ts +++ b/operator/ui/src/logic/types.ts @@ -3,7 +3,12 @@ import type { Address } from 'viem'; export type AsyncRes = Promise>; export type Result = { ok: T } | { error: string }; -export type ProcessState = { indexers: string[]; indexer: string }; +export type ProcessState = { + indexers?: string[]; + indexer?: string; + configured: boolean; + // Add other fields as needed +}; export type AllProviders = Record>; export type Category = string; export type Provider = { @@ -19,16 +24,17 @@ export type Provider = { id?: number; }; export type ProviderJson = { - category: Category; - site: string; + category?: Category; + site?: string; description: string; name: string; - provider_name: string; - provider_id: string; - price: string; + provider_name?: string; + provider_id?: string; + price?: string; // db data created?: number; - id?: number; + id: string; // Changed to string to match ProviderInfo + hash?: string; arguments?: Record; }; diff --git a/operator/ui/src/services/websocket.ts b/operator/ui/src/services/websocket.ts index 9a674a8..75ff8af 100644 --- a/operator/ui/src/services/websocket.ts +++ b/operator/ui/src/services/websocket.ts @@ -1,12 +1,9 @@ import { WsClientMessage, WsServerMessage, - AuthMessage, - ChatMessage, - CancelMessage, + SubscribeMessage, PingMessage, - SpiderMessage, - ConversationMetadata + StateUpdateTopic } from '../types/websocket'; export type MessageHandler = (message: WsServerMessage) => void; @@ -14,10 +11,10 @@ export type MessageHandler = (message: WsServerMessage) => void; class WebSocketService { private ws: WebSocket | null = null; private messageHandlers: Set = new Set(); - private reconnectTimeout: number | null = null; + private reconnectTimeout: ReturnType | null = null; private url: string = ''; - private isAuthenticated: boolean = false; - private pingInterval: number | null = null; + private isSubscribed: boolean = false; + private subscribedTopics: StateUpdateTopic[] = []; connect(url: string): Promise { return new Promise((resolve, reject) => { @@ -30,9 +27,8 @@ class WebSocketService { this.ws = new WebSocket(url); this.ws.onopen = () => { - console.log('WebSocket connected'); + console.log('WebSocket connected to operator'); this.clearReconnectTimeout(); - this.startPingInterval(); resolve(); }; @@ -42,112 +38,67 @@ class WebSocketService { }; this.ws.onclose = () => { - console.log('WebSocket disconnected'); - this.isAuthenticated = false; - this.stopPingInterval(); + console.log('WebSocket disconnected from operator'); + this.isSubscribed = false; this.scheduleReconnect(); }; this.ws.onmessage = (event) => { try { - console.log('WebSocket raw message received:', event.data); const message = JSON.parse(event.data) as WsServerMessage; - console.log('WebSocket parsed message:', message); this.handleMessage(message); } catch (error) { - console.error('Failed to parse WebSocket message:', error, 'Raw data:', event.data); + console.error('Failed to parse WebSocket message:', error); } }; }); } private handleMessage(message: WsServerMessage) { + // Handle subscribed confirmation + if (message.type === 'subscribed') { + this.isSubscribed = true; + this.subscribedTopics = message.topics; + console.log('Subscribed to operator state topics:', message.topics); + } + // Notify all handlers this.messageHandlers.forEach(handler => handler(message)); } - authenticate(apiKey: string): Promise { - return new Promise((resolve, reject) => { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - reject(new Error('WebSocket not connected')); - return; - } - - console.log('WebSocket authenticate: sending auth message with key:', apiKey); - - // Set up timeout for auth response - const authTimeout = window.setTimeout(() => { - console.error('WebSocket auth timeout - no response received'); - this.removeMessageHandler(authHandler); - reject(new Error('Authentication timeout - no response from server')); - }, 5000); // 5 second timeout - - // Set up one-time handler for auth response - const authHandler = (message: WsServerMessage) => { - console.log('WebSocket received message during auth:', message); - if (message.type === 'auth_success') { - console.log('WebSocket auth success received'); - window.clearTimeout(authTimeout); - this.isAuthenticated = true; - this.removeMessageHandler(authHandler); - resolve(); - } else if (message.type === 'auth_error') { - console.log('WebSocket auth error received:', message.error); - window.clearTimeout(authTimeout); - this.removeMessageHandler(authHandler); - // Pass the exact error message so we can detect invalid API key - reject(new Error(message.error || 'Authentication failed')); - } - }; - - // Add handler BEFORE sending message - this.addMessageHandler(authHandler); - - // Send auth message - use correct format - const authMsg: AuthMessage = { - type: 'auth', - apiKey - }; - console.log('WebSocket sending auth:', JSON.stringify(authMsg)); - this.send(authMsg); - }); + subscribe(topics?: StateUpdateTopic[]): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket not connected'); + } + + const subscribeMsg: SubscribeMessage = { + type: 'subscribe', + topics: topics || ['all'] + }; + this.send(subscribeMsg); } - sendChatMessage( - messages: SpiderMessage[], - llmProvider?: string, - model?: string, - mcpServers?: string[], - metadata?: ConversationMetadata, - conversationId?: string - ): void { - if (!this.isAuthenticated) { - throw new Error('Not authenticated'); + unsubscribe(topics?: StateUpdateTopic[]): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return; } - const chatMsg: ChatMessage = { - type: 'chat', - payload: { - messages, - llmProvider, - model, - mcpServers, - metadata, - conversationId - } + const unsubscribeMsg = { + type: 'unsubscribe' as const, + topics }; - this.send(chatMsg); + this.send(unsubscribeMsg); } - sendCancel(): void { - if (!this.isAuthenticated) { - throw new Error('Not authenticated'); + sendPing(): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return; } - const cancelMsg: CancelMessage = { - type: 'cancel' + const pingMsg: PingMessage = { + type: 'ping' }; - this.send(cancelMsg); + this.send(pingMsg); } send(data: WsClientMessage): void { @@ -168,73 +119,46 @@ class WebSocketService { disconnect(): void { this.clearReconnectTimeout(); - this.stopPingInterval(); if (this.ws) { this.ws.close(); this.ws = null; } - this.isAuthenticated = false; + this.isSubscribed = false; + this.subscribedTopics = []; } private scheduleReconnect(): void { if (this.reconnectTimeout) return; - this.reconnectTimeout = window.setTimeout(() => { - console.log('Attempting to reconnect WebSocket...'); - this.connect(this.url).catch(error => { - console.error('Reconnection failed:', error); - }); + this.reconnectTimeout = setTimeout(() => { + console.log('Attempting to reconnect WebSocket to operator...'); + this.connect(this.url) + .then(() => { + // Re-subscribe after reconnect + if (this.subscribedTopics.length > 0) { + this.subscribe(this.subscribedTopics); + } + }) + .catch(error => { + console.error('Reconnection failed:', error); + }); }, 3000); } private clearReconnectTimeout(): void { if (this.reconnectTimeout) { - window.clearTimeout(this.reconnectTimeout); + clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } } - private startPingInterval(): void { - this.stopPingInterval(); - - // Send an immediate ping for testing - if (this.isConnected) { - console.log('Sending immediate ping for testing'); - const pingMsg: PingMessage = { type: 'ping' }; - try { - this.send(pingMsg); - } catch (error) { - console.error('Failed to send ping:', error); - } - } - - this.pingInterval = window.setInterval(() => { - if (this.isConnected) { - const pingMsg: PingMessage = { type: 'ping' }; - try { - console.log('Sending periodic ping'); - this.send(pingMsg); - } catch (error) { - console.error('Failed to send ping:', error); - } - } - }, 30000); // Send ping every 30 seconds - } - - private stopPingInterval(): void { - if (this.pingInterval) { - window.clearInterval(this.pingInterval); - this.pingInterval = null; - } - } - get isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN; } get isReady(): boolean { - return this.isConnected && this.isAuthenticated; + return this.isConnected && this.isSubscribed; } } -export const webSocketService = new WebSocketService(); \ No newline at end of file +export const webSocketService = new WebSocketService(); diff --git a/operator/ui/src/types/websocket.ts b/operator/ui/src/types/websocket.ts index d9d1c79..f241f2f 100644 --- a/operator/ui/src/types/websocket.ts +++ b/operator/ui/src/types/websocket.ts @@ -1,119 +1,167 @@ -// WebSocket message types for Spider chat +// WebSocket message types for Operator state streaming // Client -> Server messages export type WsClientMessage = - | AuthMessage - | ChatMessage - | CancelMessage + | SubscribeMessage + | UnsubscribeMessage | PingMessage; -export interface AuthMessage { - type: 'auth'; - apiKey: string; +export interface SubscribeMessage { + type: 'subscribe'; + topics?: StateUpdateTopic[]; } -export interface ChatMessage { - type: 'chat'; - payload: { - messages: SpiderMessage[]; - llmProvider?: string; - model?: string; - mcpServers?: string[]; - metadata?: ConversationMetadata; - conversationId?: string; - }; -} - -export interface CancelMessage { - type: 'cancel'; +export interface UnsubscribeMessage { + type: 'unsubscribe'; + topics?: StateUpdateTopic[]; } export interface PingMessage { type: 'ping'; } +// Topics that clients can subscribe to +export type StateUpdateTopic = + | 'wallets' + | 'transactions' + | 'providers' + | 'authorization' + | 'graph_state' + | 'all'; + // Server -> Client messages export type WsServerMessage = - | AuthSuccessMessage - | AuthErrorMessage - | StatusMessage - | StreamMessage - | MessageUpdate - | ChatCompleteMessage + | SubscribedMessage + | StateUpdateMessage + | StateSnapshotMessage | ErrorMessage | PongMessage; -export interface AuthSuccessMessage { - type: 'auth_success'; - message: string; +export interface SubscribedMessage { + type: 'subscribed'; + topics: StateUpdateTopic[]; } -export interface AuthErrorMessage { - type: 'auth_error'; +export interface StateUpdateMessage { + type: 'state_update'; + topic: StateUpdateTopic; + data: StateUpdateData; +} + +export interface StateSnapshotMessage { + type: 'state_snapshot'; + state: StateSnapshotData; +} + +export interface ErrorMessage { + type: 'error'; error: string; } -export interface StatusMessage { - type: 'status'; - status: string; - message?: string; +export interface PongMessage { + type: 'pong'; } -export interface StreamMessage { - type: 'stream'; - iteration: number; - message: string; - tool_calls?: string | null; +// State update data types +export type StateUpdateData = + | WalletUpdateData + | NewTransactionData + | ProviderUpdateData + | AuthorizationUpdateData + | GraphStateUpdateData + | BalanceUpdateData; + +export interface WalletUpdateData { + update_type: 'wallet_update'; + wallets: WalletSummary[]; + selected_wallet_id: string | null; + active_signer_wallet_id: string | null; + active_account?: ActiveAccountDetails | null; } -export interface MessageUpdate { - type: 'message'; - message: SpiderMessage; +export interface NewTransactionData { + update_type: 'new_transaction'; + record: CallRecord; } -export interface ChatCompleteMessage { - type: 'chat_complete'; - payload: ChatResponse; +export interface ProviderUpdateData { + update_type: 'provider_update'; + update_info: string; } -export interface ErrorMessage { - type: 'error'; - error: string; +export interface AuthorizationUpdateData { + update_type: 'authorization_update'; + clients: Array<[string, HotWalletAuthorizedClient]>; } -export interface PongMessage { - type: 'pong'; +export interface GraphStateUpdateData { + update_type: 'graph_state_update'; + coarse_state: string; + operator_tba_address: string | null; + operator_entry_name: string | null; + paymaster_approved?: boolean | null; } -// Types matching spider/spider/src/types.rs exactly -export interface SpiderMessage { - role: string; - content: string; - toolCallsJson?: string | null; - toolResultsJson?: string | null; - timestamp: number; +export interface BalanceUpdateData { + update_type: 'balance_update'; + wallet_id: string; + eth_balance: string | null; + usdc_balance: string | null; } -export interface ConversationMetadata { - startTime: string; - client: string; - fromStt: boolean; +// State snapshot data +export interface StateSnapshotData { + wallets: WalletSummary[]; + selected_wallet_id: string | null; + active_account: ActiveAccountDetails | null; + recent_transactions: CallRecord[]; + authorized_clients: Array<[string, HotWalletAuthorizedClient]>; + coarse_state: string; + operator_tba_address: string | null; + operator_entry_name: string | null; + gasless_enabled?: boolean | null; + paymaster_approved?: boolean | null; + client_limits_cache?: Array<[string, any]>; } -export interface McpServerDetails { +// Import types from existing files (these should match your Rust structs) +export interface WalletSummary { id: string; - name: string; - tools: McpToolInfo[]; + name?: string | null; + address: string; + is_encrypted: boolean; + is_selected: boolean; + is_unlocked: boolean; } -export interface McpToolInfo { +export interface ActiveAccountDetails { + id: string; + name?: string | null; + address: string; + is_encrypted: boolean; + is_selected: boolean; + is_unlocked: boolean; + eth_balance?: string | null; + usdc_balance?: string | null; +} + +export interface CallRecord { + timestamp_start_ms: number; + provider_lookup_key: string; + target_provider_id: string; + call_args_json: string; + response_json?: string | null; + call_success: boolean; + response_timestamp_ms: number; + payment_result?: any | null; + duration_ms: number; + operator_wallet_id?: string | null; + client_id?: string | null; + provider_name?: string | null; +} + +export interface HotWalletAuthorizedClient { + id: string; name: string; - description: string; + associated_hot_wallet_address: string; } - -export interface ChatResponse { - conversationId: string; - response: SpiderMessage; - allMessages: SpiderMessage[]; - refreshedApiKey?: string; // This is added by operator backend -} \ No newline at end of file diff --git a/operator/ui/src/utils/api-endpoints.ts b/operator/ui/src/utils/api-endpoints.ts index bb67442..ecfde4f 100644 --- a/operator/ui/src/utils/api-endpoints.ts +++ b/operator/ui/src/utils/api-endpoints.ts @@ -6,20 +6,15 @@ const getApiBasePath = () => { return packagePath ? `/${packagePath}/api` : '/api'; }; -// Operations that are actual MCP (Model Context Provider) operations -const MCP_OPERATIONS = ['SearchRegistry', 'CallProvider']; - // Determine the correct endpoint based on the operation export const getEndpointForOperation = (operation: string): string => { - const basePath = getApiBasePath(); + const pathParts = window.location.pathname.split('/').filter(p => p); + const packagePath = pathParts.find(p => p.includes(':')); + const basePath = packagePath ? `/${packagePath}` : ''; - // Check if it's an actual MCP operation - if (MCP_OPERATIONS.includes(operation)) { - return `${basePath}/mcp`; - } - // All other operations go to the /api/actions endpoint - return `${basePath}/actions`; + // All other operations go to the standard /api endpoint + return `${basePath}/api`; }; // Helper to make API calls with automatic endpoint routing @@ -48,5 +43,10 @@ export const callMcpApi = async (endpoint: string, body: any) => { }; // Export endpoints for direct use if needed -export const MCP_ENDPOINT = `${getApiBasePath()}/mcp`; -export const API_ACTIONS_ENDPOINT = `${getApiBasePath()}/actions`; \ No newline at end of file +export const API_ENDPOINT = getApiBasePath(); +export const SHIM_MCP_ENDPOINT = (() => { + const pathParts = window.location.pathname.split('/').filter(p => p); + const packagePath = pathParts.find(p => p.includes(':')); + const basePath = packagePath ? `/${packagePath}` : ''; + return `${basePath}/shim/mcp`; +})(); \ No newline at end of file diff --git a/operator/ui/src/utils/api-migration.ts b/operator/ui/src/utils/api-migration.ts new file mode 100644 index 0000000..dd067b9 --- /dev/null +++ b/operator/ui/src/utils/api-migration.ts @@ -0,0 +1,135 @@ +// API Migration Adapter Layer +// This module helps transition from the old REST API patterns to the new hyperapp RPC pattern + +// Re-export all caller-utils functions and types +export * from '../../../target/ui/caller-utils'; + +// Import specific items we need for migration helpers +import { + search_providers_public, + type ProviderInfo, + ApiError +} from '../../../target/ui/caller-utils'; + +// Import Result type from the main types file +import type { Result } from '../logic/types'; + +export interface ProcessState { + // This is a simplified version - add fields as needed + configured: boolean; + // Add other state fields from the old API as needed +} + +export interface ProviderJson { + id: string; + name: string; + description: string; + site?: string; + wallet?: string; + price?: string; + hash: string; +} + +// Migration helper functions that maintain the old API patterns +// while using the new caller-utils underneath + +/** + * Migrate from fetchState() to new status endpoints + */ +export async function fetchStateMigrated(): Promise> { + try { + // Convert SetupStatus to ProcessState format + const state: ProcessState = { + configured: true, + // Add other mappings as needed + }; + return { ok: state }; + } catch (error) { + return { error: handleError(error) }; + } +} + +///** +// * Migrate from fetchAll() to get_all_providers() +// */ +//export async function fetchAllMigrated(): Promise> { +// try { +// const providers = await get_all_providers(); +// // Convert ProviderInfo[] to ProviderJson[] +// const providerJsons: ProviderJson[] = providers.map((p: ProviderInfo) => ({ +// id: p.provider_id || p.id?.toString() || '', +// name: p.name, +// description: p.description || '', +// site: p.site || undefined, +// wallet: p.wallet || undefined, +// price: p.price || undefined, +// hash: p.hash, +// })); +// return { ok: providerJsons }; +// } catch (error) { +// return { error: handleError(error) }; +// } +//} + +/** + * Migrate from searchDB() to search_providers_public() + */ +export async function searchDBMigrated(query: string): Promise> { + try { + const providers = await search_providers_public(query); + console.log('providers', providers); + // Convert ProviderInfo[] to ProviderJson[] + const providerJsons: ProviderJson[] = providers.map((p: ProviderInfo) => ({ + id: p.provider_id || p.id?.toString() || '', + name: p.name, + description: p.description || '', + site: p.site || undefined, + wallet: p.wallet || undefined, + price: p.price || undefined, + hash: p.hash, + })); + return { ok: providerJsons }; + } catch (error) { + return { error: handleError(error) }; + } +} + +// Error handling helper +function handleError(error: unknown): string { + if (error instanceof ApiError) { + // Handle structured API errors + const details = error.details as any; + return details?.toString() || error.message; + } + if (error instanceof Error) { + return error.message; + } + return 'An unknown error occurred'; +} + +// Auth context placeholder +// TODO: Implement proper auth context for endpoints that require authentication +export interface AuthCredentials { + clientId: string; + token: string; +} + +let authCredentials: AuthCredentials | null = null; + +export function setAuthCredentials(creds: AuthCredentials) { + authCredentials = creds; +} + +export function getAuthCredentials(): AuthCredentials | null { + return authCredentials; +} + +// Feature flag for gradual migration +export const USE_NEW_API = import.meta.env.VITE_USE_NEW_API === 'true' || true; // Default to true + +// Helper to log migration progress +export function logMigration(oldEndpoint: string, newFunction?: any) { + if (import.meta.env.DEV) { + console.log(`[API Migration] ${oldEndpoint}`, newFunction || ''); + } +} diff --git a/pkg/manifest.json b/pkg/manifest.json index 6fbe2df..3759bd1 100644 --- a/pkg/manifest.json +++ b/pkg/manifest.json @@ -13,10 +13,9 @@ "timer:distro:sys", "hypermap-cacher:hypermap-cacher:sys", "sign:sign:sys", - "hns-indexer:hns-indexer:sys", - "spider:spider:sys" + "hns-indexer:hns-indexer:sys" ], - "grant_capabilities": ["http-server:distro:sys", "terminal:terminal:sys", "sqlite:distro:sys", "spider:spider:sys"], + "grant_capabilities": ["http-server:distro:sys", "terminal:terminal:sys", "sqlite:distro:sys"], "public": true }, { @@ -31,8 +30,7 @@ "vfs:distro:sys", "eth:distro:sys", "sqlite:distro:sys", - "timer:distro:sys", - "spider:spider:sys" + "timer:distro:sys" ], "grant_capabilities": [ "homepage:homepage:sys", @@ -42,10 +40,9 @@ "terminal:terminal:sys", "eth:distro:sys", "sqlite:distro:sys", - "timer:distro:sys", - "spider:spider:sys" + "timer:distro:sys" ], "public": false } -] +] \ No newline at end of file diff --git a/provider/Cargo.toml b/provider/Cargo.toml index c91d53b..fb5285f 100644 --- a/provider/Cargo.toml +++ b/provider/Cargo.toml @@ -7,6 +7,7 @@ panic = "abort" members = [ "provider", "target/caller-utils", + "caller-utils", "target/caller-util?", ] resolver = "2" diff --git a/provider/metadata.json b/provider/metadata.json index 1afc573..05928ea 100644 --- a/provider/metadata.json +++ b/provider/metadata.json @@ -5,7 +5,7 @@ "properties": { "package_name": "hypergrid", "current_version": "0.1.0", - "publisher": "ware.hypr", + "publisher": "test.hypr", "mirrors": [], "code_hashes": { "0.1.0": "" diff --git a/provider/provider/Cargo.toml b/provider/provider/Cargo.toml index f31c9ca..2bbd9e2 100644 --- a/provider/provider/Cargo.toml +++ b/provider/provider/Cargo.toml @@ -7,23 +7,18 @@ serde_json = "1.0" url = "2.5.4" urlencoding = "2.1" uuid = "1.4.1" -wit-bindgen = "0.42.1" +wit-bindgen = "0.36.0" [dependencies.caller-utils] -optional = true path = "../target/caller-utils" [dependencies.hyperprocess_macro] -branch = "develop" git = "https://github.com/hyperware-ai/hyperprocess-macro" +branch = "develop" -[dependencies.hyperware_process_lib] -features = [ - "hyperapp", - "logging", -] -git = "https://github.com/hyperware-ai/process_lib" -rev = "b9f1ead" +[dependencies.hyperware_app_common] +git = "https://github.com/hyperware-ai/hyperprocess-macro" +branch = "develop" [dependencies.serde] features = ["derive"] diff --git a/provider/provider/src/db.rs b/provider/provider/src/db.rs index e5b98fc..6614219 100644 --- a/provider/provider/src/db.rs +++ b/provider/provider/src/db.rs @@ -1,6 +1,6 @@ use anyhow::{Error, Result}; -use hyperware_process_lib::{ - logging::debug, +use hyperware_app_common::hyperware_process_lib::{ + logging::info, sqlite::{self, Sqlite}, our, }; @@ -9,27 +9,27 @@ use std::collections::HashMap; /// Open the provider database - this accesses the same database that the operator uses for indexing /// Note: This assumes the provider and operator share access to the same database through the package system -pub async fn open_provider_db() -> Result { +pub fn open_provider_db() -> Result { // Use the current package ID but access the "hypergrid" database // This should be the same database the operator uses if they're in the same package context let our_address = our(); let package_id = our_address.package_id(); - let db = sqlite::open(package_id, "hypergrid", None).await?; - Ok(db) + let db = sqlite::open(package_id, "hypergrid", None); + db } /// Load and initialize the provider database with proper schema -pub async fn load_provider_db() -> anyhow::Result { - let db = open_provider_db().await?; - let good = check_provider_schema(&db).await; +pub fn load_provider_db() -> anyhow::Result { + let db = open_provider_db()?; + let good = check_provider_schema(&db); if !good { - debug!("Provider database schema not found or incomplete - this is expected if operator hasn't indexed providers yet"); + info!("Provider database schema not found or incomplete - this is expected if operator hasn't indexed providers yet"); } Ok(db) } /// Check if the provider database has the required schema -pub async fn check_provider_schema(db: &Sqlite) -> bool { +pub fn check_provider_schema(db: &Sqlite) -> bool { let required = ["providers"]; let mut found = required .iter() @@ -37,7 +37,7 @@ pub async fn check_provider_schema(db: &Sqlite) -> bool { .collect::>(); let statement = "SELECT name from sqlite_master WHERE type='table';".to_string(); - let data = db.read(statement, vec![]).await; + let data = db.read(statement, vec![]); match data { Err(_) => false, Ok(data) => { @@ -61,14 +61,14 @@ pub async fn check_provider_schema(db: &Sqlite) -> bool { } /// Get all providers from the database (indexed by operator) -pub async fn get_all_indexed_providers(db: &Sqlite) -> Result>> { +pub fn get_all_indexed_providers(db: &Sqlite) -> Result>> { let s = "SELECT * FROM providers".to_string(); - let data = db.read(s, vec![]).await?; + let data = db.read(s, vec![])?; Ok(data) } /// Search for providers in the indexed database -pub async fn search_indexed_providers(db: &Sqlite, query: String) -> Result>> { +pub fn search_indexed_providers(db: &Sqlite, query: String) -> Result>> { let like_param = format!("%{}%", query); let exact_param = query; @@ -86,22 +86,22 @@ pub async fn search_indexed_providers(db: &Sqlite, query: String) -> Result Result>> { +pub fn get_indexed_provider_by_name(db: &Sqlite, name: &str) -> Result>> { let s = "SELECT * FROM providers WHERE name = ?1 LIMIT 1".to_string(); let p = vec![serde_json::Value::String(name.to_string())]; - let data = db.read(s, p).await?; + let data = db.read(s, p)?; Ok(data.into_iter().next()) } /// Compare local provider state with indexed state to detect inconsistencies /// Only checks if local providers are properly synchronized with the index -pub async fn compare_with_indexed_state( +pub fn compare_with_indexed_state( local_providers: &[crate::RegisteredProvider], db: &Sqlite ) -> Result { @@ -110,7 +110,7 @@ pub async fn compare_with_indexed_state( // Check each local provider against the index for local_provider in local_providers { - match get_indexed_provider_by_name(db, &local_provider.provider_name).await? { + match get_indexed_provider_by_name(db, &local_provider.provider_name)? { Some(indexed) => { // Check for mismatches between local and indexed data if let Some(Value::String(indexed_id)) = indexed.get("provider_id") { diff --git a/provider/provider/src/lib.rs b/provider/provider/src/lib.rs index a039043..703dda0 100644 --- a/provider/provider/src/lib.rs +++ b/provider/provider/src/lib.rs @@ -1,7 +1,8 @@ -use hyperprocess_macro::*; +use hyperprocess_macro::hyperprocess; +use hyperware_app_common::get_server; -use hyperware_process_lib::logging::RemoteLogSettings; -use hyperware_process_lib::{ +use hyperware_app_common::hyperware_process_lib::logging::RemoteLogSettings; +use hyperware_app_common::hyperware_process_lib::{ eth::{Provider, Address as EthAddress}, get_state, hypermap, @@ -9,9 +10,9 @@ use hyperware_process_lib::{ our, vfs::{create_drive, create_file, open_file}, Address, - hyperapp::{source, SaveOptions, sleep, get_server}, }; use crate::constants::HYPR_SUFFIX; +use hyperware_app_common::{source, SaveOptions, sleep}; use rmp_serde; use serde::{Deserialize, Serialize}; use serde_json; @@ -36,8 +37,8 @@ pub struct ProviderRequest { } #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct HealthCheckRequest { - pub provider_name: String, // Provider name for availability checking +pub struct DummyArgument { + pub argument: String, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -104,7 +105,7 @@ pub struct EndpointDefinition { pub url_template: String, pub original_headers: Vec<(String, String)>, pub original_body: Option, - + // Parameter definitions for substitution pub parameters: Vec, pub parameter_names: Vec, @@ -123,7 +124,7 @@ impl<'de> Deserialize<'de> for EndpointDefinition { New(NewEndpointDefinition), Old(OldEndpointDefinition), } - + match EndpointDefinitionVariant::deserialize(deserializer) { Ok(EndpointDefinitionVariant::New(new_endpoint)) => { Ok(EndpointDefinition { @@ -138,13 +139,13 @@ impl<'de> Deserialize<'de> for EndpointDefinition { }) }, Ok(EndpointDefinitionVariant::Old(_old_endpoint)) => { - debug!("Migrating old EndpointDefinition to new structure - creating empty endpoint definition"); + info!("Migrating old EndpointDefinition to new structure - creating empty endpoint definition"); // Create an empty endpoint definition for migration Ok(EndpointDefinition::empty()) }, Err(_) => { // If both fail, create an empty endpoint definition - debug!("Failed to deserialize EndpointDefinition as old or new format - creating empty definition"); + info!("Failed to deserialize EndpointDefinition as old or new format - creating empty definition"); Ok(EndpointDefinition::empty()) } } @@ -246,12 +247,12 @@ impl HypergridProviderState { pub fn init_vfs_drive(&mut self) -> Result<(), String> { match create_drive(our().package_id(), "providers", None) { Ok(drive_path) => { - debug!("Created VFS drive for providers at: {}", drive_path); + info!("Created VFS drive for providers at: {}", drive_path); self.vfs_drive_path = Some(drive_path); // Try to load existing providers from VFS if let Err(e) = self.load_providers_from_vfs() { - debug!("No existing providers in VFS or error loading: {}", e); + info!("No existing providers in VFS or error loading: {}", e); // Create empty providers file self.save_providers_to_vfs()?; } @@ -280,7 +281,7 @@ impl HypergridProviderState { file.write(json_data.as_bytes()).map_err(Self::to_err)?; - debug!("Saved {} providers to VFS", self.registered_providers.len()); + info!("Saved {} providers to VFS", self.registered_providers.len()); Ok(()) } @@ -302,7 +303,7 @@ impl HypergridProviderState { serde_json::from_str::>(&json_data).map_err(Self::to_err)?; self.registered_providers = providers; - debug!( + info!( "Loaded {} providers from VFS", self.registered_providers.len() ); @@ -314,7 +315,7 @@ impl HypergridProviderState { let json_data = serde_json::to_string_pretty(&self.registered_providers).map_err(Self::to_err)?; - debug!( + info!( "Exported {} providers as JSON", self.registered_providers.len() ); @@ -326,7 +327,7 @@ impl HypergridProviderState { match get_state() { Some(bytes) => match rmp_serde::from_slice::(&bytes) { Ok(state) => { - debug!("Successfully loaded HypergridProviderState from checkpoint."); + info!("Successfully loaded HypergridProviderState from checkpoint."); state } Err(e) => { @@ -335,7 +336,7 @@ impl HypergridProviderState { } }, None => { - debug!("No saved state found. Creating new state."); + info!("No saved state found. Creating new state."); Self::new() } } @@ -370,13 +371,11 @@ impl Default for HypergridProviderState { impl HypergridProviderState { #[init] async fn initialize(&mut self) { - let remote_logger: RemoteLogSettings = RemoteLogSettings { - target: Address::new("hypergrid-logger.hypr", ("logging", "logging", "nick.hypr")), - level: Level::INFO - }; + let remote_logger: RemoteLogSettings = RemoteLogSettings { target: Address::new("hypergrid-logger.hypr", ("logging", "logging", "nick.hypr") ), level: Level::ERROR }; // Initialize tracing-based logging for the provider process - init_logging(Level::DEBUG, Level::INFO, Some(remote_logger), None, Some(250 * 1024 * 1024)).expect("Failed to initialize logging"); // 250MB log files - debug!("Initializing Hypergrid on node {}", our().node.to_string()); + init_logging(Level::DEBUG, Level::INFO, Some(remote_logger), None, None).expect("Failed to initialize logging"); + info!("Initializing Hypergrid Provider"); + *self = HypergridProviderState::load(); let server = get_server().expect("HTTP server should be initialized"); @@ -391,42 +390,9 @@ impl HypergridProviderState { #[local] #[remote] - async fn health_ping(&self, request: HealthCheckRequest) -> Result { - info!("Health ping received: {:?}", request); - - info!("Checking availability for provider: {}", request.provider_name); - - // Check if provider exists in registry - let provider_exists = self - .registered_providers - .iter() - .find(|p| p.provider_name == request.provider_name); - - match provider_exists { - Some(provider) => { - // Check if provider has a valid endpoint configuration - if provider.endpoint.is_empty() { - let error_msg = format!( - "Provider '{}' exists but needs endpoint configuration", - request.provider_name - ); - warn!("{}", error_msg); - return Err(error_msg); - } - - debug!( - "Provider '{}' is available and configured (price: {} USDC)", - request.provider_name, - provider.price - ); - Ok("Ack".to_string()) - } - None => { - let error_msg = format!("Provider '{}' not found in registry", request.provider_name); - warn!("{}", error_msg); - Err(error_msg) - } - } + async fn health_ping(&self, arg: DummyArgument) -> Result { + info!("Health ping received: {:?}", arg); + Ok("Ack".to_string()) } #[http] @@ -434,12 +400,7 @@ impl HypergridProviderState { &mut self, provider: RegisteredProvider, ) -> Result { - // Usage tracking log - registration started - debug!( - "provider_registration_started: provider={}, price={}", - provider.provider_name, - provider.price - ); + info!("Registering provider: {:?}", provider); // need to check if provider already exists in db + our registry, add that later if self @@ -457,12 +418,9 @@ impl HypergridProviderState { // Provider ID is set by frontend to match node identity self.registered_providers.push(provider.clone()); - - // Success tracking log - debug!( - "provider_registration_success: provider={}, total_providers={}", - provider.provider_name, - self.registered_providers.len() + info!( + "Successfully registered provider: {}", + provider.provider_name ); // Save to VFS @@ -474,7 +432,7 @@ impl HypergridProviderState { match rmp_serde::to_vec(self) { Ok(bytes) => { hyperware_process_lib::set_state(&bytes); - debug!("Manually called set_state with {} bytes.", bytes.len()); + info!("Manually called set_state with {} bytes.", bytes.len()); } Err(e) => { error!("Manual save: Failed to serialize HpnProviderState: {}", e); @@ -490,12 +448,8 @@ impl HypergridProviderState { provider: RegisteredProvider, arguments: Vec<(String, String)>, ) -> Result { - // Usage tracking log - validation started - debug!( - "provider_validation_started: provider={}, arg_count={}", - provider.provider_name, - arguments.len() - ); + info!("Validating provider: {:?}", provider); + // Check if already registered if self .registered_providers @@ -506,10 +460,10 @@ impl HypergridProviderState { "Provider with name '{}' already registered.", provider.provider_name ); - debug!("{}", error_msg); + warn!("{}", error_msg); return Err(error_msg); } - + // Use the new curl-based validation let validation_result = call_provider( provider.provider_name.clone(), @@ -518,24 +472,19 @@ impl HypergridProviderState { our().node.to_string(), ) .await?; - debug!("Validation result: {}", validation_result); + + info!("Validation result: {}", validation_result); validate_response_status(&validation_result) .map_err(|e| format!("Validation failed: {}", e))?; - let validation_start = std::time::Instant::now(); - // Success tracking log - debug!( - "provider_validation_success: provider={}, duration_ms={}, response_size_bytes={}", - provider.provider_name, - validation_start.elapsed().as_millis(), - validation_result.len() - ); + info!("Provider validation successful: {}", provider.provider_name); + // Return the validated provider object as JSON for frontend consistency let response = serde_json::json!({ "validation_result": validation_result, "provider": provider }); - + serde_json::to_string(&response) .map_err(|e| format!("Failed to serialize validation response: {}", e)) } @@ -549,7 +498,8 @@ impl HypergridProviderState { updated_provider: RegisteredProvider, arguments: Vec<(String, String)>, ) -> Result { - debug!("Validating provider update: {}", provider_name); + info!("Validating provider update: {}", provider_name); + // Check if the original provider exists if !self .registered_providers @@ -563,7 +513,7 @@ impl HypergridProviderState { warn!("{}", error_msg); return Err(error_msg); } - + // If the name is changing, check if new name already exists if provider_name != updated_provider.provider_name { if self @@ -579,7 +529,7 @@ impl HypergridProviderState { return Err(error_msg); } } - + // Use the new curl-based validation let validation_result = call_provider( updated_provider.provider_name.clone(), @@ -588,17 +538,19 @@ impl HypergridProviderState { our().node.to_string(), ) .await?; - debug!("Validation result: {}", validation_result); + + info!("Validation result: {}", validation_result); validate_response_status(&validation_result) .map_err(|e| format!("Validation failed: {}", e))?; - debug!("Provider update validation successful: {}", updated_provider.provider_name); + info!("Provider update validation successful: {}", updated_provider.provider_name); + // Return the validated provider object as JSON for frontend consistency let response = serde_json::json!({ "validation_result": validation_result, "provider": updated_provider }); - + serde_json::to_string(&response) .map_err(|e| format!("Failed to serialize validation response: {}", e)) } @@ -609,7 +561,7 @@ impl HypergridProviderState { provider_name: String, updated_provider: RegisteredProvider, ) -> Result { - debug!("Provider update request received: {}", provider_name); + info!("Provider update request received: {}", provider_name); // Find the provider to update let provider_index = self @@ -633,7 +585,7 @@ impl HypergridProviderState { "A provider with name '{}' already exists. Please choose a different name.", updated_provider.provider_name ); - debug!("{}", error_msg); + warn!("{}", error_msg); return Err(error_msg); } } @@ -647,7 +599,7 @@ impl HypergridProviderState { // Update the provider self.registered_providers[index] = updated_provider_with_id.clone(); - debug!( + info!( "Successfully updated provider: {} -> {}", provider_name, updated_provider_with_id.provider_name ); @@ -661,7 +613,7 @@ impl HypergridProviderState { match rmp_serde::to_vec(self) { Ok(bytes) => { hyperware_process_lib::set_state(&bytes); - debug!( + info!( "Manually called set_state with {} bytes after update.", bytes.len() ); @@ -686,19 +638,9 @@ impl HypergridProviderState { let mcp_request = match request { ProviderRequest { .. } => request, }; - - // Get the source node ID for tracking - let source_address = source(); - let source_node_id = source_address.node().to_string(); - - // Usage tracking log - no sensitive data info!( - "provider_call_started: provider={}, provider_node={}, source_node={}, tx_hash={}, arg_count={}", - mcp_request.provider_name, - our().node, - source_node_id, - mcp_request.payment_tx_hash.as_deref().unwrap_or("none"), - mcp_request.arguments.len() + "Received remote call for provider: {}", + mcp_request.provider_name ); // --- 0. Check if provider exists at all --- @@ -712,13 +654,7 @@ impl HypergridProviderState { "Provider '{}' not found - please make sure to enter a valid, registered provider name", mcp_request.provider_name ); - // Error tracking log - safe data only - error!( - "provider_call_failed: provider={}, source_node={}, error_type=provider_not_found, message={}", - mcp_request.provider_name, - source_node_id, - "Provider not found in registry" - ); + warn!("{}", error_msg); return Err(error_msg); } @@ -731,12 +667,9 @@ impl HypergridProviderState { if let Err(validation_err) = validate_transaction_payment(&mcp_request, self, source_node_id.clone()).await { - // Error tracking log - payment validation failed error!( - "provider_call_failed: provider={}, source_node={}, error_type=payment_validation_failed, validation_error={}", - mcp_request.provider_name, - source_node_id, - validation_err + "Payment validation failed for provider '{}' from node '{}': {}", + mcp_request.provider_name, source_node_id, validation_err ); return Err(validation_err); } @@ -754,10 +687,10 @@ impl HypergridProviderState { // --- 2. Call the provider with retry mechanism --- const MAX_RETRIES: usize = 3; let mut last_error = String::new(); - let call_start_time = std::time::Instant::now(); + for attempt in 1..=MAX_RETRIES { debug!("Attempting provider call {} of {}", attempt, MAX_RETRIES); - + let api_call_result = call_provider( // This is the HTTP call_provider registered_provider.provider_name.clone(), @@ -769,34 +702,15 @@ impl HypergridProviderState { match api_call_result { Ok(response) => { - let call_duration = call_start_time.elapsed(); - - // Success tracking log - no sensitive data - info!( - "provider_call_success: provider={}, provider_node={}, source_node={}, tx_hash={}, price_usdc={}, attempt={}, duration_ms={}, response_size_bytes={}", - registered_provider.provider_name, - our().node, - source_node_id, - mcp_request.payment_tx_hash.as_deref().unwrap_or("none"), - registered_provider.price, - attempt, - call_duration.as_millis(), - response.len() - ); - if attempt > 1 { - debug!("Provider call succeeded on attempt {} of {} after {:?}", attempt, MAX_RETRIES, call_duration); + info!("Provider call succeeded on attempt {} of {}", attempt, MAX_RETRIES); } return Ok(response); }, Err(e) => { last_error = e.clone(); - error!( - "provider_call_attempt_failed: provider={}, source_node={}, attempt={}, error_type=api_call_failed", - registered_provider.provider_name, - source_node_id, - attempt - ); + warn!("Provider call failed on attempt {} of {}: {}", attempt, MAX_RETRIES, e); + // Don't sleep after the last attempt if attempt < MAX_RETRIES { // Add a small delay between retries to handle rate limiting and temporary issues @@ -805,16 +719,9 @@ impl HypergridProviderState { } } } - + // If we get here, all retries failed - let total_duration = call_start_time.elapsed(); - error!( - "provider_call_failed: provider={}, source_node={}, error_type=all_retries_failed, attempts={}, total_duration_ms={}", - registered_provider.provider_name, - source_node_id, - MAX_RETRIES, - total_duration.as_millis() - ); + error!("All {} provider call attempts failed. Last error: {}", MAX_RETRIES, last_error); Err(last_error) } @@ -833,20 +740,21 @@ impl HypergridProviderState { .filter(|provider| provider.endpoint.is_empty()) .cloned() .collect(); - debug!("Found {} providers needing endpoint configuration", providers_needing_config.len()); + + info!("Found {} providers needing endpoint configuration", providers_needing_config.len()); Ok(providers_needing_config) } #[http] async fn export_providers(&self) -> Result { - debug!("Exporting providers as JSON"); + info!("Exporting providers as JSON"); self.export_providers_json() } #[http] async fn get_provider_namehash(&self, provider_name: String) -> Result { debug!("Getting namehash for provider: {}", provider_name); - + // Verify provider exists in our registry let provider = self .registered_providers @@ -859,7 +767,7 @@ impl HypergridProviderState { let namespace = &HYPR_SUFFIX[1..]; // Remove the leading dot from ".grid.hypr" to get "grid.hypr" let full_name = format!("{}.{}", provider.provider_name, namespace); let namehash = hypermap::namehash(&full_name); - + debug!("Calculated namehash for '{}': {}", full_name, namehash); Ok(namehash) } @@ -868,22 +776,22 @@ impl HypergridProviderState { #[http] async fn get_indexed_providers(&self) -> Result { debug!("Fetching indexed providers"); - - let db = load_provider_db().await.map_err(|e| { + + let db = load_provider_db().map_err(|e| { format!("Failed to load provider database: {}", e) })?; - - let providers = get_all_indexed_providers(&db).await.map_err(|e| { + + let providers = get_all_indexed_providers(&db).map_err(|e| { format!("Failed to fetch indexed providers: {}", e) })?; - + let json_providers: Vec = providers .into_iter() .map(|provider| serde_json::to_value(provider).unwrap_or_default()) .collect(); - + debug!("Retrieved {} indexed providers", json_providers.len()); - + serde_json::to_string(&json_providers).map_err(|e| { format!("Failed to serialize providers to JSON: {}", e) }) @@ -893,22 +801,22 @@ impl HypergridProviderState { #[http] async fn search_indexed_providers(&self, query: String) -> Result { debug!("Searching indexed providers with query: {}", query); - - let db = load_provider_db().await.map_err(|e| { + + let db = load_provider_db().map_err(|e| { format!("Failed to load provider database: {}", e) })?; - - let providers = search_indexed_providers(&db, query.clone()).await.map_err(|e| { + + let providers = search_indexed_providers(&db, query.clone()).map_err(|e| { format!("Failed to search indexed providers: {}", e) })?; - + let json_providers: Vec = providers .into_iter() .map(|provider| serde_json::to_value(provider).unwrap_or_default()) .collect(); - + debug!("Found {} providers matching query '{}'", json_providers.len(), query); - + serde_json::to_string(&json_providers).map_err(|e| { format!("Failed to serialize providers to JSON: {}", e) }) @@ -918,22 +826,22 @@ impl HypergridProviderState { #[http] async fn get_indexed_provider_details(&self, name: String) -> Result { debug!("Getting indexed provider details for name: {}", name); - - let db = load_provider_db().await.map_err(|e| { + + let db = load_provider_db().map_err(|e| { format!("Failed to load provider database: {}", e) })?; - - let provider = get_indexed_provider_by_name(&db, &name).await.map_err(|e| { + + let provider = get_indexed_provider_by_name(&db, &name).map_err(|e| { format!("Failed to get provider details: {}", e) })?; - + let result = provider.map(|p| serde_json::to_value(p).unwrap_or_default()); - + match &result { - Some(_) => debug!("Found indexed provider details for '{}'", name), - None => debug!("No indexed provider found for '{}'", name), + Some(_) => info!("Found indexed provider details for '{}'", name), + None => info!("No indexed provider found for '{}'", name), } - + serde_json::to_string(&result).map_err(|e| { format!("Failed to serialize provider details to JSON: {}", e) }) @@ -943,15 +851,15 @@ impl HypergridProviderState { #[http] async fn get_provider_sync_status(&self) -> Result { debug!("Checking provider sync status"); - - let db = load_provider_db().await.map_err(|e| { + + let db = load_provider_db().map_err(|e| { format!("Failed to load provider database: {}", e) })?; - - let comparison = compare_with_indexed_state(&self.registered_providers, &db).await.map_err(|e| { + + let comparison = compare_with_indexed_state(&self.registered_providers, &db).map_err(|e| { format!("Failed to compare provider states: {}", e) })?; - + let status = serde_json::json!({ "is_synchronized": comparison.is_synchronized(), "summary": comparison.summary(), @@ -960,7 +868,7 @@ impl HypergridProviderState { "mismatched": comparison.mismatched, "has_issues": !comparison.is_synchronized() }); - + serde_json::to_string(&status).map_err(|e| { format!("Failed to serialize sync status to JSON: {}", e) }) @@ -1091,11 +999,11 @@ impl HypergridProviderState { TerminalCommand::ViewDatabase => { debug!("Viewing database"); - let db = load_provider_db().await.map_err(|e| { + let db = load_provider_db().map_err(|e| { format!("Failed to load provider database: {}", e) })?; - let providers = get_all_indexed_providers(&db).await.map_err(|e| { + let providers = get_all_indexed_providers(&db).map_err(|e| { format!("Failed to fetch indexed providers: {}", e) })?; @@ -1128,8 +1036,8 @@ impl EndpointDefinition { /// Check if this endpoint definition is empty (needs configuration) pub fn is_empty(&self) -> bool { - self.original_curl.is_empty() && - self.base_url.is_empty() && + self.original_curl.is_empty() && + self.base_url.is_empty() && self.url_template.is_empty() } diff --git a/provider/provider/src/util.rs b/provider/provider/src/util.rs index c63c8ae..3c395ee 100644 --- a/provider/provider/src/util.rs +++ b/provider/provider/src/util.rs @@ -1,9 +1,8 @@ use crate::{EndpointDefinition, ProviderRequest}; use crate::constants::{USDC_BASE_ADDRESS, WALLET_PREFIX}; -use hyperware_process_lib::{ +use hyperware_app_common::hyperware_process_lib::{ eth::{Address as EthAddress, EthError, TransactionReceipt, TxHash, U256}, get_blob, - hyperapp::{send, sleep}, http::{ client::{HttpClientAction, HttpClientError, HttpClientResponse, OutgoingHttpRequest}, HeaderName, HeaderValue, Method as HyperwareHttpMethod, Response as HyperwareHttpResponse, @@ -11,8 +10,8 @@ use hyperware_process_lib::{ }, hypermap, Request, logging::{debug, error, warn, info}, - our, }; +use hyperware_app_common::{send, sleep}; use serde_json; use std::collections::HashMap; use std::str::FromStr; @@ -33,7 +32,7 @@ pub async fn send_async_http_request( let body_size = body.len(); let method_str = method.to_string(); let url_str = url.to_string(); - + let req = Request::to(("our", "http-client", "distro", "sys")) .expects_response(timeout) .body( @@ -118,7 +117,7 @@ pub async fn get_logs_for_tx( // 3. Retry mechanism for get_transaction_receipt const MAX_RETRIES: u32 = 3; const INITIAL_DELAY_MS: u64 = 500; // Start with 500ms - + for attempt in 1..=MAX_RETRIES { match provider.get_transaction_receipt(tx_hash) { Ok(Some(receipt)) => { @@ -162,7 +161,7 @@ pub async fn get_logs_for_tx( } } } - + // This should never be reached, but just in case Err(EthError::RpcTimeout) } @@ -388,18 +387,18 @@ pub async fn call_provider( error!("Failed to parse response body as UTF-8: {}", e); format!("Failed to parse response body as UTF-8: {}", e) })?; - + // Try to parse the body as JSON to avoid double-encoding let body_json = match serde_json::from_str::(&body_result) { Ok(json_value) => json_value, Err(_) => serde_json::Value::String(body_result), // If not JSON, wrap as string }; - + let response_wrapper = serde_json::json!({ "status": status, "body": body_json }); - + Ok(response_wrapper.to_string()) } Err(e) => { @@ -420,7 +419,7 @@ pub async fn call_provider( dynamic_args: &Vec<(String, String)>, source: String, ) -> Result { - debug!( + info!( "Calling provider via curl template: {}, method: {}", provider_id_for_log, endpoint_def.method @@ -430,10 +429,10 @@ pub async fn call_provider( // Start with original headers from the curl template let mut http_headers = endpoint_def.get_original_headers_map(); - + // Construct URL from template let mut final_url = endpoint_def.url_template.clone(); - + // Parse original curl to extract original query parameters let mut original_query_params: HashMap = HashMap::new(); // Extract URL from curl command (handle quoted and unquoted URLs) @@ -448,12 +447,12 @@ pub async fn call_provider( } } } - + debug!( "Original query params extracted from curl: {:?}", original_query_params ); - + // Start with original query parameters, then override with dynamic ones let mut query_params: Vec<(String, String)> = original_query_params.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); let mut body_json = endpoint_def.get_original_body_json(); @@ -474,7 +473,7 @@ pub async fn call_provider( let original_param_name = param_def.json_pointer .strip_prefix("/queryParams/") .ok_or_else(|| format!("Invalid query JSON pointer: {}", param_def.json_pointer))?; - + // Override existing query parameter or add new one // Remove any existing parameter with the original name first query_params.retain(|(k, _)| k != original_param_name); @@ -495,7 +494,7 @@ pub async fn call_provider( let body_relative_pointer = param_def.json_pointer .strip_prefix("/body") .unwrap_or(¶m_def.json_pointer); - + // If the pointer is just "/body", we replace the entire body if body_relative_pointer.is_empty() { // Parse the new value as JSON if possible, otherwise treat as string @@ -526,7 +525,7 @@ pub async fn call_provider( .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) .collect::>() .join("&"); - + final_url = if final_url.contains('?') { format!("{}&{}", final_url, query_string) } else { @@ -566,15 +565,7 @@ pub async fn call_provider( // Make the HTTP request let timeout: u64 = 30; let start_time = std::time::Instant::now(); - // Log HTTP request details (no sensitive data) - debug!( - "http_request_started: provider={}, method={}, url_domain={}, timeout_s={}, body_size_bytes={}", - provider_id_for_log, - endpoint_def.method, - url.host_str().unwrap_or("unknown"), - timeout, - body_bytes.len() - ); + match send_async_http_request(http_method, url, Some(http_headers), timeout, body_bytes).await { Ok(response) => { let elapsed = start_time.elapsed(); @@ -583,25 +574,16 @@ pub async fn call_provider( let body_string = String::from_utf8(body_bytes.clone()) .unwrap_or_else(|_| format!("[Binary data, {} bytes]", body_bytes.len())); - // Log HTTP response details (no sensitive response data) - debug!( - "http_response_received: provider={}, status={}, duration_ms={}, response_size_bytes={}", - provider_id_for_log, + info!( + "Provider response: status={}, elapsed={:?}, body_length={}", status, - elapsed.as_millis(), + elapsed, body_string.len() ); if status.is_success() { Ok(body_string) } else { - // Error tracking log - HTTP error status - error!( - "http_request_failed: provider={}, status={}, duration_ms={}, error_type=http_error_status", - provider_id_for_log, - status, - elapsed.as_millis() - ); Err(format!( "Provider returned error status {}: {}", status, body_string @@ -610,11 +592,9 @@ pub async fn call_provider( } Err(e) => { let elapsed = start_time.elapsed(); - // Error tracking log - network/timeout error error!( - "http_request_failed: provider={}, duration_ms={}, error_type=network_timeout", - provider_id_for_log, - elapsed.as_millis() + "HTTP request failed: provider={}, elapsed={:?}, error={:?}", + provider_id_for_log, elapsed, e ); Err(format!("Failed to call provider: {:?}", e)) } @@ -629,7 +609,7 @@ fn update_json_value_by_pointer( ) -> Result<(), String> { // Handle JSON pointers like "/body/field_name" or "/body/messages/0/content" let parts: Vec<&str> = pointer.split('/').filter(|s| !s.is_empty()).collect(); - + if parts.is_empty() { return Err("Invalid JSON pointer".to_string()); } @@ -638,12 +618,12 @@ fn update_json_value_by_pointer( let mut current = json; for i in 0..parts.len() - 1 { let part = parts[i]; - + // Check if this part is an array index (all digits) if part.chars().all(|c| c.is_ascii_digit()) { let index: usize = part.parse() .map_err(|_| format!("Invalid array index: {}", part))?; - + match current { serde_json::Value::Array(arr) => { current = arr.get_mut(index) @@ -665,15 +645,15 @@ fn update_json_value_by_pointer( // Update the final field let final_part = parts.last().unwrap(); - + // Parse the new value as JSON if possible, otherwise treat as string let parsed_value = parse_parameter_value(new_value); - + // Check if the final part is an array index if final_part.chars().all(|c| c.is_ascii_digit()) { let index: usize = final_part.parse() .map_err(|_| format!("Invalid array index: {}", final_part))?; - + match current { serde_json::Value::Array(arr) => { if index >= arr.len() { @@ -702,7 +682,7 @@ fn parse_parameter_value(value: &str) -> serde_json::Value { if let Ok(parsed) = serde_json::from_str::(value) { // Successfully parsed as JSON, return the parsed value // This handles cases like: - // - "hello world" → JSON string "hello world" + // - "hello world" → JSON string "hello world" // - 42 → JSON number 42 // - true → JSON boolean true // - [1,2,3] → JSON array [1,2,3] @@ -722,13 +702,6 @@ pub async fn validate_transaction_payment( state: &mut super::HypergridProviderState, // Now mutable source_node_id: String, // Pass source node string directly ) -> Result<(), String> { - // Usage tracking log - payment validation started - debug!( - "payment_validation_started: provider={}, source_node={}, has_tx_hash={}", - mcp_request.provider_name, - source_node_id, - mcp_request.payment_tx_hash.is_some() - ); // --- 0. Check if provider exists at all --- if !state .registered_providers @@ -828,7 +801,6 @@ pub async fn validate_transaction_payment( let mut payment_validated = false; let mut claimed_sender_address_from_log: Option = None; let mut usdc_transfer_count = 0; - let mut actual_transferred_amount = U256::from(0); // Access logs via transaction_receipt.inner (which is ReceiptEnvelope) for log in transaction_receipt.inner.logs() { @@ -936,7 +908,6 @@ pub async fn validate_transaction_payment( // Now store the sender for Hypermap check and mark payment as potentially valid. claimed_sender_address_from_log = Some(tx_sender_address); payment_validated = true; // Provisional validation, Hypermap check still pending - actual_transferred_amount = transferred_amount; // Store actual amount for logging break; // Found the second valid transfer, no need to check other logs } else { debug!( @@ -982,7 +953,7 @@ pub async fn validate_transaction_payment( let expected_namehash_for_requester = hypermap::namehash(&full_name_for_tba_lookup); if namehash_from_claimed_sender_tba != expected_namehash_for_requester { - return Err(format!("Namehash mismatch for TBA: sender identified from log as {} (namehash: {}), but request came from {} (expected namehash: {}). Sender identity could not be verified.", + return Err(format!("Namehash mismatch for TBA: sender identified from log as {} (namehash: {}), but request came from {} (expected namehash: {}). Sender identity could not be verified.", final_claimed_sender_address, namehash_from_claimed_sender_tba, source_node_id, expected_namehash_for_requester)); } @@ -997,31 +968,18 @@ pub async fn validate_transaction_payment( // This must be the last step after all validations pass. state.spent_tx_hashes.push(tx_hash_str_ref.to_string()); - // Get provider price for revenue tracking - let provider_price = state - .registered_providers - .iter() - .find(|p| p.provider_name == mcp_request.provider_name) - .map(|p| p.price) - .unwrap_or(0.0); - - // Success tracking log - payment validation successful - info!( - "payment_validation_success: provider={}, provider_node={}, source_node={}, tx_hash={}, price_usdc={}, transferred_usdc={}", - mcp_request.provider_name, - our().node, - source_node_id, - tx_hash_str_ref, // Full transaction hash for complete audit trail - provider_price, - actual_transferred_amount + + debug!( + "Successfully validated payment and marked tx {} as spent.", + tx_hash_str_ref ); Ok(()) } -pub fn default_provider() -> hyperware_process_lib::eth::Provider { +pub fn default_provider() -> hyperware_app_common::hyperware_process_lib::eth::Provider { let hypermap_timeout = 60; - hyperware_process_lib::eth::Provider::new( + hyperware_app_common::hyperware_process_lib::eth::Provider::new( hypermap::HYPERMAP_CHAIN_ID, hypermap_timeout, ) @@ -1029,7 +987,7 @@ pub fn default_provider() -> hyperware_process_lib::eth::Provider { pub fn default_hypermap() -> hypermap::Hypermap { let hypermap_timeout = 60; - let provider = hyperware_process_lib::eth::Provider::new( + let provider = hyperware_app_common::hyperware_process_lib::eth::Provider::new( hypermap::HYPERMAP_CHAIN_ID, hypermap_timeout, ); @@ -1040,13 +998,13 @@ pub fn default_hypermap() -> hypermap::Hypermap { pub fn validate_response_status(response: &str) -> Result<(), String> { // At this point, if we have a response, it means the HTTP call was successful - // (status.is_success() was true in call_provider), so we just need to check + // (status.is_success() was true in call_provider), so we just need to check // that we have a valid response body - + if response.trim().is_empty() { return Err("Empty response body".to_string()); } - + // Try to parse as JSON to ensure it's a valid response // If it's not JSON, that's also fine for some APIs match serde_json::from_str::(response) { @@ -1064,4 +1022,4 @@ pub fn validate_response_status(response: &str) -> Result<(), String> { } } } -} +} \ No newline at end of file diff --git a/provider/test/hypergrid-provider-test/api/hpn-provider-test-template-dot-os-v0.wit b/provider/test/hypergrid-provider-test/api/hpn-provider-test-template-dot-os-v0.wit deleted file mode 100644 index 08fb464..0000000 --- a/provider/test/hypergrid-provider-test/api/hpn-provider-test-template-dot-os-v0.wit +++ /dev/null @@ -1,5 +0,0 @@ -world hpn-provider-test-template-dot-os-v0 { - import hpn-provider; - import tester; - include process-v1; -} \ No newline at end of file diff --git a/provider/test/hypergrid-provider-test/api/hypergrid-provider.wit b/provider/test/hypergrid-provider-test/api/hypergrid-provider.wit new file mode 100644 index 0000000..468f3c8 --- /dev/null +++ b/provider/test/hypergrid-provider-test/api/hypergrid-provider.wit @@ -0,0 +1,177 @@ +interface hypergrid-provider { + // This interface contains function signature definitions that will be used + // by the hyper-bindgen macro to generate async function bindings. + // + // NOTE: This is currently a hacky workaround since WIT async functions are not + // available until WASI Preview 3. Once Preview 3 is integrated into Hyperware, + // we should switch to using proper async WIT function signatures instead of + // this struct-based approach with hyper-bindgen generating the async stubs. + + use standard.{address}; + + variant terminal-command { + list-providers, + register-provider(registered-provider), + unregister-provider(string), + test-provider(provider-request), + export-providers, + view-database + } + + record provider-request { + provider-name: string, + arguments: list>, + payment-tx-hash: option + } + + record registered-provider { + provider-name: string, + provider-id: string, + description: string, + instructions: string, + registered-provider-wallet: string, + price: f64, + endpoint: endpoint-definition + } + + record endpoint-definition { + original-curl: string, + method: string, + base-url: string, + url-template: string, + original-headers: list>, + original-body: option, + parameters: list, + parameter-names: list + } + + record parameter-definition { + parameter-name: string, + json-pointer: string, + location: string, + example-value: string, + value-type: string + } + + record dummy-argument { + argument: string + } + + // Function signature for: health-ping (remote) + record health-ping-signature-remote { + target: address, + arg: dummy-argument, + returning: result + } + + // Function signature for: health-ping (local) + record health-ping-signature-local { + target: address, + arg: dummy-argument, + returning: result + } + + // Function signature for: register-provider (http) + record register-provider-signature-http { + target: string, + provider: registered-provider, + returning: result + } + + // Function signature for: validate-provider (http) + record validate-provider-signature-http { + target: string, + provider: registered-provider, + arguments: list>, + returning: result + } + + // Function signature for: validate-provider-update (http) + record validate-provider-update-signature-http { + target: string, + provider-name: string, + updated-provider: registered-provider, + arguments: list>, + returning: result + } + + // Function signature for: update-provider (http) + record update-provider-signature-http { + target: string, + provider-name: string, + updated-provider: registered-provider, + returning: result + } + + // Function signature for: call-provider (remote) + record call-provider-signature-remote { + target: address, + request: provider-request, + returning: result + } + + // Function signature for: call-provider (local) + record call-provider-signature-local { + target: address, + request: provider-request, + returning: result + } + + // Function signature for: get-registered-providers (http) + record get-registered-providers-signature-http { + target: string, + returning: result + } + + // Function signature for: get-providers-needing-configuration (http) + record get-providers-needing-configuration-signature-http { + target: string, + returning: result + } + + // Function signature for: export-providers (http) + record export-providers-signature-http { + target: string, + returning: result + } + + // Function signature for: get-provider-namehash (http) + record get-provider-namehash-signature-http { + target: string, + provider-name: string, + returning: result + } + + // Function signature for: get-indexed-providers (http) + record get-indexed-providers-signature-http { + target: string, + returning: result + } + + // Function signature for: search-indexed-providers (http) + record search-indexed-providers-signature-http { + target: string, + query: string, + returning: result + } + + // Function signature for: get-indexed-provider-details (http) + record get-indexed-provider-details-signature-http { + target: string, + name: string, + returning: result + } + + // Function signature for: get-provider-sync-status (http) + record get-provider-sync-status-signature-http { + target: string, + returning: result + } + + // Function signature for: terminal-command (local) + record terminal-command-signature-local { + target: address, + command: terminal-command, + returning: result + } +} diff --git a/provider/test/hypergrid-provider-test/api/test-provider-template-dot-os-v0.wit b/provider/test/hypergrid-provider-test/api/test-provider-template-dot-os-v0.wit new file mode 100644 index 0000000..2742731 --- /dev/null +++ b/provider/test/hypergrid-provider-test/api/test-provider-template-dot-os-v0.wit @@ -0,0 +1,5 @@ +world test-provider-template-dot-os-v0 { + import tester; + import hypergrid-provider; + include process-v1; +} \ No newline at end of file diff --git a/provider/test/hypergrid-provider-test/api/types-test-provider-template-dot-os-v0.wit b/provider/test/hypergrid-provider-test/api/types-test-provider-template-dot-os-v0.wit new file mode 100644 index 0000000..b115baf --- /dev/null +++ b/provider/test/hypergrid-provider-test/api/types-test-provider-template-dot-os-v0.wit @@ -0,0 +1,4 @@ +world types-test-provider-template-dot-os-v0 { + import hypergrid-provider; + include lib; +} \ No newline at end of file diff --git a/provider/ui/src/components/HypergridEntryForm.tsx b/provider/ui/src/components/HypergridEntryForm.tsx index 232c266..019d24c 100644 --- a/provider/ui/src/components/HypergridEntryForm.tsx +++ b/provider/ui/src/components/HypergridEntryForm.tsx @@ -45,7 +45,7 @@ const HypergridEntryForm: React.FC = ({ placeholder="provider-name" className="bg-transparent border-none outline-none text-green-400 placeholder-gray-600 w-full font-mono" /> - .grid.hypr + .obfusc-grid123.hypr
diff --git a/provider/ui/src/components/RegisteredProviderView.tsx b/provider/ui/src/components/RegisteredProviderView.tsx index 8796e6b..a8959c2 100644 --- a/provider/ui/src/components/RegisteredProviderView.tsx +++ b/provider/ui/src/components/RegisteredProviderView.tsx @@ -55,7 +55,7 @@ const RegisteredProviderView: React.FC = ({ provide 🔌

- {provider.provider_name}.grid.hypr + {provider.provider_name}.obfusc-grid123.hypr

{provider.description && (

{provider.description}

diff --git a/provider/ui/src/components/UnifiedTerminalInterface.tsx b/provider/ui/src/components/UnifiedTerminalInterface.tsx index 99d52d1..c11c186 100644 --- a/provider/ui/src/components/UnifiedTerminalInterface.tsx +++ b/provider/ui/src/components/UnifiedTerminalInterface.tsx @@ -62,10 +62,6 @@ const UnifiedTerminalInterface: React.FC = ({ offchain
-
- Paste a sample curl command for your target API. You can then define dynamic parameters that agents can customize at runtime. -
-
= ({ className="bg-transparent border-none outline-none text-yellow-600 dark:text-yellow-400 placeholder-stone-500 dark:placeholder-gray-600 font-mono text-lg font-medium" /> )} - .grid.hypr + .obfusc-grid123.hypr provider namespace
From 6716b002bc3d13a57d0a1929a34a6c69bfd04740 Mon Sep 17 00:00:00 2001 From: Hallmane Date: Thu, 25 Sep 2025 15:16:51 +0200 Subject: [PATCH 2/8] trying to connect w spider --- hypergrid-shim/CHANGELOG.md | 51 -- hypergrid-shim/LOCAL_DEVELOPMENT.md | 112 ---- hypergrid-shim/README.md | 2 - metadata.json | 2 +- operator/api/operator-process.wit | 199 ++++-- operator/metadata.json | 2 +- operator/operator/src/lib.rs | 324 +++++++++- operator/operator/src/spider.rs | 4 + operator/operator/src/structs.rs | 94 +++ operator/pkg/manifest.json | 7 +- operator/ui/package.json | 1 + operator/ui/src/App.tsx | 47 +- .../ui/src/components/ShimApiConfigModal.tsx | 1 - operator/ui/src/components/SpiderChat.tsx | 568 ++++++++++++++++++ operator/ui/src/services/websocket.ts | 141 ++++- operator/ui/src/types/websocket.ts | 99 ++- provider/metadata.json | 2 +- 17 files changed, 1417 insertions(+), 239 deletions(-) delete mode 100644 hypergrid-shim/CHANGELOG.md delete mode 100644 hypergrid-shim/LOCAL_DEVELOPMENT.md create mode 100644 operator/operator/src/spider.rs create mode 100644 operator/ui/src/components/SpiderChat.tsx diff --git a/hypergrid-shim/CHANGELOG.md b/hypergrid-shim/CHANGELOG.md deleted file mode 100644 index 5405313..0000000 --- a/hypergrid-shim/CHANGELOG.md +++ /dev/null @@ -1,51 +0,0 @@ -# Changelog - -## [1.3.8] - 2025-01-10 - -### Fixed -- Function-specific endpoints now receive parameters directly, not wrapped in RPC format -- This aligns with hyperapp framework expectations for dedicated endpoints - -## [1.3.7] - 2025-01-10 - -### Fixed -- Authorization test now correctly uses search_registry endpoint -- The authorize tool configures existing credentials, doesn't generate new ones - -## [1.3.6] - 2025-01-10 - -### Fixed -- Restored RPC wrapper format for function-specific endpoints -- All requests now properly wrap parameters in function name object - -### Changed -- `/mcp-authorize` expects `{ "authorize": { "node": "...", "token": "..." } }` -- `/mcp-search-registry` expects `{ "search_registry": { ... } }` -- `/mcp-call-provider` expects `{ "call_provider": { ... } }` - -## [1.3.0] - 2025-01-09 - -### Changed -- **BREAKING**: Authentication now sent in request body instead of headers for WIT compatibility -- **BREAKING**: Requests now use proper hyperapp RPC format (wrapped in `ShimAdapter`) -- Aligned with hyperapp framework's RPC pattern -- Both `/shim/mcp` and `/mcp` endpoints are supported - -### Fixed -- Fixed authorization test request format to use proper RPC wrapping -- Fixed search-registry and call-provider to use correct RPC format -- Fixed response parsing to handle hyperapp Result wrapper (`{Ok: ...}` or `{Err: ...}`) - -### Migration Guide -When updating from 1.2.x to 1.3.0: -1. The shim now sends requests in proper hyperapp RPC format automatically -2. Both `/shim/mcp` (legacy) and `/mcp` (new) endpoints work -3. No changes needed to your Claude Desktop configuration - -### Technical Details -- Requests are now wrapped: `{ "ShimAdapter": { client_id, token, client_name, mcp_request_json } }` -- Responses are wrapped in Result type: `{ "Ok": { "json_response": "..." } }` or `{ "Err": "..." }` -- This change ensures compatibility with the hyperapp framework's RPC pattern - -## [1.2.0] - Previous Release -- Initial public release with header-based authentication diff --git a/hypergrid-shim/LOCAL_DEVELOPMENT.md b/hypergrid-shim/LOCAL_DEVELOPMENT.md deleted file mode 100644 index f1cac23..0000000 --- a/hypergrid-shim/LOCAL_DEVELOPMENT.md +++ /dev/null @@ -1,112 +0,0 @@ -# Local Development Guide for Hypergrid MCP Shim - -This guide explains how to use the local development version of the shim instead of the npm-published version. - -## Building the Local Shim - -1. Navigate to the shim directory: - ```bash - cd hypergrid-shim - ``` - -2. Install dependencies: - ```bash - npm install - ``` - -3. Build the TypeScript code: - ```bash - npm run build - ``` - -## Using the Local Version in Claude Desktop - -Instead of using the npm package, you can point Claude to your local build: - -### Option 1: Direct Path (Recommended for Development) - -Update your Claude Desktop configuration file: - -**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - -```json -{ - "mcpServers": { - "hyperware": { - "command": "node", - "args": ["/Users/hall/deve/HYPERWARE/APPS/MONO-HPN/hpn/hypergrid-shim/dist/index.js"] - } - } -} -``` - -Replace the path with the absolute path to your local `dist/index.js` file. - -### Option 2: npm link (Alternative) - -1. In the shim directory, create a global link: - ```bash - npm link - ``` - -2. Then use the same configuration as the npm version: - ```json - { - "mcpServers": { - "hyperware": { - "command": "npx", - "args": ["@hyperware-ai/hypergrid-mcp"] - } - } - } - ``` - -## Key Changes in This Version - -1. **New Endpoint**: The shim now uses `/mcp` instead of `/shim/mcp` -2. **Direct RPC Calls**: The shim routes directly to the operator's RPC functions -3. **Better Error Handling**: Improved error messages and authentication flow - -## Testing the Local Version - -After updating your Claude configuration: - -1. Restart Claude Desktop -2. Test the authorization: - ``` - Use the authorize tool with url "http://localhost:8080/operator:hypergrid:ware.hypr/mcp", token "your-token", client_id "your-client-id", and node "your-node" - ``` - Note the new `/mcp` endpoint (not `/shim/mcp`) - -3. Test searching: - ``` - Search the registry for test providers - ``` - -4. Test calling a provider: - ``` - Call a provider you found in the search - ``` - -## Debugging - -If you encounter issues: - -1. Check the Claude logs for error messages -2. Run the shim manually to see console output: - ```bash - node /path/to/hypergrid-shim/dist/index.js - ``` -3. Verify the operator is running and the `/mcp` endpoint is accessible -4. Check that your local build is up to date (`npm run build`) - -## Publishing Updates - -When ready to publish the updated shim to npm: - -1. Update the version in `package.json` -2. Build: `npm run build` -3. Publish: `npm publish` - -Remember to update any references to `/shim/mcp` in documentation to use `/mcp` instead. diff --git a/hypergrid-shim/README.md b/hypergrid-shim/README.md index 0e04dcd..7117e4c 100644 --- a/hypergrid-shim/README.md +++ b/hypergrid-shim/README.md @@ -85,8 +85,6 @@ If you prefer manual configuration, you can create a `grid-shim-api.json` file: } ``` -Note: Both `/shim/mcp` and `/mcp` endpoints are supported. The `/mcp` endpoint is the newer one. - Then run with: ```bash npx @hyperware-ai/hypergrid-mcp -c /path/to/grid-shim-api.json diff --git a/metadata.json b/metadata.json index eb77b10..5adae6a 100644 --- a/metadata.json +++ b/metadata.json @@ -5,7 +5,7 @@ "properties": { "package_name": "hypergrid", "current_version": "1.0.0", - "publisher": "test.hypr", + "publisher": "ware.hypr", "mirrors": ["sam.hypr", "backup-distro-node.os"], "code_hashes": { "1.0.0": "001a49117374abc3bdb38179d8ce05d76205b008bb55683e116be36f3e1635ce" diff --git a/operator/api/operator-process.wit b/operator/api/operator-process.wit index 7f28a41..0d3a425 100644 --- a/operator/api/operator-process.wit +++ b/operator/api/operator-process.wit @@ -1,25 +1,8 @@ interface operator-process { use standard.{address}; - record authorize-result { - url: string, - token: string, - client-id: string, - node: string - } - - record configure-authorized-client-dto { - client-id: option, - client-name: option, - raw-token: string, - hot-wallet-address-to-associate: string - } - - record configure-authorized-client-result { - client-id: string, - raw-token: string, - api-base-path: string, - node-name: string + record spider-connect-result { + api-key: string } record provider-info { @@ -40,9 +23,19 @@ use standard.{address}; description: string } - record key-value { - key: string, - value: string + record spider-chat-dto { + api-key: string, + messages: list, + llm-provider: option, + model: option, + mcp-servers: option>, + metadata: option + } + + record spider-conversation-metadata { + start-time: string, + client: string, + from-stt: bool } variant terminal-command { @@ -79,18 +72,30 @@ use standard.{address}; paymaster-approved: option, hyperwallet-session-active: bool, db-initialized: bool, - timers-initialized: bool + timers-initialized: bool, + spider-api-key: option } - record active-account-details { - id: string, - name: option, - address: string, - is-encrypted: bool, - is-selected: bool, - is-unlocked: bool, - eth-balance: option, - usdc-balance: option + record spider-api-key { + key: string, + name: string, + permissions: list, + created-at: u64 + } + + record call-record { + timestamp-start-ms: u64, + provider-lookup-key: string, + target-provider-id: string, + call-args-json: string, + response-json: option, + call-success: bool, + response-timestamp-ms: u64, + payment-result: option, + duration-ms: u64, + operator-wallet-id: option, + client-id: option, + provider-name: option } record managed-wallet { @@ -116,6 +121,11 @@ use standard.{address}; status: client-status } + variant client-status { + active, + halted + } + variant service-capabilities { all, search-only, @@ -133,26 +143,6 @@ use standard.{address}; payment-tx-hash: option } - variant client-status { - active, - halted - } - - record call-record { - timestamp-start-ms: u64, - provider-lookup-key: string, - target-provider-id: string, - call-args-json: string, - response-json: option, - call-success: bool, - response-timestamp-ms: u64, - payment-result: option, - duration-ms: u64, - operator-wallet-id: option, - client-id: option, - provider-name: option - } - record provider { name: string, hash: string, @@ -162,6 +152,74 @@ use standard.{address}; provider-id: option } + record active-account-details { + id: string, + name: option, + address: string, + is-encrypted: bool, + is-selected: bool, + is-unlocked: bool, + eth-balance: option, + usdc-balance: option + } + + record configure-authorized-client-dto { + client-id: option, + client-name: option, + raw-token: string, + hot-wallet-address-to-associate: string + } + + record spider-mcp-servers-result { + servers: list, + error: option + } + + record spider-mcp-server { + id: string, + name: string, + description: option + } + + record spider-chat-result { + conversation-id: string, + response: spider-message, + all-messages: option> + } + + record spider-message { + role: string, + content: string, + tool-calls-json: option, + tool-results-json: option, + timestamp: u64 + } + + record key-value { + key: string, + value: string + } + + record configure-authorized-client-result { + client-id: string, + raw-token: string, + api-base-path: string, + node-name: string + } + + record authorize-result { + url: string, + token: string, + client-id: string, + node: string + } + + record spider-status-result { + connected: bool, + has-api-key: bool, + spider-available: bool + } + // Function signature for: authorize (http) // HTTP: POST /mcp-authorize record authorize-signature-http { @@ -200,12 +258,6 @@ use standard.{address}; returning: result<_, string> } - // Function signature for: recheck-identity (local) - record recheck-identity-signature-local { - target: address, - returning: result<_, string> - } - // Function signature for: recheck-paymaster-approval (http) // HTTP: POST /api/recheck-paymaster-approval record recheck-paymaster-approval-signature-http { @@ -293,6 +345,37 @@ use standard.{address}; returning: result<_, string> } + // Function signature for: spider-chat (http) + // HTTP: POST /api/spider-chat + record spider-chat-signature-http { + target: string, + request: spider-chat-dto, + returning: result + } + + // Function signature for: spider-connect (http) + // HTTP: POST /api/spider-connect + record spider-connect-signature-http { + target: string, + force-new: option, + returning: result + } + + // Function signature for: spider-mcp-servers (http) + // HTTP: POST /api/spider-mcp-servers + record spider-mcp-servers-signature-http { + target: string, + api-key: string, + returning: result + } + + // Function signature for: spider-status (http) + // HTTP: POST /api/spider-status + record spider-status-signature-http { + target: string, + returning: result + } + // Function signature for: terminal-command (local) record terminal-command-signature-local { target: address, diff --git a/operator/metadata.json b/operator/metadata.json index 9cb37cd..ffddb19 100644 --- a/operator/metadata.json +++ b/operator/metadata.json @@ -5,7 +5,7 @@ "properties": { "package_name": "hypergrid", "current_version": "0.1.2", - "publisher": "test.hypr", + "publisher": "ware.hypr", "mirrors": [], "code_hashes": { "0.1.0": "5ee79597d09c2ca644e41059489f1ed99eb8c5919b2830f9e3e4a1863cb0da88", diff --git a/operator/operator/src/lib.rs b/operator/operator/src/lib.rs index d6391c7..e7150a5 100644 --- a/operator/operator/src/lib.rs +++ b/operator/operator/src/lib.rs @@ -17,6 +17,7 @@ mod structs; mod terminal; mod wallet; mod websocket; +mod spider; use crate::app_api_types::{AuthorizeResult, ProviderInfo, ProviderSearchResult, TerminalCommand}; use crate::structs::{ @@ -30,6 +31,7 @@ use hyperware_process_lib::http::server::WsMessageType; use hyperware_process_lib::hypermap; use hyperware_process_lib::logging::{error, info}; use hyperware_process_lib::LazyLoadBlob; +use hyperware_process_lib::Request as ProcessRequest; use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; @@ -117,7 +119,6 @@ impl OperatorProcess { init::initialize_ledger(self).await; } - #[local] #[http] async fn recheck_identity(&mut self) -> Result<(), String> { info!("Rechecking operator identity..."); @@ -504,6 +505,327 @@ impl OperatorProcess { Ok(provider_infos) } + // ===== Spider Chat Integration Endpoints ===== + + #[http] + async fn spider_connect(&mut self, force_new: Option) -> Result { + use structs::{CreateSpiderKeyRequest, SpiderApiKey}; + + info!("Handling spider connect request"); + + let force_new = force_new.unwrap_or(false); + + // If not forcing new and we already have a key, return it + if !force_new { + if let Some(existing_key) = &self.state.spider_api_key { + info!("Returning existing spider API key"); + return Ok(structs::SpiderConnectResult { + api_key: existing_key.clone(), + }); + } + } else { + info!("Force_new=true, creating new spider API key even if one exists"); + } + + // Find the spider process + let our = hyperware_process_lib::our(); + let spider_address = hyperware_process_lib::Address::new("our", ("spider", "spider", "sys")); + + // Create the request to get a spider API key + let request = CreateSpiderKeyRequest { + name: format!("operator-{}", our.node()), + permissions: vec!["read".to_string(), "write".to_string(), "chat".to_string()], + admin_key: String::new(), + }; + + // Send request to spider to create an API key + let body = serde_json::json!({"CreateSpiderKey": request}); + let body = serde_json::to_vec(&body).map_err(|e| format!("Failed to serialize request: {}", e))?; + + let response_result = ProcessRequest::new() + .target(spider_address) + .body(body) + .send_and_await_response(5); + + // Handle timeout or connection failure gracefully + let response = match response_result { + Ok(Ok(resp)) => resp, + Ok(Err(e)) => { + info!("Spider process returned error: {:?}", e); + return Err("Spider service is not available - Cannot contact Spider process".to_string()); + } + Err(e) => { + info!("Failed to contact Spider (timeout or not installed): {:?}", e); + return Err("Spider service is not available - Cannot contact Spider process, it may not be installed".to_string()); + } + }; + + // Parse the response + let response_body = response.body(); + let result: Result = serde_json::from_slice(response_body) + .map_err(|e| format!("Failed to parse spider response: {:?}", e))?; + + match result { + Ok(api_key) => { + // Store the API key + self.state.spider_api_key = Some(api_key.key.clone()); + // Note: state saving is handled automatically by the framework + + Ok(structs::SpiderConnectResult { + api_key: api_key.key, + }) + } + Err(e) => { + error!("Failed to create spider API key: {}", e); + Err(format!("Failed to create spider API key: {}", e)) + } + } + } + + #[http] + async fn spider_chat(&mut self, mut request: structs::SpiderChatDto) -> Result { + info!("Handling spider chat request"); + + // Find the spider process + let our = hyperware_process_lib::our(); + let spider_address = hyperware_process_lib::Address::new("our", ("spider", "spider", "sys")); + + // Try up to 2 times (once with provided key, once with refreshed key if needed) + for attempt in 1..=2 { + // Convert to spider's expected format + let chat_request = serde_json::json!({ + "Chat": { + "apiKey": request.api_key, + "messages": request.messages, + "llmProvider": request.llm_provider, + "model": request.model, + "mcpServers": request.mcp_servers, + "metadata": request.metadata, + } + }); + + let body = serde_json::to_vec(&chat_request).map_err(|e| format!("Failed to serialize chat request: {}", e))?; + let response_result = ProcessRequest::new() + .target(spider_address.clone()) + .body(body) + .send_and_await_response(30); + + // Handle timeout or connection failure gracefully + let response = match response_result { + Ok(Ok(resp)) => resp, + Ok(Err(e)) => { + info!("Spider chat returned error: {:?}", e); + return Err(format!("Spider service error: {:?}", e)); + } + Err(e) => { + info!("Failed to contact Spider for chat (timeout): {:?}", e); + return Err("Spider service is not available - Cannot contact Spider, it may not be installed or is unresponsive".to_string()); + } + }; + + // Parse and forward the response + let response_body = response.body(); + let chat_response: serde_json::Value = serde_json::from_slice(response_body) + .map_err(|e| format!("Failed to parse spider chat response: {:?}", e))?; + + // Check if it's an unauthorized error + if let Some(error_str) = chat_response.get("Err").and_then(|v| v.as_str()) { + if error_str.contains("Unauthorized: Invalid API key") && attempt == 1 { + info!("Spider API key is invalid, requesting a new one"); + + // Request a new API key + let key_request = structs::CreateSpiderKeyRequest { + name: format!("operator-{}", our.node()), + permissions: vec!["read".to_string(), "write".to_string(), "chat".to_string()], + admin_key: String::new(), + }; + + let key_body = serde_json::json!({"CreateSpiderKey": key_request}); + let key_body = serde_json::to_vec(&key_body).map_err(|e| format!("Failed to serialize key request: {}", e))?; + let key_response_result = ProcessRequest::new() + .target(spider_address.clone()) + .body(key_body) + .send_and_await_response(5); + + // Handle timeout gracefully when refreshing key + let key_response = match key_response_result { + Ok(Ok(resp)) => resp, + Ok(Err(e)) => { + info!("Failed to refresh Spider API key (SendError): {:?}", e); + return Err("Failed to refresh API key - Spider service returned an error".to_string()); + } + Err(e) => { + info!("Failed to refresh Spider API key (BuildError): {:?}", e); + return Err("Failed to refresh API key - Spider service is unavailable".to_string()); + } + }; + + let key_response_body = key_response.body(); + let key_result: Result = + serde_json::from_slice(key_response_body) + .map_err(|e| format!("Failed to parse spider key response: {:?}", e))?; + + match key_result { + Ok(api_key) => { + info!("Got new spider API key, retrying chat request"); + // Update state with new key + self.state.spider_api_key = Some(api_key.key.clone()); + + // Update the request with the new key and retry + request.api_key = api_key.key.clone(); + continue; // Try again with the new key + } + Err(e) => { + error!("Failed to create new spider API key: {}", e); + return Err(format!("Failed to refresh API key: {}", e)); + } + } + } else { + // Other error or already retried + return Err(error_str.to_string()); + } + } + + // Check if it's a non-unauthorized error + if let Some(error) = chat_response.get("Err") { + return Err(error.to_string()); + } + + // Extract the Ok variant - success! + if let Some(ok_response) = chat_response.get("Ok") { + let mut response = structs::SpiderChatResult { + conversation_id: ok_response.get("conversationId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + response: serde_json::from_value(ok_response.get("response").cloned().unwrap_or_default()) + .map_err(|e| format!("Failed to parse response message: {}", e))?, + all_messages: ok_response.get("allMessages") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + }; + + // If we refreshed the key (attempt 2), include it in the response + // Note: SpiderChatResult doesn't have refreshedApiKey field, + // so we'll need to handle this differently or add the field + + return Ok(response); + } + } + + // Should not reach here, but handle it just in case + Err("Invalid response from spider after retries".to_string()) + } + + #[http] + async fn spider_status(&self) -> Result { + info!("Handling spider status request"); + + // Try to ping spider to see if it's actually available + let spider_address = hyperware_process_lib::Address::new("our", ("spider", "spider", "sys")); + let ping_body = serde_json::json!({"Ping": null}); + let ping_body = serde_json::to_vec(&ping_body).map_err(|e| format!("Failed to serialize ping: {}", e))?; + + let spider_available = match ProcessRequest::new() + .target(spider_address) + .body(ping_body) + .send_and_await_response(2) // Short timeout for status check + { + Ok(Ok(_)) => true, + Ok(Err(e)) => { + info!("Spider returned error on ping: {:?}", e); + false + } + Err(e) => { + info!("Spider not responding to ping: {:?}", e); + false + } + }; + + Ok(structs::SpiderStatusResult { + connected: self.state.spider_api_key.is_some() && spider_available, + has_api_key: self.state.spider_api_key.is_some(), + spider_available, + }) + } + + #[http] + async fn spider_mcp_servers(&self, api_key: String) -> Result { + info!("Handling spider MCP servers request"); + + // Find the spider process + let spider_address = hyperware_process_lib::Address::new("our", ("spider", "spider", "sys")); + + // Create the request to list MCP servers + let list_request = serde_json::json!({ + "authKey": api_key, + }); + + // Send request to spider + let body = serde_json::json!({"ListMcpServers": list_request}); + let body = serde_json::to_vec(&body).map_err(|e| format!("Failed to serialize request: {}", e))?; + let response_result = ProcessRequest::new() + .target(spider_address) + .body(body) + .send_and_await_response(5); + + // Handle timeout or connection failure gracefully + let response = match response_result { + Ok(Ok(resp)) => resp, + Ok(Err(e)) => { + info!("Spider returned error for MCP servers: {:?}", e); + return Ok(structs::SpiderMcpServersResult { + error: Some("Spider service error".to_string()), + servers: Vec::new(), + }); + } + Err(e) => { + info!("Failed to contact Spider for MCP servers (timeout): {:?}", e); + return Ok(structs::SpiderMcpServersResult { + error: Some("Spider service is not available".to_string()), + servers: Vec::new(), + }); + } + }; + + // Parse the response + let response_body = response.body(); + let result: Result, String> = + serde_json::from_slice(response_body) + .map_err(|e| format!("Failed to parse spider response: {:?}", e))?; + + match result { + Ok(servers_json) => { + // Convert JSON values to our struct + let servers: Vec = servers_json.into_iter() + .filter_map(|server| { + // Extract fields from JSON and create SpiderMcpServer + let id = server.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let name = server.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let description = server.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); + + if !id.is_empty() && !name.is_empty() { + Some(structs::SpiderMcpServer { id, name, description }) + } else { + None + } + }) + .collect(); + + Ok(structs::SpiderMcpServersResult { + servers, + error: None, + }) + } + Err(e) => { + error!("Failed to get MCP servers: {}", e); + Ok(structs::SpiderMcpServersResult { + error: Some(format!("Failed to get MCP servers: {}", e)), + servers: Vec::new(), + }) + } + } + } + #[local] async fn terminal_command(&mut self, command: TerminalCommand) -> Result { match command { diff --git a/operator/operator/src/spider.rs b/operator/operator/src/spider.rs new file mode 100644 index 0000000..a17463a --- /dev/null +++ b/operator/operator/src/spider.rs @@ -0,0 +1,4 @@ +use hyperware_process_lib::logging::info; + +const SPIDER_PROCESS_ID: (&str, &str, &str) = ("spider", "spider", "sys"); + diff --git a/operator/operator/src/structs.rs b/operator/operator/src/structs.rs index 1cd8a3b..6deb1a5 100644 --- a/operator/operator/src/structs.rs +++ b/operator/operator/src/structs.rs @@ -337,6 +337,9 @@ pub struct State { pub db_initialized: bool, #[serde(default)] pub timers_initialized: bool, + + // Spider API key for chat functionality + pub spider_api_key: Option, } impl State { @@ -370,6 +373,7 @@ impl State { hyperwallet_session_active: false, db_initialized: false, timers_initialized: false, + spider_api_key: None, } } pub fn load() -> Self { @@ -784,3 +788,93 @@ pub enum CoarseState { pub struct HypergridGraphResponseWrapper { pub json_data: String, // JSON-serialized HypergridGraphResponse } + +// Spider-related structs for chat functionality +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CreateSpiderKeyRequest { + pub name: String, + pub permissions: Vec, + #[serde(rename = "adminKey")] + pub admin_key: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SpiderApiKey { + pub key: String, + pub name: String, + pub permissions: Vec, + #[serde(rename = "createdAt")] + pub created_at: u64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ConnectSpiderRequest { + // Empty for now, but can be extended with configuration options +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SpiderConnectResult { + pub api_key: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SpiderChatDto { + #[serde(rename = "apiKey")] + pub api_key: String, + pub messages: Vec, + #[serde(rename = "llmProvider")] + pub llm_provider: Option, + pub model: Option, + #[serde(rename = "mcpServers")] + pub mcp_servers: Option>, + pub metadata: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SpiderMessage { + pub role: String, + pub content: String, + #[serde(rename = "toolCallsJson")] + pub tool_calls_json: Option, + #[serde(rename = "toolResultsJson")] + pub tool_results_json: Option, + pub timestamp: u64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SpiderConversationMetadata { + #[serde(rename = "startTime")] + pub start_time: String, + pub client: String, + #[serde(rename = "fromStt")] + pub from_stt: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SpiderChatResult { + #[serde(rename = "conversationId")] + pub conversation_id: String, + pub response: SpiderMessage, + #[serde(rename = "allMessages")] + pub all_messages: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SpiderStatusResult { + pub connected: bool, + pub has_api_key: bool, + pub spider_available: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SpiderMcpServer { + pub id: String, + pub name: String, + pub description: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SpiderMcpServersResult { + pub servers: Vec, + pub error: Option, +} diff --git a/operator/pkg/manifest.json b/operator/pkg/manifest.json index 274e837..97712d7 100644 --- a/operator/pkg/manifest.json +++ b/operator/pkg/manifest.json @@ -15,11 +15,12 @@ "sign:sign:sys", "http-client:distro:sys", "hns-indexer:hns-indexer:sys", - "hyperwallet:hyperwallet:hallman.hypr" + "hyperwallet:hyperwallet:hallman.hypr", + "spider:spider:sys" ], "grant_capabilities": [ - "http-server:distro:sys", - "http-client:distro:sys", + "http-server:distro:sys", + "http-client:distro:sys", "terminal:terminal:sys" ], "public": true diff --git a/operator/ui/package.json b/operator/ui/package.json index 75ed575..6eef260 100644 --- a/operator/ui/package.json +++ b/operator/ui/package.json @@ -21,6 +21,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-toastify": "^11.0.5", "reactflow": "^11.11.4", "tailwindcss": "^4.1.11", diff --git a/operator/ui/src/App.tsx b/operator/ui/src/App.tsx index 00142c5..72d988a 100644 --- a/operator/ui/src/App.tsx +++ b/operator/ui/src/App.tsx @@ -1,8 +1,10 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; + // SearchPage is no longer directly rendered here by default, but keep import if used elsewhere or if needed later. import OperatorConsole from "./components/console/OperatorConsole"; import HeaderSearch from "./components/HeaderSearch.tsx"; import AppSwitcher from "./components/AppSwitcher.tsx"; +import SpiderChat from "./components/SpiderChat.tsx"; // Import required types import { ActiveAccountDetails, OnboardingStatusResponse } from "./logic/types.ts"; @@ -16,9 +18,7 @@ import { ConnectButton } from '@rainbow-me/rainbowkit'; import { HYPERMAP_ADDRESS } from './constants'; import { ToastContainer } from "react-toastify"; import NotificationBell from './components/NotificationBell'; - -const BASE_URL = import.meta.env.VITE_BASE_URL; - +import { callApiWithRouting } from './utils/api-endpoints'; function App() { // Popover state @@ -28,6 +28,9 @@ function App() { // State for Onboarding Data const [onboardingData, setOnboardingData] = useState(null); + const [spiderApiKey, setSpiderApiKey] = useState(null); + + // Renamed derived variable const derivedNodeName = useMemo(() => { const windowNodeName = (window as any).our?.node; @@ -62,6 +65,37 @@ function App() { } }); + // Check spider connection status on mount + useEffect(() => { + callApiWithRouting({ SpiderStatus: {} }) + .then(data => { + if (data.has_api_key) { + // If already connected, get the key + callApiWithRouting({ SpiderConnect: null }) // null means don't force new + .then(data => { + if (data.api_key) { + setSpiderApiKey(data.api_key); + } + }) + .catch(console.error); + } + }) + .catch(console.error); + }, []); + + const handleSpiderConnect = async () => { + try { + const data = await callApiWithRouting({ SpiderConnect: null }); // null means don't force new + if (data.api_key) { + setSpiderApiKey(data.api_key); + } + } catch (error: any) { + console.error('Error connecting to Spider:', error); + // Show user-friendly error message + alert(error.message || 'Failed to connect to Spider. The Spider service may not be installed.'); + } + }; + useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( @@ -85,6 +119,13 @@ function App() { Hypergrid Logo +
+ setSpiderApiKey(newKey)} + /> +
diff --git a/operator/ui/src/components/ShimApiConfigModal.tsx b/operator/ui/src/components/ShimApiConfigModal.tsx index 657445b..9b3ad23 100644 --- a/operator/ui/src/components/ShimApiConfigModal.tsx +++ b/operator/ui/src/components/ShimApiConfigModal.tsx @@ -17,7 +17,6 @@ function generateApiKey(length = 32): string { return result; } -// Define getApiBasePath directly here (copied from AccountManager.tsx) const getApiBasePath = () => { const pathParts = window.location.pathname.split('/').filter(p => p); const processIdPart = pathParts.find(part => part.includes(':')); diff --git a/operator/ui/src/components/SpiderChat.tsx b/operator/ui/src/components/SpiderChat.tsx new file mode 100644 index 0000000..796c949 --- /dev/null +++ b/operator/ui/src/components/SpiderChat.tsx @@ -0,0 +1,568 @@ +import React, { useState, useRef, useEffect } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { webSocketService } from '../services/websocket'; +import { WsServerMessage, SpiderMessage, ConversationMetadata, McpServerDetails } from '../types/websocket'; +import { callApiWithRouting } from '../utils/api-endpoints'; + +// Types +interface Conversation { + id: string; + messages: SpiderMessage[]; + metadata: ConversationMetadata; + llmProvider: string; + model?: string; + mcpServers: string[]; + mcpServersDetails?: McpServerDetails[] | null; +} + +interface ToolCall { + id: string; + tool_name: string; + parameters: string; +} + +interface ToolResult { + tool_call_id: string; + result: string; +} + +interface SpiderChatProps { + spiderApiKey: string | null; + onConnectClick: () => void; + onApiKeyRefreshed?: (newKey: string) => void; +} + +export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefreshed }: SpiderChatProps) { + const [message, setMessage] = useState(''); + const [conversation, setConversation] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [useWebSocket, setUseWebSocket] = useState(true); + const [wsConnected, setWsConnected] = useState(false); + const [currentRequestId, setCurrentRequestId] = useState(null); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const messageHandlerRef = useRef<((message: WsServerMessage) => void) | null>(null); + + const isActive = !!spiderApiKey; + + // Auto-scroll to bottom when new messages arrive + const scrollToBottom = (smooth: boolean = true) => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ + behavior: smooth ? 'smooth' : 'auto', + block: 'end' + }); + } + }; + + // Scroll on new messages + useEffect(() => { + const timer = setTimeout(() => { + scrollToBottom(); + }, 100); + return () => clearTimeout(timer); + }, [conversation?.messages?.length, isLoading]); + + // Auto-focus input when loading completes + useEffect(() => { + if (!isLoading && inputRef.current && isActive) { + inputRef.current.focus(); + } + }, [isLoading, isActive]); + + // Connect to WebSocket when API key is available + useEffect(() => { + let timer: number | undefined; + + if (spiderApiKey && useWebSocket) { + // Add a small delay to ensure the component is ready + timer = window.setTimeout(() => { + connectWebSocket(); + }, 100); + } + + return () => { + if (timer) { + clearTimeout(timer); + } + if (messageHandlerRef.current) { + webSocketService.removeMessageHandler(messageHandlerRef.current); + messageHandlerRef.current = null; + } + if (wsConnected) { + webSocketService.disconnect(); + setWsConnected(false); + } + }; + }, [spiderApiKey, useWebSocket]); + + const connectWebSocket = async (apiKey?: string) => { + const keyToUse = apiKey || spiderApiKey; + if (!keyToUse) return; + + try { + // Determine WebSocket URL - connect to spider service endpoint + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const wsUrl = `${protocol}//${host}/spider:spider:sys/ws`; + + console.log('Connecting to WebSocket at:', wsUrl); + await webSocketService.connect(wsUrl); + + // Set up message handler for progressive updates + const messageHandler = (message: WsServerMessage) => { + switch (message.type) { + case 'message': + // Progressive message update from tool loop + setConversation(prev => { + if (!prev) return prev; + const updated = { ...prev }; + updated.messages = [...updated.messages]; + // Check if we already have this message (by timestamp or content) + const lastMsg = updated.messages[updated.messages.length - 1]; + if (!lastMsg || lastMsg.timestamp !== message.message.timestamp) { + updated.messages.push(message.message); + } + return updated; + }); + break; + + case 'stream': + // Handle streaming updates (partial message content) + setConversation(prev => { + if (!prev) return prev; + const updated = { ...prev }; + updated.messages = [...updated.messages]; + + // Find or create assistant message for streaming + let assistantMsgIndex = updated.messages.length - 1; + if (assistantMsgIndex < 0 || updated.messages[assistantMsgIndex].role !== 'assistant') { + // Create new assistant message + updated.messages.push({ + role: 'assistant', + content: message.message || '', + toolCallsJson: message.tool_calls, + timestamp: Date.now(), + }); + } else { + // Update existing assistant message + updated.messages[assistantMsgIndex] = { + ...updated.messages[assistantMsgIndex], + content: message.message || updated.messages[assistantMsgIndex].content, + toolCallsJson: message.tool_calls || updated.messages[assistantMsgIndex].toolCallsJson, + }; + } + return updated; + }); + break; + + case 'chat_complete': + // Final response received + if (message.payload) { + setConversation(prev => { + if (!prev) return prev; + const updated = { ...prev }; + updated.id = message.payload.conversationId; + + // If we have allMessages, replace the conversation messages + if (message.payload.allMessages && message.payload.allMessages.length > 0) { + // Keep user messages and replace assistant responses + const userMessageCount = updated.messages.filter(m => m.role === 'user').length; + const baseMessages = updated.messages.slice(0, userMessageCount); + updated.messages = [...baseMessages, ...message.payload.allMessages]; + } else if (message.payload.response) { + // Just add the final response if not already present + const lastMsg = updated.messages[updated.messages.length - 1]; + if (!lastMsg || lastMsg.role !== 'assistant') { + updated.messages.push(message.payload.response); + } + } + + return updated; + }); + + // Handle refreshed API key + if (message.payload.refreshedApiKey && onApiKeyRefreshed) { + onApiKeyRefreshed(message.payload.refreshedApiKey); + } + } + setIsLoading(false); + setCurrentRequestId(null); + break; + + case 'error': + setError(message.error || 'WebSocket error occurred'); + setIsLoading(false); + setCurrentRequestId(null); + break; + + case 'status': + console.log('Status:', message.status, message.message); + break; + } + }; + + messageHandlerRef.current = messageHandler; + webSocketService.addMessageHandler(messageHandler); + + // Authenticate with spider API key + console.log('Authenticating with API key:', keyToUse); + await webSocketService.authenticate(keyToUse); + console.log('Authentication successful'); + + setWsConnected(true); + setError(null); + } catch (error: any) { + console.error('Failed to connect WebSocket:', error); + + // Check if it's an auth error (invalid API key) + if (error.message && (error.message.includes('Invalid API key') || error.message.includes('lacks write permission'))) { + console.log('API key is invalid, requesting a new one...'); + + // Don't retry if we already tried with a fresh key (to prevent infinite loop) + if (apiKey) { + console.error('Already tried with a fresh API key, giving up'); + setWsConnected(false); + setUseWebSocket(false); + setError('Unable to authenticate with Spider. Falling back to HTTP.'); + return; + } + + // Request a new API key + try { + const data = await callApiWithRouting({ + SpiderConnect: true // force_new = true + }); + + if (data.api_key) { + console.log('Got new API key:', data.api_key, 'retrying WebSocket connection...'); + // Update the API key in parent component + if (onApiKeyRefreshed) { + onApiKeyRefreshed(data.api_key); + } + + // Disconnect current connection + webSocketService.disconnect(); + // Small delay to ensure disconnect completes + await new Promise(resolve => setTimeout(resolve, 100)); + // CRITICAL: Explicitly pass the NEW key to prevent using stale closure value + await connectWebSocket(data.api_key); + return; + } else { + throw new Error('Failed to get new API key'); + } + } catch (refreshError: any) { + console.error('Failed to refresh API key:', refreshError); + setWsConnected(false); + setUseWebSocket(false); + setError('Failed to refresh API key. Falling back to HTTP.'); + } + } else { + // Other errors - just fall back to HTTP + setWsConnected(false); + setUseWebSocket(false); + setError('Failed to connect WebSocket. Falling back to HTTP.'); + } + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!message.trim() || isLoading || !isActive) return; + + const requestId = Math.random().toString(36).substring(7); + setCurrentRequestId(requestId); + setError(null); + setIsLoading(true); + + try { + // Create or update conversation + let updatedConversation = conversation || { + id: '', + messages: [], + metadata: { + startTime: new Date().toISOString(), + client: 'operator-ui', + fromStt: false, + }, + llmProvider: 'anthropic', + model: 'claude-sonnet-4-20250514', + mcpServers: [], + }; + + // Add user message + const userMessage: SpiderMessage = { + role: 'user', + content: message, + timestamp: Date.now(), + }; + + updatedConversation.messages.push(userMessage); + setConversation({ ...updatedConversation }); + setMessage(''); + + // Check if we should use WebSocket + console.log('WebSocket check - useWebSocket:', useWebSocket, 'isReady:', webSocketService.isReady, 'isConnected:', webSocketService.isConnected); + if (useWebSocket && webSocketService.isReady) { + // Send via WebSocket for progressive updates + console.log('Sending chat message via WebSocket'); + webSocketService.sendChatMessage( + updatedConversation.messages, + updatedConversation.llmProvider, + updatedConversation.model, + updatedConversation.mcpServers, + updatedConversation.metadata + ); + // WebSocket responses will be handled by the message handler + return; + } + console.log('Falling back to HTTP'); + + // Fallback to HTTP + const data = await callApiWithRouting({ + SpiderChat: { + api_key: spiderApiKey, + messages: updatedConversation.messages, + llm_provider: updatedConversation.llmProvider, + model: updatedConversation.model, + mcp_servers: updatedConversation.mcpServers, + metadata: updatedConversation.metadata, + } + }); + + // Only update if this request hasn't been cancelled + if (currentRequestId === requestId) { + // Update conversation with response + if (data.conversation_id) { + updatedConversation.id = data.conversation_id; + } + + if (data.all_messages && data.all_messages.length > 0) { + updatedConversation.messages.push(...data.all_messages); + } else if (data.response) { + updatedConversation.messages.push(data.response); + } + + setConversation({ ...updatedConversation }); + + // If the API key was refreshed, update it in the parent component + // Note: The backend doesn't currently return refreshedApiKey in SpiderChatResult + // This functionality would need to be added to the backend if needed + } + } catch (err: any) { + if (err.name === 'AbortError') { + // Request was cancelled + } else { + setError(err.message || 'Failed to send message'); + console.error('Chat error:', err); + } + } finally { + if (currentRequestId === requestId) { + setIsLoading(false); + setCurrentRequestId(null); + } + } + }; + + const handleCancel = () => { + if (useWebSocket && webSocketService.isReady) { + try { + webSocketService.sendCancel(); + } catch (error) { + console.error('Failed to send cancel:', error); + } + } + setCurrentRequestId(null); + setIsLoading(false); + }; + + const handleNewConversation = () => { + setConversation(null); + setError(null); + setMessage(''); + }; + + const toggleWebSocket = () => { + const newState = !useWebSocket; + setUseWebSocket(newState); + + if (newState) { + connectWebSocket(); + } else { + webSocketService.disconnect(); + setWsConnected(false); + } + }; + + const getToolEmoji = () => '🔧'; + + // Inactive state - show connect button + if (!isActive) { + return ( +
+
+

Spider Chat

+
+
+
+

Connect to Spider to enable chat

+ +
+
+
+ ); + } + + // Active state - show chat interface + return ( +
+
+

Spider Chat

+
+ + +
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ {conversation?.messages.map((msg, index) => { + const toolCalls = msg.toolCallsJson ? JSON.parse(msg.toolCallsJson) as ToolCall[] : null; + const nextMsg = conversation.messages[index + 1]; + + return ( + + {msg.role !== 'tool' && msg.content && msg.content.trim() && ( +
+
+ {msg.role === 'user' ? ( +

{msg.content}

+ ) : ( +
+ + {msg.content} + +
+ )} +
+
+ )} + + {toolCalls && toolCalls.map((toolCall, toolIndex) => { + const isLastMessage = index === conversation.messages.length - 1; + const isWaitingForResult = isLastMessage && isLoading; + + return ( +
+
+ {getToolEmoji()} + {toolCall.tool_name} + {isWaitingForResult && ( + ... + )} +
+
+ ); + })} +
+ ); + }) || ( +
+

Start a conversation by typing a message below

+
+ )} + + {isLoading && conversation && ( +
+
+
+ + Thinking... +
+
+
+ )} + +
+
+ +
+
+ setMessage(e.target.value)} + placeholder={isLoading ? "Thinking..." : "Type your message..."} + disabled={isLoading} + className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50" + /> + {isLoading ? ( + + ) : ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/operator/ui/src/services/websocket.ts b/operator/ui/src/services/websocket.ts index 75ff8af..df4a338 100644 --- a/operator/ui/src/services/websocket.ts +++ b/operator/ui/src/services/websocket.ts @@ -2,8 +2,13 @@ import { WsClientMessage, WsServerMessage, SubscribeMessage, + AuthMessage, + ChatMessage, + CancelMessage, PingMessage, - StateUpdateTopic + StateUpdateTopic, + SpiderMessage, + ConversationMetadata } from '../types/websocket'; export type MessageHandler = (message: WsServerMessage) => void; @@ -15,6 +20,8 @@ class WebSocketService { private url: string = ''; private isSubscribed: boolean = false; private subscribedTopics: StateUpdateTopic[] = []; + private isAuthenticated: boolean = false; + private pingInterval: ReturnType | null = null; connect(url: string): Promise { return new Promise((resolve, reject) => { @@ -27,8 +34,9 @@ class WebSocketService { this.ws = new WebSocket(url); this.ws.onopen = () => { - console.log('WebSocket connected to operator'); + console.log('WebSocket connected'); this.clearReconnectTimeout(); + this.startPingInterval(); resolve(); }; @@ -38,8 +46,10 @@ class WebSocketService { }; this.ws.onclose = () => { - console.log('WebSocket disconnected from operator'); + console.log('WebSocket disconnected'); this.isSubscribed = false; + this.isAuthenticated = false; + this.stopPingInterval(); this.scheduleReconnect(); }; @@ -90,6 +100,88 @@ class WebSocketService { this.send(unsubscribeMsg); } + authenticate(apiKey: string): Promise { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket not connected')); + return; + } + + console.log('WebSocket authenticate: sending auth message with key:', apiKey); + + // Set up timeout for auth response + const authTimeout = window.setTimeout(() => { + console.error('WebSocket auth timeout - no response received'); + this.removeMessageHandler(authHandler); + reject(new Error('Authentication timeout - no response from server')); + }, 5000); // 5 second timeout + + // Set up one-time handler for auth response + const authHandler = (message: WsServerMessage) => { + console.log('WebSocket received message during auth:', message); + if (message.type === 'auth_success') { + console.log('WebSocket auth success received'); + window.clearTimeout(authTimeout); + this.isAuthenticated = true; + this.removeMessageHandler(authHandler); + resolve(); + } else if (message.type === 'auth_error') { + console.log('WebSocket auth error received:', message.error); + window.clearTimeout(authTimeout); + this.removeMessageHandler(authHandler); + // Pass the exact error message so we can detect invalid API key + reject(new Error(message.error || 'Authentication failed')); + } + }; + + // Add handler BEFORE sending message + this.addMessageHandler(authHandler); + + // Send auth message - use correct format + const authMsg: AuthMessage = { + type: 'auth', + apiKey + }; + console.log('WebSocket sending auth:', JSON.stringify(authMsg)); + this.send(authMsg); + }); + } + + sendChatMessage( + messages: SpiderMessage[], + llmProvider?: string, + model?: string, + mcpServers?: string[], + metadata?: ConversationMetadata + ): void { + if (!this.isAuthenticated) { + throw new Error('Not authenticated'); + } + + const chatMsg: ChatMessage = { + type: 'chat', + payload: { + messages, + llmProvider, + model, + mcpServers, + metadata + } + }; + this.send(chatMsg); + } + + sendCancel(): void { + if (!this.isAuthenticated) { + throw new Error('Not authenticated'); + } + + const cancelMsg: CancelMessage = { + type: 'cancel' + }; + this.send(cancelMsg); + } + sendPing(): void { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return; @@ -119,11 +211,13 @@ class WebSocketService { disconnect(): void { this.clearReconnectTimeout(); + this.stopPingInterval(); if (this.ws) { this.ws.close(); this.ws = null; } this.isSubscribed = false; + this.isAuthenticated = false; this.subscribedTopics = []; } @@ -152,12 +246,51 @@ class WebSocketService { } } + private startPingInterval(): void { + this.stopPingInterval(); + + // Send an immediate ping for testing + if (this.isConnected) { + console.log('Sending immediate ping for testing'); + const pingMsg: PingMessage = { type: 'ping' }; + try { + this.send(pingMsg); + } catch (error) { + console.error('Failed to send ping:', error); + } + } + + this.pingInterval = window.setInterval(() => { + if (this.isConnected) { + const pingMsg: PingMessage = { type: 'ping' }; + try { + console.log('Sending periodic ping'); + this.send(pingMsg); + } catch (error) { + console.error('Failed to send ping:', error); + } + } + }, 30000); // Send ping every 30 seconds + } + + private stopPingInterval(): void { + if (this.pingInterval) { + window.clearInterval(this.pingInterval); + this.pingInterval = null; + } + } + get isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN; } get isReady(): boolean { - return this.isConnected && this.isSubscribed; + // For operator state subscriptions + if (this.subscribedTopics.length > 0) { + return this.isConnected && this.isSubscribed; + } + // For chat functionality + return this.isConnected && this.isAuthenticated; } } diff --git a/operator/ui/src/types/websocket.ts b/operator/ui/src/types/websocket.ts index f241f2f..1069d43 100644 --- a/operator/ui/src/types/websocket.ts +++ b/operator/ui/src/types/websocket.ts @@ -1,9 +1,12 @@ -// WebSocket message types for Operator state streaming +// WebSocket message types for Operator state streaming and Spider chat // Client -> Server messages export type WsClientMessage = | SubscribeMessage | UnsubscribeMessage + | AuthMessage + | ChatMessage + | CancelMessage | PingMessage; export interface SubscribeMessage { @@ -20,6 +23,27 @@ export interface PingMessage { type: 'ping'; } +// Chat-related messages +export interface AuthMessage { + type: 'auth'; + apiKey: string; +} + +export interface ChatMessage { + type: 'chat'; + payload: { + messages: SpiderMessage[]; + llmProvider?: string; + model?: string; + mcpServers?: string[]; + metadata?: ConversationMetadata; + }; +} + +export interface CancelMessage { + type: 'cancel'; +} + // Topics that clients can subscribe to export type StateUpdateTopic = | 'wallets' @@ -34,6 +58,12 @@ export type WsServerMessage = | SubscribedMessage | StateUpdateMessage | StateSnapshotMessage + | AuthSuccessMessage + | AuthErrorMessage + | StatusMessage + | StreamMessage + | MessageUpdate + | ChatCompleteMessage | ErrorMessage | PongMessage; @@ -62,6 +92,40 @@ export interface PongMessage { type: 'pong'; } +// Chat-related server messages +export interface AuthSuccessMessage { + type: 'auth_success'; + message: string; +} + +export interface AuthErrorMessage { + type: 'auth_error'; + error: string; +} + +export interface StatusMessage { + type: 'status'; + status: string; + message?: string; +} + +export interface StreamMessage { + type: 'stream'; + iteration: number; + message: string; + tool_calls?: string | null; +} + +export interface MessageUpdate { + type: 'message'; + message: SpiderMessage; +} + +export interface ChatCompleteMessage { + type: 'chat_complete'; + payload: ChatResponse; +} + // State update data types export type StateUpdateData = | WalletUpdateData @@ -165,3 +229,36 @@ export interface HotWalletAuthorizedClient { name: string; associated_hot_wallet_address: string; } + +// Chat-related types +export interface SpiderMessage { + role: string; + content: string; + toolCallsJson?: string | null; + toolResultsJson?: string | null; + timestamp: number; +} + +export interface ConversationMetadata { + startTime: string; + client: string; + fromStt: boolean; +} + +export interface McpServerDetails { + id: string; + name: string; + tools: McpToolInfo[]; +} + +export interface McpToolInfo { + name: string; + description: string; +} + +export interface ChatResponse { + conversationId: string; + response: SpiderMessage; + allMessages: SpiderMessage[]; + refreshedApiKey?: string; // This is added by operator backend +} diff --git a/provider/metadata.json b/provider/metadata.json index 05928ea..1afc573 100644 --- a/provider/metadata.json +++ b/provider/metadata.json @@ -5,7 +5,7 @@ "properties": { "package_name": "hypergrid", "current_version": "0.1.0", - "publisher": "test.hypr", + "publisher": "ware.hypr", "mirrors": [], "code_hashes": { "0.1.0": "" From a4283bcb4da991876b45c97c149c84dd9b09fbcc Mon Sep 17 00:00:00 2001 From: Hallmane Date: Thu, 25 Sep 2025 16:01:53 +0200 Subject: [PATCH 3/8] spider working --- operator/api/operator-process.wit | 182 +++++++++++----------- operator/operator/src/lib.rs | 12 +- operator/operator/src/spider.rs | 2 +- operator/ui/src/App.tsx | 22 ++- operator/ui/src/components/SpiderChat.tsx | 156 ++++++++++++++----- pkg/manifest.json | 2 + 6 files changed, 236 insertions(+), 140 deletions(-) diff --git a/operator/api/operator-process.wit b/operator/api/operator-process.wit index 0d3a425..8537495 100644 --- a/operator/api/operator-process.wit +++ b/operator/api/operator-process.wit @@ -1,41 +1,34 @@ interface operator-process { use standard.{address}; - record spider-connect-result { - api-key: string + record spider-status-result { + connected: bool, + has-api-key: bool, + spider-available: bool } - record provider-info { - id: option, - provider-id: string, - name: string, - description: option, - site: option, - wallet: option, - price: option, - instructions: option, - hash: string + record spider-chat-result { + conversation-id: string, + response: spider-message, + all-messages: option> } - record provider-search-result { - provider-id: string, - name: string, - description: string + record spider-mcp-servers-result { + servers: list, + error: option } - record spider-chat-dto { - api-key: string, - messages: list, - llm-provider: option, - model: option, - mcp-servers: option>, - metadata: option + record spider-mcp-server { + id: string, + name: string, + description: option } - record spider-conversation-metadata { - start-time: string, - client: string, - from-stt: bool + record authorize-result { + url: string, + token: string, + client-id: string, + node: string } variant terminal-command { @@ -76,13 +69,6 @@ use standard.{address}; spider-api-key: option } - record spider-api-key { - key: string, - name: string, - permissions: list, - created-at: u64 - } - record call-record { timestamp-start-ms: u64, provider-lookup-key: string, @@ -98,20 +84,6 @@ use standard.{address}; provider-name: option } - record managed-wallet { - id: string, - name: option, - storage-json: string, - spending-limits: spending-limits - } - - record spending-limits { - max-per-call: option, - max-total: option, - currency: option, - total-spent: option - } - record hot-wallet-authorized-client { id: string, name: string, @@ -121,11 +93,6 @@ use standard.{address}; status: client-status } - variant client-status { - active, - halted - } - variant service-capabilities { all, search-only, @@ -143,13 +110,30 @@ use standard.{address}; payment-tx-hash: option } - record provider { + variant client-status { + active, + halted + } + + record spider-api-key { + key: string, name: string, - hash: string, - facts: list>>, - wallet: option, - price: option, - provider-id: option + permissions: list, + created-at: u64 + } + + record managed-wallet { + id: string, + name: option, + storage-json: string, + spending-limits: spending-limits + } + + record spending-limits { + max-per-call: option, + max-total: option, + currency: option, + total-spent: option } record active-account-details { @@ -163,6 +147,25 @@ use standard.{address}; usdc-balance: option } + record provider-info { + id: option, + provider-id: string, + name: string, + description: option, + site: option, + wallet: option, + price: option, + instructions: option, + hash: string + } + + record configure-authorized-client-result { + client-id: string, + raw-token: string, + api-base-path: string, + node-name: string + } + record configure-authorized-client-dto { client-id: option, client-name: option, @@ -170,21 +173,28 @@ use standard.{address}; hot-wallet-address-to-associate: string } - record spider-mcp-servers-result { - servers: list, - error: option + record provider-search-result { + provider-id: string, + name: string, + description: string } - record spider-mcp-server { - id: string, - name: string, - description: option + record key-value { + key: string, + value: string } - record spider-chat-result { - conversation-id: string, - response: spider-message, - all-messages: option> + record spider-connect-result { + api-key: string + } + + record spider-chat-dto { + api-key: string, + messages: list, + llm-provider: option, + model: option, + mcp-servers: option>, + metadata: option } record spider-message { @@ -195,29 +205,19 @@ use standard.{address}; timestamp: u64 } - record key-value { - key: string, - value: string - } - - record configure-authorized-client-result { - client-id: string, - raw-token: string, - api-base-path: string, - node-name: string - } - - record authorize-result { - url: string, - token: string, - client-id: string, - node: string + record provider { + name: string, + hash: string, + facts: list>>, + wallet: option, + price: option, + provider-id: option } - record spider-status-result { - connected: bool, - has-api-key: bool, - spider-available: bool + record spider-conversation-metadata { + start-time: string, + client: string, + from-stt: bool } // Function signature for: authorize (http) diff --git a/operator/operator/src/lib.rs b/operator/operator/src/lib.rs index e7150a5..9faf62c 100644 --- a/operator/operator/src/lib.rs +++ b/operator/operator/src/lib.rs @@ -35,6 +35,8 @@ use hyperware_process_lib::Request as ProcessRequest; use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::spider::SPIDER_PROCESS_ID; + #[derive(Debug, Serialize, Deserialize)] pub struct OperatorProcess { // Make state private to prevent WIT generation @@ -529,7 +531,7 @@ impl OperatorProcess { // Find the spider process let our = hyperware_process_lib::our(); - let spider_address = hyperware_process_lib::Address::new("our", ("spider", "spider", "sys")); + let spider_address = hyperware_process_lib::Address::new("our", SPIDER_PROCESS_ID); // Create the request to get a spider API key let request = CreateSpiderKeyRequest { @@ -546,6 +548,8 @@ impl OperatorProcess { .target(spider_address) .body(body) .send_and_await_response(5); + + info!("Spider response:{:#?}", response_result.clone()); // Handle timeout or connection failure gracefully let response = match response_result { @@ -588,7 +592,7 @@ impl OperatorProcess { // Find the spider process let our = hyperware_process_lib::our(); - let spider_address = hyperware_process_lib::Address::new("our", ("spider", "spider", "sys")); + let spider_address = hyperware_process_lib::Address::new("our", SPIDER_PROCESS_ID); // Try up to 2 times (once with provided key, once with refreshed key if needed) for attempt in 1..=2 { @@ -721,7 +725,7 @@ impl OperatorProcess { info!("Handling spider status request"); // Try to ping spider to see if it's actually available - let spider_address = hyperware_process_lib::Address::new("our", ("spider", "spider", "sys")); + let spider_address = hyperware_process_lib::Address::new("our", SPIDER_PROCESS_ID); let ping_body = serde_json::json!({"Ping": null}); let ping_body = serde_json::to_vec(&ping_body).map_err(|e| format!("Failed to serialize ping: {}", e))?; @@ -753,7 +757,7 @@ impl OperatorProcess { info!("Handling spider MCP servers request"); // Find the spider process - let spider_address = hyperware_process_lib::Address::new("our", ("spider", "spider", "sys")); + let spider_address = hyperware_process_lib::Address::new("our", SPIDER_PROCESS_ID); // Create the request to list MCP servers let list_request = serde_json::json!({ diff --git a/operator/operator/src/spider.rs b/operator/operator/src/spider.rs index a17463a..acdffe1 100644 --- a/operator/operator/src/spider.rs +++ b/operator/operator/src/spider.rs @@ -1,4 +1,4 @@ use hyperware_process_lib::logging::info; -const SPIDER_PROCESS_ID: (&str, &str, &str) = ("spider", "spider", "sys"); +pub const SPIDER_PROCESS_ID: (&str, &str, &str) = ("spider", "spider", "sys"); diff --git a/operator/ui/src/App.tsx b/operator/ui/src/App.tsx index 72d988a..d15a1f5 100644 --- a/operator/ui/src/App.tsx +++ b/operator/ui/src/App.tsx @@ -69,12 +69,15 @@ function App() { useEffect(() => { callApiWithRouting({ SpiderStatus: {} }) .then(data => { - if (data.has_api_key) { + // Handle Result wrapper + const status = data.Ok || data; + if (status.has_api_key) { // If already connected, get the key callApiWithRouting({ SpiderConnect: null }) // null means don't force new .then(data => { - if (data.api_key) { - setSpiderApiKey(data.api_key); + // Handle Result wrapper + if (data.Ok && data.Ok.api_key) { + setSpiderApiKey(data.Ok.api_key); } }) .catch(console.error); @@ -85,9 +88,18 @@ function App() { const handleSpiderConnect = async () => { try { + console.log('Calling SpiderConnect...'); const data = await callApiWithRouting({ SpiderConnect: null }); // null means don't force new - if (data.api_key) { - setSpiderApiKey(data.api_key); + console.log('SpiderConnect response:', data); + + // Handle Result wrapper from Rust + if (data.Ok && data.Ok.api_key) { + console.log('Setting spider API key:', data.Ok.api_key); + setSpiderApiKey(data.Ok.api_key); + } else if (data.Err) { + throw new Error(data.Err); + } else { + console.log('No API key in response'); } } catch (error: any) { console.error('Error connecting to Spider:', error); diff --git a/operator/ui/src/components/SpiderChat.tsx b/operator/ui/src/components/SpiderChat.tsx index 796c949..cb8501f 100644 --- a/operator/ui/src/components/SpiderChat.tsx +++ b/operator/ui/src/components/SpiderChat.tsx @@ -1,6 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; import ReactMarkdown from 'react-markdown'; -import { webSocketService } from '../services/websocket'; import { WsServerMessage, SpiderMessage, ConversationMetadata, McpServerDetails } from '../types/websocket'; import { callApiWithRouting } from '../utils/api-endpoints'; @@ -42,9 +41,16 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre const [currentRequestId, setCurrentRequestId] = useState(null); const messagesEndRef = useRef(null); const inputRef = useRef(null); - const messageHandlerRef = useRef<((message: WsServerMessage) => void) | null>(null); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef | null>(null); + const authTimeoutRef = useRef | null>(null); const isActive = !!spiderApiKey; + + // Debug logging + useEffect(() => { + console.log('SpiderChat - API key changed:', spiderApiKey ? 'Key present' : 'No key'); + }, [spiderApiKey]); // Auto-scroll to bottom when new messages arrive const scrollToBottom = (smooth: boolean = true) => { @@ -86,14 +92,19 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre if (timer) { clearTimeout(timer); } - if (messageHandlerRef.current) { - webSocketService.removeMessageHandler(messageHandlerRef.current); - messageHandlerRef.current = null; - } - if (wsConnected) { - webSocketService.disconnect(); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; setWsConnected(false); } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (authTimeoutRef.current) { + clearTimeout(authTimeoutRef.current); + authTimeoutRef.current = null; + } }; }, [spiderApiKey, useWebSocket]); @@ -102,16 +113,44 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre if (!keyToUse) return; try { + // Close existing connection if any + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + // Determine WebSocket URL - connect to spider service endpoint const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; const wsUrl = `${protocol}//${host}/spider:spider:sys/ws`; - console.log('Connecting to WebSocket at:', wsUrl); - await webSocketService.connect(wsUrl); + console.log('Connecting to Spider WebSocket at:', wsUrl); + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; - // Set up message handler for progressive updates - const messageHandler = (message: WsServerMessage) => { + // Set up WebSocket event handlers + ws.onopen = () => { + console.log('Spider WebSocket connected, authenticating...'); + // Send auth message + const authMessage = { + type: 'auth', + apiKey: keyToUse + }; + ws.send(JSON.stringify(authMessage)); + + // Set auth timeout + authTimeoutRef.current = setTimeout(() => { + if (!wsConnected) { + console.error('Spider auth timeout'); + ws.close(); + throw new Error('Authentication timeout - no response from server'); + } + }, 5000); + }; + + ws.onmessage = (event) => { + const message: WsServerMessage = JSON.parse(event.data); switch (message.type) { case 'message': // Progressive message update from tool loop @@ -200,19 +239,34 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre case 'status': console.log('Status:', message.status, message.message); break; + + case 'auth_success': + console.log('Spider authentication successful'); + if (authTimeoutRef.current) { + clearTimeout(authTimeoutRef.current); + authTimeoutRef.current = null; + } + setWsConnected(true); + setError(null); + break; + + case 'auth_error': + console.error('Spider authentication failed:', message.error); + ws.close(); + throw new Error(message.error || 'Authentication failed'); } }; - messageHandlerRef.current = messageHandler; - webSocketService.addMessageHandler(messageHandler); - - // Authenticate with spider API key - console.log('Authenticating with API key:', keyToUse); - await webSocketService.authenticate(keyToUse); - console.log('Authentication successful'); + ws.onerror = (event) => { + console.error('Spider WebSocket error:', event); + setWsConnected(false); + }; - setWsConnected(true); - setError(null); + ws.onclose = () => { + console.log('Spider WebSocket disconnected'); + setWsConnected(false); + wsRef.current = null; + }; } catch (error: any) { console.error('Failed to connect WebSocket:', error); @@ -235,20 +289,26 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre SpiderConnect: true // force_new = true }); - if (data.api_key) { - console.log('Got new API key:', data.api_key, 'retrying WebSocket connection...'); + // Handle Result wrapper from Rust + if (data.Ok && data.Ok.api_key) { + console.log('Got new API key:', data.Ok.api_key, 'retrying WebSocket connection...'); // Update the API key in parent component if (onApiKeyRefreshed) { - onApiKeyRefreshed(data.api_key); + onApiKeyRefreshed(data.Ok.api_key); } // Disconnect current connection - webSocketService.disconnect(); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } // Small delay to ensure disconnect completes await new Promise(resolve => setTimeout(resolve, 100)); // CRITICAL: Explicitly pass the NEW key to prevent using stale closure value - await connectWebSocket(data.api_key); + await connectWebSocket(data.Ok.api_key); return; + } else if (data.Err) { + throw new Error(data.Err); } else { throw new Error('Failed to get new API key'); } @@ -303,24 +363,29 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre setMessage(''); // Check if we should use WebSocket - console.log('WebSocket check - useWebSocket:', useWebSocket, 'isReady:', webSocketService.isReady, 'isConnected:', webSocketService.isConnected); - if (useWebSocket && webSocketService.isReady) { + console.log('WebSocket check - useWebSocket:', useWebSocket, 'wsConnected:', wsConnected, 'ws ready:', wsRef.current?.readyState === WebSocket.OPEN); + if (useWebSocket && wsConnected && wsRef.current?.readyState === WebSocket.OPEN) { // Send via WebSocket for progressive updates console.log('Sending chat message via WebSocket'); - webSocketService.sendChatMessage( - updatedConversation.messages, - updatedConversation.llmProvider, - updatedConversation.model, - updatedConversation.mcpServers, - updatedConversation.metadata - ); + const chatMessage = { + type: 'chat', + payload: { + messages: updatedConversation.messages, + llmProvider: updatedConversation.llmProvider, + model: updatedConversation.model, + mcpServers: updatedConversation.mcpServers, + metadata: updatedConversation.metadata, + conversationId: updatedConversation.id || undefined + } + }; + wsRef.current.send(JSON.stringify(chatMessage)); // WebSocket responses will be handled by the message handler return; } console.log('Falling back to HTTP'); // Fallback to HTTP - const data = await callApiWithRouting({ + const result = await callApiWithRouting({ SpiderChat: { api_key: spiderApiKey, messages: updatedConversation.messages, @@ -331,6 +396,13 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre } }); + // Handle Result wrapper from Rust + if (result.Err) { + throw new Error(result.Err); + } + + const data = result.Ok || result; + // Only update if this request hasn't been cancelled if (currentRequestId === requestId) { // Update conversation with response @@ -366,9 +438,12 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre }; const handleCancel = () => { - if (useWebSocket && webSocketService.isReady) { + if (useWebSocket && wsConnected && wsRef.current?.readyState === WebSocket.OPEN) { try { - webSocketService.sendCancel(); + const cancelMessage = { + type: 'cancel' + }; + wsRef.current.send(JSON.stringify(cancelMessage)); } catch (error) { console.error('Failed to send cancel:', error); } @@ -390,7 +465,10 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre if (newState) { connectWebSocket(); } else { - webSocketService.disconnect(); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } setWsConnected(false); } }; diff --git a/pkg/manifest.json b/pkg/manifest.json index 3759bd1..dc7e0a2 100644 --- a/pkg/manifest.json +++ b/pkg/manifest.json @@ -30,6 +30,7 @@ "vfs:distro:sys", "eth:distro:sys", "sqlite:distro:sys", + "spider:spider:sys", "timer:distro:sys" ], "grant_capabilities": [ @@ -40,6 +41,7 @@ "terminal:terminal:sys", "eth:distro:sys", "sqlite:distro:sys", + "spider:spider:sys", "timer:distro:sys" ], "public": false From 9f3658ef055d4cdb1d432c155449a171e49240dc Mon Sep 17 00:00:00 2001 From: Hallmane Date: Thu, 25 Sep 2025 23:51:33 +0200 Subject: [PATCH 4/8] manually went through the diffs --- metadata.json | 11 +- operator/api/operator-process.wit | 178 ++++++----- operator/operator/src/lib.rs | 7 +- operator/pkg/manifest.json | 5 +- operator/ui/src/components/SpiderChat.tsx | 218 +++++++++++-- .../console/OneClickOperatorBoot.tsx | 43 ++- .../console/OperatorFinalizeSetup.tsx | 2 +- .../src/components/console/WelcomeIntro.tsx | 5 +- pkg/manifest.json | 10 +- provider/provider/Cargo.toml | 11 +- provider/provider/src/db.rs | 38 +-- provider/provider/src/lib.rs | 299 ++++++++++++------ provider/provider/src/util.rs | 130 +++++--- .../ui/src/components/HypergridEntryForm.tsx | 2 +- .../src/components/RegisteredProviderView.tsx | 2 +- .../components/UnifiedTerminalInterface.tsx | 8 +- 16 files changed, 669 insertions(+), 300 deletions(-) diff --git a/metadata.json b/metadata.json index 5adae6a..457d887 100644 --- a/metadata.json +++ b/metadata.json @@ -4,11 +4,16 @@ "image": "https://raw.githubusercontent.com/hyperware-ai/hpn/ccd0e9c0d08b2344b06ce4a5b8584f819b92e43e/hypergrid-logo.webp", "properties": { "package_name": "hypergrid", - "current_version": "1.0.0", + "current_version": "1.2.2", "publisher": "ware.hypr", - "mirrors": ["sam.hypr", "backup-distro-node.os"], + "mirrors": ["ware.hypr", "sam.hypr", "backup-distro-node.os"], "code_hashes": { - "1.0.0": "001a49117374abc3bdb38179d8ce05d76205b008bb55683e116be36f3e1635ce" + "1.0.0": "001a49117374abc3bdb38179d8ce05d76205b008bb55683e116be36f3e1635ce", + "1.1.0": "b9a3255a0778ffc9540bbae08aa05320525378a75dca5ba02f311ab192bda79f", + "1.1.1": "52c826cf4ed84b9138c01a99db09ab17079dd020623951475558a28af19a7c1c", + "1.1.2": "b8baa26b03cc6b536152638b33595a827764b4ed186f371cb61be33c0276e566", + "1.2.0": "740ef394e47fcf8e2d66e2eca0ebf8f9276ed7dad59fd12d283c6384ba11fbe7", + "1.2.1": "898ddd9e5805b5f4aa70f33a97efeea8e5e58216791713c9f8bc6f6c2c3f205d" }, "wit_version": 1, "dependencies": [] diff --git a/operator/api/operator-process.wit b/operator/api/operator-process.wit index 8537495..439e7a6 100644 --- a/operator/api/operator-process.wit +++ b/operator/api/operator-process.wit @@ -1,16 +1,32 @@ interface operator-process { use standard.{address}; - record spider-status-result { - connected: bool, - has-api-key: bool, - spider-available: bool + record spider-chat-dto { + api-key: string, + messages: list, + llm-provider: option, + model: option, + mcp-servers: option>, + metadata: option } - record spider-chat-result { - conversation-id: string, - response: spider-message, - all-messages: option> + record spider-conversation-metadata { + start-time: string, + client: string, + from-stt: bool + } + + record configure-authorized-client-dto { + client-id: option, + client-name: option, + raw-token: string, + hot-wallet-address-to-associate: string + } + + record provider-search-result { + provider-id: string, + name: string, + description: string } record spider-mcp-servers-result { @@ -24,6 +40,47 @@ use standard.{address}; description: option } + record spider-chat-result { + conversation-id: string, + response: spider-message, + all-messages: option> + } + + record spider-message { + role: string, + content: string, + tool-calls-json: option, + tool-results-json: option, + timestamp: u64 + } + + record spider-status-result { + connected: bool, + has-api-key: bool, + spider-available: bool + } + + record provider-info { + id: option, + provider-id: string, + name: string, + description: option, + site: option, + wallet: option, + price: option, + instructions: option, + hash: string + } + + record key-value { + key: string, + value: string + } + + record spider-connect-result { + api-key: string + } + record authorize-result { url: string, token: string, @@ -31,6 +88,13 @@ use standard.{address}; node: string } + record configure-authorized-client-result { + client-id: string, + raw-token: string, + api-base-path: string, + node-name: string + } + variant terminal-command { get-state, reset-state, @@ -93,6 +157,11 @@ use standard.{address}; status: client-status } + variant client-status { + active, + halted + } + variant service-capabilities { all, search-only, @@ -110,16 +179,13 @@ use standard.{address}; payment-tx-hash: option } - variant client-status { - active, - halted - } - - record spider-api-key { - key: string, + record provider { name: string, - permissions: list, - created-at: u64 + hash: string, + facts: list>>, + wallet: option, + price: option, + provider-id: option } record managed-wallet { @@ -147,77 +213,11 @@ use standard.{address}; usdc-balance: option } - record provider-info { - id: option, - provider-id: string, - name: string, - description: option, - site: option, - wallet: option, - price: option, - instructions: option, - hash: string - } - - record configure-authorized-client-result { - client-id: string, - raw-token: string, - api-base-path: string, - node-name: string - } - - record configure-authorized-client-dto { - client-id: option, - client-name: option, - raw-token: string, - hot-wallet-address-to-associate: string - } - - record provider-search-result { - provider-id: string, - name: string, - description: string - } - - record key-value { + record spider-api-key { key: string, - value: string - } - - record spider-connect-result { - api-key: string - } - - record spider-chat-dto { - api-key: string, - messages: list, - llm-provider: option, - model: option, - mcp-servers: option>, - metadata: option - } - - record spider-message { - role: string, - content: string, - tool-calls-json: option, - tool-results-json: option, - timestamp: u64 - } - - record provider { name: string, - hash: string, - facts: list>>, - wallet: option, - price: option, - provider-id: option - } - - record spider-conversation-metadata { - start-time: string, - client: string, - from-stt: bool + permissions: list, + created-at: u64 } // Function signature for: authorize (http) @@ -258,6 +258,12 @@ use standard.{address}; returning: result<_, string> } + // Function signature for: recheck-identity (local) + record recheck-identity-signature-local { + target: address, + returning: result<_, string> + } + // Function signature for: recheck-paymaster-approval (http) // HTTP: POST /api/recheck-paymaster-approval record recheck-paymaster-approval-signature-http { diff --git a/operator/operator/src/lib.rs b/operator/operator/src/lib.rs index 9faf62c..29ef2a7 100644 --- a/operator/operator/src/lib.rs +++ b/operator/operator/src/lib.rs @@ -121,6 +121,7 @@ impl OperatorProcess { init::initialize_ledger(self).await; } + #[local] #[http] async fn recheck_identity(&mut self) -> Result<(), String> { info!("Rechecking operator identity..."); @@ -508,6 +509,7 @@ impl OperatorProcess { } // ===== Spider Chat Integration Endpoints ===== + // TODO: add abstractions for these spider functions in the spider.rs file and clean here #[http] async fn spider_connect(&mut self, force_new: Option) -> Result { @@ -1010,7 +1012,7 @@ impl OperatorProcess { message_type: WsMessageType, blob: LazyLoadBlob, ) { - info!("Handling WebSocket message: {:?}", message_type); + //info!("Handling WebSocket message: {:?}", message_type); match message_type { WsMessageType::Text | WsMessageType::Binary => { let message_bytes = blob.bytes.clone(); @@ -1019,7 +1021,7 @@ impl OperatorProcess { // Parse the incoming message match serde_json::from_str::(&message_str) { Ok(msg) => { - info!("Parsed WebSocket message: {:?}", msg); + //info!("Parsed WebSocket message: {:?}", msg); match msg { WsClientMessage::Subscribe { topics } => { info!("subscribe"); @@ -1055,7 +1057,6 @@ impl OperatorProcess { } } WsClientMessage::Ping => { - info!("ping!"); let response = WsServerMessage::Pong; self.send_ws_message(channel_id, response); } diff --git a/operator/pkg/manifest.json b/operator/pkg/manifest.json index 97712d7..adcf2a0 100644 --- a/operator/pkg/manifest.json +++ b/operator/pkg/manifest.json @@ -15,13 +15,14 @@ "sign:sign:sys", "http-client:distro:sys", "hns-indexer:hns-indexer:sys", - "hyperwallet:hyperwallet:hallman.hypr", + "hyperwallet:hyperwallet:sys", "spider:spider:sys" ], "grant_capabilities": [ "http-server:distro:sys", "http-client:distro:sys", - "terminal:terminal:sys" + "terminal:terminal:sys", + "spider:spider:sys" ], "public": true } diff --git a/operator/ui/src/components/SpiderChat.tsx b/operator/ui/src/components/SpiderChat.tsx index cb8501f..a26a6ca 100644 --- a/operator/ui/src/components/SpiderChat.tsx +++ b/operator/ui/src/components/SpiderChat.tsx @@ -25,6 +25,72 @@ interface ToolResult { result: string; } +// Tool Call Modal Component +function ToolCallModal({ toolCall, toolResult, onClose }: { + toolCall: ToolCall; + toolResult?: ToolResult; + onClose: () => void; +}) { + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + // Could add a toast notification here + }).catch(err => { + console.error('Failed to copy:', err); + }); + }; + + return ( +
+
e.stopPropagation()}> +
+

Tool Call Details: {toolCall.tool_name}

+ +
+
+
+
+

Tool Call

+ +
+
+              {JSON.stringify(toolCall, null, 2)}
+            
+
+ {toolResult && ( +
+
+

Tool Result

+ +
+
+                {JSON.stringify(toolResult, null, 2)}
+              
+
+ )} +
+
+
+ ); +} + interface SpiderChatProps { spiderApiKey: string | null; onConnectClick: () => void; @@ -39,6 +105,9 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre const [useWebSocket, setUseWebSocket] = useState(true); const [wsConnected, setWsConnected] = useState(false); const [currentRequestId, setCurrentRequestId] = useState(null); + const [connectedMcpServers, setConnectedMcpServers] = useState([]); + const [selectedToolCall, setSelectedToolCall] = useState<{call: ToolCall, result?: ToolResult} | null>(null); + const [spiderUnavailable, setSpiderUnavailable] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); const wsRef = useRef(null); @@ -47,11 +116,6 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre const isActive = !!spiderApiKey; - // Debug logging - useEffect(() => { - console.log('SpiderChat - API key changed:', spiderApiKey ? 'Key present' : 'No key'); - }, [spiderApiKey]); - // Auto-scroll to bottom when new messages arrive const scrollToBottom = (smooth: boolean = true) => { if (messagesEndRef.current) { @@ -77,15 +141,43 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre } }, [isLoading, isActive]); + // Fetch MCP servers when API key is available + const fetchMcpServers = async (apiKey: string) => { + try { + const result = await callApiWithRouting({ + SpiderMcpServers: apiKey + }); + + // Handle Result wrapper + const data = result.Ok || result; + + if (data.servers) { + // Filter for connected servers and get their IDs + const connectedServerIds = data.servers + .filter((server: any) => server.connected) + .map((server: any) => server.id); + setConnectedMcpServers(connectedServerIds); + console.log('Connected MCP servers:', connectedServerIds); + } + } catch (error) { + console.error('Failed to fetch MCP servers:', error); + } + }; + // Connect to WebSocket when API key is available useEffect(() => { let timer: number | undefined; - if (spiderApiKey && useWebSocket) { - // Add a small delay to ensure the component is ready - timer = window.setTimeout(() => { - connectWebSocket(); - }, 100); + if (spiderApiKey) { + // Fetch MCP servers + fetchMcpServers(spiderApiKey); + + if (useWebSocket) { + // Add a small delay to ensure the component is ready + timer = window.setTimeout(() => { + connectWebSocket(); + }, 100); + } } return () => { @@ -150,7 +242,13 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre }; ws.onmessage = (event) => { - const message: WsServerMessage = JSON.parse(event.data); + let message: WsServerMessage; + try { + message = JSON.parse(event.data); + } catch (e) { + console.error('Failed to parse WebSocket message:', e); + return; + } switch (message.type) { case 'message': // Progressive message update from tool loop @@ -270,6 +368,17 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre } catch (error: any) { console.error('Failed to connect WebSocket:', error); + // Check if it's a timeout or connection error (Spider not installed) + if (error.message?.includes('timeout') || + (error.name === 'TypeError' && error.message?.includes('Failed to fetch'))) { + console.error('Cannot reach Spider service - may not be installed'); + setWsConnected(false); + setUseWebSocket(false); + setError('Cannot reach Spider service. Is Spider installed?'); + setSpiderUnavailable(true); + return; + } + // Check if it's an auth error (invalid API key) if (error.message && (error.message.includes('Invalid API key') || error.message.includes('lacks write permission'))) { console.log('API key is invalid, requesting a new one...'); @@ -304,7 +413,7 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre } // Small delay to ensure disconnect completes await new Promise(resolve => setTimeout(resolve, 100)); - // CRITICAL: Explicitly pass the NEW key to prevent using stale closure value + // pass the NEW key to prevent using stale closure value await connectWebSocket(data.Ok.api_key); return; } else if (data.Err) { @@ -314,9 +423,17 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre } } catch (refreshError: any) { console.error('Failed to refresh API key:', refreshError); + + // Check if the refresh failed due to timeout (Spider not available) + if (refreshError.name === 'AbortError' || refreshError.message?.includes('timeout')) { + setSpiderUnavailable(true); + setError('Cannot reach Spider service. Is Spider installed?'); + } else { + setError('Failed to refresh API key. Falling back to HTTP.'); + } + setWsConnected(false); setUseWebSocket(false); - setError('Failed to refresh API key. Falling back to HTTP.'); } } else { // Other errors - just fall back to HTTP @@ -337,8 +454,11 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre setIsLoading(true); try { - // Create or update conversation - let updatedConversation = conversation || { + // Continue existing conversation or create new one + let updatedConversation = conversation ? { + ...conversation, + mcpServers: connectedMcpServers, // Update MCP servers in case they changed + } : { id: '', messages: [], metadata: { @@ -348,7 +468,7 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre }, llmProvider: 'anthropic', model: 'claude-sonnet-4-20250514', - mcpServers: [], + mcpServers: connectedMcpServers, }; // Add user message @@ -475,6 +595,19 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre const getToolEmoji = () => '🔧'; + // Handle connect with timeout - checks Spider availability + const handleConnectWithTimeout = async () => { + const timeout = setTimeout(() => { + setSpiderUnavailable(true); + }, 3000); + + try { + await onConnectClick(); + } finally { + clearTimeout(timeout); + } + }; + // Inactive state - show connect button if (!isActive) { return ( @@ -484,13 +617,24 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre
-

Connect to Spider to enable chat

- + {spiderUnavailable ? ( + <> +

Spider service is not available

+

+ Make sure Spider is installed and running +

+ + ) : ( + <> +

Connect to Spider to enable chat

+ + + )}
@@ -499,7 +643,8 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre // Active state - show chat interface return ( -
+ <> +

Spider Chat

@@ -576,16 +721,26 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre {toolCalls && toolCalls.map((toolCall, toolIndex) => { const isLastMessage = index === conversation.messages.length - 1; const isWaitingForResult = isLastMessage && isLoading; + + // Find corresponding tool result + let toolResult: ToolResult | undefined; + if (nextMsg && nextMsg.role === 'tool') { + const results = JSON.parse(nextMsg.content || '[]') as ToolResult[]; + toolResult = results.find(r => r.tool_call_id === toolCall.id); + } return (
-
+
+
); })} @@ -641,6 +796,15 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre )}
-
+
+ + {selectedToolCall && ( + setSelectedToolCall(null)} + /> + )} + ); } \ No newline at end of file diff --git a/operator/ui/src/components/console/OneClickOperatorBoot.tsx b/operator/ui/src/components/console/OneClickOperatorBoot.tsx index ed708b3..0116419 100644 --- a/operator/ui/src/components/console/OneClickOperatorBoot.tsx +++ b/operator/ui/src/components/console/OneClickOperatorBoot.tsx @@ -44,6 +44,22 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat const operatorSubLabel = 'grid-wallet'; const [ownerNodeName, setOwnerNodeName] = useState(defaultOperatorEntryName || ''); + // Helper to format address for display + const formatAddress = (address: string | undefined) => { + if (!address) return ''; + return `${address.slice(0, 6)}...${address.slice(-4)}`; + }; + + // Check if connected wallet matches the owner EOA + const isCorrectWallet = useMemo(() => { + if (!eoa || !ownerEoa) return false; + return eoa.toLowerCase() === ownerEoa.toLowerCase(); + }, [eoa, ownerEoa]); + + const isWrongWallet = useMemo(() => { + return eoa && ownerEoa && !isCorrectWallet; + }, [eoa, ownerEoa, isCorrectWallet]); + // Debug logs console.log('[OneClickOperatorBoot] Component props:', { parentTbaAddress, @@ -66,12 +82,13 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat const disabled = useMemo(() => { // For fresh nodes without a parent TBA, we can still proceed if we have EOA and entry name - return !operatorEntryName || !eoa || isPending || isConfirming; - }, [operatorEntryName, eoa, isPending, isConfirming]); + return !operatorEntryName || !eoa || isWrongWallet || isPending || isConfirming; + }, [operatorEntryName, eoa, isWrongWallet, isPending, isConfirming]); const disabledReasons = useMemo(() => { const reasons: string[] = []; if (!eoa) reasons.push('wallet not connected - be sure to connect the wallet that owns your Hyperware name'); + if (isWrongWallet && ownerEoa) reasons.push(`wrong wallet connected - please connect ${formatAddress(ownerEoa)}`); // For fresh nodes, we might not have a parent TBA yet - that's ok, we'll mint directly if (!parentTbaAddress && ownerNodeName) { // If we have a node name but no TBA, this might be a fresh node @@ -81,7 +98,7 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat if (isPending) reasons.push('transaction pending'); if (isConfirming) reasons.push('waiting for confirmation'); return reasons; - }, [eoa, parentTbaAddress, operatorEntryName, isPending, isConfirming, ownerNodeName]); + }, [eoa, isWrongWallet, ownerEoa, parentTbaAddress, operatorEntryName, isPending, isConfirming, ownerNodeName, formatAddress]); const buildBundle = useCallback((): | { @@ -216,7 +233,7 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat disabled={disabled} style={{ background: disabled ? '#e5e7eb' : '#111827', color: disabled ? '#9ca3af' : '#ffffff', padding: '8px 12px', border: '1px solid #d1d5db', borderRadius: 8 }} > - {isPending || isConfirming ? 'Confirm in wallet…' : 'Create wallet'} + {isPending || isConfirming ? 'Confirm in wallet…' : isWrongWallet ? 'Wrong wallet' : 'Create wallet'} {hash && (
@@ -234,11 +251,27 @@ const OneClickOperatorBoot: React.FC = ({ parentTbaAddress, defaultOperat
)} - {eoa && disabled && disabledReasons.length > 0 && ( + {isWrongWallet && ownerEoa && ( +
+
+
⚠️ Wrong wallet connected
+
Current wallet: {formatAddress(eoa)}
+
Expected wallet: {formatAddress(ownerEoa)} (owns {ownerNodeName || 'your node'})
+
Please switch to the correct wallet to continue.
+
+ +
+ )} + {eoa && !isWrongWallet && disabled && disabledReasons.length > 0 && (
{disabledReasons.join(', ')}
)} + {isCorrectWallet && !disabled && ( +
+ ✅ Correct wallet connected ({formatAddress(eoa)}) +
+ )} {/* details removed per request */}
diff --git a/operator/ui/src/components/console/OperatorFinalizeSetup.tsx b/operator/ui/src/components/console/OperatorFinalizeSetup.tsx index 81a4b57..185ba1c 100644 --- a/operator/ui/src/components/console/OperatorFinalizeSetup.tsx +++ b/operator/ui/src/components/console/OperatorFinalizeSetup.tsx @@ -115,7 +115,7 @@ const OperatorFinalizeSetup: React.FC = ({ operatorTbaAddress, hotWalletA
- This final setup transaction will authorize your node running Hypergrid to make USDC payments on your behalf without you needing to manually sign each transaction. + This final setup transaction will authorize Hypergrid to make USDC payments on your behalf without you needing to manually sign each transaction.
diff --git a/provider/ui/src/components/RegisteredProviderView.tsx b/provider/ui/src/components/RegisteredProviderView.tsx index a8959c2..8796e6b 100644 --- a/provider/ui/src/components/RegisteredProviderView.tsx +++ b/provider/ui/src/components/RegisteredProviderView.tsx @@ -55,7 +55,7 @@ const RegisteredProviderView: React.FC = ({ provide 🔌

- {provider.provider_name}.obfusc-grid123.hypr + {provider.provider_name}.grid.hypr

{provider.description && (

{provider.description}

diff --git a/provider/ui/src/components/UnifiedTerminalInterface.tsx b/provider/ui/src/components/UnifiedTerminalInterface.tsx index c11c186..3a8c971 100644 --- a/provider/ui/src/components/UnifiedTerminalInterface.tsx +++ b/provider/ui/src/components/UnifiedTerminalInterface.tsx @@ -62,6 +62,10 @@ const UnifiedTerminalInterface: React.FC = ({ offchain
+
+ Paste a sample curl command for your target API. You can then define dynamic parameters that agents can customize at runtime. +
+
= ({ className="bg-transparent border-none outline-none text-yellow-600 dark:text-yellow-400 placeholder-stone-500 dark:placeholder-gray-600 font-mono text-lg font-medium" /> )} - .obfusc-grid123.hypr + .grid.hypr provider namespace
@@ -187,4 +191,4 @@ const UnifiedTerminalInterface: React.FC = ({ ); }; -export default UnifiedTerminalInterface; +export default UnifiedTerminalInterface; \ No newline at end of file From ca7118963d033afe4a9411abe56c7a9594139a4e Mon Sep 17 00:00:00 2001 From: Hallmane Date: Thu, 25 Sep 2025 23:56:14 +0200 Subject: [PATCH 5/8] oops --- operator/api/operator-process.wit | 234 +++++++++++++++--------------- provider/provider/Cargo.toml | 2 +- 2 files changed, 118 insertions(+), 118 deletions(-) diff --git a/operator/api/operator-process.wit b/operator/api/operator-process.wit index 439e7a6..36bbdf9 100644 --- a/operator/api/operator-process.wit +++ b/operator/api/operator-process.wit @@ -1,100 +1,6 @@ interface operator-process { use standard.{address}; - record spider-chat-dto { - api-key: string, - messages: list, - llm-provider: option, - model: option, - mcp-servers: option>, - metadata: option - } - - record spider-conversation-metadata { - start-time: string, - client: string, - from-stt: bool - } - - record configure-authorized-client-dto { - client-id: option, - client-name: option, - raw-token: string, - hot-wallet-address-to-associate: string - } - - record provider-search-result { - provider-id: string, - name: string, - description: string - } - - record spider-mcp-servers-result { - servers: list, - error: option - } - - record spider-mcp-server { - id: string, - name: string, - description: option - } - - record spider-chat-result { - conversation-id: string, - response: spider-message, - all-messages: option> - } - - record spider-message { - role: string, - content: string, - tool-calls-json: option, - tool-results-json: option, - timestamp: u64 - } - - record spider-status-result { - connected: bool, - has-api-key: bool, - spider-available: bool - } - - record provider-info { - id: option, - provider-id: string, - name: string, - description: option, - site: option, - wallet: option, - price: option, - instructions: option, - hash: string - } - - record key-value { - key: string, - value: string - } - - record spider-connect-result { - api-key: string - } - - record authorize-result { - url: string, - token: string, - client-id: string, - node: string - } - - record configure-authorized-client-result { - client-id: string, - raw-token: string, - api-base-path: string, - node-name: string - } - variant terminal-command { get-state, reset-state, @@ -157,11 +63,6 @@ use standard.{address}; status: client-status } - variant client-status { - active, - halted - } - variant service-capabilities { all, search-only, @@ -179,13 +80,27 @@ use standard.{address}; payment-tx-hash: option } - record provider { + variant client-status { + active, + halted + } + + record spider-api-key { + key: string, name: string, - hash: string, - facts: list>>, - wallet: option, - price: option, - provider-id: option + permissions: list, + created-at: u64 + } + + record active-account-details { + id: string, + name: option, + address: string, + is-encrypted: bool, + is-selected: bool, + is-unlocked: bool, + eth-balance: option, + usdc-balance: option } record managed-wallet { @@ -202,22 +117,107 @@ use standard.{address}; total-spent: option } - record active-account-details { - id: string, - name: option, - address: string, - is-encrypted: bool, - is-selected: bool, - is-unlocked: bool, - eth-balance: option, - usdc-balance: option + record authorize-result { + url: string, + token: string, + client-id: string, + node: string } - record spider-api-key { + record spider-chat-result { + conversation-id: string, + response: spider-message, + all-messages: option> + } + + record spider-status-result { + connected: bool, + has-api-key: bool, + spider-available: bool + } + + record spider-chat-dto { + api-key: string, + messages: list, + llm-provider: option, + model: option, + mcp-servers: option>, + metadata: option + } + + record spider-conversation-metadata { + start-time: string, + client: string, + from-stt: bool + } + + record spider-message { + role: string, + content: string, + tool-calls-json: option, + tool-results-json: option, + timestamp: u64 + } + + record key-value { key: string, + value: string + } + + record configure-authorized-client-result { + client-id: string, + raw-token: string, + api-base-path: string, + node-name: string + } + + record spider-mcp-servers-result { + servers: list, + error: option + } + + record spider-mcp-server { + id: string, name: string, - permissions: list, - created-at: u64 + description: option + } + + record provider-info { + id: option, + provider-id: string, + name: string, + description: option, + site: option, + wallet: option, + price: option, + instructions: option, + hash: string + } + + record configure-authorized-client-dto { + client-id: option, + client-name: option, + raw-token: string, + hot-wallet-address-to-associate: string + } + + record spider-connect-result { + api-key: string + } + + record provider-search-result { + provider-id: string, + name: string, + description: string + } + + record provider { + name: string, + hash: string, + facts: list>>, + wallet: option, + price: option, + provider-id: option } // Function signature for: authorize (http) diff --git a/provider/provider/Cargo.toml b/provider/provider/Cargo.toml index 0db5793..8c51347 100644 --- a/provider/provider/Cargo.toml +++ b/provider/provider/Cargo.toml @@ -13,7 +13,7 @@ wit-bindgen = "0.42.1" optional = true path = "../target/caller-utils" -dependencies.hyperware_process_lib] +[dependencies.hyperware_process_lib] features = [ "hyperapp", "logging", From ec771f020eb1786a8c06981c636122ee7d92468e Mon Sep 17 00:00:00 2001 From: Hallmane Date: Fri, 26 Sep 2025 17:15:52 +0200 Subject: [PATCH 6/8] spider panic after reboot fix --- operator/api/operator-process.wit | 170 +++++++++++----------- operator/ui/src/components/SpiderChat.tsx | 4 - 2 files changed, 85 insertions(+), 89 deletions(-) diff --git a/operator/api/operator-process.wit b/operator/api/operator-process.wit index 36bbdf9..66f30d5 100644 --- a/operator/api/operator-process.wit +++ b/operator/api/operator-process.wit @@ -1,6 +1,25 @@ interface operator-process { use standard.{address}; + record configure-authorized-client-dto { + client-id: option, + client-name: option, + raw-token: string, + hot-wallet-address-to-associate: string + } + + record provider-info { + id: option, + provider-id: string, + name: string, + description: option, + site: option, + wallet: option, + price: option, + instructions: option, + hash: string + } + variant terminal-command { get-state, reset-state, @@ -54,6 +73,38 @@ use standard.{address}; provider-name: option } + record managed-wallet { + id: string, + name: option, + storage-json: string, + spending-limits: spending-limits + } + + record spending-limits { + max-per-call: option, + max-total: option, + currency: option, + total-spent: option + } + + record active-account-details { + id: string, + name: option, + address: string, + is-encrypted: bool, + is-selected: bool, + is-unlocked: bool, + eth-balance: option, + usdc-balance: option + } + + record spider-api-key { + key: string, + name: string, + permissions: list, + created-at: u64 + } + record hot-wallet-authorized-client { id: string, name: string, @@ -63,6 +114,11 @@ use standard.{address}; status: client-status } + variant client-status { + active, + halted + } + variant service-capabilities { all, search-only, @@ -80,41 +136,34 @@ use standard.{address}; payment-tx-hash: option } - variant client-status { - active, - halted + record key-value { + key: string, + value: string } - record spider-api-key { - key: string, - name: string, - permissions: list, - created-at: u64 + record spider-status-result { + connected: bool, + has-api-key: bool, + spider-available: bool } - record active-account-details { - id: string, - name: option, - address: string, - is-encrypted: bool, - is-selected: bool, - is-unlocked: bool, - eth-balance: option, - usdc-balance: option + record spider-chat-result { + conversation-id: string, + response: spider-message, + all-messages: option> } - record managed-wallet { - id: string, - name: option, - storage-json: string, - spending-limits: spending-limits + record provider-search-result { + provider-id: string, + name: string, + description: string } - record spending-limits { - max-per-call: option, - max-total: option, - currency: option, - total-spent: option + record configure-authorized-client-result { + client-id: string, + raw-token: string, + api-base-path: string, + node-name: string } record authorize-result { @@ -124,18 +173,6 @@ use standard.{address}; node: string } - record spider-chat-result { - conversation-id: string, - response: spider-message, - all-messages: option> - } - - record spider-status-result { - connected: bool, - has-api-key: bool, - spider-available: bool - } - record spider-chat-dto { api-key: string, messages: list, @@ -159,16 +196,17 @@ use standard.{address}; timestamp: u64 } - record key-value { - key: string, - value: string + record provider { + name: string, + hash: string, + facts: list>>, + wallet: option, + price: option, + provider-id: option } - record configure-authorized-client-result { - client-id: string, - raw-token: string, - api-base-path: string, - node-name: string + record spider-connect-result { + api-key: string } record spider-mcp-servers-result { @@ -182,44 +220,6 @@ use standard.{address}; description: option } - record provider-info { - id: option, - provider-id: string, - name: string, - description: option, - site: option, - wallet: option, - price: option, - instructions: option, - hash: string - } - - record configure-authorized-client-dto { - client-id: option, - client-name: option, - raw-token: string, - hot-wallet-address-to-associate: string - } - - record spider-connect-result { - api-key: string - } - - record provider-search-result { - provider-id: string, - name: string, - description: string - } - - record provider { - name: string, - hash: string, - facts: list>>, - wallet: option, - price: option, - provider-id: option - } - // Function signature for: authorize (http) // HTTP: POST /mcp-authorize record authorize-signature-http { diff --git a/operator/ui/src/components/SpiderChat.tsx b/operator/ui/src/components/SpiderChat.tsx index a26a6ca..0ffb4e6 100644 --- a/operator/ui/src/components/SpiderChat.tsx +++ b/operator/ui/src/components/SpiderChat.tsx @@ -9,7 +9,6 @@ interface Conversation { messages: SpiderMessage[]; metadata: ConversationMetadata; llmProvider: string; - model?: string; mcpServers: string[]; mcpServersDetails?: McpServerDetails[] | null; } @@ -467,7 +466,6 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre fromStt: false, }, llmProvider: 'anthropic', - model: 'claude-sonnet-4-20250514', mcpServers: connectedMcpServers, }; @@ -492,7 +490,6 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre payload: { messages: updatedConversation.messages, llmProvider: updatedConversation.llmProvider, - model: updatedConversation.model, mcpServers: updatedConversation.mcpServers, metadata: updatedConversation.metadata, conversationId: updatedConversation.id || undefined @@ -510,7 +507,6 @@ export default function SpiderChat({ spiderApiKey, onConnectClick, onApiKeyRefre api_key: spiderApiKey, messages: updatedConversation.messages, llm_provider: updatedConversation.llmProvider, - model: updatedConversation.model, mcp_servers: updatedConversation.mcpServers, metadata: updatedConversation.metadata, } From 13be2b961b304627aa218eb0d6fc93d5f4676479 Mon Sep 17 00:00:00 2001 From: Hallmane Date: Tue, 30 Sep 2025 14:52:06 +0200 Subject: [PATCH 7/8] f5 --- operator/api/operator-process.wit | 206 +++++++++++++++--------------- operator/operator/src/shim.rs | 1 - provider/Cargo.toml | 6 +- provider/provider/Cargo.toml | 15 ++- 4 files changed, 114 insertions(+), 114 deletions(-) diff --git a/operator/api/operator-process.wit b/operator/api/operator-process.wit index 66f30d5..a4625ca 100644 --- a/operator/api/operator-process.wit +++ b/operator/api/operator-process.wit @@ -1,23 +1,57 @@ interface operator-process { use standard.{address}; - record configure-authorized-client-dto { - client-id: option, - client-name: option, - raw-token: string, - hot-wallet-address-to-associate: string + record spider-chat-result { + conversation-id: string, + response: spider-message, + all-messages: option> } - record provider-info { - id: option, - provider-id: string, + record spider-mcp-servers-result { + servers: list, + error: option + } + + record spider-mcp-server { + id: string, name: string, - description: option, - site: option, - wallet: option, - price: option, - instructions: option, - hash: string + description: option + } + + record authorize-result { + url: string, + token: string, + client-id: string, + node: string + } + + record spider-status-result { + connected: bool, + has-api-key: bool, + spider-available: bool + } + + record spider-chat-dto { + api-key: string, + messages: list, + llm-provider: option, + model: option, + mcp-servers: option>, + metadata: option + } + + record spider-conversation-metadata { + start-time: string, + client: string, + from-stt: bool + } + + record spider-message { + role: string, + content: string, + tool-calls-json: option, + tool-results-json: option, + timestamp: u64 } variant terminal-command { @@ -58,33 +92,11 @@ use standard.{address}; spider-api-key: option } - record call-record { - timestamp-start-ms: u64, - provider-lookup-key: string, - target-provider-id: string, - call-args-json: string, - response-json: option, - call-success: bool, - response-timestamp-ms: u64, - payment-result: option, - duration-ms: u64, - operator-wallet-id: option, - client-id: option, - provider-name: option - } - - record managed-wallet { - id: string, - name: option, - storage-json: string, - spending-limits: spending-limits - } - - record spending-limits { - max-per-call: option, - max-total: option, - currency: option, - total-spent: option + record spider-api-key { + key: string, + name: string, + permissions: list, + created-at: u64 } record active-account-details { @@ -98,13 +110,6 @@ use standard.{address}; usdc-balance: option } - record spider-api-key { - key: string, - name: string, - permissions: list, - created-at: u64 - } - record hot-wallet-authorized-client { id: string, name: string, @@ -136,21 +141,38 @@ use standard.{address}; payment-tx-hash: option } - record key-value { - key: string, - value: string + record managed-wallet { + id: string, + name: option, + storage-json: string, + spending-limits: spending-limits } - record spider-status-result { - connected: bool, - has-api-key: bool, - spider-available: bool + record spending-limits { + max-per-call: option, + max-total: option, + currency: option, + total-spent: option } - record spider-chat-result { - conversation-id: string, - response: spider-message, - all-messages: option> + record call-record { + timestamp-start-ms: u64, + provider-lookup-key: string, + target-provider-id: string, + call-args-json: string, + response-json: option, + call-success: bool, + response-timestamp-ms: u64, + payment-result: option, + duration-ms: u64, + operator-wallet-id: option, + client-id: option, + provider-name: option + } + + record key-value { + key: string, + value: string } record provider-search-result { @@ -159,41 +181,16 @@ use standard.{address}; description: string } - record configure-authorized-client-result { - client-id: string, - raw-token: string, - api-base-path: string, - node-name: string - } - - record authorize-result { - url: string, - token: string, - client-id: string, - node: string - } - - record spider-chat-dto { - api-key: string, - messages: list, - llm-provider: option, - model: option, - mcp-servers: option>, - metadata: option - } - - record spider-conversation-metadata { - start-time: string, - client: string, - from-stt: bool - } - - record spider-message { - role: string, - content: string, - tool-calls-json: option, - tool-results-json: option, - timestamp: u64 + record provider-info { + id: option, + provider-id: string, + name: string, + description: option, + site: option, + wallet: option, + price: option, + instructions: option, + hash: string } record provider { @@ -205,19 +202,22 @@ use standard.{address}; provider-id: option } - record spider-connect-result { - api-key: string + record configure-authorized-client-dto { + client-id: option, + client-name: option, + raw-token: string, + hot-wallet-address-to-associate: string } - record spider-mcp-servers-result { - servers: list, - error: option + record configure-authorized-client-result { + client-id: string, + raw-token: string, + api-base-path: string, + node-name: string } - record spider-mcp-server { - id: string, - name: string, - description: option + record spider-connect-result { + api-key: string } // Function signature for: authorize (http) diff --git a/operator/operator/src/shim.rs b/operator/operator/src/shim.rs index 5d5316a..02d7bea 100644 --- a/operator/operator/src/shim.rs +++ b/operator/operator/src/shim.rs @@ -4,7 +4,6 @@ use crate::structs::{ ConfigureAuthorizedClientDto, ConfigureAuthorizedClientResult, HotWalletAuthorizedClient, PaymentAttemptResult, ServiceCapabilities, State, DEFAULT_NODE_NAME, }; -// use crate::ledger; // TODO: Enable when ledger is async use alloy_primitives::Address as EthAddress; use hex; use hyperware_process_lib::{ diff --git a/provider/Cargo.toml b/provider/Cargo.toml index fb5285f..5d9160c 100644 --- a/provider/Cargo.toml +++ b/provider/Cargo.toml @@ -6,8 +6,8 @@ panic = "abort" [workspace] members = [ "provider", - "target/caller-utils", - "caller-utils", - "target/caller-util?", + "target/provider-caller-utils", + "target/provider-caller-util?", + "target/provider-caller-util?", ] resolver = "2" diff --git a/provider/provider/Cargo.toml b/provider/provider/Cargo.toml index 8c51347..17958fb 100644 --- a/provider/provider/Cargo.toml +++ b/provider/provider/Cargo.toml @@ -13,6 +13,10 @@ wit-bindgen = "0.42.1" optional = true path = "../target/caller-utils" +[dependencies.hyperprocess_macro] +branch = "develop" +git = "https://github.com/hyperware-ai/hyperprocess-macro" + [dependencies.hyperware_process_lib] features = [ "hyperapp", @@ -21,19 +25,16 @@ features = [ git = "https://github.com/hyperware-ai/process_lib" rev = "b9f1ead" -[dependencies.hyperprocess_macro] -git = "https://github.com/hyperware-ai/hyperprocess-macro" -branch = "develop" - -[dependencies.hyperware_app_common] -git = "https://github.com/hyperware-ai/hyperprocess-macro" -branch = "develop" +[dependencies.provider_caller_utils] +optional = true +path = "../target/provider-caller-utils" [dependencies.serde] features = ["derive"] version = "1.0" [features] +caller-utils = ["provider_caller_utils"] simulation-mode = [] [lib] From e08fc8b0cd39f68f0476065a2204ced57e93ac9e Mon Sep 17 00:00:00 2001 From: Hallmane Date: Wed, 1 Oct 2025 13:31:59 +0200 Subject: [PATCH 8/8] provider compiling issue --- operator/api/operator-process.wit | 176 +++++++++--------- provider/Cargo.toml | 1 - provider/provider/Cargo.toml | 4 - .../hypergrid-provider-test/Cargo.toml | 4 - 4 files changed, 88 insertions(+), 97 deletions(-) diff --git a/operator/api/operator-process.wit b/operator/api/operator-process.wit index a4625ca..d5904b6 100644 --- a/operator/api/operator-process.wit +++ b/operator/api/operator-process.wit @@ -1,28 +1,10 @@ interface operator-process { use standard.{address}; - record spider-chat-result { - conversation-id: string, - response: spider-message, - all-messages: option> - } - - record spider-mcp-servers-result { - servers: list, - error: option - } - - record spider-mcp-server { - id: string, + record provider-search-result { + provider-id: string, name: string, - description: option - } - - record authorize-result { - url: string, - token: string, - client-id: string, - node: string + description: string } record spider-status-result { @@ -31,27 +13,34 @@ use standard.{address}; spider-available: bool } - record spider-chat-dto { - api-key: string, - messages: list, - llm-provider: option, - model: option, - mcp-servers: option>, - metadata: option + record provider-info { + id: option, + provider-id: string, + name: string, + description: option, + site: option, + wallet: option, + price: option, + instructions: option, + hash: string } - record spider-conversation-metadata { - start-time: string, - client: string, - from-stt: bool + record spider-mcp-servers-result { + servers: list, + error: option } - record spider-message { - role: string, - content: string, - tool-calls-json: option, - tool-results-json: option, - timestamp: u64 + record spider-mcp-server { + id: string, + name: string, + description: option + } + + record configure-authorized-client-dto { + client-id: option, + client-name: option, + raw-token: string, + hot-wallet-address-to-associate: string } variant terminal-command { @@ -92,22 +81,18 @@ use standard.{address}; spider-api-key: option } - record spider-api-key { - key: string, - name: string, - permissions: list, - created-at: u64 - } - - record active-account-details { + record managed-wallet { id: string, name: option, - address: string, - is-encrypted: bool, - is-selected: bool, - is-unlocked: bool, - eth-balance: option, - usdc-balance: option + storage-json: string, + spending-limits: spending-limits + } + + record spending-limits { + max-per-call: option, + max-total: option, + currency: option, + total-spent: option } record hot-wallet-authorized-client { @@ -119,11 +104,6 @@ use standard.{address}; status: client-status } - variant client-status { - active, - halted - } - variant service-capabilities { all, search-only, @@ -141,18 +121,27 @@ use standard.{address}; payment-tx-hash: option } - record managed-wallet { + variant client-status { + active, + halted + } + + record active-account-details { id: string, name: option, - storage-json: string, - spending-limits: spending-limits + address: string, + is-encrypted: bool, + is-selected: bool, + is-unlocked: bool, + eth-balance: option, + usdc-balance: option } - record spending-limits { - max-per-call: option, - max-total: option, - currency: option, - total-spent: option + record spider-api-key { + key: string, + name: string, + permissions: list, + created-at: u64 } record call-record { @@ -170,27 +159,31 @@ use standard.{address}; provider-name: option } + record configure-authorized-client-result { + client-id: string, + raw-token: string, + api-base-path: string, + node-name: string + } + record key-value { key: string, value: string } - record provider-search-result { - provider-id: string, - name: string, - description: string + record spider-chat-result { + conversation-id: string, + response: spider-message, + all-messages: option> } - record provider-info { - id: option, - provider-id: string, - name: string, - description: option, - site: option, - wallet: option, - price: option, - instructions: option, - hash: string + record spider-chat-dto { + api-key: string, + messages: list, + llm-provider: option, + model: option, + mcp-servers: option>, + metadata: option } record provider { @@ -202,18 +195,25 @@ use standard.{address}; provider-id: option } - record configure-authorized-client-dto { - client-id: option, - client-name: option, - raw-token: string, - hot-wallet-address-to-associate: string + record spider-conversation-metadata { + start-time: string, + client: string, + from-stt: bool } - record configure-authorized-client-result { + record spider-message { + role: string, + content: string, + tool-calls-json: option, + tool-results-json: option, + timestamp: u64 + } + + record authorize-result { + url: string, + token: string, client-id: string, - raw-token: string, - api-base-path: string, - node-name: string + node: string } record spider-connect-result { diff --git a/provider/Cargo.toml b/provider/Cargo.toml index 5d9160c..18f5374 100644 --- a/provider/Cargo.toml +++ b/provider/Cargo.toml @@ -8,6 +8,5 @@ members = [ "provider", "target/provider-caller-utils", "target/provider-caller-util?", - "target/provider-caller-util?", ] resolver = "2" diff --git a/provider/provider/Cargo.toml b/provider/provider/Cargo.toml index 17958fb..3371980 100644 --- a/provider/provider/Cargo.toml +++ b/provider/provider/Cargo.toml @@ -9,10 +9,6 @@ urlencoding = "2.1" uuid = "1.4.1" wit-bindgen = "0.42.1" -[dependencies.caller-utils] -optional = true -path = "../target/caller-utils" - [dependencies.hyperprocess_macro] branch = "develop" git = "https://github.com/hyperware-ai/hyperprocess-macro" diff --git a/provider/test/hypergrid-provider-test/hypergrid-provider-test/Cargo.toml b/provider/test/hypergrid-provider-test/hypergrid-provider-test/Cargo.toml index 8283646..e94a93f 100644 --- a/provider/test/hypergrid-provider-test/hypergrid-provider-test/Cargo.toml +++ b/provider/test/hypergrid-provider-test/hypergrid-provider-test/Cargo.toml @@ -7,10 +7,6 @@ publish = false [dependencies.caller-utils] path = "../../../target/caller-utils" -[dependencies.hyperware_app_common] -git = "https://github.com/hyperware-ai/hyperprocess-macro" -rev = "b6ad495" - [dependencies] anyhow = "1.0" process_macros = "0.1.0"