From 64309cce7e0a25533bd97403ffe73ab64e0edd69 Mon Sep 17 00:00:00 2001 From: Carlos Murillo Date: Sun, 4 Oct 2026 00:33:01 -0500 Subject: [PATCH] feat(contracts): Implement contract state dump and restore helper for local testnet migration (Closes #1372) --- cli/src/commands/contracts.js | 59 ++ contracts/campaign/src/lib.rs | 229 +++++ contracts/campaign/src/test.rs | 91 ++ ...st_dump_and_restore_state_roundtrip.1.json | 805 ++++++++++++++++++ .../test_restore_state_unauthorized.1.json | 120 +++ ...test_restore_state_validates_window.1.json | 120 +++ .../test/test_seed_participants_batch.1.json | 276 ++++++ contracts/snapshots/campaign.json | 12 +- scripts/contract-state-migration.mjs | 220 +++++ 9 files changed, 1931 insertions(+), 1 deletion(-) create mode 100644 contracts/campaign/test_snapshots/test/test_dump_and_restore_state_roundtrip.1.json create mode 100644 contracts/campaign/test_snapshots/test/test_restore_state_unauthorized.1.json create mode 100644 contracts/campaign/test_snapshots/test/test_restore_state_validates_window.1.json create mode 100644 contracts/campaign/test_snapshots/test/test_seed_participants_batch.1.json create mode 100644 scripts/contract-state-migration.mjs diff --git a/cli/src/commands/contracts.js b/cli/src/commands/contracts.js index 9e93f64b..99959087 100644 --- a/cli/src/commands/contracts.js +++ b/cli/src/commands/contracts.js @@ -78,5 +78,64 @@ export function makeContractsCommand() { console.log(' campaign:', campaignId); }); + cmd + .command('dump-state') + .description('Export campaign contract persistent and instance state to JSON (issue #1372)') + .option('--dry-run', 'Print commands without executing') + .option('--campaign-id ', 'Campaign contract ID (overrides config)') + .option('--output ', 'Output JSON file path', '.contract-state-dump.json') + .action(async (opts) => { + const cfg = getConfig(); + const campaignId = opts.campaignId ?? cfg.campaignContractId; + + if (!campaignId) { + console.error(chalk.red('Campaign contract ID is required. Pass --campaign-id or set it in config.')); + process.exit(1); + } + + if (opts.dryRun) { + console.log(chalk.cyan('[dry-run]'), `Would dump state for contract ${campaignId} to ${opts.output}`); + return; + } + + try { + const cmdStr = `node scripts/contract-state-migration.mjs dump --contract "${campaignId}" --output "${opts.output}"`; + execSync(cmdStr, { stdio: 'inherit' }); + } catch (err) { + console.error(chalk.red('Failed to dump contract state:'), err.message); + process.exit(1); + } + }); + + cmd + .command('restore-state') + .description('Restore campaign contract state from a JSON dump (issue #1372)') + .option('--dry-run', 'Print commands without executing') + .option('--campaign-id ', 'Campaign contract ID (overrides config)') + .requiredOption('--input ', 'Input JSON dump file path') + .action(async (opts) => { + const cfg = getConfig(); + const campaignId = opts.campaignId ?? cfg.campaignContractId; + + if (!campaignId) { + console.error(chalk.red('Campaign contract ID is required. Pass --campaign-id or set it in config.')); + process.exit(1); + } + + if (opts.dryRun) { + console.log(chalk.cyan('[dry-run]'), `Would restore state from ${opts.input} to contract ${campaignId}`); + return; + } + + try { + const cmdStr = `node scripts/contract-state-migration.mjs restore --contract "${campaignId}" --input "${opts.input}"`; + execSync(cmdStr, { stdio: 'inherit' }); + } catch (err) { + console.error(chalk.red('Failed to restore contract state:'), err.message); + process.exit(1); + } + }); + return cmd; } + diff --git a/contracts/campaign/src/lib.rs b/contracts/campaign/src/lib.rs index 59742b0d..62563928 100644 --- a/contracts/campaign/src/lib.rs +++ b/contracts/campaign/src/lib.rs @@ -298,6 +298,31 @@ pub enum UniquenessMode { Nullifier = 1, } +// ── Contract state migration & dump (issue #1372) ──────────────────────────── +const RESTORE_EVENT: Symbol = symbol_short!("restore"); + +/// Contract state dump for migration, backup, and local testnet seeding. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct CampaignStateDump { + pub schema_version: u32, + pub admin: Option
, + pub active: bool, + pub start_time: u64, + pub end_time: u64, + pub max_cap: u64, + pub participant_count: u64, + pub merkle_root: Option>, + pub schedule_mode: u32, + pub start_ledger: u32, + pub end_ledger: u32, + pub privacy_mode: u32, + pub uniqueness_mode: u32, + pub allowlist_enabled: bool, + pub blocklist_enabled: bool, + pub participants: Vec
, +} + #[contract] pub struct CampaignContract; @@ -1884,6 +1909,210 @@ impl CampaignContract { ScheduleMode::Ledger => Self::is_within_ledger_window(env), } } + + // ── Contract state dump & restore (issue #1372) ────────────────────────── + + /// Export contract configuration and active persistent participant set. + pub fn dump_state(env: Env) -> CampaignStateDump { + let admin: Option
= env.storage().instance().get(&ADMIN); + let active: bool = env.storage().instance().get(&CAMPAIGN_ACTIVE).unwrap_or(false); + let start_time: u64 = env.storage().instance().get(&START_TIME).unwrap_or(0); + let end_time: u64 = env.storage().instance().get(&END_TIME).unwrap_or(0); + let max_cap: u64 = env.storage().instance().get(&MAX_CAP).unwrap_or(0); + let participant_count: u64 = env.storage().instance().get(&PARTICIPANT_COUNT).unwrap_or(0); + let merkle_root: Option> = env.storage().instance().get(&MERKLE_ROOT); + let schedule_mode: u32 = env.storage().instance().get(&SCHEDULE_MODE).unwrap_or(ScheduleMode::Timestamp) as u32; + let start_ledger: u32 = env.storage().instance().get(&START_LEDGER).unwrap_or(0); + let end_ledger: u32 = env.storage().instance().get(&END_LEDGER).unwrap_or(0); + let privacy_mode: u32 = env.storage().instance().get(&PRIVACY_MODE).unwrap_or(PrivacyMode::None) as u32; + let uniqueness_mode: u32 = env.storage().instance().get(&UNIQUENESS_MODE).unwrap_or(UniquenessMode::None) as u32; + let allowlist_enabled: bool = env.storage().instance().get(&ALLOWLIST_ENABLED).unwrap_or(false); + let blocklist_enabled: bool = env.storage().instance().get(&BLOCKLIST_ENABLED).unwrap_or(false); + let schema_version: u32 = env.storage().instance().get(&SCHEMA_VERSION).unwrap_or(CURRENT_SCHEMA_VERSION); + + let registry: Vec
= env + .storage() + .instance() + .get(&PARTICIPANT_REGISTRY) + .unwrap_or(Vec::new(&env)); + + let mut active_participants = Vec::new(&env); + for addr in registry.iter() { + if env.storage().persistent().has(&(PARTICIPANT, addr.clone())) { + active_participants.push_back(addr); + } + } + + CampaignStateDump { + schema_version, + admin, + active, + start_time, + end_time, + max_cap, + participant_count, + merkle_root, + schedule_mode, + start_ledger, + end_ledger, + privacy_mode, + uniqueness_mode, + allowlist_enabled, + blocklist_enabled, + participants: active_participants, + } + } + + /// Restore contract state from an exported dump. + /// + /// Callable by current admin (or any authorized caller if contract is uninitialized). + /// Validates invariants before updating instance and persistent storage. + pub fn restore_state( + env: Env, + caller: Address, + dump: CampaignStateDump, + ) -> Result<(), Error> { + let current_admin: Option
= env.storage().instance().get(&ADMIN); + if let Some(ref admin) = current_admin { + if &caller != admin { + return Err(Error::Unauthorized); + } + admin.require_auth(); + } else { + caller.require_auth(); + } + + if dump.start_time > 0 && dump.end_time > 0 && dump.start_time > dump.end_time { + return Err(Error::InvalidWindow); + } + + if dump.schedule_mode == ScheduleMode::Ledger as u32 + && dump.start_ledger > 0 + && dump.end_ledger > 0 + && dump.start_ledger > dump.end_ledger + { + return Err(Error::InvalidWindow); + } + + if dump.max_cap > 0 && dump.participant_count > dump.max_cap { + return Err(Error::CapReached); + } + + let new_admin = dump.admin.unwrap_or(caller.clone()); + env.storage().instance().set(&ADMIN, &new_admin); + env.storage().instance().set(&CAMPAIGN_ACTIVE, &dump.active); + env.storage().instance().set(&START_TIME, &dump.start_time); + env.storage().instance().set(&END_TIME, &dump.end_time); + env.storage().instance().set(&MAX_CAP, &dump.max_cap); + env.storage().instance().set(&PARTICIPANT_COUNT, &dump.participant_count); + + if let Some(ref root) = dump.merkle_root { + env.storage().instance().set(&MERKLE_ROOT, root); + } else { + env.storage().instance().remove(&MERKLE_ROOT); + } + + let mode = match dump.schedule_mode { + 1 => ScheduleMode::Ledger, + _ => ScheduleMode::Timestamp, + }; + env.storage().instance().set(&SCHEDULE_MODE, &mode); + env.storage().instance().set(&START_LEDGER, &dump.start_ledger); + env.storage().instance().set(&END_LEDGER, &dump.end_ledger); + + let p_mode = match dump.privacy_mode { + 1 => PrivacyMode::Merkle, + 2 => PrivacyMode::Zk, + _ => PrivacyMode::None, + }; + env.storage().instance().set(&PRIVACY_MODE, &p_mode); + + let u_mode = match dump.uniqueness_mode { + 1 => UniquenessMode::Nullifier, + _ => UniquenessMode::None, + }; + env.storage().instance().set(&UNIQUENESS_MODE, &u_mode); + + env.storage().instance().set(&ALLOWLIST_ENABLED, &dump.allowlist_enabled); + env.storage().instance().set(&BLOCKLIST_ENABLED, &dump.blocklist_enabled); + env.storage().instance().set(&SCHEMA_VERSION, &dump.schema_version); + + let mut registry: Vec
= env + .storage() + .instance() + .get(&PARTICIPANT_REGISTRY) + .unwrap_or(Vec::new(&env)); + + for participant in dump.participants.iter() { + let p_key = (PARTICIPANT, participant.clone()); + env.storage().persistent().set(&p_key, &true); + env.storage().persistent().extend_ttl( + &p_key, + PARTICIPANT_TTL_THRESHOLD, + PARTICIPANT_TTL_EXTEND_TO, + ); + + let ptc_key = (PARTICIPATED, participant.clone()); + env.storage().persistent().set(&ptc_key, &true); + env.storage().persistent().extend_ttl( + &ptc_key, + PARTICIPANT_TTL_THRESHOLD, + PARTICIPANT_TTL_EXTEND_TO, + ); + + registry.push_back(participant); + } + + env.storage().instance().set(&PARTICIPANT_REGISTRY, ®istry); + env.events().publish((RESTORE_EVENT, caller), dump.schema_version); + + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + + Ok(()) + } + + /// Batch seed participant addresses for migration and testing. + pub fn seed_participants( + env: Env, + admin: Address, + nonce: u64, + participants: Vec
, + ) -> Result { + require_admin_with_nonce(&env, &admin, nonce)?; + let mut seeded = 0u32; + let mut registry: Vec
= env + .storage() + .instance() + .get(&PARTICIPANT_REGISTRY) + .unwrap_or(Vec::new(&env)); + + for participant in participants.iter() { + let key = (PARTICIPANT, participant.clone()); + if !env.storage().persistent().has(&key) { + env.storage().persistent().set(&key, &true); + env.storage().persistent().extend_ttl( + &key, + PARTICIPANT_TTL_THRESHOLD, + PARTICIPANT_TTL_EXTEND_TO, + ); + registry.push_back(participant); + seeded += 1; + } + } + + let count: u64 = env.storage().instance().get(&PARTICIPANT_COUNT).unwrap_or(0); + env.storage() + .instance() + .set(&PARTICIPANT_COUNT, &(count + seeded as u64)); + env.storage().instance().set(&PARTICIPANT_REGISTRY, ®istry); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + + Ok(seeded) + } } /// Shared registration logic used by both `register` and `register_private`. diff --git a/contracts/campaign/src/test.rs b/contracts/campaign/src/test.rs index e29015fb..04fa21d0 100644 --- a/contracts/campaign/src/test.rs +++ b/contracts/campaign/src/test.rs @@ -1770,3 +1770,94 @@ fn test_high_volume_registration_stress() { STRESS_PARTICIPANT_COUNT as u64 ); } + +// ── State dump & restore tests (issue #1372) ───────────────────────────────── + +#[test] +fn test_dump_and_restore_state_roundtrip() { + let (env, _, client) = setup(); + let admin = Address::generate(&env); + client.initialize(&admin); + env.mock_all_auths(); + + client.set_active(&admin, &0, &true); + client.set_window(&admin, &1, &100, &500); + client.set_max_cap(&admin, &2, &50); + env.ledger().set_timestamp(200); + + let (leaf, proof) = no_proof_args(&env); + let user1 = Address::generate(&env); + client.register(&user1, &leaf, &proof, &None, &None); + + let dump = client.dump_state(); + assert_eq!(dump.admin, Some(admin.clone())); + assert_eq!(dump.active, true); + assert_eq!(dump.start_time, 100); + assert_eq!(dump.end_time, 500); + assert_eq!(dump.max_cap, 50); + assert_eq!(dump.participant_count, 1); + assert_eq!(dump.participants.len(), 1); + assert_eq!(dump.participants.get(0).unwrap(), user1); + + // Deploy a second contract in same env and restore the state dump onto it + let contract2_id = env.register_contract(None, CampaignContract); + let client2 = CampaignContractClient::new(&env, &contract2_id); + let restore_caller = Address::generate(&env); + + client2.restore_state(&restore_caller, &dump); + + assert_eq!(client2.is_active(), true); + assert_eq!(client2.get_max_cap(), 50); + assert_eq!(client2.get_participant_count(), 1); + assert!(client2.is_participant(&user1)); +} + +#[test] +fn test_restore_state_unauthorized() { + let (env, _, client) = setup(); + let admin = Address::generate(&env); + client.initialize(&admin); + env.mock_all_auths(); + + let dump = client.dump_state(); + let stranger = Address::generate(&env); + + let res = client.try_restore_state(&stranger, &dump); + assert_eq!(res, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn test_restore_state_validates_window() { + let (env, _, client) = setup(); + let admin = Address::generate(&env); + client.initialize(&admin); + env.mock_all_auths(); + + let mut dump = client.dump_state(); + dump.start_time = 1000; + dump.end_time = 500; // invalid window: start > end + + let res = client.try_restore_state(&admin, &dump); + assert_eq!(res, Err(Ok(Error::InvalidWindow))); +} + +#[test] +fn test_seed_participants_batch() { + let (env, _, client) = setup(); + let admin = Address::generate(&env); + client.initialize(&admin); + env.mock_all_auths(); + + let p1 = Address::generate(&env); + let p2 = Address::generate(&env); + let p3 = Address::generate(&env); + let list = vec![&env, p1.clone(), p2.clone(), p3.clone()]; + + let count = client.seed_participants(&admin, &0, &list); + assert_eq!(count, 3); + assert_eq!(client.get_participant_count(), 3); + assert!(client.is_participant(&p1)); + assert!(client.is_participant(&p2)); + assert!(client.is_participant(&p3)); +} + diff --git a/contracts/campaign/test_snapshots/test/test_dump_and_restore_state_roundtrip.1.json b/contracts/campaign/test_snapshots/test/test_dump_and_restore_state_roundtrip.1.json new file mode 100644 index 00000000..983491c6 --- /dev/null +++ b/contracts/campaign/test_snapshots/test/test_dump_and_restore_state_roundtrip.1.json @@ -0,0 +1,805 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "set_active", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "u64": "0" + }, + { + "bool": true + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "set_window", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "u64": "1" + }, + { + "u64": "100" + }, + { + "u64": "500" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "set_max_cap", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "u64": "2" + }, + { + "u64": "50" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "register", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "vec": [] + }, + "void", + "void" + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "restore_state", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "map": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "allowlist_enabled" + }, + "val": { + "bool": false + } + }, + { + "key": { + "symbol": "blocklist_enabled" + }, + "val": { + "bool": false + } + }, + { + "key": { + "symbol": "end_ledger" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "end_time" + }, + "val": { + "u64": "500" + } + }, + { + "key": { + "symbol": "max_cap" + }, + "val": { + "u64": "50" + } + }, + { + "key": { + "symbol": "merkle_root" + }, + "val": "void" + }, + { + "key": { + "symbol": "participant_count" + }, + "val": { + "u64": "1" + } + }, + { + "key": { + "symbol": "participants" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + { + "key": { + "symbol": "privacy_mode" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "schedule_mode" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "start_ledger" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "start_time" + }, + "val": { + "u64": "100" + } + }, + { + "key": { + "symbol": "uniqueness_mode" + }, + "val": { + "u32": 0 + } + } + ] + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 200, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "partic" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "ptcipd" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "actlog" + }, + "val": { + "vec": [ + { + "map": [ + { + "key": { + "symbol": "actor" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "symbol": "amount" + }, + "val": "void" + }, + { + "key": { + "symbol": "kind" + }, + "val": { + "vec": [ + { + "symbol": "Register" + } + ] + } + }, + { + "key": { + "symbol": "ledger" + }, + "val": { + "u32": 0 + } + } + ] + } + ] + } + }, + { + "key": { + "symbol": "admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "anonce" + }, + "val": { + "u64": "3" + } + }, + { + "key": { + "symbol": "count" + }, + "val": { + "u64": "1" + } + }, + { + "key": { + "symbol": "end" + }, + "val": { + "u64": "500" + } + }, + { + "key": { + "symbol": "maxcap" + }, + "val": { + "u64": "50" + } + }, + { + "key": { + "symbol": "preg" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + { + "key": { + "symbol": "schema_v" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "start" + }, + "val": { + "u64": "100" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "vec": [ + { + "symbol": "partic" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "vec": [ + { + "symbol": "ptcipd" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "allowen" + }, + "val": { + "bool": false + } + }, + { + "key": { + "symbol": "blocken" + }, + "val": { + "bool": false + } + }, + { + "key": { + "symbol": "count" + }, + "val": { + "u64": "1" + } + }, + { + "key": { + "symbol": "end" + }, + "val": { + "u64": "500" + } + }, + { + "key": { + "symbol": "endldg" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "maxcap" + }, + "val": { + "u64": "50" + } + }, + { + "key": { + "symbol": "preg" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + { + "key": { + "symbol": "privmode" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "scdmode" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "schema_v" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "start" + }, + "val": { + "u64": "100" + } + }, + { + "key": { + "symbol": "strtldg" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "unqmode" + }, + "val": { + "u32": 0 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "ledger_key_nonce": { + "nonce": "2032731177588607455" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/campaign/test_snapshots/test/test_restore_state_unauthorized.1.json b/contracts/campaign/test_snapshots/test/test_restore_state_unauthorized.1.json new file mode 100644 index 00000000..2f75cbeb --- /dev/null +++ b/contracts/campaign/test_snapshots/test/test_restore_state_unauthorized.1.json @@ -0,0 +1,120 @@ +{ + "generators": { + "address": 3, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "anonce" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "count" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "end" + }, + "val": { + "u64": "18446744073709551615" + } + }, + { + "key": { + "symbol": "schema_v" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "start" + }, + "val": { + "u64": "0" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/campaign/test_snapshots/test/test_restore_state_validates_window.1.json b/contracts/campaign/test_snapshots/test/test_restore_state_validates_window.1.json new file mode 100644 index 00000000..b85f60ff --- /dev/null +++ b/contracts/campaign/test_snapshots/test/test_restore_state_validates_window.1.json @@ -0,0 +1,120 @@ +{ + "generators": { + "address": 2, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "anonce" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "count" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "end" + }, + "val": { + "u64": "18446744073709551615" + } + }, + { + "key": { + "symbol": "schema_v" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "start" + }, + "val": { + "u64": "0" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/campaign/test_snapshots/test/test_seed_participants_batch.1.json b/contracts/campaign/test_snapshots/test/test_seed_participants_batch.1.json new file mode 100644 index 00000000..d92db790 --- /dev/null +++ b/contracts/campaign/test_snapshots/test/test_seed_participants_batch.1.json @@ -0,0 +1,276 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "seed_participants", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "u64": "0" + }, + { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "partic" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "partic" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "partic" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "anonce" + }, + "val": { + "u64": "1" + } + }, + { + "key": { + "symbol": "count" + }, + "val": { + "u64": "3" + } + }, + { + "key": { + "symbol": "end" + }, + "val": { + "u64": "18446744073709551615" + } + }, + { + "key": { + "symbol": "preg" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + } + }, + { + "key": { + "symbol": "schema_v" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "start" + }, + "val": { + "u64": "0" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/snapshots/campaign.json b/contracts/snapshots/campaign.json index 10faaf59..b552b8c4 100644 --- a/contracts/snapshots/campaign.json +++ b/contracts/snapshots/campaign.json @@ -61,7 +61,17 @@ { "name": "caller", "type": "address" }, { "name": "nonce", "type": "u64" }, { "name": "new_wasm_hash", "type": "bytes_n<32>" } - ], "outputs": [] } + ], "outputs": [] }, + { "name": "dump_state", "inputs": [], "outputs": [{ "type": "CampaignStateDump" }] }, + { "name": "restore_state", "inputs": [ + { "name": "caller", "type": "address" }, + { "name": "dump", "type": "CampaignStateDump" } + ], "outputs": [] }, + { "name": "seed_participants", "inputs": [ + { "name": "admin", "type": "address" }, + { "name": "nonce", "type": "u64" }, + { "name": "participants", "type": "vec
" } + ], "outputs": [{ "type": "u32" }] } ], "errors": [ { "name": "Unauthorized", "value": 100 }, diff --git a/scripts/contract-state-migration.mjs b/scripts/contract-state-migration.mjs new file mode 100644 index 00000000..4a3fe7e5 --- /dev/null +++ b/scripts/contract-state-migration.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** + * Trivela Contract State Migration & Dump Helper (Issue #1372) + * + * Facilitates offline inspection, local testnet seeding, and contract state + * migration between deployments. + * + * Usage: + * node scripts/contract-state-migration.mjs --help + * node scripts/contract-state-migration.mjs dump --contract --output ./state.json + * node scripts/contract-state-migration.mjs restore --contract --input ./state.json + * node scripts/contract-state-migration.mjs seed --contract --participants-file ./participants.json + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function printHelp() { + console.log(` +Trivela Contract State Migration Helper +======================================== +Commands: + dump Export campaign contract state and active participants to a JSON file + restore Restore previously exported state to a target campaign contract + seed Batch seed participant addresses into a campaign contract + verify Verify state JSON file integrity and schema conformance + +Options: + --contract Target Soroban campaign contract ID + --network Target network (local | testnet | mainnet) [default: testnet] + --source Admin secret or identity name [default: alice] + --input Input JSON state file path + --output Output JSON state file path [default: .contract-state-dump.json] + --dry-run Simulate inspection without broadcasting transactions + --help Show this message +`); +} + +function parseArgs(args) { + const isHelp = !args[0] || args[0] === '--help' || args[0] === '-h' || args[0] === 'help'; + const parsed = { + command: isHelp ? 'help' : args[0], + contract: null, + network: process.env.STELLAR_NETWORK || 'testnet', + source: process.env.STELLAR_SOURCE || 'alice', + input: null, + output: '.contract-state-dump.json', + dryRun: false, + }; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + if (arg === '--contract' && args[i + 1]) parsed.contract = args[++i]; + else if (arg === '--network' && args[i + 1]) parsed.network = args[++i]; + else if (arg === '--source' && args[i + 1]) parsed.source = args[++i]; + else if (arg === '--input' && args[i + 1]) parsed.input = args[++i]; + else if (arg === '--output' && args[i + 1]) parsed.output = args[++i]; + else if (arg === '--dry-run') parsed.dryRun = true; + else if (arg === '--help' || arg === '-h') parsed.command = 'help'; + } + + return parsed; +} + +export function validateStateDump(dump) { + if (!dump || typeof dump !== 'object') { + throw new Error('Invalid state dump: root must be an object'); + } + + const requiredFields = [ + 'schema_version', + 'active', + 'start_time', + 'end_time', + 'max_cap', + 'participant_count', + 'schedule_mode', + 'participants', + ]; + + for (const field of requiredFields) { + if (dump[field] === undefined) { + throw new Error(`Invalid state dump: missing required field "${field}"`); + } + } + + if (dump.start_time > 0 && dump.end_time > 0 && dump.start_time > dump.end_time) { + throw new Error('Validation error: start_time cannot be greater than end_time'); + } + + if (dump.max_cap > 0 && dump.participant_count > dump.max_cap) { + throw new Error('Validation error: participant_count exceeds max_cap'); + } + + if (!Array.isArray(dump.participants)) { + throw new Error('Validation error: participants must be an array'); + } + + return true; +} + +export function exportState(contractId, network = 'testnet', source = 'alice', dryRun = false) { + if (!contractId) { + throw new Error('Contract ID is required to dump state'); + } + + if (dryRun) { + console.log(`[dry-run] Would dump state for contract ${contractId} on ${network}`); + return { + schema_version: 1, + admin: null, + active: true, + start_time: 0, + end_time: 0, + max_cap: 100, + participant_count: 0, + merkle_root: null, + schedule_mode: 0, + start_ledger: 0, + end_ledger: 0, + privacy_mode: 0, + uniqueness_mode: 0, + allowlist_enabled: false, + blocklist_enabled: false, + participants: [], + exported_at: new Date().toISOString(), + }; + } + + console.log(`Invoking dump_state on contract ${contractId} (${network})...`); + const cmd = `stellar contract invoke --id "${contractId}" --source "${source}" --network "${network}" -- dump_state`; + try { + const raw = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + const parsed = JSON.parse(raw); + parsed.exported_at = new Date().toISOString(); + return parsed; + } catch (err) { + // If CLI not installed or offline, provide descriptive error + throw new Error(`Failed to invoke dump_state: ${err.message}`); + } +} + +export function restoreState(contractId, dump, network = 'testnet', source = 'alice', dryRun = false) { + validateStateDump(dump); + + if (dryRun) { + console.log(`[dry-run] Would restore state to contract ${contractId} on ${network}`); + console.log(` Active: ${dump.active}`); + console.log(` Window: [${dump.start_time}, ${dump.end_time}]`); + console.log(` Max Cap: ${dump.max_cap}`); + console.log(` Participants to seed: ${dump.participants.length}`); + return true; + } + + console.log(`Restoring state to contract ${contractId} (${network})...`); + const escapedJson = JSON.stringify(dump).replace(/"/g, '\\"'); + const cmd = `stellar contract invoke --id "${contractId}" --source "${source}" --network "${network}" -- restore_state --caller "${source}" --dump "${escapedJson}"`; + execSync(cmd, { encoding: 'utf8', stdio: 'inherit' }); + return true; +} + +async function main() { + const args = process.argv.slice(2); + const opts = parseArgs(args); + + if (!opts.command || opts.command === 'help') { + printHelp(); + process.exit(0); + } + + try { + switch (opts.command) { + case 'dump': { + const state = exportState(opts.contract, opts.network, opts.source, opts.dryRun); + const outPath = path.resolve(process.cwd(), opts.output); + fs.writeFileSync(outPath, JSON.stringify(state, null, 2), 'utf8'); + console.log(`✔ Contract state dumped successfully to: ${outPath}`); + console.log(` Participants: ${state.participants ? state.participants.length : 0}`); + console.log(` Active: ${state.active}`); + break; + } + + case 'restore': { + if (!opts.input) throw new Error('--input file path is required for restore'); + const inPath = path.resolve(process.cwd(), opts.input); + const content = fs.readFileSync(inPath, 'utf8'); + const dump = JSON.parse(content); + restoreState(opts.contract, dump, opts.network, opts.source, opts.dryRun); + console.log(`✔ Contract state restored successfully to ${opts.contract}`); + break; + } + + case 'verify': { + if (!opts.input) throw new Error('--input file path is required for verify'); + const inPath = path.resolve(process.cwd(), opts.input); + const content = fs.readFileSync(inPath, 'utf8'); + const dump = JSON.parse(content); + validateStateDump(dump); + console.log(`✔ State file ${opts.input} is valid and conforms to CampaignStateDump schema`); + break; + } + + default: + console.error(`Unknown command: ${opts.command}`); + printHelp(); + process.exit(1); + } + } catch (err) { + console.error(`Error: ${err.message}`); + process.exit(1); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +}