diff --git a/contracts/agent-vault/src/lib.rs b/contracts/agent-vault/src/lib.rs index 57f5b6e..f3472a4 100644 --- a/contracts/agent-vault/src/lib.rs +++ b/contracts/agent-vault/src/lib.rs @@ -33,6 +33,17 @@ pub struct DepositEvent { pub amount: i128, } +#[contractevent] +pub struct DepositForEvent { + #[topic] + pub from: Address, + #[topic] + pub user: Address, + #[topic] + pub asset: Address, + pub amount: i128, +} + #[contractevent] pub struct WithdrawEvent { #[topic] @@ -452,6 +463,60 @@ impl AgentVault { Ok(()) } + /// Deposit supported tokens from an external wallet into another user's vault balance. + /// The caller (`from`) is the funder — their wallet is debited. The deposit is + /// credited to `user`'s vault account for the given asset. + pub fn deposit_for( + env: Env, + from: Address, + user: Address, + asset: Address, + amount: i128, + ) -> Result<(), VaultError> { + from.require_auth(); + Self::require_not_paused(&env)?; + if amount <= 0 { + return Err(VaultError::InvalidAmount); + } + if !Self::is_supported_asset(env.clone(), asset.clone()) { + return Err(VaultError::AssetNotSupported); + } + + Self::extend_instance_ttl(&env); + let token_client = token::Client::new(&env, &asset); + token_client.transfer(&from, env.current_contract_address(), &amount); + + let config = Self::get_or_create_config(&env, &user); + let config_key = DataKey::UserConfig(user.clone()); + env.storage().persistent().set(&config_key, &config); + Self::extend_persistent_ttl(&env, &config_key); + + let mut asset_account = Self::get_or_create_asset_account(&env, &user, &asset); + asset_account.balance += amount; + asset_account.total_deposited += amount; + let asset_key = DataKey::UserAsset(user.clone(), asset.clone()); + env.storage().persistent().set(&asset_key, &asset_account); + Self::extend_persistent_ttl(&env, &asset_key); + + DepositForEvent { + from: from.clone(), + user: user.clone(), + asset: asset.clone(), + amount, + } + .publish(&env); + log!( + &env, + "deposit_for from={} user={} asset={} amount={} new_balance={}", + from, + user, + asset, + amount, + asset_account.balance + ); + Ok(()) + } + /// Withdraw tokens from vault back to user's external wallet. /// Only the unlocked portion (`balance - locked`) of the given asset may be /// withdrawn; funds locked by active tasks for that asset stay reserved, so a diff --git a/contracts/agent-vault/src/tests.rs b/contracts/agent-vault/src/tests.rs index 27e0e51..a03b631 100644 --- a/contracts/agent-vault/src/tests.rs +++ b/contracts/agent-vault/src/tests.rs @@ -155,6 +155,140 @@ fn test_deposit_negative_fails() { assert!(result == Err(Ok(VaultError::InvalidAmount))); } +// 2b. Deposit For Tests + +#[test] +fn test_deposit_for_success() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let from = Address::generate(&test_env.env); + let user = Address::generate(&test_env.env); + + test_env.token_admin_client.mint(&from, &1000); + assert_eq!(test_env.token_client.balance(&from), 1000); + + test_env.client.deposit_for(&from, &user, &test_env.usdc_sac, &400); + + // from's wallet debited, contract credited + assert_eq!(test_env.token_client.balance(&from), 600); + assert_eq!(test_env.token_client.balance(&test_env.contract_id), 400); + + // user's vault account credited + let account = test_env + .client + .get_account(&user, &test_env.usdc_sac) + .unwrap(); + assert_eq!(account.balance, 400); + assert_eq!(account.total_deposited, 400); + + // from has no vault account (deposit_for does not credit the funder) + assert!(test_env.client.get_account(&from, &test_env.usdc_sac).is_none()); +} + +#[test] +fn test_deposit_for_zero_fails() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + let from = Address::generate(&test_env.env); + let user = Address::generate(&test_env.env); + let result = test_env.client.try_deposit_for(&from, &user, &test_env.usdc_sac, &0); + assert!(result == Err(Ok(VaultError::InvalidAmount))); +} + +#[test] +fn test_deposit_for_negative_fails() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + let from = Address::generate(&test_env.env); + let user = Address::generate(&test_env.env); + let result = test_env.client.try_deposit_for(&from, &user, &test_env.usdc_sac, &-50); + assert!(result == Err(Ok(VaultError::InvalidAmount))); +} + +#[test] +fn test_deposit_for_non_whitelisted_asset_fails() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let from = Address::generate(&test_env.env); + let user = Address::generate(&test_env.env); + let xlm_sac = test_env + .env + .register_stellar_asset_contract_v2(test_env.admin.clone()) + .address(); + + let result = test_env.client.try_deposit_for(&from, &user, &xlm_sac, &200); + assert!(result == Err(Ok(VaultError::AssetNotSupported))); +} + +#[test] +fn test_deposit_for_multiple_funders() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let from1 = Address::generate(&test_env.env); + let from2 = Address::generate(&test_env.env); + let user = Address::generate(&test_env.env); + + test_env.token_admin_client.mint(&from1, &500); + test_env.token_admin_client.mint(&from2, &500); + + test_env.client.deposit_for(&from1, &user, &test_env.usdc_sac, &200); + test_env.client.deposit_for(&from2, &user, &test_env.usdc_sac, &300); + + let account = test_env + .client + .get_account(&user, &test_env.usdc_sac) + .unwrap(); + assert_eq!(account.balance, 500); + assert_eq!(account.total_deposited, 500); + + assert_eq!(test_env.token_client.balance(&from1), 300); + assert_eq!(test_env.token_client.balance(&from2), 200); +} + +#[test] +fn test_deposit_for_user_can_withdraw() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let from = Address::generate(&test_env.env); + let user = Address::generate(&test_env.env); + + test_env.token_admin_client.mint(&from, &500); + test_env.client.deposit_for(&from, &user, &test_env.usdc_sac, &300); + + // user can withdraw the deposited funds + test_env.client.withdraw(&user, &test_env.usdc_sac, &100); + assert_eq!(test_env.client.get_balance(&user, &test_env.usdc_sac), 200); + assert_eq!(test_env.token_client.balance(&user), 100); +} + +#[test] +fn test_deposit_for_user_can_create_task() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let from = Address::generate(&test_env.env); + let user = Address::generate(&test_env.env); + let orchestrator = Address::generate(&test_env.env); + let name = soroban_sdk::String::from_str(&test_env.env, "MyOrchestrator"); + + test_env.token_admin_client.mint(&from, &500); + test_env.client.deposit_for(&from, &user, &test_env.usdc_sac, &300); + test_env + .client + .register_orchestrator(&user, &orchestrator, &name); + + let task_id = test_env + .client + .create_task(&orchestrator, &test_env.usdc_sac, &200); + let task = test_env.client.get_task(&task_id).unwrap(); + assert_eq!(task.user, user); + assert_eq!(task.plan_cost, 200); +} + // 3. Withdraw Tests #[test] diff --git a/packages/dashboard/src/lib/vault-client.ts b/packages/dashboard/src/lib/vault-client.ts index 23cb817..6c95ec2 100644 --- a/packages/dashboard/src/lib/vault-client.ts +++ b/packages/dashboard/src/lib/vault-client.ts @@ -36,6 +36,21 @@ export async function buildDepositXdr(userAddress: string, amount: number): Prom return data.xdr; } +export async function buildDepositForXdr( + funderAddress: string, + recipientAddress: string, + amount: number, +): Promise { + const res = await fetch(`${BASE}/api/vault/deposit-for-xdr`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ funder_address: funderAddress, recipient_address: recipientAddress, amount }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? 'Failed to build deposit_for XDR'); + return data.xdr; +} + export async function buildWithdrawXdr(userAddress: string, amount: number): Promise { const res = await fetch(`${BASE}/api/vault/withdraw-xdr`, { method: 'POST', diff --git a/packages/orchestrator/src/agent-vault-client.ts b/packages/orchestrator/src/agent-vault-client.ts index de62936..68f026c 100644 --- a/packages/orchestrator/src/agent-vault-client.ts +++ b/packages/orchestrator/src/agent-vault-client.ts @@ -166,6 +166,23 @@ export async function buildDepositXdr( ]); } +/** + * Build an unsigned `deposit_for` XDR for the funder to sign in Freighter. + * Transfers `amountUsdc` from the funder's wallet into `recipientAddress`'s vault balance. + */ +export async function buildDepositForXdr( + funderAddress: string, + recipientAddress: string, + amountUsdc: number, +): Promise { + if (!VAULT_ACTIVE) return null; + return buildUnsignedXdr(funderAddress, 'deposit_for', [ + new Address(funderAddress).toScVal(), + new Address(recipientAddress).toScVal(), + nativeToScVal(usdcToStroops(amountUsdc), { type: 'i128' }), + ]); +} + /** * Build an unsigned `withdraw` XDR for the user to sign in Freighter. * Fails on-chain if `amountUsdc` exceeds the user's available (unlocked) balance. diff --git a/packages/orchestrator/src/server.ts b/packages/orchestrator/src/server.ts index d138ff2..28fa0aa 100644 --- a/packages/orchestrator/src/server.ts +++ b/packages/orchestrator/src/server.ts @@ -638,6 +638,26 @@ app.post('/api/vault/deposit-xdr', async (req, res) => { } }); +// POST /api/vault/deposit-for-xdr — build unsigned deposit_for XDR (third-party funding) +app.post('/api/vault/deposit-for-xdr', async (req, res) => { + const { funder_address, recipient_address, amount } = req.body as { + funder_address?: string; + recipient_address?: string; + amount?: number; + }; + if (!funder_address || !recipient_address || !amount || amount <= 0) { + return res.status(400).json({ error: 'funder_address, recipient_address, and amount required' }); + } + try { + const { buildDepositForXdr } = await import('./agent-vault-client.js'); + const xdr = await buildDepositForXdr(funder_address, recipient_address, amount); + if (!xdr) return res.status(503).json({ error: 'AgentVault contract not configured' }); + res.json({ xdr }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + // POST /api/vault/withdraw-xdr — build unsigned withdraw XDR app.post('/api/vault/withdraw-xdr', async (req, res) => { const { user_address, amount } = req.body as { user_address?: string; amount?: number };