@layerzerolabs/protocol-stellar-v2 0.2.51 → 0.2.52
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/.turbo/turbo-build.log +281 -185
- package/.turbo/turbo-lint.log +69 -70
- package/.turbo/turbo-test.log +2010 -1754
- package/Cargo.lock +16 -0
- package/Cargo.toml +1 -0
- package/contracts/oapps/console-oft/Cargo.toml +30 -0
- package/contracts/oapps/console-oft/integration-tests/extensions/mod.rs +5 -0
- package/contracts/oapps/console-oft/integration-tests/extensions/test_combined.rs +90 -0
- package/contracts/oapps/console-oft/integration-tests/extensions/test_oft_fee.rs +186 -0
- package/contracts/oapps/console-oft/integration-tests/extensions/test_ownership.rs +161 -0
- package/contracts/oapps/console-oft/integration-tests/extensions/test_pausable.rs +154 -0
- package/contracts/oapps/console-oft/integration-tests/extensions/test_rate_limiter.rs +479 -0
- package/contracts/oapps/console-oft/integration-tests/mod.rs +3 -0
- package/contracts/oapps/console-oft/integration-tests/setup.rs +303 -0
- package/contracts/oapps/console-oft/integration-tests/utils.rs +685 -0
- package/contracts/oapps/console-oft/src/errors.rs +7 -0
- package/contracts/oapps/console-oft/src/extensions/mod.rs +3 -0
- package/contracts/oapps/console-oft/src/extensions/oft_fee.rs +239 -0
- package/contracts/oapps/console-oft/src/extensions/pausable.rs +185 -0
- package/contracts/oapps/console-oft/src/extensions/rate_limiter.rs +478 -0
- package/contracts/oapps/console-oft/src/interfaces/mintable.rs +14 -0
- package/contracts/oapps/console-oft/src/interfaces/mod.rs +3 -0
- package/contracts/oapps/console-oft/src/lib.rs +26 -0
- package/contracts/oapps/console-oft/src/oft.rs +208 -0
- package/contracts/oapps/console-oft/src/oft_access_control.rs +93 -0
- package/contracts/oapps/console-oft/src/oft_types/lock_unlock.rs +50 -0
- package/contracts/oapps/console-oft/src/oft_types/mint_burn.rs +50 -0
- package/contracts/oapps/console-oft/src/oft_types/mod.rs +24 -0
- package/contracts/oapps/console-oft/src/tests/extensions/mod.rs +3 -0
- package/contracts/oapps/console-oft/src/tests/extensions/oft_fee.rs +255 -0
- package/contracts/oapps/console-oft/src/tests/extensions/pausable.rs +212 -0
- package/contracts/oapps/console-oft/src/tests/extensions/rate_limiter.rs +992 -0
- package/contracts/oapps/console-oft/src/tests/mod.rs +2 -0
- package/contracts/oapps/console-oft/src/tests/oft_types/lock_unlock.rs +185 -0
- package/contracts/oapps/console-oft/src/tests/oft_types/mod.rs +1 -0
- package/contracts/oapps/oft/src/extensions/oft_fee.rs +5 -2
- package/contracts/oapps/oft/src/tests/extensions/oft_fee.rs +1 -1
- package/contracts/oapps/sac-manager/src/sac_manager.rs +8 -0
- package/contracts/workers/worker/src/worker.rs +8 -1
- package/docs/oft-guide.md +2 -2
- package/package.json +4 -4
- package/sdk/.turbo/turbo-test.log +356 -422
- package/sdk/dist/generated/dvn.d.ts +9 -3
- package/sdk/dist/generated/dvn.js +4 -4
- package/sdk/dist/generated/executor.d.ts +9 -3
- package/sdk/dist/generated/executor.js +4 -4
- package/sdk/package.json +1 -1
- package/sdk/test/oft-sml.test.ts +22 -41
- package/sdk/test/sac-manager.test.ts +23 -22
- package/sdk/test/secp256k1.ts +59 -0
- package/sdk/test/suites/constants.ts +5 -1
- package/sdk/test/suites/deploy.ts +14 -8
- package/sdk/test/suites/globalSetup.ts +144 -60
- package/sdk/test/suites/localnet.ts +20 -25
- package/sdk/test/utils.ts +1 -61
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
//! Console OFT — Omnichain Fungible Token with RBAC, pausable, fee, and rate limiter extensions.
|
|
2
|
+
//!
|
|
3
|
+
//! Supports two operating modes:
|
|
4
|
+
//!
|
|
5
|
+
//! - **LockUnlock**: Locks tokens on send (transfer to contract), unlocks on receive.
|
|
6
|
+
//! - **MintBurn**: Burns tokens on send, mints on receive via a configurable `Mintable` contract.
|
|
7
|
+
//!
|
|
8
|
+
//! ## Extension Hooks (applied in `__debit` / `__credit`)
|
|
9
|
+
//!
|
|
10
|
+
//! **Send path (`__debit`):**
|
|
11
|
+
//! 1. Pause check (`__assert_not_paused`) — reverts if paused for the destination ID
|
|
12
|
+
//! 2. Token debit (burn or lock `amount_received_ld`)
|
|
13
|
+
//! 3. Rate limit outflow (`__outflow`) — consumes outbound capacity, releases inbound if net accounting
|
|
14
|
+
//! 4. Fee charge (`__charge_fee`) — transfers fee to the fee deposit address
|
|
15
|
+
//!
|
|
16
|
+
//! **Receive path (`__credit`):**
|
|
17
|
+
//! 1. Rate limit inflow (`__inflow`) — consumes inbound capacity, releases outbound if net accounting
|
|
18
|
+
//! 2. Token credit (mint or unlock `amount_ld`)
|
|
19
|
+
//! 3. No pause check, no fee
|
|
20
|
+
|
|
21
|
+
use crate::{
|
|
22
|
+
self as oft,
|
|
23
|
+
extensions::{
|
|
24
|
+
oft_fee::{OFTFee, OFTFeeInternal},
|
|
25
|
+
pausable::{OFTPausable, OFTPausableInternal},
|
|
26
|
+
rate_limiter::{RateLimiter, RateLimiterInternal, UNLIMITED_AMOUNT},
|
|
27
|
+
},
|
|
28
|
+
oft_types::{lock_unlock, mint_burn, OftType},
|
|
29
|
+
};
|
|
30
|
+
use common_macros::{contract_impl, storage, ttl_configurable, ttl_extendable, upgradeable};
|
|
31
|
+
use oapp_macros::oapp;
|
|
32
|
+
use oft_core::{
|
|
33
|
+
assert_nonnegative_amount, impl_oft_lz_receive, utils as oft_utils, OFTCore, OFTError, OFTFeeDetail, OFTInternal,
|
|
34
|
+
OFTLimit, OFTReceipt, SendParam,
|
|
35
|
+
};
|
|
36
|
+
use soroban_sdk::{assert_with_error, contract, vec, Address, Bytes, Env, Vec};
|
|
37
|
+
|
|
38
|
+
// =========================================================================
|
|
39
|
+
// Storage
|
|
40
|
+
// =========================================================================
|
|
41
|
+
|
|
42
|
+
#[storage]
|
|
43
|
+
enum OFTStorage {
|
|
44
|
+
#[instance(OftType)]
|
|
45
|
+
OftType,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// =========================================================================
|
|
49
|
+
// OFT Contract
|
|
50
|
+
// =========================================================================
|
|
51
|
+
|
|
52
|
+
#[contract]
|
|
53
|
+
#[ttl_configurable]
|
|
54
|
+
#[ttl_extendable]
|
|
55
|
+
#[upgradeable(no_migration, rbac)]
|
|
56
|
+
#[oapp(custom = [core])]
|
|
57
|
+
pub struct OFT;
|
|
58
|
+
|
|
59
|
+
// LzReceiveInternal implementation using default OFT receive logic
|
|
60
|
+
impl_oft_lz_receive!(OFT);
|
|
61
|
+
|
|
62
|
+
#[contract_impl]
|
|
63
|
+
impl OFT {
|
|
64
|
+
pub fn __constructor(
|
|
65
|
+
env: &Env,
|
|
66
|
+
token: &Address,
|
|
67
|
+
shared_decimals: u32,
|
|
68
|
+
oft_type: OftType,
|
|
69
|
+
endpoint: &Address,
|
|
70
|
+
delegate: &Address,
|
|
71
|
+
fee_deposit: &Address,
|
|
72
|
+
) {
|
|
73
|
+
Self::__initialize_oft(env, token, shared_decimals, delegate, endpoint, delegate);
|
|
74
|
+
OFTStorage::set_oft_type(env, &oft_type);
|
|
75
|
+
Self::__set_fee_deposit(env, fee_deposit);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Returns the OFT type with its target address and configuration.
|
|
79
|
+
pub fn oft_type(env: &Env) -> OftType {
|
|
80
|
+
OFTStorage::oft_type(env).unwrap()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// OFTCore trait implementation for console OFT with extensions
|
|
85
|
+
#[contract_impl(contracttrait)]
|
|
86
|
+
impl OFTCore for OFT {
|
|
87
|
+
fn quote_oft(env: &Env, _from: &Address, send_param: &SendParam) -> (OFTLimit, Vec<OFTFeeDetail>, OFTReceipt) {
|
|
88
|
+
assert_nonnegative_amount(env, send_param);
|
|
89
|
+
|
|
90
|
+
let dst_id = send_param.dst_eid as u128;
|
|
91
|
+
|
|
92
|
+
// 1. Rate limit capacity, accounting for pause
|
|
93
|
+
let mut max_amount_ld = if Self::is_paused(env, dst_id) {
|
|
94
|
+
0
|
|
95
|
+
} else {
|
|
96
|
+
Self::get_rate_limit_usages(env, dst_id).outbound_available_amount
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// 2. Back-calculate max sendable amount accounting for fees.
|
|
100
|
+
// If the rate limit is unlimited, that means there is no rate limiting applied.
|
|
101
|
+
if max_amount_ld != UNLIMITED_AMOUNT {
|
|
102
|
+
max_amount_ld = Self::get_amount_before_fee(env, dst_id, max_amount_ld);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let oft_limit = OFTLimit { min_amount_ld: 0, max_amount_ld };
|
|
106
|
+
|
|
107
|
+
// 3. Compute receipt
|
|
108
|
+
let (amount_sent_ld, amount_received_ld) =
|
|
109
|
+
Self::__debit_view(env, send_param.amount_ld, send_param.min_amount_ld, send_param.dst_eid);
|
|
110
|
+
let oft_receipt = OFTReceipt { amount_sent_ld, amount_received_ld };
|
|
111
|
+
|
|
112
|
+
// 4. Fee details
|
|
113
|
+
let fee_details = if amount_sent_ld > amount_received_ld {
|
|
114
|
+
vec![
|
|
115
|
+
env,
|
|
116
|
+
OFTFeeDetail {
|
|
117
|
+
fee_amount_ld: amount_sent_ld - amount_received_ld,
|
|
118
|
+
description: Bytes::from_slice(env, b"Fee"),
|
|
119
|
+
},
|
|
120
|
+
]
|
|
121
|
+
} else {
|
|
122
|
+
vec![env]
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
(oft_limit, fee_details, oft_receipt)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// OFT behavior for Console OFT with extension hooks
|
|
130
|
+
impl OFTInternal for OFT {
|
|
131
|
+
/// Calculates the amounts for a send without executing token operations.
|
|
132
|
+
///
|
|
133
|
+
/// UI should ensure amounts are dust-free to avoid unintended fees.
|
|
134
|
+
fn __debit_view(env: &Env, amount_ld: i128, min_amount_ld: i128, dst_eid: u32) -> (i128, i128) {
|
|
135
|
+
let fee = Self::__get_fee(env, dst_eid as u128, amount_ld);
|
|
136
|
+
let conversion_rate = Self::__decimal_conversion_rate(env);
|
|
137
|
+
let amount_received_ld = oft_utils::remove_dust(amount_ld - fee, conversion_rate);
|
|
138
|
+
|
|
139
|
+
assert_with_error!(env, amount_received_ld >= min_amount_ld, OFTError::SlippageExceeded);
|
|
140
|
+
|
|
141
|
+
(amount_ld, amount_received_ld)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// Executes the full send-side debit with all extension hooks.
|
|
145
|
+
///
|
|
146
|
+
/// Flow: pause check → token debit → rate limit outflow → fee charge.
|
|
147
|
+
fn __debit(env: &Env, sender: &Address, amount_ld: i128, min_amount_ld: i128, dst_eid: u32) -> (i128, i128) {
|
|
148
|
+
Self::__assert_not_paused(env, dst_eid as u128);
|
|
149
|
+
|
|
150
|
+
let (amount_sent_ld, amount_received_ld) = match Self::oft_type(env) {
|
|
151
|
+
OftType::LockUnlock => {
|
|
152
|
+
lock_unlock::debit::<Self>(env, &Self::token(env), sender, amount_ld, min_amount_ld, dst_eid)
|
|
153
|
+
}
|
|
154
|
+
OftType::MintBurn(_mintable) => {
|
|
155
|
+
mint_burn::debit::<Self>(env, &Self::token(env), sender, amount_ld, min_amount_ld, dst_eid)
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
Self::__outflow(env, dst_eid as u128, sender, amount_received_ld);
|
|
160
|
+
|
|
161
|
+
let fee = amount_sent_ld - amount_received_ld;
|
|
162
|
+
Self::__charge_fee(env, &Self::token(env), sender, fee);
|
|
163
|
+
|
|
164
|
+
(amount_sent_ld, amount_received_ld)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Executes the full receive-side credit with extension hooks.
|
|
168
|
+
///
|
|
169
|
+
/// Flow: rate limit inflow → token credit.
|
|
170
|
+
///
|
|
171
|
+
/// No pause check on receive — this is intentional to prevent in-flight token lockups
|
|
172
|
+
/// when a chain is paused mid-transfer. Matches EVM where `whenNotPaused` is only on
|
|
173
|
+
/// `_debit`, not `_credit`.
|
|
174
|
+
fn __credit(env: &Env, to: &Address, amount_ld: i128, src_eid: u32) -> i128 {
|
|
175
|
+
Self::__inflow(env, src_eid as u128, to, amount_ld);
|
|
176
|
+
|
|
177
|
+
match Self::oft_type(env) {
|
|
178
|
+
OftType::LockUnlock => lock_unlock::credit::<Self>(env, &Self::token(env), to, amount_ld, src_eid),
|
|
179
|
+
OftType::MintBurn(mintable) => mint_burn::credit::<Self>(env, &mintable, to, amount_ld, src_eid),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// =========================================================================
|
|
185
|
+
// Extension Trait Implementations
|
|
186
|
+
// =========================================================================
|
|
187
|
+
|
|
188
|
+
/// Pausable extension — per-Destination ID pause/unpause with separate PAUSER/UNPAUSER roles.
|
|
189
|
+
/// Default state: unpaused. Only enforced on send path (`__debit`), not receive (`__credit`).
|
|
190
|
+
#[contract_impl(contracttrait)]
|
|
191
|
+
impl OFTPausable for OFT {}
|
|
192
|
+
impl OFTPausableInternal for OFT {}
|
|
193
|
+
|
|
194
|
+
/// Fee extension — proportional fee on outbound transfers.
|
|
195
|
+
/// Default state: 0 BPS (no fee). Fees transferred to the fee deposit address.
|
|
196
|
+
#[contract_impl(contracttrait)]
|
|
197
|
+
impl OFTFee for OFT {}
|
|
198
|
+
impl OFTFeeInternal for OFT {}
|
|
199
|
+
|
|
200
|
+
/// Rate limiter extension — rolling window decay with net accounting.
|
|
201
|
+
/// Default state: closed (limits=0, outbound+inbound enabled). Admin must set limits to open.
|
|
202
|
+
#[contract_impl(contracttrait)]
|
|
203
|
+
impl RateLimiter for OFT {}
|
|
204
|
+
impl RateLimiterInternal for OFT {}
|
|
205
|
+
|
|
206
|
+
// Console-specific access control (ownership, auth, RBAC)
|
|
207
|
+
#[path = "oft_access_control.rs"]
|
|
208
|
+
mod oft_access_control;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//! Console-specific access control: ownership, auth, and RBAC.
|
|
2
|
+
//!
|
|
3
|
+
//! - Delegate is permanently synced with the owner.
|
|
4
|
+
//! - `transfer_ownership` (single-step) and `renounce_ownership` are disabled.
|
|
5
|
+
//! - Only 2-step transfer via `begin_ownership_transfer` + `accept_ownership`.
|
|
6
|
+
//! - `accept_ownership` auto-syncs the endpoint delegate to the new owner.
|
|
7
|
+
|
|
8
|
+
use super::{OFTArgs, OFTClient, OFT};
|
|
9
|
+
use crate::errors::OFTError;
|
|
10
|
+
use common_macros::contract_impl;
|
|
11
|
+
use endpoint_v2::LayerZeroEndpointV2Client;
|
|
12
|
+
use oapp::oapp_core::OAppCore;
|
|
13
|
+
use soroban_sdk::{panic_with_error, Address, Env};
|
|
14
|
+
use utils::{
|
|
15
|
+
auth::Auth,
|
|
16
|
+
ownable::{Ownable, OwnableInitializer},
|
|
17
|
+
rbac::RoleBasedAccessControl,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// =========================================================================
|
|
21
|
+
// Ownable & Auth implementation for OFT
|
|
22
|
+
// =========================================================================
|
|
23
|
+
|
|
24
|
+
impl OwnableInitializer for OFT {}
|
|
25
|
+
|
|
26
|
+
#[contract_impl]
|
|
27
|
+
impl Auth for OFT {
|
|
28
|
+
fn authorizer(env: &soroban_sdk::Env) -> Option<Address> {
|
|
29
|
+
Self::owner(env)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[contract_impl(contracttrait)]
|
|
34
|
+
impl Ownable for OFT {
|
|
35
|
+
/// Disabled one-step ownership transfer.
|
|
36
|
+
fn transfer_ownership(env: &soroban_sdk::Env, _new_owner: &soroban_sdk::Address) {
|
|
37
|
+
panic_with_error!(env, OFTError::Disabled);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Disabled renounce ownership.
|
|
41
|
+
fn renounce_ownership(env: &soroban_sdk::Env) {
|
|
42
|
+
panic_with_error!(env, OFTError::Disabled);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Accepts ownership and syncs the delegate on the endpoint to the new owner.
|
|
46
|
+
fn accept_ownership(env: &soroban_sdk::Env) {
|
|
47
|
+
OwnableDefault::accept_ownership(env);
|
|
48
|
+
|
|
49
|
+
let new_owner = Self::owner(env).unwrap();
|
|
50
|
+
LayerZeroEndpointV2Client::new(env, &Self::endpoint(env))
|
|
51
|
+
.set_delegate(&env.current_contract_address(), &Some(new_owner));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// =========================================================================
|
|
56
|
+
// RoleBasedAccessControl implementation
|
|
57
|
+
// =========================================================================
|
|
58
|
+
|
|
59
|
+
#[contract_impl(contracttrait)]
|
|
60
|
+
impl RoleBasedAccessControl for OFT {}
|
|
61
|
+
|
|
62
|
+
// =========================================================================
|
|
63
|
+
// OAppCore override — set_delegate disabled
|
|
64
|
+
// =========================================================================
|
|
65
|
+
|
|
66
|
+
#[contract_impl(contracttrait)]
|
|
67
|
+
impl OAppCore for OFT {
|
|
68
|
+
/// Disabled set delegate.
|
|
69
|
+
fn set_delegate(
|
|
70
|
+
env: &soroban_sdk::Env,
|
|
71
|
+
_delegate: &Option<soroban_sdk::Address>,
|
|
72
|
+
_operator: &soroban_sdk::Address,
|
|
73
|
+
) {
|
|
74
|
+
panic_with_error!(env, OFTError::Disabled);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// =========================================================================
|
|
79
|
+
// Helper: access default Ownable implementation
|
|
80
|
+
// =========================================================================
|
|
81
|
+
|
|
82
|
+
/// Helper type to call the default `accept_ownership` logic before applying
|
|
83
|
+
/// Console-specific post-acceptance hooks (delegate sync).
|
|
84
|
+
/// Reads from the same `OwnableStorage` — no duplication of logic.
|
|
85
|
+
struct OwnableDefault;
|
|
86
|
+
|
|
87
|
+
impl Ownable for OwnableDefault {}
|
|
88
|
+
|
|
89
|
+
impl Auth for OwnableDefault {
|
|
90
|
+
fn authorizer(env: &Env) -> Option<Address> {
|
|
91
|
+
<Self as Ownable>::owner(env)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//! LockUnlock type implementation for OFT.
|
|
2
|
+
//!
|
|
3
|
+
//! This OFT type locks tokens in the contract on debit (send) and unlocks
|
|
4
|
+
//! tokens from the contract on credit (receive).
|
|
5
|
+
//! Operates directly on the token via standard SEP-41 `transfer`.
|
|
6
|
+
|
|
7
|
+
use oft_core::OFTCore;
|
|
8
|
+
use soroban_sdk::{token::TokenClient, Address, Env};
|
|
9
|
+
|
|
10
|
+
/// Debit tokens using LockUnlock OFT type (locks tokens in contract).
|
|
11
|
+
///
|
|
12
|
+
/// # Parameters
|
|
13
|
+
/// * `env` - The Soroban environment
|
|
14
|
+
/// * `token` - Address of the token
|
|
15
|
+
/// * `sender` - Address of the token sender
|
|
16
|
+
/// * `amount_ld` - Amount to debit in local decimals
|
|
17
|
+
/// * `min_amount_ld` - Minimum amount that must be received (for slippage protection)
|
|
18
|
+
/// * `dst_eid` - Destination endpoint ID
|
|
19
|
+
///
|
|
20
|
+
/// # Returns
|
|
21
|
+
/// * `amount_sent_ld` - The amount sent in local decimals
|
|
22
|
+
/// * `amount_received_ld` - The amount received in local decimals on the remote
|
|
23
|
+
pub fn debit<T: OFTCore>(
|
|
24
|
+
env: &Env,
|
|
25
|
+
token: &Address,
|
|
26
|
+
sender: &Address,
|
|
27
|
+
amount_ld: i128,
|
|
28
|
+
min_amount_ld: i128,
|
|
29
|
+
dst_eid: u32,
|
|
30
|
+
) -> (i128, i128) {
|
|
31
|
+
let (amount_sent_ld, amount_received_ld) = T::__debit_view(env, amount_ld, min_amount_ld, dst_eid);
|
|
32
|
+
TokenClient::new(env, token).transfer(sender, env.current_contract_address(), &amount_received_ld);
|
|
33
|
+
(amount_sent_ld, amount_received_ld)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Credit tokens using LockUnlock OFT type (unlocks tokens from contract).
|
|
37
|
+
///
|
|
38
|
+
/// # Parameters
|
|
39
|
+
/// * `env` - The Soroban environment
|
|
40
|
+
/// * `token` - Address of the token
|
|
41
|
+
/// * `to` - Address of the token recipient
|
|
42
|
+
/// * `amount_ld` - Amount to credit in local decimals
|
|
43
|
+
/// * `_src_eid` - Source endpoint ID (unused)
|
|
44
|
+
///
|
|
45
|
+
/// # Returns
|
|
46
|
+
/// The amount credited
|
|
47
|
+
pub fn credit<T: OFTCore>(env: &Env, token: &Address, to: &Address, amount_ld: i128, _src_eid: u32) -> i128 {
|
|
48
|
+
TokenClient::new(env, token).transfer(&env.current_contract_address(), to, &amount_ld);
|
|
49
|
+
amount_ld
|
|
50
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//! MintBurn type implementation for OFT.
|
|
2
|
+
//!
|
|
3
|
+
//! This OFT type burns tokens on debit (send) and mints tokens on credit (receive).
|
|
4
|
+
//! Used when the OFT contract has mint authority over the token.
|
|
5
|
+
|
|
6
|
+
use crate::interfaces::MintableClient;
|
|
7
|
+
use oft_core::OFTCore;
|
|
8
|
+
use soroban_sdk::{token::TokenClient, Address, Env};
|
|
9
|
+
|
|
10
|
+
/// Debit tokens using MintBurn OFT type (burns tokens from sender).
|
|
11
|
+
///
|
|
12
|
+
/// # Parameters
|
|
13
|
+
/// * `env` - The Soroban environment
|
|
14
|
+
/// * `token` - Address of the token (SAC) to burn from
|
|
15
|
+
/// * `sender` - Address of the token sender
|
|
16
|
+
/// * `amount_ld` - Amount to debit in local decimals
|
|
17
|
+
/// * `min_amount_ld` - Minimum amount that must be received (for slippage protection)
|
|
18
|
+
/// * `dst_eid` - Destination endpoint ID
|
|
19
|
+
///
|
|
20
|
+
/// # Returns
|
|
21
|
+
/// * `amount_sent_ld` - The amount sent in local decimals
|
|
22
|
+
/// * `amount_received_ld` - The amount received in local decimals on the remote
|
|
23
|
+
pub fn debit<T: OFTCore>(
|
|
24
|
+
env: &Env,
|
|
25
|
+
token: &Address,
|
|
26
|
+
sender: &Address,
|
|
27
|
+
amount_ld: i128,
|
|
28
|
+
min_amount_ld: i128,
|
|
29
|
+
dst_eid: u32,
|
|
30
|
+
) -> (i128, i128) {
|
|
31
|
+
let (amount_sent_ld, amount_received_ld) = T::__debit_view(env, amount_ld, min_amount_ld, dst_eid);
|
|
32
|
+
TokenClient::new(env, token).burn(sender, &amount_received_ld);
|
|
33
|
+
(amount_sent_ld, amount_received_ld)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Credit tokens using MintBurn OFT type (mints tokens to recipient).
|
|
37
|
+
///
|
|
38
|
+
/// # Parameters
|
|
39
|
+
/// * `env` - The Soroban environment
|
|
40
|
+
/// * `mintable` - Address of the contract responsible for minting tokens (e.g. SAC wrapper)
|
|
41
|
+
/// * `to` - Address of the token recipient
|
|
42
|
+
/// * `amount_ld` - Amount to credit in local decimals
|
|
43
|
+
/// * `_src_eid` - Source endpoint ID (unused)
|
|
44
|
+
///
|
|
45
|
+
/// # Returns
|
|
46
|
+
/// The amount credited
|
|
47
|
+
pub fn credit<T: OFTCore>(env: &Env, mintable: &Address, to: &Address, amount_ld: i128, _src_eid: u32) -> i128 {
|
|
48
|
+
MintableClient::new(env, mintable).mint(to, &amount_ld, &env.current_contract_address());
|
|
49
|
+
amount_ld
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//! OFT mode implementations.
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides reference implementations for the two main OFT types:
|
|
4
|
+
//!
|
|
5
|
+
//! - **LockUnlock**: Locks tokens on send, unlocks on receive. Operates directly on the
|
|
6
|
+
//! token via standard SEP-41 `transfer`.
|
|
7
|
+
//! - **MintBurn**: Burns tokens on send (via TokenClient on the token), mints on receive
|
|
8
|
+
//! via a contract that implements [`Mintable`](crate::interfaces::Mintable).
|
|
9
|
+
|
|
10
|
+
use soroban_sdk::{contracttype, Address};
|
|
11
|
+
|
|
12
|
+
pub mod lock_unlock;
|
|
13
|
+
pub mod mint_burn;
|
|
14
|
+
|
|
15
|
+
/// The OFT operation type.
|
|
16
|
+
#[contracttype]
|
|
17
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
18
|
+
pub enum OftType {
|
|
19
|
+
/// Lock tokens on send, unlock on receive.
|
|
20
|
+
LockUnlock,
|
|
21
|
+
/// Burn tokens on send, mint on receive.
|
|
22
|
+
/// The address is the Mintable contract used for minting on credit
|
|
23
|
+
MintBurn(Address),
|
|
24
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
extern crate std;
|
|
2
|
+
|
|
3
|
+
use crate::extensions::oft_fee::FEE_CONFIG_MANAGER_ROLE;
|
|
4
|
+
use crate::extensions::oft_fee::{OFTFee, OFTFeeError, OFTFeeInternal};
|
|
5
|
+
use soroban_sdk::{
|
|
6
|
+
contract, contractimpl,
|
|
7
|
+
testutils::Address as _,
|
|
8
|
+
token::{StellarAssetClient, TokenClient},
|
|
9
|
+
Address, Env, Symbol,
|
|
10
|
+
};
|
|
11
|
+
use utils::auth::Auth;
|
|
12
|
+
use utils::rbac::{grant_role_no_auth, RoleBasedAccessControl};
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Test Contract
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
#[contract]
|
|
19
|
+
struct FeeTestContract;
|
|
20
|
+
|
|
21
|
+
impl Auth for FeeTestContract {
|
|
22
|
+
fn authorizer(env: &Env) -> Option<Address> {
|
|
23
|
+
Some(env.current_contract_address())
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
impl OFTFeeInternal for FeeTestContract {}
|
|
28
|
+
|
|
29
|
+
#[contractimpl(contracttrait)]
|
|
30
|
+
impl OFTFee for FeeTestContract {}
|
|
31
|
+
|
|
32
|
+
#[contractimpl(contracttrait)]
|
|
33
|
+
impl RoleBasedAccessControl for FeeTestContract {}
|
|
34
|
+
|
|
35
|
+
#[contractimpl]
|
|
36
|
+
impl FeeTestContract {
|
|
37
|
+
pub fn init_roles(env: Env) {
|
|
38
|
+
let contract_id = env.current_contract_address();
|
|
39
|
+
grant_role_no_auth(&env, &contract_id, &Symbol::new(&env, FEE_CONFIG_MANAGER_ROLE), &contract_id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pub fn charge_fee(env: Env, token: Address, from: Address, fee_amount: i128) {
|
|
43
|
+
<Self as OFTFeeInternal>::__charge_fee(&env, &token, &from, fee_amount);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Test Setup
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
struct TestSetup {
|
|
52
|
+
env: Env,
|
|
53
|
+
client: FeeTestContractClient<'static>,
|
|
54
|
+
contract_id: Address,
|
|
55
|
+
token: Address,
|
|
56
|
+
from: Address,
|
|
57
|
+
fee_deposit: Address,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fn setup() -> TestSetup {
|
|
61
|
+
let env = Env::default();
|
|
62
|
+
env.mock_all_auths_allowing_non_root_auth();
|
|
63
|
+
|
|
64
|
+
let contract_id = env.register(FeeTestContract, ());
|
|
65
|
+
let client = FeeTestContractClient::new(&env, &contract_id);
|
|
66
|
+
client.init_roles();
|
|
67
|
+
|
|
68
|
+
let token_admin = Address::generate(&env);
|
|
69
|
+
let sac = env.register_stellar_asset_contract_v2(token_admin.clone());
|
|
70
|
+
let token = sac.address();
|
|
71
|
+
|
|
72
|
+
let from = Address::generate(&env);
|
|
73
|
+
let fee_deposit = Address::generate(&env);
|
|
74
|
+
|
|
75
|
+
StellarAssetClient::new(&env, &token).mint(&from, &1_000_000i128);
|
|
76
|
+
|
|
77
|
+
TestSetup { env, client, contract_id, token, from, fee_deposit }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn id(v: u32) -> u128 {
|
|
81
|
+
v as u128
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Set Default Fee BPS Tests
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
#[test]
|
|
89
|
+
fn test_set_default_fee_bps_rejects_invalid_value() {
|
|
90
|
+
let TestSetup { client, contract_id, .. } = setup();
|
|
91
|
+
|
|
92
|
+
let res = client.try_set_default_fee_bps(&10_001u32, &contract_id);
|
|
93
|
+
assert_eq!(res.err().unwrap().ok().unwrap(), OFTFeeError::InvalidBps.into());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// Set Fee BPS Tests
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
#[test]
|
|
101
|
+
fn test_set_fee_bps_rejects_invalid_value() {
|
|
102
|
+
let TestSetup { client, contract_id, .. } = setup();
|
|
103
|
+
let id_101 = id(101);
|
|
104
|
+
|
|
105
|
+
let res = client.try_set_fee_bps(&id_101, &Some(10_001u32), &contract_id);
|
|
106
|
+
assert_eq!(res.err().unwrap().ok().unwrap(), OFTFeeError::InvalidBps.into());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#[test]
|
|
110
|
+
fn test_set_fee_bps_set_and_remove() {
|
|
111
|
+
let TestSetup { client, contract_id, .. } = setup();
|
|
112
|
+
let id_101 = id(101);
|
|
113
|
+
|
|
114
|
+
client.set_fee_bps(&id_101, &Some(200u32), &contract_id);
|
|
115
|
+
assert_eq!(client.fee_bps(&id_101), Some(200));
|
|
116
|
+
assert_eq!(client.get_fee(&id_101, &10_000i128), 200);
|
|
117
|
+
|
|
118
|
+
client.set_default_fee_bps(&111u32, &contract_id);
|
|
119
|
+
client.set_fee_bps(&id_101, &None, &contract_id);
|
|
120
|
+
assert_eq!(client.fee_bps(&id_101), None);
|
|
121
|
+
assert_eq!(client.default_fee_bps(), 111);
|
|
122
|
+
assert_eq!(client.get_fee(&id_101, &10_000i128), 111);
|
|
123
|
+
|
|
124
|
+
// Per-ID Some(0) explicitly overrides default to zero fee
|
|
125
|
+
client.set_fee_bps(&id_101, &Some(0u32), &contract_id);
|
|
126
|
+
assert_eq!(client.fee_bps(&id_101), Some(0));
|
|
127
|
+
assert_eq!(client.get_fee(&id_101, &10_000i128), 0);
|
|
128
|
+
assert_eq!(client.get_fee(&id(99), &10_000i128), 111, "other IDs still use default");
|
|
129
|
+
|
|
130
|
+
// set_default_fee_bps(0) removes the default fee
|
|
131
|
+
client.set_default_fee_bps(&0u32, &contract_id);
|
|
132
|
+
assert_eq!(client.default_fee_bps(), 0);
|
|
133
|
+
assert_eq!(client.get_fee(&id(99), &10_000i128), 0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ============================================================================
|
|
137
|
+
// Set Fee Deposit Tests
|
|
138
|
+
// ============================================================================
|
|
139
|
+
|
|
140
|
+
#[test]
|
|
141
|
+
fn test_set_fee_deposit_same_value() {
|
|
142
|
+
let TestSetup { client, contract_id, fee_deposit, .. } = setup();
|
|
143
|
+
|
|
144
|
+
client.set_fee_deposit(&fee_deposit, &contract_id);
|
|
145
|
+
let res = client.try_set_fee_deposit(&fee_deposit, &contract_id);
|
|
146
|
+
assert_eq!(res.err().unwrap().ok().unwrap(), OFTFeeError::SameValue.into());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ============================================================================
|
|
150
|
+
// Charge Fee Tests
|
|
151
|
+
// ============================================================================
|
|
152
|
+
|
|
153
|
+
#[test]
|
|
154
|
+
fn test_charge_fee_zero_amount_no_transfer() {
|
|
155
|
+
let TestSetup { env, client, contract_id, token, from, fee_deposit, .. } = setup();
|
|
156
|
+
|
|
157
|
+
client.set_fee_deposit(&fee_deposit, &contract_id);
|
|
158
|
+
|
|
159
|
+
let token_client = TokenClient::new(&env, &token);
|
|
160
|
+
let from_before = token_client.balance(&from);
|
|
161
|
+
let dep_before = token_client.balance(&fee_deposit);
|
|
162
|
+
|
|
163
|
+
client.charge_fee(&token, &from, &0i128);
|
|
164
|
+
|
|
165
|
+
assert_eq!(token_client.balance(&from), from_before);
|
|
166
|
+
assert_eq!(token_client.balance(&fee_deposit), dep_before);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#[test]
|
|
170
|
+
fn test_charge_fee_transfers() {
|
|
171
|
+
let TestSetup { env, client, contract_id, token, from, fee_deposit, .. } = setup();
|
|
172
|
+
|
|
173
|
+
client.set_fee_deposit(&fee_deposit, &contract_id);
|
|
174
|
+
|
|
175
|
+
let token_client = TokenClient::new(&env, &token);
|
|
176
|
+
let from_before = token_client.balance(&from);
|
|
177
|
+
let dep_before = token_client.balance(&fee_deposit);
|
|
178
|
+
|
|
179
|
+
client.charge_fee(&token, &from, &123i128);
|
|
180
|
+
|
|
181
|
+
assert_eq!(token_client.balance(&from), from_before - 123);
|
|
182
|
+
assert_eq!(token_client.balance(&fee_deposit), dep_before + 123);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ============================================================================
|
|
186
|
+
// Fee View Tests
|
|
187
|
+
// ============================================================================
|
|
188
|
+
|
|
189
|
+
#[test]
|
|
190
|
+
fn test_fee_view_no_fee_returns_zero() {
|
|
191
|
+
let TestSetup { client, .. } = setup();
|
|
192
|
+
|
|
193
|
+
assert_eq!(client.get_fee(&id(1), &10_000i128), 0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#[test]
|
|
197
|
+
fn test_fee_view_computes_correct_fee() {
|
|
198
|
+
let TestSetup { client, contract_id, fee_deposit, .. } = setup();
|
|
199
|
+
|
|
200
|
+
client.set_fee_deposit(&fee_deposit, &contract_id);
|
|
201
|
+
client.set_default_fee_bps(&250u32, &contract_id);
|
|
202
|
+
|
|
203
|
+
assert_eq!(client.get_fee(&id(1), &10_000i128), 250);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================================================
|
|
207
|
+
// Get Amount Before Fee Tests
|
|
208
|
+
// ============================================================================
|
|
209
|
+
|
|
210
|
+
#[test]
|
|
211
|
+
fn test_get_amount_before_fee_no_fee() {
|
|
212
|
+
let TestSetup { client, .. } = setup();
|
|
213
|
+
|
|
214
|
+
let result = client.get_amount_before_fee(&id(1), &10_000i128);
|
|
215
|
+
assert_eq!(result, 10_000);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[test]
|
|
219
|
+
fn test_get_amount_before_fee_roundtrip() {
|
|
220
|
+
let TestSetup { client, contract_id, .. } = setup();
|
|
221
|
+
|
|
222
|
+
client.set_default_fee_bps(&250u32, &contract_id); // 2.5%
|
|
223
|
+
|
|
224
|
+
let original = 100_000i128;
|
|
225
|
+
let fee = client.get_fee(&id(1), &original);
|
|
226
|
+
let after_fee = original - fee;
|
|
227
|
+
let recovered = client.get_amount_before_fee(&id(1), &after_fee);
|
|
228
|
+
assert_eq!(recovered, original);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#[test]
|
|
232
|
+
fn test_get_amount_before_fee_full_fee_returns_zero() {
|
|
233
|
+
let TestSetup { client, contract_id, .. } = setup();
|
|
234
|
+
|
|
235
|
+
client.set_default_fee_bps(&10_000u32, &contract_id); // 100%
|
|
236
|
+
|
|
237
|
+
let result = client.get_amount_before_fee(&id(1), &5_000i128);
|
|
238
|
+
assert_eq!(result, 0);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[test]
|
|
242
|
+
fn test_get_amount_before_fee_per_destination_override() {
|
|
243
|
+
let TestSetup { client, contract_id, .. } = setup();
|
|
244
|
+
|
|
245
|
+
client.set_default_fee_bps(&100u32, &contract_id); // 1% default
|
|
246
|
+
client.set_fee_bps(&id(42), &Some(500u32), &contract_id); // 5% for id 42
|
|
247
|
+
|
|
248
|
+
// 5% fee: after = 9_500 → before = 9_500 * 10_000 / 9_500 = 10_000
|
|
249
|
+
let result = client.get_amount_before_fee(&id(42), &9_500i128);
|
|
250
|
+
assert_eq!(result, 10_000);
|
|
251
|
+
|
|
252
|
+
// Other destinations still use default 1%
|
|
253
|
+
let result_default = client.get_amount_before_fee(&id(99), &9_900i128);
|
|
254
|
+
assert_eq!(result_default, 10_000);
|
|
255
|
+
}
|