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
24 changes: 17 additions & 7 deletions src/commands/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const BYTES_PREFIX_RE = /^b#([0-9a-fA-F]*)$/;
const HEX_RE = /^0x[0-9a-fA-F]+$/;

function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) {
throw new Error("Hex bytes must contain an even number of digits");
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
Expand All @@ -26,7 +29,10 @@ export function coerceValue(value: unknown): unknown {
if (typeof value === "boolean") return value;
if (typeof value === "number") {
if (Number.isSafeInteger(value)) return value;
return BigInt(value);
if (Number.isInteger(value)) return BigInt(value);
// GenVM calldata has no float type. Preserve the user's value as a string
// rather than throwing or collapsing the surrounding JSON structure.
return value.toString();
}
if (Array.isArray(value)) return value.map(coerceValue);
if (typeof value === "object" && value !== null) {
Expand All @@ -53,20 +59,24 @@ export function parseScalar(value: string): unknown {
if (bytesMatch) return hexToBytes(bytesMatch[1]);

if (HEX_RE.test(value)) return BigInt(value);
if (!isNaN(Number(value)) && Number.isSafeInteger(Number(value))) return Number(value);
if (!isNaN(Number(value))) return BigInt(value);
if (value.trim() === "") return value;
const numericValue = Number(value);
if (!Number.isNaN(numericValue) && Number.isSafeInteger(numericValue)) return numericValue;
if (!Number.isNaN(numericValue) && Number.isInteger(numericValue)) return BigInt(value);

return value;
}

export function parseArg(value: string, previous: any[] = []): any[] {
let parsed: unknown;
try {
const parsed = JSON.parse(value);
if (typeof parsed === "object" || Array.isArray(parsed)) {
return [...previous, coerceValue(parsed)];
}
parsed = JSON.parse(value);
} catch {
// not JSON, fall through to scalar parsing
return [...previous, parseScalar(value)];
}
if (typeof parsed === "object" && parsed !== null) {
return [...previous, coerceValue(parsed)];
}
return [...previous, parseScalar(value)];
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/staking/StakingAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class StakingAction extends BaseAction {
private getNetwork(config: StakingConfig): GenLayerChain {
// Priority: --network option > global config > localnet default
if (config.network) {
return {...resolveNetwork(config.network, this.getCustomNetworks())};
return resolveNetwork(config.network, this.getCustomNetworks());
}

return resolveNetwork(this.getConfig().network, this.getCustomNetworks());
Expand Down
23 changes: 17 additions & 6 deletions src/commands/staking/validatorExit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,16 @@ export class ValidatorExitAction extends StakingAction {
shares,
});

// Check epoch to determine note
const epochInfo = await client.getEpochInfo();
const isEpochZero = epochInfo.currentEpoch === 0n;
// This read only improves the explanatory note. The exit is already
// confirmed, so a transient failure here must not turn success into an
// error or hide the transaction hash.
let isEpochZero = false;
try {
const epochInfo = await client.getEpochInfo();
isEpochZero = epochInfo.currentEpoch === 0n;
} catch {
// Keep the conservative unbonding-period note.
}

const output = {
transactionHash: result.transactionHash,
Expand Down Expand Up @@ -97,9 +104,13 @@ export class ValidatorExitAction extends StakingAction {
shares,
});

// Check epoch to determine note
const epochInfo = await client.getEpochInfo();
const isEpochZero = epochInfo.currentEpoch === 0n;
let isEpochZero = false;
try {
const epochInfo = await client.getEpochInfo();
isEpochZero = epochInfo.currentEpoch === 0n;
} catch {
// The exit is already confirmed; keep the conservative note.
}

this.succeedSpinner("Exit initiated successfully!", {
transactionHash: result.transactionHash,
Expand Down
5 changes: 2 additions & 3 deletions src/commands/vesting/validatorSetIdentity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {VestingAction, VestingConfig} from "./VestingAction";
import type {Address} from "genlayer-js/types";
import {toHex} from "viem";

export interface VestingValidatorSetIdentityOptions extends VestingConfig {
walletAddress: string;
Expand Down Expand Up @@ -30,7 +29,7 @@ export class VestingValidatorSetIdentityAction extends VestingAction {
try {
const client = await this.getVestingClient(options);
const vesting = await this.resolveBeneficiaryVesting(client, options);
const extraCid = options.extraCid ? toHex(new TextEncoder().encode(options.extraCid)) : "0x";
const extraCid = options.extraCid || "0x";

this.setSpinnerText(`Setting identity for vesting validator wallet ${options.walletAddress}...`);

Expand Down Expand Up @@ -86,7 +85,7 @@ export class VestingValidatorSetIdentityAction extends VestingAction {
try {
const client = this.getBrowserVestingClient(options, session);
const vesting = await this.resolveBeneficiaryVesting(client, options);
const extraCid = options.extraCid ? toHex(new TextEncoder().encode(options.extraCid)) : "0x";
const extraCid = options.extraCid || "0x";

session.setNextLabel("Set vesting validator identity");
const result = await client.vestingValidatorSetIdentity({
Expand Down
39 changes: 34 additions & 5 deletions src/lib/actions/BaseAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,45 @@ export const BUILT_IN_NETWORKS: Record<string, GenLayerChain> = {
"testnet-bradbury": testnetBradbury,
};

function cloneNetwork(network: GenLayerChain): GenLayerChain {
const rpcUrls = Object.fromEntries(
Object.entries(network.rpcUrls).map(([key, urls]) => [
key,
{
...urls,
http: [...urls.http],
...(urls.webSocket ? {webSocket: [...urls.webSocket]} : {}),
},
]),
) as GenLayerChain["rpcUrls"];

return {
...network,
nativeCurrency: {...network.nativeCurrency},
rpcUrls,
...(network.blockExplorers
? {
blockExplorers: Object.fromEntries(
Object.entries(network.blockExplorers).map(([key, explorer]) => [
key,
{...explorer},
]),
),
}
: {}),
} as GenLayerChain;
}

/**
* Resolves a stored network config to a fresh chain object.
* Handles both new format (alias string) and old format (JSON object) for backwards compat.
*/
export function resolveNetwork(stored: string | undefined, customNetworks?: CustomNetworksConfig): GenLayerChain {
if (!stored) return localnet;
if (!stored) return cloneNetwork(localnet);

// Try as alias first (new format)
if (BUILT_IN_NETWORKS[stored]) {
return BUILT_IN_NETWORKS[stored];
return cloneNetwork(BUILT_IN_NETWORKS[stored]);
}

const customNetwork = customNetworks?.[stored];
Expand All @@ -48,7 +77,7 @@ export function resolveNetwork(stored: string | undefined, customNetworks?: Cust
// (`network add <alias>`) — not the base chain's name. Otherwise a "clarke"
// network shows up as "Genlayer Bradbury Testnet" everywhere (wizard picker,
// network info, prompts), which is confusing.
return {...applyCustomNetworkProfile(baseNetwork, customNetwork), name: stored};
return cloneNetwork({...applyCustomNetworkProfile(baseNetwork, customNetwork), name: stored});
}

// Backwards compat: try parsing as JSON (old format)
Expand All @@ -58,10 +87,10 @@ export function resolveNetwork(stored: string | undefined, customNetworks?: Cust
const alias = Object.entries(BUILT_IN_NETWORKS)
.find(([_, chain]) => chain.name === parsed.name)?.[0];
if (alias) {
return BUILT_IN_NETWORKS[alias];
return cloneNetwork(BUILT_IN_NETWORKS[alias]);
}
// Custom network - use as-is
return parsed;
return cloneNetwork(parsed);
} catch {
throw new Error(`Unknown network: ${stored}`);
}
Expand Down
21 changes: 14 additions & 7 deletions src/lib/wallet/browserBridge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import http from "node:http";
import {randomUUID, timingSafeEqual} from "node:crypto";
import type {AddressInfo} from "node:net";
import {hexToBigInt} from "viem";
import {hexToBigInt, isAddress} from "viem";
import type {Address, Hash} from "genlayer-js/types";
import {openUrl as defaultOpenUrl} from "../clients/system";
import {BRIDGE_PAGE_HTML} from "./bridgePage";
Expand Down Expand Up @@ -580,7 +580,10 @@ export class BrowserWalletBridge {
if (path === "/api/connected" && req.method === "POST") {
void this.readJson(req)
.then(body => {
const address = (body?.address ?? "") as Address;
const address = body?.address;
if (typeof address !== "string" || !isAddress(address)) {
throw new Error("invalid wallet address");
}
this.connectedAddress = address;
if (this.connectTimer) {
clearTimeout(this.connectTimer);
Expand Down Expand Up @@ -776,15 +779,19 @@ export class BrowserWalletBridge {
settled = true;
try {
const raw = Buffer.concat(chunks).toString("utf-8");
resolve(raw ? JSON.parse(raw) : {});
} catch {
resolve({});
if (!raw.trim()) {
reject(new Error("empty JSON body"));
return;
}
resolve(JSON.parse(raw));
} catch (err) {
reject(err);
}
});
req.on("error", () => {
req.on("error", err => {
if (settled) return;
settled = true;
resolve({});
reject(err);
});
});
}
Expand Down
7 changes: 5 additions & 2 deletions src/lib/wallet/browserSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ export function buildBrowserSession(
nonce?: string;
type?: string;
};
const label = nextLabel ?? "GenLayer transaction";
// A label belongs to exactly one send attempt, including a rejected
// or failed attempt. Consume it before awaiting the transport.
nextLabel = undefined;
const result = await transport.sendTransaction({
to: req.to as Address,
data: (req.data ?? "0x") as `0x${string}`,
Expand All @@ -222,10 +226,9 @@ export function buildBrowserSession(
gasPrice: req.gasPrice !== undefined ? hexToBigInt(req.gasPrice as `0x${string}`) : undefined,
nonce: req.nonce !== undefined ? Number(hexToBigInt(req.nonce as `0x${string}`)) : undefined,
type: req.type,
label: nextLabel ?? "GenLayer transaction",
label,
});
const hash = assertResultSigner(result, signerAddress);
nextLabel = undefined;
return hash;
}
default:
Expand Down
20 changes: 20 additions & 0 deletions tests/bugs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Bug reproduction tests (intentionally failing)

These test files reproduce real bugs found in the `v0.40-dev` line by a bug hunt.
**They are expected to FAIL against the current code** — each one pins the
correct behavior so the fix can be verified by making the test go green. A
follow-up change should fix the underlying bug in `src/` (not weaken the test).

Every file has a header comment with the exact source location, root cause, a
concrete reproduction, and the suggested fix direction.

| Test file | Source | Bug (instability / wrong result) |
|---|---|---|
| `contractArgParsing.test.ts` | `src/commands/contracts/index.ts` | `--args` parsing: `parseScalar("1.5")` throws an uncaught `SyntaxError` (crash); an empty-string arg becomes the number `0`; odd-length `b#` hex silently drops the last nibble; a JSON arg containing a float silently degrades to a raw string (structure lost). |
| `browserSendLabelLeak.test.ts` | `src/lib/wallet/browserSend.ts` | `nextLabel` is cleared only after a successful send, so a rejected/failed transaction leaks its signing label onto the next, unrelated transaction (user sees the wrong prompt). |
| `browserBridgeConnectedValidation.test.ts` | `src/lib/wallet/browserBridge.ts` | `POST /api/connected` accepts an empty/malformed body and marks the bridge `connected` with an empty-string address; `getState()` then reports `connected: true` while every write path rejects as "not connected". |
| `stakingNetworkSingletonMutation.test.ts` | `src/commands/staking/StakingAction.ts` (+ `src/lib/actions/BaseAction.ts` `resolveNetwork`) | Using `--staking-address` without `--network` mutates the shared, module-level chain singleton, leaking the staking address into every later network resolution in the process. |
| `vestingSetIdentityExtraCid.test.ts` | `src/commands/vesting/validatorSetIdentity.ts` | `vesting validator set-identity --extra-cid 0x...` UTF-8 re-encodes the value instead of hex passthrough (as staking does), writing corrupted identity bytes on-chain. |
| `validatorExitReportsFailureOnSuccess.test.ts` | `src/commands/staking/validatorExit.ts` | A confirmed on-chain exit is reported as "Failed to exit" (non-zero exit, tx hash discarded) when the purely-cosmetic post-exit `getEpochInfo()` read transiently fails. |

Run just these: `npx vitest run tests/bugs`
84 changes: 84 additions & 0 deletions tests/bugs/browserBridgeConnectedValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {describe, test, expect, afterEach} from "vitest";
import {BrowserWalletBridge, type BridgeChainParams} from "../../src/lib/wallet/browserBridge";

/**
* BUG: POST /api/connected accepts an empty / malformed body and marks the
* bridge "connected" with an empty-string address.
*
* src/lib/wallet/browserBridge.ts:
* - readJson (~L777-782): malformed JSON and an empty body both resolve to `{}`
* instead of rejecting, so the 400 path (handleJsonReadError) is never hit.
* - /api/connected handler (~L583): `const address = (body?.address ?? "") as Address;`
* defaults a missing address to "" and stores it unconditionally.
*
* Result: getState() reports `connected: true, address: ""`, but every write
* path guards with `if (!this.connectedAddress)` (empty string is falsy) and
* rejects with "wallet-not-connected". The session is simultaneously
* "connected" (status/UX) and "not connected" (can't sign) — an inconsistent
* state that surfaces as a bogus `wallet status: connected, address: ""`.
*
* Fix direction: reject a missing / non-`0x` address with 400 and do NOT flip
* the connection state.
*
* This test FAILS on the buggy code (connected becomes true) and should PASS
* once an empty/invalid address is rejected.
*/

const CHAIN: BridgeChainParams = {
chainId: 4221,
chainName: "Genlayer Bradbury Testnet",
rpcUrls: ["https://rpc.example"],
nativeCurrency: {name: "GEN Token", symbol: "GEN", decimals: 18},
blockExplorerUrls: ["https://explorer.example"],
};

function parse(url: string): {origin: string; token: string} {
const u = new URL(url);
const origin = `${u.protocol}//${u.host}`;
const token = new URLSearchParams(u.hash.slice(1)).get("s")!;
return {origin, token};
}

const activeBridges: BrowserWalletBridge[] = [];

afterEach(async () => {
await Promise.all(activeBridges.map(b => b.close().catch(() => {})));
activeBridges.length = 0;
});

describe("BUG: /api/connected must validate the address", () => {
test("an empty body must NOT mark the bridge connected", async () => {
const bridge = new BrowserWalletBridge({chain: CHAIN, openUrl: async () => {}, handleSigint: false});
activeBridges.push(bridge);
const {url} = await bridge.start();
const {origin, token} = parse(url);

// Valid token + origin, but no address in the (empty) body.
const res = await fetch(`${origin}/api/connected`, {
method: "POST",
headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin},
body: "",
});

// The bridge must not consider itself connected with no signer address.
expect(bridge.getState().connected).toBe(false);
expect(bridge.getState().address).not.toBe("");
// And it should signal the bad request rather than 200 OK.
expect(res.status).toBeGreaterThanOrEqual(400);
});

test("a malformed JSON body must NOT mark the bridge connected", async () => {
const bridge = new BrowserWalletBridge({chain: CHAIN, openUrl: async () => {}, handleSigint: false});
activeBridges.push(bridge);
const {url} = await bridge.start();
const {origin, token} = parse(url);

await fetch(`${origin}/api/connected`, {
method: "POST",
headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin},
body: "not-json{{{",
});

expect(bridge.getState().connected).toBe(false);
});
});
Loading
Loading