Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .env.canton.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ CANTON_PROVIDER_PARTY=your-party::1220...
# DSO party (global domain admin — used for AmuletRules lookups)
CANTON_DSO_PARTY=DSO::1220...

# ============================================================================
# Featured App Rewards (Canton 3.4 FeaturedAppActivityMarker emission)
# ============================================================================
# Enable to emit FeaturedAppActivityMarker contracts after each tool call.
# Requires: FeaturedAppRight granted by DSO governance vote for CANTON_PROVIDER_PARTY.
# SV automation converts markers into AppRewardCoupons for CC minting.
FEATURED_APP_REWARDS_ENABLED=false

# ============================================================================
# Canton JSON API v2
# ============================================================================
Expand Down
4 changes: 4 additions & 0 deletions env.production.template
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ CANTON_FACILITATOR_URL=http://46.224.109.63:3000
# TODO: Add your Canton payee party
CANTON_PAYEE_PARTY=

# Featured App Rewards — emit FeaturedAppActivityMarker per tool call
# Requires FeaturedAppRight granted by DSO governance for CANTON_PROVIDER_PARTY
FEATURED_APP_REWARDS_ENABLED=true

# Canton network (use canton-devnet for ChainSafe DevNet)
CANTON_NETWORK=canton-devnet

Expand Down
2 changes: 1 addition & 1 deletion src/canton_mcp_server/canton_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
BILLING_PORTAL_URL = get_env("BILLING_PORTAL_URL", "http://localhost:3050")

# Environment configuration
CANTON_LEDGER_URL = os.getenv("CANTON_LEDGER_URL", "http://localhost:3975")
CANTON_LEDGER_URL = get_env("CANTON_LEDGER_URL", "http://localhost:3975")
CANTON_OAUTH_TOKEN_URL = os.getenv(
"CANTON_OAUTH_TOKEN_URL",
"http://localhost:8082/realms/AppProvider/protocol/openid-connect/token"
Expand Down
13 changes: 12 additions & 1 deletion src/canton_mcp_server/core/semantic_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,8 +420,19 @@ def search_similar_files(
]

# Filter results below minimum similarity threshold
min_score = float(get_env("MIN_SIMILARITY_THRESHOLD", "0.3"))
min_score = float(get_env("MIN_SIMILARITY_THRESHOLD", "0.15"))
Comment thread
sqhell marked this conversation as resolved.
pre_filter_count = len(relevant_resources)
relevant_resources = [r for r in relevant_resources if r.get("similarity_score", 0) >= min_score]
top_score = (1.0 - result_distances[0]) if result_distances else 0.0
logger.info(
f"📊 Similarity filter: {pre_filter_count} → {len(relevant_resources)} "
f"(threshold={min_score:.2f}, top={top_score:.3f})"
)
if pre_filter_count > 0 and len(relevant_resources) == 0:
logger.warning(
f"⚠️ All {pre_filter_count} results filtered out — top similarity {top_score:.3f} "
f"< threshold {min_score:.2f}. Consider lowering MIN_SIMILARITY_THRESHOLD."
)

# Re-read file content from disk so LLM receives actual code, not empty strings
try:
Expand Down
5 changes: 3 additions & 2 deletions src/canton_mcp_server/daml/authorization_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,8 +563,9 @@ def validate_authorization(self, auth_model: AuthorizationModel) -> bool:
# Rule 1: At least one signatory
if not auth_model.signatories:
logger.warning(
f"Authorization model invalid: {auth_model.template_name} "
"has no signatories"
f"[AUTH] Invalid auth model: template={auth_model.template_name} "
f"has no signatories (observers={auth_model.observers}, "
f"controllers={dict(auth_model.controllers)})"
)
return False

Expand Down
16 changes: 15 additions & 1 deletion src/canton_mcp_server/daml/safety_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,19 @@ async def check_pattern_safety(

# Step 3: Extract authorization model with confidence scoring
auth_extraction = self.auth_validator.extract_auth_model(code, compilation_result)
if auth_extraction.model:
logger.info(
f"[AUTH] Extracted model: template={auth_extraction.model.template_name} "
f"method={auth_extraction.method} confidence={auth_extraction.confidence:.2f} "
f"signatories={auth_extraction.model.signatories} "
f"observers={auth_extraction.model.observers} "
f"controllers={dict(auth_extraction.model.controllers)}"
)
else:
logger.info(
f"[AUTH] Extraction returned no model (method={auth_extraction.method}, "
f"confidence={auth_extraction.confidence:.2f})"
)

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

# Step 6: Final safety determination
Expand Down
3 changes: 3 additions & 0 deletions src/canton_mcp_server/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@
# Canton payment defaults
ENV_VALUES["CANTON_DEFAULT_PAYER_PARTY"] = os.getenv("CANTON_DEFAULT_PAYER_PARTY", "")

# Featured App Rewards (FeaturedAppActivityMarker emission per CIP-0047)
ENV_VALUES["FEATURED_APP_REWARDS_ENABLED"] = os.getenv("FEATURED_APP_REWARDS_ENABLED", "false")

# Isolated environment flag (also read directly via os.environ at module level)
ENV_VALUES["IS_ISOLATED_ENVIRONMENT"] = os.getenv("IS_ISOLATED_ENVIRONMENT", "false")

Expand Down
184 changes: 184 additions & 0 deletions src/canton_mcp_server/featured_app_rewards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""
Featured App Activity Marker Emission

Creates FeaturedAppActivityMarker contracts on Canton ledger by exercising
the FeaturedAppRight_CreateActivityMarker choice. This is how featured apps
earn rewards — SV automation converts markers into AppRewardCoupons for CC minting.

Flow:
1. At startup, query the ledger for the FeaturedAppRight contract (granted by DSO governance)
2. After each billable tool call, exercise FeaturedAppRight_CreateActivityMarker
3. SV automation handles marker → AppRewardCoupon → CC minting (automatic)

Non-blocking: marker creation never fails a tool call.
"""

import logging
import os
from typing import Optional

from canton_mcp_server.canton_billing import (
CANTON_PROVIDER_PARTY,
CANTON_USER_ID,
_make_ledger_request,
get_ledger_offset,
)

logger = logging.getLogger(__name__)

# Feature gate
FEATURED_APP_REWARDS_ENABLED = (
os.getenv("FEATURED_APP_REWARDS_ENABLED", "false").lower() == "true"
)

# Cached FeaturedAppRight contract
_featured_app_right_cache: dict = {
"contract_id": None,
"template_id": None,
}


async def init_featured_app_right() -> bool:
"""
Query the ledger for the FeaturedAppRight contract belonging to our provider party.
Called at startup and on contract-not-found errors.

Returns True if found, False otherwise.
"""
if not CANTON_PROVIDER_PARTY:
logger.warning("CANTON_PROVIDER_PARTY not set — cannot query FeaturedAppRight")
return False

try:
offset = await get_ledger_offset()
data = await _make_ledger_request(
"POST",
"/v2/state/active-contracts",
{
"filter": {
"filtersByParty": {
CANTON_PROVIDER_PARTY: {"cumulative": []},
},
},
"activeAtOffset": offset,
"verbose": False,
},
)

contracts = data if isinstance(data, list) else data.get("activeContracts", data.get("result", []))

for c in contracts:
Comment thread
sqhell marked this conversation as resolved.
# Canton JSON API v2 wraps contracts in contractEntry.JsActiveContract.createdEvent
ce = c.get("contractEntry", {})
ac = ce.get("JsActiveContract", {})
event = ac.get("createdEvent", {}) or c.get("createdEvent", c)
template_id = event.get("templateId", "")
if "FeaturedAppRight" in template_id:
Comment thread
sqhell marked this conversation as resolved.
contract_id = event.get("contractId", "")
_featured_app_right_cache["contract_id"] = contract_id
_featured_app_right_cache["template_id"] = template_id
logger.info(
f"FeaturedAppRight contract found: {contract_id[:40]}... "
f"(template: {template_id})"
)
return True

logger.warning(
f"FeaturedAppRight not found for {CANTON_PROVIDER_PARTY}. "
"Activity markers will not be emitted. "
"Ensure the DSO has granted FeaturedAppRight to this party."
)
return False

except Exception as e:
logger.error(f"Failed to query FeaturedAppRight: {e}")
return False


async def create_activity_marker(request_id: str) -> Optional[str]:
"""
Exercise FeaturedAppRight_CreateActivityMarker to emit an activity marker.

Args:
request_id: Unique request ID (used for command deduplication)

Returns:
Contract ID of the created marker, or None on failure.
"""
if not FEATURED_APP_REWARDS_ENABLED:
return None

contract_id = _featured_app_right_cache.get("contract_id")
template_id = _featured_app_right_cache.get("template_id")

if not contract_id or not template_id:
return None

try:
data = await _make_ledger_request(
"POST",
"/v2/commands/submit-and-wait-for-transaction",
{
"commands": {
"userId": CANTON_USER_ID,
"commandId": f"activity-marker-{request_id}",
"actAs": [CANTON_PROVIDER_PARTY],
"readAs": [CANTON_PROVIDER_PARTY],
"commands": [
{
"ExerciseCommand": {
"templateId": template_id,
"contractId": contract_id,
"choice": "FeaturedAppRight_CreateActivityMarker",
"choiceArgument": {
"beneficiaries": [
{
"beneficiary": CANTON_PROVIDER_PARTY,
"weight": "1.0",
}
],
},
}
}
],
}
},
)

# Extract marker contract IDs from the exercise result
events = data.get("transaction", {}).get("events", [])
marker_cids = []
for event in events:
created = event.get("CreatedEvent") or event.get("createdEvent", {})
if created.get("contractId") and "ActivityMarker" in created.get("templateId", ""):
marker_cids.append(created["contractId"])

if marker_cids:
logger.info(f"ActivityMarker created: {marker_cids[0][:40]}...")
return marker_cids[0]

# Even without recognizing the marker template, success means it worked
logger.info(f"FeaturedAppRight_CreateActivityMarker exercised for request {request_id}")
return "exercised"

except Exception as e:
error_str = str(e)

# Contract archived / not found — re-query and retry once
if "CONTRACT_NOT_FOUND" in error_str or "not found" in error_str.lower():
logger.warning("FeaturedAppRight contract may have been archived, re-querying...")
found = await init_featured_app_right()
if found:
try:
return await create_activity_marker(f"{request_id}-retry")
except Exception as retry_err:
logger.warning(f"ActivityMarker retry failed: {retry_err}")
return None

# Auth / permission errors — don't retry
if "403" in error_str or "PERMISSION_DENIED" in error_str:
logger.warning(f"ActivityMarker permission denied (FeaturedAppRight may have been revoked): {e}")
return None

logger.warning(f"ActivityMarker creation failed (non-fatal): {e}")
return None
43 changes: 43 additions & 0 deletions src/canton_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,20 @@
# Reduce uvicorn access log noise (only show warnings/errors)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)


# Filter out successful /health and /ready probe access logs. K8s polls these
# from multiple pods several times per second, which drowns real request logs
# in Loki. We keep errors (non-2xx) visible by only filtering INFO records.
class _ProbeLogFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
if record.levelno >= logging.WARNING:
return True
msg = record.getMessage()
return not (" /health " in msg or " /ready " in msg)


logging.getLogger("uvicorn.access").addFilter(_ProbeLogFilter())

# Global instances
payment_handler = PaymentHandler()

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

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

yield

# Shutdown
Expand Down Expand Up @@ -541,6 +570,13 @@ async def create_charge():
request_id=str(mcp_request.id),
)
logger.info(f"ChargeReceipt created (streaming): {charge_contract_id}")
# Emit FeaturedAppActivityMarker for reward tracking
try:
from canton_mcp_server.featured_app_rewards import FEATURED_APP_REWARDS_ENABLED, create_activity_marker
if FEATURED_APP_REWARDS_ENABLED:
await create_activity_marker(request_id=str(mcp_request.id))
except ImportError:
pass
except Exception as e:
logger.critical(f"BILLING INTEGRITY: Failed to create ChargeReceipt for {party_id}/{tool_name}/{price_cc}CC: {e}")
asyncio.create_task(create_charge())
Expand Down Expand Up @@ -603,6 +639,13 @@ async def create_charge():
request_id=str(mcp_request.id),
)
logger.info(f"ChargeReceipt created on-chain: {tool_name} - {price_cc} CC from {party_id} (contract: {charge_contract_id})")
# Emit FeaturedAppActivityMarker for reward tracking (fire-and-forget)
try:
from canton_mcp_server.featured_app_rewards import FEATURED_APP_REWARDS_ENABLED, create_activity_marker
if FEATURED_APP_REWARDS_ENABLED:
asyncio.create_task(create_activity_marker(request_id=str(mcp_request.id)))
except ImportError:
pass
except Exception as e:
logger.critical(f"BILLING INTEGRITY: Failed to create ChargeReceipt for {party_id}/{tool_name}/{price_cc}CC: {e}")

Expand Down
8 changes: 6 additions & 2 deletions src/canton_mcp_server/websocket_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,16 @@ async def _handle_message(self, message: dict):
logger.debug(f"💰 Balance updated for {party}: ${balance:.2f}")

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

async def _reconnect(self):
"""Reconnect to WebSocket server with exponential backoff"""
Expand Down
Loading