Skip to content

Commit b3ae177

Browse files
authored
Merge pull request #6 from pyth-network/connection
PythConnection wrapper for easy subscription to pyth price data
2 parents 720668f + 9f2bff4 commit b3ae177

File tree

7 files changed

+203
-24
lines changed

7 files changed

+203
-24
lines changed

README.md

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# @pythnetwork/client
22

3-
## A library for parsing on-chain Pyth oracle data
3+
## A library for reading on-chain Pyth oracle data
44

55
[Pyth](https://pyth.network/) is building a way to deliver a decentralized, cross-chain market of verifiable data from high-quality nodes to any smart contract, anywhere.
66

7-
This library consumes on-chain Pyth `accountInfo.data` from [@solana/web3.js](https://www.npmjs.com/package/@solana/web3.js) and returns JavaScript-friendly objects.
7+
This library reads on-chain Pyth data from [@solana/web3.js](https://www.npmjs.com/package/@solana/web3.js) and returns JavaScript-friendly objects.
88

99
See our [examples repo](https://github.com/pyth-network/pyth-examples) for real-world usage examples.
1010

@@ -24,24 +24,27 @@ $ yarn add @pythnetwork/client
2424

2525
## Example Usage
2626

27+
This library provides a subscription model for consuming price updates:
28+
2729
```javascript
28-
import { Connection, PublicKey } from '@solana/web3.js'
29-
import { parseMappingData, parsePriceData, parseProductData } from '@pythnetwork/client'
30-
31-
const connection = new Connection(SOLANA_CLUSTER_URL)
32-
const publicKey = new PublicKey(ORACLE_MAPPING_PUBLIC_KEY)
33-
34-
connection.getAccountInfo(publicKey).then((accountInfo) => {
35-
const { productAccountKeys } = parseMappingData(accountInfo.data)
36-
connection.getAccountInfo(productAccountKeys[productAccountKeys.length - 1]).then((accountInfo) => {
37-
const { product, priceAccountKey } = parseProductData(accountInfo.data)
38-
connection.getAccountInfo(priceAccountKey).then((accountInfo) => {
39-
const { price, confidence } = parsePriceData(accountInfo.data)
40-
console.log(`${product.symbol}: $${price} \xB1$${confidence}`)
41-
// SRM/USD: $8.68725 ±$0.0131
42-
})
43-
})
30+
const pythConnection = new PythConnection(solanaWeb3Connection, getPythProgramKeyForCluster(solanaClusterName))
31+
pythConnection.onPriceChange((product, price) => {
32+
// sample output:
33+
// SRM/USD: $8.68725 ±$0.0131
34+
console.log(`${product.symbol}: $${price.price} \xB1$${price.confidence}`)
4435
})
36+
37+
// Start listening for price change events.
38+
pythConnection.start()
4539
```
4640

47-
To get streaming price updates, you may want to use `connection.onAccountChange`
41+
The `onPriceChange` callback will be invoked every time a Pyth price gets updated.
42+
This callback gets two arguments:
43+
* `price` contains the official Pyth price and confidence, along with the component prices that were combined to produce this result.
44+
* `product` contains metadata about the price feed, such as the symbol (e.g., "BTC/USD") and the number of decimal points.
45+
46+
See `src/example_usage.ts` for a runnable example of the above usage.
47+
You can run this example with `npm run example`.
48+
49+
You may also register to specific account updates using `connection.onAccountChange` in the solana web3 API, then
50+
use the methods in `index.ts` to parse the on-chain data structures into Javascript-friendly objects.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@pythnetwork/client",
3-
"version": "2.2.0",
4-
"description": "Pyth price oracle data structures",
3+
"version": "2.3.0",
4+
"description": "Client for consuming Pyth price data",
55
"homepage": "https://pyth.network",
66
"main": "lib/index.js",
77
"types": "lib/index.d.ts",
@@ -18,7 +18,8 @@
1818
"prepublishOnly": "npm test && npm run lint",
1919
"preversion": "npm run lint",
2020
"version": "npm run format && git add -A src",
21-
"postversion": "git push && git push --tags"
21+
"postversion": "git push && git push --tags",
22+
"example": "npm run build && node lib/example_usage.js"
2223
},
2324
"keywords": [
2425
"pyth",

src/PythConnection.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import {Connection, PublicKey, clusterApiUrl, Cluster, Commitment, AccountInfo, Account} from '@solana/web3.js'
2+
import {
3+
Base, Magic,
4+
parseMappingData,
5+
parseBaseData,
6+
parsePriceData,
7+
parseProductData, Price, PriceData, Product, ProductData,
8+
Version, AccountType,
9+
} from './index'
10+
11+
const ONES = '11111111111111111111111111111111'
12+
13+
/**
14+
* Type of callback invoked whenever a pyth price account changes. The callback additionally
15+
* gets access product, which contains the metadata for this price account (e.g., that the symbol is "BTC/USD")
16+
*/
17+
export type PythPriceCallback = (product: Product, price: PriceData) => void
18+
19+
/**
20+
* Reads Pyth price data from a solana web3 connection. This class uses a callback-driven model,
21+
* similar to the solana web3 methods for tracking updates to accounts.
22+
*/
23+
export class PythConnection {
24+
connection: Connection
25+
pythProgramKey: PublicKey
26+
commitment: Commitment
27+
28+
productAccountKeyToProduct: Record<string, Product> = {}
29+
priceAccountKeyToProductAccountKey: Record<string, string> = {}
30+
31+
callbacks: PythPriceCallback[] = []
32+
33+
private handleProductAccount(key: PublicKey, account: AccountInfo<Buffer>) {
34+
const {priceAccountKey, type, product} = parseProductData(account.data)
35+
this.productAccountKeyToProduct[key.toString()] = product
36+
if (priceAccountKey.toString() !== ONES) {
37+
this.priceAccountKeyToProductAccountKey[priceAccountKey.toString()] = key.toString()
38+
}
39+
}
40+
41+
private handlePriceAccount(key: PublicKey, account: AccountInfo<Buffer>) {
42+
const product = this.productAccountKeyToProduct[this.priceAccountKeyToProductAccountKey[key.toString()]]
43+
if (product === undefined) {
44+
// This shouldn't happen since we're subscribed to all of the program's accounts,
45+
// but let's be good defensive programmers.
46+
throw new Error('Got a price update for an unknown product. This is a bug in the library, please report it to the developers.')
47+
}
48+
49+
const priceData = parsePriceData(account.data)
50+
for (let callback of this.callbacks) {
51+
callback(product, priceData)
52+
}
53+
}
54+
55+
private handleAccount(key: PublicKey, account: AccountInfo<Buffer>, productOnly: boolean) {
56+
const base = parseBaseData(account.data)
57+
// The pyth program owns accounts that don't contain pyth data, which we can safely ignore.
58+
if (base) {
59+
switch (AccountType[base.type]) {
60+
case 'Mapping':
61+
// We can skip these because we're going to get every account owned by this program anyway.
62+
break;
63+
case 'Product':
64+
this.handleProductAccount(key, account)
65+
break;
66+
case 'Price':
67+
if (!productOnly) {
68+
this.handlePriceAccount(key, account)
69+
}
70+
break;
71+
case 'Test':
72+
break;
73+
default:
74+
throw new Error(`Unknown account type: ${base.type}. Try upgrading pyth-client.`)
75+
}
76+
}
77+
}
78+
79+
/** Create a PythConnection that reads its data from an underlying solana web3 connection.
80+
* pythProgramKey is the public key of the Pyth program running on the chosen solana cluster.
81+
*/
82+
constructor(connection: Connection, pythProgramKey: PublicKey, commitment: Commitment = 'finalized') {
83+
this.connection = connection
84+
this.pythProgramKey = pythProgramKey
85+
this.commitment = commitment
86+
}
87+
88+
/** Start receiving price updates. Once this method is called, any registered callbacks will be invoked
89+
* each time a Pyth price account is updated.
90+
*/
91+
public async start() {
92+
const accounts = await this.connection.getProgramAccounts(this.pythProgramKey, this.commitment)
93+
for (let account of accounts) {
94+
this.handleAccount(account.pubkey, account.account, true)
95+
}
96+
97+
this.connection.onProgramAccountChange(
98+
this.pythProgramKey,
99+
(keyedAccountInfo, context) => {
100+
this.handleAccount(keyedAccountInfo.accountId, keyedAccountInfo.accountInfo, false)
101+
},
102+
this.commitment,
103+
)
104+
}
105+
106+
/** Register callback to receive price updates. */
107+
public onPriceChange(callback: PythPriceCallback) {
108+
this.callbacks.push(callback)
109+
}
110+
111+
/** Stop receiving price updates. Note that this also currently deletes all registered callbacks. */
112+
public async stop() {
113+
// There's no way to actually turn off the solana web3 subscription x_x, but there should be.
114+
// Leave this method in so we don't have to update our API when solana fixes theirs.
115+
// In the interim, delete callbacks.
116+
this.callbacks = []
117+
}
118+
}

src/cluster.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {Cluster, PublicKey} from '@solana/web3.js'
2+
3+
/** Mapping from solana clusters to the public key of the pyth program. */
4+
const clusterToPythProgramKey: Record<Cluster, string> = {
5+
'mainnet-beta': 'FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH',
6+
'devnet': 'gSbePebfvPy7tRqimPoVecS2UsBvYv46ynrzWocc92s',
7+
'testnet': '8tfDNiaEyrV6Q1U4DEXrEigs9DoDtkugzFbybENEbCDz',
8+
}
9+
10+
/** Gets the public key of the Pyth program running on the given cluster. */
11+
export function getPythProgramKeyForCluster(cluster: Cluster): PublicKey {
12+
return new PublicKey(clusterToPythProgramKey[cluster]);
13+
}

src/example_usage.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {Cluster, clusterApiUrl, Connection, PublicKey} from '@solana/web3.js'
2+
import { PythConnection } from './PythConnection'
3+
import { getPythProgramKeyForCluster } from './cluster'
4+
5+
const SOLANA_CLUSTER_NAME: Cluster = 'devnet'
6+
const connection = new Connection(clusterApiUrl(SOLANA_CLUSTER_NAME))
7+
const pythPublicKey = getPythProgramKeyForCluster(SOLANA_CLUSTER_NAME)
8+
9+
const pythConnection = new PythConnection(connection, pythPublicKey)
10+
pythConnection.onPriceChange((product, price) => {
11+
// sample output:
12+
// SRM/USD: $8.68725 ±$0.0131
13+
console.log(`${product.symbol}: $${price.price} \xB1$${price.confidence}`)
14+
})
15+
16+
console.log("Reading from Pyth price feed...")
17+
pythConnection.start()

src/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ import { PublicKey } from '@solana/web3.js'
22
import { Buffer } from 'buffer'
33
import { readBigInt64LE, readBigUInt64LE } from './readBig'
44

5+
/** Constants. This section must be kept in sync with the on-chain program. */
6+
57
export const Magic = 0xa1b2c3d4
68
export const Version2 = 2
79
export const Version = Version2
810
export const PriceStatus = ['Unknown', 'Trading', 'Halted', 'Auction']
911
export const CorpAction = ['NoCorpAct']
1012
export const PriceType = ['Unknown', 'Price']
1113
export const DeriveType = ['Unknown', 'TWAP', 'Volatility']
14+
export const AccountType = ['Unknown', 'Mapping', 'Product', 'Price', 'Test']
15+
16+
/** Number of slots that can pass before a publisher's price is no longer included in the aggregate. */
17+
export const MAX_SLOT_DIFFERENCE = 25
1218

1319
const empty32Buffer = Buffer.alloc(32)
1420
const PKorNull = (data: Buffer) => (data.equals(empty32Buffer) ? null : new PublicKey(data))
@@ -86,6 +92,27 @@ export interface PriceData extends Base, Price {
8692
priceComponents: PriceComponent[]
8793
}
8894

95+
/** Parse data as a generic Pyth account. Use this method if you don't know the account type. */
96+
export function parseBaseData(data: Buffer): Base | undefined {
97+
// data is too short to have the magic number.
98+
if (data.byteLength < 4) {
99+
return undefined
100+
}
101+
102+
const magic = data.readUInt32LE(0)
103+
if (magic == Magic) {
104+
// program version
105+
const version = data.readUInt32LE(4)
106+
// account type
107+
const type = data.readUInt32LE(8)
108+
// account used size
109+
const size = data.readUInt32LE(12)
110+
return { magic, version, type, size }
111+
} else {
112+
return undefined
113+
}
114+
}
115+
89116
export const parseMappingData = (data: Buffer): MappingData => {
90117
// pyth magic number
91118
const magic = data.readUInt32LE(0)

0 commit comments

Comments
 (0)