/** * An AI trading agent on Orblivion. You write its strategy in plain words, in strategy.txt. On every tick a model on * Orbio's gateway reads it with the agent's market view and book and decides; Orblivion checks every answer before * anything trades. Orblivion's own Orbio credits pay for the model (funding "orblivion"), so you need no LLM key. * * npm run keys make the wallet's key in keyDir (never printed) and show its address * npm run options the server's strategy templates and models, and who pays for this wallet * npm run paper [-- MINUTES] create the agent on paper (no money), then watch it * npm run watch [-- MINUTES] its decisions, trades and model spend as they come * npm run live [-- MINUTES] after you type SEND: a live agent, its session, the setup and the executor's gas * npm run status the agents, today's model spend, and the executor's gas * npm run off after you type SEND: stop them, end the session, get the executor's gas back * * Watching lasts 30 minutes unless you give another number; Ctrl+C stops it sooner. The agent runs on Orblivion's * servers whether you watch or not: `off` stops it. The key stays in keyDir (default ~/.orblivion-ai-agent), outside * this folder, and is never printed. Nothing that moves money happens unless you type SEND. * * agent.json keeps the live session's revocation record (no secret: the delegation the wallet signed, its signature and * what the setup approved). With it, `off` builds the session's on-chain revocation here and sends it straight to * Robinhood Chain, so the session ends on chain even when Orblivion doesn't answer. That needs an SDK with * revocationRecord; with an older one, `off` still works, with the on-chain steps from Orblivion's answer. */ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { createInterface } from 'node:readline/promises' import { fileURLToPath } from 'node:url' import * as sdk from '@orblivion/sdk' import { OrblivionError, aiParams, createOrblivionClient, type HostedAgent, type HostedLogRow, type OrblivionClient } from '@orblivion/sdk' import { getAddress, type Hex } from 'viem' import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts' const HERE = path.dirname(fileURLToPath(import.meta.url)) const EXPLORER = 'https://robin.etherscan.io' const CHAIN_ID = 4663 // Robinhood Chain const UPDATE_SDK = 'update the SDK for a revoke that needs nothing from Orblivion: npm install @orblivion/sdk@latest' // --- settings: your strategy is in strategy.txt; these say how the agent runs it --------------------------------------- export const SETTINGS = { name: 'ai-agent', pairs: ['ETH-USDG'], // what it may trade, against USDG (up to 6 pairs) model: 'anthropic/claude-haiku-5.5', // Orblivion's credits pay for the cheap models: see `options` template: 'trend', // the server's template strategy.txt started from intervalS: 900, // it thinks every 15 minutes, the fastest Orblivion's credits pay for budgetUsdDay: 0.25, // the most its model calls may cost in a UTC day maxOrders: 2, // orders in one answer (a live agent makes at most one trade a tick) limits: { maxTradeUsd: 10, maxDayUsd: 40, maxPositionUsd: 100 }, // checked on every order paperBook: { USDG: 50, ETH: 0.02 } as Record, // what the paper agent starts with session: { maxTrades: 100, hours: 168 }, // live: what the wallet signs (per trade and per day: the limits) maxGasUsd: 3, // live: the most this script sends the executor for gas keyDir: '~/.orblivion-ai-agent', // the wallet's key and agent.json, outside this folder apiUrl: '', // empty: ORBLIVION_API_URL if set, else https://api.orblivion.com } class Stop extends Error {} type Saved = { paper?: { id: string; off?: boolean }; live?: { id: string; session_id: string; executor: string; revocation_record?: unknown; stopped?: boolean; off?: boolean; refunded?: boolean } } // --- the key, the client and what the script remembers (all in keyDir) ---------------------------------------------- const keyDir = () => path.resolve(SETTINGS.keyDir.replace(/^~(?=$|[\\/])/, os.homedir())) function loadWallet(): PrivateKeyAccount { const file = path.join(keyDir(), 'wallet.json') if (!fs.existsSync(file)) throw new Stop(`No wallet key in ${keyDir()}: run npm run keys first.`) return privateKeyToAccount(JSON.parse(fs.readFileSync(file, 'utf8')).private_key as Hex) } /** An Orblivion client for the wallet. It signs in by itself on its first call. */ const clientFor = (account: PrivateKeyAccount): OrblivionClient => createOrblivionClient({ baseUrl: SETTINGS.apiUrl || undefined, account }) function saved(): Saved { try { return JSON.parse(fs.readFileSync(path.join(keyDir(), 'agent.json'), 'utf8')) } catch { return {} } } const save = (state: Saved) => fs.writeFileSync(path.join(keyDir(), 'agent.json'), JSON.stringify(state, null, 1)) /** strategy.txt without its # lines: your words, as the model gets them. */ function readStrategy(): string { const lines = fs.readFileSync(path.join(HERE, 'strategy.txt'), 'utf8').split(/\r?\n/).filter((x) => !x.trimStart().startsWith('#')) const text = lines.join('\n').trim() const n = text.split('\n').length if (text.length < 1 || text.length > 1500 || n > 40) { throw new Stop(`strategy.txt has ${text.length} characters on ${n} lines without its # lines: it must be 1 to 1,500 characters, at most 40 lines.`) } return text } /** The AI brain's settings. funding "orblivion": Orblivion's credits pay; fallbackOwn false: when today's share is used * up, the agent waits for tomorrow's instead of asking for your own Orbio balance. */ const params = (strategy: string) => aiParams({ strategy, model: SETTINGS.model, funding: 'orblivion', fallbackOwn: false, budgetUsdDay: SETTINGS.budgetUsdDay, maxOrders: SETTINGS.maxOrders, template: SETTINGS.template }) /** Nothing that moves money happens 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('Nothing was sent.') } const tx = (h: string) => `${EXPLORER}/tx/${h}` const pad2 = (n: number) => String(n).padStart(2, '0') const clock = (ts: number) => { const d = new Date(ts * 1000); return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}` } const when = (ts: number) => new Date(ts * 1000).toLocaleString() const sleep = (s: number) => new Promise((r) => setTimeout(r, s * 1000)) /** Dollars as people read them: $0.10, and small amounts to the tenth of a cent ($0.0009). */ const usd = (x: unknown) => { const n = Number(x ?? 0); return `$${n.toFixed(n === 0 || n >= 0.01 ? 2 : 4)}` } const lim = SETTINGS.limits // --- keys and options ------------------------------------------------------------------------------------------------- /** A new key in /wallet.json, readable only by you where the system allows it. Never replaces one. */ async function keys() { fs.mkdirSync(keyDir(), { recursive: true, mode: 0o700 }) const file = path.join(keyDir(), 'wallet.json') const made = !fs.existsSync(file) if (made) fs.writeFileSync(file, JSON.stringify({ private_key: generatePrivateKey() }), { flag: 'wx', mode: 0o600 }) console.log(`Wallet ${loadWallet().address} (${made ? 'new' : 'already there'})`) console.log(`\nThe key is in ${file}. It never leaves this computer and is never printed: don't share or commit it.`) console.log(`Paper needs no money. To go live, send this wallet (Robinhood Chain, 4663) what the agent may trade, in ETH and USDG, plus about $${SETTINGS.maxGasUsd + 1} of ETH for the setup and the executor's gas.`) } /** Who pays for this wallet's model calls, from Orblivion's answer (GET /api/v1/hosted/ai). */ function whoPays(o: Record): string[] { if (!o.available) return [" Orblivion's credits aren't on on this server: its AI agents can't think today."] const out = [` Today's pool ${usd(o.pool_usd)}, ${usd(o.pool_left_usd)} left; it pays for ${(o.models ?? []).join(', ')}, thinking every ${Math.floor((o.min_interval_s ?? 900) / 60)} minutes or slower`] if (o.trial_open) { const until = o.trial_until ? `until ${new Date(o.trial_until * 1000).toDateString()}` : `for ${o.trial_days} days from your first AI agent` out.push(` Trial open ${until}: ${usd(o.trial_share_usd)} a day for this wallet's agents, paper or live`) } else if (o.trial_agents_only && !o.orbio_agent) { out.push(" Trial not for this wallet: the paper trial is for wallets that own an Orbio agent, or are one's agent wallet") } else { out.push(` Trial over: it lasts ${o.trial_days} days from a wallet's first AI agent`) } const share = Number(o.live_share_usd ?? 0) out.push(` Live share ${usd(share)} a day` + (share ? ', backed by the trading fees this wallet paid' : `: it takes ${usd(o.live_min_usd ?? 5)} of trades through Orblivion in ${o.live_days ?? 14} days and fees paid on them (this wallet: ${usd(o.live_traded_usd)} of trades, ${usd(o.live_fees_usd)} of fees)`)) out.push(" With no share, or once it is used up, the agent keeps ticking but doesn't think, or trade, until 00:00 UTC.") return out } async function options() { const opts = await clientFor(loadWallet()).hosted.aiOptions() console.log("Templates (start strategy.txt from one, and set SETTINGS.template):") for (const t of opts.templates ?? []) console.log(` ${String(t.id).padEnd(20)} ${t.title}`) const pool = opts.funding?.orblivion ?? {} console.log(`\nModels (a tick every ${SETTINGS.intervalS / 60} minutes):`) for (const m of opts.models ?? []) { const est = m.estimate?.[String(SETTINGS.intervalS)] const cost = est ? `about $${Number(est.usd_per_day).toFixed(4)} a day` : 'no estimate' const who = (pool.models ?? []).includes(m.id) ? "Orblivion's credits" : 'your own Orbio balance (the Lab)' console.log(` ${String(m.id).padEnd(30)} ${cost.padEnd(22)} paid by ${who}`) } console.log('\nWho pays for this wallet\'s model calls (funding "orblivion": Orblivion\'s own Orbio credits):') console.log(whoPays(pool).join('\n')) } // --- paper ------------------------------------------------------------------------------------------------------------ async function paper(minutes = '30') { const state = saved() if (state.paper && !state.paper.off) throw new Stop(`Paper agent ${state.paper.id} already runs: npm run watch (or off, then paper again).`) const owner = clientFor(loadWallet()) const s = SETTINGS const { agent } = await owner.hosted.create({ brain: 'orbio_llm', params: params(readStrategy()), intervalS: s.intervalS, pairs: s.pairs, limits: lim, paperBook: s.paperBook, name: s.name, mode: 'paper' }) state.paper = { id: agent.id } save(state) const book = Object.entries(s.paperBook).map(([k, v]) => `${v} ${k}`).join(', ') console.log(`Agent ${agent.id} (paper: ${book} to start; no money moves)`) console.log(`Thinks every ${s.intervalS / 60} minutes with ${s.model}, at most $${s.budgetUsdDay} a day`) console.log(`Limits $${lim.maxTradeUsd} a trade, $${lim.maxDayUsd} a day, $${lim.maxPositionUsd} in any token but USDG`) pays(agent) await watch(minutes, agent.id) } /** Whether Orblivion's credits pay for this agent's model calls now, and why not. */ function pays(agent: HostedAgent) { const pool = (agent.ai as any)?.pool if (pool?.eligible) console.log(`Paid by Orblivion's credits: a ${pool.kind} share of ${usd(pool.share_usd)} today, ${usd(pool.share_left_usd)} left`) else if (pool) console.log(`Paid by nobody yet (${pool.code}): ${pool.why_not}`) } // --- watching --------------------------------------------------------------------------------------------------------- function newest(state: Saved): string { if (state.live && !state.live.off) return state.live.id if (state.paper && !state.paper.off) return state.paper.id throw new Stop('No agent running: npm run paper') } /** Prints the agent's log as it grows: each decision with its reasoning, cost and payer, each order, trade and hold. * For `minutes`, or until Ctrl+C; the agent keeps running either way. */ async function watch(minutes = '30', agentId?: string) { const id = agentId ?? newest(saved()) const c = clientFor(loadWallet()) const end = Date.now() + 60_000 * Number(minutes) let seen = 0 console.log(`\nWatching ${id} for ${Number(minutes)} minutes (Ctrl+C stops watching; the agent runs on)`) for (;;) { const rows = (await c.hosted.logs(id, { limit: 40 })).rows.filter((r) => r.id > seen).reverse() for (const r of rows) { show(r) seen = r.id } if (rows.some((r) => r.kind === 'decision')) await spent(c, id) if (Date.now() >= end) return await sleep(10) } } function show(r: HostedLogRow) { const d = r.data as Record const at = clock(r.ts) const pad = ' '.repeat(10) if (r.kind === 'decision') { const ai = d.ai ?? {} if (ai.status === 'hold') return // the model wasn't asked: the hold row says why if (ai.status && ai.status !== 'ok') { console.log(`${at} refused ${ai.code}: the model's answer broke a rule, so nothing trades this tick`) return } console.log(`${at} thinks ${d.reasoning ?? ''}`) const payer = ({ orblivion: "Orblivion's credits", own: 'your Orbio balance' } as Record)[ai.source] ?? '?' console.log(`${pad}model ${ai.model}: $${Number(ai.cost_usd ?? 0).toFixed(6)}, paid by ${payer}`) for (const o of d.orders ?? []) console.log(`${pad}order sell $${o.amount_usd} of ${o.sell} for ${o.buy}${o.reason ? `: ${o.reason}` : ''}`) } else if (r.kind === 'trade') { const where = d.tx_hash ? ` ${tx(d.tx_hash)}` : ' (paper)' console.log(`${at} trade sold ${+Number(d.amount_in).toPrecision(6)} ${d.sell} for ${+Number(d.amount_out ?? 0).toPrecision(6)} ${d.buy}${where}`) } else { const why = d.detail ?? d.reason ?? '' console.log(`${at} ${r.kind.padEnd(8)} ${d.code ?? ''}${why ? `: ${why}` : ''}`.slice(0, 240).trimEnd()) } } async function spent(c: OrblivionClient, id: string) { const ai = (await c.hosted.spend(id, { limit: 1 })).ai as Record const pool = ai.pool ?? {} let line = `${' '.repeat(10)}spend $${Number(ai.today?.usd ?? 0).toFixed(6)} today of its ${usd(ai.budget_usd_day)} budget` if (pool.share_usd != null) line += `; this wallet's share of Orblivion's credits: $${Number(pool.share_left_usd ?? 0).toFixed(6)} of ${usd(pool.share_usd)} left` console.log(line) } // --- live ------------------------------------------------------------------------------------------------------------- async function live(minutes = '30') { const state = saved() if (state.live && !state.live.off) throw new Stop(`Live agent ${state.live.id} already runs: npm run watch (or off).`) const strategy = readStrategy() const s = SETTINGS const owner = clientFor(loadWallet()) const ethUsd = Number((await owner.marks(['ETH'])).ETH!.price8) / 1e8 const tokens = [...new Set(s.pairs.flatMap((p) => p.split('-')))].sort() console.log(`Wallet ${owner.address} ${(Number(await owner.rpc.balance(owner.address)) / 1e18).toFixed(6)} ETH`) await confirm("Going live lets the agent trade this wallet's money, inside a session the wallet signs now:", ` - the session: ${tokens.join(', ')} only, $${lim.maxTradeUsd} a trade, $${lim.maxDayUsd} a day, ${s.session.maxTrades} trades, for ${s.session.hours} hours. Orblivion's co-signer and the chain enforce it on every trade;`, ` - its setup, sent from the wallet: on a plain wallet, its code set to MetaMask's EIP7702StatelessDeleGator (EIP-7702), which sessions run on, and an approval of at most $${lim.maxDayUsd} of USDG through Permit2;`, ` - up to $${s.maxGasUsd} of ETH to the agent's executor for its gas (off sends what is left back).`) if (state.paper && !state.paper.off) { await owner.hosted.pause(state.paper.id) // one share a wallet: the paper agent stops drawing on it console.log(`Paused paper agent ${state.paper.id}`) } const { agent, grant } = await owner.hosted.create({ brain: 'orbio_llm', params: params(strategy), intervalS: s.intervalS, pairs: s.pairs, limits: lim, name: s.name, mode: 'live', session: { ...s.session }, allow7702: true }) const record = revocationRecord(grant) state.live = { id: agent.id, session_id: grant!.sessionId, executor: agent.executor!, revocation_record: record } save(state) console.log(`Agent ${agent.id} (live; it waits until it is switched on)`) console.log(`Executor ${agent.executor}: Orblivion's signing service holds its key (attestation checked)`) console.log(`Session ${grant!.sessionId}, until ${when(grant!.expires)}; ${grant!.checks.filter((c) => c.ok).length} of ${grant!.checks.length} checks passed before the wallet signed`) if (!record) console.log(`Note this SDK keeps no revocation record, so off will take its on-chain steps from Orblivion: ${UPDATE_SDK}`) for (const step of (await owner.sessions.setup(grant!)).steps) console.log(`Setup ${step.kind}: ${step.sent ? tx(step.txHash) : 'not sent'}`) await fundGas(owner, agent.id, agent.executor!, ethUsd) await owner.hosted.update(agent.id, { live: true }) const a = await owner.hosted.resume(agent.id) console.log(`On ${a.status}, ${a.trading}: it thinks every ${s.intervalS / 60} minutes`) pays(await owner.hosted.get(agent.id)) await watch(minutes, agent.id) } /** 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 } } type RevokeOutcome = { status: string; effective?: string | null; apiError?: string | null; steps: { kind: string; txHash: string; sent: boolean; status?: string | null }[]; onChain?: { status: string; detail: string[]; fromRecord?: string; signedLive?: string[] } } const showSteps = (r: RevokeOutcome) => { for (const step of r.steps) console.log(` ${step.kind}: ${step.sent ? tx(step.txHash) : 'not sent'}${step.status && step.status !== 'ok' ? ` (${step.status})` : ''}`) } /** Sends the executor twice what Orblivion says it needs for gas (about ten trades' worth at the gas cap), never more * than maxGasUsd: one plain ETH transfer from the wallet, to the attested executor only. */ async function fundGas(owner: OrblivionClient, agentId: string, executor: string, ethUsd: number) { const g = await owner.hosted.executor(agentId) if (g.executor.toLowerCase() !== executor.toLowerCase()) throw new Stop(`Orblivion named executor ${g.executor}, not the attested ${executor}: nothing sent.`) const want = 2n * BigInt(g.min_balance_wei) - BigInt(g.balance_wei ?? '0') const cap = BigInt(Math.floor((SETTINGS.maxGasUsd / ethUsd) * 1e18)) const amount = want < cap ? want : cap if (amount <= 0n) { console.log('Gas the executor has enough') return } const acct = loadWallet() const to = getAddress(executor) const gas = BigInt(await owner.rpc.call('eth_estimateGas', [{ from: acct.address, to, value: `0x${amount.toString(16)}` }])) const raw = await acct.signTransaction({ type: 'eip1559', chainId: CHAIN_ID, nonce: await owner.rpc.nonce(acct.address, 'pending'), to, value: amount, data: '0x', gas: (gas * 5n) / 4n, maxFeePerGas: (await owner.rpc.gasPrice()) * 2n, maxPriorityFeePerGas: 0n }) const h = await owner.rpc.sendRaw(raw, owner.sequencerUrl) const { receipt } = await owner.rpc.waitReceipt(h) const ok = receipt?.status === '0x1' console.log(`Gas ${(Number(amount) / 1e18).toFixed(6)} ETH ($${((Number(amount) / 1e18) * ethUsd).toFixed(2)}) to the executor: ${tx(h)}${ok ? '' : ' (not mined yet: check it before sending again)'}`) if (ok && !(await owner.hosted.executor(agentId)).enough) console.log(` still short of what it needs: maxGasUsd ($${SETTINGS.maxGasUsd}) is too low today`) } // --- status and the off switch ---------------------------------------------------------------------------------------- async function status() { const state = saved() const owner = clientFor(loadWallet()) for (const kind of ['paper', 'live'] as const) { const x = state[kind] if (!x) continue const a = await owner.hosted.get(x.id) const why = a.pause_reason console.log(`${(kind[0]!.toUpperCase() + kind.slice(1)).padEnd(9)} ${a.id} ${a.status}, ${a.trading}, ${a.tick_no} ticks${why ? `; paused: ${why.code}` : ''}`) const ai = (a.ai ?? {}) as Record console.log(` ${ai.model}: ${ai.today?.calls ?? 0} calls, $${Number(ai.today?.usd ?? 0).toFixed(6)} today; $${Number(ai.budget_left_usd ?? 0).toFixed(4)} of its ${usd(ai.budget_usd_day)} budget left`) pays(a) if (kind === 'live') { const g = await owner.hosted.executor(a.id) console.log(`Executor ${g.executor} ${usd(g.balance_usd)} of ETH for gas (${g.enough ? 'enough' : 'top it up'})`) } } } async function off(chainOnly = false) { const state = saved() const owner = clientFor(loadWallet()) const p = state.paper const l = state.live const record = l?.revocation_record const local = Boolean(record) && typeof (owner.sessions as unknown as Record).disableOnchain === 'function' if (chainOnly) return offOnChain(state, owner, record, local) const how = local ? 'They are built here from agent.json and sent straight to Robinhood Chain, even if Orblivion doesn\'t answer;' : `They come from Orblivion's answer (${record ? UPDATE_SDK : 'agent.json holds no revocation record'});` const todo: string[] = [] if (p && !p.off) todo.push(` - stop the paper agent ${p.id} (final);`) if (l && !l.off) { todo.push(` - stop the live agent ${l.id} (final) and end session ${l.session_id}: 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}`, " - send what is left of the executor's gas back to this wallet.") } else if (l?.off && !l.refunded) { if (l.stopped === false) todo.push(` - stop the live agent ${l.id} (final);`) // its session ended on chain while Orblivion couldn't stop it todo.push(" - send what is left of the executor's gas back to this wallet.") } if (!todo.length) throw new Stop('Nothing to switch off.') await confirm('The off switch:', ...todo) if (p && !p.off) { try { await owner.hosted.stop(p.id) p.off = true save(state) console.log(`Stopped paper agent ${p.id}`) } catch (e) { if (!(local && l && !l.off) || !(e instanceof OrblivionError)) throw e // Orblivion down: the live session still ends on chain below; the paper agent (no money) waits for a rerun console.log(`Not stopped paper agent ${p.id} (${e.code}: ${e.message}): run off again later`) } } if (l && !l.off) { if (!l.stopped) { try { await owner.hosted.stop(l.id) l.stopped = true save(state) console.log(`Stopped live agent ${l.id}: Orblivion co-signs nothing more for it`) } catch (e) { if (!local || !(e instanceof OrblivionError)) throw e l.stopped = false // the next off tries again save(state) console.log(`Not stopped at Orblivion (${e.code}: ${e.message}): ending its session on chain anyway`) } } // 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: l.session_id, record } : { sessionId: l.session_id } const r = (await owner.sessions.revoke(opts as { sessionId: string })) as unknown as RevokeOutcome if (r.apiError) console.log(`Not revoked at Orblivion: session ${l.session_id} (${r.apiError})`) else console.log(`Revoked session ${l.session_id} at Orblivion: ${r.effective || r.status}`) showSteps(r) let status = r.onChain?.status 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. Running off again can't check it better: ${record ? UPDATE_SDK : 'agent.json holds no revocation record (the session was granted with an older SDK). Sessions granted now keep one.'}`) } else if (status === 'unconfirmed' && r.onChain?.signedLive?.length && local) { // a root the wallet signed, named by Orblivion and still live: agent.json is most likely an older session's console.log(`On chain ${status}: Orblivion's answer carries a root your wallet signed that still reads live (${r.onChain.signedLive.map((h) => `${h.slice(0, 10)}…`).join(', ')}): agent.json is most likely an older session's record, and that root this session's own.`) 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 r2 = (await owner.sessions.revoke({ sessionId: l.session_id, record, disableSigned: true } as { sessionId: string })) as unknown as RevokeOutcome showSteps(r2) status = r2.onChain?.status if (!r2.onChain?.signedLive?.length && r2.onChain?.fromRecord === 'confirmed') { console.log('On chain every root of agent.json and every root your wallet signed that Orblivion named reads disabled, and the approvals 0, on every RPC') status = 'confirmed' } else console.log(`On chain ${status}: ${(r2.onChain?.detail ?? []).join('; ')}. Run npm run off again: it sends what is still needed`) } else if (status === 'unconfirmed' && r.onChain?.fromRecord === 'confirmed') { // everything the record holds reads done; only Orblivion's answer (another root it names) stands in the way console.log(`On chain ${status}: ${r.onChain.detail.join('; ')}. Everything agent.json holds reads done on every RPC; only Orblivion's answer stands in the way (it names a root agent.json doesn't hold, and none your wallet signed), and running off again won't change that. If agent.json is this session's own record, take the chain's word from it alone: npm run off -- --chain-only`) } else if (status) console.log(`On chain ${status}: ${r.onChain!.detail.join('; ')}. Run npm run off again: it sends what is still needed`) // off only once the chain confirms it (or, with an SDK that doesn't read it back, Orblivion's word as before) if (status === 'confirmed' || status === 'server_reported' || (r.onChain === undefined && !r.apiError)) { l.off = true save(state) } } else if (l && l.stopped === false) { try { await owner.hosted.stop(l.id) l.stopped = true save(state) console.log(`Stopped live agent ${l.id}`) } catch (e) { if (!(e instanceof OrblivionError)) throw e console.log(`Not stopped at Orblivion (${e.code}: ${e.message}): run off again later`) } } if (l && !l.refunded) { await refund(owner, l.id) l.refunded = true save(state) } } /** off --chain-only: the live session's on-chain steps from agent.json's revocation record alone (disableOnchain), and the * chain's answer read from it alone. Orblivion isn't asked anything; a later off stops the agent there and gets the * executor's gas back. */ async function offOnChain(state: Saved, owner: OrblivionClient, record: unknown, local: boolean) { const l = state.live if (!l || l.off) throw new Stop('No live session to end on chain. npm run off does the rest.') if (!local) { throw new Stop(`off --chain-only ends the live session from agent.json's revocation record alone, so it needs ${record ? `an SDK that can: ${UPDATE_SDK}` : 'a record, and agent.json holds none (the session was granted with an older SDK): npm run off'}`) } await confirm(`End session ${l.session_id} on chain from agent.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: npm run off afterwards stops the agent there and gets the executor\'s gas back.') 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 agent.json alone: the delegation reads as disabled and the approvals as 0, on every RPC') l.off = true if (!l.stopped) l.stopped = false save(state) console.log(`Session ${l.session_id} ended on chain. Next: npm run off stops the agent at Orblivion and gets the executor's gas back.`) } else { console.log(`On chain ${r.onChain?.status}: ${(r.onChain?.detail ?? []).join('; ')}`) console.log('The chain doesn\'t confirm it ended yet: run npm run off -- --chain-only again.') } } /** The executor's leftover gas, back to this wallet. Orblivion refuses it while a trade co-signed just before the stop * could still land (at most about two minutes), and says how long to wait. */ async function refund(owner: OrblivionClient, agentId: string) { for (let i = 0; i < 5; i++) { try { const r = await owner.hosted.refundGas(agentId) console.log(`Refunded ${(Number(r.value_wei) / 1e18).toFixed(6)} ETH from the executor to this wallet: ${tx(r.tx_hash)} (${r.status})`) return } catch (e) { const wait = e instanceof OrblivionError ? /try again in (\d+) s/.exec(e.message) : null if (!wait) throw e console.log(`Waiting ${wait[1]} s: Orblivion holds the refund until no trade signed before the stop can land`) await sleep(Number(wait[1]) + 2) } } throw new Stop('The refund is still held: run npm run off again in a few minutes.') } const acts: Record Promise> = { keys, options, paper, watch: (m) => watch(m), live, status, off: (a) => off(a === '--chain-only') } const [cmd, arg] = process.argv.slice(2) if (!cmd || !acts[cmd]) { console.log('npm run keys | options | paper | watch | live | status | off (paper, watch and live take -- MINUTES; off takes -- --chain-only)') process.exitCode = 1 } else { process.on('SIGINT', () => { console.log('\nStopped. An agent you made keeps running at Orblivion: npm run off stops it.') process.exit(130) }) try { await acts[cmd]!(['paper', 'watch', 'live', 'off'].includes(cmd) ? arg : undefined) } 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 } }