Documentation
¶
Overview ¶
Package pyth decodes Pyth Push Oracle price accounts and derives their well-known PDAs -- a Go port of catscope-rust-bot's own src/trader/dex/pyth.rs (parse_push_oracle and the SOL/USD feed account derivation); offsets/behavior are verified there against a real, live mainnet account, not re-derived here.
Only the Push Oracle (PriceUpdateV2) format is ported -- Pyth's older legacy `Price` account format is deliberately left out. The Rust source's own module doc comment documents a real incident: a "well-known" legacy SOL/USD address turned out to be ~728 days stale (Pyth's crank has moved on to the push-oracle model for major feeds), so a well-known address alone was never evidence of liveness for that format -- there's no live legacy account worth decoding here.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var PushDiscriminator = [8]byte{34, 241, 35, 99, 157, 126, 244, 205}
PushDiscriminator is PriceUpdateV2's Anchor account discriminator (sha256("account:PriceUpdateV2")[:8]).
var PushOracleProgramID = sgo.MustPublicKeyFromBase58("pythWSnswVUd12oZpeFP8e9CVaEqJg25g1Vtc2biRsT")
PushOracleProgramID is Pyth's Push Oracle program (DEFAULT_PUSH_ORACLE_PROGRAM_ID in Pyth's own pyth_solana_receiver SDK, address.ts) -- the program a PriceUpdateV2 "price feed account" PDA is derived against. Distinct from the Receiver program (rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ), which owns the account's actual bytes but isn't part of the PDA seeds.
var SOLUSDFeedID = [32]byte{
0xef, 0x0d, 0x8b, 0x6f, 0xda, 0x2c, 0xeb, 0xa4, 0x1d, 0xa1, 0x5d, 0x40, 0x95, 0xd1, 0xda, 0x39,
0x2a, 0x0d, 0x2f, 0x8e, 0xd0, 0xc6, 0xc7, 0xbc, 0x0f, 0x4c, 0xfa, 0xc8, 0xc2, 0x80, 0xb5, 0x6d,
}
SOLUSDFeedID is Pyth's SOL/USD price feed ID, looked up live from Pyth's own Hermes API (hermes.pyth.network/v2/price_feeds?query=SOL/USD) in catscope-rust-bot -- copied from that verified constant, not re-looked-up here.
Functions ¶
func FeedAccount ¶
FeedAccount derives the PDA holding feedID's current push-oracle price, for shard 0 -- Pyth's own crank keeps shard 0 continuously updated for popular feeds; other shards are ephemeral, SDK-caller- created (see getPriceFeedAccountForProgram in pyth_solana_receiver's address.ts). Verified live in catscope-rust-bot (this exact derivation for SOLUSDFeedID): resolves to 7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE, owned by the Receiver program, decoding to the right discriminator/feed_id with publish_time only ~31 seconds behind wall-clock at the time it was checked.
func Watch ¶
func Watch(ctx context.Context, client state.Client, feeds ...[32]byte) (<-chan PriceUpdate, <-chan error, error)
Watch subscribes to feeds (each a Pyth feed ID, e.g. SOLUSDFeedID) via client's own real-time account graph stream and returns a channel delivering a PriceUpdate every time any of them changes on-chain -- genuinely push-based, not polled: no per-update network round trip happens beyond the one long-lived stream client already holds open, so there's no equivalent of the Jupiter Price API's rate limit to run into (see optimizer/prefetch/price-feed's own doc comment on that limit -- this package exists as an alternative that avoids it entirely, for feeds Pyth actually publishes).
The returned update channel is closed once the subscription ends (ctx cancelled, or the underlying state.Client.Hook call errors); the error channel receives the terminal error, if any (nil on a clean ctx-cancellation shutdown, matching state.Client.Hook's own convention of treating context.Canceled as no error).
Types ¶
type Hub ¶
type Hub struct {
// contains filtered or unexported fields
}
Hub fans out live price updates from one underlying Watch subscription to any number of independent goroutine subscribers -- Watch's own channel has exactly one consumer (whoever reads it), so anything that wants more than one independent reader (e.g. one goroutine recording into prefetch.db, another feeding an agent's own live-price tool, in two entirely separate processes each running their own Hub fed by their own Watch call, or several consumers within the same process) needs a broadcaster in front of it. Hub is that broadcaster; it has no opinion about where updates come from (Run, below, is the convenience for the common case of "everything Watch produces").
Zero value is not usable -- construct with NewHub.
func NewHub ¶
func NewHub() *Hub
NewHub returns a ready-to-use Hub with no subscribers and no known prices yet.
func (*Hub) Latest ¶
func (h *Hub) Latest(feed [32]byte) (PriceUpdate, bool)
Latest returns the most recently published price for feed, if any -- a non-blocking alternative to Subscribe for a caller that just wants a one-off read of the current value rather than an ongoing subscription (e.g. an agent tool answering a single question, not a goroutine that stays running).
func (*Hub) Publish ¶
func (h *Hub) Publish(update PriceUpdate)
Publish delivers update to every current subscriber and updates the cached "current price" new Subscribe calls receive immediately.
Non-blocking per subscriber: a subscriber whose channel is already full (it's fallen more than subscriberBuffer updates behind) has this update dropped for it rather than blocking Publish -- for a "what's the current price" stream, a slow subscriber catching up to a slightly newer value than the one it missed is the right behavior, not making every other subscriber (or the goroutine driving Run) stall on whichever one is slowest.
func (*Hub) Run ¶
func (h *Hub) Run(updateC <-chan PriceUpdate)
Run publishes every update updateC produces (typically Watch's own return value) until updateC closes -- the common "feed a Hub from one live Watch subscription" wiring, so callers don't need to write this loop themselves. Blocks until updateC closes; run it in its own goroutine.
func (*Hub) Subscribe ¶
func (h *Hub) Subscribe() (updateC <-chan PriceUpdate, unsubscribe func())
Subscribe registers a new subscriber and returns a channel of live updates plus an unsubscribe func the caller must call when done (a deferred call in the subscribing goroutine is the usual shape) to stop receiving and let Hub release the channel. Safe to call from any goroutine, any number of times, concurrently with Publish.
If Hub already knows a price for one or more feeds (Publish has been called for them before), those are sent on the returned channel immediately, before any new live update -- see Hub's own doc comment on why. The channel is closed once unsubscribe is called; it is not closed by ctx cancellation or anything else, since Hub itself has no context of its own -- callers that want cancellation should select on both the returned channel and their own ctx.Done(), and call unsubscribe when they stop reading.
type PriceUpdate ¶
type PriceUpdate struct {
Feed [32]byte
PriceUpdateV2
}
PriceUpdate is one live update delivered on Watch's channel.
type PriceUpdateV2 ¶
type PriceUpdateV2 struct {
PriceUSD float64
ConfidenceUSD float64
// FeedID is the feed this account claims to be pricing -- callers
// that need to be sure they're reading the *right* feed
// (ParsePushOracle doesn't know what feed_id to expect) should check
// this against a known value, same as Pyth's own SDK does in its
// get_price_no_older_than helpers.
FeedID [32]byte
PublishTime int64 // unix seconds
}
PriceUpdateV2 is a parsed Pyth Push Oracle price.
func ParsePushOracle ¶
func ParsePushOracle(body []byte) (PriceUpdateV2, error)
ParsePushOracle parses a Pyth Push Oracle PriceUpdateV2 account -- ported from catscope-rust-bot's src/trader/dex/pyth.rs parse_push_oracle; see that file's own doc comment for the full account layout (offset table) and how it was verified against a real mainnet account.
Account layout — PriceUpdateV2 (Anchor) ¶
offset size field ────── ──── ──────────────────────────────────────────── 0 8 Anchor discriminator 8 32 write_authority (Pubkey, unused here) 40 1 verification_level discriminant (0=Partial, 1=Full) 41 0 or 1 (Partial only) num_signatures (u8) -- only present for Partial price_message starts at 41 (Full) or 42 (Partial): +0 32 feed_id +32 8 price (i64) +40 8 conf (u64) +48 4 exponent (i32) +52 8 publish_time (i64) +60 8 prev_publish_time (i64, unused) +68 8 ema_price (i64, unused) +76 8 ema_conf (u64, unused) price_message ends at +84, followed by posted_slot (u64, unused)
VerificationLevel is a Borsh enum (Partial{num_signatures: u8} declared first = discriminant 0, Full declared second = discriminant 1) -- not a fixed-size field, so price_message's real offset depends on which variant is actually present.
Returns an error if body doesn't start with PriceUpdateV2's Anchor discriminator (wrong account, or not this format at all), has an unrecognized verification_level discriminant, or is too short for whichever variant it claims to be.
Source Files
¶
- hub.go
- pyth.go
- watcher.go