Skip to content
This repository was archived by the owner on Jun 1, 2026. It is now read-only.

Commit 56ceeec

Browse files
committed
Add excessive-expiration warnings for approvals
Detect and surface approvals/permits with excessive expirations and refactor warning collapse logic. - Make SimulationSeverity Copy + Eq and change SimulationWarning logic: add approval_value() and collapse_priority(severity) to replace previous suppress/is_unlimited checks; collapse_warnings now selects by priority. - Refactor ApprovalRequest construction into ApprovalContext, add separate display_expiration and warning_expiration fields, and TokenField handling for token display rules. - Add expiration detection: EXCESSIVE_EXPIRATION_WINDOW (30 days) and expiration_warning() that emits a ValidationError "Excessive expiration" when expiration is too far out; include display expiration in payloads. - Propagate batch permit expiration parsing (u64) in decode and pass warning_expiration through permit2 batch decoding. - Collect expiration warnings in SimulationClient::simulate_approval so client-side simulations include the extra warning. - Add unit tests for excessive-expiration behavior for EIP-712 permit and permit2 batch cases. - Minor import formatting cleanup in chainflip provider.
1 parent 3cedd27 commit 56ceeec

6 files changed

Lines changed: 313 additions & 80 deletions

File tree

crates/primitives/src/simulation.rs

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use typeshare::typeshare;
44

55
use crate::AssetId;
66

7-
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88
#[typeshare(swift = "Equatable, Hashable, Sendable")]
99
#[serde(rename_all = "lowercase")]
1010
pub enum SimulationSeverity {
@@ -28,25 +28,24 @@ pub enum SimulationWarningType {
2828
impl SimulationWarningType {
2929
fn requires_spender_verification(&self) -> bool {
3030
match self {
31-
Self::TokenApproval { .. } | Self::NftCollectionApproval { .. } | Self::PermitApproval { .. } | Self::PermitBatchApproval { .. } => true,
3231
Self::SuspiciousSpender | Self::ExternallyOwnedSpender | Self::ValidationError => false,
32+
Self::TokenApproval { .. } | Self::NftCollectionApproval { .. } | Self::PermitApproval { .. } | Self::PermitBatchApproval { .. } => true,
3333
}
3434
}
3535

36-
fn suppresses_other_warnings(&self) -> bool {
37-
if let Self::ExternallyOwnedSpender = self {
38-
return true;
39-
}
40-
if let Self::ValidationError = self {
41-
return true;
36+
fn approval_value(&self) -> Option<&Option<BigInt>> {
37+
match self {
38+
Self::TokenApproval { value, .. } | Self::PermitApproval { value, .. } | Self::PermitBatchApproval { value } => Some(value),
39+
Self::SuspiciousSpender | Self::ExternallyOwnedSpender | Self::NftCollectionApproval { .. } | Self::ValidationError => None,
4240
}
43-
false
4441
}
4542

46-
fn is_unlimited_warning(&self) -> bool {
43+
fn collapse_priority(&self, severity: SimulationSeverity) -> u8 {
4744
match self {
48-
Self::TokenApproval { value, .. } | Self::PermitApproval { value, .. } | Self::PermitBatchApproval { value } => value.is_none(),
49-
Self::SuspiciousSpender | Self::ExternallyOwnedSpender | Self::NftCollectionApproval { .. } | Self::ValidationError => false,
45+
Self::ExternallyOwnedSpender => 2,
46+
Self::ValidationError if severity == SimulationSeverity::Critical => 2,
47+
_ if self.approval_value().is_some_and(Option::is_none) => 1,
48+
_ => 0,
5049
}
5150
}
5251
}
@@ -64,6 +63,10 @@ impl SimulationWarning {
6463
pub fn new(severity: SimulationSeverity, warning: SimulationWarningType, message: Option<String>) -> Self {
6564
Self { severity, warning, message }
6665
}
66+
67+
fn collapse_priority(&self) -> u8 {
68+
self.warning.collapse_priority(self.severity)
69+
}
6770
}
6871

6972
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -185,17 +188,15 @@ impl SimulationResult {
185188
}
186189

187190
fn collapse_warnings(warnings: Vec<SimulationWarning>) -> Vec<SimulationWarning> {
188-
let blocking_warning = warnings.iter().find(|warning| warning.warning.suppresses_other_warnings()).cloned();
189-
match blocking_warning {
190-
Some(warning) => vec![warning],
191-
None => {
192-
if let Some(warning) = warnings.iter().find(|warning| warning.warning.is_unlimited_warning()).cloned() {
193-
return vec![warning];
194-
}
191+
if let Some(warning) = warnings.iter().find(|warning| warning.collapse_priority() == 2).cloned() {
192+
return vec![warning];
193+
}
195194

196-
warnings
197-
}
195+
if let Some(warning) = warnings.iter().find(|warning| warning.collapse_priority() == 1).cloned() {
196+
return vec![warning];
198197
}
198+
199+
warnings
199200
}
200201
}
201202

crates/simulation/src/evm/approval_request.rs

Lines changed: 125 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ use primitives::{
33
AssetId, Chain, SimulationHeader, SimulationPayloadField, SimulationPayloadFieldDisplay, SimulationPayloadFieldKind, SimulationPayloadFieldType, SimulationResult,
44
SimulationSeverity, SimulationWarning, SimulationWarningType,
55
};
6+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
67

78
use super::{approval_method::ApprovalMethod, approval_value::ApprovalValue};
89
use gem_evm::ethereum_address_checksum;
910

11+
const EXCESSIVE_EXPIRATION_WINDOW: Duration = Duration::from_secs(60 * 60 * 24 * 30);
12+
1013
#[derive(Debug, Clone, PartialEq)]
1114
pub(crate) struct ApprovalRequest {
1215
asset_id: AssetId,
@@ -15,36 +18,41 @@ pub(crate) struct ApprovalRequest {
1518
pub(crate) spender_address: String,
1619
method: ApprovalMethod,
1720
approval_value: Option<ApprovalValue>,
18-
expiration: Option<String>,
21+
display_expiration: Option<u64>,
22+
warning_expiration: Option<u64>,
1923
}
2024

2125
impl ApprovalRequest {
2226
pub(crate) fn erc20(chain: Chain, contract_address: &str, spender_address: String, approval_value: String) -> Option<Self> {
23-
let contract_address = ethereum_address_checksum(contract_address).ok()?;
24-
let spender_address = ethereum_address_checksum(&spender_address).ok()?;
25-
Some(Self {
26-
asset_id: AssetId::from_token(chain, &contract_address),
27-
contract_address,
28-
token_address: None,
29-
spender_address,
30-
method: ApprovalMethod::Approve,
31-
approval_value: ApprovalValue::from_raw(&approval_value),
32-
expiration: None,
33-
})
27+
Self::new(
28+
chain,
29+
ApprovalContext {
30+
contract_address: contract_address.to_string(),
31+
spender_address,
32+
token_address: None,
33+
approval_value: ApprovalValue::from_raw(&approval_value),
34+
display_expiration: None,
35+
warning_expiration: None,
36+
method: ApprovalMethod::Approve,
37+
token_field: TokenField::AlwaysHide,
38+
},
39+
)
3440
}
3541

3642
pub(crate) fn nft_collection(chain: Chain, contract_address: &str, spender_address: String) -> Option<Self> {
37-
let contract_address = ethereum_address_checksum(contract_address).ok()?;
38-
let spender_address = ethereum_address_checksum(&spender_address).ok()?;
39-
Some(Self {
40-
asset_id: AssetId::from_token(chain, &contract_address),
41-
contract_address,
42-
token_address: None,
43-
spender_address,
44-
method: ApprovalMethod::SetApprovalForAll,
45-
approval_value: None,
46-
expiration: None,
47-
})
43+
Self::new(
44+
chain,
45+
ApprovalContext {
46+
contract_address: contract_address.to_string(),
47+
spender_address,
48+
token_address: None,
49+
approval_value: None,
50+
display_expiration: None,
51+
warning_expiration: None,
52+
method: ApprovalMethod::SetApprovalForAll,
53+
token_field: TokenField::AlwaysHide,
54+
},
55+
)
4856
}
4957

5058
pub(crate) fn permit(
@@ -56,42 +64,70 @@ impl ApprovalRequest {
5664
token_address: Option<String>,
5765
method: ApprovalMethod,
5866
) -> Option<Self> {
59-
let contract_address = ethereum_address_checksum(&contract_address).ok()?;
60-
let spender_address = ethereum_address_checksum(&spender_address).ok()?;
61-
let token_address = token_address.map(|value| ethereum_address_checksum(&value)).transpose().ok()?;
62-
let asset_address = token_address.clone().unwrap_or_else(|| contract_address.clone());
63-
let token_address = (asset_address != contract_address).then_some(asset_address.clone());
64-
Some(Self {
65-
asset_id: AssetId::from_token(chain, &asset_address),
66-
contract_address,
67-
token_address,
68-
spender_address,
69-
method,
70-
approval_value: ApprovalValue::from_raw(&approval_value),
71-
expiration,
72-
})
67+
Self::new(
68+
chain,
69+
ApprovalContext {
70+
contract_address,
71+
spender_address,
72+
token_address,
73+
approval_value: ApprovalValue::from_raw(&approval_value),
74+
display_expiration: expiration.as_deref().map(str::parse).transpose().ok()?,
75+
warning_expiration: expiration.as_deref().map(str::parse).transpose().ok()?,
76+
method,
77+
token_field: TokenField::HideWhenMatchingContract,
78+
},
79+
)
7380
}
7481

75-
pub(crate) fn permit_batch(chain: Chain, contract_address: String, spender_address: String, approval_value: ApprovalValue, token_address: Option<String>) -> Option<Self> {
76-
let contract_address = ethereum_address_checksum(&contract_address).ok()?;
77-
let spender_address = ethereum_address_checksum(&spender_address).ok()?;
78-
let token_address = token_address.map(|value| ethereum_address_checksum(&value)).transpose().ok()?;
82+
pub(crate) fn permit_batch(
83+
chain: Chain,
84+
contract_address: String,
85+
spender_address: String,
86+
approval_value: ApprovalValue,
87+
token_address: Option<String>,
88+
warning_expiration: Option<u64>,
89+
) -> Option<Self> {
90+
Self::new(
91+
chain,
92+
ApprovalContext {
93+
contract_address,
94+
spender_address,
95+
token_address,
96+
approval_value: Some(approval_value),
97+
display_expiration: None,
98+
warning_expiration,
99+
method: ApprovalMethod::PermitBatch,
100+
token_field: TokenField::ShowWhenPresent,
101+
},
102+
)
103+
}
104+
105+
fn new(chain: Chain, context: ApprovalContext) -> Option<Self> {
106+
let contract_address = ethereum_address_checksum(&context.contract_address).ok()?;
107+
let spender_address = ethereum_address_checksum(&context.spender_address).ok()?;
108+
let token_address = context.token_address.map(|value| ethereum_address_checksum(&value)).transpose().ok()?;
79109
let asset_address = token_address.clone().unwrap_or_else(|| contract_address.clone());
110+
let token_address = match context.token_field {
111+
TokenField::AlwaysHide => None,
112+
TokenField::HideWhenMatchingContract if asset_address == contract_address => None,
113+
TokenField::HideWhenMatchingContract | TokenField::ShowWhenPresent => token_address,
114+
};
80115

81116
Some(Self {
82117
asset_id: AssetId::from_token(chain, &asset_address),
83118
contract_address,
84119
token_address,
85120
spender_address,
86-
method: ApprovalMethod::PermitBatch,
87-
approval_value: Some(approval_value),
88-
expiration: None,
121+
method: context.method,
122+
approval_value: context.approval_value,
123+
display_expiration: context.display_expiration,
124+
warning_expiration: context.warning_expiration,
89125
})
90126
}
91127

92128
pub(crate) fn simulate(self) -> SimulationResult {
93-
let warning = self.primary_warning();
94-
self.build_simulation_result(vec![warning])
129+
let warnings = self.warnings();
130+
self.build_simulation_result(warnings)
95131
}
96132

97133
pub(crate) fn build_simulation_result(self, warnings: Vec<SimulationWarning>) -> SimulationResult {
@@ -135,13 +171,35 @@ impl ApprovalRequest {
135171
)
136172
}
137173

174+
pub(crate) fn warnings(&self) -> Vec<SimulationWarning> {
175+
let mut warnings = vec![self.primary_warning()];
176+
if let Some(warning) = self.expiration_warning() {
177+
warnings.push(warning);
178+
}
179+
warnings
180+
}
181+
138182
fn warning_approval_value(&self) -> Option<BigInt> {
139183
match self.approval_value.as_ref() {
140184
Some(ApprovalValue::Exact(value)) => Some(BigInt::from(value.clone())),
141185
Some(ApprovalValue::Unlimited) | None => None,
142186
}
143187
}
144188

189+
pub(crate) fn expiration_warning(&self) -> Option<SimulationWarning> {
190+
let expiration = self.warning_expiration?;
191+
let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
192+
if expiration <= now.saturating_add(EXCESSIVE_EXPIRATION_WINDOW.as_secs()) {
193+
return None;
194+
}
195+
196+
Some(SimulationWarning::new(
197+
SimulationSeverity::Warning,
198+
SimulationWarningType::ValidationError,
199+
Some("Excessive expiration".to_string()),
200+
))
201+
}
202+
145203
fn payload(&self) -> Vec<SimulationPayloadField> {
146204
let mut payload = vec![
147205
SimulationPayloadField::standard(
@@ -185,12 +243,10 @@ impl ApprovalRequest {
185243
));
186244
}
187245

188-
if self.method.supports_value_display()
189-
&& let Some(expiration) = self.expiration.as_deref()
190-
{
246+
if let Some(expiration) = self.display_expiration {
191247
payload.push(SimulationPayloadField::custom(
192248
"expiration",
193-
expiration,
249+
expiration.to_string(),
194250
SimulationPayloadFieldType::Timestamp,
195251
SimulationPayloadFieldDisplay::Secondary,
196252
));
@@ -199,3 +255,22 @@ impl ApprovalRequest {
199255
payload
200256
}
201257
}
258+
259+
#[derive(Debug, Clone)]
260+
struct ApprovalContext {
261+
contract_address: String,
262+
spender_address: String,
263+
token_address: Option<String>,
264+
approval_value: Option<ApprovalValue>,
265+
display_expiration: Option<u64>,
266+
warning_expiration: Option<u64>,
267+
method: ApprovalMethod,
268+
token_field: TokenField,
269+
}
270+
271+
#[derive(Debug, Clone, Copy)]
272+
enum TokenField {
273+
AlwaysHide,
274+
HideWhenMatchingContract,
275+
ShowWhenPresent,
276+
}

crates/simulation/src/evm/client.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ impl<'a, C: Client + Clone> SimulationClient<'a, C> {
3434
}
3535

3636
async fn simulate_approval(&self, approval: ApprovalRequest) -> Result<SimulationResult, Box<dyn Error + Send + Sync>> {
37-
let warnings = self.approval_warnings(&approval).await?;
37+
let warnings = self.approval_warnings(&approval).await?.into_iter().chain(approval.expiration_warning()).collect();
3838
Ok(approval.build_simulation_result(warnings))
3939
}
4040

@@ -136,6 +136,51 @@ mod tests {
136136
Ok(())
137137
}
138138

139+
#[tokio::test]
140+
async fn eip712_permit_with_excessive_expiration_keeps_warning_with_client() -> Result<(), Box<dyn Error + Send + Sync>> {
141+
let json: Value = serde_json::json!({
142+
"types": {
143+
"EIP712Domain": [
144+
{ "name": "name", "type": "string" },
145+
{ "name": "version", "type": "string" },
146+
{ "name": "chainId", "type": "uint256" },
147+
{ "name": "verifyingContract", "type": "address" }
148+
],
149+
"Permit": [
150+
{ "name": "owner", "type": "address" },
151+
{ "name": "spender", "type": "address" },
152+
{ "name": "value", "type": "uint256" },
153+
{ "name": "nonce", "type": "uint256" },
154+
{ "name": "deadline", "type": "uint256" }
155+
]
156+
},
157+
"primaryType": "Permit",
158+
"domain": {
159+
"name": "USD Coin",
160+
"version": "2",
161+
"chainId": "1",
162+
"verifyingContract": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
163+
},
164+
"message": {
165+
"owner": "0x1111111111111111111111111111111111111111",
166+
"spender": "0x2222222222222222222222222222222222222222",
167+
"value": "1000",
168+
"nonce": "0",
169+
"deadline": "9999999999"
170+
}
171+
});
172+
let message = parse_eip712_json(&json)?;
173+
let client = ethereum_client("0x1234");
174+
175+
let result = SimulationClient::new(&client).simulate_eip712_message(Chain::Ethereum, &message).await?;
176+
177+
assert_eq!(result.warnings.len(), 2);
178+
assert_eq!(result.warnings[1].warning, SimulationWarningType::ValidationError);
179+
assert_eq!(result.warnings[1].message.as_deref(), Some("Excessive expiration"));
180+
181+
Ok(())
182+
}
183+
139184
fn ethereum_client(code: &str) -> EthereumClient<gem_client::testkit::MockClient> {
140185
let code = code.to_string();
141186
let client = mock_jsonrpc_client(move |method, _| match method {

0 commit comments

Comments
 (0)