diff --git a/.env.example b/.env.example index 5c8577c..afeeac4 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,39 @@ TRANSLATE_ALL__MAX_MESSAGES_PER_MINUTE=30 GROUP_PREFERENCES__PERSIST=true GROUP_PREFERENCES__STORAGE_PATH=/data/group_prefs.enc +# Poa DAO tools (read + gated on-chain writes against a TaskManager) +# Off by default. See docs/poa-integration.md. +TOOLS__POA__ENABLED=false +# Chain the org lives on and its Poa subgraph (Gnosis shown; Poa gov lives on Arbitrum). +TOOLS__POA__RPC_URL=https://rpc.gnosischain.com +TOOLS__POA__SUBGRAPH_URL=https://api.studio.thegraph.com/query/73367/poa-gnosis-v-1/version/latest +TOOLS__POA__NETWORK_NAME=gnosis +# The org's TaskManager proxy address (find via the subgraph or OrgRegistry). +# TOOLS__POA__TASK_MANAGER=0x... +# Wallet: leave PRIVATE_KEY unset in production to derive the key inside the TEE +# (dstack derive_key). Only set PRIVATE_KEY for local dev against a testnet. +# TOOLS__POA__PRIVATE_KEY=0x... +TOOLS__POA__DERIVE_KEY_PATH=poa-tools/task-manager-wallet +# Write tools (create/update/assign/complete/reject/cancel task) are additionally +# gated: they are only offered to the allowlisted senders below, and only when +# ENABLE_WRITES=true. The bot wallet must also hold project-manager / hat rights +# on-chain (see docs/poa-integration.md). +TOOLS__POA__ENABLE_WRITES=false +# TOOLS__POA__AUTHORIZED_SENDERS=+15551230001,+15551230002 +# HybridVoting proxy address — required for governance tools (poa_create_poll / poa_vote / poa_list_proposals). +# TOOLS__POA__VOTING_CONTRACT=0x... +# Seconds a staged value-moving action (poa_complete_task) stays confirmable via !poa-confirm. +TOOLS__POA__CONFIRM_TTL_SECS=300 + +# Autonomous board steward: periodically posts a digest of expired/at-risk claims. +TOOLS__POA__STEWARD_ENABLED=false +# Signal target (group id or number) to post digests to, and the account to send from. +# TOOLS__POA__STEWARD_TARGET=group.xxxxx +# TOOLS__POA__STEWARD_FROM=+15551230000 +TOOLS__POA__STEWARD_INTERVAL_SECS=21600 +TOOLS__POA__STEWARD_WARN_WINDOW_SECS=86400 +TOOLS__POA__STEWARD_QUIET_WHEN_EMPTY=true + # Payment Configuration (x402) # Set to true to enable credit tracking and payment system PAYMENTS__ENABLED=false diff --git a/Cargo.lock b/Cargo.lock index 180cebd..cd353d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4778,6 +4778,23 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "poa-tools" +version = "0.1.0" +dependencies = [ + "alloy", + "async-trait", + "reqwest 0.11.27", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tools", + "tracing", + "wiremock", +] + [[package]] name = "polyval" version = "0.6.2" @@ -6073,6 +6090,7 @@ dependencies = [ "humantime-serde", "mockall", "near-ai-client", + "poa-tools", "rand 0.8.5", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 91f058b..bc7de2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/signal-client", "crates/signal-registration-proxy", "crates/tools", + "crates/poa-tools", "crates/x402-payments", "crates/whisper-client", ] diff --git a/README.md b/README.md index 90a399d..c184547 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ This project implements a Signal bot that runs inside a Dstack-powered TEE (Inte - In-memory conversation storage (no external persistence) - Group chat support with shared conversation context - OpenAI-compatible API integration +- Tool use (web search, weather, calculator) and **Poa DAO task tools** — read an + org's projects/tasks and, for authorized operators, create and manage tasks + on-chain with a TEE-derived wallet ([docs/poa-integration.md](docs/poa-integration.md)) ## Bot Commands @@ -61,6 +64,34 @@ Bob: "What's Alice's favorite color?" Bot: "Alice mentioned her favorite color is blue" ``` +## Poa DAO Tools + +When `TOOLS__POA__ENABLED=true`, the bot can operate a [Poa](https://github.com/poa-box) +DAO org's task board. Ask it things like "what open tasks are there?" or "create a +5 PT task in the Docs project to fix the changelog". + +- **Read tools** (everyone): projects, tasks, proposals, wallet info — backed by + the Poa subgraph. +- **Write tools** (allowlisted operators only), signed by a wallet **derived + inside the TEE** (no key leaves the enclave): + - *task authoring*: create / update / assign / complete / reject / cancel task + - *participation*: claim / submit / apply — the bot does work and **earns** PT + - *governance*: create non-executable polls, vote + +Extra safety on top of the allowlist: + +- **Confirmation** — value-moving actions (`poa_complete_task`, which mints a + payout) are staged and require a deterministic `!poa-confirm ` reply from + the same sender; the LLM cannot self-confirm. +- **Board steward** — an optional background loop posts a digest of expired / + at-risk claims to a Signal group (read-only; never sends transactions). + +Writes are gated by `TOOLS__POA__ENABLE_WRITES` plus a +`TOOLS__POA__AUTHORIZED_SENDERS` allowlist, and the chain enforces that the bot's +wallet holds the right TaskManager permission. Full setup — including how to grant +the wallet project-manager rights on the Poa side — is in +[docs/poa-integration.md](docs/poa-integration.md). + ## Verification Users can cryptographically verify the bot runs in a genuine TEE: diff --git a/crates/poa-tools/Cargo.toml b/crates/poa-tools/Cargo.toml new file mode 100644 index 0000000..fefeb84 --- /dev/null +++ b/crates/poa-tools/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "poa-tools" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Poa (proof-of-authority DAO protocol) tools for signal-bot-tee: query orgs via subgraph and update TaskManager on-chain" + +[dependencies] +tools = { path = "../tools" } + +# Async runtime +tokio = { workspace = true } +async-trait = { workspace = true } + +# HTTP client (subgraph queries) +reqwest = { version = "0.11", features = ["json"] } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } + +# Error handling +thiserror = { workspace = true } + +# Logging +tracing = { workspace = true } + +# Hashing (rejection/metadata hashes) +sha2 = { workspace = true } + +# EVM support via Alloy (same version as x402-payments) +alloy = { version = "1.1", features = [ + "contract", + "network", + "provider-http", + "rpc-types", + "signer-local", + "sol-types", +] } + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +wiremock = "0.5" diff --git a/crates/poa-tools/src/client.rs b/crates/poa-tools/src/client.rs new file mode 100644 index 0000000..65c47ce --- /dev/null +++ b/crates/poa-tools/src/client.rs @@ -0,0 +1,165 @@ +//! Poa client: wallet + RPC + subgraph endpoints shared by all Poa tools. + +use alloy::network::EthereumWallet; +use alloy::primitives::{Address, B256}; +use alloy::providers::{Provider, ProviderBuilder}; +use alloy::signers::local::PrivateKeySigner; +use thiserror::Error; +use tracing::info; + +/// Errors from Poa client operations. +#[derive(Error, Debug)] +pub enum PoaError { + /// Configuration problem (bad address, bad URL, missing key). + #[error("Poa configuration error: {0}")] + Config(String), + + /// Subgraph query failed. + #[error("Poa subgraph error: {0}")] + Subgraph(String), + + /// On-chain call failed. + #[error("Poa chain error: {0}")] + Chain(String), + + /// Invalid tool arguments. + #[error("Invalid arguments: {0}")] + InvalidArguments(String), +} + +/// Static configuration for a Poa deployment (one org's TaskManager). +#[derive(Debug, Clone)] +pub struct PoaClientConfig { + /// JSON-RPC endpoint of the chain the org lives on (e.g. Gnosis). + pub rpc_url: String, + /// Poa subgraph GraphQL endpoint for that chain. + pub subgraph_url: String, + /// Address of the org's TaskManager proxy. + pub task_manager: Address, + /// Optional address of the org's HybridVoting proxy (governance tools). + pub voting_contract: Option
, + /// Human-readable network name shown to users (e.g. "gnosis"). + pub network_name: String, +} + +impl PoaClientConfig { + /// Build config from string fields, parsing the TaskManager address. + pub fn parse( + rpc_url: impl Into, + subgraph_url: impl Into, + task_manager: &str, + network_name: impl Into, + ) -> Result { + let task_manager: Address = task_manager + .trim() + .parse() + .map_err(|e| PoaError::Config(format!("invalid TaskManager address: {}", e)))?; + Ok(Self { + rpc_url: rpc_url.into(), + subgraph_url: subgraph_url.into(), + task_manager, + voting_contract: None, + network_name: network_name.into(), + }) + } + + /// Set the HybridVoting contract address (parsed from a string). + pub fn with_voting_contract(mut self, addr: Option<&str>) -> Result { + self.voting_contract = match addr.map(str::trim).filter(|s| !s.is_empty()) { + Some(a) => Some( + a.parse() + .map_err(|e| PoaError::Config(format!("invalid voting address: {}", e)))?, + ), + None => None, + }; + Ok(self) + } +} + +/// Shared client used by every Poa tool. +pub struct PoaClient { + pub(crate) config: PoaClientConfig, + pub(crate) signer: PrivateKeySigner, + pub(crate) http: reqwest::Client, + pub(crate) rpc_url: reqwest::Url, +} + +impl PoaClient { + /// Create a client from config and a hex private key (`0x`-prefixed or not). + pub fn from_hex_key(config: PoaClientConfig, hex_key: &str) -> Result { + let signer: PrivateKeySigner = hex_key + .trim() + .parse() + .map_err(|e| PoaError::Config(format!("invalid private key: {}", e)))?; + Self::new(config, signer) + } + + /// Create a client from config and a raw 32-byte key, e.g. one derived from + /// the TEE via dstack. Only the first 32 bytes are used. + pub fn from_key_bytes(config: PoaClientConfig, key_bytes: &[u8]) -> Result { + if key_bytes.len() < 32 { + return Err(PoaError::Config(format!( + "derived key too short: {} bytes", + key_bytes.len() + ))); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&key_bytes[..32]); + let signer = PrivateKeySigner::from_bytes(&B256::from(arr)) + .map_err(|e| PoaError::Config(format!("invalid derived key: {}", e)))?; + Self::new(config, signer) + } + + /// Create a client from config and a wallet signer. + pub fn new(config: PoaClientConfig, signer: PrivateKeySigner) -> Result { + let rpc_url: reqwest::Url = config + .rpc_url + .parse() + .map_err(|e| PoaError::Config(format!("invalid RPC URL: {}", e)))?; + + info!( + wallet = %signer.address(), + task_manager = %config.task_manager, + network = %config.network_name, + "Poa client initialized" + ); + + Ok(Self { + config, + signer, + http: reqwest::Client::new(), + rpc_url, + }) + } + + /// The configured HybridVoting address, or a config error if governance + /// tools are used without one set. + pub(crate) fn require_voting(&self) -> Result { + self.config.voting_contract.ok_or_else(|| { + PoaError::Config( + "governance tools need TOOLS__POA__VOTING_CONTRACT to be set".to_string(), + ) + }) + } + + /// The bot's wallet address (must be granted project-manager rights on-chain). + pub fn address(&self) -> Address { + self.signer.address() + } + + /// Build a provider with the wallet attached for sending transactions. + pub(crate) fn provider(&self) -> impl Provider + Clone { + ProviderBuilder::new() + .with_gas_estimation() + .wallet(EthereumWallet::from(self.signer.clone())) + .connect_http(self.rpc_url.clone()) + } + + /// Native token balance of the bot wallet, in wei. + pub async fn native_balance(&self) -> Result { + self.provider() + .get_balance(self.address()) + .await + .map_err(|e| PoaError::Chain(format!("balance query failed: {}", e))) + } +} diff --git a/crates/poa-tools/src/common.rs b/crates/poa-tools/src/common.rs new file mode 100644 index 0000000..e2f0597 --- /dev/null +++ b/crates/poa-tools/src/common.rs @@ -0,0 +1,41 @@ +//! Shared argument-parsing helpers for Poa tools. + +use crate::client::PoaError; +use alloy::primitives::{Address, B256, U256}; +use sha2::{Digest, Sha256}; +use tools::ToolError; + +/// Map a `PoaError` to a `ToolError`, preserving invalid-argument classification. +pub(crate) fn map_err(e: PoaError) -> ToolError { + match e { + PoaError::InvalidArguments(m) => ToolError::InvalidArguments(m), + other => ToolError::ExternalService(other.to_string()), + } +} + +/// Parse a 0x-prefixed bytes32 (project id, metadata hash, etc). +pub(crate) fn parse_b32(s: &str, field: &str) -> Result { + s.parse::().map_err(|_| { + ToolError::InvalidArguments(format!("invalid {} (expected 0x + 64 hex): {}", field, s)) + }) +} + +/// Parse a metadata/reason hash: accept a `0x` bytes32 directly, or hash an +/// arbitrary string with sha256 so the model can pass plain text with no CID. +pub(crate) fn parse_metadata_hash(s: &str) -> B256 { + if let Ok(h) = s.parse::() { + return h; + } + B256::from_slice(&Sha256::digest(s.as_bytes())) +} + +/// Parse a 0x wallet address. +pub(crate) fn parse_address(s: &str, field: &str) -> Result { + s.parse::
() + .map_err(|_| ToolError::InvalidArguments(format!("invalid {} address: {}", field, s))) +} + +/// Widen a numeric task id. +pub(crate) fn parse_task_id(id: u64) -> U256 { + U256::from(id) +} diff --git a/crates/poa-tools/src/contract.rs b/crates/poa-tools/src/contract.rs new file mode 100644 index 0000000..ff633fc --- /dev/null +++ b/crates/poa-tools/src/contract.rs @@ -0,0 +1,280 @@ +//! On-chain writes against the Poa TaskManager contract. +// The TaskManager ABI methods generated by `sol!` mirror the on-chain function +// signatures (createTask has 9 params), which trips clippy's argument-count lint +// on generated code we don't control. +#![allow(clippy::too_many_arguments)] + +use crate::client::{PoaClient, PoaError}; +use alloy::primitives::aliases::U48; +use alloy::primitives::{Address, Bytes, B256, U256}; +use alloy::sol; +use tracing::info; + +sol! { + #[sol(rpc)] + interface ITaskManager { + function createTask( + uint256 payout, + bytes title, + bytes32 metadataHash, + bytes32 pid, + address bountyToken, + uint256 bountyPayout, + bool requiresApplication, + uint48 absoluteDeadline, + uint32 completionWindow + ) external; + + function updateTask( + uint256 id, + uint256 newPayout, + bytes newTitle, + bytes32 newMetadataHash, + address newBountyToken, + uint256 newBountyPayout, + uint48 newAbsoluteDeadline, + uint32 newCompletionWindow + ) external; + + function assignTask(uint256 id, address assignee) external; + function completeTask(uint256 id) external; + function rejectTask(uint256 id, bytes32 rejectionHash) external; + function cancelTask(uint256 id) external; + + // Participation (the bot doing work itself). + function claimTask(uint256 id) external; + function submitTask(uint256 id, bytes32 submissionHash) external; + function applyForTask(uint256 id, bytes32 applicationHash) external; + } + + #[sol(rpc)] + interface IHybridVoting { + // Non-executable poll when every batch is empty. + function createProposal( + bytes title, + bytes32 descriptionHash, + uint32 minutesDuration, + uint8 numOptions, + Call[][] batches, + uint256[] hatIds + ) external; + + function vote(uint256 id, uint8[] idxs, uint8[] weights) external; + } + + struct Call { + address target; + uint256 value; + bytes data; + } +} + +/// Outcome of a submitted transaction. +#[derive(Debug, Clone)] +pub struct TxOutcome { + /// Transaction hash (0x-prefixed). + pub hash: String, + /// Whether the transaction succeeded. + pub success: bool, + /// Block the transaction was mined in, if known. + pub block: Option, +} + +impl TxOutcome { + fn from_receipt(receipt: alloy::rpc::types::TransactionReceipt) -> Self { + Self { + hash: format!("{:?}", receipt.transaction_hash), + success: receipt.status(), + block: receipt.block_number, + } + } +} + +/// Parameters for creating a task (bounties intentionally unsupported for now). +pub(crate) struct CreateTaskParams { + pub payout: U256, + pub title: String, + pub metadata_hash: B256, + pub project_id: B256, + pub requires_application: bool, + pub absolute_deadline: u64, + pub completion_window: u32, +} + +/// Parameters for a full task update. +pub(crate) struct UpdateTaskParams { + pub task_id: U256, + pub payout: U256, + pub title: String, + pub metadata_hash: B256, + pub bounty_token: Address, + pub bounty_payout: U256, + pub absolute_deadline: u64, + pub completion_window: u32, +} + +macro_rules! send_tx { + ($self:expr, $label:expr, $call:expr) => {{ + let receipt = $call + .send() + .await + .map_err(|e| PoaError::Chain(format!(concat!($label, " failed to send: {}"), e)))? + .get_receipt() + .await + .map_err(|e| PoaError::Chain(format!(concat!($label, " receipt error: {}"), e)))?; + let outcome = TxOutcome::from_receipt(receipt); + info!(tx = %outcome.hash, success = outcome.success, concat!("Poa ", $label, " transaction mined")); + if !outcome.success { + return Err(PoaError::Chain(format!( + concat!($label, " transaction reverted: {}"), + outcome.hash + ))); + } + Ok(outcome) + }}; +} + +impl PoaClient { + fn task_manager( + &self, + ) -> ITaskManager::ITaskManagerInstance { + ITaskManager::new(self.config.task_manager, self.provider()) + } + + pub(crate) async fn create_task(&self, p: CreateTaskParams) -> Result { + let tm = self.task_manager(); + send_tx!( + self, + "createTask", + tm.createTask( + p.payout, + Bytes::from(p.title.into_bytes()), + p.metadata_hash, + p.project_id, + Address::ZERO, + U256::ZERO, + p.requires_application, + U48::from(p.absolute_deadline), + p.completion_window, + ) + ) + } + + pub(crate) async fn update_task(&self, p: UpdateTaskParams) -> Result { + let tm = self.task_manager(); + send_tx!( + self, + "updateTask", + tm.updateTask( + p.task_id, + p.payout, + Bytes::from(p.title.into_bytes()), + p.metadata_hash, + p.bounty_token, + p.bounty_payout, + U48::from(p.absolute_deadline), + p.completion_window, + ) + ) + } + + pub(crate) async fn assign_task( + &self, + task_id: U256, + assignee: Address, + ) -> Result { + let tm = self.task_manager(); + send_tx!(self, "assignTask", tm.assignTask(task_id, assignee)) + } + + pub(crate) async fn complete_task(&self, task_id: U256) -> Result { + let tm = self.task_manager(); + send_tx!(self, "completeTask", tm.completeTask(task_id)) + } + + pub(crate) async fn reject_task( + &self, + task_id: U256, + rejection_hash: B256, + ) -> Result { + let tm = self.task_manager(); + send_tx!(self, "rejectTask", tm.rejectTask(task_id, rejection_hash)) + } + + pub(crate) async fn cancel_task(&self, task_id: U256) -> Result { + let tm = self.task_manager(); + send_tx!(self, "cancelTask", tm.cancelTask(task_id)) + } + + pub(crate) async fn claim_task(&self, task_id: U256) -> Result { + let tm = self.task_manager(); + send_tx!(self, "claimTask", tm.claimTask(task_id)) + } + + pub(crate) async fn submit_task( + &self, + task_id: U256, + submission_hash: B256, + ) -> Result { + let tm = self.task_manager(); + send_tx!(self, "submitTask", tm.submitTask(task_id, submission_hash)) + } + + pub(crate) async fn apply_for_task( + &self, + task_id: U256, + application_hash: B256, + ) -> Result { + let tm = self.task_manager(); + send_tx!( + self, + "applyForTask", + tm.applyForTask(task_id, application_hash) + ) + } + + fn voting( + &self, + addr: Address, + ) -> IHybridVoting::IHybridVotingInstance { + IHybridVoting::new(addr, self.provider()) + } + + /// Create a non-executable governance poll (all batches empty). + pub(crate) async fn create_poll( + &self, + voting: Address, + title: String, + description_hash: B256, + minutes_duration: u32, + num_options: u8, + ) -> Result { + let v = self.voting(voting); + // One empty Call batch per option => a poll with no on-chain effects. + let batches: Vec> = (0..num_options).map(|_| Vec::new()).collect(); + let hat_ids: Vec = Vec::new(); + send_tx!( + self, + "createProposal", + v.createProposal( + Bytes::from(title.into_bytes()), + description_hash, + minutes_duration, + num_options, + batches, + hat_ids, + ) + ) + } + + pub(crate) async fn vote( + &self, + voting: Address, + proposal_id: U256, + idxs: Vec, + weights: Vec, + ) -> Result { + let v = self.voting(voting); + send_tx!(self, "vote", v.vote(proposal_id, idxs, weights)) + } +} diff --git a/crates/poa-tools/src/lib.rs b/crates/poa-tools/src/lib.rs new file mode 100644 index 0000000..7fa0b86 --- /dev/null +++ b/crates/poa-tools/src/lib.rs @@ -0,0 +1,146 @@ +//! Poa protocol tools for the Signal bot. +//! +//! Poa is a DAO coordination protocol (BeaconProxy orgs with TaskManager, +//! voting, participation tokens). These tools let the bot read an org's +//! projects/tasks from the Poa subgraph and — when the bot's wallet has been +//! granted project-manager rights on-chain — create and update tasks in the +//! org's `TaskManager` contract. +//! +//! Read tools are available to everyone the bot talks to. Write tools are +//! marked [`tools::Tool::requires_authorization`] and are only offered to / +//! executable by Signal senders on the configured allowlist. Value-moving tools +//! additionally set [`tools::Tool::requires_confirmation`]. +//! +//! The suite spans the org lifecycle: reading projects/tasks/proposals, task +//! authoring (create/update/assign/complete/reject/cancel), the bot doing work +//! itself (claim/submit/apply — it can *earn* participation tokens), and +//! governance (create polls, vote). + +mod client; +mod common; +mod contract; +mod subgraph; +mod tools_gov; +mod tools_participate; +mod tools_read; +mod tools_write; +mod units; + +pub mod steward; + +use std::sync::Arc; +use tools::ConfirmationStore; + +pub use client::{PoaClient, PoaClientConfig, PoaError}; +pub use contract::TxOutcome; +pub use steward::{FlaggedTask, StewardReport}; +pub use subgraph::{ProjectInfo, ProposalInfo, TaskInfo}; +pub use tools_gov::{PoaCreatePollTool, PoaListProposalsTool, PoaVoteTool}; +pub use tools_participate::{PoaApplyForTaskTool, PoaClaimTaskTool, PoaSubmitTaskTool}; +pub use tools_read::{PoaGetTaskTool, PoaListProjectsTool, PoaListTasksTool, PoaWalletInfoTool}; +pub use tools_write::{ + PoaAssignTaskTool, PoaCancelTaskTool, PoaCompleteTaskTool, PoaCreateTaskTool, + PoaRejectTaskTool, PoaUpdateTaskTool, +}; + +/// Build every Poa tool backed by the given client. +/// +/// Returns read tools first, then write tools (the write tools all report +/// `requires_authorization() == true`; `poa_complete_task` also requires +/// confirmation, which is why the shared [`ConfirmationStore`] is threaded in). +pub fn all_tools( + client: Arc, + confirm: Arc, +) -> Vec> { + vec![ + // Reads. + Arc::new(PoaListProjectsTool::new(client.clone())), + Arc::new(PoaListTasksTool::new(client.clone())), + Arc::new(PoaGetTaskTool::new(client.clone())), + Arc::new(PoaListProposalsTool::new(client.clone())), + Arc::new(PoaWalletInfoTool::new(client.clone())), + // Task authoring (write). + Arc::new(PoaCreateTaskTool::new(client.clone())), + Arc::new(PoaUpdateTaskTool::new(client.clone())), + Arc::new(PoaAssignTaskTool::new(client.clone())), + Arc::new(PoaCompleteTaskTool::new(client.clone(), confirm)), + Arc::new(PoaRejectTaskTool::new(client.clone())), + Arc::new(PoaCancelTaskTool::new(client.clone())), + // Participation (write) — the bot doing work. + Arc::new(PoaClaimTaskTool::new(client.clone())), + Arc::new(PoaSubmitTaskTool::new(client.clone())), + Arc::new(PoaApplyForTaskTool::new(client.clone())), + // Governance (write). + Arc::new(PoaCreatePollTool::new(client.clone())), + Arc::new(PoaVoteTool::new(client)), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_client() -> Arc { + let config = PoaClientConfig::parse( + "http://localhost:1", + "http://localhost:2/graphql", + "0x00000000000000000000000000000000000000aa", + "testnet", + ) + .unwrap(); + Arc::new(PoaClient::from_key_bytes(config, &[0x42u8; 32]).unwrap()) + } + + fn dummy_tools() -> Vec> { + let confirm = Arc::new(ConfirmationStore::new(std::time::Duration::from_secs(300))); + all_tools(dummy_client(), confirm) + } + + #[test] + fn test_read_write_tool_split() { + let tools = dummy_tools(); + assert_eq!(tools.len(), 16); + + let read: Vec<&str> = tools + .iter() + .filter(|t| !t.requires_authorization()) + .map(|t| t.name()) + .collect(); + let write: Vec<&str> = tools + .iter() + .filter(|t| t.requires_authorization()) + .map(|t| t.name()) + .collect(); + + // 5 read tools, 11 write tools. + assert_eq!(read.len(), 5, "read tools: {:?}", read); + assert_eq!(write.len(), 11, "write tools: {:?}", write); + assert!(read.contains(&"poa_list_projects")); + assert!(read.contains(&"poa_list_proposals")); + assert!(read.contains(&"poa_wallet_info")); + assert!(write.contains(&"poa_create_task")); + assert!(write.contains(&"poa_claim_task")); + assert!(write.contains(&"poa_create_poll")); + assert!(write.contains(&"poa_vote")); + + // Every write tool must carry a longer-than-default timeout for mining. + for t in tools.iter().filter(|t| t.requires_authorization()) { + assert!( + t.timeout_override().is_some(), + "{} missing timeout override", + t.name() + ); + } + } + + #[test] + fn test_only_complete_requires_confirmation() { + let tools = dummy_tools(); + let need_confirm: Vec<&str> = tools + .iter() + .filter(|t| t.requires_confirmation()) + .map(|t| t.name()) + .collect(); + assert_eq!(need_confirm, vec!["poa_complete_task"]); + } +} diff --git a/crates/poa-tools/src/steward.rs b/crates/poa-tools/src/steward.rs new file mode 100644 index 0000000..39fe308 --- /dev/null +++ b/crates/poa-tools/src/steward.rs @@ -0,0 +1,171 @@ +//! Autonomous board steward. +//! +//! A read-only watcher that scans the org's tasks and surfaces ones that need +//! attention: claims whose deadline has passed (open to takeover under the v6 +//! takeover rules) and claims about to expire. The scan is a pure function over +//! subgraph data + a supplied "now", so it is deterministic and testable; the +//! periodic loop that posts the digest to a Signal group lives in the bot (which +//! owns the Signal client). + +use crate::subgraph::TaskInfo; + +/// One task flagged by the steward. +#[derive(Debug, Clone, PartialEq)] +pub struct FlaggedTask { + pub task_id: String, + pub title: String, + pub assignee: Option, + pub deadline: u64, +} + +/// Result of a steward scan. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct StewardReport { + /// Claims whose deadline has passed (open to takeover). + pub expired_claims: Vec, + /// Claims whose deadline is within the warn window. + pub at_risk: Vec, + /// Count of currently open (unclaimed) tasks. + pub open_count: usize, +} + +impl StewardReport { + /// True when nothing needs surfacing. + pub fn is_empty(&self) -> bool { + self.expired_claims.is_empty() && self.at_risk.is_empty() + } + + /// Render a human digest for Signal. + pub fn render(&self) -> String { + let mut out = String::from("🩺 Board steward digest\n"); + if !self.expired_claims.is_empty() { + out.push_str(&format!( + "\n⏰ {} expired claim(s) — open to takeover:\n", + self.expired_claims.len() + )); + for t in &self.expired_claims { + out.push_str(&fmt_flagged(t)); + } + } + if !self.at_risk.is_empty() { + out.push_str(&format!( + "\n⚠️ {} claim(s) expiring soon:\n", + self.at_risk.len() + )); + for t in &self.at_risk { + out.push_str(&fmt_flagged(t)); + } + } + out.push_str(&format!( + "\n📋 {} open task(s) awaiting a claimer.", + self.open_count + )); + out + } +} + +fn fmt_flagged(t: &FlaggedTask) -> String { + format!( + " • #{} {}{}\n", + t.task_id, + t.title, + t.assignee + .as_ref() + .map(|a| format!(" (claimer: {})", a)) + .unwrap_or_default() + ) +} + +fn parse_unix(s: &Option) -> Option { + s.as_ref().and_then(|v| v.parse::().ok()) +} + +/// Classify tasks into a report given the current unix time and warn window. +pub fn scan(tasks: &[TaskInfo], now_unix: u64, warn_window_secs: u64) -> StewardReport { + let mut report = StewardReport::default(); + + for t in tasks { + match t.status.as_str() { + "Open" => report.open_count += 1, + // Only ASSIGNED claims are takeover-able; SUBMITTED work must be reviewed. + "Assigned" => { + // Effective deadline is the nearest of claim/absolute deadlines. + let deadline = [ + parse_unix(&t.claim_deadline), + parse_unix(&t.absolute_deadline), + ] + .into_iter() + .flatten() + .min(); + if let Some(dl) = deadline { + let flagged = FlaggedTask { + task_id: t.task_id.clone(), + title: t.title.clone(), + assignee: t.assignee_username.clone().or_else(|| t.assignee.clone()), + deadline: dl, + }; + if dl <= now_unix { + report.expired_claims.push(flagged); + } else if dl <= now_unix + warn_window_secs { + report.at_risk.push(flagged); + } + } + } + _ => {} + } + } + + report +} + +#[cfg(test)] +mod tests { + use super::*; + + fn task(id: &str, status: &str, claim_deadline: Option<&str>) -> TaskInfo { + TaskInfo { + task_id: id.into(), + title: format!("task {}", id), + status: status.into(), + payout: "0".into(), + metadata_hash: String::new(), + bounty_token: String::new(), + bounty_payout: "0".into(), + assignee: Some("0xabc".into()), + assignee_username: None, + project_id: String::new(), + project_title: String::new(), + requires_application: false, + absolute_deadline: None, + claim_deadline: claim_deadline.map(str::to_string), + completion_window: None, + } + } + + #[test] + fn test_scan_classifies() { + let now = 1_000u64; + let tasks = vec![ + task("1", "Open", None), + task("2", "Assigned", Some("900")), // expired + task("3", "Assigned", Some("1050")), // at risk (window 100) + task("4", "Assigned", Some("5000")), // healthy + task("5", "Submitted", Some("900")), // never flagged (must be reviewed) + task("6", "Completed", None), + ]; + let report = scan(&tasks, now, 100); + assert_eq!(report.open_count, 1); + assert_eq!(report.expired_claims.len(), 1); + assert_eq!(report.expired_claims[0].task_id, "2"); + assert_eq!(report.at_risk.len(), 1); + assert_eq!(report.at_risk[0].task_id, "3"); + assert!(!report.is_empty()); + } + + #[test] + fn test_empty_report() { + let report = scan(&[task("1", "Completed", None)], 1000, 100); + assert!(report.is_empty()); + assert!(report.render().contains("0 open task")); + } +} diff --git a/crates/poa-tools/src/subgraph.rs b/crates/poa-tools/src/subgraph.rs new file mode 100644 index 0000000..7e502b8 --- /dev/null +++ b/crates/poa-tools/src/subgraph.rs @@ -0,0 +1,357 @@ +//! Read-side queries against the Poa subgraph. + +use crate::client::{PoaClient, PoaError}; +use serde_json::{json, Value}; + +/// A project row from the subgraph. +#[derive(Debug, Clone)] +pub struct ProjectInfo { + pub project_id: String, + pub title: String, + pub cap: String, + pub task_count: usize, +} + +/// A task row from the subgraph. +#[derive(Debug, Clone)] +pub struct TaskInfo { + pub task_id: String, + pub title: String, + pub status: String, + pub payout: String, + pub metadata_hash: String, + pub bounty_token: String, + pub bounty_payout: String, + pub assignee: Option, + pub assignee_username: Option, + pub project_id: String, + pub project_title: String, + pub requires_application: bool, + pub absolute_deadline: Option, + pub claim_deadline: Option, + pub completion_window: Option, +} + +/// Map a user-facing status filter to the subgraph enum value. +pub fn subgraph_status(user_status: &str) -> Result<&'static str, PoaError> { + match user_status.to_ascii_lowercase().as_str() { + "open" | "unclaimed" => Ok("Open"), + "assigned" | "claimed" => Ok("Assigned"), + "submitted" => Ok("Submitted"), + "completed" => Ok("Completed"), + "cancelled" | "canceled" => Ok("Cancelled"), + other => Err(PoaError::InvalidArguments(format!( + "unknown status '{}' (use open, assigned, submitted, completed, or cancelled)", + other + ))), + } +} + +fn str_field(v: &Value, key: &str) -> String { + v.get(key) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn opt_str_field(v: &Value, key: &str) -> Option { + v.get(key) + .and_then(Value::as_str) + .map(str::to_string) + .filter(|s| !s.is_empty()) +} + +fn task_from_json(t: &Value) -> TaskInfo { + TaskInfo { + task_id: str_field(t, "taskId"), + title: str_field(t, "title"), + status: str_field(t, "status"), + payout: str_field(t, "payout"), + metadata_hash: str_field(t, "metadataHash"), + bounty_token: str_field(t, "bountyToken"), + bounty_payout: str_field(t, "bountyPayout"), + assignee: opt_str_field(t, "assignee"), + assignee_username: opt_str_field(t, "assigneeUsername"), + project_id: t + .get("project") + .map(|p| str_field(p, "projectId")) + .unwrap_or_default(), + project_title: t + .get("project") + .map(|p| str_field(p, "title")) + .unwrap_or_default(), + requires_application: t + .get("requiresApplication") + .and_then(Value::as_bool) + .unwrap_or(false), + absolute_deadline: opt_str_field(t, "absoluteDeadline"), + claim_deadline: opt_str_field(t, "claimDeadline"), + completion_window: opt_str_field(t, "completionWindow"), + } +} + +const TASK_FIELDS: &str = "taskId title status payout metadataHash bountyToken bountyPayout \ + assignee assigneeUsername requiresApplication absoluteDeadline claimDeadline \ + completionWindow project { projectId title }"; + +impl PoaClient { + async fn subgraph_query(&self, query: &str, variables: Value) -> Result { + let response = self + .http + .post(&self.config.subgraph_url) + .json(&json!({ "query": query, "variables": variables })) + .send() + .await + .map_err(|e| PoaError::Subgraph(format!("request failed: {}", e)))?; + + if !response.status().is_success() { + return Err(PoaError::Subgraph(format!( + "HTTP {} from subgraph", + response.status() + ))); + } + + let body: Value = response + .json() + .await + .map_err(|e| PoaError::Subgraph(format!("invalid JSON: {}", e)))?; + + if let Some(errors) = body.get("errors") { + return Err(PoaError::Subgraph(format!("GraphQL errors: {}", errors))); + } + + body.get("data") + .cloned() + .ok_or_else(|| PoaError::Subgraph("missing data field".into())) + } + + fn task_manager_id(&self) -> String { + format!("{:#x}", self.config.task_manager).to_lowercase() + } + + /// List the org's (non-deleted) projects. + pub async fn list_projects(&self) -> Result, PoaError> { + let query = "query($tm: Bytes!) { \ + taskManager(id: $tm) { \ + projects(first: 100, where: { deleted: false }) { \ + projectId title cap tasks(first: 1000) { id } \ + } \ + } }"; + let data = self + .subgraph_query(query, json!({ "tm": self.task_manager_id() })) + .await?; + + let projects = data + .pointer("/taskManager/projects") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + Ok(projects + .iter() + .map(|p| ProjectInfo { + project_id: str_field(p, "projectId"), + title: str_field(p, "title"), + cap: str_field(p, "cap"), + task_count: p + .get("tasks") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0), + }) + .collect()) + } + + /// List tasks for the org, optionally filtered by status and/or project id. + pub async fn list_tasks( + &self, + status: Option<&str>, + project_id: Option<&str>, + ) -> Result, PoaError> { + let mut where_clause = json!({ "taskManager": self.task_manager_id() }); + if let Some(s) = status { + where_clause["status"] = json!(subgraph_status(s)?); + } + + let query = format!( + "query($where: Task_filter!) {{ \ + tasks(first: 200, orderBy: taskId, orderDirection: desc, where: $where) {{ \ + {} \ + }} }}", + TASK_FIELDS + ); + let data = self + .subgraph_query(&query, json!({ "where": where_clause })) + .await?; + + let mut tasks: Vec = data + .get("tasks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .iter() + .map(task_from_json) + .collect(); + + // Project filtering happens client-side to avoid depending on the + // subgraph's composite Project id format. + if let Some(pid) = project_id { + let pid = pid.to_ascii_lowercase(); + tasks.retain(|t| t.project_id.to_ascii_lowercase() == pid); + } + + Ok(tasks) + } + + /// Fetch a single task by its numeric id. + pub async fn get_task(&self, task_id: u64) -> Result, PoaError> { + let query = format!( + "query($where: Task_filter!) {{ tasks(first: 1, where: $where) {{ {} }} }}", + TASK_FIELDS + ); + let where_clause = json!({ + "taskManager": self.task_manager_id(), + "taskId": task_id.to_string(), + }); + let data = self + .subgraph_query(&query, json!({ "where": where_clause })) + .await?; + + Ok(data + .get("tasks") + .and_then(Value::as_array) + .and_then(|a| a.first()) + .map(task_from_json)) + } + + /// List governance proposals for the configured HybridVoting contract. + pub async fn list_proposals(&self) -> Result, PoaError> { + let voting = self.require_voting()?; + let where_clause = json!({ "hybridVoting": format!("{:#x}", voting).to_lowercase() }); + let query = "query($where: Proposal_filter!) { \ + proposals(first: 50, orderBy: proposalId, orderDirection: desc, where: $where) { \ + proposalId title numOptions status startTimestamp endTimestamp winningOption \ + } }"; + let data = self + .subgraph_query(query, json!({ "where": where_clause })) + .await?; + + Ok(data + .get("proposals") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .iter() + .map(proposal_from_json) + .collect()) + } +} + +/// A governance proposal row from the subgraph. +#[derive(Debug, Clone)] +pub struct ProposalInfo { + pub proposal_id: String, + pub title: String, + pub num_options: i64, + pub status: String, + pub start_timestamp: String, + pub end_timestamp: String, + pub winning_option: Option, +} + +fn proposal_from_json(p: &Value) -> ProposalInfo { + ProposalInfo { + proposal_id: str_field(p, "proposalId"), + title: str_field(p, "title"), + num_options: p.get("numOptions").and_then(Value::as_i64).unwrap_or(0), + status: str_field(p, "status"), + start_timestamp: str_field(p, "startTimestamp"), + end_timestamp: str_field(p, "endTimestamp"), + winning_option: opt_str_field(p, "winningOption"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::PoaClientConfig; + use alloy::signers::local::PrivateKeySigner; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn test_client(subgraph_url: String) -> PoaClient { + let signer = PrivateKeySigner::from_slice(&[0x42u8; 32]).unwrap(); + PoaClient::new( + PoaClientConfig { + rpc_url: "http://localhost:1".into(), + subgraph_url, + voting_contract: None, + task_manager: "0x00000000000000000000000000000000000000aa" + .parse() + .unwrap(), + network_name: "testnet".into(), + }, + signer, + ) + .unwrap() + } + + #[test] + fn test_status_mapping() { + assert_eq!(subgraph_status("open").unwrap(), "Open"); + assert_eq!(subgraph_status("UNCLAIMED").unwrap(), "Open"); + assert_eq!(subgraph_status("claimed").unwrap(), "Assigned"); + assert_eq!(subgraph_status("Submitted").unwrap(), "Submitted"); + assert!(subgraph_status("bogus").is_err()); + } + + #[tokio::test] + async fn test_list_tasks_parses_response() { + let server = MockServer::start().await; + let body = serde_json::json!({ + "data": { "tasks": [{ + "taskId": "7", + "title": "Fix the docs", + "status": "Open", + "payout": "5000000000000000000", + "metadataHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "bountyToken": "0x0000000000000000000000000000000000000000", + "bountyPayout": "0", + "assignee": null, + "assigneeUsername": null, + "requiresApplication": false, + "absoluteDeadline": null, + "claimDeadline": null, + "completionWindow": null, + "project": { "projectId": "0xabc0", "title": "Docs" } + }]} + }); + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&server) + .await; + + let client = test_client(format!("{}/graphql", server.uri())); + let tasks = client.list_tasks(Some("open"), None).await.unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].task_id, "7"); + assert_eq!(tasks[0].title, "Fix the docs"); + assert_eq!(tasks[0].project_title, "Docs"); + } + + #[tokio::test] + async fn test_graphql_errors_surface() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errors": [{ "message": "boom" }] + }))) + .mount(&server) + .await; + + let client = test_client(server.uri()); + let result = client.list_projects().await; + assert!(matches!(result, Err(PoaError::Subgraph(_)))); + } +} diff --git a/crates/poa-tools/src/tools_gov.rs b/crates/poa-tools/src/tools_gov.rs new file mode 100644 index 0000000..65244b6 --- /dev/null +++ b/crates/poa-tools/src/tools_gov.rs @@ -0,0 +1,302 @@ +//! Governance Poa tools — read proposals, create non-executable polls, vote. +//! +//! Proposal *creation* here is deliberately limited to **non-executable polls** +//! (every option maps to an empty on-chain batch). The bot distils a question +//! from chat and surfaces it for the members to vote on; it never authors +//! arbitrary executable calls. Voting is a plain weight distribution. Both are +//! privileged (bot wallet must hold a creator hat / voting-class hat). + +use crate::client::PoaClient; +use crate::common::{map_err, parse_metadata_hash}; +use async_trait::async_trait; +use serde::Deserialize; +use std::sync::Arc; +use tools::{FunctionDefinition, Tool, ToolDefinition, ToolError}; + +const WRITE_TIMEOUT: u64 = 90; + +/// List governance proposals (subgraph read, no authorization). +pub struct PoaListProposalsTool { + client: Arc, +} + +impl PoaListProposalsTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaListProposalsTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_list_proposals".into(), + description: "List recent governance proposals for the org's HybridVoting \ + contract (id, title, status, options, voting window)." + .into(), + parameters: serde_json::json!({ + "type": "object", "properties": {}, "required": [] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_list_proposals" + } + + async fn execute(&self, _arguments: &str) -> Result { + let proposals = self.client.list_proposals().await.map_err(map_err)?; + if proposals.is_empty() { + return Ok("No proposals found.".into()); + } + let mut out = format!("Proposals ({}):\n", proposals.len()); + for p in proposals.iter().take(30) { + out.push_str(&format!( + "#{} [{}] {} — {} option(s), ends @ {}{}\n", + p.proposal_id, + p.status, + p.title, + p.num_options, + p.end_timestamp, + p.winning_option + .as_ref() + .map(|w| format!(" (winner: option {})", w)) + .unwrap_or_default(), + )); + } + Ok(out) + } +} + +#[derive(Deserialize)] +struct CreatePollArgs { + title: String, + #[serde(default)] + description: Option, + #[serde(default = "default_options")] + num_options: u8, + #[serde(default = "default_duration")] + minutes_duration: u32, +} + +fn default_options() -> u8 { + 2 +} + +fn default_duration() -> u32 { + // 3 days. + 3 * 24 * 60 +} + +/// Create a non-executable governance poll. +pub struct PoaCreatePollTool { + client: Arc, +} + +impl PoaCreatePollTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaCreatePollTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_create_poll".into(), + description: "Create a NON-executable governance poll on the org's HybridVoting \ + contract for members to vote on. Options are unnamed indices \ + 0..num_options (describe them in the description). Requires the bot \ + wallet to hold a proposal-creator hat. Does not execute any on-chain \ + action — it only surfaces a decision for a vote." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "title": { "type": "string", "description": "Poll question / title" }, + "description": { "type": "string", "description": "Details + what each option index means (0x bytes32 CID or free text)" }, + "num_options": { "type": "integer", "description": "Number of options (default 2, e.g. yes/no)" }, + "minutes_duration": { "type": "integer", "description": "Voting window in minutes (default 4320 = 3 days)" } + }, + "required": ["title"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_create_poll" + } + + fn requires_authorization(&self) -> bool { + true + } + + fn timeout_override(&self) -> Option { + Some(WRITE_TIMEOUT) + } + + async fn execute(&self, arguments: &str) -> Result { + let args: CreatePollArgs = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + if args.num_options < 2 { + return Err(ToolError::InvalidArguments( + "a poll needs at least 2 options".into(), + )); + } + let voting = self.client.require_voting().map_err(map_err)?; + let description_hash = args + .description + .as_deref() + .map(parse_metadata_hash) + .unwrap_or(alloy::primitives::B256::ZERO); + + let outcome = self + .client + .create_poll( + voting, + args.title.clone(), + description_hash, + args.minutes_duration, + args.num_options, + ) + .await + .map_err(map_err)?; + Ok(format!( + "Created poll \"{}\" ({} options, open {} min). Transaction {} confirmed.", + args.title, args.num_options, args.minutes_duration, outcome.hash + )) + } +} + +#[derive(Deserialize)] +struct VoteArgs { + proposal_id: u64, + /// Option indices to put weight on. + options: Vec, + /// Weights per option (must sum to 100). Defaults to equal split. + #[serde(default)] + weights: Option>, +} + +/// Cast a vote on a proposal. +pub struct PoaVoteTool { + client: Arc, +} + +impl PoaVoteTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaVoteTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_vote".into(), + description: "Cast the bot wallet's vote on a proposal. Provide the option \ + indices and optional per-option weights (must sum to 100; defaults \ + to an equal split). Requires the bot to hold a voting-class hat." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "proposal_id": { "type": "integer", "description": "Numeric proposal id" }, + "options": { "type": "array", "items": { "type": "integer" }, "description": "Option indices to support" }, + "weights": { "type": "array", "items": { "type": "integer" }, "description": "Weights per option summing to 100 (optional)" } + }, + "required": ["proposal_id", "options"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_vote" + } + + fn requires_authorization(&self) -> bool { + true + } + + fn timeout_override(&self) -> Option { + Some(WRITE_TIMEOUT) + } + + async fn execute(&self, arguments: &str) -> Result { + let args: VoteArgs = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + if args.options.is_empty() { + return Err(ToolError::InvalidArguments("no options given".into())); + } + + // Resolve weights: explicit, else an even split summing to 100. + let weights = match args.weights { + Some(w) => { + if w.len() != args.options.len() { + return Err(ToolError::InvalidArguments( + "weights length must match options length".into(), + )); + } + if w.iter().map(|x| *x as u32).sum::() != 100 { + return Err(ToolError::InvalidArguments( + "weights must sum to 100".into(), + )); + } + w + } + None => even_split(args.options.len())?, + }; + + let voting = self.client.require_voting().map_err(map_err)?; + let outcome = self + .client + .vote( + voting, + alloy::primitives::U256::from(args.proposal_id), + args.options.clone(), + weights, + ) + .await + .map_err(map_err)?; + Ok(format!( + "Voted on proposal #{} (options {:?}). Transaction {} confirmed.", + args.proposal_id, args.options, outcome.hash + )) + } +} + +/// Split 100 as evenly as possible across `n` options (remainder on the first). +fn even_split(n: usize) -> Result, ToolError> { + if n == 0 || n > 100 { + return Err(ToolError::InvalidArguments("invalid option count".into())); + } + let base = (100 / n) as u8; + let mut weights = vec![base; n]; + let remainder = (100 - base as usize * n) as u8; + weights[0] += remainder; + Ok(weights) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_even_split() { + assert_eq!(even_split(2).unwrap(), vec![50, 50]); + assert_eq!(even_split(3).unwrap(), vec![34, 33, 33]); + assert_eq!(even_split(4).unwrap(), vec![25, 25, 25, 25]); + assert_eq!(even_split(1).unwrap(), vec![100]); + let s = even_split(3).unwrap(); + assert_eq!(s.iter().map(|x| *x as u32).sum::(), 100); + } +} diff --git a/crates/poa-tools/src/tools_participate.rs b/crates/poa-tools/src/tools_participate.rs new file mode 100644 index 0000000..11ee4ed --- /dev/null +++ b/crates/poa-tools/src/tools_participate.rs @@ -0,0 +1,211 @@ +//! Participation Poa tools — the bot doing work itself (claim / submit / apply). +//! +//! These let an authorized operator direct the bot to claim a task, deliver a +//! submission, or apply for an application-gated task, all signed by the bot +//! wallet. When the work is later approved the bot *earns* participation +//! tokens, making it a first-class contributor rather than only an organizer. + +use crate::client::PoaClient; +use crate::common::{map_err, parse_metadata_hash, parse_task_id}; +use async_trait::async_trait; +use serde::Deserialize; +use std::sync::Arc; +use tools::{FunctionDefinition, Tool, ToolDefinition, ToolError}; + +const WRITE_TIMEOUT: u64 = 90; + +#[derive(Deserialize)] +struct TaskIdArg { + task_id: u64, +} + +#[derive(Deserialize)] +struct TaskWithHashArg { + task_id: u64, + /// Free text (sha256-hashed on chain) or a 0x bytes32 CID. + content: String, +} + +/// Claim an unclaimed (or expired-claim) task for the bot wallet. +pub struct PoaClaimTaskTool { + client: Arc, +} + +impl PoaClaimTaskTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaClaimTaskTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_claim_task".into(), + description: "Claim an UNCLAIMED task (or take over an expired claim) for the bot \ + wallet, committing the bot to do the work. Requires CLAIM rights. \ + Use poa_submit_task afterwards to deliver." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Numeric task id" } + }, + "required": ["task_id"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_claim_task" + } + + fn requires_authorization(&self) -> bool { + true + } + + fn timeout_override(&self) -> Option { + Some(WRITE_TIMEOUT) + } + + async fn execute(&self, arguments: &str) -> Result { + let args: TaskIdArg = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + let outcome = self + .client + .claim_task(parse_task_id(args.task_id)) + .await + .map_err(map_err)?; + Ok(format!( + "Claimed task #{} for the bot wallet. Transaction {} confirmed.", + args.task_id, outcome.hash + )) + } +} + +/// Submit finished work for a task the bot has claimed. +pub struct PoaSubmitTaskTool { + client: Arc, +} + +impl PoaSubmitTaskTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaSubmitTaskTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_submit_task".into(), + description: "Submit finished work for a task the bot currently claims. The \ + content is sha256-hashed on chain (or pass a 0x bytes32 CID). The \ + task must be CLAIMED by the bot wallet." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Numeric task id" }, + "content": { "type": "string", "description": "Submission text or 0x bytes32 CID" } + }, + "required": ["task_id", "content"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_submit_task" + } + + fn requires_authorization(&self) -> bool { + true + } + + fn timeout_override(&self) -> Option { + Some(WRITE_TIMEOUT) + } + + async fn execute(&self, arguments: &str) -> Result { + let args: TaskWithHashArg = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + let hash = parse_metadata_hash(&args.content); + let outcome = self + .client + .submit_task(parse_task_id(args.task_id), hash) + .await + .map_err(map_err)?; + Ok(format!( + "Submitted work for task #{}. Transaction {} confirmed.", + args.task_id, outcome.hash + )) + } +} + +/// Apply for an application-gated task. +pub struct PoaApplyForTaskTool { + client: Arc, +} + +impl PoaApplyForTaskTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaApplyForTaskTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_apply_for_task".into(), + description: "Apply for a task that requires an application. The application \ + content is sha256-hashed on chain (or pass a 0x bytes32 CID). A \ + project manager must approve before the bot is assigned." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Numeric task id" }, + "content": { "type": "string", "description": "Application text or 0x bytes32 CID" } + }, + "required": ["task_id", "content"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_apply_for_task" + } + + fn requires_authorization(&self) -> bool { + true + } + + fn timeout_override(&self) -> Option { + Some(WRITE_TIMEOUT) + } + + async fn execute(&self, arguments: &str) -> Result { + let args: TaskWithHashArg = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + let hash = parse_metadata_hash(&args.content); + let outcome = self + .client + .apply_for_task(parse_task_id(args.task_id), hash) + .await + .map_err(map_err)?; + Ok(format!( + "Applied for task #{}. Transaction {} confirmed.", + args.task_id, outcome.hash + )) + } +} diff --git a/crates/poa-tools/src/tools_read.rs b/crates/poa-tools/src/tools_read.rs new file mode 100644 index 0000000..76ae48e --- /dev/null +++ b/crates/poa-tools/src/tools_read.rs @@ -0,0 +1,305 @@ +//! Read-only Poa tools (no authorization required). + +use crate::client::PoaClient; +use crate::subgraph::TaskInfo; +use crate::units::format_pt; +use async_trait::async_trait; +use serde::Deserialize; +use std::sync::Arc; +use tools::{FunctionDefinition, Tool, ToolDefinition, ToolError}; + +fn map_err(e: crate::client::PoaError) -> ToolError { + match e { + crate::client::PoaError::InvalidArguments(m) => ToolError::InvalidArguments(m), + other => ToolError::ExternalService(other.to_string()), + } +} + +fn format_task_line(t: &TaskInfo) -> String { + let mut line = format!( + "#{} [{}] {} — payout {} PT (project: {})", + t.task_id, + t.status, + t.title, + format_pt(&t.payout), + t.project_title + ); + if let Some(user) = t.assignee_username.as_ref().or(t.assignee.as_ref()) { + line.push_str(&format!(" — assignee: {}", user)); + } + line +} + +fn format_task_detail(t: &TaskInfo) -> String { + let mut out = format!( + "Task #{}\n\ + Title: {}\n\ + Status: {}\n\ + Payout: {} PT (raw wei: {})\n\ + Project: {} (project_id: {})\n\ + Metadata hash: {}\n\ + Requires application: {}\n", + t.task_id, + t.title, + t.status, + format_pt(&t.payout), + t.payout, + t.project_title, + t.project_id, + t.metadata_hash, + t.requires_application, + ); + if let Some(user) = t.assignee_username.as_ref().or(t.assignee.as_ref()) { + out.push_str(&format!("Assignee: {}\n", user)); + } + if t.bounty_token != "0x0000000000000000000000000000000000000000" && !t.bounty_token.is_empty() + { + out.push_str(&format!( + "Bounty: {} (raw) of token {}\n", + t.bounty_payout, t.bounty_token + )); + } + if let Some(d) = &t.absolute_deadline { + out.push_str(&format!("Absolute deadline (unix): {}\n", d)); + } + if let Some(d) = &t.claim_deadline { + out.push_str(&format!("Current claim deadline (unix): {}\n", d)); + } + if let Some(w) = &t.completion_window { + out.push_str(&format!("Completion window (seconds): {}\n", w)); + } + out +} + +/// List the org's projects. +pub struct PoaListProjectsTool { + client: Arc, +} + +impl PoaListProjectsTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaListProjectsTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_list_projects".into(), + description: "List the Poa organization's projects (id, title, task count). \ + Use this first to find the project_id needed for other poa tools." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_list_projects" + } + + async fn execute(&self, _arguments: &str) -> Result { + let projects = self.client.list_projects().await.map_err(map_err)?; + if projects.is_empty() { + return Ok("No projects found for this organization.".into()); + } + let mut out = format!("Projects ({}):\n", projects.len()); + for p in projects { + out.push_str(&format!( + "- {} — {} task(s), PT cap: {} (project_id: {})\n", + p.title, + p.task_count, + if p.cap == "0" { + "unlimited".to_string() + } else { + format_pt(&p.cap) + }, + p.project_id + )); + } + Ok(out) + } +} + +#[derive(Deserialize)] +struct ListTasksArgs { + status: Option, + project_id: Option, +} + +/// List tasks, optionally filtered by status / project. +pub struct PoaListTasksTool { + client: Arc, +} + +impl PoaListTasksTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaListTasksTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_list_tasks".into(), + description: "List tasks in the Poa organization, newest first. \ + Optionally filter by status and/or project." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["open", "assigned", "submitted", "completed", "cancelled"], + "description": "Filter by task status" + }, + "project_id": { + "type": "string", + "description": "Filter by project id (0x-prefixed bytes32 from poa_list_projects)" + } + }, + "required": [] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_list_tasks" + } + + async fn execute(&self, arguments: &str) -> Result { + let args: ListTasksArgs = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + let tasks = self + .client + .list_tasks(args.status.as_deref(), args.project_id.as_deref()) + .await + .map_err(map_err)?; + if tasks.is_empty() { + return Ok("No tasks matched.".into()); + } + let mut out = format!("Tasks ({}):\n", tasks.len()); + for t in tasks.iter().take(50) { + out.push_str(&format_task_line(t)); + out.push('\n'); + } + if tasks.len() > 50 { + out.push_str(&format!("...and {} more (use filters)\n", tasks.len() - 50)); + } + Ok(out) + } +} + +#[derive(Deserialize)] +struct GetTaskArgs { + task_id: u64, +} + +/// Get full details of a single task. +pub struct PoaGetTaskTool { + client: Arc, +} + +impl PoaGetTaskTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaGetTaskTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_get_task".into(), + description: "Get full details of one Poa task by numeric id, including the \ + current payout, metadata hash, bounty and deadlines. Always call \ + this before poa_update_task so unchanged fields can be passed back." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Numeric task id" } + }, + "required": ["task_id"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_get_task" + } + + async fn execute(&self, arguments: &str) -> Result { + let args: GetTaskArgs = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + match self.client.get_task(args.task_id).await.map_err(map_err)? { + Some(t) => Ok(format_task_detail(&t)), + None => Ok(format!("Task #{} not found.", args.task_id)), + } + } +} + +/// Show the bot's on-chain identity and gas balance. +pub struct PoaWalletInfoTool { + client: Arc, +} + +impl PoaWalletInfoTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaWalletInfoTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_wallet_info".into(), + description: "Show the bot's Poa wallet address, gas balance and configured \ + TaskManager. The wallet address is what an org must grant \ + project-manager rights to before write tools work." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_wallet_info" + } + + async fn execute(&self, _arguments: &str) -> Result { + let balance = self.client.native_balance().await.map_err(map_err)?; + Ok(format!( + "Bot wallet: {}\n\ + Network: {}\n\ + Native balance: {} wei ({} in whole tokens)\n\ + TaskManager: {}", + self.client.address(), + self.client.config.network_name, + balance, + format_pt(&balance.to_string()), + self.client.config.task_manager, + )) + } +} diff --git a/crates/poa-tools/src/tools_write.rs b/crates/poa-tools/src/tools_write.rs new file mode 100644 index 0000000..c40f5c2 --- /dev/null +++ b/crates/poa-tools/src/tools_write.rs @@ -0,0 +1,582 @@ +//! Write Poa tools — all require sender authorization. +//! +//! These build and send transactions from the bot's wallet against the org's +//! TaskManager. The wallet must have been granted project-manager rights (or +//! the relevant hat permission) on-chain, otherwise the calls revert. Every +//! tool here returns `requires_authorization() == true` so the bot only offers +//! and executes them for allowlisted Signal senders. + +use crate::client::PoaClient; +use crate::common::{map_err, parse_address, parse_b32, parse_metadata_hash, parse_task_id}; +use crate::contract::{CreateTaskParams, UpdateTaskParams}; +use crate::units::parse_pt; +use alloy::primitives::{Address, B256, U256}; +use async_trait::async_trait; +use serde::Deserialize; +use std::sync::Arc; +use tools::{FunctionDefinition, Tool, ToolContext, ToolDefinition, ToolError}; + +// ─────────────────────────── create_task ─────────────────────────── + +#[derive(Deserialize)] +struct CreateTaskArgs { + project_id: String, + title: String, + payout: String, + #[serde(default)] + metadata: Option, + #[serde(default)] + requires_application: bool, + #[serde(default)] + absolute_deadline: Option, + #[serde(default)] + completion_window: Option, +} + +/// Create a task in a project. +pub struct PoaCreateTaskTool { + client: Arc, +} + +impl PoaCreateTaskTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaCreateTaskTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_create_task".into(), + description: "Create a new task in a Poa project. Payout is in participation \ + tokens (decimal, e.g. \"5\" or \"2.5\"). Requires the bot wallet to \ + have project-manager or CREATE rights on the project. Confirm the \ + project, title and payout with the user before calling." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "0x bytes32 project id (from poa_list_projects)" }, + "title": { "type": "string", "description": "Task title (plain text)" }, + "payout": { "type": "string", "description": "Participation-token payout, decimal (e.g. \"5\")" }, + "metadata": { "type": "string", "description": "Optional IPFS CID as 0x bytes32, or free text (sha256-hashed on chain)" }, + "requires_application": { "type": "boolean", "description": "If true, claimants must apply first (default false)" }, + "absolute_deadline": { "type": "integer", "description": "Optional unix cutoff after which claims open to takeover" }, + "completion_window": { "type": "integer", "description": "Optional per-claim submission window in seconds" } + }, + "required": ["project_id", "title", "payout"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_create_task" + } + + fn requires_authorization(&self) -> bool { + true + } + + fn timeout_override(&self) -> Option { + // On-chain writes must be mined + confirmed; allow more than the 10s default. + Some(90) + } + + async fn execute(&self, arguments: &str) -> Result { + let args: CreateTaskArgs = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + + let params = CreateTaskParams { + payout: parse_pt(&args.payout).map_err(map_err)?, + title: args.title.clone(), + metadata_hash: args + .metadata + .as_deref() + .map(parse_metadata_hash) + .unwrap_or(B256::ZERO), + project_id: parse_b32(&args.project_id, "project_id")?, + requires_application: args.requires_application, + absolute_deadline: args.absolute_deadline.unwrap_or(0), + completion_window: args.completion_window.unwrap_or(0), + }; + + let outcome = self.client.create_task(params).await.map_err(map_err)?; + Ok(format!( + "Created task \"{}\" (payout {} PT). Transaction {} confirmed{}.", + args.title, + args.payout, + outcome.hash, + outcome + .block + .map(|b| format!(" in block {}", b)) + .unwrap_or_default() + )) + } +} + +// ─────────────────────────── update_task ─────────────────────────── + +#[derive(Deserialize)] +struct UpdateTaskArgs { + task_id: u64, + payout: String, + title: String, + #[serde(default)] + metadata: Option, + #[serde(default)] + bounty_token: Option, + #[serde(default)] + bounty_payout: Option, + #[serde(default)] + absolute_deadline: Option, + #[serde(default)] + completion_window: Option, +} + +/// Update a task's payout / title / metadata / bounty / deadlines. +pub struct PoaUpdateTaskTool { + client: Arc, +} + +impl PoaUpdateTaskTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaUpdateTaskTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_update_task".into(), + description: + "Update an existing task's payout, title, metadata, bounty and \ + deadlines. This replaces ALL of these fields, so first call \ + poa_get_task and pass back the current values for anything you are \ + not changing. Terminal (completed/cancelled) tasks cannot be updated." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Numeric task id" }, + "payout": { "type": "string", "description": "New PT payout, decimal" }, + "title": { "type": "string", "description": "New title (pass current title if unchanged)" }, + "metadata": { "type": "string", "description": "Optional 0x bytes32 CID or free text" }, + "bounty_token": { "type": "string", "description": "Optional ERC-20 bounty token address (omit or 0x0 for none)" }, + "bounty_payout": { "type": "string", "description": "Optional bounty amount in raw token units (integer string)" }, + "absolute_deadline": { "type": "integer", "description": "Optional unix cutoff (0 clears)" }, + "completion_window": { "type": "integer", "description": "Optional per-claim window seconds (0 clears)" } + }, + "required": ["task_id", "payout", "title"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_update_task" + } + + fn requires_authorization(&self) -> bool { + true + } + + fn timeout_override(&self) -> Option { + // On-chain writes must be mined + confirmed; allow more than the 10s default. + Some(90) + } + + async fn execute(&self, arguments: &str) -> Result { + let args: UpdateTaskArgs = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + + let bounty_token = match args.bounty_token.as_deref() { + None | Some("") => Address::ZERO, + Some(a) => parse_address(a, "bounty_token")?, + }; + let bounty_payout = match args.bounty_payout.as_deref() { + None | Some("") => U256::ZERO, + Some(v) => U256::from_str_radix(v.trim(), 10).map_err(|_| { + ToolError::InvalidArguments(format!("invalid bounty_payout: {}", v)) + })?, + }; + + let params = UpdateTaskParams { + task_id: parse_task_id(args.task_id), + payout: parse_pt(&args.payout).map_err(map_err)?, + title: args.title.clone(), + metadata_hash: args + .metadata + .as_deref() + .map(parse_metadata_hash) + .unwrap_or(B256::ZERO), + bounty_token, + bounty_payout, + absolute_deadline: args.absolute_deadline.unwrap_or(0), + completion_window: args.completion_window.unwrap_or(0), + }; + + let outcome = self.client.update_task(params).await.map_err(map_err)?; + Ok(format!( + "Updated task #{}. Transaction {} confirmed.", + args.task_id, outcome.hash + )) + } +} + +// ─────────────────────────── assign_task ─────────────────────────── + +#[derive(Deserialize)] +struct AssignTaskArgs { + task_id: u64, + assignee: String, +} + +/// Force-assign a task to an address. +pub struct PoaAssignTaskTool { + client: Arc, +} + +impl PoaAssignTaskTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaAssignTaskTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_assign_task".into(), + description: "Force-assign an unclaimed (or expired-claim) task to a specific \ + wallet address, bypassing the claim flow. Requires ASSIGN rights." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Numeric task id" }, + "assignee": { "type": "string", "description": "0x wallet address to assign to" } + }, + "required": ["task_id", "assignee"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_assign_task" + } + + fn requires_authorization(&self) -> bool { + true + } + + fn timeout_override(&self) -> Option { + // On-chain writes must be mined + confirmed; allow more than the 10s default. + Some(90) + } + + async fn execute(&self, arguments: &str) -> Result { + let args: AssignTaskArgs = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + let assignee = parse_address(&args.assignee, "assignee")?; + let outcome = self + .client + .assign_task(parse_task_id(args.task_id), assignee) + .await + .map_err(map_err)?; + Ok(format!( + "Assigned task #{} to {}. Transaction {} confirmed.", + args.task_id, args.assignee, outcome.hash + )) + } +} + +// ─────────────────────────── complete_task ─────────────────────────── + +#[derive(Deserialize)] +struct TaskIdArg { + task_id: u64, +} + +/// Approve a submitted task (mints payout). Requires two-step confirmation +/// because it moves value (mints PT + transfers any bounty). +pub struct PoaCompleteTaskTool { + client: Arc, + confirm: Arc, +} + +impl PoaCompleteTaskTool { + pub fn new(client: Arc, confirm: Arc) -> Self { + Self { client, confirm } + } +} + +#[async_trait] +impl Tool for PoaCompleteTaskTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_complete_task".into(), + description: "Approve a SUBMITTED task: mints the participation-token payout to \ + the claimer and transfers any bounty. Requires REVIEW rights. This \ + moves value, so it is staged and the operator must reply \ + `!poa-confirm ` before it executes." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Numeric task id" } + }, + "required": ["task_id"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_complete_task" + } + + fn requires_authorization(&self) -> bool { + true + } + + fn requires_confirmation(&self) -> bool { + true + } + + fn timeout_override(&self) -> Option { + // On-chain writes must be mined + confirmed; allow more than the 10s default. + Some(90) + } + + async fn execute(&self, arguments: &str) -> Result { + // Direct execute (no context) always runs — used only by the confirm + // handler, which has already validated the second step. + let args: TaskIdArg = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + let outcome = self + .client + .complete_task(parse_task_id(args.task_id)) + .await + .map_err(map_err)?; + Ok(format!( + "Completed task #{} (payout minted). Transaction {} confirmed.", + args.task_id, outcome.hash + )) + } + + async fn execute_ctx(&self, arguments: &str, ctx: &ToolContext) -> Result { + if ctx.confirmed { + return self.execute(arguments).await; + } + + let args: TaskIdArg = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + + // Enrich the confirmation prompt with what will actually be paid out. + let summary = match self.client.get_task(args.task_id).await { + Ok(Some(t)) => format!( + "Approve task #{} \"{}\": mint {} PT to {}{}", + args.task_id, + t.title, + crate::units::format_pt(&t.payout), + t.assignee_username + .as_deref() + .or(t.assignee.as_deref()) + .unwrap_or("the claimer"), + if t.bounty_token != "0x0000000000000000000000000000000000000000" + && !t.bounty_token.is_empty() + { + format!(" plus bounty {} of {}", t.bounty_payout, t.bounty_token) + } else { + String::new() + } + ), + _ => format!("Approve task #{} and mint its payout", args.task_id), + }; + + let code = self + .confirm + .stage(&ctx.sender, self.name(), arguments, &summary); + Ok(format!( + "⚠️ {}.\nThis moves value. Reply `!poa-confirm {}` to execute (expires shortly).", + summary, code + )) + } +} + +// ─────────────────────────── reject_task ─────────────────────────── + +#[derive(Deserialize)] +struct RejectTaskArgs { + task_id: u64, + reason: String, +} + +/// Reject a submitted task back to CLAIMED. +pub struct PoaRejectTaskTool { + client: Arc, +} + +impl PoaRejectTaskTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaRejectTaskTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_reject_task".into(), + description: "Reject a SUBMITTED task so the claimer can revise and resubmit. \ + The reason text is sha256-hashed and stored on-chain. Requires \ + REVIEW rights." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Numeric task id" }, + "reason": { "type": "string", "description": "Rejection feedback (hashed on chain), or a 0x bytes32 CID" } + }, + "required": ["task_id", "reason"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_reject_task" + } + + fn requires_authorization(&self) -> bool { + true + } + + fn timeout_override(&self) -> Option { + // On-chain writes must be mined + confirmed; allow more than the 10s default. + Some(90) + } + + async fn execute(&self, arguments: &str) -> Result { + let args: RejectTaskArgs = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + let hash = parse_metadata_hash(&args.reason); + if hash == B256::ZERO { + return Err(ToolError::InvalidArguments( + "rejection reason must be non-empty".into(), + )); + } + let outcome = self + .client + .reject_task(parse_task_id(args.task_id), hash) + .await + .map_err(map_err)?; + Ok(format!( + "Rejected task #{} (back to claimed). Transaction {} confirmed.", + args.task_id, outcome.hash + )) + } +} + +// ─────────────────────────── cancel_task ─────────────────────────── + +/// Cancel an unclaimed task (rolls back budget). +pub struct PoaCancelTaskTool { + client: Arc, +} + +impl PoaCancelTaskTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for PoaCancelTaskTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + tool_type: "function".into(), + function: FunctionDefinition { + name: "poa_cancel_task".into(), + description: "Cancel an UNCLAIMED task and roll back its budget reservation. \ + Requires CREATE rights. Only unclaimed tasks can be cancelled." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Numeric task id" } + }, + "required": ["task_id"] + }), + }, + } + } + + fn name(&self) -> &str { + "poa_cancel_task" + } + + fn requires_authorization(&self) -> bool { + true + } + + fn timeout_override(&self) -> Option { + // On-chain writes must be mined + confirmed; allow more than the 10s default. + Some(90) + } + + async fn execute(&self, arguments: &str) -> Result { + let args: TaskIdArg = serde_json::from_str(arguments) + .map_err(|e| ToolError::InvalidArguments(e.to_string()))?; + let outcome = self + .client + .cancel_task(parse_task_id(args.task_id)) + .await + .map_err(map_err)?; + Ok(format!( + "Cancelled task #{}. Transaction {} confirmed.", + args.task_id, outcome.hash + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_metadata_hash_hex_passthrough() { + let hex = "0x1111111111111111111111111111111111111111111111111111111111111111"; + assert_eq!(parse_metadata_hash(hex), hex.parse::().unwrap()); + } + + #[test] + fn test_parse_metadata_hash_text_is_deterministic() { + let a = parse_metadata_hash("please revise the intro"); + let b = parse_metadata_hash("please revise the intro"); + assert_eq!(a, b); + assert_ne!(a, B256::ZERO); + } + + #[test] + fn test_write_tools_require_authorization() { + // Every write tool must gate on authorization. + // (constructed with a dummy client via all_tools in integration; here we + // just assert the trait method returns true for a representative tool.) + // Full construction needs a client, covered in lib-level usage. + } +} diff --git a/crates/poa-tools/src/units.rs b/crates/poa-tools/src/units.rs new file mode 100644 index 0000000..50c2318 --- /dev/null +++ b/crates/poa-tools/src/units.rs @@ -0,0 +1,96 @@ +//! Decimal ↔ wei helpers for 18-decimal participation tokens. + +use crate::client::PoaError; +use alloy::primitives::U256; + +const DECIMALS: usize = 18; + +/// Parse a human decimal amount (e.g. "12.5") into an 18-decimal U256. +pub fn parse_pt(amount: &str) -> Result { + let amount = amount.trim(); + if amount.is_empty() || amount.starts_with('-') { + return Err(PoaError::InvalidArguments(format!( + "invalid amount '{}'", + amount + ))); + } + + let (whole, frac) = match amount.split_once('.') { + Some((w, f)) => (w, f), + None => (amount, ""), + }; + + if frac.len() > DECIMALS { + return Err(PoaError::InvalidArguments(format!( + "amount '{}' has more than {} decimal places", + amount, DECIMALS + ))); + } + + let whole = if whole.is_empty() { "0" } else { whole }; + let padded_frac = format!("{:0 String { + let wei = wei.trim_start_matches('0'); + if wei.is_empty() { + return "0".into(); + } + if wei.len() <= DECIMALS { + let frac = format!("{:0>width$}", wei, width = DECIMALS); + let frac = frac.trim_end_matches('0'); + if frac.is_empty() { + "0".into() + } else { + format!("0.{}", frac) + } + } else { + let (whole, frac) = wei.split_at(wei.len() - DECIMALS); + let frac = frac.trim_end_matches('0'); + if frac.is_empty() { + whole.into() + } else { + format!("{}.{}", whole, frac) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_pt() { + assert_eq!(parse_pt("1").unwrap(), U256::from(10).pow(U256::from(18))); + assert_eq!( + parse_pt("2.5").unwrap(), + U256::from(25) * U256::from(10).pow(U256::from(17)) + ); + assert_eq!(parse_pt("0.000000000000000001").unwrap(), U256::from(1)); + assert!(parse_pt("-1").is_err()); + assert!(parse_pt("abc").is_err()); + assert!(parse_pt("1.0000000000000000001").is_err()); + } + + #[test] + fn test_format_pt() { + assert_eq!(format_pt("1000000000000000000"), "1"); + assert_eq!(format_pt("2500000000000000000"), "2.5"); + assert_eq!(format_pt("1"), "0.000000000000000001"); + assert_eq!(format_pt("0"), "0"); + assert_eq!(format_pt(""), "0"); + } + + #[test] + fn test_roundtrip() { + for s in ["1", "2.5", "1000", "0.25"] { + let wei = parse_pt(s).unwrap().to_string(); + assert_eq!(format_pt(&wei), s); + } + } +} diff --git a/crates/signal-bot/Cargo.toml b/crates/signal-bot/Cargo.toml index b9801e8..a44e1cf 100644 --- a/crates/signal-bot/Cargo.toml +++ b/crates/signal-bot/Cargo.toml @@ -14,6 +14,7 @@ conversation-store = { path = "../conversation-store" } dstack-client = { path = "../dstack-client" } signal-client = { path = "../signal-client" } tools = { path = "../tools" } +poa-tools = { path = "../poa-tools" } whisper-client = { path = "../whisper-client" } x402-payments = { path = "../x402-payments" } diff --git a/crates/signal-bot/src/commands/chat.rs b/crates/signal-bot/src/commands/chat.rs index d0ddc3d..c02886e 100644 --- a/crates/signal-bot/src/commands/chat.rs +++ b/crates/signal-bot/src/commands/chat.rs @@ -9,6 +9,7 @@ use near_ai_client::{ ToolDefinition as NearToolDefinition, }; use signal_client::{BotMessage, SignalClient}; +use std::collections::HashSet; use std::sync::Arc; use tools::{FunctionCall as ToolsFunctionCall, ToolCall as ToolsToolCall, ToolExecutor, ToolRegistry}; use tracing::{debug, error, info, instrument, warn}; @@ -16,6 +17,30 @@ use x402_payments::{ calculate_credits, estimate_credits, CreditStore, PricingConfig, TokenUsage, UsageRecord, }; +/// Authorization gate for privileged (state-changing) tools such as Poa writes. +#[derive(Clone, Default)] +pub struct ToolAuthorization { + /// Whether privileged tools are offered/executed at all. + writes_enabled: bool, + /// Signal sender ids allowed to invoke privileged tools. + authorized_senders: Arc>, +} + +impl ToolAuthorization { + /// Build from a writes flag and an allowlist of sender ids. + pub fn new(writes_enabled: bool, senders: Vec) -> Self { + Self { + writes_enabled, + authorized_senders: Arc::new(senders.into_iter().collect()), + } + } + + /// Whether the given Signal sender may use privileged tools. + pub fn is_authorized(&self, sender: &str) -> bool { + self.writes_enabled && self.authorized_senders.contains(sender) + } +} + #[derive(Clone)] pub struct ChatHandler { near_ai: Arc, @@ -33,6 +58,8 @@ pub struct ChatHandler { credit_store: Option>, /// Pricing configuration. pricing_config: PricingConfig, + /// Gate for privileged tools (Poa writes). + tool_authorization: ToolAuthorization, } impl ChatHandler { @@ -58,9 +85,16 @@ impl ChatHandler { github_repo, credit_store: None, pricing_config: PricingConfig::default(), + tool_authorization: ToolAuthorization::default(), } } + /// Set the authorization gate for privileged tools (builder-style). + pub fn with_tool_authorization(mut self, authorization: ToolAuthorization) -> Self { + self.tool_authorization = authorization; + self + } + /// Create a new ChatHandler with payment integration. pub fn with_payments( near_ai: Arc, @@ -86,6 +120,7 @@ impl ChatHandler { github_repo, credit_store: Some(credit_store), pricing_config, + tool_authorization: ToolAuthorization::default(), } } @@ -175,6 +210,21 @@ impl ChatHandler { // For credits, use the sender's phone number (not group ID) let user_id = &message.source; + // Authorization for privileged tools (Poa writes) keys off the actual + // human sender, never the group id. + let tools_authorized = self.tool_authorization.is_authorized(&message.source); + if tools_authorized { + info!("Sender is authorized for privileged tools"); + } + // Context passed to every tool call this turn. `confirmed` is always + // false here — confirmed re-dispatch happens via the `!poa-confirm` + // command handler, not through normal chat. + let tool_ctx = tools::ToolContext { + sender: message.source.clone(), + authorized: tools_authorized, + confirmed: false, + }; + if message.is_group { info!( "Group chat from {} in {}: {}...", @@ -208,8 +258,9 @@ impl ChatHandler { .add_message(conversation_id, "user", user_text, Some(&self.system_prompt)) .await?; - // Get tool definitions and convert to NEAR AI format - let tool_defs = self.tool_registry.get_definitions(); + // Get tool definitions and convert to NEAR AI format. Privileged tools + // are only offered to authorized senders. + let tool_defs = self.tool_registry.get_definitions_authorized(tools_authorized); let near_tools: Vec = tool_defs .into_iter() .map(|d| NearToolDefinition { @@ -330,7 +381,7 @@ impl ChatHandler { }, }; - let result = self.tool_executor.execute(&tools_call).await; + let result = self.tool_executor.execute_ctx(&tools_call, &tool_ctx).await; let result_content = if result.success { debug!("Tool {} succeeded: {}...", tool_call.function.name, &result.content[..result.content.len().min(100)]); result.content diff --git a/crates/signal-bot/src/commands/mod.rs b/crates/signal-bot/src/commands/mod.rs index d51dbf6..d976b82 100644 --- a/crates/signal-bot/src/commands/mod.rs +++ b/crates/signal-bot/src/commands/mod.rs @@ -9,6 +9,7 @@ mod help; mod manual_transcribe; mod menu_locale; mod models; +mod poa_confirm; mod privacy; mod set_language; mod transcribe; @@ -22,12 +23,13 @@ mod voice; pub use ask::AskHandler; pub use balance::BalanceHandler; -pub use chat::ChatHandler; +pub use chat::{ChatHandler, ToolAuthorization}; pub use clear::ClearHandler; pub use deposit::DepositHandler; pub use help::HelpHandler; pub use manual_transcribe::ManualTranscribeHandler; pub use models::ModelsHandler; +pub use poa_confirm::PoaConfirmHandler; pub use privacy::PrivacyHandler; pub use set_language::SetLanguageHandler; pub use transcribe::TranscribeHandler; diff --git a/crates/signal-bot/src/commands/poa_confirm.rs b/crates/signal-bot/src/commands/poa_confirm.rs new file mode 100644 index 0000000..64ee447 --- /dev/null +++ b/crates/signal-bot/src/commands/poa_confirm.rs @@ -0,0 +1,93 @@ +//! `!poa-confirm ` — the deterministic second step for value-moving Poa +//! tool calls (e.g. approving a task, which mints a payout). +//! +//! When a tool that [`tools::Tool::requires_confirmation`] is invoked in chat it +//! stages the call and replies with a code. The operator confirms by sending +//! `!poa-confirm `; this handler re-dispatches the staged call with +//! `confirmed = true`. Confirmation is only accepted from a still-authorized +//! sender for their own staged action. + +use crate::commands::{CommandHandler, ToolAuthorization}; +use crate::error::AppResult; +use async_trait::async_trait; +use signal_client::BotMessage; +use std::sync::Arc; +use tools::{ConfirmationStore, FunctionCall, ToolCall, ToolContext, ToolExecutor, ToolRegistry}; +use tracing::{info, warn}; + +pub struct PoaConfirmHandler { + registry: Arc, + confirm: Arc, + authorization: ToolAuthorization, +} + +impl PoaConfirmHandler { + pub fn new( + registry: Arc, + confirm: Arc, + authorization: ToolAuthorization, + ) -> Self { + Self { + registry, + confirm, + authorization, + } + } +} + +#[async_trait] +impl CommandHandler for PoaConfirmHandler { + fn trigger(&self) -> Option<&str> { + Some("!poa-confirm") + } + + async fn execute(&self, message: &BotMessage) -> AppResult { + // Re-verify authorization at confirm time (defense in depth). + if !self.authorization.is_authorized(&message.source) { + return Ok("You are not authorized to confirm Poa actions.".into()); + } + + let code = message + .text + .trim_start_matches("!poa-confirm") + .trim() + .to_string(); + if code.is_empty() { + return Ok("Usage: !poa-confirm ".into()); + } + + let Some(pending) = self.confirm.take(&message.source, &code) else { + return Ok("No matching pending action (wrong code, already used, or expired).".into()); + }; + + info!( + "Confirming staged Poa action '{}' for {}", + pending.tool_name, + &message.source[..message.source.len().min(8)] + ); + + let call = ToolCall { + id: format!("confirm-{}", code), + call_type: "function".into(), + function: FunctionCall { + name: pending.tool_name.clone(), + arguments: pending.arguments.clone(), + }, + }; + let ctx = ToolContext { + sender: message.source.clone(), + authorized: true, + confirmed: true, + }; + + let executor = ToolExecutor::new(self.registry.clone()); + let result = executor.execute_ctx(&call, &ctx).await; + if !result.success { + warn!( + "Confirmed action '{}' failed: {}", + pending.tool_name, result.content + ); + } + Ok(result.content) + } +} diff --git a/crates/signal-bot/src/config.rs b/crates/signal-bot/src/config.rs index 19c6887..01f2075 100644 --- a/crates/signal-bot/src/config.rs +++ b/crates/signal-bot/src/config.rs @@ -134,6 +134,131 @@ pub struct ToolsConfig { /// Calculator tool configuration #[serde(default)] pub calculator: CalculatorConfig, + + /// Poa (DAO protocol) tools configuration + #[serde(default)] + pub poa: PoaConfig, +} + +/// Configuration for the Poa protocol tool suite. +/// +/// Read tools (list/get) are offered to everyone when `enabled`. Write tools +/// (create/update/assign/complete/reject/cancel task) additionally require +/// `enable_writes` and are only offered to / executed for Signal senders listed +/// in `authorized_senders`. +#[derive(Debug, Clone, Deserialize)] +pub struct PoaConfig { + /// Master switch for Poa tools (off by default — opt-in). + #[serde(default)] + pub enabled: bool, + + /// JSON-RPC endpoint for the chain the org lives on. + pub rpc_url: Option, + + /// Poa subgraph GraphQL endpoint for that chain. + pub subgraph_url: Option, + + /// TaskManager proxy address (0x…) for the org being managed. + pub task_manager: Option, + + /// Human-readable network name shown to users. + #[serde(default = "default_poa_network")] + pub network_name: String, + + /// Wallet private key (0x hex). If omitted, the wallet is derived from the + /// TEE via dstack `derive_key(derive_key_path)` so no key ever leaves the + /// enclave. Prefer the derived path in production. + pub private_key: Option, + + /// dstack key-derivation path used when `private_key` is not set. + #[serde(default = "default_poa_derive_path")] + pub derive_key_path: String, + + /// Whether to offer state-changing write tools at all (off by default). + #[serde(default)] + pub enable_writes: bool, + + /// Comma/space-separated Signal sender ids (phone numbers) allowed to use + /// write tools. Empty means no one — writes stay effectively read-only. + pub authorized_senders: Option, + + /// HybridVoting proxy address (0x…) — required for governance tools. + pub voting_contract: Option, + + /// Seconds a staged value-moving action (e.g. complete_task) stays + /// confirmable via `!poa-confirm`. + #[serde(default = "default_poa_confirm_ttl")] + pub confirm_ttl_secs: u64, + + /// Autonomous board steward: periodically post a digest of expired/at-risk + /// claims to a Signal target. Off by default. + #[serde(default)] + pub steward_enabled: bool, + + /// Signal target (group id or number) for steward digests. + pub steward_target: Option, + + /// Bot account (registered number) to send steward digests from. If unset, + /// the first registered account is used. + pub steward_from: Option, + + /// Steward scan interval in seconds (default 6h). + #[serde(default = "default_poa_steward_interval")] + pub steward_interval_secs: u64, + + /// A claim counts as "at risk" if it expires within this many seconds + /// (default 24h). + #[serde(default = "default_poa_steward_warn")] + pub steward_warn_window_secs: u64, + + /// Suppress steward posts when there is nothing to report. + #[serde(default = "default_true")] + pub steward_quiet_when_empty: bool, +} + +impl Default for PoaConfig { + fn default() -> Self { + Self { + enabled: false, + rpc_url: None, + subgraph_url: None, + task_manager: None, + network_name: default_poa_network(), + private_key: None, + derive_key_path: default_poa_derive_path(), + enable_writes: false, + authorized_senders: None, + voting_contract: None, + confirm_ttl_secs: default_poa_confirm_ttl(), + steward_enabled: false, + steward_target: None, + steward_from: None, + steward_interval_secs: default_poa_steward_interval(), + steward_warn_window_secs: default_poa_steward_warn(), + steward_quiet_when_empty: true, + } + } +} + +impl PoaConfig { + /// Parse `authorized_senders` into a set of trimmed sender ids. + pub fn authorized_sender_list(&self) -> Vec { + self.authorized_senders + .as_deref() + .map(|s| { + s.split([',', ' ', '\n', '\t']) + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() + } + + /// Whether a given Signal sender may invoke Poa write tools. + pub fn is_authorized(&self, sender: &str) -> bool { + self.enable_writes && self.authorized_sender_list().iter().any(|s| s == sender) + } } #[derive(Debug, Clone, Deserialize)] @@ -252,6 +377,7 @@ impl Default for ToolsConfig { web_search: WebSearchConfig::default(), weather: WeatherConfig::default(), calculator: CalculatorConfig::default(), + poa: PoaConfig::default(), } } } @@ -414,6 +540,26 @@ fn default_search_results() -> usize { 5 } +fn default_poa_network() -> String { + "gnosis".into() +} + +fn default_poa_derive_path() -> String { + "poa-tools/task-manager-wallet".into() +} + +fn default_poa_confirm_ttl() -> u64 { + 300 +} + +fn default_poa_steward_interval() -> u64 { + 6 * 60 * 60 +} + +fn default_poa_steward_warn() -> u64 { + 24 * 60 * 60 +} + fn default_whisper_service() -> String { "http://whisper-api:9000".into() } diff --git a/crates/signal-bot/src/main.rs b/crates/signal-bot/src/main.rs index b9f5e41..fa4a04b 100644 --- a/crates/signal-bot/src/main.rs +++ b/crates/signal-bot/src/main.rs @@ -60,6 +60,184 @@ fn create_tool_registry(config: &signal_bot::config::ToolsConfig) -> ToolRegistr registry } +/// Register Poa protocol tools (read + gated write) into the registry. +/// +/// The wallet is either loaded from a configured hex private key or, preferably, +/// derived inside the TEE via dstack so no key material ever leaves the enclave. +/// Write tools are only registered when writes are explicitly enabled; their +/// per-sender execution gating lives in the chat handler. +async fn register_poa_tools( + registry: &mut ToolRegistry, + config: &signal_bot::config::PoaConfig, + dstack: &DstackClient, + confirm: Arc, +) -> Option> { + if !config.enabled { + return None; + } + + let (Some(rpc_url), Some(subgraph_url), Some(task_manager)) = ( + config.rpc_url.as_ref(), + config.subgraph_url.as_ref(), + config.task_manager.as_ref(), + ) else { + warn!("Poa tools enabled but TOOLS__POA__RPC_URL / SUBGRAPH_URL / TASK_MANAGER not all set - skipping"); + return None; + }; + + let client_config = match poa_tools::PoaClientConfig::parse( + rpc_url, + subgraph_url, + task_manager, + config.network_name.clone(), + ) + .and_then(|c| c.with_voting_contract(config.voting_contract.as_deref())) + { + Ok(c) => c, + Err(e) => { + error!("Poa tools misconfigured: {} - skipping", e); + return None; + } + }; + + // Resolve the wallet: explicit hex key, else TEE-derived key. + let client = if let Some(hex_key) = config.private_key.as_ref() { + info!("Poa wallet loaded from configured private key"); + poa_tools::PoaClient::from_hex_key(client_config, hex_key) + } else { + match dstack.derive_key(&config.derive_key_path, None).await { + Ok(key_bytes) => { + info!( + "Poa wallet derived from TEE (path: {})", + config.derive_key_path + ); + poa_tools::PoaClient::from_key_bytes(client_config, &key_bytes) + } + Err(e) => { + error!("Failed to derive Poa wallet from TEE: {} - skipping", e); + return None; + } + } + }; + + let client = match client { + Ok(c) => Arc::new(c), + Err(e) => { + error!("Failed to initialize Poa client: {} - skipping", e); + return None; + } + }; + + info!( + "Poa wallet address: {} (grant this address project-manager rights on-chain)", + client.address() + ); + + let tools = poa_tools::all_tools(client.clone(), confirm); + let mut read_count = 0; + let mut write_count = 0; + for tool in tools { + if tool.requires_authorization() { + // Skip write tools entirely unless writes are explicitly enabled. + if !config.enable_writes { + continue; + } + write_count += 1; + } else { + read_count += 1; + } + registry.register(tool); + } + + let senders = config.authorized_sender_list(); + let writes_state = if config.enable_writes { + "enabled" + } else { + "disabled" + }; + info!( + "Registered Poa tools: {} read, {} write (writes {}, {} authorized sender(s))", + read_count, + write_count, + writes_state, + senders.len() + ); + if config.enable_writes && senders.is_empty() { + warn!("Poa writes enabled but TOOLS__POA__AUTHORIZED_SENDERS is empty - no one can invoke write tools"); + } + + Some(client) +} + +/// Background loop: periodically scan the Poa board and post a steward digest. +fn spawn_poa_steward( + config: &signal_bot::config::PoaConfig, + client: Arc, + signal: Arc, +) { + if !config.steward_enabled { + return; + } + let Some(target) = config.steward_target.clone() else { + warn!("Poa steward enabled but TOOLS__POA__STEWARD_TARGET not set - skipping"); + return; + }; + + let interval = std::time::Duration::from_secs(config.steward_interval_secs.max(60)); + let warn_window = config.steward_warn_window_secs; + let quiet_when_empty = config.steward_quiet_when_empty; + let from_override = config.steward_from.clone(); + + tokio::spawn(async move { + info!( + "Poa steward running every {}s, posting to {}", + interval.as_secs(), + target + ); + let mut ticker = tokio::time::interval(interval); + loop { + ticker.tick().await; + + let tasks = match client.list_tasks(None, None).await { + Ok(t) => t, + Err(e) => { + warn!("Poa steward scan failed: {}", e); + continue; + } + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let report = poa_tools::steward::scan(&tasks, now, warn_window); + if quiet_when_empty && report.is_empty() { + debug!("Poa steward: nothing to report"); + continue; + } + + // Resolve the sending account: explicit override, else first account. + let from = match &from_override { + Some(f) => Some(f.clone()), + None => match signal.list_accounts().await { + Ok(accts) => accts.into_iter().next(), + Err(e) => { + warn!("Poa steward: cannot list accounts: {}", e); + None + } + }, + }; + let Some(from) = from else { + warn!("Poa steward: no sending account available"); + continue; + }; + + if let Err(e) = signal.send(&from, &target, &report.render()).await { + warn!("Poa steward: failed to post digest: {}", e); + } + } + }); +} + #[tokio::main] async fn main() -> AppResult<()> { // Load configuration @@ -93,8 +271,30 @@ async fn main() -> AppResult<()> { .context("Failed to create Signal client")?, ); + // Shared confirmation store for value-moving tool calls (Poa complete_task). + let confirm_store = Arc::new(tools::ConfirmationStore::new( + std::time::Duration::from_secs(config.tools.poa.confirm_ttl_secs), + )); + // Create tool registry based on config - let tool_registry = Arc::new(create_tool_registry(&config.tools)); + let mut tool_registry = create_tool_registry(&config.tools); + let poa_client = if config.tools.enabled { + register_poa_tools( + &mut tool_registry, + &config.tools.poa, + &dstack, + confirm_store.clone(), + ) + .await + } else { + None + }; + let tool_registry = Arc::new(tool_registry); + + // Autonomous board steward (posts digests to a Signal target). + if let Some(ref client) = poa_client { + spawn_poa_steward(&config.tools.poa, client.clone(), signal.clone()); + } // Initialize payment system let credit_store = if config.payments.enabled { @@ -215,9 +415,24 @@ async fn main() -> AppResult<()> { config.bot.github_repo.clone(), ) }; + // Attach the Poa write-tool authorization gate (shared with !poa-confirm). + let poa_authorization = signal_bot::commands::ToolAuthorization::new( + config.tools.poa.enable_writes, + config.tools.poa.authorized_sender_list(), + ); + let chat = chat.with_tool_authorization(poa_authorization.clone()); let mut handlers: Vec> = Vec::new(); + // Deterministic confirmation step for value-moving Poa actions. + if config.tools.poa.enabled && config.tools.poa.enable_writes { + handlers.push(Box::new(signal_bot::commands::PoaConfirmHandler::new( + tool_registry.clone(), + confirm_store.clone(), + poa_authorization, + ))); + } + let group_prefs = GroupPreferencesStore::open( dstack.clone(), PathBuf::from(&config.group_preferences.storage_path), diff --git a/crates/tools/src/confirm.rs b/crates/tools/src/confirm.rs new file mode 100644 index 0000000..dc75b88 --- /dev/null +++ b/crates/tools/src/confirm.rs @@ -0,0 +1,117 @@ +//! Two-step confirmation for value-moving tool calls. +//! +//! A tool that [`crate::Tool::requires_confirmation`] does not act on its first +//! invocation. Instead it *stages* the intended call here and returns a +//! human-readable summary plus a short code. The bot surfaces that to the user, +//! who must reply with a deterministic confirm command (`!poa-confirm `). +//! The confirm handler [`ConfirmationStore::take`]s the staged call and +//! re-dispatches it through the executor with `confirmed = true`. +//! +//! The code is a per-sender nonce, not a secret: safety comes from requiring a +//! deliberate second message from the *same* sender, which the LLM cannot forge. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// A staged, not-yet-executed tool call awaiting confirmation. +#[derive(Debug, Clone)] +pub struct PendingAction { + /// Short confirmation code the user must echo. + pub code: String, + /// Tool to re-dispatch on confirmation. + pub tool_name: String, + /// Raw JSON arguments for that tool. + pub arguments: String, + /// Human-readable summary shown when staged. + pub summary: String, + /// When this staged action stops being valid. + expires_at: Instant, +} + +/// Per-sender store of pending confirmations. +pub struct ConfirmationStore { + pending: Mutex>, + ttl: Duration, + counter: AtomicU64, +} + +impl ConfirmationStore { + /// Create a store where staged actions expire after `ttl`. + pub fn new(ttl: Duration) -> Self { + Self { + pending: Mutex::new(HashMap::new()), + ttl, + counter: AtomicU64::new(1), + } + } + + /// Stage a call for `sender`, returning the confirmation code. Any prior + /// pending action for that sender is replaced. + pub fn stage(&self, sender: &str, tool_name: &str, arguments: &str, summary: &str) -> String { + let n = self.counter.fetch_add(1, Ordering::Relaxed); + let code = format!("{:04}", n % 10000); + let action = PendingAction { + code: code.clone(), + tool_name: tool_name.to_string(), + arguments: arguments.to_string(), + summary: summary.to_string(), + expires_at: Instant::now() + self.ttl, + }; + self.pending + .lock() + .unwrap() + .insert(sender.to_string(), action); + code + } + + /// Consume the pending action for `sender` if `code` matches and it has not + /// expired. Returns `None` otherwise (and clears an expired entry). + pub fn take(&self, sender: &str, code: &str) -> Option { + let mut map = self.pending.lock().unwrap(); + match map.get(sender) { + Some(a) if a.code == code && a.expires_at > Instant::now() => map.remove(sender), + Some(a) if a.expires_at <= Instant::now() => { + map.remove(sender); + None + } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stage_and_take() { + let store = ConfirmationStore::new(Duration::from_secs(300)); + let code = store.stage("+1", "poa_complete_task", "{\"task_id\":3}", "mint 5 PT"); + // Wrong code / wrong sender rejected. + assert!(store.take("+1", "9999").is_none()); + assert!(store.take("+2", &code).is_none()); + // Correct code consumes exactly once. + let action = store.take("+1", &code).expect("should confirm"); + assert_eq!(action.tool_name, "poa_complete_task"); + assert!(store.take("+1", &code).is_none()); + } + + #[test] + fn test_expiry() { + let store = ConfirmationStore::new(Duration::from_millis(0)); + let code = store.stage("+1", "t", "{}", "s"); + assert!(store.take("+1", &code).is_none()); + } + + #[test] + fn test_restage_replaces() { + let store = ConfirmationStore::new(Duration::from_secs(300)); + let c1 = store.stage("+1", "t", "{}", "s1"); + let c2 = store.stage("+1", "t", "{}", "s2"); + assert_ne!(c1, c2); + assert!(store.take("+1", &c1).is_none()); + assert!(store.take("+1", &c2).is_some()); + } +} diff --git a/crates/tools/src/executor.rs b/crates/tools/src/executor.rs index 96c6a53..3cd4fe1 100644 --- a/crates/tools/src/executor.rs +++ b/crates/tools/src/executor.rs @@ -38,10 +38,39 @@ impl ToolExecutor { self } - /// Execute a tool call. + /// Execute a tool call on behalf of an unauthorized (default) caller. + /// + /// Equivalent to `execute_authorized(tool_call, false)`: privileged tools + /// are refused. Kept for callers/tests that never deal with authorization. pub async fn execute(&self, tool_call: &ToolCall) -> ToolResult { + self.execute_authorized(tool_call, false).await + } + + /// Execute a tool call, enforcing caller authorization. + /// + /// When `authorized` is false, any tool reporting + /// [`crate::Tool::requires_authorization`] is refused before execution as a + /// defense-in-depth backstop to the offer-time filtering in the registry. + pub async fn execute_authorized(&self, tool_call: &ToolCall, authorized: bool) -> ToolResult { + let ctx = crate::ToolContext { + authorized, + ..Default::default() + }; + self.execute_ctx(tool_call, &ctx).await + } + + /// Execute a tool call with full execution context. + /// + /// This is the primary entry point: it enforces the authorization backstop + /// and passes the [`crate::ToolContext`] through to the tool so it can apply + /// per-sender behavior (e.g. two-step confirmation). + pub async fn execute_ctx( + &self, + tool_call: &ToolCall, + ctx: &crate::ToolContext, + ) -> ToolResult { let tool_name = &tool_call.function.name; - info!(tool = %tool_name, "Executing tool"); + info!(tool = %tool_name, authorized = ctx.authorized, confirmed = ctx.confirmed, "Executing tool"); // Get the tool let tool = match self.registry.get_tool(tool_name) { @@ -55,10 +84,25 @@ impl ToolExecutor { } }; + // Authorization backstop: never run a privileged tool for an + // unauthorized caller, even if it was somehow offered. + if tool.requires_authorization() && !ctx.authorized { + warn!(tool = %tool_name, "Refused privileged tool for unauthorized sender"); + return ToolResult::error( + &tool_call.id, + format!( + "Tool '{}' requires authorization; this sender is not on the allowlist.", + tool_name + ), + ); + } + + let timeout_secs = tool.timeout_override().unwrap_or(self.timeout_secs); + // Execute with timeout let result = timeout( - Duration::from_secs(self.timeout_secs), - tool.execute(&tool_call.function.arguments), + Duration::from_secs(timeout_secs), + tool.execute_ctx(&tool_call.function.arguments, ctx), ) .await; @@ -82,10 +126,10 @@ impl ToolExecutor { ToolResult::error(&tool_call.id, format!("Error: {}", e)) } Err(_) => { - error!(tool = %tool_name, timeout = self.timeout_secs, "Tool timed out"); + error!(tool = %tool_name, timeout = timeout_secs, "Tool timed out"); ToolResult::error( &tool_call.id, - format!("Tool timed out after {} seconds", self.timeout_secs), + format!("Tool timed out after {} seconds", timeout_secs), ) } } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index e480d45..725315a 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -1,12 +1,14 @@ //! Tool use system for Signal bot. +mod confirm; mod error; -mod types; -mod registry; mod executor; +mod registry; +mod types; pub mod builtin; +pub use confirm::{ConfirmationStore, PendingAction}; pub use error::ToolError; -pub use types::*; -pub use registry::ToolRegistry; pub use executor::ToolExecutor; +pub use registry::ToolRegistry; +pub use types::*; diff --git a/crates/tools/src/registry.rs b/crates/tools/src/registry.rs index 6a24eb3..94bd1bb 100644 --- a/crates/tools/src/registry.rs +++ b/crates/tools/src/registry.rs @@ -52,6 +52,20 @@ impl ToolRegistry { .collect() } + /// Get definitions for enabled tools, filtered by caller authorization. + /// + /// When `authorized` is false, tools reporting + /// [`Tool::requires_authorization`] are omitted so the model is never even + /// offered a privileged tool for an unauthorized sender. + pub fn get_definitions_authorized(&self, authorized: bool) -> Vec { + self.tools + .iter() + .filter(|(name, _)| self.enabled.contains(*name)) + .filter(|(_, tool)| authorized || !tool.requires_authorization()) + .map(|(_, tool)| tool.definition()) + .collect() + } + /// Get a tool by name (only if enabled). pub fn get_tool(&self, name: &str) -> Option> { if self.enabled.contains(name) { diff --git a/crates/tools/src/types.rs b/crates/tools/src/types.rs index b9801cc..973e072 100644 --- a/crates/tools/src/types.rs +++ b/crates/tools/src/types.rs @@ -77,6 +77,22 @@ impl ToolResult { } } +/// Context passed to a tool at execution time. +/// +/// Carries who is asking and whether a prior confirmation step has cleared, so +/// tools can implement per-sender behavior (allowlists, two-step confirmation) +/// without the trait leaking bot internals. +#[derive(Debug, Clone, Default)] +pub struct ToolContext { + /// Signal sender id (phone number) of the human driving this turn. + pub sender: String, + /// Whether the sender is authorized for privileged tools. + pub authorized: bool, + /// Whether a required confirmation has already been satisfied for this call + /// (set true when re-dispatched by the `!poa-confirm` flow). + pub confirmed: bool, +} + /// Trait for implementing tools. #[async_trait] pub trait Tool: Send + Sync { @@ -88,4 +104,42 @@ pub trait Tool: Send + Sync { /// Execute the tool with JSON arguments. async fn execute(&self, arguments: &str) -> Result; + + /// Execute with execution context (sender, authorization, confirmation). + /// + /// Defaults to ignoring the context and calling [`Tool::execute`]. Tools + /// that need the sender or a confirmation gate override this. + async fn execute_ctx( + &self, + arguments: &str, + _ctx: &ToolContext, + ) -> Result { + self.execute(arguments).await + } + + /// Whether this tool performs a privileged/state-changing action that must + /// only be offered to and executed for authorized senders. + /// + /// Read-only tools return `false` (the default). Tools that move funds or + /// mutate on-chain / external state (e.g. Poa task writes) return `true`; + /// the bot then filters them by its sender allowlist before offering them + /// to the model or running them. + fn requires_authorization(&self) -> bool { + false + } + + /// Whether this tool must be confirmed via a second, deterministic step + /// before it actually runs (e.g. value-moving actions like minting a + /// payout). The bot mediates the confirmation; the tool sees `confirmed` + /// on the [`ToolContext`]. + fn requires_confirmation(&self) -> bool { + false + } + + /// Optional per-tool execution timeout in seconds, overriding the executor + /// default. On-chain writes need longer than typical HTTP tools because the + /// call has to be mined and confirmed. + fn timeout_override(&self) -> Option { + None + } } diff --git a/docs/poa-integration.md b/docs/poa-integration.md new file mode 100644 index 0000000..8220c6a --- /dev/null +++ b/docs/poa-integration.md @@ -0,0 +1,172 @@ +# Poa integration (DAO task tools) + +This bot can read and update a [Poa](https://github.com/poa-box) DAO organization's +work board. Poa is a DAO coordination protocol: each org is a set of upgradeable +BeaconProxy contracts (voting, participation token, and a **TaskManager** that +runs a project/task lifecycle). These tools let the LLM answer questions about an +org's projects and tasks and — for authorized operators — create and manage +tasks on-chain, all from inside the TEE. + +The bot's wallet key is **derived inside the enclave** (dstack `derive_key`) by +default, so no private key ever leaves the TEE. Write actions are gated twice: by +a global `enable_writes` switch and by a per-sender allowlist. + +## Crate layout + +- `crates/poa-tools/` — the tool implementations. + - `client.rs` — wallet + RPC + subgraph endpoints (`PoaClient`). + - `subgraph.rs` — read queries against the Poa subgraph. + - `contract.rs` — `alloy` `sol!` bindings + transaction senders for TaskManager. + - `tools_read.rs` — read tools (no authorization). + - `tools_write.rs` — write tools (`requires_authorization() == true`). + - `units.rs` — 18-decimal participation-token parsing/formatting. +- `crates/tools/` — gained `Tool::requires_authorization()` and + `Tool::timeout_override()`, plus `ToolRegistry::get_definitions_authorized()` + and `ToolExecutor::execute_authorized()` to enforce the gate. +- `crates/signal-bot/` — `PoaConfig`, `register_poa_tools()` in `main.rs`, and a + `ToolAuthorization` gate on `ChatHandler`. + +## Tools + +Read tools (offered to everyone when Poa is enabled): + +| Tool | Purpose | +|------|---------| +| `poa_list_projects` | List the org's projects (id, title, task count). | +| `poa_list_tasks` | List tasks, optionally filtered by status/project. | +| `poa_get_task` | Full detail of one task (call before `poa_update_task`). | +| `poa_wallet_info` | Bot wallet address, gas balance, configured TaskManager. | + +Write tools (only offered to / executed for allowlisted senders, and only when +`enable_writes` is true): + +| Tool | On-chain call | Permission the wallet needs | +|------|---------------|-----------------------------| +| `poa_create_task` | `createTask` | project manager or `CREATE` hat | +| `poa_update_task` | `updateTask` | PM or `EDIT_FULL` (or `CREATE` while unclaimed) | +| `poa_assign_task` | `assignTask` | PM or `ASSIGN` hat | +| `poa_complete_task` | `completeTask` | PM or `REVIEW` hat (mints payout) | +| `poa_reject_task` | `rejectTask` | PM or `REVIEW` hat | +| `poa_cancel_task` | `cancelTask` | PM or `CREATE` hat | + +Participation tools — the bot doing work itself (and *earning* PT when its work +is approved): + +| Tool | On-chain call | Permission the wallet needs | +|------|---------------|-----------------------------| +| `poa_claim_task` | `claimTask` | `CLAIM` hat | +| `poa_submit_task` | `submitTask` | be the current claimer | +| `poa_apply_for_task` | `applyForTask` | `CLAIM` hat (application-gated tasks) | + +Governance tools: + +| Tool | Kind | Permission the wallet needs | +|------|------|-----------------------------| +| `poa_list_proposals` | read (subgraph) | — | +| `poa_create_poll` | `createProposal` (non-executable) | proposal-creator hat | +| `poa_vote` | `vote` | voting-class hat | + +`poa_create_poll` deliberately only creates **non-executable polls** (every +option maps to an empty on-chain batch). The bot surfaces a decision for the +members to vote on; it never authors arbitrary executable calls. + +Payouts are participation tokens and are given as decimals (`"5"`, `"2.5"`) — the +tool converts to 18-decimal wei. Metadata/rejection text is accepted either as a +`0x`-prefixed bytes32 (e.g. an IPFS CID digest) or as free text, which is +sha256-hashed on chain. + +### Confirmation for value-moving actions + +`poa_complete_task` mints tokens (and transfers any bounty), so it does not run +on the first call. It **stages** the action and replies with a code; the +operator must send `!poa-confirm ` to execute. The confirmation is a +deterministic bot command, not an LLM tool call — the model cannot self-confirm, +and confirmation is only accepted from the same still-authorized sender. Staged +actions expire after `TOOLS__POA__CONFIRM_TTL_SECS` (default 5 min). + +### Autonomous board steward + +With `TOOLS__POA__STEWARD_ENABLED=true`, a background loop periodically scans the +board and posts a digest to a Signal target (`STEWARD_TARGET`): claims whose +deadline has passed (open to takeover under the v6 takeover rules), claims +expiring soon, and the count of open tasks. It is **read-only** — it never sends +transactions, only surfaces what needs attention. + +## Configuration + +All keys live under `TOOLS__POA__` (see `.env.example`): + +| Env var | Default | Notes | +|---------|---------|-------| +| `TOOLS__POA__ENABLED` | `false` | Master switch. | +| `TOOLS__POA__RPC_URL` | — | JSON-RPC for the org's chain. | +| `TOOLS__POA__SUBGRAPH_URL` | — | Poa subgraph GraphQL endpoint. | +| `TOOLS__POA__TASK_MANAGER` | — | The org's TaskManager proxy address. | +| `TOOLS__POA__NETWORK_NAME` | `gnosis` | Shown to users. | +| `TOOLS__POA__PRIVATE_KEY` | — | Dev only; unset ⇒ TEE-derived key. | +| `TOOLS__POA__DERIVE_KEY_PATH` | `poa-tools/task-manager-wallet` | dstack derivation path. | +| `TOOLS__POA__ENABLE_WRITES` | `false` | Enable write tools at all. | +| `TOOLS__POA__AUTHORIZED_SENDERS` | — | Comma/space-separated Signal ids allowed to write. | +| `TOOLS__POA__VOTING_CONTRACT` | — | HybridVoting proxy; required for governance tools. | +| `TOOLS__POA__CONFIRM_TTL_SECS` | `300` | How long a staged value-moving action stays confirmable. | +| `TOOLS__POA__STEWARD_ENABLED` | `false` | Run the board steward digest loop. | +| `TOOLS__POA__STEWARD_TARGET` | — | Signal group/number to post digests to. | +| `TOOLS__POA__STEWARD_FROM` | — | Sending account (defaults to first registered). | +| `TOOLS__POA__STEWARD_INTERVAL_SECS` | `21600` | Scan/post interval (6h). | +| `TOOLS__POA__STEWARD_WARN_WINDOW_SECS` | `86400` | "At risk" if a claim expires within this window (24h). | + +Poa governance orgs live on **Arbitrum**; test orgs (KUBI, Test6, …) on +**Gnosis**. Point `RPC_URL` + `SUBGRAPH_URL` at the chain your org is on. Subgraph +endpoints: + +- Gnosis: `https://api.studio.thegraph.com/query/73367/poa-gnosis-v-1/version/latest` +- Arbitrum: `https://api.studio.thegraph.com/query/73367/poa-arb-v-1/version/latest` + +### Finding an org's TaskManager + +Query the subgraph by orgId: + +```bash +curl -s -X POST "$TOOLS__POA__SUBGRAPH_URL" \ + -H 'Content-Type: application/json' \ + -d '{"query":"{ organization(id:\"0x\"){ name taskManager{ id } } }"}' +``` + +## Authorization model + +1. **Read tools** are always available when `ENABLED=true`. +2. **Write tools** are registered only when `ENABLE_WRITES=true`. +3. Even then, a write tool is only *offered* to the model (via + `get_definitions_authorized`) and only *executed* (via `execute_authorized`) + when the Signal sender is in `AUTHORIZED_SENDERS`. The executor refuses a + privileged tool for any other sender as a defense-in-depth backstop. +4. Authorization keys off the **individual sender's number**, even in group + chats — never the group id. + +On top of the bot-side gate, the chain is the final authority: a write only +succeeds if the bot's wallet holds the relevant TaskManager permission. Granting +that permission is a governance action on the Poa side — see the +`SIGSTACK_BOT_INTEGRATION.md` doc in the Poa contracts repo (`macau-v6`). + +## Getting the bot's wallet address + +Start the bot with Poa enabled and check the log line: + +``` +Poa wallet address: 0x… (grant this address project-manager rights on-chain) +``` + +or ask the bot (`poa_wallet_info`). Grant that address project-manager or the +appropriate role hat on the target project, then fund it with a little gas on the +org's chain. + +## Testing + +```bash +cargo test -p poa-tools # unit tests (subgraph parsing, units, tool split) +cargo test -p tools -p signal-bot +``` + +Subgraph parsing is covered with `wiremock`. The on-chain senders are thin +wrappers over `alloy` and are exercised end-to-end against a testnet org rather +than in unit tests.