@layerzerolabs/oft-core-stellar-contracts 0.2.122

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.
@@ -0,0 +1,870 @@
1
+ //! Shared test utilities for OFT unit tests.
2
+ //!
3
+ //! This module provides common test contracts and helpers used across multiple test files.
4
+
5
+ use crate::{
6
+ codec::oft_msg_codec::OFTMessage,
7
+ oft_core::OFTClient,
8
+ types::{OFTReceipt, SendParam},
9
+ };
10
+ use endpoint_v2::{LayerZeroReceiverClient, MessagingFee, MessagingParams, MessagingReceipt, Origin};
11
+ use oapp::oapp_core::OAppCoreClient;
12
+ use soroban_sdk::{
13
+ address_payload::AddressPayload,
14
+ bytes, contract, contractimpl, log, symbol_short,
15
+ testutils::{Address as _, MockAuth, MockAuthInvoke},
16
+ token::{StellarAssetClient, TokenClient},
17
+ Address, Bytes, BytesN, Env, IntoVal, String, Symbol,
18
+ };
19
+
20
+ // ==================== Constants ====================
21
+
22
+ /// Default shared decimals used for cross-chain normalization in tests
23
+ pub const DEFAULT_SHARED_DECIMALS: u32 = 6;
24
+
25
+ // ==================== Helper Functions ====================
26
+
27
+ /// Create a SendParam for testing with default options.
28
+ pub fn create_send_param(env: &Env, dst_eid: u32, amount_ld: i128, min_amount_ld: i128) -> SendParam {
29
+ SendParam {
30
+ dst_eid,
31
+ to: BytesN::from_array(env, &[1u8; 32]),
32
+ amount_ld,
33
+ min_amount_ld,
34
+ extra_options: bytes!(env),
35
+ compose_msg: bytes!(env),
36
+ oft_cmd: bytes!(env),
37
+ }
38
+ }
39
+
40
+ /// Creates a valid recipient address by deploying a dummy contract.
41
+ /// Use this in tests when the address needs to pass the `.exists()` check.
42
+ pub fn create_recipient_address(env: &Env) -> Address {
43
+ env.register(DummyRecipient, ())
44
+ }
45
+
46
+ /// Creates a G-address (account address) from a 32-byte Ed25519 public key.
47
+ /// This is useful for testing with account addresses instead of contract addresses.
48
+ pub fn create_g_address(env: &Env, public_key: &BytesN<32>) -> Address {
49
+ Address::from_payload(env, AddressPayload::AccountIdPublicKeyEd25519(public_key.clone()))
50
+ }
51
+
52
+ /// Generates a unique G-address (account address) for testing.
53
+ /// Each call generates a different address by using a counter-based approach.
54
+ pub fn generate_g_address(env: &Env) -> Address {
55
+ // Use Address::generate which creates a unique address each time
56
+ // Then convert it to ensure it's a G-address (account address)
57
+ let addr = Address::generate(env);
58
+ // Extract the payload - if it's already a G-address, use it; otherwise convert
59
+ match addr.to_payload() {
60
+ Some(AddressPayload::AccountIdPublicKeyEd25519(_pk)) => {
61
+ // Already a G-address, return as-is
62
+ addr
63
+ }
64
+ Some(AddressPayload::ContractIdHash(hash)) => {
65
+ // It's a contract address, convert hash to G-address
66
+ // Use the hash bytes as the Ed25519 public key
67
+ create_g_address(env, &hash)
68
+ }
69
+ None => {
70
+ // Fallback: create from hash bytes
71
+ let hash = BytesN::from_array(env, &[0u8; 32]);
72
+ create_g_address(env, &hash)
73
+ }
74
+ }
75
+ }
76
+
77
+ pub fn encode_oft_message(env: &Env, send_to: &BytesN<32>, amount_sd: u64) -> Bytes {
78
+ let msg = OFTMessage { send_to: send_to.clone(), amount_sd, compose: None };
79
+ msg.encode(env)
80
+ }
81
+
82
+ pub fn encode_oft_message_with_compose(
83
+ env: &Env,
84
+ send_to: &BytesN<32>,
85
+ amount_sd: u64,
86
+ compose_from: &BytesN<32>,
87
+ compose_msg: &Bytes,
88
+ ) -> Bytes {
89
+ use crate::codec::oft_msg_codec::ComposeData;
90
+ let msg = OFTMessage {
91
+ send_to: send_to.clone(),
92
+ amount_sd,
93
+ compose: Some(ComposeData { from: compose_from.clone(), msg: compose_msg.clone() }),
94
+ };
95
+ msg.encode(env)
96
+ }
97
+
98
+ pub fn create_origin(src_eid: u32, sender: &BytesN<32>, nonce: u64) -> Origin {
99
+ Origin { src_eid, sender: sender.clone(), nonce }
100
+ }
101
+
102
+ // ==================== Test OFT Contracts ====================
103
+
104
+ mod test_mint_burn_oft {
105
+ use crate::{
106
+ self as oft_core,
107
+ oft_core::{OFTCore, OFTInternal},
108
+ };
109
+ use endpoint_v2::Origin;
110
+ use oapp::oapp_receiver::LzReceiveInternal;
111
+ use soroban_sdk::{contractclient, contractimpl, Address, Bytes, BytesN, Env};
112
+
113
+ #[contractclient(name = "MintBurnTokenClient")]
114
+ #[allow(dead_code)]
115
+ trait MintBurnToken {
116
+ fn mint(env: Env, to: Address, amount: i128);
117
+ fn burn(env: Env, from: Address, amount: i128);
118
+ }
119
+
120
+ #[oapp_macros::oapp]
121
+ #[common_macros::lz_contract]
122
+ pub struct TestMintBurnOFT;
123
+
124
+ #[contractimpl]
125
+ impl TestMintBurnOFT {
126
+ pub fn __constructor(
127
+ env: &Env,
128
+ token: &Address,
129
+ owner: &Address,
130
+ endpoint: &Address,
131
+ delegate: &Address,
132
+ shared_decimals: u32,
133
+ ) {
134
+ Self::__initialize_oft(env, token, shared_decimals, owner, endpoint, delegate)
135
+ }
136
+ }
137
+
138
+ #[contractimpl(contracttrait)]
139
+ impl OFTCore for TestMintBurnOFT {}
140
+
141
+ impl LzReceiveInternal for TestMintBurnOFT {
142
+ fn __lz_receive(
143
+ env: &Env,
144
+ origin: &Origin,
145
+ guid: &BytesN<32>,
146
+ message: &Bytes,
147
+ extra_data: &Bytes,
148
+ executor: &Address,
149
+ value: i128,
150
+ ) {
151
+ <Self as OFTInternal>::__receive(env, origin, guid, message, extra_data, executor, value)
152
+ }
153
+ }
154
+
155
+ impl OFTInternal for TestMintBurnOFT {
156
+ fn __debit(env: &Env, sender: &Address, amount_ld: i128, min_amount_ld: i128, dst_eid: u32) -> (i128, i128) {
157
+ // Inline mint_burn::debit implementation
158
+ let (amount_sent_ld, amount_received_ld) = Self::__debit_view(env, amount_ld, min_amount_ld, dst_eid);
159
+ MintBurnTokenClient::new(env, &Self::token(env)).burn(sender, &amount_received_ld);
160
+ (amount_sent_ld, amount_received_ld)
161
+ }
162
+
163
+ fn __credit(env: &Env, to: &Address, amount_ld: i128, _src_eid: u32) -> i128 {
164
+ // Inline mint_burn::credit implementation
165
+ MintBurnTokenClient::new(env, &Self::token(env)).mint(to, &amount_ld);
166
+ amount_ld
167
+ }
168
+ }
169
+ }
170
+ pub use test_mint_burn_oft::TestMintBurnOFT;
171
+
172
+ mod test_lock_unlock_oft {
173
+ use crate::{
174
+ self as oft_core,
175
+ oft_core::{OFTCore, OFTInternal},
176
+ };
177
+ use endpoint_v2::Origin;
178
+ use oapp::oapp_receiver::{LzReceiveInternal, OAppReceiver};
179
+ use soroban_sdk::{contractimpl, token::TokenClient, Address, Bytes, BytesN, Env};
180
+
181
+ #[oapp_macros::oapp(custom = [receiver])]
182
+ #[common_macros::lz_contract]
183
+ pub struct TestLockUnlockOFT;
184
+
185
+ #[contractimpl]
186
+ impl TestLockUnlockOFT {
187
+ pub fn __constructor(
188
+ env: &Env,
189
+ token: &Address,
190
+ owner: &Address,
191
+ endpoint: &Address,
192
+ delegate: &Address,
193
+ shared_decimals: u32,
194
+ ) {
195
+ Self::__initialize_oft(env, token, shared_decimals, owner, endpoint, delegate)
196
+ }
197
+ }
198
+
199
+ #[contractimpl(contracttrait)]
200
+ impl OFTCore for TestLockUnlockOFT {}
201
+
202
+ impl LzReceiveInternal for TestLockUnlockOFT {
203
+ fn __lz_receive(
204
+ env: &Env,
205
+ origin: &Origin,
206
+ guid: &BytesN<32>,
207
+ message: &Bytes,
208
+ extra_data: &Bytes,
209
+ executor: &Address,
210
+ value: i128,
211
+ ) {
212
+ <Self as OFTInternal>::__receive(env, origin, guid, message, extra_data, executor, value)
213
+ }
214
+ }
215
+
216
+ // Custom receiver to demonstrate overriding next_nonce or other methods
217
+ #[contractimpl(contracttrait)]
218
+ impl OAppReceiver for TestLockUnlockOFT {}
219
+
220
+ impl OFTInternal for TestLockUnlockOFT {
221
+ fn __debit(env: &Env, sender: &Address, amount_ld: i128, min_amount_ld: i128, dst_eid: u32) -> (i128, i128) {
222
+ // Inline lock_unlock::debit implementation
223
+ let (amount_sent_ld, amount_received_ld) = Self::__debit_view(env, amount_ld, min_amount_ld, dst_eid);
224
+ TokenClient::new(env, &Self::token(env)).transfer(
225
+ sender,
226
+ env.current_contract_address(),
227
+ &amount_received_ld,
228
+ );
229
+ (amount_sent_ld, amount_received_ld)
230
+ }
231
+
232
+ fn __credit(env: &Env, to: &Address, amount_ld: i128, _src_eid: u32) -> i128 {
233
+ // Inline lock_unlock::credit implementation
234
+ TokenClient::new(env, &Self::token(env)).transfer(&env.current_contract_address(), to, &amount_ld);
235
+ amount_ld
236
+ }
237
+ }
238
+ }
239
+ pub use test_lock_unlock_oft::TestLockUnlockOFT;
240
+
241
+ // ==================== Dummy Contracts ====================
242
+
243
+ /// Dummy recipient contract for testing - used to create valid contract addresses
244
+ #[contract]
245
+ pub struct DummyRecipient;
246
+
247
+ #[contractimpl]
248
+ impl DummyRecipient {
249
+ pub fn __constructor(_env: &Env) {}
250
+ }
251
+
252
+ /// Simple token contract for testing (replaces OpenZeppelin dependency)
253
+ #[contract]
254
+ pub struct DummyToken;
255
+
256
+ #[contractimpl]
257
+ impl DummyToken {
258
+ fn admin(env: &Env) -> Address {
259
+ env.storage().instance().get(&symbol_short!("admin")).unwrap()
260
+ }
261
+
262
+ fn get_balance(env: &Env, addr: &Address) -> i128 {
263
+ env.storage().persistent().get(&addr).unwrap_or(0)
264
+ }
265
+
266
+ fn set_balance(env: &Env, addr: &Address, amount: i128) {
267
+ env.storage().persistent().set(&addr, &amount);
268
+ }
269
+
270
+ pub fn __constructor(env: &Env, owner: Address, decimals: u32) {
271
+ env.storage().instance().set(&symbol_short!("admin"), &owner);
272
+ env.storage().instance().set(&symbol_short!("decimal"), &decimals);
273
+ env.storage().instance().set(&symbol_short!("name"), &String::from_str(env, "DummyToken"));
274
+ env.storage().instance().set(&symbol_short!("symbol"), &String::from_str(env, "DUMMY"));
275
+ }
276
+
277
+ // keep the same behavior as SAC that requires admin's authorization
278
+ pub fn set_admin(env: &Env, admin: &Address) {
279
+ Self::admin(env).require_auth();
280
+ env.storage().instance().set(&symbol_short!("admin"), &admin);
281
+ }
282
+
283
+ pub fn mint(env: &Env, to: &Address, amount: i128) {
284
+ Self::admin(env).require_auth();
285
+ if amount < 0 {
286
+ panic!("negative amount");
287
+ }
288
+ let balance = Self::get_balance(env, to);
289
+ Self::set_balance(env, to, balance + amount);
290
+ log!(&env, "minted {} to {}", amount, to);
291
+ }
292
+
293
+ // keep the same behavior as SAC that requires from's authorization
294
+ pub fn burn(env: &Env, from: &Address, amount: i128) {
295
+ from.require_auth();
296
+ if amount < 0 {
297
+ panic!("negative amount");
298
+ }
299
+ let balance = Self::get_balance(env, from);
300
+ if balance < amount {
301
+ panic!("insufficient balance");
302
+ }
303
+ Self::set_balance(env, from, balance - amount);
304
+ log!(&env, "burned {} from {}", amount, from);
305
+ }
306
+
307
+ pub fn balance(env: &Env, id: Address) -> i128 {
308
+ Self::get_balance(env, &id)
309
+ }
310
+
311
+ pub fn transfer(env: &Env, from: Address, to: Address, amount: i128) {
312
+ from.require_auth();
313
+ if amount < 0 {
314
+ panic!("negative amount");
315
+ }
316
+ let from_balance = Self::get_balance(env, &from);
317
+ if from_balance < amount {
318
+ panic!("insufficient balance");
319
+ }
320
+ Self::set_balance(env, &from, from_balance - amount);
321
+ let to_balance = Self::get_balance(env, &to);
322
+ Self::set_balance(env, &to, to_balance + amount);
323
+ }
324
+
325
+ pub fn decimals(env: &Env) -> u32 {
326
+ env.storage().instance().get(&symbol_short!("decimal")).unwrap_or(7)
327
+ }
328
+
329
+ pub fn name(env: &Env) -> String {
330
+ env.storage().instance().get(&symbol_short!("name")).unwrap()
331
+ }
332
+
333
+ pub fn symbol(env: &Env) -> String {
334
+ env.storage().instance().get(&symbol_short!("symbol")).unwrap()
335
+ }
336
+ }
337
+
338
+ // ==================== Mock Endpoint ====================
339
+
340
+ /// A comprehensive mock endpoint contract for testing OFT functionality.
341
+ /// Supports: quote, set_delegate, clear, send_compose, and compose verification.
342
+ #[contract]
343
+ pub struct MockEndpointWithCompose;
344
+
345
+ #[contractimpl]
346
+ impl MockEndpointWithCompose {
347
+ pub fn __constructor(env: Env, native_fee: i128, zro_fee: i128, native_token: Address, zro_token: Address) {
348
+ env.storage().instance().set(&symbol_short!("ntv_fee"), &native_fee);
349
+ env.storage().instance().set(&symbol_short!("zro_fee"), &zro_fee);
350
+ env.storage().instance().set(&symbol_short!("ntk"), &native_token);
351
+ env.storage().instance().set(&symbol_short!("zro"), &zro_token);
352
+ }
353
+
354
+ /// Returns the native token address (required by OAppSenderInternal)
355
+ pub fn native_token(env: Env) -> Address {
356
+ env.storage().instance().get(&symbol_short!("ntk")).unwrap()
357
+ }
358
+
359
+ /// Returns the ZRO token address (required by OAppSenderInternal)
360
+ pub fn zro(env: Env) -> Option<Address> {
361
+ env.storage().instance().get(&symbol_short!("zro"))
362
+ }
363
+
364
+ /// Required by OApp initialization to set delegate
365
+ pub fn set_delegate(_env: Env, _oapp: Address, _delegate: Option<Address>) {
366
+ // No-op for testing
367
+ }
368
+
369
+ /// Required by OAppReceiver.lz_receive to clear the payload
370
+ pub fn clear(_env: Env, _oapp: Address, _origin: Origin, _receiver: Address, _guid: BytesN<32>, _message: Bytes) {
371
+ // No-op for testing
372
+ }
373
+
374
+ /// Required by quote_send to get messaging fees
375
+ pub fn quote(env: Env, _sender: Address, params: MessagingParams) -> MessagingFee {
376
+ let native_fee: i128 = env.storage().instance().get(&symbol_short!("ntv_fee")).unwrap_or(1000);
377
+ let zro_fee: i128 =
378
+ if params.pay_in_zro { env.storage().instance().get(&symbol_short!("zro_fee")).unwrap_or(500) } else { 0 };
379
+ MessagingFee { native_fee, zro_fee }
380
+ }
381
+
382
+ /// Required by send to send cross-chain messages
383
+ pub fn send(env: Env, _sender: Address, params: MessagingParams, _refund_address: Address) -> MessagingReceipt {
384
+ // Increment nonce for each send
385
+ let nonce: u64 = env.storage().instance().get(&symbol_short!("nonce")).unwrap_or(0) + 1;
386
+ env.storage().instance().set(&symbol_short!("nonce"), &nonce);
387
+
388
+ // Store send details for verification
389
+ env.storage().instance().set(&symbol_short!("sent"), &true);
390
+ env.storage().instance().set(&Symbol::new(&env, "last_dst_eid"), &params.dst_eid);
391
+ env.storage().instance().set(&Symbol::new(&env, "last_msg"), &params.message);
392
+
393
+ let native_fee: i128 = env.storage().instance().get(&symbol_short!("ntv_fee")).unwrap_or(1000);
394
+ let zro_fee: i128 =
395
+ if params.pay_in_zro { env.storage().instance().get(&symbol_short!("zro_fee")).unwrap_or(500) } else { 0 };
396
+
397
+ MessagingReceipt {
398
+ guid: BytesN::from_array(&env, &[nonce as u8; 32]),
399
+ nonce,
400
+ fee: MessagingFee { native_fee, zro_fee },
401
+ }
402
+ }
403
+
404
+ /// Helper to check if send was called
405
+ pub fn was_sent(env: Env) -> bool {
406
+ env.storage().instance().get(&symbol_short!("sent")).unwrap_or(false)
407
+ }
408
+
409
+ /// Get the last destination EID that was sent to
410
+ pub fn get_last_dst_eid(env: Env) -> Option<u32> {
411
+ env.storage().instance().get(&Symbol::new(&env, "last_dst_eid"))
412
+ }
413
+
414
+ /// Get the current nonce
415
+ pub fn get_nonce(env: Env) -> u64 {
416
+ env.storage().instance().get(&symbol_short!("nonce")).unwrap_or(0)
417
+ }
418
+
419
+ /// Implements the send_compose method from MessagingComposer
420
+ pub fn send_compose(env: Env, from: Address, to: Address, guid: BytesN<32>, index: u32, message: Bytes) {
421
+ env.storage().instance().set(&symbol_short!("composed"), &true);
422
+ env.storage().instance().set(&Symbol::new(&env, "compose_from"), &from);
423
+ env.storage().instance().set(&Symbol::new(&env, "compose_to"), &to);
424
+ env.storage().instance().set(&Symbol::new(&env, "compose_guid"), &guid);
425
+ env.storage().instance().set(&Symbol::new(&env, "compose_idx"), &index);
426
+ env.storage().instance().set(&Symbol::new(&env, "compose_msg"), &message);
427
+ }
428
+
429
+ /// Helper to check if compose was called
430
+ pub fn was_composed(env: Env) -> bool {
431
+ env.storage().instance().get(&symbol_short!("composed")).unwrap_or(false)
432
+ }
433
+
434
+ pub fn get_compose_to(env: Env) -> Option<Address> {
435
+ env.storage().instance().get(&Symbol::new(&env, "compose_to"))
436
+ }
437
+
438
+ #[allow(dead_code)]
439
+ pub fn get_compose_msg(env: Env) -> Option<Bytes> {
440
+ env.storage().instance().get(&Symbol::new(&env, "compose_msg"))
441
+ }
442
+ }
443
+
444
+ // ==================== Test Setup ====================
445
+
446
+ /// Default fees for mock endpoint
447
+ pub const DEFAULT_NATIVE_FEE: i128 = 1000;
448
+ pub const DEFAULT_ZRO_FEE: i128 = 500;
449
+ /// Large amount for pre-minting tokens during setup
450
+ pub const INITIAL_MINT_AMOUNT: i128 = 1_000_000_000_000_000_000;
451
+
452
+ /// OFT strategy type for test setup
453
+ #[derive(Clone, Copy, PartialEq, Eq, Default)]
454
+ pub enum OFTType {
455
+ #[default]
456
+ MintBurn,
457
+ LockUnlock,
458
+ }
459
+
460
+ /// Token type for test setup
461
+ #[derive(Clone, Copy, PartialEq, Eq, Default)]
462
+ pub enum TokenType {
463
+ SAC, // Stellar Asset Contract (native, 7 decimals)
464
+ #[default]
465
+ ContractToken, // Custom contract token (configurable decimals)
466
+ }
467
+
468
+ pub struct OFTTestSetup<'a> {
469
+ pub env: &'a Env,
470
+ pub oft: OFTClient<'a>,
471
+ pub endpoint_client: MockEndpointWithComposeClient<'a>,
472
+ pub token: Address,
473
+ pub token_client: TokenClient<'a>,
474
+ pub native_token: Address,
475
+ pub zro_token: Address,
476
+ pub owner: Address,
477
+ pub native_fee: i128,
478
+ pub zro_fee: i128,
479
+ pub oft_type: OFTType,
480
+ pub token_decimals: u32,
481
+ pub shared_decimals: u32,
482
+ pub issuer: Address,
483
+ }
484
+
485
+ /// Builder for OFTTestSetup
486
+ pub struct OFTTestSetupBuilder<'a> {
487
+ env: &'a Env,
488
+ native_fee: i128,
489
+ zro_fee: i128,
490
+ oft_type: OFTType,
491
+ token_type: TokenType,
492
+ token_decimals: u32,
493
+ shared_decimals: u32,
494
+ }
495
+
496
+ impl<'a> OFTTestSetupBuilder<'a> {
497
+ pub fn new(env: &'a Env) -> Self {
498
+ Self {
499
+ env,
500
+ native_fee: DEFAULT_NATIVE_FEE,
501
+ zro_fee: DEFAULT_ZRO_FEE,
502
+ oft_type: OFTType::default(),
503
+ token_type: TokenType::default(),
504
+ token_decimals: 7,
505
+ shared_decimals: DEFAULT_SHARED_DECIMALS,
506
+ }
507
+ }
508
+
509
+ pub fn with_token_decimals(mut self, decimals: u32) -> Self {
510
+ self.token_decimals = decimals;
511
+ // Automatically use ContractToken if custom decimals are requested (SAC is fixed at 7)
512
+ if decimals != 7 {
513
+ self.token_type = TokenType::ContractToken;
514
+ }
515
+ self
516
+ }
517
+
518
+ pub fn with_shared_decimals(mut self, decimals: u32) -> Self {
519
+ self.shared_decimals = decimals;
520
+ self
521
+ }
522
+
523
+ pub fn with_fees(mut self, native_fee: i128, zro_fee: i128) -> Self {
524
+ self.native_fee = native_fee;
525
+ self.zro_fee = zro_fee;
526
+ self
527
+ }
528
+
529
+ pub fn with_native_fee(mut self, native_fee: i128) -> Self {
530
+ self.native_fee = native_fee;
531
+ self
532
+ }
533
+
534
+ pub fn with_zro_fee(mut self, zro_fee: i128) -> Self {
535
+ self.zro_fee = zro_fee;
536
+ self
537
+ }
538
+
539
+ pub fn lock_unlock(mut self) -> Self {
540
+ self.oft_type = OFTType::LockUnlock;
541
+ self
542
+ }
543
+
544
+ pub fn with_sac(mut self) -> Self {
545
+ self.token_type = TokenType::SAC;
546
+ self.token_decimals = 7; // SAC has fixed 7 decimals
547
+ self
548
+ }
549
+
550
+ pub fn build(self) -> OFTTestSetup<'a> {
551
+ let env = self.env;
552
+ let native_fee = self.native_fee;
553
+ let zro_fee = self.zro_fee;
554
+ let oft_type = self.oft_type;
555
+
556
+ let owner = create_recipient_address(env);
557
+
558
+ // Create native token for fees
559
+ let native_sac = env.register_stellar_asset_contract_v2(owner.clone());
560
+ let native_token = native_sac.address();
561
+
562
+ // Create ZRO token
563
+ let zro_sac = env.register_stellar_asset_contract_v2(owner.clone());
564
+ let zro_token = zro_sac.address();
565
+
566
+ // Create OFT token based on token_type
567
+ let (token, actual_token_decimals, issuer) = match self.token_type {
568
+ TokenType::SAC => {
569
+ let sac = env.register_stellar_asset_contract_v2(owner.clone());
570
+ (sac.address(), 7u32, sac.issuer().address()) // SAC has fixed 7 decimals
571
+ }
572
+ TokenType::ContractToken => {
573
+ let token = env.register(DummyToken, (&owner, self.token_decimals));
574
+ (token, self.token_decimals, owner.clone())
575
+ }
576
+ };
577
+ let token_client = TokenClient::new(env, &token);
578
+
579
+ // Register mock endpoint
580
+ let endpoint_address =
581
+ env.register(MockEndpointWithCompose, (&native_fee, &zro_fee, &native_token, &zro_token));
582
+ let endpoint_client = MockEndpointWithComposeClient::new(env, &endpoint_address);
583
+
584
+ // Register OFT based on type
585
+ let delegate = owner.clone();
586
+ let oft_address = match oft_type {
587
+ OFTType::MintBurn => {
588
+ env.register(TestMintBurnOFT, (&token, &owner, &endpoint_address, &delegate, &self.shared_decimals))
589
+ }
590
+ OFTType::LockUnlock => {
591
+ env.register(TestLockUnlockOFT, (&token, &owner, &endpoint_address, &delegate, &self.shared_decimals))
592
+ }
593
+ };
594
+ let oft = OFTClient::new(env, &oft_address);
595
+
596
+ // Pre-mint large amounts to owner
597
+ OFTTestSetup::mint_to(env, &owner, &token, &owner, INITIAL_MINT_AMOUNT);
598
+ OFTTestSetup::mint_to(env, &owner, &native_token, &owner, INITIAL_MINT_AMOUNT);
599
+ OFTTestSetup::mint_to(env, &owner, &zro_token, &owner, INITIAL_MINT_AMOUNT);
600
+
601
+ // Setup based on OFT type
602
+ match oft_type {
603
+ OFTType::MintBurn => {
604
+ // Transfer token ownership to OFT so it can burn tokens
605
+ OFTTestSetup::transfer_token_ownership(env, &owner, &token, &oft_address);
606
+ }
607
+ OFTType::LockUnlock => {
608
+ // Fund the OFT with tokens so it can unlock/release them on receive
609
+ OFTTestSetup::mint_to(env, &owner, &token, &oft_address, INITIAL_MINT_AMOUNT);
610
+ }
611
+ }
612
+
613
+ log!(&env, "token decimals: {}", self.token_decimals);
614
+ log!(&env, "token address: {}", token);
615
+ log!(&env, "token client: {}", token_client.address);
616
+ log!(&env, "native token address: {}", native_token);
617
+ log!(&env, "zro token address: {}", zro_token);
618
+ log!(&env, "owner: {}", owner);
619
+ log!(&env, "native fee: {}", native_fee);
620
+ log!(&env, "zro fee: {}", zro_fee);
621
+ log!(&env, "oft address: {}", oft_address);
622
+
623
+ OFTTestSetup {
624
+ env,
625
+ oft,
626
+ endpoint_client,
627
+ token,
628
+ token_client,
629
+ native_token,
630
+ zro_token,
631
+ owner,
632
+ native_fee,
633
+ zro_fee,
634
+ oft_type,
635
+ token_decimals: actual_token_decimals,
636
+ shared_decimals: self.shared_decimals,
637
+ issuer,
638
+ }
639
+ }
640
+ }
641
+
642
+ impl<'a> OFTTestSetup<'a> {
643
+ /// Create a new test setup with default configuration (MintBurn OFT)
644
+ pub fn new(env: &'a Env) -> Self {
645
+ OFTTestSetupBuilder::new(env).build()
646
+ }
647
+
648
+ /// Returns true if this setup uses a LockUnlock OFT
649
+ pub fn is_lock_unlock(&self) -> bool {
650
+ self.oft_type == OFTType::LockUnlock
651
+ }
652
+
653
+ pub fn set_peer(&self, eid: u32, peer: &BytesN<32>) {
654
+ let peer_option = Some(peer.clone());
655
+ self.env.mock_auths(&[MockAuth {
656
+ address: &self.owner,
657
+ invoke: &MockAuthInvoke {
658
+ contract: &self.oft.address,
659
+ fn_name: "set_peer",
660
+ args: (&eid, &peer_option, &self.owner).into_val(self.env),
661
+ sub_invokes: &[],
662
+ },
663
+ }]);
664
+ OAppCoreClient::new(self.env, &self.oft.address).set_peer(&eid, &peer_option, &self.owner);
665
+ }
666
+
667
+ pub fn mint_to(env: &Env, owner: &Address, token: &Address, to: &Address, amount: i128) {
668
+ env.mock_auths(&[MockAuth {
669
+ address: owner,
670
+ invoke: &MockAuthInvoke {
671
+ contract: token,
672
+ fn_name: "mint",
673
+ args: (to, amount).into_val(env),
674
+ sub_invokes: &[],
675
+ },
676
+ }]);
677
+ StellarAssetClient::new(env, token).mint(to, &amount);
678
+ }
679
+
680
+ pub fn transfer_token_ownership(env: &Env, owner: &Address, token: &Address, new_admin: &Address) {
681
+ env.mock_auths(&[MockAuth {
682
+ address: owner,
683
+ invoke: &MockAuthInvoke {
684
+ contract: token,
685
+ fn_name: "set_admin",
686
+ args: (new_admin,).into_val(env),
687
+ sub_invokes: &[],
688
+ },
689
+ }]);
690
+ StellarAssetClient::new(env, token).set_admin(new_admin);
691
+ }
692
+
693
+ /// Fund an account with native fees only (transfers from owner)
694
+ pub fn fund_native_fees(&self, to: &Address, amount: i128) {
695
+ self.env.mock_auths(&[MockAuth {
696
+ address: &self.owner,
697
+ invoke: &MockAuthInvoke {
698
+ contract: &self.native_token,
699
+ fn_name: "transfer",
700
+ args: (&self.owner, to, amount).into_val(self.env),
701
+ sub_invokes: &[],
702
+ },
703
+ }]);
704
+ TokenClient::new(self.env, &self.native_token).transfer(&self.owner, to, &amount);
705
+ }
706
+
707
+ /// Fund an account with ZRO fees (transfers from owner)
708
+ pub fn fund_zro_fees(&self, to: &Address, amount: i128) {
709
+ self.env.mock_auths(&[MockAuth {
710
+ address: &self.owner,
711
+ invoke: &MockAuthInvoke {
712
+ contract: &self.zro_token,
713
+ fn_name: "transfer",
714
+ args: (&self.owner, to, amount).into_val(self.env),
715
+ sub_invokes: &[],
716
+ },
717
+ }]);
718
+ TokenClient::new(self.env, &self.zro_token).transfer(&self.owner, to, &amount);
719
+ }
720
+
721
+ /// Fund an account with OFT tokens only (transfers from owner)
722
+ pub fn fund_tokens(&self, to: &Address, amount: i128) {
723
+ self.env.mock_auths(&[MockAuth {
724
+ address: &self.owner,
725
+ invoke: &MockAuthInvoke {
726
+ contract: &self.token,
727
+ fn_name: "transfer",
728
+ args: (&self.owner, to, amount).into_val(self.env),
729
+ sub_invokes: &[],
730
+ },
731
+ }]);
732
+ self.token_client.transfer(&self.owner, to, &amount);
733
+ }
734
+
735
+ /// Quote OFT to get the receipt for authorization
736
+ pub fn quote_oft(&self, from: &Address, send_param: &SendParam) -> OFTReceipt {
737
+ let (_, _, receipt) = self.oft.quote_oft(from, send_param);
738
+ receipt
739
+ }
740
+
741
+ /// Send tokens cross-chain with proper sender authentication
742
+ pub fn send(
743
+ &self,
744
+ sender: &Address,
745
+ send_param: &SendParam,
746
+ fee: &MessagingFee,
747
+ refund_address: &Address,
748
+ oft_receipt: &OFTReceipt,
749
+ ) -> (MessagingReceipt, OFTReceipt) {
750
+ // Token operation sub-invoke differs based on OFT type
751
+ // Both MintBurn and LockUnlock use amount_received_ld (after fee/dust removal)
752
+ let token_sub_invoke = match self.oft_type {
753
+ OFTType::MintBurn => MockAuthInvoke {
754
+ contract: &self.token,
755
+ fn_name: "burn",
756
+ args: (sender, &oft_receipt.amount_received_ld).into_val(self.env),
757
+ sub_invokes: &[],
758
+ },
759
+ OFTType::LockUnlock => MockAuthInvoke {
760
+ contract: &self.token,
761
+ fn_name: "transfer",
762
+ args: (sender, &self.oft.address, &oft_receipt.amount_received_ld).into_val(self.env),
763
+ sub_invokes: &[],
764
+ },
765
+ };
766
+
767
+ self.env.mock_auths(&[MockAuth {
768
+ address: sender,
769
+ invoke: &MockAuthInvoke {
770
+ contract: &self.oft.address,
771
+ fn_name: "send",
772
+ args: (sender, send_param, fee, refund_address).into_val(self.env),
773
+ sub_invokes: &[
774
+ MockAuthInvoke {
775
+ contract: &self.native_token,
776
+ fn_name: "transfer",
777
+ args: (sender, &self.endpoint_client.address, &fee.native_fee).into_val(self.env),
778
+ sub_invokes: &[],
779
+ },
780
+ MockAuthInvoke {
781
+ contract: &self.zro_token,
782
+ fn_name: "transfer",
783
+ args: (sender, &self.endpoint_client.address, &fee.zro_fee).into_val(self.env),
784
+ sub_invokes: &[],
785
+ },
786
+ token_sub_invoke,
787
+ ],
788
+ },
789
+ }]);
790
+ self.oft.send(sender, send_param, fee, refund_address)
791
+ }
792
+
793
+ /// Try send tokens cross-chain with proper sender authentication (returns Result)
794
+ pub fn try_send(
795
+ &self,
796
+ sender: &Address,
797
+ send_param: &SendParam,
798
+ fee: &MessagingFee,
799
+ refund_address: &Address,
800
+ oft_receipt: &OFTReceipt,
801
+ ) -> Result<
802
+ Result<(MessagingReceipt, OFTReceipt), soroban_sdk::Error>,
803
+ Result<soroban_sdk::Error, soroban_sdk::InvokeError>,
804
+ > {
805
+ // Token operation sub-invoke differs based on OFT type
806
+ // Both MintBurn and LockUnlock use amount_received_ld (after fee/dust removal)
807
+ let token_sub_invoke = match self.oft_type {
808
+ OFTType::MintBurn => MockAuthInvoke {
809
+ contract: &self.token,
810
+ fn_name: "burn",
811
+ args: (sender, &oft_receipt.amount_received_ld).into_val(self.env),
812
+ sub_invokes: &[],
813
+ },
814
+ OFTType::LockUnlock => MockAuthInvoke {
815
+ contract: &self.token,
816
+ fn_name: "transfer",
817
+ args: (sender, &self.oft.address, &oft_receipt.amount_received_ld).into_val(self.env),
818
+ sub_invokes: &[],
819
+ },
820
+ };
821
+
822
+ self.env.mock_auths(&[MockAuth {
823
+ address: sender,
824
+ invoke: &MockAuthInvoke {
825
+ contract: &self.oft.address,
826
+ fn_name: "send",
827
+ args: (sender, send_param, fee, refund_address).into_val(self.env),
828
+ sub_invokes: &[
829
+ MockAuthInvoke {
830
+ contract: &self.native_token,
831
+ fn_name: "transfer",
832
+ args: (sender, &self.endpoint_client.address, &fee.native_fee).into_val(self.env),
833
+ sub_invokes: &[],
834
+ },
835
+ MockAuthInvoke {
836
+ contract: &self.zro_token,
837
+ fn_name: "transfer",
838
+ args: (sender, &self.endpoint_client.address, &fee.zro_fee).into_val(self.env),
839
+ sub_invokes: &[],
840
+ },
841
+ token_sub_invoke,
842
+ ],
843
+ },
844
+ }]);
845
+ self.oft.try_send(sender, send_param, fee, refund_address)
846
+ }
847
+
848
+ /// Execute lz_receive with proper executor authentication
849
+ pub fn lz_receive(
850
+ &self,
851
+ executor: &Address,
852
+ origin: &Origin,
853
+ guid: &BytesN<32>,
854
+ message: &Bytes,
855
+ extra_data: &Bytes,
856
+ value: i128,
857
+ ) {
858
+ self.env.mock_auths(&[MockAuth {
859
+ address: executor,
860
+ invoke: &MockAuthInvoke {
861
+ contract: &self.oft.address,
862
+ fn_name: "lz_receive",
863
+ args: (executor, origin, guid, message, extra_data, value).into_val(self.env),
864
+ sub_invokes: &[],
865
+ },
866
+ }]);
867
+ LayerZeroReceiverClient::new(self.env, &self.oft.address)
868
+ .lz_receive(executor, origin, guid, message, extra_data, &value);
869
+ }
870
+ }