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
1 change: 1 addition & 0 deletions app/onchain/contracts/aid_escrow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ soroban-sdk = { workspace = true, features = ["testutils"] }
ed25519-dalek = "2.2"
serde_json = "1.0"
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
16 changes: 16 additions & 0 deletions app/onchain/contracts/aid_escrow/GAS_BUDGETS.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"tolerance_percent": 5.0,
"budgets": {
"Single create_package": { "cpu": 87508, "memory": 17667 },
"Batch create_packages (size: 10)": { "cpu": 584710, "memory": 123066 },
"Batch create_packages (size: 25)": { "cpu": 1958279, "memory": 442401 },
"Batch create_packages (size: 50)": { "cpu": 5559545, "memory": 1350626 },
"Batch create_packages (size: 100)": { "cpu": 17573866, "memory": 4577076 },
"Batch create_packages (size: 200)": { "cpu": 60633076, "memory": 16669976 },
"Single claim": { "cpu": 78143, "memory": 11020 },
"Claim with Merkle proof": { "cpu": 157326, "memory": 18945 },
"Fund operation (1 token)": { "cpu": 110627, "memory": 16179 },
"Get package": { "cpu": 20000, "memory": 8000 },
"Get aggregates (50 packages)": { "cpu": 2000000, "memory": 500000 }
}
}
156 changes: 156 additions & 0 deletions app/onchain/contracts/aid_escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,162 @@ impl AidEscrow {
Ok(())
}

/// Admin-only. Batch revoke packages.
/// Accepts a bounded list of package ids and revokes each one.
/// Behavior: atomic validation first (rejects if any package is in an invalid state),
/// then applies revocations. Already-cancelled packages are treated as idempotent successes.
pub fn batch_revoke(env: Env, ids: Vec<u64>) -> Result<Vec<u64>, Error> {
let admin = Self::get_admin(env.clone())?;
admin.require_auth();

// Validation pass: ensure all packages exist and are revokable (Created or already Cancelled)
for i in 0..ids.len() {
let id = ids.get(i).unwrap();
let key = (symbol_short!("pkg"), id);
let package: Package = env
.storage()
.persistent()
.get(&key)
.ok_or(Error::PackageNotFound)?;

match package.status {
PackageStatus::Created | PackageStatus::Cancelled => {}
_ => return Err(Error::InvalidState),
}
}

let mut ok_ids: Vec<u64> = Vec::new(&env);

// Apply pass: perform revocations for packages that are still Created.
for i in 0..ids.len() {
let id = ids.get(i).unwrap();
let key = (symbol_short!("pkg"), id);
let mut package: Package = env
.storage()
.persistent()
.get(&key)
.ok_or(Error::PackageNotFound)?;

if package.status == PackageStatus::Created {
package.status = PackageStatus::Cancelled;
env.storage().persistent().set(&key, &package);

// Unlock funds
Self::decrement_locked(&env, &package.token, package.amount);

let timestamp = env.ledger().timestamp();
PackageRevoked {
package_id: id,
recipient: package.recipient.clone(),
amount: package.amount,
actor: admin.clone(),
timestamp,
}
.publish(&env);
}

ok_ids.push_back(id);
}

Ok(ok_ids)
}

/// Admin-only. Batch refund packages.
/// Accepts a bounded list of package ids and refunds each one to admin.
/// Atomic validation first: rejects the whole call if any package is in an invalid state
/// (e.g., Claimed). Already-refunded packages are treated as idempotent successes.
pub fn batch_refund(env: Env, ids: Vec<u64>) -> Result<Vec<u64>, Error> {
let admin = Self::get_admin(env.clone())?;
admin.require_auth();

let now = env.ledger().timestamp();

// Validation pass
for i in 0..ids.len() {
let id = ids.get(i).unwrap();
let key = (symbol_short!("pkg"), id);
let package: Package = env
.storage()
.persistent()
.get(&key)
.ok_or(Error::PackageNotFound)?;

// Determine acceptable statuses: Cancelled, Expired, Created(if actually expired), Refunded (idempotent)
match package.status {
PackageStatus::Created => {
// Created packages can only be refunded if actually expired.
if package.expires_at == 0 || now <= package.expires_at {
return Err(Error::InvalidState);
}
}
PackageStatus::Expired | PackageStatus::Cancelled | PackageStatus::Refunded => {
// ok (Refunded is idempotent)
}
_ => return Err(Error::InvalidState),
}
}

let mut ok_ids: Vec<u64> = Vec::new(&env);

// Apply pass: perform transfers and state updates for packages not already Refunded
for i in 0..ids.len() {
let id = ids.get(i).unwrap();
let key = (symbol_short!("pkg"), id);
let mut package: Package = env
.storage()
.persistent()
.get(&key)
.ok_or(Error::PackageNotFound)?;

if package.status == PackageStatus::Refunded {
ok_ids.push_back(id);
continue;
}

// If Created but expired, reflect that before transfer
let mut should_unlock_locked = false;
if package.status == PackageStatus::Created {
// At this point validation already ensured it's expired
package.status = PackageStatus::Expired;
should_unlock_locked = true;
} else if package.status == PackageStatus::Expired {
should_unlock_locked = true;
} else if package.status == PackageStatus::Cancelled {
should_unlock_locked = false;
}

// Transfer Contract -> Admin
Self::transfer_token(
&env,
&package.token,
&env.current_contract_address(),
&admin,
&package.amount,
)?;

if should_unlock_locked {
Self::decrement_locked(&env, &package.token, package.amount);
}

package.status = PackageStatus::Refunded;
env.storage().persistent().set(&key, &package);

let timestamp = env.ledger().timestamp();
PackageRefunded {
package_id: id,
recipient: package.recipient.clone(),
amount: package.amount,
actor: admin.clone(),
timestamp,
}
.publish(&env);

ok_ids.push_back(id);
}

Ok(ok_ids)
}

/// Admin-only package cancellation.
/// Requirements: Admin auth, existing package, status must be 'Created'.
pub fn cancel_package(env: Env, package_id: u64) -> Result<(), Error> {
Expand Down
81 changes: 81 additions & 0 deletions app/onchain/contracts/aid_escrow/tests/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,84 @@ fn test_batch_create_packages_empty_arrays() {
);
assert_eq!(ids.len(), 0);
}

#[test]
fn test_batch_revoke_and_idempotent() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let recipient1 = Address::generate(&env);
let recipient2 = Address::generate(&env);
let token_admin = Address::generate(&env);
let (token_client, token_admin_client) = setup_token(&env, &token_admin);

let contract_id = env.register(AidEscrow, ());
let client = AidEscrowClient::new(&env, &contract_id);

client.init(&admin);
token_admin_client.mint(&admin, &(10 * UNIT));
client.fund(&token_client.address, &admin, &(10 * UNIT));

let mut recipients = Vec::new(&env);
recipients.push_back(recipient1.clone());
recipients.push_back(recipient2.clone());

let mut amounts = Vec::new(&env);
amounts.push_back(UNIT);
amounts.push_back(UNIT);

let ids = client.batch_create_packages(&admin, &recipients, &amounts, &token_client.address, &86400, &empty_metadata(&env, 2));

// Revoke both in batch
let revoked = client.batch_revoke(&ids);
assert_eq!(revoked.len(), 2);
assert_eq!(client.get_package(&ids.get(0).unwrap()).status, aid_escrow::PackageStatus::Cancelled);

// Second invocation should be idempotent (already cancelled)
let revoked2 = client.batch_revoke(&ids);
assert_eq!(revoked2.len(), 2);
}

#[test]
fn test_batch_refund_and_partial_failure() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let recipient1 = Address::generate(&env);
let recipient2 = Address::generate(&env);
let token_admin = Address::generate(&env);
let (token_client, token_admin_client) = setup_token(&env, &token_admin);

let contract_id = env.register(AidEscrow, ());
let client = AidEscrowClient::new(&env, &contract_id);

client.init(&admin);
token_admin_client.mint(&admin, &(10 * UNIT));
client.fund(&token_client.address, &admin, &(10 * UNIT));

// create two packages, one we'll claim so refund batch should fail validation
let mut recipients = Vec::new(&env);
recipients.push_back(recipient1.clone());
recipients.push_back(recipient2.clone());

let mut amounts = Vec::new(&env);
amounts.push_back(UNIT);
amounts.push_back(UNIT);

let ids = client.batch_create_packages(&admin, &recipients, &amounts, &token_client.address, &1, &empty_metadata(&env, 2));

// Claim the second package to make it ineligible for refund
client.claim(&ids.get(1).unwrap());

// Attempt batch_refund should error because one package is Claimed
let result = client.try_batch_refund(&ids);
assert!(result.is_err());

// Now revoke first package, then refund just first package successfully
client.batch_revoke(&Vec::from_array(&env, &[ids.get(0).unwrap().clone()]));
let single = Vec::from_array(&env, &[ids.get(0).unwrap().clone()]);
let refunded = client.batch_refund(&single);
assert_eq!(refunded.len(), 1);
}
Loading