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
1 change: 1 addition & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ error-code table because they are not errors.
| `CAATINGA_ROLLBACK_TARGET_NOT_FOUND` | Requested contract ID is not in artifact history. | `ctg rollback --to` target was never recorded in `history`. | Run `ctg inspect` or redeploy; use `ctg migrate artifacts` if on schema v1. | Fail automation when rollback target is invalid. | Public code; adding a new code is minor, removal/rename/meaning change is major. |
| `CAATINGA_ESTIMATE_FAILED` | Deploy fee estimate could not be built or simulated. | WASM missing, deploy args unresolved, or Stellar CLI simulate failed. | Run `ctg build`, fix deploy args, then retry `ctg estimate deploy`. | Advisory in CI; do not block deploy pipelines on estimate alone. | Public code; adding a new code is minor, removal/rename/meaning change is major. |
| `CAATINGA_DEPLOY_FAILED` | Contract deployment failed after command execution started. | Stellar CLI deploy returned an error or deployment output could not be accepted. | Inspect deploy logs, network settings, source account, and artifact path. | Fail CI and retry only after the deploy preconditions are fixed. | Public code; adding a new code is minor, removal/rename/meaning change is major. |
| `CAATINGA_MAINNET_CONFIRMATION_REQUIRED` | Mainnet transaction requires explicit confirmation. | Command targeted mainnet without `--yes` or `CAATINGA_ASSUME_YES=true` in non-interactive mode or user declined prompt. | Pass `--yes` or set `CAATINGA_ASSUME_YES=true` in non-interactive CI environments, or confirm prompt. | Fail CI when mainnet transaction runs unattended without `--yes` or `CAATINGA_ASSUME_YES=true`. | Public code; adding a new code is minor, removal/rename/meaning change is major. |
| `CAATINGA_UPLOAD_FAILED` | Contract WASM upload failed after command execution started. | Stellar CLI upload returned an error, or uploaded hash did not match the local WASM file. | Inspect upload logs, network settings, source account, and WASM path. Rebuild if the local hash is stale. | Fail CI and retry only after upload preconditions are fixed. | Public code; adding a new code is minor, removal/rename/meaning change is major. |
| `CAATINGA_BUILD_FAILED` | Contract build failed. | Cargo, Rust target setup, or contract source compilation failed. | Inspect Cargo output and fix the contract build. | Fail CI and treat as a build-blocking error. | Public code; adding a new code is minor, removal/rename/meaning change is major. |
| `CAATINGA_BINDINGS_FAILED` | Binding generation failed. | `npx @stellar/stellar-sdk generate` failed or required deployment metadata was missing. | Deploy the contract if needed and rerun `ctg generate`. | Fail CI and do not publish stale generated clients. | Public code; adding a new code is minor, removal/rename/meaning change is major. |
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/deploy.command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ describe("deploy command", () => {
"mainnet",
"--source",
"alice",
"--yes",
]);

expect(process.exitCode).toBe(1);
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/commands/deploy.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { npxCli } from "../utils/cli-name.js";
import { runCliAction } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { confirmMainnetOperation } from "../utils/mainnet-guardrails.js";
import {
assertZkVerifierDeployAllowed,
resolveContractNamesForDeploy,
Expand All @@ -29,6 +30,7 @@ export function registerDeployCommand(program: Command): void {
"-s, --source <source>",
"Stellar CLI identity alias that can sign (for example alice)"
)
.option("-y, --yes", "Automatically confirm mainnet transactions without interactive prompt")
.option("--force", "Redeploy contracts even if artifacts already contain contract IDs")
.option(
"--if-changed",
Expand All @@ -52,6 +54,7 @@ export function registerDeployCommand(program: Command): void {
options: {
network?: string;
source: string;
yes?: boolean;
force?: boolean;
ifChanged?: boolean;
upgrade?: boolean;
Expand All @@ -75,7 +78,18 @@ export function registerDeployCommand(program: Command): void {
}

const config = await loadConfig();
const { name: networkName } = resolveNetwork(config, options.network);
const { name: networkName, config: networkConfig } = resolveNetwork(config, options.network);

if (!options.dryRun) {
await confirmMainnetOperation({
operation: "deploy",
networkName,
networkConfig,
contractName,
source: options.source,
yes: options.yes,
});
}

if (options.dryRun) {
const target = contractName ?? Object.keys(config.contracts)[0];
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/commands/invoke.command.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Command } from "commander";
import { invokeContract, loadConfig } from "@caatinga/core";
import { invokeContract, loadConfig, resolveNetwork } from "@caatinga/core";
import { runCliAction } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { confirmMainnetOperation } from "../utils/mainnet-guardrails.js";

export function registerInvokeCommand(program: Command): void {
program
Expand All @@ -14,17 +15,30 @@ export function registerInvokeCommand(program: Command): void {
"-s, --source <source>",
"Stellar CLI identity alias that can sign (for example alice)"
)
.option("-y, --yes", "Automatically confirm mainnet transactions without interactive prompt")
.action(
(
target: string,
args: string[],
options: {
network?: string;
source: string;
yes?: boolean;
}
) =>
runCliAction(async () => {
const config = await loadConfig();
const { name: networkName, config: networkConfig } = resolveNetwork(config, options.network);

await confirmMainnetOperation({
operation: "invoke",
networkName,
networkConfig,
target,
source: options.source,
yes: options.yes,
});

const result = await invokeContract({
config,
target,
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/src/commands/rollback.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@caatinga/core";
import { runCliAction } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { confirmMainnetOperation } from "../utils/mainnet-guardrails.js";

/** Stellar contract IDs are base-32 encoded 56-char strings starting with C, or hex 64-char strings. */
const VALID_CONTRACT_ID = /^(C[A-Z2-7]{55}|[0-9a-fA-F]{64})$/;
Expand All @@ -19,7 +20,8 @@ export function registerRollbackCommand(program: Command): void {
.argument("<contract>", "Contract name")
.requiredOption("--to <contractId>", "Historical contract ID to restore")
.option("-n, --network <network>", "Configured network name")
.action((contractName: string, options: { to: string; network?: string }) =>
.option("-y, --yes", "Automatically confirm mainnet transactions without interactive prompt")
.action((contractName: string, options: { to: string; network?: string; yes?: boolean }) =>
runCliAction(async () => {
if (!VALID_CONTRACT_ID.test(options.to)) {
throw new CaatingaError(
Expand All @@ -30,7 +32,16 @@ export function registerRollbackCommand(program: Command): void {
}

const config = await loadConfig();
const { name: networkName } = resolveNetwork(config, options.network);
const { name: networkName, config: networkConfig } = resolveNetwork(config, options.network);

await confirmMainnetOperation({
operation: "rollback",
networkName,
networkConfig,
contractName,
contractId: options.to,
yes: options.yes,
});

const result = await rollbackContractArtifact({
networkName,
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/commands/upgrade.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { npxCli } from "../utils/cli-name.js";
import { runCliAction } from "../utils/errors.js";
import { logger } from "../utils/logger.js";

import { confirmMainnetOperation } from "../utils/mainnet-guardrails.js";

export function registerUpgradeCommand(program: Command): void {
program
.command("upgrade")
Expand All @@ -23,6 +25,7 @@ export function registerUpgradeCommand(program: Command): void {
"-s, --source <source>",
"Stellar CLI identity alias that can sign as contract admin (for example deployer)"
)
.option("-y, --yes", "Automatically confirm mainnet transactions without interactive prompt")
.option(
"--if-changed",
"Skip upgrade when local WASM hash matches the artifact (upgrade when changed)"
Expand All @@ -40,6 +43,7 @@ export function registerUpgradeCommand(program: Command): void {
options: {
network?: string;
source: string;
yes?: boolean;
ifChanged?: boolean;
expectedHash?: string;
build?: boolean;
Expand All @@ -49,7 +53,16 @@ export function registerUpgradeCommand(program: Command): void {
) =>
runCliAction(async () => {
const config = await loadConfig();
const { name: networkName } = resolveNetwork(config, options.network);
const { name: networkName, config: networkConfig } = resolveNetwork(config, options.network);

await confirmMainnetOperation({
operation: "upgrade",
networkName,
networkConfig,
contractName,
source: options.source,
yes: options.yes,
});

const result = await upgradeContractInPlace({
config,
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/src/commands/wire.command.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Command } from "commander";
import { loadConfig, runPostDeployHooks } from "@caatinga/core";
import { loadConfig, resolveNetwork, runPostDeployHooks } from "@caatinga/core";
import { runCliAction } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { confirmMainnetOperation } from "../utils/mainnet-guardrails.js";

export function registerWireCommand(program: Command): void {
program
Expand All @@ -12,15 +13,25 @@ export function registerWireCommand(program: Command): void {
"-s, --source <source>",
"Stellar CLI identity alias that can sign (for example deployer)"
)
.action((options: { network?: string; source: string }) =>
.option("-y, --yes", "Automatically confirm mainnet transactions without interactive prompt")
.action((options: { network?: string; source: string; yes?: boolean }) =>
runCliAction(async () => {
const config = await loadConfig();
const { name: networkName, config: networkConfig } = resolveNetwork(config, options.network);

if (!config.postDeploy || config.postDeploy.length === 0) {
logger.info("No postDeploy hooks configured in caatinga.config.ts.");
return;
}

await confirmMainnetOperation({
operation: "wire",
networkName,
networkConfig,
source: options.source,
yes: options.yes,
});

const results = await runPostDeployHooks({
config,
networkName: options.network,
Expand Down
83 changes: 83 additions & 0 deletions packages/cli/src/utils/mainnet-guardrails.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CaatingaErrorCode } from "@caatinga/core";
import { confirmMainnetOperation } from "./mainnet-guardrails.js";

const mockQuestion = vi.fn();
vi.mock("node:readline/promises", () => ({
default: {
createInterface: () => ({
question: mockQuestion,
close: () => {},
}),
},
}));

describe("confirmMainnetOperation", () => {
const originalEnv = process.env.CAATINGA_ASSUME_YES;
const mainnetConfig = {
rpcUrl: "https://mainnet.sorobanrpc.com",
networkPassphrase: "Public Global Stellar Network ; September 2015",
};
const testnetConfig = {
rpcUrl: "https://soroban-testnet.stellar.org",
networkPassphrase: "Test SDF Network ; September 2015",
};

beforeEach(() => {
delete process.env.CAATINGA_ASSUME_YES;
mockQuestion.mockReset();
});

afterEach(() => {
if (originalEnv !== undefined) {
process.env.CAATINGA_ASSUME_YES = originalEnv;
} else {
delete process.env.CAATINGA_ASSUME_YES;
}
});

it("should_pass_immediately_for_non_mainnet_networks", async () => {
await expect(
confirmMainnetOperation({
operation: "deploy",
networkName: "testnet",
networkConfig: testnetConfig,
})
).resolves.toBeUndefined();
});

it("should_pass_when_yes_option_is_true", async () => {
await expect(
confirmMainnetOperation({
operation: "deploy",
networkName: "mainnet",
networkConfig: mainnetConfig,
yes: true,
})
).resolves.toBeUndefined();
});

it("should_pass_when_CAATINGA_ASSUME_YES_env_var_is_set", async () => {
process.env.CAATINGA_ASSUME_YES = "true";
await expect(
confirmMainnetOperation({
operation: "upgrade",
networkName: "mainnet",
networkConfig: mainnetConfig,
})
).resolves.toBeUndefined();
});

it("should_throw_in_non_interactive_mode_when_not_confirmed", async () => {
// In vitest environment, isTTY is false by default.
await expect(
confirmMainnetOperation({
operation: "deploy",
networkName: "mainnet",
networkConfig: mainnetConfig,
})
).rejects.toMatchObject({
code: CaatingaErrorCode.MAINNET_CONFIRMATION_REQUIRED,
});
});
});
93 changes: 93 additions & 0 deletions packages/cli/src/utils/mainnet-guardrails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import chalk from "chalk";
import type { NetworkConfig } from "@caatinga/core";
import { CaatingaError, CaatingaErrorCode, requiresMainnetConfirmation } from "@caatinga/core";
import { logger } from "./logger.js";

export type MainnetOperationDetails = {
operation: "deploy" | "upgrade" | "wire" | "invoke" | "rollback";
networkName: string;
networkConfig: NetworkConfig;
source?: string;
contractName?: string;
contractId?: string;
wasmHash?: string;
target?: string;
method?: string;
yes?: boolean;
};

export function isAssumeYesSet(): boolean {
const envVal = process.env.CAATINGA_ASSUME_YES?.toLowerCase().trim();
return envVal === "true" || envVal === "1" || envVal === "yes" || envVal === "y";
}

export async function confirmMainnetOperation(
details: MainnetOperationDetails
): Promise<void> {
const { networkName, networkConfig, operation, yes } = details;

if (!requiresMainnetConfirmation(networkName, networkConfig)) {
return;
}

if (yes || isAssumeYesSet()) {
logger.warn(
`[MAINNET GUARDRAIL] Mainnet operation "${operation.toUpperCase()}" automatically confirmed via --yes or CAATINGA_ASSUME_YES.`
);
return;
}

const isInteractive = Boolean(input.isTTY && output.isTTY);

if (!isInteractive) {
throw new CaatingaError(
`Mainnet operation "${operation.toUpperCase()}" requires interactive confirmation.`,
CaatingaErrorCode.MAINNET_CONFIRMATION_REQUIRED,
"Pass --yes or set environment variable CAATINGA_ASSUME_YES=true to confirm unattended mainnet transactions in CI/non-interactive environments."
);
}

logger.info("");
logger.info(chalk.bgRed.white.bold(" ⚠️ WARNING: MAINNET TRANSACTION "));
logger.info(chalk.red(`You are about to execute an irreversible signed mainnet operation.`));
logger.info(` Operation: ${chalk.yellow(operation.toUpperCase())}`);
logger.info(` Network: ${chalk.yellow(networkName)} (${networkConfig.rpcUrl})`);

if (details.contractName) {
logger.info(` Contract: ${details.contractName}`);
}
if (details.target) {
logger.info(` Target: ${details.target}`);
}
if (details.contractId) {
logger.info(` Contract ID: ${details.contractId}`);
}
if (details.wasmHash) {
logger.info(` WASM Hash: ${details.wasmHash}`);
}
if (details.source) {
logger.info(` Source Acc: ${details.source}`);
}
logger.info("");

const rl = readline.createInterface({ input, output });
let answer = "";
try {
answer = await rl.question(
chalk.yellow.bold(`Are you sure you want to proceed with this MAINNET transaction? [y/N]: `)
);
} finally {
rl.close();
}

const confirmed = /^(y|yes)$/i.test(answer.trim());
if (!confirmed) {
throw new CaatingaError(
`Mainnet operation "${operation.toUpperCase()}" cancelled by user.`,
CaatingaErrorCode.MAINNET_CONFIRMATION_REQUIRED,
"Operation aborted. No transaction was signed or submitted to mainnet."
);
}
}
Loading