diff --git a/bin/index.ts b/bin/index.ts
index 3c09961..a338683 100644
--- a/bin/index.ts
+++ b/bin/index.ts
@@ -80,6 +80,7 @@ interface CommandOptions {
attestRecipient?: string;
attestReason?: string;
rns?: string;
+ write?: boolean;
}
const orange = chalk.rgb(255, 165, 0);
@@ -319,10 +320,12 @@ program
.description("Interact with a contract")
.requiredOption("-a, --address
", "Address of a verified contract")
.option("-t, --testnet", "Deploy on the testnet")
+ .option("--write", "Call write (nonpayable/payable) functions")
.action(async (options: CommandOptions) => {
await ReadContract({
address: options.address! as `0x${string}`,
testnet: !!options.testnet,
+ write: !!options.write,
});
});
diff --git a/package.json b/package.json
index cf113e6..5fff854 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"tx-status": "pnpm run build && node dist/bin/index.js tx --testnet --txid 0x876a0a9b167889350c41930a4204e5d9acf5704a7f201447a337094189af961c4",
"batch-transfer": "pnpm run build && node dist/bin/index.js batch-transfer",
"history": "pnpm run build && node dist/bin/index.js history",
- "config": "pnpm run build && node dist/bin/index.js config"
+ "config": "pnpm run build && node dist/bin/index.js config",
+ "contract:write": "node dist/bin/index.js contract --testnet -a 0xcb46c0ddc60d18efeb0e586c17af6ea36452dae0 --write"
},
"keywords": [
"rootstock",
diff --git a/src/commands/contract.ts b/src/commands/contract.ts
index 643ef7a..4926064 100644
--- a/src/commands/contract.ts
+++ b/src/commands/contract.ts
@@ -1,6 +1,8 @@
import inquirer from "inquirer";
+import { parseEther, formatEther } from "viem";
import ViemProvider from "../utils/viemProvider.js";
import { ContractResult } from "../utils/types.js";
+import { getExplorerUrl } from "../utils/constants.js";
import { logError, logSuccess, logInfo, logWarning } from "../utils/logger.js";
import { createSpinner } from "../utils/spinner.js";
@@ -12,6 +14,7 @@ type InquirerAnswers = {
type ContractCommandOptions = {
address: `0x${string}`;
testnet: boolean;
+ write?: boolean;
isExternal?: boolean;
functionName?: string;
args?: string[];
@@ -23,6 +26,34 @@ function isValidAddress(address: string): boolean {
return regex.test(address);
}
+function coerceArg(value: string, solidityType: string): any {
+ if (solidityType.endsWith("[]") || solidityType.startsWith("tuple")) {
+ try {
+ return JSON.parse(value);
+ } catch {
+ throw new Error(`Expected JSON for type ${solidityType}, got: ${value}`);
+ }
+ }
+ if (/^u?int(\d+)?$/.test(solidityType)) {
+ return BigInt(value);
+ }
+ if (solidityType === "address") {
+ if (!isValidAddress(value)) throw new Error(`Invalid address: ${value}`);
+ return value;
+ }
+ if (solidityType === "bool") {
+ if (value === "true") return true;
+ if (value === "false") return false;
+ throw new Error(`Invalid bool: "${value}". Use "true" or "false".`);
+ }
+ if (/^bytes(\d+)?$/.test(solidityType)) {
+ if (!/^0x[a-fA-F0-9]*$/.test(value))
+ throw new Error(`Invalid hex for type ${solidityType}: ${value}`);
+ return value;
+ }
+ return value;
+}
+
export async function ReadContract(
params: ContractCommandOptions
): Promise {
@@ -82,6 +113,138 @@ export async function ReadContract(
const { abi } = resData.data;
+ if (params.write) {
+ spinner.stop();
+
+ const writeFunctions = abi.filter(
+ (item: any) =>
+ item.type === "function" &&
+ (item.stateMutability === "nonpayable" ||
+ item.stateMutability === "payable")
+ );
+
+ if (writeFunctions.length === 0) {
+ logWarning(isExternal, "No write functions found in this contract.");
+ return { error: "No write functions found in this contract.", success: false };
+ }
+
+ const { selectedWriteFn } = await inquirer.prompt<{ selectedWriteFn: string }>([
+ {
+ type: "list",
+ name: "selectedWriteFn",
+ message: "Select a write function to call:",
+ choices: writeFunctions.map((item: any) => item.name),
+ },
+ ]);
+
+ logSuccess(isExternal, `📜 You selected: ${selectedWriteFn}`);
+
+ const selectedAbiWriteFn = writeFunctions.find(
+ (item: any) => item.name === selectedWriteFn
+ );
+
+ let writeArgs: any[] = [];
+ if (selectedAbiWriteFn.inputs?.length > 0) {
+ const argAnswers = await inquirer.prompt(
+ selectedAbiWriteFn.inputs.map((input: any) => ({
+ type: "input",
+ name: input.name,
+ message: `Enter value for ${input.name} (${input.type}):`,
+ }))
+ );
+ try {
+ writeArgs = selectedAbiWriteFn.inputs.map((input: any) =>
+ coerceArg(argAnswers[input.name], input.type)
+ );
+ } catch (err: any) {
+ logError(isExternal, `Invalid input: ${err.message}`);
+ return { error: err.message, success: false };
+ }
+ }
+
+ let payableValue: bigint | undefined;
+ if (selectedAbiWriteFn.stateMutability === "payable") {
+ const { rbtcValue } = await inquirer.prompt<{ rbtcValue: string }>([
+ {
+ type: "input",
+ name: "rbtcValue",
+ message: "RBTC value to send (e.g. 0.01):",
+ },
+ ]);
+ const parsed = parseFloat(rbtcValue);
+ if (isNaN(parsed) || parsed <= 0) {
+ logError(isExternal, "Invalid RBTC value. Enter a positive number (e.g. 0.01).");
+ return { error: "Invalid RBTC value.", success: false };
+ }
+ payableValue = parseEther(rbtcValue);
+ }
+
+ const provider = new ViemProvider(params.testnet);
+ const publicClient = await provider.getPublicClient();
+ const { client: walletClient } = await provider.getWalletClientWithPassword();
+ const account = walletClient.account!;
+
+ if (payableValue !== undefined) {
+ const balance = await publicClient.getBalance({ address: account.address });
+ if (balance < payableValue) {
+ logError(isExternal, `Insufficient RBTC balance. Have ${formatEther(balance)} RBTC, need ${formatEther(payableValue)} RBTC.`);
+ return { error: "Insufficient RBTC balance.", success: false };
+ }
+ }
+
+ spinner.start("⏳ Simulating transaction...");
+
+ let simulateRequest: any;
+ try {
+ const { request } = await publicClient.simulateContract({
+ account,
+ address,
+ abi,
+ functionName: selectedWriteFn,
+ args: writeArgs,
+ ...(payableValue !== undefined && { value: payableValue }),
+ });
+ simulateRequest = request;
+ } catch (err: any) {
+ spinner.fail("❌ Simulation failed.");
+ logError(isExternal, err.shortMessage || err.message || "Simulation reverted.");
+ return { error: err.shortMessage || err.message, success: false };
+ }
+
+ spinner.succeed("✅ Simulation passed.");
+ spinner.start("⏳ Submitting transaction...");
+
+ const txHash = await walletClient.writeContract(simulateRequest);
+ spinner.stop();
+ logSuccess(isExternal, `🔄 Transaction submitted. Hash: ${txHash}`);
+
+ spinner.start("⏳ Waiting for confirmation...");
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
+ spinner.stop();
+
+ const explorerUrl = getExplorerUrl(params.testnet, "tx", txHash);
+
+ if (receipt.status === "success") {
+ logSuccess(isExternal, "✅ Transaction confirmed!");
+ logInfo(isExternal, `📦 Block: ${receipt.blockNumber}`);
+ logInfo(isExternal, `⛽ Gas used: ${receipt.gasUsed}`);
+ logInfo(isExternal, `🔗 View on Explorer: ${explorerUrl}`);
+ return {
+ success: true,
+ data: {
+ contractAddress: address,
+ network: params.testnet ? "Rootstock Testnet" : "Rootstock Mainnet",
+ functionName: selectedWriteFn,
+ result: txHash,
+ explorerUrl,
+ },
+ };
+ } else {
+ logError(isExternal, "❌ Transaction failed.");
+ return { error: "Transaction failed.", success: false };
+ }
+ }
+
const readFunctions = abi.filter(
(item: any) =>
item.type === "function" &&