# DeFi Agent — a PancakeSwap V3 range that runs itself — Brain On BNB AI # A concentrated-liquidity range and its reserve, kept by a cron: collect, re-set, grow. No human in the loop. # # This is the complete defi-agent bundle as a single file, so it can be read in # one fetch. 16 files, 6487 lines. # Download as a zip: https://brainonbnb.com/code/defi-agent.zip # Everything else: https://brainonbnb.com/code/index.txt # # No secrets are present: they are supplied at runtime through the environment. # MIT licensed. ============================================================================== === FILE: scripts/lp-agent.mjs ============================================================================== #!/usr/bin/env node // The DeFi agent by hand: the same three steps the daily worker runs // (worker-lp/index.js), from a laptop, plan by default. // // sweep AI income (USD1 on the x402 wallet, $U on the provider wallet) // -> BNB -> the DeFi wallet // collect the position's fees -> BNB -> part kept as capital, the rest buys // $BOBAI the wallet holds (until 2026-09-09: the buyback wallet) // increase BNB above the reserve -> more of the same position // // Every function here is imported from shared/lp-agent.js, the file the // worker runs. Nothing is reimplemented: a plan printed here is the plan the // cron would send tonight, on today's chain. // // SAFETY — a bare run changes nothing. // Nothing happens without --confirm. // --self-test drives synthetic states through the same guard functions the // real run uses, in both directions, because a guard nobody has watched // fire is a guess. // // Usage: // node scripts/lp-agent.mjs plan all three steps // node scripts/lp-agent.mjs --step collect one step // node scripts/lp-agent.mjs --step ladder what the worker's ladder step would do (planned here, never sent) // node scripts/lp-agent.mjs --self-test prove the guards fire // node scripts/lp-agent.mjs --confirm [--step x] send it import 'dotenv/config'; import { createPublicClient, createWalletClient, http, fallback } from 'viem'; import { bsc } from 'viem/chains'; import { privateKeyToAccount } from 'viem/accounts'; import { RPCS, INCOME_SOURCES, ADDR, splitForRange, amountsForRange, minsForRange, MINT_DRIFT_TICKS, tradeToRatio, TRADE_DUST_WBNB, unwindCalls, ticksAround, readBnbUsd, sender, v3SwapArgs, swapNote, swapFloor, bobaiBuyFloor, requoteOnce, POOL_SWAP_FLOOR_BPS, planSweep, executeSweep, planCollect, executeCollect, planIncrease, executeIncrease, planRebalance, planRelocate, executeRelocate, executeRebalance, ticksAdjacent, positionSide, planLadder, healLadder, readPosition, mintedIn, heldIds, HELD_IDS_CAP, shareShortfallSale, } from '../shared/lp-agent.js'; import { refuseCollect, refuseSweep, refuseIncrease, refuseRebalance, refuseRelocate, HOME_POOL, rebalanceWait, depositForcesReset, DEPOSIT_RESET_SHARE, RESET_AFTER_HOURS, GAS_RESERVE_BNB, MIN_GAS_BNB, MIN_COLLECT_BNB, MIN_SWEEP_BNB, MIN_INCREASE_BNB, MIN_REBALANCE_BNB, splitFees, FEE_SHARE_KEPT_PCT, resetForward, MIN_RESET_FORWARD_BNB, reserveCollect, RESERVE_COLLECT_MIN_BNB, widthUpgrade, widthClassOf, rangeLeft, RANGE_LEFT_TICKS, ONE_SIDED_GAP_TICKS, pickWidth, WIDTH_UPGRADE_ENABLED, ladderDecision, LADDER_GATE, ladderHeal, resumeSide, ladderActsInWatch, } from '../shared/lp-guards.js'; import { moneyFlow, flowLines, trimHistory, withArchive, HISTORY_CAP, isReset } from '../shared/lp-flow.js'; import { resetLosses } from '../worker-agent/lp-windows.js'; import { alertsOf } from '../shared/lp-alerts.js'; const CONFIRM = process.argv.includes('--confirm'); const SELF = process.argv.includes('--self-test'); const argOf = (n) => { const i = process.argv.indexOf(n); return i >= 0 ? process.argv[i + 1] : null; }; const stepArg = argOf('--step'); const ALL = ['sweep', 'collect', 'relocate', 'rebalance', 'ladder', 'increase']; // The pool a relocate moves to, named by a person: `--step relocate --to 0x…`. const TO = argOf('--to'); // Without --step, the hand script runs the four steps of a day; a relocate is // asked for by name, with the pool it moves to. const STEPS = stepArg ? [stepArg] : ALL.filter((x) => x !== 'relocate'); // A width named by a person for the re-set. It is printed as a hand-made // choice and never remembered: the record's earnings test is the standing rule. const WIDTH = argOf('--width') != null ? Number(argOf('--width')) : null; // The share of a collect kept as capital. Default is the standing rule in // lp-guards.js (the worker reads the same figure from LP_FEE_KEEP_PCT). const KEEP = argOf('--keep') != null ? Number(argOf('--keep')) : FEE_SHARE_KEPT_PCT; if (stepArg && !ALL.includes(stepArg)) { console.error(`--step must be one of ${ALL.join(', ')}, not "${stepArg}"`); process.exitCode = 2; } if (argOf('--keep') != null && !(KEEP >= 0 && KEEP <= 100)) { console.error(`--keep is a percent between 0 and 100, not "${argOf('--keep')}"`); process.exitCode = 2; } if (WIDTH != null && !(WIDTH > 0 && WIDTH <= 50)) { console.error(`--width is a percent between 0 and 50, not "${argOf('--width')}"`); process.exitCode = 2; } const WINDOWS_URL = 'https://agent.brainonbnb.com/lp/windows'; // The worker's ladder record (KV lp:ladder), as the agent's public record // carries it: which position is the main range and which the reserve. Without // it this script read a wallet with a reserve as "2 positions" and refused // every step the worker was running fine (2026-09-17). const RECORD_URL = 'https://agent.brainonbnb.com/lp/agent?format=json'; // -------------------------------------------------------------------------- // --self-test: every refusal, and the one allow, for each of the three guards // -------------------------------------------------------------------------- if (SELF) { let bad = 0, total = 0; const check = (label, r, wantRefusal) => { total += 1; const ok = wantRefusal ? !!r : !r; console.log(`${ok ? 'ok ' : 'FAIL'} ${wantRefusal ? 'refuses' : 'allows '}: ${label}${r ? ` — "${String(r).slice(0, 72)}"` : ''}`); if (!ok) bad += 1; }; console.log('collect'); const healthyCollect = { positions: 1, liquidity: 10n ** 18n, owedBnbEquivalent: 0.01, gasBnb: 0.01, quoteOffPct: 2 }; for (const [state, why] of [ [{ positions: 0 }, 'no position'], [{ positions: 2 }, 'two positions'], [{ positions: 1, liquidity: 0n }, 'position emptied'], [{ ...healthyCollect, owedBnbEquivalent: 0 }, 'nothing owed'], [{ ...healthyCollect, owedBnbEquivalent: 0.0001 }, 'dust below the gas it costs'], [{ ...healthyCollect, gasBnb: 0.0001 }, 'no gas'], [{ ...healthyCollect, quoteOffPct: 60 }, 'quote far off the pool'], [{ ...healthyCollect, quoteOffPct: -60 }, 'quote far off the pool, the other way'], ]) check(why, refuseCollect(state), true); check('a healthy position with real fees owed', refuseCollect(healthyCollect), false); check('a quote 10% off, within the 25% band', refuseCollect({ ...healthyCollect, quoteOffPct: 10 }), false); // The reserve the collect leaves behind must be enough for the next collect. check(`the reserve (${GAS_RESERVE_BNB}) covers the gas floor (${MIN_GAS_BNB})`, GAS_RESERVE_BNB >= MIN_GAS_BNB ? null : 'reserve below the floor', false); // A wallet holding exactly the reserve after a run must be allowed to act. check('a wallet holding exactly the reserve', refuseCollect({ ...healthyCollect, gasBnb: GAS_RESERVE_BNB }), false); console.log('fee split'); // Both directions: the default keeps half, an explicit 0 sends it all to the // buyback, an explicit 100 keeps it all, and a bad value falls back to the // default rather than to either extreme. const eq = (label, got, want) => check(label, got === want ? null : `got ${got}, wanted ${want}`, false); const one = 10n ** 18n; eq(`default keeps ${FEE_SHARE_KEPT_PCT}% of 1 BNB`, splitFees(one).keep, (one * BigInt(FEE_SHARE_KEPT_PCT)) / 100n); eq('default sends the rest to the buyback', splitFees(one).keep + splitFees(one).buyback, one); eq('0% keeps nothing', splitFees(one, 0).keep, 0n); eq('0% forwards everything', splitFees(one, 0).buyback, one); eq('100% keeps everything', splitFees(one, 100).keep, one); eq('100% forwards nothing', splitFees(one, 100).buyback, 0n); eq('25% of 1 BNB is 0.25', splitFees(one, 25).keep, one / 4n); eq('12.5% is honoured to the hundredth', splitFees(one, 12.5).keep, (one * 125n) / 1000n); eq('"50" as a string works (a wrangler var is a string)', splitFees(one, '50').pct, 50); eq('a typo falls back to the default, not to 0', splitFees(one, 'fifty').pct, FEE_SHARE_KEPT_PCT); eq('150 falls back to the default, not to 100', splitFees(one, 150).pct, FEE_SHARE_KEPT_PCT); eq('a negative share falls back to the default', splitFees(one, -5).pct, FEE_SHARE_KEPT_PCT); eq('undefined (var not set) falls back to the default', splitFees(one, undefined).pct, FEE_SHARE_KEPT_PCT); eq('nothing produced splits to nothing', splitFees(0n).keep + splitFees(0n).buyback, 0n); eq('a negative amount is treated as nothing', splitFees(-1n).buyback, 0n); console.log('re-set fee share'); // Both directions: a share worth sending is sent, a share under the floor // stays as capital (and is counted as kept, so nothing goes missing), a // kept share of 100% sends nothing, no fees owed sends nothing. const mBnb = 10n ** 15n; // 0.001 BNB eq('half of 0.001 BNB is sent on', resetForward(mBnb).forward, mBnb / 2n); eq('… and the other half is kept', resetForward(mBnb).kept, mBnb / 2n); eq('the share names the percentage it kept', resetForward(mBnb).pct, FEE_SHARE_KEPT_PCT); check('a share worth sending has no reason not to', resetForward(mBnb).why, false); const dust = BigInt(Math.round(MIN_RESET_FORWARD_BNB * 1e6)) * 10n ** 12n; // exactly the floor, doubled = 0.0002 folded eq('a buyback share exactly at the floor is sent', resetForward(dust * 2n).forward, dust); eq('a buyback share one wei under the floor stays', resetForward(dust * 2n - 2n).forward, 0n); eq('… and all of it counts as kept', resetForward(dust * 2n - 2n).kept, dust * 2n - 2n); check('the dust share says why it stayed', resetForward(dust * 2n - 2n).why, true); eq('0% kept sends everything', resetForward(mBnb, 0).forward, mBnb); eq('100% kept sends nothing', resetForward(mBnb, 100).forward, 0n); check('100% kept says so', resetForward(mBnb, 100).why, true); eq('no fees owed sends nothing', resetForward(0n).forward, 0n); check('no fees owed says so', resetForward(0n).why, true); eq('a typo in the share falls back to the default', resetForward(mBnb, 'half').pct, FEE_SHARE_KEPT_PCT); check(`the floor (${MIN_RESET_FORWARD_BNB}) is above two transactions at 1 gwei (0.00005)`, MIN_RESET_FORWARD_BNB > 0.00005 ? null : 'floor too low', false); console.log('money flow'); // The record as the worker writes it, with one run of each kind, a dry run // that must not count, and a collect from before the split (forwarded only). const rec = { history: [ { at: '2026-09-02T14:20:00Z', dry: true, ok: false, acted: false, steps: { collect: { acted: true, forwarded_bnb: '9', txs: [{ gas_bnb: 1 }] } } }, { at: '2026-09-03T05:23:00Z', ok: true, acted: true, steps: { sweep: [{ source: 'x402', token: 'USD1', acted: true, sold: '5', received_bnb: '0.007', txs: [{ gas_bnb: 0.00001 }, { gas_bnb: 0.00002 }] }, { source: 'provider', acted: false, why: 'nothing' }], collect: { acted: true, forwarded_bnb: '0.003', txs: [{ gas_bnb: 0.00001 }] }, } }, { at: '2026-09-04T05:23:00Z', ok: true, acted: true, steps: { collect: { acted: true, produced_bnb: '0.004', kept_bnb: '0.002', forwarded_bnb: '0.002', kept_pct: 50, txs: [{ gas_bnb: 0.00001 }] }, increase: { acted: true, wbnb_used: '0.005', bnb_spent: '0.0101', txs: [{ gas_bnb: 0.00003 }] }, rebalance: { acted: true, new_position: '7', fees_folded_bnb: 0.0006, txs: [{ gas_bnb: 0.00002 }] }, } }, { at: '2026-09-04T09:00:00Z', ok: false, acted: true, steps: { collect: { acted: true, error: 'reverted', txs: [{ gas_bnb: 0.00001 }] } } }, // A re-set since 2026-09-08: it took 0.0004 of fees and sent half on. { at: '2026-09-08T19:50:00Z', ok: true, acted: true, steps: { rebalance: { acted: true, new_position: '8', fees_folded_bnb: 0.0004, fees_forwarded_bnb: 0.0002, fees_kept_pct: 50, forwarded_to: '0xdeFC', txs: [{ gas_bnb: 0.00002 }, { gas_bnb: 0.00001 }] }, } }, // Since 2026-09-09 the share buys BOBAI the agent holds: a collect and a // re-set that wrote bobai_bnb / bobai_units instead of forwarding. { at: '2026-09-09T12:00:00Z', ok: true, acted: true, steps: { collect: { acted: true, produced_bnb: 0.001, kept_bnb: 0.0005, kept_pct: 50, bobai_bnb: 0.0005, bobai_units: 2700, txs: [{ gas_bnb: 0.00001 }] }, rebalance: { acted: true, new_position: '9', fees_folded_bnb: 0.0002, bobai_bnb: 0.0001, bobai_units: 540, fees_kept_pct: 50, txs: [{ gas_bnb: 0.00001 }] }, } }, ], last: { at: '2026-09-04T05:23:00Z', steps: { sweep: [{ source: 'x402', token: 'USD1', balance: 0.6, bnb_equivalent: 0.0008 }, { source: 'provider', token: '$U', balance: 0 }], collect: { kept_pct: 50, owed: { bnb_equivalent: 0.000016 } }, increase: { spendable_bnb: 0.0075 }, } }, }; const fl = moneyFlow(rec, { earned: { count: 3, totalUsd1: '0.70' } }); const near = (a, b) => Math.abs(a - b) < 1e-9; const is = (label, ok) => check(label, ok ? null : 'wrong', false); is('a dry run counts for nothing', fl.in.fees.bnb < 9 && fl.gas.bnb < 1); is('income is summed per source', fl.in.income.length === 1 && fl.in.income[0].source === 'x402' && near(fl.in.income[0].bnb, 0.007) && fl.in.income[0].runs === 1); is('a sweep that did not act is not a source', !fl.in.income.some((s) => s.source === 'provider')); is('a collect before the split counts what it forwarded as produced', near(fl.in.fees.collected_bnb, 0.008) && fl.in.fees.collects === 3); is('the fees three re-sets took are fees produced', near(fl.in.fees.folded_bnb, 0.0012) && fl.in.fees.resets_with_fees === 3 && near(fl.in.fees.bnb, 0.0092)); is('a re-set before 2026-09-08 folded all of it in; the ones after sent half on', near(fl.in.fees.folded_kept_bnb, 0.0009) && near(fl.in.fees.forwarded_at_resets_bnb, 0.0003)); is('the share: 0.003 + 0.002 to the buyback from old collects, 0.0002 from the old re-set, 0.0005 + 0.0001 into BOBAI since', near(fl.out.bobai_bnb, 0.0058)); is('the BOBAI the agent holds is summed from collects and re-sets (2700 + 540)', near(fl.out.bobai_units, 3240)); is('a record without bobai fields holds none', moneyFlow({ history: [rec.history[0]] }).out.bobai_units === 0); is('0.002 + 0.0005 kept by collects + 0.0006 + 0.0002 + 0.0001 folded by re-sets was kept as capital', near(fl.out.kept_as_capital_bnb, 0.0034)); is('capital that arrived = income + kept', near(fl.out.capital_arrived_bnb, 0.0104)); is('produced = the BOBAI share + what was kept', near(fl.in.fees.bnb, fl.out.bobai_bnb + fl.out.kept_as_capital_bnb)); is('the increase counts the BNB it put in — its own gas is on the gas line, not in here too (until 2026-09-18 it was in both, and left the profit twice)', near(fl.out.into_position_bnb, 0.0101 - 0.00003) && fl.out.increases === 1); // Kept fees that wait in the wallet (2026-09-18): the fixture's last collect (09-09, kept 0.0005) came after its last increase. is('fees a collect kept after the last increase still wait: counted as waiting, capped by what the wallet has above its reserve', near(fl.waiting.kept_fees_bnb, 0.0005) && near(moneyFlow({ ...rec, last: { ...rec.last, steps: { ...rec.last.steps, increase: { spendable_bnb: 0.0002 } } } }).waiting.kept_fees_bnb, 0.0002)); is('… and an increase after it takes them in: nothing waits', near(moneyFlow({ ...rec, history: [...rec.history, { at: '2026-09-10T05:00:00Z', ok: true, acted: true, steps: { increase: { acted: true, bnb_spent: '0.006', txs: [] } } }] }).waiting.kept_fees_bnb, 0)); // What the operator is told at once (shared/lp-alerts.js, 2026-09-18) — and what is left to the daily card. { const quiet = { at: '2026-09-18T12:00:00Z', ok: true, steps: { rebalance: { acted: false, why: 'the price is inside the range — nothing to re-set' }, increase: { acted: true, bnb_spent: '0.04', txs: [{ hash: '0xaa' }] }, collect: { acted: true, produced_bnb: 0.006 } } }; const stood = { at: '2026-09-16T09:00:00Z', ok: true, steps: { rebalance: { acted: false, why: 'this wallet holds 2 positions — which one to re-set is a decision for a person' }, increase: { acted: false, why: 'this wallet holds 2 positions — which one to grow is a decision for a person' } } }; const broke = { at: '2026-09-18T12:50:00Z', ok: false, steps: { rebalance: { acted: true, error: 'mint the new range reverted — stopped before the next step', txs: [{ hash: '0xabc' }, { hash: '0xdef' }] } } }; const up = { at: '2026-09-20T08:50:00Z', ok: true, steps: { rebalance: { acted: true, position: '7451444', new_position: '7477750', one_sided: 'below_price', new_ticks: [-58300, -56390], merged_reserve: '7461743', swap: { side: 'sell', notional_bnb: 0.0009 }, bobai_bnb: 0.001, txs: [{ hash: '0x12' }] } } }; is('routine is not an alert: a look, a collect and a top-up say nothing', alertsOf(quiet).length === 0); is('the day the agent stood still is ONE message, not one per refusing step — and it names them', (() => { const m = alertsOf(stood); return m.length === 1 && /rebalance, increase wait for a person/.test(m[0].text) && m[0].quietHours >= 12; })()); is('a failed step is told with what had already been sent', (() => { const m = alertsOf(broke); return m.length === 1 && /rebalance FAILED/.test(m[0].text) && /2 transaction\(s\) had already gone through/.test(m[0].text); })()); is('… the same failure has the same key (said once while it repeats), another failure another key', alertsOf(broke)[0].key === alertsOf({ ...broke, at: '2026-09-18T13:00:00Z' })[0].key && alertsOf(broke)[0].key !== alertsOf({ ...broke, steps: { rebalance: { ...broke.steps.rebalance, error: 'another reason' } } })[0].key); is('a re-set is told with its direction, the merge and the trade — keyed by the position it made', (() => { const m = alertsOf(up); return m.length === 1 && /re-set UPWARD/.test(m[0].text) && /merged the reserve #7461743/.test(m[0].text) && m[0].key === 'reset:7477750'; })()); is('a healed record, a record write that failed after a transaction and every ladder act are told', alertsOf({ at: 'x', ok: true, steps: {}, ladder_healed: { from: '1', to: '2', why: 'w' } }).length === 1 && alertsOf({ at: 'x', ok: true, steps: { ladder: { acted: true, new_reserve: '9', bnb_spent: '0.04', kv_error: 'KV put failed' } } }).length === 2); is('a dry run tells nobody anything, and markup in an error cannot reach the message', alertsOf({ ...broke, dry: true }).length === 0 && !/' } } })[0].text)); } const forcedRec = { history: [{ at: '2026-09-20T10:00:00Z', ok: true, acted: true, steps: { rebalance: { acted: true, new_position: '10', wrapped_waiting_bnb: 0.4, txs: [{ gas_bnb: 0.00005 }] } } }] }; is('a re-set forced by a deposit wraps it into its own mint: that is capital put in, counted once', near(moneyFlow(forcedRec).out.into_position_bnb, 0.4) && near(moneyFlow({ history: [{ ...forcedRec.history[0], steps: { rebalance: { ...forcedRec.history[0].steps.rebalance, wrapped_waiting_bnb: undefined } } }] }).out.into_position_bnb, 0)); is('three re-sets', fl.out.resets === 3); { // One definition of a re-set (2026-09-19; the page said 15, the record 14, the fee sentence 13). const run = (rebalance, at = '2026-09-16T08:50:00Z') => ({ at, ok: !rebalance.error, acted: true, steps: { rebalance } }); const noId = run({ acted: true, new_position: null, ticks: [-57810, -56460], tick: -57888, one_sided: 'above_price', value_bnb: 1.35 }); const failed = run({ acted: true, error: 'mint reverted', ticks: [-57810, -56460], tick: -57888 }, '2026-09-17T08:50:00Z'); const resumed = run({ acted: true, resume: true, new_position: '7', ticks: null, tick: -57900 }, '2026-09-17T09:50:00Z'); const looked = run({ acted: false, in_range: true }, '2026-09-17T10:50:00Z'); const withFees = run({ acted: true, new_position: '8', ticks: [-57810, -56460], tick: -56400, fees_folded_bnb: 0.001, bobai_bnb: 0.0005 }, '2026-09-18T08:50:00Z'); is('a re-set that moved the range but recorded no new id is a re-set (2026-09-16 08:50)', isReset(noId.steps.rebalance) && moneyFlow({ history: [noId] }).out.resets === 1); is('a run that failed half way and the run that finished it are ONE re-set; a look is none', !isReset(failed.steps.rebalance) && isReset(resumed.steps.rebalance) && !isReset(looked.steps.rebalance) && !isReset(null) && moneyFlow({ history: [failed, resumed, looked] }).out.resets === 1); const both = moneyFlow({ history: [noId, failed, resumed, withFees] }); const losses = resetLosses({ history: [noId, failed, resumed, withFees] }); is('the loss table counts the same re-sets and says how many of them it could value', losses.resets === both.out.resets && losses.resets === 3 && losses.valued === 2 && losses.rows.length === 2); is('the fee sentence names its basis: taken at 1 of 3 re-sets', /fees taken at 1 of 3 re-sets/.test(flowLines(both).came_in) && /taken at 1 re-set,/.test(flowLines(moneyFlow({ history: [withFees] })).came_in)); } is('gas is summed over every transaction, the failed run included', fl.gas.transactions === 11 && near(fl.gas.bnb, 0.00016)); is('a failed collect adds no fees', near(fl.in.fees.collected_bnb, 0.008)); is('since = first run that acted, last_moved = the newest', fl.since === '2026-09-03T05:23:00Z' && fl.last_moved === '2026-09-09T12:00:00Z'); // The cap and its archive (2026-09-15): what the cap pushes out is not // lost to the sums, and a record under the cap archives nothing. { const runs = Array.from({ length: 5 }, (_, i) => ({ at: `2026-09-0${i + 1}T05:23:00Z`, acted: true, steps: { collect: { acted: true, owed: { bnb_equivalent: 0.001 }, kept_pct: 50, kept_bnb: 0.0005, bobai_bnb: 0.0005, bobai_units: 100, txs: [{ gas_bnb: 0.00001 }] } } })); const t = trimHistory(runs.slice(0, 4), runs[4], 3); is('the cap keeps the newest runs', t.kept.length === 3 && t.kept[0].at === runs[2].at && t.kept[2].at === runs[4].at); is('what the cap pushed out comes back oldest first', t.dropped.length === 2 && t.dropped[0].at === runs[0].at && t.dropped[1].at === runs[1].at); const u = trimHistory(runs.slice(0, 2), runs[2], 3); is('a history under the cap drops nothing', u.kept.length === 3 && u.dropped.length === 0); is('the cap defaults to 200', HISTORY_CAP === 200 && trimHistory(runs, undefined).kept.length === 5); const whole = moneyFlow({ history: runs, last: runs[4] }); const merged = withArchive({ history: t.kept, last: runs[4] }, { entries: t.dropped }); const part = moneyFlow(merged); is('the sums over archive + history equal the sums over the whole', part.out.bobai_units === whole.out.bobai_units && near(part.in.fees.bnb, whole.in.fees.bnb) && near(part.gas.bnb, whole.gas.bnb) && merged.history_archived === 2); is('the sums over the capped history alone fall short', moneyFlow({ history: t.kept, last: runs[4] }).out.bobai_units < whole.out.bobai_units); is('no archive leaves the record as it is', withArchive({ history: t.kept }, null).history.length === 3 && withArchive({ history: t.kept }, { entries: [] }).history_archived === undefined); } is('a record whose last collect names no share takes it from the last re-set', moneyFlow({ history: rec.history, last: { at: '2026-09-08T19:50:00Z', steps: {} } }).rule.fee_share_kept_pct === 50); is('waiting lists only wallets holding something', fl.waiting.income.length === 1 && fl.waiting.income[0].token === 'USD1'); is('waiting carries the fees owed and the spendable BNB', near(fl.waiting.fees_owed_bnb, 0.000016) && near(fl.waiting.wallet_spendable_bnb, 0.0075)); // The newest owed figure wins (2026-09-12): an hourly check's rebalance step // folded into the last record reads what the position owes now; the daily // collect's figure is up to a day old. Without one, the collect's stands. is('an hourly rebalance step\'s owed figure outranks the daily collect\'s', (() => { const l = rec.last; const r2 = { ...rec, last: { ...l, steps: { ...l.steps, rebalance: { ...(l.steps.rebalance || {}), fees_owed_bnb: 0.0049 } } } }; return near(moneyFlow(r2).waiting.fees_owed_bnb, 0.0049); })()); is('the rule is what the last collect named', fl.rule.fee_share_kept_pct === 50 && fl.rule.fee_share_bobai_pct === 50); is('paid_for carries the service earnings', fl.paid_for.x402_answers === 3 && near(fl.paid_for.usd1, 0.7)); is('an empty record flows nothing', moneyFlow({}).in.total_bnb === 0 && moneyFlow({}).rule === null && moneyFlow(null).gas.transactions === 0); const lines = flowLines(fl); is('the lines name the source, the fees, the re-sets\' fees and the split', /USD1/.test(lines.came_in) && /0\.00800 BNB of fees over 3 collects, 0\.00120 BNB of fees taken at 3 re-sets, 0\.00030 of it spent on BOBAI held/.test(lines.came_in) && /0\.00580 BNB spent on BOBAI held in the wallet/.test(lines.went_out) && /0\.00340 BNB kept/.test(lines.went_out)); is('re-sets that forwarded nothing read as all folded in', /0\.00060 BNB of fees taken at 1 re-set, all of it folded into the capital/.test(flowLines(moneyFlow({ history: rec.history.slice(0, 4) })).came_in)); is('an empty record reads as nothing yet', /no income swept yet/.test(flowLines(moneyFlow({})).came_in) && /nothing has left/.test(flowLines(moneyFlow({})).went_out)); console.log('sweep'); const healthySweep = { symbol: 'USD1', balance: 5, bnbEquivalent: 0.007, gasBnb: 0.002, bnbUsd: 700, feedAgeS: 30, impliedUsd: 1.0 }; for (const [state, why] of [ [{ ...healthySweep, balance: 0, bnbEquivalent: 0 }, 'nothing arrived'], [{ ...healthySweep, gasBnb: 0.0001 }, 'no gas on the income wallet'], [{ ...healthySweep, feedAgeS: 7200 }, 'stale Chainlink feed'], [{ ...healthySweep, bnbUsd: 12 }, 'implausible BNB price'], [{ ...healthySweep, balance: 0.5, bnbEquivalent: 0.0007 }, 'income under the floor'], [{ ...healthySweep, impliedUsd: 0.5 }, 'route pays half a dollar'], [{ ...healthySweep, impliedUsd: 1.5 }, 'route pays a dollar and a half'], ]) check(why, refuseSweep(state), true); check('five dollars of USD1 on a wallet with gas', refuseSweep(healthySweep), false); check('the same in $U', refuseSweep({ ...healthySweep, symbol: '$U' }), false); check('a plan stage with no quote yet', refuseSweep({ ...healthySweep, impliedUsd: null }), false); console.log('increase'); const healthyIncrease = { positions: 1, spendableBnb: 0.02, inRange: true }; for (const [state, why] of [ [{ ...healthyIncrease, positions: 0 }, 'no position'], [{ ...healthyIncrease, positions: 2 }, 'two positions'], [{ ...healthyIncrease, spendableBnb: MIN_INCREASE_BNB / 2 }, 'capital under the floor'], [{ ...healthyIncrease, spendableBnb: 0 }, 'nothing above the reserve'], [{ ...healthyIncrease, inRange: false }, 'price outside the range'], ]) check(why, refuseIncrease(state), true); check('capital above the floor and a price in range', refuseIncrease(healthyIncrease), false); check('out of range but the range is all WBNB below the price: BNB joins it, no trade (2026-09-16)', refuseIncrease({ ...healthyIncrease, inRange: false, wbnbOnly: true }), false); check('out of range and the range is all of the other side above the price: refuses, names the ladder', /ladder/.test(refuseIncrease({ ...healthyIncrease, inRange: false, wbnbOnly: false }) || '') ? null : 'no ladder named', false); check(`exactly the floor (${MIN_INCREASE_BNB})`, refuseIncrease({ ...healthyIncrease, spendableBnb: MIN_INCREASE_BNB }), false); console.log('rebalance'); const healthyRebalance = { positions: 1, inRange: false, width: 1, hoursOfPrices: 30, valueBnb: 0.07 }; for (const [state, why] of [ [{ ...healthyRebalance, positions: 0 }, 'no position'], [{ ...healthyRebalance, positions: 2 }, 'two positions'], [{ ...healthyRebalance, inRange: true }, 'price still inside the range'], [{ ...healthyRebalance, width: null, hoursOfPrices: 6 }, 'no width has earned its re-sets yet'], [{ ...healthyRebalance, valueBnb: 0.005 }, 'position too small to pay for a re-set'], ]) check(why, refuseRebalance(state), true); check('out of range, a day-tested width, enough capital', refuseRebalance(healthyRebalance), false); check('at the edge (outside, within the slack): not left, refuses', refuseRebalance({ ...healthyRebalance, atEdge: true, side: 'below', ticksAway: 30 }), true); check('… and names the slack', /at the edge/.test(refuseRebalance({ ...healthyRebalance, atEdge: true, side: 'below', ticksAway: 30 })) && new RegExp(String(RANGE_LEFT_TICKS)).test(refuseRebalance({ ...healthyRebalance, atEdge: true, side: 'below', ticksAway: 30 })) ? null : 'no slack named', false); check(`exactly the floor (${MIN_REBALANCE_BNB})`, refuseRebalance({ ...healthyRebalance, valueBnb: MIN_REBALANCE_BNB }), false); console.log('rebalance resumed from the wallet (the 2026-09-05 12:50 stop before the mint)'); const resume = { positions: 0, resume: true, inRange: false, width: 1, hoursOfPrices: 30, valueBnb: 0.08 }; check('no position, the two sides in the wallet, a width, enough capital: mints', refuseRebalance(resume), false); for (const [state, why] of [ [{ ...resume, resume: false }, 'no position and no pool named: nothing to resume'], [{ ...resume, valueBnb: 0.005 }, 'the two sides are worth less than the floor'], [{ ...resume, width: null, hoursOfPrices: 6 }, 'no width has earned its re-sets yet'], [{ ...resume, positions: 1 }, 'a position exists — that is a re-set, and its in-range check applies'], ]) check(why, refuseRebalance(state), state.positions === 1 ? false : true); console.log('width upgrade (in range, the record names a better width)'); // 380 ticks is ±1.9%: the record's 2%. 190 ticks: 1%. 40 ticks: 0.25%. check('380 ticks read as the 2% class', widthClassOf([-57990, -57610]) === 2 ? null : `got ${widthClassOf([-57990, -57610])}`, false); check('190 ticks read as the 1% class', widthClassOf([-57800, -57610]) === 1 ? null : `got ${widthClassOf([-57800, -57610])}`, false); check('no ticks: no class', widthClassOf(null) === null ? null : 'got a class', false); const rows = [ { width: 1, earnings: { net_usd_per_day: 0.6983 }, earnings_24h: { net_usd_per_day: 0.71 } }, { width: 2, earnings: { net_usd_per_day: 0.6111 }, earnings_24h: { net_usd_per_day: 0.60 } }, { width: 'full', earnings: null, earnings_24h: null }, ]; // RETIRED 2026-09-16: a position in range is never touched; the width // changes at the next one-sided re-set. The live case of 2026-09-10 ($154 // at 2%, the record picking 1%) that used to go through is now refused, // and so is everything else — the function says why, in one sentence. const up = { daily: true, inRange: true, ticks: [-57990, -57610], tick: -57800, pick: rows[0], rows, hoursOfPrices: 182, valueBnb: 0.2127, bnbUsd: 723.6, resetCostUsd: 0.1 }; const u = widthUpgrade(up); check('the upgrade is retired: even the case that used to pay back in a day is refused', u.upgrade ? 'upgraded' : null, false); check('… and says the width changes at the next re-set', /next re-set/.test(u.why) ? null : u.why, false); check('the flag says so too', WIDTH_UPGRADE_ENABLED === false ? null : 'enabled', false); for (const [state, why] of [ [{ ...up, daily: false }, 'the hourly check never upgrades'], [{ ...up, inRange: false }, 'outside the range it is a re-set, not an upgrade'], [{ ...up, pick: null }, 'no pick, nothing to upgrade to'], ]) check(why, (() => { const r = widthUpgrade(state); return r.upgrade ? null : r.why; })(), true); console.log('the one-sided range (2026-09-16)'); // Below its range the position is all token0 (CAKE here); the new range // sits above the price, starts the gap beyond it on the pool's grid and // spans what a centred ±width range spans. Above: the mirror image. const tickNow = -57807, sp = 10; const aboveP = ticksAdjacent(tickNow, 7, sp, 'below'); const centred7 = ticksAround(tickNow, 7, sp); is('price below the old range: the new range is above the price', aboveP.side === 'above_price' && aboveP.tickLower > tickNow); is(`… starting at least ${ONE_SIDED_GAP_TICKS} ticks beyond it, on the grid`, aboveP.tickLower - tickNow >= ONE_SIDED_GAP_TICKS && aboveP.tickLower - tickNow < ONE_SIDED_GAP_TICKS + sp && aboveP.tickLower % sp === 0 && aboveP.tickUpper % sp === 0); is('… spanning what the centred range of that width spans (within a grid step)', Math.abs((aboveP.tickUpper - aboveP.tickLower) - (centred7.tickUpper - centred7.tickLower)) <= sp); const belowP = ticksAdjacent(tickNow, 3, sp, 'above'); is('price above the old range: the new range is below the price, the gap away', belowP.side === 'below_price' && belowP.tickUpper < tickNow && tickNow - belowP.tickUpper >= ONE_SIDED_GAP_TICKS && belowP.tickUpper % sp === 0); // A one-sided range needs only the token the old range ended in: the // ratio trade has nothing to do. const sqA = Math.pow(1.0001, tickNow / 2), spA = splitForRange(sqA, aboveP.tickLower, aboveP.tickUpper); is('a range above the price takes only token0 — all CAKE, no WBNB', spA.perL0 > 0 && spA.perL1 === 0); is('… so a wallet holding only CAKE trades nothing for it', tradeToRatio({ wbnb: 0n, other: 10n ** 20n, perLWbnb: spA.perL1, perLOther: spA.perL0, otherPerWbnb: 1 / (sqA ** 2), wbnbPerOther: sqA ** 2 }).side === null); is('… and a deposit of WBNB beside it is all spent on CAKE (the fallen side, bought low)', (() => { const t = tradeToRatio({ wbnb: 10n ** 17n, other: 10n ** 20n, perLWbnb: spA.perL1, perLOther: spA.perL0, otherPerWbnb: 1 / (sqA ** 2), wbnbPerOther: sqA ** 2 }); return t.side === 'buy' && t.amount === 10n ** 17n; })()); // rangeLeft: inside, at an edge, gone — both ways. const lo7 = -57530, hi7 = -56940; is('inside the range: not outside, not left', (() => { const r = rangeLeft(-57200, lo7, hi7); return !r.outside && !r.left && r.side === null; })()); is(`${RANGE_LEFT_TICKS} ticks below the lower edge: outside, at the edge, not left`, (() => { const r = rangeLeft(lo7 - RANGE_LEFT_TICKS, lo7, hi7); return r.outside && r.side === 'below' && !r.left && r.ticks_away === RANGE_LEFT_TICKS; })()); is('one tick further: left', rangeLeft(lo7 - RANGE_LEFT_TICKS - 1, lo7, hi7).left === true); is('the live tick of 2026-09-16 05:05 (277 ticks under): left, below', (() => { const r = rangeLeft(-57807, lo7, hi7); return r.left && r.side === 'below' && r.ticks_away === 277; })()); is('at the upper tick itself: outside (the pool counts the upper tick as out), at the edge', (() => { const r = rangeLeft(hi7, lo7, hi7); return r.outside && r.side === 'above' && !r.left; })()); is('far above: left, above', rangeLeft(hi7 + 200, lo7, hi7).left === true && rangeLeft(hi7 + 200, lo7, hi7).side === 'above'); is('no ticks: nothing', rangeLeft(-57807, null, null).outside === false); // pickWidth: the width that ended the most ahead against holding over // the week, fees in; the width in use is kept under the bar. const wk = (w, fees, vs, share = 0.9, hours = 168) => ({ width: w, earnings: { net_usd_per_day: 0.1 }, earnings_7d: { hours, hours_in_range: hours * share, fees_usd: fees, vs_holding_usd: vs } }); // The live week of 2026-09-16: narrow earned the most fees and lost the most to the trend. const week = [wk(0.25, 2.88, -5.42), wk(1, 2.53, -4.21), wk(3, 1.41, -1.91), wk(4, 1.14, -1.37), wk(5, 0.93, -0.99), wk(7, 0.72, -0.45), wk(10, 0.54, -0.05), { width: 'full', earnings_7d: null }]; const pk = pickWidth(week); is('the pick is the width with the most money against holding, fees in (±10%: $0.49, not ±4%: −$0.23)', pk && pk.width === 10 && pk.score_usd === 0.49 && pk.kept_current === false); is('… and the basis names the week', /ended the most ahead/.test(pk.basis) && /±10% \+\$0\.49/.test(pk.basis) && /±4% −\$0\.23/.test(pk.basis)); is('the width in use is replaced when the lead is over the bar (±4% in use, ±10% $0.72 ahead)', (() => { const r = pickWidth(week, { current: 4 }); return r.width === 10 && r.kept_current === false && /over the bar/.test(r.basis); })()); is('… and kept when the lead is under it (±7% in use at $0.27, ±10% at $0.29: the bar is $0.30)', (() => { const r = pickWidth([wk(7, 0.72, -0.45), wk(10, 0.54, -0.25)], { current: 7 }); return r.width === 7 && r.kept_current === true && /stays/.test(r.basis) && r.best_width === 10; })()); is('… the bar is a tenth of the score in use, at least two cents (a score of $0.05 in use needs $0.07)', (() => { const rows = [wk(2, 0.05, 0), wk(5, 0.069, 0), wk(7, 0.071, 0)]; return pickWidth(rows, { current: 2 }).width === 7 && pickWidth(rows.slice(0, 2), { current: 2 }).width === 2; })()); is('a width in use that is the best stays and says so', (() => { const r = pickWidth(week, { current: 10 }); return r.width === 10 && r.kept_current === true && /the width in use/.test(r.basis); })()); is('a ranging week (no loss to the trend) picks the narrowest, which earns the most', pickWidth([wk(1, 2.5, 0), wk(3, 1.4, 0), wk(10, 0.5, 0)]).width === 1); is('a tie goes to the wider width', pickWidth([wk(3, 1, -0.3), wk(5, 0.7, 0)]).width === 5); is('full range, rows without a week and rows without the holding line are never picked', pickWidth([{ width: 'full', earnings_7d: { hours: 168, hours_in_range: 168, fees_usd: 1, vs_holding_usd: 0 } }, { width: 2, earnings_7d: null }, { width: 3, earnings_7d: { hours: 168, hours_in_range: 100, fees_usd: 1 } }]) === null); is('a row with no hours does not count', pickWidth([{ width: 2, earnings_7d: { hours: 0, hours_in_range: 0, fees_usd: 0, vs_holding_usd: 0 } }]) === null); is('the pick carries fees, the holding line, the share of hours in range and the week', pk.fees_usd === 0.54 && pk.vs_holding_usd === -0.05 && pk.in_range_share === 0.9 && pk.hours === 168 && pk.best_width === 10); console.log('relocate'); const relOk = { positions: 1, hasTarget: true, targetHasWbnb: true, samePool: false, toPool: HOME_POOL.pool, width: 1, valueBnb: 0.3, move: { move: true, why: 'x' } }; check('a move to any pool but home: refuses by the operator\'s decision', /stays in CAKE\/BNB 0\.05%/.test(refuseRelocate({ ...relOk, toPool: '0x172fcd41e0913e95784454622d1c3724f546f849' }) || ''), true); check('a move with no pool named: refuses by the same decision', /stays in CAKE\/BNB 0\.05%/.test(refuseRelocate({ ...relOk, toPool: null }) || ''), true); check('home, spelled in capitals, is still home', refuseRelocate({ ...relOk, toPool: HOME_POOL.pool.toUpperCase().replace('0X', '0x') }), false); check('no position: refuses', refuseRelocate({ ...relOk, positions: 0 }), true); check('two positions: refuses', refuseRelocate({ ...relOk, positions: 2 }), true); check('the switch rule says stay: refuses with its reason', /switch rule says stay/.test(refuseRelocate({ ...relOk, move: { move: false, why: 'lead under 25%' } }) || ''), true); check('no pool named: refuses', refuseRelocate({ ...relOk, hasTarget: false }), true); check('a pool without WBNB: refuses', refuseRelocate({ ...relOk, targetHasWbnb: false }), true); check('the pool the position is in: refuses', refuseRelocate({ ...relOk, samePool: true }), true); check('no width: refuses', refuseRelocate({ ...relOk, width: null }), true); check('under the floor: refuses', refuseRelocate({ ...relOk, valueBnb: 0.01 }), true); check('a move the rule allows: goes', refuseRelocate(relOk), false); check('a person naming the pool passes no rule and goes', refuseRelocate({ ...relOk, move: null }), false); console.log('the ladder (2026-09-16): BNB beside a sell ladder opens a buy ladder'); const L = (over) => ({ positions: 1, reserve: false, mainSide: 'other', reserveSide: null, spendableBnb: 0.03, reserveLeft: false, ...over }); is(`main all of the other side above the price, ${LADDER_GATE} aside, BNB over the floor: mint the reserve`, ladderDecision(L({})).act === 'mint_reserve'); is('… under the floor: nothing, and it says the ladder opens with the next deposit', (() => { const d = ladderDecision(L({ spendableBnb: MIN_INCREASE_BNB / 2 })); return d.act === null && /next deposit/.test(d.why); })()); is('main all WBNB below the price: no ladder, the increase takes the BNB', (() => { const d = ladderDecision(L({ mainSide: 'wbnb' })); return d.act === null && /increase step/.test(d.why); })()); is('main in range: no ladder, the increase takes the BNB', ladderDecision(L({ mainSide: 'both' })).act === null); is('no position: nothing', ladderDecision(L({ positions: 0 })).act === null); is('two positions the record does not name: a decision for a person', /person/.test(ladderDecision(L({ positions: 2, reserve: false })).why)); is('three positions: a decision for a person', /person/.test(ladderDecision(L({ positions: 3, reserve: true })).why)); const coreSrc0 = (await import('node:fs')).readFileSync(new URL('../shared/lp-agent.js', import.meta.url), 'utf8'); const R = (over) => L({ positions: 2, reserve: true, reserveSide: 'wbnb', ...over }); is('reserve stands (WBNB below), main above, BNB over the floor: grow the reserve', ladderDecision(R({})).act === 'increase_reserve'); is('… under the floor: the ladder stands, nothing to do', (() => { const d = ladderDecision(R({ spendableBnb: 0.001 })); return d.act === null && /stands/.test(d.why); })()); is('both hold only the other side (the price fell through the reserve): merge', ladderDecision(R({ reserveSide: 'other' })).act === 'merge'); is('both hold only WBNB (the price rose through the main range): merge', ladderDecision(R({ mainSide: 'wbnb', reserveSide: 'wbnb' })).act === 'merge'); is('the merge outranks a left reserve and waiting BNB', ladderDecision(R({ reserveSide: 'other', reserveLeft: true, spendableBnb: 1 })).act === 'merge'); is('the reserve left below the price by more than the slack, main still above: re-set the reserve beside the price', ladderDecision(R({ reserveLeft: true })).act === 'reset_reserve'); // The reserve does not chase a price the main range is in (2026-09-17: eight re-sets in a day, 0.96% of the reserve, fees of dust). is('the reserve left, but the main range is in range: it waits where it is, no re-set', (() => { const d = ladderDecision(R({ reserveLeft: true, mainSide: 'both', spendableBnb: 0.001 })); return d.act === null && /waits where it is/.test(d.why) && /buys on the way down/.test(d.why); })()); is('… and with BNB over the floor the increase still takes it, not a re-set', (() => { const d = ladderDecision(R({ reserveLeft: true, mainSide: 'both' })); return d.act === null && /increase step/.test(d.why); })()); is('the reserve left and the main range unread: no re-set', ladderDecision(R({ reserveLeft: true, mainSide: null, spendableBnb: 0.001 })).act === null); is('main in range, reserve below it, BNB waits: the increase takes it, not the ladder', (() => { const d = ladderDecision(R({ mainSide: 'both' })); return d.act === null && /increase step/.test(d.why); })()); is('a standing ladder says where the main range is: in range, not "above the price"', /main in range/.test(ladderDecision(R({ mainSide: 'both', spendableBnb: 0.001 })).why) && /main above the price/.test(ladderDecision(R({ spendableBnb: 0.001 })).why)); is('the gate is a worker variable named LP_LADDER', LADDER_GATE === 'LP_LADDER'); // The reserve does not chase (2026-09-18): not on the ten-minute watch, not under the re-set floor. is('the watch opens and grows the reserve, it never re-sets or merges one', ladderActsInWatch('mint_reserve') && ladderActsInWatch('increase_reserve') && !ladderActsInWatch('reset_reserve') && !ladderActsInWatch('merge') && !ladderActsInWatch(null)); is('… and the worker asks that rule before the watch may act on the ladder (source pin)', /if \(watch && !ladderActsInWatch\(plan\.act\)\) return/.test((await import('node:fs')).readFileSync(new URL('../worker-lp/index.js', import.meta.url), 'utf8'))); is('a reserve under the re-set floor is not re-set: it waits where it is', ladderDecision(R({ reserveLeft: true, reserveBnb: MIN_REBALANCE_BNB - 0.001 })).act === null && /below the 0\.02 BNB floor/.test(ladderDecision(R({ reserveLeft: true, reserveBnb: 0.005 })).why)); is('… at the floor and above it is, and a caller that names no value is not held to one', ladderDecision(R({ reserveLeft: true, reserveBnb: MIN_REBALANCE_BNB })).act === 'reset_reserve' && ladderDecision(R({ reserveLeft: true, reserveBnb: 0.041 })).act === 'reset_reserve' && ladderDecision(R({ reserveLeft: true })).act === 'reset_reserve'); // The profit share of a re-set downward is sold out of the token the fees came in. is('the share\'s shortfall is sold out of the other side: that many WBNB at the quoted rate, half a percent over', (() => { const a = shareShortfallSale({ short: 4n * 10n ** 14n, wbnbPerOtherX18: 3280000000000000n, haveOther: 10n ** 20n }); const got = (a * 3280000000000000n) / 10n ** 18n; return got >= 4n * 10n ** 14n && got < (4n * 10n ** 14n * 1006n) / 1000n; })()); is('… never more than the wallet holds, and nothing when nothing is short, quoted or held', shareShortfallSale({ short: 10n ** 18n, wbnbPerOtherX18: 3280000000000000n, haveOther: 10n ** 18n }) === 10n ** 18n && shareShortfallSale({ short: 0n, wbnbPerOtherX18: 1n, haveOther: 1n }) === 0n && shareShortfallSale({ short: 1n, wbnbPerOtherX18: 0n, haveOther: 1n }) === 0n && shareShortfallSale({ short: 1n, wbnbPerOtherX18: 1n, haveOther: 0n }) === 0n); is('… and the re-set sells it before it decides the share "stays as capital" (source pin)', (() => { const src = (s0) => s0.slice(s0.indexOf('export async function executeRebalance'), s0.indexOf('export async function executeRelocate')); const reb = src(coreSrc0); return reb.indexOf('shareShortfallSale(') > 0 && reb.indexOf('shareShortfallSale(') < reb.indexOf('it stays as capital'); })()); // The ladder record follows the chain (2026-09-17, the day the agent stood still). const HL = (over) => ({ main: '7450561', reserve: '7450613', held: ['7450613', '7451444'], samePool: true, ...over }); is('the record names a burnt main range, the reserve stands beside one other position in its pool: that one is the main range', (() => { const h = ladderHeal(HL({})); return h && h.main === '7451444' && /7450561/.test(h.why); })()); is('… the ids may come as numbers or bigints', ladderHeal(HL({ main: 7450561, reserve: 7450613n, held: [7450613n, 7451444n] }))?.main === '7451444'); is('the main range is still held: nothing to heal', ladderHeal(HL({ held: ['7450613', '7450561'] })) === null); is('the reserve is gone: nothing to heal, the guards decide', ladderHeal(HL({ held: ['7451444', '7451500'] })) === null); is('a third position: nothing to heal', ladderHeal(HL({ held: ['7450613', '7451444', '7451500'] })) === null); // The reserve alone (2026-09-18): the main range was burned by a re-set whose mint failed. is('the reserve is the only position left: it is the main range now and the ladder is closed', (() => { const h = ladderHeal(HL({ held: ['7450613'] })); return h && h.main === '7450613' && h.reserve === null && h.closed === true && /closed/.test(h.why); })()); is('one position that is not the reserve: nothing to heal, the guards decide', ladderHeal(HL({ held: ['7451444'] })) === null); is('... and the main range alone, still held: nothing to heal', ladderHeal(HL({ held: ['7450561'] })) === null); is('the other position is in another pool: nothing to heal', ladderHeal(HL({ samePool: false })) === null); is('no reserve in the record, or no main: nothing to heal', ladderHeal(HL({ reserve: null })) === null && ladderHeal(HL({ main: null })) === null); // The same class on the reserve's side (2026-09-18): a reserve minted, its id never written. is('the main range is held beside one position the record does not name: that one is the reserve', (() => { const h = ladderHeal(HL({ main: '7451444', reserve: null, held: ['7451444', '7461743'] })); return h && h.main === '7451444' && h.reserve === '7461743' && h.adopted === true && /no reserve/.test(h.why); })()); is('… also when the record still names a reserve burnt at its re-set', (() => { const h = ladderHeal(HL({ main: '7451444', reserve: '7450613', held: ['7461743', '7451444'] })); return h && h.reserve === '7461743' && h.adopted === true && /7450613/.test(h.why); })()); is('… never across pools, never with a third position, never when the record is right', ladderHeal(HL({ main: '7451444', reserve: null, held: ['7451444', '7461743'], samePool: false })) === null && ladderHeal(HL({ main: '7451444', reserve: null, held: ['7451444', '7461743', '7461800'] })) === null && ladderHeal(HL({ main: '7451444', reserve: '7461743', held: ['7451444', '7461743'] })) === null); // A mint from the wallet finishes the re-set it belongs to (2026-09-18). is('a wallet left holding the other side alone is minted one-sided above the price, no trade', resumeSide(1) === 'below' && resumeSide(0.97) === 'below' && ticksAdjacent(-57200, 7, 10, resumeSide(1)).side === 'above_price'); is('… all WBNB: one-sided below the price', resumeSide(0) === 'above' && resumeSide(0.04) === 'above' && ticksAdjacent(-57200, 7, 10, resumeSide(0)).side === 'below_price'); is('… a mixed wallet is centred as before, and a wallet with nothing in it decides nothing', resumeSide(0.5) === null && resumeSide(0.9) === null && resumeSide(0.1) === null && resumeSide(null) === null && resumeSide(NaN) === null); is('planRebalance asks resumeSide when it mints from the wallet (source pin)', /if \(resume && valueBnb > 0\) oneSided = resumeSide\(/.test((await import('node:fs')).readFileSync(new URL('../shared/lp-agent.js', import.meta.url), 'utf8'))); // THE MAIN RANGE IS GONE, THE RESERVE STANDS (2026-09-18): a re-set burnt the // main range beside the reserve and its mint failed. Driven through the real // plan functions over a chain that answers from a table — no RPC. { const CAKE = '0x0e09fabb73bd3ade0a17ecc321fd13a19e81ce82', POOL = '0xafb2da14056725e3ba3a30dd846b6bbbd7886c56', FACT = '0x0bfbcf9fa4f9c56b0f40a671ad40e0805a091865', ME = ADDR.LP_WALLET; let tickNow = -57900; // fell out of the old main range (-57860 …), inside the reserve (-59000 … -57090) const chain = ({ cake, wbnb = 0n, ids = [7461743n] }) => ({ getBalance: async () => 6n * 10n ** 15n, simulateContract: async () => ({ result: [0n, 0n] }), getGasPrice: async () => 100000000n, waitForTransactionReceipt: async () => ({ status: 'success', gasUsed: 300000n, effectiveGasPrice: 100000000n, logs: [{ address: ADDR.V3_POSITION_MANAGER, topics: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x' + '0'.repeat(64), '0x' + ME.slice(2).toLowerCase().padStart(64, '0'), '0x' + (7470001n).toString(16).padStart(64, '0')] }] }), readContract: async ({ address, functionName, args }) => { const to = String(address).toLowerCase(); if (functionName === 'balanceOf') return to === ADDR.V3_POSITION_MANAGER ? BigInt(ids.length) : to === CAKE ? cake : to === ADDR.WBNB ? wbnb : 0n; if (functionName === 'tokenOfOwnerByIndex') return ids[Number(args[1])]; if (functionName === 'ownerOf') { if (ids.includes(BigInt(args[0]))) return ME; throw new Error('ERC721: owner query for nonexistent token'); } if (functionName === 'positions') return [0n, '0x0000000000000000000000000000000000000000', CAKE, ADDR.WBNB, 500, -59000, -57090, 10n ** 19n, 0n, 0n, 0n, 0n]; if (functionName === 'factory') return FACT; if (functionName === 'getPool') return POOL; if (functionName === 'slot0') return [BigInt(Math.floor(Math.pow(1.0001, tickNow / 2) * 2 ** 96)), tickNow, 0, 0, 0, 0, true]; if (functionName === 'tickSpacing') return 10; if (functionName === 'token0') return CAKE; if (functionName === 'token1') return ADDR.WBNB; if (functionName === 'fee') return 500; if (functionName === 'allowance') return (1n << 256n) - 1n; if (functionName === 'quoteExactInputSingle') return [args[0].amountIn / 300n, 0n, 0, 0n]; throw new Error(`the table has no answer for ${functionName}`); }, }); const LAD = { main: '7451444', reserve: '7461743' }; const REC = { hours_of_prices: 300, rows: [], earnings_pick: { width: 7, earnings: { net_usd_per_day: 0.1, resets: 1, reset_cost_usd: 0 } } }; const loose = chain({ cake: 450n * 10n ** 18n }); // ~1.37 BNB of CAKE the burnt main range left in the wallet const rp = await readPosition(loose, ME, LAD); is('only the reserve held beside a main range the record names: the wallet holds NO main range, the reserve rides along', rp.positions === 0 && rp.pos === null && String(rp.reserve?.tokenId) === '7461743' && rp.main_missing === '7451444'); is('… the same wallet without the record, or once the ladder is closed, reads the one position as the position', (await readPosition(loose, ME, null)).positions === 1 && (await readPosition(loose, ME, { main: '7461743', reserve: null })).positions === 1); is('… and nothing grows the reserve with the loose capital: increase, ladder and collect all stand', /no position to grow/.test((await planIncrease(loose, ME, null, LAD)).no || '') && (await planLadder(loose, ME, { record: REC, ladder: LAD })).act === null && /no position/.test((await planCollect(loose, ME, LAD).catch((e) => ({ no: `no position (${e.message})` }))).no || '')); const rs = await planRebalance(loose, ME, { record: REC, pool: POOL, ladder: LAD }); is('the re-set is finished from the wallet beside the reserve: one-sided above the price, all of the other side, no trade', rs.resume === true && rs.no === null && rs.oneSided === 'below' && rs.ticks.side === 'above_price' && rs.ticks.tickLower > tickNow && rs.trade === null && rs.summary.reserve?.position === '7461743' && rs.summary.main_missing === '7451444' && rs.summary.value_with_reserve_bnb > rs.summary.value_bnb); is('ladderHeal leaves that wallet alone — the capital is there to mint', (await healLadder(loose, ME, { ...LAD })) === null && ladderHeal(HL({ held: ['7450613'], looseBnb: 1.37 })) === null && ladderHeal(HL({ held: ['7450613'], looseBnb: MIN_REBALANCE_BNB })) === null); const dust = chain({ cake: 10n ** 18n }); // ~0.003 BNB: nothing to mint a main range from is('… and closes the ladder when nothing worth minting lies beside the reserve', (await healLadder(dust, ME, { ...LAD }))?.closed === true && ladderHeal(HL({ held: ['7450613'], looseBnb: MIN_REBALANCE_BNB - 0.001 }))?.closed === true); is('… where the mint would be refused too (one floor for both, so the wallet never waits between them)', /below the 0.02 BNB floor/.test((await planRebalance(dust, ME, { record: REC, pool: POOL, ladder: LAD })).no || '')); // BY THE RECORD'S IDS, NOT BY THE COUNT (2026-09-18): a stranger's dust NFT. const stuffed = chain({ cake: 0n, ids: [7451444n, 999001n, 7461743n, 999002n] }); const sp = await readPosition(stuffed, ME, LAD); is('two strangers\' NFTs in the wallet change nothing: the main range and the reserve are read by the ids the record names', sp.positions === 1 && String(sp.tokenId) === '7451444' && String(sp.reserve?.tokenId) === '7461743' && sp.positions_held === 2); is('… where the count alone read "4 positions" and every step refused', (await readPosition(stuffed, ME, null)).positions === 4 && /no position|positions/.test((await planIncrease(stuffed, ME, null, null)).no || '') && (await planIncrease(stuffed, ME, null, LAD)).state.positions === 1); is('… a record without a reserve reads the main range alone, a stranger beside it or not', (await readPosition(chain({ cake: 0n, ids: [999001n, 7451444n] }), ME, { main: '7451444', reserve: null })).positions === 1); is('… and with neither of its ids held the count decides as before', (await readPosition(chain({ cake: 0n, ids: [999001n, 999002n] }), ME, LAD)).positions === 2); is('a wallet stuffed with NFTs is never enumerated past the cap', (await heldIds(chain({ cake: 0n, ids: Array.from({ length: 500 }, (_, i) => BigInt(i + 1)) }), ME)).length === HELD_IDS_CAP); // The whole resume, sent to a wallet that writes nothing down but the requests — and the price // moves INTO the planned range between the plan and the mint (C16). { const sent = []; const w = { account: { address: ME }, writeContract: async (req) => { sent.push(req); return `0x${sent.length}`; }, sendTransaction: async (req) => { sent.push(req); return `0x${sent.length}`; } }; const planned = await planRebalance(loose, ME, { record: REC, pool: POOL, ladder: LAD }); tickNow = planned.ticks.tickLower + 40; // the price climbed past the planned lower tick const done = await executeRebalance(loose, w, { address: ME }, planned, () => {}, { txs: [] }); const mint = sent.find((r) => r.functionName === 'mint'); is('the resume beside the reserve runs through: one mint, all of the other side, no swap, the id off the receipt', sent.length === 1 && !!mint && mint.args[0].amount0Desired === 450n * 10n ** 18n && mint.args[0].amount1Desired === 0n && done.swap === null && done.new_position === '7470001' && done.one_sided === 'above_price'); is('… placed beside the price as it is at the mint, not as the plan read it: the planned range the price had entered is not minted', mint.args[0].tickLower > tickNow && mint.args[0].tickLower !== planned.ticks.tickLower && mint.args[0].tickLower - tickNow >= 20 && mint.args[0].tickLower - tickNow < 30 && done.new_ticks[0] === mint.args[0].tickLower && mint.args[0].amount0Min > (450n * 10n ** 18n * 969n) / 1000n && mint.args[0].amount0Min <= (450n * 10n ** 18n * 97n) / 100n && mint.args[0].amount1Min === 0n); tickNow = -57900; } is('a wallet that cannot be sure of paying a re-set through does not start one', /waits for BNB/.test(refuseRebalance({ positions: 1, inRange: false, atEdge: false, width: 7, valueBnb: 1.5, walletBnb: MIN_GAS_BNB - 0.0001 }) || '') && refuseRebalance({ positions: 1, inRange: false, atEdge: false, width: 7, valueBnb: 1.5, walletBnb: MIN_GAS_BNB }) === null && refuseRebalance({ positions: 1, inRange: false, atEdge: false, width: 7, valueBnb: 1.5 }) === null); is('… nor a ladder step: the plan refuses below the gas floor and runs above it', await (async () => { tickNow = -59100; /* below the range: the main range is all of the other side */ const poor = { ...chain({ cake: 0n, wbnb: 5n * 10n ** 16n, ids: [7451444n] }), getBalance: async () => 10n ** 15n }; /* 0.05 WBNB waits wrapped, 0.001 BNB to pay with */ const rich = { ...chain({ cake: 0n, ids: [7451444n] }), getBalance: async () => 5n * 10n ** 16n }; const a = await planLadder(poor, ME, { record: REC, ladder: { main: '7451444', reserve: null } }); const b = await planLadder(rich, ME, { record: REC, ladder: { main: '7451444', reserve: null } }); tickNow = -57900; return a.act === 'mint_reserve' && /below the 0\.0015 BNB/.test(a.no || '') && b.act === 'mint_reserve' && b.no === null; })()); const T = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', pad = (h) => '0x' + h.replace(/^0x/, '').padStart(64, '0'); const receipt = { logs: [ { address: CAKE, topics: [T, pad(ME), pad(POOL)], data: '0x' }, { address: ADDR.V3_POSITION_MANAGER, topics: [T, pad('0x0'), pad('0x1111111111111111111111111111111111111111'), pad((999003n).toString(16))] }, { address: ADDR.V3_POSITION_MANAGER, topics: [T, pad('0x0'), pad(ME), pad((7470001n).toString(16))] }, ] }; is('the id of a mint is read off its own receipt: the manager\'s transfer from zero to this wallet, no other', mintedIn(receipt, ME) === '7470001' && mintedIn({ logs: receipt.logs.slice(0, 2) }, ME) === null && mintedIn(null, ME) === null); } // The re-set beside a reserve: it reads its old range by id and names its new one by the mint (source pins; the chain half cannot run here). const coreSrc = (await import('node:fs')).readFileSync(new URL('../shared/lp-agent.js', import.meta.url), 'utf8'); const rebSrc = coreSrc.slice(coreSrc.indexOf('export async function executeRebalance'), coreSrc.indexOf('export async function', coreSrc.indexOf('export async function executeRebalance') + 10)); is('executeRebalance never asks for "the position of the wallet" — beside a reserve that read names none', rebSrc.length > 500 && !/readPosition\(/.test(rebSrc)); is('… it reads the old range by the id the plan names, and the new one as the id the mint added', /readOne\(pub, account\.address, plan\.tokenId\)/.test(rebSrc) && /mintedSince\(pub, account\.address, idsBeforeMint\)/.test(rebSrc)); // The reserve grows through the increase itself (2026-09-17): with the price inside it, WBNB alone is liquidity zero and the manager reverts (simulated on chain). const ladSrc = coreSrc.slice(coreSrc.indexOf('export async function executeLadder')); const incResSrc = ladSrc.slice(ladSrc.indexOf("plan.act === 'increase_reserve'"), ladSrc.indexOf("plan.act === 'reset_reserve'")); is('increase_reserve plans and runs the increase on the reserve range, read at the price now', incResSrc.length > 200 && /planIncrease\(pub, account\.address, \{ positions: 1, tokenId: plan\.reserve\.tokenId, pos: plan\.reserve\.pos/.test(incResSrc) && /executeIncrease\(pub, wallet, account, inc/.test(incResSrc)); is('… and never sends a WBNB-only increaseLiquidity of its own', !/increaseLiquidity/.test(incResSrc) && !/amount0Desired/.test(incResSrc)); is('what amountsForRange says of WBNB alone: into a range the price is in, nothing; into a range below the price, all of it', (() => { const inside = amountsForRange(Math.pow(1.0001, -57143 / 2), -59000, -57090, 0n, 10n ** 16n); const below = amountsForRange(Math.pow(1.0001, -57000 / 2), -59000, -57090, 0n, 10n ** 16n); return inside.L === 0 && inside.amount1 === 0n && below.amount1 > 99n * 10n ** 14n; })()); // positionSide: which token a range holds at a price. const psPos = [0n, '0x0', '0xcake', '0xwbnb', 500, -57780, -57000, 10n ** 20n]; // CAKE/BNB: WBNB is token1 is('a range above the price holds only the other side', positionSide(psPos, Math.pow(1.0001, -57807 / 2), false).side === 'other'); is('a range below the price holds only WBNB', positionSide(psPos, Math.pow(1.0001, -56900 / 2), false).side === 'wbnb'); is('a range around the price holds both', positionSide(psPos, Math.pow(1.0001, -57400 / 2), false).side === 'both'); is('a range with no liquidity holds nothing', positionSide([...psPos.slice(0, 7), 0n], Math.pow(1.0001, -57400 / 2), false).side === null); is('… and its value in BNB is the two sides at the price', (() => { const v = positionSide(psPos, Math.pow(1.0001, -57400 / 2), false); return v.valueBnb > 0 && Math.abs(v.valueBnb - (v.wbnb + v.other * Math.pow(1.0001, -57400)) / 1e18) < 1e-12; })()); console.log('deposit forces a re-set'); check('in range: no', depositForcesReset({ inRange: true, spendableBnb: 1, valueBnb: 0.3 }), false); check('a deposit under the increase floor: no', depositForcesReset({ inRange: false, spendableBnb: 0.004, valueBnb: 0.01 }), false); check(`a deposit under ${DEPOSIT_RESET_SHARE * 100}% of the position: no`, depositForcesReset({ inRange: false, spendableBnb: 0.05, valueBnb: 0.3 }), false); check(`a deposit of exactly ${DEPOSIT_RESET_SHARE * 100}%: yes`, depositForcesReset({ inRange: false, spendableBnb: 0.075, valueBnb: 0.3 }), true); check('the 2026-09-10 case, 0.3056 beside 0.29: yes, and it says the share', /105%/.test(depositForcesReset({ inRange: false, spendableBnb: 0.3056, valueBnb: 0.29 }) || ''), true); check('no position value: no', depositForcesReset({ inRange: false, spendableBnb: 0.3, valueBnb: 0 }), false); console.log('rebalance wait'); const H = 36e5, now = Date.parse('2026-09-04T06:50:00Z'); check('first hour outside: waits', rebalanceWait(null, now), true); check('one hour outside: still waits', rebalanceWait(now - 1 * H, now), true); // The hourly cron has seconds of jitter: the check two slots after the price // left must count as two hours, and the check one slot after must not. check('ten minutes under the delay: still waits', rebalanceWait(now - (RESET_AFTER_HOURS * H - 10 * 60e3), now), true); check('one minute under the delay (cron jitter): due', rebalanceWait(now - (RESET_AFTER_HOURS * H - 60e3), now), false); check('52 ms under the delay (the 2026-09-08 19:50 check): due', rebalanceWait(now - (RESET_AFTER_HOURS * H - 52), now), false); check(`exactly ${RESET_AFTER_HOURS} h outside: due`, rebalanceWait(now - RESET_AFTER_HOURS * H, now), false); // A measured wait of 0 h (2026-09-09): due the hour the price is first // seen outside, and due a minute later too; a wait passed explicitly is // the one used, not the set one. check('a measured wait of 0 h: due at once, first sighting', rebalanceWait(null, now, 0), false); check('a measured wait of 0 h: due a minute after the first sighting', rebalanceWait(now - 60e3, now, 0), false); check('a measured wait of 3 h: still waits at 2 h', rebalanceWait(now - 2 * H, now, 3), true); check('a measured wait of 1 h: due at 1 h', rebalanceWait(now - 1 * H, now, 1), false); check('an hour and a few seconds outside: still waits', rebalanceWait(now - (1 * H + 5e3), now), true); check('a day outside: due', rebalanceWait(now - 24 * H, now), false); console.log('unwind in one transaction'); // The V3 re-centring swap (2026-09-09): the router's struct, every field // pinned, and the note the record keeps — fee from the pool's tier, the // notional in WBNB. The addresses are the ones verified on chain that day // (factory() = the V3 factory, WETH9() = WBNB), written here a second time // so a slip in either copy shows. const v3 = v3SwapArgs(ADDR.WBNB, '0x0e09fabb73bd3ade0a17ecc321fd13a19e81ce82', 500, '0xbFAA69233741924eD5b9d5DAA9B4Bf7B84567F0A', 20000000000000000n, 6400000000000000000n, 1800000000n); check('the V3 swap sells exactly the amount asked, no more', v3.amountIn === 20000000000000000n, true); check('… into the position\'s fee tier', v3.fee === 500, true); check('… to the wallet, not the router', v3.recipient === '0xbFAA69233741924eD5b9d5DAA9B4Bf7B84567F0A', true); check('… with the minimum out as the only guard (no price limit)', v3.amountOutMinimum === 6400000000000000000n && v3.sqrtPriceLimitX96 === 0n, true); check('… and a deadline', v3.deadline === 1800000000n, true); check('the V3 router is the verified one', ADDR.V3_SWAP_ROUTER === '0x1b81d678ffb9c0263b24a97847620c99d213eb14', true); check('the V3 quoter is the verified one', ADDR.V3_QUOTER === '0xb048bbc1ee6b733fffcfb9e9cef7375518e25997', true); const note = swapNote('buy', 40000000000000000n, 500); check('a 0.04 WBNB buy through 0.05% notes a 0.00002 BNB fee', note.fee_bnb === 0.00002 && note.notional_bnb === 0.04 && note.fee_pct === 0.05, true); check('… a fifth of what the 0.25% pool took', swapNote('buy', 40000000000000000n, 2500).fee_bnb === 0.0001, true); // The reserve range's fees (2026-09-19): taken along by the collect when // they are worth a transaction of their own, left to wait when not. check('a reserve owed 0.000085 BNB (the chain, 2026-09-19) is left alone, and the plan says why', !reserveCollect(0.000085).collect && /0\.000085 BNB.*wait/.test(reserveCollect(0.000085).why), true); check('at 0.0002 BNB it is collected with the main range — its share then clears the re-set floor', reserveCollect(0.0002).collect === true && reserveCollect(0.0002).why === null && RESERVE_COLLECT_MIN_BNB === 2 * MIN_RESET_FORWARD_BNB, true); check('a reserve owed nothing, or a number that is none, is never asked', !reserveCollect(0).collect && !reserveCollect(NaN).collect && !reserveCollect(undefined).collect, true); { // The fee sum counts what a reserve unwind folded in, once it names its worth; the eight before 2026-09-19 carry none and stay out. const mk = (ladder) => ({ at: '2026-09-19T10:00:00.000Z', steps: { ladder } }); const flowOf = (h) => moneyFlow({ history: h, last: h[h.length - 1] }); const with1 = flowOf([mk({ acted: true, reserve_fees_folded: { wbnb: '0.0001', other: '0.01', bnb_equivalent: 0.00013 } })]); const old = flowOf([mk({ acted: true, reserve_fees_folded: { wbnb: '0.0001', other: '0.01' } })]); const failedRun = flowOf([mk({ acted: true, error: 'x', reserve_fees_folded: { bnb_equivalent: 0.00013 } })]); const folded = (f) => f.in.fees.folded_bnb; check('a reserve unwind that names what its fees were worth counts them as fees folded in', folded(with1) === 0.00013, true); check('… one that does not (before 2026-09-19), or whose step failed, adds nothing', folded(old) === 0 && folded(failedRun) === 0, true); } // The floor under a swap (2026-09-19): 0.3% under a pool quote, and the // $BOBAI buy with the 3% the token keeps taken off first, then 5%. check('a pool swap accepts 0.3% under its quote, not 1%', swapFloor(1000000n) === 997000n && POOL_SWAP_FLOOR_BPS === 30n, true); check('the $BOBAI buy takes the 3% off the quote first, then 5% — as the tax bot does', bobaiBuyFloor(1000000n) === 921500n, true); check('… which the eleven buys on chain (each exactly 97% of the quote) clear by 5%, where 15% flat left 12%', bobaiBuyFloor(1000000n) < 970000n && 970000n * 95n / 100n === bobaiBuyFloor(1000000n) && 850000n < bobaiBuyFloor(1000000n), true); { // A swap the node refuses before it is sent is asked once more at a fresh // quote; one that was broadcast is never repeated; a second refusal stands. const calls = []; const refusedOnce = await requoteOnce(async (again) => { calls.push(again); if (!again) { const e = new Error('Too little received'); e.sent = false; throw e; } return 'second'; }); check('a swap refused before it was sent is asked again, once, at a fresh quote', refusedOnce === 'second' && calls.join() === 'false,true', true); let broadcast = 0, twice = 0, plain = 0; const b = await requoteOnce(async () => { broadcast++; const e = new Error('reverted'); e.sent = true; throw e; }).catch((e) => e.message); check('one that was broadcast and reverted is not sent again', b === 'reverted' && broadcast === 1, true); const t = await requoteOnce(async () => { twice++; const e = new Error('Too little received'); e.sent = false; throw e; }).catch((e) => e.message); check('and a second refusal stands — no third try', t === 'Too little received' && twice === 2, true); const p = await requoteOnce(async () => { plain++; throw new Error('a read failed'); }).catch((e) => e.message); check('an error that does not say whether anything was sent is never retried', p === 'a read failed' && plain === 1, true); // The sender marks the error: nothing sent when the wallet refuses, sent once a hash exists. const refuse = sender({ waitForTransactionReceipt: async () => ({ status: 'reverted' }), getGasPrice: async () => 100000000n }, { account: { address: '0xbFAA69233741924eD5b9d5DAA9B4Bf7B84567F0A' }, writeContract: async () => { throw new Error('execution reverted: Too little received'); } }, []); const went = sender({ waitForTransactionReceipt: async () => ({ status: 'reverted' }), getGasPrice: async () => 100000000n }, { account: { address: '0xbFAA69233741924eD5b9d5DAA9B4Bf7B84567F0A' }, writeContract: async () => '0x' + 'ab'.repeat(32) }, []); const e1 = await refuse('swap', { address: ADDR.V3_SWAP_ROUTER }).catch((e) => e), e2 = await went('swap', { address: ADDR.V3_SWAP_ROUTER }).catch((e) => e); check('the sender says which it was: refused in the estimate = not sent, reverted in a block = sent', e1.sent === false && e2.sent === true, true); } const calls = unwindCalls(7309536n, 72166992217730319120n, 1n, 2n, '0xbFAA69233741924eD5b9d5DAA9B4Bf7B84567F0A', 1800000000n); check('three calls: decreaseLiquidity, collect, burn — in that order', calls.length === 3 && calls[0].startsWith('0x0c49ccbe') && calls[1].startsWith('0xfc6f7865') && calls[2].startsWith('0x42966c68') ? null : calls.map((c) => c.slice(0, 10)).join(','), false); check('the token id is in every call', calls.every((c) => c.includes(BigInt(7309536).toString(16).padStart(64, '0'))) ? null : 'a call lacks the token id', false); const t = ticksAround(-59407, 1, 10); check('a re-set range sits on the pool\'s grid around the tick', t.tickLower % 10 === 0 && t.tickUpper % 10 === 0 && t.tickLower < -59407 && t.tickUpper > -59407 ? null : `${t.tickLower}…${t.tickUpper}`, false); check('and is never wider than the width asked for', (t.tickUpper - t.tickLower) <= 2 * Math.log(1.01) / Math.log(1.0001) ? null : 'wider', false); console.log('split'); // Below the range a position is all token0, above it all token1, inside it both. const lo = -59340, hi = -59250; const mid = Math.pow(1.0001, (lo + hi) / 4), below = Math.pow(1.0001, (lo - 100) / 2), above = Math.pow(1.0001, (hi + 100) / 2); const sMid = splitForRange(mid, lo, hi), sBelow = splitForRange(below, lo, hi), sAbove = splitForRange(above, lo, hi); check('inside the range holds both tokens', sMid.perL0 > 0 && sMid.perL1 > 0 ? null : 'one side is zero', false); check('below the range holds only token0', sBelow.perL0 > 0 && sBelow.perL1 === 0 ? null : 'token1 is not zero', false); check('above the range holds only token1', sAbove.perL1 > 0 && sAbove.perL0 === 0 ? null : 'token0 is not zero', false); // Ten times the capital is ten times the liquidity: the split is linear in L. check('the split is linear in liquidity', Math.abs(splitForRange(mid, lo, hi).perL0 - sMid.perL0) < 1e-18 ? null : 'not linear', false); console.log('what the range takes (the 2026-09-05 revert)'); // Balances in the range's own ratio are taken whole; an excess on one side // is left, and the minimum follows what is taken, not what is held. The // failed run held 2.038 CAKE beside 0.0025 WBNB and asked for 90% of both. const L0 = 1e15, have0 = BigInt(Math.floor(L0 * sMid.perL0)), have1 = BigInt(Math.floor(L0 * sMid.perL1)); const whole = amountsForRange(mid, lo, hi, have0, have1); check('balances in the ratio are taken whole', whole.amount0 <= have0 && whole.amount1 <= have1 && whole.amount0 > (have0 * 999n) / 1000n && whole.amount1 > (have1 * 999n) / 1000n ? null : 'amount0 ' + whole.amount0 + ' of ' + have0 + ', amount1 ' + whole.amount1 + ' of ' + have1, false); const excess = amountsForRange(mid, lo, hi, have0 * 8n, have1); check('an excess of token0 is left, token1 is the short side', excess.amount1 === whole.amount1 && excess.amount0 <= whole.amount0 + 1n ? null : 'amount0 ' + excess.amount0 + ' amount1 ' + excess.amount1, false); const mins = minsForRange(mid, lo, hi, have0 * 8n, have1, 0); // no drift here: this pins the ratio rule alone check('the minimum for token0 follows what is taken, not the balance', mins.amount0Min < (have0 * 8n * 90n) / 100n && mins.amount0Min > (excess.amount0 * 96n) / 100n && mins.amount0Min <= excess.amount0 ? null : String(mins.amount0Min), false); check('below the range only token0 is taken, token1 not at all', amountsForRange(below, lo, hi, have0, have1).amount1 === 0n && amountsForRange(below, lo, hi, have0, have1).amount0 > 0n ? null : 'token1 taken', false); check('nothing held means nothing taken and a zero minimum', minsForRange(mid, lo, hi, 0n, have1).amount0Min === 0n && minsForRange(mid, lo, hi, 0n, have1).amount1Min === 0n ? null : 'not zero', false); console.log(`minimums with ${MINT_DRIFT_TICKS} ticks of drift (the 2026-09-05 12:50 revert)`); // The failed mint: ±1% (190 ticks), balances in the ratio at the tick, the // pool a few ticks away by the time the block came. With the drift in the // minimums a mint at any price inside the tolerance passes; at zero drift // the old behaviour is back, and a move of a handful of ticks fails it. const sqrtAt = (t) => Math.pow(1.0001, t / 2); const n1 = ticksAround(-58441, 1, 10); const s1 = sqrtAt(-58441), r0 = BigInt(Math.floor(1e15 * splitForRange(s1, n1.tickLower, n1.tickUpper).perL0)), r1 = BigInt(Math.floor(1e15 * splitForRange(s1, n1.tickLower, n1.tickUpper).perL1)); const tol = minsForRange(s1, n1.tickLower, n1.tickUpper, r0, r1); const none = minsForRange(s1, n1.tickLower, n1.tickUpper, r0, r1, 0); const passes = (m, t) => { const a = amountsForRange(sqrtAt(t), n1.tickLower, n1.tickUpper, r0, r1); return a.amount0 >= m.amount0Min && a.amount1 >= m.amount1Min; }; check('at the read price both minimums pass', passes(tol, -58441) ? null : 'fails at the read price', false); check(`${MINT_DRIFT_TICKS} ticks up still passes`, passes(tol, -58441 + MINT_DRIFT_TICKS) ? null : 'fails', false); check(`${MINT_DRIFT_TICKS} ticks down still passes`, passes(tol, -58441 - MINT_DRIFT_TICKS) ? null : 'fails', false); check(`${MINT_DRIFT_TICKS * 2} ticks up is outside the tolerance and fails`, passes(tol, -58441 + MINT_DRIFT_TICKS * 2) ? 'passes' : null, false); check('with zero drift a move of 6 ticks fails — the 12:50 revert', passes(none, -58441 + 6) ? 'passes' : null, false); check('the drift lowers the minimums, it never raises them', tol.amount0Min <= none.amount0Min && tol.amount1Min <= none.amount1Min && (tol.amount0Min < none.amount0Min || tol.amount1Min < none.amount1Min) ? null : 'not lower', false); console.log('the trade into the range\'s ratio (the 2026-09-14 leftover)'); // A wallet that came out of its old range all in WBNB (the price rose // through the top) must buy exactly the other side a centred ±3% range // takes — no 99% headroom, no 2% over-buy — so that the mint takes both // sides whole. Leftover = the share of the value neither side of the mint // uses. The old rule (99% of L, 102% of the buy) left 1.6% on 2026-09-14. const t3 = ticksAround(-57235, 3, 10), sq3 = sqrtAt(-57235); const sp3 = splitForRange(sq3, t3.tickLower, t3.tickUpper); const pxW = sq3 ** 2; // token1 per token0; WBNB is token1 here (CAKE/BNB) const pW = sp3.perL1, pO = sp3.perL0, rB = 1 / pxW, rS = pxW; // rB: other per WBNB, rS: WBNB per other const leftover = (W, C, tr) => { let w = W, c = C; if (tr.side === 'buy') { w -= Number(tr.amount); c += Number(tr.amount) * rB; } if (tr.side === 'sell') { c -= Number(tr.amount); w += Number(tr.amount) * rS; } const L = Math.min(w / pW, c / pO); const used = L * pW + L * pO * rS, value = w + c * rS; return (value - used) / value; }; const oldRule = (W, C) => { const value = W + C * rS, Ln = (value * 0.99) / (pW + pO * rS), target = Ln * pO; return C > target ? { side: 'sell', amount: BigInt(Math.floor(C - target)) } : { side: 'buy', amount: BigInt(Math.floor((target - C) * rS * 1.02)) }; }; const Wall = 1.026e18; const tBuy = tradeToRatio({ wbnb: BigInt(Wall), other: 0n, perLWbnb: pW, perLOther: pO, otherPerWbnb: rB, wbnbPerOther: rS }); is('all WBNB: it buys the other side', tBuy.side === 'buy' && tBuy.amount > 0n && tBuy.amount < BigInt(Wall)); is('all WBNB: after the buy the mint takes everything (leftover < 0.01%)', leftover(Wall, 0, tBuy) < 1e-4); is('the old rule left about 1% even at a still price (1.6% on 2026-09-14 with the fee and the drift)', leftover(Wall, 0, oldRule(Wall, 0)) > 0.009); const Call = Wall / rS; const tSell = tradeToRatio({ wbnb: 0n, other: BigInt(Math.floor(Call)), perLWbnb: pW, perLOther: pO, otherPerWbnb: rB, wbnbPerOther: rS }); is('all other side: it sells, and the mint takes everything', tSell.side === 'sell' && leftover(0, Call, tSell) < 1e-4); const Lx = 1e15, inRatio = tradeToRatio({ wbnb: BigInt(Math.floor(Lx * pW)), other: BigInt(Math.floor(Lx * pO)), perLWbnb: pW, perLOther: pO, otherPerWbnb: rB, wbnbPerOther: rS }); is('a wallet already in the ratio trades nothing', inRatio.side === null || inRatio.amount < 1000n); const feeRate = 0.9995; // a 0.05% pool: the quoter's rate carries the fee const buyFee = tradeToRatio({ wbnb: BigInt(Wall), other: 0n, perLWbnb: pW, perLOther: pO, otherPerWbnb: rB * feeRate, wbnbPerOther: rS * feeRate }); is('with the pool fee in the rate it buys a little more WBNB-worth, still no leftover', buyFee.amount > tBuy.amount && (() => { let w = Wall - Number(buyFee.amount), c = Number(buyFee.amount) * rB * feeRate; const L = Math.min(w / pW, c / pO); return (w + c * rS - L * pW - L * pO * rS) / (w + c * rS) < 1e-4; })()); const rangeAbove = splitForRange(sqrtAt(t3.tickUpper + 50), t3.tickLower, t3.tickUpper); // price above the range: only token1 (WBNB) is held const edgeW = tradeToRatio({ wbnb: 0n, other: BigInt(Math.floor(Call)), perLWbnb: rangeAbove.perL1, perLOther: rangeAbove.perL0, otherPerWbnb: rB, wbnbPerOther: rS }); is('above the range everything on the other side is sold', edgeW.side === 'sell' && edgeW.amount === BigInt(Math.floor(Call))); const rangeBelow = splitForRange(sqrtAt(t3.tickLower - 50), t3.tickLower, t3.tickUpper); const edgeC = tradeToRatio({ wbnb: BigInt(Wall), other: 0n, perLWbnb: rangeBelow.perL1, perLOther: rangeBelow.perL0, otherPerWbnb: rB, wbnbPerOther: rS }); is('below the range all WBNB is spent on the other side', edgeC.side === 'buy' && edgeC.amount === BigInt(Wall)); // A balance no double can hold: Number() of each of these rounds UP, and the // swap that asked for the rounded figure reverted for want of a few hundred wei. const odd = [31234567890123458700n, 9007199254740993n * 1001n, 1561000000000000123n + 2n ** 60n]; is('"all of one side" never asks for more than the wallet holds, whatever the balance (sell)', odd.every((c) => { const t = tradeToRatio({ wbnb: 0n, other: c, perLWbnb: rangeAbove.perL1, perLOther: rangeAbove.perL0, otherPerWbnb: rB, wbnbPerOther: rS }); return t.side === 'sell' && t.amount <= c && c - t.amount < 10n ** 6n; })); is('… nor on the buying side', odd.every((w) => { const t = tradeToRatio({ wbnb: w, other: 0n, perLWbnb: rangeBelow.perL1, perLOther: rangeBelow.perL0, otherPerWbnb: rB, wbnbPerOther: rS }); return t.side === 'buy' && t.amount <= w && w - t.amount < 10n ** 6n; })); is('… and the fixture is one the old rule failed: the nearest double lies above at least one of these balances', odd.some((c) => BigInt(Number(c)) > c)); is('no liquidity on either side: nothing to trade', tradeToRatio({ wbnb: BigInt(Wall), other: 0n, perLWbnb: 0, perLOther: 0, otherPerWbnb: rB, wbnbPerOther: rS }).side === null); is('the dust floor is a ten-thousandth of a BNB', TRADE_DUST_WBNB === 10n ** 14n); console.log('sender: a failed run still names what it sent'); // Two transactions go through, the third reverts on the chain; the error // must carry all three (the reverted one paid gas too). A simulation that // refuses before sending carries only what went before it. await (async () => { const fakePub = { getGasPrice: async () => 100000000n, waitForTransactionReceipt: async ({ hash }) => ({ status: hash === '0x3' ? 'reverted' : 'success', gasUsed: 21000n, effectiveGasPrice: 100000000n }) }; let nth = 0; const fakeWallet = { writeContract: async () => `0x${++nth}`, sendTransaction: async () => `0x${++nth}` }; const txs = []; const send = sender(fakePub, fakeWallet, txs); let err = null; try { await send('one', {}); await send('two', {}); await send('three', {}); } catch (e) { err = e; } check('a reverted third transaction throws', err ? null : 'no error', false); check('the error carries the three transactions sent', err && Array.isArray(err.txs) && err.txs.length === 3 && err.txs === txs ? null : `carried ${err && err.txs ? err.txs.length : 'none'}`, false); check('the reverted transaction has its gas measured', err && err.txs && err.txs[2].gas_bnb > 0 ? null : 'no gas on the reverted tx', false); const txs2 = []; const refusing = { writeContract: async () => { throw new Error('simulation reverted'); } }; let err2 = null; try { await sender(fakePub, refusing, txs2)('mint', {}); } catch (e) { err2 = e; } check('a simulation that refuses before sending carries an empty list, not none', err2 && Array.isArray(err2.txs) && err2.txs.length === 0 ? null : 'not carried', false); // The allowance check asks the sender whom it acts for. The collect of // 2026-09-14 04:23 forgot to say and asked for "undefined": the sender // now knows its wallet's owner on its own. const owned = sender(fakePub, { ...fakeWallet, account: { address: '0xbFAA69233741924eD5b9d5DAA9B4Bf7B84567F0A' } }, []); check("a sender knows its wallet's owner without being told", owned.owner === '0xbFAA69233741924eD5b9d5DAA9B4Bf7B84567F0A', true); check('… and a wallet without an account leaves it unset, not a guess', sender(fakePub, fakeWallet, []).owner === undefined, true); // Until 2026-09-14 each execute step made its own transaction list and it // came back only on a throw out of `send`. A step that sent and then failed // on a READ reported nothing sent: the collect of that morning recorded an // empty list against a collect that had run on chain. The list belongs to // the caller now, so it survives whatever throws. const mine = []; await sender(fakePub, { writeContract: async () => '0xaa' }, mine)('collect', {}); let readErr = null; try { await Promise.reject(new Error('a read failed after the send')); } catch (e) { readErr = e; } check('the caller keeps the transactions when the failure comes from a read', mine.length === 1 && mine[0].hash === '0xaa', true); check("… which the error itself never carried, so only the caller's list can say it", readErr.txs === undefined, true); })(); console.log(`\n${total - bad}/${total} checks behave in both directions (floors: collect ${MIN_COLLECT_BNB}, sweep ${MIN_SWEEP_BNB}, increase ${MIN_INCREASE_BNB} BNB)`); process.exitCode = bad ? 1 : 0; } // -------------------------------------------------------------------------- // The live path // -------------------------------------------------------------------------- const transport = () => process.env.BSC_RPC_URL ? http(process.env.BSC_RPC_URL) : fallback(RPCS.map((u) => http(u, { timeout: 15000 }))); const acct = (key) => privateKeyToAccount(key.startsWith('0x') ? key : `0x${key}`); const f = (n, d = 6) => Number(n).toFixed(d); async function main() { const pub = createPublicClient({ chain: bsc, transport: transport() }); const log = (s) => console.log(s); let acted = 0; if (STEPS.includes('sweep')) { console.log('SWEEP — AI income -> BNB -> DeFi wallet'); const feed = await readBnbUsd(pub); console.log(` BNB ${f(feed.bnbUsd, 2)} $ (Chainlink, ${feed.feedAgeS} s old)`); for (const src of INCOME_SOURCES) { const key = process.env[src.keyEnv]; if (!key) { console.log(` ${src.name}: no ${src.keyEnv} in .env — skipped`); continue; } const a = acct(key); if (a.address.toLowerCase() !== src.wallet.toLowerCase()) { console.log(` ${src.name}: ${src.keyEnv} does not open ${src.wallet} — skipped`); continue; } const plan = await planSweep(pub, src, feed); const s = plan.summary; console.log(` ${src.name} ${src.wallet}: ${f(s.balance, 4)} ${src.symbol} (${src.earns}), worth ${f(s.bnb_equivalent)} BNB${s.implied_usd != null ? `, route pays $${s.implied_usd}` : ''}, its wallet holds ${f(s.wallet_bnb ?? s.gas_bnb)} BNB for gas`); if (plan.no) { console.log(` nothing to do: ${plan.no}`); continue; } console.log(` would sell ${f(s.sweeping, 4)} ${src.symbol}${s.capped ? ' (capped for this run)' : ''} for ~${f(s.bnb_equivalent)} BNB, paid straight to ${ADDR.LP_WALLET}`); if (!CONFIRM) continue; const wallet = createWalletClient({ account: a, chain: bsc, transport: transport() }); const out = await executeSweep(pub, wallet, a, plan, log); console.log(` sold ${out.sold} ${src.symbol}, the DeFi wallet received ${out.received_bnb} BNB`); acted += 1; } } const lpKey = process.env.LP_PRIVATE_KEY; if (!lpKey) { console.error('No LP_PRIVATE_KEY in .env. Create the wallet first: node scripts/create-lp-wallet.mjs'); process.exitCode = 2; return; } const lp = acct(lpKey); const lpWallet = () => createWalletClient({ account: lp, chain: bsc, transport: transport() }); // The wallet as the worker sees it: through the ladder record, healed in // hand the way the worker heals it (ladderHeal) — this script writes no KV. let ladder = null; try { const rec = await fetch(RECORD_URL, { signal: AbortSignal.timeout(20000) }).then((r) => r.json()); const l = rec && rec.ladder && typeof rec.ladder === 'object' ? rec.ladder : null; ladder = l ? { main: l.main ?? null, reserve: l.reserve ?? null, since: l.since ?? null } : null; if (ladder) { const healed = await healLadder(pub, lp.address, ladder); if (healed) { console.log(`\nLADDER RECORD — ${healed.why} (read so here; the worker writes it on its next run)`); ladder.main = healed.main; if (healed.closed) ladder.reserve = null; if (healed.adopted) ladder.reserve = healed.reserve; } } if (ladder && ladder.reserve != null) { console.log(`\nLADDER RECORD — main range #${ladder.main}, reserve range #${ladder.reserve}: the two read as one position with a reserve attached`); } } catch (e) { console.log(`\nLADDER RECORD unreadable (${e.message}) — a wallet that holds a reserve range will read as two positions`); } if (STEPS.includes('collect')) { console.log(`\nCOLLECT — fees of the position held by ${lp.address} -> BNB -> kept as capital / $BOBAI held`); const plan = await planCollect(pub, lp.address, ladder); const s = plan.summary; if (plan.pos) { console.log(` position #${s.position} ticks ${s.ticks[0]} … ${s.ticks[1]} ${s.in_range ? 'in range' : 'OUT OF RANGE'} liquidity ${s.liquidity}`); console.log(` owed ${s.owed.wbnb} WBNB and ${s.owed.other} of ${s.owed.other_token}`); if (s.leftovers) console.log(` leftovers ${s.leftovers.wbnb} WBNB and ${s.leftovers.other} of the other token, from an interrupted run`); console.log(` worth ${f(s.owed.bnb_equivalent)} BNB together${s.quote_off_pct != null ? `, quote ${s.quote_off_pct}% off the pool's price${s.sells_via ? ` (sells via ${s.sells_via})` : ''}` : ''}`); } console.log(` wallet ${f(s.wallet_bnb ?? s.gas_bnb)} BNB (reserve kept: ${GAS_RESERVE_BNB})`); if (plan.no) console.log(` nothing to do: ${plan.no}`); else { console.log(` would collect, sell the other side, unwrap, keep ${KEEP}% of what this run produced as capital and buy $BOBAI with the rest, held in this wallet`); if (CONFIRM) { const out = await executeCollect(pub, lpWallet(), lp, plan, log, { keptPct: KEEP }); console.log(` produced ${out.produced_bnb || '0'} BNB: kept ${out.kept_bnb} BNB as capital, ${out.bobai_bnb} BNB bought ${out.bobai_units || '0'} BOBAI held in the wallet${out.why ? ` — ${out.why}` : ''}`); acted += 1; } } } if (STEPS.includes('rebalance')) { console.log('\nREBALANCE — a range the price has left is re-set around today\'s price'); // The window record as the cron built it, with the verdict computed by // the same function the worker uses — one record, one rule. let record = null, pool = null; try { const w = await fetch(WINDOWS_URL, { signal: AbortSignal.timeout(20000) }).then((r) => r.json()); record = w.verdict || null; pool = w.pool || null; if (record) console.log(` record: ${record.windows} windows, ${record.hours_of_prices} h of prices, earnings pick ${record.earnings_pick ? `±${record.earnings_pick.width}% ($${record.earnings_pick.earnings.net_usd_per_day}/day on $50)` : 'none yet'}, day-pick ${record.day_pick ? `±${record.day_pick.width}%` : 'none yet'}${w.last_error ? `, last cron error ${w.last_error.at.slice(0, 16)}: ${w.last_error.error}` : ''}`); } catch (e) { console.log(` record unreadable (${e.message}) — only a --width named by hand can re-set today`); } const plan = await planRebalance(pub, lp.address, { record, widthOverride: WIDTH, pool, keptPct: KEEP, ladder }); const s = plan.summary; if (plan.resume) console.log(` no position — the wallet holds ${s.held?.other} of the other side and ${s.held?.wbnb} WBNB (worth ${f(s.value_bnb)} BNB), tick now ${s.tick}: a re-set that stopped before its mint`); else if (plan.pos) console.log(` position #${s.position} ticks ${s.ticks[0]} … ${s.ticks[1]}, tick now ${s.tick}, ${s.in_range ? 'in range' : 'OUT OF RANGE'}, worth ${f(s.value_bnb)} BNB${s.reserve ? `; reserve #${s.reserve.position} ticks ${s.reserve.ticks[0]} … ${s.reserve.ticks[1]}, worth ${f(s.reserve.value_bnb)} BNB` : ''}`); if (plan.no) console.log(` nothing to do: ${plan.no}`); else { console.log(` width ±${s.width_pct}% (${s.width_basis}) -> new ticks ${s.new_ticks[0]} … ${s.new_ticks[1]}`); console.log(plan.resume ? ` would ${s.trade}, and mint the range from what the wallet then holds` : ` would withdraw and burn #${s.position}, ${s.trade}, and mint the new range from what the wallet then holds`); if (!plan.resume) console.log(` the old range owes ${f(s.fees_owed_bnb)} BNB of fees: ${s.fees_to_bobai_bnb > 0 ? `${f(s.fees_to_bobai_bnb)} BNB would buy BOBAI (held in the wallet) before the mint, the rest into the new capital` : 'all of it would be minted into the new capital'} (kept share ${s.fees_kept_pct ?? KEEP}%)`); if (CONFIRM) { const out = await executeRebalance(pub, lpWallet(), lp, plan, log, { keptPct: KEEP }); console.log(` new position #${out.new_position} at ${out.new_ticks[0]} … ${out.new_ticks[1]}, liquidity ${out.liquidity_after}`); if (ladder && ladder.reserve != null) { ladder.main = out.new_position; console.log(' the ladder record on KV still names the old main range; the worker heals it on its next run (ladderHeal)'); } if (out.fees_folded_bnb != null) console.log(` old range's fees ${f(out.fees_folded_bnb)} BNB: ${out.bobai_bnb > 0 ? `${f(out.bobai_bnb)} BNB bought ${out.bobai_units} BOBAI, held in ${out.bobai_held_in}` : out.fees_forward_why}`); acted += 1; } } } if (STEPS.includes('relocate')) { console.log('\nRELOCATE — the whole position -> another pool of the universe'); if (!TO) console.log(' name the pool with --to 0x… (the pool record at https://agent.brainonbnb.com/lp/pools says which one is worth it)'); const plan = await planRelocate(pub, lp.address, { toPool: TO, widthOverride: WIDTH, keptPct: KEEP }); const s = plan.summary; if (plan.pos) console.log(` position #${s.position} in ${s.from_pool}, ticks ${s.from_ticks[0]} … ${s.from_ticks[1]}, ${s.in_range ? 'in range' : 'OUT OF RANGE'}, worth ${f(s.value_bnb)} BNB`); if (plan.to) console.log(` to ${s.to_pool} (fee ${s.to_fee_pct}%), tick there ${s.to_tick}`); if (plan.no) console.log(` nothing to do: ${plan.no}`); else { console.log(` width ±${s.width_pct}% (${s.width_basis}) -> new ticks ${s.new_ticks[0]} … ${s.new_ticks[1]}`); console.log(` would withdraw and burn #${s.position}, ${(s.trades || ['trade nothing']).join(', ')}, and mint the range in the new pool from what the wallet then holds`); console.log(` the old range owes ${f(s.fees_owed_bnb)} BNB of fees: ${s.fees_to_bobai_bnb > 0 ? `${f(s.fees_to_bobai_bnb)} BNB would buy BOBAI (held in the wallet) before the mint, the rest into the new capital` : 'all of it would be minted into the new capital'} (kept share ${s.fees_kept_pct ?? KEEP}%)`); if (CONFIRM) { const out = await executeRelocate(pub, lpWallet(), lp, plan, log, { keptPct: KEEP }); console.log(` new position #${out.new_position} in ${out.new_pool} at ${out.new_ticks[0]} … ${out.new_ticks[1]}, liquidity ${out.liquidity_after}; gas ${f(out.gas_bnb)} BNB, swap fees ${f(out.swap_fee_bnb)} BNB`); console.log(` old range's fees ${f(out.fees_folded_bnb)} BNB: ${out.bobai_bnb > 0 ? `${f(out.bobai_bnb)} BNB bought ${out.bobai_units} BOBAI, held in ${out.bobai_held_in}` : out.fees_forward_why}`); acted += 1; } } } if (STEPS.includes('ladder')) { console.log('\nLADDER — BNB beside a main range that is all of the other side -> a reserve range below the price'); let record = null; try { record = (await fetch(WINDOWS_URL, { signal: AbortSignal.timeout(20000) }).then((r) => r.json())).verdict || null; } catch { record = null; } const plan = await planLadder(pub, lp.address, { record, ladder }); const s = plan.summary; if (s.position) console.log(` main #${s.position} ${s.main_ticks ? `ticks ${s.main_ticks[0]} … ${s.main_ticks[1]}` : ''} holds ${s.main_side === 'both' ? 'both sides (in range)' : s.main_side === 'wbnb' ? 'only WBNB' : 'only the other side'}${s.reserve ? `; reserve #${s.reserve.position} ticks ${s.reserve.ticks[0]} … ${s.reserve.ticks[1]}, ${f(s.reserve.value_bnb)} BNB${s.reserve.left ? ', left by the price' : ''}` : '; no reserve'}; ${f(s.spendable_bnb)} BNB waits`); console.log(plan.act ? ` the worker would: ${plan.act} — ${plan.why}${plan.no ? ` — ${plan.no}` : ''}` : ` nothing to do: ${plan.why}${plan.no ? ` — ${plan.no}` : ''}`); // Planned here, run by the worker alone: a reserve minted or re-set by // hand would be a position the KV record does not name, and the worker // would refuse every step until a person wrote the record. if (plan.act && CONFIRM) console.log(' not sent from here: the ladder is the worker\'s step, because it must write the ladder record with it'); } if (STEPS.includes('increase')) { console.log('\nINCREASE — BNB above the reserve -> the same position'); const plan = await planIncrease(pub, lp.address, null, ladder); const s = plan.summary; console.log(` wallet holds ${f(s.wallet_bnb)} BNB, ${f(s.spendable_bnb)} above the reserve and gas budget${s.tick != null ? `, tick ${s.tick} ${s.in_range ? 'in range' : 'OUT OF RANGE'}` : ''}`); if (plan.no) console.log(` nothing to do: ${plan.no}`); else { if (s.capital) console.log(` capital beside the position: ${f(s.capital.bnb_above_reserve)} BNB above the reserve, ${f(s.capital.wbnb_held)} WBNB held, ${s.capital.other_held} of the other side held (~${f(s.capital.other_held_in_bnb)} BNB)`); console.log(` would ${plan.nativeRaw > 0n ? `wrap ${f(Number(plan.nativeRaw) / 1e18)} BNB, ` : ''}${s.would_add.buying_other ? `buy ${s.would_add.buying_other} of ${s.would_add.other_token} for ~${s.would_add.buying_other_costs_bnb} BNB` : s.would_add.selling_other ? `sell ${s.would_add.selling_other} of ${s.would_add.other_token} for WBNB` : 'trade nothing'}, and add ${s.would_add.wbnb} WBNB + ${s.would_add.other} of the other side to #${plan.tokenId}`); if (CONFIRM) { const out = await executeIncrease(pub, lpWallet(), lp, plan, log); console.log(` added ${out.other_used} of the other token and ${out.wbnb_used} WBNB; liquidity now ${out.liquidity_after}`); acted += 1; } } } if (!CONFIRM) console.log('\nPLAN ONLY — nothing was sent. Add --confirm to send it.'); else console.log(`\nDone: ${acted} step(s) acted.`); } // NO process.exit HERE. Calling it immediately after a viem HTTP read trips a // libuv assertion on Windows and the process ends with code 127 after a run // that did what it was asked. Every path returns, failures set // process.exitCode, and the process closes its own sockets. if (!SELF && process.exitCode !== 2) await main().catch((e) => { console.error(`\nFailed: ${e.shortMessage || e.message}`); process.exitCode = 1; }); ============================================================================== === FILE: scripts/lp-fork-test.mjs ============================================================================== #!/usr/bin/env node // The DeFi agent's money paths, RUN — on a local copy of BNB Chain. // // The self-test (lp-agent.mjs --self-test) pins the rules and drives the plan // functions over a chain that answers from a table. What it cannot do is send: // whether the position manager takes a one-sided mint at these minimums, // whether the router fills a "sell all of it", whether a merge and a re-set go // through in one breath — that was only ever known after the agent had done // it with the operator's capital. Several of those paths have never run // (2026-09-18: the re-set upward, the merge, growing a reserve the price is // in, finishing a re-set whose mint failed beside the reserve, selling the // profit share of a re-set downward out of the fee token). // // This script forks the chain with anvil at the current block, takes the DeFi // wallet's place by impersonation — NO KEY is read, nothing can reach the real // chain: every transaction goes to 127.0.0.1 — moves the pool's price with a // made-up whale, and runs the SAME execute functions the worker runs // (shared/lp-agent.js) against PancakeSwap's real contracts and the agent's // real positions. After each path it reads the chain and checks what must hold. // // Needs: anvil (foundry; ~/.foundry/bin/anvil.exe or ANVIL=…), and an RPC that // serves historical state in BSC_RPC_KEYED_URL_2 (.env) — a fork reads state // at its block for minutes, and the public endpoints prune it within seconds. // // node scripts/lp-fork-test.mjs every path // node scripts/lp-fork-test.mjs --only up one of: up, down, reserve, resume, collect import 'dotenv/config'; import { spawn } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; import { createPublicClient, createWalletClient, createTestClient, http, parseAbi, parseEther, formatEther, encodeFunctionData } from 'viem'; import { bsc } from 'viem/chains'; import { ADDR, ABI, readPosition, readPool, heldIds, healLadder, positionSide, unwindCalls, planRebalance, executeRebalance, planLadder, executeLadder, planIncrease, executeIncrease, planCollect, executeCollect, } from '../shared/lp-agent.js'; import { RESERVE_COLLECT_MIN_BNB } from '../shared/lp-guards.js'; const PORT = 8546, LOCAL = `http://127.0.0.1:${PORT}`; const FORK_URL = process.env.BSC_RPC_KEYED_URL_2 || process.env.BSC_RPC_KEYED_URL_1 || process.env.BSC_ARCHIVE_RPC_URL; const ANVIL = process.env.ANVIL || path.join(os.homedir(), '.foundry', 'bin', process.platform === 'win32' ? 'anvil.exe' : 'anvil'); const only = (() => { const i = process.argv.indexOf('--only'); return i >= 0 ? process.argv[i + 1] : null; })(); const LP = ADDR.LP_WALLET, CAKE = '0x0e09fabb73bd3ade0a17ecc321fd13a19e81ce82', WHALE = '0x00000000000000000000000000000000000f00d1'; const ERC20 = parseAbi(['function balanceOf(address) view returns (uint256)', 'function transfer(address,uint256) returns (bool)', 'function approve(address,uint256) returns (bool)', 'function deposit() payable']); const NPM = parseAbi(['function multicall(bytes[] data) payable returns (bytes[] results)', 'function decreaseLiquidity((uint256 tokenId,uint128 liquidity,uint256 amount0Min,uint256 amount1Min,uint256 deadline)) payable returns (uint256,uint256)']); if (!FORK_URL) { console.error('No archive RPC in .env (BSC_RPC_KEYED_URL_2). A fork needs state at its block for minutes; the public endpoints prune it within seconds.'); process.exit(2); } if (!fs.existsSync(ANVIL)) { console.error(`anvil not found at ${ANVIL} — install foundry, or set ANVIL=…`); process.exit(2); } let failed = 0, n = 0; const ok = (label, pass, detail = '') => { n++; if (!pass) failed++; console.log(` ${pass ? 'ok ' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Half of Cloudflare's 1000 subrequests per invocation: the fork answers a receipt at once, the chain after a poll or two per transaction. const RPC_BUDGET = 500; const withinBudget = (what, count) => ok(`${what} stays inside half the worker's subrequest budget`, count > 0 && count < RPC_BUDGET, `${count} requests of ${RPC_BUDGET}`); const anvil = spawn(ANVIL, ['--fork-url', FORK_URL, '--port', String(PORT), '--chain-id', '56', '--auto-impersonate', '--silent', '--no-rate-limit', '--gas-price', '100000000', '--block-base-fee-per-gas', '0'], { stdio: 'ignore' }); const stop = () => { try { anvil.kill(); } catch { /* gone */ } }; process.on('exit', stop); process.on('SIGINT', () => { stop(); process.exit(130); }); const chain = { ...bsc, rpcUrls: { default: { http: [LOCAL] } } }; // Every request the AGENT's code makes is counted (its reads through `pub`, its sends through the LP wallet): a // Cloudflare invocation may make 1000 subrequests, and a path that grows past that would stop half way, on chain. let rpcCount = 0; const counting = () => http(LOCAL, { timeout: 120000, onFetchRequest: () => { rpcCount += 1; } }); const pub = createPublicClient({ chain, transport: counting() }); const test = createTestClient({ chain, mode: 'anvil', transport: http(LOCAL, { timeout: 120000 }) }); const walletOf = (address) => createWalletClient({ account: address, chain, transport: http(LOCAL, { timeout: 120000 }) }); // The agent's wallet, with one difference from production that belongs to the // fork and not to the agent: every transaction is sent with a fixed gas limit // instead of anvil's own estimate. const lpWallet = (() => { const w = createWalletClient({ account: LP, chain, transport: counting() }); if (process.env.FORK_GAS === '0') return w; // FORK_GAS=0: anvil's own estimate, which is too tight for the manager's multicall (burn refunds) — measured 2026-09-18, the same call runs on the real chain const write = w.writeContract.bind(w); // 700k covers the largest call (a mint across ticks). The fork answers // eth_gasPrice with 1 gwei where the chain clears at 0.05, so a limit set // generously would not fit inside the agent's 0.003 BNB gas reserve after a // few transactions — on the real chain the limit is the node's estimate. w.writeContract = (req) => write({ ...req, gas: 700000n }); return w; })(); const bal = (token, who) => pub.readContract({ address: token, abi: ERC20, functionName: 'balanceOf', args: [who] }); const sendAs = async (from, req) => { const h = await walletOf(from).writeContract({ ...req, account: from, chain, gas: 25000000n /* the whale crosses hundreds of ticks in one swap */ }); const r = await pub.waitForTransactionReceipt({ hash: h }); if (r.status !== 'success') throw new Error('setup transaction reverted'); return r; }; for (let i = 0; i < 60; i++) { try { await pub.getBlockNumber(); break; } catch { await sleep(500); } } const forkBlock = await pub.getBlockNumber(); console.log(`forked BNB Chain at block ${forkBlock} — every transaction below goes to ${LOCAL} and nowhere else\n`); // The agent's own record and width verdict, as the worker reads them. const rec = await fetch('https://agent.brainonbnb.com/lp/agent?format=json').then((r) => r.json()); const win = await fetch('https://agent.brainonbnb.com/lp/windows?format=json').then((r) => r.json()); const record = win.verdict, POOL = String(win.pool || rec.pool).toLowerCase(); const LADDER0 = { main: String(rec.ladder.main), reserve: rec.ladder.reserve == null ? null : String(rec.ladder.reserve) }; console.log(`ladder on record: main #${LADDER0.main}, reserve #${LADDER0.reserve}; pool ${POOL}; pick ±${record?.earnings_pick?.width}%\n`); // A whale with all the BNB and CAKE it needs, and an approval for the router. await test.setBalance({ address: WHALE, value: parseEther('2000000') }); await test.setBalance({ address: LP, value: (await pub.getBalance({ address: LP })) + parseEther('0.05') }); // gas for the paths, as the tax flow refills it await sendAs(WHALE, { address: ADDR.WBNB, abi: ERC20, functionName: 'deposit', value: parseEther('1000000') }); let cakeRich = null; for (const h of ['0x45c54210128a065de780c4b0df3d16664f7f859e', '0x5692db8177a81a6c6afc8084c2976c9933ec1bab', '0xa5f8c5dbd5f286960b9d90548680ae5ebff07652', '0x0ed7e52944161450477ee417de9cd3a859b14fd0']) { const b = await bal(CAKE, h); if (!cakeRich || b > cakeRich.b) cakeRich = { h, b }; } await test.setBalance({ address: cakeRich.h, value: parseEther('1') }); await sendAs(cakeRich.h, { address: CAKE, abi: ERC20, functionName: 'transfer', args: [WHALE, cakeRich.b / 2n] }); for (const t of [ADDR.WBNB, CAKE]) await sendAs(WHALE, { address: t, abi: ERC20, functionName: 'approve', args: [ADDR.V3_SWAP_ROUTER, (1n << 256n) - 1n] }); const sqrtX96AtTick = (t) => BigInt(Math.floor(Math.pow(1.0001, t / 2) * 2 ** 96)); const pos0 = await readPosition(pub, LP, LADDER0); const FEE = Number(pos0.pos[4]); // The whale trades until the pool's price stands at `tick`: one swap with a // price limit, so it stops exactly there whatever the depth is. async function pushPriceTo(tick) { const now = (await readPool(pub, pos0.pos)).tick; const up = tick > now; // CAKE is token0, WBNB token1: buying CAKE with WBNB raises the tick const [tokenIn, tokenOut] = up ? [ADDR.WBNB, CAKE] : [CAKE, ADDR.WBNB]; const amountIn = await bal(tokenIn, WHALE); await sendAs(WHALE, { address: ADDR.V3_SWAP_ROUTER, abi: ABI.V3_ROUTER, functionName: 'exactInputSingle', args: [{ tokenIn, tokenOut, fee: FEE, recipient: WHALE, deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), amountIn, amountOutMinimum: 0n, sqrtPriceLimitX96: sqrtX96AtTick(tick) }] }); return (await readPool(pub, pos0.pos)).tick; } const valueOf = async (ladder) => { const p = await readPosition(pub, LP, ladder); const info = await readPool(pub, p.pos || pos0.pos); const price = info.sqrtP ** 2; const side = (x) => (x ? positionSide(x.pos, info.sqrtP, false).valueBnb : 0); const loose = (Number(await bal(ADDR.WBNB, LP)) + Number(await bal(CAKE, LP)) * price) / 1e18; return { main: p.pos ? side(p) : 0, reserve: side(p.reserve), loose, native: Number(formatEther(await pub.getBalance({ address: LP }))), tick: info.tick, p }; }; const total = (v) => v.main + v.reserve + v.loose + v.native; const BOBAI = ADDR.BOBAI; // What the worker's rebalance step does, in its order: the merge first when the // ladder plan says so, then the re-set — with the ladder record kept in hand. async function workerRebalance(ladder, { expectMerge = null } = {}) { const rpc0 = rpcCount; const plan = await planRebalance(pub, LP, { record, pool: POOL, ladder }); if (plan.no) return { plan, refused: plan.no }; const txs = []; let merged = null; if (plan.summary.reserve) { const lp2 = await planLadder(pub, LP, { record, ladder }); if (lp2.act === 'merge') { merged = await executeLadder(pub, lpWallet, lpWallet.account, lp2, () => {}, { txs }); ladder.reserve = null; } if (expectMerge != null) ok(`the ladder plan ${expectMerge ? 'merges the reserve first' : 'leaves the reserve where it is'}`, (lp2.act === 'merge') === expectMerge, `act ${lp2.act}: ${lp2.why}`); } const done = await executeRebalance(pub, lpWallet, lpWallet.account, plan, () => {}, { txs }); if (done.new_position) ladder.main = String(done.new_position); return { plan, done, merged, txs, rpc: rpcCount - rpc0 }; } async function scenario(name, key, fn) { if (only && only !== key) return; const snap = await test.snapshot(); console.log(`\n${name}`); try { await fn(); } catch (e) { ok('the path ran to its end', false, String(e.shortMessage || e.message || e).slice(0, 300)); } await test.revert({ id: snap }); } // --------------------------------------------------------------------------- await scenario('RE-SET UPWARD — the price leaves above the main range: the reserve merges, one range below the price, all WBNB, no trade of the capital', 'up', async () => { const ladder = { ...LADDER0 }; const hi = Number(pos0.pos[6]); const tick = await pushPriceTo(hi + 150); const before = await valueOf(ladder); ok('the whale moved the price above the range', tick >= hi + 100, `tick ${tick}, range top ${hi}`); const bobai0 = await bal(BOBAI, LP); const r = await workerRebalance(ladder, { expectMerge: LADDER0.reserve != null }); if (r.refused) return ok('the plan re-sets', false, r.refused); withinBudget('plan, merge and re-set upward', r.rpc); ok('the plan is one-sided, below the price', r.plan.oneSided === 'above' && r.done.one_sided === 'below_price', `ticks ${r.done.new_ticks}`); const ids = await heldIds(pub, LP); ok('one position is left, and it is the new one — read off the mint receipt', ids.length === 1 && ids[0] === String(r.done.new_position), `held ${ids.join(', ')}`); const after = await valueOf(ladder); ok('the new range sits beside the price as it was AT THE MINT (20-29 ticks under it)', after.tick - r.done.new_ticks[1] >= 20 && after.tick - r.done.new_ticks[1] < 30, `tick ${after.tick}, range top ${r.done.new_ticks[1]}`); ok('the capital went back in whole: nothing of it is loose in the wallet', after.loose < 0.0005 * total(after), `${after.loose.toFixed(6)} BNB loose of ${total(after).toFixed(4)}`); ok('the fees the old range paid in CAKE were sold in full — the sale that asked for more than the wallet held before 2026-09-18', (await bal(CAKE, LP)) < 10n ** 15n, `CAKE left ${formatEther(await bal(CAKE, LP))}; swap ${r.done.swap ? JSON.stringify(r.done.swap).slice(0, 120) : 'none'}`); ok('the value is the same before and after, less gas and the fee share', Math.abs(total(after) + (r.done.bobai_bnb || 0) - total(before)) < 0.002 * total(before), `${total(before).toFixed(5)} → ${total(after).toFixed(5)} BNB (+ ${r.done.bobai_bnb || 0} into $BOBAI)`); ok('the profit share bought $BOBAI, held in the wallet', !(r.done.bobai_bnb > 0) || (await bal(BOBAI, LP)) > bobai0, `fees folded ${r.done.fees_folded_bnb}, into $BOBAI ${r.done.bobai_bnb}${r.done.fees_forward_why ? ' — ' + r.done.fees_forward_why : ''}`); console.log(` ${r.txs.length} transactions: ${r.txs.map((t) => t.label.split(' ').slice(0, 3).join(' ')).join(' · ')}`); }); await scenario('RE-SET DOWNWARD — the price falls out of the main range into the reserve: one range above the price, all CAKE, and the profit share sold out of the CAKE fees', 'down', async () => { const ladder = { ...LADDER0 }; const lo = Number(pos0.pos[5]); const tick = await pushPriceTo(lo - 150); const before = await valueOf(ladder); ok('the whale moved the price below the range', tick <= lo - 100, `tick ${tick}, range bottom ${lo}`); const bobai0 = await bal(BOBAI, LP); const r = await workerRebalance(ladder, { expectMerge: false }); if (r.refused) return ok('the plan re-sets', false, r.refused); withinBudget('plan and re-set downward with the share sale', r.rpc); ok('the plan is one-sided, above the price', r.done.one_sided === 'above_price', `ticks ${r.done.new_ticks}`); const ids = await heldIds(pub, LP); ok('two positions are held: the new main range and the reserve that stood', ids.length === (LADDER0.reserve ? 2 : 1) && ids.includes(String(r.done.new_position)) && (!LADDER0.reserve || ids.includes(LADDER0.reserve)), `held ${ids.join(', ')}`); const after = await valueOf(ladder); ok('the new range sits 20-29 ticks above the price at the mint', r.done.new_ticks[0] - after.tick >= 20 && r.done.new_ticks[0] - after.tick < 30, `tick ${after.tick}, range bottom ${r.done.new_ticks[0]}`); ok('nothing of the capital is loose', after.loose < 0.0005 * total(after), `${after.loose.toFixed(6)} BNB loose`); ok('the profit share was bought although the fees came in CAKE (before 2026-09-18: "stays as capital")', !(r.done.fees_folded_bnb > 0.0002) || (r.done.bobai_bnb > 0 && (await bal(BOBAI, LP)) > bobai0), `fees folded ${r.done.fees_folded_bnb}, into $BOBAI ${r.done.bobai_bnb}, share sale ${r.done.share_swap ? 'yes' : 'no'}${r.done.fees_forward_why ? ' — ' + r.done.fees_forward_why : ''}`); ok('the value is the same before and after, less gas and the fee share', Math.abs(total(after) + (r.done.bobai_bnb || 0) - total(before)) < 0.002 * total(before), `${total(before).toFixed(5)} → ${total(after).toFixed(5)} BNB`); // … and BNB that arrives now joins the reserve the price is IN. if (LADDER0.reserve) { await test.setBalance({ address: LP, value: (await pub.getBalance({ address: LP })) + parseEther('0.06') }); const lp3 = await planLadder(pub, LP, { record, ladder }); ok('BNB arriving beside an all-CAKE main range grows the reserve', lp3.act === 'increase_reserve' && !lp3.no, `act ${lp3.act}: ${lp3.no || lp3.why}`); if (lp3.act === 'increase_reserve' && !lp3.no) { const liq0 = (await readPosition(pub, LP, ladder)).reserve.pos[7]; const d = await executeLadder(pub, lpWallet, lpWallet.account, lp3, () => {}, { txs: [] }); const liq1 = (await readPosition(pub, LP, ladder)).reserve.pos[7]; ok('… through the ordinary increase, buying the side a range the price is in needs (a WBNB-only add reverted there)', liq1 > liq0 && Number(d.bnb_spent) > 0.05, `reserve side ${d.reserve_side}, liquidity ${liq0} → ${liq1}, spent ${d.bnb_spent} BNB, swap ${d.swap ? 'yes' : 'no'}`); } } }); await scenario('A RE-SET WHOSE MINT FAILED BESIDE THE RESERVE — the main range is burnt, its CAKE lies loose: finished from the wallet, not sold into the reserve', 'resume', async () => { if (!LADDER0.reserve) return ok('needs a standing reserve', true, 'skipped: no reserve on record'); const ladder = { ...LADDER0 }; const lo = Number(pos0.pos[5]); await pushPriceTo(lo - 150); // The first half of a re-set, by hand: withdraw, collect, burn — and then nothing. const p = await readPosition(pub, LP, ladder); await sendAs(LP, { address: ADDR.V3_POSITION_MANAGER, abi: NPM, functionName: 'multicall', args: [unwindCalls(p.tokenId, p.pos[7], 0n, 0n, LP, BigInt(Math.floor(Date.now() / 1000) + 3600))] }); const before = await valueOf(ladder); const seen = await readPosition(pub, LP, ladder); ok('the wallet reads as "no main range, the reserve rides along"', seen.positions === 0 && String(seen.reserve?.tokenId) === LADDER0.reserve && seen.main_missing === LADDER0.main); ok('the ladder is NOT closed while the capital lies loose', (await healLadder(pub, LP, { ...ladder })) === null, `${before.loose.toFixed(4)} BNB loose`); const inc = await planIncrease(pub, LP, null, ladder); ok('the increase step does not take the loose CAKE for a deposit', !!inc.no, inc.no); const r = await workerRebalance(ladder, { expectMerge: false }); if (r.refused) return ok('the resume mints', false, r.refused); withinBudget('plan and resume', r.rpc); ok('the resume is one-sided on the token held, and trades nothing of the capital', r.plan.resume === true && r.done.one_sided === 'above_price' && !(r.done.swap && Number(r.done.swap.notional_bnb || 0) > 0.01 * before.loose), `swap ${r.done.swap ? JSON.stringify(r.done.swap).slice(0, 100) : 'none'}`); const ids = await heldIds(pub, LP); ok('the main range stands again beside the reserve, its id off the receipt', ids.length === 2 && ids.includes(LADDER0.reserve) && ids.includes(String(r.done.new_position)), `held ${ids.join(', ')}`); const after = await valueOf(ladder); ok('nothing is loose, and the value is what it was', after.loose < 0.0005 * total(after) && Math.abs(total(after) - total(before)) < 0.002 * total(before), `${total(before).toFixed(5)} → ${total(after).toFixed(5)} BNB, loose ${after.loose.toFixed(6)}`); }); await scenario('THE COLLECT TAKES THE RESERVE RANGE\'S FEES TOO — the price trades through both ranges, both earn; one run collects both, sells, splits, buys $BOBAI', 'collect', async () => { if (!LADDER0.reserve) return ok('needs a standing reserve', true, 'skipped: no reserve on record'); const ladder = { ...LADDER0 }; // As the chain stands: the reserve is asked only when its fees are worth a transaction of their own. const asIs = await planCollect(pub, LP, ladder); const ro = asIs.summary.reserve_owed; ok('as the chain stands, the reserve is taken along exactly when it is owed the floor or more', !!ro && ro.collected_with_it === (ro.bnb_equivalent >= RESERVE_COLLECT_MIN_BNB) && (asIs.reserveTokenId != null) === ro.collected_with_it, ro ? `owed ${ro.bnb_equivalent} BNB against ${RESERVE_COLLECT_MIN_BNB}${ro.why ? ' — ' + ro.why : ''}` : 'no reserve_owed in the plan'); // Volume through both ranges: down to the bottom of the reserve and back to where the price stood. const start = (await readPool(pub, pos0.pos)).tick; const resLo = Number((await readPosition(pub, LP, ladder)).reserve.pos[5]); // A range earns 0.05% of what trades through it: a reserve of a few hundredths of a BNB needs the price across it several times. const mainHi = Number(pos0.pos[6]); let trips = 0; for (; trips < 12; trips++) { await pushPriceTo(resLo + 60); await pushPriceTo(mainHi - 60); const look = await planCollect(pub, LP, ladder); // With room above the floor: the fees are mostly CAKE, and the way back down to `start` makes them worth less in BNB. if (!look.no && look.reserveTokenId != null && look.summary.reserve_owed.bnb_equivalent >= RESERVE_COLLECT_MIN_BNB * 1.3) break; } await pushPriceTo(start); console.log(` the whale took the price across both ranges ${trips + 1} times`); const plan = await planCollect(pub, LP, ladder); const r2 = plan.summary.reserve_owed; ok('after the volume both ranges are owed fees, and the plan takes the reserve along', !plan.no && plan.reserveTokenId != null && String(plan.reserveTokenId) === LADDER0.reserve && r2.collected_with_it === true && r2.bnb_equivalent >= RESERVE_COLLECT_MIN_BNB && plan.summary.owed.bnb_equivalent > r2.bnb_equivalent, plan.no || `owed ${plan.summary.owed.bnb_equivalent} BNB in all, ${r2.bnb_equivalent} of it the reserve's`); if (plan.no || plan.reserveTokenId == null) return; const liq0 = (await readPosition(pub, LP, ladder)).reserve.pos[7], bobai0 = await bal(BOBAI, LP); const txs = []; const rpcBefore = rpcCount; const done = await executeCollect(pub, lpWallet, lpWallet.account, plan, () => {}, { txs }); withinBudget('the collect of both ranges with sale and $BOBAI buy', rpcCount - rpcBefore); const after = await readPosition(pub, LP, ladder); ok('one run: collect, collect the reserve, sell, unwrap, buy $BOBAI', txs.some((t) => t.label === 'collect') && txs.some((t) => /reserve range's fees/.test(t.label)) && txs.some((t) => /sell the other side/.test(t.label)) && txs.some((t) => /buy BOBAI/.test(t.label)), txs.map((t) => t.label.split(' ').slice(0, 3).join(' ')).join(' · ')); const dust = plan.owedOther / 1000n; // the run's own sale trades through both ranges and pays them their share of its 0.05% fee: a speck, never a thousandth of what was sold ok('both ranges are owed nothing but the speck the run\'s own sale paid them, and the reserve\'s liquidity is untouched', after.owed0 < dust && after.owed1 < dust && after.reserve.owed0 < dust && after.reserve.owed1 < dust && after.reserve.pos[7] === liq0, `reserve owed ${after.reserve.owed0}/${after.reserve.owed1}`); const produced = Number(done.produced_bnb || 0), planned = plan.summary.owed.bnb_equivalent, gas = txs.filter((t) => !/buy BOBAI/.test(t.label)).reduce((g, t) => g + (t.gas_bnb || 0), 0); // the fork charges 1 gwei, the chain 0.05; the $BOBAI buy comes after the run has counted what it produced ok('what the run produced is what both were owed, less its gas — the reserve\'s part is in the split', Math.abs(produced + gas - planned) < planned * 0.01 && produced + gas > (planned - r2.bnb_equivalent) * 1.02, `produced ${produced.toFixed(6)} + gas ${gas.toFixed(6)} of ${planned.toFixed(6)} BNB owed (main alone ${(planned - r2.bnb_equivalent).toFixed(6)})`); ok('half of it bought $BOBAI, held in the wallet', Number(done.bobai_bnb) > 0 && Math.abs(Number(done.bobai_bnb) - produced / 2) < produced * 0.01 && (await bal(BOBAI, LP)) > bobai0, `${done.bobai_bnb} BNB into $BOBAI, kept ${done.kept_bnb}`); ok('no CAKE and no WBNB of it is left loose', (await bal(CAKE, LP)) <= plan.heldOther + 10n ** 12n && (await bal(ADDR.WBNB, LP)) === 0n); }); console.log(`\n${n - failed}/${n} checks pass on the fork of block ${forkBlock}`); stop(); process.exit(failed ? 1 : 0); ============================================================================== === FILE: scripts/lp-portfolio.mjs ============================================================================== #!/usr/bin/env node // THE PORTFOLIO, from the keyboard. // // node scripts/lp-portfolio.mjs the live model, as the page and the bot see it // node scripts/lp-portfolio.mjs --self-test pin the model's rules on synthetic records, both ways // // It reads. It signs nothing, holds no key and moves nothing. import { lpPortfolio, lastDay, stepWords, holdingBenchmark, changeText } from '../worker-agent/lp-portfolio.js'; import fs from 'node:fs'; import { CANDIDATES } from '../worker-agent/lp-pools.js'; if (process.argv.includes('--self-test')) { let n = 0, bad = 0; const is = (what, cond) => { n++; if (!cond) bad++; console.log(`${cond ? 'ok ' : 'FAIL'} ${what}`); }; const NOW = Date.UTC(2026, 8, 10, 12); const at = (h) => new Date(NOW - h * 3600e3).toISOString(); const rec = { pool: CANDIDATES[0].pool, last: { at: at(8), ok: true, acted: true, steps: { sweep: [{ acted: false }], collect: { acted: false }, increase: { acted: true, position: '7397034', in_range: true, wallet_bnb: 0.003, bnb_spent: 0.08 } } }, last_check: { at: at(1), ok: true, acted: false, steps: { increase: { acted: false, position: '7397034', in_range: false, wallet_bnb: 0.3056 } } }, history: [ { at: at(30), acted: true, steps: { rebalance: { acted: true, new_position: '7390000' } } }, { at: at(8), acted: true, steps: { increase: { acted: true, bnb_spent: 0.08 } } }, { at: at(3), acted: true, steps: { rebalance: { acted: true, new_position: '7397034', bobai_bnb: 0.0003, width_pct: 1 } } }, ], }; const series = { summary: { since: '2026-09-03T05:23:19.275Z', runs_with_a_position: 21, days_in_range: 17, value_bnb: { start: 0.072, now: 0.2925, added_by_hand_bnb: 0.0794, change_pct: 1.31, capital_total_bnb: 0.2872 }, deposits_put_in_bnb: 0.1358, income_put_in_bnb: 0, fees_owed_now_bnb: 0.00087, bobai_held_units: 3895.4, fees_into_bobai_bnb: 0.001093, fees_kept_as_capital_bnb: 0.002866, profit: { bnb: 0.003762, usd: 2.72, from_price_bnb: -0.000422, from_fees_bnb: 0.004829, fees_collected_bnb: 0, fees_folded_bnb: 0.002866, fees_forwarded_at_resets_bnb: 0.001093, fees_owed_bnb: 0.00087, gas_bnb: 0.000645, bnb_usd: 722 }, }, points: [{ at: at(8), position: '7397034', in_range: true, wallet_bnb: 0.003 }], }; const m = lpPortfolio(rec, series, { now: NOW, bobaiUsd: 0.0002 }); is('a model comes out of three records', !!m && m.date === at(8).slice(0, 10)); is('put in is the series capital total, with its sources', m.put_in.bnb === 0.2872 && m.put_in.sources.map((s) => s.label).join(',') === 'start,by hand,deposited'); is('dollars at the series BNB price', m.put_in.usd === Math.round(0.2872 * 722 * 100) / 100 && m.worth.usd === Math.round(0.2925 * 722 * 100) / 100); is('the range is the newest check, not the series point', m.pool.in_range === false && m.holdings.wallet_bnb === 0.3056); is('the pool is the record\'s own, labelled from the pool list', m.pool.address === CANDIDATES[0].pool && m.pool.label === 'CAKE/BNB 0.05%' && m.pool.position === '7397034'); is('P&L carries the split and the fee parts', m.pnl.profit_bnb === 0.00376 && m.pnl.from_price_bnb === -0.00042 && m.pnl.fee_parts.length === 3 && m.pnl.gas_bnb === 0.00065); is('the other token is read off the pool label', m.pnl.other_token === 'CAKE'); is('the held $BOBAI is priced when the route has a price, and not otherwise', m.holdings.bobai_usd === 0.78 && lpPortfolio(rec, series, { now: NOW }).holdings.bobai_usd === null); is('the two halves of the profit are named: kept working, into $BOBAI', m.pnl.kept_working_bnb === 0.00287 && m.pnl.into_bobai_bnb === 0.00109 && m.pnl.bobai_units === 3895); is('the model carries no pool record: the agent stays in CAKE/BNB (2026-09-11)', !('pool_record' in m) && !m.links.pools); // The one sentence about what comes next, both ways: in range, out of range, with and without a width the record names. const w2 = { width_pct: 2, wait_hours: 3, net_usd_per_day: 0.16 }; is('out of range with a width named: the sentence says when the re-set comes, how wide, and that it trades nothing', /^Out of range for 2\.0 h\. Re-set after 3 h out of range: one-sided beside the price, ±2% wide, no trade\.$/.test(lpPortfolio(rec, series, { now: NOW, width: w2, outsideSince: new Date(NOW - 2 * 36e5).toISOString() }).next)); is('out of range with no width named yet: the re-set waits for the record', /^Out of range\. Re-set after the wait; the width record has no day of prices yet\.$/.test(m.next) && m.pool.width_pct === 1); is('at the edge of the range: not left, says so', (() => { const r3 = { ...rec, last_check: { ...rec.last_check, steps: { increase: { ...rec.last_check.steps.increase, at_edge: true } } } }; return /^At the edge of its range, not left\./.test(lpPortfolio(r3, series, { now: NOW }).next); })()); is('in range with a width named: holds and earns, re-set only after the wait', (() => { const r2 = { ...rec, last_check: { ...rec.last_check, steps: { increase: { ...rec.last_check.steps.increase, in_range: true } } } }; return /^Holds and earns\. A re-set only after 3 h out of range: one-sided beside the price, ±2% wide, no trade\.$/.test(lpPortfolio(r2, series, { now: NOW, width: w2 }).next); })()); is('the sentence carries no dollar figure (2026-09-12: the card stays simple; what a width nets lives in /lp/windows)', !/\$/.test(lpPortfolio(rec, series, { now: NOW, width: w2, outsideSince: new Date(NOW - 2 * 36e5).toISOString() }).next)); // One wording of the return for every surface (2026-09-19): base AND period, made in the model. is('the return is said once, with its base and its period', changeText(-1.21, '2026-09-03') === '−1.2% on the capital since 3 Sep' && changeText(1.31, '2026-09-03T05:23:19.275Z') === '+1.3% on the capital since 3 Sep' && changeText(0, '2026-12-24') === '0.0% on the capital since 24 Dec'); is('… without a start date it still names its base, and a figure that is none reads as zero', changeText(2, null) === '+2.0% on the capital' && changeText(undefined, '') === '0.0% on the capital'); is('… the model carries it beside the figure it is made of', m.pnl.change_text === changeText(m.pnl.change_pct, m.pnl.since) && /on the capital since 3 Sep$/.test(m.pnl.change_text)); { const tg = fs.readFileSync(new URL('../worker-tg-bot/index.js', import.meta.url), 'utf8'), page = fs.readFileSync(new URL('../dashboard/app.js', import.meta.url), 'utf8'); is('the Telegram card and the page both print the model\'s string, and neither has a wording of its own left beside it', /p\.change_text \|\| `\$\{pct\} on the capital since/.test(tg) && /p\.change_text \? esc\(p\.change_text\) : pct \+ ' on the capital'/.test(page) && !/`\$\{pct\} since \$\{day\(p\.since\)\}`/.test(tg)); } is('the P&L names what the re-sets themselves cost: both re-sets counted (one definition, 2026-09-19), none of them with a range on record to value', m.pnl.at_resets && m.pnl.at_resets.count === 2 && m.pnl.at_resets.valued === 0 && m.pnl.at_resets.lost_to_price_bnb === 0); is('the day is counted: re-sets, top-ups and the newest action', m.day.resets === 1 && m.day.top_ups === 1 && m.day.errors === 0 && m.day.last.step === undefined && /re-set/.test(m.day.last.what)); is('the last day lists the runs of the last 24 h only, newest first', m.last_24h.length === 2 && m.last_24h[0].step === 'rebalance' && m.last_24h[1].step === 'increase' && /7397034/.test(m.last_24h[0].what)); is('a re-set that bought BOBAI says so', /into \$BOBAI/.test(m.last_24h[0].what)); is('a quiet day has an empty list', lastDay({ history: [{ at: at(40), acted: true, steps: { increase: { acted: true } } }] }, NOW).length === 0); is('a failed step is listed as an error', stepWords('collect', { acted: true, error: 'boom' }).error === true); is('a step that did not act is not listed', stepWords('increase', { acted: false }) === null); is('a relocate names the pool it moved to', /USDT\/BNB 0\.01%/.test(stepWords('relocate', { acted: true, new_pool: CANDIDATES[2].pool, new_position: '9' }).what)); is('a width upgrade names both widths', /±2% → ±1%/.test(stepWords('rebalance', { acted: true, upgraded_from_pct: 2, upgraded_to_pct: 1 }).what)); is('a one-sided re-set says which side and that it traded nothing', /one-sided above the price, no trade/.test(stepWords('rebalance', { acted: true, one_sided: 'above_price', new_position: '7' }).what)); // AGAINST HOLDING: two arrivals, a price that rose 10% since the first. // 1 BNB arrived at price 1 (tick 0), 1 BNB at price 1.1 (tick ln(1.1)/ln(1.0001)); now the price is 1.1. const tk = (px) => Math.round(Math.log(px) / Math.log(1.0001)); const pts = [{ at: at(50), tick: tk(1), capital_bnb: 1 }, { at: at(40), tick: tk(1.05), capital_bnb: 1 }, { at: at(20), tick: tk(1.1), capital_bnb: 2 }]; const hb = holdingBenchmark(pts, { valueNow: 2.1, tickNow: tk(1.1), bobaiBnb: 0.01, owedBnb: 0.002, gasBnb: 0.001 }); is('two arrivals are found (a point with the same capital is not one)', hb && hb.arrivals === 2); is('holding: the first BNB half in the risen side is worth 1.05, the second 1.00 — 2.05', hb && Math.abs(hb.holding_bnb - 2.05) < 1e-4); is('the position side counts what it produced and what it paid', hb && Math.abs(hb.lp_bnb - (2.1 + 0.01 + 0.002 - 0.001)) < 1e-9); is('vs holding is the difference, in BNB and in percent of the capital', hb && Math.abs(hb.vs_holding_bnb - (2.111 - 2.05)) < 1e-4 && Math.abs(hb.vs_holding_pct - 3.05) < 0.01); is('kept fees waiting in the wallet are on the side of the position in the comparison, to the unit', Math.abs(holdingBenchmark(pts, { valueNow: 2.1, tickNow: tk(1.1), bobaiBnb: 0.01, owedBnb: 0.002, gasBnb: 0.001, waitingBnb: 0.003 }).lp_bnb - (hb.lp_bnb + 0.003)) < 1e-9 && holdingBenchmark(pts, { valueNow: 2.1, tickNow: tk(1.1), waitingBnb: 0.003 }).holding_bnb === holdingBenchmark(pts, { valueNow: 2.1, tickNow: tk(1.1) }).holding_bnb); is('the other way round (WBNB as token0) reads the price inverted', Math.abs(holdingBenchmark(pts, { valueNow: 2.1, tickNow: tk(1.1), wbnbIs0: true }).holding_bnb - (1 * (0.5 + 0.5 * (1 / 1.1) / 1) + 1 * (0.5 + 0.5 * (1 / 1.1) / (1 / 1.1)))) < 1e-4); is('no ticks, no value or no capital: no line', holdingBenchmark([], { valueNow: 1, tickNow: 0 }) === null && holdingBenchmark(pts, { valueNow: 0, tickNow: 0 }) === null && holdingBenchmark(pts, { valueNow: 1, tickNow: null }) === null); is('the model carries the line under pnl when the series has ticks', (() => { const s2 = { ...series, points: [{ at: at(8), position: '7397034', in_range: true, wallet_bnb: 0.003, tick: -57800, capital_bnb: 0.2872 }] }; const r2 = { ...rec, last_check: { ...rec.last_check, steps: { increase: { ...rec.last_check.steps.increase, tick: -57800 } } } }; const x = lpPortfolio(r2, s2, { now: NOW }); return x.pnl.vs_holding && x.pnl.vs_holding.holding_bnb > 0 && typeof x.pnl.vs_holding.vs_holding_bnb === 'number'; })()); is('… and null when it has none', m.pnl.vs_holding === null); is('without a series there is no model', lpPortfolio(rec, null) === null); console.log(`\n${n - bad}/${n} checks behave in both directions`); process.exitCode = bad ? 1 : 0; } else { const r = await fetch('https://agent.brainonbnb.com/lp/portfolio', { headers: { accept: 'application/json' } }); const j = await r.json(); console.log(JSON.stringify(j, null, 2)); } ============================================================================== === FILE: scripts/lp-windows.mjs ============================================================================== #!/usr/bin/env node // ONE WINDOW IS NOT EVIDENCE. This collects several. // // lp-plan.mjs picks a range from a single replay, and that replay covers about // an hour (thirty-seven minutes before 2026-09-09) — one log call near the // head. On that one sample a very narrow range looks best, because in // one quiet hour it never had to be nursed. Over a day it will be, // and each re-entry costs roughly a percent of a fifty-dollar position. // // So this records windows instead of arguing about one. Run it whenever, as // often as you like; each run appends what every candidate width did in that // window. Since 2026-09-02 the agent worker records one every hour on its own // (worker-agent/lp-windows.js), and `--sync` pulls those into the local file, // so the record grows while nobody is at a keyboard. `--report` then answers // the question that actually matters for a position nobody is watching: across // everything recorded so far, how often did each width hold, and what did it // collect once the nursing was paid for. // // It reads. It signs nothing, holds no key and moves nothing. // // WHY THE RECORD IS APPEND-ONLY AND KEEPS THE BLOCK NUMBERS // Two runs a minute apart cover almost the same chain and would count as two // independent observations while being one. Every entry carries its block // range, and the verdict refuses to count two windows that overlap, so the // answer cannot be inflated by running this in a loop. The verdict itself is // ONE function, imported from the worker module, so what this prints and what // lp-decision.mjs sizes the mint on cannot be two different readings. // // Usage: // node scripts/lp-windows.mjs record one window // node scripts/lp-windows.mjs --sync merge the worker's hourly record in // node scripts/lp-windows.mjs --report what the record says so far // node scripts/lp-windows.mjs --usd 50 size the replay differently // node scripts/lp-windows.mjs --self-test pin the verdict's rules, both ways import fs from 'node:fs'; import path from 'node:path'; import { verdict, appendWindow, mergeLogs, windowFromPlan, earningsTest, rangeValue, measuredResetCost, resetSwapFee, resetLosses, deriveWidths, appendTick, priceSeries, MAX_WINDOWS, MAX_TICKS, calibration, widthShare, inRangeFeeRate } from '../worker-agent/lp-windows.js'; import { RESET_AFTER_HOURS, MIN_HOURS_FOR_EARNINGS, WAIT_PICK_MIN_HOURS, WAIT_PICK_MARGIN, waitInUse, DERIVED_WIDTHS, RECORD_WIDTHS, widthClassOf } from '../shared/lp-guards.js'; const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..'); const LOG = path.join(ROOT, 'data', 'lp-windows.json'); const REMOTE = 'https://agent.brainonbnb.com/lp/windows'; const arg = (n, d) => { const i = process.argv.indexOf(n); const v = i >= 0 ? process.argv[i + 1] : null; return v && !v.startsWith('--') ? v : d; }; const USD = Number(arg('--usd', 50)) || 50; const REPORT = process.argv.includes('--report'); const SYNC = process.argv.includes('--sync'); const SELF_TEST = process.argv.includes('--self-test'); // The pool the planner chose. Passed explicitly so a record is always about one // pool: mixing two pools into one history would average away the thing being // measured. const POOL = arg('--pool', '0xafb2da14056725e3ba3a30dd846b6bbbd7886c56'); const read = () => { try { return JSON.parse(fs.readFileSync(LOG, 'utf8')); } catch { return { pool: POOL, usd: USD, windows: [] }; } }; const write = (log) => { fs.mkdirSync(path.dirname(LOG), { recursive: true }); fs.writeFileSync(LOG, JSON.stringify(log, null, 2) + '\n'); }; // --- self-test: the rules, pinned in both directions -------------------------- if (SELF_TEST) { let n = 0; const bad = []; const t = (name, cond) => { n++; if (!cond) bad.push(name); console.log(` ${cond ? 'ok ' : 'FAIL'} ${name}`); }; const row = (width, held, net, crossings = held ? 0 : 1) => ({ width, held, in_range_pct: held ? 100 : 80, crossings, fees: Math.abs(net), net }); const win = (from, to, rows) => ({ at: new Date(from * 1000).toISOString(), from_block: from, to_block: to, minutes: 37, swaps: 100, pool_fees_usd: 1, rebalance_cost_usd: 0.48, rows }); const good = [row(0.5, true, 0.09), row(1, true, 0.04), row('full', true, 0.0002)]; // Thin: one window decides nothing, however good it looks. let v = verdict({ windows: [win(100, 200, good)] }); t('one window is thin and picks nothing', v.thin && v.pick === null && v.windows === 1); // Two non-overlapping windows decide. v = verdict({ windows: [win(100, 200, good), win(300, 400, good)] }); t('two clean windows pick the best net width', !v.thin && v.pick?.width === 0.5); // Overlap counts once — and therefore stays thin. v = verdict({ windows: [win(100, 200, good), win(150, 250, good)] }); t('overlapping windows count once', v.windows === 1 && v.overlapping_runs_not_counted === 1 && v.thin); // Ever negative disqualifies even with a positive sum. v = verdict({ windows: [win(100, 200, [row(0.25, false, -2.7, 6), row(1, true, 0.04)]), win(300, 400, [row(0.25, true, 5), row(1, true, 0.04)])] }); t('a width that was ever negative is not picked despite a positive total', v.pick?.width === 1); t('… and the record says so', v.rows.find((r) => r.width === 0.25)?.everNegative === true); // Ever failed to hold disqualifies even when net stayed positive. v = verdict({ windows: [win(100, 200, [row(0.5, false, 0.01, 1), row(2, true, 0.02)]), win(300, 400, [row(0.5, true, 0.2), row(2, true, 0.02)])] }); t('a width that once failed to hold is not picked', v.pick?.width === 2); // Full range is never the pick. v = verdict({ windows: [win(100, 200, [row(0.5, false, -1, 3), row('full', true, 0.001)]), win(300, 400, [row(0.5, false, -1, 3), row('full', true, 0.001)])] }); t('full range is reported but never picked', v.pick === null && v.rows.some((r) => r.width === 'full')); // The positive direction of the same rule: nothing safe -> null, not "least bad". t('no safe width means no pick, not the least bad one', v.pick === null); // Empty log. v = verdict({ windows: [] }); t('an empty record is thin with no rows', v.thin && v.rows.length === 0 && v.pick === null); // appendWindow: duplicates by chain slice are not appended; the cap holds. let log = { pool: 'x', usd: 50, windows: [] }; let r = appendWindow(log, win(100, 200, good)); log = r.log; t('first window is appended', r.added && log.windows.length === 1); r = appendWindow(log, win(100, 200, good)); t('the same chain slice is not appended twice', !r.added && r.log.windows.length === 1); r = appendWindow(log, win(300, 400, good)); t('a different slice is appended', r.added && r.log.windows.length === 2); let big = { pool: 'x', usd: 50, windows: [] }; for (let i = 0; i < MAX_WINDOWS + 5; i++) big = appendWindow(big, win(i * 1000, i * 1000 + 500, good)).log; t(`the record is capped at ${MAX_WINDOWS} and keeps the newest`, big.windows.length === MAX_WINDOWS && big.windows[0].from_block === 5000); // mergeLogs: union by slice, sorted, refuses across pools. const a = { pool: '0xAAA', usd: 50, windows: [win(300, 400, good), win(100, 200, good)] }; const b = { pool: '0xaaa', usd: 50, windows: [win(100, 200, good), win(500, 600, good)] }; const m = mergeLogs(a, b); t('merge unions by chain slice and sorts', m.windows.length === 3 && m.windows[0].from_block === 100 && m.windows[2].from_block === 500); let threw = false; try { mergeLogs(a, { pool: '0xbbb', usd: 50, windows: [] }); } catch { threw = true; } t('merge refuses two different pools', threw); t('merge accepts a record that names no pool yet', mergeLogs({ usd: 50, windows: [] }, b).pool === '0xaaa'); // windowFromPlan: the held rule, both ways. const plan = (share, crossed) => ({ measured_window: { from_block: 1, to_block: 2, minutes: 37, swaps: 1, fees_the_pool_paid_usd: 1 }, rebalance_cost_usd_assumed: 0.48, ranges: [{ width_pct: 1, share_of_window_in_range_pct: share, times_it_crossed_the_edge: crossed, fees_usd_in_window: 0.1, net_after_rebalancing_usd_in_window: 0.1 }], }); t('100% in range with 0 crossings is held', windowFromPlan(plan(100, 0), 50).rows[0].held === true); t('100% in range but one crossing is NOT held', windowFromPlan(plan(100, 1), 50).rows[0].held === false); t('99% in range is NOT held', windowFromPlan(plan(99, 0), 50).rows[0].held === false); // The liquidity's share of a fee travels with the window (2026-09-13). t('a plan that names paid_to_liquidity_pct gives the window lp_share', windowFromPlan({ ...plan(100, 0), measured_window: { ...plan(100, 0).measured_window, paid_to_liquidity_pct: 66 } }, 50).lp_share === 0.66); t('a plan without it gives lp_share null, not 1', windowFromPlan(plan(100, 0), 50).lp_share === null); // earningsTest: each width lived through a price path, hour by hour. // Fee rows are what a centred range of that width earns in a 37.5-min // window: narrow earns more per hour, wide earns less. const H = 3600; const feeRows = [row(1, true, 0.10), row(5, true, 0.03), row(10, true, 0.015)]; const pwin = (hourIdx, price, rows = feeRows) => ({ ...win(hourIdx * 1000, hourIdx * 1000 + 500, rows), at: new Date(1_700_000_000_000 + hourIdx * H * 1000).toISOString(), minutes: 37.5, price, rebalance_cost_usd: 0.5 }); const flat = Array.from({ length: 30 }, (_, i) => pwin(i, 100)); const C = { oneSided: false }; // the centred replay as it was until 2026-09-16 let e1 = earningsTest(flat, 1, C), e5 = earningsTest(flat, 5, C); t('a flat price never needs a re-set', e1.resets === 0 && e5.resets === 0); t('… and the narrow width earns the most per day', e1.net_usd_per_day > e5.net_usd_per_day && e1.net_usd_per_day > 0); t('an hour of window fees is scaled from its minutes (0.10 per 37.5 min → 0.16 per hour)', Math.abs(e1.fees_usd / e1.hours - 0.16) < 0.001); // A price that drifts 0.15% every hour leaves a ±1% range every few hours // and a ±5% range never in a day; the narrow width pays for re-sets it // cannot earn back, the wide one keeps most of what it earns. (0.4% an // hour, the fixture until 2026-09-11, is a 12% day: once a re-set is // charged what its range lost against holding, no width earns on that.) // The rows stand in the proportion real ones do (widthShare): the replay reads every width's rate off the row that held. const driftRows = [row(1, true, 0.02), row(5, true, 0.02 * widthShare(1, 5)), row(10, true, 0.02 * widthShare(1, 10))]; // ±1% at 0.02 a window: what the live record reads const drift = Array.from({ length: 30 }, (_, i) => pwin(i, 100 * Math.pow(1.0015, i), driftRows)); e1 = earningsTest(drift, 1, C); e5 = earningsTest(drift, 5, C); const e10 = earningsTest(drift, 10, C); t('a drifting price makes the narrow width re-set again and again', e1.resets > e5.resets && e5.resets >= e10.resets); t('… so the narrow width nets less than a wider one', e1.net_usd_per_day < e5.net_usd_per_day); // rangeValue: what a range is worth against holding its minted amounts. t('a range at its minting price has lost nothing', rangeValue(100, 1, 100).loss < 1e-9); t('a ±1% range at its lower edge has lost a quarter of a percent', Math.abs(rangeValue(100, 1, 100 / 1.01).loss - 0.0025) < 0.0002); t('below the range the position is all of the priced token and moves with the price', (() => { const a = rangeValue(100, 1, 95), b = rangeValue(100, 1, 90); return Math.abs(b.value / a.value - 90 / 95) < 1e-9; })()); t('above the range the position is all of the quote and moves not at all', Math.abs(rangeValue(100, 1, 110).value - rangeValue(100, 1, 120).value) < 1e-12); t('a wide range loses less than a narrow one on the same move', rangeValue(100, 5, 98).loss < rangeValue(100, 1, 98).loss); t('the loss is never negative', [80, 99, 100, 101, 130].every((p) => rangeValue(100, 2, p).loss >= 0)); // The replay charges each re-set what its range lost, and marks the open range. t('a re-set on a drifted price is charged what the range lost against holding', e1.resets > 0 && e1.lost_to_price_usd > 0); t('… and net is fees minus re-set costs minus that loss minus the open range\'s mark', Math.abs(e1.net_usd - (e1.fees_usd - e1.resets * e1.reset_cost_usd - e1.lost_to_price_usd - e1.open_loss_usd)) < 0.001); t('a range that never re-set still carries its open loss at the last price', e5.resets === 0 && e5.open_loss_usd > 0 && e5.lost_to_price_usd === 0); t('a flat price loses nothing to the price', earningsTest(flat, 1, C).lost_to_price_usd === 0 && earningsTest(flat, 1, C).open_loss_usd === 0); t('a 12% day nets nothing at any width', (() => { const fast = Array.from({ length: 30 }, (_, i) => pwin(i, 100 * Math.pow(1.004, i))); return [1, 5, 10].every((w) => earningsTest(fast, w, C).net_usd < 0); })()); t('a re-set is only counted after the price has been outside for the delay', (() => { // outside for one hour, then back: no re-set with a 2 h delay const blip = [pwin(0, 100), pwin(1, 103), pwin(2, 100), pwin(3, 100), pwin(4, 100)]; return earningsTest(blip, 1, { ...C, resetAfterHours: 2 }).resets === 0 && earningsTest(blip, 1, { ...C, resetAfterHours: 1 }).resets >= 1; })()); t('a measured re-set cost overrides the replay\'s assumption', earningsTest(drift, 1, { ...C, resetCostUsd: 5 }).net_usd < earningsTest(drift, 1, C).net_usd); // THE ONE-SIDED REPLAY (2026-09-16), the default: a re-set trades nothing, // costs its gas, and the position is followed exactly against holding. const o1 = earningsTest(drift, 1), o5 = earningsTest(drift, 5), o10 = earningsTest(drift, 10); t('the default replay is one-sided and says so', o1.one_sided === true && earningsTest(drift, 1, C).one_sided === false); t('a one-sided re-set is charged no loss to the price and no open mark', o1.resets > 0 && o1.lost_to_price_usd === 0 && o1.open_loss_usd === 0); t('… so net is fees less re-sets alone', Math.abs(o1.net_usd - (o1.fees_usd - o1.resets * o1.reset_cost_usd)) < 1e-6); t('a drifting price still re-sets the narrow width more than the wide one', o1.resets > o5.resets && o5.resets >= o10.resets); t('every row names its share of hours in range, and wider is in range more on a drift', o1.in_range_share < o5.in_range_share && o5.in_range_share <= o10.in_range_share && o10.in_range_share <= 1); t('a steady climb leaves the liquidity behind holding — the trend line, reported', o1.vs_holding_usd < 0 && o5.vs_holding_usd < 0); t('… and a flat price leaves it level with holding', Math.abs(earningsTest(flat, 1).vs_holding_usd) < 1e-6); t('the centred replay carries the same holding line, and its charged loss is of the same order', (() => { const c = earningsTest(drift, 1, C); return c.vs_holding_usd < 0 && Math.abs(c.vs_holding_usd) > 0.2 * (c.lost_to_price_usd + c.open_loss_usd); })()); // A price that falls out of a ±1% range and comes back: the one-sided // range sat above the low, so the way back is earning hours; the centred // re-set sold at the low and paid for it. const dip = [pwin(0, 100), pwin(1, 100), pwin(2, 97), pwin(3, 97), pwin(4, 97), pwin(5, 98.5), pwin(6, 99.5), pwin(7, 100), pwin(8, 100), pwin(9, 100)]; const dOne = earningsTest(dip, 1), dCen = earningsTest(dip, 1, C); t('a dip and back: both replays re-set', dOne.resets >= 1 && dCen.resets >= 1); t('… the one-sided range earns on the way back and ends ahead of the centred one against holding', dOne.hours_in_range > 0 && dOne.vs_holding_usd > dCen.vs_holding_usd); t('… and the centred one paid a loss the one-sided one did not', dCen.lost_to_price_usd > 0 && dOne.lost_to_price_usd === 0); // The calibration: the position's own fees against the replay's dollars // for its width class, both on $50 a day. A day of series, a position. { const H = 36e5, t0 = Date.parse('2026-09-09T00:00:00Z'); const pt = (h, fees, owed, value) => ({ at: new Date(t0 + h * H).toISOString(), fees_total_bnb: fees, owed_bnb: owed, value_bnb: value, bnb_usd: 700 }); // 0.2 BNB earning 0.002 BNB in 24 h = 1% a day → $50 earns $0.50 a day. const series = [pt(0, 0.010, 0, 0.2), pt(12, 0.011, 0, 0.2), pt(24, 0.011, 0.001, 0.2)]; const rows = [{ width: 2, earnings: { hours: 48, fees_usd: 2 * 0.6 } }, { width: 1, earnings: { hours: 48, fees_usd: 2 * 0.7 } }]; const c = calibration(series, rows, 2); t('a day of series at 1% a day reads $0.50 a day on $50', c && Math.abs(c.measured_usd_per_day_on_50 - 0.5) < 1e-3 && c.hours === 24); t('the replay figure is the width class row, gross fees per day', c && Math.abs(c.replay_usd_per_day_on_50 - 0.6) < 1e-9); t('the factor is measured over replay', c && Math.abs(c.factor - 0.83) < 0.01); t('owed fees count as earned (they are the position\'s, uncollected)', calibration([pt(0, 0.01, 0, 0.2), pt(24, 0.01, 0.002, 0.2)], rows, 2).measured_usd_per_day_on_50 === c.measured_usd_per_day_on_50); t('under 20 h of series: no calibration', calibration([pt(0, 0.01, 0, 0.2), pt(12, 0.011, 0, 0.2)], rows, 2) === null); t('without a width class: no calibration', calibration(series, rows, null) === null); t('a width the record has no row for: measured, but no replay and no factor', (() => { const x = calibration(series, rows, 5); return x && x.replay_usd_per_day_on_50 === null && x.factor === null; })()); // A deposit mid-way doubles the capital; the fee rate is on the time-weighted capital, not the last value. const dep = [pt(0, 0.010, 0, 0.2), pt(12, 0.011, 0, 0.4), pt(24, 0.012, 0, 0.4)]; t('a deposit mid-way weighs the capital by time', (() => { const x = calibration(dep, rows, 2); return x && Math.abs(x.capital_bnb - 0.3) < 1e-9; })()); t('only the last 72 h of series take part', (() => { const long = [pt(-100, 0, 0, 0.2), ...series]; const x = calibration(long, rows, 2); return x && x.hours === 24; })()); } t('a gap in the record earns nothing for the gap', (() => { const gap = [pwin(0, 100), pwin(1, 100), pwin(20, 100), pwin(21, 100)]; return earningsTest(gap, 1).hours <= 1 + 3 + 1 + 0.01; })()); t('fewer than two priced windows decide nothing', earningsTest([pwin(0, 100)], 1) === null); // verdict: the earnings pick needs a day of prices and a positive net. const short = { windows: Array.from({ length: 10 }, (_, i) => pwin(i, 100)) }; t(`under ${MIN_HOURS_FOR_EARNINGS} h of prices there is no earnings pick`, verdict(short).earnings_pick === null && verdict(short).rows[0].earnings !== null); const dayFlat = { windows: flat }; t('a day of flat prices picks the narrowest width', [1, 1.5].includes(verdict(dayFlat).earnings_pick?.width) /* flat: nothing is lost to the trend; the narrowest earns the most — on this fixture's fee rows the derived ±1.5% row reads the same fees as ±1%, and a tie goes to the wider */); const dayDrift = { windows: drift }; t('a day of drifting prices picks a wider width than the narrowest', verdict(dayDrift).earnings_pick && verdict(dayDrift).earnings_pick.width > 1); t('a price that climbs 2% every hour: the widest width loses the least against holding and is the pick; the basis says so', (() => { const v = verdict({ windows: Array.from({ length: 30 }, (_, i) => pwin(i, 100 * Math.pow(1.02, i), [row(1, true, 0.01), row(5, true, 0.003), row(10, true, 0.001)])) }); return v.earnings_pick && v.earnings_pick.width === 10 && v.earnings_pick.vs_holding_usd <= 0 && /against holding/.test(v.earnings_pick.basis) && (v.net_pick === null || v.net_pick.width != null); })()); t(`the earnings rule names the ${RESET_AFTER_HOURS} h delay it replays`, /2 h/.test(verdict(dayFlat).earnings_rule)); // The delay test, both ways: reported for every wait, the wait in use // marked, nothing under a day of prices, and it never touches the pick. t('the delay test replays the waits 0 to 24 h (0,1,2,3,4,6,8,12,18,24 since 2026-09-13)', verdict(dayFlat).delay_test.delays.map((d) => d.hours).join(',') === '0,1,2,3,4,6,8,12,18,24'); t(`the wait in use (${RESET_AFTER_HOURS} h) is marked as such`, verdict(dayFlat).delay_test.delays.filter((d) => d.in_use).map((d) => d.hours).join() === String(RESET_AFTER_HOURS)); t('under a day of prices the delay test reports nothing', verdict(short).delay_test.delays.length === 0 && verdict(short).delay_test.pick === null); t('flat prices: every wait nets the same, and none re-sets', (() => { const d = verdict(dayFlat).delay_test.delays; return d.every((x) => x.resets === 0) && new Set(d.map((x) => x.net_usd_per_day)).size === 1; })()); t('a blip that returns within the hour: waiting beats re-setting at once', (() => { const blipDay = Array.from({ length: 30 }, (_, i) => pwin(i, i === 10 ? 103 : 100, [row(1, true, 0.01)])); const d = verdict({ windows: blipDay }).delay_test.delays; const at0 = d.find((x) => x.hours === 0), at2 = d.find((x) => x.hours === 2); // At once: two re-sets (out, then back) that can eat the whole net, in // which case the wait reports "nothing" rather than a width. const n0 = at0 && at0.net_usd_per_day != null ? at0.net_usd_per_day : -Infinity; return at0 && at2 && at2.resets === 0 && (at0.resets == null || at0.resets > 0) && at2.net_usd_per_day > n0; })()); t('under the bar the earnings pick is replayed with the set wait', verdict(dayDrift).delay_test.wait_basis === 'set' && verdict(dayDrift).earnings_rule.includes(`${RESET_AFTER_HOURS} h`)); // THE MEASURED WAIT (2026-09-09), both ways. A price that steps 1.5% up // every six hours and stays leaves a ±1% range four times a day; a re-set // at once earns five of the six hours, a re-set after two earns four. Over // a week of such prices 0 h nets a quarter more than 2 h — over the bar — // so the re-set uses it, and the width pick is replayed with it. The same // path over sixty hours decides nothing; a flat week, where every wait // nets the same, keeps the set wait because nothing beat it by the bar. const stepRows = [row(1, true, 1.0), row(5, true, 0.03), row(10, true, 0.015)]; const step = (hours) => Array.from({ length: hours }, (_, i) => pwin(i, 100 * Math.pow(1.015, Math.floor(i / 6)), stepRows)); const vStep = verdict({ windows: step(130) }); // Since 2026-09-16 the replay is one-sided: on a price that only steps up // no range is re-entered, so the waits differ only by the gas of their // re-sets, and which one nets the most is the fixture's business — the // rule is what is pinned: the wait with the most net is in use when it // beats the set wait by the bar, else the set wait; the row is marked; // the earnings rule names the wait in use. const stepD = vStep.delay_test.delays.filter((d) => d.net_usd_per_day != null); const stepBest = stepD.slice().sort((a, b) => b.net_usd_per_day - a.net_usd_per_day)[0], stepSet = stepD.find((d) => d.hours === RESET_AFTER_HOURS); const stepExpect = !stepBest ? RESET_AFTER_HOURS : stepBest.hours === RESET_AFTER_HOURS ? RESET_AFTER_HOURS : !stepSet ? (stepBest.net_usd_per_day > 0 ? stepBest.hours : RESET_AFTER_HOURS) : (stepBest.net_usd_per_day >= Math.round(stepSet.net_usd_per_day * (1 + WAIT_PICK_MARGIN) * 1e4) / 1e4 ? stepBest.hours : RESET_AFTER_HOURS); t('a week of stepping prices: the wait in use follows the rule (most net over the bar, else the set wait)', vStep.delay_test.in_use_hours === stepExpect); t('… that row is the one marked in use', vStep.delay_test.delays.filter((d) => d.in_use).map((d) => d.hours).join() === String(stepExpect)); t('… every wait is reported, whether or not a width nets at it', vStep.delay_test.delays.length === 10 && stepD.every((d) => d.width != null)); t('… and the earnings rule names the wait in use', new RegExp(`${stepExpect} h`).test(vStep.earnings_rule)); const vShort = verdict({ windows: step(60) }); t(`the same prices over 60 h keep the set wait (${WAIT_PICK_MIN_HOURS} h needed)`, vShort.delay_test.in_use_hours === RESET_AFTER_HOURS && vShort.delay_test.wait_basis === 'set' && vShort.delay_test.why.includes(String(WAIT_PICK_MIN_HOURS))); const vFlatWeek = verdict({ windows: Array.from({ length: 130 }, (_, i) => pwin(i, 100)) }); t('a flat week, every wait equal: the set wait stands (nothing beat it by the bar)', vFlatWeek.delay_test.in_use_hours === RESET_AFTER_HOURS && vFlatWeek.delay_test.wait_basis === 'set' && /bar/.test(vFlatWeek.delay_test.why)); // waitInUse alone, on made-up rows: the set wait winning is "measured" too; // a winner a cent over the set wait is not a change; no rows is the set wait. const dr = (hours, net) => ({ hours, width: 1, net_usd_per_day: net }); t('when the set wait nets the most it is in use and called measured', (() => { const w = waitInUse([dr(0, 0.5), dr(1, 0.6), dr(2, 0.9), dr(3, 0.7)], 200); return w.hours === 2 && w.basis === 'measured'; })()); t('a wait a cent ahead of the set wait does not replace it', (() => { const w = waitInUse([dr(0, 0.91), dr(1, 0.6), dr(2, 0.9), dr(3, 0.7)], 200); return w.hours === 2 && w.basis === 'set'; })()); t('a wait a tenth ahead of the set wait replaces it', (() => { const w = waitInUse([dr(0, 0.99), dr(1, 0.6), dr(2, 0.9), dr(3, 0.7)], 200); return w.hours === 0 && w.basis === 'measured'; })()); t('no delay rows: the set wait, called set', (() => { const w = waitInUse([], 200); return w.hours === RESET_AFTER_HOURS && w.basis === 'set'; })()); t('a set wait that nets nothing yields to a wait that does', (() => { const w = waitInUse([dr(0, 0.9), { hours: 2, width: null, net_usd_per_day: null }], 200); return w.hours === 0 && w.basis === 'measured' && /netted nothing at any width/.test(w.why); })()); t('… but not under the hours a measured wait needs', (() => { const w = waitInUse([dr(0, 0.9), { hours: 2, width: null, net_usd_per_day: null }], 60); return w.hours === RESET_AFTER_HOURS && w.basis === 'set'; })()); t('… and not to a wait that nets nothing either', (() => { const w = waitInUse([dr(0, -0.2), { hours: 2, width: null, net_usd_per_day: null }], 200); return w.hours === RESET_AFTER_HOURS && w.basis === 'set' && /no wait nets/.test(w.why); })()); // The measured re-set cost, both ways: only a re-set that acted, did not // error and recorded gas counts, the newest one wins, and without one the // verdict says the cost is assumed. const rec = { history: [ { at: '2026-09-02T17:30:00Z', steps: { rebalance: { acted: true, gas_bnb: 0.0004, txs: new Array(9) } } }, { at: '2026-09-04T07:50:00Z', steps: { rebalance: { acted: true, gas_bnb: 0.0002, txs: new Array(5) } } }, { at: '2026-09-05T07:50:00Z', steps: { rebalance: { acted: true, error: 'reverted', gas_bnb: 0.0001, txs: new Array(1) } } }, ] }; const mc = measuredResetCost(rec, 700); t('the newest clean re-set is the measured cost', mc && mc.gas_bnb === 0.0002 && mc.usd === 0.14 && mc.transactions === 5); t('a re-set that errored is not a cost measurement', mc.at === '2026-09-04T07:50:00Z'); t('no re-set on record means no measured cost', measuredResetCost({ history: [] }, 700) === null && measuredResetCost(null, 700) === null); t('no BNB price means no measured cost', measuredResetCost(rec, null) === null); // The swap fee of a re-set (2026-09-09), both ways: a measured field wins, // a buy is worked out from its WBNB, a sell from half the position, and a // re-set without a trade paid none. The cost the verdict charges is gas // plus the fee — 0.0002 BNB of gas and 0.04 WBNB through the 0.25% pool // at $700 is $0.21, not $0.14. t('a re-set with no trade on record paid no swap fee', resetSwapFee({}).bnb === 0 && /no trade/.test(resetSwapFee({}).basis)); t('a buy names its WBNB: 0.04 WBNB through 0.25% is 0.0001 BNB', resetSwapFee({ trade: 'buy the other side with 0.040000 WBNB' }).bnb === 0.0001 && /estimated/.test(resetSwapFee({ trade: 'buy the other side with 0.040000 WBNB' }).basis)); t('a sell is taken as half the position', resetSwapFee({ trade: 'sell 18.7 of 0x0e09 for WBNB', value_bnb: 0.08 }).bnb === 0.0001); t('a measured swap field wins over the estimate', resetSwapFee({ trade: 'buy the other side with 0.040000 WBNB', swap: { fee_bnb: 0.00005 } }).bnb === 0.00005 && resetSwapFee({ swap: { fee_bnb: 0.00005 } }).basis === 'measured'); const recSwap = { history: [{ at: '2026-09-09T07:50:00Z', steps: { rebalance: { acted: true, gas_bnb: 0.0002, trade: 'buy the other side with 0.040000 WBNB', txs: new Array(3) } } }] }; const mcs = measuredResetCost(recSwap, 700); t('the measured cost is gas plus the swap fee ($0.14 + $0.07 = $0.21)', mcs && mcs.usd === 0.21 && mcs.swap_fee_bnb === 0.0001 && mcs.gas_bnb === 0.0002); t('… and the verdict charges that sum', verdict(dayFlat, { resetCostUsd: mcs.usd }).reset_cost.usd === 0.21); t('a re-set that measured its impact is charged it too ($0.21 + $0.21)', (() => { const r = { history: [{ at: '2026-09-11T02:50:00Z', steps: { rebalance: { acted: true, gas_bnb: 0.0002, swap: { fee_bnb: 0.0001, impact_bnb: 0.0003 }, txs: new Array(5) } } }] }; const m = measuredResetCost(r, 700); return m.usd === 0.42 && m.impact_bnb === 0.0003 && /measured by the swap/.test(m.impact_basis); })()); t('a re-set without an impact field is charged none and says so', mcs.impact_bnb === null && /not measured/.test(mcs.impact_basis)); // Per $50 (2026-09-12): the replay sizes every width at $50, so the cost it // charges is the measured cost per $50 of the position that paid it — $0.21 // on a 0.6 BNB ($420) position is $0.025 per $50, not $0.21. Both ways: a // re-set that did not record its value gives no per-$50 figure. const recSized = { history: [{ at: '2026-09-09T07:50:00Z', steps: { rebalance: { acted: true, gas_bnb: 0.0002, value_bnb: 0.6, trade: 'buy the other side with 0.040000 WBNB', txs: new Array(3) } } }] }; const mSized = measuredResetCost(recSized, 700); t('the measured cost is also given per $50 of the position it was paid on ($0.21 on $420 → $0.025)', mSized.usd === 0.21 && mSized.position_usd_at_reset === 420 && mSized.usd_per_50 === 0.025); t('… a re-set that did not record its value gives no per-$50 figure', mcs.usd_per_50 === null && mcs.position_usd_at_reset === null); t('… and the verdict says the cost it charges is per $50', /per \$50 of the position/.test(verdict(dayFlat, { resetCostUsd: mSized.usd_per_50 }).reset_cost.basis) && verdict(dayFlat, { resetCostUsd: mSized.usd_per_50 }).reset_cost.usd === 0.025); // The re-sets' own losses, from ticks: a ±1% range minted at tick 0 and left // at tick −200 (−2.0%) lost 0.76% against holding; execution is gas + fee + impact. const recLoss = { history: [ { at: '2026-09-10T16:50:00Z', steps: { rebalance: { acted: true, ticks: [-100, 100], tick: -200, value_bnb: 0.5, fees_folded_bnb: 0.001, width_pct: 1, gas_bnb: 0.0001, swap: { fee_bnb: 0.00015, impact_bnb: 0.0002 } } } }, { at: '2026-09-11T02:50:00Z', steps: { rebalance: { acted: true, ticks: [-200, 200], tick: 0, value_bnb: 0.6, width_pct: 2, gas_bnb: 0.0001, trade: 'buy the other side with 0.3 WBNB' } } }, { at: '2026-09-11T03:50:00Z', steps: { rebalance: { acted: true, error: 'reverted', ticks: [-200, 200], tick: 0, value_bnb: 0.6, gas_bnb: 0.0001 } } }, { at: '2026-09-11T04:50:00Z', steps: { rebalance: { acted: false, ticks: [-200, 200], tick: 0 } } }, ] }; const rl = resetLosses(recLoss, { bnbUsd: 700 }); t('only re-sets that acted and did not error count, newest first', rl.resets === 2 && rl.rows[0].at === '2026-09-11T02:50:00Z'); t('a ±1% range left 2% below its middle lost about 0.76% of the position against holding', Math.abs(rl.rows[1].lost_to_price_pct - 0.758) < 0.01 && Math.abs(rl.rows[1].lost_to_price_bnb - 0.501 * 0.00758) < 0.0001 && rl.rows[1].price_move_pct === -1.98); t('a range left at its own middle lost nothing', rl.rows[0].lost_to_price_bnb === 0 && rl.rows[0].price_move_pct === 0); t('execution is gas plus fee plus the measured impact, or the fee estimated from the trade', Math.abs(rl.rows[1].execution_bnb - 0.00045) < 1e-9 && rl.rows[1].impact_bnb === 0.0002 && rl.rows[0].impact_bnb === null && Math.abs(rl.rows[0].execution_bnb - (0.0001 + 0.3 * 0.0025)) < 1e-9); t('the totals add up and the dollars follow the BNB price', Math.abs(rl.lost_to_price_bnb - rl.rows[1].lost_to_price_bnb) < 1e-9 && rl.impact_measured === 1 && rl.rows[1].lost_to_price_usd === Math.round(rl.rows[1].lost_to_price_bnb * 700 * 100) / 100); t('no re-sets: an empty table, zero totals', resetLosses({ history: [] }).resets === 0 && resetLosses(null).lost_to_price_bnb === 0); // The derived widths: read off the neighbours, never rosier than the record. const wReal = { rows: [{ width: 1, held: false, in_range_pct: 80, crossings: 2, fees: 0.02, net: 0.01 }, { width: 2, held: true, in_range_pct: 100, crossings: 0, fees: 0.01, net: 0.01 }, { width: 5, held: true, in_range_pct: 100, crossings: 0, fees: 0.004, net: 0.004 }, { width: 10, held: true, in_range_pct: 100, crossings: 0, fees: 0.002, net: 0.002 }, { width: 'full', held: true, in_range_pct: 100, crossings: 0, fees: 0.0001, net: 0.0001 }] }; const dw = deriveWidths(wReal); t(`the derived widths ${DERIVED_WIDTHS.join('/')} are added to a window`, DERIVED_WIDTHS.every((w) => dw.rows.some((r) => r.width === w && r.derived)) && dw.rows.length === wReal.rows.length + DERIVED_WIDTHS.length); t('a derived width\'s fees are the wider neighbour\'s by the liquidity law (±3% from ±5%, ±1.5% from ±2%) — a little under neighbour/width, which the record\'s own rows never followed', Math.abs(dw.rows.find((r) => r.width === 3).fees - 0.004 * widthShare(5, 3)) < 1e-6 && Math.abs(dw.rows.find((r) => r.width === 1.5).fees - 0.01 * widthShare(2, 1.5)) < 1e-6 && widthShare(5, 3) < 5 / 3 && widthShare(5, 3) > 1.6); t('… and whether it held comes off the narrower neighbour (±1.5% did not hold because ±1% did not; ±3% held because ±2% did)', dw.rows.find((r) => r.width === 1.5).held === false && dw.rows.find((r) => r.width === 1.5).crossings === 2 && dw.rows.find((r) => r.width === 3).held === true); t('a derived row\'s net carries the narrower neighbour\'s re-set cost (±1.5%: its fees minus the 0.01 that ±1% paid)', Math.abs(dw.rows.find((r) => r.width === 1.5).net - (0.01 * widthShare(2, 1.5) - 0.01)) < 1e-6); // What a range earns while the price is in it (2026-09-18). t('the law is the record\'s own: ±5% earns 1.931 times ±10% while both hold the price, not twice', Math.abs(widthShare(10, 5) - 1.931) < 0.001 && Math.abs(0.009614 / 0.004979 - widthShare(10, 5)) < 0.002); const lastLive = { minutes: 60, rows: [{ width: 0.25, held: false, in_range_pct: 17.3, fees: 0.062184 }, { width: 1, held: false, in_range_pct: 82.6, fees: 0.039728 }, { width: 2, held: true, in_range_pct: 100, fees: 0.023511 }, { width: 5, held: true, in_range_pct: 100, fees: 0.009614 }, { width: 'full', held: true, in_range_pct: 100, fees: 0.000232 }] }; t('a narrow width\'s rate while inside is read off the narrowest row that held, not off its own row — which already left out the time it was outside', Math.abs(inRangeFeeRate(lastLive, 0.25) - 0.023511 * widthShare(2, 0.25)) < 1e-9 && inRangeFeeRate(lastLive, 0.25) > 0.062184 * 2 && Math.abs(inRangeFeeRate(lastLive, 2) - 0.023511) < 1e-9 && Math.abs(inRangeFeeRate(lastLive, 5) - 0.009614) < 0.0001); t('… scaled from the window\'s minutes, and a window where nothing held keeps the row\'s own figure', Math.abs(inRangeFeeRate({ ...lastLive, minutes: 30 }, 2) - 0.047022) < 1e-9 && Math.abs(inRangeFeeRate({ minutes: 60, rows: [{ width: 1, in_range_pct: 40, fees: 0.01 }] }, 1) - 0.01) < 1e-12 && inRangeFeeRate({ minutes: 60, rows: [] }, 1) === 0); t('a window without a wider neighbour gets no derived row there', !deriveWidths({ rows: [{ width: 5, held: true, fees: 0.004, net: 0.004, crossings: 0 }] }).rows.some((r) => r.width === 7)); t('the verdict replays the derived widths and marks them', (() => { const v = verdict(dayFlat); const r3 = v.rows.find((r) => r.width === 3); return r3 && r3.derived === true && r3.earnings && r3.earnings.fees_usd > 0 && v.rows.find((r) => r.width === 5).derived === false; })()); // The price tape: samples between the hourly heads, both ways. const tapeAt = (hourIdx, minutes, price) => ({ at: new Date(1_700_000_000_000 + (hourIdx * 60 + minutes) * 60 * 1000).toISOString(), tick: 0, price, pool: '0xpool' }); let tp = appendTick([], tapeAt(0, 10, 100)); t('a sample is appended', tp.added && tp.tape.length === 1); t('a second sample within a minute is not', !appendTick(tp.tape, { ...tapeAt(0, 10, 100), at: new Date(Date.parse(tp.tape[0].at) + 30e3).toISOString() }).added); t('a sample without a price is not', !appendTick(tp.tape, { at: tapeAt(0, 20, 0).at, price: 0 }).added); t(`the tape is capped at ${MAX_TICKS} and keeps the newest`, (() => { let x = []; for (let i = 0; i < MAX_TICKS + 3; i++) x = appendTick(x, tapeAt(i, 0, 100)).tape; return x.length === MAX_TICKS && x[0].at === tapeAt(3, 0, 100).at; })()); // A flat hour-by-hour record with a flat tape: the same fees, no re-set, the samples counted. const flatTape = [].concat(...flat.slice(0, 29).map((_, i) => [10, 20, 30, 40, 50].map((m) => tapeAt(i, m, 100)))); const eFlatTape = earningsTest(flat, 1, { tape: flatTape }); t('a flat tape changes nothing but the count of points', Math.abs(eFlatTape.fees_usd - earningsTest(flat, 1).fees_usd) < 1e-6 && eFlatTape.resets === 0 && eFlatTape.tape_samples === flatTape.length && eFlatTape.price_points === 30 + flatTape.length); // A price that leaves the range for twenty minutes between two in-range hourly heads: // the hourly series never sees it; the tape does, and with no wait it is a re-set. const blipTape = [tapeAt(5, 20, 103), tapeAt(5, 40, 103)]; t('the hourly series misses an excursion inside the hour', earningsTest(flat, 1, { resetAfterHours: 0 }).resets === 0); t('… the tape sees it, and with no wait it is a re-set charged its loss (centred replay)', (() => { const e = earningsTest(flat, 1, { ...C, resetAfterHours: 0, tape: blipTape }); return e.resets >= 1 && e.lost_to_price_usd > 0; })()); t('… and with a two-hour wait a twenty-minute excursion is not', earningsTest(flat, 1, { resetAfterHours: 2, tape: blipTape }).resets === 0); t('a sample earns at the rate of the window whose hour it falls in', (() => { const ps = priceSeries(flat.slice(0, 3), [tapeAt(1, 30, 100)]); const smp = ps.find((p) => p.at === tapeAt(1, 30, 100).at); return smp && smp.window === flat[2]; })()); t('a sample within a minute of a window head is counted once', priceSeries(flat.slice(0, 3), [{ ...tapeAt(1, 0, 100), at: new Date(Date.parse(flat[1].at) + 20e3).toISOString() }]).length === 3); t('the verdict walks the tape of its own pool only and says how many samples', (() => { const v = verdict({ pool: '0xpool', windows: flat }, { tape: flatTape.concat([{ ...tapeAt(3, 15, 200), pool: '0xother' }]) }); return v.price_samples === flatTape.length && v.rows[0].earnings.resets === 0 && v.price_samples_since === flatTape[0].at; })()); t('no tape: no samples, the same verdict as before', verdict(dayFlat).price_samples === 0 && [1, 1.5].includes(verdict(dayFlat).earnings_pick?.width) /* flat: every width is in range all week; the narrowest wins */); t('every width is also replayed over the last week alone, and the pick comes from it (2026-09-16)', (() => { const v = verdict(dayFlat); const r = v.rows.find((x) => x.width === 1); return r.earnings_7d && r.earnings_7d.hours > 0 && v.earnings_pick && v.earnings_pick.earnings_7d && v.earnings_pick.basis && v.width_window_hours === 168; })()); t('the most-net width is still named beside the pick', (() => { const v = verdict(dayFlat); return v.net_pick && v.net_pick.width != null && v.net_pick.earnings; })()); t('the rule says one-sided, gas alone, the most ahead against holding over the last week', /one-sided/.test(verdict(dayFlat).earnings_rule) && /against holding/.test(verdict(dayFlat).earnings_rule) && /168 h/.test(verdict(dayFlat).earnings_rule)); t('every width is also replayed over the last day alone', (() => { const v = verdict(dayFlat); const r = v.rows.find((x) => x.width === 1); return r.earnings_24h && r.earnings_24h.hours <= 24.01 && r.earnings_24h.hours >= 20 && v.rows.every((x) => x.width !== 'full' || x.earnings_24h === null); })()); t('the last-day replay walks only the last day of the tape', (() => { const v = verdict({ pool: '0xpool', windows: flat }, { tape: flatTape }); const r = v.rows.find((x) => x.width === 1); return r.earnings_24h.tape_samples < r.earnings.tape_samples && r.earnings_24h.tape_samples > 0; })()); t('the width class snaps to the finer grid (a ±3.1% range is the 3 class, not 2 or 5)', widthClassOf([-Math.round(Math.log(1.031) / Math.log(1.0001)), Math.round(Math.log(1.031) / Math.log(1.0001))]) === 3 && RECORD_WIDTHS.includes(1.5) && RECORD_WIDTHS.includes(7)); t('the verdict charges the measured cost when given one', verdict(dayFlat, { resetCostUsd: 0.14 }).reset_cost.usd === 0.14 && /measured/.test(verdict(dayFlat, { resetCostUsd: 0.14 }).reset_cost.basis)); t('… and says the cost is assumed when not', /assumed/.test(verdict(dayFlat).reset_cost.basis)); console.log(`\n${n - bad.length} of ${n} checks passed`); if (bad.length) { bad.forEach((b) => console.log(` - ${b}`)); process.exitCode = 1; } } else if (SYNC) { // --- sync: the worker's hourly record into the local file -------------------- const r = await fetch(REMOTE, { signal: AbortSignal.timeout(20000) }); const body = await r.text(); if (/^\s* Number(formatEther(v)); const deadline = () => BigInt(Math.floor(Date.now() / 1000) + 600); const read = (pub, address, abi, functionName, args = []) => pub.readContract({ address, abi, functionName, args }); // What a transaction pays per gas: the chain's own answer with headroom, // floored at BSC's 0.05 gwei minimum and capped at 3 gwei. The floors and // reserves in lp-guards.js are still priced at 1 gwei — that is the safety // margin — but paying 1 gwei on a chain that clears at 0.05 was paying // twenty times the fare, measured 2026-09-02. export async function gasPriceNow(pub) { const g = await pub.getGasPrice().catch(() => null); if (g == null) return GAS_PRICE; const floor = 50_000_000n, cap = 3_000_000_000n; const bumped = (g * 15n) / 10n; return bumped < floor ? floor : bumped > cap ? cap : bumped; } // One transaction, waited for, refused to continue past a revert. `txs` is // the caller's list so a failure mid-sequence still reports what was sent. // Since 2026-09-14 every execute step takes that list from its caller // instead of making its own: only a throw out of `send` carried it back, // so a step that sent and then failed on a READ reported no transactions // at all. The collect of that morning recorded an empty list against a // collect that had already run on chain. export function sender(pub, wallet, txs, log = () => {}) { let price = null; const send = async (label, req) => { // Whatever throws — a simulation that reverts before sending, a sent // transaction that reverts — carries the list of what was sent so far, // so a failed run's record still names its transactions and their gas. // Without it the two failed runs of 2026-09-05 (7 transactions) counted // as none, and the page said "8 transactions so far" against a wallet // nonce of 34. let sent = false; try { if (price == null) price = await gasPriceNow(pub); const hash = req.to ? await wallet.sendTransaction({ ...req, gasPrice: price }) : await wallet.writeContract({ ...req, gasPrice: price }); const entry = { label, hash }; txs.push(entry); log(` ${label}: ${hash}`); sent = true; const r = await pub.waitForTransactionReceipt({ hash, timeout: 90000 }); // What the transaction really cost, so the record can say what a re-set // costs in measured BNB rather than in the replay's assumption. if (r.gasUsed != null && r.effectiveGasPrice != null) entry.gas_bnb = Number(formatEther(r.gasUsed * r.effectiveGasPrice)); if (r.status !== 'success') throw new Error(`${label} reverted — stopped before the next step`); return r; } catch (e) { // `sent` tells a caller whether anything left the wallet: a swap refused // in the node's estimate cost nothing and may be asked again at a fresh // quote; one that was broadcast may not. if (e && typeof e === 'object') e.sent = sent; if (e && typeof e === 'object' && !e.txs) e.txs = txs; throw e; } }; // The wallet's owner rides on the sender, so an allowance check knows whom // to ask. Until 2026-09-14 each execute step set it by hand and the collect // forgot: its first V3 sale asked the allowance of "undefined" and stopped // after the collect transaction, the fees left in the wallet unsold. send.owner = wallet && wallet.account ? wallet.account.address : undefined; return send; } // -------------------------------------------------------------------------- // The position // -------------------------------------------------------------------------- // The position this wallet holds, and what a collect would return right now. // Asked by simulation, not read off the struct: tokensOwed only updates when // the position is touched, so an untouched position reads zero there. A // simulation that fails throws — it is not "nothing owed". async function readOne(pub, address, tokenId) { const pos = await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'positions', [tokenId]); let owed0 = 0n, owed1 = 0n; if (pos[7] > 0n) { const sim = await pub.simulateContract({ address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'collect', args: [{ tokenId, recipient: address, amount0Max: MAX128, amount1Max: MAX128 }], account: address, }).catch((e) => { throw new Error(`collect simulation failed: ${e.shortMessage || e.message}`); }); owed0 = sim.result[0]; owed1 = sim.result[1]; } return { tokenId, pos, owed0, owed1 }; } // `ladder` (2026-09-16) is the worker's ladder record {main, reserve}: a // wallet that holds exactly the two positions it names reads as ONE — the // main range, with the reserve attached as `reserve` — so every step that // knows one position keeps working on the main one, and the ladder step // alone handles the reserve. Two positions the record does not name still // read as two, and every guard refuses as before. export async function readPosition(pub, address, ladder = null) { // BY THE RECORD'S IDS, NOT BY THE COUNT (2026-09-18). Anyone can mint a dust // position to this wallet, or send one, for a few cents; counted, it made // "3 positions — a decision for a person" and every step refused until a // person removed it. The ranges the record names are asked for by id // (ownerOf): what else the wallet holds is not the agent's and is not // read. A burnt id reverts and reads as not held. Neither held: the count // below decides as before, and ladderHeal follows the chain. if (ladder && ladder.main != null) { const owns = (id) => (id == null ? Promise.resolve(false) : read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'ownerOf', [BigInt(id)]).then((o) => String(o).toLowerCase() === String(address).toLowerCase()).catch(() => false)); const [mainHeld, reserveHeld] = await Promise.all([owns(ladder.main), owns(ladder.reserve)]); if (mainHeld) { const main = await readOne(pub, address, BigInt(ladder.main)); if (reserveHeld) return { positions: 1, ...main, reserve: await readOne(pub, address, BigInt(ladder.reserve)), positions_held: 2 }; return { positions: 1, ...main, positions_held: 1 }; } // The main range is gone, the reserve stands (see below): no main range, // the reserve rides along, the re-set is finished from the wallet. if (reserveHeld) return { positions: 0, tokenId: null, pos: null, owed0: 0n, owed1: 0n, reserve: await readOne(pub, address, BigInt(ladder.reserve)), positions_held: 1, main_missing: String(ladder.main) }; } const positions = Number(await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'balanceOf', [address])); if (positions === 2 && ladder && ladder.reserve != null) { const ids = [await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'tokenOfOwnerByIndex', [address, 0n]), await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'tokenOfOwnerByIndex', [address, 1n])]; const rid = ids.find((i) => String(i) === String(ladder.reserve)); const mid = ids.find((i) => String(i) !== String(ladder.reserve)); if (rid != null && mid != null && (ladder.main == null || String(mid) === String(ladder.main))) { const main = await readOne(pub, address, mid), reserve = await readOne(pub, address, rid); return { positions: 1, ...main, reserve, positions_held: 2 }; } } if (positions !== 1) return { positions, tokenId: null, pos: null, owed0: 0n, owed1: 0n }; const tokenId = await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'tokenOfOwnerByIndex', [address, 0n]); // THE MAIN RANGE IS GONE, THE RESERVE STANDS (2026-09-18, read in the code, // never seen with money). A re-set burnt the main range and its mint failed: // the wallet holds the reserve alone and the main range's capital loose. // Read as "the position", the reserve took that capital through the // increase step — all of the other side counted as a deposit and sold into // the reserve's ratio, at the low. The one position being the reserve the // record names (beside a main range it also names) is NOT the main range: // the wallet holds no main range, the reserve rides along, and the re-set // is finished from the wallet beside it (planRebalance's resume, one-sided // — resumeSide). A reserve left with nothing worth minting beside it is // closed into the main range by ladderHeal on the next tick. if (ladder && ladder.main != null && ladder.reserve != null && String(tokenId) === String(ladder.reserve)) { return { positions: 0, tokenId: null, pos: null, owed0: 0n, owed1: 0n, reserve: await readOne(pub, address, tokenId), positions_held: 1, main_missing: String(ladder.main) }; } return { positions, ...(await readOne(pub, address, tokenId)), positions_held: 1 }; } // The ids of the positions a wallet holds, as strings. A mint is named by the // id that is there after it and was not before — readPosition alone returns // no id for a wallet that holds the main range and a reserve (2026-09-17: the // re-set of 09-16 08:50 minted #7451444 beside reserve #7450613, read "two // positions", recorded new_position null, and the ladder record kept naming // the burnt main range — every step refused for a day). // Bounded: a wallet anyone can send NFTs to is never enumerated past this. export const HELD_IDS_CAP = 12; export async function heldIds(pub, address) { const n = Math.min(HELD_IDS_CAP, Number(await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'balanceOf', [address]))); const ids = []; for (let i = 0; i < n; i++) ids.push(String(await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'tokenOfOwnerByIndex', [address, BigInt(i)]))); return ids; } // The chain half of ladderHeal (lp-guards.js): what the wallet holds, and // whether the one position beside the reserve is in the reserve's pool. // Reads the two positions only when exactly one of the two the record names // is still held (both held: nothing to heal; neither: the guards decide). export async function healLadder(pub, address, ladder) { if (!ladder || ladder.main == null) return null; const held = await heldIds(pub, address); if (held.length === 1) { if (ladder.reserve == null || held[0] !== String(ladder.reserve)) return null; // Only the reserve is left. What lies loose beside it decides: enough to // mint is a re-set to finish (no heal), less closes the ladder. A read // that fails throws — the caller heals nothing on a guess. const pos = await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'positions', [BigInt(held[0])]); const wbnbIs0 = pos[2].toLowerCase() === ADDR.WBNB; const other = wbnbIs0 ? pos[3] : pos[2]; const { sqrtP } = await readPool(pub, pos); const otherInWbnb = wbnbIs0 ? 1 / (sqrtP ** 2) : sqrtP ** 2; const [w, o] = await Promise.all([read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [address]), read(pub, other, ABI.ERC20, 'balanceOf', [address])]); return ladderHeal({ main: ladder.main, reserve: ladder.reserve, held, samePool: null, looseBnb: (Number(w) + Number(o) * otherInWbnb) / 1e18 }); } if (held.length !== 2) return null; const mainHeld = held.includes(String(ladder.main)), reserveHeld = ladder.reserve != null && held.includes(String(ladder.reserve)); if (mainHeld === reserveHeld) return null; const [a, b] = await Promise.all(held.map((i) => read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'positions', [BigInt(i)]))); const samePool = a[2].toLowerCase() === b[2].toLowerCase() && a[3].toLowerCase() === b[3].toLowerCase() && Number(a[4]) === Number(b[4]); return ladderHeal({ main: ladder.main, reserve: ladder.reserve, held, samePool }); } // The id a mint made, from the mint's own receipt: the position manager's // Transfer from the zero address to the owner. Until 2026-09-18 the id was // the one held after the mint and not before — two enumerations of the // wallet, which a read behind the chain's head answered with no new id (the // record then named none) and a wallet stuffed with strangers' NFTs made as // long as they liked. The enumeration stays as the fallback. Pure. const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; export function mintedIn(receipt, owner) { for (const l of (receipt && receipt.logs) || []) { const t = l.topics || []; if (String(l.address).toLowerCase() !== ADDR.V3_POSITION_MANAGER || t.length !== 4 || t[0] !== TRANSFER_TOPIC) continue; if (BigInt(t[1]) === 0n && BigInt(t[2]) === BigInt(owner)) return String(BigInt(t[3])); } return null; } async function mintedSince(pub, address, idsBefore) { const id = (await heldIds(pub, address)).find((i) => !idsBefore.includes(i)); if (id == null) return { tokenId: null, pos: null }; return { tokenId: BigInt(id), pos: await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'positions', [BigInt(id)]) }; } // What a position holds at a price, in WBNB terms, and which side that is: // 'other' (all of the other side, the price below the range), 'wbnb' (all // WBNB, the price above it) or 'both' (inside). Pure. export function positionSide(pos, sqrtP, wbnbIs0) { const L = Number(pos[7]); const { perL0, perL1 } = splitForRange(sqrtP, Number(pos[5]), Number(pos[6])); const in0 = L * perL0, in1 = L * perL1; const price = sqrtP ** 2, otherInWbnb = wbnbIs0 ? 1 / price : price; const wbnb = wbnbIs0 ? in0 : in1, other = wbnbIs0 ? in1 : in0; const side = other > 0 && wbnb <= 0 ? 'other' : wbnb > 0 && other <= 0 ? 'wbnb' : L > 0 ? 'both' : null; return { side, wbnb, other, valueBnb: (wbnb + other * otherInWbnb) / 1e18 }; } // The pool behind a position, from the manager's own factory rather than a // constant, and where its price stands. export async function readPool(pub, pos) { const factory = await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'factory'); const pool = await read(pub, factory, ABI.FACTORY, 'getPool', [pos[2], pos[3], pos[4]]); if (!pool || pool === ZERO) throw new Error('the position names a pool the factory does not know'); const s = await read(pub, pool, ABI.POOL, 'slot0'); const sqrtP = Number(s[0]) / 2 ** 96; const tick = Number(s[1]); return { pool, sqrtP, tick, inRange: tick >= Number(pos[5]) && tick < Number(pos[6]) }; } // The pair behind a pool address, in the shape the position manager's // positions() returns (token0 at [2], token1 at [3], fee at [4], no ticks, no // liquidity), so a wallet that holds the two tokens but no position can be // planned with the same code as one that holds a position. export async function readPoolPair(pub, pool) { const [token0, token1, fee] = await Promise.all([ read(pub, pool, ABI.POOL, 'token0'), read(pub, pool, ABI.POOL, 'token1'), read(pub, pool, ABI.POOL, 'fee'), ]); return [0n, ZERO, token0, token1, fee, 0, 0, 0n]; } // How much of each token one unit of liquidity holds inside a range at a // price. Standard V3 identities, and not a preference: inside a range the // split is fixed by where the price sits in it. Pure, exported for the // self-test. const sqrtAtTick = (t) => Math.pow(1.0001, t / 2); export function splitForRange(sqrtP, tickLower, tickUpper) { const sLo = sqrtAtTick(tickLower), sHi = sqrtAtTick(tickUpper); const perL0 = sqrtP >= sHi ? 0 : (1 / Math.max(sqrtP, sLo) - 1 / sHi); const perL1 = sqrtP <= sLo ? 0 : (Math.min(sqrtP, sHi) - sLo); return { perL0, perL1 }; } // What the position manager will actually take from two balances at a price // inside a range: the liquidity the shorter side allows, and the two amounts // that liquidity needs. Minimums for a mint or an increase are a share of // THESE amounts — never of the balances. 2026-09-05: an increase asked for // 90% of every token the wallet held, the manager took the range's ratio, // the other side fell short of its own minimum and the call reverted // ("Price slippage check") after the tokens had already been bought. // Pure, exported for the self-test. export function amountsForRange(sqrtP, tickLower, tickUpper, have0, have1) { const { perL0, perL1 } = splitForRange(sqrtP, tickLower, tickUpper); const l0 = perL0 > 0 ? Number(have0) / perL0 : Infinity; const l1 = perL1 > 0 ? Number(have1) / perL1 : Infinity; const L = Math.min(l0, l1); if (!isFinite(L) || L <= 0) return { amount0: 0n, amount1: 0n, L: 0 }; return { amount0: BigInt(Math.floor(L * perL0)), amount1: BigInt(Math.floor(L * perL1)), L }; } // Minimums for a mint or an increase: what the range takes at the price now, // AND at that price moved by MINT_DRIFT_TICKS either way — per token the // smallest of the three, at 97%. The tolerance is in ticks, not in percent // of the amounts, because a percentage does not know how wide the range is. // 2026-09-05 12:50: a ±1% range (190 ticks) was minted with minimums at 97% // of the amounts read seconds before; the pool moved a few ticks before the // block, which in a range that narrow shifts the ratio by a percent per tick, // and the mint reverted ("Price slippage check") after the old position was // already unwound. 20 ticks is 0.2% of price: a normal few seconds on // CAKE/BNB pass, a sandwich that far costs less than a cent on this size. export const MIN_SHARE = 97n; export const MINT_DRIFT_TICKS = 20; export function minsForRange(sqrtP, tickLower, tickUpper, have0, have1, driftTicks = MINT_DRIFT_TICKS) { const shift = (t) => sqrtP * Math.pow(1.0001, t / 2); const at = [0, -driftTicks, driftTicks, -driftTicks / 2, driftTicks / 2].map((t) => amountsForRange(shift(t), tickLower, tickUpper, have0, have1)); const amount0 = at.reduce((m, x) => (x.amount0 < m ? x.amount0 : m), at[0].amount0); const amount1 = at.reduce((m, x) => (x.amount1 < m ? x.amount1 : m), at[0].amount1); return { amount0Min: (amount0 * MIN_SHARE) / 100n, amount1Min: (amount1 * MIN_SHARE) / 100n }; } // The trade that leaves the wallet holding the range's own ratio, so the // mint (or increase) that follows takes everything. Solved, not estimated: // spend x WBNB on the other side when the other side is short, sell s of // the other side when it is long, with x and s chosen so that after the // trade both sides buy the same liquidity — // (W - x) / perLWbnb = (C + x·r) / perLOther -> x = (W·pO - C·pW) / (pO + r·pW) // (W + s·r') / perLWbnb = (C - s) / perLOther -> s = (C·pW - W·pO) / (pW + r'·pO) // where r is what a WBNB buys of the other side (the quoter's rate, fee // included) and r' what a unit of the other side sells for. Until // 2026-09-14 the re-set sized the other side at 99% of the capital and // bought 2% more than that; the WBNB side then bound the mint and 1.6% of // the capital stayed in the wallet as CAKE, which the increase step put in // with four more transactions and a second swap fee. The same share at any // size. An edge of the range (one perL zero) resolves to "all of one side". // Pure; the self-test pins it. // // "All of one side" is the balance itself, never the double nearest to it: // above 2^53 wei (0.009 of a token) Number(balance) rounds up about every // second time, and a swap asking for a few hundred wei more than the wallet // holds reverts ("STF") — after the old range is already burnt (found by // reading 2026-09-18, before the first re-set upward ever ran). const capTo = (amount, balance) => { const b = BigInt(balance); return amount > b ? b : amount; }; export function tradeToRatio({ wbnb, other, perLWbnb, perLOther, otherPerWbnb, wbnbPerOther }) { const W = Number(wbnb), C = Number(other), pW = perLWbnb, pO = perLOther; if (!(pW > 0) && !(pO > 0)) return { side: null, amount: 0n }; const gap = W * pO - C * pW; if (gap > 0) { const x = gap / (pO + otherPerWbnb * pW); const amount = capTo(BigInt(Math.floor(Math.min(x, W))), wbnb); return amount > 0n ? { side: 'buy', amount } : { side: null, amount: 0n }; } if (gap < 0) { const s = -gap / (pW + wbnbPerOther * pO); const amount = capTo(BigInt(Math.floor(Math.min(s, C))), other); return amount > 0n ? { side: 'sell', amount } : { side: null, amount: 0n }; } return { side: null, amount: 0n }; } // THE PROFIT SHARE OF A RE-SET DOWNWARD (2026-09-18). A range the price fell // out of paid its fees mostly in the other side, so after the unwind the // wallet's WBNB is short of the share and the re-set kept all of it as // capital — the 50/50 rule held on the way up and never on the way down. // The missing part is sold out of the other side, the token the fees came // in, the way the collect sells it: `short` WBNB wanted, at `wbnbPerOther` // (the quoter's rate for one unit, fee in), half a percent over for the // rounding of rate and impact, never more than the wallet holds. Pure. export function shareShortfallSale({ short, wbnbPerOtherX18, haveOther }) { if (!(short > 0n) || !(wbnbPerOtherX18 > 0n) || !(haveOther > 0n)) return 0n; const want = ((short * 10n ** 18n) / wbnbPerOtherX18) * 1005n / 1000n + 1n; return want > haveOther ? haveOther : want; } // A trade of no size costs a transaction and moves nothing: below this much // WBNB the wallet is left as it is and the mint takes the ratio it can. export const TRADE_DUST_WBNB = 10n ** 14n; // 0.0001 BNB // -------------------------------------------------------------------------- // collect: fees -> BNB -> part kept as capital, the rest to the buyback wallet // -------------------------------------------------------------------------- export async function planCollect(pub, address, ladder = null) { const { positions, tokenId, pos, owed0, owed1, reserve } = await readPosition(pub, address, ladder); const gasBal = await pub.getBalance({ address }); const token0 = pos ? pos[2].toLowerCase() : null, token1 = pos ? pos[3].toLowerCase() : null; const wbnbIs0 = token0 === ADDR.WBNB; const other = pos ? (wbnbIs0 ? token1 : token0) : null; if (pos && !wbnbIs0 && token1 !== ADDR.WBNB) throw new Error('the position is not against WBNB; this agent only knows how to turn a WBNB pair into BNB'); const mainOwedWbnb = wbnbIs0 ? owed0 : owed1; const mainOwedOther = wbnbIs0 ? owed1 : owed0; // The reserve range beside it, when it stands in the same pool: its fees // are fees of the same position in two pieces (reserveCollect, lp-guards). const beside = !!(pos && reserve && reserve.pos && reserve.pos[2].toLowerCase() === token0 && reserve.pos[3].toLowerCase() === token1 && Number(reserve.pos[4]) === Number(pos[4])); const reserveOwedWbnb = beside ? (wbnbIs0 ? reserve.owed0 : reserve.owed1) : 0n; const reserveOwedOther = beside ? (wbnbIs0 ? reserve.owed1 : reserve.owed0) : 0n; // Tokens the wallet holds OUTSIDE the position — the headroom the mint left // over, or what an interrupted run did not finish — are capital, not fees. // They are reported here and re-used by the increase and the re-set; the // collect sells only what the collect itself returns. let heldOther = 0n, heldWbnb = 0n; if (pos) { heldOther = await read(pub, other, ABI.ERC20, 'balanceOf', [address]); heldWbnb = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [address]); } let otherInBnb = 0n, quoteOffPct = null, poolInfo = null, quoteVia = null; if (pos) poolInfo = await readPool(pub, pos); const fee = pos ? Number(pos[4]) : null; const poolWbnbPerOther = poolInfo ? (wbnbIs0 ? 1 / (poolInfo.sqrtP ** 2) : poolInfo.sqrtP ** 2) : 0; const reserveOwedBnb = bn(reserveOwedWbnb) + (Number(reserveOwedOther) / 1e18) * poolWbnbPerOther; const withReserve = beside ? reserveCollect(reserveOwedBnb) : { collect: false, why: null }; const owedWbnb = mainOwedWbnb + (withReserve.collect ? reserveOwedWbnb : 0n); const owedOther = mainOwedOther + (withReserve.collect ? reserveOwedOther : 0n); if (pos && owedOther > 0n) { // The collected other side is sold in the position's own V3 pool (since // 2026-09-13; until then through the V2 router, a 0.25% pair, five times // the 0.05% the position lives in). The quoter names the proceeds; the // V2 router only if the quoter will not answer, and the record says which. try { otherInBnb = await quoteV3(pub, other, ADDR.WBNB, fee, owedOther); quoteVia = 'v3'; } catch { const q = await read(pub, ADDR.V2_ROUTER, ABI.ROUTER, 'getAmountsOut', [owedOther, [other, ADDR.WBNB]]); otherInBnb = q[1]; quoteVia = 'v2'; } // The quote against the pool's own price. A broken quoter or a thin or // manipulated V2 pair would show here as a price far from the one the // position lives at. const quotedWbnbPerOther = Number(otherInBnb) / Number(owedOther); quoteOffPct = poolWbnbPerOther > 0 ? ((quotedWbnbPerOther - poolWbnbPerOther) / poolWbnbPerOther) * 100 : null; } const proceeds = bn(owedWbnb + otherInBnb); const state = { positions, liquidity: pos ? pos[7] : 0n, owedBnbEquivalent: proceeds, gasBnb: bn(gasBal), quoteOffPct }; return { step: 'collect', state, no: refuseCollect(state), tokenId, pos, other, wbnbIs0, fee, owedWbnb, owedOther, heldOther, heldWbnb, otherInBnb, quoteOffPct, quoteVia, reserveTokenId: withReserve.collect ? reserve.tokenId : null, summary: { position: tokenId == null ? null : String(tokenId), ticks: pos ? [Number(pos[5]), Number(pos[6])] : null, liquidity: pos ? String(pos[7]) : null, in_range: poolInfo ? poolInfo.inRange : null, owed: { wbnb: formatEther(owedWbnb), other: formatUnits(owedOther, 18), other_token: other, bnb_equivalent: proceeds }, ...(beside ? { reserve_owed: { position: String(reserve.tokenId), wbnb: formatEther(reserveOwedWbnb), other: formatUnits(reserveOwedOther, 18), bnb_equivalent: Number(reserveOwedBnb.toFixed(6)), collected_with_it: withReserve.collect, ...(withReserve.why ? { why: withReserve.why } : {}) } } : {}), held_outside_position: heldOther > 0n || heldWbnb > 0n ? { wbnb: formatEther(heldWbnb), other: formatUnits(heldOther, 18), note: 'capital, re-used by increase and re-set, not sold here' } : null, quote_off_pct: quoteOffPct == null ? null : Number(quoteOffPct.toFixed(2)), sells_via: quoteVia == null ? null : (quoteVia === 'v3' ? `the position's own V3 pool (${fee / 1e4}%)` : 'the V2 router (0.25%) — the V3 quoter did not answer'), wallet_bnb: state.gasBnb, }, }; } // Collect, sell, unwrap, split, forward — and forward ONLY what this run // produced. The wallet also holds the capital the sweep delivers for the next // increase; "everything above the reserve" would have sent that to the // buyback bot. `keptPct` of what was produced stays in the wallet as BNB — // capital for the next increase, so the position grows out of its own fees — // and the rest goes to the buyback wallet. The record carries both figures. export async function executeCollect(pub, wallet, account, plan, log = () => {}, { keptPct = FEE_SHARE_KEPT_PCT, txs = [] } = {}) { const send = sender(pub, wallet, txs, log); send.owner = account.address; const before = await pub.getBalance({ address: account.address }); const otherBefore = await read(pub, plan.other, ABI.ERC20, 'balanceOf', [account.address]); // WBNB the wallet already holds is capital that a wrap left behind (a // re-set, a reserve mint or an increase that stopped after its wrap), not // fees: it is unwrapped below so the increase step finds it as BNB, and it // is taken out of what this collect counts as produced (2026-09-18 — until // then half of it would have bought $BOBAI, never to come back). const wbnbBefore = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); await send('collect', { address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'collect', args: [{ tokenId: plan.tokenId, recipient: account.address, amount0Max: MAX128, amount1Max: MAX128 }] }); // The reserve range's fees in the same run, when they are worth a // transaction (reserveCollect): sold, split and counted with the rest. if (plan.reserveTokenId != null) await send("collect the reserve range's fees", { address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'collect', args: [{ tokenId: plan.reserveTokenId, recipient: account.address, amount0Max: MAX128, amount1Max: MAX128 }] }); // Only what the collect returned is sold; what the wallet held before it is // capital and stays. const otherAfter = await read(pub, plan.other, ABI.ERC20, 'balanceOf', [account.address]); const sell = otherAfter > otherBefore ? otherAfter - otherBefore : 0n; let swap = null; if (sell > 0n && plan.quoteVia === 'v3') { // The position's own pool, at its own fee, with the impact measured — // the same trade a re-set makes. The V3 router's allowance is set once. const q = await quoteV3(pub, plan.other, ADDR.WBNB, plan.fee, sell); swap = await swapV3(pub, send, account.address, plan.other, ADDR.WBNB, plan.fee, sell, q, q, 'sell the other side in its own pool'); } else if (sell > 0n) { await send('approve for sale', { address: plan.other, abi: ABI.ERC20, functionName: 'approve', args: [ADDR.V2_ROUTER, sell] }); // The other side of the home pool keeps nothing of a transfer, so the // floor is the trade's alone: 1% under the quote for its exact size (it // was 15% until 2026-09-19; this path has never run — the V3 quoter has // answered at every collect). const q = await read(pub, ADDR.V2_ROUTER, ABI.ROUTER, 'getAmountsOut', [sell, [plan.other, ADDR.WBNB]]); await send('sell the other side', { address: ADDR.V2_ROUTER, abi: ABI.ROUTER, functionName: 'swapExactTokensForETHSupportingFeeOnTransferTokens', args: [sell, (q[1] * 99n) / 100n, [plan.other, ADDR.WBNB], account.address, deadline()] }); const n = Number(formatEther(q[1])); swap = { side: 'sell', venue: `pancakeswap v2 ${V2_SWAP_FEE_PCT}%`, fee_pct: V2_SWAP_FEE_PCT, notional_bnb: Number(n.toFixed(6)), fee_bnb: Number((n * V2_SWAP_FEE_PCT / 100).toFixed(8)), why: 'the V3 quoter did not answer at plan time' }; } // Everything wrapped is unwrapped: this collect's WBNB is fees, what was // there before is capital that goes back to waiting as BNB. const wbnbHave = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); if (wbnbHave > 0n) await send('unwrap', { address: ADDR.WBNB, abi: ABI.ERC20, functionName: 'withdraw', args: [wbnbHave] }); const after = await pub.getBalance({ address: account.address }); const producedRaw = after - before - wbnbBefore; // net of the gas this run spent, without the capital found wrapped const produced = producedRaw > 0n ? producedRaw : 0n; const aboveReserve = after - GAS_RESERVE; // never dip into the reserve const forward = produced < aboveReserve ? produced : aboveReserve; const found = wbnbBefore > 0n ? { capital_found_wrapped_bnb: formatEther(wbnbBefore), capital_found_note: 'WBNB the wallet held before the collect, a wrap an earlier step left behind: unwrapped here as capital, not counted as fees' } : {}; if (forward <= 0n) return { txs, swap, ...found, forwarded_bnb: '0', kept_bnb: '0', why: 'collected, but nothing net of gas and the reserve to forward' }; const split = splitFees(forward, keptPct); const out = { txs, swap, ...found, produced_bnb: formatEther(forward), kept_bnb: formatEther(split.keep), kept_pct: split.pct }; if (split.buyback <= 0n) return { ...out, bobai_bnb: '0', why: `collected ${formatEther(forward)} BNB of fees; all of it stays as capital (kept share ${split.pct}%)` }; const bought = await buyBobaiHold(pub, send, account.address, split.buyback); return { ...out, bobai_bnb: formatEther(bought.spent), bobai_units: formatUnits(bought.units, 18), held_in: account.address }; } // -------------------------------------------------------------------------- // sweep: AI income -> BNB -> DeFi wallet // -------------------------------------------------------------------------- export async function readBnbUsd(pub) { const r = await read(pub, ADDR.CHAINLINK_BNB_USD, ABI.FEED, 'latestRoundData'); return { bnbUsd: Number(r[1]) / 1e8, feedAgeS: Math.floor(Date.now() / 1000) - Number(r[3]) }; } export async function planSweep(pub, source, feed = null) { const balance = await read(pub, source.token, ABI.ERC20, 'balanceOf', [source.wallet]); const gasBal = await pub.getBalance({ address: source.wallet }); const { bnbUsd, feedAgeS } = feed || (await readBnbUsd(pub)); // Bounded per run: a balance above the cap is swept in daily slices. const capRaw = parseEther(String(MAX_SWEEP_USD)); const amount = balance > capRaw ? capRaw : balance; let bnbOut = 0n, impliedUsd = null; if (amount > 0n) { const q = await read(pub, ADDR.V2_ROUTER, ABI.ROUTER, 'getAmountsOut', [amount, [source.token, ADDR.WBNB]]); bnbOut = q[1]; impliedUsd = (bn(bnbOut) * bnbUsd) / Number(formatUnits(amount, source.decimals)); } const state = { symbol: source.symbol, balance: Number(formatUnits(balance, source.decimals)), bnbEquivalent: bn(bnbOut), gasBnb: bn(gasBal), bnbUsd, feedAgeS, impliedUsd }; return { step: 'sweep', source, state, no: refuseSweep(state), amount, bnbOut, summary: { source: source.key, wallet: source.wallet, token: source.symbol, balance: state.balance, sweeping: Number(formatUnits(amount, source.decimals)), bnb_equivalent: state.bnbEquivalent, implied_usd: impliedUsd == null ? null : Number(impliedUsd.toFixed(4)), capped: balance > capRaw, wallet_bnb: state.gasBnb, }, }; } // Approve exactly the amount, sell it, and have the router pay the BNB // straight to the DeFi wallet — one transaction fewer, and the income // wallet never holds BNB it could be tempted to keep. export async function executeSweep(pub, wallet, account, plan, log = () => {}, { txs = [] } = {}) { const send = sender(pub, wallet, txs, log); const before = await pub.getBalance({ address: ADDR.LP_WALLET }); await send(`approve ${plan.source.symbol}`, { address: plan.source.token, abi: ABI.ERC20, functionName: 'approve', args: [ADDR.V2_ROUTER, plan.amount] }); // 3% floor: both pairs are deep and both tokens are dollars; the guard has // already refused a route that prices them off a dollar. await send(`sell ${plan.source.symbol} for BNB to the DeFi wallet`, { address: ADDR.V2_ROUTER, abi: ABI.ROUTER, functionName: 'swapExactTokensForETHSupportingFeeOnTransferTokens', args: [plan.amount, (plan.bnbOut * 9700n) / 10000n, [plan.source.token, ADDR.WBNB], ADDR.LP_WALLET, deadline()], }); const after = await pub.getBalance({ address: ADDR.LP_WALLET }); return { txs, sold: formatUnits(plan.amount, plan.source.decimals), received_bnb: formatEther(after - before), to: ADDR.LP_WALLET }; } // -------------------------------------------------------------------------- // rebalance: a position the price has left is re-set around today's price // -------------------------------------------------------------------------- // What a range of this width around the current tick looks like on this // pool's grid. Rounded INWARD, so the range is never wider than the one the // record tested. export function ticksAround(tick, widthPct, spacing) { const span = Math.log(1 + widthPct / 100) / Math.log(1.0001); const tickLower = Math.ceil((tick - span) / spacing) * spacing; const tickUpper = Math.floor((tick + span) / spacing) * spacing; return { tickLower, tickUpper }; } // THE ONE-SIDED RANGE (2026-09-16). A position the price has left holds one // token: all of token0 below its range, all of token1 above it. Until now a // re-set sold half of it to re-centre — at the low when the price had // fallen, at the high when it had risen — and twelve such trades cost 3.4% // of the capital in a fortnight, twice the fees. The new range is placed // beside the price instead, on the side the price came from, and needs // exactly the token the old range ended in: below its range the position // is all token0, and a range above the price is all token0 too; above, all // token1, and a range below the price is all token1. Nothing is traded; // the re-set costs its gas. The range spans what a centred ±width range // spans (the same liquidity for the same money, so the width record's fee // rows apply to it) and starts ONE_SIDED_GAP_TICKS beyond the price, so a // few ticks of drift before the block cannot put the price inside it. If // the price comes back it earns from the first tick and turns the fallen // side into the other one on the way up, with fees; if it goes on, the // position holds what it held — no worse than the hold the old rule // resorted to, and one re-set's gas away from earning again. // `side` is where the price is relative to the old range (rangeLeft): a // price below it gets a range above the price, and the other way round. export function ticksAdjacent(tick, widthPct, spacing, side, gapTicks = ONE_SIDED_GAP_TICKS) { const span = Math.round(2 * Math.log(1 + widthPct / 100) / Math.log(1.0001) / spacing) * spacing; if (side === 'below') { const tickLower = Math.ceil((tick + gapTicks) / spacing) * spacing; return { tickLower, tickUpper: tickLower + span, side: 'above_price' }; } const tickUpper = Math.floor((tick - gapTicks) / spacing) * spacing; return { tickLower: tickUpper - span, tickUpper, side: 'below_price' }; } // `record` is the verdict of the window record (agent.brainonbnb.com/lp/windows // or the same function over KV); its earnings_pick names the width — the one // that netted the most per day when every width was replayed over the recorded // prices with the agent's own re-set delay and cost. `widthOverride` is a // person's explicit choice from the hand script, and is reported as one. // `pool` is the pool the window record watches: when the wallet holds no // position but does hold that pool's two tokens, the plan is a mint from the // wallet — a re-set that stopped between its unwind and its mint (2026-09-05 // 12:50) is finished on the next run instead of leaving the capital idle. export async function planRebalance(pub, address, { record = null, widthOverride = null, position = null, pool = null, keptPct = FEE_SHARE_KEPT_PCT, ladder = null } = {}) { let p = position || (await readPosition(pub, address, ladder)); let resume = false; if (p.positions === 0 && pool) { p = { ...p, pos: await readPoolPair(pub, pool) }; resume = true; } let poolInfo = null, spacing = null, other = null, wbnbIs0 = false, valueBnb = 0, have = null, target = null, ticks = null, trade = null; let owedWei = 0n, share = null, left = null, oneSided = null; // The record's pick, re-read with the width the position is in: a width // in use is kept unless another leads it by the bar (pickWidth). Records // without weekly rows (before 2026-09-16) keep their own pick. const inUse = p.positions === 1 && p.pos ? widthClassOf([Number(p.pos[5]), Number(p.pos[6])]) : null; const pick = (Array.isArray(record?.rows) && record.rows.some((r) => r.earnings_7d) && (record.earnings_pick || record.hours_of_prices >= 24) ? pickWidth(record.rows, { current: inUse }) : null) || record?.earnings_pick || null; const width = widthOverride ?? pick?.width ?? null; const widthBasis = widthOverride != null ? 'named by hand' : (pick ? (pick.basis || `netted the most per day over ${record?.hours_of_prices} h of recorded prices: about $${pick.earnings?.net_usd_per_day} a day on $50 after ${pick.earnings?.resets} re-set${pick.earnings?.resets === 1 ? '' : 's'} at $${pick.earnings?.reset_cost_usd} each`) : null); if (p.positions === 1 || resume) { poolInfo = await readPool(pub, p.pos); spacing = Number(await read(pub, poolInfo.pool, ABI.POOL, 'tickSpacing')) || 1; const token0 = p.pos[2].toLowerCase(), token1 = p.pos[3].toLowerCase(); wbnbIs0 = token0 === ADDR.WBNB; if (!wbnbIs0 && token1 !== ADDR.WBNB) throw new Error('the position is not against WBNB; this agent only knows how to re-set a WBNB pair'); other = wbnbIs0 ? token1 : token0; // What the position holds at today's price, plus what sits in the wallet // outside it — all of it goes into the new range. const L = Number(p.pos[7]); const s = splitForRange(poolInfo.sqrtP, Number(p.pos[5]), Number(p.pos[6])); const in0 = L * s.perL0, in1 = L * s.perL1; const heldOther = Number(await read(pub, other, ABI.ERC20, 'balanceOf', [address])); const heldWbnb = Number(await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [address])); const price = poolInfo.sqrtP ** 2; const otherInWbnb = wbnbIs0 ? 1 / price : price; have = { other: (wbnbIs0 ? in1 : in0) + heldOther, wbnb: (wbnbIs0 ? in0 : in1) + heldWbnb }; valueBnb = (have.wbnb + have.other * otherInWbnb) / 1e18; // What the old range still owes in fees, in WBNB terms, and the part of // it a re-set would send on to the buyback wallet. if (!resume && p.owed0 != null) { const owedWbnb = wbnbIs0 ? p.owed0 : p.owed1, owedOther = wbnbIs0 ? p.owed1 : p.owed0; owedWei = owedWbnb + BigInt(Math.floor(Number(owedOther) * otherInWbnb)); share = resetForward(owedWei, keptPct); } // Where the price stands to the old range: inside, at an edge, or gone. if (!resume) left = rangeLeft(poolInfo.tick, Number(p.pos[5]), Number(p.pos[6])); if (width != null) { // A range the price has left is re-set one-sided, beside the price // (ticksAdjacent); a mint from the wallet (resume) or a range by hand // with the price inside is centred as before. oneSided = left && left.left ? left.side : null; // A resume finishes the re-set it belongs to: a wallet holding one // token alone is minted beside the price on that token's side, no // trade (resumeSide); a mixed wallet is centred as before. if (resume && valueBnb > 0) oneSided = resumeSide((have.other * otherInWbnb) / (valueBnb * 1e18)); ticks = oneSided ? ticksAdjacent(poolInfo.tick, width, spacing, oneSided) : ticksAround(poolInfo.tick, width, spacing); if (ticks.tickUpper <= ticks.tickLower) throw new Error(`a ${width}% range is narrower than this pool's tick spacing (${spacing})`); const n = splitForRange(poolInfo.sqrtP, ticks.tickLower, ticks.tickUpper); const perLOther = wbnbIs0 ? n.perL1 : n.perL0, perLWbnb = wbnbIs0 ? n.perL0 : n.perL1; const perLValue = perLWbnb + perLOther * otherInWbnb; const Ln = perLValue > 0 ? (valueBnb * 1e18) / perLValue : 0; target = { other: Ln * perLOther, wbnb: Ln * perLWbnb, perLOther, perLWbnb, otherInWbnb }; // The trade that turns what is held into what the new range needs — // the plan's estimate at the pool's mid price; the run sizes it again // from the wallet and the quoter (tradeToRange). const est = tradeToRatio({ wbnb: have.wbnb, other: have.other, perLWbnb, perLOther, otherPerWbnb: otherInWbnb > 0 ? 1 / otherInWbnb : 0, wbnbPerOther: otherInWbnb }); trade = est.side === 'sell' ? { sell: 'other', amount: Number(est.amount) } : est.side === 'buy' ? { sell: 'wbnb', amount: Number(est.amount) } : null; } } // A wallet without a position is never "in range"; resume says the plan is // a mint from what the wallet holds, and the guard sizes it like a re-set. const walletBnb = bn(await pub.getBalance({ address })); const state = { positions: p.positions, resume, walletBnb, inRange: poolInfo && !resume ? poolInfo.inRange : false, atEdge: !!(left && left.outside && !left.left), side: left ? left.side : null, ticksAway: left ? left.ticks_away : null, width, hoursOfPrices: record?.hours_of_prices || 0, valueBnb }; return { step: 'rebalance', state, no: refuseRebalance(state), resume, oneSided, tokenId: p.tokenId, pos: p.pos, poolInfo, spacing, other, wbnbIs0, width, ticks, target, trade, summary: { position: p.tokenId == null ? null : String(p.tokenId), ...(p.main_missing ? { main_missing: p.main_missing } : {}), ...(resume ? { resumed_from_wallet: true, held: have ? { other: (have.other / 1e18).toFixed(6), wbnb: (have.wbnb / 1e18).toFixed(6) } : null } : {}), ticks: p.tokenId != null ? [Number(p.pos[5]), Number(p.pos[6])] : null, tick: poolInfo ? poolInfo.tick : null, in_range: state.inRange, ...(state.atEdge ? { at_edge: true } : {}), ...(left && left.outside ? { price_side: left.side, ticks_beyond_edge: left.ticks_away } : {}), ...(oneSided ? { one_sided: ticks.side, one_sided_why: `the price is ${oneSided} the old range, which ended all in one token; the new range sits ${ticks.side === 'above_price' ? 'above' : 'below'} the price and takes that token as it is — no trade` } : {}), pool: poolInfo ? String(poolInfo.pool).toLowerCase() : null, wbnb_is0: wbnbIs0, // The main range and what waits in the wallet; the reserve range of // the ladder, when there is one, beside it — the sizing above is the // main range's alone, the reserve stays where it is. value_bnb: Number(valueBnb.toFixed(6)), ...(p.reserve && poolInfo ? { reserve: { position: String(p.reserve.tokenId), ticks: [Number(p.reserve.pos[5]), Number(p.reserve.pos[6])], value_bnb: Number(positionSide(p.reserve.pos, poolInfo.sqrtP, wbnbIs0).valueBnb.toFixed(6)), side: positionSide(p.reserve.pos, poolInfo.sqrtP, wbnbIs0).side }, value_with_reserve_bnb: Number((valueBnb + positionSide(p.reserve.pos, poolInfo.sqrtP, wbnbIs0).valueBnb).toFixed(6)) } : {}), width_pct: width, width_basis: widthBasis, expected_net_usd_per_day: pick ? pick.earnings.net_usd_per_day : null, new_ticks: ticks ? [ticks.tickLower, ticks.tickUpper] : null, trade: trade ? (trade.sell === 'other' ? `sell ${(trade.amount / 1e18).toFixed(6)} of ${other} for WBNB` : `buy the other side with ${(trade.amount / 1e18).toFixed(6)} WBNB`) : null, fees_owed_bnb: Number((Number(owedWei) / 1e18).toFixed(6)), fees_to_bobai_bnb: share ? Number((Number(share.forward) / 1e18).toFixed(6)) : 0, fees_kept_pct: share ? share.pct : null, }, }; } // The three calls that empty the old position, as one transaction: withdraw // its liquidity, collect what it held, burn the NFT. The position manager's // multicall runs them in order inside one transaction and reverts as a whole // if any of them does. Pure, so the self-test can pin what is encoded. export function unwindCalls(tokenId, liquidity, amount0Min, amount1Min, recipient, dl) { return [ encodeFunctionData({ abi: ABI.NPM, functionName: 'decreaseLiquidity', args: [{ tokenId, liquidity, amount0Min, amount1Min, deadline: dl }] }), encodeFunctionData({ abi: ABI.NPM, functionName: 'collect', args: [{ tokenId, recipient, amount0Max: MAX128, amount1Max: MAX128 }] }), encodeFunctionData({ abi: ABI.NPM, functionName: 'burn', args: [tokenId] }), ]; } // An approval that is only sent when the allowance is short. The first // build approved the exact amount before every trade and every mint — four // approvals in a nine-transaction re-set, each one paid for. An allowance of // the full amount range to PancakeSwap's own router and position manager is // what every PancakeSwap user grants in the interface, and it means the next // re-set skips these four transactions entirely. const MAX_ALLOWANCE = (1n << 256n) - 1n; // The re-centring trade, written down: which side, how much in WBNB terms, // which pool and the fee it paid. The window record charges this on top of // the gas when it replays the re-sets; without it the replay undercounted a // re-set by half (2026-09-09). export function swapNote(side, wbnbWei, fee) { const n = Number(formatEther(wbnbWei)), pct = fee / 10000; return { side, venue: `pancakeswap v3 ${pct}%`, fee_pct: pct, notional_bnb: Number(n.toFixed(6)), fee_bnb: Number((n * pct / 100).toFixed(8)) }; } // The one V3 swap the agent makes, as the router's struct. Pure, so the // self-test can pin every field; sqrtPriceLimitX96 = 0 means "no limit, the // minimum out is the guard". export function v3SwapArgs(tokenIn, tokenOut, fee, recipient, amountIn, amountOutMinimum, dl) { return { tokenIn, tokenOut, fee, recipient, deadline: dl, amountIn, amountOutMinimum, sqrtPriceLimitX96: 0n }; } // What the position's own pool gives for amountIn, from the quoter. export async function quoteV3(pub, tokenIn, tokenOut, fee, amountIn) { const q = await read(pub, ADDR.V3_QUOTER, ABI.V3_QUOTER, 'quoteExactInputSingle', [{ tokenIn, tokenOut, amountIn, fee, sqrtPriceLimitX96: 0n }]); return q[0]; } // Allow, swap, and return the note for the record. notionalWbnb is the // trade in WBNB terms: what went in when WBNB is sold, what the quote said // comes out when the other side is. // Since 2026-09-11 the swap also measures what it lost against the pool's // mid price: the quoter is asked for a sliver first (a fill too small to // move the price, the fee already off), that rate times the amount is what // a trade of no size would have received, and the wallet's balance of the // out-token before and after says what this one did. The gap is the price // impact, in WBNB — the piece of a re-set's cost that neither gas nor the // fee rate names, and the piece that grows with the position. A wallet // that cannot be read for it records the trade without it, never a guess. // // THE FLOOR UNDER A SWAP (2026-09-19). The minimum out guards one thing: the // price moving between the quote and the block, by itself or pushed by someone // who saw the transaction. The quote is for the exact size, so the pool's fee // and the trade's own impact are already in it. Until today the floor was 1% // below the quote and the $BOBAI buy's 15%. Read off the chain for every swap // this code has sent: nine pool swaps, eight received the quote to the wei and // one 0.0057% less; eleven $BOBAI buys, each exactly the 3% the token keeps and // nothing else. A floor of 1% is room nobody but a sandwich uses. Now 0.3% // under a pool quote, and the $BOBAI buy as the tax bot sizes its own // (worker/index.js minOutFor): the 3% off first, then 5% — that pair is thin // and one other buyer in the block moves it. A floor that close can refuse a // swap the old one let through; a swap refused in the node's estimate has cost // nothing, so it is asked once more at a fresh quote (requoteOnce). One that // was broadcast and reverted is not repeated: the step stops, as before. export const POOL_SWAP_FLOOR_BPS = 30n; export const BOBAI_TRANSFER_TAX_BPS = 300n; export const BOBAI_BUY_ROOM_PCT = 5n; export function swapFloor(quoted) { return (quoted * (10000n - POOL_SWAP_FLOOR_BPS)) / 10000n; } export function bobaiBuyFloor(quoted) { return (((quoted * (10000n - BOBAI_TRANSFER_TAX_BPS)) / 10000n) * (100n - BOBAI_BUY_ROOM_PCT)) / 100n; } export async function requoteOnce(attempt) { try { return await attempt(false); } catch (e) { if (!e || e.sent !== false) throw e; return attempt(true); } } async function swapV3(pub, send, owner, tokenIn, tokenOut, fee, amountIn, quoted, notionalWbnb, label) { await ensureAllowance(pub, send, tokenIn, ADDR.V3_SWAP_ROUTER, amountIn, `allow the V3 router to spend ${tokenIn === ADDR.WBNB ? 'WBNB' : 'the other side'} (once)`); let sliver = null, before = null; try { const tiny = 10n ** 12n; sliver = await quoteV3(pub, tokenIn, tokenOut, fee, tiny); before = await read(pub, tokenOut, ABI.ERC20, 'balanceOf', [owner]); } catch { sliver = null; } await requoteOnce(async (again) => { const q = again ? await quoteV3(pub, tokenIn, tokenOut, fee, amountIn) : quoted; return send(label, { address: ADDR.V3_SWAP_ROUTER, abi: ABI.V3_ROUTER, functionName: 'exactInputSingle', args: [v3SwapArgs(tokenIn, tokenOut, fee, owner, amountIn, swapFloor(q), deadline())] }); }); const note = swapNote(tokenIn === ADDR.WBNB ? 'buy' : 'sell', notionalWbnb, fee); if (sliver != null && sliver > 0n && before != null) { try { const after = await read(pub, tokenOut, ABI.ERC20, 'balanceOf', [owner]); const received = after > before ? after - before : 0n; const atMid = (sliver * amountIn) / 10n ** 12n; const gapOut = atMid > received ? atMid - received : 0n; // A buy's gap is in the other token; the sliver's own rate turns it into WBNB. const gapWbnb = tokenOut === ADDR.WBNB ? Number(gapOut) : (Number(gapOut) * Number(amountIn)) / Number(atMid); note.impact_bnb = Number((gapWbnb / 1e18).toFixed(8)); note.impact_pct = note.notional_bnb > 0 ? Number(((gapWbnb / 1e18) / note.notional_bnb * 100).toFixed(3)) : null; note.impact_basis = 'measured: the quoter\'s rate for a sliver times the amount, against what the wallet received'; } catch { /* the trade stands in the record without its impact */ } } return note; } // The agent's profit share buys BOBAI and holds it in the DeFi wallet, // never sells it — so the operator sees, in the wallet, exactly what the agent // earned (2026-09-09: "keep it as BOBAI in its own wallet, then I see what // really comes in"). It used to send that BNB to the buyback wallet. BOBAI // takes a 3% transfer tax, so the swap uses the fee-supporting router call and // the floor takes the tax off the quote first, then 5% (bobaiBuyFloor; it was a // flat 15% until 2026-09-19). Returns BNB spent and BOBAI held. async function buyBobaiHold(pub, send, owner, bnbWei) { const before = await read(pub, ADDR.BOBAI, ABI.ERC20, 'balanceOf', [owner]); await requoteOnce(async () => { const q = await read(pub, ADDR.V2_ROUTER, ABI.ROUTER, 'getAmountsOut', [bnbWei, [ADDR.WBNB, ADDR.BOBAI]]); return send('buy BOBAI with the profit share and hold it', { address: ADDR.V2_ROUTER, abi: ABI.ROUTER, functionName: 'swapExactETHForTokensSupportingFeeOnTransferTokens', args: [bobaiBuyFloor(q[1]), [ADDR.WBNB, ADDR.BOBAI], owner, deadline()], value: bnbWei }); }); const after = await read(pub, ADDR.BOBAI, ABI.ERC20, 'balanceOf', [owner]); return { spent: bnbWei, units: after > before ? after - before : 0n }; } // What the position holds at today's price plus the two tokens sitting in // the wallet beside it, in BNB. The re-set plan values the same way; the // increase records it too since 2026-09-09, because a deposit the watch puts // in between daily runs is a series point of its own and a point without a // value read as the capital gone (the −30 $ of 2026-09-09 11:00). export async function positionValueBnb(pub, address, p, poolInfo) { if (!p || p.positions !== 1 || !poolInfo) return null; const token0 = p.pos[2].toLowerCase(), token1 = p.pos[3].toLowerCase(); const wbnbIs0 = token0 === ADDR.WBNB; const other = wbnbIs0 ? token1 : token0; const L = Number(p.pos[7]); const s = splitForRange(poolInfo.sqrtP, Number(p.pos[5]), Number(p.pos[6])); const in0 = L * s.perL0, in1 = L * s.perL1; const heldOther = Number(await read(pub, other, ABI.ERC20, 'balanceOf', [address])); const heldWbnb = Number(await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [address])); const price = poolInfo.sqrtP ** 2; const otherInWbnb = wbnbIs0 ? 1 / price : price; const wbnb = (wbnbIs0 ? in0 : in1) + heldWbnb, oth = (wbnbIs0 ? in1 : in0) + heldOther; return Number(((wbnb + oth * otherInWbnb) / 1e18).toFixed(6)); } // Trade the wallet into the range's ratio (tradeToRatio) from what it really // holds now, at the quoter's rate for the size that will trade: the rate is // asked for a unit first, the size solved from it, then asked again for that // size and solved once more, so the fee and the impact of the trade itself // are in the amount. `reserved` WBNB (the profit share) is kept out of the // sizing and never spent. Returns the swap note, or null when nothing traded. async function tradeToRange(pub, send, owner, other, fee, perLWbnb, perLOther, { reserved = 0n, buyLabel = 'buy the missing other side', sellLabel = 'sell the excess of the other side' } = {}) { const haveOther = await read(pub, other, ABI.ERC20, 'balanceOf', [owner]); const haveWbnbAll = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [owner]); const haveWbnb = haveWbnbAll > reserved ? haveWbnbAll - reserved : 0n; const unit = 10n ** 18n; const rateBuy = async (amountIn) => { const q = await quoteV3(pub, ADDR.WBNB, other, fee, amountIn); return { q, r: Number(q) / Number(amountIn) }; }; const rateSell = async (amountIn) => { const q = await quoteV3(pub, other, ADDR.WBNB, fee, amountIn); return { q, r: Number(q) / Number(amountIn) }; }; const u = await rateBuy(unit); const v = u.q > 0n ? await rateSell(u.q) : { q: 0n, r: 0 }; const args = { wbnb: haveWbnb, other: haveOther, perLWbnb, perLOther, otherPerWbnb: u.r, wbnbPerOther: v.r }; let t = tradeToRatio(args); if (t.side == null) return null; // Second pass at the size itself. if (t.side === 'buy') { const at = await rateBuy(t.amount); t = tradeToRatio({ ...args, otherPerWbnb: at.r }); if (t.side !== 'buy' || t.amount < TRADE_DUST_WBNB) return null; const q = await quoteV3(pub, ADDR.WBNB, other, fee, t.amount); return swapV3(pub, send, owner, ADDR.WBNB, other, fee, t.amount, q, t.amount, buyLabel); } const at = await rateSell(t.amount); t = tradeToRatio({ ...args, wbnbPerOther: at.r }); if (t.side !== 'sell') return null; const q = await quoteV3(pub, other, ADDR.WBNB, fee, t.amount); if (q < TRADE_DUST_WBNB) return null; return swapV3(pub, send, owner, other, ADDR.WBNB, fee, t.amount, q, q, sellLabel); } async function ensureAllowance(pub, send, token, spender, amount, label) { const have = await read(pub, token, ABI.ERC20, 'allowance', [send.owner, spender]); if (have >= amount) return false; await send(label, { address: token, abi: ABI.ERC20, functionName: 'approve', args: [spender, MAX_ALLOWANCE] }); return true; } // Empty the old position, burn its NFT, trade to the new ratio, send the // buyback share of the old range's fees on, mint the new range from what the // wallet then holds. Native BNB is not touched: the reserve and any capital // waiting for the increase stay where they are (the forwarded share is // unwrapped and sent in the same breath, so it never sits there). // Nine transactions on 2026-09-02; four to five since 2026-09-04 (one // multicall for the unwind, approvals only when the allowance is short); // two more since 2026-09-08 when the fee share is worth sending. // The BNB waiting in the wallet above the reserve and the gas budget, wrapped // so a re-set or a move mints it with the rest. Until 2026-09-10 12:20 UTC a // re-set forced by a deposit sold the other side down to the ratio and the // increase a minute later bought it back: two trades, two fees, two price // impacts on the same capital. Sized like the increase step's own reserve. async function wrapWaiting(pub, send, address) { const bal = await pub.getBalance({ address }); const spend = bal - parseEther(String(GAS_RESERVE_BNB)) - parseEther(String(INCREASE_GAS_BUDGET_BNB)); if (spend < parseEther(String(MIN_INCREASE_BNB))) return 0n; await send(`wrap ${formatEther(spend)} BNB waiting in the wallet so the new range takes it`, { address: ADDR.WBNB, abi: ABI.ERC20, functionName: 'deposit', value: spend }); return spend; } export async function executeRebalance(pub, wallet, account, plan, log = () => {}, { keptPct = FEE_SHARE_KEPT_PCT, wrapFirst = false, txs = [] } = {}) { const send = sender(pub, wallet, txs, log); send.owner = account.address; // A resumed re-set (plan.resume) has no position to unwind: the earlier run // already did that and stopped before its mint. // The fees the old range still owes are not collected as fees here: the // unwind pays them out with the principal. Read them first, so the record // can count them as fees — on 2026-09-07 the three re-sets had folded in // 0.000998 BNB that every fees figure said was zero — and so the buyback // share of them can be sent on before the mint folds the rest in. let folded = null, share = null; // The re-centring trade, written down: which side, how much in WBNB terms, // and the pool fee it paid. The window record charges this on top of the // gas when it replays the re-sets; without it the replay undercounted a // re-set by half (2026-09-09). let swap = null; const fee = Number(plan.pos[4]); if (plan.tokenId != null) { // The range the plan names, read by its id — not "the wallet's position": // beside a reserve the wallet holds two, that read names none, and the // fees the old range owed were folded in uncounted, with no $BOBAI bought // for their half (2026-09-17; the re-set of 09-16 08:50 owed 0.000005 BNB). const old = await readOne(pub, account.address, plan.tokenId); if (old.tokenId != null && String(old.tokenId) === String(plan.tokenId)) { const owedWbnb = plan.wbnbIs0 ? old.owed0 : old.owed1, owedOther = plan.wbnbIs0 ? old.owed1 : old.owed0; const otherInWbnb = plan.target && plan.target.otherInWbnb ? plan.target.otherInWbnb : 0; const owedWei = owedWbnb + BigInt(Math.floor(Number(owedOther) * otherInWbnb)); folded = { wbnb: formatEther(owedWbnb), other: formatUnits(owedOther, 18), bnb_equivalent: Number((Number(owedWei) / 1e18).toFixed(6)) }; share = resetForward(owedWei, keptPct); } } const reserved = share && share.forward > 0n ? share.forward : 0n; if (plan.tokenId != null) { const liquidity = plan.pos[7]; const sim = await pub.simulateContract({ address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'decreaseLiquidity', args: [{ tokenId: plan.tokenId, liquidity, amount0Min: 0n, amount1Min: 0n, deadline: deadline() }], account, }); const calls = unwindCalls(plan.tokenId, liquidity, (sim.result[0] * 99n) / 100n, (sim.result[1] * 99n) / 100n, account.address, deadline()); await pub.simulateContract({ address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'multicall', args: [calls], account }); await send('withdraw, collect and burn the old range (one transaction)', { address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'multicall', args: [calls] }); } // A deposit waiting as BNB joins a centred mint wrapped, and a one-sided // mint below the price (all WBNB) the same way; a one-sided mint above the // price is all of the other side, and the trade below buys it with the // WBNB the wallet then holds — the deposit buys the fallen side at its // low instead of idling, which is what a deposit beside a left range is // for. Either way the BNB is wrapped first. const wrapped = wrapFirst ? await wrapWaiting(pub, send, account.address) : 0n; // Sized from what the wallet really holds now, not from the plan's // estimate, into the new range's exact ratio (tradeToRange). The buyback // share is not capital: it is kept out of the sizing, so the trade leaves // it as WBNB for the transfer. const t = plan.target; swap = await tradeToRange(pub, send, account.address, plan.other, fee, t.perLWbnb, t.perLOther, { reserved }); // The buyback share of the old range's fees leaves here, before the mint // can fold it into the new capital: unwrapped and sent in the same breath. // A wallet that holds less WBNB than the share after the trades (a range // that ended all on the other side, with the buy capped) keeps it as // capital and says so; the mint takes what is there. let boughtBobai = 0n, bobaiUnits = 0n, forwardWhy = share ? share.why : null; let shareSwap = null; if (reserved > 0n) { let wbnbNow = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); if (wbnbNow < reserved) { // Short of the share: the fees came in the other side (a re-set // downward). Sell that much of it — the share is not capital. const haveOther = await read(pub, plan.other, ABI.ERC20, 'balanceOf', [account.address]); const unit = await quoteV3(pub, plan.other, ADDR.WBNB, fee, 10n ** 18n).catch(() => 0n); const amountIn = shareShortfallSale({ short: reserved - wbnbNow, wbnbPerOtherX18: unit, haveOther }); if (amountIn > 0n) { const q = await quoteV3(pub, plan.other, ADDR.WBNB, fee, amountIn); shareSwap = await swapV3(pub, send, account.address, plan.other, ADDR.WBNB, fee, amountIn, q, q, "sell the profit share's part of the old range's fees (they came in the other side)"); wbnbNow = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); } } if (wbnbNow >= reserved) { await send("unwrap the profit share of the old range's fees", { address: ADDR.WBNB, abi: ABI.ERC20, functionName: 'withdraw', args: [reserved] }); const bought = await buyBobaiHold(pub, send, account.address, reserved); boughtBobai = reserved; bobaiUnits = bought.units; } else { forwardWhy = `the wallet held ${formatEther(wbnbNow)} WBNB after the trades, less than the ${formatEther(reserved)} BNB share — it stays as capital`; } } const mintOther = await read(pub, plan.other, ABI.ERC20, 'balanceOf', [account.address]); const mintWbnb = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); await ensureAllowance(pub, send, plan.other, ADDR.V3_POSITION_MANAGER, mintOther, 'allow the position manager to take the other side (once)'); await ensureAllowance(pub, send, ADDR.WBNB, ADDR.V3_POSITION_MANAGER, mintWbnb, 'allow the position manager to take WBNB (once)'); const amount0Desired = plan.wbnbIs0 ? mintWbnb : mintOther; const amount1Desired = plan.wbnbIs0 ? mintOther : mintWbnb; // The minimums come from what the new range takes at the price now, not // from the balances (see amountsForRange). // A one-sided range is entirely on one side of the price: the manager takes // all of the held token and none of the other, and the minimum is 97% of // the held token as it is. The drift tolerance of a centred mint would // shift the price INTO the range on one side and read the minimum as // zero there; a mint the price has entered would then take almost // nothing without reverting. Here it reverts instead, and the hour after // finishes the re-set from the wallet (resume). // A ONE-SIDED RANGE IS PLACED WHERE THE PRICE IS NOW (2026-09-18), not where // it was when the plan was read: between the two lie the merge, the unwind, // the share's sale and the $BOBAI buy. The plan's ticks start 20-29 ticks // from the plan's price; a price that crossed them by now would take the // held token only in part — dust liquidity, or a revert with the old range // already burnt. Beside the price now it takes all of it, always. const poolNow = await readPool(pub, plan.pos); const sqrtNow = poolNow.sqrtP; if (plan.oneSided && plan.width != null && plan.spacing) plan = { ...plan, ticks: ticksAdjacent(poolNow.tick, plan.width, plan.spacing, plan.oneSided) }; const mintMins = plan.oneSided ? (() => { const a = amountsForRange(sqrtNow, plan.ticks.tickLower, plan.ticks.tickUpper, amount0Desired, amount1Desired); return { amount0Min: (a.amount0 * MIN_SHARE) / 100n, amount1Min: (a.amount1 * MIN_SHARE) / 100n }; })() : minsForRange(sqrtNow, plan.ticks.tickLower, plan.ticks.tickUpper, amount0Desired, amount1Desired); const idsBeforeMint = await heldIds(pub, account.address); const mintReceipt = await send(`mint the new range ${plan.ticks.tickLower} … ${plan.ticks.tickUpper}`, { address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'mint', args: [{ token0: plan.pos[2], token1: plan.pos[3], fee: Number(plan.pos[4]), tickLower: plan.ticks.tickLower, tickUpper: plan.ticks.tickUpper, amount0Desired, amount1Desired, ...mintMins, recipient: account.address, deadline: deadline(), }] }); const wbnbLeft = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); if (wbnbLeft > 0n) await send('unwrap what was not needed', { address: ADDR.WBNB, abi: ABI.ERC20, functionName: 'withdraw', args: [wbnbLeft] }); const mintedId = mintedIn(mintReceipt, account.address); const np = mintedId != null ? { tokenId: BigInt(mintedId), pos: await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'positions', [BigInt(mintedId)]) } : await mintedSince(pub, account.address, idsBeforeMint); const gasBnb = txs.reduce((s, t) => s + (t.gas_bnb || 0), 0); // fees_folded_bnb is all the old range owed; fees_forwarded_bnb the part of // it that went to the buyback wallet; the difference was minted into the // new capital. Records before 2026-09-08 carry only the first. return { txs, gas_bnb: Number(gasBnb.toFixed(6)), swap, swap_fee_bnb: swap ? swap.fee_bnb : 0, ...(plan.oneSided ? { one_sided: plan.ticks.side } : {}), ...(wrapped > 0n ? { wrapped_waiting_bnb: Number(formatEther(wrapped)) } : {}), ...(shareSwap ? { share_swap: shareSwap } : {}), new_position: np.tokenId == null ? null : String(np.tokenId), new_ticks: [plan.ticks.tickLower, plan.ticks.tickUpper], liquidity_after: np.pos ? String(np.pos[7]) : null, ...(folded ? { fees_folded: folded, fees_folded_bnb: folded.bnb_equivalent, bobai_bnb: Number(formatEther(boughtBobai)), bobai_units: Number(formatUnits(bobaiUnits, 18)), fees_kept_pct: share ? share.pct : null, ...(boughtBobai > 0n ? { bobai_held_in: account.address } : { fees_forward_why: forwardWhy }), } : {}) }; } // -------------------------------------------------------------------------- // relocate: the whole position -> another pool of the universe // -------------------------------------------------------------------------- // A re-set that changes pools. The pool record (worker-agent/lp-pools.js) // replays the same fifty dollars in every pool the rule allows and its // switch rule says when the best of them is worth the move; this is the // move. Everything the position holds, plus what waits beside it, goes: // withdraw and burn the old range, sell its other side for WBNB in its own // tier (unless the new pool is the same pair in another tier), buy the new // pair's other side in the new tier for the share the new range needs, send // the profit share of the old range's fees into $BOBAI as a re-set does, // mint in the new pool. The width is the class the position had: the width // record measures the pool the agent is in, and it starts over on the new // pool the hour after the move (lp-windows.js drops a record whose pool // changed), so the first range there is the shape that earned here and the // record corrects it once it can. export async function planRelocate(pub, address, { toPool = null, widthOverride = null, keptPct = FEE_SHARE_KEPT_PCT, move = null } = {}) { const p = await readPosition(pub, address); const to = String(toPool || '').toLowerCase(); let from = null, target = null, have = null, valueBnb = 0, owedWei = 0n, share = null, ticks = null, tgt = null; let width = widthOverride ?? null; if (p.positions === 1) { const poolInfo = await readPool(pub, p.pos); const token0 = p.pos[2].toLowerCase(), token1 = p.pos[3].toLowerCase(); const wbnbIs0 = token0 === ADDR.WBNB; if (!wbnbIs0 && token1 !== ADDR.WBNB) throw new Error('the position is not against WBNB; this agent only knows how to move a WBNB pair'); const other = wbnbIs0 ? token1 : token0; const L = Number(p.pos[7]); const sp = splitForRange(poolInfo.sqrtP, Number(p.pos[5]), Number(p.pos[6])); const in0 = L * sp.perL0, in1 = L * sp.perL1; const heldOther = Number(await read(pub, other, ABI.ERC20, 'balanceOf', [address])); const heldWbnb = Number(await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [address])); const price = poolInfo.sqrtP ** 2; const otherInWbnb = wbnbIs0 ? 1 / price : price; have = { other: (wbnbIs0 ? in1 : in0) + heldOther, wbnb: (wbnbIs0 ? in0 : in1) + heldWbnb }; valueBnb = (have.wbnb + have.other * otherInWbnb) / 1e18; if (width == null) width = widthClassOf([Number(p.pos[5]), Number(p.pos[6])]); if (p.owed0 != null) { const owedWbnb = wbnbIs0 ? p.owed0 : p.owed1, owedOther = wbnbIs0 ? p.owed1 : p.owed0; owedWei = owedWbnb + BigInt(Math.floor(Number(owedOther) * otherInWbnb)); share = resetForward(owedWei, keptPct); } from = { pool: String(poolInfo.pool).toLowerCase(), other, fee: Number(p.pos[4]), wbnbIs0, tokenId: p.tokenId, tick: poolInfo.tick, inRange: poolInfo.inRange, otherInWbnb, ticks: [Number(p.pos[5]), Number(p.pos[6])] }; } if (/^0x[0-9a-f]{40}$/.test(to)) { const pair = await readPoolPair(pub, to); const t0 = String(pair[2]).toLowerCase(), t1 = String(pair[3]).toLowerCase(); const wbnbIs0 = t0 === ADDR.WBNB; const hasWbnb = wbnbIs0 || t1 === ADDR.WBNB; const spacing = Number(await read(pub, to, ABI.POOL, 'tickSpacing')) || 1; const s0 = await read(pub, to, ABI.POOL, 'slot0'); const sqrtP = Number(s0[0]) / 2 ** 96, tick = Number(s0[1]); const price = sqrtP ** 2; const otherInWbnb = wbnbIs0 ? 1 / price : price; target = { pool: to, pair, token0: pair[2], token1: pair[3], fee: Number(pair[4]), other: wbnbIs0 ? t1 : t0, wbnbIs0, hasWbnb, spacing, sqrtP, tick, otherInWbnb }; if (hasWbnb && width != null && valueBnb > 0) { ticks = ticksAround(tick, width, spacing); if (ticks.tickUpper <= ticks.tickLower) throw new Error(`a ${width}% range is narrower than this pool's tick spacing (${spacing})`); const n = splitForRange(sqrtP, ticks.tickLower, ticks.tickUpper); const perLOther = wbnbIs0 ? n.perL1 : n.perL0, perLWbnb = wbnbIs0 ? n.perL0 : n.perL1; const perLValue = perLWbnb + perLOther * otherInWbnb; const capital = valueBnb * 1e18 - (share ? Number(share.forward) : 0); const Ln = perLValue > 0 ? capital / perLValue : 0; tgt = { other: Ln * perLOther, wbnb: Ln * perLWbnb, perLOther, perLWbnb, otherInWbnb }; } } const sameOther = !!(from && target && from.other === target.other); const state = { positions: p.positions, hasTarget: !!target, targetHasWbnb: target ? target.hasWbnb : false, samePool: !!(from && target && from.pool === target.pool), toPool: target ? target.pool : null, width, valueBnb, move, }; const trades = []; if (from && target && tgt) { if (!sameOther && have.other > 0) trades.push(`sell all ${(have.other / 1e18).toFixed(6)} of ${from.other} for WBNB in the old pool`); const keep = sameOther ? have.other : 0; if (tgt.other > keep) trades.push(`buy ${((tgt.other - keep) / 1e18).toFixed(6)} of ${target.other} with ~${(((tgt.other - keep) * tgt.otherInWbnb) / 1e18).toFixed(6)} WBNB in the new pool`); else if (keep > tgt.other) trades.push(`sell ${((keep - tgt.other) / 1e18).toFixed(6)} of ${target.other} for WBNB in the new pool`); } return { step: 'relocate', state, no: refuseRelocate(state), tokenId: p.tokenId, pos: p.pos, from, to: target, width, ticks, target: tgt, sameOther, share, summary: { position: p.tokenId == null ? null : String(p.tokenId), from_pool: from ? from.pool : null, from_ticks: from ? from.ticks : null, in_range: from ? from.inRange : false, to_pool: target ? target.pool : null, to_fee_pct: target ? target.fee / 10000 : null, to_tick: target ? target.tick : null, value_bnb: Number(valueBnb.toFixed(6)), width_pct: width, width_basis: widthOverride != null ? 'named by hand' : 'the class the position had; the width record starts over on the new pool', new_ticks: ticks ? [ticks.tickLower, ticks.tickUpper] : null, trades: trades.length ? trades : null, fees_owed_bnb: Number((Number(owedWei) / 1e18).toFixed(6)), fees_to_bobai_bnb: share ? Number((Number(share.forward) / 1e18).toFixed(6)) : 0, fees_kept_pct: share ? share.pct : null, ...(move ? { move: move.move, move_why: move.why } : {}), }, }; } export async function executeRelocate(pub, wallet, account, plan, log = () => {}, { keptPct = FEE_SHARE_KEPT_PCT, wrapFirst = true, txs = [] } = {}) { if (!plan.from || !plan.to || !plan.target || !plan.ticks) throw new Error('the plan carries no move'); const send = sender(pub, wallet, txs, log); send.owner = account.address; const swaps = []; // 1. What the old range owes, read now, so the record counts it as fees // and the profit share of it can leave before the mint. const old = await readPosition(pub, account.address); if (old.tokenId == null || String(old.tokenId) !== String(plan.tokenId)) throw new Error('the position changed since the plan was made'); const owedWbnb = plan.from.wbnbIs0 ? old.owed0 : old.owed1, owedOther = plan.from.wbnbIs0 ? old.owed1 : old.owed0; const owedWei = owedWbnb + BigInt(Math.floor(Number(owedOther) * plan.from.otherInWbnb)); const folded = { wbnb: formatEther(owedWbnb), other: formatUnits(owedOther, 18), bnb_equivalent: Number((Number(owedWei) / 1e18).toFixed(6)) }; const share = resetForward(owedWei, keptPct); const reserved = share.forward > 0n ? share.forward : 0n; // 2. Withdraw, collect and burn the old range, one transaction. { const liquidity = plan.pos[7]; const sim = await pub.simulateContract({ address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'decreaseLiquidity', args: [{ tokenId: plan.tokenId, liquidity, amount0Min: 0n, amount1Min: 0n, deadline: deadline() }], account, }); const calls = unwindCalls(plan.tokenId, liquidity, (sim.result[0] * 99n) / 100n, (sim.result[1] * 99n) / 100n, account.address, deadline()); await pub.simulateContract({ address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'multicall', args: [calls], account }); await send('withdraw, collect and burn the old range (one transaction)', { address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'multicall', args: [calls] }); } const wrapped = wrapFirst ? await wrapWaiting(pub, send, account.address) : 0n; // 3. Leave the old pair: its other side becomes WBNB in the old tier. if (!plan.sameOther) { const haveOld = await read(pub, plan.from.other, ABI.ERC20, 'balanceOf', [account.address]); if (haveOld > 0n) { const q = await quoteV3(pub, plan.from.other, ADDR.WBNB, plan.from.fee, haveOld); swaps.push(await swapV3(pub, send, account.address, plan.from.other, ADDR.WBNB, plan.from.fee, haveOld, q, q, 'leave the old pair: sell its other side for WBNB')); } } // 4. Size the new range from what the wallet really holds now; the // profit share is kept out of the sizing so it stays as WBNB. const t = plan.target, fee = plan.to.fee, other = plan.to.other; const entry = await tradeToRange(pub, send, account.address, other, fee, t.perLWbnb, t.perLOther, { reserved, buyLabel: 'enter the new pair: buy its other side', sellLabel: 'sell the excess of the new other side' }); if (entry) swaps.push(entry); // 5. The profit share of the old range's fees becomes $BOBAI, held. let boughtBobai = 0n, bobaiUnits = 0n, forwardWhy = share.why; if (reserved > 0n) { const wbnbNow = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); if (wbnbNow >= reserved) { await send("unwrap the profit share of the old range's fees", { address: ADDR.WBNB, abi: ABI.ERC20, functionName: 'withdraw', args: [reserved] }); const bought = await buyBobaiHold(pub, send, account.address, reserved); boughtBobai = reserved; bobaiUnits = bought.units; } else { forwardWhy = `the wallet held ${formatEther(wbnbNow)} WBNB after the trades, less than the ${formatEther(reserved)} BNB share — it stays as capital`; } } // 6. Mint in the new pool. const mintOther = await read(pub, other, ABI.ERC20, 'balanceOf', [account.address]); const mintWbnb = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); await ensureAllowance(pub, send, other, ADDR.V3_POSITION_MANAGER, mintOther, 'allow the position manager to take the new other side (once)'); await ensureAllowance(pub, send, ADDR.WBNB, ADDR.V3_POSITION_MANAGER, mintWbnb, 'allow the position manager to take WBNB (once)'); const amount0Desired = plan.to.wbnbIs0 ? mintWbnb : mintOther; const amount1Desired = plan.to.wbnbIs0 ? mintOther : mintWbnb; const now = await readPool(pub, plan.to.pair); const mintMins = minsForRange(now.sqrtP, plan.ticks.tickLower, plan.ticks.tickUpper, amount0Desired, amount1Desired); await send(`mint the range ${plan.ticks.tickLower} … ${plan.ticks.tickUpper} in the new pool`, { address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'mint', args: [{ token0: plan.to.token0, token1: plan.to.token1, fee, tickLower: plan.ticks.tickLower, tickUpper: plan.ticks.tickUpper, amount0Desired, amount1Desired, ...mintMins, recipient: account.address, deadline: deadline(), }] }); const wbnbLeft = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); if (wbnbLeft > 0n) await send('unwrap what was not needed', { address: ADDR.WBNB, abi: ABI.ERC20, functionName: 'withdraw', args: [wbnbLeft] }); const np = await readPosition(pub, account.address); const gasBnb = txs.reduce((a, x) => a + (x.gas_bnb || 0), 0); const swapFee = swaps.reduce((a, x) => a + (x && x.fee_bnb ? x.fee_bnb : 0), 0); return { txs, gas_bnb: Number(gasBnb.toFixed(6)), swaps, swap_fee_bnb: Number(swapFee.toFixed(6)), ...(wrapped > 0n ? { wrapped_waiting_bnb: Number(formatEther(wrapped)) } : {}), new_position: np.tokenId == null ? null : String(np.tokenId), new_pool: plan.to.pool, new_ticks: [plan.ticks.tickLower, plan.ticks.tickUpper], liquidity_after: np.pos ? String(np.pos[7]) : null, fees_folded: folded, fees_folded_bnb: folded.bnb_equivalent, bobai_bnb: Number(formatEther(boughtBobai)), bobai_units: Number(formatUnits(bobaiUnits, 18)), fees_kept_pct: share.pct, ...(boughtBobai > 0n ? { bobai_held_in: account.address } : { fees_forward_why: forwardWhy }), }; } // -------------------------------------------------------------------------- // increase: BNB above the reserve -> more of the same position // -------------------------------------------------------------------------- export async function planIncrease(pub, address, position = null, ladder = null) { const p = position || (await readPosition(pub, address, ladder)); const bal = await pub.getBalance({ address }); const spendRaw = bal - GAS_RESERVE - parseEther(String(INCREASE_GAS_BUDGET_BNB)); const nativeRaw = spendRaw > 0n ? spendRaw : 0n; let poolInfo = null, target = null, other = null, wbnbIs0 = false, heldWbnb = 0n, heldOther = 0n, heldOtherInWbnb = 0, buyOtherRaw = 0n, sellOtherRaw = 0n, buyCostRaw = 0n; if (p.positions === 1) { poolInfo = await readPool(pub, p.pos); const token0 = p.pos[2].toLowerCase(), token1 = p.pos[3].toLowerCase(); wbnbIs0 = token0 === ADDR.WBNB; if (!wbnbIs0 && token1 !== ADDR.WBNB) throw new Error('the position is not against WBNB; this agent only knows how to grow it out of BNB'); other = wbnbIs0 ? token1 : token0; // Capital is everything the wallet holds beside the position: BNB above // the reserve, WBNB, and the other side. The re-set's headroom and an // interrupted run both leave tokens here; until 2026-09-05 the increase // saw only the BNB and then tripped over the tokens it had not counted. heldWbnb = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [address]); heldOther = await read(pub, other, ABI.ERC20, 'balanceOf', [address]); const { perL0, perL1 } = splitForRange(poolInfo.sqrtP, Number(p.pos[5]), Number(p.pos[6])); const price = poolInfo.sqrtP ** 2; // token1 per token0 const perLOther = wbnbIs0 ? perL1 : perL0; const perLWbnb = wbnbIs0 ? perL0 : perL1; const otherInWbnb = wbnbIs0 ? 1 / price : price; // WBNB per unit of the other token heldOtherInWbnb = Number(heldOther) * otherInWbnb; const capital = Number(nativeRaw) + Number(heldWbnb) + heldOtherInWbnb; // The L this much capital buys and the trade into the range's ratio // (tradeToRatio, at the pool's mid price here; the run asks the quoter). const perLValue = perLWbnb + perLOther * otherInWbnb; const L = perLValue > 0 ? capital / perLValue : 0; const est = tradeToRatio({ wbnb: nativeRaw + heldWbnb, other: heldOther, perLWbnb, perLOther, otherPerWbnb: otherInWbnb > 0 ? 1 / otherInWbnb : 0, wbnbPerOther: otherInWbnb }); if (est.side === 'buy') { buyCostRaw = est.amount; buyOtherRaw = BigInt(Math.floor(Number(est.amount) / otherInWbnb)); } else if (est.side === 'sell') { sellOtherRaw = est.amount; } target = { perLOther, perLWbnb, otherInWbnb, L }; } const spendableBnb = poolInfo ? bn(nativeRaw + heldWbnb) + heldOtherInWbnb / 1e18 : bn(nativeRaw); // A range that lies entirely below the price holds only WBNB: BNB joins // it as it is (refuseIncrease, wbnbOnly). The reserve range of the ladder // is counted in the value the record shows, never in what this step adds. const side = poolInfo ? positionSide(p.pos, poolInfo.sqrtP, wbnbIs0).side : null; const reserveValue = poolInfo && p.reserve ? positionSide(p.reserve.pos, poolInfo.sqrtP, wbnbIs0).valueBnb : 0; const state = { positions: p.positions, spendableBnb, inRange: poolInfo ? poolInfo.inRange : false, wbnbOnly: side === 'wbnb' }; return { step: 'increase', state, no: refuseIncrease(state), tokenId: p.tokenId, pos: p.pos, reserve: p.reserve || null, other, wbnbIs0, nativeRaw, heldWbnb, heldOther, buyOtherRaw, sellOtherRaw, buyCostRaw, target, summary: { position: p.tokenId == null ? null : String(p.tokenId), value_bnb: poolInfo ? Number(((await positionValueBnb(pub, address, p, poolInfo)) + reserveValue).toFixed(6)) : null, ...(p.reserve && poolInfo ? { reserve: { position: String(p.reserve.tokenId), ticks: [Number(p.reserve.pos[5]), Number(p.reserve.pos[6])], value_bnb: Number(reserveValue.toFixed(6)), side: positionSide(p.reserve.pos, poolInfo.sqrtP, wbnbIs0).side } } : {}), side, wallet_bnb: bn(bal), spendable_bnb: spendableBnb, in_range: state.inRange, tick: poolInfo ? poolInfo.tick : null, pool: poolInfo ? String(poolInfo.pool).toLowerCase() : null, capital: poolInfo ? { bnb_above_reserve: bn(nativeRaw), wbnb_held: bn(heldWbnb), other_held: formatUnits(heldOther, 18), other_held_in_bnb: Number((heldOtherInWbnb / 1e18).toFixed(6)) } : null, would_add: poolInfo && target && target.L > 0 ? { other: formatUnits(BigInt(Math.floor(target.L * target.perLOther)), 18), other_token: other, wbnb: formatEther(BigInt(Math.floor(target.L * target.perLWbnb))), ...(buyOtherRaw > 0n ? { buying_other: formatUnits(buyOtherRaw, 18), buying_other_costs_bnb: formatEther(buyCostRaw) } : {}), ...(sellOtherRaw > 0n ? { selling_other: formatUnits(sellOtherRaw, 18) } : {}), } : null, }, }; } export async function executeIncrease(pub, wallet, account, plan, log = () => {}, { txs = [] } = {}) { const send = sender(pub, wallet, txs, log); send.owner = account.address; const before = await pub.getBalance({ address: account.address }); // 1. BNB above the reserve becomes WBNB. Nothing to wrap when the capital // is only what was already held beside the position. if (plan.nativeRaw > 0n) await send('wrap', { address: ADDR.WBNB, abi: ABI.ERC20, functionName: 'deposit', value: plan.nativeRaw }); // 2. Trade to the range's ratio, the way the re-set does: buy the other // side that is missing, or sell what exceeds it. Sized from what the // wallet really holds now, at the price now. const t = plan.target; const fee = Number(plan.pos[4]); const swap = await tradeToRange(pub, send, account.address, plan.other, fee, t.perLWbnb, t.perLOther); // 3. Add what the wallet holds. Approvals only when the allowance is short // (see ensureAllowance). The manager takes only the ratio the range // needs; the minimums are 97% of that ratio's amounts at the price now // (amountsForRange) — never a share of the balances, which is what // reverted the run of 2026-09-05. const haveOther = await read(pub, plan.other, ABI.ERC20, 'balanceOf', [account.address]); const haveWbnb = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); await ensureAllowance(pub, send, plan.other, ADDR.V3_POSITION_MANAGER, haveOther, 'allow the position manager to take the other side (once)'); await ensureAllowance(pub, send, ADDR.WBNB, ADDR.V3_POSITION_MANAGER, haveWbnb, 'allow the position manager to take WBNB (once)'); const amount0Desired = plan.wbnbIs0 ? haveWbnb : haveOther; const amount1Desired = plan.wbnbIs0 ? haveOther : haveWbnb; // A range entirely on one side of the price (a buy ladder below it) takes // all of the held token; the minimum is 97% of that, read at the price // now — the drift tolerance of a two-sided increase would read zero there // (see the one-sided mint in executeRebalance). const sqrtInc = (await readPool(pub, plan.pos)).sqrtP; const oneSide = positionSide(plan.pos, sqrtInc, plan.wbnbIs0).side; const mins = oneSide === 'wbnb' || oneSide === 'other' ? (() => { const a = amountsForRange(sqrtInc, Number(plan.pos[5]), Number(plan.pos[6]), amount0Desired, amount1Desired); return { amount0Min: (a.amount0 * MIN_SHARE) / 100n, amount1Min: (a.amount1 * MIN_SHARE) / 100n }; })() : minsForRange(sqrtInc, Number(plan.pos[5]), Number(plan.pos[6]), amount0Desired, amount1Desired); await send('increase the position', { address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'increaseLiquidity', args: [{ tokenId: plan.tokenId, amount0Desired, amount1Desired, ...mins, deadline: deadline() }] }); // What the manager did not take goes back to being capital. The other // token's dust is left; tomorrow's collect sells it with the fees. const wbnbLeft = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); if (wbnbLeft > 0n) await send('unwrap what was not needed', { address: ADDR.WBNB, abi: ABI.ERC20, functionName: 'withdraw', args: [wbnbLeft] }); const pos = await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'positions', [plan.tokenId]); // What left the wallet as BNB for this increase, gas included — the figure // the money-flow view adds up as "put into the position". const after = await pub.getBalance({ address: account.address }); const poolAfter = await readPool(pub, pos); const reserveAfter = plan.reserve ? positionSide(await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'positions', [plan.reserve.tokenId]), poolAfter.sqrtP, plan.wbnbIs0).valueBnb : 0; const valueAfter = await positionValueBnb(pub, account.address, { positions: 1, tokenId: plan.tokenId, pos }, poolAfter).then((v) => (v == null ? null : Number((v + reserveAfter).toFixed(6)))).catch(() => null); return { txs, swap, swap_fee_bnb: swap ? swap.fee_bnb : 0, liquidity_after: String(pos[7]), value_after_bnb: valueAfter, other_used: formatUnits(haveOther - (await read(pub, plan.other, ABI.ERC20, 'balanceOf', [account.address])), 18), wbnb_used: formatEther(haveWbnb - wbnbLeft), bnb_spent: formatEther(before > after ? before - after : 0n) }; } // -------------------------------------------------------------------------- // ladder: BNB that waits beside a sell ladder opens a buy ladder (2026-09-16) // -------------------------------------------------------------------------- // See ladderDecision in lp-guards.js for the rule. The plan reads the main // range (and the reserve, when the ladder record names one), decides, and // sizes: a reserve is minted or grown from the BNB above the reserve and // the gas budget, as WBNB, into a range beside the price on its lower side // (ticksAdjacent with the price "above" it), in the width the record picks // — the same shape the main range would take there. Nothing is traded in // this step, ever. A merge is not done here: the reserve is unwound at the // main range's next re-set (worker-lp), whose mint takes the wallet's // tokens with it. export async function planLadder(pub, address, { record = null, ladder = null, position = null, widthOverride = null } = {}) { const p = position || (await readPosition(pub, address, ladder)); const bal = await pub.getBalance({ address }); const spendRaw0 = bal - GAS_RESERVE - parseEther(String(INCREASE_GAS_BUDGET_BNB)); const spendRaw = spendRaw0 > 0n ? spendRaw0 : 0n; // WBNB a stopped run left wrapped (the wrap went through, the mint after it // did not) waits for the ladder like native BNB does. Until 2026-09-18 only // native BNB counted: the wrapped deposit read as "only 0.00… BNB waits", // the increase refused it beside a main range that is all of the other // side, and it stood still until the next deposit or re-set. const heldWbnb = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [address]); let poolInfo = null, spacing = null, wbnbIs0 = false, mainSide = null, reserveSide = null, reserveLeft = false, ticks = null, width = null, reserveInfo = null, reserveBnb = null; if (p.positions === 1 && p.pos) { poolInfo = await readPool(pub, p.pos); wbnbIs0 = p.pos[2].toLowerCase() === ADDR.WBNB; spacing = Number(await read(pub, poolInfo.pool, ABI.POOL, 'tickSpacing')) || 1; mainSide = positionSide(p.pos, poolInfo.sqrtP, wbnbIs0).side; if (p.reserve) { const rs = positionSide(p.reserve.pos, poolInfo.sqrtP, wbnbIs0); reserveSide = rs.side; reserveBnb = rs.valueBnb; const lf = rangeLeft(poolInfo.tick, Number(p.reserve.pos[5]), Number(p.reserve.pos[6])); reserveLeft = lf.left; reserveInfo = { position: String(p.reserve.tokenId), ticks: [Number(p.reserve.pos[5]), Number(p.reserve.pos[6])], side: reserveSide, value_bnb: Number(rs.valueBnb.toFixed(6)), left: lf.left, ticks_beyond_edge: lf.ticks_away }; } // The same pick the main range's plan makes, with the width the main // range is in as the one in use (until 2026-09-18 the ladder picked // without it: the reserve took ±10% beside a main range kept at ±7%). const inUse = widthClassOf([Number(p.pos[5]), Number(p.pos[6])]); const pick = (Array.isArray(record?.rows) && record.rows.some((r) => r.earnings_7d) && (record.earnings_pick || record.hours_of_prices >= 24) ? pickWidth(record.rows, { current: inUse }) : null) || record?.earnings_pick || null; width = widthOverride ?? pick?.width ?? null; // A range below the price: the price is "above" it (ticksAdjacent's side). if (width != null) ticks = ticksAdjacent(poolInfo.tick, width, spacing, 'above'); } const positionsHeld = p.positions === 1 ? (p.reserve ? 2 : 1) : p.positions; const decision = ladderDecision({ positions: positionsHeld, reserve: !!p.reserve, mainSide, reserveSide, spendableBnb: bn(spendRaw + heldWbnb), reserveLeft, reserveBnb }); let no = null; if (decision.act && decision.act !== 'merge' && !(bn(bal) >= MIN_GAS_BNB)) no = `the wallet holds ${bn(bal).toFixed(6)} BNB, below the ${MIN_GAS_BNB} BNB it takes to be sure of paying the step through`; else if (decision.act && (decision.act === 'mint_reserve' || decision.act === 'reset_reserve') && (width == null || !ticks)) no = 'the width record names no width yet — the reserve range waits for a day of prices'; return { step: 'ladder', act: decision.act, why: decision.why, no, tokenId: p.tokenId, pos: p.pos, reserve: p.reserve || null, poolInfo, spacing, wbnbIs0, spendRaw, heldWbnb, ticks, width, summary: { position: p.tokenId == null ? null : String(p.tokenId), positions_held: positionsHeld, tick: poolInfo ? poolInfo.tick : null, main_side: mainSide, main_ticks: p.pos ? [Number(p.pos[5]), Number(p.pos[6])] : null, reserve: reserveInfo, wallet_bnb: bn(bal), spendable_bnb: bn(spendRaw + heldWbnb), ...(heldWbnb > 0n ? { wbnb_held: bn(heldWbnb) } : {}), act: decision.act, width_pct: width, new_reserve_ticks: ticks && (decision.act === 'mint_reserve' || decision.act === 'reset_reserve') ? [ticks.tickLower, ticks.tickUpper] : null, }, }; } // The ladder's transactions. mint_reserve: wrap, allow once, mint the WBNB // range below the price; the new token id is the one the wallet did not // hold before. increase_reserve: wrap, add the WBNB to the reserve. // reset_reserve: unwind the reserve (its WBNB and fees come back to the // wallet), mint the WBNB again beside the price. merge: unwind the reserve // alone — the caller re-sets the main range next and that mint takes what // came back. No trade in any of them. export async function executeLadder(pub, wallet, account, plan, log = () => {}, { txs = [] } = {}) { const send = sender(pub, wallet, txs, log); send.owner = account.address; const before = await pub.getBalance({ address: account.address }); const held = () => heldIds(pub, account.address); const gasOf = () => Number(txs.reduce((s, t) => s + (t.gas_bnb || 0), 0).toFixed(6)); const mintReserve = async (label) => { const wbnb = await read(pub, ADDR.WBNB, ABI.ERC20, 'balanceOf', [account.address]); if (wbnb <= 0n) throw new Error('no WBNB to mint the reserve range from'); await ensureAllowance(pub, send, ADDR.WBNB, ADDR.V3_POSITION_MANAGER, wbnb, 'allow the position manager to take WBNB (once)'); const amount0Desired = plan.wbnbIs0 ? wbnb : 0n, amount1Desired = plan.wbnbIs0 ? 0n : wbnb; // Placed beside the price as it is now, not as the plan read it (see // executeRebalance): the wrap or the unwind lies in between. const poolNow = await readPool(pub, plan.pos); const sqrtNow = poolNow.sqrtP; if (plan.width != null && plan.spacing) plan = { ...plan, ticks: ticksAdjacent(poolNow.tick, plan.width, plan.spacing, 'above') }; const a = amountsForRange(sqrtNow, plan.ticks.tickLower, plan.ticks.tickUpper, amount0Desired, amount1Desired); const idsBefore = await held(); const receipt = await send(label, { address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'mint', args: [{ token0: plan.pos[2], token1: plan.pos[3], fee: Number(plan.pos[4]), tickLower: plan.ticks.tickLower, tickUpper: plan.ticks.tickUpper, amount0Desired, amount1Desired, amount0Min: (a.amount0 * MIN_SHARE) / 100n, amount1Min: (a.amount1 * MIN_SHARE) / 100n, recipient: account.address, deadline: deadline() }] }); const fromReceipt = mintedIn(receipt, account.address); if (fromReceipt != null) return fromReceipt; const idsAfter = await held(); return idsAfter.find((i) => !idsBefore.includes(i)) || null; }; const unwindReserve = async () => { const r = plan.reserve; const sim = await pub.simulateContract({ address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'decreaseLiquidity', args: [{ tokenId: r.tokenId, liquidity: r.pos[7], amount0Min: 0n, amount1Min: 0n, deadline: deadline() }], account }); const calls = unwindCalls(r.tokenId, r.pos[7], (sim.result[0] * 99n) / 100n, (sim.result[1] * 99n) / 100n, account.address, deadline()); await pub.simulateContract({ address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'multicall', args: [calls], account }); await send('withdraw, collect and burn the reserve range (one transaction)', { address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'multicall', args: [calls] }); const owedWbnb = plan.wbnbIs0 ? r.owed0 : r.owed1, owedOther = plan.wbnbIs0 ? r.owed1 : r.owed0; // What they were worth, so the fee sum can count them (lp-flow): the // collect step takes the reserve's fees since 2026-09-19, what is left // here is under its floor and stays as capital — counted, not shared. let worth = null; try { const pool = await readPool(pub, r.pos); const perOther = plan.wbnbIs0 ? 1 / (pool.sqrtP ** 2) : pool.sqrtP ** 2; worth = Number((bn(owedWbnb) + (Number(owedOther) / 1e18) * perOther).toFixed(8)); } catch { worth = null; } return { reserve_fees_folded: { wbnb: formatEther(owedWbnb), other: formatUnits(owedOther, 18), ...(worth != null ? { bnb_equivalent: worth } : {}) } }; }; if (plan.act === 'mint_reserve') { if (plan.spendRaw <= 0n && !(plan.heldWbnb > 0n)) throw new Error('nothing above the reserve to put into the ladder'); if (plan.spendRaw > 0n) await send(`wrap ${formatEther(plan.spendRaw)} BNB for the reserve range`, { address: ADDR.WBNB, abi: ABI.ERC20, functionName: 'deposit', value: plan.spendRaw }); } if (plan.act === 'mint_reserve') { const id = await mintReserve(`mint the reserve range ${plan.ticks.tickLower} … ${plan.ticks.tickUpper} below the price, WBNB only`); const after = await pub.getBalance({ address: account.address }); return { txs, gas_bnb: gasOf(), new_reserve: id, new_reserve_ticks: [plan.ticks.tickLower, plan.ticks.tickUpper], bnb_spent: formatEther(before > after ? before - after : 0n), one_sided: 'below_price' }; } if (plan.act === 'increase_reserve') { // THE RESERVE GROWS THE WAY THE MAIN RANGE DOES (2026-09-17). The main // range is all of the other side above the price exactly when the price // has fallen out of it — into the reserve below, as often as not. A // reserve the price is IN takes both tokens: WBNB alone is liquidity // zero and the manager reverts (simulated on chain against #7461743 and // #7451444; the same WBNB mints fine into a range below the price). So // the BNB joins the reserve through the increase itself, read for the // reserve's range at the price now: below the price it is WBNB as it // is, no trade; with the price inside, the part the range needs of the // other side is bought first, the way every in-range increase does. It // also takes WBNB a stopped run left wrapped in the wallet. const inc = await planIncrease(pub, account.address, { positions: 1, tokenId: plan.reserve.tokenId, pos: plan.reserve.pos, reserve: null }); if (inc.no) throw new Error(`the reserve range does not take the BNB: ${inc.no}`); const done = await executeIncrease(pub, wallet, account, inc, log, { txs }); return { txs, gas_bnb: gasOf(), reserve: String(plan.reserve.tokenId), reserve_side: inc.summary.side, swap: done.swap, swap_fee_bnb: done.swap_fee_bnb, liquidity_after: done.liquidity_after, bnb_spent: done.bnb_spent }; } if (plan.act === 'reset_reserve') { const folded = await unwindReserve(); const id = await mintReserve(`mint the reserve range ${plan.ticks.tickLower} … ${plan.ticks.tickUpper} again beside the price, WBNB only`); return { txs, gas_bnb: gasOf(), old_reserve: String(plan.reserve.tokenId), new_reserve: id, new_reserve_ticks: [plan.ticks.tickLower, plan.ticks.tickUpper], ...folded, one_sided: 'below_price' }; } if (plan.act === 'merge') { const folded = await unwindReserve(); return { txs, gas_bnb: gasOf(), merged_reserve: String(plan.reserve.tokenId), ...folded }; } throw new Error(`the ladder plan names no action (${plan.act})`); } ============================================================================== === FILE: shared/lp-alerts.js ============================================================================== // What the DeFi agent tells its operator AT ONCE, and what it keeps for the // daily card. Pure: a tick's entry in, the messages out. // // Until 2026-09-18 the operator heard about the agent twice a day — the public // card at 05:00 UTC and the health message at 09:10 — and both arrive while an // agent stands still (09-16/17: a day, politely refusing every step with // ok:true). What cannot wait for the morning: // - a step that failed (the money may be half way: unwound, not minted), // - a step that waits for a person ("which one to re-set is a decision for a // person") — the agent does nothing until somebody looks, // - the ladder record healed, a KV write that failed after a transaction, // - the money moving in a way it rarely does: a re-set of the main range // (with its direction, a merge, a resume from the wallet, the share's // sale), and every act of the ladder. Several of these had never run with // money when this was written, and the first time each one does is the // time to look at the chain. // Routine is NOT told: a look that found nothing to do, a collect, a top-up. // // Each message carries a `key`. The worker remembers a key for `quietHours` // and says nothing again while it does — a refusal repeats every ten minutes, // and a message every ten minutes is a message nobody reads. Money events // carry the ids they made, so they are said once by construction. const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>'); const hash = (s) => { let h = 0; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) | 0; return (h >>> 0).toString(36); }; const PERSON = /decision (for )?a person|a person should make/i; export const RECORD_URL = 'https://agent.brainonbnb.com/lp/agent'; export function alertsOf(entry) { if (!entry || entry.dry) return []; const out = []; const steps = Object.entries(entry.steps || {}).flatMap(([k, v]) => (Array.isArray(v) ? v : [v]).map((x) => [k, x || {}])); const when = String(entry.at || '').slice(11, 16); const tx = (x) => (Array.isArray(x.txs) && x.txs.length ? ` · ${x.txs.length} tx, last ${esc(String(x.txs[x.txs.length - 1].hash).slice(0, 10))}…` : ''); const waits = []; for (const [k, x] of steps) { if (x.error) { const sent = Array.isArray(x.txs) && x.txs.length; out.push({ key: `err:${k}:${hash(x.error)}`, quietHours: 6, text: `🚨 DeFi agent · ${esc(k)} FAILED (${when} UTC)\n${esc(String(x.error).slice(0, 240))}\n${sent ? `${x.txs.length} transaction(s) had already gone through — the money may be half way (unwound, not minted). The next hourly check tries to finish it; look at the wallet.${tx(x)}` : 'Nothing was sent.'}` }); } else if (PERSON.test(String(x.why || ''))) { waits.push([k, x.why]); } if (x.kv_error) out.push({ key: `kv:${k}:${hash(x.kv_error)}`, quietHours: 6, text: `⚠️ DeFi agent · ${esc(k)}: the transaction went through, the record write did not (${esc(String(x.kv_error).slice(0, 160))}). The ladder record heals itself on the next tick — check that it did.` }); } if (waits.length) out.push({ key: `person:${waits.map(([k]) => k).join('+')}`, quietHours: 12, text: `✋ DeFi agent · ${esc(waits.map(([k]) => k).join(', '))} wait${waits.length === 1 ? 's' : ''} for a person (${when} UTC)\n${esc(String(waits[0][1]).slice(0, 240))}\nThe agent does nothing there until somebody looks — this is how it stood still for a day on 2026-09-16.` }); if (entry.error) out.push({ key: `err:tick:${hash(entry.error)}`, quietHours: 6, text: `🚨 DeFi agent · the tick itself failed (${when} UTC)\n${esc(String(entry.error).slice(0, 240))}` }); const h = entry.ladder_healed; if (h) out.push({ key: `heal:${h.from}:${h.to}:${h.reserve_adopted || h.reserve_closed || ''}`, quietHours: 24, text: `🩹 DeFi agent · ladder record healed (${when} UTC)\n${esc(String(h.why || '').slice(0, 300))}` }); const rb = (entry.steps || {}).rebalance; if (rb && rb.acted && !rb.error && rb.new_position) { const dir = rb.one_sided === 'below_price' ? 'UPWARD — the price left above the range; the new range is below the price, all BNB' : rb.one_sided === 'above_price' ? 'downward — the price left below the range; the new range is above the price, all ' + esc(rb.other_token || 'CAKE') : 'centred'; const notes = [ rb.resumed_from_wallet ? 'FINISHED FROM THE WALLET (an earlier re-set had stopped before its mint)' : null, rb.main_missing ? `the main range #${esc(rb.main_missing)} was gone, the reserve stood` : null, rb.merged_reserve ? `merged the reserve #${esc(rb.merged_reserve)} first` : null, rb.share_swap ? "sold the profit share's part of the CAKE fees" : null, rb.swap ? `a trade of ${esc(rb.swap.notional_bnb)} BNB (${esc(rb.swap.side)})` : 'no trade', rb.bobai_bnb > 0 ? `${esc(rb.bobai_bnb)} BNB into $BOBAI` : (rb.fees_forward_why ? esc(rb.fees_forward_why) : null), ].filter(Boolean); out.push({ key: `reset:${rb.new_position}`, quietHours: 720, text: `🔁 DeFi agent · re-set ${dir} (${when} UTC)\n#${esc(rb.position)} → #${esc(rb.new_position)}${Array.isArray(rb.new_ticks) ? `, ticks ${rb.new_ticks.join(' … ')}` : ''}${rb.width_pct != null ? `, ±${esc(rb.width_pct)}%` : ''} · ${notes.join(' · ')}${rb.gas_bnb != null ? ` · gas ${esc(rb.gas_bnb)} BNB` : ''}${tx(rb)}` }); } const ld = (entry.steps || {}).ladder; if (ld && ld.acted && !ld.error) { const what = ld.merged_reserve ? `merged the reserve #${esc(ld.merged_reserve)} into the main range` : ld.old_reserve ? `re-set the reserve #${esc(ld.old_reserve)} → #${esc(ld.new_reserve)} beside the price` : ld.new_reserve ? `opened a reserve range #${esc(ld.new_reserve)} below the price with ${esc(ld.bnb_spent)} BNB` : `grew the reserve #${esc(ld.reserve)} by ${esc(ld.bnb_spent)} BNB (${esc(ld.reserve_side || '?')}${ld.swap ? ', bought the missing side first' : ', no trade'})`; out.push({ key: `ladder:${ld.new_reserve || ld.merged_reserve || ld.reserve}:${entry.at}`, quietHours: 720, text: `🪜 DeFi agent · ladder (${when} UTC)\n${what}${ld.new_reserve === null ? ' — THE NEW ID COULD NOT BE READ; the record heals on the next tick, check it' : ''}${tx(ld)}` }); } return out.map((m) => ({ ...m, text: `${m.text}\nrecord` })); } ============================================================================== === FILE: shared/lp-flow.js ============================================================================== // Where the money came from and where it went, from the DeFi agent's // own record. // // One function, used by the agent worker (the /lp/agent JSON and page, the // series), by the liquidity page and by the Telegram report — so the three // never disagree about a total. Everything is a sum over the record's history // of runs that actually signed something; dry runs are skipped, they moved // nothing. Pure: no chain, no I/O, pinned by the self-test in // scripts/lp-agent.mjs. // // THE SHAPE // in what came in: each income wallet's sweeps (sold, BNB received, // runs) and the fees the position produced // out where it went: the buyback wallet, kept as capital, already put // into the position (increases), and how many re-sets // gas what all of it cost in BNB, over how many transactions // waiting what the last run saw still waiting: income on the wallets, // fees owed by the position, BNB in the DeFi wallet above reserve // rule the fee share kept, as the last collect named it // paid_for what the x402 service was paid for (from the agent worker's // own earnings record), when the caller has it const n = (v) => Number(v) || 0; const r6 = (v) => Number(v.toFixed(6)); // What a step's own transactions paid in gas. `bnb_spent` of an increase or a // ladder mint is the wallet's balance before minus after, so the gas is in // it; gas has its own line below. Counted in both, it left the profit twice // (2026-09-18: 0.00087 BNB over 28 steps, 6.5% of the profit on the card). const gasOf = (step) => (step && Array.isArray(step.txs) ? step.txs : []).reduce((a, t) => a + n(t.gas_bnb), 0); // WHAT COUNTS AS A RE-SET (2026-09-19). Three places counted and said 15, 14 // and 13 on the same record: the loss table counted every re-set it could // value, this file every one that named its new position, and the fee // sentence the ones that had fees to fold. The 14 was wrong: the re-set of // 2026-09-16 08:50 moved the range (#7451444 stands on it) and recorded no // new id — the reading bug fixed the day after — and was left out. A re-set // is a rebalance step that acted and did not fail. A run that failed half way // and the run that finished it from the wallet are one: the first has an // error, the second has not. Every count asks here. export function isReset(rb) { return !!(rb && rb.acted && !rb.error); } export function moneyFlow(rec, { earned = null } = {}) { const hist = (Array.isArray(rec?.history) ? rec.history : []).filter((e) => e && !e.dry); const bySource = {}; let feesProduced = 0, feesKept = 0, feesForwarded = 0, collects = 0, bobaiUnits = 0; let intoPosition = 0, increases = 0, resets = 0, gas = 0, txs = 0; let feesFolded = 0, resetsWithFees = 0, resetForwarded = 0; let lastKeptPct = null; let first = null, lastMoved = null; // Fees a collect kept that no step has put into the position yet: they wait // as BNB in the wallet until the increase floor is reached (keptWaiting). let keptWaiting = 0; for (const e of hist) { const st = e.steps || {}; const sweeps = Array.isArray(st.sweep) ? st.sweep : []; for (const s of sweeps) { if (!s.acted || s.error) continue; const k = s.source || 'income'; bySource[k] = bySource[k] || { source: k, token: s.token || null, sold: 0, bnb: 0, runs: 0 }; bySource[k].sold += n(s.sold); bySource[k].bnb += n(s.received_bnb); bySource[k].runs += 1; } const c = st.collect; if (c && c.acted && !c.error) { // Records before the split carry only forwarded_bnb; that was all of it. const produced = c.produced_bnb != null ? n(c.produced_bnb) : n(c.forwarded_bnb) + n(c.kept_bnb); if (produced > 0) { collects += 1; feesProduced += produced; feesKept += n(c.kept_bnb); keptWaiting += n(c.kept_bnb); // Since 2026-09-09 the share buys BOBAI held in the wallet (c.bobai_bnb); // older records sent it to the buyback wallet (c.forwarded_bnb). feesForwarded += n(c.bobai_bnb ?? c.forwarded_bnb); bobaiUnits += n(c.bobai_units); } } const inc = st.increase; if (inc && inc.acted && !inc.error) { increases += 1; intoPosition += inc.bnb_spent != null ? Math.max(0, n(inc.bnb_spent) - gasOf(inc)) : n(inc.wbnb_used); keptWaiting = 0; // an increase takes everything above the reserve, kept fees with it } // The ladder step (2026-09-16) puts BNB into the reserve range: capital // into the position like an increase, counted the same way. const ld = st.ladder; if (ld && ld.acted && !ld.error && n(ld.bnb_spent) > 0) { increases += 1; intoPosition += Math.max(0, n(ld.bnb_spent) - gasOf(ld)); keptWaiting = 0; } const rb = st.rebalance; if (isReset(rb)) { resets += 1; // A re-set does not collect the old range's fees as fees: the unwind // pays them out with the principal. They are fees the position // produced all the same. Before 2026-09-08 the mint folded all of them // into the new capital; since then the buyback share is sent on first // (fees_forwarded_bnb) and only the rest is folded in. if (n(rb.fees_folded_bnb) > 0) { feesFolded += n(rb.fees_folded_bnb); resetsWithFees += 1; } if (n(rb.bobai_bnb ?? rb.fees_forwarded_bnb) > 0) resetForwarded += n(rb.bobai_bnb ?? rb.fees_forwarded_bnb); bobaiUnits += n(rb.bobai_units); if (rb.fees_kept_pct != null) lastKeptPct = n(rb.fees_kept_pct); // A re-set forced by a deposit wraps the waiting BNB into its own mint: // capital put in, like an increase — no increase follows to count it. if (n(rb.wrapped_waiting_bnb) > 0) { intoPosition += n(rb.wrapped_waiting_bnb); increases += 1; keptWaiting = 0; } } // A reserve range unwound (re-set or merged) pays its fees out with its // principal too. They stay as capital — what is worth a share the collect // step has taken before (reserveCollect) — and count as fees produced. const lf = st.ladder && st.ladder.acted && !st.ladder.error ? st.ladder.reserve_fees_folded : null; if (lf && n(lf.bnb_equivalent) > 0) feesFolded += n(lf.bnb_equivalent); if (c && c.acted && !c.error && c.kept_pct != null) lastKeptPct = n(c.kept_pct); for (const step of [...sweeps, c, inc, rb, st.ladder]) { for (const t of (step && Array.isArray(step.txs) ? step.txs : [])) { txs += 1; gas += n(t.gas_bnb); } } if (e.acted) { if (!first) first = e.at; lastMoved = e.at; } } const income = Object.values(bySource).map((s) => ({ ...s, sold: r6(s.sold), bnb: r6(s.bnb) })); const incomeBnb = income.reduce((a, s) => a + s.bnb, 0); const last = rec?.last || {}, ls = last.steps || {}; const lastSweeps = Array.isArray(ls.sweep) ? ls.sweep : []; const waiting = { income: lastSweeps.filter((s) => s.balance > 0).map((s) => ({ source: s.source || null, token: s.token || s.source || null, amount: n(s.balance), bnb: s.bnb_equivalent != null ? n(s.bnb_equivalent) : null })), // The newest figure: the hourly check's rebalance step reads what the // position owes now; the daily collect's figure is up to a day old. fees_owed_bnb: ls.rebalance && ls.rebalance.fees_owed_bnb != null ? r6(n(ls.rebalance.fees_owed_bnb)) : ls.collect && ls.collect.owed ? r6(n(ls.collect.owed.bnb_equivalent)) : 0, wallet_spendable_bnb: ls.increase && ls.increase.spendable_bnb != null ? r6(n(ls.increase.spendable_bnb)) : null, }; // KEPT FEES THAT WAIT ARE STILL THE AGENT'S (2026-09-18). A collect books // its kept half as capital at once; the BNB then sits in the wallet, under // the increase floor, for a day or more. Counted as capital that went in // (it had not) it came off the deposits — "put in" dipped by it after every // collect — and it was on neither side of the comparison with holding. // Never more than the wallet has above its reserve. waiting.kept_fees_bnb = r6(waiting.wallet_spendable_bnb != null ? Math.min(keptWaiting, waiting.wallet_spendable_bnb) : keptWaiting); // The rule is what the last step that split fees named: the last collect, // or the last re-set that forwarded (since 2026-09-08 re-sets split too). const keptPct = ls.collect && ls.collect.kept_pct != null ? n(ls.collect.kept_pct) : lastKeptPct; const foldedKept = feesFolded - resetForwarded; return { since: first, last_moved: lastMoved, in: { income, income_bnb: r6(incomeBnb), // bnb = collected by the collect step + taken by re-sets; folded_bnb // is all a re-set took, folded_kept_bnb the part it minted into the // new capital, forwarded_at_resets_bnb the part it sent to the buyback. fees: { bnb: r6(feesProduced + feesFolded), collects, collected_bnb: r6(feesProduced), folded_bnb: r6(feesFolded), folded_kept_bnb: r6(foldedKept), forwarded_at_resets_bnb: r6(resetForwarded), resets_with_fees: resetsWithFees }, total_bnb: r6(incomeBnb + feesProduced + feesFolded), }, out: { // BNB the agent spent buying BOBAI it now holds in its own wallet // (before 2026-09-09 this went to the buyback wallet). bobai_bnb: r6(feesForwarded + resetForwarded), bobai_units: r6(bobaiUnits), kept_as_capital_bnb: r6(feesKept + foldedKept), capital_arrived_bnb: r6(incomeBnb + feesKept + foldedKept), into_position_bnb: r6(intoPosition), increases, resets, }, gas: { bnb: r6(gas), transactions: txs }, waiting, rule: keptPct == null ? null : { fee_share_kept_pct: keptPct, fee_share_bobai_pct: 100 - keptPct }, paid_for: earned ? { x402_answers: n(earned.count), usd1: n(earned.totalUsd1) } : null, }; } // The flow in one sentence per direction, for a page or a chat message. // Figures are BNB with five decimals; the caller adds dollars if it has them. export function flowLines(flow) { if (!flow) return null; const f = (v) => n(v).toFixed(5); const income = flow.in.income.length ? flow.in.income.map((s) => `${f(s.bnb)} BNB from ${s.sold} ${s.token || s.source} (${s.source}, ${s.runs} sweep${s.runs === 1 ? '' : 's'})`).join(', ') : 'no income swept yet'; const fd = flow.in.fees.folded_bnb || 0, rw = flow.in.fees.resets_with_fees || 0, rf = flow.in.fees.forwarded_at_resets_bnb || 0; // The basis in the sentence: the re-sets that had fees to take, of all there were. const all = flow.out.resets || 0; const folded = fd > 0 ? `${f(fd)} BNB of fees taken at ${rw}${all > rw ? ` of ${all}` : ''} re-set${(all > rw ? all : rw) === 1 ? '' : 's'}${rf > 0 ? `, ${f(rf)} of it spent on BOBAI held` : ', all of it folded into the capital'}` : ''; const fees = flow.in.fees.collects ? `${f(flow.in.fees.collected_bnb != null ? flow.in.fees.collected_bnb : flow.in.fees.bnb)} BNB of fees over ${flow.in.fees.collects} collect${flow.in.fees.collects === 1 ? '' : 's'}${folded ? `, ${folded}` : ''}` : (folded || 'no fees collected yet'); const out = flow.in.fees.collects || flow.in.income.length || fd > 0 ? `${f(flow.out.bobai_bnb)} BNB spent on BOBAI held in the wallet, ${f(flow.out.kept_as_capital_bnb)} BNB kept as capital, ${f(flow.out.into_position_bnb)} BNB already put into the position` : 'nothing has left the wallet yet'; return { came_in: `${income}; ${fees}`, went_out: out, // "on record": runs by hand and, before 2026-09-06, the transactions a // failed run sent before its revert are not in the record — the wallet's // nonce on the chain is the full count. cost: `${flow.gas.transactions} transaction${flow.gas.transactions === 1 ? '' : 's'} on record, ${n(flow.gas.bnb).toFixed(6)} BNB of gas`, }; } // THE HISTORY'S CAP AND ITS ARCHIVE (2026-09-15). The record keeps its // newest HISTORY_CAP runs; at three runs a day that is about 65 days, and // every total above is a sum over that history — put in, fees, the $BOBAI // bought, what each re-set lost. Dropping the oldest runs would have made // the totals shrink silently from early November 2026. So a run the cap // pushes out is not dropped: the writer (worker-lp) appends it to the // archive key, and every reader that sums (worker-agent) merges the // archive back in front of the history before it counts. The record a // browser sees at /lp/agent is the merged one; the archive is a store, not // a second source. Both pure, pinned by scripts/lp-agent.mjs --self-test. export const HISTORY_CAP = 200; export const ARCHIVE_KEY = 'lp:agent:archive'; // The history after one more entry: what stays on the record and what the // cap pushed out, oldest first, so the archive keeps the order of the runs. export function trimHistory(history, entry, cap = HISTORY_CAP) { const all = (Array.isArray(history) ? history : []).concat(entry === undefined ? [] : [entry]); const over = Math.max(0, all.length - cap); return { kept: all.slice(over), dropped: all.slice(0, over) }; } // The record with its archived runs back in front of the history, and how // many were archived — the shape every sum expects. export function withArchive(rec, archive) { const entries = Array.isArray(archive?.entries) ? archive.entries : []; if (!rec || !entries.length) return rec; return { ...rec, history: entries.concat(Array.isArray(rec.history) ? rec.history : []), history_archived: entries.length }; } ============================================================================== === FILE: shared/lp-guards.js ============================================================================== // Every reason the DeFi agent refuses to act, in one file. // // Shared by scripts/lp-agent.mjs (a person, plan by default) and // worker-lp/index.js (the daily cron with the keys). Two copies of a refusal // list is how one of them stops refusing. Pure: no chain, no I/O. // // The steps, their guard functions, and the floors they share: // sweep AI income (USD1, $U) -> BNB -> the DeFi wallet // collect position fees -> BNB -> half kept as capital, half into $BOBAI // the wallet holds (the buyback wallet until 2026-09-09) // rebalance a range the price has left -> one-sided beside the price // ladder BNB beside an all-other main range -> a reserve range below // increase BNB above the reserve -> more of the same position // // Every floor is a gas argument: below it, moving the money costs more than // the money. A day under a floor is a decision, not an error, and is recorded // as one. // What the DeFi wallet keeps back after every run. Above MIN_GAS_BNB on // purpose: the first build kept 0.001 and demanded 0.0015 to act, so the first // real collect would have left the wallet unable to do the second. export const GAS_RESERVE_BNB = 0.002; // Below this the collect's five transactions cannot be paid for at 1 gwei. export const MIN_GAS_BNB = 0.0015; // Fees worth less than this stay in the position: at 1 gwei the collect, // sale, unwrap and transfer cost about 0.0004 BNB, and a run should clearly // beat its gas rather than marginally. export const MIN_COLLECT_BNB = 0.002; // Income worth less than this stays where it landed: approve + swap is about // 0.0002 BNB at 1 gwei, so anything under 0.004 would lose more than 5% to gas. export const MIN_SWEEP_BNB = 0.004; // An income wallet needs this much to pay for its own approve + swap. export const MIN_SWEEP_GAS_BNB = 0.0005; // The most a single sweep moves from one wallet. Not a refusal: a larger // balance is swept in daily slices. A bug that produced a huge balance would // then move a bounded amount a day rather than everything at once. export const MAX_SWEEP_USD = 50; // Capital under this stays as BNB in the wallet: growing the position is // three to four transactions (wrap, swap, increase, unwrap; approvals only // when the allowance is short). Was 0.01 while the gas was assumed at 1 gwei; // measured on 2026-09-04 the eight-transaction re-set cost 0.000075 BNB, so // four transactions are about 0.00004 BNB and 0.005 BNB is where that stays // under 1%. Capital idling in the wallet earns nothing; since the collect // keeps half of the fees as capital, the floor decides how soon they earn. export const MIN_INCREASE_BNB = 0.005; // Kept out of the amount that goes into the position, so the increase itself // never spends the reserve. export const INCREASE_GAS_BUDGET_BNB = 0.001; // A re-set of the range is eight transactions, about 0.0008 BNB at 1 gwei; // under this much capital that is over 4% of the position for one move. export const MIN_REBALANCE_BNB = 0.02; // How long the price has to stay outside the range before a re-set is paid // for. A price that left a minute ago is often back within the hour, and a // re-set then pays for a move the market undid on its own. The agent checks // hourly; two checks outside in a row is the trigger. The earnings test in // the window record replays every width with this same delay, so the width // it picks was picked for the way the agent actually behaves. export const RESET_AFTER_HOURS = 2; // Since 2026-09-09 the wait is measured, not set. The window record replays // every width with a wait of 0 to 24 h (the grid in lp-windows.js) before a re-set and names the // net per day of each; the re-set uses the wait that netted the most — once // the record holds WAIT_PICK_MIN_HOURS of prices AND that wait beats the set // one by WAIT_PICK_MARGIN. Under either bar the set wait stands. The bar is // there because the first readings were not monotonic (157 h on 2026-09-09: // 0 h $0.89, 1 h $0.80, 2 h $0.73, 3 h $0.81 a day on $50): a wait that wins // by a few cents on a week of prices is noise with a number on it, and a // re-set rule that flips every hour would be worse than a fixed one. export const WAIT_PICK_MIN_HOURS = 120; export const WAIT_PICK_MARGIN = 0.10; // delays: the window record's delay test rows ({hours, net_usd_per_day, …}); // hoursOfPrices: how much price the record holds. Returns the wait a re-set // uses and where it comes from — a pure function, so the record page, the // worker and the self-test cannot read the same rows three ways. export function waitInUse(delays, hoursOfPrices, set = RESET_AFTER_HOURS) { const priced = (delays || []).filter((d) => d && d.net_usd_per_day != null); const base = priced.find((d) => d.hours === set) || null; const best = priced.slice().sort((a, b) => b.net_usd_per_day - a.net_usd_per_day)[0] || null; const keep = (why) => ({ hours: set, basis: 'set', why }); if (!best) return keep(`the set wait of ${set} h — the record has not yet replayed every wait`); if (!(hoursOfPrices >= WAIT_PICK_MIN_HOURS)) return keep(`the set wait of ${set} h — the record holds ${hoursOfPrices} h of prices and a measured wait needs ${WAIT_PICK_MIN_HOURS} h`); // The set wait nets nothing at any width (since 2026-09-11 a re-set is // charged what its range lost against holding, and on a trending week no // width nets at 2 h) while another wait does: that wait is in use. The // bar below guards against flipping between two waits that both earn; a // wait that turns "hold" into "earn" is not a flip. if (!base) { if (best.net_usd_per_day > 0) return { hours: best.hours, basis: 'measured', why: `${best.hours} h netted $${best.net_usd_per_day} a day over ${hoursOfPrices} h of prices while the set ${set} h netted nothing at any width` }; return keep(`the set wait of ${set} h — no wait nets anything over ${hoursOfPrices} h of prices`); } if (best.hours === set) return { hours: set, basis: 'measured', why: `${set} h netted the most per day over ${hoursOfPrices} h of prices` }; // Nets are rounded to four places; so is the bar, or 0.9 × 1.1 lands a // hair above 0.99 and a wait exactly a tenth ahead is refused. const bar = Math.round(base.net_usd_per_day * (1 + WAIT_PICK_MARGIN) * 1e4) / 1e4; if (!(best.net_usd_per_day >= bar)) return keep(`the set wait of ${set} h — ${best.hours} h netted $${best.net_usd_per_day} a day against $${base.net_usd_per_day}, under the ${Math.round(WAIT_PICK_MARGIN * 100)}% bar for a change`); return { hours: best.hours, basis: 'measured', why: `${best.hours} h netted $${best.net_usd_per_day} a day against $${base.net_usd_per_day} at the set ${set} h, over ${hoursOfPrices} h of prices — more than the ${Math.round(WAIT_PICK_MARGIN * 100)}% bar` }; } // The earnings test needs this much recorded price before it may pick; a // width chosen on six hours of a quiet afternoon is a guess with a number on it. export const MIN_HOURS_FOR_EARNINGS = 24; // Until 2026-09-09 a re-set re-centred through the PancakeSwap V2 router, a // 0.25% pool: on ~0.04 WBNB a re-set that was 0.0001 BNB of fee, more than // the re-set's whole gas (0.00007 BNB), and the measured re-set cost charged // gas alone. Since then the trade goes through the position's own V3 pool // (0.05%) and every re-set writes its fee down; this rate prices the older // records, which only name their trade. export const V2_SWAP_FEE_PCT = 0.25; // How much of the fees a collect keeps as capital, in percent. The rest goes // to the buyback wallet. Until 2026-09-04 every collected fee went to the // buyback; since then the position keeps half, so it grows out of its own // earnings and the buyback's share grows with it ("er soll auch davon // wachsen"). The kept share waits as BNB in the DeFi wallet and goes // into the position with the next increase. worker-lp reads the live value // from LP_FEE_KEEP_PCT in wrangler.toml; this is the default and the one the // hand script uses. export const FEE_SHARE_KEPT_PCT = 50; // Split what a collect produced (bigint wei) into the part kept as capital // and the part sent to the buyback wallet. A percentage that is not a number // between 0 and 100 falls back to the default rather than to "send it all" // or "keep it all": a typo in a config must not change where the money goes // by more than the default does. Pure, pinned by the self-test. export function splitFees(producedRaw, keptPct = FEE_SHARE_KEPT_PCT) { const raw = Number(keptPct); const pct = Number.isFinite(raw) && raw >= 0 && raw <= 100 ? raw : FEE_SHARE_KEPT_PCT; const total = producedRaw > 0n ? producedRaw : 0n; const keep = (total * BigInt(Math.round(pct * 100))) / 10000n; return { keep, buyback: total - keep, pct }; } // A re-set of the range pays the old range's fees out with the principal. // Until 2026-09-08 the mint then folded all of them into the new capital: // five re-sets, 0.00177 BNB of fees, and the buyback wallet saw none of it, // because the collect step never reached its own floor before the next // re-set took the fees away. So the re-set splits them the same way the // collect does — the kept share is minted into the new capital, the rest is // sent on before the mint. Under this much the buyback share stays as // capital too: the unwrap and the transfer are two transactions, about // 0.00005 BNB at 1 gwei, and a share that only pays for its own gas is not // a share. Pure, pinned by the self-test. export const MIN_RESET_FORWARD_BNB = 0.0001; export function resetForward(foldedWei, keptPct = FEE_SHARE_KEPT_PCT) { const split = splitFees(foldedWei, keptPct); const floor = BigInt(Math.round(MIN_RESET_FORWARD_BNB * 1e6)) * 10n ** 12n; if (split.buyback <= 0n) return { forward: 0n, kept: split.keep, pct: split.pct, why: split.keep > 0n ? `all of it stays as capital (kept share ${split.pct}%)` : 'the old range owed no fees' }; if (split.buyback < floor) return { forward: 0n, kept: split.keep + split.buyback, pct: split.pct, why: `the buyback share ${(Number(split.buyback) / 1e18).toFixed(6)} BNB is under the ${MIN_RESET_FORWARD_BNB} BNB floor — it stays as capital` }; return { forward: split.buyback, kept: split.keep, pct: split.pct, why: null }; } // THE RESERVE RANGE'S FEES (2026-09-19). The reserve earns whenever the price // stands in it — since 2026-09-17 10:00 it does, about a fiftieth of what the // main range earns — and the collect step never asked it for them: they lay // there until the reserve was unwound and then went into the capital whole, // no share bought $BOBAI and no sum counted them (eight unwinds, 0.000017 BNB; // 0.000087 BNB owed on the day this was written). The collect now takes them // in the same run, so they are sold, split and counted with the main range's. // A collect is one transaction, about 0.000011 BNB: under this much owed the // reserve is left alone and its fees wait — at the floor the gas is a // twentieth of what it fetches, and the share it buys clears // MIN_RESET_FORWARD_BNB. Pure, pinned by the self-test. export const RESERVE_COLLECT_MIN_BNB = 2 * MIN_RESET_FORWARD_BNB; export function reserveCollect(owedBnb) { const owed = Number(owedBnb) || 0; if (owed >= RESERVE_COLLECT_MIN_BNB) return { collect: true, why: null }; return { collect: false, why: owed > 0 ? `the reserve range is owed ${owed.toFixed(6)} BNB, under the ${RESERVE_COLLECT_MIN_BNB} BNB a collect of its own is worth — its fees wait` : 'the reserve range is owed nothing' }; } // state: { positions, liquidity (bigint), owedBnbEquivalent, gasBnb, quoteOffPct } // owedBnbEquivalent is what this run would turn into BNB: fees owed by the // position plus anything an interrupted earlier run left in the wallet. export function refuseCollect(state) { if (state.positions === 0) return 'this wallet holds no position — open one first with lp-open.mjs'; if (state.positions > 1) return `this wallet holds ${state.positions} positions. Collecting from one of several silently is a decision a person should make, not a script.`; if (state.liquidity === 0n) return 'the position has no liquidity left in it'; if (state.owedBnbEquivalent <= 0) return 'nothing is owed yet'; if (state.owedBnbEquivalent < MIN_COLLECT_BNB) return `only ${state.owedBnbEquivalent.toFixed(6)} BNB of fees are owed, below the ${MIN_COLLECT_BNB} BNB floor — collecting it would cost more gas than it recovers`; if (state.gasBnb < MIN_GAS_BNB) return `the wallet holds ${state.gasBnb.toFixed(6)} BNB, not enough gas for collect + swap + transfer`; if (state.quoteOffPct != null && Math.abs(state.quoteOffPct) > 25) return `the quote implies a price ${state.quoteOffPct.toFixed(1)}% away from the pool's own — refusing rather than trading into something that moved`; return null; } // The old name, kept for anything that still imports it. export const refuse = refuseCollect; // state: { symbol, balance, bnbEquivalent, gasBnb, bnbUsd, feedAgeS, impliedUsd } // impliedUsd is what the route pays per token in dollars; a stablecoin that // routes far from a dollar is a thin or manipulated pool, not a bargain. export function refuseSweep(state) { const sym = state.symbol || 'the token'; if (!(state.balance > 0)) return `nothing has arrived since the last sweep — the wallet holds no ${sym}`; if (state.gasBnb < MIN_SWEEP_GAS_BNB) return `the wallet holds ${Number(state.gasBnb).toFixed(6)} BNB, not enough gas for approve + swap`; if (state.feedAgeS > 3600) return `Chainlink BNB/USD is ${Math.round(state.feedAgeS / 60)} min old — not a live price, so the route cannot be checked`; if (!(state.bnbUsd >= 100 && state.bnbUsd <= 5000)) return `Chainlink reports BNB at $${state.bnbUsd} — outside the plausible range, not trusting it`; if (state.bnbEquivalent < MIN_SWEEP_BNB) return `${Number(state.balance).toFixed(4)} ${sym} is worth ${Number(state.bnbEquivalent).toFixed(6)} BNB, below the ${MIN_SWEEP_BNB} BNB floor — gas would eat it. It stays until more arrives.`; if (state.impliedUsd != null && (state.impliedUsd < 0.9 || state.impliedUsd > 1.1)) return `the route prices ${sym} at $${state.impliedUsd.toFixed(4)} — outside 0.90–1.10, refusing to sell into that`; return null; } // WHEN A RANGE COUNTS AS LEFT (2026-09-16). A one-sided range is minted // right beside the price (ticksAdjacent, ONE_SIDED_GAP_TICKS away), so the // pool's own "in range" is false the moment it exists; taken literally, the // next hourly check would call that "left" and re-set it to the same place, // every hour, for gas. A price within RANGE_LEFT_TICKS of an edge sits at // the edge and has not left — that is half a percent, the drift of a quiet // hour on CAKE/BNB. The same slack applies to a centred range: a price half // a percent past the edge is a price that is often back within the hour. // Pure; pinned by scripts/lp-agent.mjs --self-test. export const RANGE_LEFT_TICKS = 50; export function rangeLeft(tick, tickLower, tickUpper, slack = RANGE_LEFT_TICKS) { const t = Number(tick), lo = Number(tickLower), hi = Number(tickUpper); if (![t, lo, hi].every(Number.isFinite) || !(hi > lo)) return { outside: false, side: null, ticks_away: 0, left: false }; if (t < lo) return { outside: true, side: 'below', ticks_away: lo - t, left: lo - t > slack }; if (t >= hi) return { outside: true, side: 'above', ticks_away: t - hi + 1, left: t - hi + 1 > slack }; return { outside: false, side: null, ticks_away: 0, left: false }; } // A one-sided range starts this many ticks beyond the price (0.2%), so the // mint that follows the read is still entirely on its side of the price // when the block comes — a range the price has entered in between needs the // other token, which the wallet does not hold, and the mint would take // nothing. The same tolerance the minimums of a centred mint use. export const ONE_SIDED_GAP_TICKS = 20; // THE WIDTH (2026-09-16, evening): the width that left the most money // against holding over the last WIDTH_WINDOW_HOURS, fees included — // replayed the way the agent lives it (one-sided re-sets after the wait in // use). Score = fees_usd − the re-sets' cost + vs_holding_usd of the week's // replay: what the liquidity earned, less the gas of its re-sets, plus where // it ended against a wallet that held the minted amounts. That is the line the whole agent is judged by on its // card, so the width is chosen by it and by nothing else. In a ranging // week narrow widths win (fees high, nothing lost to the trend); in a // trending week wide ones win (less sold on the way up, less held on the // way down). The morning's rule, "the narrowest width in range 95% of the // hours", was a stand-in for that and picked ±4% on a week where ±10% had // ended $0.72 further ahead on $50; before it, "the most net per day once // every re-set is charged its hindsight loss" had answered "hold". A width // in use (`current`, the class of the position's ticks) is kept unless // another leads it by WIDTH_PICK_MARGIN of its own score and at least // WIDTH_PICK_MIN_LEAD_USD on $50 a week — the same bar the wait pick uses, // so two widths a few cents apart do not swap every re-set. Pure; pinned. export const WIDTH_WINDOW_HOURS = 168; export const WIDTH_PICK_MARGIN = 0.10; export const WIDTH_PICK_MIN_LEAD_USD = 0.02; // Kept for the record of the morning rule and its pins; not applied. export const IN_RANGE_TARGET = 0.95; export function pickWidth(rows, { current = null, key = 'earnings_7d', margin = WIDTH_PICK_MARGIN, minLead = WIDTH_PICK_MIN_LEAD_USD } = {}) { const r4 = (x) => Math.round(x * 1e4) / 1e4; const cand = (rows || []) .filter((r) => r && r.width !== 'full' && isFinite(Number(r.width)) && r[key] && Number(r[key].hours) > 0 && typeof r[key].vs_holding_usd === 'number' && typeof r[key].fees_usd === 'number') .map((r) => ({ row: r, width: Number(r.width), hours: Number(r[key].hours), fees: Number(r[key].fees_usd), vs: Number(r[key].vs_holding_usd), share: Number(r[key].hours_in_range) / Number(r[key].hours) })) .map((c) => ({ ...c, gas: Number(c.row[key].resets || 0) * Number(c.row[key].reset_cost_usd || 0) })) .map((c) => ({ ...c, score: r4(c.fees - c.gas + c.vs) })); if (!cand.length) return null; // The most money against holding; a tie goes to the wider width, which // is crossed less often. const sorted = cand.slice().sort((a, b) => b.score - a.score || b.width - a.width); const best = sorted[0]; const cur = current != null ? cand.find((c) => c.width === Number(current)) : null; const table = sorted.map((c) => `±${c.width}% ${c.score >= 0 ? '+' : '−'}$${Math.abs(c.score).toFixed(2)}`).join(', '); const shape = (c, kept, why) => ({ width: c.width, score_usd: c.score, fees_usd: r4(c.fees), vs_holding_usd: r4(c.vs), in_range_share: Math.round(c.share * 1000) / 1000, hours: c.hours, kept_current: kept, best_width: best.width, best_score_usd: best.score, earnings: c.row.earnings || null, earnings_7d: c.row[key], basis: why, }); if (cur && cur.width !== best.width) { const bar = r4(cur.score + Math.max(Math.abs(cur.score) * margin, minLead)); if (best.score < bar) return shape(cur, true, `±${cur.width}% stays: ±${best.width}% ended $${best.score.toFixed(2)} against holding (fees in) over the last ${Math.round(cur.hours)} h on $50, ±${cur.width}% $${cur.score.toFixed(2)} — under the bar of $${bar.toFixed(2)} for a change (${Math.round(margin * 100)}% of its own score, at least $${minLead.toFixed(2)}). The week: ${table}`); return shape(best, false, `±${best.width}% ended the most ahead against holding over the last ${Math.round(best.hours)} h on $50, fees in: $${best.score.toFixed(2)} ($${best.fees.toFixed(2)} of fees, ${best.vs >= 0 ? '+' : '−'}$${Math.abs(best.vs).toFixed(2)} against holding), over the bar of $${bar.toFixed(2)} against the ±${cur.width}% in use. The week: ${table}`); } return shape(best, !!cur, `±${best.width}% ended the most ahead against holding over the last ${Math.round(best.hours)} h on $50, fees in: $${best.score.toFixed(2)} ($${best.fees.toFixed(2)} of fees, ${best.vs >= 0 ? '+' : '−'}$${Math.abs(best.vs).toFixed(2)} against holding)${cur ? ', the width in use' : ''}. The week: ${table}`); } // state: { positions, inRange, atEdge, side, ticksAway, width, hoursOfPrices, valueBnb } // width is what the width record picked (pickWidth), or what a person named // by hand; null means the record holds no day of prices yet. atEdge: the // price is outside but within RANGE_LEFT_TICKS of an edge (rangeLeft). // state.resume: no position, but the wallet holds the pool's two tokens — a // re-set that stopped between its unwind and its mint. Then the plan is the // mint alone, sized like a re-set (the same width, the same floor), and the // "in range" question does not arise: there is no range yet. export function refuseRebalance(state) { if (state.positions !== 1 && !(state.positions === 0 && state.resume)) return state.positions === 0 ? 'this wallet holds no position to re-set' : `this wallet holds ${state.positions} positions — which one to re-set is a decision for a person`; if (state.inRange) return 'the price is inside the range — nothing to re-set'; if (state.atEdge) return `the price sits ${state.ticksAway ?? '?'} tick${state.ticksAway === 1 ? '' : 's'} ${state.side || 'beyond'} the range — at the edge, within the ${RANGE_LEFT_TICKS}-tick slack, not left`; if (state.width == null) return `no width is on record yet (${state.hoursOfPrices || 0} h of prices recorded, ${MIN_HOURS_FOR_EARNINGS} h needed before the record may name one). Holding.`; // A re-set that runs dry between its unwind and its mint leaves the capital // loose in a wallet that cannot pay to put it back (2026-09-18). Re-sets are // paid from native BNB that only fees and income refill; the collect has // held this floor since the first build, the re-set and the ladder had none. if (state.walletBnb != null && !(Number(state.walletBnb) >= MIN_GAS_BNB)) return `the wallet holds ${Number(state.walletBnb).toFixed(6)} BNB, below the ${MIN_GAS_BNB} BNB it takes to be sure of paying a re-set through to its mint — it waits for BNB`; if (!(state.valueBnb >= MIN_REBALANCE_BNB)) return `${state.resume ? "the wallet's two sides are" : 'the position is'} worth ${Number(state.valueBnb || 0).toFixed(6)} BNB, below the ${MIN_REBALANCE_BNB} BNB floor — a ${state.resume ? 'mint' : 're-set'} would cost more than it is likely to earn back`; return null; } // state: { positions, hasTarget, targetHasWbnb, samePool, width, valueBnb, move } // A relocate is a re-set into another pool: withdraw, leave the old pair, // enter the new one, mint. The pool record's switch rule (move) says whether // it is worth it; a person naming --to on the hand script is a decision of // their own and passes no move. Everything a re-set refuses, this refuses too. // THE POOL IS DECIDED. On 2026-09-11 the operator closed the question the // pool record had been measuring for a day ("wir bleiben immer bei CAKE/BNB // und optimieren nur das. keine anderen Pools"): the agent lives in // CAKE/BNB 0.05% and gets better there, width, wait and sizing, with every // hour of record about one pool. A move is a withdrawal, two trades through // two pools and a mint — two re-sets' cost plus what the range lost — paid // to chase a lead measured in gross fees; there is no pool it moves to. The // only relocate the guard lets through is one that brings a position that // is somewhere else back home. export const HOME_POOL = { pool: '0xafb2da14056725e3ba3a30dd846b6bbbd7886c56', label: 'CAKE/BNB 0.05%', since: '2026-09-11', why: 'the agent stays in CAKE/BNB 0.05% and optimises there — the operator\'s decision of 2026-09-11; there is no pool it moves to', }; export function refuseRelocate(state) { if (state.positions !== 1) return state.positions === 0 ? 'this wallet holds no position to move' : `this wallet holds ${state.positions} positions — which one to move is a decision for a person`; if (!state.toPool || String(state.toPool).toLowerCase() !== HOME_POOL.pool) return HOME_POOL.why; if (state.move && state.move.move === false) return `the switch rule says stay: ${state.move.why}`; if (!state.hasTarget) return 'no pool to move to was named'; if (!state.targetHasWbnb) return 'the pool named is not against WBNB; this agent only holds WBNB pairs, so the record stays in BNB'; if (state.samePool) return 'the pool named is the one the position is in — nothing to move'; if (state.width == null) return 'no width is known for the new range'; if (!(state.valueBnb >= MIN_REBALANCE_BNB)) return `the position is worth ${Number(state.valueBnb || 0).toFixed(6)} BNB, below the ${MIN_REBALANCE_BNB} BNB floor — a move would cost more than it is likely to earn back`; return null; } // The wait after the price leaves the range, before a re-set is paid for. // outSinceMs is when the agent first saw the price outside (null: this is the // first time), nowMs is now. Null means the wait is over and a re-set is due. // The checks run on an hourly grid, so "two hours" means the check two slots // later — not two hours to the millisecond. On 2026-09-08 the price left the // range at the 17:50:37.852 check and the 19:50:37.800 check, 52 ms short of // two hours, waited another hour for it; a few minutes of slack is the // difference between the rule and the cron's jitter. // A deposit that waits beside a range the price has left. The wait rule // weighs a re-set's cost against the chance that the price comes back on its // own — for the position alone. With a deposit of a quarter of the position // or more idle in the wallet, the hours of waiting cost more than the re-set: // on 2026-09-10 0.3056 BNB waited beside a 0.29 BNB position from 11:18 UTC // for a 12:50 re-set, a third of a day's fees on the whole capital against a // $0.09 re-set. Then the re-set is due now, and the deposit watch may call // it. Pure; pinned by scripts/lp-agent.mjs --self-test. export const DEPOSIT_RESET_SHARE = 0.25; export function depositForcesReset(state) { const spendable = Number(state.spendableBnb || 0), value = Number(state.valueBnb || 0); if (state.inRange) return null; if (!(spendable >= MIN_INCREASE_BNB)) return null; if (!(value > 0) || spendable < DEPOSIT_RESET_SHARE * value) return null; return `${spendable.toFixed(4)} BNB waits beside a ${value.toFixed(4)} BNB position the price has left — a deposit of ${Math.round((spendable / value) * 100)}% of the position earns nothing while the wait runs, so the range is re-set now`; } export const RESET_WAIT_SLACK_MIN = 5; export function rebalanceWait(outSinceMs, nowMs, hours = RESET_AFTER_HOURS) { // A measured wait of 0 h is no wait: the re-set is due the hour the price // is first seen outside. if (!(hours > 0)) return null; if (outSinceMs == null) return `the price has just left the range — waiting ${hours} h in case it comes back on its own`; const h = (nowMs - outSinceMs) / 36e5; if (!(h >= hours - RESET_WAIT_SLACK_MIN / 60)) return `the price has been outside for ${Math.max(0, h).toFixed(1)} h — waiting until ${hours} h before paying for a re-set`; return null; } // A width upgrade: the price is inside the range, so nothing forces a re-set, // but the window record's earnings test now names a different width that // nets more per day. Until 2026-09-10 the agent only took the new width when // the price left the old range — a position minted at 2% sat for days // while the record said 1% earned a seventh more. The rule: once a day, in // range, with a day of prices on record, re-set into the picked width when // the extra it nets on this position's capital clears the re-set's cost // within a day AND is more than a tenth of what the current width nets — // the same margin the wait pick uses, so noise between two close widths // never pays for a re-set. At most one such re-set a day, bounded by the // daily run; the hourly checks never upgrade. export const WIDTH_UPGRADE_MARGIN = 0.1; // until 2026-09-11; kept for the record, no longer applied // A switch of a live range pays back within this many days, or it waits // (2026-09-11): the gain a day on the position, against the switch's full // cost — execution plus what the current range would realise at today's // price. Three days, as the pool switch rule had it; a pick that will not // carry its own cost in three days is not a pick, it is a reading. export const WIDTH_UPGRADE_PAYBACK_DAYS = 3; // The widths the hourly replay measures (six, since 2026-09-02) and the // widths derived between them (2026-09-11, "die mathematisch beste Range"): // inside its range a position's fee share is its liquidity share, and for // the same dollars that is 1/width — the record's own rows say so to the // digit (±1% 0.020, ±2% 0.010, ±5% 0.004, ±10% 0.002 in one hour). So a // ±3% row is the ±5% row times 5/3, read off the wider neighbour, which was // in range whenever the narrower one was. The grid is what the earnings // test replays and what a re-set may mint; widthClassOf snaps to it. export const REPLAYED_WIDTHS = [0.25, 0.5, 1, 2, 5, 10]; export const DERIVED_WIDTHS = [1.5, 3, 4, 7]; export const RECORD_WIDTHS = REPLAYED_WIDTHS.concat(DERIVED_WIDTHS).sort((a, b) => a - b); // WHAT A RANGE IS WORTH AT ANOTHER PRICE. A position minted centred on p0 // with a value of 1, in the symmetric range p0/up ... p0*up, holds an amount // of each side that the pool's own curve fixes; at another price p it holds // different amounts, and less than a wallet that kept the minted amounts // would (`hodl`). Inside the range the curve applies; below it the position // is all of the priced token and moves with p; above it, all of the quote // and moves not at all. `loss` is what the range has given up against // holding, as a share of the holding: never negative, zero at p0. The // earnings test charges it at every replayed re-set; resetLosses reads it // off the re-sets that happened; widthUpgrade charges it to a switch. export function rangeValue(p0, widthPct, p) { const up = 1 + widthPct / 100; const pa = p0 / up, pb = p0 * up; const sa = Math.sqrt(pa), sb = Math.sqrt(pb), s0 = Math.sqrt(p0); const L = 1 / (2 * s0 - sa - p0 / sb); const x0 = L * (1 / s0 - 1 / sb), y0 = L * (s0 - sa); const value = p <= pa ? L * (1 / sa - 1 / sb) * p : p >= pb ? L * (sb - sa) : L * (2 * Math.sqrt(p) - sa - p / sb); const hodl = x0 * p + y0; return { value, hodl, loss: Math.max(0, 1 - value / hodl) }; } // The width class of a position from its ticks: half its span, in percent, // snapped to the record's width nearest on a log scale (a 380-tick range is // ±1.9%, the record's 2%). Null without ticks. export function widthClassOf(ticks) { if (!Array.isArray(ticks) || ticks.length !== 2 || !(ticks[1] > ticks[0])) return null; const half = (Math.pow(1.0001, (ticks[1] - ticks[0]) / 2) - 1) * 100; return RECORD_WIDTHS.slice().sort((a, b) => Math.abs(Math.log(a / half)) - Math.abs(Math.log(b / half)))[0]; } // state: { daily, inRange, ticks, pick: {width, earnings:{net_usd_per_day}}, // rows: the record's rows, hoursOfPrices, valueBnb, bnbUsd, resetCostUsd } // Returns { upgrade: true, why, from, to, gain_usd_per_day } or { upgrade: false, why }. // RETIRED 2026-09-16, with the one-sided re-set: a position in range is // never touched. A switch of width while in range was a full re-set with // its trade and its realised loss, paid for a lead measured in hindsight; // the width now changes at the next natural re-set, which trades nothing. // The function stays, says so, and its pins pin the refusal. export const WIDTH_UPGRADE_ENABLED = false; export function widthUpgrade(state) { const no = (why) => ({ upgrade: false, why }); if (!WIDTH_UPGRADE_ENABLED) return no('the width changes at the next re-set, never while the price is inside the range (2026-09-16)'); if (!state.daily) return no('the hourly check does not upgrade a width — the daily run does, once'); if (!state.inRange) return no('the price is outside the range — that is a re-set, not an upgrade'); const from = widthClassOf(state.ticks); if (from == null) return no('the position names no ticks'); const pick = state.pick; if (!pick || pick.earnings == null || !(pick.earnings.net_usd_per_day > 0)) return no('the record names no width that earns'); if (!(state.hoursOfPrices >= MIN_HOURS_FOR_EARNINGS)) return no(`${state.hoursOfPrices || 0} h of prices on record, ${MIN_HOURS_FOR_EARNINGS} h needed before a width may be upgraded`); if (pick.width === from) return no(`the position is at the picked width (${from}%)`); const row = (state.rows || []).find((r) => r.width === from); const nowNet = row && row.earnings ? Number(row.earnings.net_usd_per_day) : null; if (nowNet == null) return no(`the record has no earnings for the position's width (${from}%)`); const usd = Number(state.valueBnb || 0) * Number(state.bnbUsd || 0); if (!(usd > 0)) return no('the position has no dollar value to scale the record by'); const scale = usd / 50; const r4 = (x) => Math.round(x * 1e4) / 1e4; const gain = (Number(pick.earnings.net_usd_per_day) - nowNet) * scale; if (!(gain > 0)) return no(`${pick.width}% nets no more than ${from}% on $${usd.toFixed(2)}`); // The lead must hold over the last day too (2026-09-11): a lead the whole // record shows but the last day does not is one the market has left. const pickRow = (state.rows || []).find((r) => r.width === pick.width); const pickDay = pickRow && pickRow.earnings_24h ? Number(pickRow.earnings_24h.net_usd_per_day) : null; const nowDay = row && row.earnings_24h ? Number(row.earnings_24h.net_usd_per_day) : null; if (pickDay == null || nowDay == null) return no(`the record has no last-day replay for ${pick.width}% and ${from}% yet`); if (!(pickDay > nowDay)) return no(`${pick.width}% leads ${from}% over ${state.hoursOfPrices} h ($${r4(gain)} a day on $${usd.toFixed(2)}) but not over the last day ($${r4(pickDay)} against $${r4(nowDay)} on $50) — the lead is not standing, no switch`); // The full cost of switching now: the re-set's execution (gas, swap fee, // the measured impact) plus what the current range has lost against // holding at today's price — a switch realises it, holding might not. const execution = Number(state.resetCostUsd || 0); const centre = Array.isArray(state.ticks) && state.ticks.length === 2 ? (state.ticks[0] + state.ticks[1]) / 2 : null; const realised = centre != null && state.tick != null ? rangeValue(Math.pow(1.0001, centre), from, Math.pow(1.0001, Number(state.tick))).loss * usd : 0; const cost = execution + realised; const paybackDays = gain > 0 ? cost / gain : Infinity; if (!(paybackDays <= WIDTH_UPGRADE_PAYBACK_DAYS)) return no(`${pick.width}% nets $${r4(gain)} a day more than ${from}% on $${usd.toFixed(2)}, but the switch costs $${r4(cost)} ($${r4(execution)} to execute, $${r4(realised)} the range would realise at today's price) — ${paybackDays === Infinity ? 'never' : r4(paybackDays) + ' days'} to pay back, more than the ${WIDTH_UPGRADE_PAYBACK_DAYS} allowed`); return { upgrade: true, from, to: pick.width, gain_usd_per_day: r4(gain), cost_usd: r4(cost), execution_usd: r4(execution), realised_usd: r4(realised), payback_days: r4(paybackDays), why: `in range at ${from}%, but ${pick.width}% netted $${r4(gain)} a day more on $${usd.toFixed(2)} over ${state.hoursOfPrices} h of prices and leads over the last day too ($${r4(pickDay)} against $${r4(nowDay)} on $50); the switch costs $${r4(cost)} ($${r4(execution)} to execute, $${r4(realised)} realised at today's price) and pays back in ${r4(paybackDays)} days`, }; } // state: { positions, spendableBnb, inRange, wbnbOnly } // spendableBnb is what the wallet holds above the reserve and the gas budget. // wbnbOnly (2026-09-16): the range lies entirely below the price and holds // only WBNB — a buy ladder. BNB joins it as it is, no trade, and earns the // moment the price comes down into it; that is growth, not a range that // earns nothing. A range above the price (all of the other side) still // refuses: joining it would mean buying the other side, and buying is what // the ladder step does with a range of its own below the price. export function refuseIncrease(state) { if (state.positions !== 1) return state.positions === 0 ? 'this wallet holds no position to grow' : `this wallet holds ${state.positions} positions — which one to grow is a decision for a person`; if (!(state.spendableBnb >= MIN_INCREASE_BNB)) return `only ${Number(state.spendableBnb || 0).toFixed(6)} BNB above the reserve, below the ${MIN_INCREASE_BNB} BNB floor — it stays as BNB until more arrives`; if (!state.inRange && state.wbnbOnly) return null; if (!state.inRange) return 'the price is outside the position\'s range, above it: the range is all of the other side, and BNB would have to be traded into it. The BNB is held for the ladder (a range of its own below the price) or the next re-set.'; return null; } // THE LADDER (2026-09-16, the operator's "BNB als Reserve fuer Nachkauf"). // After a one-sided re-set below the price the whole position is the other // side, waiting above the price. BNB that arrives then — a deposit, the // kept half of the fees — used to be traded into the other side (the // increase) or to idle. The ladder gives it a range of its own BELOW the // price, WBNB only, no trade: a buy ladder under the sell ladder. Whichever // way the price goes, one of the two earns, and the lower one buys the other // side on the way down through fees instead of through a swap. The two are // merged back into one the moment they hold the same token (the price went // through one of them): the reserve is unwound at the main range's next // re-set and its tokens go into the new range as they are. // state: { gate, positions, mainSide ('other'|'wbnb'|'both'|null), reserve // (bool), reserveSide, spendableBnb, reserveLeft (bool), reserveBnb // (what the reserve is worth; absent = not checked) } // Returns { act: 'mint_reserve'|'increase_reserve'|'merge'|'reset_reserve' // |null, why }. Pure; pinned both ways. // A MINT FROM THE WALLET FINISHES THE RE-SET IT BELONGS TO (2026-09-18). A // one-sided re-set whose mint failed leaves the wallet holding what the old // range ended in: all of the other side, or all WBNB. Until now the resume // minted CENTRED, which trades about half of the capital — sells the fallen // side at its low or buys the risen one at its high, the very trade the // one-sided re-set exists to avoid. A wallet that holds (nearly) one token // alone is minted one-sided on that token's side; a mixed wallet is centred // as before. `shareOther` is the other side's share of the wallet's value. // Returns ticksAdjacent's side ('below' = range above the price, all of the // other side; 'above' = range below it, all WBNB) or null. Pure; pinned. export const RESUME_ONE_SIDED_SHARE = 0.95; export function resumeSide(shareOther) { if (shareOther == null || !isFinite(shareOther)) return null; if (shareOther >= RESUME_ONE_SIDED_SHARE) return 'below'; if (shareOther <= 1 - RESUME_ONE_SIDED_SHARE) return 'above'; return null; } export const LADDER_GATE = 'LP_LADDER'; // WHAT THE TEN-MINUTE WATCH MAY DO TO THE LADDER (2026-09-18). The watch is // there so a deposit goes to work within minutes: it opens and grows the // reserve. It does not re-set one. While the main range is all of the other // side, every 21-30 ticks the price climbed back re-set the reserve again at // the next watch — up to six an hour, each ~0.12% of the reserve in gas, to // stand a few ticks nearer a price the main range is about to take back. The // hourly check and the daily run re-set it, once, where the price then is. export function ladderActsInWatch(act) { return act === 'mint_reserve' || act === 'increase_reserve'; } // THE LADDER RECORD FOLLOWS THE CHAIN (2026-09-17). The record names a main // range the wallet no longer holds (burnt at a re-set whose new id was never // written — a tick that died between the mint and the KV write, or the read // that returned no id on 09-16 08:50) while the reserve it names is still // there beside exactly one other position in the same pool: that other one // is the main range. Anything else — both still held, neither held, a third // position, another pool — is left as it is, and the guards refuse. // state: { main, reserve, held: [ids], samePool (bool), looseBnb (the pool's // two tokens loose in the wallet, in BNB — read when only the // reserve is held) } // Returns { main, why } (+ closed | adopted with reserve) or null. Pure; // pinned both ways. export function ladderHeal(state) { const held = (state.held || []).map(String); if (state.main == null) return null; // THE SAME CLASS ON THE RESERVE'S SIDE (2026-09-18, read in the code, never // seen with money): a reserve was minted and its id never written — the // read after the mint named no new id, or the tick died before the KV // write. The main range is still held, beside exactly one position the // record does not name (it names no reserve, or one the wallet no longer // holds — burnt at a reserve re-set), in the same pool: that one is the // reserve. Without this the wallet reads as "2 positions" and every step // refuses until a person patches the record, as on 09-16/17. if (held.length === 2 && held.includes(String(state.main)) && (state.reserve == null || !held.includes(String(state.reserve)))) { if (state.samePool !== true) return null; const reserve = held.find((i) => i !== String(state.main)); return { main: String(state.main), reserve, adopted: true, why: `the ladder record named ${state.reserve == null ? 'no reserve' : `reserve #${state.reserve}, which this wallet no longer holds`}; beside the main range #${state.main} it holds exactly one other position in the same pool, #${reserve} — that is the reserve now` }; } if (state.reserve == null) return null; // A re-set that burned the main range and failed to mint the new one // (2026-09-18, read in the code, never seen with money): the wallet holds // the reserve alone and the record names a burnt main range. Without this // readPosition returns the reserve as the main range while the record // keeps naming the burnt id, and every step refuses forever. The reserve is // the main range now, the ladder is closed; the resume and increase paths // take it from there. if (held.length === 1 && held[0] === String(state.reserve)) { // ... unless the main range's capital lies loose in the wallet, enough to // mint (the re-set floor): then the re-set is finished from the wallet // beside the reserve (readPosition reads "no main range, reserve // attached", planRebalance resumes one-sided) and the ladder stays a // ladder. Closing it here handed that capital to the increase step, which // sold it into the reserve's ratio (2026-09-18). if (Number(state.looseBnb || 0) >= MIN_REBALANCE_BNB) return null; return { main: String(state.reserve), reserve: null, closed: true, why: `the ladder record named main range #${state.main}, which this wallet no longer holds; the reserve #${state.reserve} is the only range left, so it is the main range now and the ladder is closed` }; } if (held.length !== 2) return null; if (!held.includes(String(state.reserve)) || held.includes(String(state.main))) return null; if (state.samePool !== true) return null; const main = held.find((i) => i !== String(state.reserve)); return { main, why: `the ladder record named main range #${state.main}, which this wallet no longer holds; beside the reserve #${state.reserve} it holds exactly one other position in the same pool, #${main} — that is the main range now` }; } export function ladderDecision(state) { const no = (why) => ({ act: null, why }); if (state.positions === 0) return no('no position: the first deposit opens the main range, not a ladder'); if (state.positions > 2 || (state.positions === 2 && !state.reserve)) return no(`this wallet holds ${state.positions} positions the ladder record does not name — a decision for a person`); const spendable = Number(state.spendableBnb || 0); if (state.reserve) { if (state.mainSide && state.reserveSide && state.mainSide === state.reserveSide && state.mainSide !== 'both') return { act: 'merge', why: `main and reserve both hold only ${state.mainSide === 'wbnb' ? 'WBNB' : 'the other side'} — the price went through one of them; the reserve joins the main range at its re-set, no trade` }; // THE RESERVE DOES NOT CHASE A PRICE THE MAIN RANGE IS IN (2026-09-17). // A reserve the price has left earns nothing where it stands and nothing // more one re-set higher; each re-set costs ~0.12% of it in gas. On 09-17 // the price climbed inside the main range and the reserve was re-set // eight times behind it — 0.96% of the reserve in a day, for fees of // dust. While the main range is in range it is itself the ladder that // buys on the way down, and if the price climbs out of it both hold WBNB // and merge. The reserve is re-set only when nothing else stands at the // price: the main range is all of the other side above it. // A reserve under the re-set floor is not re-set at all: the two // transactions would cost more of it than standing nearer the price is // likely to earn back (the floor the main range is held to). It waits // where it is and joins the main range at the merge. if (state.reserveLeft && state.mainSide === 'other' && state.reserveBnb != null && !(Number(state.reserveBnb) >= MIN_REBALANCE_BNB)) return no(`the price has left the reserve range, which is worth ${Number(state.reserveBnb).toFixed(6)} BNB, below the ${MIN_REBALANCE_BNB} BNB floor — a re-set would cost more than it is likely to earn back; it waits where it is`); if (state.reserveLeft && state.mainSide === 'other') return { act: 'reset_reserve', why: 'the price has left the reserve range by more than the slack and the main range is all of the other side above it — the reserve is re-set beside the price, one-sided, no trade' }; if (state.reserveLeft && spendable < MIN_INCREASE_BNB) return no(`the price has left the reserve range, but the main range is ${state.mainSide === 'both' ? 'in range and buys on the way down itself' : 'not above the price'} — the reserve waits where it is; a re-set would cost gas and earn nothing`); if (spendable >= MIN_INCREASE_BNB && state.mainSide === 'other') return { act: 'increase_reserve', why: `${spendable.toFixed(6)} BNB waits and the main range is all of the other side above the price — the BNB joins the reserve range below it, no trade` }; return no(spendable >= MIN_INCREASE_BNB ? 'BNB waits, but the main range is not all of the other side — the increase step takes it' : `the ladder stands: main ${state.mainSide === 'both' ? 'in range' : state.mainSide === 'wbnb' ? 'below the price' : 'above the price'}, reserve below it; ${spendable.toFixed(6)} BNB waits, under the ${MIN_INCREASE_BNB} BNB floor`); } if (state.mainSide !== 'other') return no(state.mainSide === 'wbnb' ? 'the main range is all WBNB below the price — BNB joins it through the increase step, no ladder needed' : 'the main range is in range and earns on both sides — BNB joins it through the increase step'); if (!(spendable >= MIN_INCREASE_BNB)) return no(`the main range is all of the other side above the price and only ${spendable.toFixed(6)} BNB waits, under the ${MIN_INCREASE_BNB} BNB floor — the ladder opens with the next deposit`); return { act: 'mint_reserve', why: `the main range is all of the other side above the price and ${spendable.toFixed(6)} BNB waits — it opens a reserve range below the price, WBNB only, no trade` }; } ============================================================================== === FILE: shared/package.json ============================================================================== { "name": "bobai-shared", "version": "0.1.0", "private": true, "type": "module", "//": "Marker only, no dependencies. The repo root is CommonJS while worker-agent declares type:module, so a .js file here would be CommonJS to Node and its named exports invisible to the ESM workers and scripts that import it — the bundlers accept the file either way, which is why that breaks in Node long after a deploy has succeeded. This file is what makes one shared module readable from both sides." } ============================================================================== === FILE: worker-agent/lp-pools.js ============================================================================== // THE POOL RECORD — RETIRED 2026-09-11. It ran for one day. The operator // closed the question it measured: the agent stays in CAKE/BNB 0.05% // (HOME_POOL in shared/lp-guards.js) and optimises there. Nothing calls // recordLpPools any more; /lp/pools says so; the functions and their pins // stay as the record of how twelve pools were compared, and for the day // the question is opened again. // // What it was: what the same fifty dollars would have earned in each of // the pools the DeFi agent could live in, hour by hour. // // The width record (lp-windows.js) answers "how wide" for the pool the agent // is in. This answers the question that comes before it: "which pool". Until // 2026-09-10 the candidates were three pools the operator named by hand // (CAKE/BNB in two tiers, BOB/BNB). From 2026-09-10 the operator's rule is // "always the best pool BNB Chain has on offer", so the candidates are the // universe the rule allows: every PancakeSwap V3 pool on BSC that pairs // WBNB with a major — a token whose price is set on many venues and cannot // be pulled out from under a position overnight — in every fee tier that // holds liquidity. The one-hour screen of 2026-09-10 11:45 UTC put the // USDT/WBNB and USDC/WBNB 0.01% pools level with CAKE/WBNB 0.05% at ±1%, // the BTCB and ETH pools far behind, and meme pools (BNC4/USDT 0.25%, // SPCXB/WBNB) at four to five times the fees — those are out by the rule: // the fee rate is the price of the risk that the other token goes to zero // or that the whole pool's liquidity leaves with its deployer, and a // position quoted in BNB cannot carry a token that is not quoted against // anything. Pools without WBNB are out for the same reason from the other // side: the record and the series are in BNB, and a USDT/BTCB position's // value in BNB says nothing about the position. A sample is not a rate. // This keeps the samples until they are one. // // It reads. It signs nothing, holds no key and moves nothing. The verdict // names the best pool once every pool has a day of hours; switchVerdict() // below says whether that finding is worth acting on (the margin, the // payback, the lead holding over the last day). The agent's daily run acts // on that rule once the relocate step is wired to it; until then the record // says so in words. // // COST. Every candidate but the watched one is one replay per hour on our // own MCP endpoint (the watched pool's window is copied from the width // record, which measured it minutes earlier in the same tick). Twelve // candidates is ~290 replays a day and one KV write an hour; the screen of // 2026-09-10 measured 1.4-2.8 s per replay, the busiest pool (USDT/WBNB // 0.01%, 16k swaps an hour) 2.8 s. import { KV_KEY as WINDOWS_KEY, POSITION_USD, measure, windowFromPlan, watchedPool } from './lp-windows.js'; export const KV_KEY = 'lp:pools'; // Hourly windows; 200 is over a week per pool, which is more than a pool // decision should rest on before somebody looks at it. export const MAX_WINDOWS = 200; // A day of hours before any pool is called better than another. Below this // the record reports and refuses to pick — the same threshold the width // record uses before it trusts its earnings test. export const MIN_HOURS_TO_PICK = 24; // The universe, by address: WBNB paired with a major, every fee tier with // liquidity. The watched pool is whichever of these env.LP_WATCH_POOL points // at; the others are measured beside it. BOB/BNB stays as the operator's // own token pair (named 2026-09-07), measured, never favoured. export const CANDIDATES = [ { pool: '0xafb2da14056725e3ba3a30dd846b6bbbd7886c56', label: 'CAKE/BNB 0.05%', pair: 'CAKE/BNB', fee_pct: 0.05 }, { pool: '0x1e213600fa9317feac4ef4087acdf5d0e25d7187', label: 'CAKE/BNB 0.01%', pair: 'CAKE/BNB', fee_pct: 0.01 }, { pool: '0x172fcd41e0913e95784454622d1c3724f546f849', label: 'USDT/BNB 0.01%', pair: 'USDT/BNB', fee_pct: 0.01 }, { pool: '0x36696169c63e42cd08ce11f5deebbcebae652050', label: 'USDT/BNB 0.05%', pair: 'USDT/BNB', fee_pct: 0.05 }, { pool: '0xf2688fb5b81049dfb7703ada5e770543770612c4', label: 'USDC/BNB 0.01%', pair: 'USDC/BNB', fee_pct: 0.01 }, { pool: '0x4a3218606af9b4728a9f187e1c1a8c07fbc172a9', label: 'USD1/BNB 0.05%', pair: 'USD1/BNB', fee_pct: 0.05 }, { pool: '0x6bbc40579ad1bbd243895ca0acb086bb6300d636', label: 'BTCB/BNB 0.05%', pair: 'BTCB/BNB', fee_pct: 0.05 }, { pool: '0x62edaf2a56c9fb55be5f9b1399ac067f6a37013b', label: 'BTCB/BNB 0.01%', pair: 'BTCB/BNB', fee_pct: 0.01 }, { pool: '0xd0e226f674bbf064f54ab47f42473ff80db98cba', label: 'ETH/BNB 0.05%', pair: 'ETH/BNB', fee_pct: 0.05 }, { pool: '0x62fcb3c1794fb95bd8b1a97f6ad5d8a7e4943a1e', label: 'ETH/BNB 0.01%', pair: 'ETH/BNB', fee_pct: 0.01 }, { pool: '0xbffec96e8f3b5058b1817c14e4380758fada01ef', label: 'SOL/BNB 0.05%', pair: 'SOL/BNB', fee_pct: 0.05 }, { pool: '0x910a64e36da4bec09a0772b11d437869ad07dc4b', label: 'BOB/BNB 0.05%', pair: 'BOB/BNB', fee_pct: 0.05 }, ]; // The rule the universe is drawn by, in words the page can show. export const UNIVERSE_RULE = 'PancakeSwap V3 pools on BNB Chain that pair WBNB with a major (CAKE, USDT, USDC, USD1, BTCB, ETH, SOL) plus the project\'s own BOB/BNB, in every fee tier holding liquidity. Meme pairs are out whatever they pay: the fee is the price of the other token going to zero. Pools without WBNB are out: the record is in BNB.'; const labelOf = (pool) => (CANDIDATES.find((c) => c.pool === String(pool).toLowerCase()) || {}).label || String(pool); // One window, the shape the width record uses, trimmed to what a pool // comparison reads: the fees each width earned in the window, whether it // held, and how busy the pool was. export function poolWindow(entry) { return { at: entry.at, from_block: entry.from_block, to_block: entry.to_block, minutes: entry.minutes, swaps: entry.swaps, price: entry.price, pool_fees_usd: entry.pool_fees_usd, rows: (entry.rows || []).map((r) => ({ width: r.width, fees: r.fees, held: r.held })), }; } // Appends unless this exact chain slice is already there for that pool. export function appendPoolWindow(log, pool, entry) { const key = String(pool).toLowerCase(); const rec = log.pools[key] || { label: labelOf(key), windows: [] }; if (rec.windows.some((x) => x.from_block === entry.from_block && x.to_block === entry.to_block)) return { log, added: false }; const windows = rec.windows.concat(poolWindow(entry)); if (windows.length > MAX_WINDOWS) windows.splice(0, windows.length - MAX_WINDOWS); return { log: { ...log, pools: { ...log.pools, [key]: { ...rec, label: labelOf(key), windows } } }, added: true }; } // WHAT THE RECORD SAYS, per pool, at one width. Pure; pinned by // scripts/lp-pools.mjs --self-test. // - each pool is read over its own recorded hours (windows carry their // minutes; a window earns for the minutes it covers, nothing more), // - fees are what the replay says $usd would have collected inside the // width, so a pool where $50 is a large share of the working capital is // already diluted by its own arithmetic, // - a pool is "quiet" for a window in which nobody swapped, // - nothing is picked until every pool has a day of hours; then the pool // with the most fees per day at this width is named, and the watched // pool is named beside it whether or not they are the same, // - the pick is a finding. The agent never acts on it by itself. export function poolVerdict(log, widthPct, { watched = null, minHours = MIN_HOURS_TO_PICK } = {}) { const usd = log?.usd || POSITION_USD; const pools = Object.entries(log?.pools || {}).map(([pool, rec]) => { let fees = 0, hours = 0, swaps = 0, quiet = 0, held = 0, priced = 0, lastMinutes = null; for (const w of rec.windows || []) { const row = (w.rows || []).find((r) => r.width === widthPct); if (!row || typeof row.fees !== 'number') continue; const h = (w.minutes || 37.5) / 60; lastMinutes = w.minutes || 37.5; fees += row.fees; hours += h; priced += 1; swaps += Number(w.swaps || 0); if (!Number(w.swaps || 0)) quiet += 1; if (row.held) held += 1; } const r4 = (x) => Math.round(x * 10000) / 10000, r1 = (x) => Math.round(x * 10) / 10; return { pool, label: rec.label || labelOf(pool), watched: !!watched && pool === String(watched).toLowerCase(), windows: priced, hours: r1(hours), swaps, quiet_windows: quiet, held_pct: priced ? Math.round((held / priced) * 1000) / 10 : null, fees_usd: r4(fees), fees_usd_per_day: hours > 0 ? r4((fees / hours) * 24) : null, // What the newest window covers: the forecast of runs to go rests on it, // not on an average over windows of two sizes. minutes_per_window: lastMinutes, first: (rec.windows || [])[0]?.at || null, last: (rec.windows || []).slice(-1)[0]?.at || null, }; }).sort((a, b) => (b.fees_usd_per_day ?? -1) - (a.fees_usd_per_day ?? -1)); const enough = pools.length >= 2 && pools.every((p) => p.hours >= minHours); const pick = enough ? pools[0] : null; const watchedRow = pools.find((p) => p.watched) || null; // The hours are sampled chain, not clock time: an hourly window covers the // minutes it covers (37.5 until 2026-09-09, ~59 since), so a day of hours // took a day and a half of runs. On 2026-09-09 the record read "20 h" after 33 hours // and the operator took it for a delay. Each pool says how many more // hourly runs it needs, and the record names the hour the pick is due. const toGo = pools.map((p) => { if (!p.windows || p.hours >= minHours) return { ...p, runs_to_go: 0 }; const perWindow = (p.minutes_per_window || 37.5) / 60; return { ...p, runs_to_go: Math.ceil((minHours - p.hours) / perWindow) }; }); const pending = toGo.filter((p) => p.runs_to_go > 0 && p.last); const pickDue = !enough && pending.length && pending.every((p) => Date.parse(p.last)) ? new Date(Math.max(...pending.map((p) => Date.parse(p.last) + p.runs_to_go * 3600e3))).toISOString() : null; return { usd, width_pct: widthPct, pools: toGo, pick: pick ? { pool: pick.pool, label: pick.label, fees_usd_per_day: pick.fees_usd_per_day } : null, pick_due: pickDue, watched: watchedRow ? { pool: watchedRow.pool, label: watchedRow.label, fees_usd_per_day: watchedRow.fees_usd_per_day } : null, why: !pools.length ? 'nothing recorded yet' : !enough ? `no pick until every pool has ${minHours} h of sampled chain (${toGo.map((p) => `${p.label} ${p.hours} h`).join(', ')}); an hourly window covers about ${Math.round(pools[0].minutes_per_window || 37.5)} minutes, so ${Math.max(...toGo.map((p) => p.runs_to_go))} more hourly runs${pickDue ? `, the pick is due around ${pickDue.slice(11, 16)} UTC on ${pickDue.slice(0, 10)}` : ''}` : pick && watchedRow && pick.pool === watchedRow.pool ? `${pick.label}, the pool the agent is in, earned the most per day for $${usd} in ±${widthPct}%` : pick && watchedRow ? `${pick.label} earned $${pick.fees_usd_per_day} a day for $${usd} in ±${widthPct}% against $${watchedRow.fees_usd_per_day} in ${watchedRow.label}, where the agent is. Whether that is worth a move is the switch rule beside this.` : `${pick.label} earned the most per day for $${usd} in ±${widthPct}%`, rule: 'Each pool is replayed with the same code over its own recorded hours; fees are what this much capital would have collected inside the width, diluted by the pool\'s own working capital. Nothing is picked until every pool has a day of windows. Whether the pick is worth a move is the switch rule (move): a lead of a quarter over all hours and over the last day, paying the move back within three days.', }; } // WHETHER THE FINDING IS WORTH ACTING ON. Pure; pinned by scripts/lp-pools.mjs. // A pool change is a withdrawal, two trades and a mint — about two re-sets // of cost — and a record that says "the other pool paid more this week" is // evidence, not a move. The rule that turns it into one: // - every pool has its day of hours (the verdict has a pick), // - the pick is not the pool the agent is in, // - the pick leads the watched pool by SWITCH_MARGIN over all recorded hours // AND over the last day alone — a lead built on one busy hour a week ago // is not a lead, // - the extra fees on the position's own capital pay for the move within // SWITCH_PAYBACK_DAYS. // Anything short of that is a "stay", with the reason. export const SWITCH_MARGIN = 0.25; export const SWITCH_PAYBACK_DAYS = 3; export const SWITCH_COST_IN_RESETS = 2; export const DEFAULT_RESET_COST_USD = 0.25; // The measured re-set cost is gas and the pool's swap fee. A move trades the // whole position through two pools, and the price impact of that is the // larger part: on 2026-09-10 12:20 UTC a re-set plus an increase of the // same size (0.29 + 0.30 BNB through CAKE/BNB 0.05%) left the position about // half a percent short of what went in. Half a percent of the position is // charged on every move until the record measures it better. export const SWITCH_IMPACT_PCT = 0.005; export function switchVerdict(log, widthPct, { watched = null, positionUsd = POSITION_USD, resetCostUsd = DEFAULT_RESET_COST_USD, now = Date.now(), minHours = MIN_HOURS_TO_PICK } = {}) { const v = poolVerdict(log, widthPct, { watched, minHours }); const usd = v.usd; const stay = (why, extra = {}) => ({ move: false, from: v.watched, to: null, why, width_pct: widthPct, ...extra }); if (!v.pick) return stay(v.why); if (!v.watched) return stay('the watched pool is not in the record, so there is nothing to compare the pick against'); if (v.pick.pool === v.watched.pool) return stay(`${v.pick.label}, the pool the agent is in, earns the most; nothing to move to`); const all = v.pools; const pickAll = all.find((p) => p.pool === v.pick.pool), homeAll = all.find((p) => p.pool === v.watched.pool); // The last day alone, from the same windows. const since = now - 24 * 3600e3; const recentLog = { usd, pools: Object.fromEntries(Object.entries(log.pools || {}).map(([k, rec]) => [k, { ...rec, windows: (rec.windows || []).filter((w) => Date.parse(w.at) >= since) }])) }; const recent = poolVerdict(recentLog, widthPct, { watched, minHours: 0 }).pools; const pickRecent = recent.find((p) => p.pool === v.pick.pool), homeRecent = recent.find((p) => p.pool === v.watched.pool); const rate = (p) => (p && typeof p.fees_usd_per_day === 'number' ? p.fees_usd_per_day : null); const leadOf = (a, b) => (b > 0 ? (a || 0) / b - 1 : (a || 0) > 0 ? Infinity : 0); const leadAll = leadOf(rate(pickAll), rate(homeAll)); const leadRecent = leadOf(rate(pickRecent), rate(homeRecent)); const pct = (x) => (x === Infinity ? 'every dollar' : `${Math.round(x * 100)}%`); const scale = positionUsd / usd; const gainPerDay = Math.max(0, (rate(pickAll) || 0) - (rate(homeAll) || 0)) * scale; const cost = SWITCH_COST_IN_RESETS * resetCostUsd + SWITCH_IMPACT_PCT * positionUsd; const payback = gainPerDay > 0 ? cost / gainPerDay : Infinity; const facts = { lead_all_pct: leadAll === Infinity ? null : Math.round(leadAll * 1000) / 10, lead_recent_pct: leadRecent === Infinity ? null : Math.round(leadRecent * 1000) / 10, gain_usd_per_day_on_position: Math.round(gainPerDay * 10000) / 10000, switch_cost_usd: Math.round(cost * 10000) / 10000, switch_cost_basis: `${SWITCH_COST_IN_RESETS} re-sets at $${resetCostUsd} of gas and swap fee plus ${SWITCH_IMPACT_PCT * 100}% of the $${positionUsd} position for the price impact of trading it through two pools`, payback_days: payback === Infinity ? null : Math.round(payback * 10) / 10, recent_hours: { pick: pickRecent?.hours ?? 0, watched: homeRecent?.hours ?? 0 }, }; if (leadAll < SWITCH_MARGIN) return stay(`${v.pick.label} leads ${v.watched.label} by ${pct(leadAll)} over ${homeAll.hours} h — under the ${Math.round(SWITCH_MARGIN * 100)}% a move needs`, facts); if (!pickRecent || !homeRecent || !pickRecent.hours || !homeRecent.hours) return stay(`${v.pick.label} leads over all hours but one of the two has no window in the last day; no move on a stale lead`, facts); if (leadRecent < SWITCH_MARGIN) return stay(`${v.pick.label} leads by ${pct(leadAll)} over all hours but only ${pct(leadRecent)} over the last day — a lead that is fading is not acted on`, facts); if (payback > SWITCH_PAYBACK_DAYS) return stay(`${v.pick.label} leads by ${pct(leadAll)}, but $${facts.gain_usd_per_day_on_position} a day more on $${positionUsd} pays the $${facts.switch_cost_usd} move back in ${facts.payback_days} days — over the ${SWITCH_PAYBACK_DAYS} the rule allows`, facts); return { move: true, from: v.watched, to: v.pick, width_pct: widthPct, why: `${v.pick.label} earned ${pct(leadAll)} more than ${v.watched.label} over ${homeAll.hours} h and ${pct(leadRecent)} more over the last day; on $${positionUsd} that is $${facts.gain_usd_per_day_on_position} a day and pays the $${facts.switch_cost_usd} move back in ${facts.payback_days} days`, ...facts, }; } export async function readLpPools(env) { const raw = await env.AGENT.get(KV_KEY); return raw ? JSON.parse(raw) : null; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const CHAIN_REFUSED = /every BSC endpoint refused|log endpoint refused|rate limit|capacity|too many|quota|429|timed out|timeout|aborted|network|fetch failed/i; // The hourly tick, run after the width record's own so the watched pool's // window is already there to copy. One replay per other candidate, one KV // read, one KV write. A candidate the chain refuses this hour is skipped // this hour — a missing window is a gap, an invented one is a lie. export async function recordLpPools(env) { const watched = await watchedPool(env); const prev = (await readLpPools(env)) || { usd: POSITION_USD, since: new Date().toISOString(), pools: {} }; let log = { ...prev, usd: POSITION_USD, pools: { ...(prev.pools || {}) } }; const added = [], skipped = []; // One replay with the width record's own patience: a refusal from the // chain gets a second try, anything else is a reason in the skip list. const replay = async (pool) => { try { return await measure(pool, POSITION_USD); } catch (e) { if (!CHAIN_REFUSED.test(String(e.message))) { skipped.push({ pool, why: String(e.message).slice(0, 120) }); return null; } await sleep(15000); try { return await measure(pool, POSITION_USD); } catch (e2) { skipped.push({ pool, why: String(e2.message).slice(0, 120) }); return null; } } }; // The watched pool: the width record measured it minutes ago — unless its // own measurement failed this hour (2026-09-08 08:31: "the log endpoint // refused this range" while the two other pools replayed fine seconds // later). A window older than this tick is not copied; the pool is // replayed here instead, so the hours stay comparable across pools. if (watched) { const w = JSON.parse((await env.AGENT.get(WINDOWS_KEY)) || 'null'); const latest = w && w.pool && w.pool.toLowerCase() === watched ? (w.windows || []).slice(-1)[0] : null; const fresh = latest && Date.now() - Date.parse(latest.at) < 50 * 60 * 1000; if (fresh) { const r = appendPoolWindow(log, watched, latest); log = r.log; if (r.added) added.push(labelOf(watched)); } else { const plan = await replay(watched); if (plan) { const r = appendPoolWindow(log, watched, windowFromPlan(plan, POSITION_USD)); log = r.log; if (r.added) added.push(labelOf(watched) + ' (replayed here; the width record had no window this hour)'); } await sleep(3000); } } // The others: measured now. for (const c of CANDIDATES) { if (c.pool === watched) continue; const plan = await replay(c.pool); if (!plan) continue; const r = appendPoolWindow(log, c.pool, windowFromPlan(plan, POSITION_USD)); log = r.log; if (r.added) added.push(c.label); await sleep(1500); } // Every run leaves a note, added or not: a pool missing from an hour // is a gap the reader should be able to explain (2026-09-08 07:30 the // BOB/BNB window was missing and the record could not say why). log.last_run = { at: new Date().toISOString(), added, skipped }; await env.AGENT.put(KV_KEY, JSON.stringify(log)); return { ok: true, added, skipped, pools: Object.keys(log.pools).length }; } export async function noteLpPoolsError(env, e) { const prev = (await readLpPools(env)) || { usd: POSITION_USD, since: new Date().toISOString(), pools: {} }; prev.last_error = { at: new Date().toISOString(), message: String(e && e.message || e).slice(0, 200) }; await env.AGENT.put(KV_KEY, JSON.stringify(prev)); } ============================================================================== === FILE: worker-agent/lp-portfolio.js ============================================================================== // THE PORTFOLIO: the DeFi agent as one picture — what went in, what it is // worth, what it holds where, the P&L by where it came from, the pool // record's verdict, and what it did in the last day. // // One model, built here from the three records (the agent's own, the series, // the pool record) and served at agent.brainonbnb.com/lp/portfolio. The // /defi page and the Telegram card (/defi, and the 05:00 UTC post) render // this JSON and compute nothing of their own — the operator's rule since // 2026-09-07: a number the page shows and a number the bot posts come from // one function, or one of them is wrong. // // Pure. Pinned by scripts/lp-portfolio.mjs --self-test. import { CANDIDATES } from './lp-pools.js'; import { HOME_POOL, rangeLeft } from '../shared/lp-guards.js'; import { resetLosses } from './lp-windows.js'; const n = (x) => Number(x || 0); const r4 = (x) => Math.round(n(x) * 1e4) / 1e4; const r5 = (x) => Math.round(n(x) * 1e5) / 1e5; const labelOf = (pool) => (CANDIDATES.find((c) => c.pool === String(pool || '').toLowerCase()) || {}).label || null; // What one recorded step did, in a few words, or null when it did nothing. export function stepWords(name, s) { if (!s || typeof s !== 'object') return null; if (s.error) return { what: `${name}: ${s.error}`, error: true }; if (!s.acted) return null; switch (name) { case 'sweep': return { what: `swept ${s.bnb_out != null ? r5(s.bnb_out) + ' BNB of ' : ''}income into the DeFi wallet` }; case 'collect': return { what: `collected ${s.produced_bnb != null ? r5(s.produced_bnb) + ' BNB of ' : ''}fees${s.bobai_units ? `, ${Math.round(n(s.bobai_units)).toLocaleString('en-US')} $BOBAI bought and held` : ''}` }; case 'relocate': return { what: `moved the position to ${s.to_label || labelOf(s.new_pool) || s.new_pool || 'another pool'}${s.new_position ? ` (#${s.new_position})` : ''}` }; case 'rebalance': return { what: s.upgraded_to_pct != null ? `re-set the range wider/narrower: ±${s.upgraded_from_pct}% → ±${s.upgraded_to_pct}%${s.new_position ? ` (#${s.new_position})` : ''}` : `re-set the range ${s.one_sided ? `one-sided ${s.one_sided === 'above_price' ? 'above' : 'below'} the price, no trade` : 'around the price'}${s.new_position ? ` (#${s.new_position})` : ''}${n(s.bobai_bnb) > 0 ? `, ${r5(s.bobai_bnb)} BNB of its fees into $BOBAI` : ''}` }; case 'increase': return { what: `grew the position${n(s.bnb_spent) > 0 ? ` by ${r4(s.bnb_spent)} BNB` : ''}` }; case 'ladder': return { what: s.new_reserve && !s.old_reserve ? `opened a reserve range below the price with ${r4(s.bnb_spent)} BNB, no trade (#${s.new_reserve})` : s.old_reserve ? `re-set the reserve range beside the price, no trade (#${s.old_reserve} → #${s.new_reserve})` : s.merged_reserve ? `merged the reserve range into the main one (#${s.merged_reserve})` : `grew the reserve range by ${r4(s.bnb_spent)} BNB${s.swap && n(s.swap.notional_bnb) > 0 ? `, the missing side bought first (${r5(s.swap.notional_bnb)} BNB, fee ${r5(s.swap.fee_bnb)})` : ', no trade'}` }; default: return { what: `${name} acted` }; } } // Every action in the last day, newest first (the operator, 2026-09-10: // "das erste zuoberst"): the daily run and every hourly check that moved // something, from the record's own history (only runs that acted or failed // are kept there). export function lastDay(rec, now = Date.now()) { const since = now - 24 * 3600e3; const runs = (Array.isArray(rec?.history) ? rec.history : []).filter((h) => h && h.at && Date.parse(h.at) >= since); const out = []; for (const h of runs) { const steps = h.steps && typeof h.steps === 'object' ? h.steps : {}; for (const [name, v] of Object.entries(steps)) { for (const s of Array.isArray(v) ? v : [v]) { const w = stepWords(name, s); if (w) out.push({ at: h.at, step: name, ...w }); } } } return out.sort((a, b) => Date.parse(b.at) - Date.parse(a.at)); } // AGAINST HOLDING (2026-09-16). The one question a liquidity position has // to answer: is the money better off here than in a wallet? The wallet it // is measured against holds, from the moment each piece of capital arrived, // half of it as BNB and half as the other side at that day's price — the // mix a centred range is minted in. Its worth now is what the agent has to // beat: worth + the fees that left the position (into $BOBAI) + fees still // owed − gas. `points` is the series (capital_bnb, tick per run); each rise // in capital is an arrival at that run's price. Pure; pinned. export function holdingBenchmark(points, { valueNow, tickNow, wbnbIs0 = false, bobaiBnb = 0, owedBnb = 0, gasBnb = 0, waitingBnb = 0 } = {}) { const pts = (points || []).filter((p) => p && p.tick != null && Number(p.capital_bnb) > 0); if (!pts.length || tickNow == null || !(Number(valueNow) > 0)) return null; const priceAt = (tick) => { const raw = Math.pow(1.0001, Number(tick)); return wbnbIs0 ? 1 / raw : raw; }; const pNow = priceAt(tickNow); let holding = 0, prevCap = 0; const arrivals = []; for (const p of pts) { const cap = Number(p.capital_bnb); const d = cap - prevCap; if (d > 1e-9) { arrivals.push({ at: p.at, bnb: +d.toFixed(6), price: priceAt(p.tick) }); holding += d * (0.5 + 0.5 * (pNow / priceAt(p.tick))); } prevCap = Math.max(prevCap, cap); } if (!(holding > 0)) return null; // waitingBnb: fees a collect kept that still wait in the wallet as BNB — // the agent's, outside the position, until the next increase takes them. const lp = Number(valueNow) + n(bobaiBnb) + n(owedBnb) + n(waitingBnb) - n(gasBnb); const vs = lp - holding; return { holding_bnb: r5(holding), lp_bnb: r5(lp), vs_holding_bnb: r5(vs), vs_holding_pct: prevCap > 0 ? Math.round((vs / prevCap) * 10000) / 100 : null, arrivals: arrivals.length, basis: "a wallet that held each arrival half as BNB, half as the other side at that run's price, against the position now plus the fees that left it as $BOBAI, the fees still owed and the kept fees that wait in the wallet, less gas", }; } // The return in words, ONE wording for every surface (2026-09-19): the card // said '-1.2% since 3 Sep' and the page '-1.2% on the capital' of the same // figure — one named the period, the other the base, neither both. It is the // profit over all the capital put in, since the first run with a position. const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; export function changeText(changePct, since) { const v = Math.round(n(changePct) * 10) / 10; const pct = (v > 0 ? '+' : v < 0 ? '−' : '') + Math.abs(v).toFixed(1) + '%'; const s = String(since || ''), dd = Number(s.slice(8, 10)), mm = Number(s.slice(5, 7)); return `${pct} on the capital${dd && mm ? ` since ${dd} ${MONTHS[mm - 1]}` : ''}`; } export function lpPortfolio(rec, series, { now = Date.now(), bobaiUsd = null, width = null, outsideSince = null } = {}) { const last = rec && rec.last; const sum = series && series.summary; if (!last || !last.at || !sum || !sum.profit) return null; const pts = Array.isArray(series.points) ? series.points : []; const pt = pts.length ? pts[pts.length - 1] : null; const bnbUsd = n(sum.profit.bnb_usd) || null; const usd = (bnb) => (bnbUsd ? Math.round(n(bnb) * bnbUsd * 100) / 100 : null); const value = sum.value_bnb || {}; const putIn = n(value.capital_total_bnb) || (n(value.start) + n(value.added_by_hand_bnb) + n(sum.deposits_put_in_bnb) + n(sum.income_put_in_bnb)); const sources = [{ label: 'start', bnb: r4(value.start) }]; if (n(value.added_by_hand_bnb) > 0) sources.push({ label: 'by hand', bnb: r4(value.added_by_hand_bnb) }); if (n(sum.deposits_put_in_bnb) > 0) sources.push({ label: 'deposited', bnb: r4(sum.deposits_put_in_bnb) }); if (n(sum.income_put_in_bnb) > 0) sources.push({ label: 'from AI income', bnb: r4(sum.income_put_in_bnb) }); // The range as it stands: the newest check, not the series' last point — // on 2026-09-10 the card said "in range" from the 05:40 point while the // 11:30 check had the price outside for two hours. const chk = rec.last_check && rec.last_check.at && Date.parse(rec.last_check.at) >= Date.parse(last.at) ? rec.last_check : last; const inc = (chk.steps && chk.steps.increase) || (last.steps && last.steps.increase) || {}; const rb = (chk.steps && chk.steps.rebalance) || {}; const position = (inc.position != null ? String(inc.position) : null) || (pt && pt.position != null ? String(pt.position) : null) || (rb.new_position != null ? String(rb.new_position) : null); const inRange = inc.in_range != null ? !!inc.in_range : rb.in_range != null ? !!rb.in_range : pt ? !!pt.in_range : null; const poolAddr = String(rec.pool || HOME_POOL.pool).toLowerCase(); const poolLabel = labelOf(poolAddr) || (poolAddr === HOME_POOL.pool ? HOME_POOL.label : null); const walletBnb = inc.wallet_bnb != null ? n(inc.wallet_bnb) : pt ? n(pt.wallet_bnb) : 0; const reserve = inc.reserve || rb.reserve || null; const p = sum.profit; const feeParts = []; if (n(p.fees_collected_bnb) > 0) feeParts.push({ label: 'collected', bnb: r5(p.fees_collected_bnb) }); if (n(p.fees_folded_bnb) > 0) feeParts.push({ label: 'folded into the position', bnb: r5(p.fees_folded_bnb) }); if (n(p.fees_forwarded_at_resets_bnb) > 0) feeParts.push({ label: 'into $BOBAI', bnb: r5(p.fees_forwarded_at_resets_bnb) }); if (n(p.fees_owed_bnb) > 0) feeParts.push({ label: 'still owed by the position', bnb: r5(p.fees_owed_bnb) }); const day = lastDay(rec, now); // The range as a person asks about it: how wide, in or out, and what the // agent does next — one sentence, from the record's own width and wait. const hist = Array.isArray(rec.history) ? rec.history : []; let widthPct = null; for (let i = hist.length - 1; i >= 0 && widthPct == null; i--) { const r = hist[i]?.steps?.rebalance; if (r && r.acted && !r.error && n(r.width_pct) > 0) widthPct = n(r.width_pct); } if (widthPct == null && n(rb.width_pct) > 0) widthPct = n(rb.width_pct); const pick = width && n(width.width_pct) > 0 ? width : null; const waitH = width && width.wait_hours != null ? n(width.wait_hours) : null; const outH = outsideSince ? Math.max(0, (now - Date.parse(outsideSince)) / 36e5) : null; const hm = (h) => (h == null ? '' : h < 1 ? `${Math.round(h * 60)} min` : `${h.toFixed(1)} h`); // Two short sentences, no figures the card does not need (the operator, // 2026-09-12: "einfacher, uebersichtlicher, klarer"). What the pick nets // a day stays in the record (/lp/windows), not on the card. let next; // At the edge is read off the ticks, not off a flag: the ten-minute watch // keeps no rebalance step when it did nothing, so the flag would only be // there after the hourly check; the position's own ticks are always there. const posTicks = (rb.acted && !rb.error && rb.new_ticks) || rb.ticks || (pt && pt.ticks) || null; const tickSeen = inc.tick ?? rb.tick ?? (pt ? pt.tick : null); const edgeRead = Array.isArray(posTicks) && posTicks.length === 2 && tickSeen != null ? rangeLeft(tickSeen, posTicks[0], posTicks[1]) : null; const atEdge = rb.at_edge === true || inc.at_edge === true || !!(edgeRead && edgeRead.outside && !edgeRead.left); if (!position) next = 'No position yet. The first deposit above the floor opens one.'; else if (inRange) next = pick ? `Holds and earns. A re-set only after ${waitH ?? 2} h out of range: one-sided beside the price, ±${pick.width_pct}% wide, no trade.` : 'Holds and earns. A re-set only after the wait out of range; the width record has no day of prices yet.'; else if (atEdge) next = 'At the edge of its range, not left. Earns again from the first tick back inside.'; else next = pick ? `Out of range${outH != null ? ` for ${hm(outH)}` : ''}. Re-set after ${waitH ?? 2} h out of range: one-sided beside the price, ±${pick.width_pct}% wide, no trade.` : `Out of range${outH != null ? ` for ${hm(outH)}` : ''}. Re-set after the wait; the width record has no day of prices yet.`; const losses = resetLosses(rec); // Where the position was left: the newest tick the record saw. const tickNow = inc.tick ?? rb.tick ?? (pt ? pt.tick : null); const wbnbIs0 = rb.wbnb_is0 === true || (hist.slice().reverse().find((x) => x?.steps?.rebalance?.wbnb_is0 != null)?.steps.rebalance.wbnb_is0 === true); const vsHold = holdingBenchmark(pts, { valueNow: value.now, tickNow, wbnbIs0, bobaiBnb: sum.fees_into_bobai_bnb ?? sum.fees_sent_to_buyback_bnb, owedBnb: sum.fees_owed_now_bnb, gasBnb: p.gas_bnb, waitingBnb: sum.fees_kept_waiting_bnb }); // WHAT THE AGENT HOLDS BESIDE THE POSITION (2026-09-18). "Worth now" is the // position; the result above it also counts what the position produced and // no longer holds — $BOBAI bought (at what it cost), fees still owed, kept // fees waiting in the wallet. Without this line worth − put in was a // tenth of the result shown beside it. worth + beside − put in − gas = result. const besideParts = [ { label: '$BOBAI held, at cost', bnb: r5(sum.fees_into_bobai_bnb ?? sum.fees_sent_to_buyback_bnb) }, { label: 'fees owed by the position', bnb: r5(sum.fees_owed_now_bnb) }, { label: 'kept fees waiting in the wallet', bnb: r5(sum.fees_kept_waiting_bnb) }, ].filter((x) => n(x.bnb) > 0); const besideBnb = besideParts.reduce((a, x) => a + n(x.bnb), 0); const dayCount = (step) => day.filter((d) => d.step === step && !d.error).length; const daySummary = { resets: dayCount('rebalance'), top_ups: dayCount('increase'), collects: dayCount('collect'), sweeps: dayCount('sweep'), reserve_moves: dayCount('ladder'), errors: day.filter((d) => d.error).length, last: day.length ? { at: day[0].at, what: day[0].what, error: !!day[0].error } : null, }; return { at: last.at, date: String(last.at).slice(0, 10), checked_at: chk.at || last.at, bnb_usd: bnbUsd, pool: { address: poolAddr, label: poolLabel, position, reserve_position: reserve ? String(reserve.position) : null, in_range: inRange, range_checked_at: last.range_checked_at || chk.at || null, width_pct: widthPct, outside_since: outsideSince || null, outside_hours: outH == null ? null : Math.round(outH * 10) / 10 }, next, day: daySummary, put_in: { bnb: r4(putIn), usd: usd(putIn), sources }, worth: { bnb: r4(value.now), usd: usd(value.now), beside_bnb: r4(besideBnb), beside_usd: usd(besideBnb), beside_parts: besideParts, all_in_bnb: r4(n(value.now) + besideBnb) }, holdings: { position_bnb: r4(value.now), fees_owed_bnb: r5(sum.fees_owed_now_bnb), bobai_units: Math.round(n(sum.bobai_held_units)), bobai_bnb: r5(sum.fees_into_bobai_bnb ?? sum.fees_sent_to_buyback_bnb), // What the held $BOBAI is worth now, at the pair's own price — read by // the route, so the model can say "3,895 $BOBAI (≈ $x)" beside the // BNB it cost. bobai_usd: bobaiUsd > 0 ? Math.round(n(sum.bobai_held_units) * bobaiUsd * 100) / 100 : null, bobai_usd_price: bobaiUsd > 0 ? bobaiUsd : null, wallet_bnb: r4(walletBnb), kept_waiting_bnb: r5(sum.fees_kept_waiting_bnb), // The ladder's reserve range (2026-09-16), when one stands: BNB below // the price, waiting to buy the other side through fees. // Where the reserve stands: 'wbnb' = below the price (a buy ladder), 'other' = the price fell through it (it waits to merge), 'both' = the price is inside it. reserve: reserve ? { position: String(reserve.position), ticks: reserve.ticks || null, bnb: r4(reserve.value_bnb), side: reserve.side || null } : null, }, pnl: { // Where the profit went, the operator's two halves: the fees kept as // capital (collected and kept, or folded in by a re-set) keep working; // the other half became $BOBAI the agent holds. kept_working_bnb: r5(sum.fees_kept_as_capital_bnb), into_bobai_bnb: r5(sum.fees_into_bobai_bnb ?? sum.fees_sent_to_buyback_bnb), bobai_units: Math.round(n(sum.bobai_held_units)), profit_bnb: r5(p.bnb), profit_usd: p.usd != null ? Math.round(n(p.usd) * 100) / 100 : usd(p.bnb), change_pct: Math.round(n(value.change_pct) * 100) / 100, change_text: changeText(value.change_pct, sum.since), from_price_bnb: r5(p.from_price_bnb), from_fees_bnb: r5(p.from_fees_bnb), fee_parts: feeParts, gas_bnb: r5(p.gas_bnb), // What the re-sets themselves cost, from the record's ticks: the loss // against holding each re-set realised, and its execution. at_resets: { count: losses.resets, valued: losses.valued, lost_to_price_bnb: r5(losses.lost_to_price_bnb), execution_bnb: r5(losses.execution_bnb) }, // The line the whole thing is judged by: the position, with everything // it produced, against a wallet that simply held (holdingBenchmark). vs_holding: vsHold, in_range_runs: n(sum.days_in_range), runs: n(sum.runs_with_a_position), since: String(sum.since || '').slice(0, 10), other_token: poolLabel ? poolLabel.split('/')[0] : 'the other side', }, last_24h: day, last_run_ok: last.ok !== false, links: { page: 'https://brainonbnb.com/defi', record: 'https://agent.brainonbnb.com/lp/agent', series: 'https://agent.brainonbnb.com/lp/series' }, }; } ============================================================================== === FILE: worker-agent/lp-service.js ============================================================================== // The DeFi agent, for somebody else's position. // // Point 5 of the Block-05 list. The agent that runs this project's own // PancakeSwap V3 position reads, plans and re-sets it every day from ONE core // (shared/lp-agent.js). This module points the same reading and the same // planning at any position on the chain — by token id, or by the wallet that // holds exactly one — and returns what the agent would decide about it: is it // in range, what it is worth, what it is owed, whether collecting pays for its // own gas, whether a re-set is due and in which width (the width that netted // the most per day when every width was replayed over the window record's // prices with the agent's own re-set delay and cost — the same record and // rule our own position uses), and what the wallet's spare BNB would add. // // STAGE 1, DELIBERATELY: it reads and advises. It signs nothing and it holds // nothing. Executing on a stranger's position needs a session key on THEIR // account with an allowlist and a cap (the Altana path this project already // runs for its own escrow spending), and that stage is designed, not built — // see docs/lp-service-stage2.md. A plan you can check against the chain is // worth shipping today; a key to somebody's liquidity is not something to // ship in an afternoon. import { createPublicClient, http, fallback, formatEther } from 'viem'; import { bsc } from 'viem/chains'; import { ADDR, ABI, RPCS, readPool, splitForRange, planRebalance, planIncrease, readBnbUsd } from '../shared/lp-agent.js'; import { widthVerdict } from './lp-windows.js'; const MAX128 = (1n << 128n) - 1n; const ZERO = '0x0000000000000000000000000000000000000000'; const bn = (v) => Number(formatEther(v)); const client = () => createPublicClient({ chain: bsc, transport: fallback(RPCS.map((u) => http(u, { timeout: 15000 }))) }); const read = (pub, address, abi, functionName, args = []) => pub.readContract({ address, abi, functionName, args }); const OWNER_ABI = [{ name: 'ownerOf', type: 'function', stateMutability: 'view', inputs: [{ type: 'uint256' }], outputs: [{ type: 'address' }] }]; // A position by id: the struct, its owner, and what a collect would pay out // right now (simulated as the owner, so the manager answers for them). async function readById(pub, tokenId) { const pos = await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'positions', [tokenId]).catch(() => null); if (!pos) throw new Error(`position #${tokenId} does not exist on the PancakeSwap V3 manager`); const owner = await read(pub, ADDR.V3_POSITION_MANAGER, OWNER_ABI, 'ownerOf', [tokenId]).catch(() => null); if (!owner || owner === ZERO) throw new Error(`position #${tokenId} has no owner — burned?`); let owed0 = 0n, owed1 = 0n; if (pos[7] > 0n) { const sim = await pub.simulateContract({ address: ADDR.V3_POSITION_MANAGER, abi: ABI.NPM, functionName: 'collect', args: [{ tokenId, recipient: owner, amount0Max: MAX128, amount1Max: MAX128 }], account: owner, }).catch(() => null); if (sim) { owed0 = sim.result[0]; owed1 = sim.result[1]; } } return { positions: 1, tokenId, pos, owed0, owed1, owner }; } // The facts of a position, read from the chain: what it holds, whether it is // in range and how much room is left, what it is worth, what it is owed. // Shared by the free look (/lp/look) and the paid plan — the plan is these // facts plus the agent's decisions about them. export async function lpPositionFacts(params = {}) { const pub = client(); let tokenId = null, alsoHeld = null; const idIn = params.position ?? params.tokenId ?? params.id; if (idIn != null && /^\d+$/.test(String(idIn))) tokenId = BigInt(String(idIn)); const address = String(params.address || params.wallet || params.owner || '').match(/0x[a-fA-F0-9]{40}/)?.[0] || null; if (tokenId == null) { if (!address) throw new Error('give a PancakeSwap V3 position id, or the address of a wallet that holds exactly one'); const n = Number(await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'balanceOf', [address])); if (n === 0) return { facts: { service: 'lp_position_plan', address, positions: 0, verdict: 'This wallet holds no PancakeSwap V3 position. Nothing to plan.' } }; if (n > 1) { // Name the ids, so the holder can pick one without another tool (up to // ten: the dead address holds tens of thousands of burned LP NFTs). const ids = []; for (let i = 0n; i < BigInt(Math.min(n, 10)); i++) ids.push(String(await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'tokenOfOwnerByIndex', [address, i]))); // The agent's own wallet holds two by design since 2026-09-16: the main // range and the reserve range of its ladder. Asked by that address, the // look is the main range, and it says which other id the wallet holds. const ladder = params.ladder && params.ladder.main != null ? params.ladder : null; if (ladder && ids.includes(String(ladder.main))) { tokenId = BigInt(String(ladder.main)); alsoHeld = ids.filter((id) => id !== String(ladder.main)); } else { return { facts: { service: 'lp_position_plan', address, positions: n, position_ids: ids, verdict: `This wallet holds ${n} PancakeSwap V3 positions${n > ids.length ? ` (the first ${ids.length})` : ''}: ${ids.map((id) => '#' + id).join(', ')}. Name one by its id (position: ) and the plan is for that one.` } }; } } else tokenId = await read(pub, ADDR.V3_POSITION_MANAGER, ABI.NPM, 'tokenOfOwnerByIndex', [address, 0n]); } const p = await readById(pub, tokenId); const owner = p.owner; const token0 = p.pos[2].toLowerCase(), token1 = p.pos[3].toLowerCase(); const wbnbIs0 = token0 === ADDR.WBNB, wbnbIs1 = token1 === ADDR.WBNB; const poolInfo = await readPool(pub, p.pos); const L = Number(p.pos[7]); const s = splitForRange(poolInfo.sqrtP, Number(p.pos[5]), Number(p.pos[6])); const in0 = L * s.perL0 / 1e18, in1 = L * s.perL1 / 1e18; const priceOtherInBnb = wbnbIs0 ? 1 / (poolInfo.sqrtP * poolInfo.sqrtP) : poolInfo.sqrtP * poolInfo.sqrtP; // other per BNB → BNB per other const holdsBnb = wbnbIs0 ? in0 : wbnbIs1 ? in1 : null; const holdsOther = wbnbIs0 ? in1 : wbnbIs1 ? in0 : null; const valueBnb = holdsBnb == null ? null : holdsBnb + holdsOther * priceOtherInBnb; const owedBnb = wbnbIs0 ? bn(p.owed0) : wbnbIs1 ? bn(p.owed1) : null; const owedOther = wbnbIs0 ? bn(p.owed1) : wbnbIs1 ? bn(p.owed0) : null; const owedBnbEquiv = owedBnb == null ? null : owedBnb + owedOther * priceOtherInBnb; const facts = { service: 'lp_position_plan', positions: 1, position: String(tokenId), owner, pool: { address: poolInfo.pool, token0: p.pos[2], token1: p.pos[3], fee_tier_pct: Number(p.pos[4]) / 10000, tick: poolInfo.tick, ticks: [Number(p.pos[5]), Number(p.pos[6])] }, in_range: poolInfo.inRange, // Distance to each edge in percent of price: how much room is left. room: { to_lower_pct: +((1 - Math.pow(1.0001, Number(p.pos[5]) - poolInfo.tick)) * 100).toFixed(2), to_upper_pct: +((Math.pow(1.0001, Number(p.pos[6]) - poolInfo.tick) - 1) * 100).toFixed(2) }, liquidity: p.pos[7].toString(), holds: { token0: in0, token1: in1 }, value_bnb: valueBnb == null ? null : +valueBnb.toFixed(6), fees_owed: { token0: bn(p.owed0), token1: bn(p.owed1), bnb_equivalent: owedBnbEquiv == null ? null : +owedBnbEquiv.toFixed(6) }, against_wbnb: wbnbIs0 || wbnbIs1, ...(alsoHeld ? { also_held: alsoHeld, also_held_note: 'the wallet holds these too; this is the main range of the ladder the agent runs, the other is its reserve range' } : {}), }; return { facts, pub, p, owner, poolInfo, owedBnbEquiv }; } // The free look: the facts, and the one sentence a holder wants first — in // range or not, room left, fees owed and whether collecting them pays. No // plan: the width, the re-set and what spare BNB would add are the paid // answer. A holder who has seen the look knows what the plan is about. // The ladder record (worker-lp, KV lp:ladder): which position is the main // range and which the reserve. Read only when the worker's KV is at hand and // the question came as an address, so the agent's own wallet resolves to its // main range rather than to "name one of two". async function withLadder(params, env) { if (!env || !env.AGENT || params.position != null || params.ladder) return params; try { const l = JSON.parse((await env.AGENT.get('lp:ladder')) || 'null'); return l && l.main != null ? { ...params, ladder: l } : params; } catch { return params; } } export async function lpPositionLook(params = {}, env = null) { const r = await lpPositionFacts(await withLadder(params, env)); if (r.facts.positions !== 1) return { ...r.facts, service: 'lp_position_look' }; const { facts, poolInfo, owedBnbEquiv } = r; const lines = []; lines.push(poolInfo.inRange ? `In range: ${facts.room.to_lower_pct}% of room below the price, ${facts.room.to_upper_pct}% above.` : 'Out of range: the position is all one token and earns nothing until the price returns or the range is re-set.'); if (owedBnbEquiv != null) lines.push(owedBnbEquiv >= 0.002 ? `Fees owed: ${owedBnbEquiv.toFixed(6)} BNB — collecting pays for its gas.` : `Fees owed: ${owedBnbEquiv.toFixed(6)} BNB — under the 0.002 BNB floor, collecting would cost more gas than it recovers.`); return { ...facts, service: 'lp_position_look', verdict: lines.join(' '), the_plan: facts.against_wbnb ? 'What the agent would do about it — whether a re-set is due and in which width, what the wallet\'s spare BNB would add — is the paid answer: lp_position_plan, 0.10 USD1, POST /answer?service=lp_position_plan.' : 'The position is not against WBNB. The agent reads it, but plans only WBNB pairs — that is the one thing it knows how to turn into BNB and back.', measured_at: new Date().toISOString(), source: 'PancakeSwap V3 NonfungiblePositionManager and the pool itself, read live', }; } export async function lpPositionPlan(params = {}, env = null) { params = await withLadder(params, env); const r = await lpPositionFacts(params); // A PAID PLAN THAT HAS NO POSITION TO PLAN IS NOT DELIVERED (2026-09-18). A // wallet with none, or with several and no id named, used to be answered // with the free look's facts — and charged 0.10 for it, because the sale // refunds only when the work throws. It throws, with the same sentence (and // the ids to choose from): the buyer keeps the payment as a credit. if (r.facts.positions !== 1) throw new Error(`${r.facts.verdict}${Array.isArray(r.facts.position_ids) && r.facts.position_ids.length ? ` Position ids: ${r.facts.position_ids.join(', ')}.` : ''} The free look says the same: https://agent.brainonbnb.com/lp/look`); const { facts, pub, p, owner, poolInfo, owedBnbEquiv } = r; if (!facts.against_wbnb) { return { ...facts, verdict: 'The position is not against WBNB. This agent reads it, but plans only WBNB pairs — that is the one thing it knows how to turn into BNB and back.', measured_at: new Date().toISOString() }; } // The agent's own decisions, on this position, from the same code. // The verdict the agent itself acts on: measured re-set cost and the price // tape in (widthVerdict) — not a bare replay of the hourly windows. const record = env ? await widthVerdict(env, await readBnbUsd(pub).then((r) => r.bnbUsd).catch(() => null)).then((x) => x.v).catch(() => null) : null; let rebalance = null, increase = null; try { rebalance = await planRebalance(pub, owner, { record, position: p, ladder: params.ladder || null }); } catch (e) { rebalance = { error: String(e.shortMessage || e.message).slice(0, 200) }; } try { increase = await planIncrease(pub, owner, p, params.ladder || null); } catch (e) { increase = { error: String(e.shortMessage || e.message).slice(0, 200) }; } const strip = (x) => (x && x.summary ? { ...x.summary, ...(x.no ? { why: x.no } : {}) } : x); const collectPays = owedBnbEquiv != null ? owedBnbEquiv >= 0.002 : null; const lines = []; lines.push(poolInfo.inRange ? `In range: ${facts.room.to_lower_pct}% of room below the price, ${facts.room.to_upper_pct}% above.` : 'Out of range: the position is all one token and earns nothing until the price returns or the range is re-set.'); lines.push(owedBnbEquiv != null ? (collectPays ? `Fees owed: ${owedBnbEquiv.toFixed(6)} BNB — collecting pays for its gas.` : `Fees owed: ${owedBnbEquiv.toFixed(6)} BNB — under the 0.002 BNB floor, collecting would cost more gas than it recovers.`) : 'Fees owed could not be priced.'); const rb = strip(rebalance); if (rb && rb.why) lines.push(`Re-set: ${rb.why}`); else if (rb && rb.new_ticks) lines.push(`Re-set due: the agent would move the range to ticks ${rb.new_ticks.join(' … ')} (±${rb.width_pct}%, ${rb.width_basis || 'from the width record'}).`); const ic = strip(increase); if (ic && ic.why) lines.push(`Grow: ${ic.why}`); else if (ic && ic.would_add) lines.push(`Grow: the capital beside the position would add ${ic.would_add.wbnb} WBNB and ${ic.would_add.other} of the other side${ic.would_add.buying_other ? `, buying ${ic.would_add.buying_other} of it first` : ic.would_add.selling_other ? `, selling ${ic.would_add.selling_other} of the other side first` : ''}.`); return { ...facts, collect: { pays_for_gas: collectPays, floor_bnb: 0.002 }, rebalance: rb, increase: ic, width_record: record ? { width_pct: record.earnings_pick?.width ?? null, expected_net_usd_per_day_on_50: record.earnings_pick?.earnings?.net_usd_per_day ?? null, hours_of_prices: record.hours_of_prices ?? null, source: 'https://agent.brainonbnb.com/lp/windows' } : null, verdict: lines.join(' '), what_this_is_not: 'An execution. This agent signs nothing on your position; it tells you what it would do, from the same code that runs its own. Measurement, not advice.', measured_at: new Date().toISOString(), source: 'PancakeSwap V3 NonfungiblePositionManager and the pool itself, read live; the width from the recorded price windows', }; } ============================================================================== === FILE: worker-agent/lp-windows.js ============================================================================== // THE RECORD THAT DECIDES THE LP POSITION'S WIDTH, kept around the clock. // // scripts/lp-windows.mjs records one window each time somebody runs it, and // scripts/lib/lp-decision.mjs lets that record overrule the single fresh replay // once it holds two non-overlapping windows. That is the right rule — measured // on the same pool two hours apart, +/-0.25% went from best in the list to six // crossings and minus $2.74 on fifty dollars — but a record that only grows // while a person is at a keyboard is a record of that person's working hours. // The price moves at 4 a.m. too. // // So the agent worker records a window every hour on its own cron. It reads // the same measurement the script reads, over our own MCP endpoint rather // than by copying the arithmetic (a second copy of a replay is how two answers // drift apart), and it appends to a log in KV that has exactly the shape of // data/lp-windows.json. `lp-windows.mjs --sync` pulls that log into the local // file, and the decision keeps reading ONE file. // // It reads. It holds no key, signs nothing and moves nothing. Phase B — the // collect-and-burn leg with a key on the worker — is deliberately not here: // there is no position yet to test it against. // // The aggregation lives in this file and nowhere else. The script's --report // and the decision module both import verdict() from here, so the number a // person reads and the number the mint is sized on come from one function. import { RESET_AFTER_HOURS, MIN_HOURS_FOR_EARNINGS, waitInUse, V2_SWAP_FEE_PCT, widthClassOf, DERIVED_WIDTHS, rangeValue, pickWidth, WIDTH_WINDOW_HOURS, ONE_SIDED_GAP_TICKS, RANGE_LEFT_TICKS } from '../shared/lp-guards.js'; import { isReset } from '../shared/lp-flow.js'; const MEASURE = 'https://brainonbnb.com/mcp'; export const KV_KEY = 'lp:windows'; // Hourly windows of ~59 minutes (37 before 2026-09-09) never overlap, so the count is honest by // construction; the cap only keeps the KV value from growing without bound. // 400 hourly windows is over two weeks, which is more than the decision needs. export const MAX_WINDOWS = 400; export const POSITION_USD = 50; // THE PRICE TAPE (2026-09-11). The hourly window carries one price, so the // earnings test saw the price once an hour: a range left and re-entered // within the hour never happened to it, and a wait of "three hours outside" // was three hourly readings. The DeFi worker reads the pool every ten // minutes anyway (the deposit watch, the hourly check) and now writes the // tick down: one sample, one KV write, 144 a day. The tape sharpens the // replay's in-or-out and its re-set timing; the fees still come from the // hourly windows, scaled to the minutes each sample stands for. Thirty days // at six an hour is the cap. export const TICKS_KEY = 'lp:ticks'; export const MAX_TICKS = 4320; // Appends a sample unless one within a minute of it is already there. Pure. export function appendTick(tape, sample) { const list = Array.isArray(tape) ? tape : []; if (!sample || !sample.at || !(Number(sample.price) > 0)) return { tape: list, added: false }; const t = Date.parse(sample.at); if (list.some((x) => Math.abs(Date.parse(x.at) - t) < 60e3)) return { tape: list, added: false }; const next = list.concat({ at: sample.at, tick: sample.tick ?? null, price: Number(sample.price), pool: sample.pool ? String(sample.pool).toLowerCase() : null }) .sort((a, b) => Date.parse(a.at) - Date.parse(b.at)); if (next.length > MAX_TICKS) next.splice(0, next.length - MAX_TICKS); return { tape: next, added: true }; } export async function readLpTicks(env) { try { const raw = await env.AGENT.get(TICKS_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } } export async function recordLpTick(env, sample) { const { tape, added } = appendTick(await readLpTicks(env), sample); if (added) await env.AGENT.put(TICKS_KEY, JSON.stringify(tape)); return added; } // ONE PRICE SERIES from the hourly windows and the tape: every window's head // price and every tape sample, in time order, a sample within a minute of a // window's head counted once. Each point names the window whose hour it // falls in (the first window at or after it; past the last window, the // last), so the fee rate a point earns at is that window's row. export function priceSeries(priced, tape = null) { const pts = priced.map((w) => ({ at: w.at, t: Date.parse(w.at), price: w.price, window: w })); for (const x of Array.isArray(tape) ? tape : []) { if (!x || !x.at || !(Number(x.price) > 0)) continue; const t = Date.parse(x.at); if (pts.some((p) => Math.abs(p.t - t) < 60e3)) continue; pts.push({ at: x.at, t, price: Number(x.price), window: null }); } pts.sort((a, b) => a.t - b.t); let wi = 0; for (const p of pts) { if (p.window) { wi = priced.indexOf(p.window); continue; } while (wi < priced.length - 1 && Date.parse(priced[wi].at) < p.t) wi++; p.window = priced[wi]; } return pts; } // One replay -> one log entry. Same field names, same "held" rule as the // script: in range for the WHOLE window and never across the edge. A range is // centred on today's price and replayed backwards, so it can just as easily be // arrived in as left, and "never seen leaving" would call that a hold. export function windowFromPlan(plan, usd, at = new Date().toISOString()) { const w = plan.measured_window; return { at, from_block: w.from_block, to_block: w.to_block, minutes: w.minutes, swaps: w.swaps, // The price at the window's head, kept so the record can answer the // question an hour's replay cannot: would this width have held for a // DAY. The position went out of a +/-0.5% range within five hours of a // record in which that width had held every window. price: typeof plan.price_now === 'number' ? plan.price_now : null, pool_fees_usd: w.fees_the_pool_paid_usd, // The liquidity's share of a fee (2026-09-13): CAKE/BNB 0.05% keeps 34% // for the protocol, so the rows hold 66% of what the trader paid. Null // on a window recorded before the replay knew — those were scaled once // on 2026-09-13 and carry `lp_share` since. lp_share: typeof w.paid_to_liquidity_pct === 'number' ? Number((w.paid_to_liquidity_pct / 100).toFixed(4)) : null, rebalance_cost_usd: plan.rebalance_cost_usd_assumed, rows: plan.ranges.map((r) => ({ width: r.full_range ? 'full' : r.width_pct, held: r.share_of_window_in_range_pct === 100 && r.times_it_crossed_the_edge === 0, in_range_pct: r.share_of_window_in_range_pct, crossings: r.times_it_crossed_the_edge, fees: r.fees_usd_in_window, net: r.net_after_rebalancing_usd_in_window, })), }; } // Appends, unless this exact chain slice is already in the log. Two runs that // read the same blocks are one observation, and the report already refuses to // count overlaps twice — but an identical entry is not even a second reading, // it is the same reading written down again. export function appendWindow(log, entry) { const dup = log.windows.some((x) => x.from_block === entry.from_block && x.to_block === entry.to_block); if (dup) return { log, added: false }; const windows = log.windows.concat(entry); if (windows.length > MAX_WINDOWS) windows.splice(0, windows.length - MAX_WINDOWS); return { log: { ...log, windows }, added: true }; } // Merges two logs of the SAME pool by chain slice. Used by --sync: the local // file may hold windows the worker never saw (recorded by hand before the cron // existed) and the worker holds windows nobody was awake for. Refuses across // pools — averaging two pools into one history would average away the thing // being measured. export function mergeLogs(a, b) { if (a.pool && b.pool && a.pool.toLowerCase() !== b.pool.toLowerCase()) { throw new Error(`records are about different pools: ${a.pool} and ${b.pool}`); } const seen = new Set(); const windows = []; for (const w of a.windows.concat(b.windows)) { const k = `${w.from_block}-${w.to_block}`; if (seen.has(k)) continue; seen.add(k); windows.push(w); } windows.sort((x, y) => x.from_block - y.from_block); return { pool: a.pool || b.pool, usd: a.usd || b.usd, windows }; } // WHAT THE RECORD SAYS. This is the decision's input, so its rules are // spelled out here and pinned by scripts/lp-windows.mjs --self-test: // - overlapping windows count once (the earliest survives), // - fewer than two non-overlapping windows is "thin" and decides nothing, // - a width that ever went negative is not a candidate, whatever its average, // - a width that ever failed to hold is not a candidate, // - among the rest, the one with the most net collected wins, // - full range is reported but never picked; it is the floor, not a choice. // - the width a re-set USES is the earnings pick: the most net per day when // every width is replayed over the recorded prices with the agent's own // re-set delay and cost (see earningsTest); nothing until a day of prices. // THE WIDTHS BETWEEN THE REPLAYED ONES. For each derived width the row is // read off its wider replayed neighbour (fees by the liquidity law, // widthShare — until 2026-09-18 by neighbour/width, 2% too much at ±7%), and whether it held, how often it crossed and what it // netted off its narrower neighbour — a width that held at ±2% held at ±3%, // and a width that crossed at ±2% crossed at most as often at ±3%, so the // derived row is never rosier than the record allows. A window that lacks // either neighbour gets no derived row for that width. Pure; pinned. // WHAT A RANGE EARNS WHILE THE PRICE IS IN IT (2026-09-18). A centred ±w range // holds, per dollar, liquidity in proportion to 1 / (1 − 1/√(1+w)) — the V3 // identity, not a fit: the record's own rows follow it to the digit (±5% to // ±10%: 0.009614 / 0.004979 = 1.931; the law says 1.931, "1/w" says 2). Two // ranges the price is inside see the same swaps, so their fees stand in that // proportion exactly. widthShare(W, w) is what a ±w range earns for every // dollar a ±W range earns while both hold the price. Pure; pinned. const liqPerUsd = (w) => 1 / (1 - 1 / Math.sqrt(1 + Number(w) / 100)); export const widthShare = (fromWidth, toWidth) => liqPerUsd(toWidth) / liqPerUsd(fromWidth); // A window's row counts the fees of a range centred on the window's first // price, in hindsight, and only the swaps made while that range held the // price (in_range_pct). The earnings test follows its OWN range and asks // whether the price is inside it — so a narrow width was marked down twice // for the time it spends outside: once in the row, once by the test (±0.25% // read 40% too little over a week, ±0.5% 7%). The rate the test needs is // what the width earns per hour WHILE INSIDE: read off the narrowest // replayed row of the window that held the price throughout, by the law // above. A window where no row held (a move past ±10% inside the hour) // keeps the row's own figure. Pure; pinned. export function inRangeFeeRate(window, widthPct) { const rows = Array.isArray(window?.rows) ? window.rows : []; const hours = (window?.minutes || 37.5) / 60; const held = rows.filter((r) => typeof r.width === 'number' && !r.derived && typeof r.fees === 'number' && Number(r.in_range_pct) >= 100).sort((a, b) => a.width - b.width)[0]; if (held) return (held.fees * widthShare(held.width, widthPct)) / hours; const own = rows.find((r) => r.width === widthPct); return own && typeof own.fees === 'number' ? own.fees / hours : 0; } export function deriveWidths(window, derived = DERIVED_WIDTHS) { const rows = Array.isArray(window?.rows) ? window.rows : []; const numeric = rows.filter((r) => isFinite(Number(r.width))).map((r) => ({ ...r, width: Number(r.width) })); const out = rows.slice(); for (const w of derived) { if (numeric.some((r) => r.width === w)) continue; const wider = numeric.filter((r) => r.width > w).sort((a, b) => a.width - b.width)[0]; const narrower = numeric.filter((r) => r.width < w).sort((a, b) => b.width - a.width)[0]; if (!wider || !narrower || typeof wider.fees !== 'number') continue; const fees = wider.fees * widthShare(wider.width, w); out.push({ width: w, derived: true, derived_from: [narrower.width, wider.width], held: !!narrower.held, in_range_pct: narrower.in_range_pct, crossings: narrower.crossings, fees: Number(fees.toFixed(6)), net: Number((fees - (Number(narrower.fees) - Number(narrower.net))).toFixed(6)), }); } return { ...window, rows: out }; } export function verdict(log, opts = {}) { const poolOf = String(log?.pool || '').toLowerCase(); const tape = (Array.isArray(opts.tape) ? opts.tape : []).filter((x) => x && (!x.pool || !poolOf || String(x.pool).toLowerCase() === poolOf)); opts = { ...opts, tape }; const sorted = (log?.windows || []).slice().sort((a, b) => a.from_block - b.from_block).map((w) => deriveWidths(w)); const used = []; for (const w of sorted) { const last = used[used.length - 1]; if (!last || w.from_block > last.to_block) used.push(w); } const skipped = sorted.length - used.length; const widths = [...new Set(used.flatMap((w) => w.rows.map((r) => r.width)))] .sort((a, b) => (a === 'full' ? 1e9 : a) - (b === 'full' ? 1e9 : b)); const rows = widths.map((w) => { const rs = used.map((x) => x.rows.find((r) => r.width === w)).filter(Boolean); return { width: w, derived: rs.length > 0 && rs.every((r) => r.derived === true), derived_from: rs.find((r) => r.derived)?.derived_from || null, of: rs.length, held: rs.filter((r) => r.held).length, heldEvery: rs.length > 0 && rs.every((r) => r.held), everNegative: rs.some((r) => r.net < 0), crossings: rs.reduce((s, r) => s + r.crossings, 0), fees: rs.reduce((s, r) => s + r.fees, 0), net: rs.reduce((s, r) => s + r.net, 0), }; }); const thin = used.length < 2; const safe = rows.filter((r) => r.width !== 'full' && r.heldEvery && !r.everNegative && r.net > 0); safe.sort((a, b) => b.net - a.net); // THE DAY TEST. A width that held every hourly window is the narrowest // width that held for an hour; a position nobody watches is left alone // for a day. So each priced window is treated as a hypothetical mint and // asked whether the price stayed inside +/-width for the 24 hours after it. // Only windows with at least twenty hours of later record count as tested; // a width is a day-pick when it held through EVERY tested day. Until the // record holds a day of prices this decides nothing, and says so. for (const r of rows) r.day = r.width === 'full' ? null : dayHold(used, r.width); const dayHolders = safe.filter((r) => r.day && r.day.tested > 0 && r.day.held === r.day.tested); const priced = used.filter((w) => typeof w.price === 'number' && w.price > 0); const hoursOfPrices = priced.length >= 2 ? Math.round((Date.parse(priced[priced.length - 1].at) - Date.parse(priced[0].at)) / 36e5) : 0; // THE DELAY TEST. On 2026-09-08 CAKE rose 18% in five days, the ±1% // range was re-set twice before noon, and an hour after the second re-set // the price was back below the new range — the case the wait before a // re-set exists for, and the case it costs earning hours in. So every // width is replayed with a wait of 0, 1, 2 and 3 hours (more since), and the best net // per day for each wait is named. Reported only until 2026-09-09; since // then the re-set USES the wait that netted the most, behind the bar in // waitInUse (a week of prices, a tenth over the set wait) — the same way // the width has been measured rather than set since 2026-09-04. // Up to 12 h since 2026-09-12: with 0-3 h the net still rose at the last // step (0.002 / 0.002 / 0.12 / 0.21 a day), so the grid ended where the // curve had not. 18 and 24 h since 2026-09-13: 12 h was the edge again // and led for a day (0.30 a day on 229 h) — a grid's last step must never // be its answer, so the grid now reaches a full day, the wait a position // nobody watches would get. const DELAYS_H = [0, 1, 2, 3, 4, 6, 8, 12, 18, 24]; const delayRows = thin || hoursOfPrices < MIN_HOURS_FOR_EARNINGS ? [] : DELAYS_H.map((h) => { const best = rows.filter((r) => r.width !== 'full') .map((r) => ({ width: r.width, e: earningsTest(used, r.width, { ...opts, resetAfterHours: h }) })) .filter((x) => x.e && x.e.net_usd_per_day > 0) .sort((a, b) => b.e.net_usd_per_day - a.e.net_usd_per_day)[0]; return best ? { hours: h, width: best.width, net_usd_per_day: best.e.net_usd_per_day, resets: best.e.resets, fees_usd: best.e.fees_usd } : { hours: h, width: null, net_usd_per_day: null, resets: null, fees_usd: null }; }); const wait = waitInUse(delayRows, hoursOfPrices); const delays = delayRows.map((d) => ({ ...d, in_use: d.hours === wait.hours })); const delayPick = delays.filter((d) => d.net_usd_per_day != null).sort((a, b) => b.net_usd_per_day - a.net_usd_per_day)[0] || null; // THE EARNINGS TEST, with the wait in use. The day test names the width // that would not have needed a re-set; it says nothing about what a width // earns. A width that holds every day earns a tenth of one that needs a // re-set a week, and a position exists to earn. So each width is replayed // the way the agent lives it — with the wait it really uses before a // re-set — and the one with the most left after its re-sets is the pick. for (const r of rows) r.earnings = r.width === 'full' ? null : earningsTest(used, r.width, { ...opts, resetAfterHours: wait.hours }); // THE LAST DAY ALONE (2026-09-11). A width that leads over the whole record // but not over the last day is a lead the market has already moved away // from; the width-upgrade rule asks for both (widthUpgrade). Replayed over // the windows of the last 24 h with the wait in use; null under two of them. const dayAgo = used.length ? Date.parse(used[used.length - 1].at) - 24 * 36e5 : 0; const lastDayWins = used.filter((w) => Date.parse(w.at) >= dayAgo); const dayTape = tape.filter((x) => Date.parse(x.at) >= dayAgo); for (const r of rows) r.earnings_24h = r.width === 'full' ? null : earningsTest(lastDayWins, r.width, { ...opts, tape: dayTape, resetAfterHours: wait.hours }); // THE LAST WEEK (2026-09-16): the window the width is picked on — // WIDTH_WINDOW_HOURS of windows and tape, the wait in use, one-sided // re-sets. A fortnight ago is not this week's volatility; a day is a mood. const weekAgo = used.length ? Date.parse(used[used.length - 1].at) - WIDTH_WINDOW_HOURS * 36e5 : 0; const weekWins = used.filter((w) => Date.parse(w.at) >= weekAgo); const weekTape = tape.filter((x) => Date.parse(x.at) >= weekAgo); for (const r of rows) r.earnings_7d = r.width === 'full' ? null : earningsTest(weekWins, r.width, { ...opts, tape: weekTape, resetAfterHours: wait.hours }); const earners = rows.filter((r) => r.earnings && r.earnings.net_usd_per_day > 0) .sort((a, b) => b.earnings.net_usd_per_day - a.earnings.net_usd_per_day); const widthPick = thin || hoursOfPrices < MIN_HOURS_FOR_EARNINGS ? null : pickWidth(rows); const first = used[0], last = used[used.length - 1]; return { windows: used.length, overlapping_runs_not_counted: skipped, thin, from: first?.at || null, to: last?.at || null, from_block: first?.from_block ?? null, to_block: last?.to_block ?? null, rows, pick: thin ? null : (safe[0] || null), priced_windows: priced.length, hours_of_prices: hoursOfPrices, // The ten-minute tape beside the hourly heads (2026-09-11): how many // samples the replay walked and since when. None before the tape began. price_samples: tape.length, price_samples_since: tape.length ? tape[0].at : null, // Best net among the widths that held every tested day — reported, no // longer the width a re-set uses (it was, until 2026-09-04). day_pick: thin ? null : (dayHolders[0] || null), // The width a re-set uses (2026-09-16): the width that ended the most // ahead against holding over the last week, fees in, replayed with // one-sided re-sets (pickWidth); the plan re-reads it with the width in // use for the bar. The most-net width is still named beside it. earnings_pick: widthPick, net_pick: thin || hoursOfPrices < MIN_HOURS_FOR_EARNINGS ? null : (earners[0] ? { width: earners[0].width, earnings: earners[0].earnings } : null), width_window_hours: WIDTH_WINDOW_HOURS, delay_test: { in_use_hours: wait.hours, wait_basis: wait.basis, why: wait.why, delays, pick: delayPick, note: 'Every width replayed with each wait before a re-set; the best width per wait is named. The re-set uses the wait that netted the most once the record holds a week of prices and it beats the set wait by a tenth; under either bar the set wait stands.', }, reset_cost: opts.resetCostUsd != null ? { usd: opts.resetCostUsd, basis: (opts.resetCostBasis || 'measured: the agent\'s last re-set, in today\'s dollars') + `; charged per $${POSITION_USD} of the position, the size every width is replayed at. Since 2026-09-16 a re-set trades nothing (one-sided), so it costs its gas and nothing is lost to the price at it` } : { usd: rows.find((r) => r.earnings)?.earnings?.reset_cost_usd ?? null, basis: 'assumed by the replay (median over the windows) — no re-set has been measured yet. Since 2026-09-16 a re-set trades nothing (one-sided), so it costs its gas and nothing is lost to the price at it' }, earnings_rule: `each width replayed over the recorded prices: minted centred on the first price, earning that hour's fees inside the range and nothing outside, re-set once the price has been outside for ${wait.hours} h — the wait the agent uses (${wait.basis}). Since 2026-09-16 the re-set is one-sided, the way the agent does it: the new range sits beside the price on the side it came from, takes the one token the old range ended in and trades nothing, so it is charged its gas alone. Net per day is fees less re-sets. The width a re-set uses is the one that ended the most ahead against holding over the last ${WIDTH_WINDOW_HOURS} h in that replay, fees included (fees_usd + vs_holding_usd: what the liquidity earned plus where it ended against a wallet that held the minted amounts — the line the card judges the agent by); a width in use is kept unless another leads it by a tenth of its own score and at least two cents on $50 a week; nothing until ${MIN_HOURS_FOR_EARNINGS} h of prices are on record.`, }; } // The re-set cost the earnings test charges: what the agent's last real // re-set cost in gas, in today's dollars, once the record has one; the // replay's assumption until then. `agentRecord` is the lp:agent record the // worker writes (history entries carry the rebalance step with gas_bnb). // The swap fee a re-set paid. Records since 2026-09-09 carry it measured // (steps.rebalance.swap); older ones name the trade, and the fee is worked // out from it — a buy names its WBNB, a sell is taken as half the position // (what a re-centre moves). A re-set with no trade on record paid none. export function resetSwapFee(rb) { if (rb?.swap && Number(rb.swap.fee_bnb) >= 0) return { bnb: Number(rb.swap.fee_bnb), basis: 'measured' }; const trade = String(rb?.trade || ''); const buy = /with ([\d.]+) WBNB/.exec(trade); if (buy) return { bnb: Number((Number(buy[1]) * V2_SWAP_FEE_PCT / 100).toFixed(8)), basis: `estimated from the trade (${buy[1]} WBNB through the ${V2_SWAP_FEE_PCT}% pool)` }; if (/^sell /.test(trade) && Number(rb.value_bnb) > 0) return { bnb: Number((Number(rb.value_bnb) / 2 * V2_SWAP_FEE_PCT / 100).toFixed(8)), basis: `estimated as half the position through the ${V2_SWAP_FEE_PCT}% pool` }; return { bnb: 0, basis: 'no trade on record' }; } // What the agent's last clean re-set cost: its gas plus the fee of its // re-centring trade, in today's dollars. Gas alone was the figure until // 2026-09-09, and it was half the truth. export function measuredResetCost(agentRecord, bnbUsd) { const hist = Array.isArray(agentRecord?.history) ? agentRecord.history : []; for (let i = hist.length - 1; i >= 0; i--) { const rb = hist[i]?.steps?.rebalance; if (rb && rb.acted && !rb.error && Number(rb.gas_bnb) > 0 && Number(bnbUsd) > 0) { const fee = resetSwapFee(rb); // The trade's price impact, measured by the swap itself since // 2026-09-11 (swap.impact_bnb); older re-sets carry none and are // charged none — the record says which. const impact = rb.swap && Number(rb.swap.impact_bnb) >= 0 ? Number(rb.swap.impact_bnb) : null; const bnb = Number(rb.gas_bnb) + fee.bnb + (impact || 0); // The same cost per $50 of the position it was paid on: the replay // sizes every width at $50 (POSITION_USD), and until 2026-09-12 it // charged each replayed re-set the whole $0.16 a $424 position had // paid — eight times too much, which tilted the pick to wide ranges. const positionUsd = Number(rb.value_bnb) > 0 ? Number(rb.value_bnb) * Number(bnbUsd) : null; const per50 = positionUsd ? bnb * Number(bnbUsd) * POSITION_USD / positionUsd : null; return { usd: Math.round(bnb * Number(bnbUsd) * 100) / 100, bnb: Number(bnb.toFixed(8)), usd_per_50: per50 == null ? null : Math.round(per50 * 10000) / 10000, position_usd_at_reset: positionUsd == null ? null : Math.round(positionUsd * 100) / 100, gas_bnb: Number(rb.gas_bnb), swap_fee_bnb: fee.bnb, swap_basis: fee.basis, impact_bnb: impact, impact_basis: impact == null ? 'not measured by this re-set (before 2026-09-11)' : 'measured by the swap against the pool\'s mid price', at: hist[i].at, transactions: Array.isArray(rb.txs) ? rb.txs.length : null, }; } } return null; } // WHAT EACH RE-SET COST, from the record's own ticks. A re-set's range was // minted centred on a price (the middle of its ticks) and left at another // (the tick the re-set saw); rangeValue says exactly what that range then // held against the amounts it was minted with — the loss against holding, // realised the moment the re-set trades. Beside it the execution: gas, the // swap fee and, since 2026-09-11, the measured impact. Nothing here is a // replay; every number comes from a re-set that happened. Newest first. export function resetLosses(agentRecord, { bnbUsd = null } = {}) { const hist = Array.isArray(agentRecord?.history) ? agentRecord.history : []; const out = []; // Counted by the one definition (isReset, lp-flow); valued where the record // names the range that was left and the tick it was left at. A re-set // finished from the wallet after a failed run has no old range to value. let counted = 0; for (let i = hist.length - 1; i >= 0; i--) { if (hist[i]?.dry) continue; const rb = hist[i]?.steps?.rebalance; if (!isReset(rb)) continue; counted += 1; if (!Array.isArray(rb.ticks) || rb.ticks.length !== 2 || rb.tick == null) continue; const width = widthClassOf(rb.ticks); if (width == null) continue; const centre = (rb.ticks[0] + rb.ticks[1]) / 2; const pMint = Math.pow(1.0001, centre), pNow = Math.pow(1.0001, Number(rb.tick)); // A one-sided re-set (2026-09-16) trades nothing: the token the old range // ended in goes into the new range as it is, and nothing is realised. const loss = rb.one_sided ? 0 : rangeValue(pMint, width, pNow).loss; const base = Number(rb.value_bnb || 0) + Number(rb.fees_folded_bnb || 0); const fee = resetSwapFee(rb); const impact = rb.swap && Number(rb.swap.impact_bnb) >= 0 ? Number(rb.swap.impact_bnb) : null; const execution = Number(rb.gas_bnb || 0) + fee.bnb + (impact || 0); out.push({ at: hist[i].at, from_width_pct: width, to_width_pct: rb.width_pct ?? null, price_move_pct: Number(((pNow / pMint - 1) * 100).toFixed(2)), position_bnb: Number(base.toFixed(6)), lost_to_price_bnb: Number((loss * base).toFixed(6)), lost_to_price_pct: Number((loss * 100).toFixed(3)), execution_bnb: Number(execution.toFixed(6)), gas_bnb: Number(rb.gas_bnb || 0), swap_fee_bnb: fee.bnb, impact_bnb: impact, forced_by_deposit: !!rb.forced_by_deposit, one_sided: rb.one_sided || null, ...(bnbUsd > 0 ? { lost_to_price_usd: Math.round(loss * base * bnbUsd * 100) / 100, execution_usd: Math.round(execution * bnbUsd * 100) / 100 } : {}), }); } const sum = (k) => Number(out.reduce((a, r) => a + (r[k] || 0), 0).toFixed(6)); return { resets: counted, valued: out.length, lost_to_price_bnb: sum('lost_to_price_bnb'), execution_bnb: sum('execution_bnb'), impact_measured: out.filter((r) => r.impact_bnb != null).length, rows: out, basis: 'each re-set: the range it left, minted at the middle of its ticks, valued at the tick the re-set saw against the amounts it was minted with (rangeValue) — the loss against holding, realised by the re-set\'s trade; a one-sided re-set (since 2026-09-16) trades nothing and realises nothing; execution is gas, swap fee and the measured impact', }; } // rangeValue — what a range is worth at another price against holding — // lives in shared/lp-guards.js since 2026-09-11 (the width-upgrade rule // needs it too) and is re-exported here for the record and its pins. export { rangeValue }; // One width, lived through the record. `used` is the non-overlapping window // list in block order; only windows that carry a price take part. A window // earns for the hour it stands for (its fee row scaled from its own minutes // to the time until the next window), never for a gap in the record. The // re-set cost is the replay's own assumed cost, median over the windows, // unless the caller passes a measured one. // // A RE-SET IS CHARGED WHAT IT LOSES AGAINST HOLDING. Until 2026-09-11 a // re-set cost its gas and its swap fee, $0.16, and +/-1% was the pick. That // night the agent re-set twice on CAKE/BNB: out below the range at 16:50 it // sold CAKE at the low, out above the next range at 02:50 it bought CAKE // back 1.5% higher, and with the price back where it had started the // position was 3.5% lighter, $15 on $424, against $1.90 of fees. The gas // was never the cost. The cost is that a range sells the side that is // rising and holds the side that is falling, and a re-set makes that // permanent. So every re-set in the replay is charged what the range it // leaves had lost against simply holding its minted amounts (rangeValue), // and the range still open at the end is marked the same way at the last // price. Net is what is left after the fees paid for all of it. const MAX_GAP_HOURS = 3; const r2 = (x) => Math.round(x * 100) / 100, r4 = (x) => Math.round(x * 10000) / 10000; // What one unit of liquidity holds of each side at price p in [lo, hi]: // token0 (the priced side) and token1 (the quote), the pool's own curve. const perL = (p, lo, hi) => ({ x: p >= hi ? 0 : 1 / Math.sqrt(Math.max(p, lo)) - 1 / Math.sqrt(hi), y: p <= lo ? 0 : Math.sqrt(Math.min(p, hi)) - Math.sqrt(lo), }); // THE ONE-SIDED REPLAY (2026-09-16). The agent re-sets one-sided: the new // range sits beside the price on the side the price came from, spans what // a centred ±width range spans, starts ONE_SIDED_GAP_TICKS beyond the // price, and takes the one token the old range ended in — nothing is // traded. So the replay does the same: a re-set costs its gas (the // measured cost, swap fee zero) and nothing is "lost to the price" at it, // because nothing is sold. What the position holds is followed exactly // (perL) from the centred mint through every re-set, and at the end it is // valued against holding the minted amounts: vs_holding_usd, the honest // line — an LP behind holding in a trend, ahead of it when the price comes // back through its ranges. It is reported, not charged: the width is // picked by how much of the time it earns (pickWidth), the wait by net. // oneSided false is the replay as it was until 2026-09-16 (centred re-sets // charged their realised loss), kept for the pins and the older records. export function earningsTest(used, widthPct, { resetAfterHours = RESET_AFTER_HOURS, resetCostUsd = null, usd = POSITION_USD, tape = null, oneSided = true, resetAfterHoursAbove = null } = {}) { const priced = used.filter((w) => typeof w.price === 'number' && w.price > 0 && (w.rows || []).some((r) => r.width === widthPct)); if (priced.length < 2) return null; const costs = priced.map((w) => w.rebalance_cost_usd).filter((c) => typeof c === 'number' && c > 0).sort((a, b) => a - b); const cost = resetCostUsd ?? (costs.length ? costs[Math.floor(costs.length / 2)] : 0.5); const up = 1 + widthPct / 100, down = 1 / up, gap = Math.pow(1.0001, ONE_SIDED_GAP_TICKS); // The agent's own notion of "left" (rangeLeft): a price within the slack // of an edge has not left, and the wait does not run there. A one-sided // range sits the gap beyond the price by construction; without the slack // the replay would re-set it every hour for gas, which the agent does not. const slack = oneSided ? Math.pow(1.0001, RANGE_LEFT_TICKS) : 1; const series = priceSeries(priced, tape); const p0 = series[0].price; let centre = p0, lo = p0 * down, hi = p0 * up; // The position followed exactly: L units of liquidity in [lo, hi], and the // amounts it was minted with, for the holding line. const m0 = perL(p0, lo, hi); let L = usd / (m0.x * p0 + m0.y); const x0 = L * m0.x, y0 = L * m0.y; let outRun = 0, fees = 0, resets = 0, hoursIn = 0, hoursOut = 0, lost = 0, samples = 0; for (let i = 1; i < series.length; i++) { const pt = series[i], prev = series[i - 1]; const dtH = Math.min(MAX_GAP_HOURS, (pt.t - prev.t) / 36e5); if (!(dtH > 0)) continue; if (pt.window.at !== pt.at) samples += 1; const p = pt.price; if (p <= hi && p >= lo) { fees += inRangeFeeRate(pt.window, widthPct) * dtH; hoursIn += dtH; outRun = 0; } else { hoursOut += dtH; if (p < lo / slack || p > hi * slack) outRun += dtH; else outRun = 0; // resetAfterHoursAbove (a measurement option, 2026-09-17): a wait of its // own for a price that left above the range (the position all WBNB). if (outRun >= (p > hi && resetAfterHoursAbove != null ? resetAfterHoursAbove : resetAfterHours) && (p < lo / slack || p > hi * slack)) { resets += 1; const held = perL(p, lo, hi); const x = L * held.x, y = L * held.y; if (oneSided) { // Below the range all token0: a range above the price, all token0. // Above it all token1: a range below the price, all token1. if (p < lo) { lo = p * gap; hi = lo * up * up; } else { hi = p / gap; lo = hi * down * down; } const n = perL(p, lo, hi); L = p < lo ? x / n.x : y / n.y; } else { lost += rangeValue(centre, widthPct, p).loss * usd; centre = p; lo = p * down; hi = p * up; const n = perL(p, lo, hi); L = (x * p + y) / (n.x * p + n.y); } outRun = 0; } } } const pEnd = series[series.length - 1].price; const open = oneSided ? 0 : rangeValue(centre, widthPct, pEnd).loss * usd; const end = perL(pEnd, lo, hi); const valueEnd = L * (end.x * pEnd + end.y), holdEnd = x0 * pEnd + y0; const hours = hoursIn + hoursOut, net = fees - resets * cost - lost - open; return { hours: r2(hours), hours_in_range: r2(hoursIn), in_range_share: hours > 0 ? Math.round((hoursIn / hours) * 1000) / 1000 : null, fees_usd: r4(fees), resets, reset_cost_usd: r4(cost), one_sided: !!oneSided, lost_to_price_usd: r4(lost), open_loss_usd: r4(open), // The position at the end against holding what it was minted with, fees // beside it: what the liquidity itself did to the money. vs_holding_usd: r4(valueEnd - holdEnd), net_usd: r4(net), net_usd_per_day: hours > 0 ? r4(net / (hours / 24)) : null, price_points: series.length, tape_samples: samples, }; } // THE CALIBRATION. The replay says what $50 in a width would have collected // from the pool's fees; the agent's own position says what it did collect. // On 2026-09-10 the position at the 2% class had earned about three // quarters of what the replay put on that width — the replay overstates // every width alike, so the pick stands, but the dollar figure on the record // should say so. `points` is the liquidity series (fees_total_bnb, owed_bnb, // value_bnb, at), `rows` the verdict's rows, `widthClass` the position's // width class. Gross fees on both sides: the measured window may hold // re-sets, whose cost is not a fee. Null under a day of series. export function calibration(points, rows, widthClass, { minHours = 20, maxHours = 72 } = {}) { const pts = (points || []).filter((p) => p && p.at && typeof p.value_bnb === 'number' && p.value_bnb > 0 && p.fees_total_bnb != null); if (pts.length < 2 || widthClass == null) return null; const last = pts[pts.length - 1]; const cutoff = Date.parse(last.at) - maxHours * 36e5; const used = pts.filter((p) => Date.parse(p.at) >= cutoff); if (used.length < 2) return null; const first = used[0]; const hours = (Date.parse(last.at) - Date.parse(first.at)) / 36e5; if (!(hours >= minHours)) return null; const feesBnb = (Number(last.fees_total_bnb) + Number(last.owed_bnb || 0)) - (Number(first.fees_total_bnb) + Number(first.owed_bnb || 0)); // Capital, time-weighted over the points, minus what the operator put in // with each point (a deposit is not fees' doing). let capBnbH = 0; for (let i = 1; i < used.length; i++) capBnbH += used[i - 1].value_bnb * ((Date.parse(used[i].at) - Date.parse(used[i - 1].at)) / 36e5); const capitalBnb = capBnbH / hours; if (!(capitalBnb > 0) || !(feesBnb >= 0)) return null; // Fees over capital is a rate; on $50 a day it is dollars, whatever BNB costs. const measured = (feesBnb / capitalBnb) * 50 * (24 / hours); const row = (rows || []).find((r) => r.width === widthClass && r.earnings && r.earnings.hours > 0); const replay = row ? row.earnings.fees_usd / (row.earnings.hours / 24) : null; const factor = replay > 0 ? measured / replay : null; return { hours: r2(hours), from: first.at, to: last.at, position_width_pct: widthClass, fees_bnb: Number(feesBnb.toFixed(6)), capital_bnb: Number(capitalBnb.toFixed(6)), measured_usd_per_day_on_50: r4(measured), replay_usd_per_day_on_50: replay == null ? null : r4(replay), factor: factor == null ? null : r2(factor), basis: `the position's own fees over ${r2(hours)} h against the replay's gross fees for the ±${widthClass}% width, both on $50 a day; the pick compares widths with each other and is not scaled`, }; } const DAY_MS = 24 * 3600 * 1000; const MIN_LATER_MS = 20 * 3600 * 1000; function dayHold(used, widthPct) { const priced = used.filter((w) => typeof w.price === 'number' && w.price > 0); const up = 1 + widthPct / 100, down = 1 / up; let tested = 0, held = 0; for (let i = 0; i < priced.length; i++) { const t0 = Date.parse(priced[i].at), p0 = priced[i].price; const later = priced.slice(i + 1).filter((w) => Date.parse(w.at) - t0 <= DAY_MS); if (!later.length || Date.parse(later[later.length - 1].at) - t0 < MIN_LATER_MS) continue; tested += 1; if (later.every((w) => w.price / p0 <= up && w.price / p0 >= down)) held += 1; } return { tested, held }; } // A failed hour is written down. The cron swallowed its errors, and between // 06:30 and 14:00 UTC on 2026-09-02 four of eight hourly windows were simply // missing, with nothing anywhere to say why. export async function noteLpWindowError(env, e) { const prev = (await readLpWindows(env)) || { pool: await watchedPool(env), usd: POSITION_USD, windows: [] }; prev.last_error = { at: new Date().toISOString(), error: String(e?.message || e).slice(0, 200) }; prev.errors = (prev.errors || 0) + 1; await env.AGENT.put(KV_KEY, JSON.stringify(prev)); } export async function measure(address, usd) { const r = await fetch(MEASURE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'pancakeswap_range_plan', arguments: { address, capitalUsd: usd } }, }), signal: AbortSignal.timeout(45000), }); const j = await r.json(); if (j.error) throw new Error(j.error.message || 'the range could not be replayed'); const text = j.result?.content?.[0]?.text; if (!text) throw new Error('the replay returned nothing readable'); // A tool that could not answer says why in plain words with isError set. // Parsed as JSON, "every BSC endpoint refused …" became "Unexpected token // 'e'" — which CHAIN_REFUSED below does not match, so the one refusal the // retry exists for was never retried and the hour's window went missing // (2026-09-20; the same line in grid.js, lp-tiers.js and rebalance.js). if (j.result?.isError) throw new Error(String(text).slice(0, 300)); const plan = JSON.parse(text); if (plan.error) throw new Error(plan.error); if (!plan.measured_window || !Array.isArray(plan.ranges)) throw new Error('the replay came back without a window'); return plan; } // The pool the agent is in: what its own record says (worker-lp writes the // position's pool with every run since 2026-09-10, so a relocate is followed // the hour after), else the var the first weeks used. export async function watchedPool(env) { try { const rec = JSON.parse((await env.AGENT.get('lp:agent')) || 'null'); const p = String(rec?.pool || '').toLowerCase(); if (/^0x[0-9a-f]{40}$/.test(p)) return p; } catch { /* the var stands */ } return String(env.LP_WATCH_POOL || '').toLowerCase(); } // THE VERDICT THE AGENT ACTS ON, FOR EVERY READER (2026-09-18). The replay // charged with the agent's own measured re-set cost (per $50) and walked over // the ten-minute price tape. /lp/windows and the portfolio used it; the paid // position plan called verdict(log) bare — an assumed cost ~280 times the // measured one and hourly prices only — and printed another week table and // another wait than the agent's own record while saying "the same record and // rule". One loader; `bnbUsd` is the caller's BNB price (null: the replay's // own cost assumption stands). export async function widthVerdict(env, bnbUsd = null) { const log = await readLpWindows(env); if (!log) return { log: null, v: null }; let costOpts = {}; try { const rec = JSON.parse((await env.AGENT.get('lp:agent')) || 'null'); const m = measuredResetCost(rec, bnbUsd); // The replay is charged the cost per $50 of the position (usd_per_50); // the full figure is what a real re-set pays (the width-upgrade rule). if (m) costOpts = { resetCostUsd: m.usd_per_50 ?? m.usd, resetCostBasis: `measured: the re-set of ${m.at.slice(0, 16).replace('T', ' ')} UTC cost $${m.usd} on a $${m.position_usd_at_reset ?? '?'} position — ${m.gas_bnb} BNB of gas in ${m.transactions ?? '?'} transactions and ${m.swap_fee_bnb} BNB of swap fee (${m.swap_basis})` }; } catch { /* the replay's assumption stands */ } return { log, v: verdict(log, { ...costOpts, tape: await readLpTicks(env) }) }; } export async function readLpWindows(env) { const raw = await env.AGENT.get(KV_KEY); return raw ? JSON.parse(raw) : null; } // The hourly tick. One measurement, one KV read, one KV write. // WHY THE TICK WAITS BEFORE IT MEASURES, AND ASKS TWICE. // On 2026-09-02 four of eight hourly windows were missing and the record // said why: "every BSC endpoint refused eth_blockNumber". The window tick // rides the same cron invocation as the telemetry refresh and the watch // checks, so all three hit the same public nodes from the same egress in the // same second — we throttled ourselves, the lesson the census already taught. // So the replay starts after the burst has passed, and a refusal gets one // more try a little later. The entry says how many asks it took. // "The log endpoint refused this range" (2026-09-08 08:31) is the same class // of refusal and used to fall through to the error note without a retry. const CHAIN_REFUSED = /every BSC endpoint refused|log endpoint refused|rate limit|capacity|too many|quota|429|timed out|timeout|aborted|network|fetch failed/i; const SETTLE_MS = 25000, RETRY_MS = 20000; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export async function recordLpWindow(env, { settle = true } = {}) { const pool = await watchedPool(env); if (!/^0x[0-9a-f]{40}$/.test(pool)) return { ok: false, error: 'no watched pool: the agent record names none and LP_WATCH_POOL is not set' }; if (settle) await sleep(SETTLE_MS); let plan, attempts = 1; try { plan = await measure(pool, POSITION_USD); } catch (e) { if (!CHAIN_REFUSED.test(String(e.message))) throw e; attempts = 2; await sleep(RETRY_MS); plan = await measure(pool, POSITION_USD); } const prev = (await readLpWindows(env)) || { pool, usd: POSITION_USD, windows: [] }; if (prev.pool && prev.pool.toLowerCase() !== plan.pool.toLowerCase()) { // The var changed pools. Start over rather than mix — the old record is // in the local file of whoever synced it, and a mixed one is worth nothing. prev.pool = plan.pool; prev.windows = []; } const entry = windowFromPlan(plan, POSITION_USD); if (attempts > 1) entry.attempts = attempts; const { log, added } = appendWindow({ ...prev, pool: plan.pool, usd: POSITION_USD }, entry); if (added) await env.AGENT.put(KV_KEY, JSON.stringify(log)); return { ok: true, added, attempts, windows: log.windows.length, from_block: entry.from_block, to_block: entry.to_block }; } ============================================================================== === FILE: worker-lp/index.js ============================================================================== // The DeFi agent's tick: AI income into the position, half of the position's // fees kept as capital, the other half into $BOBAI the agent holds. // // THE FLOW, as the user set it on 2026-09-02 (fee rule as of 2026-09-09) // 1. everything the AI side earns (USD1 for watches, $U for delivered jobs) // goes to the DeFi wallet, in BNB -> sweep // 2. the DeFi wallet's profit — the fees the position earns — is // split: part stays as capital so the position grows out of its own // earnings (LP_FEE_KEEP_PCT, half since 2026-09-04: "er soll auch davon // wachsen"), the rest buys $BOBAI that stays in this wallet, never sold // (since 2026-09-09; before that it went to the buyback wallet); the // capital stays in the position, always -> collect // 3. a range the price has left is re-set beside the price, one-sided, with // the token it ended in and no trade (2026-09-16); the old range's fees // are split the same way on the way -> rebalance // 4. BNB that arrives while the main range is all of the other side opens // a reserve range below the price (2026-09-16) -> ladder // 5. capital that arrived — swept income, the kept fee share, deposits — // grows the same position -> increase // The buyback bot and the dev sweep are not touched by any of this; this // worker sends them nothing and reads nothing from either. // // The three steps live in shared/lp-agent.js, shared with the hand script // scripts/lp-agent.mjs — the same functions, so what a person can plan on a // laptop is what this cron sends. The keys are Worker secrets, the same class // of thing the buyback bot has held since July. This worker has no public // face beyond a secret-gated /run: everything readable about it is served by // the agent worker from the KV record it writes (agent.brainonbnb.com/lp/agent). import { createPublicClient, createWalletClient, http, fallback } from 'viem'; import { bsc } from 'viem/chains'; import { privateKeyToAccount } from 'viem/accounts'; import { RPCS, INCOME_SOURCES, planSweep, executeSweep, planCollect, executeCollect, planIncrease, executeIncrease, planRebalance, executeRebalance, readBnbUsd, planLadder, executeLadder, healLadder, } from '../shared/lp-agent.js'; import { readLpWindows, verdict, measuredResetCost, readLpTicks, recordLpTick } from '../worker-agent/lp-windows.js'; import { trimHistory, ARCHIVE_KEY } from '../shared/lp-flow.js'; import { alertsOf } from '../shared/lp-alerts.js'; import { rebalanceWait, splitFees, widthUpgrade, depositForcesReset, rangeLeft, RESET_AFTER_HOURS, HOME_POOL, LADDER_GATE, ladderActsInWatch } from '../shared/lp-guards.js'; export const KV_KEY = 'lp:agent'; // When the agent first saw the price outside the range, so an hourly check // can tell "just left" from "gone for two hours". Cleared the moment the // price is back inside or the range has been re-set. export const OUT_SINCE_KEY = 'lp:out_since'; // THE LADDER RECORD (2026-09-16): which position is the main range and // which the reserve range below the price (shared/lp-agent.js, planLadder). // Every step reads the wallet through it, so two positions it names read as // one main range with a reserve attached; two it does not name are refused // as before. Written by the ladder step (a reserve minted, re-set, or // merged away) and corrected here when the chain holds fewer positions // than the record says. export const LADDER_KEY = 'lp:ladder'; async function readLadder(env) { try { const raw = await env.AGENT.get(LADDER_KEY); const l = raw ? JSON.parse(raw) : null; return l && typeof l === 'object' ? { main: l.main ?? null, reserve: l.reserve ?? null, since: l.since ?? null } : { main: null, reserve: null, since: null }; } catch { return { main: null, reserve: null, since: null }; } } async function writeLadder(env, ladder) { await env.AGENT.put(LADDER_KEY, JSON.stringify(ladder)); } // WHAT WAS SENT IS RECORDED, WHATEVER KV DOES AFTERWARDS (2026-09-18). The KV // writes that follow a step's transactions used to sit in the same try: a put // that threw turned a re-set that had happened into `{ error, txs }` without // its new position, its fees or its $BOBAI — the money flow never counted it // and the ladder record went stale. They run on their own now; a failure is // named beside the result (`kv_error`), and ladderHeal follows the chain on // the next tick. async function afterSend(fn) { try { await fn(); return {}; } catch (e) { return { kv_error: String(e.message || e).slice(0, 200) }; } } // relocate sits before rebalance: a day on which the pool record's switch // rule says "move" ends with the position in the new pool, and the re-set // step then finds it in range. It runs in the daily tick only. // ladder sits between rebalance and increase (2026-09-16): a deposit that // waits beside a sell ladder above the price becomes a buy ladder below it // before the increase can refuse it; gated by LP_LADDER in wrangler.toml. const STEPS = ['sweep', 'collect', 'relocate', 'rebalance', 'ladder', 'increase']; const DAILY_CRON = '23 4 * * *'; // The hourly check re-sets the range and, since 2026-09-09, also puts in // what the wallet holds — a re-set that lands the position back in range // used to leave a deposit idle until the next 04:23. The deposit watch // runs every ten minutes on the other minutes and does only the increase: // one balance read, and nothing at all under the floor or out of range. // The operator's rule, 2026-09-09: "such things have to happen within // seconds or minutes" — a deposit that sits for a day is a broken agent. const HOURLY_CRON = '50 * * * *'; const json = (obj, status = 200) => new Response(JSON.stringify(obj, null, 2), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } }); const account = (key) => privateKeyToAccount(key.startsWith('0x') ? key : `0x${key}`); // Three public endpoints in turn. The first build used one and a throttled // node would have read as "the position holds nothing". const transport = () => fallback(RPCS.map((u) => http(u, { timeout: 15000 }))); async function readState(env) { const raw = await env.AGENT.get(KV_KEY); return raw ? JSON.parse(raw) : { history: [], last: null }; } // One run. `dry` reads and decides but signs nothing — the same code path up // to the first transaction, which is the part worth being able to test after // a deploy without moving money. `steps` narrows a hand-triggered run. // `watch` marks the ten-minute deposit watch: it re-sets the range only when // a large deposit waits beside a range the price has left (depositForcesReset), // and its rebalance step is not recorded otherwise, so the hourly check's // own record is not overwritten six times an hour with "no re-set". export async function agentTick(env, { dry = false, steps = STEPS, watch = false } = {}) { const at = new Date().toISOString(); const pub = createPublicClient({ chain: bsc, transport: transport() }); const entry = { at, dry, ok: true, acted: false, steps: {} }; const run = async (name, fn) => { if (!steps.includes(name)) return; try { const out = await fn(); entry.steps[name] = out; const parts = Array.isArray(out) ? out : [out]; for (const p of parts) { if (p.acted) entry.acted = true; if (p.error) entry.ok = false; } } catch (e) { entry.ok = false; entry.steps[name] = { acted: false, error: String(e.shortMessage || e.message).slice(0, 300) }; } }; // 1. sweep, one income wallet at a time. A missing key is a red line, not a // quiet day: the flow the site describes would silently not be running. await run('sweep', async () => { const feed = await readBnbUsd(pub); const out = []; for (const src of INCOME_SOURCES) { const key = env[src.keyEnv]; if (!key) { out.push({ source: src.key, acted: false, error: `${src.keyEnv} is not set on this worker` }); continue; } const acct = account(key); if (acct.address.toLowerCase() !== src.wallet.toLowerCase()) { out.push({ source: src.key, acted: false, error: `${src.keyEnv} does not open ${src.wallet}` }); continue; } const txs = []; try { const plan = await planSweep(pub, src, feed); if (plan.no) { out.push({ ...plan.summary, acted: false, why: plan.no }); continue; } if (dry) { out.push({ ...plan.summary, acted: false, why: 'dry run — would have sold and sent to the DeFi wallet' }); continue; } const wallet = createWalletClient({ account: acct, chain: bsc, transport: transport() }); out.push({ ...plan.summary, acted: true, ...(await executeSweep(pub, wallet, acct, plan, () => {}, { txs })) }); } catch (e) { // A sweep that sent before it failed did act: saying otherwise would // hide its transactions from the record and from the day's counters. out.push({ source: src.key, acted: txs.length > 0, error: String(e.shortMessage || e.message).slice(0, 300), txs }); } } return out; }); const lpKey = env.LP_PRIVATE_KEY; if (!lpKey) { entry.ok = false; for (const s of ['collect', 'relocate', 'rebalance', 'ladder', 'increase']) if (steps.includes(s)) entry.steps[s] = { acted: false, error: 'LP_PRIVATE_KEY is not set on this worker' }; entry.why = whyOf(entry.steps); return record(env, entry, steps.length < STEPS.length); } const lp = account(lpKey); entry.wallet = lp.address; const lpWallet = () => createWalletClient({ account: lp, chain: bsc, transport: transport() }); const ladder = await readLadder(env); // The record follows the chain (ladderHeal, 2026-09-17): a main range the // wallet no longer holds is replaced by the one position that stands beside // the reserve in the same pool. A dry run heals in hand only. try { const healed = await healLadder(pub, lp.address, ladder); if (healed) { entry.ladder_healed = { from: ladder.main, to: healed.main, ...(healed.closed ? { reserve_closed: ladder.reserve } : {}), ...(healed.adopted ? { reserve_adopted: healed.reserve, reserve_was: ladder.reserve ?? null } : {}), why: healed.why }; ladder.main = healed.main; if (healed.closed) ladder.reserve = null; if (healed.adopted) ladder.reserve = healed.reserve; if (!dry) await writeLadder(env, ladder); } } catch { /* an RPC that did not answer heals nothing; the guards refuse as before */ } const ladderOn = String(env[LADDER_GATE] || '0') === '1'; // The width record's verdict, replayed once per tick (the rebalance step // fills it; the ladder step reads it). let widthRecord = null; // The width record's verdict with the agent's own measured re-set cost — // one loader for the rebalance and the ladder step, so the two never pick // a different width from the same record (2026-09-16: a hand-narrowed // ladder run replayed without the cost and named ±4% where the re-set // had just taken ±7%). const loadWidthRecord = async () => { const log = await readLpWindows(env); let costOpts = {}, bnbUsd = null; try { bnbUsd = (await readBnbUsd(pub)).bnbUsd; const m = measuredResetCost(await readState(env), bnbUsd); if (m) costOpts = { resetCostUsd: m.usd_per_50 ?? m.usd, resetCostFullUsd: m.usd, resetCostBasis: `measured: the re-set of ${m.at.slice(0, 16).replace('T', ' ')} UTC cost $${m.usd} on a $${m.position_usd_at_reset ?? '?'} position — ${m.gas_bnb} BNB of gas and ${m.swap_fee_bnb} BNB of swap fee (${m.swap_basis})` }; } catch { /* the replay's assumption stands */ } const record = log ? verdict(log, { ...costOpts, tape: await readLpTicks(env) }) : null; return { log, record, costOpts, bnbUsd }; }; // 2. collect: fees -> BNB -> part kept as capital, the rest to the buyback // wallet. Only what this run produced. The share is a var, not a secret: // it is public policy, and the record names it with every collect. const keptPct = splitFees(0n, env.LP_FEE_KEEP_PCT).pct; await run('collect', async () => { const plan = await planCollect(pub, lp.address, ladder); const split = { kept_pct: keptPct, buyback_pct: 100 - keptPct }; if (plan.no) return { ...plan.summary, ...split, acted: false, why: plan.no }; if (dry) return { ...plan.summary, ...split, acted: false, why: `dry run — would have collected, kept ${keptPct}% as capital and forwarded the rest`, would_forward_bnb_about: plan.state.owedBnbEquivalent }; const txs = []; try { return { ...plan.summary, ...split, acted: true, ...(await executeCollect(pub, lpWallet(), lp, plan, () => {}, { keptPct, txs })) }; } catch (e) { return { ...plan.summary, acted: txs.length > 0, error: String(e.shortMessage || e.message).slice(0, 300), txs }; } }); // 2b. relocate: retired on 2026-09-11. For one day (2026-09-10) the pool // record replayed fifty dollars in twelve pools each hour and this // step would have moved the position to whichever led by a quarter. // The operator closed the question: the agent stays in CAKE/BNB 0.05% // and optimises there (HOME_POOL in shared/lp-guards.js). The step // stays in the record so the day's entries keep their shape, and says // so in words; the hand script can still bring a stray position home. await run('relocate', async () => ({ acted: false, move: false, why: `stay: ${HOME_POOL.why}` })); // 3. rebalance: a position the price has left is re-set around today's // price, in the width the window record's earnings test picked — the // width that netted the most per day over the recorded prices, re-sets // included. Checked every hour, not once a day: a position outside its // range earns nothing, and the daily tick left it there for up to a day. // But not on the first hour outside — a price that just left is often // back on its own, so the agent waits before paying for a re-set. The // wait is the record's own since 2026-09-09: delay_test.in_use_hours, // the wait that netted the most per day when every width was replayed // with each wait (a set 2 h until the record clears the bar). Gated by // LP_REBALANCE in wrangler.toml: the first re-set was run by hand and // watched (2026-09-02), then the cron took over. await run('rebalance', async () => { // THE WATCH LOOKS BEFORE IT REPLAYS (2026-09-13). Every ten minutes the // watch used to read the width record (~100 KB), the price tape and // replay ten widths with ten waits, twice — to decide, nearly always, // that there is nothing for it to do: it re-sets only when a large // deposit waits beside a range the price has left, or finishes a re-set // that stopped half way. Both show in one look at the position and the // wallet, which the increase step reads anyway. So the watch looks first // and replays only when it may act; the hourly check and the daily run // still replay every time, since they may re-set on the wait alone. if (watch) { const inc = await planIncrease(pub, lp.address, null, ladder); const s = inc.summary; if (inc.pos && s.in_range != null) { // "Left" is the guard's word, not the pool's: a one-sided range sits // beside the price by construction, and a price within the slack of // an edge has not left (rangeLeft, 2026-09-16). const lf = rangeLeft(s.tick, Number(inc.pos[5]), Number(inc.pos[6])); const settled = s.in_range || !lf.left; const forced = settled || (ladderOn && s.side === 'other') ? null : depositForcesReset({ inRange: false, spendableBnb: s.spendable_bnb, valueBnb: s.value_bnb }); if (!forced) { const outSinceRaw = await env.AGENT.get(OUT_SINCE_KEY); if (settled) { if (outSinceRaw != null && !dry) await env.AGENT.delete(OUT_SINCE_KEY); } else if (outSinceRaw == null && !dry) await env.AGENT.put(OUT_SINCE_KEY, at); return { position: s.position, ticks: [Number(inc.pos[5]), Number(inc.pos[6])], tick: s.tick, in_range: s.in_range, pool: s.pool, wbnb_is0: inc.wbnbIs0, value_bnb: s.value_bnb, acted: false, watch: true, looked_only: true, ...(lf.outside && !lf.left ? { at_edge: true, ticks_beyond_edge: lf.ticks_away } : {}), outside_since: settled ? null : (outSinceRaw || at), deposit_beside: { spendable_bnb: s.spendable_bnb, value_bnb: s.value_bnb }, why: 'deposit watch: the range is re-set here only when a large deposit waits beside it; the hourly check does the rest', }; } } } // The width is picked with the cost the agent really pays, once it has // paid one: the last re-set's gas from its own record, in today's dollars // (loadWidthRecord; the replay is charged the cost per $50 of the // position, the full figure stays for the record). const { log, record, costOpts, bnbUsd } = await loadWidthRecord(); // The pool the record watches lets the plan finish a re-set that stopped // between its unwind and its mint: no position, the two tokens in the // wallet (2026-09-05 12:50). Such a resume does not wait the two hours — // the capital is already out of the pool and earning nothing. widthRecord = record; const plan = await planRebalance(pub, lp.address, { record, pool: log?.pool || null, keptPct, ladder }); const outSinceRaw = await env.AGENT.get(OUT_SINCE_KEY); const outSince = outSinceRaw ? Date.parse(outSinceRaw) : null; let upgrade = null; // At the edge (within the slack) counts as settled: the wait does not // run, and a note that ran is cleared — the price is back at the range. if (plan.summary.at_edge === true && !plan.summary.in_range) { if (outSince != null && !dry) await env.AGENT.delete(OUT_SINCE_KEY); return { ...plan.summary, acted: false, why: plan.no }; } if (plan.summary.in_range) { if (outSince != null && !dry) await env.AGENT.delete(OUT_SINCE_KEY); // In range, nothing forces a re-set — unless the record's pick now // nets enough more on this capital to pay for one within a day. The // daily run alone may upgrade (once a day, by construction). upgrade = widthUpgrade({ daily: steps.length === STEPS.length, inRange: true, ticks: plan.summary.ticks, tick: plan.summary.tick, pick: record?.earnings_pick || null, rows: record?.rows || [], hoursOfPrices: record?.hours_of_prices || 0, valueBnb: plan.summary.value_bnb, bnbUsd, resetCostUsd: costOpts.resetCostFullUsd ?? record?.reset_cost?.usd ?? 0, }); if (!upgrade.upgrade) return { ...plan.summary, acted: false, why: plan.no, upgrade: upgrade.why }; if (plan.width == null || !plan.ticks) return { ...plan.summary, acted: false, why: plan.no, upgrade: 'the plan carries no new ticks to upgrade into' }; } else if (plan.no) return { ...plan.summary, acted: false, outside_since: outSinceRaw || null, why: plan.no }; // A large deposit waiting beside a range the price has left ends the wait. let forced = null, waiting = null; if (!plan.resume && !plan.summary.in_range) { try { const inc = await planIncrease(pub, lp.address, null, ladder); waiting = { spendable_bnb: inc.state.spendableBnb, value_bnb: plan.summary.value_bnb }; // With the ladder on, a deposit beside a sell ladder (the main range // all of the other side, above the price) is the ladder step's: it // becomes a buy ladder below the price, no trade. Forcing a re-set // here would buy the other side with it, the very trade the ladder // exists to avoid. forced = ladderOn && inc.summary.side === 'other' ? null : depositForcesReset({ inRange: false, spendableBnb: inc.state.spendableBnb, valueBnb: plan.summary.value_bnb }); } catch (e) { waiting = { error: String(e.shortMessage || e.message).slice(0, 160) }; forced = null; } } const forcedNote = { ...(waiting ? { deposit_beside: waiting } : {}), ...(forced ? { forced_by_deposit: forced } : {}) }; // The ten-minute watch stamps the moment the price left the range, so the // wait runs from then; until 2026-09-12 only the :50 check stamped it, and // a range left at :51 waited up to an hour longer than the record says. if (watch && !plan.resume && !plan.summary.in_range && outSince == null && !dry) await env.AGENT.put(OUT_SINCE_KEY, at); if (watch && !forced && !plan.resume) return { ...plan.summary, ...forcedNote, acted: false, watch: true, outside_since: outSinceRaw || at, why: 'deposit watch: the range is re-set here only when a large deposit waits beside it; the hourly check does the rest' }; if (upgrade && upgrade.upgrade) { if (String(env.LP_REBALANCE || '0') !== '1') return { ...plan.summary, acted: false, why: 'a width upgrade is due and LP_REBALANCE is not 1', upgrade: upgrade.why }; if (dry) return { ...plan.summary, acted: false, why: `dry run — would have upgraded the width from ${upgrade.from}% to ${upgrade.to}%`, upgrade: upgrade.why }; const txs = []; try { const done = await executeRebalance(pub, lpWallet(), lp, plan, () => {}, { keptPct, txs }); return { ...plan.summary, acted: true, upgrade: upgrade.why, upgraded_from_pct: upgrade.from, upgraded_to_pct: upgrade.to, gain_usd_per_day: upgrade.gain_usd_per_day, ...done }; } catch (e) { return { ...plan.summary, acted: txs.length > 0, upgrade: upgrade.why, error: String(e.shortMessage || e.message).slice(0, 300), txs }; } } const waitH = record?.delay_test?.in_use_hours ?? RESET_AFTER_HOURS; const waitNote = { wait_h: waitH, wait_basis: record?.delay_test?.wait_basis || 'set', ...(forced ? { forced_by_deposit: forced } : {}) }; if (!plan.resume && !forced) { if (outSince == null) { if (!dry) await env.AGENT.put(OUT_SINCE_KEY, at); const first = rebalanceWait(null, Date.parse(at), waitH); if (first) return { ...plan.summary, ...waitNote, acted: false, outside_since: at, why: first }; } else { const wait = rebalanceWait(outSince, Date.parse(at), waitH); if (wait) return { ...plan.summary, ...waitNote, acted: false, outside_since: outSinceRaw, why: wait }; } } if (String(env.LP_REBALANCE || '0') !== '1') return { ...plan.summary, ...forcedNote, acted: false, outside_since: outSinceRaw, why: 'a re-set is due and LP_REBALANCE is not 1 — the first one is run by hand and watched, then the cron takes over' }; if (dry) return { ...plan.summary, ...forcedNote, acted: false, outside_since: outSinceRaw, why: plan.resume ? 'dry run — would have minted the range from what the wallet holds' : `dry run — would have re-set the range${plan.oneSided ? ` one-sided, ${plan.ticks.side === 'above_price' ? 'above' : 'below'} the price, no trade` : ''}${plan.summary.fees_to_bobai_bnb > 0 ? ` and bought BOBAI with ${plan.summary.fees_to_bobai_bnb} BNB of the old range's fees` : ''}` }; const txs = []; try { // THE MERGE (2026-09-16). A reserve range that holds the same token as // the main range (the price went through one of them) is unwound // first; its tokens and fees come back to the wallet, and the mint // below takes them with the rest — one range again, no trade. let merged = null; if (plan.summary.reserve) { const lp2 = await planLadder(pub, lp.address, { record: widthRecord, ladder }); if (lp2.act === 'merge') { const m = await executeLadder(pub, lpWallet(), lp, lp2, () => {}, { txs }); merged = { merged_reserve: m.merged_reserve, reserve_fees_folded: m.reserve_fees_folded }; ladder.reserve = null; Object.assign(merged, await afterSend(() => writeLadder(env, ladder))); } } // A re-set a deposit forced takes the deposit with it: wrapped after // the unwind, minted with the rest, no sell-then-buy-back. const done = await executeRebalance(pub, lpWallet(), lp, plan, () => {}, { keptPct, wrapFirst: !!forced, txs }); if (done.new_position) { ladder.main = String(done.new_position); ladder.since = ladder.since || at; } const kv = await afterSend(async () => { await env.AGENT.delete(OUT_SINCE_KEY); if (done.new_position) await writeLadder(env, ladder); }); return { ...plan.summary, ...forcedNote, acted: true, outside_since: outSinceRaw, ...(merged || {}), ...done, ...kv }; } catch (e) { return { ...plan.summary, ...forcedNote, acted: txs.length > 0, outside_since: outSinceRaw, error: String(e.shortMessage || e.message).slice(0, 300), txs }; } }); // The price the check saw goes on the tape, every ten minutes, whatever // the step decided (lp-windows.js, THE PRICE TAPE). The price is WBNB per // unit of the other side, the way the window record quotes it. { const rb = entry.steps.rebalance; if (!dry && rb && rb.tick != null && rb.pool) { const raw = Math.pow(1.0001, Number(rb.tick)); await recordLpTick(env, { at, tick: Number(rb.tick), price: rb.wbnb_is0 ? 1 / raw : raw, pool: rb.pool }).catch(() => {}); } } // The watch's rebalance step is kept only when it did something or a // deposit forced it; a "no re-set here" every ten minutes is not a record. // A dry run keeps it, so a hand check can see what the watch saw. if (watch && !dry && entry.steps.rebalance && !entry.steps.rebalance.acted && !entry.steps.rebalance.forced_by_deposit && !entry.steps.rebalance.error) delete entry.steps.rebalance; // 3b. ladder (2026-09-16): BNB waiting beside a main range that is all of // the other side above the price opens, or grows, a reserve range // below the price — WBNB only, no trade (planLadder, ladderDecision). // A reserve the price has left is re-set beside it the same way. The // merge back into one range happens at the main range's re-set above. // Gated by LP_LADDER: "0" plans and records, "1" runs. The ten-minute // watch runs it too, so a deposit becomes a ladder within minutes. await run('ladder', async () => { // A hand-narrowed run (step=ladder) has no rebalance step before it to // fill the width record; replay it here then, so the reserve's width is known. if (!widthRecord) { try { widthRecord = (await loadWidthRecord()).record; } catch { widthRecord = null; } } const plan = await planLadder(pub, lp.address, { record: widthRecord, ladder }); const base = { ...plan.summary, gate: ladderOn ? 'on' : 'off' }; if (plan.no) return { ...base, acted: false, why: `${plan.why} — ${plan.no}` }; if (!plan.act) return { ...base, acted: false, why: plan.why }; if (plan.act === 'merge') return { ...base, acted: false, why: `${plan.why} (done at the main range's re-set)` }; if (!ladderOn) return { ...base, acted: false, why: `${plan.why} — ${LADDER_GATE} is not 1: planned, not run` }; // The watch opens and grows the reserve; re-setting it is the hourly // check's (ladderActsInWatch) — the reserve does not chase the price // every ten minutes. if (watch && !ladderActsInWatch(plan.act)) return { ...base, acted: false, why: `${plan.why} — left to the hourly check: the ten-minute watch does not re-set the reserve` }; if (dry) return { ...base, acted: false, why: `dry run — would have ${plan.act === 'mint_reserve' ? 'minted the reserve range' : plan.act === 'increase_reserve' ? 'grown the reserve range' : 're-set the reserve range'}: ${plan.why}` }; const txs = []; try { const done = await executeLadder(pub, lpWallet(), lp, plan, () => {}, { txs }); // The record on KV and the one this tick holds in hand: the increase // step that follows must read the wallet through the new reserve too. let kv = {}; if (done.new_reserve) { ladder.main = plan.summary.position; ladder.reserve = String(done.new_reserve); ladder.since = ladder.since || at; kv = await afterSend(() => writeLadder(env, ladder)); } return { ...base, acted: true, ...done, ...kv }; } catch (e) { return { ...base, acted: txs.length > 0, error: String(e.shortMessage || e.message).slice(0, 300), txs }; } }); // The watch keeps its ladder step only when it did something. if (watch && !dry && entry.steps.ladder && !entry.steps.ladder.acted && !entry.steps.ladder.error) delete entry.steps.ladder; // 4. increase: whatever is above the reserve, into the same position. await run('increase', async () => { const plan = await planIncrease(pub, lp.address, null, ladder); if (plan.no) return { ...plan.summary, acted: false, why: plan.no }; if (dry) return { ...plan.summary, acted: false, why: 'dry run — would have grown the position' }; const txs = []; try { return { ...plan.summary, acted: true, ...(await executeIncrease(pub, lpWallet(), lp, plan, () => {}, { txs })) }; } catch (e) { return { ...plan.summary, acted: txs.length > 0, error: String(e.shortMessage || e.message).slice(0, 300), txs }; } }); entry.why = whyOf(entry.steps); return record(env, entry, steps.length < STEPS.length); } // The one-line summary of a record, rebuilt from its steps — so a daily // record whose rebalance step was replaced by an hourly check does not keep // saying "inside the range" from the morning while the step says "outside". function whyOf(steps) { const out = []; for (const name of STEPS) { const v = steps[name]; if (!v) continue; for (const p of Array.isArray(v) ? v : [v]) if (p.why) out.push(`${name}${p.source ? ` ${p.source}` : ''}: ${p.why}`); } return out.join(' · ') || null; } // `partial` is an hourly range check (or a hand-narrowed run): it becomes // `last_check`, and its rebalance step is folded into the daily record so // the page and the series see the range as it is now — but the daily // record's sweep, collect and increase are not wiped by a run that never // looked at them. A full run replaces the daily record as before. // THE OPERATOR IS TOLD AT ONCE (2026-09-18) what cannot wait for the morning // card: a failed step, a step waiting for a person, a healed record, and the // money moving in a way it rarely does (shared/lp-alerts.js decides what). // Sent through the Telegram bot's own worker (service binding TG, target // "operator": the operator's private chat, never the channel). Each message // has a key the worker remembers for its quiet hours, so a refusal that // repeats every ten minutes is said once. Never throws: a message that did // not go out must not cost the tick its record. async function tellOperator(env, entry) { if (!env.TG || !env.BROADCAST_SECRET) return { told: 0, why: 'no TG binding or BROADCAST_SECRET on this worker' }; let told = 0; for (const m of alertsOf(entry)) { try { const k = `lp:alert:${m.key}`; if (await env.AGENT.get(k)) continue; const r = await env.TG.fetch('https://tg/broadcast', { method: 'POST', headers: { 'content-type': 'application/json', 'x-broadcast-secret': env.BROADCAST_SECRET }, body: JSON.stringify({ target: 'operator', text: m.text }), }).then((x) => x.json()).catch(() => null); if (r && r.ok === true) { told++; await env.AGENT.put(k, entry.at, { expirationTtl: Math.max(3600, Math.round((m.quietHours || 6) * 3600)) }); } } catch { /* the next tick says it */ } } return { told }; } async function record(env, entry, partial = false) { const st = await readState(env); // A dry run reads and decides but signs nothing, and it is not the day's // run: on 2026-09-10 a hand-triggered dry run at 04:41 replaced the 04:23 // daily record, the series had no point for it, and the 05:00 card was // held back. A dry run is kept as `last_dry` and touches nothing else. if (entry.dry) { st.last_dry = entry; await env.AGENT.put(KV_KEY, JSON.stringify(st)); return entry; } // Every real action and every error is kept; quiet days are summarised as // the last check so the history is a history of what happened, not of the // cron firing. if (entry.acted || !entry.ok) { // The cap keeps the record readable every ten minutes; a run it pushes // out goes to the archive, where the sums still find it (lp-flow.js). const { kept, dropped } = trimHistory(st.history, entry); if (dropped.length) { const arch = JSON.parse((await env.AGENT.get(ARCHIVE_KEY)) || 'null') || { what_this_is: 'Runs the DeFi agent record no longer holds (it keeps the newest 200): the writer moves them here, oldest first, and every total the agent worker reports still counts them.', entries: [] }; // Idempotent: if the archive put went through and the record's put after // it did not, the same oldest runs are dropped again on the next trim — // a run already in the archive (by its `at`) is not appended twice. const have = new Set((Array.isArray(arch.entries) ? arch.entries : []).map((x) => x && x.at)); arch.entries = (Array.isArray(arch.entries) ? arch.entries : []).concat(dropped.filter((x) => x && !have.has(x.at))); await env.AGENT.put(ARCHIVE_KEY, JSON.stringify(arch)); } st.history = kept; } st.last_check = entry; // Where the position lives, for the records that follow it (the width and // pool records watch this pool). A relocate names the new one the moment // it minted; every other run names what the increase step read. const pool = entry.steps?.relocate?.new_pool || entry.steps?.increase?.pool; if (pool && /^0x[0-9a-f]{40}$/i.test(pool)) st.pool = String(pool).toLowerCase(); if (partial && st.last && st.last.steps) { const steps = { ...st.last.steps, ...entry.steps }; // "Range checked" only when the range was: a hand-narrowed collect run // must not read as an hourly check on the record page. st.last = { ...st.last, steps, why: whyOf(steps), ...(entry.steps.rebalance ? { range_checked_at: entry.at } : {}) }; } else { st.last = entry; } st.note = 'Once a day: what the AI side earned is sold for BNB and sent to the DeFi wallet (sweep); the fees the PancakeSwap V3 position earned are sold for BNB, part stays as capital (the kept share, named in every collect) and the rest buys $BOBAI that the agent holds in its own wallet, never sold (collect; until 2026-09-09 that share went to the buyback wallet); BNB above the reserve — swept income and kept fees — grows the same position (increase); the position stays in its home pool, CAKE/BNB 0.05% — the pool question is closed since 2026-09-11, and the relocate step only records that it stays. Every hour: a position the price has left (more than half a percent past an edge, for the wait in use) is re-set beside the price, on the side the price came from, with the one token the old range ended in and no trade — one-sided, since 2026-09-16; the width is the one that ended the most ahead against holding over the last week, fees in, when every width was replayed that way, kept unless another leads it by a tenth (rebalance). BNB that waits beside a main range that is all of the other side above the price opens a reserve range below the price, WBNB only, no trade — a buy ladder under the sell ladder (ladder, since 2026-09-16, gated by LP_LADDER); the two merge back into one at the main range\'s next re-set once they hold the same token. The capital never leaves. Each step has a floor under which moving the money would cost more than the money, and a run under a floor is recorded as a decision, not an error.'; st.cadence = { daily_utc: '04:23 — sweep, collect, rebalance, ladder, increase (relocate is retired and only records that the position stays)', hourly_utc: ':50 — rebalance (one-sided, no trade, since 2026-09-16), ladder, then increase', deposit_watch_utc: 'every 10 min — increase (a deposit goes in within minutes, in range and above the floor), and a re-set at once when a deposit of a quarter of the position or more waits beside a range the price has left' }; await env.AGENT.put(KV_KEY, JSON.stringify(st)); await tellOperator(env, entry).catch(() => {}); return entry; } export default { async fetch(request, env) { const url = new URL(request.url); // Proves the alert channel end to end (binding, secret, the bot's operator // target) without waiting for something to go wrong. Same secret as /run. if (url.pathname === '/alert-test' && request.method === 'POST') { if (!env.HIT_SECRET || request.headers.get('x-hit-secret') !== env.HIT_SECRET) return new Response('forbidden', { status: 403 }); if (!env.TG || !env.BROADCAST_SECRET) return new Response(JSON.stringify({ ok: false, why: 'no TG binding or BROADCAST_SECRET on this worker' }), { status: 503, headers: { 'content-type': 'application/json' } }); const r = await env.TG.fetch('https://tg/broadcast', { method: 'POST', headers: { 'content-type': 'application/json', 'x-broadcast-secret': env.BROADCAST_SECRET }, body: JSON.stringify({ target: 'operator', text: '🔔 DeFi agent · alert channel test\nThis is where a failed step, a step waiting for a person, a healed record and every re-set or ladder move will be said at once. Routine (looks, collects, top-ups) stays on the daily card.' }) }).then((x) => x.json()).catch((e) => ({ ok: false, error: String(e && e.message || e) })); return new Response(JSON.stringify({ ok: r && r.ok === true, bot: r }), { headers: { 'content-type': 'application/json' } }); } if (url.pathname === '/run' && request.method === 'POST') { if (request.headers.get('x-hit-secret') !== env.HIT_SECRET) return json({ error: 'no' }, 403); // Dry unless asked otherwise: a hand-triggered run is for checking the // deploy, and checking must not be the thing that moves money. const dry = url.searchParams.get('dry') !== '0'; const step = url.searchParams.get('step'); const steps = step && STEPS.includes(step) ? [step] : STEPS; const watch = url.searchParams.get('watch') === '1'; return json(await agentTick(env, { dry, steps, watch })); } if (url.pathname === '/') return json(await readState(env)); return json({ error: 'not found' }, 404); }, async scheduled(event, env, ctx) { // The daily tick runs all five steps; the :50 firing is the hourly range // check (rebalance, then increase so a fresh range takes what waits in the // wallet); every other firing is the deposit watch: increase, and a re-set // only when a large deposit waits beside a range the price has left. const watch = event.cron !== DAILY_CRON && event.cron !== HOURLY_CRON; const steps = event.cron === DAILY_CRON ? STEPS : ['rebalance', 'ladder', 'increase']; ctx.waitUntil(agentTick(env, { steps, watch }).catch(async (e) => record(env, { at: new Date().toISOString(), ok: false, acted: false, error: String(e.message).slice(0, 300) }))); }, }; ============================================================================== === FILE: worker-lp/package.json ============================================================================== { "name": "bobai-lp-agent", "private": true, "type": "module", "//": "Marker only, no dependencies: viem resolves from the repo root. type:module is what lets Node load index.js for a check; wrangler bundles it either way." } ============================================================================== === FILE: worker-lp/wrangler.toml ============================================================================== name = "bobai-lp-agent" main = "index.js" compatibility_date = "2025-01-01" # Once a day for the money steps: the collect floor is 0.002 BNB of fees, the # sweep floor 0.004 BNB of income, and a fifty-dollar position in a 0.05% pool # earns the first over days, not hours. 04:23 UTC is a quiet hour on the chain # and shares no minute with the agent worker's own jobs (05:23 until # 2026-09-08, moved an hour earlier at the operator's request). # Every hour at :50 for the range alone: a position outside its range earns # nothing, and the daily tick left it there for up to a day (2026-09-03: out # from about 08:30 to the next daily tick). The check reads the position and the # pool, and re-sets only once the price has been outside for the wait the # width record measured (0 to 24 h; two hours was the set wait until 2026-09-09). :50 # is after the agent worker's window tick at :30, which the check reads. [triggers] # 04:23 the daily run; :50 the hourly range check; the deposit watch on the # other tens (not :50 — two firings in the same minute would both try to wrap # the same BNB and the second would revert). crons = ["23 4 * * *", "50 * * * *", "0,10,20,30,40 * * * *"] # The same KV namespace the agent worker reads, so agent.brainonbnb.com can # serve /lp/agent from the record this worker writes. This worker itself has # no custom domain and no public face beyond the secret-gated /run. # The operator is told at once about a failed step, a step waiting for a person # and the rare money moves (shared/lp-alerts.js) — through the Telegram bot's # worker, to the operator's private chat. Needs the secret BROADCAST_SECRET # (the one worker-health uses); without it the agent runs and says nothing. [[services]] binding = "TG" service = "bobai-tg-bot" [[kv_namespaces]] binding = "AGENT" id = "" # Whether the cron may re-set the range on its own when the price has left # it. Was "0" until the first re-set had been run by hand and watched on # 2026-09-02 (it worked, nine transactions); "1" since that night, on the # operator's word. Still gated by the day test and the size floor. Was: # (node scripts/lp-agent.mjs --step rebalance --confirm); the tick still plans # it and records that it is due. Flip to "1" and deploy to hand it over. # How much of each collect stays as capital, in percent; the rest buys # $BOBAI the agent's wallet holds (since 2026-09-09; the buyback wallet before). "0" was the rule until 2026-09-04 (every fee to the # buyback); "50" since, on the operator's word: the position grows out of # its own fees, so the buyback's share grows with it. A value that is not # 0–100 falls back to the default in shared/lp-guards.js (50), never to 0 or 100. [vars] LP_REBALANCE = "1" # LP_RELOCATE is gone (2026-09-11): the agent stays in CAKE/BNB 0.05%, # HOME_POOL in shared/lp-guards.js; the relocate step only says so. LP_FEE_KEEP_PCT = "50" # LP_LADDER (2026-09-16): "0" plans the buy ladder and records what it would # do; "1" mints and grows the reserve range below the price from BNB that # arrives while the main range is all of the other side above it. The first # reserve is watched before the gate opens, as the first re-set was. LP_LADDER = "1" # Secrets, set with `wrangler secret put`, never in this file: # LP_PRIVATE_KEY the liquidity wallet 0xbFAA69233741924eD5b9d5DAA9B4Bf7B84567F0A # X402_PRIVATE_KEY the x402 income wallet 0x690E950214980BC329823A2DB2fD90C06Bd54dE4 (sweep) # AGENT_PROVIDER_PRIVATE_KEY the job-provider income wallet 0x73809F69916FcF7Ddc5BB1315fBdf96A569a5963 (sweep) # HIT_SECRET the same shared secret the agent worker's /run-* routes use # The two income keys sign one thing here: the sale of what arrived, paid to # the liquidity wallet. Without them the sweep records a red line, not a quiet day.