Skip to content

RWA: transfer compliance modules#652

Open
pasevin wants to merge 3 commits intoOpenZeppelin:mainfrom
pasevin:feat/rwa-transfer-standalone
Open

RWA: transfer compliance modules#652
pasevin wants to merge 3 commits intoOpenZeppelin:mainfrom
pasevin:feat/rwa-transfer-standalone

Conversation

@pasevin
Copy link
Copy Markdown
Contributor

@pasevin pasevin commented Mar 23, 2026

Summary

Adds three transfer-related compliance modules for RWA tokens: allowlist-based transfer restriction, rolling transfer limits, and an initial post-mint lockup period.

Changes

  • Library: adds transfer_restrict, time_transfers_limits, and initial_lockup_period compliance modules with their storage and hook logic.
  • Behavior support: includes the state seeding needed for late-attached rolling counters and lockup tracking.
  • Examples: adds rwa-transfer-restrict, rwa-time-transfers-limits, and rwa-initial-lockup-period example crates.
  • Workspace: includes the new example crates in the workspace and module exports.

Test plan

  • cargo +nightly fmt --all -- --check
  • cargo test -p stellar-tokens --lib transfer
  • cargo test -p stellar-tokens --lib lockup
  • cargo test -p rwa-transfer-restrict --lib
  • cargo test -p rwa-time-transfers-limits --lib
  • cargo test -p rwa-initial-lockup-period --lib

Transplant the reviewed transfer restriction, time transfer, and lockup
modules plus their example crates onto upstream/main as an independent PR.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 23, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 98fa6d77-1de0-4b1f-9392-59d52a0dcb5b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Three new RWA compliance module examples are added to the workspace: InitialLockupPeriod (enforces token lockup on creation), TimeTransfersLimits (enforces per-identity transfer limits within time windows), and TransferRestrict (enforces per-token allowlists using T-REX semantics). The workspace is updated to include these examples, and core module implementations are added to the compliance modules package alongside storage, tests, and documentation.

Changes

Cohort / File(s) Summary
Workspace Configuration
Cargo.toml, packages/tokens/src/rwa/compliance/modules/mod.rs
Root workspace updated to register three new example crates; compliance modules namespace extended to expose new submodules (initial_lockup_period, time_transfers_limits, transfer_restrict).
Initial Lockup Period Module
examples/rwa-initial-lockup-period/*, packages/tokens/src/rwa/compliance/modules/initial_lockup_period/*
New compliance module enforcing token lockup periods on creation, with state management (locks per wallet, total locked, internal balances), transfer validation, lifecycle hooks (on_created, on_transfer, on_destroyed), and admin-gated configuration. Includes example contract, storage helpers, tests, and documentation.
Time Transfers Limits Module
examples/rwa-time-transfers-limits/*, packages/tokens/src/rwa/compliance/modules/time_transfers_limits/*
New compliance module enforcing time-windowed per-identity transfer limits (up to 4 limits per token) with IRS integration, counter tracking/reset logic, batch limit management, and hook wiring. Includes example contract, storage types, tests, and comprehensive documentation.
Transfer Restrict Module
examples/rwa-transfer-restrict/*, packages/tokens/src/rwa/compliance/modules/transfer_restrict/*
New compliance module implementing T-REX allowlist semantics (transfer allowed if sender or recipient is allowlisted), with batch allow/disallow operations, hook validation, and admin bootstrap pattern. Includes example contract, storage helpers, tests, and documentation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • Rwa reorg #620: Introduced the modules namespace and reorganized ComplianceModule/clients infrastructure that these compliance modules directly build upon.
  • feat(rwa): add compliance module base architecture #607: Added RWA compliance-module infrastructure (helpers, error codes, TTL constants) that these new modules depend on and extend.
  • RWA: examples #614: Overlapping workspace configuration updates and RWA example/module additions affecting the same directory structure.

Suggested reviewers

  • brozorec
  • ozgunozerk

Poem

🐰 Three hopping modules, lockups aligned,
Transfer limits and allowlists intertwined!
Storage persists through each clever hook,
Compliance secured—a well-ordered nook! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: adding three transfer compliance modules for RWA tokens, which aligns with the primary intent of the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description check ✅ Passed PR description is well-structured with a clear summary, detailed list of changes, and a comprehensive test plan with all items marked complete.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
examples/rwa-transfer-restrict/src/lib.rs (1)

3-3: Remove unused import String.

The String type is imported but not used anywhere in this file.

🧹 Proposed fix
-use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Vec};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/rwa-transfer-restrict/src/lib.rs` at line 3, The import list
includes an unused symbol `String` in the `use soroban_sdk::{contract,
contractimpl, contracttype, Address, Env, String, Vec};` declaration; remove
`String` from that use statement so it becomes `use soroban_sdk::{contract,
contractimpl, contracttype, Address, Env, Vec};` to clear the unused-import
warning.
examples/rwa-time-transfers-limits/src/lib.rs (1)

50-71: Avoid maintaining a second copy of the transfer-limit engine here.

Most of this file mirrors packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs. Keeping two copies of the counter/reset/update logic makes it easy for the example path to miss future compliance fixes. Export shared helpers from stellar_tokens and keep only the example-specific auth/constructor surface here.

Also applies to: 80-201

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/rwa-time-transfers-limits/src/lib.rs` around lines 50 - 71, This
file duplicates the transfer-limit engine (functions is_counter_finished,
reset_counter_if_needed, increase_counters and their use of
get_counter/set_counter/get_limits/TransferCounter); instead of maintaining this
copy, remove these duplicate functions and call the shared helpers exported from
the stellar_tokens time_transfers_limits module (import the common
reset/update/counter helpers and types) and wire them into the example’s
auth/constructor surface only, keeping example-specific glue but delegating all
counter/reset/update logic to the centralized helpers.
examples/rwa-initial-lockup-period/src/lib.rs (1)

47-88: Prefer a thin example wrapper over a forked lockup engine.

These helpers and hook bodies are effectively a second implementation of packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs, just with different auth gating. That makes future compliance fixes easy to miss on the example path. Since this crate already depends on stellar_tokens, consider exporting shared auth-agnostic helpers and keeping only the example-specific auth wrapper here.

Also applies to: 97-227

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs`:
- Around line 183-204: The on_transfer path currently mutates balances without
re-checking that the transfer is allowed; fix by enforcing the unlocked-balance
predicate immediately before state updates: after computing/consuming matured
locks (use get_total_locked, get_internal_balance and update_locked_tokens as
already done), recompute the effective free balance (e.g., let post_balance =
get_internal_balance(e, &token, &from); let post_total_locked =
get_total_locked(e, &token, &from); let post_free = post_balance -
post_total_locked) and require that amount <= post_free (or call the existing
transfer-check helper if one exists) and abort otherwise; only then call
sub_i128_or_panic and add_i128_or_panic to mutate balances.

In `@packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs`:
- Around line 217-223: The on_transfer hook currently updates counters without
re-checking the transfer limit predicate, so make it fail-closed by calling the
same limit-check used in the CanTransfer path before mutating state: after
require_non_negative_amount and resolving irs/from_id (in on_transfer), invoke
the predicate function used by CanTransfer (the limit-check function used to
allow/reject transfers) with the same params (e.g., token, from_id, amount) and
use a require-style assertion to abort if it fails, then only call
increase_counters; reference on_transfer, get_irs_client, stored_identity, and
increase_counters to locate where to insert the check.

---

Nitpick comments:
In `@examples/rwa-time-transfers-limits/src/lib.rs`:
- Around line 50-71: This file duplicates the transfer-limit engine (functions
is_counter_finished, reset_counter_if_needed, increase_counters and their use of
get_counter/set_counter/get_limits/TransferCounter); instead of maintaining this
copy, remove these duplicate functions and call the shared helpers exported from
the stellar_tokens time_transfers_limits module (import the common
reset/update/counter helpers and types) and wire them into the example’s
auth/constructor surface only, keeping example-specific glue but delegating all
counter/reset/update logic to the centralized helpers.

In `@examples/rwa-transfer-restrict/src/lib.rs`:
- Line 3: The import list includes an unused symbol `String` in the `use
soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};`
declaration; remove `String` from that use statement so it becomes `use
soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Vec};` to
clear the unused-import warning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a7a7bda9-48a0-4635-97fb-70ef341afb19

📥 Commits

Reviewing files that changed from the base of the PR and between ff17d24 and 668c103.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (20)
  • Cargo.toml
  • examples/rwa-initial-lockup-period/Cargo.toml
  • examples/rwa-initial-lockup-period/README.md
  • examples/rwa-initial-lockup-period/src/lib.rs
  • examples/rwa-time-transfers-limits/Cargo.toml
  • examples/rwa-time-transfers-limits/README.md
  • examples/rwa-time-transfers-limits/src/lib.rs
  • examples/rwa-transfer-restrict/Cargo.toml
  • examples/rwa-transfer-restrict/README.md
  • examples/rwa-transfer-restrict/src/lib.rs
  • packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs
  • packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs
  • packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs
  • packages/tokens/src/rwa/compliance/modules/mod.rs
  • packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs
  • packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs
  • packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs
  • packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs
  • packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs
  • packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs

Comment on lines +183 to +204
fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
get_compliance_address(e).require_auth();
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));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

on_transfer needs its own unlocked-balance guard.

Unlike on_destroyed, this path never proves that the amount is actually unlocked. If CanTransfer is miswired or skipped, it will subtract the full amount after consuming only matured locks, which can move still-locked value and violate the total_locked <= internal_balance invariant. Re-check the transfer predicate before mutating state.

🛠️ Proposed fix
 fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
     get_compliance_address(e).require_auth();
     require_non_negative_amount(e, amount);
+    assert!(
+        Self::can_transfer(e, from.clone(), to.clone(), amount, token.clone()),
+        "InitialLockupPeriodModule: insufficient unlocked balance for transfer"
+    );
 
     let total_locked = get_total_locked(e, &token, &from);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
get_compliance_address(e).require_auth();
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_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
get_compliance_address(e).require_auth();
require_non_negative_amount(e, amount);
assert!(
Self::can_transfer(e, from.clone(), to.clone(), amount, token.clone()),
"InitialLockupPeriodModule: insufficient unlocked balance for transfer"
);
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));
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs`
around lines 183 - 204, The on_transfer path currently mutates balances without
re-checking that the transfer is allowed; fix by enforcing the unlocked-balance
predicate immediately before state updates: after computing/consuming matured
locks (use get_total_locked, get_internal_balance and update_locked_tokens as
already done), recompute the effective free balance (e.g., let post_balance =
get_internal_balance(e, &token, &from); let post_total_locked =
get_total_locked(e, &token, &from); let post_free = post_balance -
post_total_locked) and require that amount <= post_free (or call the existing
transfer-check helper if one exists) and abort otherwise; only then call
sub_i128_or_panic and add_i128_or_panic to mutate balances.

Comment on lines +217 to +223
fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) {
get_compliance_address(e).require_auth();
require_non_negative_amount(e, amount);
let irs = get_irs_client(e, &token);
let from_id = irs.stored_identity(&from);
increase_counters(e, &token, &from_id, amount);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep on_transfer fail-closed.

This hook trusts that CanTransfer executed first. If Transferred is wired without CanTransfer, or this entrypoint is invoked directly, the transfer is accepted and the counter is updated after the fact. Re-check the limit predicate here before mutating counters.

🛠️ Proposed fix
-    fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) {
+    fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
         get_compliance_address(e).require_auth();
         require_non_negative_amount(e, amount);
+        assert!(
+            Self::can_transfer(e, from.clone(), to.clone(), amount, token.clone()),
+            "TimeTransfersLimitsModule: transfer exceeds configured limits"
+        );
         let irs = get_irs_client(e, &token);
         let from_id = irs.stored_identity(&from);
         increase_counters(e, &token, &from_id, amount);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) {
get_compliance_address(e).require_auth();
require_non_negative_amount(e, amount);
let irs = get_irs_client(e, &token);
let from_id = irs.stored_identity(&from);
increase_counters(e, &token, &from_id, amount);
}
fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
get_compliance_address(e).require_auth();
require_non_negative_amount(e, amount);
assert!(
Self::can_transfer(e, from.clone(), to.clone(), amount, token.clone()),
"TimeTransfersLimitsModule: transfer exceeds configured limits"
);
let irs = get_irs_client(e, &token);
let from_id = irs.stored_identity(&from);
increase_counters(e, &token, &from_id, amount);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs`
around lines 217 - 223, The on_transfer hook currently updates counters without
re-checking the transfer limit predicate, so make it fail-closed by calling the
same limit-check used in the CanTransfer path before mutating state: after
require_non_negative_amount and resolving irs/from_id (in on_transfer), invoke
the predicate function used by CanTransfer (the limit-check function used to
allow/reject transfers) with the same params (e.g., token, from_id, amount) and
use a require-style assertion to abort if it fails, then only call
increase_counters; reference on_transfer, get_irs_client, stored_identity, and
increase_counters to locate where to insert the check.

pasevin added 2 commits March 24, 2026 17:49
Cover the shared compliance storage helpers directly so Codecov reflects the
real exercised behavior without touching production logic.
Apply rustfmt to the new shared-helper coverage assertions so the transfer PR
passes the workspace formatting check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant