Skip to content

Commit 6c6e6c8

Browse files
committed
fix: harden proxy view fee previews (#FIND-039 #FIND-077 #FIND-080 #FIND-085 Nexus ae756926-e93c-4d54-a4a6-3a706ed20867 Nexus 082f9cb2-f33c-4006-8528-ef7a7b2918cc Nexus 4460259b-12db-44b2-9396-262b531275c7 Nexus 86ddc60c-b4d9-4e2e-a9ad-705890b3fc89)
Trace: 8e550d7
1 parent ba08a36 commit 6c6e6c8

3 files changed

Lines changed: 240 additions & 14 deletions

File tree

contract/vault/soroban/src/contract/entrypoints.rs

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -902,7 +902,7 @@ impl SorobanVaultContract {
902902
),
903903
(i128, i128, bool),
904904
(i128, i128, i128, i128),
905-
(i128, u64, i128, i128),
905+
(i128, u64, i128, i128, i128),
906906
),
907907
(
908908
soroban_sdk::Vec<u32>,
@@ -921,11 +921,16 @@ impl SorobanVaultContract {
921921
let idle_assets = to_i128(state.idle_assets)?;
922922
let external_assets = to_i128(state.external_assets)?;
923923
let total_assets = to_i128(state.total_assets)?;
924+
let fee_growth_rate = match config.fees.max_total_assets_growth_rate {
925+
Some(rate) => to_i128(u128::from(rate))?,
926+
None => 0,
927+
};
924928
let fee_info = (
925-
state.fee_anchor.total_assets as i128,
929+
to_i128(state.fee_anchor.total_assets)?,
926930
state.fee_anchor.timestamp_ns.as_u64(),
927-
u128::from(config.fees.management.fee_wad) as i128,
928-
u128::from(config.fees.performance.fee_wad) as i128,
931+
to_i128(u128::from(config.fees.management.fee_wad))?,
932+
to_i128(u128::from(config.fees.performance.fee_wad))?,
933+
fee_growth_rate,
929934
);
930935
let policy_state = runtime_to_contract(storage.load_policy_state())?.unwrap_or_default();
931936
for entry in policy_state.supply_queue().entries() {
@@ -969,13 +974,55 @@ impl SorobanVaultContract {
969974
};
970975

971976
let (max_deposit_value, max_mint_value) = if state.op_state.is_idle() && !config.paused {
972-
let max_assets = u128::MAX
977+
let asset_headroom = u128::MAX
973978
.saturating_sub(state.total_assets)
974-
.min(i128::MAX as u128) as i128;
975-
let max_shares = u128::MAX
976-
.saturating_sub(state.total_shares)
977-
.min(i128::MAX as u128) as i128;
978-
(max_assets, max_shares)
979+
.min(u128::MAX.saturating_sub(state.idle_assets));
980+
let share_headroom = u128::MAX.saturating_sub(state.total_shares);
981+
982+
let max_assets = asset_headroom.min(i128::MAX as u128);
983+
let shares_for_max_assets = convert_to_shares_bounded(
984+
&state,
985+
&config,
986+
max_assets,
987+
u128::MAX,
988+
InvalidStateCode::MintOverflowTotalShares,
989+
);
990+
let max_deposit = if matches!(shares_for_max_assets, Ok(shares) if shares <= share_headroom)
991+
{
992+
max_assets
993+
} else {
994+
convert_to_assets_bounded(
995+
&state,
996+
&config,
997+
share_headroom,
998+
i128::MAX as u128,
999+
InvalidStateCode::RequestWithdrawExpectedAssetsExceedTotalAssets,
1000+
)
1001+
.map_err(|_| ContractError::ConversionOverflow)?
1002+
};
1003+
1004+
let max_shares = share_headroom.min(i128::MAX as u128);
1005+
let assets_for_max_shares = convert_to_assets_bounded(
1006+
&state,
1007+
&config,
1008+
max_shares,
1009+
u128::MAX,
1010+
InvalidStateCode::RequestWithdrawExpectedAssetsExceedTotalAssets,
1011+
);
1012+
let max_mint = if matches!(assets_for_max_shares, Ok(assets) if assets <= asset_headroom)
1013+
{
1014+
max_shares
1015+
} else {
1016+
convert_to_shares_bounded(
1017+
&state,
1018+
&config,
1019+
asset_headroom,
1020+
i128::MAX as u128,
1021+
InvalidStateCode::MintOverflowTotalShares,
1022+
)
1023+
.map_err(|_| ContractError::ConversionOverflow)?
1024+
};
1025+
(to_i128(max_deposit)?, to_i128(max_mint)?)
9791026
} else {
9801027
(0, 0)
9811028
};

contract/vault/soroban/src/fungible_vault.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,12 @@ fn preview_state_with_fee_accrual(
6262
anchor.timestamp_ns.as_u64(),
6363
now_ns,
6464
);
65+
let max_supply = Number::from(u128::MAX);
6566
let supply_after_management =
6667
Number::from(state.total_shares).saturating_add(management_shares);
68+
if supply_after_management > max_supply {
69+
return Err(ContractError::ConversionOverflow);
70+
}
6771

6872
let profit = fee_assets_base.saturating_sub(anchor.total_assets);
6973
let performance_fee_assets = config
@@ -77,9 +81,11 @@ fn preview_state_with_fee_accrual(
7781
supply_after_management,
7882
);
7983

80-
state.total_shares = supply_after_management
81-
.saturating_add(performance_shares)
82-
.as_u128_saturating();
84+
let total_supply = supply_after_management.saturating_add(performance_shares);
85+
if total_supply > max_supply {
86+
return Err(ContractError::ConversionOverflow);
87+
}
88+
state.total_shares = total_supply.as_u128_trunc();
8389
state.fee_anchor = FeeAccrualAnchor::new(current_assets, TimestampNs(now_ns));
8490

8591
Ok(state)

contract/vault/soroban/tests/integration_tests.rs

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ type ProxyCoreView = (
5555
),
5656
(i128, i128, bool),
5757
(i128, i128, i128, i128),
58-
(i128, u64, i128, i128),
58+
(i128, u64, i128, i128, i128),
5959
);
6060
type ProxyPolicyView = (
6161
soroban_sdk::Vec<u32>,
@@ -174,6 +174,20 @@ impl<'a> VaultProxy<'a> {
174174
.7)
175175
}
176176

177+
fn max_deposit(&self) -> Result<i128, templar_soroban_runtime::ContractError> {
178+
Ok(self
179+
.view(soroban_sdk::Address::generate(self.env), 0, 0)?
180+
.2
181+
.2)
182+
}
183+
184+
fn max_mint(&self) -> Result<i128, templar_soroban_runtime::ContractError> {
185+
Ok(self
186+
.view(soroban_sdk::Address::generate(self.env), 0, 0)?
187+
.2
188+
.3)
189+
}
190+
177191
fn execute(
178192
&self,
179193
command: &VaultCommand,
@@ -587,6 +601,165 @@ fn soroban_contract_proxy_view_does_not_inflate_from_zero_fee_anchor(
587601
});
588602
}
589603

604+
#[rstest]
605+
fn soroban_contract_proxy_view_reports_fee_growth_cap_and_checked_fee_fields(
606+
soroban_contract_fixture: SorobanContractFixture,
607+
) {
608+
let env = soroban_contract_fixture.env;
609+
let contract_id = soroban_contract_fixture.contract_id;
610+
let proxy = VaultProxy::new(&env);
611+
let owner = soroban_sdk::Address::generate(&env);
612+
613+
env.as_contract(&contract_id, || {
614+
let fees = FeesSpec::new(
615+
FeeSlot::new(Wad::one() / 5, Address([1u8; 32])),
616+
FeeSlot::new(Wad::one() / 10, Address([2u8; 32])),
617+
Some(Wad::one() / 20),
618+
);
619+
let mut bytes = Vec::with_capacity(113);
620+
bytes.extend_from_slice(&fees.performance.fee_wad.as_u128_trunc().to_le_bytes());
621+
bytes.extend_from_slice(fees.performance.recipient.as_bytes());
622+
bytes.extend_from_slice(&fees.management.fee_wad.as_u128_trunc().to_le_bytes());
623+
bytes.extend_from_slice(fees.management.recipient.as_bytes());
624+
bytes.push(1);
625+
bytes.extend_from_slice(
626+
&fees
627+
.max_total_assets_growth_rate
628+
.expect("growth cap configured")
629+
.as_u128_trunc()
630+
.to_le_bytes(),
631+
);
632+
env.storage().instance().set(
633+
&templar_soroban_runtime::contract::VaultDataKey::FeesSpec,
634+
&Bytes::from_slice(&env, &bytes),
635+
);
636+
637+
let mut storage = SorobanStorage::new(&env);
638+
storage
639+
.save_state(&VaultState {
640+
total_assets: 1_000,
641+
total_shares: 1_000,
642+
idle_assets: 0,
643+
external_assets: 1_000,
644+
fee_anchor: FeeAccrualAnchor::new(
645+
i128::MAX as u128 + 1,
646+
templar_vault_kernel::TimestampNs(123),
647+
),
648+
..Default::default()
649+
})
650+
.expect("save state");
651+
652+
assert_eq!(
653+
proxy.view(owner.clone(), 0, 0),
654+
Err(templar_soroban_runtime::ContractError::ConversionOverflow)
655+
);
656+
657+
storage
658+
.save_state(&VaultState {
659+
total_assets: 1_000,
660+
total_shares: 1_000,
661+
idle_assets: 0,
662+
external_assets: 1_000,
663+
fee_anchor: FeeAccrualAnchor::new(900, templar_vault_kernel::TimestampNs(123)),
664+
..Default::default()
665+
})
666+
.expect("save state");
667+
668+
let fee_info = proxy.view(owner, 0, 0).unwrap().0 .3;
669+
assert_eq!(fee_info.0, 900);
670+
assert_eq!(fee_info.1, 123);
671+
assert_eq!(fee_info.2, (Wad::one() / 10).as_u128_trunc() as i128);
672+
assert_eq!(fee_info.3, (Wad::one() / 5).as_u128_trunc() as i128);
673+
assert_eq!(fee_info.4, (Wad::one() / 20).as_u128_trunc() as i128);
674+
});
675+
}
676+
677+
#[rstest]
678+
fn soroban_contract_proxy_view_max_deposit_and_mint_respect_opposite_headroom(
679+
soroban_contract_fixture: SorobanContractFixture,
680+
) {
681+
let env = soroban_contract_fixture.env;
682+
let contract_id = soroban_contract_fixture.contract_id;
683+
let proxy = VaultProxy::new(&env);
684+
685+
env.as_contract(&contract_id, || {
686+
let mut storage = SorobanStorage::new(&env);
687+
storage
688+
.save_state(&VaultState {
689+
total_assets: 2,
690+
total_shares: 1,
691+
idle_assets: 2,
692+
..Default::default()
693+
})
694+
.expect("save state");
695+
696+
assert_eq!(proxy.max_deposit().unwrap(), i128::MAX);
697+
assert_eq!(proxy.max_mint().unwrap(), i128::MAX);
698+
699+
storage
700+
.save_state(&VaultState {
701+
total_assets: 1,
702+
total_shares: 2,
703+
idle_assets: 1,
704+
..Default::default()
705+
})
706+
.expect("save state");
707+
708+
let expected_max_deposit = (((i128::MAX as u128) * 2) / 3) as i128;
709+
assert_eq!(proxy.max_deposit().unwrap(), expected_max_deposit);
710+
assert_eq!(proxy.max_mint().unwrap(), i128::MAX);
711+
});
712+
}
713+
714+
#[rstest]
715+
fn soroban_contract_fee_aware_preview_fails_on_supply_overflow(
716+
soroban_contract_fixture: SorobanContractFixture,
717+
) {
718+
let env = soroban_contract_fixture.env;
719+
let contract_id = soroban_contract_fixture.contract_id;
720+
let proxy = VaultProxy::new(&env);
721+
722+
env.ledger().set(LedgerInfo {
723+
timestamp: 100,
724+
protocol_version: 25,
725+
..Default::default()
726+
});
727+
728+
env.as_contract(&contract_id, || {
729+
let fees = FeesSpec::new(
730+
FeeSlot::new(Wad::zero(), Address([1u8; 32])),
731+
FeeSlot::new(Wad::one(), Address([2u8; 32])),
732+
None,
733+
);
734+
let mut bytes = Vec::with_capacity(97);
735+
bytes.extend_from_slice(&fees.performance.fee_wad.as_u128_trunc().to_le_bytes());
736+
bytes.extend_from_slice(fees.performance.recipient.as_bytes());
737+
bytes.extend_from_slice(&fees.management.fee_wad.as_u128_trunc().to_le_bytes());
738+
bytes.extend_from_slice(fees.management.recipient.as_bytes());
739+
bytes.push(0);
740+
env.storage().instance().set(
741+
&templar_soroban_runtime::contract::VaultDataKey::FeesSpec,
742+
&Bytes::from_slice(&env, &bytes),
743+
);
744+
745+
let mut storage = SorobanStorage::new(&env);
746+
storage
747+
.save_state(&VaultState {
748+
total_assets: u128::MAX,
749+
total_shares: u128::MAX,
750+
idle_assets: u128::MAX,
751+
fee_anchor: FeeAccrualAnchor::new(1, templar_vault_kernel::TimestampNs(1)),
752+
..Default::default()
753+
})
754+
.expect("save state");
755+
756+
assert_eq!(
757+
proxy.preview_deposit(1),
758+
Err(templar_soroban_runtime::ContractError::ConversionOverflow)
759+
);
760+
});
761+
}
762+
590763
#[rstest]
591764
fn soroban_contract_refresh_fees_command_updates_anchor() {
592765
let env = Env::default();

0 commit comments

Comments
 (0)