Skip to content

Commit ec2254b

Browse files
committed
clamp filler output for safety
1 parent b631c63 commit ec2254b

5 files changed

Lines changed: 242 additions & 72 deletions

File tree

sdk/packages/simplex/filler-config-example.toml

Lines changed: 57 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ hyperbridgeWsUrl = ""
4949
# maxPriorityFeePerGasBumpPercent = 8 # Percentage added to gasPrice for priority fee (default: 8)
5050
# maxFeePerGasBumpPercent = 10 # Percentage added to gasPrice for max fee (default: 10)
5151

52+
# Overfill protection (optional)
53+
# Bounds per-leg loss when internal pricing is wrong (bug, stale cache, manipulated venue).
54+
# The filler clamps its computed output to at most (1 + maxOverfillBps) × user-requested amount.
55+
# After `maxConsecutiveClamps` consecutive orders where the clamp activated, the strategy
56+
# halts itself — likely a systemic pricing error — and requires operator restart.
57+
# If not provided, defaults will be used (maxOverfillBps = 100, maxConsecutiveClamps = 3)
58+
# [simplex.overfillProtection]
59+
# maxOverfillBps = 100 # 1% ceiling above user-requested output (default: 100)
60+
# maxConsecutiveClamps = 3 # Halt threshold (default: 3)
61+
5262

5363
# Logging level: trace, debug, info, warn, error
5464
logging = "debug"
@@ -90,7 +100,7 @@ triggerPercentage = 0.5
90100
type = "basic"
91101
# Filler BPS (basis points) curve based on order value
92102
# Uses polynomial interpolation to create a smooth curve through all points
93-
# - Small orders: Higher BPS to make filling worthwhile
103+
# - Small orders: Higher BPS to make filling worthwhile
94104
# - Large orders: Lower BPS to be more competitive
95105
bpsCurve = [
96106
{ amount = "100", value = 100 }, # $100 orders: 1% margin
@@ -99,6 +109,52 @@ bpsCurve = [
99109
{ amount = "100000", value = 10 }, # $100000 orders: 0.1% margin
100110
]
101111

112+
# Per-chain confirmation policies for this strategy (optional).
113+
# Uses polynomial interpolation to create a smooth curve through all points.
114+
# The chain ID should match the numeric ID from the chain configuration.
115+
# Sensible defaults are merged in for chains you don't list (ETH, BSC, Polygon, Base, Arbitrum).
116+
[strategies.confirmationPolicies."1"] # Ethereum Mainnet
117+
points = [
118+
{ amount = "5", value = 3 },
119+
{ amount = "500", value = 6 },
120+
{ amount = "5000", value = 12 },
121+
]
122+
123+
[strategies.confirmationPolicies."56"] # BSC Mainnet
124+
points = [
125+
{ amount = "1", value = 3 },
126+
{ amount = "500", value = 6 },
127+
{ amount = "5000", value = 15 },
128+
]
129+
130+
[strategies.confirmationPolicies."42161"] # Arbitrum Mainnet
131+
points = [
132+
{ amount = "2", value = 2 },
133+
{ amount = "1000", value = 5 },
134+
{ amount = "8000", value = 10 },
135+
]
136+
137+
[strategies.confirmationPolicies."8453"] # Base Mainnet
138+
points = [
139+
{ amount = "2", value = 2 },
140+
{ amount = "1000", value = 5 },
141+
{ amount = "8000", value = 10 },
142+
]
143+
144+
[strategies.confirmationPolicies."137"] # Polygon Mainnet
145+
points = [
146+
{ amount = "2", value = 2 },
147+
{ amount = "1000", value = 5 },
148+
{ amount = "8000", value = 10 },
149+
]
150+
151+
[strategies.confirmationPolicies."130"] # Unichain Mainnet
152+
points = [
153+
{ amount = "2", value = 2 },
154+
{ amount = "1000", value = 5 },
155+
{ amount = "8000", value = 10 },
156+
]
157+
102158
# HyperFX strategy for exotic token swaps (e.g. stablecoin <-> cNGN)
103159
# Uncomment and configure to enable
104160
# [[strategies]]
@@ -164,66 +220,3 @@ rpcUrl = "" # Unichain Mainnet
164220
bundlerUrl = ""
165221

166222

167-
# Confirmation policies per chain
168-
# Uses polynomial interpolation to create a smooth curve through all points
169-
# The chain ID should match the numeric ID from the chain configuration
170-
171-
[confirmationPolicies."97"] # BSC Testnet
172-
points = [
173-
{ amount = "1", value = 1 },
174-
{ amount = "1000", value = 5 },
175-
]
176-
177-
[confirmationPolicies."10200"] # Gnosis Chiado
178-
points = [
179-
{ amount = "1", value = 1 },
180-
{ amount = "1000", value = 5 },
181-
]
182-
183-
[confirmationPolicies."1"] # Ethereum Mainnet
184-
points = [
185-
{ amount = "5", value = 3 },
186-
{ amount = "500", value = 6 },
187-
{ amount = "5000", value = 12 },
188-
]
189-
190-
[confirmationPolicies."56"] # BSC Mainnet
191-
points = [
192-
{ amount = "1", value = 3 },
193-
{ amount = "500", value = 6 },
194-
{ amount = "5000", value = 15 },
195-
]
196-
197-
[confirmationPolicies."11155111"] # Sepolia
198-
points = [
199-
{ amount = "1", value = 1 },
200-
{ amount = "1000", value = 5 },
201-
]
202-
203-
[confirmationPolicies."42161"] # Arbitrum Mainnet
204-
points = [
205-
{ amount = "2", value = 2 },
206-
{ amount = "1000", value = 5 },
207-
{ amount = "8000", value = 10 },
208-
]
209-
210-
[confirmationPolicies."8453"] # Base Mainnet
211-
points = [
212-
{ amount = "2", value = 2 },
213-
{ amount = "1000", value = 5 },
214-
{ amount = "8000", value = 10 },
215-
]
216-
217-
[confirmationPolicies."137"] # Polygon Mainnet
218-
points = [
219-
{ amount = "2", value = 2 },
220-
{ amount = "1000", value = 5 },
221-
{ amount = "8000", value = 10 },
222-
]
223-
224-
[confirmationPolicies."130"] # Unichain Mainnet
225-
points = [
226-
{ amount = "2", value = 2 },
227-
{ amount = "1000", value = 5 },
228-
{ amount = "8000", value = 10 },
229-
]

sdk/packages/simplex/src/bin/simplex.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,16 @@ interface FillerTomlConfig {
170170
solverAccountContractAddress?: string
171171
/** Target gas units for EntryPoint deposits per chain. Defaults to 3,000,000. */
172172
targetGasUnits?: number
173+
/** Gas fee bump (percentages added to base gasPrice). Defaults: priority=8%, max=10%. */
174+
gasFeeBump?: {
175+
maxPriorityFeePerGasBumpPercent?: number
176+
maxFeePerGasBumpPercent?: number
177+
}
178+
/** Overfill protection knobs. Defaults: maxOverfillBps=100, maxConsecutiveClamps=3. */
179+
overfillProtection?: {
180+
maxOverfillBps?: number
181+
maxConsecutiveClamps?: number
182+
}
173183
}
174184
strategies: StrategyConfig[]
175185
chains: UserProvidedChainConfig[]
@@ -232,6 +242,8 @@ program
232242
dataDir: options.dataDir,
233243
rebalancing: config.rebalancing,
234244
targetGasUnits: config.simplex.targetGasUnits,
245+
gasFeeBump: config.simplex.gasFeeBump,
246+
overfillProtection: config.simplex.overfillProtection,
235247
}
236248

237249
const configService = new FillerConfigService(resolvedChains, fillerConfigForService)

sdk/packages/simplex/src/services/FillerConfigService.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ export interface RebalancingConfig {
5757
}
5858
}
5959

60+
export interface OverfillProtectionConfig {
61+
/** Ceiling bps above user-requested output; filler clamps its computed output to this. Default 100 (1%). */
62+
maxOverfillBps?: number
63+
/** Consecutive clamped evaluations before the strategy halts itself. Default 3. */
64+
maxConsecutiveClamps?: number
65+
}
66+
6067
export interface FillerConfig {
6168
maxConcurrentOrders: number
6269
logging?: LogLevel
@@ -75,6 +82,11 @@ export interface FillerConfig {
7582
* Defaults to 3,000,000 if not set.
7683
*/
7784
targetGasUnits?: number
85+
/**
86+
* Overfill protection knobs. If omitted, defaults are used
87+
* (maxOverfillBps=100, maxConsecutiveClamps=3).
88+
*/
89+
overfillProtection?: OverfillProtectionConfig
7890
}
7991

8092
/**
@@ -364,4 +376,14 @@ export class FillerConfigService {
364376
getTargetGasUnits(): bigint {
365377
return BigInt(this.fillerConfig?.targetGasUnits ?? 3_000_000)
366378
}
379+
380+
/** Ceiling bps above user-requested output. Default 100 (1%). */
381+
getMaxOverfillBps(): bigint {
382+
return BigInt(this.fillerConfig?.overfillProtection?.maxOverfillBps ?? 100)
383+
}
384+
385+
/** Consecutive clamped evaluations before the strategy halts. Default 3. */
386+
getMaxConsecutiveClamps(): number {
387+
return this.fillerConfig?.overfillProtection?.maxConsecutiveClamps ?? 3
388+
}
367389
}

sdk/packages/simplex/src/strategies/basic.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ export class BasicFiller implements FillerStrategy {
2727
private bpsPolicy: FillerBpsPolicy
2828
private signer: SigningAccount
2929
private logger = getLogger("basic-simplex")
30+
/** Consecutive orders where overfill clamp activated. Reset on a clean evaluation. */
31+
private consecutiveClamps = 0
32+
/** Once set, the filler refuses all orders until restart — likely systemic pricing error. */
33+
private halted = false
34+
/** Ceiling bps above user-requested output. Sourced from filler config. */
35+
private readonly maxOverfillBps: bigint
36+
/** Consecutive clamped evaluations before halting. Sourced from filler config. */
37+
private readonly maxConsecutiveClamps: number
3038
confirmationPolicy: { getConfirmationBlocks: (chainId: number, amountUsd: number) => number }
3139

3240
constructor(
@@ -46,6 +54,8 @@ export class BasicFiller implements FillerStrategy {
4654
confirmationPolicy.getConfirmationBlocks(chainId, new Decimal(amountUsd)),
4755
}
4856
this.signer = signer
57+
this.maxOverfillBps = configService.getMaxOverfillBps()
58+
this.maxConsecutiveClamps = configService.getMaxConsecutiveClamps()
4959
}
5060

5161
/**
@@ -55,6 +65,10 @@ export class BasicFiller implements FillerStrategy {
5565
* @returns True if the strategy can fill the order
5666
*/
5767
async canFill(order: Order): Promise<boolean> {
68+
if (this.halted) {
69+
this.logger.warn({ orderId: order.id }, "BasicFiller halted — rejecting order")
70+
return false
71+
}
5872
try {
5973
// Validate basic structure
6074
if (order.inputs.length === 0 || order.inputs.length !== order.output.assets.length) {
@@ -127,6 +141,10 @@ export class BasicFiller implements FillerStrategy {
127141
* @returns The profit in USD (Number), or 0 if not profitable or output amounts don't meet minimum
128142
*/
129143
async calculateProfitability(order: Order): Promise<number> {
144+
if (this.halted) {
145+
this.logger.warn({ orderId: order.id }, "BasicFiller halted — rejecting order")
146+
return 0
147+
}
130148
try {
131149
const { decimals: destFeeTokenDecimals } = await this.contractService.getFeeTokenWithDecimals(
132150
order.destination,
@@ -212,6 +230,7 @@ export class BasicFiller implements FillerStrategy {
212230
const basisPoints = 10000n
213231
let totalProfitNormalized = 0n
214232
const fillerOutputs: TokenInfo[] = []
233+
let anyLegClamped = false
215234

216235
for (let i = 0; i < order.inputs.length; i++) {
217236
const input = order.inputs[i]
@@ -228,17 +247,17 @@ export class BasicFiller implements FillerStrategy {
228247

229248
// Calculate max output filler will provide based on their bps
230249
// Formula: inputAmount * (10000 - fillerBps) / 10000
231-
const fillerMaxOutput = (convertedInputAmount * (basisPoints - fillerBps)) / basisPoints
250+
const bpsOutput = (convertedInputAmount * (basisPoints - fillerBps)) / basisPoints
232251

233252
// Reject if user expects more than filler can provide
234-
if (output.amount > fillerMaxOutput) {
253+
if (output.amount > bpsOutput) {
235254
this.logger.debug(
236255
{
237256
index: i,
238257
inputAmount: input.amount.toString(),
239258
inputDecimals,
240259
userExpects: output.amount.toString(),
241-
fillerWillProvide: fillerMaxOutput.toString(),
260+
fillerWillProvide: bpsOutput.toString(),
242261
outputDecimals,
243262
fillerBps: fillerBps.toString(),
244263
},
@@ -247,6 +266,25 @@ export class BasicFiller implements FillerStrategy {
247266
return { isValid: false, profitFromSlippage: 0n }
248267
}
249268

269+
// Clamp to at most (1 + maxOverfillBps) × user-requested to bound loss on pricing errors.
270+
const overfillCeiling = (output.amount * (10000n + this.maxOverfillBps)) / 10000n
271+
let fillerMaxOutput = bpsOutput
272+
if (bpsOutput > overfillCeiling) {
273+
this.logger.warn(
274+
{
275+
orderId: order.id,
276+
index: i,
277+
userRequested: output.amount.toString(),
278+
unclamped: bpsOutput.toString(),
279+
clamped: overfillCeiling.toString(),
280+
maxOverfillBps: this.maxOverfillBps.toString(),
281+
},
282+
"Overfill clamp activated",
283+
)
284+
fillerMaxOutput = overfillCeiling
285+
anyLegClamped = true
286+
}
287+
250288
// Store the filler's calculated output for this token
251289
fillerOutputs.push({
252290
token: output.token,
@@ -262,6 +300,8 @@ export class BasicFiller implements FillerStrategy {
262300
totalProfitNormalized += profitNormalized
263301
}
264302

303+
this.recordOrderOutcome(anyLegClamped, order.id)
304+
265305
// Cache filler outputs for use during order execution
266306
this.contractService.cacheService.setFillerOutputs(order.id!, fillerOutputs)
267307
this.logger.debug(
@@ -275,6 +315,26 @@ export class BasicFiller implements FillerStrategy {
275315
return { isValid: true, profitFromSlippage: totalProfitNormalized }
276316
}
277317

318+
/**
319+
* Update consecutive-clamp counter after a successful order evaluation.
320+
* Halts the filler if maxConsecutiveClamps is reached — a pattern that strongly
321+
* suggests a systemic pricing bug rather than benign one-off over-provision.
322+
*/
323+
private recordOrderOutcome(clamped: boolean, orderId: string | undefined) {
324+
if (clamped) {
325+
this.consecutiveClamps += 1
326+
if (this.consecutiveClamps >= this.maxConsecutiveClamps) {
327+
this.halted = true
328+
this.logger.error(
329+
{ orderId, consecutiveClamps: this.consecutiveClamps, maxConsecutiveClamps: this.maxConsecutiveClamps },
330+
"BasicFiller HALTED — overfill clamp triggered consecutively; restart required after investigation",
331+
)
332+
}
333+
} else {
334+
this.consecutiveClamps = 0
335+
}
336+
}
337+
278338
/**
279339
* Executes the order fill.
280340
* If hyperbridge is provided, submits a bid (solver selection mode).

0 commit comments

Comments
 (0)