Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ members = [
"crates/signal-client",
"crates/signal-registration-proxy",
"crates/tools",
"crates/poa-tools",
"crates/x402-payments",
"crates/whisper-client",
]
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <code>` 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:
Expand Down
45 changes: 45 additions & 0 deletions crates/poa-tools/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
165 changes: 165 additions & 0 deletions crates/poa-tools/src/client.rs
Original file line number Diff line number Diff line change
@@ -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<Address>,
/// 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<String>,
subgraph_url: impl Into<String>,
task_manager: &str,
network_name: impl Into<String>,
) -> Result<Self, PoaError> {
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, PoaError> {
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<Self, PoaError> {
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<Self, PoaError> {
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<Self, PoaError> {
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<Address, PoaError> {
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<alloy::primitives::U256, PoaError> {
self.provider()
.get_balance(self.address())
.await
.map_err(|e| PoaError::Chain(format!("balance query failed: {}", e)))
}
}
41 changes: 41 additions & 0 deletions crates/poa-tools/src/common.rs
Original file line number Diff line number Diff line change
@@ -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<B256, ToolError> {
s.parse::<B256>().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::<B256>() {
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<Address, ToolError> {
s.parse::<Address>()
.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)
}
Loading