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
20 changes: 18 additions & 2 deletions lume/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,17 @@
Outcome,
RelayResponse,
)
from lume.utils import from_atomic, generate_wallet, to_atomic
from lume.utils import (
DEFAULT_KEYSTORE_PATH,
decrypt_private_key,
encrypt_private_key,
from_atomic,
generate_wallet,
keystore_exists,
load_keystore,
save_keystore,
to_atomic,
)
from lume.websocket import GraphQLWebSocketClient

__version__ = "0.1.0"
Expand Down Expand Up @@ -127,9 +137,15 @@
"SettlementBatch",
"SubscriptionManager",
# Utils
"DEFAULT_KEYSTORE_PATH",
"decrypt_private_key",
"encrypt_private_key",
"from_atomic",
"generate_wallet",
"keystore_exists",
"load_keystore",
"save_keystore",
"to_atomic",
"from_atomic",
# Types
"Event",
"Market",
Expand Down
92 changes: 87 additions & 5 deletions lume/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,24 @@

from __future__ import annotations

import getpass
from dataclasses import dataclass
from typing import Any, Optional

from eth_account import Account

from lume.client import LumeClient
from lume.constants import SIGNATURE_TYPE_POLY_GNOSIS_SAFE, get_config_with_env_overrides
from lume.exceptions import GraphQLError
from lume.utils import (
DEFAULT_KEYSTORE_PATH,
decrypt_private_key,
encrypt_private_key,
generate_wallet,
keystore_exists,
load_keystore,
save_keystore,
)


class AgentRegistrationError(GraphQLError):
Expand Down Expand Up @@ -326,38 +338,108 @@ def verify_authentication(self) -> bool:
return False


def _resolve_private_key_interactive(keystore_path: str) -> str:
"""Resolve a private key interactively via keystore.

If a keystore file exists at *keystore_path*, the user is prompted for the
password (up to 3 attempts). Otherwise a new wallet is generated, the user
is asked to create a password (with confirmation), and the encrypted
keystore is persisted.

Returns:
Hex private key **without** the ``0x`` prefix.

Raises:
ValueError: After 3 failed password attempts or mismatched confirmation.
"""

if keystore_exists(keystore_path):
keystore = load_keystore(keystore_path)
for attempt in range(1, 4):
password = getpass.getpass(
f"Enter keystore password ({attempt}/3): "
)
try:
private_key = decrypt_private_key(keystore, password)
address = Account.from_key(private_key).address
print(f"Wallet loaded: {address}")
print(f"Keystore: {keystore_path}")
return private_key
except ValueError:
if attempt < 3:
print("Incorrect password, try again.")
raise ValueError("Failed to decrypt keystore after 3 attempts")

# No existing keystore — generate a new wallet
private_key, address = generate_wallet()

password = getpass.getpass("Create keystore password: ")
password_confirm = getpass.getpass("Confirm keystore password: ")
if password != password_confirm:
raise ValueError("Passwords do not match")

keystore = encrypt_private_key(private_key, password)
saved_path = save_keystore(keystore, keystore_path)
print(f"New wallet generated: {address}")
print(f"Keystore saved: {saved_path}")
return private_key
Comment on lines +341 to +385
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Normalize keystore load failures to a consistent ValueError.

Right now, corrupt JSON or permission issues bubble as JSONDecodeError/PermissionError, while docs mention ValueError. Consider wrapping keystore load failures to produce a consistent error.

🛠️ Suggested adjustment
-    if keystore_exists(keystore_path):
-        keystore = load_keystore(keystore_path)
+    if keystore_exists(keystore_path):
+        try:
+            keystore = load_keystore(keystore_path)
+            if not keystore:
+                raise ValueError("Keystore file is empty or unreadable")
+        except Exception as exc:
+            raise ValueError(
+                f"Unable to load keystore at {keystore_path}"
+            ) from exc
         for attempt in range(1, 4):
             password = getpass.getpass(
                 f"Enter keystore password ({attempt}/3): "
             )
🤖 Prompt for AI Agents
In `@lume/agent.py` around lines 341 - 385, The keystore loading path in
_resolve_private_key_interactive currently lets JSONDecodeError/PermissionError
bubble up; wrap the call to load_keystore(keystore_path) in a try/except that
catches JSONDecodeError, PermissionError, OSError (or Exception) and re-raise a
ValueError with a clear message (e.g., "Failed to load keystore: <detail>") so
callers always see ValueError as documented; keep the rest of the existing
decryption/password retry flow unchanged and include the original exception text
in the new ValueError for debugging.



def create_agent_from_env(
agent_id: Optional[str] = None,
display_name: Optional[str] = None,
auto_register: bool = True,
keystore_path: Optional[str] = None,
use_keystore: bool = True,
) -> AgentClient:
"""Create an AgentClient from environment variables.

Reads configuration from environment:
- LUME_PRIVATE_KEY or PRIVATE_KEY: Required wallet private key
- LUME_PRIVATE_KEY or PRIVATE_KEY: Wallet private key
- LUME_API_URL: Optional API URL override
- LUME_AGENT_ID: Optional agent ID (overridden by agent_id param)
- LUME_AGENT_NAME: Optional display name (overridden by display_name param)
- LUME_KEYSTORE_PATH: Optional custom keystore file path

**Priority chain** for resolving the private key:

1. ``LUME_PRIVATE_KEY`` / ``PRIVATE_KEY`` environment variable
2. Encrypted keystore file (if *use_keystore* is ``True``)
3. Generate a new wallet and save to keystore

Args:
agent_id: Agent identifier (defaults to LUME_AGENT_ID env var)
display_name: Display name (defaults to LUME_AGENT_NAME env var)
auto_register: Whether to register automatically
keystore_path: Path to keystore JSON file (defaults to
``LUME_KEYSTORE_PATH`` env var or ``~/.lume/keystore.json``)
use_keystore: If ``True`` (default) and no env-var key is present,
interactively resolve the key via keystore. If ``False``,
a missing env var raises ``ValueError``.

Returns:
Configured AgentClient instance

Raises:
ValueError: If PRIVATE_KEY is not set
ValueError: If no private key can be resolved
AgentRegistrationError: If auto_register is True and registration fails
"""
import os

private_key = os.getenv("LUME_PRIVATE_KEY") or os.getenv("PRIVATE_KEY")

if not private_key:
raise ValueError(
"LUME_PRIVATE_KEY or PRIVATE_KEY environment variable is required"
)
if use_keystore:
resolved_path = (
keystore_path
or os.getenv("LUME_KEYSTORE_PATH")
or DEFAULT_KEYSTORE_PATH
)
private_key = _resolve_private_key_interactive(resolved_path)
else:
raise ValueError(
"LUME_PRIVATE_KEY or PRIVATE_KEY environment variable is required"
)
Comment on lines 388 to +442
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Expand ~ and env vars in the keystore path.

LUME_KEYSTORE_PATH commonly uses ~, but os.path.isfile won’t expand it. Normalize the path before passing it into _resolve_private_key_interactive.

🛠️ Suggested adjustment
         if use_keystore:
             resolved_path = (
                 keystore_path
                 or os.getenv("LUME_KEYSTORE_PATH")
                 or DEFAULT_KEYSTORE_PATH
             )
+            resolved_path = os.path.expanduser(os.path.expandvars(resolved_path))
             private_key = _resolve_private_key_interactive(resolved_path)
🤖 Prompt for AI Agents
In `@lume/agent.py` around lines 388 - 442, When resolving the keystore path in
create_agent_from_env before calling _resolve_private_key_interactive, expand
user (~) and environment variables so paths like
LUME_KEYSTORE_PATH="~/.lume/keystore.json" work; normalize the chosen
resolved_path (keystore_path or os.getenv("LUME_KEYSTORE_PATH") or
DEFAULT_KEYSTORE_PATH) with os.path.expanduser and os.path.expandvars (and
optionally os.path.abspath) and pass that expanded path into
_resolve_private_key_interactive.


api_url = os.getenv("LUME_API_URL")
resolved_agent_id = agent_id or os.getenv("LUME_AGENT_ID")
Expand Down
89 changes: 89 additions & 0 deletions lume/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@

from __future__ import annotations

import json
import os
from decimal import Decimal, ROUND_DOWN
from pathlib import Path

from eth_account import Account

DEFAULT_KEYSTORE_DIR: str = os.path.join(str(Path.home()), ".lume")
DEFAULT_KEYSTORE_PATH: str = os.path.join(DEFAULT_KEYSTORE_DIR, "keystore.json")


def generate_wallet() -> tuple[str, str]:
"""Generate a new Ethereum private key and address.
Expand Down Expand Up @@ -51,3 +57,86 @@ def from_atomic(amount: int) -> Decimal:
Decimal value (e.g. ``Decimal('1.500000')``).
"""
return Decimal(amount) / Decimal("1000000")


def encrypt_private_key(private_key: str, password: str) -> dict:
"""Encrypt a private key using Ethereum keystore v3 format.

Args:
private_key: Hex private key (with or without 0x prefix).
password: Password to encrypt with.

Returns:
Keystore v3 JSON-compatible dict.
"""
if not private_key.startswith("0x"):
private_key = "0x" + private_key
return Account.encrypt(private_key, password)


def decrypt_private_key(keystore: dict, password: str) -> str:
"""Decrypt a private key from an Ethereum keystore v3 dict.

Args:
keystore: Keystore v3 dict (as returned by :func:`encrypt_private_key`).
password: Password used during encryption.

Returns:
Hex private key *without* the ``0x`` prefix.

Raises:
ValueError: If the password is incorrect.
"""
try:
raw = Account.decrypt(keystore, password)
return raw.hex()
except Exception as exc:
raise ValueError("Incorrect password or corrupted keystore") from exc


def save_keystore(keystore: dict, path: str = DEFAULT_KEYSTORE_PATH) -> str:
"""Persist a keystore dict to disk with restrictive permissions.

Creates the parent directory (mode 0o700) if it does not exist and
writes the file with mode 0o600 so only the current user can read it.

Args:
keystore: Keystore v3 dict.
path: Destination file path.

Returns:
The absolute path where the keystore was saved.
"""
directory = os.path.dirname(path)
os.makedirs(directory, mode=0o700, exist_ok=True)
with open(path, "w") as f:
json.dump(keystore, f, indent=2)
os.chmod(path, 0o600)
return os.path.abspath(path)
Comment on lines +97 to +115
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Harden keystore saving for empty directories and restrictive perms at creation.

If path is just a filename (no directory), os.path.dirname(path) returns "" and os.makedirs("", ...) fails. Also, the file is created with default umask before chmod, briefly widening permissions. Consider skipping directory creation when empty and creating the file with mode 0o600 up-front.

🛠️ Proposed fix
 def save_keystore(keystore: dict, path: str = DEFAULT_KEYSTORE_PATH) -> str:
@@
-    directory = os.path.dirname(path)
-    os.makedirs(directory, mode=0o700, exist_ok=True)
-    with open(path, "w") as f:
-        json.dump(keystore, f, indent=2)
-    os.chmod(path, 0o600)
+    directory = os.path.dirname(path)
+    if directory:
+        os.makedirs(directory, mode=0o700, exist_ok=True)
+    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+    with os.fdopen(fd, "w") as f:
+        json.dump(keystore, f, indent=2)
     return os.path.abspath(path)
🤖 Prompt for AI Agents
In `@lume/utils.py` around lines 97 - 115, In save_keystore, handle the case where
os.path.dirname(path) is empty (skip os.makedirs) and avoid a brief window of
permissive permissions by creating/truncating the file with mode 0o600 up-front
instead of relying on chmod afterwards: if directory := os.path.dirname(path) is
non-empty, call os.makedirs(directory, mode=0o700, exist_ok=True); then open the
file using a low-level open that accepts a mode (e.g., os.open with
os.O_WRONLY|os.O_CREAT|os.O_TRUNC and mode=0o600) and wrap the resulting fd with
os.fdopen to json.dump the keystore; keep returning os.path.abspath(path).
Ensure references to DEFAULT_KEYSTORE_PATH and the function save_keystore remain
intact.



def load_keystore(path: str = DEFAULT_KEYSTORE_PATH) -> dict | None:
"""Load a keystore dict from disk.

Args:
path: Path to the keystore JSON file.

Returns:
The parsed keystore dict, or ``None`` if the file does not exist.
"""
if not os.path.isfile(path):
return None
with open(path) as f:
return json.load(f)


def keystore_exists(path: str = DEFAULT_KEYSTORE_PATH) -> bool:
"""Check whether a keystore file exists at *path*.

Args:
path: Path to check.

Returns:
``True`` if the file exists, ``False`` otherwise.
"""
return os.path.isfile(path)
67 changes: 43 additions & 24 deletions skills/lume-trading/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,50 +66,69 @@ You can override individual settings with environment variables:
`LUME_ENV`, `LUME_API_URL`, `LUME_CHAIN_ID`, `LUME_CTF_EXCHANGE_ADDRESS`,
`LUME_NEGRISK_EXCHANGE_ADDRESS`, `LUME_FEE_RATE_BPS`.

### Initialize the agent
### Initialize the agent — IMPORTANT: Follow this procedure

For autonomous agents, use `AgentClient` (extends `LumeClient` with
registration and authentication):
Before ANY trading operation, you MUST resolve the agent's private key. Follow
this priority chain **in order** — stop at the first one that succeeds:

```python
import os
from lume import AgentClient
#### Priority 1: Encrypted keystore (highest)

agent = AgentClient(private_key=os.environ["PRIVATE_KEY"])
info = agent.register()
Check if `~/.lume/keystore.json` exists (or `LUME_KEYSTORE_PATH` env var).
Since `getpass` does not work in non-interactive shells, you MUST ask the user
for their password using `AskUserQuestion`, then decrypt programmatically:

print(f"EOA Address: {agent.eoa_address}")
print(f"Proxy Wallet: {agent.proxy_wallet}")
print(f"Registered: {info.is_registered}")
```python
from lume import keystore_exists, load_keystore, decrypt_private_key

if keystore_exists():
# keystore found — ASK the user for their password via AskUserQuestion,
# then decrypt:
keystore = load_keystore()
private_key = decrypt_private_key(keystore, password) # password from user
```

Or use the environment helper:
If decryption fails (`ValueError`), tell the user the password was wrong and
ask again (up to 3 attempts).

```python
from lume import create_agent_from_env
#### Priority 2: Environment variable

If no keystore exists, check `LUME_PRIVATE_KEY` or `PRIVATE_KEY`:

# Reads LUME_PRIVATE_KEY (or PRIVATE_KEY), LUME_API_URL, LUME_AGENT_ID, LUME_AGENT_NAME
agent = create_agent_from_env(agent_id="my-bot", display_name="My Trading Bot")
```python
import os
private_key = os.getenv("LUME_PRIVATE_KEY") or os.getenv("PRIVATE_KEY")
```

You can also use `LumeClient` directly if you don't need agent registration:
#### Priority 3: Generate new wallet

If neither keystore nor env var is available, generate a new wallet, ask the
user to create a password (with confirmation), encrypt, and save:

```python
from lume import LumeClient
from lume import generate_wallet, encrypt_private_key, save_keystore

client = LumeClient(private_key=os.environ["PRIVATE_KEY"])
private_key, address = generate_wallet()
# ASK user to provide a password via AskUserQuestion
keystore = encrypt_private_key(private_key, password)
save_keystore(keystore)
```

### Generate a new wallet (if needed)
#### Create the AgentClient

Once you have the `private_key`, create the agent directly:

```python
from lume import generate_wallet
from lume import AgentClient

private_key, address = generate_wallet()
print(f"Address: {address}")
# Save private_key securely
agent = AgentClient(private_key=private_key, auto_register=True)
print(f"EOA Address: {agent.eoa_address}")
print(f"Proxy Wallet: {agent.proxy_wallet}")
```

> **Note:** Do NOT use `create_agent_from_env()` from within this skill because
> its `getpass` prompts fail in non-interactive shells. Always resolve the key
> yourself using the procedure above, then pass it to `AgentClient` directly.
Comment on lines +69 to +130
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Align the snippet with LUME_KEYSTORE_PATH support.

Line 76 says LUME_KEYSTORE_PATH is honored, but the snippet uses keystore_exists() / load_keystore() without passing the resolved path, so the env var won’t be used. Consider wiring the path into the snippet.

✏️ Suggested doc fix
-from lume import keystore_exists, load_keystore, decrypt_private_key
+import os
+from lume import (
+    DEFAULT_KEYSTORE_PATH,
+    keystore_exists,
+    load_keystore,
+    decrypt_private_key,
+)
+
+keystore_path = os.getenv("LUME_KEYSTORE_PATH") or DEFAULT_KEYSTORE_PATH

-if keystore_exists():
+if keystore_exists(keystore_path):
     # keystore found — ASK the user for their password via AskUserQuestion,
     # then decrypt:
-    keystore = load_keystore()
+    keystore = load_keystore(keystore_path)
     private_key = decrypt_private_key(keystore, password)  # password from user
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Initialize the agent — IMPORTANT: Follow this procedure
For autonomous agents, use `AgentClient` (extends `LumeClient` with
registration and authentication):
Before ANY trading operation, you MUST resolve the agent's private key. Follow
this priority chain **in order** — stop at the first one that succeeds:
```python
import os
from lume import AgentClient
#### Priority 1: Encrypted keystore (highest)
agent = AgentClient(private_key=os.environ["PRIVATE_KEY"])
info = agent.register()
Check if `~/.lume/keystore.json` exists (or `LUME_KEYSTORE_PATH` env var).
Since `getpass` does not work in non-interactive shells, you MUST ask the user
for their password using `AskUserQuestion`, then decrypt programmatically:
print(f"EOA Address: {agent.eoa_address}")
print(f"Proxy Wallet: {agent.proxy_wallet}")
print(f"Registered: {info.is_registered}")
```python
from lume import keystore_exists, load_keystore, decrypt_private_key
if keystore_exists():
# keystore found — ASK the user for their password via AskUserQuestion,
# then decrypt:
keystore = load_keystore()
private_key = decrypt_private_key(keystore, password) # password from user
```
Or use the environment helper:
If decryption fails (`ValueError`), tell the user the password was wrong and
ask again (up to 3 attempts).
```python
from lume import create_agent_from_env
#### Priority 2: Environment variable
If no keystore exists, check `LUME_PRIVATE_KEY` or `PRIVATE_KEY`:
# Reads LUME_PRIVATE_KEY (or PRIVATE_KEY), LUME_API_URL, LUME_AGENT_ID, LUME_AGENT_NAME
agent = create_agent_from_env(agent_id="my-bot", display_name="My Trading Bot")
```python
import os
private_key = os.getenv("LUME_PRIVATE_KEY") or os.getenv("PRIVATE_KEY")
```
You can also use `LumeClient` directly if you don't need agent registration:
#### Priority 3: Generate new wallet
If neither keystore nor env var is available, generate a new wallet, ask the
user to create a password (with confirmation), encrypt, and save:
```python
from lume import LumeClient
from lume import generate_wallet, encrypt_private_key, save_keystore
client = LumeClient(private_key=os.environ["PRIVATE_KEY"])
private_key, address = generate_wallet()
# ASK user to provide a password via AskUserQuestion
keystore = encrypt_private_key(private_key, password)
save_keystore(keystore)
```
### Generate a new wallet (if needed)
#### Create the AgentClient
Once you have the `private_key`, create the agent directly:
```python
from lume import generate_wallet
from lume import AgentClient
private_key, address = generate_wallet()
print(f"Address: {address}")
# Save private_key securely
agent = AgentClient(private_key=private_key, auto_register=True)
print(f"EOA Address: {agent.eoa_address}")
print(f"Proxy Wallet: {agent.proxy_wallet}")
```
> **Note:** Do NOT use `create_agent_from_env()` from within this skill because
> its `getpass` prompts fail in non-interactive shells. Always resolve the key
> yourself using the procedure above, then pass it to `AgentClient` directly.
import os
from lume import (
DEFAULT_KEYSTORE_PATH,
keystore_exists,
load_keystore,
decrypt_private_key,
)
keystore_path = os.getenv("LUME_KEYSTORE_PATH") or DEFAULT_KEYSTORE_PATH
if keystore_exists(keystore_path):
# keystore found — ASK the user for their password via AskUserQuestion,
# then decrypt:
keystore = load_keystore(keystore_path)
private_key = decrypt_private_key(keystore, password) # password from user
🤖 Prompt for AI Agents
In `@skills/lume-trading/SKILL.md` around lines 69 - 130, The snippet claims to
honor LUME_KEYSTORE_PATH but calls keystore_exists() and load_keystore() without
a path; update the flow to first resolve keystore_path =
os.getenv("LUME_KEYSTORE_PATH") (fallback to default), then pass keystore_path
into keystore_exists(keystore_path), load_keystore(keystore_path), and
save_keystore(keystore_path) so the env var is actually used; do the same for
decrypt_private_key/encrypt_private_key usage and keep AskUserQuestion,
generate_wallet, and AgentClient usage unchanged.


## Key Concepts

### Price format: 1e6 atomic units
Expand Down
Loading