Skip to content

Commit 5cfa120

Browse files
committed
feat(server): actp serve — AIP-2.1 quote-channel daemon
New CLI command + Python package (agirails.server): $ actp serve --policy ./provider-policy.json --port 8787 \ --network base-sepolia FastAPI app exposes: GET / — health (provider+chains) POST /quote-channel/{chainId}/{txId} — counter-offer ingest POST endpoint enforces the AIP-2.1 §8 security model: - URL path binding (chainId / txId match message) - TTL + 30s grace (cheap reject before sig recovery) - EIP-712 signature recovery via CounterOfferBuilder.verify - in-memory nonce LRU dedup (caller plugs distributed store for multi-worker deploys) Verified counters are evaluated against a loaded ProviderPolicy via evaluate_counter (walk / concede strategies, concede_pct math, floor + ideal band check). Verdicts logged for the operator; v1 does NOT auto-deliver CounterAccept back to the buyer (AIP-2.1 §5.3 — operator handles reply). Package contents: server/policy.py — ProviderPolicy + PricingPolicy + loader server/policy_engine.py — evaluate_counter → Verdict server/quote_channel.py — QuoteChannelHandler + InMemoryDedupStore server/app.py — FastAPI factory create_app(...) cli/commands/serve.py — typer command wiring uvicorn FastAPI + uvicorn ship as the optional [server] extra to keep library-only callers free of the install cost: pip install agirails[server] 21 tests cover: policy invariants + JSON loader, policy engine verdicts (accept/reject/walk/concede), QuoteChannelHandler (valid/path-mismatch/tampered-sig/expired/dedup/wrong-type), InMemoryDedupStore atomicity + expiry, FastAPI surface via TestClient (health + 201/400 routing). P2.2 of python-sdk parity sprint.
1 parent d2e640c commit 5cfa120

11 files changed

Lines changed: 1299 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,25 @@ swaps needed only if:
141141
reverted with kernel `_requesterCheck` / `_providerCheck` mismatch.
142142
Legacy EOA / mock path is preserved when wallet provider lacks
143143
`pay_actp_batched`.
144+
- **`actp serve` — AIP-2.1 quote-channel daemon.** New CLI command +
145+
Python package (`agirails.server`) that runs a FastAPI app exposing:
146+
- `GET /` — health check (provider address + supported chainIds)
147+
- `POST /quote-channel/{chainId}/{txId}` — receives AIP-2.1
148+
`agirails.counteroffer.v1` messages, runs URL-path-binding +
149+
TTL-with-grace + EIP-712 signature verification (via
150+
`CounterOfferBuilder`) + in-memory nonce dedup, then evaluates the
151+
verified counter against a loaded `ProviderPolicy`
152+
(`ACCEPT` / `COUNTER` / `REJECT` verdicts logged for the operator).
153+
Includes `ProviderPolicy` + `PricingPolicy` dataclasses with JSON
154+
loader (`load_policy_from_file`), minimal `evaluate_counter` policy
155+
engine (walk / concede strategies with concede_pct math), and
156+
`QuoteChannelHandler` framework-agnostic verifier with
157+
`InMemoryDedupStore`. FastAPI / uvicorn ship as the optional
158+
`server` extra — install via `pip install agirails[server]`. Mirrors
159+
the TS daemon's v1 surface; out-of-scope (parity-matched): on-chain
160+
INITIATED watcher (lives in `actp agent`) and reverse-channel
161+
delivery of `CounterAcceptMessage` (operator-handled per
162+
AIP-2.1 §5.3).
144163
- **`X402Adapter` auto-registration** — when an `ACTPClient` is created
145164
with a `wallet_provider` exposing `send_transaction` (both
146165
`EOAWalletProvider` and `AutoWalletProvider` qualify) and `mode` is
@@ -159,7 +178,6 @@ swaps needed only if:
159178
by passing `private_key=None` (orchestrator side).
160179

161180
### Coming in 3.x
162-
- `actp serve` daemon (FastAPI quote-channel HTTP for AIP-2.1)
163181
- Web Receipts (EIP-712 ReceiptWrite + agirails.app upload)
164182
- `actp repair`, `actp claim-code`, `actp request`, `actp verify` CLI commands
165183

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ security = [
7575
mutation = [
7676
"mutmut>=2.4.0", # Mutation testing
7777
]
78+
server = [
79+
"fastapi>=0.110.0", # actp serve daemon (AIP-2.1 quote channel)
80+
"uvicorn>=0.30.0",
81+
]
7882

7983
[project.scripts]
8084
actp = "agirails.cli.main:run"

src/agirails/cli/commands/serve.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""``actp serve`` — run the AIP-2.1 quote-channel daemon.
2+
3+
Loads a ProviderPolicy JSON file, builds a FastAPI app via
4+
:func:`agirails.server.app.create_app`, and serves it with uvicorn.
5+
6+
Scope:
7+
- accept + verify incoming counter-offers via :class:`QuoteChannelHandler`
8+
- log the policy verdict (ACCEPT / COUNTER / REJECT) per round
9+
- one-line health response on ``GET /``
10+
11+
Not in scope here (matches TS daemon v1):
12+
- On-chain INITIATED-tx detection (handled by ``actp agent`` /
13+
long-running `Agent` instances).
14+
- Sending CounterAcceptMessage back to buyer (no reverse-endpoint
15+
discovery in v1 — operator handles delivery).
16+
17+
Usage::
18+
19+
actp serve --policy ./provider-policy.json --port 8787 --network base-sepolia
20+
actp serve --policy ./provider-policy.json --mock # local testing
21+
22+
Example policy JSON (saved as ``provider-policy.json``)::
23+
24+
{
25+
"services": ["text-generation"],
26+
"pricing": {
27+
"min_acceptable": {"amount": 500000, "currency": "USDC", "unit": "base"},
28+
"ideal_price": {"amount": 1000000, "currency": "USDC", "unit": "base"}
29+
},
30+
"quote_ttl": "15m",
31+
"counter_strategy": "concede",
32+
"concede_pct": 30,
33+
"max_requotes": 2
34+
}
35+
"""
36+
37+
from pathlib import Path
38+
from typing import Optional
39+
40+
import typer
41+
42+
from agirails.cli.utils.output import print_error, print_info, print_success
43+
44+
45+
def serve(
46+
policy: Path = typer.Option(
47+
...,
48+
"--policy",
49+
help="Path to ProviderPolicy JSON file.",
50+
exists=True,
51+
dir_okay=False,
52+
readable=True,
53+
),
54+
port: int = typer.Option(
55+
8787, "--port", min=1, max=65535, help="HTTP port to listen on."
56+
),
57+
host: str = typer.Option(
58+
"0.0.0.0", "--host", help="Bind address (default: 0.0.0.0)."
59+
),
60+
network: str = typer.Option(
61+
"base-sepolia",
62+
"--network",
63+
help="Network — base-sepolia | base-mainnet | mock.",
64+
),
65+
mock: bool = typer.Option(
66+
False,
67+
"--mock",
68+
help="Use a mock provider address / zero kernel for local testing.",
69+
),
70+
provider_address: Optional[str] = typer.Option(
71+
None,
72+
"--provider-address",
73+
help=(
74+
"Provider EOA / Smart Wallet address shown on /health. "
75+
"Defaults to env ACTP_PROVIDER_ADDRESS or a placeholder."
76+
),
77+
),
78+
) -> None:
79+
"""Run a long-running provider daemon (AIP-2.1 quote channel)."""
80+
try:
81+
# Lazy imports — server stack is an optional dependency.
82+
try:
83+
import uvicorn # noqa: F401
84+
except ImportError as exc:
85+
raise RuntimeError(
86+
"uvicorn is not installed. Install the server extras:\n"
87+
" pip install agirails[server]"
88+
) from exc
89+
90+
from agirails.config.networks import get_network
91+
from agirails.server.app import create_app
92+
from agirails.server.policy import load_policy_from_file
93+
from agirails.server.quote_channel import build_channel_path
94+
95+
# 1. Load + validate policy.
96+
loaded_policy = load_policy_from_file(policy)
97+
98+
# 2. Resolve kernel address + chainId from network config.
99+
if mock:
100+
kernel_address = "0x" + "0" * 40
101+
chain_id = 84532
102+
else:
103+
network_cfg = get_network(network)
104+
kernel_address = network_cfg.contracts.actp_kernel
105+
chain_id = network_cfg.chain_id
106+
107+
# 3. Provider address for /health.
108+
import os
109+
signer_address = (
110+
provider_address
111+
or os.environ.get("ACTP_PROVIDER_ADDRESS")
112+
or "0x" + "0" * 40
113+
)
114+
115+
# 4. Build app.
116+
app = create_app(
117+
policy=loaded_policy,
118+
kernel_address_by_chain_id={chain_id: kernel_address},
119+
signer_address=signer_address,
120+
service_label="actp-serve",
121+
)
122+
123+
# 5. Banner + serve.
124+
print_success(f"actp serve listening on http://{host}:{port}")
125+
print_info(f" Network: {network}{' (mock)' if mock else ''}")
126+
print_info(f" Provider: {signer_address}")
127+
print_info(f" Channel base: {build_channel_path(chain_id, '<txId>')}")
128+
print_info(f" Health: GET /")
129+
print_info("")
130+
print_info(
131+
"Counter-offers POSTed to /quote-channel/{chainId}/{txId} are verified +"
132+
)
133+
print_info(
134+
"evaluated against the policy. Verdicts are logged here; v1 does NOT"
135+
)
136+
print_info(
137+
"auto-deliver CounterAccept back to the buyer (AIP-2.1 §5.3)."
138+
)
139+
140+
import uvicorn as _uvicorn
141+
142+
_uvicorn.run(app, host=host, port=port, log_level="info")
143+
except Exception as exc:
144+
print_error(f"actp serve failed: {exc}")
145+
raise typer.Exit(code=1)

src/agirails/cli/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def main(
130130
from agirails.cli.commands import test as test_cmd
131131
from agirails.cli.commands import register as register_cmd
132132
from agirails.cli.commands import negotiate as negotiate_cmd
133+
from agirails.cli.commands import serve as serve_cmd
133134

134135
# Register commands
135136
app.command(name="init")(init_cmd.init)
@@ -163,6 +164,9 @@ def main(
163164
app.command(name="register")(register_cmd.register)
164165
app.command(name="negotiate")(negotiate_cmd.negotiate)
165166

167+
# AIP-2.1 quote-channel daemon
168+
app.command(name="serve")(serve_cmd.serve)
169+
166170
# Deploy subcommand group
167171
deploy_app = typer.Typer(
168172
name="deploy",

src/agirails/server/__init__.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""AGIRAILS server package — ``actp serve`` daemon for AIP-2.1 quote channel."""
2+
3+
from agirails.server.policy import (
4+
PLATFORM_MIN_BASE_UNITS,
5+
PricingPolicy,
6+
ProviderPolicy,
7+
load_policy_from_dict,
8+
load_policy_from_file,
9+
)
10+
from agirails.server.policy_engine import (
11+
Verdict,
12+
VerdictAction,
13+
evaluate_counter,
14+
)
15+
from agirails.server.quote_channel import (
16+
DEDUP_TTL_SECONDS,
17+
HandlerContext,
18+
HandlerResult,
19+
InMemoryDedupStore,
20+
QuoteChannelHandler,
21+
TTL_GRACE_SECONDS,
22+
build_channel_path,
23+
)
24+
25+
__all__ = [
26+
"PLATFORM_MIN_BASE_UNITS",
27+
"PricingPolicy",
28+
"ProviderPolicy",
29+
"load_policy_from_dict",
30+
"load_policy_from_file",
31+
"Verdict",
32+
"VerdictAction",
33+
"evaluate_counter",
34+
"DEDUP_TTL_SECONDS",
35+
"HandlerContext",
36+
"HandlerResult",
37+
"InMemoryDedupStore",
38+
"QuoteChannelHandler",
39+
"TTL_GRACE_SECONDS",
40+
"build_channel_path",
41+
]

src/agirails/server/app.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
FastAPI app factory for the AIP-2.1 quote channel daemon (``actp serve``).
3+
4+
Builds an ASGI app exposing:
5+
6+
- ``GET /`` → health check
7+
- ``POST /quote-channel/{chainId}/{txId}`` → counter-offer ingest
8+
9+
The counter-offer endpoint runs :class:`QuoteChannelHandler` (signature
10+
+ TTL + dedup + path-binding checks) and, on success, evaluates the
11+
verified message against the loaded :class:`ProviderPolicy` via
12+
:func:`evaluate_counter`. The verdict is logged but NOT auto-sent back
13+
to the buyer in v1 (see AIP-2.1-DRAFT §5.3 — operator handles reply
14+
delivery).
15+
16+
Note: FastAPI / uvicorn are an OPTIONAL dependency. Install with::
17+
18+
pip install agirails[server]
19+
20+
@module server/app
21+
"""
22+
23+
from typing import Any, Dict
24+
25+
from fastapi import FastAPI, Request
26+
from fastapi.responses import JSONResponse
27+
28+
from agirails.server.policy import ProviderPolicy
29+
from agirails.server.policy_engine import evaluate_counter
30+
from agirails.server.quote_channel import (
31+
HandlerContext,
32+
QuoteChannelHandler,
33+
build_channel_path,
34+
)
35+
from agirails.utils.logger import Logger
36+
37+
_logger = Logger("agirails.server.app")
38+
39+
40+
def create_app(
41+
policy: ProviderPolicy,
42+
kernel_address_by_chain_id: Dict[int, str],
43+
signer_address: str,
44+
service_label: str = "actp-serve",
45+
) -> Any:
46+
"""Construct the FastAPI app.
47+
48+
Args:
49+
policy: Loaded :class:`ProviderPolicy`.
50+
kernel_address_by_chain_id: Mapping ``{chainId: kernel_address}``
51+
so the handler can verify EIP-712 signatures bound to the
52+
on-chain ACTPKernel for each supported chain.
53+
signer_address: Provider address (shown on the health endpoint
54+
for operational visibility).
55+
service_label: Identifier returned by the health check.
56+
57+
Returns:
58+
A configured :class:`fastapi.FastAPI` instance ready to be
59+
passed to ``uvicorn.run`` or mounted under a parent app.
60+
"""
61+
app = FastAPI(title="AGIRAILS — actp serve", version="1.0.0")
62+
handler = QuoteChannelHandler(
63+
kernel_address_by_chain_id=kernel_address_by_chain_id,
64+
)
65+
66+
@app.get("/")
67+
async def health() -> Dict[str, Any]:
68+
return {
69+
"status": "ok",
70+
"provider": signer_address,
71+
"chains": list(kernel_address_by_chain_id.keys()),
72+
"service": service_label,
73+
}
74+
75+
@app.post("/quote-channel/{chain_id}/{tx_id}")
76+
async def quote_channel(
77+
chain_id: int, tx_id: str, request: Request
78+
) -> Any:
79+
try:
80+
payload = await request.json()
81+
except Exception:
82+
return JSONResponse(
83+
status_code=400,
84+
content={"accepted": False, "reason": "Invalid JSON"},
85+
)
86+
87+
result = handler.handle(
88+
payload,
89+
HandlerContext(path_chain_id=chain_id, path_tx_id=tx_id),
90+
)
91+
92+
# Evaluate verified counter-offers against policy and log the verdict.
93+
# The HTTP response is the handler's accepted-or-not signal; the
94+
# verdict is operator-visible via logs (v1 doesn't auto-deliver
95+
# CounterAcceptMessage back — see AIP-2.1 §5.3).
96+
if result.parsed_message is not None:
97+
try:
98+
verdict = evaluate_counter(result.parsed_message, policy)
99+
_logger.info(
100+
f"[counter] tx={tx_id[:12]}… "
101+
f"counter={result.parsed_message.counterAmount} "
102+
f"→ {verdict.action.value}: {verdict.reason}"
103+
)
104+
except Exception as exc:
105+
_logger.warning(
106+
f"[counter] tx={tx_id[:12]}… "
107+
f"policy eval failed: {exc}"
108+
)
109+
110+
return JSONResponse(status_code=result.status, content=result.body)
111+
112+
return app
113+
114+
115+
__all__ = ["create_app"]

0 commit comments

Comments
 (0)