feat(oracle-service): heartbeat same-rate refresh + confirm UpdateRate from DeliverTx wasm events #32
Labels
No labels
enhancement
governance
QA
testing
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
code/ust1-window#32
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Bundle two oracle-service production fixes observed on columbus-5 after
/ust1reported Oracle stale while the bot was still polling Venus:UpdateRatewith the current Venus-normalized rate when the number is unchanged, as soon as INV-ORACLE-THROTTLE-001 allows (4h), solast_update_secnever exceeds windowmax_oracle_age_sec(6h).action=update_rate+ matchingrate, instead of fail-closing on a lagged LCDStatequery.Incident: #31. Ops hub: DEX #503. Related hygiene (do not expand into this issue): #29. Confirm-before-liveness origin: #23 (INV-ORACLE-LIVENESS-001). Poll/silence budget: #24.
Current codebase
Why
/ust1goes stale while the bot looks “healthy”Window
effective_swap/ensure_oracle_usablefail closed whennow - oracle.last_update_sec > max_oracle_age_sec(21600 on mainnet). DEX UIust1WindowGates.tsisOracleStaleuses the same fields. Staleness is timestamp age, not “Venus disagrees”.oracle-servicepolls BSC (~1h, INV-ORACLE-OPS-POLL-001) and only broadcasts whendecide_tick_actionreturnsSubmit. Equal Venus vs on-chain rate short-circuits before policy:On-chain
check_rate_update(INV-ORACLE-THROTTLE-001 / DAILY-001 / MONO-001) already allowsnew_rate == old_rate(monotonic>=). The skip is off-chain only. After 6h of a bit-identical VenusexchangeRateStored, the window rejects mint/redeem even though the rate is still the live Venus reading.Live evidence (Aug 11–16 2026): gaps of 9–30h between
update_ratetxs while logs showed repeatingno rate change from BSCandLIVENESS_ORACLE_NO_BROADCAST(silence_secs21602 → 115000+). Most recent user-facing stale window: 2026-08-16 ~08:53–11:53 UTC (9h after46276C8F…). Probe afterC903047F…(11:53 UTC): age ~57 min, rate1225669001749054409, not paused.Why liveness pages after a successful include
INV-ORACLE-LIVENESS-001 (#23):
LivenessTracker::record_successful_broadcastonly after DeliverTxcode == 0andconfirm::oracle_state_matches_intended_update(LCDQueryMsg::State: rate == proposed andlast_update_secadvanced).submit_and_confirm_oracle_update(main.rs):sign_and_broadcast_execute(CheckTx / SYNC — not success).wait_for_deliver_tx_success— polls/cosmos/tx/v1beta1/txs/{hash}untilcode == 0.TxResponseSummarykeeps onlytxhash,code,raw_log— wasm events are discarded.query_wasm_smart(State). On mismatch:oracle state rate mismatch after inclusion … (fail-closed)→ tickErr→ no liveness record.Mainnet counterexamples (DeliverTx code 0, wasm
action=update_ratewith the expected rate; LCDStatestill showed the previous rate):174D7855…19213122555337456129495112255476521826394382067CBE1…3FD6C1225566211664260479122555337456129495116B487DB…DA5C112256033806055089301225566211664260479PublicNode LCD lag, not a failed execute. Fail-closed then keeps
LIVENESS_ORACLE_NO_BROADCASTclimbing even when on-chainlast_update_secalready advanced. A later tick may hitUpdateTooSoon { min_interval: 14400 }against the rate that did land.Timing constants (must stay aligned)
MIN_ORACLE_UPDATE_INTERVAL_SECSust1-commonDEFAULT_MAX_ORACLE_AGE_SECSust1-common/ window configDEFAULT_POLL_INTERVAL_SECSoracle-serviceconfig.rsDEFAULT_ORACLE_MAX_SILENCE_SECSORACLE_MAX_SILENCE_SECSORACLE_TX_CONFIRM_TIMEOUT_SECSThere is a 2h gap after throttle where a same-rate refresh can land before the window expires. Poll at 1h gives ~two chances. Heartbeat must use that gap; it must not widen
max_oracle_age_secor disable age checks (O2 / DEX runbook).Contract behavior (no migrate required if heartbeat = same-rate
UpdateRate)ust1-oracleexecute_update_ratecalls sharedcheck_rate_updatethen writesst.rate = new_rateandst.last_update_sec = now, emittingaction=update_rate+rate. Same-rate after 4h advances the timestamp. Operator-only; paused oracle skips. Governance pause / monotonic / daily +2% caps unchanged.Why this is needed
/ust1mint-arb is the $1 source of UST1. Stale oracle fail-closed pushes flow onto thin AMM pools (incident #31). Heartbeat keeps the window open while Venus is flat.LIVENESS_ORACLE_NO_BROADCASTis supposed to mean “no confirmed on-chain update” (INV-ORACLE-OPS-SILENCE-001). Today it also means “Venus unchanged” and “LCD State lagged after a real include”. That pages operators into #31-style incidents and hides the real dead-bot case.UpdateRateincluded and applied. Wasm events on the confirmed tx are the inclusion proof. A follow-upStateread is a consistency check, not the source of truth, and must not undo a proven include.Constraints / guardrails
UpdateRate(including heartbeat) inside 4h oflast_update_sec > 0.proposed(multi-RPC consensus). Never post a cached/old rate, never decrease, never breach daily cap.BROADCAST_MODE_SYNCalone is still not success. DeliverTxcode != 0, hash mismatch, timeout, missing/wrong wasm attrs → fail-closed, no liveness record.state.paused.evm_rpc.rs). Heartbeat must not short-circuit to a single provider.txhashmatch,code == 0, wasm_contract_address == ORACLE_CONTRACT,action == update_rate,rateequal to this tick’sproposed(string/Uint128 canonical). Ignore other contracts’ events in the same tx.UpdateRateis proven insufficient. Prefer off-chain tick change first.Ok(i.e. after 4h).SkipEqualRateinside the throttle window stays a cheap no-op.UpdateRate.oracle-service+ skills/DEPLOYMENT. Contract change only if heartbeat cannot land same-rate. DEX runbook/skill pointers are a small follow-up, not a DEX contract change.Relevant files
ust1-window (this repo)
oracle-service/src/main.rsdecide_tick_action,SkipEqualRate,run_once,submit_and_confirm_oracle_updateoracle-service/src/confirm.rsoracle_state_matches_intended_update(INV-ORACLE-LIVENESS-001)oracle-service/src/liveness.rsrecord_successful_broadcastoracle-service/src/terra_tx.rsTxResponseSummary,wait_for_deliver_tx_success,query_tx_by_hashoracle-service/src/config.rsoracle-service/src/evm_rpc.rs/bsc.rssmartcontracts-terraclassic/packages/ust1-common/src/oracle_policy.rscheck_rate_update(already allows equal rate)contracts/ust1-oracle/src/contract.rsexecute_update_rate+ wasm attrsskills/oracle-liveness-confirm/SKILL.mdskills/oracle-ops-poll-silence/SKILL.mddocs/DEPLOYMENT.mdDEX (docs / UI gates — follow-up pointers, not the implementation)
frontend-dapp/src/utils/ust1WindowGates.tsisOracleStalescripts/check-ust1-wrap-ops-health.shmax_oracle_age_secdocs/runbooks/ust1-wrap-production-ops.mdLIVENESS_ORACLE_NO_BROADCASTdocs/user-incident-faq.mdRecommended direction
A. Heartbeat (same-rate
UpdateRate)Remove the unconditional
proposed == state.rate → SkipEqualRateshort-circuit as a hard skip. Route equal rates throughcheck_rate_update:UpdateTooSoon→ keep skip (no gas); log at info (optionallyoutcome="throttled"not"failed"— do not expand into #29 unless one-line).Okwithproposed == state.rate→TickAction::Heartbeat(orSubmit { heartbeat: true }) → sameUpdateRate { new_rate: proposed }path.Okwithproposed != state.rate→ existingSubmit.RateDecreased/DailyCapExceeded/ZeroRate→ unchanged skips.Log heartbeat distinctly (
event="oracle_heartbeat"/tick_kind=heartbeat) so ops can tell refresh vs rate move.Keep using Venus
proposedfrom this tick’s consensus, not a stored last-success rate.Optional env
ORACLE_HEARTBEAT_ENABLEdefault on; if introduced, document inDEPLOYMENT.mdand default must be safe for prod (enabled). Prefer no knob if tests cover the policy routing.No window
max_oracle_age_secchange. After deploy, max silence between confirmed updates should be ≤ 4h + one poll + confirm timeout while the process is healthy (~5h), inside the 6h window budget.B. Confirm from DeliverTx wasm events
code == 0(extendTxResponseSummaryor return events fromwait_for_deliver_tx_success).confirm.rs):oracle_tx_events_match_update(contract, proposed, events) -> Result<()>.State(bounded retries) forrate+last_update_sec; on lagwarn!only (oracle_state_lag_after_event_ok), do notErrthe tick.Do not treat raw_log substring matching as sufficient if structured
eventsexist.Acceptance criteria
oracle_ris unchanged, the service still submitsUpdateRatewith that rate oncenow - last_update_sec >= 14400, and a successful include advances on-chainlast_update_sec.UpdateRate(heartbeat or otherwise) whenstate.paused, whenUpdateTooSoon, whenproposed < state.rate, or when daily cap would fail.proposed, never a stale cache.LIVENESS_ORACLE_NO_BROADCASTdoes not fire solely because Venus was unchanged for 6h while heartbeats confirmed.code == 0with matching wasmupdate_rate/rate, liveness is recorded even if the nextStatequery still shows the previous rate.code != 0, confirm timeout, txhash mismatch, wrong_contract_address, missingaction, or eventrate != proposed→ no liveness record.docs/DEPLOYMENT.mddescribe heartbeat + event-confirm; silence alert text still means “no confirmed include”.UpdateRate). If a migrate is proposed, call it out explicitly and do not mix it into the default path.Test plan — functional paths
Unit / wiremock (
oracle-servicetests inmain.rs,confirm.rs,terra_tx.rs,liveness.rs):proposed == state.rate,now - last < 14400→ no broadcast, no liveness.proposed == state.rate, age ≥ 14400, not paused → broadcastUpdateRatewith that rate; after DeliverTx + events, liveness recorded;last_update_secwould advance (contract multitest and/or mocked State after include).last_update_sec == 0— bootstrap still allowed (existing policy tests)._contract_address— Err even if rate string matches.ust1-commonalready allow equal rate after throttle — add an explicit “same-rate after 4h is Ok” test if missing.UpdateRateafter 4h advanceslast_update_secand emitsupdate_rate.Integration / ops (staging or LocalTerra if available; else document as manual):
scripts/verify_oracle_operator_env.shstill passes; new env knobs documented if any.Test plan — attack, hack, and abuse vectors
VENUS_VTOKEN_ADDRESSstill enforced on chain id 56.check_rate_updatemust still returnUpdateTooSoon. Fuzz/unit: equal rate atlast+14399rejected.proposed < state.ratestillRateDecreased; no tx.code == 0on the broadcast hash. Failed DeliverTx cannot be rescued by a stale-matching State.eq_ignore_ascii_casethe hash we broadcast. Wrong hash → fail-closed._contract_address == ORACLE_CONTRACTandaction=update_rateand rate == proposed. Other contracts’ events ignored.ratespoof / decimal encoding mismatchUint128as in mainnetrate=12255…). Reject empty/ambiguous attrs.max_oracle_age_secuntouched. DEX health script / UI gates unchanged.UpdateRatewithin policy. Heartbeat does not add a new execute. Pause remains governance circuit breaker./healthzprocess-up vs chain livenessVerification criteria
Code / CI
cargo test -p ust1-oracle-service(or workspace equivalent) covers heartbeat routing + event-confirm vs lagged State.cargo test -p ust1-common/ust1-oracleinclude same-rate-after-throttle.oracle-liveness-confirmandoracle-ops-poll-silenceupdated;DEPLOYMENT.mdlog examples includeoracle_heartbeatand event-confirm success.Staging / mainnet (after Coolify deploy)
UpdateRate, Finder wasmaction=update_rate, windoweffective_swap.oracle.last_update_secadvances,/ust1stays Fresh (age ≤ 21600).tick failed/oracle_state_confirm outcome=failedwhen events match; liveness resets (LIVENESS_ORACLE_NO_BROADCASTsilence_secs drops after success).UST1_OPS_STRICT_STALE=1 ./scripts/check-ust1-wrap-ops-health.sh(DEX repo) PASSes while the service is healthy.Close #31 only after mainnet age stays inside 6h across a multi-day Venus-flat stretch or an explicit waiver with evidence that remaining gaps are LCD/key/RPC outages (true dead-bot), not
SkipEqualRate.marked as related to #31
marked as related to #23
marked as related to #29
mentioned in commit
a86eda7892mentioned in merge request !46
mentioned in commit
de0e8f01caMerge follow-up for !46 (
de0e8f0onmain)!46 is merged (no conflicts; not auto-merge / not waiting on CI). Code review vs this issue’s acceptance criteria: code AC 1–9 PASS. Unit tests:
ust1-oracle-service106,ust1-common25,ust1-oracle10 — 141 passed.Remaining gaps (not merge blockers; track after Coolify deploy):
docs/runbooks/ust1-wrap-production-ops.mdstill needs the “do not disable age checks; heartbeat is the operator fix” pointer. Follow-up: cl8y-dex-terraclassic#503.action=update_rate, windowlast_update_secadvances,/ust1Fresh, LCD State lag does nottick failed, no heartbeat inside 4h) are not satisfied by the merge alone. Leave #31 open until age stays inside 6h across a Venus-flat stretch (or an explicit waiver).scripts/verify_oracle_operator_env.shunchanged — test plan item 20. Low risk: no new env knobs; behavior is policy-driven. Frozen-Venus integration (item 19) was not run.No code defects found that would require reverting !46.
Post-deploy live check (Coolify just started)
New binary is live: startup log includes heartbeat + event-confirm (#32 / #23 / #24). First tick after deploy is correct
SkipEqualRate, not a missed heartbeat.oracle_r(2 RPCs)1225669001749054409State.rate1225669001749054409(match → no rate change)last_update_sec1786881183= 2026-08-16 11:53:03 UTCC903047F…wasmaction=update_raterate match,code=0false/ust1UST1_OPS_STRICT_STALE=1 ./scripts/check-ust1-wrap-ops-health.shHeartbeat eligibility:
11:53 + 4h = 15:53 UTC. With hourly poll from this tick, expect skip at ~14:40 and ~15:40, thenevent=oracle_heartbeataround ~16:40 UTC (still ~1h before 6h stale at 17:53). No heartbeat inside 4h is required.Still open for soak: observe that heartbeat include, Finder wasm,
last_update_secadvance,/ust1stays Fresh, and LCD lag does nottick failed. Duplicate Venusnormalized …INFO is #29, not this deploy.mentioned in issue #31