diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..904a081 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,26 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Run e2e tests + run: pnpm test:e2e diff --git a/coinfello/SKILL.md b/coinfello/SKILL.md index f2f2346..ba61e15 100644 --- a/coinfello/SKILL.md +++ b/coinfello/SKILL.md @@ -1,6 +1,6 @@ --- name: coinfello -description: 'Interact with CoinFello using the openclaw CLI to create MetaMask smart accounts, manage delegations, send prompts with ERC-20 token subdelegations, and check transaction status. Use when the user wants to send crypto transactions via natural language prompts, manage smart account delegations, or check CoinFello transaction results.' +description: 'Interact with CoinFello using the openclaw CLI to create MetaMask smart accounts, sign in with SIWE, manage delegations, send prompts with server-driven ERC-20 token subdelegations, and check transaction status. Use when the user wants to send crypto transactions via natural language prompts, manage smart account delegations, or check CoinFello transaction results.' compatibility: Requires Node.js 20+ and pnpm. metadata: { @@ -11,7 +11,7 @@ metadata: # CoinFello CLI Skill -Use the `openclaw` CLI to interact with CoinFello through MetaMask Smart Accounts. The CLI handles smart account creation, delegation management, prompt-based ERC-20 token transactions, and transaction status checks. +Use the `openclaw` CLI to interact with CoinFello through MetaMask Smart Accounts. The CLI handles smart account creation, SIWE authentication, delegation management, prompt-based transactions, and transaction status checks. ## Prerequisites @@ -27,13 +27,13 @@ The CLI binary is available at `./dist/index.js` after building, or as `openclaw # 1. Create a smart account on a chain (generates a new private key automatically) openclaw create_account sepolia -# 2. Send a prompt with token subdelegation -openclaw send_prompt "swap 5 USDC for ETH" \ - --token-address 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ - --amount 5 \ - --decimals 6 +# 2. Sign in to CoinFello with your smart account (SIWE) +openclaw sign_in -# 3. Check transaction status +# 3. Send a natural language prompt — the server will request a delegation if needed +openclaw send_prompt "send 5 USDC to 0xRecipient..." + +# 4. Check transaction status openclaw get_transaction_status ``` @@ -63,6 +63,20 @@ openclaw get_account - Prints the stored `smart_account_address` - Exits with an error if no account has been created yet +### sign_in + +Authenticates with CoinFello using Sign-In with Ethereum (SIWE) and your smart account. Saves the session token to local config. + +```bash +openclaw sign_in [--base-url ] +``` + +- `--base-url ` — Auth server base URL (default: `https://app.coinfello.com/api/auth`) +- Signs in using the private key stored in config +- Saves the session token to `~/.clawdbot/skills/coinfello/config.json` +- The session token is loaded automatically for subsequent `send_prompt` calls +- Must be run after `create_account` and before `send_prompt` for authenticated flows + ### set_delegation Stores a signed parent delegation (JSON) in local config for use with redelegation flows. @@ -76,34 +90,28 @@ openclaw set_delegation '' ### send_prompt -Sends a natural language prompt to CoinFello with a locally-created and signed ERC-20 token subdelegation. +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. ```bash -openclaw send_prompt "" \ - --token-address \ - --amount \ - [--decimals ] \ - [--use-redelegation] +openclaw send_prompt "" [--use-redelegation] ``` -**Required options:** - -- `--token-address
` — ERC-20 token contract address for the subdelegation scope -- `--amount ` — Maximum token amount in human-readable form (e.g. `5`, `100.5`) - **Optional:** -- `--decimals ` — Token decimals for parsing `--amount` (default: `18`) -- `--use-redelegation` — Create a redelegation from a stored parent delegation (requires `set_delegation` first) +- `--use-redelegation` — Create a redelegation from a stored parent delegation instead of a fresh subdelegation (requires `set_delegation` first) **What happens internally:** -1. Fetches CoinFello's delegate address from the API -2. Rebuilds the smart account from the stored private key and chain in config -3. Creates a subdelegation scoped to `erc20TransferAmount` with the specified token and max amount -4. Signs the subdelegation with the smart account -5. Sends the prompt + signed subdelegation to CoinFello's conversation endpoint -6. Returns a `txn_id` for tracking +1. Sends the prompt to CoinFello's conversation endpoint +2. If the server returns a read-only response (no transaction needed) → prints the response text and exits +3. If the server returns a `txn_id` directly → prints it and exits +4. If the server sends an `ask_for_delegation` 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 + - Sends the signed delegation back to the conversation endpoint + - Returns a `txn_id` for tracking ### get_transaction_status @@ -117,22 +125,30 @@ openclaw get_transaction_status ## Common Workflows -### Basic: Send a Token Transfer Prompt +### Basic: Send a Prompt (Server-Driven Delegation) ```bash # Create account if not already done openclaw create_account sepolia -# Send prompt to transfer up to 10 USDC -openclaw send_prompt "send 5 USDC to 0xRecipient..." \ - --token-address 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ - --amount 10 \ - --decimals 6 +# Sign in (required for delegation flows) +openclaw sign_in + +# Send a natural language prompt — delegation is handled automatically +openclaw send_prompt "send 5 USDC to 0xRecipient..." # Check the result openclaw get_transaction_status ``` +### Read-Only Prompt + +Some prompts don't require a transaction. The CLI detects this automatically and just prints the response. + +```bash +openclaw send_prompt "what is the chain ID for Base?" +``` + ### With Redelegation Use this when you have a parent delegation from another delegator and want to create a subdelegation chain. @@ -142,20 +158,19 @@ Use this when you have a parent delegation from another delegator and want to cr openclaw set_delegation '{"delegate":"0x...","delegator":"0x...","authority":"0x...","caveats":[],"salt":"0x...","signature":"0x..."}' # Send with redelegation -openclaw send_prompt "swap tokens" \ - --token-address 0xTokenAddress \ - --amount 100 \ - --use-redelegation +openclaw send_prompt "swap tokens" --use-redelegation ``` ## Edge Cases - **No smart account**: Run `create_account` before `send_prompt`. The CLI checks for a saved private key and address in config. +- **Not signed in**: Run `sign_in` before `send_prompt` if the server requires authentication. - **Invalid chain name**: The CLI throws an error listing valid viem chain names. - **Missing parent delegation with --use-redelegation**: The CLI exits with an error. Run `set_delegation` first. +- **Read-only response**: If the server returns a text response with no transaction, the CLI prints it and exits without creating a delegation. ## Reference -See [references/REFERENCE.md](references/REFERENCE.md) for the full config schema, supported chains, API details, and troubleshooting. +See [references/REFERENCE.md](references/REFERENCE.md) for the full config schema, supported chains, API details, scope types, and troubleshooting. See [scripts/setup-and-send.sh](scripts/setup-and-send.sh) for an end-to-end automation script. diff --git a/coinfello/references/REFERENCE.md b/coinfello/references/REFERENCE.md index aabbede..053968f 100644 --- a/coinfello/references/REFERENCE.md +++ b/coinfello/references/REFERENCE.md @@ -11,6 +11,7 @@ Created automatically by `create_account`. Schema: "private_key": "0xabc123...def", "smart_account_address": "0x1234...abcd", "chain": "sepolia", + "session_token": "...", "delegation": { "delegate": "0x...", "delegator": "0x...", @@ -22,12 +23,13 @@ Created automatically by `create_account`. Schema: } ``` -| Field | Type | Set by | Description | -| ----------------------- | -------- | ---------------- | ------------------------------------------- | -| `private_key` | `string` | `create_account` | Auto-generated hex private key | -| `smart_account_address` | `string` | `create_account` | Counterfactual address of the smart account | -| `chain` | `string` | `create_account` | viem chain name used for account creation | -| `delegation` | `object` | `set_delegation` | Optional parent delegation for redelegation | +| Field | Type | Set by | Description | +| ----------------------- | -------- | ---------------- | ---------------------------------------------- | +| `private_key` | `string` | `create_account` | Auto-generated hex private key | +| `smart_account_address` | `string` | `create_account` | Counterfactual address of the smart account | +| `chain` | `string` | `create_account` | viem chain name used for account creation | +| `session_token` | `string` | `sign_in` | SIWE session token for authenticated API calls | +| `delegation` | `object` | `set_delegation` | Optional parent delegation for redelegation | ## Command Reference @@ -51,6 +53,18 @@ openclaw get_account No parameters. Prints the stored smart account address from config. Exits with an error if no account has been created. +### openclaw sign_in + +``` +openclaw sign_in [--base-url ] +``` + +| Parameter | Type | Required | Default | Description | +| ------------ | -------- | -------- | ------------------------------------ | -------------------- | +| `--base-url` | `string` | No | `https://app.coinfello.com/api/auth` | Auth server base URL | + +Performs a Sign-In with Ethereum (SIWE) flow using the private key from config. Saves the `session_token` to config on success. The session token is automatically injected as a cookie for subsequent API calls. + ### openclaw set_delegation ``` @@ -64,18 +78,15 @@ openclaw set_delegation ### openclaw send_prompt ``` -openclaw send_prompt --token-address --amount [--decimals ] [--use-redelegation] +openclaw send_prompt [--use-redelegation] ``` -| Parameter | Type | Required | Default | Description | -| -------------------- | --------- | -------- | ------- | --------------------------------------------- | -| `prompt` | `string` | Yes | — | Natural language prompt to send to CoinFello | -| `--token-address` | `string` | Yes | — | ERC-20 token contract address | -| `--amount` | `string` | Yes | — | Max token amount (human-readable, e.g. `"5"`) | -| `--decimals` | `string` | No | `"18"` | Token decimals for parsing amount | -| `--use-redelegation` | `boolean` | No | `false` | Use stored parent delegation for redelegation | +| Parameter | Type | Required | Default | Description | +| -------------------- | --------- | -------- | ------- | ----------------------------------------------------------- | +| `prompt` | `string` | Yes | — | Natural language prompt to send to CoinFello | +| `--use-redelegation` | `boolean` | No | `false` | Use stored parent delegation to create a redelegation chain | -Uses the private key and chain stored in config (from `create_account`). +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` tool call response. ### openclaw get_transaction_status @@ -104,39 +115,104 @@ Any chain exported by `viem/chains`. Common examples: ## API Endpoints -Base URL: `https://app.coinfello.com/api/v1` +Base URL: `https://app.coinfello.com` + +| Endpoint | Method | Description | +| ---------------------------------------- | ------ | ------------------------------------------------- | +| `/api/v1/automation/coinfello-address` | GET | Returns CoinFello's delegate address | +| `/api/v1/automation/coinfello-agents` | GET | Returns available CoinFello agents | +| `/api/conversation` | POST | Submits prompt (and optionally signed delegation) | +| `/api/v1/transaction_status?txn_id=` | GET | Returns transaction status | + +### POST /api/conversation body + +Initial request (prompt only): + +```json +{ + "inputMessage": "send 5 USDC to 0xRecipient...", + "agentId": 1, + "stream": false +} +``` + +Follow-up request (with signed delegation): + +```json +{ + "inputMessage": "send 5 USDC to 0xRecipient...", + "agentId": 1, + "stream": false, + "signed_subdelegation": { "...delegation object with signature..." } +} +``` + +### POST /api/conversation response + +Read-only response: + +```json +{ + "responseText": "The chain ID for Base is 8453." +} +``` + +Delegation request (server asks client to sign): -| Endpoint | Method | Description | -| --------------------------------- | ------ | ------------------------------------- | -| `/coinfello-address` | GET | Returns CoinFello's delegate address | -| `/conversation` | POST | Submits prompt + signed subdelegation | -| `/transaction_status?txn_id=` | GET | Returns transaction status | +```json +{ + "toolCalls": [ + { + "type": "function_call", + "name": "ask_for_delegation", + "callId": "...", + "arguments": "{\"chainId\": 8453, \"scope\": {\"type\": \"erc20TransferAmount\", \"tokenAddress\": \"0x...\", \"maxAmount\": \"5000000\"}}" + } + ] +} +``` -### POST /conversation body +Final response (after delegation submitted): ```json { - "prompt": "swap 5 USDC for ETH", - "signed_subdelegation": { "...delegation object with signature..." }, - "smart_account_address": "0x..." + "txn_id": "abc123..." } ``` +## Delegation Scope Types + +The server may request any of the following scope types via `ask_for_delegation`. The CLI parses and creates the appropriate delegation caveat automatically. + +| Scope Type | Fields | +| --------------------------- | ---------------------------------------------------------------------------- | +| `erc20TransferAmount` | `tokenAddress`, `maxAmount` | +| `erc20PeriodTransfer` | `tokenAddress`, `periodAmount`, `periodDuration`, `startDate` | +| `erc20Streaming` | `tokenAddress`, `initialAmount`, `maxAmount`, `amountPerSecond`, `startTime` | +| `nativeTokenTransferAmount` | `maxAmount` | +| `nativeTokenPeriodTransfer` | `periodAmount`, `periodDuration`, `startDate` | +| `nativeTokenStreaming` | `initialAmount`, `maxAmount`, `amountPerSecond`, `startTime` | +| `erc721Transfer` | `tokenAddress`, `tokenId` | +| `functionCall` | `targets`, `selectors` | + +All `amount` fields are in the token's smallest unit (e.g. `5000000` for 5 USDC with 6 decimals). + ## Common Token Decimals -| Token | Decimals | Note | -| ----- | -------- | ------------------------------- | -| USDC | 6 | Use `--decimals 6` | -| USDT | 6 | Use `--decimals 6` | -| DAI | 18 | Default, no `--decimals` needed | -| WETH | 18 | Default, no `--decimals` needed | +| Token | Decimals | Note | +| ----- | -------- | ----------------------------- | +| USDC | 6 | amounts use 6 decimal places | +| USDT | 6 | amounts use 6 decimal places | +| DAI | 18 | amounts use 18 decimal places | +| WETH | 18 | amounts use 18 decimal places | ## Error Messages -| Error | Cause | Fix | -| ------------------------------------------------------------------------------ | ------------------------------- | -------------------------------------- | -| `Unknown chain ""` | Invalid chain name | Use a valid viem chain name | -| `No private key found in config. Run 'create_account' first.` | Missing private key in config | Run `openclaw create_account ` | -| `No smart account found. Run 'create_account' first.` | Missing smart account in config | Run `openclaw create_account ` | -| `No chain found in config. Run 'create_account' first.` | Missing chain in config | Run `openclaw create_account ` | -| `--use-redelegation requires a parent delegation. Run 'set_delegation' first.` | No stored delegation | Run `openclaw set_delegation ''` | +| Error | Cause | Fix | +| ------------------------------------------------------------------------------ | ----------------------------------- | -------------------------------------- | +| `Unknown chain ""` | Invalid chain name | Use a valid viem chain name | +| `No private key found in config. Run 'create_account' first.` | Missing private key in config | Run `openclaw create_account ` | +| `No smart account found. Run 'create_account' first.` | Missing smart account in config | Run `openclaw create_account ` | +| `No chain found in config. Run 'create_account' first.` | Missing chain in config | Run `openclaw create_account ` | +| `--use-redelegation requires a parent delegation. Run 'set_delegation' first.` | No stored delegation | Run `openclaw set_delegation ''` | +| `No delegation request received from the server.` | Server returned unexpected response | Check the full response JSON printed | diff --git a/package.json b/package.json index d86cd36..6bce666 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coinfello/agent-cli", - "version": "0.0.1", + "version": "0.0.2", "description": "", "type": "module", "main": "dist/index.js", @@ -19,7 +19,8 @@ "lint": "eslint src", "prettier-fix": "prettier --write src coinfello", "prettier": "prettier --check src coinfello", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "vitest run", + "test:e2e": "vitest run tests/e2e" }, "keywords": [], "author": "", @@ -28,6 +29,7 @@ "dependencies": { "@metamask/smart-accounts-kit": "0.4.0-beta.1", "commander": "^14.0.3", + "tough-cookie": "^6.0.0", "viem": "^2.45.1" }, "devDependencies": { @@ -37,6 +39,7 @@ "prettier": "^3.8.1", "typescript": "^5.9.3", "typescript-eslint": "^8.56.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ece4f0..38c5125 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: commander: specifier: ^14.0.3 version: 14.0.3 + tough-cookie: + specifier: ^6.0.0 + version: 6.0.0 viem: specifier: ^2.45.1 version: 2.45.1(typescript@5.9.3) @@ -39,6 +42,9 @@ importers: vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.2.1) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.1) packages: @@ -263,6 +269,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@metamask/7715-permission-types@0.5.0': resolution: {integrity: sha512-UTlAXhfVM83/dCtghIqZiPqJmeGa4KI2HhkKYjmeP0oFtwzsgDwFfNakdICC4VX82338AiyVVtbEFyx6t7SE1w==} engines: {node: ^18.18 || >=20} @@ -463,9 +472,18 @@ packages: '@scure/bip39@1.6.0': resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -543,6 +561,35 @@ packages: resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + abitype@1.2.3: resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} peerDependencies: @@ -567,6 +614,10 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -587,6 +638,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -612,6 +667,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -665,6 +723,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -675,6 +736,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -771,6 +836,9 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + micro-ftch@0.3.1: resolution: {integrity: sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg==} @@ -793,6 +861,9 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -821,6 +892,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -867,14 +941,45 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.23: + resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + + tldts@7.0.23: + resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + hasBin: true + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -955,6 +1060,40 @@ packages: yaml: optional: true + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + webauthn-p256@0.0.10: resolution: {integrity: sha512-EeYD+gmIT80YkSIDb2iWq0lq2zbHo1CxHlQTeJ+KkCILWpVy3zASH3ByD4bopzfk0uCwXxLqKGLqp2W4O28VFA==} @@ -963,6 +1102,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -1126,6 +1270,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@metamask/7715-permission-types@0.5.0': {} '@metamask/abi-utils@3.0.0': @@ -1296,10 +1442,19 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.8': {} @@ -1405,6 +1560,45 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.0 + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.1))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.2.1) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + abitype@1.2.3(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -1422,6 +1616,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + assertion-error@2.0.1: {} + balanced-match@1.0.2: {} balanced-match@4.0.3: {} @@ -1441,6 +1637,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + chai@6.2.2: {} + commander@14.0.3: {} crc-32@1.2.2: {} @@ -1457,6 +1655,8 @@ snapshots: deep-is@0.1.4: {} + es-module-lexer@1.7.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -1554,6 +1754,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} ethereum-cryptography@2.2.1: @@ -1565,6 +1769,8 @@ snapshots: eventemitter3@5.0.1: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -1639,6 +1845,10 @@ snapshots: lodash@4.17.23: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + micro-ftch@0.3.1: {} minimatch@10.2.1: @@ -1655,6 +1865,8 @@ snapshots: natural-compare@1.4.0: {} + obug@2.1.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1691,6 +1903,8 @@ snapshots: path-key@3.1.1: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -1748,13 +1962,35 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + + tldts-core@7.0.23: {} + + tldts@7.0.23: + dependencies: + tldts-core: 7.0.23 + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.23 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -1813,6 +2049,43 @@ snapshots: '@types/node': 25.2.1 fsevents: 2.3.3 + vitest@4.0.18(@types/node@25.2.1): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.1)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.2.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.2.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + webauthn-p256@0.0.10: dependencies: '@noble/curves': 1.9.7 @@ -1822,6 +2095,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} ws@8.18.3: {} diff --git a/src/account.ts b/src/account.ts index f3d0db5..651d025 100644 --- a/src/account.ts +++ b/src/account.ts @@ -4,12 +4,14 @@ import { createDelegation, type ToMetaMaskSmartAccountReturnType, type Delegation, + type CreateDelegationOptions, } from '@metamask/smart-accounts-kit' import { PrivateKeyAccount, privateKeyToAccount } from 'viem/accounts' import { createPublicClient, http, type Hex, type Chain } from 'viem' import * as chains from 'viem/chains' export type HybridSmartAccount = ToMetaMaskSmartAccountReturnType +export type DelegationScope = CreateDelegationOptions['scope'] export function resolveChain(chainName: string): Chain { const chain = (chains as Record)[chainName] @@ -21,11 +23,28 @@ export function resolveChain(chainName: string): Chain { return chain } +export function resolveChainById(chainId: number): Chain { + const chain = Object.values(chains as Record).find( + (c) => typeof c === 'object' && c !== null && 'id' in c && c.id === chainId + ) + if (!chain) { + throw new Error(`Unknown chain ID ${chainId}. No viem chain found with that ID.`) + } + return chain +} + +function resolveChainInput(chainInput: string | number): Chain { + if (typeof chainInput === 'number') { + return resolveChainById(chainInput) + } + return resolveChain(chainInput) +} + export async function createSmartAccount( privateKey: Hex, - chainName: string + chainInput: string | number ): Promise<{ smartAccount: HybridSmartAccount; address: string; owner: PrivateKeyAccount }> { - const chain = resolveChain(chainName) + const chain = resolveChainInput(chainInput) const publicClient = createPublicClient({ chain, @@ -48,9 +67,9 @@ export async function createSmartAccount( export async function getSmartAccount( privateKey: Hex, - chainName: string + chainInput: string | number ): Promise { - const { smartAccount } = await createSmartAccount(privateKey, chainName) + const { smartAccount } = await createSmartAccount(privateKey, chainInput) return smartAccount } @@ -58,21 +77,15 @@ export function createSubdelegation({ smartAccount, delegateAddress, parentDelegation, - tokenAddress, - maxAmount, + scope, }: { smartAccount: HybridSmartAccount delegateAddress: Hex parentDelegation?: Delegation - tokenAddress: Hex - maxAmount: bigint + scope: DelegationScope }): Delegation { return createDelegation({ - scope: { - type: 'erc20TransferAmount', - tokenAddress, - maxAmount, - }, + scope, to: delegateAddress, from: smartAccount.address, parentDelegation, diff --git a/src/api.ts b/src/api.ts index 9e9eee0..dcc3f3c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,10 @@ -const BASE_URL = 'https://app.coinfello.com/api/v1' +import { fetchWithCookies } from './cookies.js' + +export const BASE_URL = 'https://app.coinfello.com/' +export const BASE_URL_V1 = BASE_URL + 'api/v1' export async function getCoinFelloAddress(): Promise { - const response = await fetch(`${BASE_URL}/coinfello-address`) + const response = await fetchWithCookies(`${BASE_URL_V1}/automation/coinfello-address`) if (!response.ok) { const text = await response.text() @@ -12,25 +15,62 @@ export async function getCoinFelloAddress(): Promise { return data.address } +export interface CoinFelloAgent { + id: number + name: string +} + +export async function getCoinFelloAgents(): Promise { + const response = await fetchWithCookies(`${BASE_URL_V1}/automation/coinfello-agents`) + + if (!response.ok) { + const text = await response.text() + console.error(`Error getting CoinFello agents ${text}`) + throw new Error(`Failed to get CoinFello agents (${response.status}): ${text}`) + } + + const data = (await response.json()) as { availableAgents: CoinFelloAgent[] } + return data.availableAgents +} + +export interface ToolCall { + type: 'function_call' + arguments: string + name: string + callId: string +} + +export interface ConversationResponse { + responseText?: string + txn_id?: string + toolCalls?: ToolCall[] +} + export interface SendConversationParams { prompt: string - signedSubdelegation: unknown - smartAccountAddress: string + signedSubdelegation?: unknown } export async function sendConversation({ prompt, signedSubdelegation, - smartAccountAddress, -}: SendConversationParams): Promise<{ txn_id: string }> { - const response = await fetch(`${BASE_URL}/conversation`, { +}: SendConversationParams): Promise { + const agents = await getCoinFelloAgents() + const body: Record = { + inputMessage: prompt, + stream: false, + } + if (agents.length) { + body.agentId = agents[0].id + } + if (signedSubdelegation !== undefined) { + body.signed_subdelegation = signedSubdelegation + } + + const response = await fetchWithCookies(`${BASE_URL}/api/conversation`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - prompt, - signed_subdelegation: signedSubdelegation, - smart_account_address: smartAccountAddress, - }), + body: JSON.stringify(body), }) if (!response.ok) { @@ -38,11 +78,13 @@ export async function sendConversation({ throw new Error(`Conversation request failed (${response.status}): ${text}`) } - return response.json() as Promise<{ txn_id: string }> + return response.json() as Promise } export async function getTransactionStatus(txnId: string): Promise> { - const response = await fetch(`${BASE_URL}/transaction_status?txn_id=${encodeURIComponent(txnId)}`) + const response = await fetchWithCookies( + `${BASE_URL_V1}/transaction_status?txn_id=${encodeURIComponent(txnId)}` + ) if (!response.ok) { const text = await response.text() diff --git a/src/cookies.ts b/src/cookies.ts new file mode 100644 index 0000000..73d95c0 --- /dev/null +++ b/src/cookies.ts @@ -0,0 +1,24 @@ +import { CookieJar } from 'tough-cookie' + +export const cookieJar = new CookieJar() + +export async function fetchWithCookies(url: string, init?: RequestInit): Promise { + const cookieString = await cookieJar.getCookieString(url) + const headers = new Headers(init?.headers as HeadersInit) + if (cookieString) { + headers.set('Cookie', cookieString) + } + + const response = await fetch(url, { ...init, headers }) + + for (const cookie of response.headers.getSetCookie()) { + await cookieJar.setCookie(cookie, url) + } + + return response +} + +export async function loadSessionToken(token: string, url: string): Promise { + await cookieJar.setCookie(`better-auth.session_token=${token}`, url) + await cookieJar.setCookie(`logged_in=true`, url) +} diff --git a/src/index.ts b/src/index.ts index 75a34c1..e804bb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ import { Command } from 'commander' import { createSmartAccount, getSmartAccount, createSubdelegation } from './account.js' import { loadConfig, saveConfig, CONFIG_PATH } from './config.js' -import { getCoinFelloAddress, sendConversation, getTransactionStatus } from './api.js' +import { getCoinFelloAddress, sendConversation, getTransactionStatus, BASE_URL_V1 } from './api.js' +import { loadSessionToken } from './cookies.js' import { signInWithAgent } from './siwe.js' -import { type Hex, parseUnits } from 'viem' +import { parseScope, type RawScope } from './scope.js' +import type { Hex } from 'viem' import { generatePrivateKey } from 'viem/accounts' import type { Delegation } from '@metamask/smart-accounts-kit' @@ -106,22 +108,13 @@ program // ── send_prompt ───────────────────────────────────────────────── program .command('send_prompt') - .description('Send a prompt to CoinFello, creating and signing a subdelegation locally') + .description('Send a prompt to CoinFello, creating a delegation if requested by the server') .argument('', 'The prompt to send') - .requiredOption( - '--token-address
', - 'ERC-20 token contract address for the subdelegation scope' - ) - .requiredOption('--amount ', "Maximum token amount (human-readable, e.g. '5')") - .option('--decimals ', 'Token decimals for parsing max-amount', '18') .option('--use-redelegation', 'Create a redelegation from a stored parent delegation') .action( async ( prompt: string, opts: { - tokenAddress: string - amount: string - decimals: string useRedelegation?: boolean } ) => { @@ -146,43 +139,85 @@ program process.exit(1) } - // 1. Get CoinFello delegate address + // 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({ + prompt, + }) + + // Read-only response: no tool calls and no transaction + if (!initialResponse.toolCalls?.length && !initialResponse.txn_id) { + console.log(initialResponse.responseText ?? '') + return + } + + // If we got a direct txn_id with no tool calls, we're done + if (initialResponse.txn_id && !initialResponse.toolCalls?.length) { + console.log('Transaction submitted successfully.') + console.log(`Transaction ID: ${initialResponse.txn_id}`) + return + } + + // 2. Look for ask_for_delegation tool call + const delegationToolCall = initialResponse.toolCalls?.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)) + process.exit(1) + } + + // 3. Parse tool call arguments + const args = JSON.parse(delegationToolCall.arguments) as { + chainId: number + scope: RawScope + } + 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() - // 2. Rebuild smart account + // 5. Rebuild smart account using chainId from tool call console.log('Loading smart account...') - const smartAccount = await getSmartAccount(config.private_key as Hex, config.chain) + const smartAccount = await getSmartAccount(config.private_key as Hex, args.chainId) - // 3. Create subdelegation locally - console.log('Parsing amount...') - const maxAmount = parseUnits(opts.amount, Number(opts.decimals)) + // 6. Parse scope and create subdelegation + const scope = parseScope(args.scope) console.log('Creating subdelegation...') const subdelegation = createSubdelegation({ smartAccount, delegateAddress: delegateAddress as Hex, parentDelegation: opts.useRedelegation ? config.delegation : undefined, - tokenAddress: opts.tokenAddress as Hex, - maxAmount, + scope, }) - // 4. Sign the subdelegation + // 7. Sign the subdelegation console.log('Signing subdelegation...') const signature = await smartAccount.signDelegation({ delegation: subdelegation, }) const signedSubdelegation = { ...subdelegation, signature } - // 5. Send to conversation endpoint - console.log('Sending to conversation endpoint...') - const result = await sendConversation({ + // 8. Send signed delegation back to conversation endpoint + console.log('Sending signed delegation...') + const finalResponse = await sendConversation({ prompt, signedSubdelegation, - smartAccountAddress: config.smart_account_address, }) - console.log('Transaction submitted successfully.') - console.log(`Transaction ID: ${result.txn_id}`) + if (finalResponse.txn_id) { + console.log('Transaction submitted successfully.') + console.log(`Transaction ID: ${finalResponse.txn_id}`) + } else { + console.log('Response:', JSON.stringify(finalResponse, null, 2)) + } } catch (err) { console.error(`Failed to send prompt: ${(err as Error).message}`) process.exit(1) diff --git a/src/scope.ts b/src/scope.ts new file mode 100644 index 0000000..1fc4003 --- /dev/null +++ b/src/scope.ts @@ -0,0 +1,87 @@ +import type { Hex } from 'viem' +import type { DelegationScope } from './account.js' + +export interface RawScope { + type: string + tokenAddress?: string + maxAmount?: string + periodAmount?: string + periodDuration?: number + startDate?: number + initialAmount?: string + amountPerSecond?: string + startTime?: number + tokenId?: string + targets?: string[] + selectors?: string[] +} + +export function parseScope(raw: RawScope): DelegationScope { + switch (raw.type) { + case 'erc20TransferAmount': + return { + type: 'erc20TransferAmount', + tokenAddress: raw.tokenAddress! as Hex, + maxAmount: BigInt(raw.maxAmount!), + } + + case 'erc20PeriodTransfer': + return { + type: 'erc20PeriodTransfer', + tokenAddress: raw.tokenAddress! as Hex, + periodAmount: BigInt(raw.periodAmount!), + periodDuration: raw.periodDuration!, + startDate: raw.startDate!, + } + + case 'erc20Streaming': + return { + type: 'erc20Streaming', + tokenAddress: raw.tokenAddress! as Hex, + initialAmount: BigInt(raw.initialAmount!), + maxAmount: BigInt(raw.maxAmount!), + amountPerSecond: BigInt(raw.amountPerSecond!), + startTime: raw.startTime!, + } + + case 'nativeTokenTransferAmount': + return { + type: 'nativeTokenTransferAmount', + maxAmount: BigInt(raw.maxAmount!), + } + + case 'nativeTokenPeriodTransfer': + return { + type: 'nativeTokenPeriodTransfer', + periodAmount: BigInt(raw.periodAmount!), + periodDuration: raw.periodDuration!, + startDate: raw.startDate!, + } + + case 'nativeTokenStreaming': + return { + type: 'nativeTokenStreaming', + initialAmount: BigInt(raw.initialAmount!), + maxAmount: BigInt(raw.maxAmount!), + amountPerSecond: BigInt(raw.amountPerSecond!), + startTime: raw.startTime!, + } + + case 'erc721Transfer': + return { + type: 'erc721Transfer', + tokenAddress: raw.tokenAddress! as Hex, + tokenId: BigInt(raw.tokenId!), + } + + case 'functionCall': + return { + type: 'functionCall', + targets: (raw.targets ?? []).map((t) => t as Hex), + selectors: (raw.selectors ?? []).map((s) => s as Hex), + } + + default: + throw new Error(`Unsupported delegation scope type: "${raw.type}"`) + } +} diff --git a/src/siwe.ts b/src/siwe.ts index f6d8274..7b6d532 100644 --- a/src/siwe.ts +++ b/src/siwe.ts @@ -2,6 +2,7 @@ import { createSiweMessage } from 'viem/siwe' import { type Hex, type Address } from 'viem' import { Config, saveConfig } from './config.js' import { createSmartAccount, resolveChain } from './account.js' +import { fetchWithCookies } from './cookies.js' export interface SignInResult { token: string @@ -36,7 +37,7 @@ export async function signInWithAgent(baseUrl: string, config: Config): Promise< // Fetch nonce from server console.log('fetching nonce...') - const nonceResponse = await fetch(`${baseUrl}/siwe/nonce`, { + const nonceResponse = await fetchWithCookies(`${baseUrl}/siwe/nonce`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ walletAddress, chainId }), @@ -70,7 +71,7 @@ export async function signInWithAgent(baseUrl: string, config: Config): Promise< // Verify signature with server console.log('signing in with siwe message...') - const verifyResponse = await fetch(`${baseUrl}/siwe/verify`, { + const verifyResponse = await fetchWithCookies(`${baseUrl}/siwe/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, signature, walletAddress, chainId }), diff --git a/tests/e2e/send-prompt.test.ts b/tests/e2e/send-prompt.test.ts new file mode 100644 index 0000000..37c1b8c --- /dev/null +++ b/tests/e2e/send-prompt.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { generatePrivateKey } from "viem/accounts"; +import type { Hex } from "viem"; +import { createSmartAccount } from "../../src/account.js"; +import { signInWithAgent } from "../../src/siwe.js"; +import { sendConversation } from "../../src/api.js"; + +const SIWE_BASE_URL = "https://app.coinfello.com/api/auth"; +const CHAIN = "sepolia"; + +// NOTE: This test makes real network calls and writes to +// ~/.clawdbot/skills/coinfello/config.json as a side effect of sign-in. + +describe("send_prompt read-only flow", () => { + let smartAccountAddress: string; + + beforeAll(async () => { + const privateKey = generatePrivateKey(); + const { address } = await createSmartAccount(privateKey, CHAIN); + smartAccountAddress = address; + + const config = { + private_key: privateKey as Hex, + smart_account_address: address, + chain: CHAIN, + }; + + await signInWithAgent(SIWE_BASE_URL, config); + }); + + it("returns responseText with no tool calls when sending a greeting", async () => { + const response = await sendConversation({ + prompt: "hello", + smartAccountAddress, + }); + + expect(response.responseText).toBeTruthy(); + expect(response.txn_id).toBeUndefined(); + expect(response.toolCalls?.length ?? 0).toBe(0); + }); + + it("returns responseText with no tool calls when asking for the chain id of Base", async () => { + const response = await sendConversation({ + prompt: "what is the chain id for base?", + smartAccountAddress, + }); + + expect(response.responseText).toBeTruthy(); + expect(response.txn_id).toBeUndefined(); + expect(response.toolCalls?.length ?? 0).toBe(0); + }); + + it("returns responseText with no tool calls when asking for the native currency of Arbitrum", async () => { + const response = await sendConversation({ + prompt: "what is the native currency for arbitrum?", + smartAccountAddress, + }); + + expect(response.responseText).toBeTruthy(); + expect(response.txn_id).toBeUndefined(); + expect(response.toolCalls?.length ?? 0).toBe(0); + }); + + it("returns responseText with no tool calls when asking for token balances", async () => { + const response = await sendConversation({ + prompt: "what are my token balances?", + smartAccountAddress, + }); + + expect(response.responseText).toBeTruthy(); + expect(response.txn_id).toBeUndefined(); + expect(response.toolCalls?.length ?? 0).toBe(0); + }); +}); + +describe("send_prompt delegation flow", () => { + let smartAccountAddress: string; + + beforeAll(async () => { + const privateKey = generatePrivateKey(); + const { address } = await createSmartAccount(privateKey, CHAIN); + smartAccountAddress = address; + + const config = { + private_key: privateKey as Hex, + smart_account_address: address, + chain: CHAIN, + }; + + await signInWithAgent(SIWE_BASE_URL, config); + }); + + it.skip("requests a delegation when asked to send 0.001 USDC on Base", async () => { + const response = await sendConversation({ + prompt: + "send 0.001 USDC on Base to 0x000000000000000000000000000000000000dEaD", + smartAccountAddress, + }); + + expect(response.txn_id).toBeUndefined(); + + const delegationCall = response.toolCalls?.find( + (tc) => tc.name === "ask_for_delegation" + ); + expect(delegationCall).toBeDefined(); + + const args = JSON.parse(delegationCall!.arguments); + expect(args.chainId).toBeDefined(); + expect(args.scope).toBeDefined(); + }); + + it.skip("requests a delegation when asked to swap 0.001 USDC to ETH on Base", async () => { + const response = await sendConversation({ + prompt: "swap 0.001 USDC to ETH on Base", + smartAccountAddress, + }); + + expect(response.txn_id).toBeUndefined(); + + const delegationCall = response.toolCalls?.find( + (tc) => tc.name === "ask_for_delegation" + ); + expect(delegationCall).toBeDefined(); + + const args = JSON.parse(delegationCall!.arguments); + expect(args.chainId).toBeDefined(); + expect(args.scope).toBeDefined(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index a70ed03..c6ed80c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import { resolve } from "node:path"; export default defineConfig({ @@ -25,4 +25,7 @@ export default defineConfig({ }, minify: false, }, + test: { + testTimeout: 30_000, + }, });