diff --git a/src/abi/calldata/decoder.ts b/src/abi/calldata/decoder.ts index 6b7680b..eb4c26a 100644 --- a/src/abi/calldata/decoder.ts +++ b/src/abi/calldata/decoder.ts @@ -7,6 +7,9 @@ function readULeb128(data: Uint8Array, index: {i: number}): bigint { let accum = 0n; let shouldContinue = true; while (shouldContinue) { + if (index.i >= data.length) { + throw new Error("unexpected end of calldata while reading length"); + } const byte = data[index.i]; index.i++; const rest = byte & 0x7f; @@ -27,6 +30,9 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable { case BigInt(consts.SPECIAL_FALSE): return false; case BigInt(consts.SPECIAL_ADDR): { + if (data.length - index.i < 20) { + throw new Error("unexpected end of calldata while reading address"); + } const res = data.slice(index.i, index.i + 20); index.i += 20; return new CalldataAddress(res); @@ -36,8 +42,9 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable { const rest = cur >> BigInt(consts.BITS_IN_TYPE); switch (type) { case consts.TYPE_BYTES: { - const ret = data.slice(index.i, index.i + Number(rest)); - index.i += Number(rest); + const length = checkedRemainingLength(rest, data, index, "bytes"); + const ret = data.slice(index.i, index.i + length); + index.i += length; return ret; } case consts.TYPE_PINT: @@ -45,11 +52,13 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable { case consts.TYPE_NINT: return -1n - rest; case consts.TYPE_STR: { - const ret = data.slice(index.i, index.i + Number(rest)); - index.i += Number(rest); + const length = checkedRemainingLength(rest, data, index, "string"); + const ret = data.slice(index.i, index.i + length); + index.i += length; return new TextDecoder("utf-8").decode(ret); } case consts.TYPE_ARR: { + ensureContainerCountFits(rest, data, index, "array"); const ret = [] as CalldataEncodable[]; let elems = rest; while (elems > 0) { @@ -59,11 +68,12 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable { return ret; } case consts.TYPE_MAP: { + ensureContainerCountFits(rest, data, index, "map"); const ret = new Map(); let elems = rest; while (elems > 0) { elems--; - const strLen = Number(readULeb128(data, index)); + const strLen = checkedRemainingLength(readULeb128(data, index), data, index, "map key"); const key = data.slice(index.i, index.i + strLen); index.i += strLen; const keyStr = new TextDecoder("utf-8").decode(key); @@ -76,6 +86,33 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable { } } +function checkedRemainingLength( + length: bigint, + data: Uint8Array, + index: {i: number}, + label: string, +): number { + const remaining = data.length - index.i; + if (length > BigInt(remaining)) { + throw new Error(`${label} length ${length} exceeds ${remaining} remaining calldata bytes`); + } + return Number(length); +} + +function ensureContainerCountFits( + count: bigint, + data: Uint8Array, + index: {i: number}, + label: string, +): void { + // Every encoded element consumes at least one byte. Reject impossible counts + // before allocating or iterating based on untrusted wire data. + const remaining = data.length - index.i; + if (count > BigInt(remaining)) { + throw new Error(`${label} element count ${count} exceeds ${remaining} remaining calldata bytes`); + } +} + export function decode(data: Uint8Array): CalldataEncodable { const index = {i: 0}; const res = decodeImpl(data, index); diff --git a/src/abi/calldata/string.ts b/src/abi/calldata/string.ts index b87410c..2196237 100644 --- a/src/abi/calldata/string.ts +++ b/src/abi/calldata/string.ts @@ -7,7 +7,12 @@ function reportError(msg: string, data: CalldataEncodable): never { function toStringImplMap(data: Iterable<[string, CalldataEncodable]>, to: string[]) { to.push("{"); + let first = true; for (const [k, v] of data) { + if (!first) { + to.push(","); + } + first = false; to.push(JSON.stringify(k)); to.push(":"); toStringImpl(v, to); @@ -48,7 +53,7 @@ function toStringImplMap(data: Iterable<[string, CalldataEncodable]>, to: string if (data instanceof Uint8Array) { to.push("b#"); for (const b of data) { - to.push(b.toString(16)); + to.push(b.toString(16).padStart(2, "0")); } } else if (data instanceof Array) { to.push("["); @@ -62,7 +67,7 @@ function toStringImplMap(data: Iterable<[string, CalldataEncodable]>, to: string } else if (data instanceof CalldataAddress) { to.push("addr#"); for (const c of data.bytes) { - to.push(c.toString(16)); + to.push(c.toString(16).padStart(2, "0")); } } else if (Object.getPrototypeOf(data) === Object.prototype) { toStringImplMap(Object.entries(data), to); diff --git a/src/client/client.ts b/src/client/client.ts index 5e46852..ad92032 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -129,10 +129,19 @@ const getCustomTransportConfig = (config: ClientConfig, chainConfig: GenLayerCha * @returns Configured client with contract, transaction, and staking methods */ export const createClient = (config: ClientConfig = {chain: localnet}): GenLayerClient => { - const chainConfig = config.chain || localnet; - if (config.endpoint) { - chainConfig.rpcUrls.default.http = [config.endpoint]; - } + const configuredChain = config.chain || localnet; + const chainConfig = config.endpoint + ? { + ...configuredChain, + rpcUrls: { + ...configuredChain.rpcUrls, + default: { + ...configuredChain.rpcUrls.default, + http: [config.endpoint], + }, + }, + } + : configuredChain; const customTransport = custom(getCustomTransportConfig(config, chainConfig as GenLayerChain), {retryCount: 0, retryDelay: 0}); const publicClient = createPublicClient(chainConfig as GenLayerChain, customTransport).extend( diff --git a/src/contracts/schema.ts b/src/contracts/schema.ts index d0d94e3..d90930e 100644 --- a/src/contracts/schema.ts +++ b/src/contracts/schema.ts @@ -52,7 +52,10 @@ function validateValueAgainstType( return typeof value === 'string' || value instanceof Uint8Array; } if (type === 'address') return typeof value === 'string'; - if (type === 'int') return typeof value === 'number' || typeof value === 'bigint'; + if (type === 'int') { + return typeof value === 'bigint' || + (typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value)); + } if (type === 'array') return Array.isArray(value); if (type === 'dict') return isPlainObject(value) || value instanceof Map; @@ -99,15 +102,14 @@ function validateValueAgainstType( } // Struct-like dict schema: validate each provided key that exists in schema. - if (isPlainObject(value)) { - return Object.entries(type).every(([key, keyType]) => { - if (!(key in value)) return true; - return validateValueAgainstType( - value[key], - keyType as ContractParamsSchema, - ); - }); - } + if (!isPlainObject(value)) return false; + return Object.entries(type).every(([key, keyType]) => { + if (!(key in value)) return true; + return validateValueAgainstType( + value[key], + keyType as ContractParamsSchema, + ); + }); } return true; diff --git a/src/staking/actions.ts b/src/staking/actions.ts index a96f86c..a714723 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -1,4 +1,4 @@ -import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError} from "viem"; +import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, isHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError} from "viem"; import {GenLayerClient, GenLayerChain, Address} from "@/types"; import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "@/abi/staking"; import {parseStakingAmount, formatStakingAmount} from "./utils"; @@ -470,7 +470,9 @@ export const stakingActions = ( twitter: identityRaw.twitter, telegram: identityRaw.telegram, github: identityRaw.github, - extraCid: identityRaw.extraCid ? toHex(identityRaw.extraCid) : "", + extraCid: identityRaw.extraCid + ? (isHex(identityRaw.extraCid) ? identityRaw.extraCid : toHex(identityRaw.extraCid)) + : "", }; } @@ -547,7 +549,7 @@ export const stakingActions = ( }, /** Checks whether a validator's self-stake is below the configured validator minimum. */ - isValidatorBelowMin: async (validator: Address): Promise => { + isValidatorBelowMinStake: async (validator: Address): Promise => { const contract = getReadOnlyStakingContract(); const [view, minStake] = await Promise.all([ contract.read.validatorView([validator as ViemAddress]) as Promise<{vStake: bigint}>, diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index 491067a..e2e5615 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -78,10 +78,18 @@ export const isSuccessful = (transaction: GenLayerTransaction): boolean => { ? undefined : executionResultNumberToName[String(transaction.txExecutionResult) as keyof typeof executionResultNumberToName] ); + const studioExecutionSucceeded = + executionResultName === undefined && + transaction.consensus_data?.leader_receipt?.some( + receipt => receipt.execution_result === "SUCCESS", + ) === true; return ( (statusName === TransactionStatus.ACCEPTED || statusName === TransactionStatus.FINALIZED) && - executionResultName === ExecutionResult.FINISHED_WITH_RETURN + ( + executionResultName === ExecutionResult.FINISHED_WITH_RETURN || + studioExecutionSucceeded + ) ); }; diff --git a/src/types/staking.ts b/src/types/staking.ts index 9d9c657..435b2e9 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -218,6 +218,7 @@ export interface StakingActions { delegatorExit: (options: DelegatorExitOptions) => Promise; delegatorClaim: (options: DelegatorClaimOptions) => Promise; isValidator: (address: Address) => Promise; + isValidatorBelowMinStake: (validator: Address) => Promise; getValidatorInfo: (validator: Address) => Promise; getStakeInfo: (delegator: Address, validator: Address) => Promise; getEpochInfo: () => Promise; diff --git a/src/utils/jsonifier.ts b/src/utils/jsonifier.ts index c16e014..9e1c2e6 100644 --- a/src/utils/jsonifier.ts +++ b/src/utils/jsonifier.ts @@ -87,41 +87,47 @@ function _toJsonSafeDeep(value: CalldataEncodable, seen: WeakSet): any { // Objects and structured values if (typeof value === "object") { - if (seen.has(value as object)) { - // Prevent potential cycles; represent as null - return null; - } - seen.add(value as object); - if (value instanceof Uint8Array) { return toHex(value); } - if (value instanceof Array) { - return value.map((v) => _toJsonSafeDeep(v as CalldataEncodable, seen)); + if (value instanceof CalldataAddress) { + return toHex(value.bytes); } - if (value instanceof Map) { - const obj: Record = {}; - for (const [k, v] of value.entries()) { - obj[k] = _toJsonSafeDeep(v as CalldataEncodable, seen); - } - return obj; + if (seen.has(value as object)) { + // Prevent potential cycles; represent as null + return null; } + seen.add(value as object); - if (value instanceof CalldataAddress) { - return toHex(value.bytes); - } + try { + if (value instanceof Array) { + return value.map((v) => _toJsonSafeDeep(v as CalldataEncodable, seen)); + } - if (Object.getPrototypeOf(value) === Object.prototype) { - const obj: Record = {}; - for (const [k, v] of Object.entries(value)) { - obj[k] = _toJsonSafeDeep(v as CalldataEncodable, seen); + if (value instanceof Map) { + const obj: Record = {}; + for (const [k, v] of value.entries()) { + obj[k] = _toJsonSafeDeep(v as CalldataEncodable, seen); + } + return obj; } - return obj; + + if (Object.getPrototypeOf(value) === Object.prototype) { + const obj: Record = {}; + for (const [k, v] of Object.entries(value)) { + obj[k] = _toJsonSafeDeep(v as CalldataEncodable, seen); + } + return obj; + } + } finally { + // Track only the active recursion path. Shared references in an acyclic + // object graph must be serialized each time they occur. + seen.delete(value as object); } } // Fallback: return as-is (shouldn't normally reach here) return value as any; -} \ No newline at end of file +} diff --git a/src/vesting/actions.ts b/src/vesting/actions.ts index 5e2cef1..c7eac84 100644 --- a/src/vesting/actions.ts +++ b/src/vesting/actions.ts @@ -8,6 +8,7 @@ import { decodeErrorResult, encodeFunctionData, getContract, + isHex, PublicClient, RawContractError, toHex, @@ -94,10 +95,26 @@ function extractRevertReason(err: unknown): string { function encodeExtraCid(extraCid?: string): `0x${string}` { if (!extraCid) return "0x"; - if (extraCid.startsWith("0x")) return extraCid as `0x${string}`; + if (extraCid.startsWith("0x")) { + if (!isHex(extraCid, {strict: true}) || extraCid.length % 2 !== 0) { + throw new Error("extraCid must be a valid even-length hex string"); + } + return extraCid; + } return toHex(new TextEncoder().encode(extraCid)); } +function parseExitShares(shares: bigint | string): bigint { + if (typeof shares === "string" && shares.trim() === "") { + throw new Error("shares must not be empty"); + } + const parsed = typeof shares === "string" ? BigInt(shares) : shares; + if (parsed <= 0n) { + throw new Error("shares must be greater than zero"); + } + return parsed; +} + export const vestingActions = ( client: GenLayerClient, publicClient: PublicClient, @@ -307,7 +324,7 @@ export const vestingActions = ( /** Exits a vesting contract's delegation by burning shares. Must be called by the vesting beneficiary. */ vestingDelegatorExit: async (options: VestingDelegatorExitOptions): Promise => { - const shares = typeof options.shares === "string" ? BigInt(options.shares) : options.shares; + const shares = parseExitShares(options.shares); const data = encodeFunctionData({ abi: VESTING_ABI, functionName: "vestingDelegatorExit", @@ -359,7 +376,7 @@ export const vestingActions = ( /** Exits validator self-stake by burning shares from a vesting-owned validator wallet. */ vestingValidatorExit: async (options: VestingValidatorExitOptions): Promise => { - const shares = typeof options.shares === "string" ? BigInt(options.shares) : options.shares; + const shares = parseExitShares(options.shares); const data = encodeFunctionData({ abi: VESTING_ABI, functionName: "vestingValidatorExit", diff --git a/tests/bug-hunt-v2dev.test.ts b/tests/bug-hunt-v2dev.test.ts new file mode 100644 index 0000000..07d535c --- /dev/null +++ b/tests/bug-hunt-v2dev.test.ts @@ -0,0 +1,315 @@ +/** + * Bug-hunt regression tests for the v2-dev (v0.6) integration branch. + * + * Every test in this file is EXPECTED TO FAIL against the current code — each one + * reproduces a distinct, confirmed defect that causes wrong results or instability. + * A follow-up change should make them pass. Each block documents the file:line and + * root cause of the bug it pins down. + */ +import {describe, it, expect, vi} from "vitest"; + +import {toString} from "@/abi/calldata/string"; +import {decode} from "@/abi/calldata/decoder"; +import * as consts from "@/abi/calldata/consts"; +import {toJsonSafeDeep} from "@/utils/jsonifier"; +import {createClient} from "@/client/client"; +import {localnet} from "@/chains/localnet"; +import {buildGenVmPositionalArgs} from "@/contracts/schema"; +import {stakingActions} from "@/staking/actions"; +import {vestingActions} from "@/vesting/actions"; +import {isSuccessful} from "@/transactions/actions"; + +// --------------------------------------------------------------------------- +// calldata/string.ts — human-readable rendering (used for decoded-tx `readable`) +// --------------------------------------------------------------------------- +describe("BUG: calldata toString() rendering", () => { + // src/abi/calldata/string.ts:8-16 — toStringImplMap never pushes a separator + // between key/value pairs (the array branch does, at line 57). Multi-entry + // maps render as an unparseable, ambiguous blob. + it("separates map entries with a comma", () => { + expect(toString({a: 1, b: 2})).toBe('{"a":1,"b":2}'); + // Actual: '{"a":1"b":2}' + }); + + // src/abi/calldata/string.ts:48-52 (bytes) and 62-66 (address) — per-byte hex is + // emitted via b.toString(16) with no padStart(2,"0"), so distinct byte arrays + // collide and addresses render with fewer than 40 hex chars. + it("zero-pads each byte so distinct byte arrays do not collide", () => { + expect(toString(new Uint8Array([0x00, 0xff]))).toBe("b#00ff"); // actual: "b#0ff" + expect(toString(new Uint8Array([0x01, 0x02]))).not.toBe(toString(new Uint8Array([0x12]))); + // Actual: both render as "b#12" + }); +}); + +// --------------------------------------------------------------------------- +// calldata/decoder.ts — untrusted length drives unbounded allocation +// --------------------------------------------------------------------------- +describe("BUG: calldata decode() trusts attacker-supplied container counts", () => { + // src/abi/calldata/decoder.ts:52-73 — the TYPE_ARR / TYPE_MAP element count comes + // straight off the wire and drives a while/slice loop with no check against the + // bytes actually remaining. Since decode() runs on consensus/validator-supplied + // bytes, a tiny malformed payload forces multi-second allocation before it errors. + it("rejects an oversized array header without unbounded work", () => { + // Build a TYPE_ARR header claiming 20,000,000 elements with no payload. + const count = 20_000_000n; + const tagged = (count << BigInt(consts.BITS_IN_TYPE)) | BigInt(consts.TYPE_ARR); + const buf: number[] = []; + let v = tagged; + while (v > 0n) { + let cur = Number(v & 0x7fn); + v >>= 7n; + if (v > 0n) cur |= 0x80; + buf.push(cur); + } + const bytes = new Uint8Array(buf); + + const start = Date.now(); + let threw = false; + try { + decode(bytes); + } catch { + threw = true; + } + const elapsed = Date.now() - start; + + expect(threw).toBe(true); + // A bounds check (count <= remaining bytes) should reject in O(1). Today this + // allocates ~20M entries first, taking seconds. Fails on the timing assertion. + expect(elapsed).toBeLessThan(300); + }, 30_000); +}); + +// --------------------------------------------------------------------------- +// utils/jsonifier.ts — cycle guard is never released +// --------------------------------------------------------------------------- +describe("BUG: toJsonSafeDeep() nulls out shared (non-cyclic) references", () => { + // src/utils/jsonifier.ts:~90-94 — `seen.add(value)` is never removed after a + // subtree finishes, so the WeakSet tracks "ever visited" instead of "on the + // current path". A DAG (same object referenced twice) loses the 2nd occurrence. + it("keeps both occurrences of a shared object", () => { + const shared = {x: 1}; + expect(toJsonSafeDeep([shared, shared])).toEqual([{x: 1}, {x: 1}]); + // Actual: [{x:1}, null] + }); +}); + +// --------------------------------------------------------------------------- +// client/client.ts — endpoint override mutates the shared chain singleton +// --------------------------------------------------------------------------- +describe("BUG: createClient({endpoint}) mutates the shared chain config", () => { + // src/client/client.ts:~132-135 — `chainConfig.rpcUrls.default.http = [endpoint]` + // assigns in place on the imported chain object. Every client shares that object, + // so one client's endpoint override leaks into all other clients (and later + // createClient() calls with no override inherit the last mutation). + it("does not leak one client's endpoint into other clients", () => { + const original = localnet.rpcUrls.default.http; + try { + createClient({chain: localnet, endpoint: "http://leaked.example:1234"} as any); + const b = createClient({chain: localnet} as any); + expect(b.chain.rpcUrls.default.http[0]).toBe("http://127.0.0.1:4000/api"); + // Actual: "http://leaked.example:1234" + } finally { + // Restore the singleton so this test cannot pollute the rest of the run. + (localnet.rpcUrls.default as {http: readonly string[]}).http = original; + } + }); +}); + +// --------------------------------------------------------------------------- +// contracts/schema.ts — strictTypes validation is too permissive +// --------------------------------------------------------------------------- +describe("BUG: buildGenVmPositionalArgs strict validation gaps", () => { + const structSchema = { + ctor: {params: [], kwparams: {}}, + methods: { + m: {params: [["profile", {name: "string"}]], kwparams: {}, ret: "any", readonly: false}, + }, + }; + const intSchema = { + ctor: {params: [], kwparams: {}}, + methods: { + m: {params: [["n", "int"]], kwparams: {}, ret: "any", readonly: false}, + }, + }; + + // src/contracts/schema.ts:101-113 — for a struct-like dict schema the key checks + // only run `if (isPlainObject(value))`; a non-object value skips the branch and + // falls through to the terminal `return true`, so a scalar passes as a struct. + it("rejects a non-object value for a struct parameter", () => { + expect(() => + buildGenVmPositionalArgs({ + schema: structSchema as any, + functionName: "m", + valuesByParamName: {profile: 42}, + }), + ).toThrow(/Invalid argument "profile"/); + // Actual: returns [42], no error. + }); + + // src/contracts/schema.ts:55 — `'int'` accepts `typeof value === 'number'`, which + // includes non-integer floats and NaN. Validation passes, then the value later + // throws deep inside calldata.encode ("invalid calldata input '1.5'"). + it("rejects a non-integer number for an int parameter", () => { + expect(() => + buildGenVmPositionalArgs({ + schema: intSchema as any, + functionName: "m", + valuesByParamName: {n: 1.5}, + }), + ).toThrow(/Invalid argument "n"/); + // Actual: returns [1.5], later crashes at encode time. + }); +}); + +// --------------------------------------------------------------------------- +// staking/actions.ts — identity extraCid double-encoding + advertised API name +// --------------------------------------------------------------------------- +describe("BUG: staking getValidatorInfo re-encodes an already-hex extraCid", () => { + const STAKING_ADDRESS = "0x0000000000000000000000000000000000000044"; + const VALIDATOR = "0x0000000000000000000000000000000000000099"; + const OWNER = "0x0000000000000000000000000000000000000011"; + const OPERATOR = "0x00000000000000000000000000000000000000aa"; + + const chain = { + id: 1, + name: "test", + nativeCurrency: {name: "GEN", symbol: "GEN", decimals: 18}, + rpcUrls: {default: {http: ["http://127.0.0.1"]}}, + isStudio: false, + stakingContract: {address: STAKING_ADDRESS}, + }; + + const emptyView = { + vStake: 0n, vShares: 0n, dStake: 0n, dShares: 0n, + vDeposit: 0n, vWithdrawal: 0n, ePrimed: 0n, live: true, eBanned: 0n, + }; + + const makeHarness = () => { + const publicClient = { + readContract: vi.fn(async ({functionName}: {functionName: string}) => { + switch (functionName) { + case "isValidator": return true; + case "validatorView": return emptyView; + case "owner": return OWNER; + case "operator": return OPERATOR; + case "getIdentity": + return { + moniker: "val", logoUri: "", website: "", description: "", + email: "", twitter: "", telegram: "", github: "", + // viem decodes ABI `bytes` as an 0x-prefixed hex string. + extraCid: "0xdeadbeef", + }; + case "epoch": return 1n; + case "validatorMinStake": return 0n; + case "validatorDepositLen": return 0n; + case "validatorWithdrawalLen": return 0n; + default: throw new Error(`unexpected read: ${functionName}`); + } + }), + }; + const client = {chain}; + return stakingActions(client as any, publicClient as any); + }; + + // src/staking/actions.ts:473 — `extraCid: toHex(identityRaw.extraCid)`. viem already + // returns `bytes` as hex, so toHex() UTF-8-encodes the "0x…" characters, corrupting + // the value on read (and breaking the SDK's own setIdentity→getValidatorInfo round-trip). + it("returns the on-chain extraCid unchanged", async () => { + const actions = makeHarness(); + const info = await actions.getValidatorInfo(VALIDATOR as any); + expect(info.identity!.extraCid).toBe("0xdeadbeef"); + // Actual: "0x30786465616462656566" (hex of the ASCII string "0xdeadbeef"). + }); + + // Commit a338ad5 advertised the epoch-zero helper as `isValidatorBelowMinStake`, + // but src/staking/actions.ts:550 implements it as `isValidatorBelowMin`, so the + // documented API name is missing at runtime (a caller gets `is not a function`). + it("exposes the advertised isValidatorBelowMinStake helper", () => { + const actions = makeHarness(); + expect(typeof (actions as any).isValidatorBelowMinStake).toBe("function"); + // Actual: undefined (method is named isValidatorBelowMin). + }); +}); + +// --------------------------------------------------------------------------- +// vesting/actions.ts — unvalidated extraCid + unsafe `shares` string coercion +// --------------------------------------------------------------------------- +describe("BUG: vesting input handling", () => { + const ACCOUNT = "0x0000000000000000000000000000000000000011"; + const VESTING = "0x0000000000000000000000000000000000000022"; + const VALIDATOR = "0x0000000000000000000000000000000000000033"; + const WALLET = "0x0000000000000000000000000000000000000099"; + const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; + + const makeHarness = () => { + const signTransaction = vi.fn().mockResolvedValue("0xsigned"); + const client = { + account: {address: ACCOUNT, type: "local", signTransaction}, + chain: { + id: 1, name: "test", + nativeCurrency: {name: "GEN", symbol: "GEN", decimals: 18}, + rpcUrls: {default: {http: ["http://127.0.0.1"]}}, + isStudio: false, + consensusMainContract: {address: "0x0000000000000000000000000000000000000044", abi: [], bytecode: "0x"}, + }, + }; + const publicClient = { + call: vi.fn().mockResolvedValue("0x"), + estimateGas: vi.fn().mockResolvedValue(21000n), + getTransactionCount: vi.fn().mockResolvedValue(7), + prepareTransactionRequest: vi.fn().mockImplementation(async (r: any) => r), + sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: vi.fn().mockResolvedValue({ + status: "success", transactionHash: MOCK_TX_HASH, blockNumber: 12n, gasUsed: 345n, logs: [], + }), + readContract: vi.fn(), + }; + return {actions: vestingActions(client as any, publicClient as any), publicClient}; + }; + + // src/vesting/actions.ts:95-99 — encodeExtraCid blindly casts any "0x…" string + // through with no isHex/even-length check. An odd-length CID is silently right- + // padded by viem's encoder, writing a corrupted identity on-chain with no error. + it("does not silently corrupt an odd-length hex extraCid", async () => { + const {actions, publicClient} = makeHarness(); + await expect( + actions.vestingValidatorSetIdentity({ + vesting: VESTING, wallet: WALLET, moniker: "m", extraCid: "0x123", + } as any), + ).rejects.toThrow(/even-length hex/); + expect(publicClient.call).not.toHaveBeenCalled(); + }); + + // src/vesting/actions.ts:310 — `BigInt(options.shares)` turns "" into 0n, so an + // empty shares string is silently broadcast as a zero-share exit instead of being + // rejected as invalid input. + it("rejects an empty shares string instead of sending a zero-share exit", async () => { + const {actions} = makeHarness(); + await expect( + actions.vestingDelegatorExit({vesting: VESTING, validator: VALIDATOR, shares: ""} as any), + ).rejects.toThrow(); + // Actual: resolves, having encoded vestingDelegatorExit(validator, 0n). + }); +}); + +// --------------------------------------------------------------------------- +// transactions/actions.ts — isSuccessful() unsatisfiable on studio/localnet +// --------------------------------------------------------------------------- +describe("BUG: isSuccessful() always false for finalized studio transactions", () => { + // src/transactions/actions.ts:68-86 — success requires + // executionResultName === FINISHED_WITH_RETURN, derived only from + // txExecutionResult(Name). The studio getTransaction path never populates those + // fields (the result lives at consensus_data.leader_receipt[].execution_result), + // so a finalized, successful studio tx is reported as unsuccessful. + it("reports a finalized studio transaction with a SUCCESS leader receipt as successful", () => { + const studioTx = { + status: "FINALIZED", + statusName: "FINALIZED", + txExecutionResult: undefined, + txExecutionResultName: undefined, + consensus_data: {final: true, leader_receipt: [{execution_result: "SUCCESS"}]}, + }; + expect(isSuccessful(studioTx as any)).toBe(true); + // Actual: false. + }); +});