Skip to content

Commit f6ba6cd

Browse files
authoredDec 27, 2024··
Merge pull request #1 from officialnico/nico/world_interactions
Created World and Player classes
2 parents 12b1db7 + 6254aa7 commit f6ba6cd

9 files changed

+1451
-481
lines changed
 

‎examples/cafecosmos-contracts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit f02134dddda929c655c02e3b6c46e7fd1503d52b

‎examples/env.example

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
PLAYER1=PRIVATE_KEY1
2+
PLAYER2=PRIVATE_KEY2

‎examples/mud.config.ts

-423
This file was deleted.

‎examples/player.ipynb

+882
Large diffs are not rendered by default.

‎examples/querying_tables.ipynb

+349-31
Large diffs are not rendered by default.

‎mud/MUDIndexerSDK.py

+24-26
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import Any, Dict, List, TypedDict, Type
12
import re
23
import requests
34

@@ -23,7 +24,7 @@ def parse_mud_config(file_path: str):
2324

2425

2526
class BaseTable:
26-
RESERVED_SQL_KEYWORDS = {"exists", "from", "values", "limit", "index"} # Add more keywords if needed
27+
RESERVED_SQL_KEYWORDS = {"exists", "from", "values", "limit", "index"}
2728

2829
def __init__(self, sdk, table_name, schema, keys):
2930
self.sdk = sdk
@@ -46,25 +47,10 @@ def get(self, limit=1000, **filters):
4647
Returns:
4748
List[Dict[str, Any]]: A list of records from the table.
4849
"""
49-
# Determine the columns to select based on whether the keys are filtered
50-
if any(key in filters for key in self.keys):
51-
# Exclude keys from SELECT if they are filtered
52-
select_columns = ", ".join(
53-
self._escape_column_name(col)
54-
for col in self.schema.keys()
55-
if col not in self.keys
56-
)
57-
else:
58-
# Include all columns if no keys are filtered
59-
select_columns = ", ".join(
60-
self._escape_column_name(col) for col in self.schema.keys()
61-
)
62-
63-
# Construct the WHERE clause
50+
select_columns = ", ".join(self.schema.keys())
6451
where_clause = " AND ".join(
6552
f"{self._escape_column_name(key)}={repr(value)}" for key, value in filters.items()
6653
)
67-
6854
query = f"SELECT {select_columns} FROM {self.table_name}"
6955
if where_clause:
7056
query += f" WHERE {where_clause}"
@@ -77,24 +63,40 @@ def get(self, limit=1000, **filters):
7763
def _parse_response(self, response):
7864
if "result" not in response or not response["result"]:
7965
return None # Return None if there are no results
80-
8166
results = response["result"][0]
8267
if not results: # Check if the results array is empty
8368
return None
84-
8569
headers, *rows = results
8670
return [dict(zip(headers, row)) for row in rows]
8771

8872

8973
class TableRegistry:
9074
def __init__(self, sdk):
9175
self.sdk = sdk
76+
self.SOLIDITY_TO_PYTHON_TYPE = self._generate_solidity_to_python_type_map()
9277

93-
def register_table(self, table_name, schema, keys):
78+
@staticmethod
79+
def _generate_solidity_to_python_type_map():
9480
"""
95-
Dynamically create and register a table as an attribute of the registry.
81+
Generate a mapping of all Solidity integer types to Python int.
9682
"""
97-
table_class = type(table_name, (BaseTable,), {})
83+
solidity_types = {}
84+
for bits in range(8, 257, 8):
85+
solidity_types[f"int{bits}"] = int
86+
solidity_types[f"uint{bits}"] = int
87+
solidity_types.update({"bool": bool, "address": str, "string": str, "bytes32": bytes, "bytes": bytes})
88+
return solidity_types
89+
90+
def register_table(self, table_name, schema, keys):
91+
schema_typed_dict = TypedDict(
92+
f"{table_name}Schema",
93+
{k: self.SOLIDITY_TO_PYTHON_TYPE.get(v, Any) for k, v in schema.items()}
94+
)
95+
96+
def get(self, limit: int = 1000, **filters: schema_typed_dict) -> List[schema_typed_dict]:
97+
return super(type(self), self).get(limit=limit, **filters)
98+
99+
table_class = type(table_name, (BaseTable,), {"get": get})
98100
table_instance = table_class(self.sdk, table_name, schema, keys)
99101
setattr(self, table_name, table_instance)
100102

@@ -104,8 +106,6 @@ def __init__(self, indexer_url, world_address, mud_config_path):
104106
self.indexer_url = indexer_url
105107
self.world_address = world_address
106108
self.tables = TableRegistry(self)
107-
108-
# Parse and register tables from the configuration file
109109
self._parsed_tables = parse_mud_config(mud_config_path)
110110
for table_name, table_info in self._parsed_tables.items():
111111
self.tables.register_table(table_name, table_info["schema"], table_info["key"])
@@ -117,6 +117,4 @@ def post(self, payload):
117117
return response.json()
118118

119119
def get_table_names(self):
120-
"""Return a list of all table names from the parsed configuration."""
121120
return list(self._parsed_tables.keys())
122-

‎mud/Player.py

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from .World import World
2+
from web3 import Web3
3+
from dotenv import load_dotenv
4+
import os
5+
6+
# Load environment variables from a .env file
7+
load_dotenv()
8+
9+
class Player:
10+
def __init__(self, private_key: str = None, env_key_name: str = None):
11+
"""
12+
Initialize the Player instance.
13+
Either `private_key` or `env_key_name` must be provided.
14+
15+
Args:
16+
private_key (str): The private key for the player (optional).
17+
env_key_name (str): The name of the environment variable holding the private key (optional).
18+
19+
Raises:
20+
ValueError: If neither `private_key` nor `env_key_name` is provided.
21+
ValueError: If `env_key_name` is provided but the environment variable is not set.
22+
"""
23+
if private_key:
24+
self.private_key = private_key
25+
elif env_key_name:
26+
self.private_key = os.getenv(env_key_name)
27+
if not self.private_key:
28+
raise ValueError(
29+
f"Environment variable '{env_key_name}' is not set or contains an invalid value. "
30+
"Please set it in your .env file or provide a private key directly."
31+
)
32+
else:
33+
raise ValueError(
34+
"Initialization failed: You must provide either a `private_key` or an `env_key_name`. "
35+
"For example: Player(private_key='0xYourPrivateKey') or Player(env_key_name='PLAYER1')."
36+
)
37+
38+
self.private_key = self.private_key if self.private_key.startswith('0x') else '0x' + self.private_key
39+
self.player_address = self._derive_address(self.private_key)
40+
self.worlds = {} # Dictionary to manage multiple worlds
41+
42+
def add_world(self, world: World, world_name: str):
43+
"""
44+
Add a world to the player and assign it a dynamic name.
45+
46+
Args:
47+
world (World): An instance of the World class.
48+
world_name (str): The name to assign to the world for dynamic access.
49+
"""
50+
if not isinstance(world, World):
51+
raise TypeError("The `world` parameter must be an instance of the World class.")
52+
self.worlds[world_name] = world
53+
setattr(self, world_name, world)
54+
55+
def _derive_address(self, private_key):
56+
"""Derive the Ethereum address from the private key."""
57+
account = Web3().eth.account.from_key(private_key)
58+
return account.address

‎mud/World.py

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from web3 import Web3
2+
import json
3+
from pathlib import Path
4+
from .MUDIndexerSDK import MUDIndexerSDK
5+
6+
def find_abi_files(root_dir):
7+
"""Recursively find all ABI files"""
8+
abi_files = []
9+
root_path = Path(root_dir)
10+
abi_patterns = ["*.abi.json", "*.json"]
11+
for pattern in abi_patterns:
12+
abi_files.extend(root_path.rglob(pattern))
13+
return abi_files
14+
15+
def load_abis(root_dir) -> dict:
16+
"""Load all ABI files from directory structure"""
17+
abis = {}
18+
for abi_file in find_abi_files(root_dir):
19+
try:
20+
with open(abi_file, 'r') as f:
21+
abi_data = json.load(f)
22+
23+
if isinstance(abi_data, dict):
24+
if 'abi' in abi_data:
25+
abi_data = abi_data['abi']
26+
elif 'contracts' in abi_data:
27+
for contract_name, contract_data in abi_data['contracts'].items():
28+
if 'abi' in contract_data:
29+
abis[contract_name] = contract_data['abi']
30+
continue
31+
32+
contract_name = abi_file.parent.name if abi_file.name == 'abi.json' else abi_file.stem.replace('.abi', '')
33+
abis[contract_name] = abi_data
34+
35+
except Exception as e:
36+
print(f"Error processing {abi_file}: {e}")
37+
38+
return abis
39+
40+
class World:
41+
def __init__(self, rpc, world_address, abis_dir, indexer_url=None, mud_config_path=None):
42+
"""
43+
Initialize the World instance.
44+
45+
Args:
46+
rpc (str): RPC endpoint URL.
47+
world_address (str): The address of the World contract.
48+
abis_dir (str): Directory containing ABI files.
49+
indexer_url (str, optional): URL for the indexer. If provided, initializes the indexer.
50+
mud_config_path (str, optional): Path to the mud.config.ts file. Required if indexer_url is provided.
51+
"""
52+
self.w3 = Web3(Web3.HTTPProvider(rpc))
53+
self.chain_id = self.w3.eth.chain_id # Automatically fetch the chain ID
54+
self.abis = load_abis(abis_dir)
55+
self.indexer = None
56+
57+
# Initialize the contract
58+
if "IWorld" in self.abis:
59+
self.contract = self.w3.eth.contract(address=world_address, abi=self.abis["IWorld"])
60+
self.errors = self._extract_all_errors()
61+
62+
for func_name in dir(self.contract.functions):
63+
if not func_name.startswith("_"):
64+
original_function = getattr(self.contract.functions, func_name)
65+
setattr(self, func_name, self._wrap_function(original_function, func_name))
66+
else:
67+
raise Exception("IWorld ABI not found")
68+
69+
# Automatically set up the indexer if parameters are provided
70+
if indexer_url and mud_config_path:
71+
self._initialize_indexer(indexer_url, world_address, mud_config_path)
72+
73+
def _initialize_indexer(self, indexer_url, world_address, mud_config_path):
74+
"""
75+
Initialize and set the indexer.
76+
77+
Args:
78+
indexer_url (str): URL for the indexer.
79+
world_address (str): The address of the World contract.
80+
mud_config_path (str): Path to the mud.config.ts file.
81+
"""
82+
from mud import MUDIndexerSDK # Assuming MUDIndexerSDK is part of your mud package
83+
84+
# Create the indexer
85+
indexer = MUDIndexerSDK(indexer_url, world_address, mud_config_path)
86+
self.set_indexer(indexer)
87+
88+
def set_indexer(self, indexer):
89+
"""
90+
Set the indexer instance and expose its tables.
91+
92+
Args:
93+
indexer (MUDIndexerSDK): The indexer instance.
94+
"""
95+
self.indexer = indexer
96+
97+
# Expose tables as attributes directly under world.indexer
98+
for table_name in indexer.get_table_names():
99+
table_instance = getattr(indexer.tables, table_name)
100+
setattr(self.indexer, table_name, table_instance)
101+
102+
def _extract_all_errors(self):
103+
errors = {}
104+
for contract_name, abi in self.abis.items():
105+
if not isinstance(abi, list):
106+
continue
107+
108+
for item in abi:
109+
if item.get('type') == 'error':
110+
signature = f"{item['name']}({','.join(inp['type'] for inp in item.get('inputs', []))})"
111+
selector = self.w3.keccak(text=signature)[:4].hex()
112+
errors[selector] = (contract_name, item['name'])
113+
return errors
114+
115+
def _wrap_function(self, contract_function, func_name):
116+
def wrapped_function(*args, **kwargs):
117+
try:
118+
return contract_function(*args, **kwargs).call()
119+
except Exception as e:
120+
error_str = str(e)
121+
if '0x' in error_str:
122+
import re
123+
hex_match = re.search(r'0x[a-fA-F0-9]+', error_str)
124+
if hex_match:
125+
selector = hex_match.group(0)[2:10]
126+
if selector in self.errors:
127+
contract, error = self.errors[selector]
128+
error_msg = f"{error} when calling {func_name}"
129+
new_error = type(e)((error_msg,))
130+
raise new_error from None
131+
raise e
132+
return wrapped_function

‎mud/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from .World import World
2+
from .Player import Player
13
from .MUDIndexerSDK import MUDIndexerSDK
24

3-
__all__ = ["MUDIndexerSDK"] # Optional, restricts what is accessible via `from mud import *`
5+
__all__ = ["World", "Player", "MUDIndexerSDK"]

0 commit comments

Comments
 (0)
Please sign in to comment.