From be8d37ed49e4c7ee02ab118715627f8912ea6890 Mon Sep 17 00:00:00 2001 From: Mohamed Abdullah Date: Sat, 15 Aug 2026 17:29:13 +0300 Subject: [PATCH] feat: add Taskmarket action provider Adds a TaskmarketActionProvider to the Python AgentKit package with read-only task browsing/detail actions (public REST API) and a create-task action that wraps the first-party taskmarket CLI via subprocess. create_task requires explicit confirmation, enforces a TASKMARKET_MAX_SPEND_USDC spending limit (default 10 USDC), echoes the order before executing, and never retries payments with unknown settlement. No API keys or private keys are handled by the provider. 26 unit tests pass (tests/action_providers/taskmarket/). --- .../add-taskmarket-action-provider.feature.md | 1 + .../coinbase_agentkit/__init__.py | 2 + .../action_providers/__init__.py | 6 + .../action_providers/taskmarket/README.md | 143 +++++ .../action_providers/taskmarket/__init__.py | 8 + .../action_providers/taskmarket/schemas.py | 86 +++ .../taskmarket/taskmarket_action_provider.py | 519 ++++++++++++++++++ .../action_providers/taskmarket/__init__.py | 1 + .../action_providers/taskmarket/conftest.py | 64 +++ .../test_taskmarket_action_provider.py | 507 +++++++++++++++++ 10 files changed, 1337 insertions(+) create mode 100644 python/coinbase-agentkit/changelog.d/add-taskmarket-action-provider.feature.md create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/README.md create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/__init__.py create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/schemas.py create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/taskmarket_action_provider.py create mode 100644 python/coinbase-agentkit/tests/action_providers/taskmarket/__init__.py create mode 100644 python/coinbase-agentkit/tests/action_providers/taskmarket/conftest.py create mode 100644 python/coinbase-agentkit/tests/action_providers/taskmarket/test_taskmarket_action_provider.py diff --git a/python/coinbase-agentkit/changelog.d/add-taskmarket-action-provider.feature.md b/python/coinbase-agentkit/changelog.d/add-taskmarket-action-provider.feature.md new file mode 100644 index 000000000..f1631cd33 --- /dev/null +++ b/python/coinbase-agentkit/changelog.d/add-taskmarket-action-provider.feature.md @@ -0,0 +1 @@ +Added a new Taskmarket action provider for interacting with the Taskmarket onchain agent task marketplace on Base (USDC rewards). The provider supports browsing open tasks and fetching task details through the public Taskmarket REST API, and creating tasks through the official first-party taskmarket CLI, which owns the wallet and performs x402 payments. Task creation is gated by an explicit confirmation flag and a maximum spend limit (TASKMARKET_MAX_SPEND_USDC). diff --git a/python/coinbase-agentkit/coinbase_agentkit/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/__init__.py index e31253873..25e02e7b6 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/__init__.py +++ b/python/coinbase-agentkit/coinbase_agentkit/__init__.py @@ -21,6 +21,7 @@ pyth_action_provider, ssh_action_provider, superfluid_action_provider, + taskmarket_action_provider, twitter_action_provider, wallet_action_provider, weth_action_provider, @@ -74,6 +75,7 @@ "pyth_action_provider", "ssh_action_provider", "superfluid_action_provider", + "taskmarket_action_provider", "twitter_action_provider", "wallet_action_provider", "weth_action_provider", diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py index 68573da62..18ced31cc 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py @@ -32,6 +32,10 @@ SuperfluidActionProvider, superfluid_action_provider, ) +from .taskmarket.taskmarket_action_provider import ( + TaskmarketActionProvider, + taskmarket_action_provider, +) from .twitter.twitter_action_provider import TwitterActionProvider, twitter_action_provider from .wallet.wallet_action_provider import WalletActionProvider, wallet_action_provider from .weth.weth_action_provider import WethActionProvider, weth_action_provider @@ -57,6 +61,7 @@ "PythActionProvider", "SshActionProvider", "SuperfluidActionProvider", + "TaskmarketActionProvider", "TwitterActionProvider", "WalletActionProvider", "WethActionProvider", @@ -78,6 +83,7 @@ "pyth_action_provider", "ssh_action_provider", "superfluid_action_provider", + "taskmarket_action_provider", "twitter_action_provider", "wallet_action_provider", "weth_action_provider", diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/README.md b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/README.md new file mode 100644 index 000000000..23dc95ef0 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/README.md @@ -0,0 +1,143 @@ +# Taskmarket Action Provider + +This directory contains the **TaskmarketActionProvider** implementation, which provides actions for interacting with **Taskmarket** (https://taskmarket.dev), an onchain agent task marketplace on the **Base network** that pays in **USDC**. + +## Directory Structure + +``` +taskmarket/ +├── taskmarket_action_provider.py # Main provider with Taskmarket functionality +├── schemas.py # Pydantic schemas for action inputs +├── __init__.py # Package exports +└── README.md # This file + +# From python/coinbase-agentkit/ +tests/action_providers/taskmarket/ +├── conftest.py # Test fixtures +└── test_taskmarket_action_provider.py # Tests for the Taskmarket action provider +``` + +## Overview + +Taskmarket lets agents post and complete onchain tasks with USDC rewards escrowed on Base. Rewards are stored onchain as integer base units; **USDC amount = reward / 1e6**. + +This provider ships two kinds of actions: + +- **Read-only actions** (`browse_tasks`, `get_task`) call the public Taskmarket REST API directly over HTTPS (`https://api.taskmarket.dev/api`). They require **no authentication** and touch no wallet. +- **The write action** (`create_task`) wraps the official first-party **`taskmarket` CLI** (npm package `@lucid-agents/taskmarket`) via subprocess. The CLI owns the wallet, performs the x402 payment, and produces the EIP-191 signature. This provider **never reimplements the Taskmarket API, never stores API keys, and never handles private keys**. + +## Setup + +The `taskmarket` CLI is required for creating tasks (read-only actions do not need it): + +```bash +npm i -g @lucid-agents/taskmarket +taskmarket init # creates ~/.taskmarket/keystore.json (the CLI owns this wallet) +taskmarket wallet balance +``` + +Do not run `taskmarket init` with a keystore you cannot recover. The provider never reads or writes the keystore itself. + +## Usage + +```python +from coinbase_agentkit import taskmarket_action_provider + +provider = taskmarket_action_provider() + +# Read-only: browse open tasks (public API, no auth) +result = provider.browse_tasks( + { + "min_reward_usdc": 1.0, + "max_reward_usdc": 25.0, + "mode": "bounty", + "limit": 20, + } +) + +# Read-only: fetch one task +result = provider.get_task({"task_id": "0xb4e0e2150a5b69a781769fe71f9092de3cffe978a03fd7286ebd408b99b152e3"}) + +# Write: create a task via the first-party CLI (spends USDC) +result = provider.create_task( + { + "description": "Write a landing page for an agent marketplace", + "reward_usdc": 5.0, + "duration_hours": 48, + "mode": "bounty", + "confirmation": True, + } +) +``` + +## Actions + +### Taskmarket Actions + +- `browse_tasks`: List open Taskmarket tasks (newest first) via the public REST API. + + - Optional filters: `max_reward_usdc`, `min_reward_usdc`, `mode` (`bounty` / `claim` / `pitch` / `benchmark` / `auction`), `limit` (default 20, max 100). + - Returns each task's id, description, reward in USDC, mode, status, submission count, expiry time, tags, and requester. + - Rewards are converted from integer base units to whole USDC. + +- `get_task`: Fetch the full details of a single task by its 0x-prefixed id via the public REST API. + + - Returns the task id, status, reward in USDC, expiry time, submission count, mode, requester, tags, and description. + +- `create_task`: Create a task with USDC escrow on Base by delegating to the first-party `taskmarket` CLI. + + - Inputs: `description`, `reward_usdc`, `duration_hours`, optional `mode`, and `confirmation` (bool). + - The CLI performs the x402 payment and signs the task (EIP-191). The provider never handles keys. + +## Safety Gates + +`create_task` spends real USDC. Three gates are enforced before any payment: + +1. **Explicit confirmation.** The `confirmation` parameter MUST be `true`. When `false`, the action returns a full preview of the order (description, reward, duration, network) and spends nothing. +2. **Spending limit.** The reward must not exceed `TASKMARKET_MAX_SPEND_USDC` (default **10.0 USDC**): + + ```bash + export TASKMARKET_MAX_SPEND_USDC=25.0 + ``` + + The limit can also be set per-instance: `taskmarket_action_provider(max_spend_usdc=25.0)`. +3. **Order echo.** Every response includes the exact `order` (description, reward in USDC, duration in hours, network = Base mainnet, paid in USDC) so the agent and user always see precisely what was authorized. + +Additional rules baked into the provider: + +- If the CLI times out, the settlement status of the payment is unknown. The provider returns an error and explicitly instructs **not to retry**; check task and wallet status first (`taskmarket task search`, `taskmarket wallet balance`). +- Non-zero CLI exits surface the CLI's own error output; the provider never swallows or fabricates results. +- The provider never logs secrets: it does not read the keystore, does not print environment variables, and returns CLI output as-is. + +## Configuration + +The provider factory accepts optional configuration: + +```python +provider = taskmarket_action_provider( + api_base_url="https://api.taskmarket.dev/api", # default + max_spend_usdc=25.0, # overrides TASKMARKET_MAX_SPEND_USDC + cli_timeout_seconds=120, # taskmarket CLI timeout + request_timeout_seconds=30, # REST API timeout +) +``` + +## Network Support + +The marketplace runs on Base mainnet. Read-only actions use the public REST API, and `create_task` delegates to the CLI, which owns the wallet and handles the network itself. The provider is therefore network-agnostic from the agent's perspective (`supports_network` always returns `True`). + +## Adding New Actions + +To add new Taskmarket actions: + +1. Define the action schema in `schemas.py`. See [Defining the input schema](https://github.com/coinbase/agentkit/blob/main/CONTRIBUTING-PYTHON.md#defining-the-input-schema) for more information. +2. Implement the action in `taskmarket_action_provider.py`. +3. Implement tests in `tests/action_providers/taskmarket/test_taskmarket_action_provider.py`. + +Write actions that mutate onchain state must continue to delegate to the first-party `taskmarket` CLI (subprocess) and must not reimplement the API, store API keys, or handle private keys. + +## Notes + +- Official docs: https://docs.taskmarket.dev +- Public API: `GET https://api.taskmarket.dev/api/tasks?status=open&limit=50` and `GET https://api.taskmarket.dev/api/tasks/{taskId}`. +- The CLI keystore lives at `~/.taskmarket/keystore.json` and is owned exclusively by the CLI. diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/__init__.py new file mode 100644 index 000000000..ec1898e46 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/__init__.py @@ -0,0 +1,8 @@ +"""Taskmarket action provider.""" + +from .taskmarket_action_provider import ( + TaskmarketActionProvider, + taskmarket_action_provider, +) + +__all__ = ["TaskmarketActionProvider", "taskmarket_action_provider"] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/schemas.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/schemas.py new file mode 100644 index 000000000..e77ff38ae --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/schemas.py @@ -0,0 +1,86 @@ +"""Schemas for the Taskmarket action provider.""" + +from typing import Literal + +from pydantic import BaseModel, Field + +TASKMARKET_MODES = Literal["bounty", "claim", "pitch", "benchmark", "auction"] + + +class BrowseTasksSchema(BaseModel): + """Schema for browsing open tasks on the Taskmarket marketplace.""" + + max_reward_usdc: float | None = Field( + default=None, + ge=0, + description=( + "Maximum reward in whole USDC units (e.g. 5.0 for 5 USDC). " + "Tasks with a reward above this amount are excluded." + ), + ) + min_reward_usdc: float | None = Field( + default=None, + ge=0, + description=( + "Minimum reward in whole USDC units (e.g. 1.0 for 1 USDC). " + "Tasks with a reward below this amount are excluded." + ), + ) + mode: TASKMARKET_MODES | None = Field( + default=None, + description="Task mode to filter by: bounty, claim, pitch, benchmark, or auction.", + ) + limit: int = Field( + default=20, + ge=1, + le=100, + description="Maximum number of tasks to return (default 20, max 100).", + ) + + +class GetTaskSchema(BaseModel): + """Schema for fetching a single task from the Taskmarket marketplace.""" + + task_id: str = Field( + ..., + description="The id of the task to fetch (a 0x-prefixed 32-byte hex string).", + ) + + +class CreateTaskSchema(BaseModel): + """Schema for creating a task on the Taskmarket marketplace.""" + + description: str = Field( + ..., + description=( + "The description of the task to be completed by workers. " + "This exact text is posted onchain when the task is created." + ), + ) + reward_usdc: float = Field( + ..., + gt=0, + description=( + "The task reward in whole USDC units (e.g. 5.0 for 5 USDC). " + "The reward is escrowed onchain when the task is created." + ), + ) + duration_hours: float = Field( + ..., + gt=0, + description="The task duration in hours. The task must be completed before this deadline.", + ) + mode: TASKMARKET_MODES | None = Field( + default=None, + description=( + "Optional task mode: bounty, claim, pitch, benchmark, or auction. " + "Defaults to the taskmarket CLI default (bounty)." + ), + ) + confirmation: bool = Field( + ..., + description=( + "MUST be set to true to authorize the task creation and the associated USDC escrow " + "payment. Set to false to preview the task without spending any funds." + ), + ) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/taskmarket_action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/taskmarket_action_provider.py new file mode 100644 index 000000000..ef1d582db --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/taskmarket_action_provider.py @@ -0,0 +1,519 @@ +"""Taskmarket action provider. + +This provider enables agents to interact with the Taskmarket onchain agent task +marketplace (https://taskmarket.dev), which runs on the Base network and pays in +USDC. + +Read-only actions (browse_tasks, get_task) call the public Taskmarket REST API +directly over HTTPS and require no authentication. + +The write action (create_task) delegates to the official first-party +``taskmarket`` CLI (npm package @lucid-agents/taskmarket). The CLI owns the +wallet, performs x402 payments, and produces EIP-191 signatures. This provider +never reimplements the Taskmarket API, never stores API keys, and never touches +private keys. All spending is gated by an explicit confirmation flag and a +maximum spend limit controlled by the TASKMARKET_MAX_SPEND_USDC environment +variable. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from typing import Any + +import requests + +from ...network import Network +from ..action_decorator import create_action +from ..action_provider import ActionProvider +from .schemas import BrowseTasksSchema, CreateTaskSchema, GetTaskSchema + +DEFAULT_API_BASE_URL = "https://api.taskmarket.dev/api" +DEFAULT_MAX_SPEND_USDC = 10.0 +MAX_SPEND_USDC_ENV_VAR = "TASKMARKET_MAX_SPEND_USDC" +USDC_DECIMALS = 6 +TASKMARKET_NETWORK_DESCRIPTION = "Base network (mainnet), paid in USDC" +DEFAULT_CLI_TIMEOUT_SECONDS = 120 +DEFAULT_REQUEST_TIMEOUT_SECONDS = 30 +MAX_PAGES_PER_BROWSE = 20 +MAX_TASKS_PER_PAGE = 50 + + +class TaskmarketActionProvider(ActionProvider): + """Provides actions for interacting with the Taskmarket marketplace. + + Browse and read actions use the public Taskmarket REST API. The create + action wraps the official first-party ``taskmarket`` CLI, which owns the + wallet and performs x402 payments and EIP-191 signing. + """ + + def __init__( + self, + api_base_url: str | None = None, + max_spend_usdc: float | None = None, + cli_timeout_seconds: int = DEFAULT_CLI_TIMEOUT_SECONDS, + request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT_SECONDS, + ) -> None: + """Initialize the Taskmarket action provider. + + Args: + api_base_url: Optional base URL of the Taskmarket REST API. + Defaults to the public Taskmarket API. + max_spend_usdc: Optional maximum spend per task in USDC. When set, + this overrides the TASKMARKET_MAX_SPEND_USDC environment + variable. Defaults to 10.0 USDC. + cli_timeout_seconds: Timeout in seconds for taskmarket CLI calls. + request_timeout_seconds: Timeout in seconds for REST API calls. + + """ + super().__init__("taskmarket", []) + self._api_base_url = (api_base_url or DEFAULT_API_BASE_URL).rstrip("/") + self._max_spend_usdc = max_spend_usdc + self._cli_timeout_seconds = cli_timeout_seconds + self._request_timeout_seconds = request_timeout_seconds + + @create_action( + name="browse_tasks", + description=""" +Browse open tasks on the Taskmarket onchain task marketplace (Base network, USDC rewards). + +Calls the public Taskmarket REST API (no authentication required) and returns open tasks +sorted by newest first. Optional filters: +- max_reward_usdc: only return tasks with a reward at or below this amount (USDC) +- min_reward_usdc: only return tasks with a reward at or above this amount (USDC) +- mode: only return tasks with this mode (bounty, claim, pitch, benchmark, auction) +- limit: maximum number of tasks to return (default 20, max 100) + +Rewards are reported in whole USDC units (the API stores rewards as integer base units; +USDC amount = reward / 1e6). +""", + schema=BrowseTasksSchema, + ) + def browse_tasks(self, args: dict[str, Any]) -> str: + """Browse open tasks on the Taskmarket marketplace. + + Args: + args: BrowseTasksSchema fields: max_reward_usdc, min_reward_usdc, + mode, limit. + + Returns: + str: JSON string with the list of matching open tasks. + + """ + try: + validated_args = BrowseTasksSchema(**args) + raw_tasks = self._fetch_open_tasks(validated_args.limit) + + tasks = [] + for raw_task in raw_tasks: + reward_usdc = self._reward_to_usdc(raw_task.get("reward")) + if ( + validated_args.min_reward_usdc is not None + and reward_usdc < validated_args.min_reward_usdc + ): + continue + if ( + validated_args.max_reward_usdc is not None + and reward_usdc > validated_args.max_reward_usdc + ): + continue + if ( + validated_args.mode is not None + and (raw_task.get("mode") or "") != validated_args.mode + ): + continue + tasks.append(self._summarize_task(raw_task)) + + return json.dumps( + { + "success": True, + "tasks": tasks, + "count": len(tasks), + "filters": { + "minRewardUsdc": validated_args.min_reward_usdc, + "maxRewardUsdc": validated_args.max_reward_usdc, + "mode": validated_args.mode, + }, + }, + indent=2, + ) + except Exception as error: + return json.dumps( + { + "error": True, + "message": "Failed to browse Taskmarket tasks", + "details": str(error), + }, + indent=2, + ) + + @create_action( + name="get_task", + description=""" +Fetch the full details of a single Taskmarket task by its id. + +Calls the public Taskmarket REST API (no authentication required). The task id is a +0x-prefixed 32-byte hex string (e.g. 0xb4e0e215...b152e3). Returns the task id, status, +reward in USDC, expiry time, submission count, mode, requester, tags, and description. +""", + schema=GetTaskSchema, + ) + def get_task(self, args: dict[str, Any]) -> str: + """Fetch a single task from the Taskmarket marketplace. + + Args: + args: GetTaskSchema fields: task_id. + + Returns: + str: JSON string with the task details. + + """ + try: + validated_args = GetTaskSchema(**args) + response = requests.get( + f"{self._api_base_url}/tasks/{validated_args.task_id}", + timeout=self._request_timeout_seconds, + ) + response.raise_for_status() + task = response.json() + return json.dumps( + { + "success": True, + "task": self._summarize_task(task), + }, + indent=2, + ) + except Exception as error: + return json.dumps( + { + "error": True, + "message": "Failed to fetch Taskmarket task", + "details": str(error), + }, + indent=2, + ) + + @create_action( + name="create_task", + description=""" +Create a new task on the Taskmarket onchain task marketplace (Base network, USDC rewards). + +This action WRITES ONCHAIN AND SPENDS USDC. It delegates to the official first-party +'taskmarket' CLI (npm package @lucid-agents/taskmarket), which owns the wallet, performs +the x402 payment, and signs the task with EIP-191. This provider never reimplements the +Taskmarket API, never stores API keys, and never handles private keys. + +Required inputs: +- description: the exact task description that will be posted onchain +- reward_usdc: the task reward in whole USDC units (escrowed onchain) +- duration_hours: the task duration in hours +- confirmation: MUST be true to authorize the payment. If false, the action returns a + preview and spends nothing. + +Safety gates (enforced before any payment): +1. Explicit confirmation: confirmation must be true. +2. Spending limit: the reward must not exceed TASKMARKET_MAX_SPEND_USDC (default 10.0 USDC). +3. The exact description, reward, duration, and network are echoed in every response. + +If the CLI times out, the settlement status of the payment is unknown: do NOT retry this +action. Check the task and wallet status first (taskmarket task search, taskmarket wallet +balance). +""", + schema=CreateTaskSchema, + ) + def create_task(self, args: dict[str, Any]) -> str: + """Create a task on the Taskmarket marketplace via the first-party CLI. + + Args: + args: CreateTaskSchema fields: description, reward_usdc, + duration_hours, mode, confirmation. + + Returns: + str: JSON string with the creation result, including an echo of the + exact order (description, reward, duration, network). + + """ + try: + validated_args = CreateTaskSchema(**args) + + order = { + "description": validated_args.description, + "rewardUsdc": validated_args.reward_usdc, + "durationHours": validated_args.duration_hours, + "mode": validated_args.mode, + "network": TASKMARKET_NETWORK_DESCRIPTION, + } + + if not validated_args.confirmation: + return json.dumps( + { + "error": True, + "message": "Task creation requires explicit confirmation", + "order": order, + "details": ( + "Set confirmation to true to authorize creating this task. " + "No payment was made and no funds were spent." + ), + }, + indent=2, + ) + + max_spend_usdc = self._get_max_spend_usdc() + if validated_args.reward_usdc > max_spend_usdc: + return json.dumps( + { + "error": True, + "message": "Task reward exceeds spending limit", + "order": order, + "details": ( + f"The requested reward of {validated_args.reward_usdc} USDC exceeds the " + f"maximum allowed spend of {max_spend_usdc} USDC configured via the " + f"{MAX_SPEND_USDC_ENV_VAR} environment variable. No payment was made." + ), + }, + indent=2, + ) + + cli_path = shutil.which("taskmarket") + if cli_path is None: + return json.dumps( + { + "error": True, + "message": "taskmarket CLI not found", + "order": order, + "details": ( + "The first-party taskmarket CLI is required to create tasks. " + "Install it with 'npm i -g @lucid-agents/taskmarket' and run " + "'taskmarket init' to set up the wallet. No payment was made." + ), + }, + indent=2, + ) + + command = [ + cli_path, + "task", + "create", + "--description", + validated_args.description, + "--reward", + self._format_amount(validated_args.reward_usdc), + "--duration", + self._format_amount(validated_args.duration_hours), + ] + if validated_args.mode is not None: + command.extend(["--mode", validated_args.mode]) + + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=self._cli_timeout_seconds, + check=False, + ) + except subprocess.TimeoutExpired: + return json.dumps( + { + "error": True, + "message": "taskmarket CLI timed out", + "order": order, + "details": ( + "The taskmarket CLI did not finish within the timeout. The settlement " + "status of the payment is unknown. Do NOT retry this action; check the " + "task and wallet status first (taskmarket task search, taskmarket wallet " + "balance)." + ), + }, + indent=2, + ) + + if result.returncode != 0: + error_output = (result.stderr or result.stdout or "Unknown CLI error").strip() + return json.dumps( + { + "error": True, + "message": "taskmarket CLI failed", + "order": order, + "details": error_output, + }, + indent=2, + ) + + cli_output = (result.stdout or "").strip() + return json.dumps( + { + "success": True, + "message": "Task created on Taskmarket via the first-party CLI", + "order": order, + "cliOutput": cli_output, + }, + indent=2, + ) + except Exception as error: + return json.dumps( + { + "error": True, + "message": "Failed to create Taskmarket task", + "details": str(error), + }, + indent=2, + ) + + def supports_network(self, network: Network) -> bool: + """Check if this provider supports the specified network. + + The marketplace runs on Base mainnet, but the read-only actions use the + public REST API and the create action delegates to the taskmarket CLI, + which owns the wallet and handles the network itself. The provider is + therefore network-agnostic from the agent's perspective. + + Args: + network: The network to check. + + Returns: + bool: Always True as Taskmarket is network-agnostic for agents. + + """ + return True + + def _fetch_open_tasks(self, limit: int) -> list[dict[str, Any]]: + """Fetch open tasks from the Taskmarket REST API with pagination. + + Args: + limit: Maximum number of tasks to fetch. + + Returns: + list[dict[str, Any]]: The raw task objects, newest first. + + """ + tasks: list[dict[str, Any]] = [] + cursor: str | None = None + for _ in range(MAX_PAGES_PER_BROWSE): + if len(tasks) >= limit: + break + params: dict[str, Any] = { + "status": "open", + "sort": "newest", + "limit": min(MAX_TASKS_PER_PAGE, limit - len(tasks)), + } + if cursor is not None: + params["cursor"] = cursor + response = requests.get( + f"{self._api_base_url}/tasks", + params=params, + timeout=self._request_timeout_seconds, + ) + response.raise_for_status() + payload = response.json() + tasks.extend(payload.get("tasks", [])) + if not payload.get("hasMore") or not payload.get("nextCursor"): + break + cursor = payload["nextCursor"] + return tasks[:limit] + + @staticmethod + def _reward_to_usdc(reward: Any) -> float: + """Convert a raw Taskmarket reward (integer base units) to USDC. + + Args: + reward: The raw reward value from the API, an integer string in + base units. + + Returns: + float: The reward in whole USDC units, or 0.0 if unparseable. + + """ + try: + return int(reward) / (10**USDC_DECIMALS) + except (TypeError, ValueError): + return 0.0 + + @staticmethod + def _summarize_task(task: dict[str, Any]) -> dict[str, Any]: + """Build a compact summary of a task for agent consumption. + + Args: + task: The raw task object from the Taskmarket API. + + Returns: + dict[str, Any]: A summary with id, description, reward, mode, + status, submission count, expiry, tags, and requester. + + """ + return { + "id": task.get("id"), + "description": task.get("description"), + "rewardUsdc": TaskmarketActionProvider._reward_to_usdc(task.get("reward")), + "mode": task.get("mode"), + "status": task.get("status"), + "submissionCount": task.get("submissionCount"), + "expiryTime": task.get("expiryTime"), + "tags": task.get("tags"), + "requester": task.get("requester"), + } + + def _get_max_spend_usdc(self) -> float: + """Resolve the maximum allowed spend per task in USDC. + + Precedence: constructor argument, then TASKMARKET_MAX_SPEND_USDC + environment variable, then the default of 10.0 USDC. + + Returns: + float: The maximum allowed spend in USDC. + + """ + if self._max_spend_usdc is not None: + return self._max_spend_usdc + raw = os.getenv(MAX_SPEND_USDC_ENV_VAR) + if raw is None or raw.strip() == "": + return DEFAULT_MAX_SPEND_USDC + try: + return float(raw) + except ValueError: + return DEFAULT_MAX_SPEND_USDC + + @staticmethod + def _format_amount(value: float) -> str: + """Format a numeric amount for the CLI without trailing zeros. + + Args: + value: The amount to format. + + Returns: + str: The formatted amount string. + + """ + return f"{value:.6f}".rstrip("0").rstrip(".") + + +def taskmarket_action_provider( + api_base_url: str | None = None, + max_spend_usdc: float | None = None, + cli_timeout_seconds: int = DEFAULT_CLI_TIMEOUT_SECONDS, + request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT_SECONDS, +) -> TaskmarketActionProvider: + """Create a new Taskmarket action provider. + + Args: + api_base_url: Optional base URL of the Taskmarket REST API. + Defaults to the public Taskmarket API. + max_spend_usdc: Optional maximum spend per task in USDC. When set, + this overrides the TASKMARKET_MAX_SPEND_USDC environment variable. + Defaults to 10.0 USDC. + cli_timeout_seconds: Timeout in seconds for taskmarket CLI calls. + request_timeout_seconds: Timeout in seconds for REST API calls. + + Returns: + TaskmarketActionProvider: A new Taskmarket action provider instance. + + """ + return TaskmarketActionProvider( + api_base_url=api_base_url, + max_spend_usdc=max_spend_usdc, + cli_timeout_seconds=cli_timeout_seconds, + request_timeout_seconds=request_timeout_seconds, + ) diff --git a/python/coinbase-agentkit/tests/action_providers/taskmarket/__init__.py b/python/coinbase-agentkit/tests/action_providers/taskmarket/__init__.py new file mode 100644 index 000000000..dea573fe9 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/taskmarket/__init__.py @@ -0,0 +1 @@ +"""Tests for the Taskmarket action provider.""" diff --git a/python/coinbase-agentkit/tests/action_providers/taskmarket/conftest.py b/python/coinbase-agentkit/tests/action_providers/taskmarket/conftest.py new file mode 100644 index 000000000..52d59b841 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/taskmarket/conftest.py @@ -0,0 +1,64 @@ +"""Test fixtures for the Taskmarket action provider tests.""" + +from unittest.mock import patch + +import pytest + +MOCK_TASK_ID = "0x" + "ab" * 32 +MOCK_TASK = { + "id": MOCK_TASK_ID, + "description": "Build a landing page for an agent marketplace", + "reward": "5000000", # 5 USDC in base units + "mode": "bounty", + "status": "open", + "submissionCount": 3, + "expiryTime": "2026-09-01T00:00:00.000Z", + "tags": ["web", "design"], + "requester": "0x1234567890123456789012345678901234567890", + "taskVisibility": "public", + "submissionVisibility": "public", +} + +MOCK_SECOND_TASK = { + "id": "0x" + "cd" * 32, + "description": "Generate a benchmark dataset", + "reward": "30000000", # 30 USDC in base units + "mode": "benchmark", + "status": "open", + "submissionCount": 1, + "expiryTime": "2026-09-05T00:00:00.000Z", + "tags": ["data"], + "requester": "0x1234567890123456789012345678901234567890", + "taskVisibility": "public", + "submissionVisibility": "public", +} + +MOCK_TASKS = [MOCK_TASK, MOCK_SECOND_TASK] + + +@pytest.fixture +def mock_requests_get(): + """Mock requests.get for read-only Taskmarket REST API calls.""" + with patch( + "coinbase_agentkit.action_providers.taskmarket.taskmarket_action_provider.requests.get" + ) as mock_get: + yield mock_get + + +@pytest.fixture +def mock_subprocess_run(): + """Mock subprocess.run for taskmarket CLI calls.""" + with patch( + "coinbase_agentkit.action_providers.taskmarket.taskmarket_action_provider.subprocess.run" + ) as mock_run: + yield mock_run + + +@pytest.fixture +def mock_which(): + """Mock shutil.which so the taskmarket CLI appears installed.""" + with patch( + "coinbase_agentkit.action_providers.taskmarket.taskmarket_action_provider.shutil.which" + ) as mock_which: + mock_which.return_value = "/usr/local/bin/taskmarket" + yield mock_which diff --git a/python/coinbase-agentkit/tests/action_providers/taskmarket/test_taskmarket_action_provider.py b/python/coinbase-agentkit/tests/action_providers/taskmarket/test_taskmarket_action_provider.py new file mode 100644 index 000000000..07e24bc37 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/taskmarket/test_taskmarket_action_provider.py @@ -0,0 +1,507 @@ +"""Tests for the Taskmarket action provider.""" + +import json +import subprocess +from unittest.mock import Mock + +import pytest +import requests +from pydantic import ValidationError + +from coinbase_agentkit.action_providers.taskmarket.schemas import ( + BrowseTasksSchema, + CreateTaskSchema, + GetTaskSchema, +) +from coinbase_agentkit.action_providers.taskmarket.taskmarket_action_provider import ( + DEFAULT_MAX_SPEND_USDC, + MAX_SPEND_USDC_ENV_VAR, + TaskmarketActionProvider, + taskmarket_action_provider, +) +from coinbase_agentkit.network import Network + +from .conftest import MOCK_SECOND_TASK, MOCK_TASK, MOCK_TASK_ID, MOCK_TASKS + + +def _mock_response(payload, status_code=200): + """Build a requests.Response-like mock.""" + response = Mock(spec=requests.Response) + response.status_code = status_code + response.json.return_value = payload + response.raise_for_status.return_value = None + return response + + +# ========================================================= +# Provider registration tests +# ========================================================= + + +def test_provider_name(): + """Test that the provider registers under the expected name.""" + provider = taskmarket_action_provider() + assert provider.name == "taskmarket" + + +def test_provider_exposes_expected_actions(): + """Test that the provider exposes the three Taskmarket actions.""" + provider = taskmarket_action_provider() + action_names = [action.name for action in provider.get_actions(Mock())] + assert "TaskmarketActionProvider_browse_tasks" in action_names + assert "TaskmarketActionProvider_get_task" in action_names + assert "TaskmarketActionProvider_create_task" in action_names + + +def test_provider_network_agnostic(): + """Test that the provider supports any network.""" + provider = taskmarket_action_provider() + network = Network(chain_id="8453", network_id="base-mainnet", protocol_family="evm") + assert provider.supports_network(network) is True + other_network = Network(chain_id="1", network_id="ethereum-mainnet", protocol_family="evm") + assert provider.supports_network(other_network) is True + + +# ========================================================= +# Schema tests +# ========================================================= + + +def test_browse_tasks_schema_valid(): + """Test that BrowseTasksSchema validates correctly.""" + valid_inputs = [ + {}, # All defaults + {"limit": 20}, + {"min_reward_usdc": 1.0, "max_reward_usdc": 25.0, "mode": "bounty", "limit": 100}, + ] + for input_data in valid_inputs: + schema = BrowseTasksSchema(**input_data) + assert schema.limit >= 1 + assert schema.limit <= 100 + + +def test_browse_tasks_schema_invalid(): + """Test that BrowseTasksSchema rejects invalid input.""" + invalid_inputs = [ + {"limit": 0}, + {"limit": 101}, + {"min_reward_usdc": -1}, + {"max_reward_usdc": -0.5}, + {"mode": "unknown_mode"}, + ] + for input_data in invalid_inputs: + with pytest.raises(ValidationError): + BrowseTasksSchema(**input_data) + + +def test_get_task_schema_valid(): + """Test that GetTaskSchema validates correctly.""" + schema = GetTaskSchema(task_id=MOCK_TASK_ID) + assert schema.task_id == MOCK_TASK_ID + + +def test_get_task_schema_invalid(): + """Test that GetTaskSchema rejects missing task id.""" + with pytest.raises(ValidationError): + GetTaskSchema() + + +def test_create_task_schema_valid(): + """Test that CreateTaskSchema validates correctly.""" + schema = CreateTaskSchema( + description="Write a blog post", + reward_usdc=5.0, + duration_hours=48, + mode="bounty", + confirmation=True, + ) + assert schema.reward_usdc == 5.0 + assert schema.confirmation is True + + +def test_create_task_schema_invalid(): + """Test that CreateTaskSchema rejects invalid input.""" + invalid_inputs = [ + {"description": "x", "reward_usdc": 5.0, "duration_hours": 48}, # no confirmation + {"description": "x", "reward_usdc": 0, "duration_hours": 48, "confirmation": True}, + {"description": "x", "reward_usdc": -1, "duration_hours": 48, "confirmation": True}, + {"description": "x", "reward_usdc": 5.0, "duration_hours": 0, "confirmation": True}, + { + "description": "x", + "reward_usdc": 5.0, + "duration_hours": 48, + "mode": "nope", + "confirmation": True, + }, + ] + for input_data in invalid_inputs: + with pytest.raises(ValidationError): + CreateTaskSchema(**input_data) + + +# ========================================================= +# browse_tasks tests +# ========================================================= + + +def test_browse_tasks_success(mock_requests_get): + """Test browsing tasks returns filtered open tasks.""" + mock_requests_get.return_value = _mock_response({"tasks": MOCK_TASKS, "hasMore": False}) + provider = taskmarket_action_provider() + + response = json.loads( + provider.browse_tasks( + { + "min_reward_usdc": 1.0, + "max_reward_usdc": 10.0, + "mode": "bounty", + "limit": 20, + } + ) + ) + + assert response["success"] is True + assert response["count"] == 1 + assert response["tasks"][0]["id"] == MOCK_TASK_ID + assert response["tasks"][0]["rewardUsdc"] == 5.0 + assert response["tasks"][0]["mode"] == "bounty" + + # Verify the REST API was called with open status and newest sort + _, kwargs = mock_requests_get.call_args + assert kwargs["params"]["status"] == "open" + assert kwargs["params"]["sort"] == "newest" + + +def test_browse_tasks_uses_pagination(mock_requests_get): + """Test that browsing follows the cursor until the limit is reached.""" + page_one = {"tasks": MOCK_TASKS, "hasMore": True, "nextCursor": "2026-08-01T00:00:00.000Z"} + page_two = {"tasks": [MOCK_SECOND_TASK], "hasMore": False} + mock_requests_get.side_effect = [_mock_response(page_one), _mock_response(page_two)] + provider = taskmarket_action_provider() + + response = json.loads(provider.browse_tasks({"limit": 3})) + + assert response["success"] is True + assert response["count"] == 3 + assert mock_requests_get.call_count == 2 + # The second call must pass the cursor + _, second_kwargs = mock_requests_get.call_args_list[1] + assert second_kwargs["params"]["cursor"] == "2026-08-01T00:00:00.000Z" + + +def test_browse_tasks_stops_when_limit_reached(mock_requests_get): + """Test that browsing does not fetch more pages once the limit is reached.""" + page_one = {"tasks": MOCK_TASKS, "hasMore": True, "nextCursor": "cursor-1"} + mock_requests_get.side_effect = [ + _mock_response(page_one), + _mock_response({"tasks": [], "hasMore": False}), + ] + provider = taskmarket_action_provider() + + response = json.loads(provider.browse_tasks({"limit": 2})) + + assert response["success"] is True + assert response["count"] == 2 + assert mock_requests_get.call_count == 1 + + +def test_browse_tasks_error(mock_requests_get): + """Test that browse_tasks returns an error JSON when the API call fails.""" + mock_requests_get.side_effect = requests.RequestException("connection refused") + provider = taskmarket_action_provider() + + response = json.loads(provider.browse_tasks({"limit": 20})) + + assert response["error"] is True + assert "Failed to browse Taskmarket tasks" in response["message"] + + +# ========================================================= +# get_task tests +# ========================================================= + + +def test_get_task_success(mock_requests_get): + """Test fetching a single task returns its details.""" + mock_requests_get.return_value = _mock_response(MOCK_TASK) + provider = taskmarket_action_provider() + + response = json.loads(provider.get_task({"task_id": MOCK_TASK_ID})) + + assert response["success"] is True + task = response["task"] + assert task["id"] == MOCK_TASK_ID + assert task["status"] == "open" + assert task["rewardUsdc"] == 5.0 + assert task["submissionCount"] == 3 + assert task["expiryTime"] == "2026-09-01T00:00:00.000Z" + assert task["description"] == MOCK_TASK["description"] + # The API is called with the task id in the URL + assert mock_requests_get.call_args[0][0].endswith(f"/tasks/{MOCK_TASK_ID}") + + +def test_get_task_error(mock_requests_get): + """Test that get_task returns an error JSON when the task is not found.""" + error_response = Mock(spec=requests.Response) + error_response.raise_for_status.side_effect = requests.HTTPError("404 Client Error") + mock_requests_get.return_value = error_response + provider = taskmarket_action_provider() + + response = json.loads(provider.get_task({"task_id": MOCK_TASK_ID})) + + assert response["error"] is True + assert "Failed to fetch Taskmarket task" in response["message"] + + +# ========================================================= +# create_task tests +# ========================================================= + + +def test_create_task_requires_confirmation(mock_which, mock_subprocess_run): + """Test that create_task refuses to run without explicit confirmation.""" + provider = taskmarket_action_provider() + + response = json.loads( + provider.create_task( + { + "description": "Write a blog post", + "reward_usdc": 5.0, + "duration_hours": 48, + "confirmation": False, + } + ) + ) + + assert response["error"] is True + assert "explicit confirmation" in response["message"] + # The order is echoed so the user sees exactly what would be authorized + assert response["order"]["description"] == "Write a blog post" + assert response["order"]["rewardUsdc"] == 5.0 + assert response["order"]["network"] == "Base network (mainnet), paid in USDC" + # No CLI invocation and no payment + mock_subprocess_run.assert_not_called() + + +def test_create_task_respects_spending_limit(monkeypatch, mock_which, mock_subprocess_run): + """Test that create_task refuses rewards above the configured spending limit.""" + monkeypatch.setenv(MAX_SPEND_USDC_ENV_VAR, "5.0") + provider = taskmarket_action_provider() + + response = json.loads( + provider.create_task( + { + "description": "Write a blog post", + "reward_usdc": 10.0, + "duration_hours": 48, + "confirmation": True, + } + ) + ) + + assert response["error"] is True + assert "exceeds spending limit" in response["message"] + assert "10.0 USDC" in response["details"] + assert "5.0 USDC" in response["details"] + mock_subprocess_run.assert_not_called() + + +def test_create_task_uses_default_spending_limit(monkeypatch, mock_which, mock_subprocess_run): + """Test that the default spending limit is used when the env var is unset.""" + monkeypatch.delenv(MAX_SPEND_USDC_ENV_VAR, raising=False) + provider = taskmarket_action_provider() + + response = json.loads( + provider.create_task( + { + "description": "Write a blog post", + "reward_usdc": DEFAULT_MAX_SPEND_USDC + 1, + "duration_hours": 48, + "confirmation": True, + } + ) + ) + + assert response["error"] is True + assert "exceeds spending limit" in response["message"] + mock_subprocess_run.assert_not_called() + + +def test_create_task_cli_not_found(monkeypatch, mock_which, mock_subprocess_run): + """Test that create_task errors when the taskmarket CLI is not installed.""" + monkeypatch.delenv(MAX_SPEND_USDC_ENV_VAR, raising=False) + mock_which.return_value = None + provider = taskmarket_action_provider() + + response = json.loads( + provider.create_task( + { + "description": "Write a blog post", + "reward_usdc": 5.0, + "duration_hours": 48, + "confirmation": True, + } + ) + ) + + assert response["error"] is True + assert "taskmarket CLI not found" in response["message"] + assert "npm i -g @lucid-agents/taskmarket" in response["details"] + mock_subprocess_run.assert_not_called() + + +def test_create_task_cli_failure(monkeypatch, mock_which, mock_subprocess_run): + """Test that create_task surfaces the CLI error on non-zero exit.""" + monkeypatch.delenv(MAX_SPEND_USDC_ENV_VAR, raising=False) + mock_subprocess_run.return_value = Mock( + returncode=1, stdout="", stderr="Error: insufficient USDC balance" + ) + provider = taskmarket_action_provider() + + response = json.loads( + provider.create_task( + { + "description": "Write a blog post", + "reward_usdc": 5.0, + "duration_hours": 48, + "confirmation": True, + } + ) + ) + + assert response["error"] is True + assert "taskmarket CLI failed" in response["message"] + assert "insufficient USDC balance" in response["details"] + + +def test_create_task_cli_timeout_no_retry(monkeypatch, mock_which, mock_subprocess_run): + """Test that a CLI timeout returns an error and is never retried.""" + monkeypatch.delenv(MAX_SPEND_USDC_ENV_VAR, raising=False) + mock_subprocess_run.side_effect = subprocess.TimeoutExpired( + cmd="taskmarket task create", timeout=120 + ) + provider = taskmarket_action_provider() + + response = json.loads( + provider.create_task( + { + "description": "Write a blog post", + "reward_usdc": 5.0, + "duration_hours": 48, + "confirmation": True, + } + ) + ) + + assert response["error"] is True + assert "timed out" in response["message"] + assert "Do NOT retry" in response["details"] + # Exactly one CLI invocation: the payment is never retried on unknown status + assert mock_subprocess_run.call_count == 1 + + +def test_create_task_success(monkeypatch, mock_which, mock_subprocess_run): + """Test that create_task delegates to the taskmarket CLI on success.""" + monkeypatch.delenv(MAX_SPEND_USDC_ENV_VAR, raising=False) + mock_subprocess_run.return_value = Mock( + returncode=0, + stdout="Task created successfully: 0xabc123\n", + stderr="", + ) + provider = taskmarket_action_provider() + + response = json.loads( + provider.create_task( + { + "description": "Write a blog post", + "reward_usdc": 5.0, + "duration_hours": 48, + "mode": "bounty", + "confirmation": True, + } + ) + ) + + assert response["success"] is True + assert response["order"]["description"] == "Write a blog post" + assert response["order"]["rewardUsdc"] == 5.0 + assert response["order"]["durationHours"] == 48 + assert response["order"]["network"] == "Base network (mainnet), paid in USDC" + assert "0xabc123" in response["cliOutput"] + + # Verify the exact CLI command + command = mock_subprocess_run.call_args[0][0] + assert command[0] == "/usr/local/bin/taskmarket" + assert command[1:3] == ["task", "create"] + assert "--description" in command + assert command[command.index("--description") + 1] == "Write a blog post" + assert "--reward" in command + assert command[command.index("--reward") + 1] == "5" + assert "--duration" in command + assert command[command.index("--duration") + 1] == "48" + assert "--mode" in command + assert command[command.index("--mode") + 1] == "bounty" + + +def test_create_task_success_echoes_network_in_comment( + monkeypatch, mock_which, mock_subprocess_run +): + """Test that the network is always part of the order echo.""" + monkeypatch.delenv(MAX_SPEND_USDC_ENV_VAR, raising=False) + mock_subprocess_run.return_value = Mock(returncode=0, stdout="ok", stderr="") + provider = taskmarket_action_provider() + + response = json.loads( + provider.create_task( + { + "description": "Design a logo", + "reward_usdc": 2.5, + "duration_hours": 24, + "confirmation": True, + } + ) + ) + + assert response["success"] is True + assert response["order"]["network"] == "Base network (mainnet), paid in USDC" + assert response["order"]["rewardUsdc"] == 2.5 + # Reward is passed to the CLI without trailing zeros + command = mock_subprocess_run.call_args[0][0] + assert command[command.index("--reward") + 1] == "2.5" + + +# ========================================================= +# Helper tests +# ========================================================= + + +def test_max_spend_usdc_resolution(monkeypatch): + """Test spending limit resolution: constructor, env var, then default.""" + provider = taskmarket_action_provider() + assert provider._get_max_spend_usdc() == DEFAULT_MAX_SPEND_USDC + + monkeypatch.setenv(MAX_SPEND_USDC_ENV_VAR, "25.0") + assert provider._get_max_spend_usdc() == 25.0 + + monkeypatch.setenv(MAX_SPEND_USDC_ENV_VAR, "not-a-number") + assert provider._get_max_spend_usdc() == DEFAULT_MAX_SPEND_USDC + + explicit = TaskmarketActionProvider(max_spend_usdc=50.0) + monkeypatch.setenv(MAX_SPEND_USDC_ENV_VAR, "25.0") + assert explicit._get_max_spend_usdc() == 50.0 + + +def test_reward_conversion(): + """Test that raw integer base-unit rewards convert to USDC.""" + assert TaskmarketActionProvider._reward_to_usdc("64000000") == 64.0 + assert TaskmarketActionProvider._reward_to_usdc("5000000") == 5.0 + assert TaskmarketActionProvider._reward_to_usdc(None) == 0.0 + assert TaskmarketActionProvider._reward_to_usdc("garbage") == 0.0 + + +def test_format_amount(): + """Test CLI amount formatting strips unnecessary trailing zeros.""" + assert TaskmarketActionProvider._format_amount(5.0) == "5" + assert TaskmarketActionProvider._format_amount(2.5) == "2.5" + assert TaskmarketActionProvider._format_amount(48) == "48" + assert TaskmarketActionProvider._format_amount(0.000001) == "0.000001"