99//
1010// node packages/stitch-bot/scripts/run-local.mjs
1111//
12- // Env: STITCH_PRIVATE_KEY, FEED_URL (base), INDEXER_URL, BLOCKCHAIN_RPC_URL,
13- // OFFSET_BPS / SELL_OFFSET_BPS (spreads), TOTAL_ORDER_SIZE_USD (per-side
14- // notional), MIN_ORDER_SIZE_USD (smallest ladder slice).
12+ // Env: STITCH_PRIVATE_KEY, FEED_URL (base), INDEXER_URL, BLOCKCHAIN_RPC_URL.
13+ // Production-like defaults are intentional: short TTL, real refresh threshold,
14+ // one book of inventory, and max liquidity. Override STITCH_LOCAL_* only when
15+ // you deliberately want local convenience over production parity.
1516import { spawn } from 'node:child_process'
1617import { readFileSync , writeFileSync } from 'node:fs'
1718import { fileURLToPath } from 'node:url'
@@ -27,8 +28,10 @@ const PERMIT2 = '0x000000000022D473030F116dDEE9F6B43aC78BA3'
2728const KEY =
2829 process . env . STITCH_PRIVATE_KEY ||
2930 '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'
30- const BUY_OFFSET_BPS = Number ( process . env . OFFSET_BPS || 50 )
31- const SELL_OFFSET_BPS = Number ( process . env . SELL_OFFSET_BPS || 50 )
31+ const BUY_OFFSET_BPS = Number ( process . env . OFFSET_BPS || 3 )
32+ const SELL_OFFSET_BPS = Number (
33+ process . env . SELL_OFFSET_BPS || process . env . OFFSET_BPS || 3
34+ )
3235const TOTAL_ORDER_SIZE_USD = Number (
3336 process . env . TOTAL_ORDER_SIZE_USD || process . env . ORDER_SIZE_USD || 2000
3437) // each side quotes roughly this many USD
@@ -38,14 +41,23 @@ const positiveIntEnv = (name, fallback) => {
3841 const value = Number . parseInt ( process . env [ name ] || '' , 10 )
3942 return Number . isFinite ( value ) && value > 0 ? value : fallback
4043}
44+ const nonNegativeIntEnv = ( name , fallback ) => {
45+ const value = Number . parseInt ( process . env [ name ] || '' , 10 )
46+ return Number . isFinite ( value ) && value >= 0 ? value : fallback
47+ }
4148const FEED_WAIT_SECONDS = positiveIntEnv ( 'FEED_WAIT_SECONDS' , 600 )
4249const FEED_PROBE_TIMEOUT_MS = positiveIntEnv ( 'FEED_PROBE_TIMEOUT_MS' , 8000 )
43- // The local feed is a static seeded rate (the oracle is flat — see
44- // refresh_threshold_bps below) and nothing refreshes its observedAt while the
45- // stack runs, so a tight staleness window makes the bot skip every pool ~1
46- // minute after the last seed. Default to a day so a local stack quotes all day;
47- // override with FEED_STALENESS_SECS to exercise real staleness handling.
48- const FEED_STALENESS_SECS = positiveIntEnv ( 'FEED_STALENESS_SECS' , 86400 )
50+ const FEED_STALENESS_SECS = positiveIntEnv ( 'FEED_STALENESS_SECS' , 900 )
51+ const ORACLE_REFRESH_SECONDS = positiveIntEnv ( 'ORACLE_REFRESH_SECONDS' , 300 )
52+ const STITCH_LOCAL_TTL_SECS = positiveIntEnv ( 'STITCH_LOCAL_TTL_SECS' , 120 )
53+ const STITCH_LOCAL_REFRESH_THRESHOLD_BPS = nonNegativeIntEnv (
54+ 'STITCH_LOCAL_REFRESH_THRESHOLD_BPS' ,
55+ nonNegativeIntEnv ( 'REFRESH_THRESHOLD_BPS' , 10 )
56+ )
57+ const STITCH_LOCAL_FUNDING_HEADROOM = positiveIntEnv (
58+ 'STITCH_LOCAL_FUNDING_HEADROOM' ,
59+ 1
60+ )
4961
5062const addrs = JSON . parse (
5163 readFileSync ( `${ CRATE } ../constants/src/addresses.localhost.json` )
@@ -87,6 +99,7 @@ const erc20 = parseAbi([
8799 'function balanceOf(address) view returns (uint256)' ,
88100 'function allowance(address,address) view returns (uint256)' ,
89101 'function mint(address,uint256)' ,
102+ 'function burn(address,uint256)' ,
90103 'function approve(address,uint256) returns (bool)' ,
91104] )
92105const oracleAbi = parseAbi ( [ 'function buyRate() view returns (uint256)' ] )
@@ -260,7 +273,7 @@ const account = privateKeyToAccount(KEY)
260273const wallet = createWalletClient ( { account, chain, transport : http ( RPC ) } )
261274const MAX = ( 1n << 256n ) - 1n
262275for ( const [ token , amt ] of Object . entries ( need ) ) {
263- const want = amt * 100n // headroom for many re-quotes
276+ const want = amt * BigInt ( STITCH_LOCAL_FUNDING_HEADROOM )
264277 const [ bal , allow ] = await Promise . all ( [
265278 pub . readContract ( {
266279 address : token ,
@@ -283,6 +296,23 @@ for (const [token, amt] of Object.entries(need)) {
283296 args : [ account . address , want ] ,
284297 } )
285298 await pub . waitForTransactionReceipt ( { hash } )
299+ } else if ( bal > want && STITCH_LOCAL_FUNDING_HEADROOM === 1 ) {
300+ try {
301+ const hash = await wallet . writeContract ( {
302+ address : token ,
303+ abi : erc20 ,
304+ functionName : 'burn' ,
305+ args : [ account . address , bal - want ] ,
306+ } )
307+ await pub . waitForTransactionReceipt ( { hash } )
308+ } catch {
309+ console . warn (
310+ ` ${ token } balance is above the production-like target and could ` +
311+ `not be burned down; "max" will quote the full local balance. Run ` +
312+ `yarn dev:reset or use a fresh STITCH_PRIVATE_KEY to test one-book ` +
313+ `funding exactly.`
314+ )
315+ }
286316 }
287317 if ( allow < want ) {
288318 const hash = await wallet . writeContract ( {
@@ -323,18 +353,8 @@ sell_offset_bps = ${SELL_OFFSET_BPS}
323353sell_total_liquidity_collateral = "max"
324354sell_min_slice_debt = "${ p . minAskDebtAtomic } "
325355sell_max_orders = ${ MAX_LADDER_ORDERS }
326- # The bot signs deadlines off its wall clock, but the local Hardhat chain's
327- # block.timestamp can run minutes ahead of wall time (tests bump EVM time and
328- # it can't go back). A short TTL then makes every order expire on-chain before
329- # it's fillable — executeBatch reverts with Permit2 SignatureExpired. 30 min
330- # absorbs realistic local drift. (Prod chains track wall time, so TTL there is
331- # the bot's own short value.)
332- ttl_secs = 1800
333- # 0 → re-sign with a fresh Permit2 nonce every tick. The local oracle is flat,
334- # so a price-gated bot would never rotate the nonce, and a filled order would
335- # linger in the book and revert the next fill with InvalidNonce. (Production
336- # uses a real threshold — the live feed moves, so nonces rotate on their own.)
337- refresh_threshold_bps = 0
356+ ttl_secs = ${ STITCH_LOCAL_TTL_SECS }
357+ refresh_threshold_bps = ${ STITCH_LOCAL_REFRESH_THRESHOLD_BPS }
338358`
339359}
340360
@@ -346,6 +366,12 @@ for (const p of pools) {
346366 ` ${ p . key } : bid ${ TOTAL_ORDER_SIZE_USD } stable / ask ~${ p . askSoftHuman } soft, min ${ MIN_ORDER_SIZE_USD } stable (feed ${ FEED_BASE } ?pair=${ p . key } )`
347367 )
348368}
369+ console . log (
370+ ` production-like defaults: ttl=${ STITCH_LOCAL_TTL_SECS } s, ` +
371+ `refresh_threshold=${ STITCH_LOCAL_REFRESH_THRESHOLD_BPS } bps, ` +
372+ `funding_headroom=${ STITCH_LOCAL_FUNDING_HEADROOM } x, ` +
373+ `feed_staleness=${ FEED_STALENESS_SECS } s`
374+ )
349375console . log ( '' )
350376
351377// A bot with no reachable feed silently skips every pool ("feed fetch failed")
@@ -400,13 +426,12 @@ if (!feedUp) {
400426}
401427console . log ( `Using price endpoint ${ FEED_BASE } .\n` )
402428
403- // Keep the oracles fresh for the whole session (they'd otherwise re-stale after
404- // 30 min and the feed/UI would start reverting again).
429+ // Keep local oracles fresh inside the production-like feed staleness window.
405430const refreshTimer = setInterval (
406431 ( ) => {
407432 refreshOracles ( ) . catch ( ( ) => { } )
408433 } ,
409- 20 * 60 * 1000
434+ ORACLE_REFRESH_SECONDS * 1000
410435)
411436
412437const bot = spawn (
0 commit comments