diff --git a/.env.example b/.env.example index 970216e11..7706e62ba 100644 --- a/.env.example +++ b/.env.example @@ -86,9 +86,9 @@ IONETAPIKEY='' ######################################## # API Rate Limiting Configuration RATE_LIMIT_ENABLED='false' # Enable/disable API key rate limiting -RATE_LIMIT_ANON_PER_MINUTE='10' # Anonymous (no API key) per-minute limit -RATE_LIMIT_ANON_PER_HOUR='100' # Anonymous per-hour limit -RATE_LIMIT_ANON_PER_DAY='1000' # Anonymous per-day limit +RATE_LIMIT_ANON_PER_MINUTE='30' # Anonymous (no API key) per-minute limit +RATE_LIMIT_ANON_PER_HOUR='500' # Anonymous per-hour limit +RATE_LIMIT_ANON_PER_DAY='5000' # Anonymous per-day limit ######################################## # Usage Metrics Configuration (Optional) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 9a5f8f86d..bc221d360 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -1802,7 +1802,6 @@ def _build_timeout_receipt(validator_dict: dict) -> Receipt: mode=ExecutionMode.VALIDATOR, contract_state={}, node_config=validator_dict, - eq_outputs={}, execution_result=ExecutionResultStatus.ERROR, vote=None, genvm_result={ @@ -1835,7 +1834,6 @@ def _build_internal_error_receipt( mode=ExecutionMode.VALIDATOR, contract_state={}, node_config=validator_dict, - eq_outputs={}, execution_result=ExecutionResultStatus.ERROR, vote=Vote.TIMEOUT, genvm_result={ diff --git a/backend/database_handler/migration/versions/c3d7f2a8b104_double_free_tier_rate_limits.py b/backend/database_handler/migration/versions/c3d7f2a8b104_double_free_tier_rate_limits.py new file mode 100644 index 000000000..dcf77369f --- /dev/null +++ b/backend/database_handler/migration/versions/c3d7f2a8b104_double_free_tier_rate_limits.py @@ -0,0 +1,42 @@ +"""double free tier rate limits + +Revision ID: c3d7f2a8b104 +Revises: b1c3e5f7a902 +Create Date: 2026-03-16 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "c3d7f2a8b104" +down_revision: Union[str, None] = "b1c3e5f7a902" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + UPDATE api_tiers + SET rate_limit_minute = 60, + rate_limit_hour = 1000, + rate_limit_day = 10000 + WHERE name = 'free' + """ + ) + + +def downgrade() -> None: + op.execute( + """ + UPDATE api_tiers + SET rate_limit_minute = 30, + rate_limit_hour = 500, + rate_limit_day = 5000 + WHERE name = 'free' + """ + ) diff --git a/backend/node/base.py b/backend/node/base.py index ab4fb8c0d..cf47f9a70 100644 --- a/backend/node/base.py +++ b/backend/node/base.py @@ -933,7 +933,7 @@ async def _run_genvm( self.timing_callback("GENVM_PREPARATION_START") leader_res: None | dict[int, bytes] - if self.leader_receipt is None: + if self.leader_receipt is None or not self.leader_receipt.eq_outputs: leader_res = None else: leader_res = { @@ -1046,10 +1046,14 @@ async def _run_genvm( result = Receipt( result=genvmbase.encode_result_to_bytes(result.result), gas_used=0, - eq_outputs={ - k: base64.b64encode(v).decode("ascii") - for k, v in result.eq_outputs.items() - }, + eq_outputs=( + { + k: base64.b64encode(v).decode("ascii") + for k, v in result.eq_outputs.items() + } + if self.validator_mode == ExecutionMode.LEADER + else None + ), pending_transactions=result.pending_transactions, vote=None, execution_result=result_exec_code, diff --git a/backend/node/types.py b/backend/node/types.py index 89613e365..5427779f9 100644 --- a/backend/node/types.py +++ b/backend/node/types.py @@ -201,8 +201,8 @@ class Receipt: mode: ExecutionMode contract_state: dict[str, str] node_config: dict - eq_outputs: dict[int, str] execution_result: ExecutionResultStatus + eq_outputs: dict[int, str] | None = None vote: Optional[Vote] = None pending_transactions: Iterable[PendingTransaction] = () genvm_result: dict[str, str] | None = None @@ -229,7 +229,7 @@ def to_dict(self, strip_contract_state: bool = False): "mode": self.mode.value, "contract_state": {} if strip_contract_state else self.contract_state, "node_config": self.node_config, - "eq_outputs": self.eq_outputs, + **({"eq_outputs": self.eq_outputs} if self.eq_outputs is not None else {}), "pending_transactions": [ pending_transaction.to_dict() for pending_transaction in self.pending_transactions @@ -254,7 +254,11 @@ def from_dict(cls, input: dict) -> Optional["Receipt"]: mode=ExecutionMode.from_string(input.get("mode")), contract_state=input.get("contract_state"), node_config=input.get("node_config"), - eq_outputs={int(k): v for k, v in input.get("eq_outputs", {}).items()}, + eq_outputs=( + {int(k): v for k, v in raw_eq.items()} + if (raw_eq := input.get("eq_outputs")) is not None + else None + ), pending_transactions=[ PendingTransaction.from_dict(pending_transaction) for pending_transaction in input.get("pending_transactions", []) diff --git a/docker-compose.yml b/docker-compose.yml index eef7a037c..e77bf9740 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,9 +116,9 @@ services: - LOG_LEVEL=${LOG_LEVEL:-info} - GENVMROOT=/genvm - RATE_LIMIT_ENABLED=${RATE_LIMIT_ENABLED:-false} - - RATE_LIMIT_ANON_PER_MINUTE=${RATE_LIMIT_ANON_PER_MINUTE:-10} - - RATE_LIMIT_ANON_PER_HOUR=${RATE_LIMIT_ANON_PER_HOUR:-100} - - RATE_LIMIT_ANON_PER_DAY=${RATE_LIMIT_ANON_PER_DAY:-1000} + - RATE_LIMIT_ANON_PER_MINUTE=${RATE_LIMIT_ANON_PER_MINUTE:-30} + - RATE_LIMIT_ANON_PER_HOUR=${RATE_LIMIT_ANON_PER_HOUR:-500} + - RATE_LIMIT_ANON_PER_DAY=${RATE_LIMIT_ANON_PER_DAY:-5000} ports: - "${RPCPORT}:${RPCPORT}" expose: diff --git a/frontend/src/components/Simulator/settings/ApiKeySection.vue b/frontend/src/components/Simulator/settings/ApiKeySection.vue new file mode 100644 index 000000000..b3148a856 --- /dev/null +++ b/frontend/src/components/Simulator/settings/ApiKeySection.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 41cd90adb..a2bcbaa3e 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -14,6 +14,9 @@ import { createPlausible } from 'v-plausible/vue'; import { getRuntimeConfig } from '@/utils/runtimeConfig'; import { initAppKit, wagmiAdapterRef } from '@/hooks/useAppKit'; import { WagmiPlugin } from '@wagmi/vue'; +import { installApiKeyFetchInterceptor } from '@/utils/apiKey'; + +installApiKeyFetchInterceptor(); async function bootstrap() { const app = createApp(App); diff --git a/frontend/src/utils/apiKey.ts b/frontend/src/utils/apiKey.ts new file mode 100644 index 000000000..c022a978e --- /dev/null +++ b/frontend/src/utils/apiKey.ts @@ -0,0 +1,45 @@ +import { getRuntimeConfig } from '@/utils/runtimeConfig'; + +const API_KEY_STORAGE_KEY = 'settingsStore.apiKey'; + +export function getApiKey(): string | null { + return localStorage.getItem(API_KEY_STORAGE_KEY); +} + +export function getApiKeyHeaders(): Record { + const apiKey = getApiKey(); + return apiKey ? { 'X-API-Key': apiKey } : {}; +} + +/** + * Patches globalThis.fetch to inject the X-API-Key header on requests + * to the JSON-RPC endpoint. This is needed because the genlayer-js SDK + * makes its own fetch calls with no extension point for custom headers. + */ +export function installApiKeyFetchInterceptor(): void { + const rpcUrl = getRuntimeConfig( + 'VITE_JSON_RPC_SERVER_URL', + 'http://127.0.0.1:4000/api', + ); + const originalFetch = globalThis.fetch; + + globalThis.fetch = function ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise { + const url = input instanceof Request ? input.url : input.toString(); + + if (url === rpcUrl) { + const apiKey = getApiKey(); + if (apiKey) { + const headers = new Headers(init?.headers); + if (!headers.has('X-API-Key')) { + headers.set('X-API-Key', apiKey); + } + init = { ...init, headers }; + } + } + + return originalFetch.call(globalThis, input, init); + }; +} diff --git a/frontend/src/views/Simulator/SettingsView.vue b/frontend/src/views/Simulator/SettingsView.vue index 38dc5fca3..44d18348f 100644 --- a/frontend/src/views/Simulator/SettingsView.vue +++ b/frontend/src/views/Simulator/SettingsView.vue @@ -4,6 +4,7 @@ import MainTitle from '@/components/Simulator/MainTitle.vue'; import ProviderSection from '@/components/Simulator/settings/ProviderSection.vue'; import ConsensusSection from '@/components/Simulator/settings/ConsensusSection.vue'; import SimulatorSection from '@/components/Simulator/settings/SimulatorSection.vue'; +import ApiKeySection from '@/components/Simulator/settings/ApiKeySection.vue'; const { canUpdateProviders } = useConfig(); @@ -12,6 +13,7 @@ const { canUpdateProviders } = useConfig();
Settings + diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 000000000..daca9f13f --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,31 @@ +import json + +import pytest +import requests + + +@pytest.fixture(scope="session", autouse=True) +def ensure_rate_limiting_disabled(): + """Fail fast if the backend has rate limiting enabled. + + RATE_LIMIT_ENABLED defaults to false, so integration tests run without + rate limits unless someone explicitly enables it. This guard prevents + confusing 429 errors during test runs. + """ + url = "http://localhost:4000/api" + # Send a burst of rapid requests — if we get a 429, rate limiting is on. + for _ in range(15): + resp = requests.post( + url, + data=json.dumps( + {"jsonrpc": "2.0", "method": "eth_chainId", "params": [], "id": 1} + ), + headers={"Content-Type": "application/json"}, + ) + if resp.status_code == 429: + pytest.exit( + "Rate limiting is enabled on the backend. " + "Set RATE_LIMIT_ENABLED=false in .env and restart containers " + "before running integration tests.", + returncode=1, + ) diff --git a/tests/unit/consensus/test_helpers.py b/tests/unit/consensus/test_helpers.py index f1a6087a1..af00e6032 100644 --- a/tests/unit/consensus/test_helpers.py +++ b/tests/unit/consensus/test_helpers.py @@ -635,8 +635,8 @@ async def exec_with_dynamic_state(transaction: Transaction, llm_mocked: bool): "address": node["address"], "private_key": node["private_key"], }, - eq_outputs={}, execution_result=ExecutionResultStatus.SUCCESS, + eq_outputs={} if mode == ExecutionMode.LEADER else None, ) if USE_MOCK_LLMS: diff --git a/tests/unit/consensus/test_validator_exec_timeout.py b/tests/unit/consensus/test_validator_exec_timeout.py index fb6bb1f13..c72d10c6b 100644 --- a/tests/unit/consensus/test_validator_exec_timeout.py +++ b/tests/unit/consensus/test_validator_exec_timeout.py @@ -23,7 +23,6 @@ def _make_receipt(address: str, vote: Vote) -> Receipt: mode=ExecutionMode.VALIDATOR, contract_state={}, node_config={"address": address}, - eq_outputs={}, execution_result=ExecutionResultStatus.SUCCESS, vote=vote, genvm_result={"raw_error": {"fatal": False}}, diff --git a/tests/unit/test_leader_llm_recovery.py b/tests/unit/test_leader_llm_recovery.py index bffa4e733..62e08e6e7 100644 --- a/tests/unit/test_leader_llm_recovery.py +++ b/tests/unit/test_leader_llm_recovery.py @@ -175,8 +175,8 @@ async def test_validator_fatal_error_returns_receipt(): mode=ExecutionMode.LEADER, contract_state={}, node_config={}, - eq_outputs={}, execution_result=ExecutionResultStatus.SUCCESS, + eq_outputs={}, vote=None, genvm_result=None, ) diff --git a/tests/unit/test_set_vote.py b/tests/unit/test_set_vote.py index ba9ec96a4..0ec954d6a 100644 --- a/tests/unit/test_set_vote.py +++ b/tests/unit/test_set_vote.py @@ -49,7 +49,6 @@ def _make_receipt( mode=ExecutionMode.VALIDATOR, contract_state=contract_state or {}, node_config={}, - eq_outputs={}, execution_result=execution_result, vote=None, genvm_result={ @@ -69,8 +68,8 @@ def _make_success_receipt() -> Receipt: mode=ExecutionMode.LEADER, contract_state={"slot": "data"}, node_config={}, - eq_outputs={}, execution_result=ExecutionResultStatus.SUCCESS, + eq_outputs={}, vote=None, genvm_result=None, ) @@ -191,7 +190,6 @@ def test_no_genvm_result_does_not_crash(): mode=ExecutionMode.VALIDATOR, contract_state={}, node_config={}, - eq_outputs={}, execution_result=ExecutionResultStatus.ERROR, vote=None, genvm_result=None,