Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions crates/precompiles/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ pub enum TempoPrecompileError {
#[error("Panic({0:?})")]
Panic(PanicKind),

/// Internal storage delta underflow that carries the observed slot value for error mapping.
#[error("Storage delta underflow: current={0}")]
StorageDeltaUnderflow(U256),

/// Error from validator config
#[error("Validator config error: {0:?}")]
ValidatorConfigError(ValidatorConfigError),
Expand Down Expand Up @@ -158,7 +162,7 @@ impl TempoPrecompileError {
Self::SignatureVerifierError(e) => e.selector(),
Self::ReceivePolicyGuardError(e) => e.selector(),
Self::UnknownFunctionSelector(selector) => *selector,
Self::Panic(_) => Panic::SELECTOR,
Self::Panic(_) | Self::StorageDeltaUnderflow(_) => Panic::SELECTOR,
Self::OutOfGas | Self::Fatal(_) => [0, 0, 0, 0],
}
.into()
Expand All @@ -168,7 +172,9 @@ impl TempoPrecompileError {
/// rather than swallowed, because state may be inconsistent.
pub fn is_system_error(&self) -> bool {
match self {
Self::OutOfGas | Self::Fatal(_) | Self::Panic(_) => true,
Self::OutOfGas | Self::Fatal(_) | Self::Panic(_) | Self::StorageDeltaUnderflow(_) => {
true
}
Self::StablecoinDEX(_)
| Self::TIP20(_)
| Self::TIP20ChannelReserveError(_)
Expand All @@ -193,6 +199,11 @@ impl TempoPrecompileError {
Self::Panic(PanicKind::UnderOverflow)
}

/// Creates a storage delta underflow that carries the current slot value.
pub fn storage_delta_underflow(current: U256) -> Self {
Self::StorageDeltaUnderflow(current)
}

/// Creates an enum conversion error panic (Solidity Panic `0x21`).
pub fn enum_conversion_error() -> Self {
Self::Panic(PanicKind::EnumConversionError)
Expand Down Expand Up @@ -227,6 +238,13 @@ impl TempoPrecompileError {

panic.abi_encode().into()
}
Self::StorageDeltaUnderflow(_) => {
let panic = Panic {
code: U256::from(PanicKind::UnderOverflow as u32),
};

panic.abi_encode().into()
}
Self::ValidatorConfigError(e) => e.abi_encode().into(),
Self::ValidatorConfigV2Error(e) => e.abi_encode().into(),
Self::AccountKeychainError(e) => e.abi_encode().into(),
Expand Down
48 changes: 48 additions & 0 deletions crates/precompiles/src/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,30 @@ pub trait PrecompileStorageProvider {
/// Performs an SSTORE operation (persistent storage write).
fn sstore(&mut self, address: Address, key: U256, value: U256) -> Result<()>;

/// Increments a persistent storage slot by `delta`.
///
/// Intentionally returns no post-increment value, preserving `sinc` as a semantic
/// storage delta rather than an observation point that callers can branch on.
fn sinc(&mut self, address: Address, key: U256, delta: U256) -> Result<()> {
let value = self
.sload(address, key)?
.checked_add(delta)
.ok_or_else(TempoPrecompileError::under_overflow)?;
self.sstore(address, key, value)
}

/// Decrements a persistent storage slot by `delta`.
///
/// Intentionally returns no post-decrement value, preserving `sdec` as a semantic
/// storage delta rather than an observation point that callers can branch on.
fn sdec(&mut self, address: Address, key: U256, delta: U256) -> Result<()> {
let current = self.sload(address, key)?;
let value = current
.checked_sub(delta)
.ok_or_else(|| TempoPrecompileError::storage_delta_underflow(current))?;
self.sstore(address, key, value)
}

/// Performs a TSTORE operation (transient storage write).
fn tstore(&mut self, address: Address, key: U256, value: U256) -> Result<()>;

Expand Down Expand Up @@ -169,6 +193,30 @@ pub trait StorageOps {
fn store(&mut self, slot: U256, value: U256) -> Result<()>;
/// Loads a value from the provided slot.
fn load(&self, slot: U256) -> Result<U256>;

/// Increments a value at the provided slot by `delta`.
///
/// Intentionally returns no post-increment value, preserving `sinc` as a semantic
/// storage delta rather than an observation point that callers can branch on.
fn sinc(&mut self, slot: U256, delta: U256) -> Result<()> {
let value = self
.load(slot)?
.checked_add(delta)
.ok_or_else(TempoPrecompileError::under_overflow)?;
self.store(slot, value)
}

/// Decrements a value at the provided slot by `delta`.
///
/// Intentionally returns no post-decrement value, preserving `sdec` as a semantic
/// storage delta rather than an observation point that callers can branch on.
fn sdec(&mut self, slot: U256, delta: U256) -> Result<()> {
let current = self.load(slot)?;
let value = current
.checked_sub(delta)
.ok_or_else(|| TempoPrecompileError::storage_delta_underflow(current))?;
self.store(slot, value)
}
}

/// Trait providing access to a contract's address.
Expand Down
10 changes: 10 additions & 0 deletions crates/precompiles/src/storage/thread_local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ impl StorageCtx {
Self::try_with_storage(|s| s.sstore(address, key, value))
}

/// Increments a persistent storage slot by `delta`.
pub fn sinc(&mut self, address: Address, key: U256, delta: U256) -> Result<()> {
Self::try_with_storage(|s| s.sinc(address, key, delta))
}

/// Decrements a persistent storage slot by `delta`.
pub fn sdec(&mut self, address: Address, key: U256, delta: U256) -> Result<()> {
Self::try_with_storage(|s| s.sdec(address, key, delta))
}

/// Performs a TSTORE operation (transient storage write).
pub fn tstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> {
Self::try_with_storage(|s| s.tstore(address, key, value))
Expand Down
10 changes: 10 additions & 0 deletions crates/precompiles/src/storage/types/slot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ impl<T> StorageOps for Slot<T> {
let mut storage = StorageCtx;
storage.sstore(self.address, slot, value)
}

fn sinc(&mut self, slot: U256, delta: U256) -> Result<()> {
let mut storage = StorageCtx;
storage.sinc(self.address, slot, delta)
}

fn sdec(&mut self, slot: U256, delta: U256) -> Result<()> {
let mut storage = StorageCtx;
storage.sdec(self.address, slot, delta)
}
}

/// Wrapper that routes storage operations through transient storage (TLOAD/TSTORE).
Expand Down
161 changes: 106 additions & 55 deletions crates/precompiles/src/tip20/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use crate::{
address_registry::AddressRegistry,
error::{Result, TempoPrecompileError},
receive_policy_guard::{InboundKind, ReceivePolicyGuard, RecoveryMode},
storage::{Handler, Mapping},
storage::{Handler, Mapping, StorageOps},
tip20::{rewards::UserRewardInfo, roles::DEFAULT_ADMIN_ROLE},
tip20_factory::TIP20Factory,
tip403_registry::{AuthRole, ITIP403Registry, TIP403Registry},
Expand Down Expand Up @@ -540,11 +540,7 @@ impl TIP20Token {
self.handle_rewards_on_mint(to.target, amount)?;

self.set_total_supply(new_supply)?;
let to_balance = self.get_balance(to.target)?;
let new_to_balance = to_balance
.checked_add(amount)
.ok_or(TempoPrecompileError::under_overflow())?;
self.set_balance(to.target, new_to_balance)?;
self.increment_balance(to.target, amount)?;

self.emit_event(to.build_transfer_event(Address::ZERO, amount))
}
Expand Down Expand Up @@ -1002,6 +998,29 @@ impl TIP20Token {
self.balances[account].write(amount)
}

fn increment_balance(&mut self, account: Address, amount: U256) -> Result<()> {
let balance = self.balances.at_mut(&account);
let slot = balance.slot();
balance.sinc(slot, amount).map_err(|err| {
if err == TempoPrecompileError::under_overflow() {
TIP20Error::supply_cap_exceeded().into()
} else {
err
}
})
Comment on lines +1002 to +1010

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

}

fn decrement_balance(&mut self, account: Address, amount: U256) -> Result<()> {
let balance = self.balances.at_mut(&account);
let slot = balance.slot();
balance.sdec(slot, amount).map_err(|err| match err {
TempoPrecompileError::StorageDeltaUnderflow(current) => {
TIP20Error::insufficient_balance(current, amount, self.address).into()
}
err => err,
})
}

fn get_allowance(&self, owner: Address, spender: Address) -> Result<U256> {
self.allowances[owner][spender].read()
}
Expand Down Expand Up @@ -1148,29 +1167,13 @@ impl TIP20Token {
/// For virtual recipients the event address is the virtual alias; the balance update always
/// targets `to.target` (the resolved master).
pub(crate) fn _transfer(&mut self, from: Address, to: &Recipient, amount: U256) -> Result<()> {
let from_balance = self.get_balance(from)?;
if amount > from_balance {
return Err(
TIP20Error::insufficient_balance(from_balance, amount, self.address).into(),
);
}

self.handle_rewards_on_transfer(from, to.target, amount)?;

// Adjust balances
let new_from_balance = from_balance
.checked_sub(amount)
.ok_or(TempoPrecompileError::under_overflow())?;

self.set_balance(from, new_from_balance)?;
self.decrement_balance(from, amount)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [LOW] _transfer balance pre-check removal lets rewards accounting turn insufficient balance into a Panic

The PR removes the up-front from balance check and now relies on decrement_balance to map storage underflow back into TIP20Error::insufficient_balance. That mapping happens only here, after handle_rewards_on_transfer(from, to.target, amount) has already run. For an opted-in sender that transfers more than its balance to a non-opted-in recipient, rewards accounting can subtract amount from opted_in_supply first; when amount exceeds the opted-in supply, that path returns TempoPrecompileError::under_overflow(), which is encoded as Solidity Panic(0x11) and treated as a system error instead of the expected TIP20 insufficient-balance error.

Recommended Fix:
Restore an explicit balance check before handle_rewards_on_transfer, or make handle_rewards_on_transfer receive the already-validated sender balance / post-debit amount so insufficient-balance transfers fail with TIP20Error::insufficient_balance before reward accounting can underflow.


if to.target != Address::ZERO {
let to_balance = self.get_balance(to.target)?;
let new_to_balance = to_balance
.checked_add(amount)
.ok_or(TempoPrecompileError::under_overflow())?;

self.set_balance(to.target, new_to_balance)?;
self.increment_balance(to.target, amount)?;
}

self.emit_event(to.build_transfer_event(from, amount))
Expand Down Expand Up @@ -1295,22 +1298,10 @@ impl TIP20Token {
)?;
}

let new_from_balance =
from_balance
.checked_sub(amount)
.ok_or(TIP20Error::insufficient_balance(
from_balance,
amount,
self.address,
))?;

self.set_balance(from, new_from_balance)?;
self.decrement_balance(from, amount)?;
self.increment_balance(TIP_FEE_MANAGER_ADDRESS, amount)?;

let to_balance = self.get_balance(TIP_FEE_MANAGER_ADDRESS)?;
let new_to_balance = to_balance
.checked_add(amount)
.ok_or(TIP20Error::supply_cap_exceeded())?;
self.set_balance(TIP_FEE_MANAGER_ADDRESS, new_to_balance)
Ok(())
}

/// Refunds unused fee tokens from the fee manager back to `to` and emits a transfer event for
Expand Down Expand Up @@ -1353,23 +1344,10 @@ impl TIP20Token {
)?;
}

let from_balance = self.get_balance(TIP_FEE_MANAGER_ADDRESS)?;
let new_from_balance =
from_balance
.checked_sub(refund)
.ok_or(TIP20Error::insufficient_balance(
from_balance,
refund,
self.address,
))?;
self.decrement_balance(TIP_FEE_MANAGER_ADDRESS, refund)?;
self.increment_balance(to, refund)?;

self.set_balance(TIP_FEE_MANAGER_ADDRESS, new_from_balance)?;

let to_balance = self.get_balance(to)?;
let new_to_balance = to_balance
.checked_add(refund)
.ok_or(TIP20Error::supply_cap_exceeded())?;
self.set_balance(to, new_to_balance)
Ok(())
}
}

Expand Down Expand Up @@ -2311,6 +2289,30 @@ pub(crate) mod tests {
})
}

#[test]
fn test_transfer_fee_pre_tx_fee_manager_overflow() -> eyre::Result<()> {
let mut storage = HashMapStorageProvider::new(1);
let admin = Address::random();
let user = Address::random();
let fee_amount = U256::ONE;

StorageCtx::enter(&mut storage, || {
let mut token = TIP20Setup::create("Test", "TST", admin)
.with_issuer(admin)
.with_mint(user, fee_amount)
.apply()?;
token.set_balance(TIP_FEE_MANAGER_ADDRESS, U256::MAX)?;

assert_eq!(
token.transfer_fee_pre_tx(user, fee_amount),
Err(TempoPrecompileError::TIP20(
TIP20Error::supply_cap_exceeded()
))
);
Ok(())
})
}

#[test]
fn test_transfer_fee_pre_tx_paused() -> eyre::Result<()> {
let mut storage = HashMapStorageProvider::new(1);
Expand Down Expand Up @@ -2369,6 +2371,55 @@ pub(crate) mod tests {
})
}

#[test]
fn test_transfer_fee_post_tx_insufficient_fee_manager_balance() -> eyre::Result<()> {
let mut storage = HashMapStorageProvider::new(1);
let admin = Address::random();
let user = Address::random();
let initial_fee = U256::from(10);
let refund_amount = U256::from(30);

StorageCtx::enter(&mut storage, || {
let mut token = TIP20Setup::create("Test", "TST", admin)
.with_issuer(admin)
.with_mint(TIP_FEE_MANAGER_ADDRESS, initial_fee)
.apply()?;

assert_eq!(
token.transfer_fee_post_tx(user, refund_amount, U256::ZERO),
Err(TempoPrecompileError::TIP20(
TIP20Error::insufficient_balance(initial_fee, refund_amount, token.address)
))
);

Ok(())
})
}

#[test]
fn test_transfer_fee_post_tx_recipient_overflow() -> eyre::Result<()> {
let mut storage = HashMapStorageProvider::new(1);
let admin = Address::random();
let user = Address::random();
let refund_amount = U256::ONE;

StorageCtx::enter(&mut storage, || {
let mut token = TIP20Setup::create("Test", "TST", admin)
.with_issuer(admin)
.apply()?;
token.set_balance(TIP_FEE_MANAGER_ADDRESS, refund_amount)?;
token.set_balance(user, U256::MAX)?;

assert_eq!(
token.transfer_fee_post_tx(user, refund_amount, U256::ZERO),
Err(TempoPrecompileError::TIP20(
TIP20Error::supply_cap_exceeded()
))
);
Ok(())
})
}

#[test]
fn test_transfer_fee_post_tx_refunds_spending_limit() -> eyre::Result<()> {
let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T1C);
Expand Down
Loading
Loading