diff --git a/README.md b/README.md index 0008f17c..d33dc464 100644 --- a/README.md +++ b/README.md @@ -169,11 +169,15 @@ USAGE: genlayer deploy [options] genlayer call [options] genlayer write [options] + genlayer estimate-fees [contractAddress] [method] [options] genlayer schema [options] OPTIONS (deploy): --contract (Optional) Path to the intelligent contract to deploy --rpc RPC URL for the network + --fees Transaction fee options JSON passed to genlayer-js + --fee-value Explicit fee deposit value + --valid-until Unix timestamp after which the transaction is invalid --args Contract arguments (see Argument Types below) OPTIONS (call): @@ -182,8 +186,17 @@ OPTIONS (call): OPTIONS (write): --rpc RPC URL for the network + --fees Transaction fee options JSON passed to genlayer-js + --fee-value Explicit fee deposit value + --valid-until Unix timestamp after which the transaction is invalid --args Method arguments (see Argument Types below) +OPTIONS (estimate-fees): + --rpc RPC URL for the network + --fees Fee estimate options JSON, or a transaction fee object + --include-report Include simulation fee accounting/report in the generated estimate output + --args Method arguments for simulation-derived estimates + OPTIONS (schema): --rpc RPC URL for the network @@ -191,14 +204,51 @@ EXAMPLES: genlayer deploy genlayer deploy --contract ./my_contract.gpy genlayer deploy --contract ./my_contract.gpy --args "arg1" "arg2" 123 + genlayer deploy --contract ./my_contract.gpy --fees '{"distribution":{"leaderTimeunitsAllocation":"100","validatorTimeunitsAllocation":"200","rotations":["0"]}}' genlayer call 0x123456789abcdef greet --args "Hello World!" genlayer write 0x123456789abcdef updateValue --args 42 + genlayer write 0x123456789abcdef updateValue --fees '{"distribution":{"leaderTimeunitsAllocation":"100","validatorTimeunitsAllocation":"200","rotations":["0"]}}' --args 42 + genlayer estimate-fees + genlayer estimate-fees 0x123456789abcdef updateValue --args 42 genlayer write 0x123456789abcdef sendReward --args 0x6857Ed54CbafaA74Fc0357145eC0ee1536ca45A0 genlayer write 0x123456789abcdef setScores --args '[1, 2, 3]' genlayer write 0x123456789abcdef setConfig --args '{"timeout": 30, "retries": 5}' genlayer schema 0x123456789abcdef ``` +##### Transaction Fee Options + +`--fees` accepts the same transaction fee object as `genlayer-js`. Quote large +integer values as strings to preserve precision. `messageAllocations[].messageType` +may be `"internal"`, `"external"`, `0`, or `1`. + +For targeted message budgets, the CLI can derive GenVM call keys before passing +the JSON to `genlayer-js`: + +```bash +genlayer estimate-fees 0x123456789abcdef settle \ + --fees '{"messageAllocations":[{"messageType":"internal","recipient":"0x0000000000000000000000000000000000000001","callKeyMethod":"settle_campaign","budget":"700000"}]}' + +genlayer write 0x123456789abcdef sendReward \ + --fees '{"messageAllocations":[{"messageType":"external","recipient":"0x0000000000000000000000000000000000000002","callKeySelector":"0xa9059cbb","budget":"210000"}]}' +``` + +Use `callKeyMethod` for internal GenVM messages, `callKeySelector` for a 4-byte +EVM selector, or `callKeyCalldata` for full external calldata. Explicit +`callKey` is still accepted for advanced cases. + +If `--fees` includes a `distribution` and `--fee-value` is omitted, the SDK +derives the fee deposit from FeeManager on network backends, or from +`sim_getFeeConfig` on Studio. Use `--fee-value` only when you need to force an +explicit deposit value. + +`estimate-fees` prints the SDK fee preset. Without a contract/method it calls +`estimateTransactionFees`. With a contract/method it uses the SDK target-write +estimation helper so the preset reflects the observed Studio fee accounting +report. Add `--include-report` to use the explicit simulate-and-derive path and +include the simulation fee accounting and execution fee report next to the +preset for reproducible gas-unit debugging. + ##### Argument Types The `--args` option automatically detects and converts values to the correct type: @@ -522,4 +572,3 @@ We welcome contributions to GenLayerJS SDK! Whether it's new features, improved ## License This project is licensed under the ... License - see the [LICENSE](LICENSE) file for details. - diff --git a/package-lock.json b/package-lock.json index 6e59d72a..02b49aa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "^1.0.0", + "genlayer-js": "^1.1.8", "inquirer": "^12.0.0", "keytar": "^7.9.0", "node-fetch": "^3.0.0", @@ -5581,9 +5581,9 @@ } }, "node_modules/genlayer-js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-1.0.0.tgz", - "integrity": "sha512-372szMXY95jI32u7xOTrqHOXk3Wf4WdsrmXSzgXywv7TBSRwj/W3W4s9oBBpzMIxUzS06Bv80rTCrblvuH78ug==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-1.1.8.tgz", + "integrity": "sha512-qlqh8oqR9Ad7FVbIdqIrHfsMPLLJ24ZRHUZ2LGMpw6DX5ySjrEWdV1X93bVIHO44cu9CLGdx8m2ubkPv78/RLg==", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", diff --git a/package.json b/package.json index c0c54ebd..34276046 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "^1.0.0", + "genlayer-js": "^1.1.8", "inquirer": "^12.0.0", "keytar": "^7.9.0", "node-fetch": "^3.0.0", diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index d4f1d703..198f9c84 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -4,8 +4,9 @@ import {BaseAction} from "../../lib/actions/BaseAction"; import {pathToFileURL} from "url"; import {TransactionStatus} from "genlayer-js/types"; import {buildSync} from "esbuild"; +import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; -export interface DeployOptions { +export interface DeployOptions extends ContractFeeCliOptions { contract?: string; args?: any[]; rpc?: string; @@ -131,6 +132,10 @@ export class DeployAction extends BaseAction { const leaderOnly = false; const deployParams: any = {code: contractCode, args: options.args, leaderOnly}; + const fees = parseTransactionFees(options); + const validUntil = parseValidUntil(options); + if (fees) deployParams.fees = fees; + if (validUntil !== undefined) deployParams.validUntil = validUntil; this.setSpinnerText("Starting contract deployment..."); this.log("Deployment Parameters:", deployParams); diff --git a/src/commands/contracts/estimateFees.ts b/src/commands/contracts/estimateFees.ts new file mode 100644 index 00000000..fa3cf873 --- /dev/null +++ b/src/commands/contracts/estimateFees.ts @@ -0,0 +1,133 @@ +import {BaseAction} from "../../lib/actions/BaseAction"; +import {ContractFeeCliOptions, parseFeeEstimateOptions} from "./fees"; + +export interface EstimateFeesOptions extends Pick { + args?: any[]; + rpc?: string; + json?: boolean; + includeReport?: boolean; +} + +const toTransactionFees = (estimate: Record): Record => ({ + distribution: estimate.distribution, + ...(estimate.messageAllocations ? {messageAllocations: estimate.messageAllocations} : {}), + feeValue: estimate.feeValue ?? estimate.fee_value, +}); + +const toJsonSafe = (value: any): any => { + if (typeof value === "bigint") return value.toString(); + if (Array.isArray(value)) return value.map(toJsonSafe); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([, item]) => item !== undefined) + .map(([key, item]) => [key, toJsonSafe(item)]), + ); + } + return value; +}; + +const simulationFeeReport = (simulation: Record): Record | undefined => ( + simulation.feeReport ?? simulation.feeAccounting?.execution_fee_report +); + +const withSimulationReport = (estimate: unknown, simulation: unknown): unknown => { + if (!simulation || typeof simulation !== "object" || Array.isArray(simulation)) { + return estimate; + } + + const simulationRecord = simulation as Record; + return { + ...(estimate && typeof estimate === "object" && !Array.isArray(estimate) + ? estimate as Record + : {estimate}), + simulation: { + feeAccounting: simulationRecord.feeAccounting, + feeReport: simulationFeeReport(simulationRecord), + }, + }; +}; + +export class EstimateFeesAction extends BaseAction { + constructor() { + super(); + } + + async estimate({ + contractAddress, + method, + args, + rpc, + fees, + json, + includeReport, + }: EstimateFeesOptions & { + contractAddress?: string; + method?: string; + }): Promise { + try { + const client = await this.getClient(rpc, true); + await client.initializeConsensusSmartContract(); + const estimateOptions = parseFeeEstimateOptions({fees}); + + if (!json) this.startSpinner("Estimating transaction fees..."); + let estimate: unknown; + + if (contractAddress || method) { + if (!contractAddress || !method) { + this.failSpinner("Both contractAddress and method are required for simulation-derived fee estimates."); + return; + } + + if (!json) this.setSpinnerText(`Simulating ${method} on ${contractAddress}...`); + if (!includeReport && typeof client.estimateTransactionFeesForWrite === "function") { + estimate = await client.estimateTransactionFeesForWrite({ + ...(estimateOptions ?? {}), + address: contractAddress as any, + functionName: method, + args: args ?? [], + }); + } else { + if (typeof client.simulateWriteContract !== "function") { + this.failSpinner("The active genlayer-js client does not support write simulation."); + return; + } + if (typeof client.estimateTransactionFeesFromSimulation !== "function") { + this.failSpinner("The active genlayer-js client does not support simulation-derived fee estimates."); + return; + } + + const initialEstimate = await client.estimateTransactionFees(estimateOptions); + const simulation = await client.simulateWriteContract({ + address: contractAddress as any, + functionName: method, + args: args ?? [], + includeReceipt: true, + fees: toTransactionFees(initialEstimate as Record), + }); + estimate = await client.estimateTransactionFeesFromSimulation({ + ...(estimateOptions ?? {}), + simulation, + }); + if (includeReport) { + estimate = withSimulationReport(estimate, simulation); + } + } + } else { + if (includeReport) { + this.failSpinner("--include-report requires both contractAddress and method."); + return; + } + estimate = await client.estimateTransactionFees(estimateOptions); + } + + if (json) { + console.log(JSON.stringify(toJsonSafe(estimate))); + } else { + this.succeedSpinner("Fee estimate generated", toJsonSafe(estimate)); + } + } catch (error) { + this.failSpinner("Error estimating transaction fees", error); + } + } +} diff --git a/src/commands/contracts/fees.ts b/src/commands/contracts/fees.ts new file mode 100644 index 00000000..4e2a839e --- /dev/null +++ b/src/commands/contracts/fees.ts @@ -0,0 +1,236 @@ +import {hexToBytes, keccak256, toHex, type Hex} from "viem"; + +export interface ContractFeeCliOptions { + fees?: string; + feeValue?: string; + validUntil?: string; +} + +const parseJsonObject = (value: string, optionName: string): Record => { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error(`${optionName} must be valid JSON.`); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${optionName} must be a JSON object.`); + } + + assertSafeJsonNumbers(parsed, optionName); + return parsed as Record; +}; + +const assertSafeJsonNumbers = (value: unknown, path: string): void => { + if (typeof value === "number" && !Number.isSafeInteger(value)) { + throw new Error(`${path} contains an unsafe number. Quote large integer values as strings.`); + } + + if (Array.isArray(value)) { + value.forEach((item, index) => assertSafeJsonNumbers(item, `${path}[${index}]`)); + return; + } + + if (value && typeof value === "object") { + for (const [key, item] of Object.entries(value)) { + assertSafeJsonNumbers(item, `${path}.${key}`); + } + } +}; + +const parseBigNumberishOption = (value: string | undefined, optionName: string): string | undefined => { + if (value === undefined) return undefined; + const trimmed = value.trim(); + if (!/^(0x[0-9a-fA-F]+|[0-9]+)$/.test(trimmed)) { + throw new Error(`${optionName} must be a non-negative integer.`); + } + return trimmed; +}; + +const CALL_KEY_UNNAMED = "0x0000000000000000000000000000000000000000000000000000000000000000" as const; + +const bytesToPaddedCallKey = (bytes: Uint8Array): Hex => { + if (bytes.length > 32) { + throw new Error("call key source bytes must be 32 bytes or fewer."); + } + return `0x${toHex(bytes).slice(2).padEnd(64, "0")}` as Hex; +}; + +const deriveInternalMessageCallKey = (methodName = ""): Hex => { + const methodBytes = new TextEncoder().encode(methodName); + if (methodBytes.length < 32) { + return bytesToPaddedCallKey(methodBytes); + } + + const hashed = keccak256(methodBytes); + const lastByte = Number.parseInt(hashed.slice(-2), 16) | 1; + return `${hashed.slice(0, -2)}${lastByte.toString(16).padStart(2, "0")}` as Hex; +}; + +const deriveExternalMessageCallKey = (selectorOrCalldata: Hex): Hex => { + const bytes = hexToBytes(selectorOrCalldata); + if (bytes.length < 4) { + return CALL_KEY_UNNAMED; + } + return bytesToPaddedCallKey(bytes.slice(0, 4)); +}; + +const normalizeMessageType = (messageType: unknown, index: number): 0 | 1 | undefined => { + if (messageType === undefined) { + return undefined; + } + + if (typeof messageType === "number") { + if (messageType === 0 || messageType === 1) { + return messageType; + } + throw new Error(`--fees.messageAllocations[${index}].messageType must be "internal", "external", 0, or 1.`); + } + + if (typeof messageType !== "string") { + return undefined; + } + + const normalized = messageType.toLowerCase(); + if (normalized === "internal") { + return 1; + } + if (normalized === "external") { + return 0; + } + throw new Error(`--fees.messageAllocations[${index}].messageType must be "internal" or "external".`); +}; + +const readStringField = (allocation: Record, field: string, index: number): string => { + const value = allocation[field]; + if (typeof value !== "string") { + throw new Error(`--fees.messageAllocations[${index}].${field} must be a string.`); + } + return value; +}; + +const assertFourByteSelector = (selector: string, field: string, index: number): void => { + if (!/^0x[0-9a-fA-F]{8}$/.test(selector)) { + throw new Error(`--fees.messageAllocations[${index}].${field} must be a 4-byte hex selector.`); + } +}; + +const assertHexBytes = (hex: string, field: string, index: number): void => { + if (!/^0x([0-9a-fA-F]{2})*$/.test(hex)) { + throw new Error(`--fees.messageAllocations[${index}].${field} must be even-length hex bytes.`); + } +}; + +const normalizeMessageAllocationCallKey = ( + allocation: Record, + messageType: 0 | 1 | undefined, + index: number, +): Record => { + const helperFields = [ + "callKeyMethod", + "callKeySelector", + "callKeyCalldata", + "functionSelector", + ].filter((field) => allocation[field] !== undefined); + + if (allocation.callKey !== undefined && helperFields.length > 0) { + throw new Error(`--fees.messageAllocations[${index}] cannot combine callKey with call-key helper fields.`); + } + if (helperFields.length > 1) { + throw new Error(`--fees.messageAllocations[${index}] must use only one call-key helper field.`); + } + + const { + callKeyMethod, + callKeySelector, + callKeyCalldata, + functionSelector, + ...normalized + } = allocation; + + if (helperFields.length === 0) { + return normalized; + } + + const helperField = helperFields[0]; + if (helperField === "callKeyMethod") { + if (messageType === 0) { + throw new Error(`--fees.messageAllocations[${index}].callKeyMethod requires an internal message allocation.`); + } + normalized.messageType = messageType ?? 1; + normalized.callKey = deriveInternalMessageCallKey(readStringField(allocation, helperField, index)); + return normalized; + } + + if (messageType === 1) { + throw new Error(`--fees.messageAllocations[${index}].${helperField} requires an external message allocation.`); + } + + const selectorOrCalldata = readStringField(allocation, helperField, index); + if (helperField === "callKeySelector" || helperField === "functionSelector") { + assertFourByteSelector(selectorOrCalldata, helperField, index); + } else { + assertHexBytes(selectorOrCalldata, helperField, index); + } + normalized.messageType = messageType ?? 0; + normalized.callKey = deriveExternalMessageCallKey(selectorOrCalldata as `0x${string}`); + return normalized; +}; + +const normalizeMessageTypes = (fees: Record): Record => { + if (!Array.isArray(fees.messageAllocations)) { + return fees; + } + + return { + ...fees, + messageAllocations: fees.messageAllocations.map((allocation: any, index: number) => { + if (!allocation || typeof allocation !== "object" || Array.isArray(allocation)) { + throw new Error(`--fees.messageAllocations[${index}] must be an object.`); + } + + const messageType = normalizeMessageType(allocation.messageType, index); + return normalizeMessageAllocationCallKey({ + ...allocation, + ...(messageType === undefined ? {} : {messageType}), + }, messageType, index); + }), + }; +}; + +export const parseTransactionFees = (options: ContractFeeCliOptions): Record | undefined => { + const feeValue = parseBigNumberishOption(options.feeValue, "--fee-value"); + let fees = options.fees ? parseJsonObject(options.fees, "--fees") : undefined; + + if (!fees && feeValue === undefined) { + return undefined; + } + + fees = normalizeMessageTypes(fees ?? {}); + if (feeValue !== undefined) { + fees.feeValue = feeValue; + } + return fees; +}; + +export const parseFeeEstimateOptions = (options: Pick): Record | undefined => { + if (!options.fees) { + return undefined; + } + + const parsed = normalizeMessageTypes(parseJsonObject(options.fees, "--fees")); + if (parsed.distribution && typeof parsed.distribution === "object" && !Array.isArray(parsed.distribution)) { + const {distribution, messageAllocations, ...rest} = parsed; + return { + ...distribution, + ...(messageAllocations !== undefined ? {messageAllocations} : {}), + ...rest, + }; + } + return parsed; +}; + +export const parseValidUntil = (options: ContractFeeCliOptions): string | undefined => { + return parseBigNumberishOption(options.validUntil, "--valid-until"); +}; diff --git a/src/commands/contracts/index.ts b/src/commands/contracts/index.ts index 85caa190..07c2f993 100644 --- a/src/commands/contracts/index.ts +++ b/src/commands/contracts/index.ts @@ -5,6 +5,7 @@ import {CallAction, CallOptions} from "./call"; import {WriteAction, WriteOptions} from "./write"; import {SchemaAction, SchemaOptions} from "./schema"; import {CodeAction, CodeOptions} from "./code"; +import {EstimateFeesAction, EstimateFeesOptions} from "./estimateFees"; const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; const ADDR_PREFIX_RE = /^addr#([0-9a-fA-F]{40})$/; @@ -81,12 +82,33 @@ const ARGS_HELP = [ ' dict: \'{"key": "value"}\'', ].join("\n"); +const FEES_HELP = [ + "Transaction fee options JSON passed to genlayer-js.", + "Example:", + ' \'{"distribution":{"leaderTimeunitsAllocation":"100","validatorTimeunitsAllocation":"200","rotations":["0"]}}\'', + "Omit --fee-value to let genlayer-js derive the fee deposit from FeeManager or Studio.", + 'Message allocation messageType may be "internal", "external", 0, or 1.', + "Use callKeyMethod for internal messages, or callKeySelector/callKeyCalldata for external messages.", +].join("\n"); + +const FEE_ESTIMATE_HELP = [ + "Fee estimate options JSON passed to genlayer-js estimateTransactionFees.", + "You may pass either flat estimate options or a transaction fee object with distribution/messageAllocations.", + "Example:", + ' \'{"distribution":{"leaderTimeunitsAllocation":"100","validatorTimeunitsAllocation":"200","rotations":["0"]}}\'', + 'Message allocation messageType may be "internal", "external", 0, or 1.', + "Use callKeyMethod for internal messages, or callKeySelector/callKeyCalldata for external messages.", +].join("\n"); + export function initializeContractsCommands(program: Command) { program .command("deploy") .description("Deploy intelligent contracts") .option("--contract ", "Path to the smart contract to deploy") .option("--rpc ", "RPC URL for the network") + .option("--fees ", FEES_HELP) + .option("--fee-value ", "Fee deposit value to send with the transaction") + .option("--valid-until ", "Unix timestamp after which the transaction is invalid") .option("--args ", ARGS_HELP, parseArg, []) .action(async (options: DeployOptions) => { const deployer = new DeployAction(); @@ -117,6 +139,9 @@ export function initializeContractsCommands(program: Command) { .command("write ") .description("Sends a transaction to a contract method that modifies the state") .option("--rpc ", "RPC URL for the network") + .option("--fees ", FEES_HELP) + .option("--fee-value ", "Fee deposit value to send with the transaction") + .option("--valid-until ", "Unix timestamp after which the transaction is invalid") .option( "--args ", ARGS_HELP, @@ -128,6 +153,24 @@ export function initializeContractsCommands(program: Command) { await writeAction.write({contractAddress, method, ...options}); }); + program + .command("estimate-fees [contractAddress] [method]") + .description("Build a transaction fee preset, optionally from a Studio/localnet write simulation") + .option("--rpc ", "RPC URL for the network") + .option("--fees ", FEE_ESTIMATE_HELP) + .option("--json", "Print the fee estimate as JSON without spinner output") + .option("--include-report", "Include simulation fee accounting/report in the generated estimate output") + .option( + "--args ", + ARGS_HELP, + parseArg, + [], + ) + .action(async (contractAddress: string | undefined, method: string | undefined, options: EstimateFeesOptions) => { + const estimateFeesAction = new EstimateFeesAction(); + await estimateFeesAction.estimate({contractAddress, method, ...options}); + }); + program .command("schema ") .description("Get the schema for a deployed contract") diff --git a/src/commands/contracts/write.ts b/src/commands/contracts/write.ts index 3d19a15b..3187098d 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -1,8 +1,9 @@ // import {simulator} from "genlayer-js/chains"; // import type {GenLayerClient} from "genlayer-js/types"; import {BaseAction} from "../../lib/actions/BaseAction"; +import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; -export interface WriteOptions { +export interface WriteOptions extends ContractFeeCliOptions { args: any[]; rpc?: string; } @@ -17,23 +18,35 @@ export class WriteAction extends BaseAction { method, args, rpc, + fees, + feeValue, + validUntil, }: { contractAddress: string; method: string; args: any[]; rpc?: string; + fees?: string; + feeValue?: string; + validUntil?: string; }): Promise { const client = await this.getClient(rpc); await client.initializeConsensusSmartContract(); this.startSpinner(`Calling write method ${method} on contract at ${contractAddress}...`); try { - const hash = await client.writeContract({ + const writeParams: any = { address: contractAddress as any, functionName: method, args, value: 0n, - }); + }; + const parsedFees = parseTransactionFees({fees, feeValue, validUntil}); + const parsedValidUntil = parseValidUntil({fees, feeValue, validUntil}); + if (parsedFees) writeParams.fees = parsedFees; + if (parsedValidUntil !== undefined) writeParams.validUntil = parsedValidUntil; + + const hash = await client.writeContract(writeParams); this.log("Write Transaction Hash:", hash); const result = await client.waitForTransactionReceipt({ diff --git a/src/commands/localnet/validators.ts b/src/commands/localnet/validators.ts index 6fc3f169..b185edf6 100644 --- a/src/commands/localnet/validators.ts +++ b/src/commands/localnet/validators.ts @@ -108,15 +108,15 @@ export class ValidatorsAction extends BaseAction { const parsedStake = options.stake ? parseInt(options.stake, 10) - : currentValidator.result.stake; + : Number(currentValidator.result.stake); - if (isNaN(parsedStake) || parsedStake < 0) { + if (!Number.isInteger(parsedStake) || parsedStake < 0) { return this.failSpinner("Invalid stake value. Stake must be a positive integer."); } const updatedValidator = { address: options.address, - stake: options.stake || currentValidator.result.stake, + stake: parsedStake, provider: options.provider || currentValidator.result.provider, model: options.model || currentValidator.result.model, config: options.config ? JSON.parse(options.config) : currentValidator.result.config, @@ -266,4 +266,3 @@ export class ValidatorsAction extends BaseAction { } } } - diff --git a/src/lib/clients/jsonRpcClient.ts b/src/lib/clients/jsonRpcClient.ts index 25274a72..0213b581 100644 --- a/src/lib/clients/jsonRpcClient.ts +++ b/src/lib/clients/jsonRpcClient.ts @@ -29,12 +29,17 @@ export class JsonRpcClient { }), }); - if (response.ok) { - return response.json(); - } const result = await response.json(); - throw new Error(result?.error?.message || response.statusText); + if (!response.ok) { + throw new Error(result?.error?.message || result?.error || response.statusText); + } + + if (result?.error) { + throw new Error(result.error.message || String(result.error)); + } + + return result; } } diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index 4d5f5890..00c26034 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -95,6 +95,55 @@ describe("DeployAction", () => { expect(mockClient.deployContract).toHaveReturnedWith(Promise.resolve("mocked_tx_hash")); }); + test("deploys contract with fee options", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + args: [1], + fees: JSON.stringify({ + distribution: { + leaderTimeunitsAllocation: "10", + rotations: ["0"], + }, + messageAllocations: [{ + messageType: "internal", + recipient: "0x0000000000000000000000000000000000000001", + budget: "5", + }], + }), + feeValue: "123", + validUntil: "999", + }; + const contractContent = "contract code"; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(contractContent); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + data: {contract_address: "0xdasdsadasdasdada"}, + }); + + await deployer.deploy(options); + + expect(mockClient.deployContract).toHaveBeenCalledWith({ + code: contractContent, + args: [1], + leaderOnly: false, + fees: { + distribution: { + leaderTimeunitsAllocation: "10", + rotations: ["0"], + }, + messageAllocations: [{ + messageType: 1, + recipient: "0x0000000000000000000000000000000000000001", + budget: "5", + }], + feeValue: "123", + }, + validUntil: "999", + }); + }); + test("throws error for missing contract", async () => { const options: DeployOptions = {}; diff --git a/tests/actions/estimateFees.test.ts b/tests/actions/estimateFees.test.ts new file mode 100644 index 00000000..c9634b42 --- /dev/null +++ b/tests/actions/estimateFees.test.ts @@ -0,0 +1,271 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import {createClient, createAccount} from "genlayer-js"; +import {EstimateFeesAction} from "../../src/commands/contracts/estimateFees"; + +vi.mock("genlayer-js"); + +describe("EstimateFeesAction", () => { + let action: EstimateFeesAction; + const mockClient = { + initializeConsensusSmartContract: vi.fn(), + estimateTransactionFees: vi.fn(), + estimateTransactionFeesForWrite: vi.fn(), + simulateWriteContract: vi.fn(), + estimateTransactionFeesFromSimulation: vi.fn(), + }; + + const mockPrivateKey = "mocked_private_key"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createClient).mockReturnValue(mockClient as any); + vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any); + action = new EstimateFeesAction(); + vi.spyOn(action as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey}); + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "setSpinnerText").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("builds a static fee estimate", async () => { + const estimate = { + distribution: {leaderTimeunitsAllocation: 100n, rotations: [0n]}, + feeValue: 1100n, + policy: {enabled: true}, + }; + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(estimate); + + await action.estimate({ + fees: JSON.stringify({ + distribution: { + leaderTimeunitsAllocation: "100", + rotations: ["0"], + }, + }), + }); + + expect(mockClient.estimateTransactionFees).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "100", + rotations: ["0"], + }); + expect(mockClient.simulateWriteContract).not.toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Fee estimate generated", { + distribution: {leaderTimeunitsAllocation: "100", rotations: ["0"]}, + feeValue: "1100", + policy: {enabled: true}, + }); + }); + + test("prints a static fee estimate as JSON without spinner output", async () => { + const estimate = { + distribution: {leaderTimeunitsAllocation: 100n, rotations: [0n]}, + feeValue: 1100n, + policy: {enabled: true}, + }; + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(estimate); + + await action.estimate({json: true}); + + expect(action["startSpinner"]).not.toHaveBeenCalled(); + expect(action["succeedSpinner"]).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ + distribution: {leaderTimeunitsAllocation: "100", rotations: ["0"]}, + feeValue: "1100", + policy: {enabled: true}, + })); + }); + + test("derives a fee estimate for a target write through the SDK one-call helper", async () => { + const finalEstimate = { + distribution: {leaderTimeunitsAllocation: 100n, totalMessageFees: 110n, rotations: [0n]}, + messageAllocations: [{messageType: 1, budget: 110n}], + feeValue: 1310n, + observed: {messageFeeBudget: 110n, messageFeeConsumed: 55n}, + policy: {enabled: true}, + }; + vi.mocked(mockClient.estimateTransactionFeesForWrite).mockResolvedValue(finalEstimate); + + await action.estimate({ + contractAddress: "0x0000000000000000000000000000000000000001", + method: "update", + args: ["after"], + fees: JSON.stringify({ + messageAllocations: [{ + messageType: "internal", + callKeyMethod: "settle_campaign", + budget: "110", + }], + }), + }); + + expect(mockClient.estimateTransactionFeesForWrite).toHaveBeenCalledWith({ + messageAllocations: [{ + messageType: 1, + callKey: `0x${Buffer.from("settle_campaign", "utf8").toString("hex").padEnd(64, "0")}`, + budget: "110", + }], + address: "0x0000000000000000000000000000000000000001", + functionName: "update", + args: ["after"], + }); + expect(mockClient.estimateTransactionFees).not.toHaveBeenCalled(); + expect(mockClient.simulateWriteContract).not.toHaveBeenCalled(); + expect(mockClient.estimateTransactionFeesFromSimulation).not.toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Fee estimate generated", { + distribution: {leaderTimeunitsAllocation: "100", totalMessageFees: "110", rotations: ["0"]}, + messageAllocations: [{messageType: 1, budget: "110"}], + feeValue: "1310", + observed: {messageFeeBudget: "110", messageFeeConsumed: "55"}, + policy: {enabled: true}, + }); + }); + + test("falls back to explicit simulation when the SDK one-call helper is unavailable", async () => { + const legacyClient = { + ...mockClient, + estimateTransactionFeesForWrite: undefined, + }; + vi.mocked(createClient).mockReturnValue(legacyClient as any); + const initialEstimate = { + distribution: {leaderTimeunitsAllocation: 100n, rotations: [0n]}, + messageAllocations: [{messageType: 1, budget: 55n}], + feeValue: 1200n, + policy: {enabled: true}, + }; + const simulation = {feeAccounting: {status: "active"}}; + const finalEstimate = { + distribution: {leaderTimeunitsAllocation: 100n, totalMessageFees: 55n, rotations: [0n]}, + messageAllocations: [{messageType: 1, budget: 55n}], + feeValue: 1255n, + observed: {messageFeeConsumed: 55n}, + policy: {enabled: true}, + }; + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(initialEstimate); + vi.mocked(mockClient.simulateWriteContract).mockResolvedValue(simulation); + vi.mocked(mockClient.estimateTransactionFeesFromSimulation).mockResolvedValue(finalEstimate); + + await action.estimate({ + contractAddress: "0x0000000000000000000000000000000000000001", + method: "update", + args: ["after"], + fees: JSON.stringify({ + messageAllocations: [{messageType: "internal", budget: "55"}], + }), + }); + + expect(mockClient.estimateTransactionFees).toHaveBeenCalledWith({ + messageAllocations: [{messageType: 1, budget: "55"}], + }); + expect(mockClient.simulateWriteContract).toHaveBeenCalledWith({ + address: "0x0000000000000000000000000000000000000001", + functionName: "update", + args: ["after"], + includeReceipt: true, + fees: { + distribution: initialEstimate.distribution, + messageAllocations: initialEstimate.messageAllocations, + feeValue: initialEstimate.feeValue, + }, + }); + expect(mockClient.estimateTransactionFeesFromSimulation).toHaveBeenCalledWith({ + messageAllocations: [{messageType: 1, budget: "55"}], + simulation, + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Fee estimate generated", { + distribution: {leaderTimeunitsAllocation: "100", totalMessageFees: "55", rotations: ["0"]}, + messageAllocations: [{messageType: 1, budget: "55"}], + feeValue: "1255", + observed: {messageFeeConsumed: "55"}, + policy: {enabled: true}, + }); + }); + + test("includes simulation fee report when requested", async () => { + const initialEstimate = { + distribution: {leaderTimeunitsAllocation: 100n, rotations: [0n]}, + messageAllocations: [{messageType: 1, budget: 55n}], + feeValue: 1200n, + policy: {enabled: true}, + }; + const simulation = { + feeAccounting: { + status: "active", + message_fee_budget: 55n, + message_fee_consumed: 55n, + execution_fee_report: { + messageFees: { + budget: 55n, + declaredConsumed: 55n, + remaining: 0n, + }, + }, + }, + feeReport: { + totalEstimatedFee: 501664n, + }, + }; + const finalEstimate = { + distribution: {leaderTimeunitsAllocation: 100n, totalMessageFees: 55n, rotations: [0n]}, + messageAllocations: [{messageType: 1, budget: 55n}], + feeValue: 1255n, + observed: {messageFeeConsumed: 55n}, + policy: {enabled: true}, + }; + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(initialEstimate); + vi.mocked(mockClient.simulateWriteContract).mockResolvedValue(simulation); + vi.mocked(mockClient.estimateTransactionFeesFromSimulation).mockResolvedValue(finalEstimate); + + await action.estimate({ + contractAddress: "0x0000000000000000000000000000000000000001", + method: "update", + args: ["after"], + includeReport: true, + fees: JSON.stringify({ + messageAllocations: [{messageType: "internal", budget: "55"}], + }), + }); + + expect(mockClient.estimateTransactionFeesForWrite).not.toHaveBeenCalled(); + expect(mockClient.simulateWriteContract).toHaveBeenCalledWith({ + address: "0x0000000000000000000000000000000000000001", + functionName: "update", + args: ["after"], + includeReceipt: true, + fees: { + distribution: initialEstimate.distribution, + messageAllocations: initialEstimate.messageAllocations, + feeValue: initialEstimate.feeValue, + }, + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Fee estimate generated", { + distribution: {leaderTimeunitsAllocation: "100", totalMessageFees: "55", rotations: ["0"]}, + messageAllocations: [{messageType: 1, budget: "55"}], + feeValue: "1255", + observed: {messageFeeConsumed: "55"}, + policy: {enabled: true}, + simulation: { + feeAccounting: { + status: "active", + message_fee_budget: "55", + message_fee_consumed: "55", + execution_fee_report: { + messageFees: { + budget: "55", + declaredConsumed: "55", + remaining: "0", + }, + }, + }, + feeReport: { + totalEstimatedFee: "501664", + }, + }, + }); + }); +}); diff --git a/tests/actions/validators.test.ts b/tests/actions/validators.test.ts index a01cc503..1cf65ffd 100644 --- a/tests/actions/validators.test.ts +++ b/tests/actions/validators.test.ts @@ -581,7 +581,7 @@ describe("ValidatorsAction", () => { method: "sim_updateValidator", params: [ "mocked_address", - "200", + 200, "Provider1", "Model1", { max_tokens: 500 }, @@ -624,7 +624,7 @@ describe("ValidatorsAction", () => { method: "sim_updateValidator", params: [ "mocked_address", - "100", + 100, "Provider2", "Model2", { max_tokens: 500 }, @@ -668,7 +668,7 @@ describe("ValidatorsAction", () => { method: "sim_updateValidator", params: [ "mocked_address", - "100", + 100, "Provider1", "Model1", { max_tokens: 1000 }, @@ -709,7 +709,7 @@ describe("ValidatorsAction", () => { method: "sim_updateValidator", params: [ "mocked_address", - "200", + 200, "Provider1", "Model1", { max_tokens: 500 }, @@ -747,4 +747,4 @@ describe("ValidatorsAction", () => { expect(rpcClient.request).toHaveBeenCalledTimes(1); }); }); -}); \ No newline at end of file +}); diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index dc886580..e3820c7b 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -58,6 +58,53 @@ describe("WriteAction", () => { ); }); + test("calls writeContract with fee options", async () => { + const mockHash = "0xMockedTransactionHash"; + const mockReceipt = {status: "success"}; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); + + await writeAction.write({ + contractAddress: "0xMockedContract", + method: "updateData", + args: [42], + fees: JSON.stringify({ + distribution: { + totalMessageFees: "3", + }, + messageAllocations: [{ + messageType: "external", + recipient: "0x0000000000000000000000000000000000000001", + callKeySelector: "0xaabbccdd", + budget: "3", + }], + }), + feeValue: "4", + validUntil: "999", + }); + + expect(mockClient.writeContract).toHaveBeenCalledWith({ + address: "0xMockedContract", + functionName: "updateData", + args: [42], + value: 0n, + fees: { + distribution: { + totalMessageFees: "3", + }, + messageAllocations: [{ + messageType: 0, + recipient: "0x0000000000000000000000000000000000000001", + callKey: `0xaabbccdd${"0".repeat(56)}`, + budget: "3", + }], + feeValue: "4", + }, + validUntil: "999", + }); + }); + test("handles writeContract errors", async () => { vi.mocked(mockClient.writeContract).mockRejectedValue(new Error("Mocked write error")); diff --git a/tests/commands/deploy.test.ts b/tests/commands/deploy.test.ts index 853fe326..271e60ec 100644 --- a/tests/commands/deploy.test.ts +++ b/tests/commands/deploy.test.ts @@ -52,6 +52,31 @@ describe("deploy command", () => { }); }); + test("DeployAction.deploy receives fee options", async () => { + const fees = '{"distribution":{"totalMessageFees":"3"}}'; + program.parse([ + "node", + "test", + "deploy", + "--contract", + "./path/to/contract", + "--fees", + fees, + "--fee-value", + "4", + "--valid-until", + "999", + ]); + + expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({ + contract: "./path/to/contract", + args: [], + fees, + feeValue: "4", + validUntil: "999", + }); + }); + test("DeployAction is instantiated when the deploy command is executed", async () => { program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"]); expect(DeployAction).toHaveBeenCalledTimes(1); diff --git a/tests/commands/estimateFees.test.ts b/tests/commands/estimateFees.test.ts new file mode 100644 index 00000000..942644da --- /dev/null +++ b/tests/commands/estimateFees.test.ts @@ -0,0 +1,98 @@ +import {Command} from "commander"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeContractsCommands} from "../../src/commands/contracts"; +import {EstimateFeesAction} from "../../src/commands/contracts/estimateFees"; + +vi.mock("../../src/commands/contracts/estimateFees"); +vi.mock("esbuild", () => ({ + buildSync: vi.fn(), +})); + +describe("estimate-fees command", () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + initializeContractsCommands(program); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("EstimateFeesAction.estimate is called with static estimate options", async () => { + const fees = '{"distribution":{"totalMessageFees":"3"}}'; + program.parse([ + "node", + "test", + "estimate-fees", + "--fees", + fees, + "--rpc", + "http://127.0.0.1:4000/api", + ]); + + expect(EstimateFeesAction).toHaveBeenCalledTimes(1); + expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ + args: [], + fees, + rpc: "http://127.0.0.1:4000/api", + contractAddress: undefined, + method: undefined, + }); + }); + + test("EstimateFeesAction.estimate is called with simulation target and args", async () => { + program.parse([ + "node", + "test", + "estimate-fees", + "0x0000000000000000000000000000000000000001", + "update", + "--args", + "after", + "2", + ]); + + expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ + args: ["after", 2], + contractAddress: "0x0000000000000000000000000000000000000001", + method: "update", + }); + }); + + test("EstimateFeesAction.estimate receives json output flag", async () => { + program.parse([ + "node", + "test", + "estimate-fees", + "--json", + ]); + + expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ + args: [], + contractAddress: undefined, + json: true, + method: undefined, + }); + }); + + test("EstimateFeesAction.estimate receives include-report flag", async () => { + program.parse([ + "node", + "test", + "estimate-fees", + "0x0000000000000000000000000000000000000001", + "update", + "--include-report", + ]); + + expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ + args: [], + contractAddress: "0x0000000000000000000000000000000000000001", + includeReport: true, + method: "update", + }); + }); +}); diff --git a/tests/commands/write.test.ts b/tests/commands/write.test.ts index 19850c69..5af4a623 100644 --- a/tests/commands/write.test.ts +++ b/tests/commands/write.test.ts @@ -54,6 +54,32 @@ describe("write command", () => { }); }); + test("WriteAction.write receives fee options", async () => { + const fees = '{"distribution":{"totalMessageFees":"3"}}'; + program.parse([ + "node", + "test", + "write", + "0xMockedContract", + "updateCounter", + "--fees", + fees, + "--fee-value", + "4", + "--valid-until", + "999", + ]); + + expect(WriteAction.prototype.write).toHaveBeenCalledWith({ + contractAddress: "0xMockedContract", + method: "updateCounter", + args: [], + fees, + feeValue: "4", + validUntil: "999", + }); + }); + test("WriteAction is instantiated when the write command is executed", async () => { program.parse(["node", "test", "write", "0xMockedContract", "anotherMethod"]); expect(WriteAction).toHaveBeenCalledTimes(1); diff --git a/tests/libs/jsonRpcClient.test.ts b/tests/libs/jsonRpcClient.test.ts index 33a0b537..3c2baed5 100644 --- a/tests/libs/jsonRpcClient.test.ts +++ b/tests/libs/jsonRpcClient.test.ts @@ -56,4 +56,22 @@ describe("JsonRpcClient - Successful and Unsuccessful Requests", () => { body: expect.stringContaining('"method":"testMethod"'), }); }); + + test("should reject JSON-RPC error responses even when HTTP status is ok", async () => { + (fetch as Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: "2.0", + error: { code: -32603, message: "RPC failed" }, + id: "1", + }), + }); + + const params: JsonRPCParams = { + method: "testMethod", + params: ["param1"], + }; + + await expect(rpcClient.request(params)).rejects.toThrowError("RPC failed"); + }); }); diff --git a/tsconfig.json b/tsconfig.json index cb0e5b8b..085a4d51 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,9 +39,8 @@ ] /* Allow multiple folders to be treated as one when resolving modules. */, // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ "types": [ - "jest", - "node", - "@types/jest" + "vitest/globals", + "node" ] /* Specify type package names to be included without being referenced in a source file. */, // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */