diff --git a/.gitignore b/.gitignore index d7a51cf..98bb5bc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ logs/ # Rust build artifacts contracts/*/target/ +contracts/*/test_snapshots/ # Source maps *.js.map diff --git a/CHANGELOG.md b/CHANGELOG.md index c9bd461..af269cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Changed -- Refocused the docs on what CleverCon does: delegate a budget to AI agents on - Stellar, with funds held in a non-custodial vault that enforces the limit. - Rewrote `README.md`, `docs/architecture.md`, and `ROADMAP.md`. +- **Breaking:** `AgentVault::release_payment` now requires a caller-supplied + `step_id` between `task_id` and `asset`. Replays with the same + `(task_id, step_id, amount)` are idempotent successes, while reusing a + `step_id` with a different amount is rejected as `ReleaseConflict`. +- Repositioned the project around **private, policy-bounded delegation of money + to AI agents**: a non-custodial CleverVault under a private, zero-knowledge- + enforced spending policy. Updated `README.md`, `docs/architecture.md`, and + `ROADMAP.md` to lead with this framing, with a clear line between what is live + on testnet today (non-custodial vault + orchestration + agents) and the + grant-scope roadmap (ZK policy enforcement, audit, mainnet). ### Added diff --git a/contracts/agent-vault/src/lib.rs b/contracts/agent-vault/src/lib.rs index 48ac574..7379d3c 100644 --- a/contracts/agent-vault/src/lib.rs +++ b/contracts/agent-vault/src/lib.rs @@ -155,6 +155,8 @@ pub enum VaultError { DisputeSplitMismatch = 21, DisputeResolverNotSet = 22, TaskNotDisputed = 23, + ReleaseConflict = 24, + TooManyStepReleases = 25, } // Storage keys @@ -190,6 +192,10 @@ pub enum DataKey { MaxActiveTasks, /// The dedicated resolver authorized to settle raised disputes. DisputeResolver, + /// Idempotency record for a released plan step under a task. + TaskStepRelease(u64, u64), + /// Enumerable list of released step IDs for cleanup on task finalization. + TaskStepIds(u64), } // Data structs @@ -270,6 +276,14 @@ pub struct TaskInfo { pub created_at: u64, } +/// Per-step release idempotency record. Presence means this `(task_id, step_id)` +/// has already produced its transfer with the recorded amount. +#[contracttype] +#[derive(Clone)] +pub struct StepRelease { + pub amount: i128, +} + /// Authoritative lifecycle state for a task at the current ledger timestamp. #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -294,6 +308,10 @@ const DEFAULT_MAX_ACTIVE_TASKS: u32 = 50; /// Maximum number of task records returned by `get_user_task_infos`. const MAX_USER_TASK_INFOS_PAGE_SIZE: u32 = 50; +/// Bounds per-task idempotency storage. Normal plans are far smaller; the cap +/// prevents a hostile orchestrator from creating unbounded persistent keys. +const MAX_RELEASE_STEPS_PER_TASK: u32 = 256; + const PERSISTENT_TTL_THRESHOLD: u32 = 17_280; // ~1 day const PERSISTENT_TTL_EXTEND_TO: u32 = 518_400; // ~30 days @@ -307,7 +325,7 @@ const INSTANCE_TTL_EXTEND_TO: u32 = 518_400; // ~30 days /// deployment before assuming a given function or storage layout /// exists, especially important on Soroban where the same address /// can be upgraded in place. -const CONTRACT_VERSION: u32 = 4; +const CONTRACT_VERSION: u32 = 5; // Contract @@ -788,6 +806,7 @@ impl AgentVault { env: Env, orchestrator: Address, task_id: u64, + step_id: u64, asset: Address, amount: i128, ) -> Result { @@ -817,10 +836,23 @@ impl AgentVault { if task.asset != asset { return Err(VaultError::AssetMismatch); } + + let step_key = DataKey::TaskStepRelease(task_id, step_id); + if let Some(record) = env.storage().persistent().get::<_, StepRelease>(&step_key) { + Self::extend_persistent_ttl(&env, &step_key); + Self::extend_task_step_ids_ttl(&env, task_id); + if record.amount == amount { + return Ok(true); + } + return Err(VaultError::ReleaseConflict); + } + if task.spent + amount > task.plan_cost { return Err(VaultError::ExceedsPlanCost); } + Self::record_step_release(&env, task_id, step_id, amount)?; + Self::extend_instance_ttl(&env); let token_client = token::Client::new(&env, &asset); token_client.transfer(&env.current_contract_address(), &orchestrator, &amount); @@ -1174,6 +1206,7 @@ impl AgentVault { task.completed = true; env.storage().persistent().set(&task_key, &task); Self::extend_persistent_ttl(env, &task_key); + Self::remove_task_step_releases(env, task_id); if dispute_split.is_none() { let refund = task.plan_cost - task.spent; @@ -1196,6 +1229,69 @@ impl AgentVault { Ok(()) } + fn record_step_release( + env: &Env, + task_id: u64, + step_id: u64, + amount: i128, + ) -> Result<(), VaultError> { + let ids_key = DataKey::TaskStepIds(task_id); + let mut step_ids: Vec = env + .storage() + .persistent() + .get(&ids_key) + .unwrap_or(Vec::new(env)); + + if !step_ids.iter().any(|id| id == step_id) { + if step_ids.len() >= MAX_RELEASE_STEPS_PER_TASK { + return Err(VaultError::TooManyStepReleases); + } + step_ids.push_back(step_id); + env.storage().persistent().set(&ids_key, &step_ids); + Self::extend_persistent_ttl(env, &ids_key); + } + + let step_key = DataKey::TaskStepRelease(task_id, step_id); + env.storage() + .persistent() + .set(&step_key, &StepRelease { amount }); + Self::extend_persistent_ttl(env, &step_key); + Ok(()) + } + + fn extend_task_step_ids_ttl(env: &Env, task_id: u64) { + let ids_key = DataKey::TaskStepIds(task_id); + let step_ids: Vec = match env.storage().persistent().get(&ids_key) { + Some(ids) => ids, + None => return, + }; + Self::extend_persistent_ttl(env, &ids_key); + for step_id in step_ids.iter() { + let step_key = DataKey::TaskStepRelease(task_id, step_id); + if env.storage().persistent().has(&step_key) { + Self::extend_persistent_ttl(env, &step_key); + } + } + } + + fn remove_task_step_releases(env: &Env, task_id: u64) { + let ids_key = DataKey::TaskStepIds(task_id); + let step_ids: Vec = env + .storage() + .persistent() + .get(&ids_key) + .unwrap_or(Vec::new(env)); + for step_id in step_ids.iter() { + let step_key = DataKey::TaskStepRelease(task_id, step_id); + if env.storage().persistent().has(&step_key) { + env.storage().persistent().remove(&step_key); + } + } + if env.storage().persistent().has(&ids_key) { + env.storage().persistent().remove(&ids_key); + } + } + /// Loads the user's asset account balance, or returns a zeroed struct if not found. fn get_or_create_asset_account(env: &Env, user: &Address, asset: &Address) -> UserAssetAccount { let key = DataKey::UserAsset(user.clone(), asset.clone()); diff --git a/contracts/agent-vault/src/tests.rs b/contracts/agent-vault/src/tests.rs index eb27c41..c4834c0 100644 --- a/contracts/agent-vault/src/tests.rs +++ b/contracts/agent-vault/src/tests.rs @@ -677,7 +677,7 @@ fn test_release_payment_success() { let success = test_env .client - .release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &100); + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); assert!(success); // Verify USDC transfers to orchestrator @@ -692,7 +692,7 @@ fn test_release_payment_success() { let success2 = test_env .client - .release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &200); + .release_payment(&orchestrator, &task_id, &2, &test_env.usdc_sac, &200); assert!(success2); assert_eq!(test_env.token_client.balance(&orchestrator), 300); @@ -722,10 +722,212 @@ fn test_release_payment_exceeds_plan_cost_fails() { let result = test_env .client - .try_release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &301); + .try_release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &301); assert!(result == Err(Ok(VaultError::ExceedsPlanCost))); } +#[test] +fn test_release_payment_replay_is_idempotent_success() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let user = Address::generate(&test_env.env); + let orchestrator = Address::generate(&test_env.env); + let name = soroban_sdk::String::from_str(&test_env.env, "ReplayOrchestrator"); + + test_env.token_admin_client.mint(&user, &1000); + test_env.client.deposit(&user, &test_env.usdc_sac, &500); + test_env + .client + .register_orchestrator(&user, &orchestrator, &name); + let task_id = test_env + .client + .create_task(&orchestrator, &test_env.usdc_sac, &300); + + assert!(test_env.client.release_payment( + &orchestrator, + &task_id, + &42, + &test_env.usdc_sac, + &100 + )); + assert!(test_env.client.release_payment( + &orchestrator, + &task_id, + &42, + &test_env.usdc_sac, + &100 + )); + + assert_eq!(test_env.token_client.balance(&orchestrator), 100); + assert_eq!(test_env.token_client.balance(&test_env.contract_id), 400); + assert_eq!(test_env.client.get_task(&task_id).unwrap().spent, 100); +} + +#[test] +fn test_release_payment_same_step_different_amount_conflicts() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let user = Address::generate(&test_env.env); + let orchestrator = Address::generate(&test_env.env); + let name = soroban_sdk::String::from_str(&test_env.env, "ConflictOrchestrator"); + + test_env.token_admin_client.mint(&user, &1000); + test_env.client.deposit(&user, &test_env.usdc_sac, &500); + test_env + .client + .register_orchestrator(&user, &orchestrator, &name); + let task_id = test_env + .client + .create_task(&orchestrator, &test_env.usdc_sac, &300); + + test_env + .client + .release_payment(&orchestrator, &task_id, &7, &test_env.usdc_sac, &100); + + let result = + test_env + .client + .try_release_payment(&orchestrator, &task_id, &7, &test_env.usdc_sac, &101); + + assert!(result == Err(Ok(VaultError::ReleaseConflict))); + assert_eq!(test_env.token_client.balance(&orchestrator), 100); + assert_eq!(test_env.client.get_task(&task_id).unwrap().spent, 100); +} + +#[test] +fn test_release_payment_distinct_steps_same_amount_accumulate() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let user = Address::generate(&test_env.env); + let orchestrator = Address::generate(&test_env.env); + let name = soroban_sdk::String::from_str(&test_env.env, "DistinctStepsOrchestrator"); + + test_env.token_admin_client.mint(&user, &1000); + test_env.client.deposit(&user, &test_env.usdc_sac, &500); + test_env + .client + .register_orchestrator(&user, &orchestrator, &name); + let task_id = test_env + .client + .create_task(&orchestrator, &test_env.usdc_sac, &300); + + test_env + .client + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); + test_env + .client + .release_payment(&orchestrator, &task_id, &2, &test_env.usdc_sac, &100); + + assert_eq!(test_env.token_client.balance(&orchestrator), 200); + assert_eq!(test_env.client.get_task(&task_id).unwrap().spent, 200); +} + +#[test] +fn test_release_payment_step_records_are_bounded_per_task() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let user = Address::generate(&test_env.env); + let orchestrator = Address::generate(&test_env.env); + let name = soroban_sdk::String::from_str(&test_env.env, "BoundedStepsOrchestrator"); + + test_env.token_admin_client.mint(&user, &1000); + test_env.client.deposit(&user, &test_env.usdc_sac, &500); + test_env + .client + .register_orchestrator(&user, &orchestrator, &name); + let task_id = test_env + .client + .create_task(&orchestrator, &test_env.usdc_sac, &300); + + for step_id in 1..=256 { + assert!(test_env.client.release_payment( + &orchestrator, + &task_id, + &step_id, + &test_env.usdc_sac, + &1 + )); + } + + let result = + test_env + .client + .try_release_payment(&orchestrator, &task_id, &257, &test_env.usdc_sac, &1); + + assert!(result == Err(Ok(VaultError::TooManyStepReleases))); + assert_eq!(test_env.client.get_task(&task_id).unwrap().spent, 256); +} + +#[test] +fn test_release_payment_replay_after_completion_rejects_cleanly() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let user = Address::generate(&test_env.env); + let orchestrator = Address::generate(&test_env.env); + let name = soroban_sdk::String::from_str(&test_env.env, "CompletedReplayOrchestrator"); + + test_env.token_admin_client.mint(&user, &1000); + test_env.client.deposit(&user, &test_env.usdc_sac, &500); + test_env + .client + .register_orchestrator(&user, &orchestrator, &name); + let task_id = test_env + .client + .create_task(&orchestrator, &test_env.usdc_sac, &300); + + test_env + .client + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); + test_env.client.complete_task(&orchestrator, &task_id); + + let result = + test_env + .client + .try_release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); + + assert!(result == Err(Ok(VaultError::TaskAlreadyCompleted))); + assert_eq!(test_env.token_client.balance(&orchestrator), 100); +} + +#[test] +fn test_release_payment_replay_after_force_complete_rejects_cleanly() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + test_env.env.ledger().set_timestamp(1000); + let user = Address::generate(&test_env.env); + let orchestrator = Address::generate(&test_env.env); + let name = soroban_sdk::String::from_str(&test_env.env, "StaleReplayOrchestrator"); + + test_env.token_admin_client.mint(&user, &1000); + test_env.client.deposit(&user, &test_env.usdc_sac, &500); + test_env + .client + .register_orchestrator(&user, &orchestrator, &name); + let task_id = test_env + .client + .create_task(&orchestrator, &test_env.usdc_sac, &300); + + test_env + .client + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); + test_env.env.ledger().set_timestamp(2801); + test_env.client.force_complete_stale_task(&task_id); + + let result = + test_env + .client + .try_release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); + + assert!(result == Err(Ok(VaultError::TaskAlreadyCompleted))); + assert_eq!(test_env.token_client.balance(&orchestrator), 100); +} + #[test] fn test_release_payment_on_completed_task_fails() { let test_env = setup_test(); @@ -747,14 +949,14 @@ fn test_release_payment_on_completed_task_fails() { test_env .client - .release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &100); + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); test_env.client.complete_task(&orchestrator, &task_id); // Try releasing on completed task let result = test_env .client - .try_release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &50); + .try_release_payment(&orchestrator, &task_id, &2, &test_env.usdc_sac, &50); assert!(result == Err(Ok(VaultError::TaskAlreadyCompleted))); } @@ -782,6 +984,7 @@ fn test_release_payment_unauthorized_orchestrator_fails() { let result = test_env.client.try_release_payment( &wrong_orchestrator, &task_id, + &1, &test_env.usdc_sac, &100, ); @@ -811,7 +1014,7 @@ fn test_release_payment_zero_amount_fails() { let result = test_env .client - .try_release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &0); + .try_release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &0); assert!(result == Err(Ok(VaultError::InvalidAmount))); } @@ -838,7 +1041,7 @@ fn test_release_payment_negative_amount_fails() { let result = test_env .client - .try_release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &-50); + .try_release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &-50); assert!(result == Err(Ok(VaultError::InvalidAmount))); } @@ -865,7 +1068,7 @@ fn test_complete_task_success() { test_env .client - .release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &100); + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); // Complete the task test_env.client.complete_task(&orchestrator, &task_id); @@ -914,10 +1117,10 @@ fn test_two_concurrent_tasks_complete_independently() { test_env .client - .release_payment(&orchestrator, &first_task_id, &test_env.usdc_sac, &60); + .release_payment(&orchestrator, &first_task_id, &1, &test_env.usdc_sac, &60); test_env .client - .release_payment(&orchestrator, &second_task_id, &test_env.usdc_sac, &90); + .release_payment(&orchestrator, &second_task_id, &1, &test_env.usdc_sac, &90); test_env.client.complete_task(&orchestrator, &first_task_id); @@ -1005,7 +1208,7 @@ fn test_cancel_task_success() { test_env .client - .release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &100); + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); // User cancels their own task test_env.client.cancel_task(&user, &task_id); @@ -1397,6 +1600,62 @@ fn persistent_ttl(test_env: &TestEnv, key: &DataKey) -> u32 { }) } +fn persistent_has(test_env: &TestEnv, key: &DataKey) -> bool { + test_env.env.as_contract(&test_env.contract_id, || { + test_env.env.storage().persistent().has(key) + }) +} + +#[test] +fn test_step_release_records_refresh_ttl_and_are_removed_on_finalize() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let user = Address::generate(&test_env.env); + let orchestrator = Address::generate(&test_env.env); + let name = soroban_sdk::String::from_str(&test_env.env, "TtlOrchestrator"); + + test_env.token_admin_client.mint(&user, &1000); + test_env.client.deposit(&user, &test_env.usdc_sac, &500); + test_env + .client + .register_orchestrator(&user, &orchestrator, &name); + let task_id = test_env + .client + .create_task(&orchestrator, &test_env.usdc_sac, &300); + + test_env + .client + .release_payment(&orchestrator, &task_id, &99, &test_env.usdc_sac, &100); + + let record_key = DataKey::TaskStepRelease(task_id, 99); + let ids_key = DataKey::TaskStepIds(task_id); + assert!(persistent_has(&test_env, &record_key)); + assert!(persistent_has(&test_env, &ids_key)); + + let start = test_env.env.ledger().sequence(); + test_env + .env + .ledger() + .set_sequence_number(start + TTL_DECAY_STEP); + assert!(persistent_ttl(&test_env, &record_key) < TTL_EXTEND_THRESHOLD); + assert!(persistent_ttl(&test_env, &ids_key) < TTL_EXTEND_THRESHOLD); + + assert!(test_env.client.release_payment( + &orchestrator, + &task_id, + &99, + &test_env.usdc_sac, + &100 + )); + assert!(persistent_ttl(&test_env, &record_key) > TTL_EXTEND_THRESHOLD); + assert!(persistent_ttl(&test_env, &ids_key) > TTL_EXTEND_THRESHOLD); + + test_env.client.complete_task(&orchestrator, &task_id); + assert!(!persistent_has(&test_env, &record_key)); + assert!(!persistent_has(&test_env, &ids_key)); +} + #[test] fn test_is_supported_asset_refreshes_index_ttl() { // Direction 1: per-asset lookups (as deposit/create_task perform) must keep @@ -1554,7 +1813,7 @@ fn test_multi_asset_deposit_withdraw_task_flow() { // Release payment in XLM test_env .client - .release_payment(&orchestrator, &task_id, &xlm_sac, &200); + .release_payment(&orchestrator, &task_id, &1, &xlm_sac, &200); assert_eq!(xlm_client.balance(&orchestrator), 200); assert_eq!(test_env.token_client.balance(&orchestrator), 0); // No USDC transferred @@ -1676,7 +1935,7 @@ fn test_release_payment_reverts_when_paused() { let result = test_env .client - .try_release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &100); + .try_release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); assert!(result == Err(Ok(VaultError::ContractPaused))); } @@ -2289,6 +2548,7 @@ mod invariant_tests { user_idx: usize, orchestrator_idx: usize, asset_idx: usize, + next_step_id: u64, } struct InvariantTestHarness { @@ -2491,6 +2751,7 @@ mod invariant_tests { user_idx: orch_idx, orchestrator_idx: orch_idx, asset_idx, + next_step_id: 1, }); } } @@ -2499,15 +2760,17 @@ mod invariant_tests { if task_idx >= self.active_tasks.len() { return; } - let task_state = &self.active_tasks[task_idx]; + let task_state = &mut self.active_tasks[task_idx]; let task_id = task_state.id; + let step_id = task_state.next_step_id; + task_state.next_step_id += 1; let orchestrator = &self.orchestrators[task_state.orchestrator_idx]; let assets = [self.usdc_sac.clone(), self.xlm_sac.clone()]; let asset = &assets[task_state.asset_idx]; - let _ = self - .client - .try_release_payment(orchestrator, &task_id, asset, &amount); + let _ = + self.client + .try_release_payment(orchestrator, &task_id, &step_id, asset, &amount); } fn complete_task(&mut self, task_idx: usize, seed: u64, step_idx: usize) { @@ -2716,7 +2979,7 @@ mod invariant_tests { // Release step payment of 100 test_env .client - .release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &100); + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); // Token balance drops by 100 assert_eq!(test_env.client.token_balance(&test_env.usdc_sac), 400); @@ -2734,7 +2997,7 @@ mod invariant_tests { #[test] fn test_version_returns_contract_version() { let test_env = setup_test(); - assert_eq!(test_env.client.version(), 4); + assert_eq!(test_env.client.version(), 5); } // 13. Dispute & Arbitration Tests @@ -2808,7 +3071,7 @@ fn test_dispute_happy_path_split() { // Orchestrator already released 100 of the 300 plan cost. test_env .client - .release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &100); + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &100); // User raises the dispute: it freezes releases and completion. test_env.client.raise_dispute(&user, &task_id); @@ -2820,9 +3083,10 @@ fn test_dispute_happy_path_split() { Some(TaskStatus::Disputed) ); - let rel = test_env - .client - .try_release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &50); + let rel = + test_env + .client + .try_release_payment(&orchestrator, &task_id, &2, &test_env.usdc_sac, &50); assert!(rel == Err(Ok(VaultError::TaskDisputed))); let cmp = test_env.client.try_complete_task(&orchestrator, &task_id); assert!(cmp == Err(Ok(VaultError::TaskDisputed))); @@ -3169,7 +3433,7 @@ fn test_dispute_resolution_does_not_claw_back_spent() { // 200 already released; remaining locked = 100. test_env .client - .release_payment(&orchestrator, &task_id, &test_env.usdc_sac, &200); + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &200); test_env.client.raise_dispute(&user, &task_id); // Resolution only touches the still-locked 100; the released 200 stays out. diff --git a/packages/orchestrator/src/__tests__/vault-client.integration.test.ts b/packages/orchestrator/src/__tests__/vault-client.integration.test.ts index d4a7707..6e13900 100644 --- a/packages/orchestrator/src/__tests__/vault-client.integration.test.ts +++ b/packages/orchestrator/src/__tests__/vault-client.integration.test.ts @@ -78,6 +78,19 @@ async function signAndSubmit( return pollForConfirmation(server, response.hash); } +async function createVaultTask(orchestrator: Keypair, planCostUsdc: number): Promise { + const hash = await signAndSubmit(orchestrator, 'create_task', [ + new Address(orchestrator.publicKey()).toScVal(), + new Address(USDC_SAC).toScVal(), + usdcToScVal(planCostUsdc), + ]); + const result = await rpcServer().getTransaction(hash); + if (result.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS || !result.returnValue) { + throw new Error(`create_task returned no task id: ${hash}`); + } + return BigInt(scValToNative(result.returnValue)); +} + async function pollForConfirmation(server: SorobanRpc.Server, hash: string): Promise { for (let i = 0; i < 30; i++) { await new Promise((r) => setTimeout(r, 1000)); @@ -200,11 +213,7 @@ describe.skipIf(!CONTRACT_ID || !USDC_SAC)('vault-client integration @integratio const before = await getAvailable(user.publicKey()); - await signAndSubmit(orchestratorKp, 'create_task', [ - new Address(orchestratorKp.publicKey()).toScVal(), - new Address(usdcSac).toScVal(), - usdcToScVal(0.1), - ]); + await createVaultTask(orchestratorKp, 0.1); const after = await getAvailable(user.publicKey()); expect(before - after).toBe(BigInt(Math.round(0.1 * STROOPS_PER_USDC))); @@ -233,17 +242,14 @@ describe.skipIf(!CONTRACT_ID || !USDC_SAC)('vault-client integration @integratio ]); // Create task with 0.1 USDC - await signAndSubmit(orchestratorKp, 'create_task', [ - new Address(orchestratorKp.publicKey()).toScVal(), - new Address(usdcSac).toScVal(), - usdcToScVal(0.1), - ]); + const taskId = await createVaultTask(orchestratorKp, 0.1); const orchBefore = await getBalance(orchestratorKp.publicKey()); - // Release 0.05 USDC (task_id=1 for first task in this context) + // Release 0.05 USDC against the task id returned by create_task. await signAndSubmit(orchestratorKp, 'release_payment', [ new Address(orchestratorKp.publicKey()).toScVal(), + nativeToScVal(taskId, { type: 'u64' }), nativeToScVal(1n, { type: 'u64' }), new Address(usdcSac).toScVal(), usdcToScVal(0.05), @@ -276,15 +282,11 @@ describe.skipIf(!CONTRACT_ID || !USDC_SAC)('vault-client integration @integratio ]); // Create + complete a task - await signAndSubmit(orchestratorKp, 'create_task', [ - new Address(orchestratorKp.publicKey()).toScVal(), - new Address(usdcSac).toScVal(), - usdcToScVal(0.3), - ]); + const taskId = await createVaultTask(orchestratorKp, 0.3); await signAndSubmit(orchestratorKp, 'complete_task', [ new Address(orchestratorKp.publicKey()).toScVal(), - nativeToScVal(1n, { type: 'u64' }), + nativeToScVal(taskId, { type: 'u64' }), ]); const available = await getAvailable(user.publicKey()); @@ -305,7 +307,7 @@ describe.skipIf(!CONTRACT_ID || !USDC_SAC)('vault-client integration @integratio // 5. Double release ─────────────────────────────────────────────────────── it( - 'double release_payment on same task fails', + 'duplicate release_payment with same step_id is idempotent', async () => { const user = Keypair.random(); await fundViaFriendbot(user.publicKey()); @@ -323,24 +325,34 @@ describe.skipIf(!CONTRACT_ID || !USDC_SAC)('vault-client integration @integratio ]); // Task with 0.05 budget - await signAndSubmit(orchestratorKp, 'create_task', [ + const taskId = await createVaultTask(orchestratorKp, 0.05); + + // First release: 0.05 (full budget) + await signAndSubmit(orchestratorKp, 'release_payment', [ new Address(orchestratorKp.publicKey()).toScVal(), + nativeToScVal(taskId, { type: 'u64' }), + nativeToScVal(1n, { type: 'u64' }), new Address(usdcSac).toScVal(), usdcToScVal(0.05), ]); - // First release: 0.05 (full budget) + const orchBeforeReplay = await getBalance(orchestratorKp.publicKey()); + await signAndSubmit(orchestratorKp, 'release_payment', [ new Address(orchestratorKp.publicKey()).toScVal(), + nativeToScVal(taskId, { type: 'u64' }), nativeToScVal(1n, { type: 'u64' }), new Address(usdcSac).toScVal(), usdcToScVal(0.05), ]); - // Second release should fail (exceeds plan_cost) + const orchAfterReplay = await getBalance(orchestratorKp.publicKey()); + expect(orchAfterReplay).toBe(orchBeforeReplay); + await expect( signAndSubmit(orchestratorKp, 'release_payment', [ new Address(orchestratorKp.publicKey()).toScVal(), + nativeToScVal(taskId, { type: 'u64' }), nativeToScVal(1n, { type: 'u64' }), new Address(usdcSac).toScVal(), usdcToScVal(0.01), diff --git a/packages/orchestrator/src/agent-vault-client.ts b/packages/orchestrator/src/agent-vault-client.ts index de62936..fc08c0a 100644 --- a/packages/orchestrator/src/agent-vault-client.ts +++ b/packages/orchestrator/src/agent-vault-client.ts @@ -22,6 +22,7 @@ import { const CONTRACT_ID = process.env.AGENT_VAULT_CONTRACT_ID ?? ''; const RPC_URL = process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org'; +const USDC_SAC = process.env.USDC_SAC ?? ''; const NETWORK_PASSPHRASE = Networks.TESTNET; const STROOPS_PER_USDC = 10_000_000; @@ -37,6 +38,13 @@ function usdcToStroops(usdc: number): bigint { return BigInt(Math.round(usdc * STROOPS_PER_USDC)); } +function usdcSacScVal(): xdr.ScVal { + if (!USDC_SAC) { + throw new Error('USDC_SAC is required for AgentVault multi-asset calls'); + } + return new Address(USDC_SAC).toScVal(); +} + function rpc() { return new SorobanRpc.Server(RPC_URL, { allowHttp: false }); } @@ -162,6 +170,7 @@ export async function buildDepositXdr( if (!VAULT_ACTIVE) return null; return buildUnsignedXdr(userAddress, 'deposit', [ new Address(userAddress).toScVal(), + usdcSacScVal(), nativeToScVal(usdcToStroops(amountUsdc), { type: 'i128' }), ]); } @@ -177,6 +186,7 @@ export async function buildWithdrawXdr( if (!VAULT_ACTIVE) return null; return buildUnsignedXdr(userAddress, 'withdraw', [ new Address(userAddress).toScVal(), + usdcSacScVal(), nativeToScVal(usdcToStroops(amountUsdc), { type: 'i128' }), ]); } @@ -207,6 +217,7 @@ export async function createTask( contract.call( 'create_task', new Address(orchestratorKeypair.publicKey()).toScVal(), + usdcSacScVal(), nativeToScVal(usdcToStroops(planCostUsdc), { type: 'i128' }), ), ) @@ -242,6 +253,7 @@ export async function createTask( export async function releasePayment( orchestratorKeypair: Keypair, taskId: bigint, + stepId: bigint, amountUsdc: number, ): Promise { if (!VAULT_ACTIVE || !taskId) return null; @@ -249,6 +261,8 @@ export async function releasePayment( const hash = await signAndSubmit(orchestratorKeypair, 'release_payment', [ new Address(orchestratorKeypair.publicKey()).toScVal(), nativeToScVal(taskId, { type: 'u64' }), + nativeToScVal(stepId, { type: 'u64' }), + usdcSacScVal(), nativeToScVal(usdcToStroops(amountUsdc), { type: 'i128' }), ]); return hash; @@ -344,7 +358,10 @@ async function callView(method: string, args: xdr.ScVal[]): Promise { export async function getBalance(userAddress: string): Promise { if (!VAULT_ACTIVE) return 0n; try { - const result = await callView('get_balance', [new Address(userAddress).toScVal()]); + const result = await callView('get_balance', [ + new Address(userAddress).toScVal(), + usdcSacScVal(), + ]); return result !== null ? BigInt(result) : 0n; } catch { return 0n; @@ -355,7 +372,10 @@ export async function getBalance(userAddress: string): Promise { export async function getAvailable(userAddress: string): Promise { if (!VAULT_ACTIVE) return 0n; try { - const result = await callView('get_available', [new Address(userAddress).toScVal()]); + const result = await callView('get_available', [ + new Address(userAddress).toScVal(), + usdcSacScVal(), + ]); return result !== null ? BigInt(result) : 0n; } catch { return 0n; @@ -383,7 +403,7 @@ export interface VaultAccount { export async function getAccount(userAddress: string): Promise { if (!VAULT_ACTIVE) return null; // Let exceptions propagate — caller distinguishes RPC errors from "no account" - const raw = await callView('get_account', [new Address(userAddress).toScVal()]); + const raw = await callView('get_account', [new Address(userAddress).toScVal(), usdcSacScVal()]); // Option::None from the contract → account doesn't exist yet → zero balance if (raw === null || raw === undefined) { return { diff --git a/packages/orchestrator/src/executor.ts b/packages/orchestrator/src/executor.ts index 65231ce..5fabb8d 100644 --- a/packages/orchestrator/src/executor.ts +++ b/packages/orchestrator/src/executor.ts @@ -259,8 +259,14 @@ export class PlanExecutor extends EventEmitter { // ── Vault release: contract → orchestrator (serialized to avoid sequence conflicts) let releaseHash: string | null = null; if (VAULT_ACTIVE && this.orchestratorKeypair && this.vaultTaskId !== null) { + const vaultStepId = BigInt(step.step_id); const released = await this.releaseSequential(async () => { - return releasePayment(this.orchestratorKeypair!, this.vaultTaskId!, amountUsdc); + return releasePayment( + this.orchestratorKeypair!, + this.vaultTaskId!, + vaultStepId, + amountUsdc, + ); }); if (!released) {