From 3ed16da4376922d01b995d0a191953fedfb2c005 Mon Sep 17 00:00:00 2001 From: penDerGraft Date: Tue, 18 Aug 2026 14:01:09 -0500 Subject: [PATCH 1/3] convert test-stablecoin to SAC contract --- soroban/Cargo.lock | 30 -- soroban/scripts/deploy.sh | 171 ++++--- soroban/test-stablecoin/Cargo.toml | 4 +- soroban/test-stablecoin/README.md | 77 +++ soroban/test-stablecoin/src/lib.rs | 771 ++++++++++++++++++++--------- 5 files changed, 709 insertions(+), 344 deletions(-) create mode 100644 soroban/test-stablecoin/README.md diff --git a/soroban/Cargo.lock b/soroban/Cargo.lock index 496c9fb8..0255cd38 100644 --- a/soroban/Cargo.lock +++ b/soroban/Cargo.lock @@ -1525,24 +1525,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "stellar-access" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "974e461afe1538ee54dc9314ed46144f393265c7625606c240760cd6a2baaadf" -dependencies = [ - "soroban-sdk", -] - -[[package]] -name = "stellar-contract-utils" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a918daa29a4cfc0fe97118a2425893a0336a33b436e8f70ca4b4f5c739f23e4" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "stellar-strkey" version = "0.0.13" @@ -1564,16 +1546,6 @@ dependencies = [ "heapless", ] -[[package]] -name = "stellar-tokens" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc8be39a374a3520726017623d7c79d7d0530e626778619ba8f9462037e37aa" -dependencies = [ - "soroban-sdk", - "stellar-contract-utils", -] - [[package]] name = "stellar-xdr" version = "23.0.0" @@ -1632,8 +1604,6 @@ name = "test-stablecoin" version = "0.1.0" dependencies = [ "soroban-sdk", - "stellar-access", - "stellar-tokens", ] [[package]] diff --git a/soroban/scripts/deploy.sh b/soroban/scripts/deploy.sh index f42a8655..fbc5db7b 100755 --- a/soroban/scripts/deploy.sh +++ b/soroban/scripts/deploy.sh @@ -1,103 +1,118 @@ #!/usr/bin/env bash set -euo pipefail -# Deploy TestStablecoin to Stellar testnet -# -# Prerequisites: -# - stellar CLI installed (https://developers.stellar.org/docs/tools/developer-tools/cli/install-cli) -# - An identity configured: stellar keys generate --network testnet -# or import existing: stellar keys add --secret-key +# TEST ONLY: deploy a private classic asset, its SAC, and the minimal test +# administration wrapper. This script is not suitable for production assets. # # Usage: -# ./deploy.sh [admin_address] [manager_address] [blocker_address] +# STELLAR_NETWORK=testnet ./deploy.sh [deployer-key] \ +# [minter-address] [onboarder-address] [blocker-address] [unblocker-address] +# +# Defaults: +# - deployer-key defaults to issuer-key +# - every role defaults to the issuer's public address +# - ASSET_CODE defaults to TSTUSD # -# Examples: -# ./deploy.sh alice # alice is admin, manager, and blocker -# ./deploy.sh alice GA...ADMIN GA...MANAGER GA...BLOCKER # separate roles +# Mainnet requires: +# STELLAR_NETWORK=mainnet \ +# ALLOW_MAINNET_TEST_DEPLOY=I_UNDERSTAND_TEST_ONLY \ +# ./deploy.sh ... SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SOROBAN_DIR="$(dirname "$SCRIPT_DIR")" -NETWORK="testnet" -TOKEN_NAME="Test USD" -TOKEN_SYMBOL="TUSD" -INITIAL_SUPPLY="1000000000" # 1000 tokens with 6 decimals - -# --- Parse args --- - -if [ $# -lt 1 ]; then - echo "Usage: $0 [admin_address] [manager_address] [blocker_address]" - echo "" - echo " identity Stellar CLI identity name (from 'stellar keys')" - echo " admin_address Admin address (defaults to identity's address)" - echo " manager_address Manager/compliance address (defaults to admin)" - echo " blocker_address Blocker/freeze address (defaults to admin)" +if [[ $# -lt 1 || $# -gt 6 ]]; then + echo "Usage: $0 [deployer-key] [minter-address] [onboarder-address] [blocker-address] [unblocker-address]" >&2 exit 1 fi -IDENTITY="$1" -ADMIN_ADDRESS="${2:-}" -MANAGER_ADDRESS="${3:-}" -BLOCKER_ADDRESS="${4:-}" +ISSUER_KEY="$1" +DEPLOYER_KEY="${2:-$ISSUER_KEY}" +NETWORK="${STELLAR_NETWORK:-testnet}" +ASSET_CODE="${ASSET_CODE:-TSTUSD}" +ISSUER_ADDRESS="$(stellar keys address "$ISSUER_KEY")" -# Resolve addresses from identity if not provided -if [ -z "$ADMIN_ADDRESS" ]; then - ADMIN_ADDRESS=$(stellar keys address "$IDENTITY") - echo "Using identity address as admin: $ADMIN_ADDRESS" -fi +MINTER_ADDRESS="${3:-$ISSUER_ADDRESS}" +ONBOARDER_ADDRESS="${4:-$ISSUER_ADDRESS}" +BLOCKER_ADDRESS="${5:-$ISSUER_ADDRESS}" +UNBLOCKER_ADDRESS="${6:-$ISSUER_ADDRESS}" -if [ -z "$MANAGER_ADDRESS" ]; then - MANAGER_ADDRESS="$ADMIN_ADDRESS" - echo "Using admin address as manager: $MANAGER_ADDRESS" +if [[ "$NETWORK" == "mainnet" && "${ALLOW_MAINNET_TEST_DEPLOY:-}" != "I_UNDERSTAND_TEST_ONLY" ]]; then + echo "Refusing mainnet deployment without ALLOW_MAINNET_TEST_DEPLOY=I_UNDERSTAND_TEST_ONLY" >&2 + exit 1 fi -if [ -z "$BLOCKER_ADDRESS" ]; then - BLOCKER_ADDRESS="$ADMIN_ADDRESS" - echo "Using admin address as blocker: $BLOCKER_ADDRESS" +if [[ ! "$ASSET_CODE" =~ ^[A-Z0-9]{1,12}$ ]]; then + echo "ASSET_CODE must contain 1-12 uppercase letters or digits" >&2 + exit 1 fi -# --- Build --- +echo "WARNING: deploying an unaudited TEST-ONLY contract." +echo "Network: $NETWORK" +echo "Asset: $ASSET_CODE:$ISSUER_ADDRESS" +echo -echo "" -echo "Building test-stablecoin..." -cd "$SOROBAN_DIR" -cargo build --release --target wasm32-unknown-unknown - -echo "Optimizing WASM for Soroban VM..." -stellar contract optimize --wasm "$SOROBAN_DIR/target/wasm32-unknown-unknown/release/test_stablecoin.wasm" -WASM_PATH="$SOROBAN_DIR/target/wasm32-unknown-unknown/release/test_stablecoin.optimized.wasm" - -if [ ! -f "$WASM_PATH" ]; then - echo "ERROR: Optimized WASM not found at $WASM_PATH" +echo "[1/5] Setting issuer authorization flags..." +stellar tx new set-options \ + --source-account "$ISSUER_KEY" \ + --network "$NETWORK" \ + --set-required \ + --set-revocable \ + --set-clawback-enabled + +echo "[2/5] Deploying the Stellar Asset Contract..." +SAC_CONTRACT_ID="$( + stellar contract asset deploy \ + --source-account "$DEPLOYER_KEY" \ + --network "$NETWORK" \ + --asset "$ASSET_CODE:$ISSUER_ADDRESS" | + tr -d '\r\n' +)" + +echo "[3/5] Building and optimizing the test wrapper..." +( + cd "$SOROBAN_DIR" + cargo build --release --target wasm32-unknown-unknown --package test-stablecoin +) +RAW_WASM="$SOROBAN_DIR/target/wasm32-unknown-unknown/release/test_stablecoin.wasm" +OPTIMIZED_WASM="$SOROBAN_DIR/target/wasm32-unknown-unknown/release/test_stablecoin.optimized.wasm" +stellar contract optimize --wasm "$RAW_WASM" + +if [[ ! -f "$OPTIMIZED_WASM" ]]; then + echo "Optimized WASM not found at $OPTIMIZED_WASM" >&2 exit 1 fi -echo "WASM size: $(wc -c < "$WASM_PATH" | tr -d ' ') bytes (optimized)" - -# --- Deploy + Initialize --- - -echo "" -echo "Deploying to $NETWORK (name=$TOKEN_NAME, symbol=$TOKEN_SYMBOL, supply=$INITIAL_SUPPLY)..." -CONTRACT_ID=$(stellar contract deploy \ - --wasm "$WASM_PATH" \ - --source "$IDENTITY" \ +echo "[4/5] Deploying the test administration wrapper..." +WRAPPER_CONTRACT_ID="$( + stellar contract deploy \ + --source-account "$DEPLOYER_KEY" \ + --network "$NETWORK" \ + --wasm "$OPTIMIZED_WASM" \ + -- \ + --sac_token "$SAC_CONTRACT_ID" \ + --minter "$MINTER_ADDRESS" \ + --onboarder "$ONBOARDER_ADDRESS" \ + --block_operator "$BLOCKER_ADDRESS" \ + --unblock_operator "$UNBLOCKER_ADDRESS" | + tr -d '\r\n' +)" + +echo "[5/5] Transferring SAC administration to the wrapper..." +stellar contract invoke \ + --source-account "$ISSUER_KEY" \ --network "$NETWORK" \ + --id "$SAC_CONTRACT_ID" \ -- \ - --name "\"${TOKEN_NAME}\"" \ - --symbol "\"${TOKEN_SYMBOL}\"" \ - --admin "$ADMIN_ADDRESS" \ - --manager "$MANAGER_ADDRESS" \ - --blocker "$BLOCKER_ADDRESS" \ - --initial_supply "$INITIAL_SUPPLY") - -echo "Contract deployed: $CONTRACT_ID" - -echo "" -echo "=== Deployment complete ===" -echo " Network: $NETWORK" -echo " Contract: $CONTRACT_ID" -echo " Admin: $ADMIN_ADDRESS" -echo " Manager: $MANAGER_ADDRESS" -echo " Blocker: $BLOCKER_ADDRESS" -echo " Token: $TOKEN_NAME ($TOKEN_SYMBOL)" -echo " Supply: $INITIAL_SUPPLY (6 decimals = 1,000 tokens)" + set_admin --new_admin "$WRAPPER_CONTRACT_ID" + +echo +echo "Test deployment complete" +echo " Network: $NETWORK" +echo " Asset: $ASSET_CODE:$ISSUER_ADDRESS" +echo " SAC: $SAC_CONTRACT_ID" +echo " Wrapper: $WRAPPER_CONTRACT_ID" +echo " Minter: $MINTER_ADDRESS" +echo " Onboarder: $ONBOARDER_ADDRESS" +echo " Block operator: $BLOCKER_ADDRESS" +echo " Unblock operator: $UNBLOCKER_ADDRESS" diff --git a/soroban/test-stablecoin/Cargo.toml b/soroban/test-stablecoin/Cargo.toml index 40077ffa..37baa4db 100644 --- a/soroban/test-stablecoin/Cargo.toml +++ b/soroban/test-stablecoin/Cargo.toml @@ -2,16 +2,14 @@ name = "test-stablecoin" version = "0.1.0" edition = "2021" +description = "TEST ONLY: minimal SAC onboarding and block-list integration harness" [lib] crate-type = ["cdylib"] doctest = false [dependencies] -# Pinned to 23.5.3 for compatibility with OpenZeppelin Stellar Contracts 0.6.0 soroban-sdk = "23.5.3" -stellar-tokens = "0.6.0" -stellar-access = "0.6.0" [dev-dependencies] soroban-sdk = { version = "23.5.3", features = ["testutils"] } diff --git a/soroban/test-stablecoin/README.md b/soroban/test-stablecoin/README.md new file mode 100644 index 00000000..d95c9312 --- /dev/null +++ b/soroban/test-stablecoin/README.md @@ -0,0 +1,77 @@ +# Test SAC Admin Contract + +> TEST ONLY. This contract is unaudited, intentionally incomplete, and not +> production-ready. Do not use it to administer assets of value. + +This package is a small Soroban integration harness for testing a private +Stellar asset. It administers a Stellar Asset Contract (SAC) whose issuer has +`AUTH_REQUIRED`, `AUTH_REVOCABLE`, and `AUTH_CLAWBACK_ENABLED` set. + +It is an independent test implementation built against Stellar's public SAC +interfaces. It is not affiliated with, endorsed by, or a production substitute +for M0, MoneyGram, MGUSD, or their contracts. + +## State model + +The contract intentionally keeps three related states: + +- `is_onboarded(address)` is permanent onboarding history. +- `is_on_block_list(address)` is the current compliance hold. +- `blocked(address)` is the inverse of the SAC's current authorization flag. + +These states can differ. In particular: + +- A fresh trustline is not onboarded, not block-listed, and SAC-blocked. +- Blocking before onboarding and then unblocking clears the hold but does not + authorize the address. +- Onboarding, blocking, and then unblocking restores authorization because the + onboarding record persists. +- Deleting and recreating a trustline resets SAC authorization. Calling + `onboard_user` again reauthorizes it without emitting a second onboarding + event. + +## Public test API + +- `onboard_user(user, operator)` +- `block_user(user, operator)` +- `unblock_user(user, operator)` +- `mint(caller, to, amount)` +- `is_onboarded(account)` +- `is_on_block_list(account)` +- `blocked(account)` +- `balance(account)` +- `sac_token()` + +The onboarder, block operator, unblock operator, and minter are fixed at +deployment. One wallet may hold every role. + +Transfers are made directly through the SAC. This wrapper intentionally does +not proxy the SEP-41 transfer interface. + +## Deliberate omissions + +The contract has no batch operations, role rotation, burn endpoint, forced +transfer, pause, upgrade, yield, supply accounting, or issuer-renunciation +logic. Those omissions keep the artifact focused on onboarding and compliance +state permutations. + +## Deployment + +Use `../scripts/deploy.sh`. The script: + +1. Sets the required issuer flags. +2. Deploys the SAC for the configured classic asset. +3. Builds and deploys this wrapper. +4. Transfers SAC administration to the wrapper. + +The script defaults all identities and roles to one wallet. Mainnet execution +requires an explicit `ALLOW_MAINNET_TEST_DEPLOY=I_UNDERSTAND_TEST_ONLY` +acknowledgement. + +## License + +This package is part of the Predicate contracts repository and is distributed +under the repository's MIT license. Functional behavior was independently +implemented from public Stellar interfaces. Similarity in behavior to another +system does not establish legal clearance; consult counsel if licensing risk is +material to deployment or distribution. diff --git a/soroban/test-stablecoin/src/lib.rs b/soroban/test-stablecoin/src/lib.rs index 4b0e61b3..95c85d1a 100644 --- a/soroban/test-stablecoin/src/lib.rs +++ b/soroban/test-stablecoin/src/lib.rs @@ -1,342 +1,647 @@ #![no_std] +//! Test-only SAC administration contract for onboarding and compliance holds. +//! +//! This crate is intentionally limited to integration testing. It is unaudited, +//! is not production-ready, and must not be used to administer assets of value. -// MuxedAddress, Symbol, Vec are used by OZ's #[contractimpl(contracttrait)] macro expansions use soroban_sdk::{ - contract, contracterror, contractimpl, symbol_short, Address, Env, MuxedAddress, String, - Symbol, Vec, -}; -use stellar_access::access_control::{self as access_control, AccessControl}; -use stellar_tokens::fungible::{ - blocklist::{BlockList, FungibleBlockList}, - burnable::FungibleBurnable, - Base, FungibleToken, + contract, contracterror, contractevent, contractimpl, contracttype, panic_with_error, token, + Address, Env, }; +const DAY_IN_LEDGERS: u32 = 17_280; +const INSTANCE_BUMP_AMOUNT: u32 = 30 * DAY_IN_LEDGERS; +const INSTANCE_LIFETIME_THRESHOLD: u32 = INSTANCE_BUMP_AMOUNT - DAY_IN_LEDGERS; +const PERSISTENT_BUMP_AMOUNT: u32 = 365 * DAY_IN_LEDGERS; +const PERSISTENT_LIFETIME_THRESHOLD: u32 = PERSISTENT_BUMP_AMOUNT - 30 * DAY_IN_LEDGERS; + #[contract] -pub struct TestStablecoinContract; +pub struct TestSacAdminContract; +/// Errors returned by the test SAC administrator. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] -pub enum TestStablecoinError { - Unauthorized = 1, +pub enum TestSacAdminError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + InvalidAmount = 4, + UserBlocked = 5, + DestinationNotAuthorized = 6, } -#[contractimpl] -impl TestStablecoinContract { - /// Deploy the stablecoin and mint the initial supply to `admin`. - /// - /// # Arguments - /// - /// * `name` - Token display name (e.g. "USD Stablecoin"). - /// * `symbol` - Ticker symbol (e.g. "USDC"). 3-7 characters recommended. - /// * `admin` - Address with role-admin privileges. Holds the initial - /// supply and can grant or revoke any role. - /// * `manager` - Address granted the `manager` role at deploy time. Can - /// be the same as `admin` for simple deployments. - /// * `blocker` - Address granted the `blocker` role at deploy time. - /// Required to call `block_user` and `unblock_user`. - /// * `initial_supply` - Tokens minted to `admin` at deploy, in base - /// units. The token uses 6 decimals, so 1 token = 1_000_000. - pub fn __constructor( - e: &Env, - name: String, - symbol: String, - admin: Address, - manager: Address, - blocker: Address, - initial_supply: i128, - ) { - Base::set_metadata(e, 6, name, symbol); - access_control::set_admin(e, &admin); - access_control::grant_role_no_auth(e, &manager, &symbol_short!("manager"), &admin); - access_control::grant_role_no_auth(e, &blocker, &symbol_short!("blocker"), &admin); - Base::mint(e, &admin, initial_supply); - } +#[derive(Clone)] +#[contracttype] +enum DataKey { + SacToken, + Minter, + Onboarder, + BlockOperator, + UnblockOperator, + Onboarded(Address), + BlockListed(Address), +} - pub fn mint(e: &Env, to: Address, amount: i128) { - access_control::enforce_admin_auth(e); - Base::mint(e, &to, amount); - } +/// Emitted the first time an address is approved by the onboarder. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserOnboarded { + #[topic] + pub user: Address, } -#[contractimpl(contracttrait)] -impl FungibleToken for TestStablecoinContract { - type ContractType = BlockList; +/// Emitted when an address is placed on the compliance block list. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserBlocked { + #[topic] + pub user: Address, } -// Implement FungibleBlockList methods as contract functions. -// FungibleBlockList trait does not have #[contracttrait], so we implement -// the trait for access and expose the methods via a separate #[contractimpl]. -impl FungibleBlockList for TestStablecoinContract { - fn blocked(e: &Env, account: Address) -> bool { - BlockList::blocked(e, &account) +/// Emitted when an address is removed from the compliance block list. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserUnblocked { + #[topic] + pub user: Address, +} + +fn extend_instance_ttl(e: &Env) { + e.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); +} + +fn read_instance_address(e: &Env, key: &DataKey) -> Address { + match e.storage().instance().get(key) { + Some(address) => address, + None => panic_with_error!(e, TestSacAdminError::NotInitialized), } +} - fn block_user(e: &Env, user: Address, operator: Address) { - access_control::ensure_role(e, &symbol_short!("blocker"), &operator); - operator.require_auth(); - BlockList::block_user(e, &user) +fn require_role(e: &Env, operator: &Address, key: &DataKey) -> Result<(), TestSacAdminError> { + operator.require_auth(); + if operator != &read_instance_address(e, key) { + return Err(TestSacAdminError::Unauthorized); } + Ok(()) +} - fn unblock_user(e: &Env, user: Address, operator: Address) { - access_control::ensure_role(e, &symbol_short!("blocker"), &operator); - operator.require_auth(); - BlockList::unblock_user(e, &user) +fn persistent_entry_exists(e: &Env, key: &DataKey) -> bool { + if !e.storage().persistent().has(key) { + return false; } + e.storage() + .persistent() + .extend_ttl(key, PERSISTENT_LIFETIME_THRESHOLD, PERSISTENT_BUMP_AMOUNT); + true +} + +fn set_persistent_entry(e: &Env, key: &DataKey) { + e.storage().persistent().set(key, &()); + e.storage() + .persistent() + .extend_ttl(key, PERSISTENT_LIFETIME_THRESHOLD, PERSISTENT_BUMP_AMOUNT); +} + +fn is_onboarded(e: &Env, account: &Address) -> bool { + persistent_entry_exists(e, &DataKey::Onboarded(account.clone())) +} + +fn is_on_block_list(e: &Env, account: &Address) -> bool { + persistent_entry_exists(e, &DataKey::BlockListed(account.clone())) +} + +fn sac_is_authorized(e: &Env, account: &Address) -> bool { + let sac = read_instance_address(e, &DataKey::SacToken); + matches!( + token::StellarAssetClient::new(e, &sac).try_authorized(account), + Ok(Ok(true)) + ) } -// Expose the FungibleBlockList methods as contract endpoints #[contractimpl] -impl TestStablecoinContract { - pub fn blocked(e: &Env, account: Address) -> bool { - ::blocked(e, account) +impl TestSacAdminContract { + /// Initializes the test wrapper and its fixed operator addresses. + /// + /// Every operator argument may be the same address for single-wallet tests. + pub fn __constructor( + e: &Env, + sac_token: Address, + minter: Address, + onboarder: Address, + block_operator: Address, + unblock_operator: Address, + ) -> Result<(), TestSacAdminError> { + if e.storage().instance().has(&DataKey::SacToken) { + return Err(TestSacAdminError::AlreadyInitialized); + } + + e.storage().instance().set(&DataKey::SacToken, &sac_token); + e.storage().instance().set(&DataKey::Minter, &minter); + e.storage().instance().set(&DataKey::Onboarder, &onboarder); + e.storage() + .instance() + .set(&DataKey::BlockOperator, &block_operator); + e.storage() + .instance() + .set(&DataKey::UnblockOperator, &unblock_operator); + extend_instance_ttl(e); + Ok(()) } - pub fn block_user(e: &Env, user: Address, operator: Address) { - ::block_user(e, user, operator) + /// Approves an address for SAC use and records permanent onboarding history. + /// + /// Re-onboarding restores SAC authorization after trustline recreation but + /// emits [`UserOnboarded`] only for the first successful onboarding. + /// + /// # Errors + /// + /// Returns [`TestSacAdminError::UserBlocked`] while the address is on the + /// compliance block list. + pub fn onboard_user( + e: &Env, + user: Address, + operator: Address, + ) -> Result<(), TestSacAdminError> { + require_role(e, &operator, &DataKey::Onboarder)?; + extend_instance_ttl(e); + + if is_on_block_list(e, &user) { + return Err(TestSacAdminError::UserBlocked); + } + + let first_onboarding = !is_onboarded(e, &user); + if first_onboarding { + set_persistent_entry(e, &DataKey::Onboarded(user.clone())); + } + + let sac = read_instance_address(e, &DataKey::SacToken); + token::StellarAssetClient::new(e, &sac).set_authorized(&user, &true); + + if first_onboarding { + UserOnboarded { user }.publish(e); + } + Ok(()) } - pub fn unblock_user(e: &Env, user: Address, operator: Address) { - ::unblock_user(e, user, operator) + /// Places an address on the compliance block list and revokes SAC access. + /// + /// Calling this for an already block-listed address is a silent no-op. + pub fn block_user(e: &Env, user: Address, operator: Address) -> Result<(), TestSacAdminError> { + require_role(e, &operator, &DataKey::BlockOperator)?; + extend_instance_ttl(e); + + if is_on_block_list(e, &user) { + return Ok(()); + } + + set_persistent_entry(e, &DataKey::BlockListed(user.clone())); + let sac = read_instance_address(e, &DataKey::SacToken); + token::StellarAssetClient::new(e, &sac).set_authorized(&user, &false); + UserBlocked { user }.publish(e); + Ok(()) } -} -#[contractimpl(contracttrait)] -impl AccessControl for TestStablecoinContract {} + /// Removes an address from the compliance block list. + /// + /// SAC access is restored only if the address was previously onboarded. + /// Calling this for an address not on the block list is a silent no-op. + pub fn unblock_user( + e: &Env, + user: Address, + operator: Address, + ) -> Result<(), TestSacAdminError> { + require_role(e, &operator, &DataKey::UnblockOperator)?; + extend_instance_ttl(e); + + let block_key = DataKey::BlockListed(user.clone()); + if !persistent_entry_exists(e, &block_key) { + return Ok(()); + } + + e.storage().persistent().remove(&block_key); + if is_onboarded(e, &user) { + let sac = read_instance_address(e, &DataKey::SacToken); + token::StellarAssetClient::new(e, &sac).set_authorized(&user, &true); + } + UserUnblocked { user }.publish(e); + Ok(()) + } -#[contractimpl(contracttrait)] -impl FungibleBurnable for TestStablecoinContract { - fn burn(e: &Env, from: Address, amount: i128) { - BlockList::burn(e, &from, amount); + /// Mints test tokens to an onboarded and currently authorized destination. + /// + /// # Errors + /// + /// Returns [`TestSacAdminError::InvalidAmount`] for non-positive amounts and + /// [`TestSacAdminError::DestinationNotAuthorized`] when the destination + /// cannot currently receive the SAC. + pub fn mint( + e: &Env, + caller: Address, + to: Address, + amount: i128, + ) -> Result<(), TestSacAdminError> { + require_role(e, &caller, &DataKey::Minter)?; + extend_instance_ttl(e); + + if amount <= 0 { + return Err(TestSacAdminError::InvalidAmount); + } + if !is_onboarded(e, &to) || is_on_block_list(e, &to) || !sac_is_authorized(e, &to) { + return Err(TestSacAdminError::DestinationNotAuthorized); + } + + let sac = read_instance_address(e, &DataKey::SacToken); + token::StellarAssetClient::new(e, &sac).mint(&to, &amount); + Ok(()) } - fn burn_from(e: &Env, spender: Address, from: Address, amount: i128) { - BlockList::burn_from(e, &spender, &from, amount); + /// Returns whether the address has ever been onboarded. + pub fn is_onboarded(e: &Env, account: Address) -> bool { + extend_instance_ttl(e); + is_onboarded(e, &account) + } + + /// Returns whether the address is currently on the compliance block list. + pub fn is_on_block_list(e: &Env, account: Address) -> bool { + extend_instance_ttl(e); + is_on_block_list(e, &account) + } + + /// Returns the inverse of current SAC authorization. + /// + /// Missing trustlines and failed authorization queries are treated as + /// blocked, independently of the contract-level block list. + pub fn blocked(e: &Env, account: Address) -> bool { + extend_instance_ttl(e); + !sac_is_authorized(e, &account) + } + + /// Returns the canonical SAC balance for an address. + pub fn balance(e: &Env, account: Address) -> i128 { + extend_instance_ttl(e); + let sac = read_instance_address(e, &DataKey::SacToken); + token::TokenClient::new(e, &sac).balance(&account) + } + + /// Returns the administered SAC contract address. + pub fn sac_token(e: &Env) -> Address { + extend_instance_ttl(e); + read_instance_address(e, &DataKey::SacToken) } } #[cfg(test)] mod test { extern crate std; - use soroban_sdk::{testutils::Address as _, Address, Env, String}; + + use soroban_sdk::{ + testutils::{storage::Persistent as _, Address as _, Events as _, IssuerFlags}, + token::{StellarAssetClient, TokenClient}, + Address, Env, Event, Val, + }; use super::*; - fn setup(e: &Env) -> (Address, Address, Address, TestStablecoinContractClient<'_>) { - e.mock_all_auths(); - let admin = Address::generate(e); - let manager = Address::generate(e); - let blocker = Address::generate(e); - let name = String::from_str(e, "Test USD"); - let symbol = String::from_str(e, "TUSD"); - let initial_supply = 1_000_000_000i128; // 1000 tokens with 6 decimals - let address = e.register( - TestStablecoinContract, - (name, symbol, &admin, &manager, &blocker, initial_supply), + struct Setup { + env: Env, + contract: TestSacAdminContractClient<'static>, + sac: TokenClient<'static>, + sac_admin: StellarAssetClient<'static>, + minter: Address, + onboarder: Address, + blocker: Address, + unblocker: Address, + } + + fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths(); + + let issuer_admin = Address::generate(&env); + let minter = Address::generate(&env); + let onboarder = Address::generate(&env); + let blocker = Address::generate(&env); + let unblocker = Address::generate(&env); + + let registered_sac = env.register_stellar_asset_contract_v2(issuer_admin); + let issuer = registered_sac.issuer(); + issuer.set_flag(IssuerFlags::RequiredFlag); + issuer.set_flag(IssuerFlags::RevocableFlag); + issuer.set_flag(IssuerFlags::ClawbackEnabledFlag); + + let sac_address = registered_sac.address(); + let contract_address = env.register( + TestSacAdminContract, + (&sac_address, &minter, &onboarder, &blocker, &unblocker), ); - let client = TestStablecoinContractClient::new(e, &address); - (admin, blocker, manager, client) + let contract = TestSacAdminContractClient::new(&env, &contract_address); + let sac = TokenClient::new(&env, &sac_address); + let sac_admin = StellarAssetClient::new(&env, &sac_address); + sac_admin.set_admin(&contract_address); + + Setup { + env, + contract, + sac, + sac_admin, + minter, + onboarder, + blocker, + unblocker, + } } - #[test] - fn test_constructor() { - let e = Env::default(); - let (admin, _blocker, _manager, client) = setup(&e); + fn wrapper_events(s: &Setup) -> std::vec::Vec<(Address, soroban_sdk::Vec, Val)> { + s.env + .events() + .all() + .iter() + .filter(|(address, _, _)| address == &s.contract.address) + .collect() + } - assert_eq!(client.decimals(), 6u32); - assert_eq!(client.name(), String::from_str(&e, "Test USD")); - assert_eq!(client.symbol(), String::from_str(&e, "TUSD")); - assert_eq!(client.balance(&admin), 1_000_000_000i128); + fn assert_last_event(s: &Setup, expected: &E) { + let events = wrapper_events(s); + let (_, topics, _) = events.last().expect("expected wrapper event"); + assert_eq!(topics, &expected.topics(&s.env)); } #[test] - fn test_mint() { - let e = Env::default(); - let (_admin, _blocker, _manager, client) = setup(&e); - let user = Address::generate(&e); + fn fresh_user_is_not_onboarded_or_block_list_but_is_sac_blocked() { + let s = setup(); + let user = Address::generate(&s.env); - client.mint(&user, &500_000i128); - assert_eq!(client.balance(&user), 500_000i128); + assert!(!s.contract.is_onboarded(&user)); + assert!(!s.contract.is_on_block_list(&user)); + assert!(s.contract.blocked(&user)); } #[test] - fn test_transfer() { - let e = Env::default(); - let (admin, _blocker, _manager, client) = setup(&e); - let bob = Address::generate(&e); + fn first_onboarding_records_history_authorizes_and_emits() { + let s = setup(); + let user = Address::generate(&s.env); + + s.contract.onboard_user(&user, &s.onboarder); - client.transfer(&admin, &bob, &100_000i128); - assert_eq!(client.balance(&admin), 999_900_000i128); - assert_eq!(client.balance(&bob), 100_000i128); + assert_last_event(&s, &UserOnboarded { user: user.clone() }); + assert!(s.contract.is_onboarded(&user)); + assert!(!s.contract.blocked(&user)); } #[test] - fn test_block_unblock() { - let e = Env::default(); - let (admin, blocker, _manager, client) = setup(&e); - let user = Address::generate(&e); + fn repeated_onboarding_reauthorizes_without_duplicate_event() { + let s = setup(); + let user = Address::generate(&s.env); + s.contract.onboard_user(&user, &s.onboarder); + s.sac_admin.set_authorized(&user, &false); + let event_count = wrapper_events(&s).len(); - // Block user - client.block_user(&user, &blocker); - assert!(client.blocked(&user)); + s.contract.onboard_user(&user, &s.onboarder); - // Unblock user - client.unblock_user(&user, &blocker); - assert!(!client.blocked(&user)); + assert!(!s.contract.blocked(&user)); + assert_eq!(wrapper_events(&s).len(), event_count); + } + + #[test] + fn block_before_onboarding_then_unblock_does_not_authorize() { + let s = setup(); + let user = Address::generate(&s.env); - // Transfer works after unblock - client.transfer(&admin, &user, &100i128); - assert_eq!(client.balance(&user), 100i128); + s.contract.block_user(&user, &s.blocker); + s.contract.unblock_user(&user, &s.unblocker); + + assert!(!s.contract.is_onboarded(&user)); + assert!(!s.contract.is_on_block_list(&user)); + assert!(s.contract.blocked(&user)); } #[test] - #[should_panic(expected = "Error(Contract, #114)")] - fn test_blocked_user_cannot_transfer() { - let e = Env::default(); - let (admin, blocker, _manager, client) = setup(&e); - let user = Address::generate(&e); + fn onboarding_after_pre_onboarding_block_cycle_then_blocking_preserves_invariants() { + let s = setup(); + let user = Address::generate(&s.env); + s.contract.block_user(&user, &s.blocker); + s.contract.unblock_user(&user, &s.unblocker); + + s.contract.onboard_user(&user, &s.onboarder); + s.contract.block_user(&user, &s.blocker); + + assert!(s.contract.is_onboarded(&user)); + assert!(s.contract.is_on_block_list(&user)); + assert!(s.contract.blocked(&user)); + } - client.transfer(&admin, &user, &1000i128); - client.block_user(&user, &blocker); - client.transfer(&user, &admin, &500i128); + #[test] + fn onboarding_blocking_and_unblocking_restores_authorization() { + let s = setup(); + let user = Address::generate(&s.env); + s.contract.onboard_user(&user, &s.onboarder); + + s.contract.block_user(&user, &s.blocker); + assert!(s.contract.is_onboarded(&user)); + assert!(s.contract.is_on_block_list(&user)); + assert!(s.contract.blocked(&user)); + + s.contract.unblock_user(&user, &s.unblocker); + assert!(s.contract.is_onboarded(&user)); + assert!(!s.contract.is_on_block_list(&user)); + assert!(!s.contract.blocked(&user)); } #[test] - #[should_panic(expected = "Error(Contract, #114)")] - fn test_transfer_to_blocked_user() { - let e = Env::default(); - let (admin, blocker, _manager, client) = setup(&e); - let user = Address::generate(&e); + fn onboarding_block_listed_user_returns_typed_error() { + let s = setup(); + let user = Address::generate(&s.env); + s.contract.block_user(&user, &s.blocker); + + let result = s.contract.try_onboard_user(&user, &s.onboarder); - client.block_user(&user, &blocker); - client.transfer(&admin, &user, &500i128); + assert_eq!(result, Err(Ok(TestSacAdminError::UserBlocked))); + assert!(!s.contract.is_onboarded(&user)); + assert!(s.contract.is_on_block_list(&user)); } #[test] - #[should_panic(expected = "Error(Contract, #114)")] - fn test_blocked_user_cannot_approve() { - let e = Env::default(); - let (_admin, blocker, _manager, client) = setup(&e); - let user = Address::generate(&e); - let spender = Address::generate(&e); + fn repeated_block_is_silent_noop() { + let s = setup(); + let user = Address::generate(&s.env); + s.contract.block_user(&user, &s.blocker); + + s.contract.block_user(&user, &s.blocker); - client.block_user(&user, &blocker); - client.approve(&user, &spender, &100i128, &1000u32); + assert!(wrapper_events(&s).is_empty()); } #[test] - fn test_approve_and_transfer_from() { - let e = Env::default(); - let (admin, _blocker, _manager, client) = setup(&e); - let spender = Address::generate(&e); - let recipient = Address::generate(&e); + fn unblock_when_not_block_listed_is_silent_noop() { + let s = setup(); + let user = Address::generate(&s.env); + let event_count = wrapper_events(&s).len(); - client.approve(&admin, &spender, &500_000i128, &1000u32); - assert_eq!(client.allowance(&admin, &spender), 500_000i128); + s.contract.unblock_user(&user, &s.unblocker); - client.transfer_from(&spender, &admin, &recipient, &200_000i128); - assert_eq!(client.balance(&recipient), 200_000i128); - assert_eq!(client.allowance(&admin, &spender), 300_000i128); + assert_eq!(wrapper_events(&s).len(), event_count); } #[test] - #[should_panic(expected = "Error(Contract, #114)")] - fn test_transfer_from_blocked_user() { - let e = Env::default(); - let (admin, blocker, _manager, client) = setup(&e); - let spender = Address::generate(&e); - let recipient = Address::generate(&e); + fn block_and_unblock_emit_transition_events() { + let s = setup(); + let user = Address::generate(&s.env); + s.contract.onboard_user(&user, &s.onboarder); - client.approve(&admin, &spender, &500_000i128, &1000u32); - client.block_user(&admin, &blocker); - client.transfer_from(&spender, &admin, &recipient, &100i128); + s.contract.block_user(&user, &s.blocker); + assert_last_event(&s, &UserBlocked { user: user.clone() }); + + s.contract.unblock_user(&user, &s.unblocker); + assert_last_event(&s, &UserUnblocked { user }); } #[test] - #[should_panic(expected = "Error(Contract, #114)")] - fn test_transfer_from_to_blocked_user() { - let e = Env::default(); - let (admin, blocker, _manager, client) = setup(&e); - let spender = Address::generate(&e); - let recipient = Address::generate(&e); + fn transfer_succeeds_only_when_sender_and_recipient_are_authorized() { + let s = setup(); + let sender = Address::generate(&s.env); + let recipient = Address::generate(&s.env); + s.contract.onboard_user(&sender, &s.onboarder); + s.contract.onboard_user(&recipient, &s.onboarder); + s.contract.mint(&s.minter, &sender, &1_000); + + s.sac.transfer(&sender, &recipient, &400); - client.approve(&admin, &spender, &500_000i128, &1000u32); - client.block_user(&recipient, &blocker); - client.transfer_from(&spender, &admin, &recipient, &100i128); + assert_eq!(s.contract.balance(&recipient), 400); } #[test] - fn test_burn() { - let e = Env::default(); - let (admin, _blocker, _manager, client) = setup(&e); + fn blocked_sender_cannot_transfer_until_unblocked() { + let s = setup(); + let sender = Address::generate(&s.env); + let recipient = Address::generate(&s.env); + s.contract.onboard_user(&sender, &s.onboarder); + s.contract.onboard_user(&recipient, &s.onboarder); + s.contract.mint(&s.minter, &sender, &1_000); + s.contract.block_user(&sender, &s.blocker); + + assert!(s.sac.try_transfer(&sender, &recipient, &400).is_err()); + + s.contract.unblock_user(&sender, &s.unblocker); + s.sac.transfer(&sender, &recipient, &400); + assert_eq!(s.contract.balance(&recipient), 400); + } - let before = client.balance(&admin); - client.burn(&admin, &100_000i128); - assert_eq!(client.balance(&admin), before - 100_000i128); + #[test] + fn unauthorized_recipient_cannot_receive_transfer() { + let s = setup(); + let sender = Address::generate(&s.env); + let recipient = Address::generate(&s.env); + s.contract.onboard_user(&sender, &s.onboarder); + s.contract.mint(&s.minter, &sender, &1_000); + + assert!(s.sac.try_transfer(&sender, &recipient, &400).is_err()); + assert_eq!(s.contract.balance(&recipient), 0); + } + + #[test] + fn mint_rejects_non_positive_amount_and_unauthorized_destination() { + let s = setup(); + let onboarded = Address::generate(&s.env); + let unauthorized = Address::generate(&s.env); + s.contract.onboard_user(&onboarded, &s.onboarder); + + assert_eq!( + s.contract.try_mint(&s.minter, &onboarded, &0), + Err(Ok(TestSacAdminError::InvalidAmount)) + ); + assert_eq!( + s.contract.try_mint(&s.minter, &unauthorized, &1), + Err(Ok(TestSacAdminError::DestinationNotAuthorized)) + ); } #[test] - #[should_panic(expected = "Error(Contract, #114)")] - fn test_blocked_user_cannot_burn() { - let e = Env::default(); - let (admin, blocker, _manager, client) = setup(&e); + fn mint_rejects_onboarded_but_block_listed_destination() { + let s = setup(); + let user = Address::generate(&s.env); + s.contract.onboard_user(&user, &s.onboarder); + s.contract.block_user(&user, &s.blocker); + + let result = s.contract.try_mint(&s.minter, &user, &1); - client.block_user(&admin, &blocker); - client.burn(&admin, &100i128); + assert_eq!(result, Err(Ok(TestSacAdminError::DestinationNotAuthorized))); + assert_eq!(s.contract.balance(&user), 0); } #[test] - #[should_panic(expected = "Error(Contract, #114)")] - fn test_burn_from_blocked_user() { - let e = Env::default(); - let (admin, blocker, _manager, client) = setup(&e); - let spender = Address::generate(&e); + fn wrong_role_cannot_perform_other_operator_action() { + let s = setup(); + let user = Address::generate(&s.env); - client.approve(&admin, &spender, &500_000i128, &1000u32); - client.block_user(&admin, &blocker); - client.burn_from(&spender, &admin, &100i128); + assert_eq!( + s.contract.try_onboard_user(&user, &s.blocker), + Err(Ok(TestSacAdminError::Unauthorized)) + ); + assert_eq!( + s.contract.try_block_user(&user, &s.onboarder), + Err(Ok(TestSacAdminError::Unauthorized)) + ); + assert_eq!( + s.contract.try_unblock_user(&user, &s.blocker), + Err(Ok(TestSacAdminError::Unauthorized)) + ); } #[test] - #[should_panic] - fn test_non_admin_cannot_mint() { - let e = Env::default(); - // Set up contract without mocking auths globally - let admin = Address::generate(&e); - let manager = Address::generate(&e); - let blocker = Address::generate(&e); - let name = String::from_str(&e, "Test USD"); - let symbol = String::from_str(&e, "TUSD"); - let address = e.register( - TestStablecoinContract, - (name, symbol, &admin, &manager, &blocker, 1_000_000i128), + fn one_wallet_can_hold_every_operator_role() { + let env = Env::default(); + env.mock_all_auths(); + let wallet = Address::generate(&env); + let registered_sac = env.register_stellar_asset_contract_v2(wallet.clone()); + let issuer = registered_sac.issuer(); + issuer.set_flag(IssuerFlags::RequiredFlag); + issuer.set_flag(IssuerFlags::RevocableFlag); + issuer.set_flag(IssuerFlags::ClawbackEnabledFlag); + let sac_address = registered_sac.address(); + let contract_address = env.register( + TestSacAdminContract, + (&sac_address, &wallet, &wallet, &wallet, &wallet), ); - let client = TestStablecoinContractClient::new(&e, &address); - let user = Address::generate(&e); + StellarAssetClient::new(&env, &sac_address).set_admin(&contract_address); + let contract = TestSacAdminContractClient::new(&env, &contract_address); + let user = Address::generate(&env); + + contract.onboard_user(&user, &wallet); + contract.mint(&wallet, &user, &100); + contract.block_user(&user, &wallet); + contract.unblock_user(&user, &wallet); - // Call mint without any auth mocked — should fail - client.mint(&user, &1000i128); + assert!(!contract.blocked(&user)); + assert_eq!(contract.balance(&user), 100); } #[test] - #[should_panic] - fn test_non_blocker_cannot_block() { - let e = Env::default(); - let admin = Address::generate(&e); - let manager = Address::generate(&e); - let blocker = Address::generate(&e); - let name = String::from_str(&e, "Test USD"); - let symbol = String::from_str(&e, "TUSD"); - let address = e.register( - TestStablecoinContract, - (name, symbol, &admin, &manager, &blocker, 1_000_000i128), - ); - let client = TestStablecoinContractClient::new(&e, &address); - let user = Address::generate(&e); - let not_blocker = Address::generate(&e); + fn operator_signature_is_required() { + let s = setup(); + s.env.mock_auths(&[]); + let user = Address::generate(&s.env); + + assert!(s.contract.try_onboard_user(&user, &s.onboarder).is_err()); + assert!(s.contract.try_block_user(&user, &s.blocker).is_err()); + assert!(s.contract.try_unblock_user(&user, &s.unblocker).is_err()); + } - // Call block_user with a non-blocker operator — should fail - client.block_user(&user, ¬_blocker); + #[test] + fn persistent_state_is_extended_for_long_lived_tests() { + let s = setup(); + let user = Address::generate(&s.env); + s.contract.onboard_user(&user, &s.onboarder); + + let ttl = s.env.as_contract(&s.contract.address, || { + s.env + .storage() + .persistent() + .get_ttl(&DataKey::Onboarded(user)) + }); + + assert!(ttl >= PERSISTENT_LIFETIME_THRESHOLD); } } From 29cd513209f17422f61edd8240f3ad39a0f17e70 Mon Sep 17 00:00:00 2001 From: penDerGraft Date: Tue, 18 Aug 2026 22:17:46 -0500 Subject: [PATCH 2/3] add is_onboarder read function --- soroban/test-stablecoin/README.md | 1 + soroban/test-stablecoin/src/lib.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/soroban/test-stablecoin/README.md b/soroban/test-stablecoin/README.md index d95c9312..059a50f7 100644 --- a/soroban/test-stablecoin/README.md +++ b/soroban/test-stablecoin/README.md @@ -37,6 +37,7 @@ These states can differ. In particular: - `unblock_user(user, operator)` - `mint(caller, to, amount)` - `is_onboarded(account)` +- `is_onboarder(account)` - `is_on_block_list(account)` - `blocked(account)` - `balance(account)` diff --git a/soroban/test-stablecoin/src/lib.rs b/soroban/test-stablecoin/src/lib.rs index 95c85d1a..85eed599 100644 --- a/soroban/test-stablecoin/src/lib.rs +++ b/soroban/test-stablecoin/src/lib.rs @@ -264,6 +264,12 @@ impl TestSacAdminContract { is_onboarded(e, &account) } + /// Returns whether the address holds the fixed onboarder role. + pub fn is_onboarder(e: &Env, account: Address) -> bool { + extend_instance_ttl(e); + account == read_instance_address(e, &DataKey::Onboarder) + } + /// Returns whether the address is currently on the compliance block list. pub fn is_on_block_list(e: &Env, account: Address) -> bool { extend_instance_ttl(e); @@ -629,6 +635,14 @@ mod test { assert!(s.contract.try_unblock_user(&user, &s.unblocker).is_err()); } + #[test] + fn is_onboarder_identifies_only_the_configured_onboarder() { + let s = setup(); + + assert!(s.contract.is_onboarder(&s.onboarder)); + assert!(!s.contract.is_onboarder(&s.blocker)); + } + #[test] fn persistent_state_is_extended_for_long_lived_tests() { let s = setup(); From 533ad721135f647ba636df4874af3939e9501062 Mon Sep 17 00:00:00 2001 From: penDerGraft Date: Tue, 18 Aug 2026 22:25:42 -0500 Subject: [PATCH 3/3] add remaining view functions --- soroban/test-stablecoin/README.md | 2 ++ soroban/test-stablecoin/src/lib.rs | 35 ++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/soroban/test-stablecoin/README.md b/soroban/test-stablecoin/README.md index 059a50f7..54937f5a 100644 --- a/soroban/test-stablecoin/README.md +++ b/soroban/test-stablecoin/README.md @@ -38,6 +38,8 @@ These states can differ. In particular: - `mint(caller, to, amount)` - `is_onboarded(account)` - `is_onboarder(account)` +- `is_block_operator(account)` +- `is_unblock_operator(account)` - `is_on_block_list(account)` - `blocked(account)` - `balance(account)` diff --git a/soroban/test-stablecoin/src/lib.rs b/soroban/test-stablecoin/src/lib.rs index 85eed599..079dc92d 100644 --- a/soroban/test-stablecoin/src/lib.rs +++ b/soroban/test-stablecoin/src/lib.rs @@ -27,8 +27,8 @@ pub enum TestSacAdminError { NotInitialized = 2, Unauthorized = 3, InvalidAmount = 4, - UserBlocked = 5, DestinationNotAuthorized = 6, + UserBlockedError = 105, } #[derive(Clone)] @@ -158,7 +158,7 @@ impl TestSacAdminContract { /// /// # Errors /// - /// Returns [`TestSacAdminError::UserBlocked`] while the address is on the + /// Returns [`TestSacAdminError::UserBlockedError`] while the address is on the /// compliance block list. pub fn onboard_user( e: &Env, @@ -169,7 +169,7 @@ impl TestSacAdminContract { extend_instance_ttl(e); if is_on_block_list(e, &user) { - return Err(TestSacAdminError::UserBlocked); + return Err(TestSacAdminError::UserBlockedError); } let first_onboarding = !is_onboarded(e, &user); @@ -270,6 +270,18 @@ impl TestSacAdminContract { account == read_instance_address(e, &DataKey::Onboarder) } + /// Returns whether the address holds the fixed block-operator role. + pub fn is_block_operator(e: &Env, account: Address) -> bool { + extend_instance_ttl(e); + account == read_instance_address(e, &DataKey::BlockOperator) + } + + /// Returns whether the address holds the fixed unblock-operator role. + pub fn is_unblock_operator(e: &Env, account: Address) -> bool { + extend_instance_ttl(e); + account == read_instance_address(e, &DataKey::UnblockOperator) + } + /// Returns whether the address is currently on the compliance block list. pub fn is_on_block_list(e: &Env, account: Address) -> bool { extend_instance_ttl(e); @@ -464,11 +476,16 @@ mod test { let result = s.contract.try_onboard_user(&user, &s.onboarder); - assert_eq!(result, Err(Ok(TestSacAdminError::UserBlocked))); + assert_eq!(result, Err(Ok(TestSacAdminError::UserBlockedError))); assert!(!s.contract.is_onboarded(&user)); assert!(s.contract.is_on_block_list(&user)); } + #[test] + fn blocked_onboarding_error_matches_gateway_compatibility_code() { + assert_eq!(TestSacAdminError::UserBlockedError as u32, 105); + } + #[test] fn repeated_block_is_silent_noop() { let s = setup(); @@ -643,6 +660,16 @@ mod test { assert!(!s.contract.is_onboarder(&s.blocker)); } + #[test] + fn operator_views_identify_only_their_configured_roles() { + let s = setup(); + + assert!(s.contract.is_block_operator(&s.blocker)); + assert!(!s.contract.is_block_operator(&s.unblocker)); + assert!(s.contract.is_unblock_operator(&s.unblocker)); + assert!(!s.contract.is_unblock_operator(&s.blocker)); + } + #[test] fn persistent_state_is_extended_for_long_lived_tests() { let s = setup();