/** * The starter bot's loop. Your logic goes in strategy.ts and your settings in config.json: you shouldn't need to edit * this file. * * Every interval_s seconds it: * 1. reads the USD prices of the configured tokens (Chainlink, read on chain by the SDK) and the wallet's balances; * 2. asks decide(market, book, state) in strategy.ts what to trade; * 3. cuts each order to the bot's own limits (per trade, per rolling 24 hours, what the wallet holds, at least $1); * 4. trades it, or with dry_run on (the default) only checks it, and logs everything to the console and bot-log.jsonl. * * npm run bot run until Ctrl+C, or until a file named STOP appears in this folder * npm run bot -- --once one tick, then exit * npm run bot -- --config my.json * * A dry run never uses the session's limits. Orblivion counts every trade its co-signer signs against the session's day * cap, sent or not, so a dry run here never asks for a session trade: it asks for a quote (session mode) or a plan * (direct mode), and nothing is co-signed, signed or sent. */ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import { OrblivionApiError, OrblivionError, VerificationError, createOrblivionClient, type OrblivionClient } from '@orblivion/sdk' import { formatUnits, type Address, type Hex } from 'viem' import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts' import { decide as strategyDecide, type Book, type Market, type Order, type State } from './strategy.ts' export const HERE = path.dirname(fileURLToPath(import.meta.url)) export const EXPLORER = 'https://robin.etherscan.io' const MIN_TRADE_USD = 1 const DAY_S = 24 * 3600 const KEEP_BACK = 0.98 // an order spends at most 98% of what the wallet holds of a token (gas, rounding) /** Orblivion's refusals after which this session can't trade again: the bot stops and says what to do. */ const SESSION_OVER: Record = { SESSION_REVOKED: 'the session was revoked', SESSION_EXPIRED: 'the session has ended', SESSION_EXPIRY: 'the session is about to end', SESSION_PENDING: "the session isn't switched on", SESSION_TRADES_LEFT: "the session's trades are used up", SESSION_CODE: "the wallet can't run the session's code yet", } /** What to do about some refusals the bot can carry on after. */ const HINTS: Record = { SESSION_SETUP: "the wallet hasn't approved this token for the session: npm run setup -- setup", SESSION_MAX_DAY: "the session's day cap is used up; it frees up as the 24 hours roll on", MAX_NOTIONAL: "over the SDK's backstop, a little above limits.max_trade_usd", INSUFFICIENT_FUNDS: 'send the wallet (or in session mode the executor, for gas) more ETH', } /** A problem the person running the bot has to fix: shown as a message, not a stack trace. */ export class Stop extends Error {} // --- settings ---------------------------------------------------------------------------------------------------------- export type Config = { tokens: string[] intervalS: number dryRun: boolean mode: 'session' | 'direct' keyDir: string apiUrl?: string // undefined: ORBLIVION_API_URL, else https://api.orblivion.com stopFile: string maxTradeUsd: number maxDayUsd: number session: { maxTradeUsd: number; maxDayUsd: number; maxTrades: number; hours: number; setupBudgetUsd?: number } } const isNum = (x: unknown): x is number => typeof x === 'number' && Number.isFinite(x) /** config.json, checked: a setting that doesn't make sense stops the bot before it does anything. */ export function loadConfig(file: string): Config { const raw = JSON.parse(fs.readFileSync(file, 'utf8')) const bot = raw.bot ?? {} const limits = raw.limits ?? {} const session = raw.session ?? {} const keyDir = String(bot.key_dir ?? '~/.orblivion-bot') const cfg: Config = { tokens: bot.tokens ?? ['ETH', 'USDG'], intervalS: bot.interval_s ?? 60, dryRun: bot.dry_run ?? true, mode: bot.mode ?? 'session', keyDir: path.resolve(keyDir.startsWith('~') ? path.join(os.homedir(), keyDir.slice(1)) : keyDir), apiUrl: bot.api_url || undefined, stopFile: bot.stop_file ?? 'STOP', maxTradeUsd: limits.max_trade_usd ?? 10, maxDayUsd: limits.max_day_usd ?? 40, session: { maxTradeUsd: session.max_trade_usd ?? 10, maxDayUsd: session.max_day_usd ?? 40, maxTrades: session.max_trades ?? 200, hours: session.hours ?? 168, setupBudgetUsd: session.setup_budget_usd ?? undefined, }, } const problems: string[] = [] if (typeof cfg.dryRun !== 'boolean') problems.push('dry_run is true or false (no quotes)') if (cfg.mode !== 'session' && cfg.mode !== 'direct') problems.push('mode is "session" or "direct"') const t = cfg.tokens if (!Array.isArray(t) || t.length < 2 || !t.every((x) => typeof x === 'string' && x) || new Set(t).size !== t.length) { problems.push('tokens is a list of at least two different tokens, e.g. ["ETH", "USDG"]') } if (!isNum(cfg.intervalS) || cfg.intervalS < 5) problems.push('interval_s is a number of seconds, at least 5') if (!isNum(cfg.maxTradeUsd) || cfg.maxTradeUsd < MIN_TRADE_USD) problems.push('limits.max_trade_usd is a number of dollars, at least 1') if (!isNum(cfg.maxDayUsd) || cfg.maxDayUsd < cfg.maxTradeUsd) problems.push('limits.max_day_usd is at least limits.max_trade_usd') const s = cfg.session if (![s.maxTradeUsd, s.maxDayUsd, s.maxTrades, s.hours].every(isNum)) { problems.push('session.max_trade_usd, max_day_usd, max_trades and hours are numbers') } else if (!(s.hours > 0 && s.hours <= 720)) problems.push('session.hours is above 0 and at most 720 (30 days)') if (problems.length) throw new Stop(`${file}:\n ${problems.join('\n ')}`) return cfg } // --- keys and the session file (in key_dir, outside this folder) -------------------------------------------------------- export const keyPath = (cfg: Config, name: string) => path.join(cfg.keyDir, `${name}.json`) /** The key in /.json, as a viem account. The key itself is never printed or logged. */ export function loadAccount(cfg: Config, name: string): PrivateKeyAccount { const file = keyPath(cfg, name) if (!fs.existsSync(file)) throw new Stop(`No ${name} key in ${cfg.keyDir}: run npm run setup first.`) return privateKeyToAccount(JSON.parse(fs.readFileSync(file, 'utf8')).private_key as Hex) } export type SavedSession = { session_id: string wallet: string executor: string expires: number tokens: string[] setup_budget_usd?: number setup?: boolean revoked?: boolean [k: string]: unknown } export const sessionPath = (cfg: Config) => path.join(cfg.keyDir, 'session.json') export function loadSession(cfg: Config): SavedSession { if (!fs.existsSync(sessionPath(cfg))) throw new Stop(`No session in ${cfg.keyDir}: run npm run setup (session mode needs one).`) return JSON.parse(fs.readFileSync(sessionPath(cfg), 'utf8')) } /** * An Orblivion client for this key. maxNotionalUsd is the SDK's own backstop, a little above the bot's per-trade limit * so that a price move between the order and the SDK's check doesn't refuse an order the bot already capped. */ export const clientFor = (cfg: Config, account: PrivateKeyAccount): OrblivionClient => createOrblivionClient({ baseUrl: cfg.apiUrl, account, maxNotionalUsd: cfg.maxTradeUsd * 1.1 }) /** What the bot uses of the client (a test can stand in for it). */ export type BotClient = Pick & { wallet: Pick sessions: Pick } // --- reading prices and balances (no key needed: public chain reads) ------------------------------------------------------ /** USD prices from Chainlink, read on chain by the SDK. USDG is $1; a token without a feed is left out. */ export async function readMarket(client: Pick, tokens: string[]): Promise { const marks = await client.marks([...tokens]) const market: Market = {} for (const [sym, m] of Object.entries(marks)) if (m && tokens.includes(sym)) market[sym] = Number(m.price8) / 1e8 if (tokens.includes('WETH') && marks.ETH) market.WETH = Number(marks.ETH.price8) / 1e8 return market } /** `who`'s balances, read on chain: units, USD (null without a price) and the priced total. */ export async function readBook(client: Pick, tokens: string[], who: string, market: Market, decimals?: Record): Promise { const names = new Map(tokens.map((t) => [t.startsWith('0x') ? t.toLowerCase() : t, t])) const balances: Book['balances'] = {} let totalUsd = 0 for (const b of await client.wallet.balances([...tokens], who)) { const name = names.get(b.token) ?? b.token const units = b.balance === null || b.decimals === null ? null : Number(formatUnits(b.balance, b.decimals)) if (decimals && b.decimals !== null) decimals[name] = b.decimals const price = market[name] const usd = units === null || price === undefined ? null : units * price balances[name] = { units, usd } totalUsd += usd ?? 0 } return { balances, totalUsd } } // --- the bot's own records ------------------------------------------------------------------------------------------------ type LedgerRow = { ts: number; usd: number; dry: boolean; sell: string; buy: string } /** * What the bot has spent in the last 24 hours, in a JSON file next to it. Dry runs and live trades are counted apart, so * a dry run shows what the limits would let through, and going live starts with a clean day. */ export class Ledger { rows: LedgerRow[] = [] constructor(readonly file: string, readonly clock: () => number) { try { this.rows = JSON.parse(fs.readFileSync(file, 'utf8')).rows ?? [] } catch { this.rows = [] } } spent(dry: boolean): number { const since = this.clock() - DAY_S return this.rows.filter((r) => r.ts > since && r.dry === dry).reduce((a, r) => a + r.usd, 0) } add(usd: number, dry: boolean, sell: string, buy: string): LedgerRow { const row = { ts: this.clock(), usd, dry, sell, buy } this.rows.push(row) this.save() return row } remove(row: LedgerRow) { const i = this.rows.indexOf(row) if (i >= 0) { this.rows.splice(i, 1) this.save() } } save() { const since = this.clock() - DAY_S this.rows = this.rows.filter((r) => r.ts > since) fs.writeFileSync(this.file, JSON.stringify({ rows: this.rows }, null, 1)) } } const jsonSafe = (_k: string, v: unknown) => (typeof v === 'bigint' ? v.toString() : v) // --- the bot ------------------------------------------------------------------------------------------------------------- export type Decide = (market: Market, book: Book, state: State) => Order[] | Order | null | undefined export type BotOptions = { cfg: Config /** The executor's client in session mode, the wallet's in direct mode. */ client: BotClient /** The wallet that holds the money. */ wallet: string /** The session the executor trades in (session mode). */ sessionId?: string decide?: Decide folder?: string /** Seconds since the epoch. */ clock?: () => number sleep?: (seconds: number) => Promise out?: (line: string) => void } export class Bot { readonly cfg: Config readonly client: BotClient readonly wallet: string readonly sessionId?: string readonly decide: Decide readonly folder: string readonly clock: () => number sleep: (seconds: number) => Promise readonly out: (line: string) => void readonly ledger: Ledger state: State readonly decimals: Record = {} /** Set when Orblivion asks the bot to wait before trying again. */ waitUntil = 0 /** Why the bot must stop (a session that can't trade any more). */ stopped: string | null = null /** Set by Ctrl+C: the bot stops after the current tick. */ interrupted = false private stateWarned = false constructor(o: BotOptions) { this.cfg = o.cfg this.client = o.client this.wallet = o.wallet this.sessionId = o.sessionId this.decide = o.decide ?? strategyDecide this.folder = o.folder ?? HERE this.clock = o.clock ?? (() => Date.now() / 1000) this.sleep = o.sleep ?? ((s) => new Promise((r) => setTimeout(r, s * 1000))) this.out = o.out ?? ((line) => console.log(line)) this.ledger = new Ledger(path.join(this.folder, 'bot-ledger.json'), this.clock) this.state = this.loadState() } get sessionMode() { return this.cfg.mode === 'session' } /** Each line to the console, and as JSON to bot-log.jsonl. */ log(kind: string, msg: string, data: Record = {}) { const ts = this.clock() fs.appendFileSync(path.join(this.folder, 'bot-log.jsonl'), JSON.stringify({ ts: Math.round(ts * 1000) / 1000, kind, msg, ...data }, jsonSafe) + '\n') this.out(`${new Date(ts * 1000).toTimeString().slice(0, 8)} ${kind.padEnd(6)} ${msg}`) } // -- one tick ------------------------------------------------------------------------------------------------------- async tick() { if (this.clock() < this.waitUntil) { this.log('wait', `waiting ${Math.round(this.waitUntil - this.clock())} s more, as Orblivion asked`) return } let market: Market let book: Book try { market = await readMarket(this.client, this.cfg.tokens) book = await readBook(this.client, this.cfg.tokens, this.wallet, market, this.decimals) } catch (e) { this.error('reading prices and balances', e) // an RPC or network failure: try again next tick return } const prices = Object.entries(market).map(([k, v]) => `${k} $${v.toFixed(2)}`).join(', ') const held = Object.entries(book.balances).filter(([, v]) => v.units !== null).map(([k, v]) => `${k} ${+v.units!.toPrecision(6)}`).join(', ') this.log('tick', `${prices || 'no prices'} | ${held || 'no balances'} | $${book.totalUsd.toFixed(2)}`, { market, book }) let orders: unknown[] = [] try { const got = this.decide(market, book, this.state) ?? [] orders = Array.isArray(got) ? got : [got] } catch (e) { this.log('error', `decide raised ${(e as Error)?.name ?? 'an error'}: ${(e as Error)?.message ?? e}`) // a bug in strategy.ts mustn't stop the bot } this.saveState() if (!orders.length) this.log('hold', 'no orders') for (const order of orders) { if (this.stopped) break await this.place(order, book) } } /** An order as [sell, buy, amountUsd, reason], or null (logged) when it isn't one the bot can place. */ check(order: unknown): [string, string, number, string] | null { const o = (order ?? {}) as Partial const { sell, buy, amountUsd } = o if (!sell || !buy || !this.cfg.tokens.includes(sell) || !this.cfg.tokens.includes(buy) || sell === buy) { this.log('skip', `${JSON.stringify(order)}: sell and buy must be two different tokens from config.json's tokens`) return null } if (!isNum(amountUsd) || amountUsd <= 0) { this.log('skip', `${JSON.stringify(order)}: amountUsd must be a number above 0`) return null } return [sell, buy, amountUsd, String(o.reason ?? '')] } async place(order: unknown, book: Book) { const checked = this.check(order) if (!checked) return const [sell, buy, want, reason] = checked const dry = this.cfg.dryRun const limits: Record = { max_trade_usd: this.cfg.maxTradeUsd, 'max_day_usd left': this.cfg.maxDayUsd - this.ledger.spent(dry) } const have = book.balances[sell]?.usd if (have !== null && have !== undefined) limits[`the wallet's ${sell}`] = have * KEEP_BACK const cents = (x: number) => Math.floor(x * 100) / 100 const usd = cents(Math.min(want, ...Object.values(limits))) const cut = Object.entries(limits).filter(([, v]) => v < want && cents(v) === usd).map(([k]) => k) if (usd < MIN_TRADE_USD) { this.log('skip', `${sell} -> ${buy}: $${Math.max(usd, 0).toFixed(2)} after the limits (${cut.join(', ') || 'too small'}), under $${MIN_TRADE_USD}; asked $${want.toFixed(2)}`, { reason }) return } const note = cut.length ? ` (asked $${want.toFixed(2)}; cut to ${cut.join(', ')})` : '' this.log('order', `sell $${usd.toFixed(2)} of ${sell} for ${buy}${note}${reason ? `: ${reason}` : ''}`,{ sell, buy, usd, asked_usd: want, reason, dry_run: dry }) const row = this.ledger.add(usd, dry, sell, buy) try { if (dry) await this.dryRun(sell, buy, usd) else await this.trade(sell, buy, usd) } catch (e) { if (dry || nothingSent(e)) this.ledger.remove(row) // nothing moved: the day's limit gets it back this.error(`${dry ? 'dry run' : 'trade'} ${sell} -> ${buy}`, e) } } async dryRun(sell: string, buy: string, usd: number) { if (this.sessionMode) { // A quote: read-only. Not sessions.trade({ dryRun: true }), which has the co-signer sign a leaf that counts against // the session's day cap even though it is never sent. const q = await this.client.quote({ sell, buy, amountUsd: usd }) const out = q.best?.amountOut ?? null this.log('dry', `would sell ${this.units(q.amountIn, sell)} ${sell} for about ${this.units(out, buy)} ${buy} (${q.feeBps ?? '?'} bps fee); a quote: nothing co-signed or sent`, { sell, buy, usd, amount_in: q.amountIn, amount_out: out, fee_bps: q.feeBps }) } else { // A plan: every check the real trade gets, with nothing signed or sent. const p = await this.client.plan({ sell, buy, amountUsd: usd }) const passed = p.checks.filter((c) => c.ok).length this.log('dry', `would sell ${this.units(p.amountIn, sell)} ${sell} for about ${this.units(p.simulatedOut, buy)} ${buy}, at least ${this.units(p.minOut, buy)}; ${passed} of ${p.checks.length} checks passed; nothing signed or sent`, { sell, buy, usd, amount_in: p.amountIn, simulated_out: p.simulatedOut, min_out: p.minOut, fee_bps: p.feeBps }) } } async trade(sell: string, buy: string, usd: number) { const t = this.sessionMode ? await this.client.sessions.trade({ sessionId: this.sessionId!, sell, buy, amountUsd: usd, wallet: this.wallet as Address }) : await this.client.trade({ sell, buy, amountUsd: usd }) this.log('trade', `sold ${this.units(t.amountIn, sell)} ${sell} for ${this.units(t.amountOut, buy)} ${buy} ${EXPLORER}/tx/${t.txHash}`, { sell, buy, usd, tx_hash: t.txHash, amount_in: t.amountIn, amount_out: t.amountOut, spend: 'spend' in t ? t.spend : undefined }) } error(what: string, e: unknown) { if (e instanceof OrblivionError) { const hint = HINTS[e.code] this.log('error', `${what}: ${e.code}: ${e.message}${hint ? ` (${hint})` : ''}`, { code: e.code, retryable: e.retryable, retry_after: e.retryAfter }) if (SESSION_OVER[e.code] && this.sessionMode) { const fix = e.code === 'SESSION_CODE' ? 'npm run setup -- setup' : 'npm run setup -- session, for a new session' this.stopped = `${SESSION_OVER[e.code]} (${e.code}). Run ${fix}.` } else if (e.retryable) { this.waitUntil = this.clock() + (e.retryAfter || Math.max(this.cfg.intervalS, 30)) } } else { const err = e as Error this.log('error', `${what}: ${err?.name ?? 'Error'}: ${err?.message ?? String(e)} (trying again next tick)`) } } // -- the loop ------------------------------------------------------------------------------------------------------- stopFileFound() { return fs.existsSync(path.join(this.folder, this.cfg.stopFile)) } async run(once = false): Promise { const c = this.cfg this.log('start', `${c.mode} mode, ${c.dryRun ? 'DRY RUN: nothing is sent' : 'LIVE: trades are sent'}; wallet ${this.wallet}; every ${c.intervalS} s; at most $${c.maxTradeUsd} a trade and $${c.maxDayUsd} a day`) try { while (!this.stopFileFound() && !this.interrupted) { await this.tick() if (once || this.stopped) break await this.nap(c.intervalS) } if (this.interrupted) this.log('stop', 'Ctrl+C: stopped') else if (this.stopFileFound()) this.log('stop', `found ${c.stopFile}: stopping (delete it to run again)`) } finally { this.saveState() } if (this.stopped) { this.log('stop', this.stopped) return 1 } return 0 } async nap(seconds: number) { const end = this.clock() + seconds while (this.clock() < end && !this.stopFileFound() && !this.interrupted) await this.sleep(Math.min(1, end - this.clock())) } // -- helpers -------------------------------------------------------------------------------------------------------- units(atoms: bigint | null | undefined, token: string): string { const d = this.decimals[token] if (atoms === null || atoms === undefined || d === undefined) return '?' return String(+Number(formatUnits(atoms, d)).toPrecision(6)) } private loadState(): State { try { const s = JSON.parse(fs.readFileSync(path.join(this.folder, 'bot-state.json'), 'utf8')) return s && typeof s === 'object' && !Array.isArray(s) ? s : {} } catch { return {} } } private saveState() { let text: string try { text = JSON.stringify(this.state, null, 1) } catch (e) { if (!this.stateWarned) this.log('error', `state can't be saved to bot-state.json (${(e as Error).message}): keep plain values in it`) this.stateWarned = true return } fs.writeFileSync(path.join(this.folder, 'bot-state.json'), text) } } /** True when the error proves nothing was sent: a local check refused to sign, or Orblivion refused the request. */ const nothingSent = (e: unknown) => e instanceof VerificationError || (e instanceof OrblivionApiError && e.status >= 400 && e.status < 500) export async function main(argv: string[]): Promise { const at = argv.indexOf('--config') const cfg = loadConfig(at >= 0 && argv[at + 1] ? argv[at + 1]! : path.join(HERE, 'config.json')) let bot: Bot if (cfg.mode === 'session') { const s = loadSession(cfg) if (s.revoked) throw new Stop('The saved session was revoked: run npm run setup -- session for a new one.') if ((s.expires ?? 0) <= Date.now() / 1000) throw new Stop('The saved session has ended: run npm run setup -- session for a new one.') if ([...(s.tokens ?? [])].sort().join() !== [...cfg.tokens].sort().join()) { console.log(`Note: the session trades ${(s.tokens ?? []).join(', ')}; config.json lists ${cfg.tokens.join(', ')}. Orblivion refuses trades outside the session.`) } bot = new Bot({ cfg, client: clientFor(cfg, loadAccount(cfg, 'executor')), wallet: s.wallet, sessionId: s.session_id }) } else { const client = clientFor(cfg, loadAccount(cfg, 'wallet')) bot = new Bot({ cfg, client, wallet: client.address }) } process.on('SIGINT', () => { if (bot.interrupted) process.exit(130) // a second Ctrl+C: out now bot.interrupted = true // the first: finish this tick, then stop }) return bot.run(argv.includes('--once')) } const invoked = process.argv[1] ? path.resolve(process.argv[1]) : '' const self = fileURLToPath(import.meta.url) if (invoked && (process.platform === 'win32' ? invoked.toLowerCase() === self.toLowerCase() : invoked === self)) { main(process.argv.slice(2)).then( (code) => { process.exitCode = code }, (e) => { console.error(e instanceof Stop ? e.message : e) process.exitCode = 1 }, ) }