diff --git a/examples/read-only-auth/.gitignore b/examples/read-only-auth/.gitignore new file mode 100644 index 0000000..94a2dd1 --- /dev/null +++ b/examples/read-only-auth/.gitignore @@ -0,0 +1 @@ +*.json \ No newline at end of file diff --git a/examples/read-only-auth/README.md b/examples/read-only-auth/README.md new file mode 100644 index 0000000..90d3208 --- /dev/null +++ b/examples/read-only-auth/README.md @@ -0,0 +1,168 @@ +# Read-Only Auth Token Pre-Generation + +This example demonstrates how to pre-generate authentication tokens for read-only operations on the Lighter platform. By generating tokens ahead of time, you can avoid needing access to your API private keys during runtime for read-only queries. + +## Overview + +Authentication tokens on Lighter have a maximum expiry of 8 hours. This example allows you to: + +1. Configure a dedicated API key (index 253) for all your accounts +2. Pre-generate authentication tokens for future time periods +3. Use these tokens for read-only operations without exposing your private keys + +The tokens are generated at 6-hour intervals (aligned to Unix timestamp // 6 hours), with each token valid for 8 hours. This provides an overlap period ensuring continuous coverage. + +## Setup + +The setup script configures API key 253 for all accounts associated with your Ethereum private key. + +### Running Setup + +```bash +cd examples/read-only-auth +python3 setup.py config.json +``` + +This will: +- Query all accounts for your L1 address +- Generate new API key pairs for each account +- Change API key 253 to use the new keys +- Output configuration in JSON format + +### Configuration Variables + +Edit the constants in `setup.py`: + +```python +BASE_URL = "https://testnet.zklighter.elliot.ai" +ETH_PRIVATE_KEY = "your_ethereum_private_key_here" +API_KEY_INDEX = 253 # Using 253 as it's typically unused +``` + +### Config Format + +```json +{ + "BASE_URL": "https://testnet.zklighter.elliot.ai", + "ACCOUNTS": [ + { + "api_key_private_key": "...", + "account_index": 0, + "api_key_index": 253 + }, + { + "api_key_private_key": "...", + "account_index": 1, + "api_key_index": 253 + } + ] +} +``` + +## Generating Tokens + +The generation script creates authentication tokens for future time periods. + +### Running Generation + +```bash +NUM_DAYS=10 python3 generate.py config.json +``` + +If no config file is specified, it defaults to `config.json`. + +### Duration Configuration + +You can specify the duration in days using the `NUM_DAYS` environment variable, as in the command above. +If the value is not specified, it defaults to 28 days. + +### Output Format + +The script generates `auth-tokens.json`: + +```json +{ + "0": { + "1697184000": "auth_token_string_1", + "1697205600": "auth_token_string_2", + "1697227200": "auth_token_string_3" + }, + "1": { + "1697184000": "auth_token_string_1", + "1697205600": "auth_token_string_2" + } +} +``` + +Where: +- First level key: account index +- Second level key: Unix timestamp (aligned to 6-hour boundaries) +- Value: authentication token + +## Usage + +### Looking Up Tokens + +Check the `get_auth_token.py` script which prints the Auth Token that should be used **at this moment**, as this will be invalidated in at most 8 hours. + +### Time Alignment + +All timestamps are aligned to 6-hour boundaries: +- Timestamps are divisible by 21600 seconds (6 hours) +- Calculation: `unix_timestamp // (6 * 3600) * (6 * 3600)` +- This ensures consistent token lookup across different systems + +### Token Expiry + +Each token is valid for 8 hours from its timestamp: +- Token timestamp: aligned to 6-hour boundary +- Valid until: timestamp + 8 hours +- This provides 2 hours of overlap between consecutive tokens + +## Security + +### API Key 253 + +We use API key index 253 because: +- It's the last available index [0-253] +- It's not typically used by trading +- Easy to remember for this specific use case +- Easy to change and invalidate all tokens. + +### Invalidating Tokens + +To invalidate all existing tokens: + +```bash +python3 setup.py config.json +``` + +Re-running the setup script generates new API keys for index 253, which invalidates all previously generated authentication tokens. This is useful if: +- You suspect your tokens have been compromised +- You want to rotate your tokens periodically +- You need to revoke access immediately + +### Best Practices + +1. **Store tokens securely**: The `auth-tokens.json` file contains sensitive data (read only, but still) +2. **Dedicated API key**: Use API key 253 for read-only token generation, as it can be invalidated easely. + + +## Troubleshooting + +### "Account not found" error + +Make sure your Ethereum private key corresponds to an account registered on the Lighter platform. + +### "Failed to change API key" error + +This could happen if: +- The API key change transaction failed +- Network connectivity issues +- The account is not active + +## Additional Notes + +- Tokens are specific to each account index +- Each account has its own set of time-aligned tokens +- The system uses the SignerClient's native `create_auth_token_with_expiry` method diff --git a/examples/read-only-auth/generate.py b/examples/read-only-auth/generate.py new file mode 100644 index 0000000..bd5c407 --- /dev/null +++ b/examples/read-only-auth/generate.py @@ -0,0 +1,103 @@ +import asyncio +import json +import logging +import os +import time +import sys +import lighter + +logging.basicConfig(level=logging.INFO, force=True) + + +def create_auth_token_for_timestamp(signer_client, timestamp, expiry_hours): + auth_token, error = signer_client.create_auth_token_with_expiry(expiry_hours * 3600, timestamp=timestamp) + if error is not None: + raise Exception(f"Failed to create auth token: {error}") + return auth_token + + +async def generate_tokens_for_account(account_info, base_url, duration_days): + account_index = account_info["account_index"] + api_key_private_key = account_info["api_key_private_key"] + api_key_index = account_info["api_key_index"] + + logging.info(f"Generating tokens for account {account_index}") + + signer_client = lighter.SignerClient( + url=base_url, + private_key=api_key_private_key, + account_index=account_index, + api_key_index=api_key_index, + ) + + current_time = int(time.time()) + interval_seconds = 6 * 3600 + start_timestamp = (current_time // interval_seconds) * interval_seconds + + num_tokens = 4 * duration_days + expiry_hours = 8 + + tokens = {} + for i in range(num_tokens): + timestamp = start_timestamp + (i * interval_seconds) + try: + auth_token = create_auth_token_for_timestamp(signer_client, timestamp, expiry_hours) + tokens[str(timestamp)] = auth_token + logging.debug(f"Generated token for timestamp {timestamp}") + except Exception as e: + logging.error(f"Failed to generate token for timestamp {timestamp}: {e}") + + await signer_client.close() + + return account_index, tokens + + +async def main(): + config_file = "config.json" + if len(sys.argv) > 1: + config_file = sys.argv[1] + + try: + with open(config_file, "r") as f: + config = json.load(f) + except FileNotFoundError: + logging.error(f"Config file '{config_file}' not found") + logging.error("Run setup.py first: python3 setup.py > config.json") + sys.exit(1) + except json.JSONDecodeError as e: + logging.error(f"Invalid JSON in config file: {e}") + sys.exit(1) + + num_days = int(os.getenv("NUM_DAYS") or 28) + base_url = config.get("BASE_URL") + accounts = config.get("ACCOUNTS", []) + duration_days = config.get("DURATION_IN_DAYS", num_days) + + if not base_url: + logging.error("BASE_URL not found in config") + sys.exit(1) + + if not accounts: + logging.error("No accounts found in config") + sys.exit(1) + + logging.info(f"Generating tokens for {len(accounts)} account(s)") + logging.info(f"Duration: {duration_days} days ({4 * duration_days} tokens per account)") + + auth_tokens = {} + for account_info in accounts: + account_index, tokens = await generate_tokens_for_account(account_info, base_url, duration_days) + auth_tokens[str(account_index)] = tokens + + output_file = "auth-tokens.json" + with open(output_file, "w") as f: + json.dump(auth_tokens, f, indent=2) + + logging.info(f"Successfully generated tokens and saved to {output_file}") + logging.info(f"Total accounts: {len(auth_tokens)}") + for account_index, tokens in auth_tokens.items(): + logging.info(f" Account {account_index}: {len(tokens)} tokens") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/read-only-auth/get_auth_token.py b/examples/read-only-auth/get_auth_token.py new file mode 100644 index 0000000..e12522a --- /dev/null +++ b/examples/read-only-auth/get_auth_token.py @@ -0,0 +1,30 @@ +import json +import logging +import sys +import time + +logging.basicConfig(level=logging.INFO, force=True) + + +def main(): + if len(sys.argv) == 1: + logging.error("No account index specified") + return + + account_index = sys.argv[1] + + # Load pre-generated tokens + with open('auth-tokens.json') as f: + auth_tokens = json.load(f) + + # Get current aligned timestamp (6-hour boundary) + current_timestamp = (int(time.time()) // (6 * 3600)) * (6 * 3600) + + # Look up token for specific account + auth_token = auth_tokens[account_index][str(current_timestamp)] + + print(f"{auth_token=}") + + +if __name__ == "__main__": + main() diff --git a/examples/read-only-auth/setup.py b/examples/read-only-auth/setup.py new file mode 100644 index 0000000..31790ad --- /dev/null +++ b/examples/read-only-auth/setup.py @@ -0,0 +1,113 @@ +import asyncio +import json +import logging +import sys +import time +import eth_account +import lighter + +logging.basicConfig(level=logging.INFO, force=True) + +# use https://mainnet.zklighter.elliot.ai for mainnet +BASE_URL = "https://testnet.zklighter.elliot.ai" +ETH_PRIVATE_KEY = "1234567812345678123456781234567812345678123456781234567812345678" +API_KEY_INDEX = 253 + + +async def setup_account(eth_private_key, account_index, base_url, api_key_index): + private_key, public_key, err = lighter.create_api_key() + if err is not None: + return None, f"Failed to create API key for account {account_index}: {err}" + + tx_client = lighter.SignerClient( + url=base_url, + private_key=private_key, + account_index=account_index, + api_key_index=api_key_index, + ) + + response, err = await tx_client.change_api_key( + eth_private_key=eth_private_key, + new_pubkey=public_key, + ) + if err is not None: + await tx_client.close() + return None, f"Failed to change API key for account {account_index}: {err}" + + time.sleep(5) + + err = tx_client.check_client() + if err is not None: + await tx_client.close() + return None, f"Failed to verify API key for account {account_index}: {err}" + + await tx_client.close() + + return { + "api_key_private_key": private_key, + "account_index": account_index, + "api_key_index": api_key_index, + }, None + + +async def main(): + config_file = "config.json" + if len(sys.argv) > 1: + config_file = sys.argv[1] + + api_client = lighter.ApiClient(configuration=lighter.Configuration(host=BASE_URL)) + eth_acc = eth_account.Account.from_key(ETH_PRIVATE_KEY) + eth_address = eth_acc.address + + try: + response = await lighter.AccountApi(api_client).accounts_by_l1_address( + l1_address=eth_address + ) + except lighter.ApiException as e: + if e.data.message == "account not found": + print(f"error: account not found for {eth_address}", file=__import__('sys').stderr) + await api_client.close() + return + else: + await api_client.close() + raise e + + if len(response.sub_accounts) == 0: + print(f"error: no accounts found for {eth_address}", file=__import__('sys').stderr) + await api_client.close() + return + + logging.info(f"Found {len(response.sub_accounts)} account(s)") + + # don't do this async + accounts = [] + for sub_account in response.sub_accounts: + logging.info(f"Setting up account index: {sub_account.index}") + result, err = await setup_account( + ETH_PRIVATE_KEY, + sub_account.index, + BASE_URL, + API_KEY_INDEX, + ) + + if err is not None: + logging.error(err) + else: + accounts.append(result) + + if not accounts: + print("error: failed to setup any accounts", file=__import__('sys').stderr) + await api_client.close() + return + + with open(config_file, "w", encoding="utf-8") as f: + json.dump({ + "BASE_URL": BASE_URL, + "ACCOUNTS": accounts, + }, f, ensure_ascii=False, indent=2) + + await api_client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/lighter/signer_client.py b/lighter/signer_client.py index ca464a7..7812da5 100644 --- a/lighter/signer_client.py +++ b/lighter/signer_client.py @@ -575,12 +575,15 @@ def sign_update_leverage(self, market_index, fraction, margin_mode, nonce=-1): error = result.err.decode("utf-8") if result.err else None return tx_info, error - def create_auth_token_with_expiry(self, deadline: int = DEFAULT_10_MIN_AUTH_EXPIRY): + def create_auth_token_with_expiry(self, deadline: int = DEFAULT_10_MIN_AUTH_EXPIRY, *, timestamp: int = None): if deadline == SignerClient.DEFAULT_10_MIN_AUTH_EXPIRY: - deadline = int(time.time() + 10 * SignerClient.MINUTE) + deadline = 10 * SignerClient.MINUTE + if timestamp is None: + timestamp = int(time.time()) + self.signer.CreateAuthToken.argtypes = [ctypes.c_longlong] self.signer.CreateAuthToken.restype = StrOrErr - result = self.signer.CreateAuthToken(deadline) + result = self.signer.CreateAuthToken(timestamp + deadline) auth = result.str.decode("utf-8") if result.str else None error = result.err.decode("utf-8") if result.err else None