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
6 changes: 5 additions & 1 deletion docs/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Only the configured administrator address can perform administrative operations.
* `pause` / `unpause`
* `add_caller` / `remove_caller`
* `transfer_admin`
* `set_limits`

## Admin Ownership Transfer (Two-Step)

Expand Down Expand Up @@ -39,7 +40,10 @@ Recommended custody practices:
* Prefer a multisig or timelock arrangement for any administrative action that would materially affect operations.
* Keep the admin key materially segregated from day-to-day operational keys and rotate or recover it through a documented process.

Security note: compromise of the admin key can pause the contract and change the allowlist, but it cannot directly withdraw escrowed funds from the contract.
Security note: compromise of the admin key can pause the contract, change the
allowlist, and change operational limits, but it cannot directly withdraw
escrowed funds from the contract. A new global escrow limit cannot be set below
the amount already held in escrow.

## Privileged Callers Allowlist
The contract maintains an allowlist of privileged callers who are authorized to lock funds and create new escrow transfers.
Expand Down
9 changes: 9 additions & 0 deletions docs/cli-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ stellar contract invoke --id $CONTRACT_ID --network $NETWORK \
# Get configured limits
stellar contract invoke --id $CONTRACT_ID --network $NETWORK -- get_limits

# Update operational limits (admin only)
stellar contract invoke --id $CONTRACT_ID --network $NETWORK --source-account $ADMIN \
-- set_limits --limits '{
"max_amount": "500",
"max_expiry_window": "2000",
"max_total_escrowed": "2000",
"max_page_size": 25
}'

```

---
Expand Down
13 changes: 8 additions & 5 deletions docs/data-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ See the README and the sources under src/ for the authoritative implementation.

## ConfiguredLimits

`ConfiguredLimits` represents the static operational bounds and limits configured for the contract.
`ConfiguredLimits` represents the operational bounds enforced by the contract.
Defaults match the compile-time constants (`MAX_AMOUNT`, `MAX_EXPIRY_WINDOW`,
`MAX_TOTAL_ESCROWED`, `MAX_PAGE_SIZE`). After initialization the admin may
update them through [`set_limits`](entrypoint-reference.md#set_limitslimits-configuredlimits---result-error).

| Field | Type | Description |
| --- | --- | --- |
| `max_amount` | `i128` | Largest token amount accepted for a single escrowed transfer (`1_000_000_000_000_000_000`). |
| `max_expiry_window` | `u64` | Maximum allowed distance, in seconds, between current timestamp and transfer expiry (`31_536_000`, ~1 year). |
| `max_total_escrowed` | `i128` | Global cap on the total escrowed amount (`1_000_000_000_000_000_000`). |
| `max_page_size` | `u32` | Maximum number of records returned by a paginated transfer query (`100`). |
| `max_amount` | `i128` | Largest token amount accepted for a single escrowed transfer (default `1_000_000_000_000_000_000`). |
| `max_expiry_window` | `u64` | Maximum allowed distance, in seconds, between current timestamp and transfer expiry (default `31_536_000`, ~1 year). |
| `max_total_escrowed` | `i128` | Global cap on the total escrowed amount (default `1_000_000_000_000_000_000`). |
| `max_page_size` | `u32` | Maximum number of records returned by a paginated transfer query (default `100`). |

21 changes: 17 additions & 4 deletions docs/entrypoint-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,20 +107,33 @@ Returns a bounded page of transfer records ordered by ascending transfer id.

* **Authorization**: None (public view)
* **Cursor**: `start_id` is inclusive; `0` is treated as transfer id `1`
* **Page size**: Returns at most `min(limit, MAX_PAGE_SIZE)`, where
`MAX_PAGE_SIZE` is 100
* **Page size**: Returns at most `min(limit, configured max_page_size)`, where
the default `max_page_size` is 100 (see [`get_limits`](#get_limits---configuredlimits))
* **Empty pages**: Returns an empty vector when `limit` is zero, no transfers
exist, or `start_id` is beyond the current transfer counter
* **Next page**: If a full page is returned, pass the last returned transfer
id plus one as the next `start_id`

## Operational Configuration & Resource Queries

### `set_limits(limits: ConfiguredLimits) -> Result<(), Error>`

Updates the operational limits enforced for new transfers and paginated queries.

* **Authorization**: Current admin (`admin.require_auth()`)
* **Effect**: Stores the new `max_amount`, `max_expiry_window`,
`max_total_escrowed`, and `max_page_size` values in instance storage.
* **Validation**: Every value must be positive, `max_amount` cannot exceed
`max_total_escrowed`, and `max_total_escrowed` cannot be lower than the
amount currently held in escrow.
* **Events**: Emits `limits_changed` with the admin address and the complete
old and new [`ConfiguredLimits`](data-types.md#configuredlimits) values.
Supplying the current configuration is a no-op and emits no event.
* **Errors**: `NotInitialized`, `InvalidLimits`, or `CooldownNotElapsed`.

### `get_limits() -> ConfiguredLimits`

Returns the contract's configured operational limits.

* **Authorization**: None (public view, callable pre/post-initialization)
* **Returns**: [`ConfiguredLimits`](data-types.md#configuredlimits) containing `max_amount`, `max_expiry_window`, `max_total_escrowed`, and `max_page_size`.


4 changes: 3 additions & 1 deletion docs/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ meaning, and the entrypoints that can produce it.
| Code | Variant | Description | Returned By |
|------|---------|-------------|-------------|
| 1 | `AlreadyInitialized` | The contract has already been initialised with an admin and token address. Second calls to `initialize` are rejected. | `initialize` |
| 2 | `NotInitialized` | The contract has not been initialised yet. Most public entrypoints require initialisation before they can proceed. | `initialize`, `get_admin`, `get_token`, `get_initialized_at`, `pause`, `unpause`, `create_transfer`, `claim_transfer`, `cancel_transfer`, `add_caller`, `remove_caller` |
| 2 | `NotInitialized` | The contract has not been initialised yet. Most public entrypoints require initialisation before they can proceed. | `initialize`, `get_admin`, `get_token`, `get_initialized_at`, `pause`, `unpause`, `create_transfer`, `claim_transfer`, `cancel_transfer`, `add_caller`, `remove_caller`, `set_limits` |
| 3 | `TransferNotFound` | No record exists for the supplied transfer `id`. Either the id was never created or it was assigned to a transfer that was purged. | `get_transfer`, `get_status`, `transfer_exists`, `is_expired`, `claim_transfer`, `cancel_transfer` |
| 4 | `InvalidAmount` | The supplied transfer `amount` is not strictly positive (zero or negative). | `create_transfer` |
| 5 | `InvalidExpiry` | The supplied `expiry` timestamp is not in the future — it must be strictly greater than the current ledger timestamp. | `create_transfer` |
Expand All @@ -31,6 +31,7 @@ meaning, and the entrypoints that can produce it.
| 18 | `InvalidAddress` | A supplied address resolves to the contract's own address, where an external party address is required. Guards against uninitialized or placeholder address inputs masquerading as a valid party. | `initialize`, `create_transfer`, `claim_transfer`, `cancel_transfer`, `add_caller`, `transfer_admin` |
| 20 | `SupplyInvariantViolation` | The contract's actual token balance is less than its internally tracked `TotalEscrowed` liability. Checked automatically after every entrypoint that moves escrowed funds; see [Invariants](./invariants.md). | `create_transfer`, `claim_transfer`, `cancel_transfer`, `check_supply_invariant` |
| 21 | `BatchTooLarge` | The number of operations in a `batch_operations` call exceeds `MAX_BATCH_SIZE`. | `batch_operations` |
| 22 | `InvalidLimits` | One or more fields in the supplied `ConfiguredLimits` are invalid (non-positive values, `max_amount` above `max_total_escrowed`, or `max_total_escrowed` below the amount already held in escrow). | `set_limits` |

---

Expand All @@ -50,6 +51,7 @@ meaning, and the entrypoints that can produce it.
| `check_supply_invariant` | `NotInitialized` (2), `SupplyInvariantViolation` (20) |
| `transfer_admin` | `NotInitialized` (2), `InvalidAddress` (18) |
| `accept_admin` | `NoPendingAdmin` (17), `NotInitialized` (2) |
| `set_limits` | `NotInitialized` (2), `InvalidLimits` (22), `CooldownNotElapsed` (21) |
| `get_pending_admin` | None† |
| `get_transfer`, `transfer_exists`, `get_status`, `is_expired` | `NotInitialized` (2), `TransferNotFound` (3)‡ |
| `total_escrowed`, `count_for_sender`, `count_for_recipient`, `count_by_status`, `get_transfers_paged` | None† |
Expand Down
1 change: 1 addition & 0 deletions docs/event-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This document describes all events emitted by the RemitFlow smart contract, deta
| `created` | `("created", id: u64)` | `(from: Address, recipient: Address, amount: i128, expiry: u64)` | A new transfer is created and funds escrowed. |
| `claimed` | `("claimed", id: u64)` | `(recipient: Address, amount: i128)` | Recipient claims escrowed transfer. |
| `cancelled` | `("cancelled", id: u64)` | `(from: Address, amount: i128)` | Sender cancels and receives a refund for an expired transfer. |
| `limits_changed` | `("limits_changed",)` | `(admin: Address, old_limits: ConfiguredLimits, new_limits: ConfiguredLimits)` | Admin changes the contract's operational limits. |
| `admin_transfer_started` | `("admin_transfer_started",)` | `(current_admin: Address, pending_admin: Address)` | Admin initiates ownership transfer. |
| `admin_transfer_completed` | `("admin_transfer_completed",)` | `(old_admin: Address, new_admin: Address)` | Pending admin accepts ownership transfer. |

Expand Down
2 changes: 2 additions & 0 deletions docs/storage-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ Instance and persistent stores are separate namespaces at the Soroban host level
| `Counter` | `u64` | Monotonically increasing id issued to the next transfer |
| `Paused` | `bool` | When `true`, `create_transfer` is blocked |
| `InitializedAt` | `u64` | Ledger timestamp at which `initialize` was called |
| `LastPrivilegedCall` | `u64` | Timestamp of the most recent privileged administrative call |
| `Limits` | `ConfiguredLimits` | Mutable operational limits enforced for new transfers and pagination |

### `PersistentKey` — persistent storage

Expand Down
16 changes: 15 additions & 1 deletion docs/testing-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Admin-only guards are authorization checks that restrict certain contract operat
1. **initialize()** - Sets up the contract with an admin and token address
2. **pause()** - Blocks creation of new transfers
3. **unpause()** - Re-enables transfer creation
4. **set_limits()** - Updates operational transfer and pagination limits

### Testing Admin Authorization

Expand Down Expand Up @@ -95,11 +96,24 @@ fn test_unpause_requires_admin_auth() {
}
```

#### Limit-Change Guards
```rust
#[test]
fn test_set_limits_requires_admin_auth() {
// Verifies set_limits() checks admin.require_auth() and does not mutate state
}

#[test]
fn test_set_limits_by_admin_succeeds() {
// Verifies the admin can update ConfiguredLimits when authorized
}
```

#### Operational State Guards
```rust
#[test]
fn test_admin_operations_require_initialization() {
// Ensures pause, unpause, and other admin ops fail when contract is not initialized
// Ensures pause, unpause, set_limits, and other admin ops fail when contract is not initialized
}
```

Expand Down
5 changes: 5 additions & 0 deletions docs/unit-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ The contract includes comprehensive unit tests organized by functionality:
- **test_count_by_status_tracks_lifecycle**: Tests transfer status counting
- **test_get_limits_returns_configured_constants**: Confirms `get_limits` returns configured bounds (`MAX_AMOUNT`, `MAX_EXPIRY_WINDOW`, `MAX_TOTAL_ESCROWED`, `MAX_PAGE_SIZE`)
- **test_get_limits_works_uninitialized**: Verifies `get_limits` can be queried before contract initialization
- **test_get_limits_returns_defaults**: Confirms initialized contracts expose the default `ConfiguredLimits`
- **test_set_limits_by_admin_succeeds**: Confirms the admin can update operational limits when authorized
- **test_set_limits_requires_admin_auth**: Verifies `set_limits()` rejects callers that do not satisfy `admin.require_auth()`, leaving stored limits unchanged
- **test_updated_limits_are_enforced**: Ensures newly configured `max_amount` is applied by `create_transfer`
- **test_set_limits_rejects_invalid_configuration**: Ensures invalid `ConfiguredLimits` return `InvalidLimits` without mutating state

- **test_get_balances_returns_balances_in_order**: Validates bulk-reading token balances for a list of addresses
- **test_get_balances_empty_addresses_list**: Verifies get_balances with an empty address vector
Expand Down
2 changes: 2 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,7 @@ pub enum Error {
/// The number of operations in a atch_operations call exceeds
/// MAX_BATCH_SIZE.
BatchTooLarge = 21,
/// One or more configured operational limits are invalid.
InvalidLimits = 22,
}

16 changes: 16 additions & 0 deletions src/events.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use soroban_sdk::{Address, Env, Symbol};

use crate::types::ConfiguredLimits;

/// Publish an event recording contract initialization.
pub fn init(env: &Env, admin: &Address, token: &Address) {
let topics = (Symbol::new(env, "init"),);
Expand Down Expand Up @@ -68,3 +70,17 @@ pub fn admin_transfer_completed(env: &Env, old_admin: &Address, new_admin: &Addr
env.events()
.publish(topics, (old_admin.clone(), new_admin.clone()));
}

/// Publish an event recording an administrator's operational-limit update.
pub fn limits_changed(
env: &Env,
admin: &Address,
old_limits: &ConfiguredLimits,
new_limits: &ConfiguredLimits,
) {
let topics = (Symbol::new(env, "limits_changed"),);
env.events().publish(
topics,
(admin.clone(), old_limits.clone(), new_limits.clone()),
);
}
57 changes: 47 additions & 10 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ pub const MAX_PAGE_SIZE: u32 = 100;
/// Maximum number of operations allowed in a single batch_operations call.
pub const MAX_BATCH_SIZE: u32 = 50;

fn default_limits() -> ConfiguredLimits {
ConfiguredLimits {
max_amount: MAX_AMOUNT,
max_expiry_window: MAX_EXPIRY_WINDOW,
max_total_escrowed: MAX_TOTAL_ESCROWED,
max_page_size: MAX_PAGE_SIZE,
}
}

fn require_external_address(env: &Env, address: &Address) -> Result<(), Error> {
if *address == env.current_contract_address() {
return Err(Error::InvalidAddress);
Expand Down Expand Up @@ -137,6 +146,7 @@ impl RemitFlowContract {
storage::set_token(&env, &token);
storage::set_counter(&env, 0);
storage::set_initialized_at(&env, env.ledger().timestamp());
storage::set_limits(&env, &default_limits());
storage::extend_instance(&env);
events::init(&env, &admin, &token);
Ok(())
Expand Down Expand Up @@ -209,23 +219,24 @@ impl RemitFlowContract {
if amount <= 0 {
return Err(Error::InvalidAmount);
}
let limits = storage::get_limits(&env).unwrap_or_else(default_limits);
if storage::get_account_op_count(&env, &from) >= MAX_ACCOUNT_OPS {
return Err(Error::AccountLimitReached);
}
if amount > MAX_AMOUNT {
if amount > limits.max_amount {
return Err(Error::AmountTooLarge);
}
let total_escrowed = storage::get_total_escrowed(&env);
let updated_total =
math::checked_add_amount(total_escrowed, amount).ok_or(Error::AmountTooLarge)?;
if updated_total > MAX_TOTAL_ESCROWED {
if updated_total > limits.max_total_escrowed {
return Err(Error::EscrowCapReached);
}
let now = env.ledger().timestamp();
if expiry <= now {
return Err(Error::InvalidExpiry);
}
if expiry - now > MAX_EXPIRY_WINDOW {
if expiry - now > limits.max_expiry_window {
return Err(Error::ExpiryTooFar);
}
if from == recipient {
Expand Down Expand Up @@ -346,7 +357,11 @@ impl RemitFlowContract {
let last = storage::get_counter(&env);
let mut page = Vec::new(&env);
let mut id = start_id.max(1);
let page_size = limit.min(MAX_PAGE_SIZE);
let page_size = limit.min(
storage::get_limits(&env)
.unwrap_or_else(default_limits)
.max_page_size,
);
while id <= last && page.len() < page_size {
if let Some(transfer) = storage::get_transfer(&env, id) {
page.push_back(transfer);
Expand Down Expand Up @@ -485,13 +500,35 @@ impl RemitFlowContract {
storage::get_pending_admin(&env)
}

pub fn get_limits(_env: Env) -> ConfiguredLimits {
ConfiguredLimits {
max_amount: MAX_AMOUNT,
max_expiry_window: MAX_EXPIRY_WINDOW,
max_total_escrowed: MAX_TOTAL_ESCROWED,
max_page_size: MAX_PAGE_SIZE,
pub fn set_limits(env: Env, limits: ConfiguredLimits) -> Result<(), Error> {
let admin = storage::get_admin(&env).ok_or(Error::NotInitialized)?;
admin.require_auth();

let old_limits = storage::get_limits(&env).unwrap_or_else(default_limits);
if limits == old_limits {
return Ok(());
}
require_cooldown(&env)?;

if limits.max_amount <= 0
|| limits.max_expiry_window == 0
|| limits.max_total_escrowed <= 0
|| limits.max_page_size == 0
|| limits.max_amount > limits.max_total_escrowed
|| limits.max_total_escrowed < storage::get_total_escrowed(&env)
{
return Err(Error::InvalidLimits);
}

storage::set_limits(&env, &limits);
record_privileged_call(&env);
storage::extend_instance(&env);
events::limits_changed(&env, &admin, &old_limits, &limits);
Ok(())
}

pub fn get_limits(env: Env) -> ConfiguredLimits {
storage::get_limits(&env).unwrap_or_else(default_limits)
}

/// Sweeps an expired transfer, returning the escrowed funds to the original sender.
Expand Down
18 changes: 15 additions & 3 deletions src/storage.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use soroban_sdk::{contracttype, Address, Env};

use crate::types::Transfer;
use crate::types::{ConfiguredLimits, Transfer};

/// Number of ledgers used as the threshold before bumping instance TTL.
pub const INSTANCE_BUMP_THRESHOLD: u32 = 518_400;
Expand All @@ -20,8 +20,8 @@ pub const PERSISTENT_BUMP_AMOUNT: u32 = 535_680;
/// # Collision safety
/// Soroban serialises #[contracttype] enum keys as an XDR ScVec whose
/// first element is the variant name as a Symbol. Because the name string is
/// part of the on-chain key, no two distinct variants even with identical
/// payloads can ever collide. Separating instance and persistent keys into
/// part of the on-chain key, no two distinct variants  even with identical
/// payloads  can ever collide. Separating instance and persistent keys into
/// two enums makes a mis-routed write (e.g. passing an [InstanceKey] to the
/// persistent store) a compile error rather than a silent bug.
#[contracttype]
Expand All @@ -48,6 +48,8 @@ pub enum InstanceKey {
InitializedAt,
/// Timestamp of the most recent privileged administrative call.
LastPrivilegedCall,
/// Mutable operational limits enforced by the contract.
Limits,
}

/// Keys for values held in **persistent** storage.
Expand Down Expand Up @@ -193,6 +195,16 @@ pub fn set_last_privileged_call(env: &Env, timestamp: u64) {
.set(&InstanceKey::LastPrivilegedCall, &timestamp);
}

/// Read the configured operational limits, if they have been stored.
pub fn get_limits(env: &Env) -> Option<ConfiguredLimits> {
env.storage().instance().get(&InstanceKey::Limits)
}

/// Persist the operational limits in instance storage.
pub fn set_limits(env: &Env, limits: &ConfiguredLimits) {
env.storage().instance().set(&InstanceKey::Limits, limits);
}

// ---------------------------------------------------------------------------
// Persistent storage helpers
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading