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

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ members = [
"examples/ownable",
"examples/pausable",
"examples/rwa/*",
"examples/rwa-time-transfers-limits",
"examples/rwa-transfer-restrict",
"examples/rwa-initial-lockup-period",
"examples/sac-admin-generic",
"examples/sac-admin-wrapper",
"examples/multisig-smart-account/*",
Expand Down
15 changes: 15 additions & 0 deletions examples/rwa-initial-lockup-period/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "rwa-initial-lockup-period"
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 }
64 changes: 64 additions & 0 deletions examples/rwa-initial-lockup-period/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Initial Lockup Period Module

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

## What it enforces

This module applies a lockup period to tokens received through primary
emissions. When tokens are minted, the minted amount is locked until the
configured release timestamp.

The example follows the library semantics:

- minted tokens are subject to lockup
- peer-to-peer transfers do not create new lockups for the recipient
- transfers and burns can consume only unlocked balance

## How it stays in sync

The module maintains internal balances plus lock records and therefore must be
wired to all of the hooks it depends on:

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

After those hooks are registered, `verify_hook_wiring()` must be called once so
the module marks itself as armed before 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 configured from the CLI before handing control to
Compliance.

## Main entrypoints

- `__constructor(admin)` initializes the bootstrap admin
- `set_lockup_period(token, lockup_seconds)` configures the mint lockup window
- `pre_set_lockup_state(token, wallet, balance, locks)` seeds an existing
holder's mirrored balance and active lock entries
- `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
- The module stores detailed lock entries plus aggregate locked totals
- If the module is attached after live minting, seed existing balances and any
still-active lock entries before relying on transfer or burn enforcement
- Transfer and burn flows consume unlocked balance first, then matured locks if
needed
228 changes: 228 additions & 0 deletions examples/rwa-initial-lockup-period/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#![no_std]

use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec};
use stellar_tokens::rwa::compliance::{
modules::{
initial_lockup_period::{
storage::{
get_internal_balance, get_locks, get_lockup_period, get_total_locked,
set_internal_balance, set_locks, set_lockup_period, set_total_locked,
},
InitialLockupPeriod, LockedTokens, LockupPeriodSet,
},
storage::{
add_i128_or_panic, set_compliance_address, sub_i128_or_panic, verify_required_hooks,
ComplianceModuleStorageKey,
},
},
ComplianceHook,
};

#[contracttype]
enum DataKey {
Admin,
}

#[contract]
pub struct InitialLockupPeriodContract;

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();
}
}

fn calculate_unlocked_amount(e: &Env, locks: &Vec<LockedTokens>) -> i128 {
let now = e.ledger().timestamp();
let mut unlocked = 0i128;
for i in 0..locks.len() {
let lock = locks.get(i).unwrap();
if lock.release_timestamp <= now {
unlocked = add_i128_or_panic(e, unlocked, lock.amount);
}
}
unlocked
}

fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) {
let locks = get_locks(e, token, wallet);
let now = e.ledger().timestamp();
let mut new_locks = Vec::new(e);
let mut consumed_total = 0i128;

for i in 0..locks.len() {
let lock = locks.get(i).unwrap();
if amount_to_consume > 0 && lock.release_timestamp <= now {
if amount_to_consume >= lock.amount {
amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount);
consumed_total = add_i128_or_panic(e, consumed_total, lock.amount);
} else {
consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume);
new_locks.push_back(LockedTokens {
amount: sub_i128_or_panic(e, lock.amount, amount_to_consume),
release_timestamp: lock.release_timestamp,
});
amount_to_consume = 0;
}
} else {
new_locks.push_back(lock);
}
}

set_locks(e, token, wallet, &new_locks);

let total_locked = get_total_locked(e, token, wallet);
set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total));
}

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

#[contractimpl(contracttrait)]
impl InitialLockupPeriod for InitialLockupPeriodContract {
fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) {
require_module_admin_or_compliance_auth(e);
set_lockup_period(e, &token, lockup_seconds);
LockupPeriodSet { token, lockup_seconds }.publish(e);
}

fn pre_set_lockup_state(
e: &Env,
token: Address,
wallet: Address,
balance: i128,
locks: Vec<LockedTokens>,
) {
require_module_admin_or_compliance_auth(e);
stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, balance);

let mut total_locked = 0i128;
for i in 0..locks.len() {
let lock = locks.get(i).unwrap();
stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(
e,
lock.amount,
);
total_locked = add_i128_or_panic(e, total_locked, lock.amount);
}

assert!(
total_locked <= balance,
"InitialLockupPeriodModule: total locked amount cannot exceed balance"
);

set_internal_balance(e, &token, &wallet, balance);
set_locks(e, &token, &wallet, &locks);
set_total_locked(e, &token, &wallet, total_locked);
}

fn required_hooks(e: &Env) -> Vec<ComplianceHook> {
vec![
e,
ComplianceHook::CanTransfer,
ComplianceHook::Created,
ComplianceHook::Transferred,
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 total_locked = get_total_locked(e, &token, &from);

if total_locked > 0 {
let pre_balance = get_internal_balance(e, &token, &from);
let pre_free = pre_balance - total_locked;

if amount > pre_free.max(0) {
let to_consume = amount - pre_free.max(0);
update_locked_tokens(e, &token, &from, to_consume);
}
}

let from_bal = get_internal_balance(e, &token, &from);
set_internal_balance(e, &token, &from, sub_i128_or_panic(e, from_bal, amount));

let to_bal = get_internal_balance(e, &token, &to);
set_internal_balance(e, &token, &to, add_i128_or_panic(e, to_bal, amount));
}

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 period = get_lockup_period(e, &token);
if period > 0 {
let mut locks = get_locks(e, &token, &to);
locks.push_back(LockedTokens {
amount,
release_timestamp: e.ledger().timestamp().saturating_add(period),
});
set_locks(e, &token, &to, &locks);

let total = get_total_locked(e, &token, &to);
set_total_locked(e, &token, &to, add_i128_or_panic(e, total, amount));
}

let current = get_internal_balance(e, &token, &to);
set_internal_balance(e, &token, &to, add_i128_or_panic(e, current, amount));
}

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 total_locked = get_total_locked(e, &token, &from);

if total_locked > 0 {
let pre_balance = get_internal_balance(e, &token, &from);
let mut free_amount = pre_balance - total_locked;

if free_amount < amount {
let locks = get_locks(e, &token, &from);
free_amount += calculate_unlocked_amount(e, &locks);
}

assert!(
free_amount >= amount,
"InitialLockupPeriodModule: insufficient unlocked balance for burn"
);

let pre_free = pre_balance - total_locked;
if amount > pre_free.max(0) {
let to_consume = amount - pre_free.max(0);
update_locked_tokens(e, &token, &from, to_consume);
}
}

let current = get_internal_balance(e, &token, &from);
set_internal_balance(e, &token, &from, 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-time-transfers-limits/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "rwa-time-transfers-limits"
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 }
Loading
Loading