Skip to content

Commit 5aedfa1

Browse files
authored
Merge pull request #65 from ChainSafe/fix/autobot-env-var
feat: reward marker emission
2 parents b12b94b + 9aab31e commit 5aedfa1

10 files changed

Lines changed: 279 additions & 7 deletions

.env.canton.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ CANTON_PROVIDER_PARTY=your-party::1220...
4040
# DSO party (global domain admin — used for AmuletRules lookups)
4141
CANTON_DSO_PARTY=DSO::1220...
4242

43+
# ============================================================================
44+
# Featured App Rewards (Canton 3.4 FeaturedAppActivityMarker emission)
45+
# ============================================================================
46+
# Enable to emit FeaturedAppActivityMarker contracts after each tool call.
47+
# Requires: FeaturedAppRight granted by DSO governance vote for CANTON_PROVIDER_PARTY.
48+
# SV automation converts markers into AppRewardCoupons for CC minting.
49+
FEATURED_APP_REWARDS_ENABLED=false
50+
4351
# ============================================================================
4452
# Canton JSON API v2
4553
# ============================================================================

env.production.template

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ CANTON_FACILITATOR_URL=http://46.224.109.63:3000
4848
# TODO: Add your Canton payee party
4949
CANTON_PAYEE_PARTY=
5050

51+
# Featured App Rewards — emit FeaturedAppActivityMarker per tool call
52+
# Requires FeaturedAppRight granted by DSO governance for CANTON_PROVIDER_PARTY
53+
FEATURED_APP_REWARDS_ENABLED=true
54+
5155
# Canton network (use canton-devnet for ChainSafe DevNet)
5256
CANTON_NETWORK=canton-devnet
5357

src/canton_mcp_server/canton_billing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
BILLING_PORTAL_URL = get_env("BILLING_PORTAL_URL", "http://localhost:3050")
3333

3434
# Environment configuration
35-
CANTON_LEDGER_URL = os.getenv("CANTON_LEDGER_URL", "http://localhost:3975")
35+
CANTON_LEDGER_URL = get_env("CANTON_LEDGER_URL", "http://localhost:3975")
3636
CANTON_OAUTH_TOKEN_URL = os.getenv(
3737
"CANTON_OAUTH_TOKEN_URL",
3838
"http://localhost:8082/realms/AppProvider/protocol/openid-connect/token"

src/canton_mcp_server/core/semantic_search.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,8 +420,19 @@ def search_similar_files(
420420
]
421421

422422
# Filter results below minimum similarity threshold
423-
min_score = float(get_env("MIN_SIMILARITY_THRESHOLD", "0.3"))
423+
min_score = float(get_env("MIN_SIMILARITY_THRESHOLD", "0.15"))
424+
pre_filter_count = len(relevant_resources)
424425
relevant_resources = [r for r in relevant_resources if r.get("similarity_score", 0) >= min_score]
426+
top_score = (1.0 - result_distances[0]) if result_distances else 0.0
427+
logger.info(
428+
f"📊 Similarity filter: {pre_filter_count}{len(relevant_resources)} "
429+
f"(threshold={min_score:.2f}, top={top_score:.3f})"
430+
)
431+
if pre_filter_count > 0 and len(relevant_resources) == 0:
432+
logger.warning(
433+
f"⚠️ All {pre_filter_count} results filtered out — top similarity {top_score:.3f} "
434+
f"< threshold {min_score:.2f}. Consider lowering MIN_SIMILARITY_THRESHOLD."
435+
)
425436

426437
# Re-read file content from disk so LLM receives actual code, not empty strings
427438
try:

src/canton_mcp_server/daml/authorization_validator.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -563,8 +563,9 @@ def validate_authorization(self, auth_model: AuthorizationModel) -> bool:
563563
# Rule 1: At least one signatory
564564
if not auth_model.signatories:
565565
logger.warning(
566-
f"Authorization model invalid: {auth_model.template_name} "
567-
"has no signatories"
566+
f"[AUTH] Invalid auth model: template={auth_model.template_name} "
567+
f"has no signatories (observers={auth_model.observers}, "
568+
f"controllers={dict(auth_model.controllers)})"
568569
)
569570
return False
570571

src/canton_mcp_server/daml/safety_checker.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,19 @@ async def check_pattern_safety(
321321

322322
# Step 3: Extract authorization model with confidence scoring
323323
auth_extraction = self.auth_validator.extract_auth_model(code, compilation_result)
324+
if auth_extraction.model:
325+
logger.info(
326+
f"[AUTH] Extracted model: template={auth_extraction.model.template_name} "
327+
f"method={auth_extraction.method} confidence={auth_extraction.confidence:.2f} "
328+
f"signatories={auth_extraction.model.signatories} "
329+
f"observers={auth_extraction.model.observers} "
330+
f"controllers={dict(auth_extraction.model.controllers)}"
331+
)
332+
else:
333+
logger.info(
334+
f"[AUTH] Extraction returned no model (method={auth_extraction.method}, "
335+
f"confidence={auth_extraction.confidence:.2f})"
336+
)
324337

325338
# Store extraction result for insights (will be added to SafetyCheckResult)
326339
extraction_insights = None
@@ -392,8 +405,9 @@ async def check_pattern_safety(
392405
auth_valid = True
393406
if auth_extraction.model:
394407
auth_valid = self.auth_validator.validate_authorization(auth_extraction.model)
408+
logger.info(f"[AUTH] validate_authorization returned auth_valid={auth_valid}")
395409
else:
396-
logger.warning("Could not extract authorization model")
410+
logger.warning("[AUTH] Could not extract authorization model — treating as invalid")
397411
auth_valid = False
398412

399413
# Step 6: Final safety determination

src/canton_mcp_server/env.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@
141141
# Canton payment defaults
142142
ENV_VALUES["CANTON_DEFAULT_PAYER_PARTY"] = os.getenv("CANTON_DEFAULT_PAYER_PARTY", "")
143143

144+
# Featured App Rewards (FeaturedAppActivityMarker emission per CIP-0047)
145+
ENV_VALUES["FEATURED_APP_REWARDS_ENABLED"] = os.getenv("FEATURED_APP_REWARDS_ENABLED", "false")
146+
144147
# Isolated environment flag (also read directly via os.environ at module level)
145148
ENV_VALUES["IS_ISOLATED_ENVIRONMENT"] = os.getenv("IS_ISOLATED_ENVIRONMENT", "false")
146149

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""
2+
Featured App Activity Marker Emission
3+
4+
Creates FeaturedAppActivityMarker contracts on Canton ledger by exercising
5+
the FeaturedAppRight_CreateActivityMarker choice. This is how featured apps
6+
earn rewards — SV automation converts markers into AppRewardCoupons for CC minting.
7+
8+
Flow:
9+
1. At startup, query the ledger for the FeaturedAppRight contract (granted by DSO governance)
10+
2. After each billable tool call, exercise FeaturedAppRight_CreateActivityMarker
11+
3. SV automation handles marker → AppRewardCoupon → CC minting (automatic)
12+
13+
Non-blocking: marker creation never fails a tool call.
14+
"""
15+
16+
import logging
17+
import os
18+
from typing import Optional
19+
20+
from canton_mcp_server.canton_billing import (
21+
CANTON_PROVIDER_PARTY,
22+
CANTON_USER_ID,
23+
_make_ledger_request,
24+
get_ledger_offset,
25+
)
26+
27+
logger = logging.getLogger(__name__)
28+
29+
# Feature gate
30+
FEATURED_APP_REWARDS_ENABLED = (
31+
os.getenv("FEATURED_APP_REWARDS_ENABLED", "false").lower() == "true"
32+
)
33+
34+
# Cached FeaturedAppRight contract
35+
_featured_app_right_cache: dict = {
36+
"contract_id": None,
37+
"template_id": None,
38+
}
39+
40+
41+
async def init_featured_app_right() -> bool:
42+
"""
43+
Query the ledger for the FeaturedAppRight contract belonging to our provider party.
44+
Called at startup and on contract-not-found errors.
45+
46+
Returns True if found, False otherwise.
47+
"""
48+
if not CANTON_PROVIDER_PARTY:
49+
logger.warning("CANTON_PROVIDER_PARTY not set — cannot query FeaturedAppRight")
50+
return False
51+
52+
try:
53+
offset = await get_ledger_offset()
54+
data = await _make_ledger_request(
55+
"POST",
56+
"/v2/state/active-contracts",
57+
{
58+
"filter": {
59+
"filtersByParty": {
60+
CANTON_PROVIDER_PARTY: {"cumulative": []},
61+
},
62+
},
63+
"activeAtOffset": offset,
64+
"verbose": False,
65+
},
66+
)
67+
68+
contracts = data if isinstance(data, list) else data.get("activeContracts", data.get("result", []))
69+
70+
for c in contracts:
71+
# Canton JSON API v2 wraps contracts in contractEntry.JsActiveContract.createdEvent
72+
ce = c.get("contractEntry", {})
73+
ac = ce.get("JsActiveContract", {})
74+
event = ac.get("createdEvent", {}) or c.get("createdEvent", c)
75+
template_id = event.get("templateId", "")
76+
if "FeaturedAppRight" in template_id:
77+
contract_id = event.get("contractId", "")
78+
_featured_app_right_cache["contract_id"] = contract_id
79+
_featured_app_right_cache["template_id"] = template_id
80+
logger.info(
81+
f"FeaturedAppRight contract found: {contract_id[:40]}... "
82+
f"(template: {template_id})"
83+
)
84+
return True
85+
86+
logger.warning(
87+
f"FeaturedAppRight not found for {CANTON_PROVIDER_PARTY}. "
88+
"Activity markers will not be emitted. "
89+
"Ensure the DSO has granted FeaturedAppRight to this party."
90+
)
91+
return False
92+
93+
except Exception as e:
94+
logger.error(f"Failed to query FeaturedAppRight: {e}")
95+
return False
96+
97+
98+
async def create_activity_marker(request_id: str) -> Optional[str]:
99+
"""
100+
Exercise FeaturedAppRight_CreateActivityMarker to emit an activity marker.
101+
102+
Args:
103+
request_id: Unique request ID (used for command deduplication)
104+
105+
Returns:
106+
Contract ID of the created marker, or None on failure.
107+
"""
108+
if not FEATURED_APP_REWARDS_ENABLED:
109+
return None
110+
111+
contract_id = _featured_app_right_cache.get("contract_id")
112+
template_id = _featured_app_right_cache.get("template_id")
113+
114+
if not contract_id or not template_id:
115+
return None
116+
117+
try:
118+
data = await _make_ledger_request(
119+
"POST",
120+
"/v2/commands/submit-and-wait-for-transaction",
121+
{
122+
"commands": {
123+
"userId": CANTON_USER_ID,
124+
"commandId": f"activity-marker-{request_id}",
125+
"actAs": [CANTON_PROVIDER_PARTY],
126+
"readAs": [CANTON_PROVIDER_PARTY],
127+
"commands": [
128+
{
129+
"ExerciseCommand": {
130+
"templateId": template_id,
131+
"contractId": contract_id,
132+
"choice": "FeaturedAppRight_CreateActivityMarker",
133+
"choiceArgument": {
134+
"beneficiaries": [
135+
{
136+
"beneficiary": CANTON_PROVIDER_PARTY,
137+
"weight": "1.0",
138+
}
139+
],
140+
},
141+
}
142+
}
143+
],
144+
}
145+
},
146+
)
147+
148+
# Extract marker contract IDs from the exercise result
149+
events = data.get("transaction", {}).get("events", [])
150+
marker_cids = []
151+
for event in events:
152+
created = event.get("CreatedEvent") or event.get("createdEvent", {})
153+
if created.get("contractId") and "ActivityMarker" in created.get("templateId", ""):
154+
marker_cids.append(created["contractId"])
155+
156+
if marker_cids:
157+
logger.info(f"ActivityMarker created: {marker_cids[0][:40]}...")
158+
return marker_cids[0]
159+
160+
# Even without recognizing the marker template, success means it worked
161+
logger.info(f"FeaturedAppRight_CreateActivityMarker exercised for request {request_id}")
162+
return "exercised"
163+
164+
except Exception as e:
165+
error_str = str(e)
166+
167+
# Contract archived / not found — re-query and retry once
168+
if "CONTRACT_NOT_FOUND" in error_str or "not found" in error_str.lower():
169+
logger.warning("FeaturedAppRight contract may have been archived, re-querying...")
170+
found = await init_featured_app_right()
171+
if found:
172+
try:
173+
return await create_activity_marker(f"{request_id}-retry")
174+
except Exception as retry_err:
175+
logger.warning(f"ActivityMarker retry failed: {retry_err}")
176+
return None
177+
178+
# Auth / permission errors — don't retry
179+
if "403" in error_str or "PERMISSION_DENIED" in error_str:
180+
logger.warning(f"ActivityMarker permission denied (FeaturedAppRight may have been revoked): {e}")
181+
return None
182+
183+
logger.warning(f"ActivityMarker creation failed (non-fatal): {e}")
184+
return None

src/canton_mcp_server/server.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@
103103
# Reduce uvicorn access log noise (only show warnings/errors)
104104
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
105105

106+
107+
# Filter out successful /health and /ready probe access logs. K8s polls these
108+
# from multiple pods several times per second, which drowns real request logs
109+
# in Loki. We keep errors (non-2xx) visible by only filtering INFO records.
110+
class _ProbeLogFilter(logging.Filter):
111+
def filter(self, record: logging.LogRecord) -> bool:
112+
if record.levelno >= logging.WARNING:
113+
return True
114+
msg = record.getMessage()
115+
return not (" /health " in msg or " /ready " in msg)
116+
117+
118+
logging.getLogger("uvicorn.access").addFilter(_ProbeLogFilter())
119+
106120
# Global instances
107121
payment_handler = PaymentHandler()
108122

@@ -196,6 +210,21 @@ async def periodic_broadcast():
196210
else:
197211
logger.warning("⚠️ DCAP enabled but DCAP_SERVER_URL not configured - skipping semantic_discover")
198212

213+
# Initialize FeaturedAppRight contract lookup for reward marker emission
214+
# Import lazily — this module imports canton_billing which pulls in httpx/orjson;
215+
# at Docker build time the __init__.py → server.py import chain runs during the
216+
# ChromaDB pre-index step where these aren't needed and may not resolve.
217+
try:
218+
from canton_mcp_server.featured_app_rewards import FEATURED_APP_REWARDS_ENABLED, init_featured_app_right
219+
if FEATURED_APP_REWARDS_ENABLED and payment_handler.canton_enabled:
220+
found = await init_featured_app_right()
221+
if found:
222+
logger.info("Featured app rewards enabled — activity markers will be emitted per tool call")
223+
else:
224+
logger.warning("Featured app rewards enabled but FeaturedAppRight not found — markers disabled")
225+
except Exception as e:
226+
logger.warning(f"FeaturedAppRight init failed (non-fatal): {e}")
227+
199228
yield
200229

201230
# Shutdown
@@ -541,6 +570,13 @@ async def create_charge():
541570
request_id=str(mcp_request.id),
542571
)
543572
logger.info(f"ChargeReceipt created (streaming): {charge_contract_id}")
573+
# Emit FeaturedAppActivityMarker for reward tracking
574+
try:
575+
from canton_mcp_server.featured_app_rewards import FEATURED_APP_REWARDS_ENABLED, create_activity_marker
576+
if FEATURED_APP_REWARDS_ENABLED:
577+
await create_activity_marker(request_id=str(mcp_request.id))
578+
except ImportError:
579+
pass
544580
except Exception as e:
545581
logger.critical(f"BILLING INTEGRITY: Failed to create ChargeReceipt for {party_id}/{tool_name}/{price_cc}CC: {e}")
546582
asyncio.create_task(create_charge())
@@ -603,6 +639,13 @@ async def create_charge():
603639
request_id=str(mcp_request.id),
604640
)
605641
logger.info(f"ChargeReceipt created on-chain: {tool_name} - {price_cc} CC from {party_id} (contract: {charge_contract_id})")
642+
# Emit FeaturedAppActivityMarker for reward tracking (fire-and-forget)
643+
try:
644+
from canton_mcp_server.featured_app_rewards import FEATURED_APP_REWARDS_ENABLED, create_activity_marker
645+
if FEATURED_APP_REWARDS_ENABLED:
646+
asyncio.create_task(create_activity_marker(request_id=str(mcp_request.id)))
647+
except ImportError:
648+
pass
606649
except Exception as e:
607650
logger.critical(f"BILLING INTEGRITY: Failed to create ChargeReceipt for {party_id}/{tool_name}/{price_cc}CC: {e}")
608651

src/canton_mcp_server/websocket_client.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,16 @@ async def _handle_message(self, message: dict):
113113
logger.debug(f"💰 Balance updated for {party}: ${balance:.2f}")
114114

115115
elif msg_type == "access-denied":
116-
# Access denied due to threshold
116+
# Access denied due to threshold.
117+
# Demoted to DEBUG: the x402 facilitator's access-denied broadcast uses
118+
# the legacy "balance = amountDue - amountPaid" model, which does NOT
119+
# see on-chain Canton CreditReceipts. Real access control runs via
120+
# ChargeReceipt/CreditReceipt balance check in server.py, not here.
117121
balance = data.get("balance", 0)
118122
reason = data.get("reason", "Unknown")
119123
if party:
120124
self.balance_cache[party] = balance
121-
logger.warning(f"🚫 Access denied for {party}: {reason} (balance: ${balance:.2f})")
125+
logger.debug(f"🚫 Facilitator reported access-denied for {party}: {reason} (balance: ${balance:.2f})")
122126

123127
async def _reconnect(self):
124128
"""Reconnect to WebSocket server with exponential backoff"""

0 commit comments

Comments
 (0)