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
3 changes: 3 additions & 0 deletions bin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ interface CommandOptions {
attestRecipient?: string;
attestReason?: string;
rns?: string;
write?: boolean;
}

const orange = chalk.rgb(255, 165, 0);
Expand Down Expand Up @@ -319,10 +320,12 @@ program
.description("Interact with a contract")
.requiredOption("-a, --address <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,
});
});

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
163 changes: 163 additions & 0 deletions src/commands/contract.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -12,6 +14,7 @@ type InquirerAnswers = {
type ContractCommandOptions = {
address: `0x${string}`;
testnet: boolean;
write?: boolean;
isExternal?: boolean;
functionName?: string;
args?: string[];
Expand All @@ -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<ContractResult | void> {
Expand Down Expand Up @@ -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" &&
Expand Down
Loading