Skip to content

Commit e30b399

Browse files
authored
Add Dockerized Anvil test network (#723)
* Add Dockerized Anvil test network * Make bot RPC quorum configurable
1 parent 8c25dca commit e30b399

33 files changed

Lines changed: 343 additions & 84 deletions

bots/liquidator/README.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
The liquidator discovers every security pool registered by a configured
44
`SecurityPoolFactory`, shows pool and vault statistics in a local dashboard, and
55
evaluates unsafe vaults in operator-selected pools. Dry-run is the default. Live
6-
execution requires an explicit signer, independent read RPC quorum, execution
7-
flag, and non-zero deployment addresses.
6+
execution requires an explicit signer, the configured read RPC quorum, the
7+
execution flag, and non-zero deployment addresses.
88

99
The bot owns an ordinary vault under its signer address in each selected pool. A
1010
liquidation moves ETH-denominated open-interest debt, the proportional attoREP
@@ -41,6 +41,13 @@ password. Compose publishes the port only on host loopback, so connect from anot
4141
machine through a trusted tunnel to the host rather than changing the port binding.
4242
Keep `ZOLTAR_BOT_DASHBOARD_LOOPBACK_PUBLISHED` paired with that `127.0.0.1` mapping.
4343

44+
Compose passes `ZOLTAR_BOT_RPC_QUORUM`, which defaults to `2`. This production
45+
policy requires two agreeing readers and two independent quorum RPC URLs in addition
46+
to the primary reader so one endpoint may be unavailable. For an isolated local
47+
development chain only, put `ZOLTAR_BOT_RPC_QUORUM=1` in this directory's `.env`
48+
before starting Compose. That setting permits the primary reader to operate alone
49+
and removes independent RPC corroboration. Values other than `1` or `2` stop startup.
50+
4451
Save the chain and RPCs in **Chain and RPC connectivity**, finish the remaining
4552
configuration, and resume only after reviewing the saved settings. Run
4653
`docker compose down` to stop the bot and `docker compose up` to start it again.
@@ -81,8 +88,8 @@ saved. Same-chain RPC changes apply at the next scan. A configured operator file
8188
cannot be retargeted to another chain: create a separate paused configuration with
8289
a separate `runtime.stateFile`, then select its chain and endpoints in the
8390
dashboard. This boundary prevents transactions, staged operations, and scan state
84-
from crossing chains. Live execution requires two independent quorum RPCs in addition
85-
to the primary read RPC.
91+
from crossing chains. Under the default quorum policy, live execution requires two
92+
independent quorum RPCs in addition to the primary read RPC.
8693

8794
A configured bot keeps its dashboard available when retryable RPC transport
8895
unavailability prevents startup validation. It reports `connectivity-degraded`, shows
@@ -107,13 +114,14 @@ endpoints, gas limits, and REP limits have been reviewed. When execution is
107114
enabled:
108115

109116
- `connectivity.readRpcUrl` supplies the local operational view.
110-
- `connectivity.quorumRpcUrls` must contain at least two independent read RPCs.
117+
- Under the default quorum policy, `connectivity.quorumRpcUrls` must contain at
118+
least two independent read RPCs.
111119
- For a critical pool, price, vault, or candidate snapshot, only a retryable
112-
transport failure makes a reader unavailable. At least two readers must respond,
113-
and every responding reader must agree exactly before a transaction is sent. One
114-
transport-unavailable endpoint degrades health without stopping a healthy
115-
two-reader quorum; a malformed or contradictory response is a safety fault and
116-
fails closed.
120+
transport failure makes a reader unavailable. The configured number of readers
121+
must respond, and every responding reader must agree exactly before a transaction
122+
is sent. Under the default policy, one transport-unavailable endpoint degrades
123+
health without stopping a healthy two-reader quorum. A malformed or contradictory
124+
response is a safety fault and fails closed.
117125
- `submission.mode` may be `public` or `private`. ETH-funded stale-price requests
118126
use the same signed-transaction delivery policy as other actions.
119127
- `privateKey` is stored in the local operator file only when explicitly saved.

bots/liquidator/compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ services:
1010
environment:
1111
# Keep this assertion paired with the host-loopback port mapping below.
1212
ZOLTAR_BOT_DASHBOARD_LOOPBACK_PUBLISHED: "true"
13+
ZOLTAR_BOT_RPC_QUORUM: ${ZOLTAR_BOT_RPC_QUORUM-2}
1314
ports:
1415
- 127.0.0.1:4183:4183
1516
healthcheck:

bots/liquidator/src/cli/run.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createRpcEndpointPool } from '@zoltar/bot-shared/ethereum'
55
import { checkConnectivity, checkSubmissionEndpoints, endpointLabel, readRpcChainId } from '@zoltar/bot-shared/monitoring/connectivity'
66
import { availableSettledValues, settledQuorumValue } from '@zoltar/bot-shared/monitoring/read-quorum'
77
import { ConnectivityDegradedError, operationalFailureDisposition, pollUntilStopped, retryDelayMilliseconds } from '@zoltar/bot-shared/monitoring/resilience'
8+
import { rpcQuorumRequirement } from '@zoltar/bot-shared/monitoring/rpc-quorum-policy'
89
import { availableExecutionObservations } from '#monitoring/execution-quorum'
910
import { signerCandidate } from '@zoltar/bot-shared/config/signer'
1011
import { loadSettings, parseDesiredPools, parseStrategy, saveSettings, serializedSettings, type OperatorSettings } from '#config/settings'
@@ -128,7 +129,7 @@ async function runOperator(loaded: Awaited<ReturnType<typeof loadSettings>>, pro
128129
currentHeads: async () => {
129130
const settled = await Promise.allSettled(endpoints.map(async endpoint => await createPublicClient({ chain, transport: readPool.transportFor(endpoint) }).getBlockNumber()))
130131
const heads = availableSettledValues(settled)
131-
if (heads.length < 2) throw new ConnectivityDegradedError('Replacement reconciliation requires at least two available independent RPC endpoints')
132+
if (heads.length < rpcQuorumRequirement()) throw new ConnectivityDegradedError('Replacement reconciliation does not satisfy the configured RPC quorum requirement')
132133
return heads
133134
},
134135
replacement: async () => replacementEvidence,

bots/liquidator/src/config/settings.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { signerCandidate } from '@zoltar/bot-shared/config/signer'
66
import { validateConnectivitySettings, validateIndependentReadRpcUrls, type ConnectivitySettings, type NetworkName } from '@zoltar/bot-shared/monitoring/connectivity'
77
import { validateSubmissionSettings, type SubmissionSettings } from '@zoltar/bot-shared/execution/transaction-submission'
88
import { parseCentralizedMarketSettings, serializeCentralizedMarketSettings, type CentralizedMarketSettings } from '@zoltar/bot-shared/monitoring/centralized-markets'
9+
import { configuredQuorumRpcUrlMinimum, rpcQuorumRequirement } from '@zoltar/bot-shared/monitoring/rpc-quorum-policy'
910

1011
export type CandidatePriority = 'largest-bonus' | 'largest-debt' | 'lowest-top-up'
1112

@@ -256,6 +257,7 @@ function parseConnectivity(value: unknown): OperatorSettings['connectivity'] {
256257
}
257258

258259
export function parseSettings(value: unknown): OperatorSettings {
260+
rpcQuorumRequirement()
259261
const root = record(value, 'operator settings')
260262
if (root['version'] !== 1) throw new Error('operator settings version must be 1')
261263
const deployment = record(root['deployment'], 'deployment')
@@ -331,7 +333,7 @@ export function parseSettings(value: unknown): OperatorSettings {
331333
const marketAssetIds = [settings.centralizedMarkets, ...settings.childMarketConfigurations].map(configuration => configuration.assetAddress.toLowerCase())
332334
if (new Set(marketAssetIds).size !== marketAssetIds.length) throw new Error('Market configurations must target distinct REP assets')
333335
if (settings.runtime.execute && settings.privateKey === undefined) throw new Error('Live execution requires privateKey')
334-
if (settings.runtime.execute && settings.connectivity.quorumRpcUrls.length < 2) throw new Error('Live execution requires at least two independent quorum RPCs (three read endpoints total)')
336+
if (settings.runtime.execute && settings.connectivity.quorumRpcUrls.length < configuredQuorumRpcUrlMinimum()) throw new Error('Live execution requires at least two independent quorum RPCs (three read endpoints total)')
335337
if (settings.runtime.execute && settings.deployment.securityPoolFactory === getAddress('0x0000000000000000000000000000000000000000')) throw new Error('Live execution requires a deployed security-pool factory')
336338
if (settings.runtime.execute && settings.deployment.weth === getAddress('0x0000000000000000000000000000000000000000')) throw new Error('Live execution requires a deployed WETH contract')
337339
if (settings.runtime.execute && settings.deployment.zoltar === getAddress('0x0000000000000000000000000000000000000000')) throw new Error('Live execution requires a deployed Zoltar contract')

bots/liquidator/src/core/network-connectivity.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { checkConnectivity, checkSubmissionEndpoints, endpointLabel, readRpcChainId, validateConnectivitySettings, validateIndependentReadRpcUrls } from '@zoltar/bot-shared/monitoring/connectivity'
2+
import { configuredQuorumRpcUrlMinimum } from '@zoltar/bot-shared/monitoring/rpc-quorum-policy'
23
import type { OperatorSettings } from '#config/settings'
34

45
type ConnectivityChecks = {
@@ -22,7 +23,7 @@ export async function updateNetworkConnectivity(parameters: { apply: (settings:
2223
const quorumRpcUrls = validateIndependentReadRpcUrls(connectivity.readRpcUrl, rawQuorumRpcUrls.map(String))
2324
const network: OperatorSettings['network'] = networkName === 'mainnet' ? { chainId: 1, explorerUrl: 'https://etherscan.io', name: networkName } : { chainId: 11_155_111, explorerUrl: 'https://sepolia.etherscan.io', name: networkName }
2425
if (settings.networkConfigured && network.chainId !== settings.network.chainId) throw new Error('Use a separate operator configuration and durable state file to change chains')
25-
if (settings.runtime.execute && quorumRpcUrls.length < 2) throw new Error('Live execution requires at least two independent quorum RPCs (three read endpoints total)')
26+
if (settings.runtime.execute && quorumRpcUrls.length < configuredQuorumRpcUrlMinimum()) throw new Error('Live execution requires at least two independent quorum RPCs (three read endpoints total)')
2627
const checks = parameters.checks ?? defaultChecks
2728
await checks.checkConnectivity(connectivity, network.chainId)
2829
for (const rpcUrl of quorumRpcUrls) {

bots/liquidator/src/execution/recovery.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { confirmCanonicalReceiptFinality } from '@zoltar/bot-shared/execution/ca
55
import { sendRawTransactionToRpc } from '@zoltar/bot-shared/monitoring/connectivity'
66
import { availableSettledValues, settledQuorumValue } from '@zoltar/bot-shared/monitoring/read-quorum'
77
import { ConnectivityDegradedError } from '@zoltar/bot-shared/monitoring/resilience'
8+
import { rpcQuorumRequirement } from '@zoltar/bot-shared/monitoring/rpc-quorum-policy'
89
import { submitSignedTransaction } from '@zoltar/bot-shared/execution/transaction-submission'
910
import type { OperatorSettings } from '#config/settings'
1011
import { stagedOperationOutcome } from '#core/staged-outcome'
@@ -115,7 +116,7 @@ export async function recoverPendingTransactions(settings: OperatorSettings, wal
115116
}
116117
const settledBlocks = await Promise.allSettled(clients.map(async ({ client }) => await client.getBlockNumber()))
117118
const blocks = availableSettledValues(settledBlocks)
118-
if (blocks.length < 2) throw new ConnectivityDegradedError(`Transaction ${intent.hash} recovery requires at least two available independent RPC endpoints`)
119+
if (blocks.length < rpcQuorumRequirement()) throw new ConnectivityDegradedError(`Transaction ${intent.hash} recovery does not satisfy the configured RPC quorum requirement`)
119120
const recoveryAction = ambiguousRecoveryAction(intent, blocks)
120121
if (recoveryAction === 'expire-private') {
121122
await canonicalBlockHash(settings, intent.maxBlockNumber + PRIVATE_INTENT_FINALITY_BLOCKS, pool)
@@ -150,7 +151,7 @@ export async function reconcilePendingStagedOperations(settings: OperatorSetting
150151
for (const pending of [...state.pendingStagedOperations]) {
151152
const settledHeads = await Promise.allSettled(clients.map(async ({ client }) => await client.getBlockNumber()))
152153
const heads = availableSettledValues(settledHeads)
153-
if (heads.length < 2) throw new ConnectivityDegradedError(`Staged operation ${pending.operationId.toString()} recovery requires at least two available independent RPC endpoints`)
154+
if (heads.length < rpcQuorumRequirement()) throw new ConnectivityDegradedError(`Staged operation ${pending.operationId.toString()} recovery does not satisfy the configured RPC quorum requirement`)
154155
const toBlock = heads.reduce((minimum, head) => (head < minimum ? head : minimum))
155156
let outcome: (NonNullable<ReturnType<typeof stagedOperationOutcome>> & { blockHash: Hex; blockNumber: bigint; transactionHash: Hex }) | undefined
156157
for (const range of stagedOperationRecoveryRanges(pending.queuedBlock, toBlock)) {
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { quorumValue } from '@zoltar/bot-shared/monitoring/read-quorum'
22
import { ConnectivityDegradedError, operationalFailureDisposition } from '@zoltar/bot-shared/monitoring/resilience'
3+
import { rpcQuorumRequirement } from '@zoltar/bot-shared/monitoring/rpc-quorum-policy'
34

45
export function availableExecutionObservations<T, V>(label: string, settled: readonly PromiseSettledResult<T>[], observation: (value: T) => { endpoint: string; value: V }) {
56
const safetyFailure = settled.find(result => result.status === 'rejected' && operationalFailureDisposition(result.reason) === 'safety-paused')
67
if (safetyFailure?.status === 'rejected') throw safetyFailure.reason
78
const available = settled.flatMap(result => (result.status === 'fulfilled' ? [result.value] : []))
8-
if (available.length < 2) {
9+
const requirement = rpcQuorumRequirement()
10+
if (available.length < requirement) {
911
const failures = settled.flatMap(result => (result.status === 'rejected' ? [result.reason instanceof Error ? result.reason.message : String(result.reason)] : []))
10-
throw new ConnectivityDegradedError(`${label} requires at least two available independent RPC endpoints${failures.length === 0 ? '' : `: ${failures.join('; ')}`}`)
12+
throw new ConnectivityDegradedError(`${label} requires at least ${requirement === 1 ? 'one available RPC endpoint' : 'two available independent RPC endpoints'}${failures.length === 0 ? '' : `: ${failures.join('; ')}`}`)
1113
}
12-
quorumValue(label, available.map(observation))
14+
quorumValue(label, available.map(observation), requirement)
1315
return available
1416
}

bots/liquidator/tests/config/settings.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,17 @@ describe('liquidator settings', () => {
119119
).toThrow('name and chainId must identify the same supported chain')
120120
})
121121

122+
test('rejects an explicitly empty RPC quorum policy during settings parsing', () => {
123+
const previous = process.env['ZOLTAR_BOT_RPC_QUORUM']
124+
try {
125+
process.env['ZOLTAR_BOT_RPC_QUORUM'] = ''
126+
expect(() => parseSettings(settings)).toThrow('ZOLTAR_BOT_RPC_QUORUM must be 1 or 2')
127+
} finally {
128+
if (previous === undefined) delete process.env['ZOLTAR_BOT_RPC_QUORUM']
129+
else process.env['ZOLTAR_BOT_RPC_QUORUM'] = previous
130+
}
131+
})
132+
122133
test('requires independent quorum reads for live execution', () => {
123134
expect(() =>
124135
parseSettings({

0 commit comments

Comments
 (0)