@layerzerolabs/ethena-sac-manager-stellar-contracts 1.2.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1859 -0
- package/Cargo.toml +38 -0
- package/LICENSE +23 -0
- package/clippy.toml +6 -0
- package/package.json +43 -0
- package/rust-toolchain.toml +4 -0
- package/rustfmt.toml +12 -0
- package/src/errors.rs +19 -0
- package/src/lib.rs +19 -0
- package/src/sac_manager.rs +262 -0
- package/src/storage.rs +18 -0
- package/src/tests/mod.rs +8 -0
- package/src/tests/sac_manager/clawback.rs +85 -0
- package/src/tests/sac_manager/mint.rs +103 -0
- package/src/tests/sac_manager/mod.rs +8 -0
- package/src/tests/sac_manager/redistribute_funds.rs +103 -0
- package/src/tests/sac_manager/set_admin.rs +47 -0
- package/src/tests/sac_manager/set_authorized.rs +85 -0
- package/src/tests/sac_manager/set_escrow.rs +146 -0
- package/src/tests/sac_manager/test_helper.rs +29 -0
- package/src/tests/sac_manager/view_functions.rs +13 -0
- package/src/tests/test_helper.rs +160 -0
- package/turbo-snapshot.lock.json +142 -0
- package/turbo.json +12 -0
package/Cargo.toml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "ethena-sac-manager"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
publish = false
|
|
6
|
+
|
|
7
|
+
[lib]
|
|
8
|
+
crate-type = ["cdylib", "rlib"]
|
|
9
|
+
doctest = false
|
|
10
|
+
|
|
11
|
+
[features]
|
|
12
|
+
library = []
|
|
13
|
+
testutils = ["soroban-sdk/testutils"]
|
|
14
|
+
|
|
15
|
+
[dependencies]
|
|
16
|
+
soroban-sdk = { version = "25.1.1", features = ["hazmat-address", "hazmat-crypto"] }
|
|
17
|
+
utils = { path = "dependencies/common-utils-stellar-contracts" }
|
|
18
|
+
common-macros = { path = "dependencies/common-utils-macros-stellar-contracts" }
|
|
19
|
+
sac-manager = { path = "dependencies/sac-manager-stellar-contracts", features = ["library"] }
|
|
20
|
+
cfg-if = { version = "1.0", default-features = false }
|
|
21
|
+
|
|
22
|
+
[profile.release]
|
|
23
|
+
opt-level = "z"
|
|
24
|
+
overflow-checks = true
|
|
25
|
+
debug = 0
|
|
26
|
+
strip = "symbols"
|
|
27
|
+
debug-assertions = false
|
|
28
|
+
panic = "abort"
|
|
29
|
+
codegen-units = 1
|
|
30
|
+
lto = true
|
|
31
|
+
|
|
32
|
+
[profile.release-with-logs]
|
|
33
|
+
inherits = "release"
|
|
34
|
+
debug-assertions = true
|
|
35
|
+
|
|
36
|
+
[dev-dependencies]
|
|
37
|
+
soroban-sdk = { version = "25.1.1", features = ["hazmat-address", "hazmat-crypto", "testutils"] }
|
|
38
|
+
utils = { path = "dependencies/common-utils-stellar-contracts", features = ["testutils"] }
|
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Copyright (c) 2026 - LayerZero Labs Ltd.
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any
|
|
4
|
+
person obtaining a copy of this software and associated
|
|
5
|
+
documentation files (the "Software"), to deal in the
|
|
6
|
+
Software without restriction, including without
|
|
7
|
+
limitation the rights to use, copy, modify, merge,
|
|
8
|
+
publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software
|
|
10
|
+
is furnished to do so, subject to the following
|
|
11
|
+
conditions:
|
|
12
|
+
The above copyright notice and this permission notice
|
|
13
|
+
shall be included in all copies or substantial portions
|
|
14
|
+
of the Software.
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
|
16
|
+
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
|
17
|
+
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
18
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
|
19
|
+
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
20
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
|
22
|
+
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
23
|
+
DEALINGS IN THE SOFTWARE.
|
package/clippy.toml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Clippy configuration for Stellar contracts
|
|
2
|
+
|
|
3
|
+
# Set the too_many_arguments threshold to 11.
|
|
4
|
+
# This aligns with Stellar: contract functions are limited to 10 parameters, plus 1 for 'env'.
|
|
5
|
+
# (Default is 7; raised here to prevent unnecessary clippy warnings on valid contract interfaces.)
|
|
6
|
+
too-many-arguments-threshold = 11
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@layerzerolabs/ethena-sac-manager-stellar-contracts",
|
|
3
|
+
"version": "1.2.28",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@layerzerolabs/common-utils-macros-stellar-contracts": "1.2.28",
|
|
8
|
+
"@layerzerolabs/common-utils-stellar-contracts": "1.2.28",
|
|
9
|
+
"@layerzerolabs/sac-manager-stellar-contracts": "1.2.28"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@layerzerolabs/build-utils-rust": "1.2.28",
|
|
13
|
+
"@layerzerolabs/vm-tooling-stellar": "1.2.28"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public",
|
|
17
|
+
"registry": "https://registry.npmjs.org/"
|
|
18
|
+
},
|
|
19
|
+
"externalRepoConfig": {
|
|
20
|
+
"targets": [
|
|
21
|
+
"audit-external"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"implicitDependencies": {
|
|
25
|
+
"@layerzerolabs/common-utils-macros-stellar-contracts": "workspace:*",
|
|
26
|
+
"@layerzerolabs/common-utils-stellar-contracts": "workspace:*",
|
|
27
|
+
"@layerzerolabs/sac-manager-stellar-contracts": "workspace:*"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "pnpm package:source && pnpm exec lz-tool extra stellar verifiable-build --stellar-version 25.1.0 --archive .artifacts/.incoming/contracts-source.zip --package ethena-sac-manager:Cargo.toml",
|
|
31
|
+
"build:native": "pnpm resolve-dependencies && pnpm exec lz-tool --docker-platform native stellar contract build --package ethena-sac-manager",
|
|
32
|
+
"clean": "rm -rf ./dependencies ./target ./node_modules ./.artifacts*",
|
|
33
|
+
"format": "pnpm exec lz-tool --script \"cargo fmt\" stellar",
|
|
34
|
+
"format:check": "pnpm exec lz-tool --script \"cargo fmt -- --check\" stellar",
|
|
35
|
+
"lint": "pnpm resolve-dependencies && pnpm exec lz-tool --script \"cargo fmt -- --check && cargo clippy --all-targets -- -D warnings\" stellar",
|
|
36
|
+
"lint:fix": "pnpm resolve-dependencies && pnpm exec lz-tool --script \"cargo clippy --all-targets --fix --allow-dirty --allow-staged && cargo fmt\" stellar",
|
|
37
|
+
"package:source": "pnpm resolve-dependencies && pnpm exec lz-tool extra stellar package-source --output .artifacts/.incoming/contracts-source.zip --exclude 'src/tests/**'",
|
|
38
|
+
"resolve-dependencies": "pnpm exec build-utils-rust resolve",
|
|
39
|
+
"test": "pnpm resolve-dependencies && pnpm exec lz-tool --script \"cargo nextest run\" stellar",
|
|
40
|
+
"test:snapshot:check": "pnpm exec turbo-snapshot check",
|
|
41
|
+
"test:snapshot:update": "pnpm exec turbo-snapshot update"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/rustfmt.toml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Only stable rustfmt options are kept here: the Stellar docker toolchain runs
|
|
2
|
+
# rustfmt on the stable channel (rust 1.90.0), which silently drops unstable
|
|
3
|
+
# options (they only apply on nightly). Do not re-add unstable options unless the
|
|
4
|
+
# tooling is switched to a pinned nightly (see the Solana packages for that setup).
|
|
5
|
+
max_width = 120
|
|
6
|
+
use_field_init_shorthand = true
|
|
7
|
+
use_small_heuristics = "Max"
|
|
8
|
+
|
|
9
|
+
# style_edition is a stable option honored by the docker toolchain. Pin it to
|
|
10
|
+
# 2021 to match every crate's `edition = "2021"` so formatting stays stable
|
|
11
|
+
# across rustfmt versions (setting it to 2024 would reformat the code).
|
|
12
|
+
style_edition = "2021"
|
package/src/errors.rs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//! Errors for the SAC Manager contract.
|
|
2
|
+
|
|
3
|
+
use common_macros::contract_error;
|
|
4
|
+
|
|
5
|
+
/// SAC Manager errors.
|
|
6
|
+
#[contract_error]
|
|
7
|
+
pub enum SACManagerError {
|
|
8
|
+
/// Account is not blacklisted (required for redistribution).
|
|
9
|
+
NotBlacklisted,
|
|
10
|
+
/// Escrow is not configured — redistribution feature is disabled.
|
|
11
|
+
EscrowNotSet,
|
|
12
|
+
/// Cannot configure an escrow that is currently blacklisted on the SAC —
|
|
13
|
+
/// mints to it would revert and break redistribution.
|
|
14
|
+
EscrowIsBlocked,
|
|
15
|
+
/// Cannot blacklist (un-authorize) the address that is currently configured
|
|
16
|
+
/// as the redistribution escrow. Clear the escrow first via
|
|
17
|
+
/// `set_escrow(None)` if you need to blacklist it.
|
|
18
|
+
CannotBlockEscrow,
|
|
19
|
+
}
|
package/src/lib.rs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#![no_std]
|
|
2
|
+
|
|
3
|
+
mod errors;
|
|
4
|
+
|
|
5
|
+
pub use ::sac_manager::SACAdminWrapper;
|
|
6
|
+
pub use errors::*;
|
|
7
|
+
|
|
8
|
+
cfg_if::cfg_if! {
|
|
9
|
+
// Include implementation when NOT in library mode, OR when testutils is enabled (for tests)
|
|
10
|
+
if #[cfg(any(not(feature = "library"), feature = "testutils"))] {
|
|
11
|
+
mod storage;
|
|
12
|
+
mod sac_manager;
|
|
13
|
+
|
|
14
|
+
pub use sac_manager::{SACManager, SACManagerClient};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[cfg(test)]
|
|
19
|
+
mod tests;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
//! SAC Manager Contract - Manages a Stellar Asset Contract (SAC) as its admin.
|
|
2
|
+
//!
|
|
3
|
+
//! This contract becomes the admin of a SAC and provides:
|
|
4
|
+
//! - Mintable interface for OFT (mint on credit only; OFT burns on the token directly)
|
|
5
|
+
//! - Role-based access control operations (clawback, set_admin, set_authorized)
|
|
6
|
+
//! - **Optional** redistribution feature, toggled by configuring an escrow address.
|
|
7
|
+
//!
|
|
8
|
+
//! ## Trust Model Requirement
|
|
9
|
+
//!
|
|
10
|
+
//! **The issuer account must be locked (master weight set to 0).** In Stellar classic assets,
|
|
11
|
+
//! transfers from/to the issuer are equivalent to minting/burning. The issuer can always mint
|
|
12
|
+
//! more tokens and perform other classic operations directly, even when an explicit admin (this
|
|
13
|
+
//! contract) is set. If the issuer account is not locked, the RBAC model enforced by this
|
|
14
|
+
//! contract can be bypassed, breaking the trust model.
|
|
15
|
+
//!
|
|
16
|
+
//! ## Authorization Model
|
|
17
|
+
//!
|
|
18
|
+
//! - **Owner** (via Ownable): The deployer/admin address. Can manage TTL,
|
|
19
|
+
//! grant/revoke roles, and configure the redistribution escrow (`set_escrow`).
|
|
20
|
+
//! - **RBAC roles**: Access is enforced by role.
|
|
21
|
+
//! - `ADMIN_MANAGER_ROLE`: set_admin
|
|
22
|
+
//! - `MINTER_ROLE`: mint (e.g. for OFT credit)
|
|
23
|
+
//! - `BLACKLISTER_ROLE`: set_authorized
|
|
24
|
+
//! - `CLAWBACK_ROLE`: clawback
|
|
25
|
+
//! - `REDISTRIBUTOR_ROLE`: redistribute_blacklisted_funds
|
|
26
|
+
//!
|
|
27
|
+
//! ## Redistribution Feature
|
|
28
|
+
//!
|
|
29
|
+
//! Redistribution is **opt-in** via the escrow address:
|
|
30
|
+
//!
|
|
31
|
+
//! - When the escrow is `None` (default), the contract behaves like a plain SAC admin
|
|
32
|
+
//! wrapper. Mints to blacklisted accounts revert at the SAC layer; no redirection
|
|
33
|
+
//! occurs and `redistribute_blacklisted_funds` is unavailable.
|
|
34
|
+
//! - When the escrow is `Some(address)`, the redistribution feature is enabled:
|
|
35
|
+
//! - `mint(to, ...)` to a blacklisted recipient is silently redirected to the escrow.
|
|
36
|
+
//! - A `REDISTRIBUTOR_ROLE` holder may call `redistribute_blacklisted_funds(from, amount)`
|
|
37
|
+
//! to claw back from a blacklisted account and re-mint to the escrow.
|
|
38
|
+
//!
|
|
39
|
+
//! ### Escrow Constraint (caller-enforced invariant)
|
|
40
|
+
//!
|
|
41
|
+
//! **The escrow account MUST NOT be the underlying classic asset issuer.** In Stellar,
|
|
42
|
+
//! the asset issuer cannot hold a trustline to its own asset, so any mint to the issuer
|
|
43
|
+
//! would revert at the SAC layer and break redistribution. This invariant is not
|
|
44
|
+
//! enforced on-chain; the owner is responsible for verifying it off-chain (see
|
|
45
|
+
//! `set_escrow` for the rationale).
|
|
46
|
+
//!
|
|
47
|
+
//! `set_escrow` does, however, refuse to configure an escrow that is currently
|
|
48
|
+
//! blacklisted on the SAC (`EscrowIsBlocked`), and `set_authorized` refuses to
|
|
49
|
+
//! blacklist the address that is currently configured as the escrow
|
|
50
|
+
//! (`CannotBlockEscrow`). These on-chain guards keep the redistribution path live
|
|
51
|
+
//! once enabled.
|
|
52
|
+
//!
|
|
53
|
+
//! ### SAC Auth Mode Assumption (when redistribution is enabled)
|
|
54
|
+
//!
|
|
55
|
+
//! The underlying SAC is expected to be configured with the following issuer flags:
|
|
56
|
+
//! - `AUTH_CLAWBACK_ENABLED`: allows clawback of tokens from any account
|
|
57
|
+
//! - `AUTH_REVOCABLE`: allows revoking authorization (blacklisting) from accounts
|
|
58
|
+
//! - `AUTH_REQUIRED` **must** be set to `false` (blacklist mode) — all accounts are
|
|
59
|
+
//! authorized by default, and only explicitly deauthorized accounts are blocked.
|
|
60
|
+
//! This is critical for the redistribution logic: if `AUTH_REQUIRED` were `true`
|
|
61
|
+
//! (allowlist mode), every new account would start as unauthorized, causing every
|
|
62
|
+
//! mint to be redirected to the escrow.
|
|
63
|
+
|
|
64
|
+
use crate::{errors::SACManagerError, storage::SACManagerStorage};
|
|
65
|
+
use common_macros::{contract_impl, lz_contract, only_auth, only_role};
|
|
66
|
+
use sac_manager::SACAdminWrapper;
|
|
67
|
+
use soroban_sdk::{assert_with_error, contractevent, token::StellarAssetClient, Address, Env};
|
|
68
|
+
use utils::{option_ext::OptionExt, rbac::RoleBasedAccessControl};
|
|
69
|
+
|
|
70
|
+
/// Role that can set the admin
|
|
71
|
+
const ADMIN_MANAGER_ROLE: &str = "ADMIN_MANAGER_ROLE";
|
|
72
|
+
/// Role that can mint tokens
|
|
73
|
+
const MINTER_ROLE: &str = "MINTER_ROLE";
|
|
74
|
+
/// Role that can blacklist users
|
|
75
|
+
const BLACKLISTER_ROLE: &str = "BLACKLISTER_ROLE";
|
|
76
|
+
/// Role that can clawback tokens from users
|
|
77
|
+
const CLAWBACK_ROLE: &str = "CLAWBACK_ROLE";
|
|
78
|
+
/// Role that can execute redistribution (redistribute_blacklisted_funds).
|
|
79
|
+
/// Configuring the escrow itself is owner-gated (see `set_escrow`).
|
|
80
|
+
const REDISTRIBUTOR_ROLE: &str = "REDISTRIBUTOR_ROLE";
|
|
81
|
+
|
|
82
|
+
// =========================================================================
|
|
83
|
+
// Redistribution (events)
|
|
84
|
+
// =========================================================================
|
|
85
|
+
|
|
86
|
+
/// Event emitted when the redistribution escrow is set or cleared.
|
|
87
|
+
#[contractevent]
|
|
88
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
89
|
+
pub struct EscrowSet {
|
|
90
|
+
pub previous_escrow: Option<Address>,
|
|
91
|
+
pub new_escrow: Option<Address>,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Event emitted when funds are redistributed from a blacklisted account
|
|
95
|
+
/// (either via the auto-redirect in `mint` or via `redistribute_blacklisted_funds`).
|
|
96
|
+
#[contractevent]
|
|
97
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
98
|
+
pub struct RedistributeFunds {
|
|
99
|
+
#[topic]
|
|
100
|
+
pub user: Address,
|
|
101
|
+
#[topic]
|
|
102
|
+
pub escrow: Address,
|
|
103
|
+
pub amount: i128,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// =========================================================================
|
|
107
|
+
// SAC Manager Contract
|
|
108
|
+
// =========================================================================
|
|
109
|
+
|
|
110
|
+
/// SAC Manager Contract
|
|
111
|
+
///
|
|
112
|
+
/// Manages a SAC as its admin, forwarding token actions to the underlying SAC
|
|
113
|
+
/// while enforcing access control and (optionally) redirecting mints away from
|
|
114
|
+
/// blacklisted accounts to a configured escrow.
|
|
115
|
+
#[lz_contract]
|
|
116
|
+
pub struct SACManager;
|
|
117
|
+
|
|
118
|
+
#[contract_impl]
|
|
119
|
+
impl SACManager {
|
|
120
|
+
/// Constructs the SAC manager contract.
|
|
121
|
+
///
|
|
122
|
+
/// # Arguments
|
|
123
|
+
/// * `sac_token` - The underlying Stellar Asset Contract address
|
|
124
|
+
/// * `owner` - The initial owner address (for TTL management, role grants)
|
|
125
|
+
pub fn __constructor(env: &Env, sac_token: &Address, owner: &Address) {
|
|
126
|
+
Self::init_owner(env, owner);
|
|
127
|
+
SACManagerStorage::set_sac_token(env, sac_token);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Returns the underlying SAC (Stellar Asset Contract) address.
|
|
131
|
+
pub fn underlying_sac(env: &Env) -> Address {
|
|
132
|
+
SACManagerStorage::sac_token(env).unwrap()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// Returns the currently configured escrow address, if any.
|
|
136
|
+
///
|
|
137
|
+
/// `None` means the redistribution feature is disabled.
|
|
138
|
+
pub fn escrow(env: &Env) -> Option<Address> {
|
|
139
|
+
SACManagerStorage::escrow(env)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// =========================================================================
|
|
143
|
+
// Redistribution configuration
|
|
144
|
+
// =========================================================================
|
|
145
|
+
|
|
146
|
+
/// Sets (or clears) the escrow address that receives redistributed funds.
|
|
147
|
+
///
|
|
148
|
+
/// Pass `Some(address)` to enable the redistribution feature, or `None` to
|
|
149
|
+
/// disable it. Callable only by the owner.
|
|
150
|
+
///
|
|
151
|
+
/// When setting a new escrow, the address must not be currently blacklisted
|
|
152
|
+
/// on the SAC, otherwise the call reverts with `EscrowIsBlocked`. Emits an
|
|
153
|
+
/// `EscrowSet` event with the previous and new values.
|
|
154
|
+
///
|
|
155
|
+
/// # Escrow Constraint
|
|
156
|
+
///
|
|
157
|
+
/// **The escrow MUST NOT be the underlying classic asset issuer.** Stellar
|
|
158
|
+
/// asset issuers cannot hold trustlines to their own asset, so any
|
|
159
|
+
/// redistribution mint to the issuer would revert at the SAC layer.
|
|
160
|
+
///
|
|
161
|
+
/// The SAC exposes no direct getter for the classic issuer, though it can be
|
|
162
|
+
/// derived sideways from the asset name (format `{asset_code}:{issuer_address}`).
|
|
163
|
+
/// This contract deliberately does not enforce the invariant on-chain — the
|
|
164
|
+
/// name-parsing check is brittle (asset code length, strkey format) — so
|
|
165
|
+
/// **the owner calling this function is responsible for verifying off-chain** that
|
|
166
|
+
/// the configured escrow is not the issuer account.
|
|
167
|
+
#[only_auth]
|
|
168
|
+
pub fn set_escrow(env: &Env, escrow: &Option<Address>) {
|
|
169
|
+
if let Some(addr) = escrow {
|
|
170
|
+
assert_with_error!(env, sac_client(env).authorized(addr), SACManagerError::EscrowIsBlocked);
|
|
171
|
+
}
|
|
172
|
+
let previous_escrow = Self::escrow(env);
|
|
173
|
+
SACManagerStorage::set_or_remove_escrow(env, escrow);
|
|
174
|
+
EscrowSet { previous_escrow, new_escrow: escrow.clone() }.publish(env);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// =========================================================================
|
|
178
|
+
// Redistribution action
|
|
179
|
+
// =========================================================================
|
|
180
|
+
|
|
181
|
+
/// Redistributes funds from a blacklisted account to the configured escrow.
|
|
182
|
+
///
|
|
183
|
+
/// Requires:
|
|
184
|
+
/// - Operator with `REDISTRIBUTOR_ROLE`.
|
|
185
|
+
/// - An escrow has been configured by the owner (`set_escrow(Some(_))`).
|
|
186
|
+
/// - `from` is currently blacklisted on the underlying SAC.
|
|
187
|
+
#[only_role(operator, REDISTRIBUTOR_ROLE)]
|
|
188
|
+
pub fn redistribute_blacklisted_funds(env: &Env, from: &Address, amount: i128, operator: &Address) {
|
|
189
|
+
let sac = sac_client(env);
|
|
190
|
+
let escrow = Self::escrow(env).unwrap_or_panic(env, SACManagerError::EscrowNotSet);
|
|
191
|
+
assert_with_error!(env, !sac.authorized(from), SACManagerError::NotBlacklisted);
|
|
192
|
+
|
|
193
|
+
// Clawback + mint must both succeed for the redistribution to be atomic.
|
|
194
|
+
// See the escrow/issuer constraint in `set_escrow` docs — if the escrow
|
|
195
|
+
// is misconfigured to the classic asset issuer, the mint will revert and
|
|
196
|
+
// the clawback will roll back with it.
|
|
197
|
+
sac.clawback(from, &amount);
|
|
198
|
+
sac.mint(&escrow, &amount);
|
|
199
|
+
|
|
200
|
+
RedistributeFunds { user: from.clone(), escrow, amount }.publish(env);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#[contract_impl(contracttrait)]
|
|
205
|
+
impl SACAdminWrapper for SACManager {
|
|
206
|
+
#[only_role(operator, ADMIN_MANAGER_ROLE)]
|
|
207
|
+
fn set_admin(env: &Env, new_admin: &Address, operator: &Address) {
|
|
208
|
+
sac_client(env).set_admin(new_admin);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Blacklists (`authorize=false`) or re-authorizes (`authorize=true`) an
|
|
212
|
+
/// address on the underlying SAC.
|
|
213
|
+
///
|
|
214
|
+
/// Refuses to blacklist the address currently configured as the
|
|
215
|
+
/// redistribution escrow — clear the escrow first via `set_escrow(None)`
|
|
216
|
+
/// to ensure the redistribution path stays live.
|
|
217
|
+
#[only_role(operator, BLACKLISTER_ROLE)]
|
|
218
|
+
fn set_authorized(env: &Env, id: &Address, authorize: bool, operator: &Address) {
|
|
219
|
+
assert_with_error!(
|
|
220
|
+
env,
|
|
221
|
+
authorize || Self::escrow(env).as_ref() != Some(id),
|
|
222
|
+
SACManagerError::CannotBlockEscrow
|
|
223
|
+
);
|
|
224
|
+
sac_client(env).set_authorized(id, &authorize);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#[only_role(operator, CLAWBACK_ROLE)]
|
|
228
|
+
fn clawback(env: &Env, from: &Address, amount: i128, operator: &Address) {
|
|
229
|
+
sac_client(env).clawback(from, &amount);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// Mints `amount` to `to`.
|
|
233
|
+
///
|
|
234
|
+
/// If the redistribution feature is enabled (escrow set) and `to` is
|
|
235
|
+
/// blacklisted on the SAC, the mint is silently redirected to the escrow
|
|
236
|
+
/// and a `RedistributeFunds` event is emitted. Otherwise the mint is
|
|
237
|
+
/// forwarded as-is to the SAC.
|
|
238
|
+
#[only_role(operator, MINTER_ROLE)]
|
|
239
|
+
fn mint(env: &Env, to: &Address, amount: i128, operator: &Address) {
|
|
240
|
+
let sac = sac_client(env);
|
|
241
|
+
match Self::escrow(env) {
|
|
242
|
+
Some(escrow_addr) if !sac.authorized(to) => {
|
|
243
|
+
sac.mint(&escrow_addr, &amount);
|
|
244
|
+
RedistributeFunds { user: to.clone(), escrow: escrow_addr, amount }.publish(env);
|
|
245
|
+
}
|
|
246
|
+
_ => {
|
|
247
|
+
sac.mint(to, &amount);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#[contract_impl(contracttrait)]
|
|
254
|
+
impl RoleBasedAccessControl for SACManager {}
|
|
255
|
+
|
|
256
|
+
// =========================================================================
|
|
257
|
+
// Helper Functions
|
|
258
|
+
// =========================================================================
|
|
259
|
+
|
|
260
|
+
fn sac_client(env: &Env) -> StellarAssetClient<'_> {
|
|
261
|
+
StellarAssetClient::new(env, &SACManager::underlying_sac(env))
|
|
262
|
+
}
|
package/src/storage.rs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//! Storage definitions for the SAC manager contract.
|
|
2
|
+
//!
|
|
3
|
+
//! This file contains the core storage.
|
|
4
|
+
|
|
5
|
+
use common_macros::storage;
|
|
6
|
+
use soroban_sdk::Address;
|
|
7
|
+
|
|
8
|
+
#[storage]
|
|
9
|
+
pub enum SACManagerStorage {
|
|
10
|
+
/// The underlying SAC (Stellar Asset Contract) address
|
|
11
|
+
#[instance(Address)]
|
|
12
|
+
SacToken,
|
|
13
|
+
/// Escrow address that receives funds redistributed away from blacklisted
|
|
14
|
+
/// accounts. Presence of this value enables the redistribution feature;
|
|
15
|
+
/// absence disables it.
|
|
16
|
+
#[instance(Address)]
|
|
17
|
+
Escrow,
|
|
18
|
+
}
|
package/src/tests/mod.rs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//! Clawback Integration Tests
|
|
2
|
+
//!
|
|
3
|
+
//! Operator must hold CLAWBACK_ROLE. SAC must have AUTH_CLAWBACK_ENABLED.
|
|
4
|
+
|
|
5
|
+
use super::test_helper::mock_clawback_auth;
|
|
6
|
+
use crate::tests::test_helper::{mock_oft_mint_auth, TestSetup};
|
|
7
|
+
use soroban_sdk::testutils::IssuerFlags;
|
|
8
|
+
use utils::errors::RbacError;
|
|
9
|
+
|
|
10
|
+
// =========================================================================
|
|
11
|
+
// clawback Tests
|
|
12
|
+
// =========================================================================
|
|
13
|
+
|
|
14
|
+
#[test]
|
|
15
|
+
fn test_clawback_by_owner() {
|
|
16
|
+
let setup = TestSetup::new().with_manager_as_sac_admin().build();
|
|
17
|
+
let user = setup.generate_address();
|
|
18
|
+
|
|
19
|
+
setup.sac_contract.issuer().set_flag(IssuerFlags::RevocableFlag);
|
|
20
|
+
setup.sac_contract.issuer().set_flag(IssuerFlags::ClawbackEnabledFlag);
|
|
21
|
+
|
|
22
|
+
mock_oft_mint_auth(&setup, &user, 1000_i128);
|
|
23
|
+
setup.sac_manager_client.mint(&user, &1000, &setup.minter);
|
|
24
|
+
assert_eq!(setup.sac_client.balance(&user), 1000);
|
|
25
|
+
|
|
26
|
+
mock_clawback_auth(&setup, &setup.owner, &user, 500_i128);
|
|
27
|
+
setup.sac_manager_client.clawback(&user, &500, &setup.owner);
|
|
28
|
+
assert_eq!(setup.sac_client.balance(&user), 500);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#[test]
|
|
32
|
+
fn test_clawback_full_balance() {
|
|
33
|
+
let setup = TestSetup::new().with_manager_as_sac_admin().build();
|
|
34
|
+
let user = setup.generate_address();
|
|
35
|
+
|
|
36
|
+
setup.sac_contract.issuer().set_flag(IssuerFlags::RevocableFlag);
|
|
37
|
+
setup.sac_contract.issuer().set_flag(IssuerFlags::ClawbackEnabledFlag);
|
|
38
|
+
|
|
39
|
+
mock_oft_mint_auth(&setup, &user, 1000_i128);
|
|
40
|
+
setup.sac_manager_client.mint(&user, &1000, &setup.minter);
|
|
41
|
+
|
|
42
|
+
mock_clawback_auth(&setup, &setup.owner, &user, 1000_i128);
|
|
43
|
+
setup.sac_manager_client.clawback(&user, &1000, &setup.owner);
|
|
44
|
+
assert_eq!(setup.sac_client.balance(&user), 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[test]
|
|
48
|
+
fn test_clawback_fails_when_amount_exceeds_balance() {
|
|
49
|
+
let setup = TestSetup::new().with_manager_as_sac_admin().build();
|
|
50
|
+
let user = setup.generate_address();
|
|
51
|
+
|
|
52
|
+
setup.sac_contract.issuer().set_flag(IssuerFlags::RevocableFlag);
|
|
53
|
+
setup.sac_contract.issuer().set_flag(IssuerFlags::ClawbackEnabledFlag);
|
|
54
|
+
|
|
55
|
+
mock_oft_mint_auth(&setup, &user, 100_i128);
|
|
56
|
+
setup.sac_manager_client.mint(&user, &100, &setup.minter);
|
|
57
|
+
|
|
58
|
+
mock_clawback_auth(&setup, &setup.owner, &user, 200_i128);
|
|
59
|
+
let result = setup.sac_manager_client.try_clawback(&user, &200, &setup.owner);
|
|
60
|
+
assert!(result.is_err());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#[test]
|
|
64
|
+
fn test_clawback_operator_without_role_fails() {
|
|
65
|
+
let setup = TestSetup::new().with_manager_as_sac_admin().build();
|
|
66
|
+
let user = setup.generate_address();
|
|
67
|
+
let random = setup.generate_address();
|
|
68
|
+
|
|
69
|
+
mock_clawback_auth(&setup, &random, &user, 500_i128);
|
|
70
|
+
let result = setup.sac_manager_client.try_clawback(&user, &500, &random);
|
|
71
|
+
assert_eq!(result.err().unwrap().unwrap(), RbacError::Unauthorized.into());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// =========================================================================
|
|
75
|
+
// clawback Auth Tests
|
|
76
|
+
// =========================================================================
|
|
77
|
+
|
|
78
|
+
#[test]
|
|
79
|
+
#[should_panic(expected = "Error(Auth, InvalidAction)")]
|
|
80
|
+
fn test_clawback_fails_without_auth() {
|
|
81
|
+
let setup = TestSetup::new().build();
|
|
82
|
+
let user = setup.generate_address();
|
|
83
|
+
|
|
84
|
+
setup.sac_manager_client.clawback(&user, &300, &setup.owner);
|
|
85
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
//! Mint (Mintable) Integration Tests
|
|
2
|
+
//!
|
|
3
|
+
//! Operator must hold MINTER_ROLE and authorize the call.
|
|
4
|
+
//! When the redistribution feature is enabled (escrow set) and the recipient
|
|
5
|
+
//! is blacklisted, funds are silently redirected to the escrow.
|
|
6
|
+
|
|
7
|
+
use super::test_helper::mock_set_authorized_auth;
|
|
8
|
+
use crate::sac_manager::RedistributeFunds;
|
|
9
|
+
use crate::tests::test_helper::{mock_auth, mock_oft_mint_auth, TestSetup};
|
|
10
|
+
use soroban_sdk::testutils::IssuerFlags;
|
|
11
|
+
use utils::{errors::RbacError, testing_utils::assert_contains_event};
|
|
12
|
+
|
|
13
|
+
// =========================================================================
|
|
14
|
+
// Mint success — operator with MINTER_ROLE calls mint
|
|
15
|
+
// =========================================================================
|
|
16
|
+
|
|
17
|
+
#[test]
|
|
18
|
+
fn test_mint_by_minter() {
|
|
19
|
+
let setup = TestSetup::new().with_manager_as_sac_admin().build();
|
|
20
|
+
let recipient = setup.generate_address();
|
|
21
|
+
|
|
22
|
+
mock_oft_mint_auth(&setup, &recipient, 1000_i128);
|
|
23
|
+
setup.sac_manager_client.mint(&recipient, &1000, &setup.minter);
|
|
24
|
+
assert_eq!(setup.sac_client.balance(&recipient), 1000);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// =========================================================================
|
|
28
|
+
// Mint redirect — escrow set + recipient blacklisted -> escrow receives funds
|
|
29
|
+
// =========================================================================
|
|
30
|
+
|
|
31
|
+
#[test]
|
|
32
|
+
fn test_mint_redistributes_when_recipient_blacklisted_and_escrow_set() {
|
|
33
|
+
let setup = TestSetup::new().with_manager_as_sac_admin().with_escrow_enabled().build();
|
|
34
|
+
let escrow = setup.escrow.clone().unwrap();
|
|
35
|
+
let blacklisted_recipient = setup.generate_address();
|
|
36
|
+
|
|
37
|
+
setup.sac_contract.issuer().set_flag(IssuerFlags::RevocableFlag);
|
|
38
|
+
setup.sac_contract.issuer().set_flag(IssuerFlags::ClawbackEnabledFlag);
|
|
39
|
+
|
|
40
|
+
mock_set_authorized_auth(&setup, &setup.owner, &blacklisted_recipient, false);
|
|
41
|
+
setup.sac_manager_client.set_authorized(&blacklisted_recipient, &false, &setup.owner);
|
|
42
|
+
assert!(!setup.sac_client.authorized(&blacklisted_recipient));
|
|
43
|
+
|
|
44
|
+
let escrow_balance_before = setup.sac_client.balance(&escrow);
|
|
45
|
+
let blacklisted_balance_before = setup.sac_client.balance(&blacklisted_recipient);
|
|
46
|
+
|
|
47
|
+
mock_oft_mint_auth(&setup, &blacklisted_recipient, 700_i128);
|
|
48
|
+
setup.sac_manager_client.mint(&blacklisted_recipient, &700, &setup.minter);
|
|
49
|
+
|
|
50
|
+
let expected = RedistributeFunds { user: blacklisted_recipient.clone(), escrow: escrow.clone(), amount: 700 };
|
|
51
|
+
assert_contains_event(&setup.env, &setup.sac_manager, expected);
|
|
52
|
+
|
|
53
|
+
assert_eq!(setup.sac_client.balance(&blacklisted_recipient), blacklisted_balance_before);
|
|
54
|
+
assert_eq!(setup.sac_client.balance(&escrow), escrow_balance_before + 700);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// =========================================================================
|
|
58
|
+
// Mint without escrow — escrow disabled, mint to blacklisted account fails
|
|
59
|
+
// at the SAC layer (no redirection happens).
|
|
60
|
+
// =========================================================================
|
|
61
|
+
|
|
62
|
+
#[test]
|
|
63
|
+
fn test_mint_to_blacklisted_without_escrow_fails() {
|
|
64
|
+
let setup = TestSetup::new().with_manager_as_sac_admin().build();
|
|
65
|
+
let blacklisted_recipient = setup.generate_address();
|
|
66
|
+
|
|
67
|
+
setup.sac_contract.issuer().set_flag(IssuerFlags::RevocableFlag);
|
|
68
|
+
|
|
69
|
+
mock_set_authorized_auth(&setup, &setup.owner, &blacklisted_recipient, false);
|
|
70
|
+
setup.sac_manager_client.set_authorized(&blacklisted_recipient, &false, &setup.owner);
|
|
71
|
+
|
|
72
|
+
mock_oft_mint_auth(&setup, &blacklisted_recipient, 500_i128);
|
|
73
|
+
let result = setup.sac_manager_client.try_mint(&blacklisted_recipient, &500, &setup.minter);
|
|
74
|
+
assert!(result.is_err());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// =========================================================================
|
|
78
|
+
// Mint role auth — operator without MINTER_ROLE fails
|
|
79
|
+
// =========================================================================
|
|
80
|
+
|
|
81
|
+
#[test]
|
|
82
|
+
fn test_mint_operator_without_role_fails() {
|
|
83
|
+
let setup = TestSetup::new().with_manager_as_sac_admin().build();
|
|
84
|
+
let recipient = setup.generate_address();
|
|
85
|
+
let random = setup.generate_address();
|
|
86
|
+
|
|
87
|
+
mock_auth(&setup.env, &setup.sac_manager, &random, "mint", (&recipient, 1000_i128, &random));
|
|
88
|
+
let result = setup.sac_manager_client.try_mint(&recipient, &1000, &random);
|
|
89
|
+
assert_eq!(result.err().unwrap().unwrap(), RbacError::Unauthorized.into());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// =========================================================================
|
|
93
|
+
// Mint auth — operator must authorize
|
|
94
|
+
// =========================================================================
|
|
95
|
+
|
|
96
|
+
#[test]
|
|
97
|
+
#[should_panic(expected = "Error(Auth, InvalidAction)")]
|
|
98
|
+
fn test_mint_fails_without_minter_auth() {
|
|
99
|
+
let setup = TestSetup::new().with_manager_as_sac_admin().build();
|
|
100
|
+
let recipient = setup.generate_address();
|
|
101
|
+
|
|
102
|
+
setup.sac_manager_client.mint(&recipient, &1000, &setup.minter);
|
|
103
|
+
}
|