Skip to content

Commit 7ecac7e

Browse files
Protect-against-frontrunning-and-sandwich-attacks-on-subscription-charges
1 parent 05c28de commit 7ecac7e

4 files changed

Lines changed: 748 additions & 7 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# MEV Threat Model for SubTrackr Subscription Charges
2+
3+
## Overview
4+
5+
Maximal Extractable Value (MEV) in the context of Soroban subscription
6+
charges refers to the ability of validators, sequencers, or bots to
7+
reorder, include, or front-run charge transactions to extract value from
8+
subscribers. This document catalogues the threats and describes the
9+
protections implemented.
10+
11+
## Threat Categories
12+
13+
### 1. Front-running
14+
15+
| Threat | Description | Severity |
16+
|--------|-------------|----------|
17+
| **Price Oracle Front-run** | An adversary observes a pending charge tx and submits their own tx with a manipulated oracle price before the charge is confirmed, causing the subscriber to overpay. | High |
18+
| **Insertion Front-run** | Validator inserts their own transfer before the subscriber's charge, draining the subscriber's token balance and causing the charge to fail (DoS). | Medium |
19+
20+
**Mitigations:**
21+
22+
- **Commit-Reveal** (`commit_charge` / `reveal_charge`): The subscriber
23+
commits to a SHA-256 hash of (amount, nonce) before the actual charge
24+
is executed. The price is hidden until `reveal_charge`, preventing
25+
front-runners from knowing the charge amount.
26+
- **Oracle Slippage Protection** (`PriceBounds` / `resolve_charge_price`):
27+
The resolved charge price is clamped to `[min_price_bps, max_price_bps]`
28+
of the plan price, limiting the impact of oracle manipulation.
29+
- **Per-call `max_gas_fee`**: The subscriber sets a maximum acceptable
30+
base fee per call; if the ledger base fee exceeds this threshold at
31+
execution time, the charge is rejected.
32+
33+
### 2. Sandwich Attacks
34+
35+
| Threat | Description | Severity |
36+
|--------|-------------|----------|
37+
| **Oracle Sandwich** | Adversary manipulates the oracle price feed before and after the subscriber's charge, profiting from the price difference. | Medium |
38+
39+
**Mitigations:**
40+
41+
- `resolve_charge_price` uses the oracle's `get_price_with_cache` (TTL
42+
= 600 ledgers) which returns a cached price rather than a live feed,
43+
reducing the window for oracle sandwich attacks.
44+
- Slippage bounds (`PriceBounds`) cap the maximum deviation from the
45+
plan's base price.
46+
47+
### 3. Time-bandit / Reorg Attacks
48+
49+
| Threat | Description | Severity |
50+
|--------|-------------|----------|
51+
| **Ledger Reorg** | A validator rewinds the ledger state to re-execute a charge at a more favourable price, or to double-spend a commitment. | Low |
52+
53+
**Mitigations:**
54+
55+
- Commitments include a `deadline` timestamp. If the ledger timestamp
56+
regresses past the deadline, `reveal_charge` rejects the reveal.
57+
- Commitments are single-use: `reveal_charge` removes the commitment
58+
from storage after successful execution, preventing replay.
59+
60+
### 4. Gas Price Manipulation
61+
62+
| Threat | Description | Severity |
63+
|--------|-------------|----------|
64+
| **Gas Price Spiking** | Validator raises the base fee to force subscribers into paying more gas than expected, or to extract rent from urgent charges. | Medium |
65+
| **Gas Griefing** | Adversary causes the charge transaction to consume more gas (e.g. by bloating storage reads) so the subscriber exceeds their gas budget. | Low |
66+
67+
**Mitigations:**
68+
69+
- **Per-subscription `MevChargeConfig.max_gas`**: Subscribers can set a
70+
hard cap on total gas per charge. If the actual gas used exceeds this
71+
cap, the transaction panics (and any partial state is rolled back).
72+
- **`GasPriceSnapshot`**: After each charge, a snapshot of (ledger_seq,
73+
base_fee, gas_used, amount_charged) is stored. Off-chain monitoring
74+
can detect abnormal gas price patterns.
75+
- **Per-call `max_gas_fee`**: Inline parameter on `charge_subscription`
76+
allows the caller to reject charges when the base fee is too high.
77+
78+
### 5. Private Mempool / Censorship
79+
80+
| Threat | Description | Severity |
81+
|--------|-------------|----------|
82+
| **Tx Censorship** | A validator censors the subscriber's reveal transaction, letting the commitment expire, then submits their own reveal with a manipulated price. | Medium |
83+
| **Forced Failure** | Validator delays charge transactions to cause `next_charge_at` violations, then collects late fees or penalties. | Low |
84+
85+
**Mitigations:**
86+
87+
- **Private Mempool Config** (`MevChargeConfig.use_private_mempool`):
88+
When enabled, the contract emits a
89+
`MevEventKind::PrivateMempoolSubmitted` event. Off-chain indexers
90+
forward the event to a private mempool (e.g. via a relayer) to bypass
91+
public tx visibility.
92+
- `deadline` on commitments is set by the subscriber. A sufficiently
93+
long deadline (e.g. several ledger closes) gives the subscriber
94+
ample time to retry the reveal if censored.
95+
96+
## Architecture Diagram
97+
98+
```
99+
Subscriber Contract Storage
100+
| | |
101+
|-- commit_charge(hash, fee, dl)-->| |
102+
| |--- persist ChargeCommitment -->|
103+
| |--- emit MevEvent::Committed -->|
104+
| | |
105+
| ... time passes ... | |
106+
| | |
107+
|-- reveal_charge(amount, nonce)->| |
108+
| |--- load ChargeCommitment ---->|
109+
| |--- verify sha256 match -------|
110+
| |--- check base_fee <= max_fee -|
111+
| |--- token.transfer() --------->|
112+
| |--- persist GasPriceSnapshot ->|
113+
| |--- emit MevEvent::Revealed -->|
114+
```
115+
116+
## Configuration Reference
117+
118+
| Parameter | Type | Scope | Description |
119+
|-----------|------|-------|-------------|
120+
| `use_private_mempool` | `bool` | Per-sub | Emit event for private mempool relay |
121+
| `max_gas_fee` (config) | `i128` | Per-sub | Base fee ceiling from persistent config |
122+
| `max_gas_fee` (per-call) | `Option<i128>` | Per-charge | Inline base fee ceiling (overrides config) |
123+
| `max_gas` | `Option<u64>` | Per-charge | Gas budget ceiling |
124+
| `commitment_hash` | `Bytes` | Per-charge | SHA-256(amount \|\| nonce \|\| subscriber) |
125+
| `deadline` | `u64` | Per-charge | Timestamp after which commitment expires |
126+
127+
## Error Codes
128+
129+
| Code | Error | Condition |
130+
|------|-------|-----------|
131+
| 22 | `SlippageExceeded` | Base fee exceeds `max_gas_fee` |
132+
| 23 | `CommitmentExpired` | `now > deadline` on reveal or `deadline < now` on commit |
133+
| 24 | `CommitmentMismatch` | SHA-256(amount, nonce, subscriber) does not match stored hash |
134+
| 25 | `MaxGasExceeded` | Actual gas used exceeds `max_gas` |
135+
| 26 | `PrivateMempoolRequired` | Charge attempted without private mempool when config requires it |
136+
137+
## Monitoring & Alerting
138+
139+
Off-chain indexers should watch for the following events:
140+
141+
| Event Topic | Action |
142+
|-------------|--------|
143+
| `mev_event` + `GasPriceAnomaly` | Alert: gas price spike detected for subscriber |
144+
| `mev_event` + `PrivateMempoolSubmitted` | Verify that the tx was routed through private mempool |
145+
| `mev_event` + `Expired` | Alert: commitment expired without reveal (possible censorship) |
146+
| `rate_limit_violation` (on `charge_subscription`) | Check for DoS attempts on the subscriber |
147+
148+
Compare `GasPriceSnapshot.base_fee` across consecutive charges for the
149+
same subscription. A sudden increase > 2x may indicate a gas price
150+
attack and should trigger a manual review.

contracts/subscription/src/errors.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ pub enum ContractError {
8181
RefundExceedsTotalPaid = 20,
8282
/// Caller is not the merchant/owner of the plan being modified.
8383
PlanOwnerMismatch = 21,
84+
/// Charge price exceeds configured slippage bounds.
85+
SlippageExceeded = 22,
86+
/// Commit-reveal deadline has passed.
87+
CommitmentExpired = 23,
88+
/// Revealed values do not match the commitment.
89+
CommitmentMismatch = 24,
90+
/// Gas cost exceeds subscriber's configured maximum.
91+
MaxGasExceeded = 25,
92+
/// This charge requires a private mempool submission.
93+
PrivateMempoolRequired = 26,
8494
}
8595

8696
impl ContractError {
@@ -111,6 +121,11 @@ impl ContractError {
111121
Self::InvalidMigrationPath => "Unsupported migration path.",
112122
Self::RefundExceedsTotalPaid => "Refund amount exceeds total amount paid.",
113123
Self::PlanOwnerMismatch => "Only the plan owner can perform this action.",
124+
Self::SlippageExceeded => "Charge price exceeds configured slippage bounds.",
125+
Self::CommitmentExpired => "Commit-reveal deadline has passed.",
126+
Self::CommitmentMismatch => "Revealed values do not match the commitment.",
127+
Self::MaxGasExceeded => "Gas cost exceeds subscriber's configured maximum.",
128+
Self::PrivateMempoolRequired => "This charge requires a private mempool submission.",
114129
}
115130
}
116131

@@ -135,6 +150,11 @@ mod tests {
135150
assert_eq!(ContractError::PaymentNotYetDue as u32, 10);
136151
assert_eq!(ContractError::RefundExceedsTotalPaid as u32, 20);
137152
assert_eq!(ContractError::PlanOwnerMismatch as u32, 21);
153+
assert_eq!(ContractError::SlippageExceeded as u32, 22);
154+
assert_eq!(ContractError::CommitmentExpired as u32, 23);
155+
assert_eq!(ContractError::CommitmentMismatch as u32, 24);
156+
assert_eq!(ContractError::MaxGasExceeded as u32, 25);
157+
assert_eq!(ContractError::PrivateMempoolRequired as u32, 26);
138158
}
139159

140160
/// Every variant must have a non-empty user_message.
@@ -148,7 +168,8 @@ mod tests {
148168
InsufficientAllowance, InvalidAmount, InvalidInterval, InvalidPriceBounds,
149169
MaxPauseDurationExceeded, RateLimited, OracleUnavailable,
150170
StorageVersionMismatch, InvalidMigrationPath, RefundExceedsTotalPaid,
151-
PlanOwnerMismatch,
171+
PlanOwnerMismatch, SlippageExceeded, CommitmentExpired, CommitmentMismatch,
172+
MaxGasExceeded, PrivateMempoolRequired,
152173
];
153174
for v in variants {
154175
let msg = v.user_message();

0 commit comments

Comments
 (0)