GenLayer Studio: 8 Things That Break and How to Fix Them
Real code from CryptoOracle, WeatherOracle, SocialOracle, and MultiKeyVault — four deployed contracts built for the GenLayer Builder Program.
GenLayer's documentation tells you how Intelligent Contracts work. It doesn't tell you what silently breaks, what doesn't persist, and what will kill your deploy without a useful error message.
I built four production-ready contracts and hit all of it. This is the complete record — every bug, every pattern, every fix — with real code from the actual contracts.
What I Built
| Contract | Data Source | Write Methods | Read Methods |
|---|---|---|---|
| CryptoOracle | Binance API (10 assets) | 4 | 9 |
| WeatherOracle | Open-Meteo (13 cities) | 6 | 9 |
| SocialOracle | Hacker News Firebase (3 feeds) | 5 | 7 |
| MultiKeyVault | N/A — secret storage | 13 | 11 |
All contracts: github.com/Manablaq/genlayer-contracts
Lesson 1: web.get() Silently Stores Nothing
This is the bug that will waste the most hours for new GenLayer developers, because it produces no error.
# Looks correct. Compiles. Deploys. Finalizes. Stores nothing.
response = gl.nondet.web.get(url)
self.store = response.body # Always an empty string on Studio
The transaction succeeds. The Studio UI shows finalized. Your state is empty.
The fix: Use web.render() inside a closure, wrapped in prompt_comparative.
Here's the real fetch pattern from CryptoOracle:
@gl.public.write
def fetch_price(self, symbol: str) -> typing.Any:
url = f"https://api.binance.com/api/v3/ticker/24hr?symbol={symbol}"
def fetch() -> str:
try:
raw = gl.nondet.web.render(url, mode="text")
if not raw or raw.strip() == "null":
return json.dumps({"error": "Binance API is down.", "status": "unavailable"})
data = json.loads(raw)
price = float(data["lastPrice"])
return json.dumps({"symbol": symbol, "price": price, "status": "ok"}, sort_keys=True)
except Exception:
return json.dumps({"error": "Binance API is down.", "status": "unavailable"})
fresh = gl.eq_principle.prompt_comparative(
fetch,
"The outputs represent the same crypto price. "
"They are equivalent if both show an API error, "
"or if price values are within 2% and symbol matches."
)
all_data = json.loads(self.store)
all_data[symbol] = json.loads(fresh)
self.store = json.dumps(all_data, sort_keys=True)
Rule: web.render() only. Never web.get() with response.body.
Lesson 2: Your Equivalence String Is Not Boilerplate
The second argument to prompt_comparative is not a placeholder you copy-paste. It's a real design decision.
Validators independently fetch the same data and must reach consensus on whether their results are "equivalent." The string you write tells them how strict to be.
In CryptoOracle, prices can differ slightly between validator calls due to market movement:
gl.eq_principle.prompt_comparative(
fetch,
"They are equivalent if price values are within 2% and symbol matches."
)
In WeatherOracle, the tolerances are calibrated to sensor precision:
gl.eq_principle.prompt_comparative(
fetch,
"They are equivalent if temperature is within 1 degree, "
"humidity within 5%, and wind speed within 3 km/h of each other."
)
For the forecast method in the same contract, a looser threshold applies because forecast data is less real-time:
gl.eq_principle.prompt_comparative(
fetch,
"They are equivalent if temperatures are within 2 degrees and dates match exactly."
)
In SocialOracle, news feeds are volatile — stories change by the minute — so the threshold is count-based, and differs per feed:
# Trending (stable): requires 3 matching titles
gl.eq_principle.prompt_comparative(
fetch,
"They are equivalent if they share at least 3 of the same story titles."
)
# Latest (volatile): only requires 2 matches
gl.eq_principle.prompt_comparative(
fetch,
"They are equivalent if they share at least 2 of the same story titles or IDs."
)
Rule: Write your equivalence string to match the volatility and precision of your actual data source. Too tight and consensus fails on valid data. Too loose and bad data passes.
Lesson 3: TreeMap State Does Not Persist on Studio
If you store data in a TreeMap, it will work in local testing and silently vanish after every transaction on Studio.
# Dangerous — state is lost after each transaction on Studio
class CryptoOracle(gl.Contract):
prices: TreeMap[str, str]
Use str for all state fields and serialize with json.dumps:
# What all 4 of my contracts use
class CryptoOracle(gl.Contract):
store: str # JSON string — persists correctly on Studio
pulse: str # Metadata / freshness tracking
def __init__(self):
self.store = "{}"
self.pulse = "{}"
Serialize everything:
# Write
all_data[symbol] = json.loads(fresh)
self.store = json.dumps(all_data, sort_keys=True)
# Read
all_data = json.loads(self.store)
entry = all_data.get(symbol)
Note the sort_keys=True. This ensures all validators produce byte-identical JSON during consensus. Without it, key ordering can differ between Python dicts and consensus can fail on what is actually the same data.
Lesson 4: Booleans and Integers Are Strings
Because all state must be str, booleans have to be stored as "true" and "false" strings. From MultiKeyVault:
class MultiKeyVault(gl.Contract):
is_paused: str # "true" or "false" — NOT a bool
rate_limit: str # "100" — NOT an int
key_version: str # "1", "2", "3" — NOT an int
def __init__(self, owner_address: str):
self.is_paused = "false"
self.rate_limit = "100"
self.key_version = "1"
@gl.public.write
def pause(self, owner_address: str) -> typing.Any:
assert self.is_paused == "false", "Vault is already paused."
self.is_paused = "true"
# Incrementing an integer stored as string
self.key_version = str(int(self.key_version) + 1)
Rule: Every state field is str. Cast everything explicitly.
Lesson 5: Never Use sender_address in __init__
Using gl.message.sender_address inside __init__ causes a runtime error at deploy time. The contract won't deploy.
# Breaks on deploy
def __init__(self):
self.owner = gl.message.sender_address # Runtime error
Pass the address as a constructor parameter instead. From MultiKeyVault:
def __init__(self, owner_address: str):
assert is_valid_address(owner_address), "Invalid owner address. Must start with 0x and be 42 characters long."
self.owner = owner_address
self.allowed_callers = json.dumps([owner_address])
When deploying on Studio, enter your wallet address as the owner_address parameter manually.
Lesson 6: Module-Level Functions Work — Use Them
You can define helper functions outside your contract class and call them from inside closures. This is useful when multiple write methods share the same fetch logic.
SocialOracle and WeatherOracle both use this pattern:
# Defined outside the class — called from inside closures
def _pull_stories(feed: str, limit: int = 10) -> str:
try:
ids_raw = gl.nondet.web.render(
f"https://hacker-news.firebaseio.com/v0/{feed}stories.json",
mode="text"
)
ids = json.loads(ids_raw)
items = []
for story_id in ids[:limit]:
raw = gl.nondet.web.render(
f"https://hacker-news.firebaseio.com/v0/item/{story_id}.json",
mode="text"
)
s = json.loads(raw)
items.append({"id": s.get("id"), "title": s.get("title", "No title"), ...})
return json.dumps({"feed": feed, "items": items, "status": "ok"}, sort_keys=True)
except Exception:
return json.dumps({"error": "Hacker News API is down.", "status": "unavailable"})
class SocialOracle(gl.Contract):
@gl.public.write
def fetch_trending(self) -> typing.Any:
def fetch() -> str:
return _pull_stories("top", 10) # Calls the module-level function
fresh = gl.eq_principle.prompt_comparative(fetch, "...")
Note that _pull_stories makes multiple web.render() calls — once for IDs, then once per story item in a loop. All of this happens inside a single prompt_comparative closure. Multiple web requests per closure are fine.
Lesson 7: Structure Your AI Prompts to Return Specific JSON
When using exec_prompt for AI analysis, always define an exact JSON response schema in the prompt. This makes output parseable and makes the equivalence check precise.
From WeatherOracle's generate_alert:
def analyze() -> str:
prompt = (
f"Analyze this weather data for {city} and determine if conditions are dangerous:\n\n"
f"{json.dumps(weather)}\n\n"
f"Consider: temperature extremes, high winds above 60 km/h, "
f"heavy rain above 10mm, severe weathercodes (95-99).\n\n"
f"Respond ONLY with JSON:\n"
f'{{\"city\": \"{city}\", '
f'\"alert_level\": \"safe\" or \"caution\" or \"danger\", '
f'\"reason\": \"one sentence explanation\", '
f'\"recommendation\": \"one sentence advice\"}}'
)
return gl.nondet.exec_prompt(prompt)
alert = gl.eq_principle.prompt_comparative(
analyze,
"Both outputs assess the same weather conditions. "
"They are equivalent if they assign the same alert level."
)
data[city]["alert"] = json.loads(alert)
The equivalence string only checks alert_level. Wording of reason and recommendation can differ between validators — and that's intentional. Consensus only needs to agree on the key decision, not the exact phrasing.
Lesson 8: Closures in Loops — A Subtle Bug Risk
Both CryptoOracle's fetch_prices and WeatherOracle's fetch_multiple fetch data for multiple items in a loop, defining a fetch closure inside each iteration:
@gl.public.write
def fetch_prices(self, symbols: list) -> typing.Any:
for symbol in symbols:
url = f"https://api.binance.com/api/v3/ticker/24hr?symbol={symbol}"
info = SUPPORTED[symbol]
def fetch() -> str:
raw = gl.nondet.web.render(url, mode="text") # Which url? Which info?
# ...
fresh = gl.eq_principle.prompt_comparative(fetch, "...")
In standard Python, this is a classic closure-in-loop bug: the fetch function captures the variable url and info, not their values at the time of definition. By the time fetch is called, those variables may have been overwritten by the next loop iteration.
In practice, GenLayer Studio called prompt_comparative immediately within the same iteration before the loop advanced — so this worked. But it is not safe to rely on. The correct pattern is to use default argument binding to capture values explicitly:
for symbol in symbols:
url = f"https://api.binance.com/api/v3/ticker/24hr?symbol={symbol}"
info = SUPPORTED[symbol]
def fetch(u=url, i=info) -> str: # Capture values at definition time
raw = gl.nondet.web.render(u, mode="text")
# ...
fresh = gl.eq_principle.prompt_comparative(fetch, "...")
Rule: When defining closures inside loops, always use default argument binding (def fetch(u=url)) to capture the current value, not the variable reference.
The Decision Table
| Situation | Use This | Not This |
|---|---|---|
| Fetch web data | web.render(mode="text") |
web.get() + response.body
|
| Persistent state |
str fields + json.dumps
|
TreeMap |
| JSON serialization | json.dumps(..., sort_keys=True) |
json.dumps() without sort |
| Web + AI consensus | prompt_comparative |
run_nondet_unsafe |
| Booleans in state |
"true" / "false" strings |
bool type |
| Integers in state |
str(int) — "100", "1"
|
int type |
| Get caller address | Constructor parameter |
gl.message.sender_address in __init__
|
| Validate in constructor | assert |
gl.vm.UserError |
| Validate in write methods |
gl.vm.UserError or assert
|
— |
| Shared fetch logic | Module-level function | Duplicated closures |
| AI output | Prompt for specific JSON schema | Free-form text |
| Closures in loops | Default argument binding (u=url) |
Bare variable capture |
Known Limitations (Not Your Bug)
| Limitation | Impact | Workaround |
|---|---|---|
web.get() + response.body returns empty |
Silent data loss | Use web.render()
|
TreeMap state does not persist on Studio |
State lost after each transaction | Use str + JSON |
run_nondet_unsafe fails with web fetching |
Cannot combine with web calls | Use prompt_comparative
|
| Private APIs incompatible with consensus | Auth-required APIs fail at consensus | Use public APIs only |
sender_address in __init__ causes deploy failure |
Contract won't deploy | Pass address as constructor parameter |
| Leader rotation shows more than 5 validators | Looks alarming in logs | Normal behavior — not a bug |
The Contracts
All four contracts are open source with full READMEs and testing guides:
👉 github.com/Manablaq/genlayer-contracts
Built for the GenLayer Builder Program — Tools & Infrastructure track.
Top comments (0)