Fall back to reading Pyth's price objects when Hermes is unreachable - #129
Conversation
`aggregateBasicPrices` rejected the moment the Hermes fetch failed, so an outage at Pyth's off-chain endpoint took down every SDK path that prices collateral — `getOraclePrices`, PSM swaps, and any borrow or withdraw. Nothing about that rejection was load-bearing. `pyth_rule::feed` reads the `PriceInfoObject` with `get_price_unsafe` and hands the aggregator `option::none()` when the reading is past that coin type's tolerance, so a stale price is never collected; `aggregate` then aborts `ERiskyPrice` unless the surviving sources clear the weight threshold. The freshness decision already lives on-chain. Refusing to build the PTB only denied the chain the chance to make it. So on a Hermes failure the PTB is now built against the price objects as they already stand. The rule is still fed — `remove_outliers` aborts `EMissingPriceSource` unless every weighted rule was collected in that PTB, and abstaining counts as collected while omitting the call does not. Only the VAA update that would have preceded it is dropped, along with its wormhole verification and per-feed fee. Dry-run against mainnet with the endpoint pointed at a closed port prices SUI, USDC and BTC within 0.0001% of the Hermes baseline: the shared price objects are refreshed constantly by other protocols' transactions, well inside the 30s tolerance. That is the case this rescues today. The longer outage — where the reading really is stale and a second source has to carry the aggregate — needs SupraRule weight, which is still with the multisig; until then it degrades to an on-chain `ERiskyPrice` rather than an SDK throw. Same failure, better diagnostics. `pythStaleReadFallback: false` keeps the old behaviour, and every engagement is reported through `onPythStaleRead`, which defaults to a console.error so the degradation is never silent. Resolving the price object id is now its own function rather than a by-product of building the update calls: it is a pure on-chain lookup and answers fine when Hermes does not. Both paths share the PythCache, so the fallback usually costs no extra RPC. Guarding only the fetch is deliberate. It runs before anything touches the transaction, so the fallback can never inherit a half-built PTB — and `buildPythPriceUpdateCalls` no longer has a throw that could leave one: the missing-feed check used to fire after `parse_and_verify`, the accumulator call and the fee split were already appended, stranding a hot potato in a PTB that could never build. All validation is hoisted above the first mutation, pinned by a test asserting the transaction is untouched when it throws. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
waterlabs-bot
left a comment
There was a problem hiding this comment.
Code review
Found 2 non-blocking issues:
- Async stale-read handlers can become unhandled rejections.
onPythStaleReadis typed as returningvoid, but TypeScript accepts anasyncfunction at that call site. The returned promise is discarded, so a rejected telemetry/reporting callback becomes an unhandled rejection (and can terminate Node under strict/default process policy) while a synchronous throw propagates throughaggregateBasicPrices. Please make the callback contract consistent: either acceptvoid | Promise<void>and await it, or isolate/log both synchronous throws and promise rejections if this hook is intended to be observational only.
bucket-protocol-sdk/src/client.ts
Lines 1170 to 1176 in 572435f
- The new fallback path is not exercised end-to-end by automated tests. These tests mock both the Hermes failure and
resolvePythPriceInfoObjectIds, while the normal update builder is mocked suite-wide. The existing oracle E2E only covers the reachable-Hermes path, so CI does not preserve the key sequence: Hermes fails → the real on-chain resolver supplies thePriceInfoObject→pyth_rule::feedreceives it → the fallback PTB dry-run succeeds. Please add a focused E2E/integration case with a deliberately unreachable endpoint and assert the callback plus a successful price/dry-run. (I ran that exact mainnet SUI probe against this SHA and it succeeded; this is a regression-coverage gap, not evidence that the implementation currently fails.)
bucket-protocol-sdk/test/unit/price-aggregation.test.ts
Lines 263 to 272 in 572435f
Severity/confidence: both Medium · High confidence. No blocking protocol-safety issue found: the deployed pyth_rule::feed freshness check collects none for stale readings, and aggregator::remove_outliers counts the rule as present before dropping abstentions and enforcing the surviving weight threshold.
🤖 Generated with Hermes Agent
Review follow-up on #129. `onPythStaleRead` reports a degraded oracle read, and could cause one. A synchronous throw propagated straight out of `aggregateBasicPrices`, so a broken metrics call turned a survivable Hermes outage into exactly the hard failure the fallback exists to prevent. An `async` handler — which TypeScript admits at a `void` return position — discarded its rejection into an unhandled promise instead. Both are now contained, and the hook's return type says `void | Promise<void>` so the async case is honest rather than merely tolerated. It is deliberately not awaited: reporting is observational, and PTB construction should not wait on someone's telemetry endpoint. The fallback also had no unmocked coverage. The unit tests mock the Hermes failure and the resolver both, so nothing in CI held the sequence that actually matters: Hermes fails, the real on-chain resolver supplies the `PriceInfoObject`, `pyth_rule::feed` receives it, and the PTB dry-runs. The new E2E case points the endpoint at a closed port and asserts all four, plus the absence of every command the VAA update would have added. That test leans on mainnet's SUI price object sitting inside `pyth_rule`'s 30s tolerance, which other protocols' transactions keep it well within. If it ever does go stale the test aborts `ERiskyPrice`, which is the correct outcome to see: `SupraRule` has no aggregator weight yet, so no second source can carry the price. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both findings addressed in 6c823f8 — thanks, they were fair. 1. Async stale-read handlers → unhandled rejections. Fixed, and the sharper half of this was the synchronous case: a throw escaping the hook propagated straight out of Deliberately not awaited — reporting is observational, and PTB construction shouldn't block on someone's telemetry endpoint. Failures from the hook go to Two unit tests cover it, and I verified they're not vacuous by reverting the wrapper: both fail without it, the async one surfacing as a process-level unhandled rejection. 2. Fallback not exercised end-to-end. Agreed — and thanks for running the mainnet probe to confirm it was a coverage gap rather than a live failure. New E2E case in One caveat worth stating: that test leans on mainnet's SUI price object sitting inside Verification: Unrelated: the |
waterlabs-bot
left a comment
There was a problem hiding this comment.
Follow-up review
Both prior findings are resolved at 6c823f8f9e241c310b28fceafc14be50c7cf15e2.
- F-01 resolved:
reportPythStaleReadcontains synchronous throws and attaches a rejection handler immediately for async callbacks, while keeping telemetry fire-and-forget. Focused unit coverage proves both failure modes leave the fallback PTB intact and do not emit an unhandled rejection. - F-02 resolved: the new mainnet E2E forces Hermes offline, uses the real on-chain resolver, requires
pyth_rule::feed, excludes all Pyth/Wormhole update calls, dry-runs the PTB successfully, and verifies a positive price.
No new blocking or publishable non-blocking issues found in the follow-up delta. CI at this head passed 190 tests (2 skipped), including all 6 oracle E2E cases; lint and secret scan are green. I also reran the focused unit test, the fallback E2E repeatedly, TypeScript checking, and the full package build successfully. The failing enable-auto-merge check remains an unrelated workflow PAT 401.
🤖 Generated with Hermes Agent
waterlabs-bot
left a comment
There was a problem hiding this comment.
Follow-up correction
One additional Medium · High-confidence public-type issue surfaced in the completed independent check:
onPythStaleReadwas changed from(event) => voidto(event) => void | Promise<void>. In TypeScript, the originalvoidcallback intentionally accepts value-returning handlers, while the union does not. Common callbacks such asevent => events.push(event)or logger/counter functions now fail with TS2322 even though their return value is ignored. This was reproduced with TypeScript 5.9.2.
Please retain (event: PythStaleReadEvent) => void in the public signatures/private field (the runtime Promise.resolve(...) wrapper still observes actual async returns and catches rejections), or use an unknown return type if accepting arbitrary observational callback results should be explicit.
bucket-protocol-sdk/src/client.ts
Lines 92 to 95 in 6c823f8
bucket-protocol-sdk/src/client.ts
Lines 125 to 128 in 6c823f8
bucket-protocol-sdk/src/client.ts
Lines 176 to 179 in 6c823f8
This is non-blocking at runtime, but it should be corrected before publishing the SDK API. My earlier approval remains recorded; this comment corrects the statement that the follow-up delta had no publishable issues.
🤖 Generated with Hermes Agent
Problem
aggregateBasicPricesrejected the moment the Hermes fetch failed, so an outage at Pyth's off-chain endpoint took down every SDK path that prices collateral —getOraclePrices, PSM swaps, and any borrow or withdraw.Nothing about that rejection was load-bearing.
pyth_rule::feedreads thePriceInfoObjectwithget_price_unsafeand hands the aggregatoroption::none()when the reading is past that coin type's tolerance, so a stale price is never collected;aggregatethen abortsERiskyPriceunless the surviving sources clear the weight threshold. The freshness decision already lives on-chain. Refusing to build the PTB only denied the chain the chance to make it.Change
On a Hermes failure the PTB is now built against the price objects as they already stand.
The rule is still fed —
remove_outliersabortsEMissingPriceSourceunless every weighted rule was collected in that PTB, and abstaining counts as collected while omitting the call does not. Only the VAA update that would have preceded it is dropped, along with its wormhole verification and per-feed fee.pythStaleReadFallback: falsekeeps the old behaviour.onPythStaleRead, defaulting to aconsole.errorso the degradation is never silent.resolvePythPriceInfoObjectIds) rather than a by-product of building the update calls — it is a pure on-chain lookup and answers fine when Hermes does not. Both paths share thePythCache, so the fallback usually costs no extra RPC.Verified against mainnet
Dry-run with
PRICE_SERVICE_ENDPOINTpointed at a closed port:All three price successfully with Hermes fully unreachable, within 0.0001% of baseline — the shared price objects are refreshed constantly by other protocols' transactions, well inside the 30s tolerance.
Scope — what this covers today
SupraRuleweight, which is currently going through the multisig (pnpm supra:weights).supra:readshows every aggregator at threshold 1 with exactly one weighted rule today, so until that lands, a genuinely stale reading degrades to an on-chainERiskyPricerather than an SDK throw. Same failure, better diagnostics — and the SDK side is then already in place.No Move changes needed;
pyth_ruleandaggregatoralready behave correctly.Bug fixed along the way
buildPythPriceUpdateCallsthrewPrice feed … not foundafterparse_and_verify, the accumulator call and the fee split were already appended — stranding a hot potato in a PTB that could never build, and impossible to recover from by catching. All validation is hoisted above the first mutation, pinned by a test asserting the transaction is untouched when it throws. This is also what makes the fallback safe to wrap in atry/catch.Test plan
pnpm test:unit— 126 pass (12 new: fallback engages/reports/respects the opt-out, does not swallow update-path errors, resolver behaviour, no-poison guarantee)pnpm vitest run test/e2e/oracle.test.ts— 5 pass against mainnetpnpm lint,pnpm build🤖 Generated with Claude Code