Skip to content

Commit de91a36

Browse files
authored
fix(staking): S-1 LDV controller hijack + S-2 adapter swap-under-load (#127)
S-1 (LiquidDelegationVault.requestRedeem): An authorized operator (ERC-7540 `_operators[owner][caller]`) could file a redemption with `controller != owner`, then drive `redeem` to route assets anywhere. Now an operator-driven request must set `controller == owner`; owners filing on their own behalf may still pick any controller (the legit ERC-7540 aggregator-vault use case). S-2 (StakingAssetsFacet.registerAdapter / removeAdapter): Switching adapters while the asset has live deposits silently strands balances in the old adapter or double-counts them in the new one. Both paths now revert with `AdapterChangeWhileDepositsExist(token, deposits)` when `currentDeposits != 0`. The M-8 migration framework (`startAdapterMigration` -> `completeAdapterMigration`) remains the intended drain path for live assets. The new error is exposed on `IMultiAssetDelegation` so the Rust bindings decode the revert. Tests: - test_Vault_OperatorCannotPickArbitraryController_S1 - test_Vault_OwnerMayPickArbitraryController_S1 (preserves legit case) - test_registerAdapter_blockedWhileDepositsExist_S2 - test_removeAdapter_blockedWhileDepositsExist_S2 - test_registerAdapter_allowedWhenDepositsAreZero_S2 Bindings: ABI regenerated; new custom error decoded by alloy. Version bump deferred to whichever Round 4 PR lands last so we don't race four PRs onto v0.15.0.
1 parent 98a5484 commit de91a36

9 files changed

Lines changed: 445 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bindings/TNT_CORE_VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
5201cf08bbe64238f0533d113840d7280afdaa08
1+
98a5484534148b6b556085cf450ac5abb5e0d6b5

bindings/abi/IMultiAssetDelegation.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

bindings/src/bindings/i_multi_asset_delegation.rs

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4015,6 +4015,8 @@ library Types {
40154015
}
40164016

40174017
interface IMultiAssetDelegation {
4018+
error AdapterChangeWhileDepositsExist(address token, uint256 currentDeposits);
4019+
40184020
event AdapterRegistered(address indexed token, address indexed adapter);
40194021
event AdapterRemoved(address indexed token);
40204022
event AssetDisabled(address indexed token);
@@ -7002,6 +7004,22 @@ interface IMultiAssetDelegation {
70027004
}
70037005
],
70047006
"anonymous": false
7007+
},
7008+
{
7009+
"type": "error",
7010+
"name": "AdapterChangeWhileDepositsExist",
7011+
"inputs": [
7012+
{
7013+
"name": "token",
7014+
"type": "address",
7015+
"internalType": "address"
7016+
},
7017+
{
7018+
"name": "currentDeposits",
7019+
"type": "uint256",
7020+
"internalType": "uint256"
7021+
}
7022+
]
70057023
}
70067024
]
70077025
```*/
@@ -7037,6 +7055,103 @@ pub mod IMultiAssetDelegation {
70377055
);
70387056
#[derive(serde::Serialize, serde::Deserialize)]
70397057
#[derive(Default, Debug, PartialEq, Eq, Hash)]
7058+
/**Custom error with signature `AdapterChangeWhileDepositsExist(address,uint256)` and selector `0x0d136063`.
7059+
```solidity
7060+
error AdapterChangeWhileDepositsExist(address token, uint256 currentDeposits);
7061+
```*/
7062+
#[allow(non_camel_case_types, non_snake_case, clippy::pub_underscore_fields)]
7063+
#[derive(Clone)]
7064+
pub struct AdapterChangeWhileDepositsExist {
7065+
#[allow(missing_docs)]
7066+
pub token: alloy::sol_types::private::Address,
7067+
#[allow(missing_docs)]
7068+
pub currentDeposits: alloy::sol_types::private::primitives::aliases::U256,
7069+
}
7070+
#[allow(
7071+
non_camel_case_types,
7072+
non_snake_case,
7073+
clippy::pub_underscore_fields,
7074+
clippy::style
7075+
)]
7076+
const _: () = {
7077+
use alloy::sol_types as alloy_sol_types;
7078+
#[doc(hidden)]
7079+
#[allow(dead_code)]
7080+
type UnderlyingSolTuple<'a> = (
7081+
alloy::sol_types::sol_data::Address,
7082+
alloy::sol_types::sol_data::Uint<256>,
7083+
);
7084+
#[doc(hidden)]
7085+
type UnderlyingRustTuple<'a> = (
7086+
alloy::sol_types::private::Address,
7087+
alloy::sol_types::private::primitives::aliases::U256,
7088+
);
7089+
#[cfg(test)]
7090+
#[allow(dead_code, unreachable_patterns)]
7091+
fn _type_assertion(
7092+
_t: alloy_sol_types::private::AssertTypeEq<UnderlyingRustTuple>,
7093+
) {
7094+
match _t {
7095+
alloy_sol_types::private::AssertTypeEq::<
7096+
<UnderlyingSolTuple as alloy_sol_types::SolType>::RustType,
7097+
>(_) => {}
7098+
}
7099+
}
7100+
#[automatically_derived]
7101+
#[doc(hidden)]
7102+
impl ::core::convert::From<AdapterChangeWhileDepositsExist>
7103+
for UnderlyingRustTuple<'_> {
7104+
fn from(value: AdapterChangeWhileDepositsExist) -> Self {
7105+
(value.token, value.currentDeposits)
7106+
}
7107+
}
7108+
#[automatically_derived]
7109+
#[doc(hidden)]
7110+
impl ::core::convert::From<UnderlyingRustTuple<'_>>
7111+
for AdapterChangeWhileDepositsExist {
7112+
fn from(tuple: UnderlyingRustTuple<'_>) -> Self {
7113+
Self {
7114+
token: tuple.0,
7115+
currentDeposits: tuple.1,
7116+
}
7117+
}
7118+
}
7119+
#[automatically_derived]
7120+
impl alloy_sol_types::SolError for AdapterChangeWhileDepositsExist {
7121+
type Parameters<'a> = UnderlyingSolTuple<'a>;
7122+
type Token<'a> = <Self::Parameters<
7123+
'a,
7124+
> as alloy_sol_types::SolType>::Token<'a>;
7125+
const SIGNATURE: &'static str = "AdapterChangeWhileDepositsExist(address,uint256)";
7126+
const SELECTOR: [u8; 4] = [13u8, 19u8, 96u8, 99u8];
7127+
#[inline]
7128+
fn new<'a>(
7129+
tuple: <Self::Parameters<'a> as alloy_sol_types::SolType>::RustType,
7130+
) -> Self {
7131+
tuple.into()
7132+
}
7133+
#[inline]
7134+
fn tokenize(&self) -> Self::Token<'_> {
7135+
(
7136+
<alloy::sol_types::sol_data::Address as alloy_sol_types::SolType>::tokenize(
7137+
&self.token,
7138+
),
7139+
<alloy::sol_types::sol_data::Uint<
7140+
256,
7141+
> as alloy_sol_types::SolType>::tokenize(&self.currentDeposits),
7142+
)
7143+
}
7144+
#[inline]
7145+
fn abi_decode_raw_validate(data: &[u8]) -> alloy_sol_types::Result<Self> {
7146+
<Self::Parameters<
7147+
'_,
7148+
> as alloy_sol_types::SolType>::abi_decode_sequence_validate(data)
7149+
.map(Self::new)
7150+
}
7151+
}
7152+
};
7153+
#[derive(serde::Serialize, serde::Deserialize)]
7154+
#[derive(Default, Debug, PartialEq, Eq, Hash)]
70407155
/**Event with signature `AdapterRegistered(address,address)` and selector `0xc47df14ad9309b59073546f93dbe3115ed09c8b206d940f8441ddb07f745b10b`.
70417156
```solidity
70427157
event AdapterRegistered(address indexed token, address indexed adapter);
@@ -31622,6 +31737,160 @@ function unpause() external;
3162231737
}
3162331738
}
3162431739
}
31740+
///Container for all the [`IMultiAssetDelegation`](self) custom errors.
31741+
#[derive(Clone)]
31742+
#[derive(serde::Serialize, serde::Deserialize)]
31743+
#[derive(Debug, PartialEq, Eq, Hash)]
31744+
pub enum IMultiAssetDelegationErrors {
31745+
#[allow(missing_docs)]
31746+
AdapterChangeWhileDepositsExist(AdapterChangeWhileDepositsExist),
31747+
}
31748+
impl IMultiAssetDelegationErrors {
31749+
/// All the selectors of this enum.
31750+
///
31751+
/// Note that the selectors might not be in the same order as the variants.
31752+
/// No guarantees are made about the order of the selectors.
31753+
///
31754+
/// Prefer using `SolInterface` methods instead.
31755+
pub const SELECTORS: &'static [[u8; 4usize]] = &[[13u8, 19u8, 96u8, 99u8]];
31756+
/// The names of the variants in the same order as `SELECTORS`.
31757+
pub const VARIANT_NAMES: &'static [&'static str] = &[
31758+
::core::stringify!(AdapterChangeWhileDepositsExist),
31759+
];
31760+
/// The signatures in the same order as `SELECTORS`.
31761+
pub const SIGNATURES: &'static [&'static str] = &[
31762+
<AdapterChangeWhileDepositsExist as alloy_sol_types::SolError>::SIGNATURE,
31763+
];
31764+
/// Returns the signature for the given selector, if known.
31765+
#[inline]
31766+
pub fn signature_by_selector(
31767+
selector: [u8; 4usize],
31768+
) -> ::core::option::Option<&'static str> {
31769+
match Self::SELECTORS.binary_search(&selector) {
31770+
::core::result::Result::Ok(idx) => {
31771+
::core::option::Option::Some(Self::SIGNATURES[idx])
31772+
}
31773+
::core::result::Result::Err(_) => ::core::option::Option::None,
31774+
}
31775+
}
31776+
/// Returns the enum variant name for the given selector, if known.
31777+
#[inline]
31778+
pub fn name_by_selector(
31779+
selector: [u8; 4usize],
31780+
) -> ::core::option::Option<&'static str> {
31781+
let sig = Self::signature_by_selector(selector)?;
31782+
sig.split_once('(').map(|(name, _)| name)
31783+
}
31784+
}
31785+
#[automatically_derived]
31786+
impl alloy_sol_types::SolInterface for IMultiAssetDelegationErrors {
31787+
const NAME: &'static str = "IMultiAssetDelegationErrors";
31788+
const MIN_DATA_LENGTH: usize = 64usize;
31789+
const COUNT: usize = 1usize;
31790+
#[inline]
31791+
fn selector(&self) -> [u8; 4] {
31792+
match self {
31793+
Self::AdapterChangeWhileDepositsExist(_) => {
31794+
<AdapterChangeWhileDepositsExist as alloy_sol_types::SolError>::SELECTOR
31795+
}
31796+
}
31797+
}
31798+
#[inline]
31799+
fn selector_at(i: usize) -> ::core::option::Option<[u8; 4]> {
31800+
Self::SELECTORS.get(i).copied()
31801+
}
31802+
#[inline]
31803+
fn valid_selector(selector: [u8; 4]) -> bool {
31804+
Self::SELECTORS.binary_search(&selector).is_ok()
31805+
}
31806+
#[inline]
31807+
#[allow(non_snake_case)]
31808+
fn abi_decode_raw(
31809+
selector: [u8; 4],
31810+
data: &[u8],
31811+
) -> alloy_sol_types::Result<Self> {
31812+
static DECODE_SHIMS: &[fn(
31813+
&[u8],
31814+
) -> alloy_sol_types::Result<IMultiAssetDelegationErrors>] = &[
31815+
{
31816+
fn AdapterChangeWhileDepositsExist(
31817+
data: &[u8],
31818+
) -> alloy_sol_types::Result<IMultiAssetDelegationErrors> {
31819+
<AdapterChangeWhileDepositsExist as alloy_sol_types::SolError>::abi_decode_raw(
31820+
data,
31821+
)
31822+
.map(
31823+
IMultiAssetDelegationErrors::AdapterChangeWhileDepositsExist,
31824+
)
31825+
}
31826+
AdapterChangeWhileDepositsExist
31827+
},
31828+
];
31829+
let Ok(idx) = Self::SELECTORS.binary_search(&selector) else {
31830+
return Err(
31831+
alloy_sol_types::Error::unknown_selector(
31832+
<Self as alloy_sol_types::SolInterface>::NAME,
31833+
selector,
31834+
),
31835+
);
31836+
};
31837+
DECODE_SHIMS[idx](data)
31838+
}
31839+
#[inline]
31840+
#[allow(non_snake_case)]
31841+
fn abi_decode_raw_validate(
31842+
selector: [u8; 4],
31843+
data: &[u8],
31844+
) -> alloy_sol_types::Result<Self> {
31845+
static DECODE_VALIDATE_SHIMS: &[fn(
31846+
&[u8],
31847+
) -> alloy_sol_types::Result<IMultiAssetDelegationErrors>] = &[
31848+
{
31849+
fn AdapterChangeWhileDepositsExist(
31850+
data: &[u8],
31851+
) -> alloy_sol_types::Result<IMultiAssetDelegationErrors> {
31852+
<AdapterChangeWhileDepositsExist as alloy_sol_types::SolError>::abi_decode_raw_validate(
31853+
data,
31854+
)
31855+
.map(
31856+
IMultiAssetDelegationErrors::AdapterChangeWhileDepositsExist,
31857+
)
31858+
}
31859+
AdapterChangeWhileDepositsExist
31860+
},
31861+
];
31862+
let Ok(idx) = Self::SELECTORS.binary_search(&selector) else {
31863+
return Err(
31864+
alloy_sol_types::Error::unknown_selector(
31865+
<Self as alloy_sol_types::SolInterface>::NAME,
31866+
selector,
31867+
),
31868+
);
31869+
};
31870+
DECODE_VALIDATE_SHIMS[idx](data)
31871+
}
31872+
#[inline]
31873+
fn abi_encoded_size(&self) -> usize {
31874+
match self {
31875+
Self::AdapterChangeWhileDepositsExist(inner) => {
31876+
<AdapterChangeWhileDepositsExist as alloy_sol_types::SolError>::abi_encoded_size(
31877+
inner,
31878+
)
31879+
}
31880+
}
31881+
}
31882+
#[inline]
31883+
fn abi_encode_raw(&self, out: &mut alloy_sol_types::private::Vec<u8>) {
31884+
match self {
31885+
Self::AdapterChangeWhileDepositsExist(inner) => {
31886+
<AdapterChangeWhileDepositsExist as alloy_sol_types::SolError>::abi_encode_raw(
31887+
inner,
31888+
out,
31889+
)
31890+
}
31891+
}
31892+
}
31893+
}
3162531894
///Container for all the [`IMultiAssetDelegation`](self) events.
3162631895
#[derive(Clone)]
3162731896
#[derive(serde::Serialize, serde::Deserialize)]

src/facets/staking/StakingAssetsFacet.sol

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,25 +91,45 @@ contract StakingAssetsFacet is StakingFacetBase, IFacetSelectors {
9191
/// @param token The token address
9292
/// @param adapter The adapter address
9393
/// @dev Adapter must support the token (checked via supportsAsset)
94+
/// @dev Round 4 audit S-2: rejects when the asset has active deposits.
95+
/// Switching adapters under live balances either strands assets in the
96+
/// old adapter or double-counts them in the new one — `currentDeposits`
97+
/// is the protocol's accounting source of truth, so refuse the swap and
98+
/// route admins through `startAdapterMigration` (M-8) instead.
9499
function registerAdapter(address token, address adapter) external onlyRole(ASSET_MANAGER_ROLE) {
95100
require(token != address(0), "Cannot set adapter for native");
96101
require(adapter != address(0), "Invalid adapter");
97-
98-
// Verify adapter supports the token
99102
require(IAssetAdapter(adapter).supportsAsset(token), "Adapter doesn't support token");
100103

104+
bytes32 assetHash = _assetHash(Types.Asset(Types.AssetKind.ERC20, token));
105+
uint256 deposits = _assetConfigs[assetHash].currentDeposits;
106+
if (deposits != 0) revert AdapterChangeWhileDepositsExist(token, deposits);
107+
101108
_assetAdapters[token] = adapter;
102109
emit AdapterRegistered(token, adapter);
103110
}
104111

105112
/// @notice Remove adapter for a token (falls back to direct transfers)
106113
/// @param token The token address
114+
/// @dev Round 4 audit S-2: same `currentDeposits == 0` gate as
115+
/// `registerAdapter`. Removing an adapter while balances are held there
116+
/// would silently strand them — admins should use
117+
/// `startAdapterMigration(token, address(0))` to drain first.
107118
function removeAdapter(address token) external onlyRole(ASSET_MANAGER_ROLE) {
108119
require(_assetAdapters[token] != address(0), "No adapter registered");
120+
121+
bytes32 assetHash = _assetHash(Types.Asset(Types.AssetKind.ERC20, token));
122+
uint256 deposits = _assetConfigs[assetHash].currentDeposits;
123+
if (deposits != 0) revert AdapterChangeWhileDepositsExist(token, deposits);
124+
109125
delete _assetAdapters[token];
110126
emit AdapterRemoved(token);
111127
}
112128

129+
/// @notice Adapter changes are forbidden while the asset has live deposits.
130+
/// @dev Use `startAdapterMigration` (M-8) for the controlled drain path.
131+
error AdapterChangeWhileDepositsExist(address token, uint256 currentDeposits);
132+
113133
/// @notice Set whether adapters are required for ERC20 deposits
114134
/// @param required If true, deposits revert when no adapter is registered
115135
function setRequireAdapters(bool required) external onlyRole(ASSET_MANAGER_ROLE) {

src/interfaces/IMultiAssetDelegation.sol

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,11 @@ interface IMultiAssetDelegation {
249249
function getAssetConfig(address token) external view returns (Types.AssetConfig memory);
250250
function registerAdapter(address token, address adapter) external;
251251
function removeAdapter(address token) external;
252+
253+
/// @notice Round 4 audit S-2: raw register/remove of an adapter is rejected
254+
/// while the asset has live deposits. Use the M-8 migration path
255+
/// (`startAdapterMigration` → `completeAdapterMigration`) instead.
256+
error AdapterChangeWhileDepositsExist(address token, uint256 currentDeposits);
252257
function setRequireAdapters(bool required) external;
253258
function enableAssetWithAdapter(
254259
address token,

0 commit comments

Comments
 (0)