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
107 changes: 107 additions & 0 deletions cli/src/commands/orchestrator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Command } from "commander";
import logger from "../utils/logger.js";
import { initializeSuperAdmin } from "../orchestrator/init-superadmin.js";
import { connectContractIds } from "../orchestrator/connect-contracts.js";
import { runDeploymentOrchestrator } from "../orchestrator/orchestrator.js";

export function createInitSuperAdminCommand(): Command {
return new Command("init-superadmin")
.description("Initialize contract natively with deployer as SuperAdmin and verify on-chain")
.option("--contract-id <string>", "Contract ID to initialize")
.option("--deployer <string>", "Deployer Stellar public key (G...)")
.option("--secret-key <string>", "Deployer secret key (S...)")
.option("--name <string>", "Token name")
.option("--symbol <string>", "Token symbol")
.option("--decimals <number>", "Decimal places")
.option("--no-verify", "Skip on-chain SuperAdmin verification")
.action(async (options) => {
try {
const result = await initializeSuperAdmin({
contractId: options.contractId,
deployer: options.deployer,
secretKey: options.secretKey,
name: options.name,
symbol: options.symbol,
decimals: options.decimals ? parseInt(options.decimals, 10) : undefined,
verify: options.verify,
});
if (!result.success) {
logger.error(`Failed to initialize SuperAdmin: ${result.error}`);
process.exitCode = 1;
}
} catch (err: any) {
logger.error(`Error: ${err.message}`);
process.exitCode = 1;
}
});
}

export function createConnectCommand(): Command {
return new Command("connect")
.alias("link")
.description("Connect deployed contract IDs post-deployment")
.option("--admin <string>", "Admin Contract ID")
.option("--token <string>", "Token Contract ID")
.option("--vesting <string>", "Vesting Contract ID")
.option("--wrapper <string>", "Wrapper Contract ID")
.option("--secret-key <string>", "Deployer secret key")
.option("--file [file]", "Path to .bc-forge.json")
.action(async (options) => {
try {
const result = await connectContractIds({
adminContractId: options.admin,
tokenContractId: options.token,
vestingContractId: options.vesting,
wrapperContractId: options.wrapper,
secretKey: options.secretKey,
configPath: options.file,
});
if (!result.success) {
logger.error("Failed to connect contract IDs:");
result.errors?.forEach((err) => logger.error(` - ${err}`));
process.exitCode = 1;
}
} catch (err: any) {
logger.error(`Error: ${err.message}`);
process.exitCode = 1;
}
});
}

export function createOrchestrateCommand(): Command {
return new Command("orchestrate")
.description("Run full deployment orchestrator: initialize SuperAdmin and connect contract IDs")
.option("--admin <string>", "Admin Contract ID")
.option("--token <string>", "Token Contract ID")
.option("--vesting <string>", "Vesting Contract ID")
.option("--wrapper <string>", "Wrapper Contract ID")
.option("--name <string>", "Token name")
.option("--symbol <string>", "Token symbol")
.option("--decimals <number>", "Token decimals")
.option("--secret-key <string>", "Deployer secret key")
.option("--file [file]", "Path to .bc-forge.json")
.option("--skip-verify", "Skip on-chain verification steps")
.action(async (options) => {
try {
const result = await runDeploymentOrchestrator({
adminContractId: options.admin,
tokenContractId: options.token,
vestingContractId: options.vesting,
wrapperContractId: options.wrapper,
name: options.name,
symbol: options.symbol,
decimals: options.decimals ? parseInt(options.decimals, 10) : undefined,
secretKey: options.secretKey,
configPath: options.file,
skipVerify: options.skipVerify,
});
if (!result.success) {
logger.error("Orchestration encountered errors.");
process.exitCode = 1;
}
} catch (err: any) {
logger.error(`Error: ${err.message}`);
process.exitCode = 1;
}
});
}
174 changes: 174 additions & 0 deletions cli/src/orchestrator/__tests__/connect-contracts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { jest } from '@jest/globals';
import { Keypair } from '@stellar/stellar-sdk';
import { connectContractIds, linkContracts } from '../connect-contracts.js';
import * as configParser from '../../utils/config-parser.js';
import * as configUtil from '../../utils/config.js';

const ADMIN_CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1';
const TOKEN_CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2';
const VESTING_CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3';
const WRAPPER_CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4';
const SIGNER_KEYPAIR = Keypair.random();
const SIGNER_SECRET = SIGNER_KEYPAIR.secret();

describe('connectContractIds (Issue #693)', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('Happy Paths', () => {
it('should successfully link Admin Contract ID to Token Contract', async () => {
jest.spyOn(configUtil, 'getSecretKey').mockReturnValue(SIGNER_SECRET);

const result = await connectContractIds({
adminContractId: ADMIN_CONTRACT_ID,
tokenContractId: TOKEN_CONTRACT_ID,
deployerKeypair: SIGNER_KEYPAIR,
});

expect(result.success).toBe(true);
expect(result.linkedContracts['token.adminContractId']).toBe(ADMIN_CONTRACT_ID);
expect(result.verifiedLinks['token.adminContractId']).toBe(true);
});

it('should successfully link Token Contract ID to Vesting and Wrapper dependent contracts', async () => {
const result = await connectContractIds({
tokenContractId: TOKEN_CONTRACT_ID,
vestingContractId: VESTING_CONTRACT_ID,
wrapperContractId: WRAPPER_CONTRACT_ID,
deployerKeypair: SIGNER_KEYPAIR,
});

expect(result.success).toBe(true);
expect(result.linkedContracts['vesting.tokenContractId']).toBe(TOKEN_CONTRACT_ID);
expect(result.linkedContracts['wrapper.tokenContractId']).toBe(TOKEN_CONTRACT_ID);
expect(result.verifiedLinks['vesting.tokenContractId']).toBe(true);
expect(result.verifiedLinks['wrapper.tokenContractId']).toBe(true);
});

it('should support custom contract links', async () => {
const result = await connectContractIds({
customLinks: [
{
sourceContractId: TOKEN_CONTRACT_ID,
targetContractId: ADMIN_CONTRACT_ID,
linkType: 'adminGovernance',
},
],
deployerKeypair: SIGNER_KEYPAIR,
});

expect(result.success).toBe(true);
expect(result.linkedContracts[`adminGovernance.${TOKEN_CONTRACT_ID}`]).toBe(ADMIN_CONTRACT_ID);
});

it('should persist all linked contract IDs to .bc-forge.json deployment config', async () => {
const mockSave = jest.spyOn(configParser, 'saveConfigFile').mockReturnValue({
success: true,
filePath: '/mock/.bc-forge.json',
});
jest.spyOn(configParser, 'loadConfigFile').mockReturnValue({
success: true,
filePath: '/mock/.bc-forge.json',
config: {
name: 'MyProject',
symbol: 'PRJ',
decimals: 7,
contracts: {
token: { contractId: TOKEN_CONTRACT_ID },
admin: { contractId: ADMIN_CONTRACT_ID },
},
},
});

const result = await linkContracts({
adminContractId: ADMIN_CONTRACT_ID,
tokenContractId: TOKEN_CONTRACT_ID,
vestingContractId: VESTING_CONTRACT_ID,
wrapperContractId: WRAPPER_CONTRACT_ID,
deployerKeypair: SIGNER_KEYPAIR,
configPath: '/mock/.bc-forge.json',
});

expect(result.success).toBe(true);
expect(mockSave).toHaveBeenCalledTimes(1);

const savedConfig = mockSave.mock.calls[0][0];
expect(savedConfig.contracts?.token?.adminContractId).toBe(ADMIN_CONTRACT_ID);
expect(savedConfig.contracts?.vesting?.tokenContractId).toBe(TOKEN_CONTRACT_ID);
expect(savedConfig.contracts?.wrapper?.tokenContractId).toBe(TOKEN_CONTRACT_ID);
});
});

describe('Error States', () => {
it('should fail when no contract IDs are provided or found in config', async () => {
jest.spyOn(configParser, 'loadConfigFile').mockReturnValue({ success: false });
jest.spyOn(configUtil, 'getClientConfig').mockReturnValue({
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
contractId: '',
});

const result = await connectContractIds({});

expect(result.success).toBe(false);
expect(result.errors?.[0]).toContain('No contract IDs provided to connect');
});

it('should fail when Admin Contract ID format is invalid', async () => {
const result = await connectContractIds({
adminContractId: 'INVALID_ADMIN_ID',
tokenContractId: TOKEN_CONTRACT_ID,
deployerKeypair: SIGNER_KEYPAIR,
});

expect(result.success).toBe(false);
expect(result.errors?.[0]).toContain('Invalid Admin Contract ID format');
});

it('should fail when Token Contract ID format is invalid', async () => {
const result = await connectContractIds({
adminContractId: ADMIN_CONTRACT_ID,
tokenContractId: 'INVALID_TOKEN_ID',
deployerKeypair: SIGNER_KEYPAIR,
});

expect(result.success).toBe(false);
expect(result.errors?.[0]).toContain('Invalid Token Contract ID format');
});

it('should fail when Vesting Contract ID format is invalid', async () => {
const result = await connectContractIds({
tokenContractId: TOKEN_CONTRACT_ID,
vestingContractId: 'INVALID_VESTING_ID',
deployerKeypair: SIGNER_KEYPAIR,
});

expect(result.success).toBe(false);
expect(result.errors?.[0]).toContain('Invalid Vesting Contract ID format');
});

it('should fail when Wrapper Contract ID format is invalid', async () => {
const result = await connectContractIds({
tokenContractId: TOKEN_CONTRACT_ID,
wrapperContractId: 'INVALID_WRAPPER_ID',
deployerKeypair: SIGNER_KEYPAIR,
});

expect(result.success).toBe(false);
expect(result.errors?.[0]).toContain('Invalid Wrapper Contract ID format');
});

it('should fail when deployer secret key is not provided or configured', async () => {
jest.spyOn(configUtil, 'getSecretKey').mockReturnValue('');

const result = await connectContractIds({
adminContractId: ADMIN_CONTRACT_ID,
tokenContractId: TOKEN_CONTRACT_ID,
});

expect(result.success).toBe(false);
expect(result.errors?.[0]).toContain('Deployer/Admin secret key not configured');
});
});
});
Loading
Loading