Skip to content

RWA: deploy example and testnet e2e flow#654

Draft
pasevin wants to merge 3 commits intoOpenZeppelin:mainfrom
pasevin:feat/rwa-deploy-standalone
Draft

RWA: deploy example and testnet e2e flow#654
pasevin wants to merge 3 commits intoOpenZeppelin:mainfrom
pasevin:feat/rwa-deploy-standalone

Conversation

@pasevin
Copy link
Copy Markdown
Contributor

@pasevin pasevin commented Mar 23, 2026

Summary

Adds deployable RWA example contracts together with scripts for building, deploying, wiring, and running testnet end-to-end validation.

Changes

  • examples/rwa-deploy/: adds deploy crates for compliance, token, IRS, and verifier, plus the deployment README.
  • Scripts: adds build.sh, build-module.sh, deploy.sh, deploy-module.sh, wire.sh, e2e.sh, and test-happy-path.sh.
  • Workspace: includes the deploy sub-crates as workspace members.
  • Support files: ignores generated WASM artifacts and deployed-address state files.
  • Compatibility: keeps the testnet flow working on current main, including the IRS CLI payload encoding expected by the current contract interface.

Test plan

  • cargo +nightly fmt --all -- --check
  • cargo test -p deploy-irs --lib
  • cargo test -p deploy-verifier --lib
  • cargo test -p deploy-compliance --lib
  • cargo test -p deploy-token --lib
  • PATH="/usr/local/bin:$PATH" ./examples/rwa-deploy/scripts/e2e.sh --skip-build

pasevin added 2 commits March 23, 2026 18:41
Transplant the reviewed deploy crates, scripts, and required module assets onto
upstream/main so the deploy and testnet e2e PR can stand on its own.
Encode IRS country profiles as explicit ScVal payloads and reuse the existing
invoke retry helper so the standalone deploy branch passes testnet e2e.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 23, 2026

Walkthrough

This pull request introduces comprehensive Real-World Assets (RWA) compliance module infrastructure for Stellar tokens, including seven new compliance modules (country allow/restrict, supply limits, transfer restrictions, initial lockup periods, max balance, time-based transfer limits), deployment contracts (token, identity registry, verifier, compliance), build and deployment automation scripts, and supporting storage layers.

Changes

Cohort / File(s) Summary
Gitignore Configuration
.gitignore
Added exclusion rules for compiled WASM artifacts in examples/rwa-deploy/wasm/ and testnet state file examples/rwa-deploy/testnet-addresses.json.
Workspace Configuration
Cargo.toml
Narrowed workspace members from wildcard examples/rwa/* to explicit enumeration of individual example directories and nested examples/rwa-deploy/* subdirectories for enhanced build control.
Compliance Module Examples
examples/rwa-country-allow/*, examples/rwa-country-restrict/*, examples/rwa-initial-lockup-period/*, examples/rwa-max-balance/*, examples/rwa-supply-limit/*, examples/rwa-time-transfers-limits/*, examples/rwa-transfer-restrict/*
Seven new compliance module example crates, each with Cargo.toml, README.md documentation, and contract implementation (src/lib.rs) demonstrating specific compliance logic (country-based allowlisting/restriction, supply caps, per-identity balance limits, time-windowed transfers, and user allowlists).
Deployment Infrastructure Contracts
examples/rwa-deploy/{irs,verifier,compliance,token}/*
Four core RWA deployment crates providing Identity Registry Storage, identity verification, compliance orchestration, and token implementation with support for compliance hooks and access control.
Deployment Build and Automation Scripts
examples/rwa-deploy/scripts/{build.sh,build-module.sh,common.sh,deploy.sh,deploy-module.sh,wire.sh,e2e.sh,test-happy-path.sh}
Eight bash scripts automating WASM compilation, contract deployment, module registration, hook wiring, identity registration, and end-to-end testing with retry logic and state persistence.
Deployment Documentation
examples/rwa-deploy/README.md
End-to-end deployment guide describing crate purposes, build artifacts, deployment sequence, module configuration, and follow-on wiring/testing workflows.
Core Compliance Module Trait Definitions
packages/tokens/src/rwa/compliance/modules/{country_allow,country_restrict,initial_lockup_period,max_balance,supply_limit,time_transfers_limits,transfer_restrict}/mod.rs
Seven new public contract traits and module implementations defining compliance-hook behavior, state management interfaces, and lifecycle hooks for token transfer/creation/destruction validation.
Compliance Module Storage Layers
packages/tokens/src/rwa/compliance/modules/{country_allow,country_restrict,initial_lockup_period,max_balance,supply_limit,time_transfers_limits,transfer_restrict}/storage.rs
Persistent storage helpers for each module, managing token-scoped and identity-scoped state with TTL extension, defaults handling, and contract-type key definitions.
Compliance Module Test Suites
packages/tokens/src/rwa/compliance/modules/{initial_lockup_period,max_balance,supply_limit,time_transfers_limits,transfer_restrict}/test.rs
Comprehensive unit tests with mock Soroban contracts validating hook wiring, lifecycle enforcement, state transitions, and edge cases for each compliance module.
Compliance Modules Namespace
packages/tokens/src/rwa/compliance/modules/mod.rs
Added public module exports for all seven new compliance module submodules to enable workspace-wide visibility.

Sequence Diagram(s)

sequenceDiagram
    participant Deploy as Deployment Script
    participant IRS as Identity Registry
    participant Token as RWA Token
    participant Compliance as Compliance Orchestrator
    participant Module as Compliance Module

    Deploy->>IRS: Deploy & initialize (manager)
    Deploy->>Compliance: Deploy & initialize (admin)
    Deploy->>Token: Deploy & initialize (compliance)
    Deploy->>Module: Deploy (admin bootstrap)
    Module->>Module: set_identity_registry_storage
    Module->>Module: Configure module state<br/>(limits, allowlists, etc.)
    Module->>Module: set_compliance_address<br/>(transfer control)
    Deploy->>Compliance: Register module on hooks<br/>(CanTransfer, CanCreate, etc.)
    Deploy->>Compliance: Verify hook wiring
    
    Note over Deploy,Module: Token Operations Phase
    Token->>Compliance: Transfer/Create request
    Compliance->>Module: Invoke compliance hook<br/>(can_transfer/can_create)
    Module->>IRS: Query identity data<br/>(country, balance, etc.)
    Module->>Module: Validate against config
    Module-->>Compliance: Allow/Deny decision
    Compliance-->>Token: Proceed/Reject
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

This change introduces substantial new functionality across multiple interconnected subsystems: seven distinct compliance modules with custom storage, state management, and lifecycle hooks; four infrastructure deployment contracts; comprehensive automation scripts with complex orchestration logic; and supporting module traits with default implementations. The heterogeneity is significant—compliance logic differs per module (country-based, time-windowed, identity-based restrictions), storage patterns vary, and scripts contain sophisticated retry/validation logic. While individual components follow patterns established in the codebase, the aggregate scope, density of new public interfaces, cross-module dependencies, and deployment orchestration complexity demand thorough review across contract logic, storage safety, authorization gates, and integration points.

Possibly related PRs

  • RWA: Val for country data instead assoc type #623: The new RWA compliance modules (country_allow/country_restrict) interact with Identity Registry Storage country-data APIs that this PR modifies from a CountryData assoc type to Val, creating direct interface dependencies.
  • Rwa reorg #620: Overlapping reorganization and new module/trait declarations under packages/tokens/src/rwa/compliance/modules/ with the retrieved "Rwa reorg" PR.
  • feat(rwa): add compliance module base architecture #607: The new compliance modules rely on common compliance infrastructure, constants (MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT), and error types introduced in this PR.

Suggested reviewers

  • brozorec
  • ozgunozerk

🐰 Seven modules, scripts, and contracts dance,
Compliance flows through each token's chance,
With hooks and storage, identity and trust,
RWA compliance is now a must!
From deployment to wiring, the automation's grand,
A fully orchestrated token command!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'RWA: deploy example and testnet e2e flow' clearly describes the main change: transplanting RWA deploy example crates, scripts, and modules with testnet e2e flow support.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description check ✅ Passed PR description is thorough with clear summary, detailed changes section, and comprehensive test plan. Follows the template structure with issue link and context.

✏️ 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: 4

🧹 Nitpick comments (14)
examples/rwa-country-allow/README.md (1)

15-27: Consider adding an explicit “example-only, not production-hardening guidance” note.
Given this section explains authorization behavior, a short disclaimer would reduce the risk of downstream copy/paste into production setups without added hardening.

Based on learnings: contracts under examples/rwa-compliance/ and related examples are intentionally simplified test/example infrastructure and should not be treated as production auth patterns.

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

In `@examples/rwa-country-allow/README.md` around lines 15 - 27, Add a short,
explicit disclaimer to the Authorization model section stating this is an
example-only simplified pattern and not production-ready for hardening; mention
the constructor’s one-time admin, the one-time set_compliance_address flow and
reliance on the bound Compliance contract are intentionally simplified, and
refer readers to the examples/rwa-compliance folder for other test-only
contracts and to consult production security guidance before reuse.
examples/rwa-deploy/scripts/e2e.sh (2)

21-22: Shellcheck warning is a false positive.

NETWORK is used by the sourced common.sh helper functions (e.g., invoke(), invoke_readonly()). Consider adding a shellcheck directive:

# shellcheck disable=SC2034  # Used by sourced common.sh
NETWORK="${STELLAR_NETWORK:-testnet}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/rwa-deploy/scripts/e2e.sh` around lines 21 - 22, The shellcheck
warning about NETWORK being unused is a false positive because NETWORK (set from
STELLAR_NETWORK) is consumed by sourced helper functions in common.sh (e.g.,
invoke(), invoke_readonly()); fix by adding a shellcheck directive above the
assignment to silence SC2034 (e.g., add a comment like "# shellcheck
disable=SC2034  # Used by sourced common.sh") so the
NETWORK="${STELLAR_NETWORK:-testnet}" line remains but the linter is suppressed
with clear justification referencing common.sh and the
invoke()/invoke_readonly() helpers.

346-346: Minor: Summary box alignment may break with multi-digit counts.

The fixed padding assumes single-digit or small counts. If tests grow significantly, the box alignment will be off. Consider using dynamic width calculation:

♻️ Optional fix for dynamic alignment
-printf "║ Tests: %d passed, %d failed (of %d)                 ║\n" "$PASS" "$FAIL" "$TOTAL"
+printf "║ Tests: %-3d passed, %-3d failed (of %-3d)            ║\n" "$PASS" "$FAIL" "$TOTAL"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/rwa-deploy/scripts/e2e.sh` at line 346, The fixed-width printf line
using PASS, FAIL, TOTAL can misalign for multi-digit counts; update the printing
to compute the needed field width dynamically (e.g., determine max length among
PASS/FAIL/TOTAL using string length or fmt_width=$(printf "%s\n%s\n%s" "$PASS"
"$FAIL" "$TOTAL" | awk '{ if (length>m) m=length } END{print m }')) and then use
a dynamic width specifier in the printf call (use "%*d" with the computed width)
when printing the summary so the box stays aligned regardless of digit count;
locate and replace the existing printf invocation that prints PASS/FAIL/TOTAL.
examples/rwa-deploy/scripts/wire.sh (1)

19-29: Shellcheck warnings are false positives.

SOURCE, NETWORK, and ADMIN are used by the helper functions in the sourced common.sh file (e.g., invoke() uses $SOURCE and $NETWORK; ensure_hook_registration uses $ADMIN). Consider adding shellcheck directives to suppress these warnings for clarity:

# shellcheck disable=SC2034  # Used by sourced common.sh
SOURCE="${STELLAR_SOURCE:-alice}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/rwa-deploy/scripts/wire.sh` around lines 19 - 29, ShellCheck reports
unused-variable warnings for SOURCE, NETWORK, and ADMIN even though they are
consumed by functions in the sourced common.sh; add a ShellCheck suppression
comment above each variable definition (e.g., add "# shellcheck disable=SC2034 
# used by sourced common.sh" immediately before
SOURCE="${STELLAR_SOURCE:-alice}", similarly for NETWORK and ADMIN) so the
linter knows these vars are intentionally exported for use by functions like
invoke(), ensure_hook_registration(), and read_addr() in common.sh.
examples/rwa-deploy/scripts/test-happy-path.sh (1)

36-61: Consolidate retry helper to avoid policy drift.

invoke_with_retry duplicates retry logic already used in deploy scripts. Consider moving/using a single shared implementation in examples/rwa-deploy/scripts/common.sh so retry behavior changes stay consistent everywhere.

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

In `@examples/rwa-deploy/scripts/test-happy-path.sh` around lines 36 - 61, The
invoke_with_retry implementation in test-happy-path.sh duplicatesretry logic;
extract and consolidate it into the shared helper file (common.sh) by moving the
invoke_with_retry function there (including its use of STELLAR_INVOKE_RETRIES,
STELLAR_INVOKE_RETRY_DELAY_SECONDS and retryable_invoke_error), then remove the
duplicate from test-happy-path.sh and source common.sh at the top so the script
calls the centralized invoke_with_retry; ensure retryable_invoke_error is
exported or present in common.sh and preserve the same return codes and backoff
behavior.
examples/rwa-transfer-restrict/src/lib.rs (1)

3-3: Unused import String.

The String type is imported but not used in this contract.

🧹 Proposed fix to remove unused import
-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, Remove the unused
import String from the top-level use statement in lib.rs: update the import line
that currently lists soroban_sdk::{contract, contractimpl, contracttype,
Address, Env, String, Vec} to omit String so only used symbols remain (contract,
contractimpl, contracttype, Address, Env, Vec); ensure no other references to
String exist in functions or types such as those implemented under
contract/contractimpl before committing.
examples/rwa-country-allow/src/lib.rs (1)

3-3: Unused import String.

The String type is imported but not used in this contract.

🧹 Proposed fix to remove unused import
-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-country-allow/src/lib.rs` at line 3, The import list includes an
unused symbol `String` from soroban_sdk; remove `String` from the use statement
that declares contract, contractimpl, contracttype, Address, Env, String, Vec so
it becomes contract, contractimpl, contracttype, Address, Env, Vec to eliminate
the unused import and clean up warnings.
packages/tokens/src/rwa/compliance/modules/max_balance/test.rs (1)

22-208: Extract the shared Soroban mocks into a reusable test helper.

MockIRSContract, MockComplianceContract, and arm_hooks() are now near-identical here, in packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs, and in packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs. Keeping one shared helper will make future hook/IRS behavior changes much less likely to drift across module tests.

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

In `@packages/tokens/src/rwa/compliance/modules/max_balance/test.rs` around lines
22 - 208, Extract the repeated Soroban test mocks into a shared test helper
module: move MockIRSContract, MockComplianceContract, and arm_hooks() into a
common test-utils module and re-export their key helper methods (e.g.,
MockIRSContract::set_identity, MockComplianceContract::register_hook, and
arm_hooks) so existing tests can call the same symbols; update the tests in
TestMaxBalanceContract and the other two test files to import these helpers
instead of redefining the contracts, ensuring the storage key enums
(MockIRSStorageKey, MockComplianceStorageKey) and trait impls keep the same
names and visibility so callers (set_identity/register_hook/arm_hooks) work
unchanged.
packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs (1)

84-88: Remove empty/zero entries instead of persisting them.

get_locks(), get_total_locked(), and get_internal_balance() already treat missing keys as empty/0. Persisting empty lock vectors and zero balances keeps dead per-wallet state around and needlessly extends TTL/rent. Dropping those keys when the value reaches empty/0 would preserve behavior and cap long-term storage growth.

Also applies to: 116-120, 148-152

🤖 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/storage.rs`
around lines 84 - 88, The current setters persist empty vectors/zero values
which keeps dead state and extends TTL; update the setter functions (e.g.,
set_locks, the setter handling InitialLockupStorageKey::TotalLocked, and the
setter for InitialLockupStorageKey::InternalBalance) to remove the storage key
when the value is empty or zero (call persistent().remove(&key)) and only call
persistent().set(&key, value) and extend_ttl when the value is
non-empty/non-zero so behavior of
get_locks/get_total_locked/get_internal_balance (which treat missing keys as
empty/0) is preserved while avoiding stale storage.
examples/rwa-time-transfers-limits/src/lib.rs (1)

63-71: Consider consolidating counter read/write operations.

The increase_counters function calls reset_counter_if_needed (which may write the counter), then immediately reads the counter again with get_counter. This results in redundant storage operations when the counter is reset.

However, this matches the pattern in the reference module implementation, so it's acceptable for this example contract.

🤖 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 63 - 71, The
current increase_counters implementation calls reset_counter_if_needed (which
may write a reset counter) and then immediately calls get_counter, causing
redundant storage reads/writes; refactor so you read/update/write the counter
only once by having reset_counter_if_needed return the current counter (or
create a new helper like get_or_reset_counter) and then update that returned
Counter and call set_counter once; update references in increase_counters to use
the returned Counter from reset_counter_if_needed (or the new helper) and remove
the extra get_counter call to eliminate the redundant operation.
examples/rwa-deploy/scripts/common.sh (3)

20-39: Consider strengthening the contract ID validation pattern.

The is_contract_id function uses a basic pattern match C[A-Z0-9]* which accepts any string starting with 'C' followed by uppercase alphanumeric characters. Stellar contract IDs are 56 characters long (C + 55 base32 characters).

For this deploy script context, the current validation is likely sufficient as it catches obvious errors.

🔧 Optional: More precise validation
 is_contract_id() {
-  case "$1" in
-    C[A-Z0-9]*)
-      return 0
-      ;;
-    *)
-      return 1
-      ;;
-  esac
+  local id=$1
+  if [[ ${`#id`} -eq 56 && $id =~ ^C[A-Z2-7]{55}$ ]]; then
+    return 0
+  fi
+  return 1
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/rwa-deploy/scripts/common.sh` around lines 20 - 39, The current
is_contract_id function accepts any string starting with 'C' plus uppercase
alphanumerics; tighten it to validate the full Stellar contract ID format by
matching 'C' followed by exactly 55 base32 characters (uppercase A-Z and 2-7)
e.g. use a pattern like ^C[A-Z2-7]{55}$ inside is_contract_id to enforce length
and allowed characters, leaving require_contract_id to call is_contract_id as
before; update any related tests or callers that assume the looser validation.

41-57: Inconsistent source flag usage between invoke and invoke_readonly.

invoke uses --source "$SOURCE" while invoke_readonly uses --source-account "$ADMIN". The Stellar CLI accepts both --source and --source-account as synonyms, so this works, but using consistent flag names would improve readability.

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

In `@examples/rwa-deploy/scripts/common.sh` around lines 41 - 57, The invoke and
invoke_readonly functions use inconsistent CLI flag names for the transaction
sender: update one to match the other for readability (either change invoke to
use --source-account "$SOURCE" or change invoke_readonly to use --source
"$ADMIN"); edit the invoke function (invoke) or invoke_readonly function
(invoke_readonly) so both use the same flag name (--source or --source-account)
while keeping their respective variables ($SOURCE and $ADMIN) unchanged.

86-88: Consider adding error handling for missing file or invalid JSON.

The read_addr function will fail silently or with a cryptic Python error if $ADDR_FILE doesn't exist or contains invalid JSON.

🔧 Optional: Add validation
 read_addr() {
+  if [ ! -f "$ADDR_FILE" ]; then
+    echo "ERROR: Address file not found: $ADDR_FILE" >&2
+    return 1
+  fi
   python3 -c "import json; d=json.load(open('$ADDR_FILE')); print(d$1)"
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/rwa-deploy/scripts/common.sh` around lines 86 - 88, The read_addr
function currently calls Python directly and will fail with cryptic errors if
$ADDR_FILE is missing or contains invalid JSON; update read_addr to validate
that ADDR_FILE exists and is readable (e.g., check [ -f "$ADDR_FILE" ] and
readable) before invoking Python, and change the Python invocation inside
read_addr to wrap json.load/open and d$1 access in a try/except that prints a
clear error to stderr and exits non‑zero on failure so callers can detect the
error; reference the read_addr function, the ADDR_FILE variable and the Python
expression d$1 when making these changes.
packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs (1)

38-88: Bound the lock list before it turns into a budget hotspot.

on_created appends one LockedTokens entry per mint, and can_transfer / update_locked_tokens scan the full vector. That makes transfer and burn cost proportional to the wallet’s mint history, so long-lived accounts can become expensive to use. Coalescing by release timestamp and pruning fully consumed/released entries would keep runtime and storage growth predictable.

Also applies to: 111-135, 206-224

🤖 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 38 - 88, The lock vector for a wallet grows unbounded because
on_created appends one LockedTokens per mint and functions like can_transfer,
calculate_unlocked_amount, calculate_total_locked_amount and
update_locked_tokens iterate the full Vec, making operations O(history) — fix by
coalescing and pruning: when adding a lock (on_created) merge into an existing
LockedTokens entry with the same release_timestamp (or nearest bucket) instead
of always push_back, and in update_locked_tokens/remove paths
(update_locked_tokens, can_transfer, and any setter/getter flows) drop entries
with zero amount or entries with release_timestamp <= now after
consuming/releasing them; additionally enforce a reasonable max buckets per
(token,wallet) and read/update locks via a helper (e.g., consolidate_locks or
normalize_locks) to ensure the stored Vec remains bounded and each scan
processes at most that cap.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/rwa-deploy/scripts/build.sh`:
- Around line 47-55: The loop that iterates over ALL and prints warnings when
WASM files under WASM_DIR are missing should instead cause the script to fail;
modify the for-loop that sets WASM_NAME and checks -f to record missing
artifacts (e.g., set a flag or increment a counter when the else branch runs)
and after the loop exit with a non-zero status (exit 1) if any missing were
detected; update the echo in the else branch to remain informative but ensure
the script returns failure by using the recorded flag/counter rather than
leaving the current warning-only behavior.

In `@packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs`:
- Around line 62-65: The setter set_identity_registry_storage should reject
changing the IRS once per-identity balances exist: before calling
set_irs_address, check the IDBalance storage (e.g., iterate or query IDBalance
entries) and revert if any non-zero/any rows exist (so registry swaps are
blocked after balances are seeded); keep the existing require_auth check. Also
add the identical guard to the example contract's equivalent setter used in
pre_set_module_state so both places prevent registry swaps after balances exist.

In `@packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs`:
- Around line 158-177: The removal logic in remove_time_transfer_limit leaves
per-(token,identity,limit_time) counters in storage, allowing old counters to
incorrectly influence future windows; update remove_time_transfer_limit to also
invalidate or version associated counters when a limit is removed (or increment
a window/epoch stored with the limit). Specifically, when removing the limit
from get_limits / set_limits, either (a) delete or mark expired all counters
keyed by (token, limit_time) (and identity) or (b) add/modify a version/epoch
field on the TimeTransferLimit struct and persist that change so can_transfer
(and the counter key builder) uses the version/epoch when reading/updating
counters; change the counter key construction used in can_transfer to include
this version/epoch so re-adding a limit starts fresh.
- Around line 119-122: The set_identity_registry_storage function currently
allows swapping the IRS after counters exist; change it to make the IRS binding
immutable once counters have begun accruing by rejecting updates when any
counter exists for that token. In set_identity_registry_storage, before calling
set_irs_address, check the counters storage (via get_counter or the underlying
counters map) for the given token to detect any non-zero/initialized window
entries (or alternatively add and check an initialized flag per token when the
first counter is created); if any counters are present, return/reject the change
(require_auth remains) and only allow set_irs_address when no counters exist
yet. Ensure you reference get_counter, set_identity_registry_storage,
set_irs_address and get_compliance_address when making the change.

---

Nitpick comments:
In `@examples/rwa-country-allow/README.md`:
- Around line 15-27: Add a short, explicit disclaimer to the Authorization model
section stating this is an example-only simplified pattern and not
production-ready for hardening; mention the constructor’s one-time admin, the
one-time set_compliance_address flow and reliance on the bound Compliance
contract are intentionally simplified, and refer readers to the
examples/rwa-compliance folder for other test-only contracts and to consult
production security guidance before reuse.

In `@examples/rwa-country-allow/src/lib.rs`:
- Line 3: The import list includes an unused symbol `String` from soroban_sdk;
remove `String` from the use statement that declares contract, contractimpl,
contracttype, Address, Env, String, Vec so it becomes contract, contractimpl,
contracttype, Address, Env, Vec to eliminate the unused import and clean up
warnings.

In `@examples/rwa-deploy/scripts/common.sh`:
- Around line 20-39: The current is_contract_id function accepts any string
starting with 'C' plus uppercase alphanumerics; tighten it to validate the full
Stellar contract ID format by matching 'C' followed by exactly 55 base32
characters (uppercase A-Z and 2-7) e.g. use a pattern like ^C[A-Z2-7]{55}$
inside is_contract_id to enforce length and allowed characters, leaving
require_contract_id to call is_contract_id as before; update any related tests
or callers that assume the looser validation.
- Around line 41-57: The invoke and invoke_readonly functions use inconsistent
CLI flag names for the transaction sender: update one to match the other for
readability (either change invoke to use --source-account "$SOURCE" or change
invoke_readonly to use --source "$ADMIN"); edit the invoke function (invoke) or
invoke_readonly function (invoke_readonly) so both use the same flag name
(--source or --source-account) while keeping their respective variables ($SOURCE
and $ADMIN) unchanged.
- Around line 86-88: The read_addr function currently calls Python directly and
will fail with cryptic errors if $ADDR_FILE is missing or contains invalid JSON;
update read_addr to validate that ADDR_FILE exists and is readable (e.g., check
[ -f "$ADDR_FILE" ] and readable) before invoking Python, and change the Python
invocation inside read_addr to wrap json.load/open and d$1 access in a
try/except that prints a clear error to stderr and exits non‑zero on failure so
callers can detect the error; reference the read_addr function, the ADDR_FILE
variable and the Python expression d$1 when making these changes.

In `@examples/rwa-deploy/scripts/e2e.sh`:
- Around line 21-22: The shellcheck warning about NETWORK being unused is a
false positive because NETWORK (set from STELLAR_NETWORK) is consumed by sourced
helper functions in common.sh (e.g., invoke(), invoke_readonly()); fix by adding
a shellcheck directive above the assignment to silence SC2034 (e.g., add a
comment like "# shellcheck disable=SC2034  # Used by sourced common.sh") so the
NETWORK="${STELLAR_NETWORK:-testnet}" line remains but the linter is suppressed
with clear justification referencing common.sh and the
invoke()/invoke_readonly() helpers.
- Line 346: The fixed-width printf line using PASS, FAIL, TOTAL can misalign for
multi-digit counts; update the printing to compute the needed field width
dynamically (e.g., determine max length among PASS/FAIL/TOTAL using string
length or fmt_width=$(printf "%s\n%s\n%s" "$PASS" "$FAIL" "$TOTAL" | awk '{ if
(length>m) m=length } END{print m }')) and then use a dynamic width specifier in
the printf call (use "%*d" with the computed width) when printing the summary so
the box stays aligned regardless of digit count; locate and replace the existing
printf invocation that prints PASS/FAIL/TOTAL.

In `@examples/rwa-deploy/scripts/test-happy-path.sh`:
- Around line 36-61: The invoke_with_retry implementation in test-happy-path.sh
duplicatesretry logic; extract and consolidate it into the shared helper file
(common.sh) by moving the invoke_with_retry function there (including its use of
STELLAR_INVOKE_RETRIES, STELLAR_INVOKE_RETRY_DELAY_SECONDS and
retryable_invoke_error), then remove the duplicate from test-happy-path.sh and
source common.sh at the top so the script calls the centralized
invoke_with_retry; ensure retryable_invoke_error is exported or present in
common.sh and preserve the same return codes and backoff behavior.

In `@examples/rwa-deploy/scripts/wire.sh`:
- Around line 19-29: ShellCheck reports unused-variable warnings for SOURCE,
NETWORK, and ADMIN even though they are consumed by functions in the sourced
common.sh; add a ShellCheck suppression comment above each variable definition
(e.g., add "# shellcheck disable=SC2034  # used by sourced common.sh"
immediately before SOURCE="${STELLAR_SOURCE:-alice}", similarly for NETWORK and
ADMIN) so the linter knows these vars are intentionally exported for use by
functions like invoke(), ensure_hook_registration(), and read_addr() in
common.sh.

In `@examples/rwa-time-transfers-limits/src/lib.rs`:
- Around line 63-71: The current increase_counters implementation calls
reset_counter_if_needed (which may write a reset counter) and then immediately
calls get_counter, causing redundant storage reads/writes; refactor so you
read/update/write the counter only once by having reset_counter_if_needed return
the current counter (or create a new helper like get_or_reset_counter) and then
update that returned Counter and call set_counter once; update references in
increase_counters to use the returned Counter from reset_counter_if_needed (or
the new helper) and remove the extra get_counter call to eliminate the redundant
operation.

In `@examples/rwa-transfer-restrict/src/lib.rs`:
- Line 3: Remove the unused import String from the top-level use statement in
lib.rs: update the import line that currently lists soroban_sdk::{contract,
contractimpl, contracttype, Address, Env, String, Vec} to omit String so only
used symbols remain (contract, contractimpl, contracttype, Address, Env, Vec);
ensure no other references to String exist in functions or types such as those
implemented under contract/contractimpl before committing.

In `@packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs`:
- Around line 38-88: The lock vector for a wallet grows unbounded because
on_created appends one LockedTokens per mint and functions like can_transfer,
calculate_unlocked_amount, calculate_total_locked_amount and
update_locked_tokens iterate the full Vec, making operations O(history) — fix by
coalescing and pruning: when adding a lock (on_created) merge into an existing
LockedTokens entry with the same release_timestamp (or nearest bucket) instead
of always push_back, and in update_locked_tokens/remove paths
(update_locked_tokens, can_transfer, and any setter/getter flows) drop entries
with zero amount or entries with release_timestamp <= now after
consuming/releasing them; additionally enforce a reasonable max buckets per
(token,wallet) and read/update locks via a helper (e.g., consolidate_locks or
normalize_locks) to ensure the stored Vec remains bounded and each scan
processes at most that cap.

In `@packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs`:
- Around line 84-88: The current setters persist empty vectors/zero values which
keeps dead state and extends TTL; update the setter functions (e.g., set_locks,
the setter handling InitialLockupStorageKey::TotalLocked, and the setter for
InitialLockupStorageKey::InternalBalance) to remove the storage key when the
value is empty or zero (call persistent().remove(&key)) and only call
persistent().set(&key, value) and extend_ttl when the value is
non-empty/non-zero so behavior of
get_locks/get_total_locked/get_internal_balance (which treat missing keys as
empty/0) is preserved while avoiding stale storage.

In `@packages/tokens/src/rwa/compliance/modules/max_balance/test.rs`:
- Around line 22-208: Extract the repeated Soroban test mocks into a shared test
helper module: move MockIRSContract, MockComplianceContract, and arm_hooks()
into a common test-utils module and re-export their key helper methods (e.g.,
MockIRSContract::set_identity, MockComplianceContract::register_hook, and
arm_hooks) so existing tests can call the same symbols; update the tests in
TestMaxBalanceContract and the other two test files to import these helpers
instead of redefining the contracts, ensuring the storage key enums
(MockIRSStorageKey, MockComplianceStorageKey) and trait impls keep the same
names and visibility so callers (set_identity/register_hook/arm_hooks) work
unchanged.
🪄 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: 67986632-1a53-40a0-91a1-ac25e4964d5c

📥 Commits

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

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (60)
  • .gitignore
  • Cargo.toml
  • examples/rwa-country-allow/Cargo.toml
  • examples/rwa-country-allow/README.md
  • examples/rwa-country-allow/src/lib.rs
  • examples/rwa-country-restrict/Cargo.toml
  • examples/rwa-country-restrict/README.md
  • examples/rwa-country-restrict/src/lib.rs
  • examples/rwa-deploy/README.md
  • examples/rwa-deploy/compliance/Cargo.toml
  • examples/rwa-deploy/compliance/src/lib.rs
  • examples/rwa-deploy/irs/Cargo.toml
  • examples/rwa-deploy/irs/src/lib.rs
  • examples/rwa-deploy/scripts/build-module.sh
  • examples/rwa-deploy/scripts/build.sh
  • examples/rwa-deploy/scripts/common.sh
  • examples/rwa-deploy/scripts/deploy-module.sh
  • examples/rwa-deploy/scripts/deploy.sh
  • examples/rwa-deploy/scripts/e2e.sh
  • examples/rwa-deploy/scripts/test-happy-path.sh
  • examples/rwa-deploy/scripts/wire.sh
  • examples/rwa-deploy/token/Cargo.toml
  • examples/rwa-deploy/token/src/lib.rs
  • examples/rwa-deploy/verifier/Cargo.toml
  • examples/rwa-deploy/verifier/src/lib.rs
  • examples/rwa-initial-lockup-period/Cargo.toml
  • examples/rwa-initial-lockup-period/README.md
  • examples/rwa-initial-lockup-period/src/lib.rs
  • examples/rwa-max-balance/Cargo.toml
  • examples/rwa-max-balance/README.md
  • examples/rwa-max-balance/src/lib.rs
  • examples/rwa-supply-limit/Cargo.toml
  • examples/rwa-supply-limit/README.md
  • examples/rwa-supply-limit/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/country_allow/mod.rs
  • packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs
  • packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs
  • packages/tokens/src/rwa/compliance/modules/country_restrict/storage.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/max_balance/mod.rs
  • packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs
  • packages/tokens/src/rwa/compliance/modules/max_balance/test.rs
  • packages/tokens/src/rwa/compliance/modules/mod.rs
  • packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs
  • packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs
  • packages/tokens/src/rwa/compliance/modules/supply_limit/test.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 +47 to +55
for pkg in "${ALL[@]}"; do
WASM_NAME="${pkg//-/_}.wasm"
if [ -f "$WASM_DIR/$WASM_NAME" ]; then
SIZE=$(wc -c < "$WASM_DIR/$WASM_NAME" | tr -d ' ')
echo " $WASM_NAME (${SIZE} bytes)"
else
echo " WARNING: $WASM_NAME not found!"
fi
done
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

Fail the script when expected WASM artifacts are missing.
On Line 53, missing outputs are logged as warnings, but the script still exits successfully. That can mask broken builds and cause harder-to-debug downstream deploy failures.

Suggested fix
 echo ""
 echo "=== WASM sizes ==="
+missing=0
 for pkg in "${ALL[@]}"; do
   WASM_NAME="${pkg//-/_}.wasm"
   if [ -f "$WASM_DIR/$WASM_NAME" ]; then
     SIZE=$(wc -c < "$WASM_DIR/$WASM_NAME" | tr -d ' ')
     echo "  $WASM_NAME (${SIZE} bytes)"
   else
     echo "  WARNING: $WASM_NAME not found!"
+    missing=1
   fi
 done
 
+if [ "$missing" -ne 0 ]; then
+  echo "ERROR: One or more expected WASM artifacts are missing." >&2
+  exit 1
+fi
+
 echo ""
 echo "=== All WASMs built to examples/rwa-deploy/wasm/ ==="
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/rwa-deploy/scripts/build.sh` around lines 47 - 55, The loop that
iterates over ALL and prints warnings when WASM files under WASM_DIR are missing
should instead cause the script to fail; modify the for-loop that sets WASM_NAME
and checks -f to record missing artifacts (e.g., set a flag or increment a
counter when the else branch runs) and after the loop exit with a non-zero
status (exit 1) if any missing were detected; update the echo in the else branch
to remain informative but ensure the script returns failure by using the
recorded flag/counter rather than leaving the current warning-only behavior.

Comment on lines +62 to +65
fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
get_compliance_address(e).require_auth();
set_irs_address(e, &token, &irs);
}
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

Freeze the IRS binding once balances exist.

IDBalance is persisted by identity, so swapping the registry contract after pre_set_module_state or any hook update changes the key space without migrating those rows. Existing holders can then look like they have a zero tracked balance under the new registry, which lets incoming transfers/mints bypass the cap until state is rebuilt and can make later debits underflow. Please reject registry changes once balances have been seeded. The standalone example contract mirrors this setter at Lines 53-56 in examples/rwa-max-balance/src/lib.rs, so it needs the same guard.

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

In `@packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs` around lines
62 - 65, The setter set_identity_registry_storage should reject changing the IRS
once per-identity balances exist: before calling set_irs_address, check the
IDBalance storage (e.g., iterate or query IDBalance entries) and revert if any
non-zero/any rows exist (so registry swaps are blocked after balances are
seeded); keep the existing require_auth check. Also add the identical guard to
the example contract's equivalent setter used in pre_set_module_state so both
places prevent registry swaps after balances exist.

Comment on lines +119 to +122
fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
get_compliance_address(e).require_auth();
set_irs_address(e, &token, &irs);
}
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

Freeze the IRS binding once counters have started accruing.

get_counter keys state by (token, identity, limit_time), so changing the registry contract after counters exist makes all active windows invisible under the new identity mapping. The sender effectively starts from zero again, bypassing the transfer-volume limits until every counter is reseeded. Please make this binding one-way or reject changes after the module has been initialized.

🤖 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 119 - 122, The set_identity_registry_storage function currently
allows swapping the IRS after counters exist; change it to make the IRS binding
immutable once counters have begun accruing by rejecting updates when any
counter exists for that token. In set_identity_registry_storage, before calling
set_irs_address, check the counters storage (via get_counter or the underlying
counters map) for the given token to detect any non-zero/initialized window
entries (or alternatively add and check an initialized flag per token when the
first counter is created); if any counters are present, return/reject the change
(require_auth remains) and only allow set_irs_address when no counters exist
yet. Ensure you reference get_counter, set_identity_registry_storage,
set_irs_address and get_compliance_address when making the change.

Comment on lines +158 to +177
fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) {
get_compliance_address(e).require_auth();
let mut limits = get_limits(e, &token);

let mut found = false;
for i in 0..limits.len() {
let current = limits.get(i).expect("limit exists");
if current.limit_time == limit_time {
limits.remove(i);
found = true;
break;
}
}

if !found {
panic_with_error!(e, ComplianceModuleError::MissingLimit);
}

set_limits(e, &token, &limits);
TimeTransferLimitRemoved { token, limit_time }.publish(e);
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

Removing a limit leaves stale counters behind.

remove_time_transfer_limit drops the config but keeps every (token, identity, limit_time) counter in storage. If the same limit_time is added again before those timers expire, can_transfer resumes from pre-removal volume while ignoring transfers made during the disabled interval, so enforcement becomes inconsistent across config changes. Consider versioning the counter key or bumping an epoch whenever a window is removed/recreated.

🤖 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 158 - 177, The removal logic in remove_time_transfer_limit leaves
per-(token,identity,limit_time) counters in storage, allowing old counters to
incorrectly influence future windows; update remove_time_transfer_limit to also
invalidate or version associated counters when a limit is removed (or increment
a window/epoch stored with the limit). Specifically, when removing the limit
from get_limits / set_limits, either (a) delete or mark expired all counters
keyed by (token, limit_time) (and identity) or (b) add/modify a version/epoch
field on the TimeTransferLimit struct and persist that change so can_transfer
(and the counter key builder) uses the version/epoch when reading/updating
counters; change the counter key construction used in can_transfer to include
this version/epoch so re-adding a limit starts fresh.

Keep the retrying invoke helper in common.sh, remove script-local duplicates,
and use a neutral retry message across the deploy scripts.
@pasevin pasevin marked this pull request as draft March 23, 2026 19:30
@pasevin
Copy link
Copy Markdown
Contributor Author

pasevin commented Mar 23, 2026

Marking this draft for now. This PR is intentionally self-contained against current main, which makes the diff larger than ideal. The plan is to merge the module PRs first, then re-spin this as a slimmer follow-up on top of updated main so review can focus only on the deploy and e2e layer.

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