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
65 changes: 65 additions & 0 deletions contracts/agent-vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions contracts/agent-vault/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
15 changes: 15 additions & 0 deletions packages/dashboard/src/lib/vault-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string> {
const res = await fetch(`${BASE}/api/vault/withdraw-xdr`, {
method: 'POST',
Expand Down
17 changes: 17 additions & 0 deletions packages/orchestrator/src/agent-vault-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
if (!VAULT_ACTIVE) return null;
return buildUnsignedXdr(funderAddress, 'deposit_for', [
new Address(funderAddress).toScVal(),
new Address(recipientAddress).toScVal(),
nativeToScVal(usdcToStroops(amountUsdc), { type: 'i128' }),
]);
Comment on lines +173 to +183

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Pass the required asset argument through the full flow.

deposit_for(from, user, asset, amount) requires four contract arguments, but this XDR encodes only from, user, and amount. The simulator will reject the call because the i128 amount occupies the required asset: Address position.

  • packages/orchestrator/src/agent-vault-client.ts#L173-L183: accept assetAddress and encode new Address(assetAddress).toScVal() before the i128 amount.
  • packages/orchestrator/src/server.ts#L641-L659: require asset_address and pass it to buildDepositForXdr.
  • packages/dashboard/src/lib/vault-client.ts#L39-L52: accept and send asset_address.
📍 Affects 3 files
  • packages/orchestrator/src/agent-vault-client.ts#L173-L183 (this comment)
  • packages/orchestrator/src/server.ts#L641-L659
  • packages/dashboard/src/lib/vault-client.ts#L39-L52
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestrator/src/agent-vault-client.ts` around lines 173 - 183,
Update buildDepositForXdr in packages/orchestrator/src/agent-vault-client.ts
(lines 173-183) to accept assetAddress and encode it between recipientAddress
and the i128 amount. In packages/orchestrator/src/server.ts (lines 641-659),
require asset_address and pass it through to buildDepositForXdr. In
packages/dashboard/src/lib/vault-client.ts (lines 39-52), accept and include
asset_address in the request payload so the full deposit_for argument flow is
preserved.

}

/**
* Build an unsigned `withdraw` XDR for the user to sign in Freighter.
* Fails on-chain if `amountUsdc` exceeds the user's available (unlocked) balance.
Expand Down
20 changes: 20 additions & 0 deletions packages/orchestrator/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Loading