diff --git a/docs/errors.md b/docs/errors.md index 7698def1..1cfaab9d 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -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. | diff --git a/packages/cli/src/commands/deploy.command.test.ts b/packages/cli/src/commands/deploy.command.test.ts index 24647fb6..0bd6cb19 100644 --- a/packages/cli/src/commands/deploy.command.test.ts +++ b/packages/cli/src/commands/deploy.command.test.ts @@ -294,6 +294,7 @@ describe("deploy command", () => { "mainnet", "--source", "alice", + "--yes", ]); expect(process.exitCode).toBe(1); diff --git a/packages/cli/src/commands/deploy.command.ts b/packages/cli/src/commands/deploy.command.ts index 8f6523f7..ee6b3cfc 100644 --- a/packages/cli/src/commands/deploy.command.ts +++ b/packages/cli/src/commands/deploy.command.ts @@ -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, @@ -29,6 +30,7 @@ export function registerDeployCommand(program: Command): void { "-s, --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", @@ -52,6 +54,7 @@ export function registerDeployCommand(program: Command): void { options: { network?: string; source: string; + yes?: boolean; force?: boolean; ifChanged?: boolean; upgrade?: boolean; @@ -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]; diff --git a/packages/cli/src/commands/invoke.command.ts b/packages/cli/src/commands/invoke.command.ts index 7e541900..fdf945b6 100644 --- a/packages/cli/src/commands/invoke.command.ts +++ b/packages/cli/src/commands/invoke.command.ts @@ -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 @@ -14,6 +15,7 @@ export function registerInvokeCommand(program: Command): void { "-s, --source ", "Stellar CLI identity alias that can sign (for example alice)" ) + .option("-y, --yes", "Automatically confirm mainnet transactions without interactive prompt") .action( ( target: string, @@ -21,10 +23,22 @@ export function registerInvokeCommand(program: Command): void { 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, diff --git a/packages/cli/src/commands/rollback.command.ts b/packages/cli/src/commands/rollback.command.ts index b519a0c0..e1689fe6 100644 --- a/packages/cli/src/commands/rollback.command.ts +++ b/packages/cli/src/commands/rollback.command.ts @@ -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})$/; @@ -19,7 +20,8 @@ export function registerRollbackCommand(program: Command): void { .argument("", "Contract name") .requiredOption("--to ", "Historical contract ID to restore") .option("-n, --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( @@ -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, diff --git a/packages/cli/src/commands/upgrade.command.ts b/packages/cli/src/commands/upgrade.command.ts index 3f49107b..5708982f 100644 --- a/packages/cli/src/commands/upgrade.command.ts +++ b/packages/cli/src/commands/upgrade.command.ts @@ -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") @@ -23,6 +25,7 @@ export function registerUpgradeCommand(program: Command): void { "-s, --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)" @@ -40,6 +43,7 @@ export function registerUpgradeCommand(program: Command): void { options: { network?: string; source: string; + yes?: boolean; ifChanged?: boolean; expectedHash?: string; build?: boolean; @@ -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, diff --git a/packages/cli/src/commands/wire.command.ts b/packages/cli/src/commands/wire.command.ts index a19b4d3e..bfd23691 100644 --- a/packages/cli/src/commands/wire.command.ts +++ b/packages/cli/src/commands/wire.command.ts @@ -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 @@ -12,15 +13,25 @@ export function registerWireCommand(program: Command): void { "-s, --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, diff --git a/packages/cli/src/utils/mainnet-guardrails.test.ts b/packages/cli/src/utils/mainnet-guardrails.test.ts new file mode 100644 index 00000000..dd41bfcd --- /dev/null +++ b/packages/cli/src/utils/mainnet-guardrails.test.ts @@ -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, + }); + }); +}); diff --git a/packages/cli/src/utils/mainnet-guardrails.ts b/packages/cli/src/utils/mainnet-guardrails.ts new file mode 100644 index 00000000..9b4b8c3b --- /dev/null +++ b/packages/cli/src/utils/mainnet-guardrails.ts @@ -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 { + 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." + ); + } +} diff --git a/packages/core/src/config/config.schema.ts b/packages/core/src/config/config.schema.ts index 0a86238e..23a5ef88 100644 --- a/packages/core/src/config/config.schema.ts +++ b/packages/core/src/config/config.schema.ts @@ -14,6 +14,7 @@ export const ContractConfigSchema = z.object({ export const NetworkConfigSchema = z.object({ rpcUrl: z.string().url(), networkPassphrase: z.string().min(1), + requireConfirmation: z.boolean().optional(), }); const ZkCircuitSchema = z.object({ diff --git a/packages/core/src/contracts/deploy-contract.ts b/packages/core/src/contracts/deploy-contract.ts index 213df976..efa5bcd2 100644 --- a/packages/core/src/contracts/deploy-contract.ts +++ b/packages/core/src/contracts/deploy-contract.ts @@ -7,6 +7,7 @@ import { collectDeploymentMetadata } from "../artifacts/metadata.js"; import type { CaatingaConfig } from "../config/config.schema.js"; import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; import { resolveNetwork } from "../networks/resolve-network.js"; +import { isMainnetNetwork } from "../networks/mainnet-guardrails.js"; import { checkBinary } from "../shell/check-binary.js"; import { runCommand } from "../shell/run-command.js"; import { buildStellarNetworkArgs } from "../stellar-cli/build-stellar-network-args.js"; @@ -153,7 +154,10 @@ export async function deployContract(options: DeployContractOptions) { ]; let deployOutcome: { output: string; contractId: string } | undefined; - const retryDelaysMs = options.deployRetryDelaysMs ?? DEFAULT_DEPLOY_RETRY_DELAYS_MS; + const defaultRetryDelays = isMainnetNetwork(network.name, network.config) + ? [] + : DEFAULT_DEPLOY_RETRY_DELAYS_MS; + const retryDelaysMs = options.deployRetryDelaysMs ?? defaultRetryDelays; const maxDeployAttempts = retryDelaysMs.length + 1; for (let attempt = 0; attempt < maxDeployAttempts; attempt++) { diff --git a/packages/core/src/contracts/run-post-deploy.ts b/packages/core/src/contracts/run-post-deploy.ts index 74d9dd97..02292ae5 100644 --- a/packages/core/src/contracts/run-post-deploy.ts +++ b/packages/core/src/contracts/run-post-deploy.ts @@ -2,6 +2,7 @@ import { readArtifacts } from "../artifacts/read-artifacts.js"; import type { CaatingaConfig, PostDeployHook } from "../config/config.schema.js"; import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; import { resolveNetwork } from "../networks/resolve-network.js"; +import { isMainnetNetwork } from "../networks/mainnet-guardrails.js"; import { checkBinary } from "../shell/check-binary.js"; import { isTransientCaatingaFailure } from "../shell/is-transient-command-failure.js"; import { runCommand } from "../shell/run-command.js"; @@ -183,7 +184,10 @@ export async function runPostDeployHooks( }); output = readResult.result?.trim() ?? ""; } else { - const retryDelaysMs = options.hookRetryDelaysMs ?? DEFAULT_HOOK_RETRY_DELAYS_MS; + const defaultRetryDelays = isMainnetNetwork(network.name, network.config) + ? [] + : DEFAULT_HOOK_RETRY_DELAYS_MS; + const retryDelaysMs = options.hookRetryDelaysMs ?? defaultRetryDelays; const maxHookAttempts = retryDelaysMs.length + 1; let result: { stdout: string; stderr: string; all: string } = undefined!; diff --git a/packages/core/src/contracts/upgrade-contract.ts b/packages/core/src/contracts/upgrade-contract.ts index 7bf0aea8..f8cbe2ed 100644 --- a/packages/core/src/contracts/upgrade-contract.ts +++ b/packages/core/src/contracts/upgrade-contract.ts @@ -6,6 +6,7 @@ import { collectDeploymentMetadata } from "../artifacts/metadata.js"; import type { CaatingaConfig } from "../config/config.schema.js"; import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; import { resolveNetwork } from "../networks/resolve-network.js"; +import { isMainnetNetwork } from "../networks/mainnet-guardrails.js"; import { checkBinary } from "../shell/check-binary.js"; import { isTransientCaatingaFailure } from "../shell/is-transient-command-failure.js"; import { runCommand } from "../shell/run-command.js"; @@ -114,7 +115,10 @@ export async function upgradeContractInPlace( expectedHash: options.expectedHash, }); - const retryDelaysMs = options.upgradeRetryDelaysMs ?? DEFAULT_UPGRADE_RETRY_DELAYS_MS; + const defaultRetryDelays = isMainnetNetwork(network.name, network.config) + ? [] + : DEFAULT_UPGRADE_RETRY_DELAYS_MS; + const retryDelaysMs = options.upgradeRetryDelaysMs ?? defaultRetryDelays; const maxAttempts = retryDelaysMs.length + 1; for (let attempt = 0; attempt < maxAttempts; attempt++) { diff --git a/packages/core/src/errors/CaatingaErrorCode.ts b/packages/core/src/errors/CaatingaErrorCode.ts index 4dbcdf7c..dc00be56 100644 --- a/packages/core/src/errors/CaatingaErrorCode.ts +++ b/packages/core/src/errors/CaatingaErrorCode.ts @@ -60,6 +60,7 @@ export const CaatingaErrorCode = { POST_DEPLOY_VERIFY_FAILED: "CAATINGA_POST_DEPLOY_VERIFY_FAILED", ADDRESS_ALIAS_UNRESOLVED: "CAATINGA_ADDRESS_ALIAS_UNRESOLVED", NETWORK_ARTIFACTS_MISSING: "CAATINGA_NETWORK_ARTIFACTS_MISSING", + MAINNET_CONFIRMATION_REQUIRED: "CAATINGA_MAINNET_CONFIRMATION_REQUIRED", } as const; export type CaatingaErrorCodeValue = (typeof CaatingaErrorCode)[keyof typeof CaatingaErrorCode]; diff --git a/packages/core/src/errors/error-surface.test.ts b/packages/core/src/errors/error-surface.test.ts index 5da52657..cfb4a41a 100644 --- a/packages/core/src/errors/error-surface.test.ts +++ b/packages/core/src/errors/error-surface.test.ts @@ -270,6 +270,10 @@ const productionTriggerTests: Record { + const testnetConfig = { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + }; + + const mainnetConfig = { + rpcUrl: "https://mainnet.sorobanrpc.com", + networkPassphrase: "Public Global Stellar Network ; September 2015", + }; + + it("should_identify_mainnet_by_name_or_passphrase", () => { + expect(isMainnetNetwork("mainnet", testnetConfig)).toBe(true); + expect(isMainnetNetwork("custom-main", mainnetConfig)).toBe(true); + expect(isMainnetNetwork("testnet", testnetConfig)).toBe(false); + }); + + it("should_require_confirmation_for_mainnet_by_default", () => { + expect(requiresMainnetConfirmation("mainnet", testnetConfig)).toBe(true); + expect(requiresMainnetConfirmation("prod", mainnetConfig)).toBe(true); + expect(requiresMainnetConfirmation("testnet", testnetConfig)).toBe(false); + }); + + it("should_respect_requireConfirmation_config_knob", () => { + expect( + requiresMainnetConfirmation("testnet", { ...testnetConfig, requireConfirmation: true }) + ).toBe(true); + expect( + requiresMainnetConfirmation("mainnet", { ...mainnetConfig, requireConfirmation: false }) + ).toBe(false); + }); + + it("should_keep_isMainnetNetwork_true_when_requireConfirmation_is_false", () => { + const optOutMainnet = { ...mainnetConfig, requireConfirmation: false }; + expect(isMainnetNetwork("mainnet", optOutMainnet)).toBe(true); + expect(requiresMainnetConfirmation("mainnet", optOutMainnet)).toBe(false); + }); +}); diff --git a/packages/core/src/networks/mainnet-guardrails.ts b/packages/core/src/networks/mainnet-guardrails.ts new file mode 100644 index 00000000..d6105d2b --- /dev/null +++ b/packages/core/src/networks/mainnet-guardrails.ts @@ -0,0 +1,17 @@ +import type { NetworkConfig } from "../config/config.schema.js"; + +const MAINNET_PASSPHRASE = "Public Global Stellar Network ; September 2015"; + +export function isMainnetNetwork(networkName: string, networkConfig: NetworkConfig): boolean { + return networkName === "mainnet" || networkConfig.networkPassphrase === MAINNET_PASSPHRASE; +} + +export function requiresMainnetConfirmation( + networkName: string, + networkConfig: NetworkConfig +): boolean { + if (networkConfig.requireConfirmation !== undefined) { + return networkConfig.requireConfirmation; + } + return isMainnetNetwork(networkName, networkConfig); +}