Skip to content

Commit e997dc2

Browse files
authored
Use coordinator state discovery and improve bot diagnostics (#720)
* a * Format coordinator discovery test * Fix dashboard audit findings * Harden bot error redaction and legacy recovery - Redact URLs and relative paths from public poll errors - Recover replacement credits for legacy positions using quorum log scans * Harden canonical recovery snapshots
1 parent 3c5493b commit e997dc2

22 files changed

Lines changed: 803 additions & 118 deletions

bots/open-oracle-arbitrager/README.md

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,13 @@ for the report lifecycle assumptions and economics used by the arbitrager.
7979
## Requirements
8080

8181
- Bun and this project's frozen dependencies.
82-
- An RPC endpoint for Ethereum mainnet or Sepolia. An archive-capable endpoint is recommended when
82+
- An RPC endpoint for Ethereum mainnet or Sepolia. Approved-coordinator discovery
83+
uses current contract state and does not require historical log access. Legacy
84+
journals without a persisted dispute index are the bounded exception: after a
85+
restart, replacement-credit recovery scans only that report's dispute logs from
86+
its entry block until the immediate successor. Execution RPCs must therefore
87+
retain log history back to the oldest open legacy position's entry block. Archive
88+
access can also be useful for coordinator-free diagnostic mode when
8389
`runtime.lookbackBlocks` reaches beyond the provider's retained log history.
8490
- The deployed OpenOracle contract address.
8591
- At least one reviewed Zoltar `OpenOraclePriceCoordinator` address for every
@@ -268,36 +274,33 @@ available when RPC validation is temporarily unavailable, reports
268274
`connectivity-degraded`, and retries with bounded backoff. The bot checks the chain
269275
before every scan.
270276

271-
Startup enters **Syncing** while the bot scans the configured historical lookback in
272-
100-block chunks. The deliberately bounded response size prevents permissionless
273-
OpenOracle event volume from turning one historical RPC response into an unbounded
274-
memory spike. Once caught up, it polls the latest head and covers every unseen
275-
height in the OpenOracle event-log query; if several blocks arrive between polls, no
276-
event-log height is skipped. Opportunity evaluation and pool sampling run once at
277-
the newest observed head, not once at every intermediate historical height. A
278-
12-block overlap is re-read and reconciled for shallow reorgs in event-derived
279-
report state. With no new head the bot remains **Running** without re-evaluating or
280-
writing duplicate price samples.
277+
With approved coordinators configured, startup discovers reports by reading each
278+
coordinator's `pendingReportId` at one fixed block and then reads the corresponding
279+
stored OpenOracle state. It repeats those current-state reads at each new head and
280+
does not query historical OpenOracle logs. Execution mode requires a quorum to agree
281+
on the block and report snapshot. Opportunity evaluation and pool sampling run once
282+
at the newest agreed head. With no new head the bot remains **Running** without
283+
re-evaluating or writing duplicate price samples.
284+
285+
Coordinator-free diagnostic mode retains the historical fallback: startup scans
286+
`runtime.lookbackBlocks` in 100-block log-query chunks, then reads a 12-block overlap
287+
at each new head for shallow reorganization handling. The deliberately bounded
288+
response size prevents permissionless event volume from producing an unbounded RPC
289+
response.
281290

282291
### Data freshness and retention
283292

284-
Startup lookback backfills OpenOracle events, but it does not backfill historical
285-
pool prices. The price file can retain a sample from a block displaced by a reorg;
286-
the 12-block reconciliation applies only to report events. The bot reads at most the
287-
latest 8 MiB, loads and charts the latest 2,000 valid price records, and atomically
288-
compacts the file to those records after it crosses 8 MiB. Approved-coordinator report
289-
paths are reconstructed in memory from the startup lookback plus events observed by
290-
the current process. Consequently a path can begin at a dispute when its submission
291-
predates the lookback, and a settlement-only report is not shown when no earlier
292-
event for that report was observed. For each active report, the scanner retains one
293-
pre-overlap state anchor plus every event in the 12-block reorg window; older dispute
294-
steps are compacted instead of replayed forever. Settled paths remain through that
295-
reorg window and are then removed from the live scanner; confirmed transaction
296-
history remains in the execution history file. Unapproved reports are not retained
297-
in the execution cache. In diagnostic mode without configured coordinators, at most
293+
The price file can retain a sample from a block displaced by a reorganization. The
294+
bot reads at most the latest 8 MiB, loads and charts the latest 2,000 valid price
295+
records, and atomically compacts the file to those records after it crosses 8 MiB.
296+
Approved-coordinator monitoring keeps the complete current state of each pending
297+
report but does not reconstruct its historical dispute path from logs. Reports that
298+
are no longer pending are removed from the live cache; confirmed bot transaction
299+
history remains in the execution history file. In coordinator-free diagnostic mode,
300+
the startup lookback backfills report events but not historical pool prices. At most
298301
256 reports and 64 permissionlessly observed tokens are retained so event spam
299-
cannot create ever-growing per-block work. Increase `runtime.lookbackBlocks` when
300-
complete active-game context is operationally important.
302+
cannot create ever-growing per-block work. Increase `runtime.lookbackBlocks` only
303+
when broader diagnostic event history is operationally important.
301304

302305
## Run on Sepolia
303306

@@ -630,9 +633,8 @@ The dashboard shows:
630633
- Selected network, expected chain ID, read/public RPC controls, and endpoint checks.
631634
- A local signer control, connected address, and its ETH/WETH/REP balances.
632635
- ETH, WETH, REP, executable REP value, and estimated portfolio value.
633-
- Native ETH stakes, WETH stakes, and ETH settler rewards locked in active games
634-
observed within the configured event lookback. The combined figure treats 1 WETH
635-
as 1 ETH and can undercount games created before that lookback.
636+
- Native ETH stakes, WETH stakes, and ETH settler rewards locked in reports currently
637+
pending on configured coordinators. The combined figure treats 1 WETH as 1 ETH.
636638
- Current opportunities, token-metadata-normalized inventory requirements, deadline
637639
window, token-specific direction, pool, and decision.
638640
- Durable positions with actual hedge execution, entry and lifecycle gas, exact
@@ -658,8 +660,10 @@ The dashboard shows:
658660
address links to the selected-network explorer. The [market discovery section](#token-and-pool-discovery)
659661
owns the venue, price, and liquidity semantics. A token with no supported pool is
660662
explicitly labeled instead of disappearing.
661-
- The submitted/disputed/settled events observed for each OpenOracle report,
662-
including blocks, reporters, raw locked amounts, and transaction links. See
663+
- In coordinator-free diagnostic mode, the submitted/disputed/settled events
664+
observed for each OpenOracle report, including blocks, reporters, raw locked
665+
amounts, and transaction links. Configured-coordinator mode shows current report
666+
state without reconstructing these historical paths. See
663667
[data freshness and retention](#data-freshness-and-retention) for lookback limits.
664668
- A per-asset current-head price-history chart with one series per supported pool,
665669
axes, point tooltips, and a recent exact-value table. Samples persist across
@@ -1050,7 +1054,7 @@ scan. The same values live under `strategy` in the complete configuration:
10501054
| TWAP window | `1800 seconds` | `twapSeconds` | Controls the Uniswap manipulation-resistance window. Minimum: 60 seconds. |
10511055
| Remaining time | `36 seconds` | `minimumRemainingSeconds` | Inclusion buffer for timestamp-based games. |
10521056
| Remaining blocks | `3 blocks` | `minimumRemainingBlocks` | Inclusion buffer for block-based games. |
1053-
| Head poll interval | `1000 ms` | `pollMilliseconds` | Delay between latest-head checks. Every unseen event-log height is queried. |
1057+
| Head poll interval | `1000 ms` | `pollMilliseconds` | Delay between latest-head checks. Coordinator-free diagnostic mode queries every unseen event-log height. |
10541058

10551059
Increasing profit thresholds reduces execution frequency. Increasing the TWAP
10561060
window or remaining-time buffers is generally more conservative, while decreasing
@@ -1271,10 +1275,10 @@ entry from depending on wallet inventory already committed to recovery.
12711275
- Private delivery reduces public-mempool exposure but does not guarantee
12721276
confidentiality, inclusion, fair ordering, or relay/builder behavior. Configuring
12731277
multiple relays shares the signed payload with every listed operator.
1274-
- A 12-block event overlap is replayed whenever a new head is processed to tolerate
1275-
short reorganizations. The retained pre-overlap block hash is checked on every
1276-
poll; a deeper reorganization stops execution and requires restart so the complete
1277-
lookback is rebuilt. Operators still need independent alerting.
1278+
- Approved-coordinator reports are reread from a fixed block whenever a new head is
1279+
processed. A retained block hash is checked on every poll; a deeper reorganization
1280+
stops execution and requires restart. Coordinator-free diagnostic mode separately
1281+
replays a 12-block event overlap. Operators still need independent alerting.
12781282
- Continuous mode retries transient poll failures with bounded exponential backoff.
12791283
The dashboard exposes per-endpoint health and the latest error, `/healthz` supports
12801284
container supervision, and Compose restarts an unexpectedly exited process.

bots/open-oracle-arbitrager/scripts/capture-docs-screenshots.mts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
33
import { join } from 'node:path'
44
import { getAddress, keccak256, toHex } from '#ethereum'
55
import { startDashboardServer } from '#dashboard/dashboard-server'
6-
import { operatorSnapshot, type OperatorSnapshot, type OperatorState } from '#state/operator-state'
6+
import { operatorSnapshot, publicOperatorFailure, publicPollFailure, type OperatorSnapshot, type OperatorState } from '#state/operator-state'
77
import type { PositionRecord } from '#state/position-store'
88

99
const address = (value: number) => getAddress(`0x${value.toString(16).padStart(40, '0')}`)
@@ -20,6 +20,13 @@ const pool = address(0x3000)
2020
const hash = transactionHash('open-oracle-documentation-fixture')
2121
const checkedAt = sampledAt(0)
2222
const protectedFailureMarker = 'operator-secret'
23+
const longProviderFailureDetail = ` ${'provider response detail '.repeat(30).trim()}`
24+
const rawRpcFailure = `Read RPC https://operator:${protectedFailureMarker}@rpc.example failed at block 23842152:${longProviderFailureDetail}`
25+
const rawRelayFailure = `Private relay https://operator:${protectedFailureMarker}@relay.example rejected the transaction:${longProviderFailureDetail}`
26+
const expectedRpcPollFailure = publicPollFailure(rawRpcFailure)
27+
const expectedRpcOperatorFailure = publicOperatorFailure(rawRpcFailure)
28+
const expectedRelayOperatorFailure = publicOperatorFailure(rawRelayFailure)
29+
const expectedStateUnavailableFailure = `${publicPollFailure('fixture state endpoint unavailable', 'load the latest operator state for the dashboard')} Use Refresh to retry now.`
2330
let fixtureStatus: OperatorSnapshot['status'] = 'running'
2431
let paused = false
2532
let fixtureAttention: 'error' | 'none' | 'recovery' | 'transaction' = 'none'
@@ -740,15 +747,21 @@ async function captureScreenshots(chromium: string, origin: string, outputDirect
740747
attentionText: attention?.textContent,
741748
bodyScrollWidth: document.body.scrollWidth,
742749
clientWidth: document.documentElement.clientWidth,
750+
disputePathsEmptyText: document.querySelector('#dispute-paths-empty')?.textContent,
743751
hash: window.location.hash,
744752
label: badge?.textContent,
745753
noticeCopy: document.querySelector('#notice-copy')?.textContent,
746754
bodyContainsCredential: document.body.textContent?.includes('operator-secret') === true,
747755
endpointText: document.querySelector('#endpoint-checks')?.textContent,
748756
noticeTitle: document.querySelector('#notice-title')?.textContent,
749757
noticeTone: notice instanceof HTMLElement ? notice.dataset.tone : undefined,
758+
operationDetailsWidth: document.querySelector('#operations-body td[data-label="Details"]')?.getBoundingClientRect().width,
759+
operationReasonWidth: document.querySelector('#operations-body td[data-label="Why"]')?.getBoundingClientRect().width,
760+
operationTableWidth: document.querySelector('#operations-body')?.closest('table')?.getBoundingClientRect().width,
750761
operationText: document.querySelector('#operations-body')?.textContent,
751762
status: badge instanceof HTMLElement ? badge.dataset.status : undefined,
763+
transactionTableWidth: document.querySelector('#transactions-body')?.closest('table')?.getBoundingClientRect().width,
764+
transactionTargetWidth: document.querySelector('#transactions-body td[data-label="Target results"]')?.getBoundingClientRect().width,
752765
transactionText: document.querySelector('#transactions-body')?.textContent,
753766
}
754767
})()`,
@@ -758,6 +771,9 @@ async function captureScreenshots(chromium: string, origin: string, outputDirect
758771
)
759772
const value = typeof state === 'object' && state !== null && 'result' in state && typeof state.result === 'object' && state.result !== null && 'value' in state.result ? state.result.value : undefined
760773
if (typeof value !== 'object' || value === null || !('status' in value) || value.status !== status) throw new Error(`Run badge did not render ${status}`)
774+
if (!('disputePathsEmptyText' in value) || value.disputePathsEmptyText !== 'No historical dispute path is available. Configured coordinator mode reads current reports directly, while coordinator-free diagnostic mode reconstructs paths from its configured event lookback.') {
775+
throw new Error('Empty dispute paths did not explain both discovery modes')
776+
}
761777
if (
762778
status === 'error' &&
763779
(!('attentionHref' in value) ||
@@ -773,18 +789,34 @@ async function captureScreenshots(chromium: string, origin: string, outputDirect
773789
!('noticeTitle' in value) ||
774790
value.noticeTitle !== 'Latest poll failed' ||
775791
!('noticeCopy' in value) ||
776-
value.noticeCopy !== 'RPC connectivity or canonical chain reads failed. Automatic retry remains active.' ||
792+
value.noticeCopy !== expectedRpcPollFailure ||
777793
!('bodyContainsCredential' in value) ||
778794
value.bodyContainsCredential !== false ||
779795
!('endpointText' in value) ||
780796
typeof value.endpointText !== 'string' ||
781-
!value.endpointText.includes('RPC connectivity or canonical chain reads failed. Automatic retry remains active.') ||
797+
!value.endpointText.includes(expectedRpcOperatorFailure) ||
798+
!('operationDetailsWidth' in value) ||
799+
typeof value.operationDetailsWidth !== 'number' ||
800+
value.operationDetailsWidth > 480 ||
801+
!('operationReasonWidth' in value) ||
802+
typeof value.operationReasonWidth !== 'number' ||
803+
value.operationReasonWidth > 480 ||
804+
!('operationTableWidth' in value) ||
805+
typeof value.operationTableWidth !== 'number' ||
806+
value.operationDetailsWidth > value.operationTableWidth ||
807+
value.operationReasonWidth > value.operationTableWidth ||
782808
!('operationText' in value) ||
783809
typeof value.operationText !== 'string' ||
784-
!value.operationText.includes('Transaction confirmation or delivery tracking failed. Review transaction activity while automatic retry remains active.') ||
810+
!value.operationText.includes(expectedRelayOperatorFailure) ||
811+
!('transactionTargetWidth' in value) ||
812+
typeof value.transactionTargetWidth !== 'number' ||
813+
value.transactionTargetWidth > 480 ||
814+
!('transactionTableWidth' in value) ||
815+
typeof value.transactionTableWidth !== 'number' ||
816+
value.transactionTargetWidth > value.transactionTableWidth ||
785817
!('transactionText' in value) ||
786818
typeof value.transactionText !== 'string' ||
787-
!value.transactionText.includes('Transaction confirmation or delivery tracking failed. Review transaction activity while automatic retry remains active.'))
819+
!value.transactionText.includes(expectedRelayOperatorFailure))
788820
)
789821
throw new Error('Error state did not expose its attention and recovery context')
790822
if (mobile && 'bodyScrollWidth' in value && typeof value.bodyScrollWidth === 'number' && value.bodyScrollWidth > width) throw new Error(`${status} header overflows its ${width.toString()}px viewport`)
@@ -851,6 +883,10 @@ async function captureScreenshots(chromium: string, origin: string, outputDirect
851883
initialFailure.mode !== 'Mode unavailable' ||
852884
!('network' in initialFailure) ||
853885
initialFailure.network !== 'Network unavailable' ||
886+
!('noticeCopy' in initialFailure) ||
887+
initialFailure.noticeCopy !== expectedStateUnavailableFailure ||
888+
!('noticeTitle' in initialFailure) ||
889+
initialFailure.noticeTitle !== 'Dashboard disconnected' ||
854890
!('pauseDisabled' in initialFailure) ||
855891
initialFailure.pauseDisabled !== true ||
856892
!('runStatus' in initialFailure) ||
@@ -882,7 +918,9 @@ async function captureScreenshots(chromium: string, origin: string, outputDirect
882918
!('network' in postSuccessFailure) ||
883919
postSuccessFailure.network !== 'mainnet · 1 · last known' ||
884920
!('noticeCopy' in postSuccessFailure) ||
885-
postSuccessFailure.noticeCopy !== 'State polling failed. Automatic retry remains active; use Refresh to retry now.' ||
921+
postSuccessFailure.noticeCopy !== expectedStateUnavailableFailure ||
922+
!('noticeTitle' in postSuccessFailure) ||
923+
postSuccessFailure.noticeTitle !== 'Dashboard disconnected' ||
886924
!('pauseDisabled' in postSuccessFailure) ||
887925
postSuccessFailure.pauseDisabled !== false ||
888926
!('runStatus' in postSuccessFailure) ||
@@ -1485,8 +1523,6 @@ if (
14851523

14861524
function currentFixtureSnapshot(): OperatorSnapshot {
14871525
const fixturePositions = fixtureAttention === 'recovery' ? snapshot.positions.map((position, index) => (index === 0 ? { ...position, status: 'recovery-required' as const } : position)) : snapshot.positions
1488-
const rawRpcFailure = `Read RPC https://operator:${protectedFailureMarker}@rpc.example failed at block 23842152`
1489-
const rawRelayFailure = `Private relay https://operator:${protectedFailureMarker}@relay.example rejected the transaction`
14901526
const fixtureTransactions = snapshot.transactionActivity.map((transaction, index) => {
14911527
if (index !== 0) return transaction
14921528
if (fixtureAttention === 'transaction') return { ...transaction, status: 'confirmation-unknown' as const }

bots/open-oracle-arbitrager/scripts/check-docs.mts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ assert.match(guide, /Uniswap V3 remains the\s+reference and TWAP anchor/)
5151
assert.match(readme, /Uniswap V2, V3, or hookless V4/)
5252
assert.match(readme, /### Executor public surface/)
5353
assert.match(readme, /`dispute` is a lower-level, unhedged funding helper/)
54+
assert.match(readme, /Legacy\s+journals without a persisted dispute index are the bounded exception/)
55+
assert.match(readme, /retain log\s+history back to the oldest open legacy position's entry block/)
5456

5557
await assertLocalLinksResolve(guidePath, guide)
5658
const fixturePath = path.join(projectRoot, 'docs', 'market-fixture.html')

bots/open-oracle-arbitrager/src/contracts/abi.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export const erc20Abi = [
190190

191191
export const openOraclePriceCoordinatorAbi = [
192192
{ type: 'function', name: 'openOracle', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'address' }] },
193+
{ type: 'function', name: 'pendingReportId', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'uint256' }] },
193194
{ type: 'function', name: 'reputationToken', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'address' }] },
194195
{ type: 'function', name: 'weth', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'address' }] },
195196
{ type: 'function', name: 'settlementTime', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'uint48' }] },
@@ -253,6 +254,21 @@ const timingComponents = [
253254

254255
export const openOracleAbi = [
255256
{ type: 'function', name: 'oracleGame', stateMutability: 'view', inputs: [{ name: '', type: 'uint256' }], outputs: [{ name: '', type: 'bytes32' }] },
257+
{
258+
type: 'function',
259+
name: 'disputeHistory',
260+
stateMutability: 'view',
261+
inputs: [
262+
{ name: '', type: 'uint256' },
263+
{ name: '', type: 'uint256' },
264+
],
265+
outputs: [
266+
{ name: 'amount1', type: 'uint128' },
267+
{ name: 'amount2', type: 'uint128' },
268+
{ name: 'baseFee', type: 'uint128' },
269+
{ name: 'reportTimestamp', type: 'uint48' },
270+
],
271+
},
256272
{ type: 'function', name: 'storedGame', stateMutability: 'view', inputs: [{ name: '', type: 'uint256' }], outputs: gameComponents },
257273
{
258274
type: 'function',

0 commit comments

Comments
 (0)