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 ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ release_with_approvals(escrow_id, approvers) -> result // IMPLEMENTED (Phase E
refund(escrow_id, caller) -> result // IMPLEMENTED (Phase E2 + E4) — a releaser when threshold is 1; anyone too, once refund_after has passed, and then still only back to payer
refund_with_approvals(escrow_id, approvers) -> result // IMPLEMENTED (Phase E5, issue #119) — same approval rules as release_with_approvals
top_up(escrow_id, payer, amount) -> result // IMPLEMENTED (issue #26) — original payer only, funded state only; emits `top_up`
create_escrows(payer, items) -> escrow_ids // IMPLEMENTED (issue #28) — atomic batch of 1–5 escrows
```

No new contract-level role came in with the M-of-N work: `releasers` is
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ deployment, so choosing an empty list deliberately disables the protection.
|---|---|---|
| `__constructor(allowed_tokens)` | ✅ Implemented | Stores the immutable deploy-time token allowlist; an empty list allows all tokens. |
| `create_escrow(payer, payee, token, amount, condition_ref, releaser, refund_after, notify_payee) -> escrow_id` | ✅ Implemented locally (callback not deployed) | Pulls funds from `payer`, stores a bounded `condition_ref`, and stores the single `releaser` as a one-address set with threshold 1. Requires payer auth; when `notify_payee` is true, also requires payee auth and calls `on_escrow_released` on release. |
| `create_escrows(payer, items) -> escrow_ids` | ✅ Implemented locally | Atomically creates 1–5 escrows for one payer. Each item can use a different payee, token, amount, releaser, and refund deadline; any invalid item reverts the full batch. |
| `create_escrow_with_releasers(payer, payee, token, amount, condition_ref, releasers, threshold, refund_after, notify_payee) -> escrow_id` | ✅ Implemented | Same, but settles by M-of-N: 1–10 distinct `releasers` (`MAX_RELEASERS`), `1 <= threshold <= releasers.len()`. Callback opt-in has the same payee-authorization requirement. See [ADR 0007](docs/adr/0007-in-contract-m-of-n-releaser-quorum.md). |
| `get_status(escrow_id) -> EscrowStatus` | ✅ Implemented | Read-only. Errors on an unknown id. |
| `get_escrow(escrow_id) -> EscrowData` | ✅ Implemented | Read-only: everything stored about the escrow, including `releasers`, `threshold` and `refund_after`, so payees can check the deal before relying on it. |
Expand Down
6 changes: 6 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@
deployment predates this constructor; a new testnet deployment and
verification are required before claiming the live contract is
protected. Closes issue #36.
- [x] Atomic batch creation (issue #28): `create_escrows` accepts 1–5
escrow parameter records for one payer, checks combined token funding for
overflow, and uses the same validation and exact-delivery checks as
individual creation. A failure reverts all transfers and storage writes.
Regression tests cover mixed tokens, sequential ids, invalid-item
rollback, and batch-size bounds. Not yet redeployed to testnet.
- [x] Optional release callback (issue #53): both single-releaser and
quorum creation require payee authorization when `notify_payee` is true;
release calls `on_escrow_released` after transfer and rolls back atomically
Expand Down
97 changes: 95 additions & 2 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env,
IntoVal, String, Vec,
IntoVal, Map, String, Vec,
};

/// Largest allowed releaser set for one escrow. Ten covers every
Expand All @@ -33,6 +33,21 @@ pub const MAX_RELEASERS: u32 = 10;
/// Maximum `condition_ref` length in bytes (threat model T10, Phase E5).
pub const MAX_CONDITION_REF_LEN: u32 = 64;

/// Conservative upper bound for a single atomic batch; see ADR 0008.
pub const MAX_BATCH_SIZE: u32 = 5;

/// One escrow to create in an atomic batch. All entries share the payer.
#[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<u64>,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum EscrowStatus {
Expand Down Expand Up @@ -115,6 +130,13 @@ pub enum Error {
DuplicateApprover = 16,
/// The same address appeared twice in the releaser set at creation.
DuplicateReleaser = 17,
InvalidBatchSize = 30,
InvalidBatchItem0 = 31,
InvalidBatchItem1 = 32,
InvalidBatchItem2 = 33,
InvalidBatchItem3 = 34,
InvalidBatchItem4 = 35,
BatchAmountOverflow = 36,
}

#[contract]
Expand Down Expand Up @@ -163,6 +185,7 @@ impl EscrowContract {
1,
refund_after,
notify_payee,
false,
)
}

Expand Down Expand Up @@ -196,9 +219,66 @@ impl EscrowContract {
threshold,
refund_after,
notify_payee,
false,
)
}

/// Atomically creates up to five escrows for one payer. Any failed item
/// reverts the whole invocation, including transfers and storage writes.
pub fn create_escrows(
env: Env,
payer: Address,
items: Vec<EscrowParams>,
) -> Result<Vec<u64>, Error> {
if items.is_empty() || items.len() > MAX_BATCH_SIZE {
return Err(Error::InvalidBatchSize);
}
payer.require_auth();

// Check combined funding before any transfer. Per-item validation is
// also performed by create_escrow_inner below, before its transfer.
let now = env.ledger().timestamp();
let mut totals: Map<Address, i128> = 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 as u32));
}
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 mut ids = Vec::new(&env);
for item in items.iter() {
let mut releasers = Vec::new(&env);
releasers.push_back(item.releaser);
let id = Self::create_escrow_inner(
&env,
payer.clone(),
item.payee,
item.token,
item.amount,
item.condition_ref,
releasers,
1,
item.refund_after,
false,
true,
)
.map_err(|error| match error {
Error::InvalidAmount | Error::InvalidRefundAfter => {
Self::batch_item_error(ids.len())
}
other => other,
})?;
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<EscrowStatus, Error> {
let data: EscrowData = env
Expand Down Expand Up @@ -345,6 +425,7 @@ impl EscrowContract {
threshold: u32,
refund_after: Option<u64>,
notify_payee: bool,
payer_already_authorized: bool,
) -> Result<u64, Error> {
let allowed_tokens: Vec<Address> = env
.storage()
Expand All @@ -370,7 +451,9 @@ impl EscrowContract {
}
Self::check_releaser_set(env, &releasers, threshold)?;

payer.require_auth();
if !payer_already_authorized {
payer.require_auth();
}
if notify_payee {
payee.require_auth();
}
Expand Down Expand Up @@ -529,6 +612,16 @@ impl EscrowContract {
id
}

fn batch_item_error(index: u32) -> Error {
match index {
0 => Error::InvalidBatchItem0,
1 => Error::InvalidBatchItem1,
2 => Error::InvalidBatchItem2,
3 => Error::InvalidBatchItem3,
_ => Error::InvalidBatchItem4,
}
}

fn transfer_in_exact(
env: &Env,
token: &Address,
Expand Down
Loading
Loading