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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
31 changes: 31 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ CLI deployment orchestrator and management toolkit for **bc-forge** Soroban smar
- [`verify-hash`](#verify-hash)
- [`smoke-test`](#smoke-test)
- [`generate-bindings`](#generate-bindings)
- [`export-deployments`](#export-deployments)
- [Workflow Examples](#workflow-examples)
- [Deploy & Status Check](#1-deploy--status-check)
- [Contract WASM Upgrade](#2-contract-wasm-upgrade)
Expand Down Expand Up @@ -273,6 +274,36 @@ bc-forge generate-bindings \

---

### `export-deployments`

Exports deployed Contract IDs and transaction hashes to `deployments.json` safely using atomic file overwriting.

```bash
bc-forge export-deployments [options]
```

#### Options

- `-o, --out <path>`: Target output JSON file path (default: `deployments.json`).
- `-c, --config <file>`: Deployment configuration file to load contract entries from (default: `.bc-forge.json`).
- `--vault-id <id>`: Vault contract ID override.
- `--fee-id <id>`: Fee contract ID override.
- `--tx-hash <hash>`: Transaction hash to include in the output.
- `--network <name>`: Stellar network name (e.g. `testnet`, `mainnet`).

#### Example

```bash
bc-forge export-deployments \
--out deployments.json \
--vault-id CDEX...123 \
--fee-id CFEE...456 \
--tx-hash 0xabc...123 \
--network testnet
```

---

## Workflow Examples

### 1. Deploy & Status Check
Expand Down
22 changes: 20 additions & 2 deletions cli/src/__tests__/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**
/**
* CLI deploy command tests (#746)
*
* Tests for deployVault() and the createDeployCommand() factory.
Expand All @@ -7,6 +7,7 @@

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { deployVault, createDeployCommand, type DeployVaultOptions } from '../commands/deploy.js';

// ─── Mocks ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -156,6 +157,22 @@ describe('CLI deploy command (#746)', () => {
expect(result.success).toBe(false);
expect(result.message).toMatch(/Deployment failed/);
});

it('exports deployment artifacts JSON when out option is specified', async () => {
spawnMock
.mockReturnValueOnce(fakeSpawn('hash_vault_wasm'))
.mockReturnValueOnce(fakeSpawn('CVAULT_EXPORT_TEST'))
.mockReturnValueOnce(fakeSpawn(''));

const outPath = '/tmp/test-deployments-out.json';
const result = await deployVault({
...COMMON_OPTS,
out: outPath,
});

expect(result.success).toBe(true);
expect(result.outPath).toBe(path.resolve(outPath));
});
});

describe('createDeployCommand', () => {
Expand All @@ -175,10 +192,11 @@ describe('CLI deploy command (#746)', () => {
expect(optionNames).toContain('--symbol');
});

it('has optional --fee-wasm and --dry-run options', () => {
it('has optional --fee-wasm, --out, and --dry-run options', () => {
const cmd = createDeployCommand();
const optionNames = cmd.options.map((o) => o.long);
expect(optionNames).toContain('--fee-wasm');
expect(optionNames).toContain('--out');
expect(optionNames).toContain('--dry-run');
});
});
Expand Down
188 changes: 188 additions & 0 deletions cli/src/__tests__/deployments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
buildDeploymentArtifacts,
exportDeploymentsToFile,
loadDeploymentsFromFile,
DeploymentArtifacts,
} from '../utils/deployments.js';
import { createExportDeploymentsCommand } from '../commands/export-deployments.js';

describe('Deployments Export Utilities & Command', () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deployments-test-'));
});

afterEach(() => {
if (fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

describe('buildDeploymentArtifacts', () => {
it('constructs a complete deployment artifact payload', () => {
const artifact = buildDeploymentArtifacts({
network: 'testnet',
rpcUrl: 'https://soroban-testnet.stellar.org',
vaultContractId: 'CVAULT12345',
vaultWasmHash: 'hashvault123',
feeContractId: 'CFEE12345',
feeWasmHash: 'hashfee123',
linkTxHash: 'txlink123',
});

expect(artifact.version).toBe('1.0.0');
expect(artifact.network).toBe('testnet');
expect(artifact.rpcUrl).toBe('https://soroban-testnet.stellar.org');
expect(artifact.timestamp).toBeDefined();
expect(artifact.contracts.vault).toEqual({
contractId: 'CVAULT12345',
wasmHash: 'hashvault123',
deployedAt: expect.any(String),
});
expect(artifact.contracts.fee).toEqual({
contractId: 'CFEE12345',
wasmHash: 'hashfee123',
deployedAt: expect.any(String),
});
expect(artifact.txHashes).toEqual({
linkTxHash: 'txlink123',
});
});
});

describe('exportDeploymentsToFile & loadDeploymentsFromFile', () => {
it('saves deployment artifacts to JSON file and loads them back', () => {
const targetFile = path.join(tmpDir, 'deployments.json');
const artifacts: DeploymentArtifacts = {
version: '1.0.0',
network: 'testnet',
timestamp: new Date().toISOString(),
contracts: {
token: {
contractId: 'CTOKEN123',
wasmHash: 'wasmhash123',
},
},
txHashes: {
deployTx: 'tx123456',
},
};

const result = exportDeploymentsToFile(artifacts, targetFile);
expect(result.success).toBe(true);
expect(result.filePath).toBe(path.resolve(targetFile));
expect(fs.existsSync(targetFile)).toBe(true);

const loadResult = loadDeploymentsFromFile(targetFile);
expect(loadResult.success).toBe(true);
expect(loadResult.artifacts).toEqual(artifacts);
});

it('handles overwrites safely without leaving temporary files', () => {
const targetFile = path.join(tmpDir, 'deployments.json');
const initialArtifacts: DeploymentArtifacts = {
version: '1.0.0',
timestamp: new Date().toISOString(),
contracts: {
vault: { contractId: 'OLD_VAULT_ID' },
},
};

// First write
exportDeploymentsToFile(initialArtifacts, targetFile);
expect(fs.readFileSync(targetFile, 'utf-8')).toContain('OLD_VAULT_ID');

// Overwrite
const updatedArtifacts: DeploymentArtifacts = {
version: '1.0.0',
timestamp: new Date().toISOString(),
contracts: {
vault: { contractId: 'NEW_VAULT_ID' },
},
};

const overwriteResult = exportDeploymentsToFile(updatedArtifacts, targetFile);
expect(overwriteResult.success).toBe(true);
expect(fs.readFileSync(targetFile, 'utf-8')).toContain('NEW_VAULT_ID');
expect(fs.readFileSync(targetFile, 'utf-8')).not.toContain('OLD_VAULT_ID');

// Check no .tmp files remain in tmpDir
const filesInDir = fs.readdirSync(tmpDir);
const tmpFiles = filesInDir.filter((f) => f.endsWith('.tmp'));
expect(tmpFiles).toHaveLength(0);
});

it('creates missing nested target directories', () => {
const nestedFile = path.join(tmpDir, 'nested', 'sub', 'deployments.json');
const artifacts: DeploymentArtifacts = {
version: '1.0.0',
timestamp: new Date().toISOString(),
contracts: {
vault: { contractId: 'CNESTED123' },
},
};

const result = exportDeploymentsToFile(artifacts, nestedFile);
expect(result.success).toBe(true);
expect(fs.existsSync(nestedFile)).toBe(true);
});

it('returns error when loading from a non-existent file', () => {
const result = loadDeploymentsFromFile(path.join(tmpDir, 'non-existent.json'));
expect(result.success).toBe(false);
expect(result.error).toContain('Deployment file not found');
});

it('returns error when loading invalid JSON content', () => {
const invalidFile = path.join(tmpDir, 'invalid.json');
fs.writeFileSync(invalidFile, '{ broken json', 'utf-8');

const result = loadDeploymentsFromFile(invalidFile);
expect(result.success).toBe(false);
expect(result.error).toContain('Failed to read or parse deployment file');
});

it('returns error when loading non-object or missing contracts field JSON', () => {
const invalidSchemaFile = path.join(tmpDir, 'bad-schema.json');
fs.writeFileSync(invalidSchemaFile, JSON.stringify({ foo: 'bar' }), 'utf-8');

const result = loadDeploymentsFromFile(invalidSchemaFile);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid deployment JSON schema');
});
});

describe('export-deployments Command', () => {
it('exports deployment artifacts via CLI options', async () => {
const targetPath = path.join(tmpDir, 'deployments-cli.json');
const cmd = createExportDeploymentsCommand();

await cmd.parseAsync([
'node',
'export-deployments',
'--out',
targetPath,
'--vault-id',
'CVAULT_CLI_TEST',
'--fee-id',
'CFEE_CLI_TEST',
'--tx-hash',
'0x123456789abcdef',
'--network',
'testnet',
]);

const loadResult = loadDeploymentsFromFile(targetPath);
expect(loadResult.success).toBe(true);
expect(loadResult.artifacts?.contracts.vault.contractId).toBe('CVAULT_CLI_TEST');
expect(loadResult.artifacts?.contracts.fee.contractId).toBe('CFEE_CLI_TEST');
expect(loadResult.artifacts?.txHashes?.exportTxHash).toBe('0x123456789abcdef');
expect(loadResult.artifacts?.network).toBe('testnet');
});
});
});
27 changes: 27 additions & 0 deletions cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { addNetworkOptions, explicitNetworkOverrides } from '../network.js';
import { getClientConfig } from '../utils/config.js';
import { buildDeploymentArtifacts, exportDeploymentsToFile } from '../utils/deployments.js';
import logger from '../utils/logger.js';

// ─── Types ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -32,6 +33,8 @@ export interface DeployVaultOptions {
network?: string;
/** If true, print commands but do not execute them. */
dryRun?: boolean;
/** Target path to export deployed contract IDs and transaction hashes (e.g. "deployments.json"). */
out?: string;
}

export interface DeployVaultResult {
Expand All @@ -41,6 +44,7 @@ export interface DeployVaultResult {
vaultWasmHash?: string;
feeWasmHash?: string;
linkTxHash?: string;
outPath?: string;
message: string;
steps: string[];
}
Expand Down Expand Up @@ -283,6 +287,23 @@ export async function deployVault(opts: DeployVaultOptions): Promise<DeployVault
}
}

let outPath: string | undefined;
if (opts.out) {
const artifacts = buildDeploymentArtifacts({
network: opts.network,
rpcUrl: opts.rpcUrl,
vaultContractId,
vaultWasmHash,
feeContractId,
feeWasmHash,
linkTxHash,
});
const exportRes = exportDeploymentsToFile(artifacts, opts.out);
if (exportRes.success) {
outPath = exportRes.filePath;
}
}

const message = dryRun
? 'Dry-run completed — no contracts were actually deployed.'
: `Vault deployment complete. Contract ID: ${vaultContractId ?? '(dry-run)'}`;
Expand All @@ -294,6 +315,7 @@ export async function deployVault(opts: DeployVaultOptions): Promise<DeployVault
vaultWasmHash,
feeWasmHash,
linkTxHash,
outPath,
message,
steps,
};
Expand All @@ -319,6 +341,7 @@ export function createDeployCommand(): Command {
.requiredOption('--name <name>', 'Human-readable name for the wrapped token')
.requiredOption('--symbol <symbol>', 'Ticker symbol for the wrapped token')
.option('--decimals <n>', 'Decimal places (default: 7)', '7')
.option('-o, --out <path>', 'Output file path to export deployment artifact JSON (e.g. deployments.json)')
.option('--dry-run', 'Print commands but do not execute them', false);

addNetworkOptions(cmd);
Expand All @@ -340,6 +363,7 @@ export function createDeployCommand(): Command {
networkPassphrase: netCfg.networkPassphrase,
network: netCfg.network,
dryRun: opts.dryRun,
out: opts.out,
});

if (result.success) {
Expand All @@ -350,6 +374,9 @@ export function createDeployCommand(): Command {
if (result.feeContractId) {
logger.info(` Fee contract ID : ${result.feeContractId}`);
}
if (result.outPath) {
logger.info(` Artifact exported : ${result.outPath}`);
}
} else {
logger.error(result.message);
process.exitCode = 1;
Expand Down
Loading
Loading