Skip to content

Commit bfb5fc4

Browse files
authored
Merge pull request #742 from AugurProject/t3code/check-liquidator-deployment-1
Guard liquidator scans until deployment
2 parents 01c87e8 + 9a3ce29 commit bfb5fc4

9 files changed

Lines changed: 211 additions & 49 deletions

File tree

bots/liquidator/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ bun run check
409409
The reusable Ethereum, connectivity, quorum, block synchronization, signer gate,
410410
retry, and transaction-submission primitives live in `../shared`.
411411

412-
> Live liquidation is experimental. Use a dedicated low-balance signer, begin on
413-
> Sepolia, keep dry-run logs, and supervise pool health. Assumed pool open interest
412+
> Use a dedicated low-balance signer, begin on Sepolia, keep dry-run logs, and
413+
> supervise pool health. Assumed pool open interest
414414
> remains an economic obligation even when the fixed liquidation bonus is
415415
> positive.

bots/liquidator/src/cli/run.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { clearOrphanedDexEvidenceForHeadReplacement, discardDexMarketObservation
2828
import { canonicalBlockHash, chainFor, desiredPoolStatus } from '#monitoring/operator-chain'
2929
import { canonicalMarketPriceAllowsExecution, marketConfigurations, marketPriceAllowsExecution, selectedCandidate } from '#core/candidate-selection'
3030
import { reconcilePendingStagedOperations, recoverPendingTransactions } from '#execution/recovery'
31+
import { createSystemDeploymentGate } from '#core/deployment-gate'
3132

3233
const constantProductPairAbi = [
3334
{ inputs: [], name: 'token0', outputs: [{ type: 'address' }], stateMutability: 'view', type: 'function' },
@@ -397,6 +398,8 @@ async function runOperator(loaded: Awaited<ReturnType<typeof loadSettings>>, pro
397398
status: 'info',
398399
})
399400
let lastDryRunKey: string | undefined
401+
let missingDeploymentAddress: string | undefined
402+
const checkSystemDeployment = createSystemDeploymentGate()
400403
await pollUntilStopped(
401404
async () => {
402405
if (shutdown.isRequested()) return true
@@ -408,6 +411,21 @@ async function runOperator(loaded: Awaited<ReturnType<typeof loadSettings>>, pro
408411
const currentChain = chainFor(settings)
409412
chain = currentChain
410413
client = createPrimaryClient()
414+
const deploymentStatus = await checkSystemDeployment(client, settings.network.chainId, settings.deployment)
415+
if (!deploymentStatus.deployed) {
416+
state.status = state.paused ? 'paused' : 'starting'
417+
if (missingDeploymentAddress !== deploymentStatus.address) {
418+
recordActivity(state, {
419+
details: `chain=${settings.network.chainId.toString()} contract=${deploymentStatus.address}`,
420+
kind: 'deployment',
421+
message: `${deploymentStatus.name} is not deployed; waiting before checking again`,
422+
status: 'info',
423+
})
424+
missingDeploymentAddress = deploymentStatus.address
425+
}
426+
return 'deferred'
427+
}
428+
missingDeploymentAddress = undefined
411429
let primary
412430
if (settings.runtime.execute) {
413431
const endpoints = [settings.connectivity.readRpcUrl, ...settings.connectivity.quorumRpcUrls]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { Address, Hex } from '@zoltar/bot-shared/ethereum'
2+
3+
type DeploymentReader = {
4+
getCode(parameters: { address: Address }): Promise<Hex | undefined>
5+
}
6+
7+
type CoreDeployment = {
8+
securityPoolFactory: Address
9+
weth: Address
10+
zoltar: Address
11+
}
12+
13+
export type SystemDeploymentStatus = { deployed: true } | { address: Address; deployed: false; name: string }
14+
15+
export async function systemDeploymentStatus(client: DeploymentReader, deployment: CoreDeployment): Promise<SystemDeploymentStatus> {
16+
const contracts = [
17+
{ address: deployment.zoltar, name: 'Zoltar' },
18+
{ address: deployment.securityPoolFactory, name: 'security-pool factory' },
19+
{ address: deployment.weth, name: 'WETH' },
20+
] as const
21+
22+
for (const contract of contracts) {
23+
const code = await client.getCode({ address: contract.address })
24+
if (code === undefined || code === '0x') return { ...contract, deployed: false }
25+
}
26+
return { deployed: true }
27+
}
28+
29+
export function createSystemDeploymentGate() {
30+
let verifiedDeployment: string | undefined
31+
return async (client: DeploymentReader, chainId: number, deployment: CoreDeployment): Promise<SystemDeploymentStatus> => {
32+
const deploymentKey = `${chainId.toString()}:${deployment.zoltar}:${deployment.securityPoolFactory}:${deployment.weth}`
33+
if (verifiedDeployment === deploymentKey) return { deployed: true }
34+
verifiedDeployment = undefined
35+
const status = await systemDeploymentStatus(client, deployment)
36+
if (status.deployed) verifiedDeployment = deploymentKey
37+
return status
38+
}
39+
}

bots/liquidator/tests/cli/run-startup.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ async function waitForJson(origin: string, path: string) {
2020
throw new Error('Dashboard did not become ready')
2121
}
2222

23+
async function waitForRpcMethod(methods: string[], method: string) {
24+
for (let attempt = 0; attempt < 100; attempt++) {
25+
if (methods.includes(method)) return
26+
await Bun.sleep(20)
27+
}
28+
throw new Error(`Liquidator did not call ${method}`)
29+
}
30+
2331
afterEach(async () => {
2432
for (const child of children.splice(0)) {
2533
child.kill()
@@ -174,3 +182,45 @@ test('rejects a wrong-chain private relay during startup validation', async () =
174182
expect(exitCode).toBe(1)
175183
expect(output).toContain('Expected chain 11155111, received 1')
176184
})
185+
186+
test('queries only deployment bytecode when the configured system is undeployed', async () => {
187+
const directory = await mkdtemp(join(tmpdir(), 'zoltar-liquidator-undeployed-'))
188+
directories.push(directory)
189+
const methods: string[] = []
190+
const rpc = Bun.serve({
191+
async fetch(request) {
192+
const body = (await request.json()) as { id: unknown; method: string }
193+
methods.push(body.method)
194+
return Response.json({ id: body.id, jsonrpc: '2.0', result: body.method === 'eth_chainId' ? '0xaa36a7' : '0x' })
195+
},
196+
hostname: '127.0.0.1',
197+
port: 0,
198+
})
199+
servers.push(rpc)
200+
if (rpc.port === undefined) throw new Error('Test RPC did not expose a port')
201+
const examplePath = join(import.meta.dir, '..', '..', 'config', 'operator.example.json')
202+
const configuration = JSON.parse(await Bun.file(examplePath).text()) as {
203+
connectivity: { publicRpcUrls: string[]; quorumRpcUrls: string[]; readRpcUrl: string }
204+
runtime: { pollMilliseconds: number; stateFile: string; ui: boolean }
205+
}
206+
const rpcUrl = `http://127.0.0.1:${rpc.port.toString()}`
207+
configuration.connectivity = { publicRpcUrls: [rpcUrl], quorumRpcUrls: [], readRpcUrl: rpcUrl }
208+
Reflect.set(configuration, 'network', { chainId: 11_155_111, explorerUrl: 'https://sepolia.etherscan.io', name: 'sepolia' })
209+
configuration.runtime.pollMilliseconds = 1_000
210+
configuration.runtime.stateFile = join(directory, 'state.json')
211+
configuration.runtime.ui = false
212+
const configurationPath = join(directory, 'operator.json')
213+
await writeFile(configurationPath, JSON.stringify(configuration), 'utf8')
214+
const child = Bun.spawn([process.execPath, join(import.meta.dir, '..', '..', 'src', 'cli', 'run.ts')], {
215+
cwd: join(import.meta.dir, '..', '..'),
216+
env: { ...process.env, ZOLTAR_LIQUIDATOR_CONFIG: configurationPath },
217+
stderr: 'pipe',
218+
stdout: 'pipe',
219+
})
220+
children.push(child)
221+
222+
await waitForRpcMethod(methods, 'eth_getCode')
223+
const deploymentCheckIndex = methods.indexOf('eth_getCode')
224+
await Bun.sleep(100)
225+
expect(methods.slice(deploymentCheckIndex)).toEqual(['eth_getCode'])
226+
})
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { expect, test } from 'bun:test'
2+
import { createSystemDeploymentGate, systemDeploymentStatus } from '#core/deployment-gate'
3+
4+
const zoltar = '0x0000000000000000000000000000000000000001' as const
5+
const securityPoolFactory = '0x0000000000000000000000000000000000000002' as const
6+
const weth = '0x0000000000000000000000000000000000000003' as const
7+
8+
test('stops deployment checks at the first contract without runtime bytecode', async () => {
9+
const queried: string[] = []
10+
const status = await systemDeploymentStatus(
11+
{
12+
getCode: async ({ address }) => {
13+
queried.push(address)
14+
return undefined
15+
},
16+
},
17+
{ securityPoolFactory, weth, zoltar },
18+
)
19+
20+
expect(status).toEqual({ address: zoltar, deployed: false, name: 'Zoltar' })
21+
expect(queried).toEqual([zoltar])
22+
})
23+
24+
test('requires runtime bytecode for every core system contract', async () => {
25+
const queried: string[] = []
26+
const status = await systemDeploymentStatus(
27+
{
28+
getCode: async ({ address }) => {
29+
queried.push(address)
30+
return address === securityPoolFactory ? '0x' : '0x6000'
31+
},
32+
},
33+
{ securityPoolFactory, weth, zoltar },
34+
)
35+
36+
expect(status).toEqual({ address: securityPoolFactory, deployed: false, name: 'security-pool factory' })
37+
expect(queried).toEqual([zoltar, securityPoolFactory])
38+
})
39+
40+
test('reports the system deployed only after all core contracts have runtime bytecode', async () => {
41+
const queried: string[] = []
42+
const status = await systemDeploymentStatus(
43+
{
44+
getCode: async ({ address }) => {
45+
queried.push(address)
46+
return '0x6000'
47+
},
48+
},
49+
{ securityPoolFactory, weth, zoltar },
50+
)
51+
52+
expect(status).toEqual({ deployed: true })
53+
expect(queried).toEqual([zoltar, securityPoolFactory, weth])
54+
})
55+
56+
test('caches a verified deployment across scan cycles and resets for another chain', async () => {
57+
const queried: string[] = []
58+
const client = {
59+
getCode: async ({ address }: { address: string }) => {
60+
queried.push(address)
61+
return '0x6000' as const
62+
},
63+
}
64+
const checkDeployment = createSystemDeploymentGate()
65+
66+
await expect(checkDeployment(client, 1, { securityPoolFactory, weth, zoltar })).resolves.toEqual({ deployed: true })
67+
await expect(checkDeployment(client, 1, { securityPoolFactory, weth, zoltar })).resolves.toEqual({ deployed: true })
68+
expect(queried).toHaveLength(3)
69+
70+
await expect(checkDeployment(client, 2, { securityPoolFactory, weth, zoltar })).resolves.toEqual({ deployed: true })
71+
expect(queried).toHaveLength(6)
72+
})
73+
74+
test('does not reuse readiness after an intervening undeployed identity', async () => {
75+
const queried: string[] = []
76+
let deployed = true
77+
const client = {
78+
getCode: async ({ address }: { address: string }) => {
79+
queried.push(address)
80+
return deployed ? ('0x6000' as const) : undefined
81+
},
82+
}
83+
const checkDeployment = createSystemDeploymentGate()
84+
85+
await expect(checkDeployment(client, 1, { securityPoolFactory, weth, zoltar })).resolves.toEqual({ deployed: true })
86+
deployed = false
87+
await expect(checkDeployment(client, 2, { securityPoolFactory, weth, zoltar })).resolves.toEqual({ address: zoltar, deployed: false, name: 'Zoltar' })
88+
deployed = true
89+
await expect(checkDeployment(client, 1, { securityPoolFactory, weth, zoltar })).resolves.toEqual({ deployed: true })
90+
expect(queried).toHaveLength(7)
91+
})

bots/open-oracle-arbitrager/README.md

Lines changed: 5 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,9 @@ Dry-run is the example default. The bot cannot submit a transaction unless
1515
`runtime.execute` is enabled in its configuration and a signer is saved in that
1616
configuration or supplied through the local dashboard.
1717

18-
> **Live execution is experimental.** Mainnet commands below are operator
19-
> references, not production approval. Rehearse on Sepolia with a dedicated
20-
> low-balance key, validate current executable liquidity, and supervise every
21-
> position; no automated strategy can guarantee a profit or prevent every loss.
18+
> Use a dedicated low-balance key, validate current executable liquidity, and
19+
> supervise every position; no automated strategy can guarantee a profit or prevent
20+
> every loss.
2221
> The [latest pinned market fixture](./docs/market-fixture.html#open-oracle-market-fixture)
2322
> is dated historical evidence, not a live liquidity or profitability claim.
2423
@@ -143,41 +142,6 @@ dashboard binds to `127.0.0.1`; the host-loopback container setup is documented
143142
under [Docker](#docker). The execution key still lives in the bot process and must
144143
be protected like any hot wallet.
145144

146-
## End-user readiness backlog
147-
148-
The repository implementation is a guarded operator tool, not yet a supported
149-
retail release. Complete these items before declaring or packaging a supported
150-
end-user release. The commands below remain experimental operator references:
151-
152-
1. Publish separate, reviewed mainnet and Sepolia **execution manifests** containing
153-
the deployed executor, OpenOracle, approved coordinators, router, factory,
154-
quoter, WETH, and executable tokens with runtime bytecode hashes. The protocol
155-
deployment manifests for other projects are not a substitute for this bot trust
156-
root.
157-
2. Deploy and source-verify the stateless executor on each supported network, then
158-
reproduce every manifest hash through at least two independently operated RPCs.
159-
3. Run a funded, low-limit Sepolia rehearsal covering entry, replacement, normal
160-
settlement, withdrawal, restart after each journal stage, relay rejection,
161-
RPC disagreement, and signer-authorized manual reconciliation. Retain transaction
162-
hashes and recovery evidence as release artifacts.
163-
4. Add an external signer or encrypted-keystore interface so routine operators do
164-
not need to paste a raw private key into the dashboard or save it in plaintext.
165-
Until then, use a dedicated low-balance key and leave **Save this new key in
166-
plaintext for future restarts** off.
167-
5. Extend the deterministic interrupted-write, partial-relay, same-origin RPC,
168-
clock-skew, and deep-reorganization tests with host-level disk-full and
169-
provider-specific chaos rehearsals.
170-
6. Publish a supported relay/RPC compatibility matrix and continuously exercise
171-
exact bundle simulation, submission, receipt, and archive-read behavior against
172-
those providers.
173-
7. Package a versioned release with pinned Bun support, checksums or signatures,
174-
reproducible installation, default service supervision, log rotation, health
175-
checks, and alerts for paused, syncing, error, stale-head, recovery-required,
176-
low-inventory, and unconfirmed-bundle states.
177-
8. Commission an independent review of the final deployed addresses, manifests,
178-
signer integration, release package, and funded rehearsal evidence. Repeat the
179-
review whenever execution dependencies or token allowlists change.
180-
181145
## Install
182146

183147
From the monorepo root, install the root package before entering the arbitrager
@@ -1321,7 +1285,6 @@ entry from depending on wallet inventory already committed to recovery.
13211285
tokens, OpenOracle/Uniswap defects, compromised keys, and market movement can
13221286
still cause loss. Start on Sepolia, use a dedicated low-balance wallet, set small
13231287
risk limits, and supervise every live position.
1324-
- A profitable dry-run observation is not production approval. Before enabling
1325-
execution, verify the current pools, relay simulations, inventory, risk limits,
1288+
- Before enabling execution, verify the current pools, relay simulations, inventory, risk limits,
13261289
deployment manifest, settlement path, and recovery procedure with a low-value
1327-
rehearsal.
1290+
transaction.

bots/open-oracle-arbitrager/docs/operator-guide.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
<h1>OpenOracle Arbitrager</h1>
1313
<p>This operator guide explains how the bot discovers OpenOracle games, compares their exchange rate with onchain liquidity, submits an atomic hedge and dispute, and accounts for the final withdrawal. It covers the operator configuration, local dashboard, strategy math, supported exchanges, durable recovery, and the balances an execution wallet needs.</p>
1414
<div class="callout">
15-
<strong>Experimental operator software</strong>
16-
Dry-run first, rehearse with a dedicated low-balance key on Sepolia, and supervise every funded position. A modeled profit is not a guarantee: fees, failed inclusion, later disputes, token behavior, RPC failure, MEV, and market movement can still cause loss.
15+
<strong>Execution risk</strong>
16+
Use a dedicated low-balance key and supervise every funded position. A modeled profit is not a guarantee: fees, failed inclusion, later disputes, token behavior, RPC failure, MEV, and market movement can still cause loss.
1717
</div>
1818
<nav class="guide-nav" aria-label="Guide sections">
1919
<a href="#quick-start">Quick start</a>

bots/open-oracle-arbitrager/src/dashboard/dashboard.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -995,8 +995,8 @@ function render(snapshot: PublicOperatorSnapshot) {
995995
launchNotice.dataset['tone'] = 'warning'
996996
} else {
997997
launchNotice.hidden = true
998-
setText('launch-notice-title', 'Sepolia rehearsal network')
999-
setText('launch-notice-copy', 'Use this network to rehearse execution and recovery. Testnet success is not production approval.')
998+
setText('launch-notice-title', 'Sepolia network')
999+
setText('launch-notice-copy', 'Use this network to exercise execution and recovery with a dedicated low-balance key and low risk limits.')
10001000
launchNotice.dataset['tone'] = 'warning'
10011001
}
10021002
const notice = element('notice')

bots/open-oracle-arbitrager/tests/dashboard/dashboard-server.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,8 @@ test('serves dashboard state and protects mutable controls with same-origin JSON
205205
expect(browserSource).toContain('aria-labelledby')
206206
expect(browserSource).toContain('Recent exact price samples')
207207
expect(browserSource).toContain('Mainnet execution network')
208-
expect(browserSource).toContain('Sepolia rehearsal network')
208+
expect(browserSource).toContain('Sepolia network')
209+
expect(browserSource).not.toContain('production approval')
209210
expect(browserSource).toContain('details.dataset["reportId"]')
210211
expect(browserSource).toContain('focus({ preventScroll: true })')
211212
expect(browserSource).toContain('stroke-dasharray')

0 commit comments

Comments
 (0)