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
7 changes: 7 additions & 0 deletions .changeset/immutable-token-caps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@docforum/escrow-sdk": minor
---

Expose immutable per-token maximum amounts in the generated escrow constructor
bindings. Cap configuration is fixed at deployment and enforced for ordinary
escrows, pooled funding targets, and payer top-ups (issue #56, ADR 0013).
7 changes: 4 additions & 3 deletions .github/workflows/sdk-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,15 @@ jobs:
--alias native-token
token_id=$(stellar contract alias show native-token --network escrow-local)

# __constructor(allowed_tokens: Vec<Address>) — empty list keeps the
# legacy open-token behavior, which is fine for an ephemeral local net.
# __constructor(allowed_tokens: Vec<Address>, maximum_amounts:
# Map<Address, i128>) — an empty list and an empty map keep the
# legacy open-token, uncapped behavior, fine for an ephemeral local net.
stellar contract deploy \
--wasm "$wasm" \
--source-account deployer \
--network escrow-local \
--alias escrow \
-- --allowed_tokens '[]'
-- --allowed_tokens '[]' --maximum_amounts '{}'
contract_id=$(stellar contract alias show escrow --network escrow-local)

echo "token_id=$token_id"
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

## Contract surface
```
__constructor(allowed_tokens: Vec<Address>) // immutable token allowlist; empty means allow all
__constructor(allowed_tokens: Vec<Address>, maximum_amounts: Map<Address, i128>) // immutable token allowlist and optional per-token caps
version() -> String // semantic contract version; source revision and Soroban SDK version are embedded as WASM metadata
create_escrow(payer, payee, token, amount, condition_ref, releaser, refund_after, notify_payee, refund_to, fee_recipient, fee_bps) -> escrow_id // Phase E5 callback + sponsored refund destination (ADR 0009) + optional protocol fee (ADR 0010); the fee is not yet on testnet
create_escrow_release_time(..., fee_recipient, fee_bps) -> escrow_id // single-releaser creation with an earliest release time; accepts the same optional fee pair
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ An opt-in creation method can require the payee to authorize `accept` before rel
`docs/testnet-deployments.md`.
## Phase E5 — Contract features

- [x] Immutable per-token escrow caps (issue #56, ADR 0013): constructor cap map enforced for creation, pooled targets, and top-ups before funds move. Tests: contracts/escrow/tests/token_caps.rs. Not yet on testnet.
- [x] Batch release and refund (issue #29, `docs/adr/0012`): `settle_many(caller, ops: Vec<(escrow_id, Release|Refund)>)`, best-effort with one result per op, per-op auth against each escrow's own releasers, at most `MAX_SETTLE_OPS` = 20 (measured against the 16 KiB events budget). T1 response guidance updated. Tests: `contracts/escrow/tests/settle_many.rs`. **Not yet on testnet.**
- [x] Per-token liability accounting and an on-chain solvency view (issue #39): `solvency(token) -> (balance, liabilities)`; the counter moves in the same invocation as every value-moving state change and its TTL is extended on each write. Property test: `contracts/escrow/tests/liabilities.rs`. Monitoring guidance in `docs/threat-model.md`. **Not yet on testnet.**
- [x] Optional escrow-wide earliest release time (issue #43, ADR 0009); refund path remains independent.
Expand Down
9 changes: 9 additions & 0 deletions changelog.d/56-per-token-escrow-caps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Issue #56 (Phase E5): immutable per-token escrow caps for a guarded mainnet
beta. The constructor now takes `maximum_amounts: Map<Address, i128>` next to
`allowed_tokens`; an empty map keeps the uncapped behaviour, otherwise tokens
without an entry are rejected (`TokenCapNotConfigured = 36`) and any escrow
total above its token's cap fails with `AmountCapExceeded = 37` — on every
creation path including each batch item, on `top_up`, and on a pooled
escrow's funding target. Staged-launch plan in ADR 0013; `scripts/deploy`
gains `--maximum-amounts`. SDK bindings regenerated; every snapshot gains the
(empty) `MaximumAmounts` instance entry. Not yet deployed to testnet.
121 changes: 91 additions & 30 deletions contracts/escrow/benches/resource_profile.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
#![allow(deprecated)]

use docforum_escrow::{EscrowContract, EscrowContractClient};
use soroban_sdk::{
testutils::Address as _,
token::StellarAssetClient,
Address, Env, String, Vec,
};
use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, Env, String, Vec};

struct Fixture<'a> {
env: Env,
Expand All @@ -26,9 +22,22 @@ fn fixture() -> Fixture<'static> {
let payee = Address::generate(&env);
let releaser = Address::generate(&env);
asset.mint(&payer, &10_000);
let contract = env.register(EscrowContract, (Vec::<Address>::new(&env),));
let contract = env.register(
EscrowContract,
(
Vec::<Address>::new(&env),
soroban_sdk::Map::<soroban_sdk::Address, i128>::new(&env),
),
);
let client = EscrowContractClient::new(&env, &contract);
Fixture { env, client, payer, payee, releaser, token }
Fixture {
env,
client,
payer,
payee,
releaser,
token,
}
}

impl Fixture<'_> {
Expand Down Expand Up @@ -59,51 +68,85 @@ fn measure(name: &str, env: &Env, operation: impl FnOnce()) {

fn main() {
let f = fixture();
measure("create_escrow", &f.env, || { f.create(); });
measure("create_escrow", &f.env, || {
f.create();
});

let f = fixture();
measure("create_escrow_release_time", &f.env, || {
f.client.create_escrow_release_time(
&f.payer, &f.payee, &f.token, &100,
&String::from_str(&f.env, "resource-profile"), &f.releaser,
&None, &false, &None, &Some(1_000),
&f.payer,
&f.payee,
&f.token,
&100,
&String::from_str(&f.env, "resource-profile"),
&f.releaser,
&None,
&false,
&None,
&Some(1_000),
);
});

let f = fixture();
measure("create_escrow_with_acceptance", &f.env, || {
f.client.create_escrow_with_acceptance(
&f.payer, &f.payee, &f.token, &100,
&String::from_str(&f.env, "resource-profile"), &f.releaser,
&None, &true, &None,
&f.payer,
&f.payee,
&f.token,
&100,
&String::from_str(&f.env, "resource-profile"),
&f.releaser,
&None,
&true,
&None,
);
});

let f = fixture();
let releasers = soroban_sdk::vec![&f.env, f.releaser.clone()];
measure("create_escrow_with_releasers", &f.env, || {
f.client.create_escrow_with_releasers(
&f.payer, &f.payee, &f.token, &100,
&String::from_str(&f.env, "resource-profile"), &releasers,
&1, &None, &false, &None,
&f.payer,
&f.payee,
&f.token,
&100,
&String::from_str(&f.env, "resource-profile"),
&releasers,
&1,
&None,
&false,
&None,
);
});

let f = fixture();
let id = f.client.create_escrow_with_acceptance(
&f.payer, &f.payee, &f.token, &100,
&String::from_str(&f.env, "resource-profile"), &f.releaser,
&None, &true, &None,
&f.payer,
&f.payee,
&f.token,
&100,
&String::from_str(&f.env, "resource-profile"),
&f.releaser,
&None,
&true,
&None,
);
measure("accept", &f.env, || { f.client.accept(&id, &f.payee); });
measure("accept", &f.env, || {
f.client.accept(&id, &f.payee);
});

let f = fixture();
let id = f.create();
measure("get_status", &f.env, || { f.client.get_status(&id); });
measure("get_status", &f.env, || {
f.client.get_status(&id);
});

let f = fixture();
let id = f.create();
measure("get_escrow", &f.env, || { f.client.get_escrow(&id); });
measure("get_escrow", &f.env, || {
f.client.get_escrow(&id);
});

let f = fixture();
let id = f.create();
Expand All @@ -114,18 +157,28 @@ fn main() {

let f = fixture();
let id = f.create();
measure("top_up", &f.env, || { f.client.top_up(&id, &f.payer, &10); });
measure("top_up", &f.env, || {
f.client.top_up(&id, &f.payer, &10);
});

let f = fixture();
let id = f.create();
measure("release", &f.env, || { f.client.release(&id, &f.releaser); });
measure("release", &f.env, || {
f.client.release(&id, &f.releaser);
});

let f = fixture();
let id = f.client.create_escrow_with_releasers(
&f.payer, &f.payee, &f.token, &100,
&f.payer,
&f.payee,
&f.token,
&100,
&String::from_str(&f.env, "resource-profile"),
&soroban_sdk::vec![&f.env, f.releaser.clone()],
&1, &None, &false, &None,
&1,
&None,
&false,
&None,
);
let approvers = soroban_sdk::vec![&f.env, f.releaser.clone()];
measure("release_with_approvals", &f.env, || {
Expand All @@ -134,14 +187,22 @@ fn main() {

let f = fixture();
let id = f.create();
measure("refund", &f.env, || { f.client.refund(&id, &f.releaser); });
measure("refund", &f.env, || {
f.client.refund(&id, &f.releaser);
});

let f = fixture();
let id = f.client.create_escrow_with_releasers(
&f.payer, &f.payee, &f.token, &100,
&f.payer,
&f.payee,
&f.token,
&100,
&String::from_str(&f.env, "resource-profile"),
&soroban_sdk::vec![&f.env, f.releaser.clone()],
&1, &None, &false, &None,
&1,
&None,
&false,
&None,
);
let approvers = soroban_sdk::vec![&f.env, f.releaser.clone()];
measure("refund_with_approvals", &f.env, || {
Expand Down
58 changes: 51 additions & 7 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@

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

// Bump this value whenever a contract interface or behavior change is made.
// CI checks this constant against the base branch when this file changes.
pub const CONTRACT_VERSION: &str = "2.2.0";
pub const CONTRACT_VERSION: &str = "2.5.0";

include!(concat!(env!("OUT_DIR"), "/contract_metadata.rs"));

Expand Down Expand Up @@ -223,6 +223,8 @@ enum DataKey {
/// of `amount` over every escrow in that token whose status is
/// `Funded`. Appended last, so existing entries keep their encoding.
Liability(Address),
/// Immutable per-token maximum funded amount (issue #56, ADR 0013).
MaximumAmounts,
}

#[contracterror]
Expand Down Expand Up @@ -316,6 +318,10 @@ pub enum Error {
/// contract itself, or an amount that would overflow the fee math —
/// see docs/adr/0010.
InvalidFee = 35,
/// A token has no configured amount cap when cap enforcement is enabled.
TokenCapNotConfigured = 36,
/// An escrow amount, top-up total, or pooled target exceeds its token cap.
AmountCapExceeded = 37,
}

#[contract]
Expand All @@ -325,10 +331,23 @@ pub struct EscrowContract;
impl EscrowContract {
/// Sets the immutable token allowlist at deployment. An empty list
/// preserves the legacy behavior and allows any token address.
pub fn __constructor(env: Env, allowed_tokens: Vec<Address>) {
pub fn __constructor(
env: Env,
allowed_tokens: Vec<Address>,
maximum_amounts: Map<Address, i128>,
) -> Result<(), Error> {
for (_, maximum) in maximum_amounts.iter() {
if maximum <= 0 {
return Err(Error::InvalidAmount);
}
}
env.storage()
.instance()
.set(&DataKey::AllowedTokens, &allowed_tokens);
env.storage()
.instance()
.set(&DataKey::MaximumAmounts, &maximum_amounts);
Ok(())
}

/// Semantic version of this contract interface and behavior.
Expand Down Expand Up @@ -579,8 +598,7 @@ impl EscrowContract {

/// Read-only status query. Errors if `escrow_id` doesn't exist.
pub fn get_status(env: Env, escrow_id: u64) -> Result<EscrowStatus, Error> {
env
.storage()
env.storage()
.persistent()
.get(&DataKey::Status(escrow_id))
.ok_or(Error::NotFound)
Expand Down Expand Up @@ -638,6 +656,7 @@ impl EscrowContract {
if funding_target <= 0 {
return Err(Error::InvalidAmount);
}
Self::check_token_cap(&env, &token, funding_target)?;
if funding_deadline <= env.ledger().timestamp() {
return Err(Error::InvalidFundingDeadline);
}
Expand Down Expand Up @@ -679,7 +698,9 @@ impl EscrowContract {
fee_bps: None,
};
env.storage().persistent().set(&DataKey::Escrow(id), &data);
env.storage().persistent().set(&DataKey::Status(id), &data.status);
env.storage()
.persistent()
.set(&DataKey::Status(id), &data.status);
env.storage().persistent().set(
&DataKey::Pooled(id),
&PooledData {
Expand Down Expand Up @@ -942,6 +963,7 @@ impl EscrowContract {
return Err(Error::NotFunded);
}
let updated_amount = data.amount.checked_add(amount).ok_or(Error::Overflow)?;
Self::check_token_cap(&env, &data.token, updated_amount)?;
// The fee is a bps of the escrowed amount, so a top-up grows it; fail
// now (before the transfer) if the larger amount would overflow the
// fee math, rather than at release (docs/adr/0010).
Expand Down Expand Up @@ -1129,6 +1151,25 @@ impl EscrowContract {
Self::settle(&env, escrow_id, &mut data, EscrowStatus::Refunded)
}

/// Enforces the immutable deployment-time cap for this token.
fn check_token_cap(env: &Env, token: &Address, amount: i128) -> Result<(), Error> {
let maximum_amounts: Map<Address, i128> = env
.storage()
.instance()
.get(&DataKey::MaximumAmounts)
.unwrap_or(Map::new(env));
if maximum_amounts.is_empty() {
return Ok(());
}
let maximum = maximum_amounts
.get(token.clone())
.ok_or(Error::TokenCapNotConfigured)?;
if amount > maximum {
return Err(Error::AmountCapExceeded);
}
Ok(())
}

fn create_escrow_inner(
env: &Env,
payer: Address,
Expand Down Expand Up @@ -1157,6 +1198,7 @@ impl EscrowContract {
if amount <= 0 {
return Err(Error::InvalidAmount);
}
Self::check_token_cap(env, &token, amount)?;
if let Some(deadline) = refund_after {
if deadline <= env.ledger().timestamp() {
return Err(Error::InvalidRefundAfter);
Expand Down Expand Up @@ -1227,7 +1269,9 @@ impl EscrowContract {
fee_bps,
};
env.storage().persistent().set(&DataKey::Escrow(id), &data);
env.storage().persistent().set(&DataKey::Status(id), &data.status);
env.storage()
.persistent()
.set(&DataKey::Status(id), &data.status);
Self::publish_state(env, symbol_short!("created"), id, &data);
Ok(id)
}
Expand Down
Loading
Loading