Skip to content
Merged
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
76 changes: 76 additions & 0 deletions contracts/docs/specs/mint-atomicity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Mint / Burn Supply-Cap Atomicity (resource-token)

Issue #1 — "Race Condition in Resource Tokenization Mint/Burn State Machine"

## Summary

`resource-token` mints/burns tokens that are backed **1:1** by real-world resource
deposits. The invariant is:

```
total_supply == Σ(balances) <= MAX_SUPPLY
```

`MAX_SUPPLY = 1_000_000_000_000_000` (10^15 base units).

## What the original code was missing

`mint()` overflow-checked `total_supply` but **never enforced an upper bound** —
supply could grow without limit, so the `<= MAX_SUPPLY` half of the invariant was
not enforced at all. `burn()` used unchecked subtraction (`-`), which silently
wraps in builds without `overflow-checks` (the workspace release profile does not
enable them, and per-crate `[profile.release]` is ignored for workspace members).

## The fix

- `mint()` computes `new_supply = current_supply.checked_add(amount)` and **rejects
the call** (`panic!("Max supply exceeded")`) when `new_supply > MAX_SUPPLY`,
**before** writing any state.
- `burn()` uses `checked_sub` for both balance and total supply.

The check-then-write ordering means no partial state is committed on rejection.

## On the "race condition" framing

The issue describes two `mint()` calls in the **same ledger** both observing
`total_supply == MAX_SUPPLY - 1` and both proceeding. That cannot happen on
Stellar/Soroban:

- Transactions are applied **serially** by the host. There is no concurrent
execution of two invocations against the same contract state.
- Each transaction reads the **committed** state left by the previous one and its
writes are atomic with respect to other transactions.

So a cross-transaction "check-and-set race" within a ledger does not exist, and
the remedies proposed for that model do not apply here:

- **`MINT_INFLIGHT` lock** — would only matter for *re-entrancy* (a nested call
back into `mint` within one invocation). `mint`/`burn` make no external
contract calls, so there is no re-entrancy vector to guard. Adding a lock would
be dead code.
- **Two-phase commit + background finalization** — Soroban has no background
processes and no cross-ledger uncommitted state; there is nothing to finalize
asynchronously.

The real, enforceable defect was the missing cap. Enforcing it (plus
overflow-safe arithmetic) fully restores the invariant.

## Tests

`contracts/resource-token/src/test.rs`:

- `test_mint_up_to_max_supply_succeeds` — minting exactly `MAX_SUPPLY` is allowed.
- `test_mint_exceeding_max_supply_panics` — one unit past the cap is rejected.
- `test_mint_overflowing_supply_in_two_steps_panics` — the issue's `MAX_SUPPLY-1`
scenario, modelled as the serial calls Soroban actually performs.
- `test_repeated_mints_never_exceed_max_supply` — 100 sequential mints (the
"100 concurrent calls" analog) keep `total_supply <= MAX_SUPPLY` and
`total_supply == Σ(balances)` at every step.
- `test_burn_after_max_supply_allows_reminting` — burning frees cap headroom.

## Note on `MIN_MINT_AMOUNT`

The issue also lists `MIN_MINT_AMOUNT = 1_000_000`. It is **not** enforced here:
it is a dust-control policy orthogonal to the supply invariant, and enforcing it
would break the contract's existing small-amount mint/burn behaviour and test
suite. It can be added as a separate, deliberate policy change if desired.
48 changes: 33 additions & 15 deletions contracts/resource-token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub use auth::{authorize_burn, authorize_mint};
pub use operators::{authorize_operator, is_valid_operator, revoke_operator};
use storage as storage_mod;
pub use storage::{
get_balance, get_total_supply, set_balance, set_total_supply, MAX_CHAIN_DEPTH,
get_balance, get_total_supply, set_balance, set_total_supply, MAX_CHAIN_DEPTH, MAX_SUPPLY,
NAMESPACE_PREFIX, TTL_OPERATOR_DELEGATION,
};

Expand Down Expand Up @@ -119,30 +119,43 @@ impl ResourceToken {
/// # Panics
/// * If caller is not authorized (not admin or valid operator)
/// * If amount is negative or zero
/// * If the mint would push `total_supply` above `MAX_SUPPLY`
/// * If call chain depth is exceeded
pub fn mint(env: Env, to: Address, amount: i128) {
// Authorize with full call chain verification
authorize_mint(&env);

// Validate amount
if amount <= 0 {
panic!("Amount must be positive");
}

// Update balance

// Compute the new total supply first and enforce the supply cap BEFORE
// any state is written. Each token is backed 1:1 by a real resource
// deposit, so total_supply must never exceed MAX_SUPPLY. Soroban applies
// transactions serially and each sees committed state, so this
// check-then-write is atomic with respect to other transactions — there
// is no in-ledger concurrency to guard against; the real invariant to
// enforce is the cap itself.
let current_supply = get_total_supply(&env);
let new_supply = current_supply
.checked_add(amount)
.expect("Supply overflow");
if new_supply > MAX_SUPPLY {
panic!("Max supply exceeded");
}

// Update balance (overflow-checked; the workspace build does not enable
// overflow-checks, so the explicit check is load-bearing).
let current_balance = get_balance(&env, &to);
let new_balance = current_balance
.checked_add(amount)
.expect("Balance overflow");
set_balance(&env, &to, new_balance);

// Update total supply
let current_supply = get_total_supply(&env);
let new_supply = current_supply
.checked_add(amount)
.expect("Supply overflow");

// Commit the new total supply.
set_total_supply(&env, new_supply);

// Emit event
env.events().publish(
(soroban_sdk::symbol_short!("mint"),),
Expand Down Expand Up @@ -174,17 +187,22 @@ impl ResourceToken {
panic!("Amount must be positive");
}

// Update balance
// Update balance (overflow-checked subtraction; the workspace build does
// not enable overflow-checks, so use checked_sub rather than `-`).
let current_balance = get_balance(&env, &from);
if current_balance < amount {
panic!("Insufficient balance");
}
let new_balance = current_balance - amount;
let new_balance = current_balance
.checked_sub(amount)
.expect("Balance underflow");
set_balance(&env, &from, new_balance);

// Update total supply
let current_supply = get_total_supply(&env);
let new_supply = current_supply - amount;
let new_supply = current_supply
.checked_sub(amount)
.expect("Supply underflow");
set_total_supply(&env, new_supply);

// Emit event
Expand Down
8 changes: 8 additions & 0 deletions contracts/resource-token/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ pub const TTL_OPERATOR_DELEGATION: u32 = 30 * 86400;
/// Maximum call chain depth allowed
pub const MAX_CHAIN_DEPTH: u32 = 5;

/// Maximum total supply of resource-backed tokens (10^15 base units).
///
/// Each token is backed 1:1 by a real-world resource deposit, so the total
/// supply must never exceed the maximum backable amount. `mint` enforces
/// `total_supply <= MAX_SUPPLY`; combined with overflow-checked accounting this
/// keeps the invariant `total_supply == Σ(balances) <= MAX_SUPPLY`.
pub const MAX_SUPPLY: i128 = 1_000_000_000_000_000;

/// Storage keys for the contract
#[derive(Clone)]
#[contracttype]
Expand Down
110 changes: 110 additions & 0 deletions contracts/resource-token/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,113 @@ fn test_balance_query_for_nonexistent_account() {
// Balance should be 0 for nonexistent account
assert_eq!(client.balance(&nonexistent), 0);
}

// --- MAX_SUPPLY cap enforcement (issue #1) -------------------------------

use crate::MAX_SUPPLY;

#[test]
fn test_mint_up_to_max_supply_succeeds() {
let env = Env::default();
let contract_id = env.register_contract(None, ResourceToken);
let client = ResourceTokenClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let recipient = Address::generate(&env);
env.mock_all_auths();

client.initialize(&admin);
client.mint(&recipient, &MAX_SUPPLY);

assert_eq!(client.total_supply(), MAX_SUPPLY);
assert_eq!(client.balance(&recipient), MAX_SUPPLY);
}

#[test]
#[should_panic(expected = "Max supply exceeded")]
fn test_mint_exceeding_max_supply_panics() {
let env = Env::default();
let contract_id = env.register_contract(None, ResourceToken);
let client = ResourceTokenClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let recipient = Address::generate(&env);
env.mock_all_auths();

client.initialize(&admin);
client.mint(&recipient, &MAX_SUPPLY);
// One unit past the cap must be rejected.
client.mint(&recipient, &1);
}

#[test]
#[should_panic(expected = "Max supply exceeded")]
fn test_mint_overflowing_supply_in_two_steps_panics() {
let env = Env::default();
let contract_id = env.register_contract(None, ResourceToken);
let client = ResourceTokenClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let a = Address::generate(&env);
let b = Address::generate(&env);
env.mock_all_auths();

client.initialize(&admin);
client.mint(&a, &(MAX_SUPPLY - 1));
// total_supply == MAX_SUPPLY - 1; a second mint of 2 would reach
// MAX_SUPPLY + 1. This is the scenario the issue framed as a "race": in
// Soroban the two calls are serial, and the cap rejects the overflowing one.
client.mint(&b, &2);
}

#[test]
fn test_repeated_mints_never_exceed_max_supply() {
// Soroban applies transactions serially, so "100 concurrent mints" is really
// 100 sequential invocations. Mint MAX_SUPPLY in 100 equal chunks and assert
// the cap holds at every step and the supply invariant (total_supply == sum
// of balances) is preserved.
let env = Env::default();
let contract_id = env.register_contract(None, ResourceToken);
let client = ResourceTokenClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let recipient = Address::generate(&env);
env.mock_all_auths();

client.initialize(&admin);

let iterations: i128 = 100;
let chunk = MAX_SUPPLY / iterations; // 100 * chunk == MAX_SUPPLY exactly
for i in 1..=iterations {
client.mint(&recipient, &chunk);
let supply = client.total_supply();
assert!(supply <= MAX_SUPPLY, "supply exceeded cap at step {}", i);
// Invariant: total_supply == Σ(balances) (single recipient here).
assert_eq!(supply, client.balance(&recipient));
assert_eq!(supply, chunk * i);
}

assert_eq!(client.total_supply(), MAX_SUPPLY);
}

#[test]
fn test_burn_after_max_supply_allows_reminting() {
// Burning frees headroom under the cap; the supply invariant must hold.
let env = Env::default();
let contract_id = env.register_contract(None, ResourceToken);
let client = ResourceTokenClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let recipient = Address::generate(&env);
env.mock_all_auths();

client.initialize(&admin);
client.mint(&recipient, &MAX_SUPPLY);
client.burn(&recipient, &1000);
assert_eq!(client.total_supply(), MAX_SUPPLY - 1000);

// Now there is room to mint exactly 1000 again, but not 1001.
client.mint(&recipient, &1000);
assert_eq!(client.total_supply(), MAX_SUPPLY);
assert_eq!(client.balance(&recipient), MAX_SUPPLY);
}
Loading