diff --git a/README.md b/README.md index 0eb7b48..9aba8c2 100644 --- a/README.md +++ b/README.md @@ -105,27 +105,56 @@ node dist/index.js new_chat ### 6. send_prompt -Sends a natural language prompt to CoinFello. If the server requires a delegation to execute the action, the CLI creates and signs a subdelegation automatically based on the server's requested scope and chain. Requires `create_account` and `sign_in` to have been run first. +Sends a natural language prompt to CoinFello. If the server requires a delegation to execute the action, the CLI saves the delegation request to a local file and logs the details to the terminal. The delegation is **not** signed or submitted automatically — you must explicitly approve it with `approve_delegation_request`. Requires `create_account` and `sign_in` to have been run first. ```bash node dist/index.js send_prompt "send 5 USDC to 0xRecipient..." ``` -Expected output: +Expected output (when delegation is requested): ``` Sending prompt... -Delegation requested: scope=erc20, chainId=8453 +=== Delegation Request === +Scope type: erc20TransferAmount +Chain ID: 8453 +Token address: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 +Max amount: 5000000 +Original prompt: "send 5 USDC to 0xRecipient..." +Justification: "Transfer 5 USDC to 0xRecipient on Base" +Requested at: 2026-03-16T12:34:56.789Z +Chat ID: chat_abc123 +Call ID: call_abc123 +========================== +Delegation request saved to: /home//.clawdbot/skills/coinfello/pending_delegation.json +Run 'approve_delegation_request' to sign and submit this delegation. +``` + +### 7. approve_delegation_request + +Approves and signs a pending delegation request saved by `send_prompt`, then submits it to CoinFello. + +```bash +node dist/index.js approve_delegation_request +``` + +Expected output: + +``` +Approving delegation request... +=== Delegation Request === +... +========================== Fetching CoinFello delegate address... Loading smart account... Creating subdelegation... Signing subdelegation... Sending signed delegation... Transaction submitted successfully. -Transaction ID: +Transaction ID: ``` -### 7. signer-daemon +### 8. signer-daemon Manages the Secure Enclave signing daemon. Without the daemon, each signing operation (account creation, sign-in, delegation signing) triggers a separate Touch ID / password prompt. Starting the daemon authenticates once and caches the authorization for subsequent operations. diff --git a/coinfello/SKILL.md b/coinfello/SKILL.md index 954fdad..adefd55 100644 --- a/coinfello/SKILL.md +++ b/coinfello/SKILL.md @@ -54,7 +54,7 @@ This skill performs the following sensitive operations: - **Key generation and storage**: By default, `create_account` generates a hardware-backed P256 key in the **macOS Secure Enclave** (or TPM 2.0 where available). The private key never leaves the hardware and cannot be exported — only public key coordinates and a key tag are saved to `~/.clawdbot/skills/coinfello/config.json`. If hardware key support is not available, the CLI warns and falls back to a software private key. You can also explicitly opt into a plaintext software key by passing `--use-unsafe-private-key`, which stores a raw private key in the config file — **this is intended only for development and testing**. - **Signer daemon**: Running `signer-daemon start` authenticates once via Touch ID / password and caches the authorization. All subsequent signing operations reuse this cached context, eliminating repeated auth prompts. The daemon communicates over a user-scoped Unix domain socket with restricted permissions (`0600`). If the daemon is not running, signing operations fall back to direct execution (prompting Touch ID each time). - **Session token storage**: Running `sign_in` stores a SIWE session token in the same config file. -- **Delegation signing**: Running `send_prompt` may automatically create and sign blockchain delegations based on server-requested scopes, then submit them to the CoinFello API. +- **Delegation signing**: Running `send_prompt` may receive a delegation request from the server, which is saved to a local file. Running `approve_delegation_request` creates and signs the delegation, then submits it to the CoinFello API. Users should ensure they trust the CoinFello API endpoint configured via `COINFELLO_BASE_URL` before running delegation flows. @@ -70,8 +70,11 @@ npx @coinfello/agent-cli@latest create_account # 3. Sign in to CoinFello with your smart account (SIWE) npx @coinfello/agent-cli@latest sign_in -# 4. Send a natural language prompt — the server will request a delegation if needed +# 4. Send a natural language prompt — if a delegation is needed, it will be saved for review npx @coinfello/agent-cli@latest send_prompt "send 5 USDC to 0xRecipient..." + +# 5. Approve the delegation request (if one was saved by send_prompt) +npx @coinfello/agent-cli@latest approve_delegation_request ``` ## Commands @@ -148,7 +151,7 @@ npx @coinfello/agent-cli@latest signer-daemon stop # Stop the daemon ### send_prompt -Sends a natural language prompt to CoinFello. If the server requires a delegation to execute the action, the CLI creates and signs a subdelegation automatically based on the server's requested scope and chain. +Sends a natural language prompt to CoinFello. If the server requires a delegation to execute the action, the CLI saves the delegation request to a local file and logs the details to the terminal for review. The delegation is **not** signed automatically — you must explicitly approve it with `approve_delegation_request`. ```bash npx @coinfello/agent-cli@latest send_prompt "" @@ -164,12 +167,27 @@ then you should call `npx @coinfello/agent-cli@latest new_chat` to start a new c 2. If the server returns a read-only response (no `clientToolCalls` and no `txn_id`) → prints the response text and exits 3. If the server returns a `txn_id` directly with no tool calls → prints it and exits 4. If the server sends an `ask_for_delegation` client tool call with a `chainId` and `scope`: - - Fetches CoinFello's delegate address - - Rebuilds the smart account using the chain ID from the tool call - - Parses the server-provided scope (supports ERC-20, native token, ERC-721, and function call scopes) - - Creates and signs a subdelegation (wraps with ERC-6492 signature if the smart account is not yet deployed on-chain) - - Sends the signed delegation back as a `clientToolCallResponse` along with the `chatId` and `callId` from the initial response - - Returns a `txn_id` for tracking + - Saves the delegation request (scope, chain ID, call ID, chat ID) to `~/.clawdbot/skills/coinfello/pending_delegation.json` + - Logs a human-readable summary of the delegation request to the terminal + - Exits without signing — run `approve_delegation_request` to approve + +### approve_delegation_request + +Approves and signs a pending delegation request saved by `send_prompt`, then submits it to CoinFello. + +```bash +npx @coinfello/agent-cli@latest approve_delegation_request +``` + +**What happens internally:** + +1. Reads the pending delegation from `~/.clawdbot/skills/coinfello/pending_delegation.json` +2. Fetches CoinFello's delegate address +3. Rebuilds the smart account using the chain ID from the delegation request +4. Parses the scope and creates a subdelegation (wraps with ERC-6492 signature if the smart account is not yet deployed on-chain) +5. Sends the signed delegation back as a `clientToolCallResponse` along with the `chatId` and `callId` +6. Clears the pending delegation file +7. Returns a `txn_id` for tracking ## Common Workflows @@ -185,8 +203,11 @@ npx @coinfello/agent-cli@latest create_account # Sign in (required for delegation flows) npx @coinfello/agent-cli@latest sign_in -# Send a natural language prompt — delegation is handled automatically +# Send a natural language prompt — if a delegation is needed, it will be saved for review npx @coinfello/agent-cli@latest send_prompt "send 5 USDC to 0xRecipient..." + +# Review the delegation request logged to the terminal, then approve it +npx @coinfello/agent-cli@latest approve_delegation_request ``` ### Read-Only Prompt diff --git a/coinfello/references/REFERENCE.md b/coinfello/references/REFERENCE.md index 8496743..561f11e 100644 --- a/coinfello/references/REFERENCE.md +++ b/coinfello/references/REFERENCE.md @@ -50,6 +50,40 @@ Created automatically by `create_account`. The schema depends on the signer type | `delegation` | `object` | `set_delegation` | Optional stored delegation | | `chat_id` | `string` | `send_prompt` | Persisted conversation chat ID reused across prompts; removed by `new_chat` | +## Pending Delegation File + +Location: `~/.clawdbot/skills/coinfello/pending_delegation.json` + +Created by `send_prompt` when the server requests a delegation. Read and cleared by `approve_delegation_request`. + +```json +{ + "delegationArgs": { + "chainId": 8453, + "scope": { + "type": "erc20TransferAmount", + "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "maxAmount": "5000000" + }, + "justification": "Transfer 5 USDC to 0xRecipient on Base" + }, + "callId": "call_abc123", + "chatId": "chat_xyz789", + "originalPrompt": "send 5 USDC to 0xRecipient...", + "createdAt": "2026-03-16T12:34:56.789Z", + "description": "Delegation for scope=erc20TransferAmount, chainId=8453" +} +``` + +| Field | Type | Description | +| ---------------- | -------- | ------------------------------------------------------------------------- | +| `delegationArgs` | `object` | Tool call arguments from the server (chainId, scope, justification, etc.) | +| `callId` | `string` | Tool call ID from the `ask_for_delegation` response | +| `chatId` | `string` | Chat session ID for conversation continuity | +| `originalPrompt` | `string` | The prompt that triggered the delegation request | +| `createdAt` | `string` | ISO-8601 timestamp of when the request was received | +| `description` | `string` | Human-readable summary of the delegation request | + ## Command Reference ### npx @coinfello/agent-cli@latest create_account @@ -131,10 +165,20 @@ npx @coinfello/agent-cli@latest send_prompt | --------- | -------- | -------- | ------- | -------------------------------------------- | | `prompt` | `string` | Yes | — | Natural language prompt to send to CoinFello | -The server determines whether a delegation is needed and, if so, what scope and chain to use. The client creates and signs the subdelegation based on the server's `ask_for_delegation` client tool call response. Each subdelegation is created with a unique random salt to ensure delegation uniqueness. +The server determines whether a delegation is needed and, if so, what scope and chain to use. If the server responds with an `ask_for_delegation` tool call, the delegation request is saved to `~/.clawdbot/skills/coinfello/pending_delegation.json` and logged to the terminal. The delegation is **not** signed automatically — run `approve_delegation_request` to approve it. `send_prompt` reuses `chat_id` from config when available and persists server-returned `chatId` values for continued context across calls. +### npx @coinfello/agent-cli@latest approve_delegation_request + +``` +npx @coinfello/agent-cli@latest approve_delegation_request +``` + +No parameters. Reads the pending delegation request from `~/.clawdbot/skills/coinfello/pending_delegation.json`, creates and signs a subdelegation, and submits it to CoinFello. Clears the pending delegation file on success. + +Each subdelegation is created with a unique random salt to ensure delegation uniqueness. + **ERC-6492 signature wrapping**: If the smart account has not yet been deployed on-chain, the CLI wraps the delegation signature using ERC-6492 (`serializeErc6492Signature`) with the account's factory address and factory data. This allows the delegation to be verified even before the account contract exists. ## Supported Chains @@ -177,7 +221,7 @@ The `send_prompt` command fetches this list and uses the first agent's `id` as ` ### POST /api/conversation body -Initial request (prompt only): +Initial request (prompt only, sent by `send_prompt`): ```json { @@ -189,7 +233,7 @@ Initial request (prompt only): `agentId` is dynamically resolved from the `/api/v1/automation/coinfello-agents` endpoint (not hardcoded). -The follow-up request (sending the signed delegation back) is handled internally by `send_prompt` — no manual construction is needed. +The follow-up request (sending the signed delegation back) is handled internally by `approve_delegation_request` — no manual construction is needed. ### POST /api/conversation response @@ -278,7 +322,7 @@ All `amount` fields are in the token's smallest unit (e.g. `5000000` for 5 USDC - **Key generation and storage**: By default, `create_account` generates a hardware-backed P256 key in the **macOS Secure Enclave**. The private key never leaves the hardware and cannot be exported — only public key coordinates and a key tag are saved to `~/.clawdbot/skills/coinfello/config.json`. A plaintext private key is **only** stored when `--use-unsafe-private-key` is explicitly passed (intended for development/testing). Restrict file permissions (e.g. `chmod 600`) and do not share or commit this file. - **Signer daemon**: The daemon caches a single authenticated `LAContext` on startup, so all signing operations within the session reuse the same authorization. The Unix domain socket is created with `0600` permissions and scoped to the current OS user. The daemon cleans up socket and PID files on `SIGTERM`/`SIGINT`. - **Session token storage**: `sign_in` stores a SIWE session token in the same config file. -- **Automatic delegation signing**: `send_prompt` may create and sign delegations based on scopes requested by the server, then submit them to the CoinFello API endpoint. Ensure the `COINFELLO_BASE_URL` points to a trusted endpoint before running delegation flows. +- **Delegation signing**: `send_prompt` saves delegation requests from the server to a local file. `approve_delegation_request` creates and signs the delegation, then submits it to the CoinFello API endpoint. Ensure the `COINFELLO_BASE_URL` points to a trusted endpoint before approving delegation requests. ## Error Messages @@ -288,5 +332,6 @@ All `amount` fields are in the token's smallest unit (e.g. `5000000` for 5 USDC | `Secure Enclave config missing. Run 'create_account' first.` | Missing Secure Enclave key data | Run `npx @coinfello/agent-cli@latest create_account` | | `No smart account found. Run 'create_account' first.` | Missing smart account in config | Run `npx @coinfello/agent-cli@latest create_account` | | `No delegation request received from the server.` | Server returned unexpected response | Check the full response JSON printed | +| `No pending delegation request found.` | No delegation saved by `send_prompt` | Run `send_prompt` first to generate a request | | `Signing daemon is already running.` | Daemon already started | Use `signer-daemon status` to confirm | | `Signing daemon is not running.` | Daemon not started or already stopped | Run `signer-daemon start` | diff --git a/package.json b/package.json index 4b65efc..5697b25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coinfello/agent-cli", - "version": "0.2.2", + "version": "0.3.0", "description": "CLI for managing a web3 smart account and executing blockchain transactions via CoinFello", "repository": "CoinFello/agent-cli", "homepage": "https://coinfello.com", diff --git a/src/config.ts b/src/config.ts index 4c6895b..ffdbc93 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,7 +18,7 @@ export interface Config { } } -const CONFIG_DIR = join(homedir(), '.clawdbot', 'skills', 'coinfello') +export const CONFIG_DIR = join(homedir(), '.clawdbot', 'skills', 'coinfello') export const CONFIG_PATH = join(CONFIG_DIR, 'config.json') export async function loadConfig(): Promise { diff --git a/src/delegation.ts b/src/delegation.ts new file mode 100644 index 0000000..82ba5b3 --- /dev/null +++ b/src/delegation.ts @@ -0,0 +1,234 @@ +import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises' +import { join } from 'node:path' +import { serializeErc6492Signature, type Hex } from 'viem' +import { + getSmartAccount, + getSmartAccountFromSecureEnclave, + createSubdelegation, + resolveChainInput, + type HybridSmartAccount, +} from './account.js' +import { getCoinFelloAddress, sendConversation, type ConversationResponse } from './api.js' +import { CONFIG_DIR, type Config, saveConfig } from './config.js' +import { parseScope } from './scope.js' +import type { RawScope } from './scope.js' +import { SignedSubdelegation } from './types.js' +import { createPublicClient } from './services/createPublicClient.js' + +// ── Pending delegation file ──────────────────────────────────── + +export const PENDING_DELEGATION_PATH = join(CONFIG_DIR, 'pending_delegation.json') + +export interface PendingDelegationRequest { + delegationArgs: { + chainId: string | number + scope: RawScope + [key: string]: unknown + justification?: string + } + callId: string + chatId: string + originalPrompt: string + createdAt: string + description: string +} + +export async function savePendingDelegation(request: PendingDelegationRequest): Promise { + await mkdir(CONFIG_DIR, { recursive: true }) + await writeFile(PENDING_DELEGATION_PATH, JSON.stringify(request, null, 2), 'utf-8') +} + +export async function loadPendingDelegation(): Promise { + try { + const raw = await readFile(PENDING_DELEGATION_PATH, 'utf-8') + return JSON.parse(raw) as PendingDelegationRequest + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error( + "No pending delegation request found. Run 'send_prompt' first to generate a delegation request." + ) + } + throw err + } +} + +export async function clearPendingDelegation(): Promise { + try { + await unlink(PENDING_DELEGATION_PATH) + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err + } + } +} + +// ── Display formatting ───────────────────────────────────────── + +export function formatDelegationRequestForDisplay(request: PendingDelegationRequest): string { + const { delegationArgs, callId, chatId, originalPrompt, createdAt } = request + const scope = delegationArgs.scope + const justification = delegationArgs.justification + + const lines: string[] = [ + '=== Delegation Request ===', + `Scope type: ${scope.type}`, + `Chain ID: ${delegationArgs.chainId}`, + ] + + if (scope.tokenAddress) lines.push(`Token address: ${scope.tokenAddress}`) + if (scope.maxAmount) lines.push(`Max amount: ${scope.maxAmount}`) + if (scope.periodAmount) lines.push(`Period amount: ${scope.periodAmount}`) + if (scope.periodDuration) lines.push(`Period duration: ${scope.periodDuration}`) + if (scope.startDate) lines.push(`Start date: ${scope.startDate}`) + if (scope.initialAmount) lines.push(`Initial amount: ${scope.initialAmount}`) + if (scope.amountPerSecond) lines.push(`Amount per second: ${scope.amountPerSecond}`) + if (scope.startTime) lines.push(`Start time: ${scope.startTime}`) + if (scope.tokenId) lines.push(`Token ID: ${scope.tokenId}`) + if (scope.targets?.length) lines.push(`Targets: ${scope.targets.join(', ')}`) + if (scope.selectors?.length) lines.push(`Selectors: ${scope.selectors.join(', ')}`) + if (scope.valueLte?.maxValue) lines.push(`Value <= ${scope.valueLte.maxValue}`) + + lines.push(`Original prompt: "${originalPrompt}"`) + if (justification) { + lines.push(`Justification: "${justification}"`) + } + lines.push(`Requested at: ${createdAt}`) + lines.push(`Chat ID: ${chatId}`) + lines.push(`Call ID: ${callId}`) + lines.push('==========================') + + return lines.join('\n') +} + +// ── Shared response handling ─────────────────────────────────── + +/** + * Handles a ConversationResponse uniformly — used by both send_prompt + * and approve_delegation_request so that chained delegation requests + * are handled identically. + * + * Returns true if the response was fully handled, false otherwise. + */ +export async function handleConversationResponse( + response: ConversationResponse, + config: Config, + originalPrompt: string +): Promise { + if (response.chatId && response.chatId !== config.chat_id) { + config.chat_id = response.chatId + await saveConfig(config) + } + + // Read-only response + if (!response.clientToolCalls?.length && !response.txn_id) { + console.log(response.responseText ?? '') + return + } + + // Direct transaction (no delegation needed) + if (response.txn_id && !response.clientToolCalls?.length) { + console.log('Transaction submitted successfully.') + console.log(`Transaction ID: ${response.txn_id}`) + return + } + + // Delegation requested — save for review instead of auto-approving + const delegationToolCall = response.clientToolCalls?.find( + (tc) => tc.name === 'ask_for_delegation' + ) + if (!delegationToolCall) { + console.error('Error: No delegation request received from the server.') + console.log('Response:', JSON.stringify(response, null, 2)) + process.exit(1) + } + + /* eslint-disable-next-line */ + const args = JSON.parse(delegationToolCall.arguments) as any + const pending = { + delegationArgs: args, + callId: delegationToolCall.callId, + chatId: response.chatId ?? config.chat_id ?? '', + originalPrompt, + createdAt: new Date().toISOString(), + description: `Delegation for scope=${args.scope?.type}, chainId=${args.chainId}`, + } + + await savePendingDelegation(pending) + + console.log(formatDelegationRequestForDisplay(pending)) + console.log(`Delegation request saved to: ${PENDING_DELEGATION_PATH}`) + console.log("Run 'approve_delegation_request' to sign and submit this delegation.") +} + +// ── Shared signing & submission ──────────────────────────────── + +export async function signAndSubmitDelegation( + config: Config, + pending: PendingDelegationRequest +): Promise { + const { delegationArgs, callId, chatId } = pending + + // 1. Get CoinFello delegate address + console.log('Fetching CoinFello delegate address...') + const delegateAddress = await getCoinFelloAddress() + + // 2. Load smart account for the requested chain + console.log('Loading smart account...') + let smartAccount: HybridSmartAccount + if (config.signer_type === 'secureEnclave') { + if (!config.secure_enclave) { + throw new Error("Secure Enclave config missing. Run 'create_account' first.") + } + smartAccount = await getSmartAccountFromSecureEnclave( + config.secure_enclave.key_tag, + config.secure_enclave.public_key_x, + config.secure_enclave.public_key_y, + config.secure_enclave.key_id as Hex, + delegationArgs.chainId + ) + } else { + smartAccount = await getSmartAccount(config.private_key as Hex, delegationArgs.chainId) + } + + // 3. Parse scope and create subdelegation + const scope = parseScope(delegationArgs.scope) + console.log('Creating subdelegation...') + const subdelegation = createSubdelegation({ + smartAccount, + delegateAddress: delegateAddress as Hex, + scope, + }) + + // 4. Sign the subdelegation + console.log('Signing subdelegation...') + const signature = await smartAccount.signDelegation({ delegation: subdelegation }) + let sig = signature + + // 5. Wrap with ERC-6492 if account is not deployed + const chain = resolveChainInput(delegationArgs.chainId) + const publicClient = createPublicClient(chain) + const code = await publicClient.getCode({ address: smartAccount.address }) + const isDeployed = !!(code && code !== '0x') + if (!isDeployed) { + const factoryArgs = await smartAccount.getFactoryArgs() + if (factoryArgs.factory && factoryArgs.factoryData) { + sig = serializeErc6492Signature({ + signature, + address: factoryArgs.factory as `0x${string}`, + data: factoryArgs.factoryData as `0x${string}`, + }) + } + } + + const signedSubdelegation: SignedSubdelegation = { ...subdelegation, signature: sig } + + // 6. Send signed delegation back to conversation endpoint + console.log('Sending signed delegation...') + return sendConversation({ + prompt: 'Please refer to the previous conversation messages and redeem this delegation.', + signedSubdelegation, + chatId, + delegationArguments: JSON.stringify(delegationArgs), + callId, + }) +} diff --git a/src/index.ts b/src/index.ts index 069bda3..fa4a5dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,29 +1,24 @@ import { Command } from 'commander' -import { - createSmartAccount, - getSmartAccount, - createSmartAccountWithSecureEnclave, - getSmartAccountFromSecureEnclave, - createSubdelegation, - resolveChainInput, - type HybridSmartAccount, -} from './account.js' +import { createSmartAccount, createSmartAccountWithSecureEnclave } from './account.js' import { loadConfig, saveConfig, CONFIG_PATH } from './config.js' -import { getCoinFelloAddress, sendConversation, BASE_URL_V1, BASE_URL } from './api.js' +import { sendConversation, BASE_URL_V1, BASE_URL } from './api.js' import { loadSessionToken } from './cookies.js' import { signInWithAgent } from './siwe.js' -import { parseScope } from './scope.js' -import { serializeErc6492Signature, type Hex } from 'viem' -import { createPublicClient } from './services/createPublicClient.js' import { generatePrivateKey } from 'viem/accounts' import type { Delegation } from '@metamask/smart-accounts-kit' -import { SignedSubdelegation } from './types.js' import { isSecureEnclaveAvailable, startDaemon, stopDaemon, isDaemonRunning, } from './secure-enclave/index.js' +import { + loadPendingDelegation, + clearPendingDelegation, + formatDelegationRequestForDisplay, + signAndSubmitDelegation, + handleConversationResponse, +} from './delegation.js' import packageJson from '../package.json' const program = new Command() @@ -187,7 +182,7 @@ program // ── send_prompt ───────────────────────────────────────────────── program .command('send_prompt') - .description('Send a prompt to CoinFello, creating a delegation if requested by the server') + .description('Send a prompt to CoinFello. If a delegation is requested, saves it for review.') .argument('', 'The prompt to send') .action(async (prompt: string) => { try { @@ -201,130 +196,55 @@ program process.exit(1) } - // Load persisted session token into cookie jar if (config.session_token) { await loadSessionToken(config.session_token, BASE_URL_V1) } - // 1. Send prompt-only to conversation endpoint console.log('Sending prompt...') - const initialResponse = await sendConversation({ + const response = await sendConversation({ prompt, chatId: config.chat_id, }) - if (initialResponse.chatId && initialResponse.chatId !== config.chat_id) { - config.chat_id = initialResponse.chatId - await saveConfig(config) - } - // Read-only response: no tool calls and no transaction - if (!initialResponse.clientToolCalls?.length && !initialResponse.txn_id) { - console.log(initialResponse.responseText ?? '') - return - } + await handleConversationResponse(response, config, prompt) + } catch (err) { + console.error(`Failed to send prompt: ${(err as Error).message}`) + process.exit(1) + } + }) - // If we got a direct txn_id with no tool calls, we're done - if (initialResponse.txn_id && !initialResponse.clientToolCalls?.length) { - console.log('Transaction submitted successfully.') - console.log(`Transaction ID: ${initialResponse.txn_id}`) - return +// ── approve_delegation_request ───────────────────────────────── +program + .command('approve_delegation_request') + .description('Approve and sign a pending delegation request, then submit it to CoinFello') + .action(async () => { + try { + const config = await loadConfig() + if (!config.smart_account_address) { + console.error("Error: No smart account found. Run 'create_account' first.") + process.exit(1) } - - // 2. Look for ask_for_delegation tool call - const delegationToolCall = initialResponse.clientToolCalls?.find( - (tc) => tc.name === 'ask_for_delegation' - ) - if (!delegationToolCall) { - console.error('Error: No delegation request received from the server.') - console.log('Response:', JSON.stringify(initialResponse, null, 2)) + if (config.signer_type !== 'secureEnclave' && !config.private_key) { + console.error("Error: No private key found in config. Run 'create_account' first.") process.exit(1) } - // 3. Parse tool call arguments - /* eslint-disable-next-line */ - const args = JSON.parse(delegationToolCall.arguments) as any - console.log(`Delegation requested: scope=${args.scope.type}, chainId=${args.chainId}`) - - // 4. Get CoinFello delegate address - console.log('Fetching CoinFello delegate address...') - const delegateAddress = await getCoinFelloAddress() - - // 5. Rebuild smart account using chainId from tool call - console.log('Loading smart account...') - let smartAccount: HybridSmartAccount - if (config.signer_type === 'secureEnclave') { - if (!config.secure_enclave) { - console.error("Error: Secure Enclave config missing. Run 'create_account' first.") - process.exit(1) - } - smartAccount = await getSmartAccountFromSecureEnclave( - config.secure_enclave.key_tag, - config.secure_enclave.public_key_x, - config.secure_enclave.public_key_y, - config.secure_enclave.key_id as Hex, - args.chainId - ) - } else { - smartAccount = await getSmartAccount(config.private_key as Hex, args.chainId) - } + const pending = await loadPendingDelegation() - // 6. Parse scope and create subdelegation - const scope = parseScope(args.scope) - console.log('Creating subdelegation...') - const subdelegation = createSubdelegation({ - smartAccount, - delegateAddress: delegateAddress as Hex, - scope, - }) + console.log('Approving delegation request...') + console.log(formatDelegationRequestForDisplay(pending)) - // 7. Sign the subdelegation - console.log('Signing subdelegation... ', JSON.stringify(subdelegation, null, 4)) - const signature = await smartAccount.signDelegation({ - delegation: subdelegation, - }) - console.log('Signed subdelegation') - let sig = signature - const chain = resolveChainInput(args.chainId) - - const publicClient = createPublicClient(chain) - console.log('Getting code...') - const code = await publicClient.getCode({ address: smartAccount.address }) - console.log('code is ', code) - const isDeployed = !!(code && code !== '0x') - if (!isDeployed) { - console.log('Getting factory args...') - const factoryArgs = await smartAccount.getFactoryArgs() - console.log('factory args ', JSON.stringify(factoryArgs, null, 4)) - if (factoryArgs.factory && factoryArgs.factoryData) { - sig = serializeErc6492Signature({ - signature, - address: factoryArgs.factory as `0x${string}`, - data: factoryArgs.factoryData as `0x${string}`, - }) - console.log('Serialized 6492 sig') - } + if (config.session_token) { + await loadSessionToken(config.session_token, BASE_URL_V1) } - const signedSubdelegation: SignedSubdelegation = { ...subdelegation, signature: sig } + const finalResponse = await signAndSubmitDelegation(config, pending) - // 8. Send signed delegation back to conversation endpoint - console.log('Sending signed delegation...') - const finalResponse = await sendConversation({ - prompt: 'Please refer to the previous conversation messages and redeem this delegation.', - signedSubdelegation, - chatId: initialResponse.chatId, - delegationArguments: JSON.stringify(args), - callId: delegationToolCall.callId, - }) + await clearPendingDelegation() - if (finalResponse.txn_id) { - console.log('Transaction submitted successfully.') - console.log(`Transaction ID: ${finalResponse.txn_id}`) - } else { - console.log('Final Response:', JSON.stringify(finalResponse, null, 2)) - } + await handleConversationResponse(finalResponse, config, pending.originalPrompt) } catch (err) { - console.error(`Failed to send prompt: ${(err as Error).message}`) + console.error(`Failed to approve delegation: ${(err as Error).message}`) process.exit(1) } }) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 660974a..4300efd 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -130,6 +130,7 @@ describe("send_prompt CLI end-to-end", () => { const balanceBefore = await sepoliaPublicClient.getBalance({ address: testnetSmartAcctAddress }); console.log(`Smart account Base Sepolia balance before send: ${formatEther(balanceBefore)} ETH`); + // Step 1: send_prompt saves delegation request const { stdout, stderr} = await runCli([ "send_prompt", "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD", @@ -137,6 +138,16 @@ describe("send_prompt CLI end-to-end", () => { console.log(stdout) console.error(stderr) + expect(stdout).toContain("Delegation Request"); + expect(stdout).toContain("approve_delegation_request"); + + // Step 2: approve_delegation_request signs and submits + const { stdout: approveOut, stderr: approveErr } = await runCli([ + "approve_delegation_request", + ]); + + console.log(approveOut) + console.error(approveErr) // wait for 2 blocks so balance check gets fresh data await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 6000)) @@ -155,6 +166,13 @@ describe("send_prompt CLI end-to-end", () => { console.log(stdout2) console.error(stderr2) + const { stdout: approveOut2, stderr: approveErr2 } = await runCli([ + "approve_delegation_request", + ]); + + console.log(approveOut2) + console.error(approveErr2) + // wait for 2 blocks so balance check gets fresh data await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 6000)) @@ -206,18 +224,31 @@ describe("send_prompt CLI end-to-end", () => { console.log(stdout); console.error(stderr); + const { stdout: approveOut, stderr: approveErr } = await runCli([ + "approve_delegation_request", + ]); + + console.log(approveOut); + console.error(approveErr); + const balanceAfter = await basePublicClient.getBalance({ address: baseSmartAccountAddress }); console.log(`Smart account Base mainnet balance after swap: ${formatEther(balanceAfter)} ETH`); expect(balanceAfter).toBeLessThan(balanceBefore); expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.00000001")); - // clean up + // clean up const { stdout: stdoutCleanup, stderr: stderrCleanup } = await runCli([ "send_prompt", "Swap 0.2 USDC for ETH on base", ]); console.log(stdoutCleanup); console.error(stderrCleanup); + + const { stdout: cleanupApproveOut, stderr: cleanupApproveErr } = await runCli([ + "approve_delegation_request", + ]); + console.log(cleanupApproveOut); + console.error(cleanupApproveErr); }); it("completes the staking/unstaking flow for USDC in the fluid vault on Base via the CLI", async () => { @@ -253,14 +284,20 @@ describe("send_prompt CLI end-to-end", () => { expect(stdout1).toContain("Sending prompt..."); expect(stdout1.trim()).toBeTruthy(); - // Step 2: Stake entire USDC balance into the fluid vault + // Step 2: Stake USDC into the fluid vault (send_prompt + approve) const { stdout: stdout2, stderr: stderr2 } = await runCli([ "send_prompt", "stake 2 USDC into the fluid vault on Base", ]); console.log(stdout2); console.error(stderr2); - + + const { stdout: stakeApproveOut, stderr: stakeApproveErr } = await runCli([ + "approve_delegation_request", + ]); + console.log(stakeApproveOut); + console.error(stakeApproveErr); + // wait for 2 blocks so balance check gets fresh data await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 4000)) @@ -273,14 +310,20 @@ describe("send_prompt CLI end-to-end", () => { console.log(`Smart account USDC balance after staking: ${formatUnits(usdcAfterStake, 6)} USDC`); expect(usdcAfterStake).toBeLessThan(usdcBefore); - // Step 3: Unstake entire USDC balance from the fluid vault + // Step 3: Unstake USDC from the fluid vault (send_prompt + approve) const { stdout: stdout3, stderr: stderr3 } = await runCli([ "send_prompt", "swap ALL of my FUSDC to USDC on Base", ]); console.log(stdout3); console.error(stderr3); - + + const { stdout: unstakeApproveOut, stderr: unstakeApproveErr } = await runCli([ + "approve_delegation_request", + ]); + console.log(unstakeApproveOut); + console.error(unstakeApproveErr); + // wait for 2 blocks so balance check gets fresh data await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 4000))