Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ IONETAPIKEY='<add_your_ionet_api_key_here>'
########################################
# 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)
Expand Down
2 changes: 0 additions & 2 deletions backend/consensus/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down Expand Up @@ -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={
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
"""
)
14 changes: 9 additions & 5 deletions backend/node/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions backend/node/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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", [])
Expand Down
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/components/Simulator/settings/ApiKeySection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { notify } from '@kyvg/vue3-notification';
import PageSection from '@/components/Simulator/PageSection.vue';
import { KeyIcon } from 'lucide-vue-next';

const API_KEY_STORAGE_KEY = 'settingsStore.apiKey';

const apiKey = ref('');
const isMasked = ref(true);

onMounted(() => {
apiKey.value = localStorage.getItem(API_KEY_STORAGE_KEY) || '';
});

function saveApiKey() {
localStorage.setItem(API_KEY_STORAGE_KEY, apiKey.value.trim());

Check failure

Code scanning / CodeQL

Clear text storage of sensitive information High

This stores sensitive data returned by
a call to getItem
as clear text.
This stores sensitive data returned by
an access to apiKey
as clear text.

Copilot Autofix

AI 1 day ago

In general, to fix this kind of problem you should avoid storing secrets directly in browser storage; instead, store either (a) nothing, requiring re‑entry, (b) a short-lived opaque token from a backend, or (c) an encrypted form of the secret, where the encryption key is not stored alongside the ciphertext (e.g., derived from a user-provided passphrase).

For this specific component, the minimal invasive fix is:

  • Introduce simple symmetric encryption and decryption using the Web Crypto API (available in the browser) or a small helper, without adding backend dependencies.
  • Derive a per-user encryption key from a passphrase (which the user must enter each time they want to unlock/use the stored API key) using PBKDF2.
  • When saving the API key:
    • Prompt (via a simple extra field) for a passphrase.
    • Derive a key from the passphrase, generate a random salt and IV, encrypt the API key, and store only the ciphertext bundle (salt + iv + ciphertext, base64) in localStorage under the same API_KEY_STORAGE_KEY.
  • When loading:
    • Read the encrypted blob from localStorage and keep it as an encrypted value in state.
    • Only decrypt it when the user supplies the correct passphrase (e.g., via a “Unlock” button), at which point you derive the key again and decrypt to fill apiKey.value. This way, the cleartext key is not persisted across reloads and exists only in memory.

To keep the change constrained to the given file:

  • Replace the direct localStorage.getItem/setItem use for the cleartext API key with encryption/decryption helpers that operate on strings and use window.crypto.subtle.
  • Add new reactive refs for encryptedApiKey, passphrase, and maybe hasStoredKey.
  • Adjust the template to include a passphrase input and separate “Save encrypted” / “Unlock stored key” buttons.
  • The key technical pieces to add:
    • An async deriveKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey>
    • An async encryptApiKey(plainKey: string, passphrase: string): Promise<string>
    • An async decryptApiKey(encryptedBundle: string, passphrase: string): Promise<string>
  • Keep existing notifications and masking behavior unchanged, extending them to handle the new flows.
Suggested changeset 1
frontend/src/components/Simulator/settings/ApiKeySection.vue

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/frontend/src/components/Simulator/settings/ApiKeySection.vue b/frontend/src/components/Simulator/settings/ApiKeySection.vue
--- a/frontend/src/components/Simulator/settings/ApiKeySection.vue
+++ b/frontend/src/components/Simulator/settings/ApiKeySection.vue
@@ -8,22 +8,168 @@
 
 const apiKey = ref('');
 const isMasked = ref(true);
+const passphrase = ref('');
+const hasStoredKey = ref(false);
+const isBusy = ref(false);
 
+type EncryptedPayload = {
+  salt: string;
+  iv: string;
+  ciphertext: string;
+};
+
+function bufToBase64(buf: ArrayBuffer): string {
+  return btoa(String.fromCharCode(...new Uint8Array(buf)));
+}
+
+function base64ToBuf(b64: string): ArrayBuffer {
+  const binaryString = atob(b64);
+  const len = binaryString.length;
+  const bytes = new Uint8Array(len);
+  for (let i = 0; i < len; i++) {
+    bytes[i] = binaryString.charCodeAt(i);
+  }
+  return bytes.buffer;
+}
+
+async function deriveKey(pass: string, salt: Uint8Array): Promise<CryptoKey> {
+  const enc = new TextEncoder();
+  const keyMaterial = await crypto.subtle.importKey(
+    'raw',
+    enc.encode(pass),
+    { name: 'PBKDF2' },
+    false,
+    ['deriveKey']
+  );
+  return crypto.subtle.deriveKey(
+    {
+      name: 'PBKDF2',
+      salt,
+      iterations: 100000,
+      hash: 'SHA-256',
+    },
+    keyMaterial,
+    { name: 'AES-GCM', length: 256 },
+    false,
+    ['encrypt', 'decrypt']
+  );
+}
+
+async function encryptApiKey(plainKey: string, pass: string): Promise<string> {
+  const enc = new TextEncoder();
+  const salt = crypto.getRandomValues(new Uint8Array(16));
+  const iv = crypto.getRandomValues(new Uint8Array(12));
+  const cryptoKey = await deriveKey(pass, salt);
+  const ciphertextBuf = await crypto.subtle.encrypt(
+    { name: 'AES-GCM', iv },
+    cryptoKey,
+    enc.encode(plainKey)
+  );
+  const payload: EncryptedPayload = {
+    salt: bufToBase64(salt.buffer),
+    iv: bufToBase64(iv.buffer),
+    ciphertext: bufToBase64(ciphertextBuf),
+  };
+  return JSON.stringify(payload);
+}
+
+async function decryptApiKey(encrypted: string, pass: string): Promise<string> {
+  const payload: EncryptedPayload = JSON.parse(encrypted);
+  const salt = new Uint8Array(base64ToBuf(payload.salt));
+  const iv = new Uint8Array(base64ToBuf(payload.iv));
+  const ciphertext = base64ToBuf(payload.ciphertext);
+  const cryptoKey = await deriveKey(pass, salt);
+  const decrypted = await crypto.subtle.decrypt(
+    { name: 'AES-GCM', iv },
+    cryptoKey,
+    ciphertext
+  );
+  const dec = new TextDecoder();
+  return dec.decode(decrypted);
+}
+
 onMounted(() => {
-  apiKey.value = localStorage.getItem(API_KEY_STORAGE_KEY) || '';
+  const stored = localStorage.getItem(API_KEY_STORAGE_KEY);
+  hasStoredKey.value = !!stored;
 });
 
-function saveApiKey() {
-  localStorage.setItem(API_KEY_STORAGE_KEY, apiKey.value.trim());
-  notify({
-    title: 'API key saved',
-    type: 'success',
-  });
+async function saveApiKey() {
+  const trimmed = apiKey.value.trim();
+  if (!trimmed) {
+    notify({
+      title: 'API key is empty',
+      type: 'warn',
+    });
+    return;
+  }
+  if (!passphrase.value) {
+    notify({
+      title: 'Passphrase required',
+      text: 'Please enter a passphrase to encrypt your API key.',
+      type: 'warn',
+    });
+    return;
+  }
+  try {
+    isBusy.value = true;
+    const encrypted = await encryptApiKey(trimmed, passphrase.value);
+    localStorage.setItem(API_KEY_STORAGE_KEY, encrypted);
+    hasStoredKey.value = true;
+    notify({
+      title: 'API key saved securely',
+      type: 'success',
+    });
+  } catch (e) {
+    notify({
+      title: 'Failed to save API key',
+      type: 'error',
+    });
+  } finally {
+    isBusy.value = false;
+  }
 }
 
+async function unlockStoredApiKey() {
+  const stored = localStorage.getItem(API_KEY_STORAGE_KEY);
+  if (!stored) {
+    notify({
+      title: 'No stored API key found',
+      type: 'warn',
+    });
+    hasStoredKey.value = false;
+    return;
+  }
+  if (!passphrase.value) {
+    notify({
+      title: 'Passphrase required',
+      text: 'Enter the passphrase used when saving the API key.',
+      type: 'warn',
+    });
+    return;
+  }
+  try {
+    isBusy.value = true;
+    const decrypted = await decryptApiKey(stored, passphrase.value);
+    apiKey.value = decrypted;
+    notify({
+      title: 'API key unlocked',
+      type: 'success',
+    });
+  } catch (e) {
+    notify({
+      title: 'Failed to unlock API key',
+      text: 'The passphrase may be incorrect.',
+      type: 'error',
+    });
+  } finally {
+    isBusy.value = false;
+  }
+}
+
 function clearApiKey() {
   apiKey.value = '';
   localStorage.removeItem(API_KEY_STORAGE_KEY);
+  hasStoredKey.value = false;
   notify({
     title: 'API key removed',
     type: 'success',
@@ -40,7 +177,7 @@
       anonymous rate limits apply.
     </p>
 
-    <div class="flex items-center gap-2">
+    <div class="mb-3 flex items-center gap-2">
       <input
         v-model="apiKey"
         :type="isMasked ? 'password' : 'text'"
@@ -58,17 +195,38 @@
       </Btn>
     </div>
 
-    <div class="mt-3 flex gap-2">
+    <div class="mb-3 flex items-center gap-2">
+      <input
+        v-model="passphrase"
+        type="password"
+        placeholder="Passphrase to encrypt/unlock API key"
+        class="border-border bg-background focus:ring-ring flex-1 rounded border px-3 py-2 text-sm focus:outline-none focus:ring-1"
+        data-testid="input-api-key-passphrase"
+      />
+    </div>
+
+    <div class="mt-3 flex flex-wrap gap-2">
       <Btn
         @click="saveApiKey"
         :icon="KeyIcon"
+        :disabled="isBusy"
         size="sm"
         data-testid="btn-save-api-key"
       >
-        Save
+        Save encrypted
       </Btn>
       <Btn
-        v-if="apiKey"
+        v-if="hasStoredKey"
+        @click="unlockStoredApiKey"
+        secondary
+        :disabled="isBusy"
+        size="sm"
+        data-testid="btn-unlock-api-key"
+      >
+        Unlock stored key
+      </Btn>
+      <Btn
+        v-if="apiKey || hasStoredKey"
         @click="clearApiKey"
         secondary
         size="sm"
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
notify({
Comment on lines +16 to +18
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Normalize apiKey state before saving to avoid UI/storage mismatch.

On Line 17 the trimmed value is persisted, but apiKey.value itself remains untrimmed. Combined with Line 71 (v-if="apiKey"), the UI can indicate a key exists when persisted value is empty.

Proposed fix
 function saveApiKey() {
-  localStorage.setItem(API_KEY_STORAGE_KEY, apiKey.value.trim());
+  apiKey.value = apiKey.value.trim();
+  if (apiKey.value) {
+    localStorage.setItem(API_KEY_STORAGE_KEY, apiKey.value);
+  } else {
+    localStorage.removeItem(API_KEY_STORAGE_KEY);
+  }
   notify({
     title: 'API key saved',
     type: 'success',
   });
 }

Also applies to: 71-71

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/Simulator/settings/ApiKeySection.vue` around lines 16
- 18, The saveApiKey routine trims the string for storage but doesn't update the
reactive apiKey state, causing a UI/storage mismatch (e.g., v-if="apiKey" still
sees the untrimmed value); before calling localStorage.setItem in function
saveApiKey (and any other save handlers), assign apiKey.value =
apiKey.value.trim() and then persist API_KEY_STORAGE_KEY using that trimmed
apiKey.value, then call notify — this ensures the reactive state and stored
value are normalized and the UI (v-if="apiKey") reflects the true saved value.

title: 'API key saved',
type: 'success',
});
}

function clearApiKey() {
apiKey.value = '';
localStorage.removeItem(API_KEY_STORAGE_KEY);
notify({
title: 'API key removed',
type: 'success',
});
}
</script>

<template>
<PageSection>
<template #title>API Key</template>

<p class="text-muted-foreground mb-3 text-sm">
Enter your API key to use authenticated rate limits. Without a key,
anonymous rate limits apply.
</p>

<div class="flex items-center gap-2">
<input
v-model="apiKey"
:type="isMasked ? 'password' : 'text'"
placeholder="glk_..."
class="border-border bg-background focus:ring-ring flex-1 rounded border px-3 py-2 font-mono text-sm focus:outline-none focus:ring-1"
data-testid="input-api-key"
/>
<Btn
@click="isMasked = !isMasked"
secondary
size="sm"
data-testid="btn-toggle-api-key-visibility"
>
{{ isMasked ? 'Show' : 'Hide' }}
</Btn>
</div>

<div class="mt-3 flex gap-2">
<Btn
@click="saveApiKey"
:icon="KeyIcon"
size="sm"
data-testid="btn-save-api-key"
>
Save
</Btn>
<Btn
v-if="apiKey"
@click="clearApiKey"
secondary
size="sm"
data-testid="btn-clear-api-key"
>
Clear
</Btn>
</div>
</PageSection>
</template>
3 changes: 3 additions & 0 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/utils/apiKey.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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<Response> {
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 };
}
Comment on lines +35 to +40
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether Request objects are used with fetch in frontend code paths.
rg -nP --type=ts --type=tsx --type=vue -C2 '\bnew\s+Request\s*\('
rg -nP --type=ts --type=tsx --type=vue -C2 '\bfetch\s*\(\s*new\s+Request\s*\('

Repository: genlayerlabs/genlayer-studio

Length of output: 127


🏁 Script executed:

#!/bin/bash
# First, let's check if the file exists and read it
echo "=== Checking apiKey.ts file ==="
cat -n frontend/src/utils/apiKey.ts | head -50

echo ""
echo "=== Searching for Request object instantiation ==="
rg -n 'new\s+Request' --type=ts -C2

echo ""
echo "=== Searching for fetch calls with Request ==="
rg -n 'fetch\s*\(' --type=ts -C3 | grep -A3 -B3 "Request"

echo ""
echo "=== Checking for fetch usage patterns in frontend ==="
fd -e ts -e js frontend/src | xargs rg -l 'fetch' | head -10

Repository: genlayerlabs/genlayer-studio

Length of output: 2632


🏁 Script executed:

#!/bin/bash
# Let's check how the SDK is being used and if Request objects could be passed
echo "=== Searching for installApiKeyFetchInterceptor usage ==="
rg -n 'installApiKeyFetchInterceptor' --type=ts

echo ""
echo "=== Searching for genlayer-js SDK usage ==="
rg -n 'from.*genlayer' --type=ts

echo ""
echo "=== Checking package.json for genlayer-js ==="
cat package.json | grep -A2 -B2 'genlayer'

Repository: genlayerlabs/genlayer-studio

Length of output: 2817


🏁 Script executed:

#!/bin/bash
# Let's examine how the genlayer-js SDK might be making fetch calls
echo "=== Looking for GenLayerClient usage ==="
rg -n 'createClient' --type=ts -A 5 | head -30

echo ""
echo "=== Checking useGenlayer hook ==="
cat -n frontend/src/hooks/useGenlayer.ts | head -40

Repository: genlayerlabs/genlayer-studio

Length of output: 2420


🏁 Script executed:

#!/bin/bash
# Let's verify the actual Fetch API behavior and check if there's any documentation
echo "=== Looking at how fetch is being called in the interceptor context ==="
cat -n frontend/src/utils/apiKey.ts

echo ""
echo "=== Checking if there are any direct fetch calls in the codebase ==="
rg -n 'fetch\s*\(' --type=ts frontend/src | head -20

Repository: genlayerlabs/genlayer-studio

Length of output: 1924


🏁 Script executed:

#!/bin/bash
# Let's check the rpc.ts file to see how fetch is being used
echo "=== Checking rpc.ts fetch usage ==="
cat -n frontend/src/clients/rpc.ts | sed -n '35,50p'

echo ""
echo "=== Verify the Fetch API spec behavior documentation ==="
cat -n frontend/src/utils/apiKey.ts | sed -n '26,43p'

Repository: genlayerlabs/genlayer-studio

Length of output: 1277


Preserve existing request headers when input is a Request.

On Line 35, headers are built from init?.headers only. If a caller passes a Request object with existing headers, Line 39 can overwrite them, which may drop required headers (e.g., Content-Type). Since the genlayer-js SDK makes its own fetch calls, this interceptor must properly merge headers from both sources.

Proposed fix
-        const headers = new Headers(init?.headers);
+        const headers = new Headers();
+        if (input instanceof Request) {
+          input.headers.forEach((value, key) => headers.set(key, value));
+        }
+        if (init?.headers) {
+          new Headers(init.headers).forEach((value, key) =>
+            headers.set(key, value),
+          );
+        }
         if (!headers.has('X-API-Key')) {
           headers.set('X-API-Key', apiKey);
         }
         init = { ...init, headers };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/utils/apiKey.ts` around lines 35 - 40, When input is a Request,
the current code builds headers only from init?.headers and can drop headers
present on the Request (e.g., Content-Type); update the logic that creates
Headers (the Headers(...) call and subsequent headers.set('X-API-Key', apiKey))
to merge Request headers and init.headers: if input is a Request, start with new
Headers(input.headers), then iterate over init?.headers to copy/override into
that Headers object, then only set 'X-API-Key' when not already present; keep
using the same headers variable and assign back to init = { ...init, headers }
so existing Request headers are preserved.

}

return originalFetch.call(globalThis, input, init);
};
}
2 changes: 2 additions & 0 deletions frontend/src/views/Simulator/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();
</script>
Expand All @@ -12,6 +13,7 @@ const { canUpdateProviders } = useConfig();
<div class="flex max-h-[93vh] w-full flex-col overflow-y-auto">
<MainTitle data-testid="settings-page-title">Settings</MainTitle>

<ApiKeySection />
<SimulatorSection />
<ConsensusSection />
<ProviderSection v-if="canUpdateProviders" />
Expand Down
31 changes: 31 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -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,
)
Comment on lines +18 to +31
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify requests.* calls in this file include an explicit timeout keyword.
python - <<'PY'
import ast
from pathlib import Path

path = Path("tests/integration/conftest.py")
tree = ast.parse(path.read_text())

for node in ast.walk(tree):
    if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
        if isinstance(node.func.value, ast.Name) and node.func.value.id == "requests":
            has_timeout = any(k.arg == "timeout" for k in node.keywords if isinstance(k, ast.keyword))
            print(f"{path}:{node.lineno} requests.{node.func.attr} timeout={has_timeout}")
PY

Repository: genlayerlabs/genlayer-studio

Length of output: 132


Add timeout and connection-error handling to the probe request.

The requests.post call on line 18 lacks a timeout parameter and will hang the entire test session if the backend is unreachable. Add explicit timeout and exception handling:

Suggested patch
     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"},
-        )
+        try:
+            resp = requests.post(
+                url,
+                json={"jsonrpc": "2.0", "method": "eth_chainId", "params": [], "id": 1},
+                timeout=(2, 5),
+            )
+        except requests.RequestException as exc:
+            pytest.exit(
+                f"Cannot reach backend at {url}: {exc}. "
+                "Ensure containers are running before integration tests.",
+                returncode=1,
+            )
         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,
             )
🧰 Tools
🪛 Ruff (0.15.6)

[error] 18-18: Probable use of requests call without timeout

(S113)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/conftest.py` around lines 18 - 31, The probe request using
requests.post in conftest.py currently has no timeout and can hang; update the
call in the probe logic to pass an explicit timeout (e.g. timeout=5) and wrap
the request in a try/except that catches requests.exceptions.RequestException
(including ConnectionError and Timeout), calling pytest.exit with a clear
message and nonzero returncode when a connection error/timeout occurs; preserve
the existing resp.status_code == 429 handling for rate-limiting when the request
succeeds.

2 changes: 1 addition & 1 deletion tests/unit/consensus/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 0 additions & 1 deletion tests/unit/consensus/test_validator_exec_timeout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}},
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_leader_llm_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
4 changes: 1 addition & 3 deletions tests/unit/test_set_vote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down
Loading