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
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ members = [
"examples/ownable",
"examples/pausable",
"examples/rwa/*",
"examples/rwa-max-balance",
"examples/rwa-supply-limit",
"examples/sac-admin-generic",
"examples/sac-admin-wrapper",
"examples/multisig-smart-account/*",
Expand Down
15 changes: 15 additions & 0 deletions examples/rwa-max-balance/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "rwa-max-balance"
edition.workspace = true
license.workspace = true
repository.workspace = true
publish = false
version.workspace = true

[lib]
crate-type = ["cdylib", "rlib"]
doctest = false

[dependencies]
soroban-sdk = { workspace = true }
stellar-tokens = { workspace = true }
62 changes: 62 additions & 0 deletions examples/rwa-max-balance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Max Balance Module

Concrete deployable example of the `MaxBalance` compliance module for Stellar
RWA tokens.

## What it enforces

This module tracks balances per investor identity, not per wallet, and enforces
a maximum balance cap for each token.

Because the accounting is identity-based, the module must be configured with an
Identity Registry Storage (IRS) contract for each token it serves.

## How it stays in sync

The module maintains internal per-identity balances and therefore must be wired
to all of the hooks it depends on:

- `CanTransfer`
- `CanCreate`
- `Transferred`
- `Created`
- `Destroyed`

After those hooks are registered, `verify_hook_wiring()` must be called once so
the module marks itself as armed before mint and transfer validation starts.

## Authorization model

This example uses the bootstrap-admin pattern introduced in this port:

- The constructor stores a one-time `admin`
- Before `set_compliance_address`, configuration calls require that admin's
auth
- After `set_compliance_address`, privileged calls require auth from the bound
Compliance contract
- `set_compliance_address` itself remains a one-time admin action

This allows the module to be seeded and configured from the CLI before handing
control to Compliance.

## Main entrypoints

- `__constructor(admin)` initializes the bootstrap admin
- `set_identity_registry_storage(token, irs)` stores the IRS address for a
token
- `set_max_balance(token, max)` configures the per-identity cap
- `pre_set_module_state(token, identity, balance)` seeds an identity balance
- `batch_pre_set_module_state(token, identities, balances)` seeds many
identity balances
- `required_hooks()` returns the required hook set
- `verify_hook_wiring()` marks the module as armed after registration
- `set_compliance_address(compliance)` performs the one-time handoff to the
Compliance contract

## Notes

- Storage is token-scoped, so one deployed module can be reused across many
tokens
- Transfers between two wallets that resolve to the same identity do not change
the tracked balance distribution
- A configured max of `0` behaves as "no cap"
167 changes: 167 additions & 0 deletions examples/rwa-max-balance/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#![no_std]

use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec};
use stellar_tokens::rwa::compliance::{
modules::{
max_balance::{
storage::{get_id_balance, get_max_balance, set_id_balance, set_max_balance},
IDBalancePreSet, MaxBalance, MaxBalanceSet,
},
storage::{
add_i128_or_panic, get_irs_client, set_compliance_address, set_irs_address,
sub_i128_or_panic, verify_required_hooks, ComplianceModuleStorageKey,
},
},
ComplianceHook,
};

#[contracttype]
enum DataKey {
Admin,
}

#[contract]
pub struct MaxBalanceContract;

fn set_admin(e: &Env, admin: &Address) {
e.storage().instance().set(&DataKey::Admin, admin);
}

fn get_admin(e: &Env) -> Address {
e.storage().instance().get(&DataKey::Admin).expect("admin must be set")
}

fn require_module_admin_or_compliance_auth(e: &Env) {
if let Some(compliance) =
e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance)
{
compliance.require_auth();
} else {
get_admin(e).require_auth();
}
}

#[contractimpl]
impl MaxBalanceContract {
pub fn __constructor(e: &Env, admin: Address) {
set_admin(e, &admin);
}
}

#[contractimpl(contracttrait)]
impl MaxBalance for MaxBalanceContract {
fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
require_module_admin_or_compliance_auth(e);
set_irs_address(e, &token, &irs);
}

fn set_max_balance(e: &Env, token: Address, max: i128) {
require_module_admin_or_compliance_auth(e);
stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, max);
set_max_balance(e, &token, max);
MaxBalanceSet { token, max_balance: max }.publish(e);
}

fn pre_set_module_state(e: &Env, token: Address, identity: Address, balance: i128) {
require_module_admin_or_compliance_auth(e);
stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, balance);
set_id_balance(e, &token, &identity, balance);
IDBalancePreSet { token, identity, balance }.publish(e);
}

fn batch_pre_set_module_state(
e: &Env,
token: Address,
identities: Vec<Address>,
balances: Vec<i128>,
) {
require_module_admin_or_compliance_auth(e);
assert!(
identities.len() == balances.len(),
"MaxBalanceModule: identities and balances length mismatch"
);
for i in 0..identities.len() {
let id = identities.get(i).unwrap();
let bal = balances.get(i).unwrap();
stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, bal);
set_id_balance(e, &token, &id, bal);
IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e);
}
}

fn required_hooks(e: &Env) -> Vec<ComplianceHook> {
vec![
e,
ComplianceHook::CanTransfer,
ComplianceHook::CanCreate,
ComplianceHook::Transferred,
ComplianceHook::Created,
ComplianceHook::Destroyed,
]
}

fn verify_hook_wiring(e: &Env) {
verify_required_hooks(e, Self::required_hooks(e));
}

fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
require_module_admin_or_compliance_auth(e);
stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount);

let irs = get_irs_client(e, &token);
let from_id = irs.stored_identity(&from);
let to_id = irs.stored_identity(&to);

if from_id == to_id {
return;
}

let from_balance = get_id_balance(e, &token, &from_id);
let to_balance = get_id_balance(e, &token, &to_id);
let new_to_balance = add_i128_or_panic(e, to_balance, amount);

let max = get_max_balance(e, &token);
assert!(
max == 0 || new_to_balance <= max,
"MaxBalanceModule: recipient identity balance exceeds max"
);

set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, from_balance, amount));
set_id_balance(e, &token, &to_id, new_to_balance);
}

fn on_created(e: &Env, to: Address, amount: i128, token: Address) {
require_module_admin_or_compliance_auth(e);
stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount);

let irs = get_irs_client(e, &token);
let to_id = irs.stored_identity(&to);

let current = get_id_balance(e, &token, &to_id);
let new_balance = add_i128_or_panic(e, current, amount);

let max = get_max_balance(e, &token);
assert!(
max == 0 || new_balance <= max,
"MaxBalanceModule: recipient identity balance exceeds max after mint"
);

set_id_balance(e, &token, &to_id, new_balance);
}

fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) {
require_module_admin_or_compliance_auth(e);
stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount);

let irs = get_irs_client(e, &token);
let from_id = irs.stored_identity(&from);

let current = get_id_balance(e, &token, &from_id);
set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, current, amount));
}

fn set_compliance_address(e: &Env, compliance: Address) {
get_admin(e).require_auth();
set_compliance_address(e, &compliance);
}
}
15 changes: 15 additions & 0 deletions examples/rwa-supply-limit/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "rwa-supply-limit"
edition.workspace = true
license.workspace = true
repository.workspace = true
publish = false
version.workspace = true

[lib]
crate-type = ["cdylib", "rlib"]
doctest = false

[dependencies]
soroban-sdk = { workspace = true }
stellar-tokens = { workspace = true }
61 changes: 61 additions & 0 deletions examples/rwa-supply-limit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Supply Limit Module

Concrete deployable example of the `SupplyLimit` compliance module for Stellar
RWA tokens.

## What it enforces

This module caps the total amount of tokens that may be minted for a given
token contract.

It keeps an internal supply counter and checks that each mint would stay within
the configured per-token limit.

## How it stays in sync

The module maintains internal supply state and therefore must be wired to all
of the hooks it depends on:

- `CanCreate`
- `Created`
- `Destroyed`

After those hooks are registered, `verify_hook_wiring()` must be called once so
the module marks itself as armed before mint validation starts.

## Authorization model

This example uses the bootstrap-admin pattern introduced in this port:

- The constructor stores a one-time `admin`
- Before `set_compliance_address`, configuration calls require that admin's
auth
- After `set_compliance_address`, privileged calls require auth from the bound
Compliance contract
- `set_compliance_address` itself remains a one-time admin action

This allows the module to be configured from the CLI before handing control to
Compliance.

## Main entrypoints

- `__constructor(admin)` initializes the bootstrap admin
- `set_supply_limit(token, limit)` sets the per-token cap
- `pre_set_internal_supply(token, supply)` seeds tracked supply when wiring the
module after historical minting
- `get_supply_limit(token)` reads the configured cap
- `get_internal_supply(token)` reads the tracked internal supply
- `required_hooks()` returns the required hook set
- `verify_hook_wiring()` marks the module as armed after registration
- `set_compliance_address(compliance)` performs the one-time handoff to the
Compliance contract

## Notes

- Storage is token-scoped, so one deployed module can be reused across many
tokens
- A configured limit of `0` behaves as "no cap"
- If the module is attached after a token already has minted supply, seed the
existing amount with `pre_set_internal_supply` before relying on `can_create`
- The internal supply is updated only through the registered `Created` and
`Destroyed` hooks
Loading
Loading