/** * One-time setup for the starter bot, and its off switch. * * npm run setup the whole setup: keys, funding, and in session mode the session * npm run setup -- keys make the keys (the wallet, and in session mode the executor) in key_dir * npm run setup -- fund say what to send where, and wait until it arrives (Ctrl+C stops waiting) * npm run setup -- session grant a session from config.json's session caps, and send its setup * npm run setup -- setup send the session's setup again (after SESSION_CODE or SESSION_SETUP) * npm run status balances, the session, and what its caps have left * npm run revoke the off switch: end the session at Orblivion at once, then on chain * npm run reset-code the emergency switch: make the wallet a plain account again (no smart-account code) * * The keys are made on this computer, in key_dir (config.json; default ~/.orblivion-bot), outside this folder, and are * never printed. Nothing is sent on chain unless you type SEND. * * session.json keeps the session's revocation record (no secret: the delegation the wallet signed, its signature and * what the setup approved). With it, revoke builds the on-chain steps here and sends them straight to Robinhood Chain, so * it ends the session on chain even when Orblivion doesn't answer. That needs an SDK with revocationRecord; with an older * one, revoke still works, with the on-chain steps from Orblivion's answer. */ import fs from 'node:fs' import path from 'node:path' import { createInterface } from 'node:readline/promises' import * as sdk from '@orblivion/sdk' import { OrblivionError, type OrblivionClient } from '@orblivion/sdk' import { generatePrivateKey } from 'viem/accounts' import { EXPLORER, HERE, Ledger, Stop, clientFor, keyPath, loadAccount, loadConfig, loadSession, readBook, readMarket, sessionPath, type Config, type SavedSession } from './bot.ts' const GAS_USD = 2 // what the executor should hold for gas: a session trade costs about 2 cents const MIN_GAS_USD = 1 // the executor counts as funded from here const MIN_WALLET_USD = 2 // the wallet counts as funded from here const MIN_WALLET_ETH_USD = 0.25 // the wallet's own ETH for gas (the setup, or every trade in direct mode) const UPDATE_SDK = 'update the SDK for a revoke that needs nothing from Orblivion: npm install @orblivion/sdk@latest' /** Nothing is sent unless SEND is typed. */ async function confirm(...lines: string[]) { console.log() for (const line of lines) console.log(line) const rl = createInterface({ input: process.stdin, output: process.stdout }) const answer = await rl.question('Type SEND to go ahead: ') rl.close() if (answer.trim() !== 'SEND') throw new Stop('Not sent.') } const tx = (h: string) => `${EXPLORER}/tx/${h}` const when = (ts: number) => new Date(ts * 1000).toLocaleString() const sleep = (s: number) => new Promise((r) => setTimeout(r, s * 1000)) const names = (cfg: Config) => (cfg.mode === 'session' ? ['wallet', 'executor'] : ['wallet']) // --- keys ---------------------------------------------------------------------------------------------------------------- /** A new key in /.json, readable only by you where the system allows it. Never replaces a key. */ function makeKey(cfg: Config, name: string): boolean { fs.mkdirSync(cfg.keyDir, { recursive: true, mode: 0o700 }) const file = keyPath(cfg, name) if (fs.existsSync(file)) return false fs.writeFileSync(file, JSON.stringify({ private_key: generatePrivateKey() }), { flag: 'wx', mode: 0o600 }) return true } async function keys(cfg: Config) { for (const name of names(cfg)) { const made = makeKey(cfg, name) console.log(`${name.padEnd(9)} ${loadAccount(cfg, name).address} (${made ? 'new' : 'already there'})`) } console.log(`\nThe keys are in ${cfg.keyDir}. They never leave this computer and are never printed.`) console.log('Anyone with wallet.json controls the wallet: never share or commit that folder, and back it up.') } // --- funding ------------------------------------------------------------------------------------------------------------- async function fund(cfg: Config, wait = true): Promise { const wallet = loadAccount(cfg, 'wallet') const client = clientFor(cfg, wallet) const session = cfg.mode === 'session' const executor = session ? loadAccount(cfg, 'executor').address : null console.log(`\nSend the wallet ${wallet.address}`) console.log(` what the bot trades (${cfg.tokens.join(', ')}), plus about $1 of ETH for ${session ? 'its session setup.' : 'gas: each trade costs about a cent.'}`) console.log(' Use a wallet that holds only what the bot trades.') if (executor) { console.log(`Send the executor ${executor}`) console.log(` about $${GAS_USD} of ETH: it pays the gas of every trade (about 2 cents each). It can't move the wallet's money.`) } console.log('Both are on Robinhood Chain (chain 4663).\n') for (;;) { try { const market = await readMarket(client, cfg.tokens) const book = await readBook(client, cfg.tokens, wallet.address, market) const gas = executor ? (await readBook(client, ['ETH'], executor, market)).totalUsd : 0 const held = Object.entries(book.balances).filter(([, v]) => v.units !== null).map(([k, v]) => `${k} ${+v.units!.toPrecision(6)}`).join(', ') const ethUsd = book.balances.ETH?.usd ?? 0 console.log(`${new Date().toTimeString().slice(0, 8)} wallet $${book.totalUsd.toFixed(2)} (${held})${executor ? ` | executor $${gas.toFixed(2)} of ETH` : ''}`) if (book.totalUsd >= MIN_WALLET_USD && ethUsd >= MIN_WALLET_ETH_USD && (!executor || gas >= MIN_GAS_USD)) { console.log('Funded.') return true } } catch (e) { console.log(`Couldn't read balances (${e instanceof OrblivionError ? `${e.code}: ${e.message}` : (e as Error).message}); trying again.`) } if (!wait) return false await sleep(15) } } // --- the session ----------------------------------------------------------------------------------------------------------- function savedSession(cfg: Config): SavedSession | null { return fs.existsSync(sessionPath(cfg)) ? JSON.parse(fs.readFileSync(sessionPath(cfg), 'utf8')) : null } const saveSession = (cfg: Config, s: SavedSession) => fs.writeFileSync(sessionPath(cfg), JSON.stringify(s, null, 1)) type Steps = { steps: { kind: string; txHash: string; sent: boolean; status?: string | null }[] } function showSteps(r: Steps) { for (const step of r.steps) console.log(` ${step.kind}: ${step.sent ? tx(step.txHash) : 'not sent'}${step.status && step.status !== 'ok' ? ` (${step.status})` : ''}`) if (!r.steps.length) console.log(' nothing to send') } function markSetup(cfg: Config) { const s = savedSession(cfg) if (s) saveSession(cfg, { ...s, setup: true }) } /** The session's revocation record (the SDK's revocationRecord(grant)), or null on an SDK without it. */ function revocationRecord(grant: unknown): unknown { const fn = (sdk as Record).revocationRecord if (typeof fn !== 'function') return null try { return fn(grant) } catch { return null } } async function session(cfg: Config) { if (cfg.mode !== 'session') throw new Stop('config.json says mode "direct": there is no session to grant.') const old = savedSession(cfg) if (old && !old.revoked && (old.expires ?? 0) > Date.now() / 1000) { throw new Stop(`Session ${old.session_id} is active until ${when(old.expires)}. To replace it, end it first: npm run revoke`) } const s = cfg.session const owner = clientFor(cfg, loadAccount(cfg, 'wallet')) const executor = loadAccount(cfg, 'executor').address const budget = s.setupBudgetUsd ?? s.maxDayUsd console.log(`Granting a session: the executor ${executor} may trade ${cfg.tokens.join(', ')} from the wallet ${owner.address}:`) console.log(` $${s.maxTradeUsd} a trade, $${s.maxDayUsd} a day, ${s.maxTrades} trades, for ${s.hours} hours; at most $${budget} of each ERC-20 it sells, over the whole session.`) const grant = await owner.sessions.grant({ executor, tokens: cfg.tokens, maxTradeUsd: s.maxTradeUsd, maxDayUsd: s.maxDayUsd, maxTrades: Math.trunc(s.maxTrades), hours: s.hours, setupBudgetUsd: budget, allow7702: true, // your consent to set a plain wallet's smart-account code (see README) requireDayCap: cfg.tokens.includes('ETH'), // the chain caps ETH per 24 hours too }) const lim = grant.limits console.log(`\nSession ${grant.sessionId}, until ${when(grant.expires)}`) console.log(`Caps $${lim.maxTradeUsd} a trade, $${lim.maxDayUsd} a day, ${lim.maxTrades} trades, ${lim.tokens.join(', ')} only`) console.log(`Checked ${grant.checks.filter((c) => c.ok).length} of ${grant.checks.length} checks passed before signing`) for (const x of grant.exposure ?? []) console.log(`Note the wallet has an approval outside the session; consider revoking it: ${JSON.stringify(x)}`) const record = revocationRecord(grant) saveSession(cfg, { session_id: grant.sessionId, wallet: owner.address.toLowerCase(), executor: executor.toLowerCase(), expires: grant.expires, tokens: [...lim.tokens], max_trade_usd: lim.maxTradeUsd, max_day_usd: lim.maxDayUsd, max_trades: lim.maxTrades, setup_budget_usd: budget, setup: false, revocation_record: record, }) console.log(`Saved ${sessionPath(cfg)}${record ? ', with the session\'s revocation record' : ''}`) if (!record) console.log(`Note this SDK keeps no revocation record, so revoke will take its on-chain steps from Orblivion: ${UPDATE_SDK}`) if (grant.setup.length || grant.authorizationSigned) { await confirm('The setup sends transactions from the wallet:', " - on a plain wallet, one that sets its code to MetaMask's EIP7702StatelessDeleGator (EIP-7702), which sessions run on. The key stays the owner; an authorization to address 0 undoes it;", ` - for each ERC-20 it sells, an approval of at most $${budget} through Permit2.`) showSteps(await owner.sessions.setup(grant)) } markSetup(cfg) console.log('\nDone. Next: npm run bot (a dry run until you set "dry_run": false in config.json).') } async function setup(cfg: Config) { const s = loadSession(cfg) const owner = clientFor(cfg, loadAccount(cfg, 'wallet')) await confirm(`Send session ${s.session_id}'s setup from the wallet: its smart-account code if it's still a plain wallet, then any approval the session is missing.`) const d = await owner.wallet.delegateCode() console.log(`Code ${d.status}${d.txHash && d.status !== 'delegated' ? ` ${tx(d.txHash)}` : ''}`) // each approval is held to what the wallet signed, and to the budget it was granted with showSteps(await owner.sessions.setupSteps({ sessionId: s.session_id, setupBudgetUsd: s.setup_budget_usd })) markSetup(cfg) } async function status(cfg: Config) { const owner: OrblivionClient = clientFor(cfg, loadAccount(cfg, 'wallet')) const market = await readMarket(owner, cfg.tokens) const book = await readBook(owner, cfg.tokens, owner.address, market) console.log(`Wallet ${owner.address} $${book.totalUsd.toFixed(2)}`) for (const [k, v] of Object.entries(book.balances)) { console.log(` ${k.padEnd(6)} ${(v.units === null ? '?' : String(+v.units.toPrecision(8))).padEnd(14)} ${v.usd === null ? 'unpriced' : `$${v.usd.toFixed(2)}`}`) } for (const dry of [false, true]) { const spent = new Ledger(path.join(HERE, 'bot-ledger.json'), () => Date.now() / 1000).spent(dry) console.log(`Bot $${spent.toFixed(2)} of $${cfg.maxDayUsd} used in the last 24 h (${dry ? 'dry runs' : 'live'})`) } if (cfg.mode !== 'session') return const s = savedSession(cfg) if (!s) { console.log('Session none yet: npm run setup -- session') return } const gas = (await readBook(owner, ['ETH'], s.executor, market)).totalUsd console.log(`Executor ${s.executor} $${gas.toFixed(2)} of ETH for gas${gas >= MIN_GAS_USD ? '' : ' (top it up)'}`) console.log(`Session ${s.session_id}, until ${when(s.expires)}${s.revoked ? ' REVOKED' : ''}`) let live try { live = (await owner.me.sessions()).sessions.find((x) => String(x.sessionId) === String(s.session_id)) } catch (e) { if (!(e instanceof OrblivionError)) throw e console.log(` couldn't read it from Orblivion: ${e.code}: ${e.message}`) return } if (!live) { console.log(' not active at Orblivion (revoked or ended)') return } console.log(` ${live.status}; ${live.tradesUsed} trades used, ${live.tradesLeft} left; $${Number(live.last24hUsd ?? 0).toFixed(2)} in the last 24 h, $${Number(live.remaining24hUsd ?? 0).toFixed(2)} left today`) } type RevokeOutcome = Steps & { status: string; effective?: string | null; apiError?: string | null; onChain?: { status: string; detail: string[]; fromRecord?: string; signedLive?: string[] } } async function revoke(cfg: Config, chainOnly = false) { const s = loadSession(cfg) const owner = clientFor(cfg, loadAccount(cfg, 'wallet')) const record = s.revocation_record const local = Boolean(record) && typeof (owner.sessions as unknown as Record).disableOnchain === 'function' if (chainOnly) return revokeOnChain(cfg, s, owner, record, local) const how = local ? 'They are built on this computer from session.json and sent straight to Robinhood Chain, even if Orblivion doesn\'t answer.' : record ? `This SDK takes them from Orblivion's answer, so they need Orblivion to answer: ${UPDATE_SDK}` : 'session.json holds no revocation record (the session was granted with an older SDK), so they come from Orblivion\'s answer and need it to answer.' await confirm(`End session ${s.session_id} now: Orblivion stops co-signing at once, then transactions from the wallet end it on chain (the delegation disabled, the setup's approvals back to 0).`, how) // the record goes in only on an SDK that takes it: an older one's revoke has no such option const opts: Record = local ? { sessionId: s.session_id, record } : { sessionId: s.session_id } const r = (await owner.sessions.revoke(opts as { sessionId: string })) as unknown as RevokeOutcome const atOrblivion = !r.apiError && r.status === 'revoked' console.log(r.apiError ? `Orblivion's own revoke failed (${r.apiError})` : `Revoked at Orblivion: ${r.effective || r.status}`) showSteps(r) const status = r.onChain?.status // everything the record holds reads done, and only Orblivion's answer (another root it names) stands in the way const disputed = status === 'unconfirmed' && r.onChain?.fromRecord === 'confirmed' if (status === 'confirmed') console.log('On chain confirmed: the delegation reads as disabled and the approvals as 0, on every RPC') else if (status === 'server_reported') console.log('On chain sent, and read back as Orblivion named it: which roots were disabled is Orblivion\'s word (session.json holds no revocation record to check it)') else if (status) console.log(`On chain ${status}: ${r.onChain!.detail.join('; ')}`) const ended = status === 'confirmed' || (r.onChain === undefined && atOrblivion) // an SDK without the read-back: as before if (ended || atOrblivion || status === 'server_reported') saveSession(cfg, { ...s, revoked: true }) if (ended) console.log('The bot can\'t trade in it any more. For a new session: npm run setup -- session') else if (status === 'server_reported') { console.log(`Orblivion co-signs nothing more for it, so the bot can't trade in it, and the steps it named read back done. Running revoke again can't check it better: ${record ? UPDATE_SDK : 'session.json holds no revocation record (the session was granted with an older SDK), so which roots ended is Orblivion\'s word. Sessions granted now keep one.'}`) } else if (r.onChain?.signedLive?.length && local) { console.log(`${atOrblivion ? 'Orblivion co-signs nothing more for it, so the bot can\'t trade in it. ' : ''}On chain, Orblivion's answer carries a root your wallet signed that still reads live (${r.onChain.signedLive.map((h) => `${h.slice(0, 10)}…`).join(', ')}): session.json is most likely an older session's record, and that root this session's own.`) await disableSigned(cfg, s, owner, record) } else if (disputed) { console.log(`${atOrblivion ? 'Orblivion co-signs nothing more for it, so the bot can\'t trade in it. ' : ''}On chain, everything session.json holds reads done on every RPC; only Orblivion's answer stands in the way (it names a root session.json doesn't hold, and none your wallet signed). Running revoke again won't change that. If session.json is this session's own record, take the chain's word from it alone: npm run revoke -- --chain-only`) } else if (atOrblivion) console.log('Orblivion co-signs nothing more for it, so the bot can\'t trade in it; the chain doesn\'t confirm it ended yet: run npm run revoke again.') else console.log('Neither Orblivion nor the chain confirms the session ended: run npm run revoke again.') } /** The roots Orblivion's answer carries that the wallet signed and that read live, disabled too (behind SEND): the wallet's * signature proves a root is the wallet's, not which of its sessions it is. */ async function disableSigned(cfg: Config, s: ReturnType, owner: ReturnType, record: unknown) { await confirm('Disable that root too: one more transaction from the wallet, straight to Robinhood Chain.', 'Your wallet signed it, so it is yours. If it is another session of yours that you still use, don\'t: this would end that one too.') const r = (await owner.sessions.revoke({ sessionId: s.session_id, record, disableSigned: true } as { sessionId: string })) as unknown as RevokeOutcome showSteps(r) if (!r.onChain?.signedLive?.length && r.onChain?.fromRecord === 'confirmed') { console.log('On chain every root of session.json and every root your wallet signed that Orblivion named reads disabled, and the approvals 0, on every RPC') saveSession(cfg, { ...s, revoked: true }) console.log('The bot can\'t trade in it any more. For a new session: npm run setup -- session') } else { console.log(`On chain ${r.onChain?.status}: ${(r.onChain?.detail ?? []).join('; ')}`) console.log('Not everything reads done yet: run npm run revoke again.') } } /** revoke --chain-only: the on-chain steps from session.json's revocation record alone (disableOnchain), and the chain's * answer read from it alone. Orblivion isn't asked anything. */ async function revokeOnChain(cfg: Config, s: ReturnType, owner: ReturnType, record: unknown, local: boolean) { if (!local) { throw new Stop(`revoke --chain-only ends the session from session.json's revocation record alone, so it needs ${record ? `an SDK that can: ${UPDATE_SDK}` : 'a record, and session.json holds none (the session was granted with an older SDK): npm run revoke'}`) } await confirm(`End session ${s.session_id} on chain from session.json alone: transactions from the wallet disable its delegation and set the setup's approvals back to 0, sent straight to Robinhood Chain.`, 'Orblivion isn\'t asked anything, so it isn\'t told either: the disabled delegation refuses every trade on chain anyway.') const r = (await (owner.sessions as unknown as { disableOnchain: (rec: unknown) => Promise }).disableOnchain(record)) as RevokeOutcome showSteps(r) if (r.onChain?.status === 'confirmed') { console.log('On chain confirmed from session.json alone: the delegation reads as disabled and the approvals as 0, on every RPC') saveSession(cfg, { ...s, revoked: true }) console.log('The bot can\'t trade in it any more. For a new session: npm run setup -- session') } else { console.log(`On chain ${r.onChain?.status}: ${(r.onChain?.detail ?? []).join('; ')}`) console.log('The chain doesn\'t confirm the session ended yet: run npm run revoke -- --chain-only again.') } } /** The emergency switch: the wallet's own transaction to itself that sets its code back to none. */ async function resetCode(cfg: Config) { const owner = clientFor(cfg, loadAccount(cfg, 'wallet')) const st = await owner.wallet.state() if (!st.delegatedTo) { console.log(`The wallet ${owner.address} runs no smart-account code: there is nothing to reset.`) return } await confirm('The emergency switch: make the wallet a plain account again. One transaction from the wallet to itself (an EIP-7702 authorization to address 0), sent straight to Robinhood Chain, not through Orblivion.', 'What it does: the wallet runs no code any more, so no session can move anything from it. Every session trade fails on chain, whatever Orblivion or the executor do. Your key still controls the wallet, and your own transactions work as before.', 'What it doesn\'t: the session isn\'t revoked (Orblivion still co-signs for it, and its delegation isn\'t disabled on chain), and the approvals stay (only the wallet\'s own transactions can use them). If the wallet\'s code is set again (npm run setup -- setup, or a new session), the old session works again until it expires: run npm run revoke as well.') const r = (await owner.wallet.resetCode()) as { status: string; txHash: string | null; codeAfter?: string } console.log(`Code ${r.status}${r.txHash ? ` ${tx(r.txHash)}` : ''}`) if (r.status === 'reset') console.log('The wallet is a plain account again. To end the session as well: npm run revoke') else if (r.status === 'check') console.log(`Sent, but the wallet's code still reads ${r.codeAfter}: check the transaction before sending anything else`) } async function everything(cfg: Config) { await keys(cfg) await fund(cfg) if (cfg.mode === 'session') { console.log() await session(cfg) } else { console.log('\nDone. Next: npm run bot (a dry run until you set "dry_run": false in config.json).') } } const acts: Record Promise> = { all: everything, keys, fund: (cfg) => fund(cfg), session, setup, status, revoke: (cfg) => revoke(cfg, process.argv.slice(3).includes('--chain-only')), 'reset-code': resetCode } const cmd = process.argv[2] ?? 'all' if (!acts[cmd]) { console.log('npm run setup [-- keys | fund | session | setup | status | revoke [--chain-only] | reset-code]') process.exitCode = 1 } else { try { await acts[cmd]!(loadConfig(path.join(HERE, 'config.json'))) } catch (e) { if (e instanceof Stop) console.error(e.message) else if (e instanceof OrblivionError) console.error(`\nRefused: ${e.code}: ${e.message}`) else throw e process.exitCode = 1 } }