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
47 changes: 37 additions & 10 deletions backend/protocol_rpc/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,20 @@ async def get_contract_schema(
"Contract not deployed.",
)

contract_code = base64.b64decode(contract_account["data"]["code"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix NameError in schema decode path (blocker).

Line 825 references contract_account, which is undefined in this scope. This raises immediately and prevents both SDK extraction and GenVM fallback.

🐛 Proposed fix
-    contract_code = base64.b64decode(contract_account["data"]["code"])
+    contract_code = base64.b64decode(code_b64)
🧰 Tools
🪛 Ruff (0.15.6)

[error] 825-825: Undefined name contract_account

(F821)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/protocol_rpc/endpoints.py` at line 825, The NameError comes from
using contract_account which isn't defined in this scope; locate the line that
sets contract_code = base64.b64decode(contract_account["data"]["code"]) and
replace contract_account with the actual variable that contains the account dict
in this function (e.g., account, account_info or contract_state as used
elsewhere in this function), then add a defensive check that ["data"]["code"]
exists before decoding and wrap the decode in a try/except to log/raise a clear
error if decoding fails; reference the exact occurrence of contract_code =
base64.b64decode(...) to make this change.


# Try SDK reflection first (faster: ~50-100ms vs ~200-300ms with GenVM)
try:
from backend.services.sdk_schema import extract_schema_via_sdk

sdk_schema = extract_schema_via_sdk(contract_code)
if sdk_schema is not None:
return sdk_schema
logger.debug("SDK schema extraction returned None, falling back to GenVM")
except Exception as e:
logger.debug(f"SDK schema extraction failed: {e}, falling back to GenVM")

# Fallback to GenVM-based extraction
node = Node( # Mock node just to get the data from the GenVM
contract_snapshot=None,
validator_mode=ExecutionMode.LEADER,
Expand All @@ -841,13 +855,35 @@ async def get_contract_schema(
contract_snapshot_factory=None,
manager=genvm_manager,
)
schema = await node.get_contract_schema(base64.b64decode(code_b64))
schema = await node.get_contract_schema(contract_code)
return json.loads(schema)


async def get_contract_schema_for_code(
genvm_manager: GenVMManager, msg_handler: IMessageHandler, contract_code_hex: str
) -> dict:
# Contract code is expected to be a hex string, but it can be a plain UTF-8 string
# When hex decoding fails, fall back to UTF-8 encoding
try:
contract_code = eth_utils.hexadecimal.decode_hex(contract_code_hex)
except ValueError:
logger.debug(
"Contract code is not hex-encoded, treating as UTF-8 string",
)
contract_code = contract_code_hex.encode("utf-8")

# Try SDK reflection first (faster: ~50-100ms vs ~200-300ms with GenVM)
try:
from backend.services.sdk_schema import extract_schema_via_sdk

sdk_schema = extract_schema_via_sdk(contract_code)
if sdk_schema is not None:
return sdk_schema
logger.debug("SDK schema extraction returned None, falling back to GenVM")
except Exception as e:
logger.debug(f"SDK schema extraction failed: {e}, falling back to GenVM")

# Fallback to GenVM-based extraction
node = Node( # Mock node just to get the data from the GenVM
contract_snapshot=None,
validator_mode=ExecutionMode.LEADER,
Expand All @@ -867,15 +903,6 @@ async def get_contract_schema_for_code(
contract_snapshot_factory=None,
manager=genvm_manager,
)
# Contract code is expected to be a hex string, but it can be a plain UTF-8 string
# When hex decoding fails, fall back to UTF-8 encoding
try:
contract_code = eth_utils.hexadecimal.decode_hex(contract_code_hex)
except ValueError:
logger.debug(
"Contract code is not hex-encoded, treating as UTF-8 string",
)
contract_code = contract_code_hex.encode("utf-8")
schema = await node.get_contract_schema(contract_code)
return json.loads(schema)

Expand Down
3 changes: 3 additions & 0 deletions backend/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
# backend/services/__init__.py
from backend.services.sdk_schema import extract_schema_via_sdk

__all__ = ["extract_schema_via_sdk"]
175 changes: 175 additions & 0 deletions backend/services/sdk_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""
SDK-based contract schema extraction using Python reflection.

This module provides a faster alternative to GenVM-based schema extraction
by using the genlayer-py-std SDK's reflection capabilities directly.
Performance improvement: ~50-100ms vs ~200-300ms with GenVM.

The approach:
1. Mock _genlayer_wasi (the WASI module that provides storage/balance ops)
2. Set GENERATING_DOCS=true to enable doc-generation mode in the SDK
3. Import the contract source and find the Contract class
4. Use genlayer.py.get_schema() for reflection-based schema extraction
"""

from __future__ import annotations

import importlib.util
import logging
import os
import sys
import tempfile
import time
from pathlib import Path
from unittest.mock import MagicMock

logger = logging.getLogger(__name__)

# SDK paths relative to GENVMROOT
_SDK_SRC_SUBPATH = "runners/genlayer-py-std/src"
_SDK_EMB_SUBPATH = "runners/genlayer-py-std/src-emb"


def _get_sdk_paths() -> tuple[Path, Path] | None:
"""Get SDK paths from GENVMROOT environment variable."""
genvmroot = os.environ.get("GENVMROOT")
if not genvmroot:
logger.debug("GENVMROOT not set, SDK schema extraction unavailable")
return None

root = Path(genvmroot)
sdk_src = root / _SDK_SRC_SUBPATH
sdk_emb = root / _SDK_EMB_SUBPATH

# In Docker, GenVM is extracted to /genvm but SDK paths may not exist
# Fall back to checking if the executor contains the SDK
if not sdk_src.exists():
# Try alternate location in executor
tag = os.environ.get("GENVM_TAG", "")
alt_root = root / "executor" / tag
sdk_src_alt = alt_root / "runners" / "genlayer-py-std" / "src"
if sdk_src_alt.exists():
sdk_src = sdk_src_alt
sdk_emb = alt_root / "runners" / "genlayer-py-std" / "src-emb"

if not sdk_src.exists():
logger.debug(f"SDK source path not found: {sdk_src}")
return None

return sdk_src, sdk_emb


def _setup_wasi_mocks() -> None:
"""Mock the _genlayer_wasi module that provides WASI bindings."""
if "_genlayer_wasi" in sys.modules:
return # Already mocked

wasi_mock = MagicMock()
wasi_mock.storage_read = MagicMock(return_value=None)
wasi_mock.storage_write = MagicMock(return_value=None)
wasi_mock.get_balance = MagicMock(return_value=0)
wasi_mock.get_self_balance = MagicMock(return_value=0)
wasi_mock.gl_call = MagicMock(return_value=0)
sys.modules["_genlayer_wasi"] = wasi_mock


def extract_schema_via_sdk(contract_code: bytes) -> dict | None:
"""
Extract contract schema using SDK reflection.

Args:
contract_code: Contract source code as bytes (UTF-8 encoded Python)

Returns:
Schema dict if successful, None if SDK extraction failed.
Caller should fall back to GenVM-based extraction on None.

Performance: ~50-100ms vs ~200-300ms with GenVM
"""
sdk_paths = _get_sdk_paths()
if sdk_paths is None:
return None

sdk_src, sdk_emb = sdk_paths
start_time = time.time()

try:
# Setup environment
_setup_wasi_mocks()
os.environ["GENERATING_DOCS"] = "true"
Comment on lines +96 to +99
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

GENERATING_DOCS environment variable is never restored.

The environment variable is set but not cleaned up after extraction. This could affect subsequent operations in the same process that depend on this variable being unset.

🐛 Proposed fix
     try:
         # Setup environment
         _setup_wasi_mocks()
+        original_generating_docs = os.environ.get("GENERATING_DOCS")
         os.environ["GENERATING_DOCS"] = "true"

         # Add SDK paths to sys.path temporarily
         original_path = sys.path.copy()
         sys.path.insert(0, str(sdk_src))
         if sdk_emb.exists():
             sys.path.insert(0, str(sdk_emb))

         try:
             # ... existing code ...
         finally:
             # Restore sys.path
             sys.path = original_path
+            # Restore GENERATING_DOCS
+            if original_generating_docs is None:
+                os.environ.pop("GENERATING_DOCS", None)
+            else:
+                os.environ["GENERATING_DOCS"] = original_generating_docs
🤖 Prompt for AI Agents
In `@backend/services/sdk_schema.py` around lines 146 - 149, The GENERATING_DOCS
env var is set to "true" in the try block but never restored; preserve the
previous value before setting it, and restore or delete it in a finally block
after the extraction completes. Specifically, around the code that calls
_setup_wasi_mocks() and sets os.environ["GENERATING_DOCS"] = "true", capture
prev = os.environ.get("GENERATING_DOCS"), set the var for the extraction work,
and in a finally ensure you either del os.environ["GENERATING_DOCS"] if prev is
None or reset os.environ["GENERATING_DOCS"] = prev to avoid leaking the flag to
subsequent operations.


# Add SDK paths to sys.path temporarily
original_path = sys.path.copy()
sys.path.insert(0, str(sdk_src))
if sdk_emb.exists():
sys.path.insert(0, str(sdk_emb))

try:
# Import the schema extraction function
from genlayer.py.get_schema import get_schema

# Write contract to temp file and load it as a module
with tempfile.NamedTemporaryFile(
mode="wb", suffix=".py", delete=False
) as f:
f.write(contract_code)
temp_path = f.name

try:
# Load contract module
spec = importlib.util.spec_from_file_location(
"__sdk_contract__", temp_path
)
if spec is None or spec.loader is None:
logger.debug("Failed to create module spec for contract")
return None

module = importlib.util.module_from_spec(spec)
sys.modules["__sdk_contract__"] = module
spec.loader.exec_module(module)

# Find the Contract class
contract_class = None
for name, obj in vars(module).items():
if isinstance(obj, type) and name != "Contract":
# Check if it inherits from Contract
bases = [b.__name__ for b in obj.__mro__]
if "Contract" in bases:
contract_class = obj
break

if contract_class is None:
logger.debug("No Contract class found in module")
return None

# Extract schema using SDK reflection
schema = get_schema(contract_class)

elapsed_ms = int((time.time() - start_time) * 1000)
logger.info(
f"SDK schema extraction succeeded in {elapsed_ms}ms "
f"for {contract_class.__name__}"
)

return schema

finally:
# Cleanup temp file
try:
os.unlink(temp_path)
except Exception:
pass
# Remove from sys.modules
sys.modules.pop("__sdk_contract__", None)

finally:
# Restore sys.path
sys.path = original_path
Comment on lines +96 to +167
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Thread safety concern with global state modifications.

This function modifies global state (sys.path, sys.modules, os.environ) which could cause race conditions if called concurrently from multiple threads. While there's a cache lock for the cache itself, the extraction logic is not protected.

Consider adding a lock around the entire extraction process or documenting that concurrent extractions of different contracts may interfere with each other.

🔒️ Proposed fix - add extraction lock

Add a module-level lock:

_extraction_lock = threading.Lock()

Then wrap the extraction in the lock:

     sdk_src, sdk_emb = sdk_paths
     start_time = time.time()

-    try:
+    with _extraction_lock:
+      try:
         # Setup environment
         _setup_wasi_mocks()
         # ... rest of the try block ...
+      except Exception as e:
+          # ... exception handling ...
🧰 Tools
🪛 Ruff (0.14.14)

213-214: try-except-pass detected, consider logging the exception

(S110)


213-213: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In `@backend/services/sdk_schema.py` around lines 146 - 220, The extraction
modifies global process-wide state (sys.path, sys.modules, os.environ) and must
be serialized: add a module-level threading.Lock named _extraction_lock and
acquire it around the whole extraction sequence that begins with calling
_setup_wasi_mocks() and ends after restoring sys.path and removing
"__sdk_contract__" from sys.modules (i.e., wrap the try/.../finally that
contains get_schema, temp file handling, and cleanup) so concurrent calls can't
interleave; ensure the lock is always released in a finally block and does not
change existing error handling or the calls to get_schema, _cache_schema, or
other referenced symbols.


except Exception as e:
elapsed_ms = int((time.time() - start_time) * 1000)
logger.debug(
f"SDK schema extraction failed after {elapsed_ms}ms: {e}",
exc_info=True,
)
return None
Comment on lines +76 to +175
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing clear_cache export breaks imports (CI failure).

The pipeline error indicates clear_cache is imported from this module but no longer exists. Either remove those imports or provide a compatibility no‑op to keep the API stable.

🩹 Suggested compatibility shim
+def clear_cache() -> None:
+    """No-op: cache removed, retained for backward compatibility."""
+    return None
🧰 Tools
🪛 Ruff (0.14.14)

160-161: try-except-pass detected, consider logging the exception

(S110)


160-160: Do not catch blind exception: Exception

(BLE001)


169-169: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In `@backend/services/sdk_schema.py` around lines 76 - 175, The module no longer
exports clear_cache which breaks callers importing it; add a compatibility shim
by defining a module-level function named clear_cache() that performs a no-op
(or delegates to any existing cache-clear helper like
_get_sdk_paths/_setup_wasi_mocks if appropriate) and ensure it is exported from
the module so imports succeed without changing callers; place the clear_cache
definition near extract_schema_via_sdk and any other helpers so it’s
discoverable and update __all__ if present.

66 changes: 66 additions & 0 deletions tests/unit/test_sdk_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Tests for SDK-based contract schema extraction."""

import pytest
import os
from unittest.mock import patch, MagicMock
from backend.services.sdk_schema import (
extract_schema_via_sdk,
_get_sdk_paths,
)


class TestSdkSchemaExtraction:
"""Test cases for SDK schema extraction."""

def test_get_sdk_paths_without_genvmroot(self):
"""Test that _get_sdk_paths returns None when GENVMROOT is not set."""
with patch.dict(os.environ, {}, clear=True):
# Remove GENVMROOT if present
os.environ.pop("GENVMROOT", None)
result = _get_sdk_paths()
assert result is None

def test_get_sdk_paths_with_invalid_genvmroot(self, tmp_path):
"""Test that _get_sdk_paths returns None when paths don't exist."""
with patch.dict(os.environ, {"GENVMROOT": str(tmp_path)}):
result = _get_sdk_paths()
assert result is None

def test_extract_schema_returns_none_without_sdk(self):
"""Test that extraction returns None when SDK is not available."""
with patch.dict(os.environ, {}, clear=True):
os.environ.pop("GENVMROOT", None)
result = extract_schema_via_sdk(b"some contract code")
assert result is None


class TestSdkSchemaIntegration:
"""Integration tests that require the SDK to be available."""

@pytest.fixture
def has_sdk(self):
"""Check if SDK is available for testing."""
paths = _get_sdk_paths()
if paths is None:
pytest.skip("SDK not available (GENVMROOT not set or paths don't exist)")
return paths

def test_extract_simple_contract(self, has_sdk):
"""Test extraction of a simple contract (requires SDK)."""
simple_contract = b"""from genlayer import *

class SimpleContract(Contract):
def __init__(self):
pass

@public
def hello(self) -> str:
return "Hello"
"""
result = extract_schema_via_sdk(simple_contract)

# Should return a schema or None (if SDK import fails)
# We can't assert the exact schema without the full SDK setup
if result is not None:
assert "ctor" in result
assert "methods" in result
Loading