From e985316b7207a1d29afea7459bb5dd07db27d67b Mon Sep 17 00:00:00 2001 From: Adesokan-2 <196428077+Adesokan-2@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:07:52 +0100 Subject: [PATCH 1/2] feat(contract): create multiple escrows atomically --- ARCHITECTURE_ESSENTIALS.md | 1 + README.md | 1 + ROADMAP.md | 7 ++ contracts/escrow/src/lib.rs | 92 +++++++++++++++++++++++++- contracts/escrow/tests/batch_create.rs | 80 ++++++++++++++++++++++ 5 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 contracts/escrow/tests/batch_create.rs diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 3d5b571c..8ce56bec 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -15,6 +15,7 @@ get_status(escrow_id) -> EscrowStatus // enum: Funded | Released | Refunded — get_escrow(escrow_id) -> EscrowData // read-only: payer, payee, token, amount, condition_ref, status, releaser, refund_after — IMPLEMENTED + LIVE ON TESTNET (Phase E4) release(escrow_id, caller) -> result // IMPLEMENTED + LIVE ON TESTNET (Phase E2) — only escrow_id's `releaser` may call this, only while Funded refund(escrow_id, caller) -> result // IMPLEMENTED + LIVE ON TESTNET (Phase E2 + E4) — releaser always; anyone too, once refund_after has passed, and then still only back to payer +create_escrows(payer, items) -> ids // IMPLEMENTED in PR for issue #28 — atomic batch, max 5 items pending resource measurements ``` `create_escrow`'s param order in code is `(payer, payee, token, amount, condition_ref, releaser, refund_after)` — `token` before `amount`, `releaser` added by Phase E2 and `refund_after` diff --git a/README.md b/README.md index 80c2a243..d98e976b 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Two reasons, stated plainly rather than blended together: | `get_escrow(escrow_id) -> EscrowData` | ✅ Implemented | Read-only: everything stored about the escrow, including `releaser` and `refund_after`, so payees can check the deal before relying on it. | | `release(escrow_id, caller) -> Result<()>` | ✅ Implemented | Restricted to the escrow's designated `releaser`; moves funds to `payee`. Never gated by `refund_after`. | | `refund(escrow_id, caller) -> Result<()>` | ✅ Implemented | Returns funds to `payer`. The `releaser` may always call it; once `refund_after` has passed, anyone may — still only back to `payer` (see [ADR 0004](docs/adr/0004-timeout-refund-path.md)). | +| `create_escrows(payer, items) -> Vec` | ✅ Implemented | Atomically creates up to five escrows, validates all items first, and verifies the aggregate balance delta per token. Item validation error codes identify the zero-based failing item. | ``` EscrowStatus = Funded | Released | Refunded diff --git a/ROADMAP.md b/ROADMAP.md index 3894e468..06475c15 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,13 @@ repo's. ## Changelog +- 2026-10-16 — Implemented issue #28 batch escrow creation for up to five + items. The call validates each item before transferring, aggregates + expected funds per token, checks each aggregate balance delta, and + returns IDs in input order; invalid item errors encode the failing + zero-based index. Added contract coverage for mixed tokens, ordering, + rollback, and size bounds. Five is a conservative initial cap pending + resource measurements. Not yet deployed; SDK bindings are not regenerated. - 2026-09-09 — Repo created as part of the DocForum 3-repo org split. - 2026-09-14 — Phase E1 implemented: `create_escrow`/`get_status` on `soroban-sdk` 27.0.6, 4 tests passing, wasm build verified. Testnet diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index a900b94c..ba475631 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -14,7 +14,21 @@ // consuming application (docforum-core), never here. #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, token, Address, Env, String}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, Map, String, Vec}; + +/// Kept deliberately small until resource measurements justify a larger cap. +pub const MAX_BATCH_SIZE: u32 = 5; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowParams { + pub payee: Address, + pub token: Address, + pub amount: i128, + pub condition_ref: String, + pub releaser: Address, + pub refund_after: Option, +} #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -64,6 +78,13 @@ pub enum Error { /// across `create_escrow`'s transfer (a lying or short-delivering /// token) — see docs/threat-model.md T5. BalanceMismatch = 6, + InvalidBatchSize = 9, + InvalidBatchItem0 = 10, + InvalidBatchItem1 = 11, + InvalidBatchItem2 = 12, + InvalidBatchItem3 = 13, + InvalidBatchItem4 = 14, + BatchAmountOverflow = 15, } #[contract] @@ -123,6 +144,65 @@ impl EscrowContract { Ok(id) } + /// Atomically creates several escrows for the same payer. Each item's + /// validation error is encoded by its zero-based item index in the + /// returned contract error. The cap remains conservative until resource + /// measurements are available. + pub fn create_escrows(env: Env, payer: Address, items: Vec) -> Result, Error> { + if items.is_empty() || items.len() > MAX_BATCH_SIZE { + return Err(Error::InvalidBatchSize); + } + payer.require_auth(); + + let now = env.ledger().timestamp(); + let mut totals: Map = Map::new(&env); + for (index, item) in items.iter().enumerate() { + if item.amount <= 0 || item.refund_after.map_or(false, |deadline| deadline <= now) { + return Err(Self::batch_item_error(index)); + } + let total = totals.get(item.token.clone()).unwrap_or(0_i128) + .checked_add(item.amount).ok_or(Error::BatchAmountOverflow)?; + totals.set(item.token, total); + } + + let contract_address = env.current_contract_address(); + let mut balances_before: Map = Map::new(&env); + for (token_address, _) in totals.iter() { + let token_client = token::Client::new(&env, &token_address); + balances_before.set(token_address.clone(), token_client.balance(&contract_address)); + } + for item in items.iter() { + let token_client = token::Client::new(&env, &item.token); + token_client.transfer(&payer, &contract_address, &item.amount); + } + for (token_address, expected_delta) in totals.iter() { + let token_client = token::Client::new(&env, &token_address); + let after = token_client.balance(&contract_address); + if after.checked_sub(balances_before.get(token_address).unwrap_or(0_i128)) != Some(expected_delta) { + return Err(Error::BalanceMismatch); + } + } + + let mut ids = Vec::new(&env); + for item in items.iter() { + let id = Self::next_id(&env); + let data = EscrowData { + payer: payer.clone(), + payee: item.payee.clone(), + token: item.token.clone(), + amount: item.amount, + condition_ref: item.condition_ref.clone(), + status: EscrowStatus::Funded, + releaser: item.releaser.clone(), + refund_after: item.refund_after, + }; + env.storage().persistent().set(&DataKey::Escrow(id), &data); + env.events().publish((symbol_short!("created"), id), (item.token, item.amount)); + ids.push_back(id); + } + Ok(ids) + } + /// Read-only status query. Errors if `escrow_id` doesn't exist. pub fn get_status(env: Env, escrow_id: u64) -> Result { let data: EscrowData = env @@ -222,4 +302,14 @@ impl EscrowContract { env.storage().instance().set(&DataKey::NextId, &(id + 1)); id } + + fn batch_item_error(index: u32) -> Error { + match index { + 0 => Error::InvalidBatchItem0, + 1 => Error::InvalidBatchItem1, + 2 => Error::InvalidBatchItem2, + 3 => Error::InvalidBatchItem3, + _ => Error::InvalidBatchItem4, + } + } } diff --git a/contracts/escrow/tests/batch_create.rs b/contracts/escrow/tests/batch_create.rs new file mode 100644 index 00000000..0203c8e6 --- /dev/null +++ b/contracts/escrow/tests/batch_create.rs @@ -0,0 +1,80 @@ +use docforum_escrow::{EscrowContract, EscrowContractClient, EscrowParams, EscrowStatus, MAX_BATCH_SIZE}; +use soroban_sdk::{ + testutils::Address as _, + token::{StellarAssetClient, TokenClient}, + Address, Env, String, Vec, +}; + +fn item(env: &Env, token: &Address, amount: i128) -> EscrowParams { + EscrowParams { + payee: Address::generate(env), + token: token.clone(), + amount, + condition_ref: String::from_str(env, "batch-item"), + releaser: Address::generate(env), + refund_after: None, + } +} + +#[test] +fn batch_creation_returns_sequential_ids_and_checks_mixed_tokens() { + let env = Env::default(); + env.mock_all_auths(); + let payer = Address::generate(&env); + let token_a = env.register_stellar_asset_contract_v2(Address::generate(&env)).address(); + let token_b = env.register_stellar_asset_contract_v2(Address::generate(&env)).address(); + StellarAssetClient::new(&env, &token_a).mint(&payer, &1_000); + StellarAssetClient::new(&env, &token_b).mint(&payer, &1_000); + let contract_id = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &contract_id); + let mut items = Vec::new(&env); + items.push_back(item(&env, &token_a, 100)); + items.push_back(item(&env, &token_b, 200)); + items.push_back(item(&env, &token_a, 300)); + + let ids = client.create_escrows(&payer, &items); + assert_eq!(ids.len(), 3); + assert_eq!(ids.get(0), Some(0)); + assert_eq!(ids.get(1), Some(1)); + assert_eq!(ids.get(2), Some(2)); + assert_eq!(client.get_escrow(&ids.get(1).unwrap()).amount, 200); + assert_eq!(client.get_status(&ids.get(2).unwrap()), EscrowStatus::Funded); + assert_eq!(TokenClient::new(&env, &token_a).balance(&contract_id), 400); + assert_eq!(TokenClient::new(&env, &token_b).balance(&contract_id), 200); +} + +#[test] +fn invalid_item_reports_index_and_rolls_back_all_funding() { + let env = Env::default(); + env.mock_all_auths(); + let payer = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(Address::generate(&env)).address(); + let token = TokenClient::new(&env, &token_id); + StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); + let contract_id = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &contract_id); + let mut items = Vec::new(&env); + items.push_back(item(&env, &token_id, 100)); + items.push_back(item(&env, &token_id, 0)); + let result = client.try_create_escrows(&payer, &items); + assert!(result.is_err()); + assert_eq!(token.balance(&payer), 1_000); + assert_eq!(token.balance(&contract_id), 0); + assert!(client.try_get_escrow(&0).is_err()); +} + +#[test] +fn batch_size_is_bounded_and_empty_batches_are_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let payer = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(Address::generate(&env)).address(); + let contract_id = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &contract_id); + assert!(client.try_create_escrows(&payer, &Vec::new(&env)).is_err()); + let mut too_many = Vec::new(&env); + for _ in 0..=MAX_BATCH_SIZE { + too_many.push_back(item(&env, &token_id, 1)); + } + assert!(client.try_create_escrows(&payer, &too_many).is_err()); +} From 29adeeab17ac6ee74157d05ec2decacaba3e0268 Mon Sep 17 00:00:00 2001 From: Adesokan-2 <196428077+Adesokan-2@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:30:53 +0100 Subject: [PATCH 2/2] fix(contract): assign unique error codes --- contracts/escrow/src/lib.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index ba475631..0db25fb6 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -78,13 +78,13 @@ pub enum Error { /// across `create_escrow`'s transfer (a lying or short-delivering /// token) — see docs/threat-model.md T5. BalanceMismatch = 6, - InvalidBatchSize = 9, - InvalidBatchItem0 = 10, - InvalidBatchItem1 = 11, - InvalidBatchItem2 = 12, - InvalidBatchItem3 = 13, - InvalidBatchItem4 = 14, - BatchAmountOverflow = 15, + InvalidBatchSize = 13, + InvalidBatchItem0 = 14, + InvalidBatchItem1 = 15, + InvalidBatchItem2 = 16, + InvalidBatchItem3 = 17, + InvalidBatchItem4 = 18, + BatchAmountOverflow = 19, } #[contract]