chore: remove attester and price service (#1272)
This commit is contained in:
parent
7e65fd6597
commit
f5b78e5a8c
|
@ -1,44 +0,0 @@
|
|||
name: Build and Push Attester Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- pyth-wormhole-attester-v*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dispatch_description:
|
||||
description: "Dispatch description"
|
||||
required: true
|
||||
type: string
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
jobs:
|
||||
p2w-attest-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set image tag to version of the git tag
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/pyth-wormhole-attester-v') }}
|
||||
run: |
|
||||
PREFIX="refs/tags/pyth-wormhole-attester-"
|
||||
VERSION="${GITHUB_REF:${#PREFIX}}"
|
||||
echo "IMAGE_TAG=${VERSION}" >> "${GITHUB_ENV}"
|
||||
- name: Set image tag to the git commit hash
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/pyth-wormhole-attester-v') }}
|
||||
run: |
|
||||
echo "IMAGE_TAG=${{ github.sha }}" >> "${GITHUB_ENV}"
|
||||
- uses: aws-actions/configure-aws-credentials@8a84b07f2009032ade05a88a28750d733cc30db1
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::192824654885:role/github-actions-ecr
|
||||
aws-region: eu-west-2
|
||||
- uses: docker/login-action@v2
|
||||
with:
|
||||
registry: public.ecr.aws
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
- run: |
|
||||
DOCKER_BUILDKIT=1 docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f wormhole-attester/client/Dockerfile.p2w-attest .
|
||||
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
|
||||
env:
|
||||
ECR_REGISTRY: public.ecr.aws
|
||||
ECR_REPOSITORY: pyth-network/xc-attest
|
|
@ -1,45 +0,0 @@
|
|||
name: Build and Push Price Service Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- pyth-price-server-v*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dispatch_description:
|
||||
description: "Dispatch description"
|
||||
required: true
|
||||
type: string
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
jobs:
|
||||
price-server-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set image tag to version of the git tag
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/pyth-price-server-v') }}
|
||||
run: |
|
||||
PREFIX="refs/tags/pyth-price-server-"
|
||||
VERSION="${GITHUB_REF:${#PREFIX}}"
|
||||
echo "IMAGE_TAG=${VERSION}" >> "${GITHUB_ENV}"
|
||||
- name: Set image tag to the git commit hash
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/pyth-price-server-v') }}
|
||||
run: |
|
||||
echo "IMAGE_TAG=${{ github.sha }}" >> "${GITHUB_ENV}"
|
||||
- uses: aws-actions/configure-aws-credentials@8a84b07f2009032ade05a88a28750d733cc30db1
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::192824654885:role/github-actions-ecr
|
||||
aws-region: eu-west-2
|
||||
- uses: docker/login-action@v2
|
||||
with:
|
||||
registry: public.ecr.aws
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
- run: |
|
||||
DOCKER_BUILDKIT=1 docker build -t lerna -f Dockerfile.lerna .
|
||||
DOCKER_BUILDKIT=1 docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f price_service/server/Dockerfile .
|
||||
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
|
||||
env:
|
||||
ECR_REGISTRY: public.ecr.aws
|
||||
ECR_REPOSITORY: pyth-network/xc-server
|
|
@ -23,6 +23,7 @@
|
|||
"@pythnetwork/client": "^2.17.0",
|
||||
"@solana/web3.js": "^1.76.0",
|
||||
"@sqds/mesh": "^1.0.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"cors": "^2.8.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"xc_admin_common": "*"
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -4,7 +4,6 @@
|
|||
"governance/xc_admin/packages/*",
|
||||
"governance/multisig_wh_message_builder",
|
||||
"price_pusher",
|
||||
"price_service/server",
|
||||
"price_service/sdk/js",
|
||||
"price_service/client/js",
|
||||
"pythnet/message_buffer",
|
||||
|
@ -19,7 +18,6 @@
|
|||
"target_chains/ethereum/examples/oracle_swap/app",
|
||||
"target_chains/sui/sdk/js",
|
||||
"target_chains/sui/cli",
|
||||
"wormhole_attester/sdk/js",
|
||||
"contract_manager"
|
||||
],
|
||||
"dependencies": {
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
node_modules
|
||||
lib
|
||||
.env
|
|
@ -1,24 +0,0 @@
|
|||
# Local DevNet:
|
||||
SPY_SERVICE_HOST=0.0.0.0:7072
|
||||
|
||||
# Filters (if provided) should be valid JSON like below:
|
||||
# These filters tell the spy to only retrieve messages sent from certain chains/contracts.
|
||||
# See the docker-compose.<network>.yaml files for the appropriate configuration for a
|
||||
# testnet/mainnet pyth price_service deployment.
|
||||
SPY_SERVICE_FILTERS=[{"chain_id":1,"emitter_address":"71f8dcb863d176e2c420ad6610cf687359612b6fb392e0642b0ca6b1f186aa3b"}]
|
||||
|
||||
WORMHOLE_CLUSTER=localnet
|
||||
|
||||
# Number of seconds to sync with spy to be sure to have latest messages
|
||||
READINESS_SPY_SYNC_TIME_SECONDS=60
|
||||
READINESS_NUM_LOADED_SYMBOLS=5
|
||||
|
||||
WS_PORT=6200
|
||||
REST_PORT=4200
|
||||
PROM_PORT=8081
|
||||
|
||||
# The default is to log with level info.
|
||||
#LOG_LEVEL=debug
|
||||
|
||||
REMOVE_EXPIRED_VALUES_INTERVAL_SECONDS=60
|
||||
CACHE_TTL_SECONDS=300
|
|
@ -1 +0,0 @@
|
|||
/lib
|
|
@ -1,18 +0,0 @@
|
|||
# Defined in Dockerfile.lerna
|
||||
FROM lerna
|
||||
|
||||
USER root
|
||||
RUN apt-get update && apt-get install -y ncat
|
||||
|
||||
WORKDIR /home/node/
|
||||
USER 1000
|
||||
|
||||
COPY --chown=1000:1000 price_service/server price_service/server
|
||||
COPY --chown=1000:1000 price_service/sdk/js price_service/sdk/js
|
||||
COPY --chown=1000:1000 wormhole_attester/sdk/js wormhole_attester/sdk/js
|
||||
|
||||
RUN npx lerna run build --scope="@pythnetwork/price-service-server" --include-dependencies
|
||||
|
||||
WORKDIR /home/node/price_service/server
|
||||
|
||||
CMD [ "npm", "run", "start" ]
|
|
@ -1,72 +0,0 @@
|
|||
# Pyth Price Service
|
||||
|
||||
** Pyth price service is deprecated. Please use [Hermes](../../hermes/) instead. **
|
||||
|
||||
The Pyth price service is a webservice that listens to the Wormhole Network for Pyth price updates and serves them via a
|
||||
convenient web API. The service allows users to easily query for recent price updates via a REST API, or subscribe to
|
||||
a websocket for streaming updates. [Price service JS client](https://github.com/pyth-network/pyth-crosschain/tree/main/price_service/sdk/js) connects
|
||||
to an instance of the price service in order to fetch on-demand price updates.
|
||||
|
||||
## Wormhole Spy
|
||||
|
||||
The price service depends on a Wormhole Spy to stream Pyth messages from the Wormhole Network to it. The
|
||||
[spy](https://github.com/wormhole-foundation/wormhole/blob/main/node/cmd/spy/spy.go) is a Wormhole component that listens to the Wormhole verified
|
||||
messages from the Wormhole Network peer-to-peer network; then, it streams the messages that are coming from certain emitters (e.g., Pyth data emitters) to its subscribers.
|
||||
|
||||
The price service subscribes to the spy to fetch all verified prices coming from the Pyth data sources. The Pyth data sources should
|
||||
be defined in `SPY_SERVICE_FILTERS` environment variable as a JSON array.
|
||||
|
||||
## Run
|
||||
|
||||
This repository contains testnet and mainnet docker-compose files to run
|
||||
both the price service and spy. To run the mainnet docker compose file run
|
||||
the following command:
|
||||
|
||||
```
|
||||
docker compose -f docker-compose.mainnet.yaml up
|
||||
```
|
||||
|
||||
Now your own instance of the price service is up and running! Running the following command should give you the Pyth price of ETH/USD :
|
||||
|
||||
```
|
||||
curl localhost:4200/api/latest_price_feeds?ids[]=0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace
|
||||
```
|
||||
|
||||
If everything is working, you should get an output like this :
|
||||
|
||||
```
|
||||
[{"ema_price":{"conf":"52359655","expo":-8,"price":"169041770000","publish_time":1675365813},"id":"ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace","price":{"conf":"64041644","expo":-8,"price":"167043958356","publish_time":1675365813}}]
|
||||
```
|
||||
|
||||
The compose files use a public release of Pyth price service and spy. If you wish to change the
|
||||
price service you should:
|
||||
|
||||
1. Build an image for using it first according to the section below.
|
||||
2. Change the price service image to your local docker image (e.g., `pyth_price_server`)
|
||||
|
||||
### Self-Hosting
|
||||
|
||||
If you would like to host your own instance of the price service, we recommend running the process on a 4 core machine with 4 GB of RAM.
|
||||
We also recommend using a host like [Latitude](https://www.latitude.sh/) or [Hetzner](https://www.hetzner.com/) and avoiding cloud service providers like AWS in order to reduce the cost.
|
||||
The price service participates in a peer-to-peer network which can use a lot of bandwidth.
|
||||
Cloud hosts like AWS charge high fees for bandwidth, which makes running the service much more expensive than necessary.
|
||||
Using one of the recommended hosts above should cost $10-50 / month.
|
||||
|
||||
## Build an image
|
||||
|
||||
Build the image from [the repo root](../../) like below. It will create a
|
||||
local image named `pyth_price_server`.
|
||||
|
||||
```
|
||||
$ docker buildx build -f tilt_devnet/docker_images/Dockerfile.lerna -t lerna .
|
||||
$ docker buildx build -f price_service/server/Dockerfile -t pyth_price_server .
|
||||
```
|
||||
|
||||
If you wish to build price service without docker, please follow the instruction of the price service
|
||||
[`Dockerfile`](./Dockerfile)
|
||||
|
||||
## Known Issues
|
||||
|
||||
The spy sometimes fails to connect to the peer-to-peer network on initialization. If this happens, the price
|
||||
service will not be able to retrieve any data. You can fix this problem by quitting, removing the containers from Docker,
|
||||
then restarting both containers. Simply stopping and starting the services tends not to work.
|
|
@ -1,61 +0,0 @@
|
|||
services:
|
||||
spy:
|
||||
# Find latest Guardian images in https://github.com/wormhole-foundation/wormhole/pkgs/container/guardiand
|
||||
image: ghcr.io/wormhole-foundation/guardiand:v2.23.28
|
||||
restart: on-failure
|
||||
command:
|
||||
- "spy"
|
||||
- "--nodeKey"
|
||||
- "/node.key"
|
||||
- "--spyRPC"
|
||||
- "[::]:7072"
|
||||
- "--bootstrap"
|
||||
- "/dns4/wormhole-v2-mainnet-bootstrap.xlabs.xyz/udp/8999/quic/p2p/12D3KooWNQ9tVrcb64tw6bNs2CaNrUGPM7yRrKvBBheQ5yCyPHKC,/dns4/wormhole.mcf.rocks/udp/8999/quic/p2p/12D3KooWDZVv7BhZ8yFLkarNdaSWaB43D6UbQwExJ8nnGAEmfHcU,/dns4/wormhole-v2-mainnet-bootstrap.staking.fund/udp/8999/quic/p2p/12D3KooWG8obDX9DNi1KUwZNu9xkGwfKqTp2GFwuuHpWZ3nQruS1"
|
||||
- "--network"
|
||||
- "/wormhole/mainnet/2"
|
||||
- "--logLevel"
|
||||
- "warn"
|
||||
price-service:
|
||||
# Find latest price service images https://gallery.ecr.aws/pyth-network/xc-server
|
||||
image: public.ecr.aws/pyth-network/xc-server:v3.0.8
|
||||
restart: on-failure
|
||||
# Or alternatively use a locally built image
|
||||
# image: pyth_price_server
|
||||
environment:
|
||||
SPY_SERVICE_HOST: "spy:7072"
|
||||
SPY_SERVICE_FILTERS: |
|
||||
[
|
||||
{
|
||||
"chain_id": 1,
|
||||
"emitter_address": "6bb14509a612f01fbbc4cffeebd4bbfb492a86df717ebe92eb6df432a3f00a25"
|
||||
},
|
||||
{
|
||||
"chain_id": 26,
|
||||
"emitter_address": "f8cd23c2ab91237730770bbea08d61005cdda0984348f3f6eecb559638c0bba0"
|
||||
}
|
||||
]
|
||||
REST_PORT: "4200"
|
||||
PROM_PORT: "8081"
|
||||
READINESS_SPY_SYNC_TIME_SECONDS: "5"
|
||||
READINESS_NUM_LOADED_SYMBOLS: "280"
|
||||
LOG_LEVEL: warning
|
||||
WORMHOLE_CLUSTER: mainnet
|
||||
DB_API_CLUSTER: pythnet
|
||||
REMOVE_EXPIRED_VALUES_INTERVAL_SECONDS: "60"
|
||||
CACHE_TTL_SECONDS: "300"
|
||||
DB_API_ENDPOINT: "https://web-api.pyth.network"
|
||||
ports:
|
||||
- "4200:4200"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:4200/ready",
|
||||
]
|
||||
start_period: 20s
|
||||
depends_on:
|
||||
- spy
|
|
@ -1,61 +0,0 @@
|
|||
services:
|
||||
spy:
|
||||
# Find latest Guardian images in https://github.com/wormhole-foundation/wormhole/pkgs/container/guardiand
|
||||
image: ghcr.io/wormhole-foundation/guardiand:v2.23.28
|
||||
restart: on-failure
|
||||
command:
|
||||
- "spy"
|
||||
- "--nodeKey"
|
||||
- "/node.key"
|
||||
- "--spyRPC"
|
||||
- "[::]:7072"
|
||||
- "--bootstrap"
|
||||
- "/dns4/t-guardian-01.nodes.stable.io/udp/8999/quic/p2p/12D3KooWCW3LGUtkCVkHZmVSZHzL3C4WRKWfqAiJPz1NR7dT9Bxh,/dns4/t-guardian-02.nodes.stable.io/udp/8999/quic/p2p/12D3KooWJXA6goBCiWM8ucjzc4jVUBSqL9Rri6UpjHbkMPErz5zK"
|
||||
- "--network"
|
||||
- "/wormhole/testnet/2/1"
|
||||
- "--logLevel"
|
||||
- "warn"
|
||||
price-service:
|
||||
# Find latest price service images https://gallery.ecr.aws/pyth-network/xc-server
|
||||
image: public.ecr.aws/pyth-network/xc-server:v3.0.8
|
||||
restart: on-failure
|
||||
# Or alternatively use a locally built image
|
||||
# image: pyth_price_server
|
||||
environment:
|
||||
SPY_SERVICE_HOST: "spy:7072"
|
||||
SPY_SERVICE_FILTERS: |
|
||||
[
|
||||
{
|
||||
"chain_id": 1,
|
||||
"emitter_address": "f346195ac02f37d60d4db8ffa6ef74cb1be3550047543a4a9ee9acf4d78697b0"
|
||||
},
|
||||
{
|
||||
"chain_id": 26,
|
||||
"emitter_address": "a27839d641b07743c0cb5f68c51f8cd31d2c0762bec00dc6fcd25433ef1ab5b6"
|
||||
}
|
||||
]
|
||||
REST_PORT: "4200"
|
||||
PROM_PORT: "8081"
|
||||
READINESS_SPY_SYNC_TIME_SECONDS: "5"
|
||||
READINESS_NUM_LOADED_SYMBOLS: "280"
|
||||
LOG_LEVEL: warning
|
||||
WORMHOLE_CLUSTER: testnet
|
||||
DB_API_CLUSTER: devnet
|
||||
REMOVE_EXPIRED_VALUES_INTERVAL_SECONDS: "60"
|
||||
CACHE_TTL_SECONDS: "300"
|
||||
DB_API_ENDPOINT: "https://web-api.pyth.network"
|
||||
ports:
|
||||
- "4200:4200"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:4200/ready",
|
||||
]
|
||||
start_period: 20s
|
||||
depends_on:
|
||||
- spy
|
|
@ -1,5 +0,0 @@
|
|||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
};
|
|
@ -1,69 +0,0 @@
|
|||
{
|
||||
"name": "@pythnetwork/price-service-server",
|
||||
"version": "3.1.2",
|
||||
"description": "Webservice for retrieving prices from the Pyth oracle.",
|
||||
"private": "true",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"build": "tsc",
|
||||
"start": "node lib/index.js",
|
||||
"test": "jest src/",
|
||||
"lint": "tslint -p tsconfig.json",
|
||||
"preversion": "npm run lint",
|
||||
"version": "npm run format && git add -A src"
|
||||
},
|
||||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/keccak": "^3.0.1",
|
||||
"@types/long": "^4.0.1",
|
||||
"@types/node": "^16.6.1",
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/secp256k1": "^4.0.3",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"jest": "^29.4.0",
|
||||
"prettier": "^2.3.2",
|
||||
"supertest": "^6.2.3",
|
||||
"ts-jest": "^29.0.5",
|
||||
"tslint": "^6.1.3",
|
||||
"tslint-config-prettier": "^1.18.0",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@certusone/wormhole-sdk": "^0.9.9",
|
||||
"@certusone/wormhole-spydk": "^0.0.1",
|
||||
"@pythnetwork/price-service-sdk": "*",
|
||||
"@pythnetwork/wormhole-attester-sdk": "*",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/morgan": "^1.9.3",
|
||||
"@types/response-time": "^2.3.5",
|
||||
"@types/ws": "^8.5.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^10.0.0",
|
||||
"express": "^4.17.2",
|
||||
"express-validation": "^4.0.1",
|
||||
"http-status-codes": "^2.2.0",
|
||||
"joi": "^17.6.0",
|
||||
"keccak": "^3.0.3",
|
||||
"lru-cache": "^7.14.1",
|
||||
"morgan": "^1.10.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"prom-client": "^14.0.1",
|
||||
"response-time": "^2.3.2",
|
||||
"secp256k1": "^5.0.0",
|
||||
"ts-retry-promise": "^0.7.0",
|
||||
"winston": "^3.3.3",
|
||||
"ws": "^8.12.0"
|
||||
},
|
||||
"directories": {
|
||||
"lib": "lib"
|
||||
},
|
||||
"keywords": [],
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.6",
|
||||
"utf-8-validate": "^5.0.9"
|
||||
}
|
||||
}
|
|
@ -1,139 +0,0 @@
|
|||
import { VaaConfig, VaaCache } from "../listen";
|
||||
|
||||
describe("VAA Cache works", () => {
|
||||
test("Setting and getting works as expected", async () => {
|
||||
const cache = new VaaCache();
|
||||
|
||||
expect(cache.get("a", 3)).toBeUndefined();
|
||||
|
||||
cache.set("a", 1, 0, "a-1");
|
||||
|
||||
expect(cache.get("a", 3)).toBeUndefined();
|
||||
|
||||
cache.set("a", 4, 3, "a-2");
|
||||
|
||||
expect(cache.get("a", 3)).toEqual<VaaConfig>({
|
||||
publishTime: 4,
|
||||
lastAttestedPublishTime: 3,
|
||||
vaa: "a-2",
|
||||
});
|
||||
|
||||
cache.set("a", 10, 9, "a-3");
|
||||
cache.set("a", 10, 10, "a-4");
|
||||
cache.set("a", 10, 10, "a-5");
|
||||
cache.set("a", 10, 10, "a-6");
|
||||
cache.set("a", 11, 11, "a-7");
|
||||
|
||||
// Adding some elements with other keys to make sure
|
||||
// they are not stored separately.
|
||||
cache.set("b", 3, 2, "b-1");
|
||||
cache.set("b", 7, 6, "b-2");
|
||||
cache.set("b", 9, 8, "b-3");
|
||||
|
||||
expect(cache.get("a", 3)).toEqual<VaaConfig>({
|
||||
publishTime: 4,
|
||||
lastAttestedPublishTime: 3,
|
||||
vaa: "a-2",
|
||||
});
|
||||
expect(cache.get("a", 4)).toEqual<VaaConfig>({
|
||||
publishTime: 4,
|
||||
lastAttestedPublishTime: 3,
|
||||
vaa: "a-2",
|
||||
});
|
||||
expect(cache.get("a", 5)).toEqual<VaaConfig>({
|
||||
publishTime: 10,
|
||||
lastAttestedPublishTime: 9,
|
||||
vaa: "a-3",
|
||||
});
|
||||
// There are multiple elements at 10, but we prefer to return the one with a lower lastAttestedPublishTime.
|
||||
expect(cache.get("a", 10)).toEqual<VaaConfig>({
|
||||
publishTime: 10,
|
||||
lastAttestedPublishTime: 9,
|
||||
vaa: "a-3",
|
||||
});
|
||||
// If the cache only contains elements where the lastAttestedPublishTime==publishTime, those will be returned.
|
||||
// Note that this behavior is undesirable (as this means we can return a noncanonical VAA for a query time);
|
||||
// this test simply documents it.
|
||||
expect(cache.get("a", 11)).toEqual<VaaConfig>({
|
||||
publishTime: 11,
|
||||
lastAttestedPublishTime: 11,
|
||||
vaa: "a-7",
|
||||
});
|
||||
|
||||
expect(cache.get("b", 3)).toEqual<VaaConfig>({
|
||||
publishTime: 3,
|
||||
lastAttestedPublishTime: 2,
|
||||
vaa: "b-1",
|
||||
});
|
||||
expect(cache.get("b", 4)).toEqual<VaaConfig>({
|
||||
publishTime: 7,
|
||||
lastAttestedPublishTime: 6,
|
||||
vaa: "b-2",
|
||||
});
|
||||
|
||||
// When no item item more recent than asked pubTime is asked it should return undefined
|
||||
expect(cache.get("a", 12)).toBeUndefined();
|
||||
expect(cache.get("b", 10)).toBeUndefined();
|
||||
|
||||
// When the asked pubTime is less than the first existing pubTime we are not sure that
|
||||
// this is the first vaa after that time, so we should return undefined.
|
||||
expect(cache.get("a", 0)).toBeUndefined();
|
||||
expect(cache.get("b", 1)).toBeUndefined();
|
||||
expect(cache.get("b", 2)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removeExpiredValues clears the old values", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// TTL of 500 seconds for the cache
|
||||
const cache = new VaaCache(500);
|
||||
|
||||
cache.set("a", 300, 299, "a-1");
|
||||
cache.set("a", 700, 699, "a-2");
|
||||
cache.set("a", 900, 899, "a-3");
|
||||
|
||||
expect(cache.get("a", 300)).toEqual<VaaConfig>({
|
||||
publishTime: 300,
|
||||
lastAttestedPublishTime: 299,
|
||||
vaa: "a-1",
|
||||
});
|
||||
|
||||
expect(cache.get("a", 500)).toEqual<VaaConfig>({
|
||||
publishTime: 700,
|
||||
lastAttestedPublishTime: 699,
|
||||
vaa: "a-2",
|
||||
});
|
||||
|
||||
// Set time to second 1000
|
||||
jest.setSystemTime(1000 * 1000);
|
||||
|
||||
cache.removeExpiredValues();
|
||||
|
||||
expect(cache.get("a", 300)).toBeUndefined();
|
||||
expect(cache.get("a", 500)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("the cache clean loop works", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// TTL of 500 seconds for the cache and cleanup of every 100 seconds
|
||||
const cache = new VaaCache(500, 100);
|
||||
cache.runRemoveExpiredValuesLoop();
|
||||
|
||||
cache.set("a", 300, 299, "a-1");
|
||||
cache.set("a", 700, 699, "a-2");
|
||||
cache.set("a", 900, 899, "a-3");
|
||||
|
||||
expect(cache.get("a", 900)).toEqual<VaaConfig>({
|
||||
publishTime: 900,
|
||||
lastAttestedPublishTime: 899,
|
||||
vaa: "a-3",
|
||||
});
|
||||
|
||||
// Set time to second 2000. Everything should be evicted from cache now.
|
||||
jest.setSystemTime(2000 * 1000);
|
||||
jest.advanceTimersToNextTimer();
|
||||
|
||||
expect(cache.get("a", 900)).toBeUndefined();
|
||||
});
|
||||
});
|
|
@ -1,566 +0,0 @@
|
|||
import {
|
||||
HexString,
|
||||
Price,
|
||||
PriceFeed,
|
||||
PriceFeedMetadata,
|
||||
} from "@pythnetwork/price-service-sdk";
|
||||
import express, { Express } from "express";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import request from "supertest";
|
||||
import { PriceInfo, PriceStore, VaaCache, VaaConfig } from "../listen";
|
||||
import { RestAPI, VaaResponse } from "../rest";
|
||||
|
||||
let priceInfo: PriceStore;
|
||||
let app: Express;
|
||||
let priceInfoMap: Map<string, PriceInfo>;
|
||||
let vaasCache: VaaCache;
|
||||
|
||||
function expandTo64Len(id: string): string {
|
||||
return id.repeat(64).substring(0, 64);
|
||||
}
|
||||
|
||||
function dummyPriceFeed(id: string): PriceFeed {
|
||||
return new PriceFeed({
|
||||
emaPrice: new Price({
|
||||
conf: "1",
|
||||
expo: 2,
|
||||
price: "3",
|
||||
publishTime: 4,
|
||||
}),
|
||||
id,
|
||||
price: new Price({
|
||||
conf: "5",
|
||||
expo: 6,
|
||||
price: "7",
|
||||
publishTime: 8,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function dummyPriceInfoPair(
|
||||
id: HexString,
|
||||
seqNum: number,
|
||||
vaa: HexString
|
||||
): [HexString, PriceInfo] {
|
||||
return [
|
||||
id,
|
||||
{
|
||||
priceFeed: dummyPriceFeed(id),
|
||||
publishTime: 1,
|
||||
attestationTime: 2,
|
||||
seqNum,
|
||||
vaa: Buffer.from(vaa, "hex"),
|
||||
emitterChainId: 0,
|
||||
priceServiceReceiveTime: 0,
|
||||
lastAttestedPublishTime: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Add some dummy data to the provided vaa cache.
|
||||
function addAbcdDataToCache(id: string, cache: VaaCache) {
|
||||
cache.set(id, 10, 9, "abcd10");
|
||||
cache.set(id, 20, 19, "abcd20");
|
||||
cache.set(id, 30, 29, "abcd30");
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
priceInfoMap = new Map<string, PriceInfo>([
|
||||
dummyPriceInfoPair(expandTo64Len("abcd"), 1, "a1b2c3d4"),
|
||||
dummyPriceInfoPair(expandTo64Len("ef01"), 1, "a1b2c3d4"),
|
||||
dummyPriceInfoPair(expandTo64Len("3456"), 2, "bad01bad"),
|
||||
dummyPriceInfoPair(expandTo64Len("10101"), 3, "bidbidbid"),
|
||||
]);
|
||||
|
||||
vaasCache = new VaaCache();
|
||||
|
||||
priceInfo = {
|
||||
getLatestPriceInfo: (priceFeedId: string) => {
|
||||
return priceInfoMap.get(priceFeedId);
|
||||
},
|
||||
addUpdateListener: (_callback: (priceInfo: PriceInfo) => any) => undefined,
|
||||
getPriceIds: () => new Set(),
|
||||
getVaa: (vaasCacheKey: string, publishTime: number) => {
|
||||
return vaasCache.get(vaasCacheKey, publishTime);
|
||||
},
|
||||
};
|
||||
|
||||
const api = new RestAPI({ port: 8889 }, priceInfo, () => true);
|
||||
app = await api.createApp();
|
||||
});
|
||||
|
||||
describe("Latest Price Feed Endpoint", () => {
|
||||
test("When called with valid ids, returns correct price feed", async () => {
|
||||
const ids = [expandTo64Len("abcd"), expandTo64Len("3456")];
|
||||
const resp = await request(app)
|
||||
.get("/api/latest_price_feeds")
|
||||
.query({ ids });
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body.length).toBe(2);
|
||||
expect(resp.body).toContainEqual(dummyPriceFeed(ids[0]).toJson());
|
||||
expect(resp.body).toContainEqual(dummyPriceFeed(ids[1]).toJson());
|
||||
});
|
||||
|
||||
test("When called with valid ids with leading 0x, returns correct price feed", async () => {
|
||||
const ids = [expandTo64Len("abcd"), expandTo64Len("3456")];
|
||||
const resp = await request(app)
|
||||
.get("/api/latest_price_feeds")
|
||||
.query({
|
||||
ids: ids.map((id) => "0x" + id), // Add 0x to the queries
|
||||
});
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body.length).toBe(2);
|
||||
|
||||
// Please note that the response id is without 0x
|
||||
expect(resp.body).toContainEqual(dummyPriceFeed(ids[0]).toJson());
|
||||
expect(resp.body).toContainEqual(dummyPriceFeed(ids[1]).toJson());
|
||||
});
|
||||
|
||||
test("When called with valid ids and verbose flag set to true, returns correct price feed with verbose information", async () => {
|
||||
const ids = [expandTo64Len("abcd"), expandTo64Len("3456")];
|
||||
const resp = await request(app)
|
||||
.get("/api/latest_price_feeds")
|
||||
.query({ ids, verbose: true });
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body.length).toBe(2);
|
||||
expect(resp.body).toContainEqual({
|
||||
...priceInfoMap.get(ids[0])!.priceFeed.toJson(),
|
||||
metadata: new PriceFeedMetadata({
|
||||
attestationTime: priceInfoMap.get(ids[0])!.attestationTime,
|
||||
emitterChain: priceInfoMap.get(ids[0])!.emitterChainId,
|
||||
receiveTime: priceInfoMap.get(ids[0])!.priceServiceReceiveTime,
|
||||
sequenceNumber: priceInfoMap.get(ids[0])!.seqNum,
|
||||
}).toJson(),
|
||||
});
|
||||
expect(resp.body).toContainEqual({
|
||||
...priceInfoMap.get(ids[1])!.priceFeed.toJson(),
|
||||
metadata: new PriceFeedMetadata({
|
||||
attestationTime: priceInfoMap.get(ids[1])!.attestationTime,
|
||||
emitterChain: priceInfoMap.get(ids[1])!.emitterChainId,
|
||||
receiveTime: priceInfoMap.get(ids[1])!.priceServiceReceiveTime,
|
||||
sequenceNumber: priceInfoMap.get(ids[1])!.seqNum,
|
||||
}).toJson(),
|
||||
});
|
||||
});
|
||||
|
||||
test("When called with valid ids and binary flag set to true, returns correct price feed with binary vaa", async () => {
|
||||
const ids = [expandTo64Len("abcd"), expandTo64Len("3456")];
|
||||
const resp = await request(app)
|
||||
.get("/api/latest_price_feeds")
|
||||
.query({ ids, binary: true });
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body.length).toBe(2);
|
||||
expect(resp.body).toContainEqual({
|
||||
...priceInfoMap.get(ids[0])!.priceFeed.toJson(),
|
||||
vaa: priceInfoMap.get(ids[0])!.vaa.toString("base64"),
|
||||
});
|
||||
expect(resp.body).toContainEqual({
|
||||
...priceInfoMap.get(ids[1])!.priceFeed.toJson(),
|
||||
vaa: priceInfoMap.get(ids[1])!.vaa.toString("base64"),
|
||||
});
|
||||
});
|
||||
|
||||
test("When called with a target_chain, returns correct price feed with binary vaa encoded properly", async () => {
|
||||
const ids = [expandTo64Len("abcd"), expandTo64Len("3456")];
|
||||
const resp = await request(app)
|
||||
.get("/api/latest_price_feeds")
|
||||
.query({ ids, target_chain: "evm" });
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body.length).toBe(2);
|
||||
expect(resp.body).toContainEqual({
|
||||
...priceInfoMap.get(ids[0])!.priceFeed.toJson(),
|
||||
vaa: "0x" + priceInfoMap.get(ids[0])!.vaa.toString("hex"),
|
||||
});
|
||||
expect(resp.body).toContainEqual({
|
||||
...priceInfoMap.get(ids[1])!.priceFeed.toJson(),
|
||||
vaa: "0x" + priceInfoMap.get(ids[1])!.vaa.toString("hex"),
|
||||
});
|
||||
});
|
||||
|
||||
test("When called with some non-existent ids within ids, returns error mentioning non-existent ids", async () => {
|
||||
const ids = [
|
||||
expandTo64Len("ab01"),
|
||||
expandTo64Len("3456"),
|
||||
expandTo64Len("effe"),
|
||||
];
|
||||
const resp = await request(app)
|
||||
.get("/api/latest_price_feeds")
|
||||
.query({ ids });
|
||||
expect(resp.status).toBe(StatusCodes.BAD_REQUEST);
|
||||
expect(resp.body.message).toContain(ids[0]);
|
||||
expect(resp.body.message).not.toContain(ids[1]);
|
||||
expect(resp.body.message).toContain(ids[2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Latest Vaa Bytes Endpoint", () => {
|
||||
test("When called with valid ids, returns vaa bytes as array, merged if necessary", async () => {
|
||||
const ids = [
|
||||
expandTo64Len("abcd"),
|
||||
expandTo64Len("ef01"),
|
||||
expandTo64Len("3456"),
|
||||
];
|
||||
const resp = await request(app).get("/api/latest_vaas").query({ ids });
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body.length).toBe(2);
|
||||
expect(resp.body).toContain(
|
||||
Buffer.from("a1b2c3d4", "hex").toString("base64")
|
||||
);
|
||||
expect(resp.body).toContain(
|
||||
Buffer.from("bad01bad", "hex").toString("base64")
|
||||
);
|
||||
});
|
||||
|
||||
test("When called with target_chain, returns vaa bytes encoded correctly", async () => {
|
||||
const ids = [
|
||||
expandTo64Len("abcd"),
|
||||
expandTo64Len("ef01"),
|
||||
expandTo64Len("3456"),
|
||||
];
|
||||
const resp = await request(app)
|
||||
.get("/api/latest_vaas")
|
||||
.query({ ids, target_chain: "evm" });
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body.length).toBe(2);
|
||||
expect(resp.body).toContain("0xa1b2c3d4");
|
||||
expect(resp.body).toContain("0xbad01bad");
|
||||
});
|
||||
|
||||
test("When called with valid ids with leading 0x, returns vaa bytes as array, merged if necessary", async () => {
|
||||
const ids = [
|
||||
expandTo64Len("abcd"),
|
||||
expandTo64Len("ef01"),
|
||||
expandTo64Len("3456"),
|
||||
];
|
||||
|
||||
const resp = await request(app)
|
||||
.get("/api/latest_vaas")
|
||||
.query({
|
||||
ids: ids.map((id) => "0x" + id), // Add 0x to the queries
|
||||
});
|
||||
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body.length).toBe(2);
|
||||
expect(resp.body).toContain(
|
||||
Buffer.from("a1b2c3d4", "hex").toString("base64")
|
||||
);
|
||||
expect(resp.body).toContain(
|
||||
Buffer.from("bad01bad", "hex").toString("base64")
|
||||
);
|
||||
});
|
||||
|
||||
test("When called with some non-existent ids within ids, returns error mentioning non-existent ids", async () => {
|
||||
const ids = [
|
||||
expandTo64Len("ab01"),
|
||||
expandTo64Len("3456"),
|
||||
expandTo64Len("effe"),
|
||||
];
|
||||
const resp = await request(app).get("/api/latest_vaas").query({ ids });
|
||||
expect(resp.status).toBe(StatusCodes.BAD_REQUEST);
|
||||
expect(resp.body.message).toContain(ids[0]);
|
||||
expect(resp.body.message).not.toContain(ids[1]);
|
||||
expect(resp.body.message).toContain(ids[2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Get VAA endpoint and Get VAA CCIP", () => {
|
||||
test("When called with valid id and timestamp in the cache returns the correct answer", async () => {
|
||||
const id = expandTo64Len("abcd");
|
||||
addAbcdDataToCache(id, vaasCache);
|
||||
|
||||
const resp = await request(app).get("/api/get_vaa").query({
|
||||
id,
|
||||
publish_time: 16,
|
||||
});
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body).toEqual<VaaResponse>({
|
||||
vaa: "abcd20",
|
||||
publishTime: 20,
|
||||
});
|
||||
|
||||
const pubTime16AsHex64Bit = "0000000000000010";
|
||||
const ccipResp = await request(app)
|
||||
.get("/api/get_vaa_ccip")
|
||||
.query({
|
||||
data: "0x" + id + pubTime16AsHex64Bit,
|
||||
});
|
||||
expect(ccipResp.status).toBe(StatusCodes.OK);
|
||||
expect(ccipResp.body).toEqual({
|
||||
data: "0x" + Buffer.from("abcd20", "base64").toString("hex"),
|
||||
});
|
||||
});
|
||||
|
||||
test("When called with valid id with leading 0x and timestamp in the cache returns the correct answer", async () => {
|
||||
const id = expandTo64Len("abcd");
|
||||
addAbcdDataToCache(id, vaasCache);
|
||||
|
||||
const resp = await request(app)
|
||||
.get("/api/get_vaa")
|
||||
.query({
|
||||
id: "0x" + id,
|
||||
publish_time: 16,
|
||||
});
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body).toEqual<VaaResponse>({
|
||||
vaa: "abcd20",
|
||||
publishTime: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test("When called with target_chain, encodes resulting VAA in the right format", async () => {
|
||||
const id = expandTo64Len("abcd");
|
||||
addAbcdDataToCache(id, vaasCache);
|
||||
|
||||
const resp = await request(app)
|
||||
.get("/api/get_vaa")
|
||||
.query({
|
||||
id: "0x" + id,
|
||||
publish_time: 16,
|
||||
target_chain: "evm",
|
||||
});
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body).toEqual<VaaResponse>({
|
||||
vaa: "0x" + Buffer.from("abcd20", "base64").toString("hex"),
|
||||
publishTime: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test("When called with invalid id returns price id found", async () => {
|
||||
// dead does not exist in the ids
|
||||
const id = expandTo64Len("dead");
|
||||
|
||||
const resp = await request(app).get("/api/get_vaa").query({
|
||||
id,
|
||||
publish_time: 16,
|
||||
});
|
||||
expect(resp.status).toBe(StatusCodes.BAD_REQUEST);
|
||||
expect(resp.body.message).toContain(id);
|
||||
|
||||
const pubTime16AsHex64Bit = "0000000000000010";
|
||||
const ccipResp = await request(app)
|
||||
.get("/api/get_vaa_ccip")
|
||||
.query({
|
||||
data: "0x" + id + pubTime16AsHex64Bit,
|
||||
});
|
||||
expect(ccipResp.status).toBe(StatusCodes.BAD_REQUEST);
|
||||
expect(ccipResp.body.message).toContain(id);
|
||||
});
|
||||
|
||||
test("When called with valid id and timestamp not in the cache without db returns vaa not found", async () => {
|
||||
const id = expandTo64Len("abcd");
|
||||
addAbcdDataToCache(id, vaasCache);
|
||||
|
||||
const resp = await request(app)
|
||||
.get("/api/get_vaa")
|
||||
.query({
|
||||
id: "0x" + id,
|
||||
publish_time: 5,
|
||||
});
|
||||
expect(resp.status).toBe(StatusCodes.NOT_FOUND);
|
||||
|
||||
const pubTime5AsHex64Bit = "0000000000000005";
|
||||
const ccipResp = await request(app)
|
||||
.get("/api/get_vaa_ccip")
|
||||
.query({
|
||||
data: "0x" + id + pubTime5AsHex64Bit,
|
||||
});
|
||||
// On CCIP we expect bad gateway so the client want to retry other ccip endpoints.
|
||||
expect(ccipResp.status).toBe(StatusCodes.BAD_GATEWAY);
|
||||
});
|
||||
|
||||
test("When called with valid id and timestamp not in the cache with db returns ok", async () => {
|
||||
const dbBackend = express();
|
||||
dbBackend.get("/vaa", (req, res) => {
|
||||
const priceId = req.query.id;
|
||||
const pubTime = Number(req.query.publishTime);
|
||||
const cluster = req.query.cluster;
|
||||
|
||||
res.json([
|
||||
{
|
||||
vaa: `${cluster}${priceId}${pubTime}`,
|
||||
publishTime: new Date(pubTime * 1000).toISOString(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
const dbApp = dbBackend.listen({ port: 37777 });
|
||||
|
||||
const apiWithDb = new RestAPI(
|
||||
{
|
||||
port: 8889,
|
||||
dbApiCluster: "pythnet",
|
||||
dbApiEndpoint: "http://localhost:37777",
|
||||
},
|
||||
priceInfo,
|
||||
() => true
|
||||
);
|
||||
const appWithDb = await apiWithDb.createApp();
|
||||
|
||||
const id = expandTo64Len("abcd");
|
||||
addAbcdDataToCache(id, vaasCache);
|
||||
|
||||
const resp = await request(appWithDb)
|
||||
.get("/api/get_vaa")
|
||||
.query({
|
||||
id: "0x" + id,
|
||||
publish_time: 5,
|
||||
});
|
||||
expect(resp.status).toBe(StatusCodes.OK);
|
||||
expect(resp.body).toEqual<VaaResponse>({
|
||||
vaa: `pythnet${id}5`,
|
||||
publishTime: 5,
|
||||
});
|
||||
|
||||
const pubTime5AsHex64Bit = "0000000000000005";
|
||||
const ccipResp = await request(appWithDb)
|
||||
.get("/api/get_vaa_ccip")
|
||||
.query({
|
||||
data: "0x" + id + pubTime5AsHex64Bit,
|
||||
});
|
||||
expect(ccipResp.status).toBe(StatusCodes.OK);
|
||||
expect(ccipResp.body).toEqual({
|
||||
data: "0x" + Buffer.from(`pythnet${id}5`, "base64").toString("hex"),
|
||||
});
|
||||
|
||||
dbApp.close();
|
||||
});
|
||||
|
||||
test(
|
||||
"When called with valid id and timestamp not in the cache" +
|
||||
"and not in the db returns vaa not found",
|
||||
async () => {
|
||||
const dbBackend = express();
|
||||
dbBackend.get("/vaa", (_req, res) => {
|
||||
// Return an empty array when vaa is not there, this is the same
|
||||
// behaviour as our api.
|
||||
res.json([]);
|
||||
});
|
||||
|
||||
const dbApp = dbBackend.listen({ port: 37777 });
|
||||
|
||||
const apiWithDb = new RestAPI(
|
||||
{
|
||||
port: 8889,
|
||||
dbApiCluster: "pythnet",
|
||||
dbApiEndpoint: "http://localhost:37777",
|
||||
},
|
||||
priceInfo,
|
||||
() => true
|
||||
);
|
||||
const appWithDb = await apiWithDb.createApp();
|
||||
|
||||
const id = expandTo64Len("abcd");
|
||||
addAbcdDataToCache(id, vaasCache);
|
||||
|
||||
const resp = await request(appWithDb)
|
||||
.get("/api/get_vaa")
|
||||
.query({
|
||||
id: "0x" + id,
|
||||
publish_time: 5,
|
||||
});
|
||||
expect(resp.status).toBe(StatusCodes.NOT_FOUND);
|
||||
|
||||
const pubTime5AsHex64Bit = "0000000000000005";
|
||||
const ccipResp = await request(appWithDb)
|
||||
.get("/api/get_vaa_ccip")
|
||||
.query({
|
||||
data: "0x" + id + pubTime5AsHex64Bit,
|
||||
});
|
||||
|
||||
// On CCIP we expect bad gateway so the client want to retry other ccip endpoints.
|
||||
expect(ccipResp.status).toBe(StatusCodes.BAD_GATEWAY);
|
||||
|
||||
dbApp.close();
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"When called with valid id and timestamp not in the cache" +
|
||||
"and db is not available returns internal server error",
|
||||
async () => {
|
||||
const apiWithDb = new RestAPI(
|
||||
{
|
||||
port: 8889,
|
||||
dbApiCluster: "pythnet",
|
||||
dbApiEndpoint: "http://localhost:37777",
|
||||
},
|
||||
priceInfo,
|
||||
() => true
|
||||
);
|
||||
const appWithDb = await apiWithDb.createApp();
|
||||
|
||||
const id = expandTo64Len("abcd");
|
||||
addAbcdDataToCache(id, vaasCache);
|
||||
|
||||
const resp = await request(appWithDb)
|
||||
.get("/api/get_vaa")
|
||||
.query({
|
||||
id: "0x" + id,
|
||||
publish_time: 5,
|
||||
});
|
||||
expect(resp.status).toBe(StatusCodes.INTERNAL_SERVER_ERROR);
|
||||
|
||||
const pubTime5AsHex64Bit = "0000000000000005";
|
||||
const ccipResp = await request(appWithDb)
|
||||
.get("/api/get_vaa_ccip")
|
||||
.query({
|
||||
data: "0x" + id + pubTime5AsHex64Bit,
|
||||
});
|
||||
expect(ccipResp.status).toBe(StatusCodes.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
);
|
||||
|
||||
test("vaaToPriceInfo works with accumulator update data", () => {
|
||||
// An update data taken from Hermes with the following price feed:
|
||||
// {
|
||||
// "id":"e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43",
|
||||
// "price":{"price":"2836040669135","conf":"3282830965","expo":-8,"publish_time":1692280808},
|
||||
// "ema_price":{"price":"2845324900000","conf":"3211773100","expo":-8,"publish_time":1692280808},
|
||||
// "metadata":{"slot":89783664,"emitter_chain":26,"price_service_receive_time":1692280809}
|
||||
// }
|
||||
const updateData = Buffer.from(
|
||||
"UE5BVQEAAAADuAEAAAADDQAsKPsmb7Vz7io3taJQKgoi1m/z0kqKgtpmlkv+ZuunX2Iegsf+8fuUtpHPLKgCWPU8PN2x9NyAZz5" +
|
||||
"BY9M3SWwJAALYlM0U7f2GFWfEjKwSJlHZ5sf+n6KXCocVC66ImS2o0TD0SBhTWcp0KdcuzR1rY1jfIHaFpVneroRLbTjNrk/WAA" +
|
||||
"MuAYxPVPf1DR30wYQo12Dbf+in3akTjhKERNQ+nPwRjxAyIQD+52LU3Rh2VL7nOIStMNTiBMaiWHywaPoXowWAAQbillhhX4MR+" +
|
||||
"7h81PfxHIbiXBmER4c5M7spilWKkROb+VXhrqnVJL162t9TdhYk56PDIhvXO1Tm/ldjVJw130y0AAk6qpccfsxDZEmVN8LI4z87" +
|
||||
"39Ni/kb+CB3yW2l2dWhKTjBeNanhK6TCCoNH/jRzWfrjrEk5zjNrUr82JwL4fR1OAQrYZescxbH26m8QHiH+RHzwlXpUKJgbHD5" +
|
||||
"NnWtB7oFb9AFM15jbjd4yIEBEtAlXPE0Q4j+X+DLnCtZbLSQiYNh5AQvz70LTbYry1lEExuUcO+IRJiysw5AFyqZ9Y1E//WKIqg" +
|
||||
"EysfcnHwoOxtDtAc5Z9sTUEYfPqQ1d27k3Yk0X7dvCAQ10cdG0qYHb+bQrYRIKKnb0aeCjkCs0HZQY2fXYmimyfTNfECclmPW9k" +
|
||||
"+CfOvW0JKuFxC1l11zJ3zjsgN/peA8BAQ5oIFQGjq9qmf5gegE1DjuzXsGksKao6nsjTXYIspCczCe2h5KNQ9l5hws11hauUKS2" +
|
||||
"0JoOYjHwxPD2x0adJKvkAQ+4UjVcZgVEQP8y3caqUDH81Ikcadz2bESpYg93dpnzZTH6A7Ue+RL34PTNx6cCRzukwQuhiStuyL1" +
|
||||
"WYEIrLI4nABAjGv3EBXjWaPLUj59OzVnGkzxkr6C4KDjMmpsYNzx7I2lp2iQV46TM78El8i9h7twiEDUOSdC5CmfQjRpkP72yAB" +
|
||||
"GVAQELUm2/SjkpF0O+/rVDgA/Y2/wMacD1ZDahdyvSNSFThn5NyRYA1JXGgIDxoYeAZgkr1gL1cjCLWiO+Bs9QARIiCvHfIkn2a" +
|
||||
"YhYHQq/u6cHB/2DxE3OgbCZyTv8OVO55hQDkJ1gDwAec+IJ4M5Od4OxWEu+OywhJT7zUmwZko9MAGTeJ+kAAAAAABrhAfrtrFhR" +
|
||||
"4yubI7X5QRqMK6xKrj7U3XuBHdGnLqSqcQAAAAAAWllxAUFVV1YAAAAAAAVZ/XAAACcQ8Xfx5wQ+nj1rn6IeTUAy+VER1nUBAFU" +
|
||||
"A5i32yLSoX+GmfbRNwS3l2zMPesZrctxliv7fD0pBW0MAAAKUUTJXzwAAAADDrAZ1////+AAAAABk3ifoAAAAAGTeJ+cAAAKWep" +
|
||||
"R2oAAAAAC/b8SsCasjFzENKvXWwOycuzCVaDWfm0IuuuesmamDKl2lNXss15orlNN+xHVNEEIIq7Xg8GRZGVLt43fkg7xli6EPQ" +
|
||||
"/Nyxl6SixiYteNt1uTTh4M1lQTUjPxKnkE5JEea4RnhOWgmSAWMf8ft4KgE7hvRifV1JP0rOsNgsOYFRbs6iDKW1qLpxgZLMAiO" +
|
||||
"clwS3Tjw2hj8sPfq1NHeVttsBEK5SIM14GjAuD/p2V0+NqHqMHxU/kfftg==",
|
||||
"base64"
|
||||
);
|
||||
|
||||
const priceInfo = RestAPI.vaaToPriceInfo(
|
||||
"e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43",
|
||||
updateData
|
||||
);
|
||||
|
||||
expect(priceInfo).toBeDefined();
|
||||
expect(priceInfo?.priceFeed).toEqual(
|
||||
new PriceFeed({
|
||||
id: "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43",
|
||||
price: new Price({
|
||||
price: "2836040669135",
|
||||
conf: "3282830965",
|
||||
publishTime: 1692280808,
|
||||
expo: -8,
|
||||
}),
|
||||
emaPrice: new Price({
|
||||
price: "2845324900000",
|
||||
conf: "3211773100",
|
||||
publishTime: 1692280808,
|
||||
expo: -8,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(priceInfo?.emitterChainId).toEqual(26);
|
||||
});
|
||||
});
|
|
@ -1,46 +0,0 @@
|
|||
import { isValidVaa } from "../vaa";
|
||||
import { GuardianSignature, parseVaa } from "@certusone/wormhole-sdk";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const VAA = Buffer.from(
|
||||
"AQAAAAMNABIL4Zs/yZlmGGUiAZujW8PMYR2ffWKTcuSNMRL+Yr3uQrVO1qxLToA8iksg/NWfsD3NeMSJujxSgd4fnjmqtSYBAiP92Eci7vsIVouS93bSack2bYg5ERXxZpcTb9LSWpEmILv62jbAd1HcbWu1w8WVbm++nqgbHH5S8eUY57QytegABIBcyvERWN2j9kb74zvQy+AEfXW6wjbrRKzlMvOUaKYpMG9nRzXkxd6wehsVFgV+i3G/lykR1hcrvgIczEPCuIYACPApsIJGheEpt/VQ4d36Tc0ZMzqq/kw1mTDJ8eKHikHeL8yFfo+Q9PtYK0CF1UYTKVpl32kFTtU+ubdKM7oVHMYBCiw25jnpX5+KOzxSTy+9Q5ovM3zqcN3yJBSbF80VL9N2AnehBhMTr1DylzpcYppdly4w/Iz5OHFGoZqT8dVgeY0AC0MseKj4EN0XUIGj8kXQ0CZKczfxywJPiueGTkAD6VkAOwpxnfZu212yXHAbojECKqtRCvb4UobTu+RK0pyemb0BDJKvSJ8RALV4CAGGWiS7XzHfa+/SxzCB6zxUsiOh0FGGEZBK+6i//7YUY83TOXp5SZzDGA0aH5tLXd6peL6np4ABDeHulcBX2LA1cIpmH+nqQLRq5zDPlKNBa6RVwHQUBVotBAWnCoTjOv+8xPZssl7r/BidPUbdu7j+0MGB/4/Oh6wBDub405biSsppFuBFxrBuFrJJdnsf3NvU5TWKF61aZKFtcWpxzyxNDsB3Nd7g+QYafiMkyL4okvvcthYaoiEzwX0BD1qhc5333/TKKbInkZcsitd0F/isWptZygRNqsh29f/xNuFyD4915mNWtsx3OaRAAkPcq21YzJb7ObzUB0OjhVcBEK46eqvVfpDHkF/w6+GWKACsICaAdgwDkmEwrCxXY2BgJe7cXkmDGl0Sfl8836AHd5OBwIC7g7EldFkLUanUUUwAEWpFfXwzaAnMQp+bO3RHKnpbPvJgKacjxFaCExe7dNkvYcVQ4UEC13QqIK3k7egZpHZp45O9AXfwmtpbBlJAvlgAEgu9te25pvTJ2alsQsxicrf5QyhDT7P6Ywr2WbNUnsfXKPFPC3U1P3G1yQOIjbUhrFtYkEGQ1+uZ4rNxsq2CchwBZGbcRwAAAAAAGvjNI8KrkSN3MHcLvqCNYQBc3aCYQ0jz9u7LVZY4wLugAAAAABlYLUwBUDJXSAADAAEAAQIABQCdLvoSNauGwJNctCSxAr5PIX500RCd+edd+oM4/A8JCHgvlYYrBFZwzSK+4xFMOXY6Sgi+62Y7FF0oPDHX0RAcTwAAAActs1rgAAAAAAFPo9T////4AAAABy9GhdAAAAAAARhiQwEAAAARAAAAFwAAAABkZtxHAAAAAGRm3EcAAAAAZGbcRgAAAActwjt4AAAAAAFAwzwAAAAAZGbcRkjWAz1zPieVDC4DUeJQVJHNkVSCT3FtlRNRTHS5+Y9YPdK2NoakUOxykN86HgtYPASB9lE1Ht+nY285rtVc+KMAAAACruGVUAAAAAAAv9F3////+AAAAAKux0rYAAAAAAC3HSIBAAAAFQAAABwAAAAAZGbcRwAAAABkZtxHAAAAAGRm3EYAAAACruGVUAAAAAAAv9F3AAAAAGRm3EY1FbOGHo/pPl9UC6QHfCFkBHgrhtXngHezy/0nMTqzvOYt9si0qF/hpn20TcEt5dszD3rGa3LcZYr+3w9KQVtDAAACcHgoTeAAAAAAJ08r4P////gAAAJwomgKAAAAAAAiQ9syAQAAABUAAAAfAAAAAGRm3EcAAAAAZGbcRwAAAABkZtxGAAACcHOoRAAAAAAAJlVaXAAAAABkZtxGm19z4AdefXA3YBIYDdupQnL2jYXq5BBOM1VhyYIlPUGhnQSsaWx6ZhbSkcfl0Td8yL5DfDJ7da213ButdF/K6AAAAAAE4NsJAAAAAAABWWT////4AAAAAATkLakAAAAAAAEvVQEAAAALAAAACwAAAABkZtxHAAAAAGRm3EcAAAAAZGbcRgAAAAAE4NsJAAAAAAABWWQAAAAAZGbcRuh2/NEwrdiYSjOqtSrza8G5+CLJ6+N286py1jCXThXw3O9Q3QpM0tzBfkXfFnbcszahGmHGnfegKZsBUMZy0lwAAAAAAG/y7wAAAAAAABJP////+AAAAAAAb/S7AAAAAAAAEfUBAAAAFQAAAB4AAAAAZGbcRwAAAABkZtxHAAAAAGRm3EYAAAAAAG/zhgAAAAAAABLmAAAAAGRm3EY=",
|
||||
"base64"
|
||||
);
|
||||
|
||||
describe("VAA validation works", () => {
|
||||
test("with valid signatures", async () => {
|
||||
let parsedVaa = parseVaa(VAA);
|
||||
|
||||
expect(isValidVaa(parsedVaa, "mainnet")).toBe(true);
|
||||
});
|
||||
|
||||
test("with a wrong address", async () => {
|
||||
let parsedVaa = parseVaa(VAA);
|
||||
const vaaIndex = 8;
|
||||
const setIndex1 = 4;
|
||||
const setIndex2 = 5;
|
||||
|
||||
// Replace the signature from guardian at setIndex1 with the one from
|
||||
// setIndex2.
|
||||
parsedVaa.guardianSignatures[vaaIndex] = {
|
||||
index: setIndex1,
|
||||
signature: parsedVaa.guardianSignatures[setIndex2].signature,
|
||||
};
|
||||
|
||||
expect(isValidVaa(parsedVaa, "mainnet")).toBe(false);
|
||||
});
|
||||
|
||||
test("with an invalid signature", async () => {
|
||||
let parsedVaa = parseVaa(VAA);
|
||||
const vaaIndex = 8;
|
||||
const setIndex = 4;
|
||||
|
||||
// Inject a random buffer as the signature of the guardian at setIndex.
|
||||
parsedVaa.guardianSignatures[vaaIndex] = {
|
||||
index: setIndex,
|
||||
signature: randomBytes(65), // invalid signature
|
||||
};
|
||||
|
||||
expect(isValidVaa(parsedVaa, "mainnet")).toBe(false);
|
||||
});
|
||||
});
|
|
@ -1,516 +0,0 @@
|
|||
import {
|
||||
HexString,
|
||||
Price,
|
||||
PriceFeed,
|
||||
PriceFeedMetadata,
|
||||
} from "@pythnetwork/price-service-sdk";
|
||||
import { Server } from "http";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { sleep } from "../helpers";
|
||||
import { PriceInfo, PriceStore } from "../listen";
|
||||
import { ClientMessage, WebSocketAPI } from "../ws";
|
||||
|
||||
const port = 2524;
|
||||
|
||||
let api: WebSocketAPI;
|
||||
let server: Server;
|
||||
let wss: WebSocketServer;
|
||||
|
||||
let priceInfos: PriceInfo[];
|
||||
|
||||
function expandTo64Len(id: string): string {
|
||||
return id.repeat(64).substring(0, 64);
|
||||
}
|
||||
|
||||
function dummyPriceInfo(id: HexString, vaa: HexString): PriceInfo {
|
||||
return {
|
||||
seqNum: 1,
|
||||
publishTime: 0,
|
||||
attestationTime: 2,
|
||||
emitterChainId: 3,
|
||||
priceFeed: dummyPriceFeed(id),
|
||||
vaa: Buffer.from(vaa, "hex"),
|
||||
priceServiceReceiveTime: 4,
|
||||
lastAttestedPublishTime: -1,
|
||||
};
|
||||
}
|
||||
|
||||
function dummyPriceFeed(id: string): PriceFeed {
|
||||
return new PriceFeed({
|
||||
emaPrice: new Price({
|
||||
conf: "1",
|
||||
expo: 2,
|
||||
price: "3",
|
||||
publishTime: 4,
|
||||
}),
|
||||
id,
|
||||
price: new Price({
|
||||
conf: "5",
|
||||
expo: 6,
|
||||
price: "7",
|
||||
publishTime: 8,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSocketState(
|
||||
client: WebSocket,
|
||||
state: number
|
||||
): Promise<void> {
|
||||
while (client.readyState !== state) {
|
||||
await sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMessages(messages: any[], cnt: number): Promise<void> {
|
||||
while (messages.length < cnt) {
|
||||
await sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSocketClient(): Promise<[WebSocket, any[]]> {
|
||||
const client = new WebSocket(`ws://localhost:${port}/ws`);
|
||||
|
||||
await waitForSocketState(client, client.OPEN);
|
||||
|
||||
const messages: any[] = [];
|
||||
|
||||
client.on("message", (data) => {
|
||||
messages.push(JSON.parse(data.toString()));
|
||||
});
|
||||
|
||||
return [client, messages];
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
priceInfos = [
|
||||
dummyPriceInfo(expandTo64Len("abcd"), "a1b2c3d4"),
|
||||
dummyPriceInfo(expandTo64Len("ef01"), "a1b2c3d4"),
|
||||
dummyPriceInfo(expandTo64Len("2345"), "bad01bad"),
|
||||
dummyPriceInfo(expandTo64Len("6789"), "bidbidbid"),
|
||||
];
|
||||
|
||||
const priceInfo: PriceStore = {
|
||||
getLatestPriceInfo: (_priceFeedId: string) => undefined,
|
||||
addUpdateListener: (_callback: (priceInfo: PriceInfo) => any) => undefined,
|
||||
getPriceIds: () => new Set(priceInfos.map((info) => info.priceFeed.id)),
|
||||
getVaa: (_vaasCacheKey: string) => undefined,
|
||||
};
|
||||
|
||||
api = new WebSocketAPI(priceInfo);
|
||||
|
||||
server = new Server();
|
||||
server.listen(port);
|
||||
|
||||
wss = api.run(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
wss.close();
|
||||
server.close();
|
||||
});
|
||||
|
||||
describe("Client receives data", () => {
|
||||
test("When subscribes with valid ids without verbose flag, returns correct price feed", async () => {
|
||||
const [client, serverMessages] = await createSocketClient();
|
||||
|
||||
const message: ClientMessage = {
|
||||
ids: [priceInfos[0].priceFeed.id, priceInfos[1].priceFeed.id],
|
||||
type: "subscribe",
|
||||
};
|
||||
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
await waitForMessages(serverMessages, 1);
|
||||
|
||||
expect(serverMessages[0]).toStrictEqual({
|
||||
type: "response",
|
||||
status: "success",
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[0]);
|
||||
|
||||
await waitForMessages(serverMessages, 2);
|
||||
|
||||
expect(serverMessages[1]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: priceInfos[0].priceFeed.toJson(),
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[1]);
|
||||
|
||||
await waitForMessages(serverMessages, 3);
|
||||
|
||||
expect(serverMessages[2]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: priceInfos[1].priceFeed.toJson(),
|
||||
});
|
||||
|
||||
client.close();
|
||||
await waitForSocketState(client, client.CLOSED);
|
||||
});
|
||||
|
||||
test("When subscribes with valid ids and verbose flag set to true, returns correct price feed with metadata", async () => {
|
||||
const [client, serverMessages] = await createSocketClient();
|
||||
|
||||
const message: ClientMessage = {
|
||||
ids: [priceInfos[0].priceFeed.id, priceInfos[1].priceFeed.id],
|
||||
type: "subscribe",
|
||||
verbose: true,
|
||||
};
|
||||
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
await waitForMessages(serverMessages, 1);
|
||||
|
||||
expect(serverMessages[0]).toStrictEqual({
|
||||
type: "response",
|
||||
status: "success",
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[0]);
|
||||
|
||||
await waitForMessages(serverMessages, 2);
|
||||
|
||||
expect(serverMessages[1]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: {
|
||||
...priceInfos[0].priceFeed.toJson(),
|
||||
metadata: new PriceFeedMetadata({
|
||||
attestationTime: 2,
|
||||
emitterChain: 3,
|
||||
receiveTime: 4,
|
||||
sequenceNumber: 1,
|
||||
}).toJson(),
|
||||
},
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[1]);
|
||||
|
||||
await waitForMessages(serverMessages, 3);
|
||||
|
||||
expect(serverMessages[2]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: {
|
||||
...priceInfos[1].priceFeed.toJson(),
|
||||
metadata: new PriceFeedMetadata({
|
||||
attestationTime: 2,
|
||||
emitterChain: 3,
|
||||
receiveTime: 4,
|
||||
sequenceNumber: 1,
|
||||
}).toJson(),
|
||||
},
|
||||
});
|
||||
|
||||
client.close();
|
||||
await waitForSocketState(client, client.CLOSED);
|
||||
});
|
||||
|
||||
test("When subscribes with valid ids and verbose flag set to false, returns correct price feed without metadata", async () => {
|
||||
const [client, serverMessages] = await createSocketClient();
|
||||
|
||||
const message: ClientMessage = {
|
||||
ids: [priceInfos[0].priceFeed.id, priceInfos[1].priceFeed.id],
|
||||
type: "subscribe",
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
await waitForMessages(serverMessages, 1);
|
||||
|
||||
expect(serverMessages[0]).toStrictEqual({
|
||||
type: "response",
|
||||
status: "success",
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[0]);
|
||||
|
||||
await waitForMessages(serverMessages, 2);
|
||||
|
||||
expect(serverMessages[1]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: priceInfos[0].priceFeed.toJson(),
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[1]);
|
||||
|
||||
await waitForMessages(serverMessages, 3);
|
||||
|
||||
expect(serverMessages[2]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: priceInfos[1].priceFeed.toJson(),
|
||||
});
|
||||
|
||||
client.close();
|
||||
await waitForSocketState(client, client.CLOSED);
|
||||
});
|
||||
|
||||
test("When subscribes with valid ids and binary flag set to true, returns correct price feed with vaa", async () => {
|
||||
const [client, serverMessages] = await createSocketClient();
|
||||
|
||||
const message: ClientMessage = {
|
||||
ids: [priceInfos[0].priceFeed.id, priceInfos[1].priceFeed.id],
|
||||
type: "subscribe",
|
||||
binary: true,
|
||||
};
|
||||
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
await waitForMessages(serverMessages, 1);
|
||||
|
||||
expect(serverMessages[0]).toStrictEqual({
|
||||
type: "response",
|
||||
status: "success",
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[0]);
|
||||
|
||||
await waitForMessages(serverMessages, 2);
|
||||
|
||||
expect(serverMessages[1]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: {
|
||||
...priceInfos[0].priceFeed.toJson(),
|
||||
vaa: priceInfos[0].vaa.toString("base64"),
|
||||
},
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[1]);
|
||||
|
||||
await waitForMessages(serverMessages, 3);
|
||||
|
||||
expect(serverMessages[2]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: {
|
||||
...priceInfos[1].priceFeed.toJson(),
|
||||
vaa: priceInfos[1].vaa.toString("base64"),
|
||||
},
|
||||
});
|
||||
|
||||
client.close();
|
||||
await waitForSocketState(client, client.CLOSED);
|
||||
});
|
||||
|
||||
test("When subscribes with valid ids and binary flag set to false, returns correct price feed without vaa", async () => {
|
||||
const [client, serverMessages] = await createSocketClient();
|
||||
|
||||
const message: ClientMessage = {
|
||||
ids: [priceInfos[0].priceFeed.id, priceInfos[1].priceFeed.id],
|
||||
type: "subscribe",
|
||||
binary: false,
|
||||
};
|
||||
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
await waitForMessages(serverMessages, 1);
|
||||
|
||||
expect(serverMessages[0]).toStrictEqual({
|
||||
type: "response",
|
||||
status: "success",
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[0]);
|
||||
|
||||
await waitForMessages(serverMessages, 2);
|
||||
|
||||
expect(serverMessages[1]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: priceInfos[0].priceFeed.toJson(),
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[1]);
|
||||
|
||||
await waitForMessages(serverMessages, 3);
|
||||
|
||||
expect(serverMessages[2]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: priceInfos[1].priceFeed.toJson(),
|
||||
});
|
||||
|
||||
client.close();
|
||||
await waitForSocketState(client, client.CLOSED);
|
||||
});
|
||||
|
||||
test("When subscribes with invalid ids, returns error", async () => {
|
||||
const [client, serverMessages] = await createSocketClient();
|
||||
|
||||
const message: ClientMessage = {
|
||||
ids: [expandTo64Len("aaaa")],
|
||||
type: "subscribe",
|
||||
};
|
||||
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
await waitForMessages(serverMessages, 1);
|
||||
|
||||
expect(serverMessages.length).toBe(1);
|
||||
expect(serverMessages[0].type).toBe("response");
|
||||
expect(serverMessages[0].status).toBe("error");
|
||||
|
||||
client.close();
|
||||
await waitForSocketState(client, client.CLOSED);
|
||||
});
|
||||
|
||||
test("When subscribes for Price Feed A, doesn't receive updates for Price Feed B", async () => {
|
||||
const [client, serverMessages] = await createSocketClient();
|
||||
|
||||
const message: ClientMessage = {
|
||||
ids: [priceInfos[0].priceFeed.id],
|
||||
type: "subscribe",
|
||||
};
|
||||
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
await waitForMessages(serverMessages, 1);
|
||||
|
||||
expect(serverMessages[0]).toStrictEqual({
|
||||
type: "response",
|
||||
status: "success",
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[1]);
|
||||
|
||||
await sleep(100);
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[0]);
|
||||
|
||||
await waitForMessages(serverMessages, 2);
|
||||
|
||||
expect(serverMessages[1]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: priceInfos[0].priceFeed.toJson(),
|
||||
});
|
||||
|
||||
await sleep(100);
|
||||
expect(serverMessages.length).toBe(2);
|
||||
|
||||
client.close();
|
||||
await waitForSocketState(client, client.CLOSED);
|
||||
});
|
||||
|
||||
test("When subscribes for Price Feed A, receives updated and when unsubscribes stops receiving", async () => {
|
||||
const [client, serverMessages] = await createSocketClient();
|
||||
|
||||
let message: ClientMessage = {
|
||||
ids: [priceInfos[0].priceFeed.id],
|
||||
type: "subscribe",
|
||||
};
|
||||
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
await waitForMessages(serverMessages, 1);
|
||||
|
||||
expect(serverMessages[0]).toStrictEqual({
|
||||
type: "response",
|
||||
status: "success",
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[0]);
|
||||
|
||||
await waitForMessages(serverMessages, 2);
|
||||
|
||||
expect(serverMessages[1]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: priceInfos[0].priceFeed.toJson(),
|
||||
});
|
||||
|
||||
message = {
|
||||
ids: [priceInfos[0].priceFeed.id],
|
||||
type: "unsubscribe",
|
||||
};
|
||||
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
await waitForMessages(serverMessages, 3);
|
||||
|
||||
expect(serverMessages[2]).toStrictEqual({
|
||||
type: "response",
|
||||
status: "success",
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[0]);
|
||||
|
||||
await sleep(100);
|
||||
|
||||
expect(serverMessages.length).toBe(3);
|
||||
|
||||
client.close();
|
||||
await waitForSocketState(client, client.CLOSED);
|
||||
});
|
||||
|
||||
test("Unsubscribe on not subscribed price feed is ok", async () => {
|
||||
const [client, serverMessages] = await createSocketClient();
|
||||
|
||||
const message: ClientMessage = {
|
||||
ids: [priceInfos[0].priceFeed.id],
|
||||
type: "unsubscribe",
|
||||
};
|
||||
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
await waitForMessages(serverMessages, 1);
|
||||
|
||||
expect(serverMessages[0]).toStrictEqual({
|
||||
type: "response",
|
||||
status: "success",
|
||||
});
|
||||
|
||||
client.close();
|
||||
await waitForSocketState(client, client.CLOSED);
|
||||
});
|
||||
|
||||
test("Multiple clients with different price feed works", async () => {
|
||||
const [client1, serverMessages1] = await createSocketClient();
|
||||
const [client2, serverMessages2] = await createSocketClient();
|
||||
|
||||
const message1: ClientMessage = {
|
||||
ids: [priceInfos[0].priceFeed.id],
|
||||
type: "subscribe",
|
||||
};
|
||||
|
||||
client1.send(JSON.stringify(message1));
|
||||
|
||||
const message2: ClientMessage = {
|
||||
ids: [priceInfos[1].priceFeed.id],
|
||||
type: "subscribe",
|
||||
};
|
||||
|
||||
client2.send(JSON.stringify(message2));
|
||||
|
||||
await waitForMessages(serverMessages1, 1);
|
||||
await waitForMessages(serverMessages2, 1);
|
||||
|
||||
expect(serverMessages1[0]).toStrictEqual({
|
||||
type: "response",
|
||||
status: "success",
|
||||
});
|
||||
|
||||
expect(serverMessages2[0]).toStrictEqual({
|
||||
type: "response",
|
||||
status: "success",
|
||||
});
|
||||
|
||||
api.dispatchPriceFeedUpdate(priceInfos[0]);
|
||||
api.dispatchPriceFeedUpdate(priceInfos[1]);
|
||||
|
||||
await waitForMessages(serverMessages1, 2);
|
||||
await waitForMessages(serverMessages2, 2);
|
||||
|
||||
expect(serverMessages1[1]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: priceInfos[0].priceFeed.toJson(),
|
||||
});
|
||||
|
||||
expect(serverMessages2[1]).toEqual({
|
||||
type: "price_update",
|
||||
price_feed: priceInfos[1].priceFeed.toJson(),
|
||||
});
|
||||
|
||||
client1.close();
|
||||
client2.close();
|
||||
|
||||
await waitForSocketState(client1, client1.CLOSED);
|
||||
await waitForSocketState(client2, client2.CLOSED);
|
||||
});
|
||||
});
|
|
@ -1,47 +0,0 @@
|
|||
// Utilities for encoding VAAs for specific target chains
|
||||
|
||||
// List of all possible target chains. Note that "default" is an option because we need at least one chain
|
||||
// with a base64 encoding (which is the old default behavior of all API methods).
|
||||
export type TargetChain = "evm" | "cosmos" | "aptos" | "default";
|
||||
export const validTargetChains = ["evm", "cosmos", "aptos", "default"];
|
||||
export const defaultTargetChain: TargetChain = "default";
|
||||
|
||||
// Possible encodings of the binary VAA data as a string.
|
||||
// "0x" is the same as "hex" with a leading "0x" prepended to the hex string.
|
||||
export type VaaEncoding = "base64" | "hex" | "0x";
|
||||
export const defaultVaaEncoding: VaaEncoding = "base64";
|
||||
export const chainToEncoding: Record<TargetChain, VaaEncoding> = {
|
||||
evm: "0x",
|
||||
cosmos: "base64",
|
||||
// TODO: I think aptos actually wants a number[] for this data... need to decide how to
|
||||
// handle that case.
|
||||
aptos: "base64",
|
||||
default: "base64",
|
||||
};
|
||||
|
||||
// Given a VAA represented as either a string in base64 or a Buffer, encode it as a string
|
||||
// appropriate for the given targetChain.
|
||||
export function encodeVaaForChain(
|
||||
vaa: string | Buffer,
|
||||
targetChain: TargetChain
|
||||
): string {
|
||||
const encoding = chainToEncoding[targetChain];
|
||||
|
||||
let vaaBuffer: Buffer;
|
||||
if (typeof vaa === "string") {
|
||||
if (encoding === defaultVaaEncoding) {
|
||||
return vaa;
|
||||
} else {
|
||||
vaaBuffer = Buffer.from(vaa, defaultVaaEncoding as BufferEncoding);
|
||||
}
|
||||
} else {
|
||||
vaaBuffer = vaa;
|
||||
}
|
||||
|
||||
switch (encoding) {
|
||||
case "0x":
|
||||
return "0x" + vaaBuffer.toString("hex");
|
||||
default:
|
||||
return vaaBuffer.toString(encoding);
|
||||
}
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
// Time in seconds
|
||||
export type TimestampInSec = number;
|
||||
export type DurationInSec = number;
|
||||
export type DurationInMs = number;
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Shorthand for optional/mandatory envs
|
||||
export function envOrErr(env: string): string {
|
||||
const val = process.env[env];
|
||||
if (!val) {
|
||||
throw new Error(`environment variable "${env}" must be set`);
|
||||
}
|
||||
return String(process.env[env]);
|
||||
}
|
||||
|
||||
export function parseToOptionalNumber(
|
||||
s: string | undefined
|
||||
): number | undefined {
|
||||
if (s === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parseInt(s, 10);
|
||||
}
|
||||
|
||||
export function removeLeading0x(s: string): string {
|
||||
if (s.startsWith("0x")) {
|
||||
return s.substring(2);
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
// Helper for treating T | undefined as an optional value. This lets you pick a
|
||||
// default if value is undefined.
|
||||
export function getOrElse<T>(value: T | undefined, defaultValue: T): T {
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
import { envOrErr, parseToOptionalNumber } from "./helpers";
|
||||
import { Listener } from "./listen";
|
||||
import { initLogger } from "./logging";
|
||||
import { PromClient } from "./promClient";
|
||||
import { RestAPI } from "./rest";
|
||||
import { WebSocketAPI } from "./ws";
|
||||
|
||||
let configFile: string = ".env";
|
||||
if (process.env.PYTH_PRICE_SERVICE_CONFIG) {
|
||||
configFile = process.env.PYTH_PRICE_SERVICE_CONFIG;
|
||||
}
|
||||
|
||||
// tslint:disable:no-console
|
||||
console.log("Loading config file [%s]", configFile);
|
||||
// tslint:disable:no-var-requires
|
||||
require("dotenv").config({ path: configFile });
|
||||
|
||||
// Set up the logger.
|
||||
initLogger({ logLevel: process.env.LOG_LEVEL });
|
||||
|
||||
async function run() {
|
||||
const promClient = new PromClient({
|
||||
name: "price_service",
|
||||
port: parseInt(envOrErr("PROM_PORT"), 10),
|
||||
});
|
||||
|
||||
const listener = new Listener(
|
||||
{
|
||||
spyServiceHost: envOrErr("SPY_SERVICE_HOST"),
|
||||
filtersRaw: process.env.SPY_SERVICE_FILTERS,
|
||||
wormholeCluster: process.env.WORMHOLE_CLUSTER,
|
||||
readiness: {
|
||||
spySyncTimeSeconds: parseInt(
|
||||
envOrErr("READINESS_SPY_SYNC_TIME_SECONDS"),
|
||||
10
|
||||
),
|
||||
numLoadedSymbols: parseInt(
|
||||
envOrErr("READINESS_NUM_LOADED_SYMBOLS"),
|
||||
10
|
||||
),
|
||||
},
|
||||
cacheCleanupLoopInterval: parseToOptionalNumber(
|
||||
process.env.REMOVE_EXPIRED_VALUES_INTERVAL_SECONDS
|
||||
),
|
||||
cacheTtl: parseToOptionalNumber(process.env.CACHE_TTL_SECONDS),
|
||||
},
|
||||
promClient
|
||||
);
|
||||
|
||||
// In future if we have more components we will modify it to include them all
|
||||
const isReady = () => listener.isReady();
|
||||
|
||||
const restAPI = new RestAPI(
|
||||
{
|
||||
port: parseInt(envOrErr("REST_PORT"), 10),
|
||||
dbApiEndpoint: process.env.DB_API_ENDPOINT,
|
||||
dbApiCluster: process.env.DB_API_CLUSTER,
|
||||
},
|
||||
listener,
|
||||
isReady,
|
||||
promClient
|
||||
);
|
||||
|
||||
const wsAPI = new WebSocketAPI(listener, promClient);
|
||||
|
||||
listener.run();
|
||||
|
||||
const server = await restAPI.run();
|
||||
wsAPI.run(server);
|
||||
}
|
||||
|
||||
run();
|
|
@ -1,469 +0,0 @@
|
|||
import {
|
||||
createSpyRPCServiceClient,
|
||||
subscribeSignedVAA,
|
||||
} from "@certusone/wormhole-spydk";
|
||||
|
||||
import { ChainId, uint8ArrayToHex, parseVaa } from "@certusone/wormhole-sdk";
|
||||
|
||||
import {
|
||||
FilterEntry,
|
||||
SubscribeSignedVAAResponse,
|
||||
} from "@certusone/wormhole-spydk/lib/cjs/proto/spy/v1/spy";
|
||||
import { ClientReadableStream } from "@grpc/grpc-js";
|
||||
import {
|
||||
getBatchSummary,
|
||||
parseBatchPriceAttestation,
|
||||
priceAttestationToPriceFeed,
|
||||
PriceAttestation,
|
||||
} from "@pythnetwork/wormhole-attester-sdk";
|
||||
import { HexString, PriceFeed } from "@pythnetwork/price-service-sdk";
|
||||
import LRUCache from "lru-cache";
|
||||
import { DurationInSec, sleep, TimestampInSec } from "./helpers";
|
||||
import { logger } from "./logging";
|
||||
import { PromClient } from "./promClient";
|
||||
import { isValidVaa, WormholeCluster, wormholeClusterFromString } from "./vaa";
|
||||
|
||||
export type PriceInfo = {
|
||||
vaa: Buffer;
|
||||
seqNum: number;
|
||||
publishTime: TimestampInSec;
|
||||
attestationTime: TimestampInSec;
|
||||
lastAttestedPublishTime: TimestampInSec;
|
||||
priceFeed: PriceFeed;
|
||||
emitterChainId: number;
|
||||
priceServiceReceiveTime: number;
|
||||
};
|
||||
|
||||
export function createPriceInfo(
|
||||
priceAttestation: PriceAttestation,
|
||||
vaa: Buffer,
|
||||
sequence: bigint,
|
||||
emitterChain: number
|
||||
): PriceInfo {
|
||||
const priceFeed = priceAttestationToPriceFeed(priceAttestation);
|
||||
return {
|
||||
seqNum: Number(sequence),
|
||||
vaa,
|
||||
publishTime: priceAttestation.publishTime,
|
||||
attestationTime: priceAttestation.attestationTime,
|
||||
lastAttestedPublishTime: priceAttestation.lastAttestedPublishTime,
|
||||
priceFeed,
|
||||
emitterChainId: emitterChain,
|
||||
priceServiceReceiveTime: Math.floor(new Date().getTime() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PriceStore {
|
||||
getPriceIds(): Set<HexString>;
|
||||
getLatestPriceInfo(priceFeedId: HexString): PriceInfo | undefined;
|
||||
addUpdateListener(callback: (priceInfo: PriceInfo) => any): void;
|
||||
getVaa(priceFeedId: string, publishTime: number): VaaConfig | undefined;
|
||||
}
|
||||
|
||||
type ListenerReadinessConfig = {
|
||||
spySyncTimeSeconds: number;
|
||||
numLoadedSymbols: number;
|
||||
};
|
||||
|
||||
type ListenerConfig = {
|
||||
spyServiceHost: string;
|
||||
wormholeCluster?: string;
|
||||
filtersRaw?: string;
|
||||
readiness: ListenerReadinessConfig;
|
||||
webApiEndpoint?: string;
|
||||
webApiCluster?: string;
|
||||
cacheCleanupLoopInterval?: DurationInSec;
|
||||
cacheTtl?: DurationInSec;
|
||||
};
|
||||
|
||||
type VaaKey = string;
|
||||
|
||||
export type VaaConfig = {
|
||||
publishTime: number;
|
||||
lastAttestedPublishTime: number;
|
||||
vaa: string;
|
||||
};
|
||||
|
||||
export class VaaCache {
|
||||
private cache: Map<string, VaaConfig[]>;
|
||||
private ttl: DurationInSec;
|
||||
private cacheCleanupLoopInterval: DurationInSec;
|
||||
|
||||
constructor(
|
||||
ttl: DurationInSec = 300,
|
||||
cacheCleanupLoopInterval: DurationInSec = 60
|
||||
) {
|
||||
this.cache = new Map();
|
||||
this.ttl = ttl;
|
||||
this.cacheCleanupLoopInterval = cacheCleanupLoopInterval;
|
||||
}
|
||||
|
||||
set(
|
||||
key: VaaKey,
|
||||
publishTime: TimestampInSec,
|
||||
lastAttestedPublishTime: TimestampInSec,
|
||||
vaa: string
|
||||
): void {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.get(key)!.push({ publishTime, lastAttestedPublishTime, vaa });
|
||||
} else {
|
||||
this.cache.set(key, [{ publishTime, lastAttestedPublishTime, vaa }]);
|
||||
}
|
||||
}
|
||||
|
||||
get(key: VaaKey, publishTime: TimestampInSec): VaaConfig | undefined {
|
||||
if (!this.cache.has(key)) {
|
||||
return undefined;
|
||||
} else {
|
||||
const vaaConf = this.find(this.cache.get(key)!, publishTime);
|
||||
return vaaConf;
|
||||
}
|
||||
}
|
||||
|
||||
private find(
|
||||
arr: VaaConfig[],
|
||||
publishTime: TimestampInSec
|
||||
): VaaConfig | undefined {
|
||||
// If the publishTime is less than the first element we are
|
||||
// not sure that this VAA is actually the first VAA after that
|
||||
// time.
|
||||
if (arr.length === 0 || publishTime < arr[0].publishTime) {
|
||||
return undefined;
|
||||
}
|
||||
let left = 0;
|
||||
let right = arr.length - 1;
|
||||
let nextLargest = -1;
|
||||
|
||||
while (left <= right) {
|
||||
const middle = Math.floor((left + right) / 2);
|
||||
if (
|
||||
arr[middle].publishTime === publishTime &&
|
||||
arr[middle].lastAttestedPublishTime < publishTime
|
||||
) {
|
||||
return arr[middle];
|
||||
} else if (arr[middle].publishTime < publishTime) {
|
||||
left = middle + 1;
|
||||
} else {
|
||||
nextLargest = middle;
|
||||
right = middle - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return nextLargest !== -1 ? arr[nextLargest] : undefined;
|
||||
}
|
||||
|
||||
async removeExpiredValues() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const key of this.cache.keys()) {
|
||||
this.cache.set(
|
||||
key,
|
||||
this.cache
|
||||
.get(key)!
|
||||
.filter((vaaConf) => now - vaaConf.publishTime < this.ttl)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
runRemoveExpiredValuesLoop() {
|
||||
setInterval(
|
||||
this.removeExpiredValues.bind(this),
|
||||
this.cacheCleanupLoopInterval * 1000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Listener implements PriceStore {
|
||||
// Mapping of Price Feed Id to Vaa
|
||||
private priceFeedVaaMap = new Map<string, PriceInfo>();
|
||||
private promClient: PromClient | undefined;
|
||||
private spyServiceHost: string;
|
||||
private filters: FilterEntry[] = [];
|
||||
private ignorePricesOlderThanSecs: number;
|
||||
private spyConnectionTime: TimestampInSec | undefined;
|
||||
private readinessConfig: ListenerReadinessConfig;
|
||||
private updateCallbacks: ((priceInfo: PriceInfo) => any)[];
|
||||
private observedVaas: LRUCache<VaaKey, boolean>;
|
||||
private vaasCache: VaaCache;
|
||||
private wormholeCluster: WormholeCluster;
|
||||
|
||||
constructor(config: ListenerConfig, promClient?: PromClient) {
|
||||
this.promClient = promClient;
|
||||
this.spyServiceHost = config.spyServiceHost;
|
||||
this.loadFilters(config.filtersRaw);
|
||||
// Don't store any prices received from wormhole that are over 5 minutes old.
|
||||
this.ignorePricesOlderThanSecs = 60;
|
||||
this.readinessConfig = config.readiness;
|
||||
this.updateCallbacks = [];
|
||||
this.observedVaas = new LRUCache({
|
||||
max: 10000, // At most 10000 items
|
||||
ttl: 60 * 1000, // 1 minutes which is equal to ignorePricesOlderThanSecs
|
||||
});
|
||||
this.vaasCache = new VaaCache(
|
||||
config.cacheTtl,
|
||||
config.cacheCleanupLoopInterval
|
||||
);
|
||||
if (config.wormholeCluster !== undefined) {
|
||||
this.wormholeCluster = wormholeClusterFromString(config.wormholeCluster);
|
||||
} else {
|
||||
this.wormholeCluster = "mainnet";
|
||||
}
|
||||
}
|
||||
|
||||
private loadFilters(filtersRaw?: string) {
|
||||
if (!filtersRaw) {
|
||||
logger.info("No filters provided. Will process all signed VAAs");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedJsonFilters = JSON.parse(filtersRaw);
|
||||
|
||||
for (const filter of parsedJsonFilters) {
|
||||
const myChainId = parseInt(filter.chain_id, 10) as ChainId;
|
||||
const myEmitterAddress = filter.emitter_address;
|
||||
const myEmitterFilter: FilterEntry = {
|
||||
emitterFilter: {
|
||||
chainId: myChainId,
|
||||
emitterAddress: myEmitterAddress,
|
||||
},
|
||||
};
|
||||
logger.info(
|
||||
"adding filter: chainId: [" +
|
||||
myEmitterFilter.emitterFilter!.chainId +
|
||||
"], emitterAddress: [" +
|
||||
myEmitterFilter.emitterFilter!.emitterAddress +
|
||||
"]"
|
||||
);
|
||||
this.filters.push(myEmitterFilter);
|
||||
}
|
||||
|
||||
logger.info("loaded " + this.filters.length + " filters");
|
||||
}
|
||||
|
||||
async run() {
|
||||
logger.info(
|
||||
"pyth_relay starting up, will listen for signed VAAs from " +
|
||||
this.spyServiceHost
|
||||
);
|
||||
|
||||
this.vaasCache.runRemoveExpiredValuesLoop();
|
||||
|
||||
while (true) {
|
||||
let stream: ClientReadableStream<SubscribeSignedVAAResponse> | undefined;
|
||||
try {
|
||||
const client = createSpyRPCServiceClient(this.spyServiceHost);
|
||||
stream = await subscribeSignedVAA(client, { filters: this.filters });
|
||||
|
||||
stream!.on("data", ({ vaaBytes }: { vaaBytes: Buffer }) => {
|
||||
this.processVaa(vaaBytes);
|
||||
});
|
||||
|
||||
this.spyConnectionTime = this.currentTimeInSeconds();
|
||||
|
||||
let connected = true;
|
||||
stream!.on("error", (err: any) => {
|
||||
logger.error("spy service returned an error: %o", err);
|
||||
connected = false;
|
||||
});
|
||||
|
||||
stream!.on("close", () => {
|
||||
logger.error("spy service closed the connection!");
|
||||
connected = false;
|
||||
});
|
||||
|
||||
logger.info("connected to spy service, listening for messages");
|
||||
|
||||
while (connected) {
|
||||
await sleep(1000);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("spy service threw an exception: %o", e);
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
stream.destroy();
|
||||
}
|
||||
this.spyConnectionTime = undefined;
|
||||
|
||||
await sleep(1000);
|
||||
logger.info("attempting to reconnect to the spy service");
|
||||
}
|
||||
}
|
||||
|
||||
isNewPriceInfo(
|
||||
cachedInfo: PriceInfo | undefined,
|
||||
observedInfo: PriceInfo
|
||||
): boolean {
|
||||
if (cachedInfo === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cachedInfo.attestationTime < observedInfo.attestationTime) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
cachedInfo.attestationTime === observedInfo.attestationTime &&
|
||||
cachedInfo.seqNum < observedInfo.seqNum
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async processVaa(vaa: Buffer) {
|
||||
const parsedVaa = parseVaa(vaa);
|
||||
|
||||
const vaaEmitterAddressHex = Buffer.from(parsedVaa.emitterAddress).toString(
|
||||
"hex"
|
||||
);
|
||||
|
||||
const observedVaasKey: VaaKey = `${parsedVaa.emitterChain}#${vaaEmitterAddressHex}#${parsedVaa.sequence}`;
|
||||
|
||||
if (this.observedVaas.has(observedVaasKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidVaa(parsedVaa, this.wormholeCluster)) {
|
||||
logger.info("Ignoring an invalid VAA");
|
||||
return;
|
||||
}
|
||||
|
||||
let batchAttestation;
|
||||
|
||||
try {
|
||||
batchAttestation = parseBatchPriceAttestation(
|
||||
Buffer.from(parsedVaa.payload)
|
||||
);
|
||||
} catch (e: any) {
|
||||
logger.error(e, e.stack);
|
||||
logger.error("Parsing failed. Dropping vaa: %o", parsedVaa);
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchAttestation.priceAttestations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Attestation time is the same in all feeds in the batch.
|
||||
// Return early if an attestation is old to exclude it from
|
||||
// the counter metric.
|
||||
if (
|
||||
batchAttestation.priceAttestations[0].attestationTime <
|
||||
this.currentTimeInSeconds() - this.ignorePricesOlderThanSecs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// There is no `await` clause to release the current thread since the previous check
|
||||
// but this is here to ensure this is correct as the code evolves.
|
||||
if (this.observedVaas.has(observedVaasKey)) {
|
||||
return;
|
||||
} else {
|
||||
this.observedVaas.set(observedVaasKey, true);
|
||||
this.promClient?.incReceivedVaa();
|
||||
}
|
||||
|
||||
for (const priceAttestation of batchAttestation.priceAttestations) {
|
||||
const key = priceAttestation.priceId;
|
||||
|
||||
const priceInfo = createPriceInfo(
|
||||
priceAttestation,
|
||||
vaa,
|
||||
parsedVaa.sequence,
|
||||
parsedVaa.emitterChain
|
||||
);
|
||||
const cachedPriceInfo = this.priceFeedVaaMap.get(key);
|
||||
|
||||
if (this.isNewPriceInfo(cachedPriceInfo, priceInfo)) {
|
||||
this.vaasCache.set(
|
||||
priceInfo.priceFeed.id,
|
||||
priceInfo.publishTime,
|
||||
priceInfo.lastAttestedPublishTime,
|
||||
priceInfo.vaa.toString("base64")
|
||||
);
|
||||
this.priceFeedVaaMap.set(key, priceInfo);
|
||||
|
||||
if (cachedPriceInfo !== undefined) {
|
||||
this.promClient?.addPriceUpdatesAttestationTimeGap(
|
||||
priceAttestation.attestationTime - cachedPriceInfo.attestationTime
|
||||
);
|
||||
this.promClient?.addPriceUpdatesPublishTimeGap(
|
||||
priceAttestation.publishTime - cachedPriceInfo.publishTime
|
||||
);
|
||||
}
|
||||
|
||||
for (const callback of this.updateCallbacks) {
|
||||
callback(priceInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Parsed a new Batch Price Attestation: [" +
|
||||
parsedVaa.emitterChain +
|
||||
":" +
|
||||
uint8ArrayToHex(parsedVaa.emitterAddress) +
|
||||
"], seqNum: " +
|
||||
parsedVaa.sequence +
|
||||
", Batch Summary: " +
|
||||
getBatchSummary(batchAttestation)
|
||||
);
|
||||
}
|
||||
|
||||
getVaa(priceFeedId: string, publishTime: number): VaaConfig | undefined {
|
||||
return this.vaasCache.get(priceFeedId, publishTime);
|
||||
}
|
||||
|
||||
getLatestPriceInfo(priceFeedId: string): PriceInfo | undefined {
|
||||
return this.priceFeedVaaMap.get(priceFeedId);
|
||||
}
|
||||
|
||||
addUpdateListener(callback: (priceInfo: PriceInfo) => any) {
|
||||
this.updateCallbacks.push(callback);
|
||||
}
|
||||
|
||||
getPriceIds(): Set<HexString> {
|
||||
return new Set(this.priceFeedVaaMap.keys());
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
const currentTime: TimestampInSec = Math.floor(Date.now() / 1000);
|
||||
if (
|
||||
this.spyConnectionTime === undefined ||
|
||||
currentTime <
|
||||
this.spyConnectionTime + this.readinessConfig.spySyncTimeSeconds
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (this.priceFeedVaaMap.size < this.readinessConfig.numLoadedSymbols) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if too many price feeds are stale it probably means that the price service
|
||||
// is not receiving messages from Wormhole at all and is essentially dead.
|
||||
const stalenessThreshold = 60;
|
||||
const maxToleratedStaleFeeds = 10;
|
||||
|
||||
const priceIds = [...this.getPriceIds()];
|
||||
let stalePriceCnt = 0;
|
||||
|
||||
for (const priceId of priceIds) {
|
||||
const latency =
|
||||
currentTime - this.getLatestPriceInfo(priceId)!.attestationTime;
|
||||
if (latency > stalenessThreshold) {
|
||||
stalePriceCnt++;
|
||||
}
|
||||
}
|
||||
|
||||
if (stalePriceCnt > maxToleratedStaleFeeds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private currentTimeInSeconds(): number {
|
||||
return new Date().getTime() / 1000;
|
||||
}
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
import * as winston from "winston";
|
||||
|
||||
export let logger = winston.createLogger({
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
|
||||
// Logger should be initialized before using logger
|
||||
export function initLogger(config?: { logLevel?: string }) {
|
||||
let logLevel = "info";
|
||||
if (config?.logLevel) {
|
||||
logLevel = config.logLevel;
|
||||
}
|
||||
|
||||
let transport: any;
|
||||
// tslint:disable:no-console
|
||||
console.log(
|
||||
"price_service is logging to the console at level [%s]",
|
||||
logLevel
|
||||
);
|
||||
|
||||
transport = new winston.transports.Console({
|
||||
level: logLevel,
|
||||
});
|
||||
|
||||
const logConfiguration = {
|
||||
transports: [transport],
|
||||
format: winston.format.combine(
|
||||
winston.format.splat(),
|
||||
winston.format.simple(),
|
||||
winston.format.timestamp({
|
||||
format: "YYYY-MM-DD HH:mm:ss.SSS",
|
||||
}),
|
||||
winston.format.printf(
|
||||
(info: any) => `${[info.timestamp]}|${info.level}|${info.message}`
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
logger = winston.createLogger(logConfiguration);
|
||||
}
|
|
@ -1,90 +0,0 @@
|
|||
import http = require("http");
|
||||
import client = require("prom-client");
|
||||
import { DurationInMs, DurationInSec } from "./helpers";
|
||||
import { logger } from "./logging";
|
||||
|
||||
// NOTE: To create a new metric:
|
||||
// 1) Create a private counter/gauge with appropriate name and help in metrics section of PromHelper
|
||||
// 2) Create a method to set the metric to a value (such as `incIncoming` function below)
|
||||
// 3) Register the metric using `register.registerMetric` function.
|
||||
|
||||
const SERVICE_PREFIX = "pyth__price_service__";
|
||||
|
||||
type WebSocketInteractionType =
|
||||
| "connection"
|
||||
| "close"
|
||||
| "timeout"
|
||||
| "server_update"
|
||||
| "client_message";
|
||||
|
||||
export class PromClient {
|
||||
private register = new client.Registry();
|
||||
|
||||
// Actual metrics
|
||||
private receivedVaaCounter = new client.Counter({
|
||||
name: `${SERVICE_PREFIX}vaas_received`,
|
||||
help: "number of Pyth VAAs received",
|
||||
});
|
||||
private priceUpdatesPublishTimeGapHistogram = new client.Histogram({
|
||||
name: `${SERVICE_PREFIX}price_updates_publish_time_gap_seconds`,
|
||||
help: "Summary of publish time gaps between price updates",
|
||||
buckets: [1, 3, 5, 10, 15, 30, 60, 120],
|
||||
});
|
||||
private priceUpdatesAttestationTimeGapHistogram = new client.Histogram({
|
||||
name: `${SERVICE_PREFIX}price_updates_attestation_time_gap_seconds`,
|
||||
help: "Summary of attestation time gaps between price updates",
|
||||
buckets: [1, 3, 5, 10, 15, 30, 60, 120],
|
||||
});
|
||||
private webSocketInteractionCounter = new client.Counter({
|
||||
name: `${SERVICE_PREFIX}websocket_interaction`,
|
||||
help: "number of Web Socket interactions",
|
||||
labelNames: ["type", "status"],
|
||||
});
|
||||
// End metrics
|
||||
|
||||
private server = http.createServer(async (req, res) => {
|
||||
if (req.url === "/metrics") {
|
||||
// Return all metrics in the Prometheus exposition format
|
||||
res.setHeader("Content-Type", this.register.contentType);
|
||||
res.write(await this.register.metrics());
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
constructor(config: { name: string; port: number }) {
|
||||
this.register.setDefaultLabels({
|
||||
app: config.name,
|
||||
});
|
||||
// Register each metric
|
||||
this.register.registerMetric(this.receivedVaaCounter);
|
||||
this.register.registerMetric(this.priceUpdatesPublishTimeGapHistogram);
|
||||
this.register.registerMetric(this.priceUpdatesAttestationTimeGapHistogram);
|
||||
this.register.registerMetric(this.webSocketInteractionCounter);
|
||||
// End registering metric
|
||||
|
||||
logger.info("prometheus client listening on port " + config.port);
|
||||
this.server.listen(config.port);
|
||||
}
|
||||
|
||||
incReceivedVaa() {
|
||||
this.receivedVaaCounter.inc();
|
||||
}
|
||||
|
||||
addPriceUpdatesPublishTimeGap(gap: DurationInSec) {
|
||||
this.priceUpdatesPublishTimeGapHistogram.observe(gap);
|
||||
}
|
||||
|
||||
addPriceUpdatesAttestationTimeGap(gap: DurationInSec) {
|
||||
this.priceUpdatesAttestationTimeGapHistogram.observe(gap);
|
||||
}
|
||||
|
||||
addWebSocketInteraction(
|
||||
type: WebSocketInteractionType,
|
||||
status: "ok" | "err"
|
||||
) {
|
||||
this.webSocketInteractionCounter.inc({
|
||||
type,
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,673 +0,0 @@
|
|||
import { HexString, Price, PriceFeed } from "@pythnetwork/price-service-sdk";
|
||||
import cors from "cors";
|
||||
import express, { NextFunction, Request, Response } from "express";
|
||||
import { Joi, schema, validate, ValidationError } from "express-validation";
|
||||
import { Server } from "http";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import morgan from "morgan";
|
||||
import fetch from "node-fetch";
|
||||
import { parseBatchPriceAttestation } from "@pythnetwork/wormhole-attester-sdk";
|
||||
import { removeLeading0x, TimestampInSec } from "./helpers";
|
||||
import { createPriceInfo, PriceInfo, PriceStore } from "./listen";
|
||||
import { logger } from "./logging";
|
||||
import { PromClient } from "./promClient";
|
||||
import { retry } from "ts-retry-promise";
|
||||
import { parseVaa } from "@certusone/wormhole-sdk";
|
||||
import { getOrElse } from "./helpers";
|
||||
import {
|
||||
TargetChain,
|
||||
validTargetChains,
|
||||
defaultTargetChain,
|
||||
encodeVaaForChain,
|
||||
} from "./encoding";
|
||||
|
||||
const MORGAN_LOG_FORMAT =
|
||||
':remote-addr - :remote-user ":method :url HTTP/:http-version"' +
|
||||
' :status :res[content-length] :response-time ms ":referrer" ":user-agent"';
|
||||
|
||||
// GET argument string to represent the options for target_chain
|
||||
export const targetChainArgString = `target_chain=<${validTargetChains.join(
|
||||
"|"
|
||||
)}>`;
|
||||
|
||||
export class RestException extends Error {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
constructor(statusCode: number, message: string) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
static PriceFeedIdNotFound(notFoundIds: string[]): RestException {
|
||||
return new RestException(
|
||||
StatusCodes.BAD_REQUEST,
|
||||
`Price Feed(s) with id(s) ${notFoundIds.join(", ")} not found.`
|
||||
);
|
||||
}
|
||||
|
||||
static DbApiError(): RestException {
|
||||
return new RestException(StatusCodes.INTERNAL_SERVER_ERROR, `DB API Error`);
|
||||
}
|
||||
|
||||
static VaaNotFound(): RestException {
|
||||
return new RestException(StatusCodes.NOT_FOUND, "VAA not found.");
|
||||
}
|
||||
}
|
||||
|
||||
function asyncWrapper(
|
||||
callback: (req: Request, res: Response, next: NextFunction) => Promise<any>
|
||||
) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
callback(req, res, next).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export type VaaResponse = {
|
||||
publishTime: number;
|
||||
vaa: string;
|
||||
};
|
||||
|
||||
export class RestAPI {
|
||||
private port: number;
|
||||
private priceFeedVaaInfo: PriceStore;
|
||||
private isReady: (() => boolean) | undefined;
|
||||
private promClient: PromClient | undefined;
|
||||
private dbApiEndpoint?: string;
|
||||
private dbApiCluster?: string;
|
||||
|
||||
constructor(
|
||||
config: { port: number; dbApiEndpoint?: string; dbApiCluster?: string },
|
||||
priceFeedVaaInfo: PriceStore,
|
||||
isReady?: () => boolean,
|
||||
promClient?: PromClient
|
||||
) {
|
||||
this.port = config.port;
|
||||
this.dbApiEndpoint = config.dbApiEndpoint;
|
||||
this.dbApiCluster = config.dbApiCluster;
|
||||
this.priceFeedVaaInfo = priceFeedVaaInfo;
|
||||
this.isReady = isReady;
|
||||
this.promClient = promClient;
|
||||
}
|
||||
|
||||
async getVaaWithDbLookup(
|
||||
priceFeedId: string,
|
||||
publishTime: TimestampInSec
|
||||
): Promise<VaaResponse | undefined> {
|
||||
// Try to fetch the vaa from the local cache
|
||||
const vaaConfig = this.priceFeedVaaInfo.getVaa(priceFeedId, publishTime);
|
||||
let vaa: VaaResponse | undefined;
|
||||
|
||||
// if publishTime is older than cache ttl or vaa is not found, fetch from db
|
||||
if (vaaConfig !== undefined) {
|
||||
vaa = {
|
||||
vaa: vaaConfig.vaa,
|
||||
publishTime: vaaConfig.publishTime,
|
||||
};
|
||||
} else if (vaa === undefined && this.dbApiEndpoint && this.dbApiCluster) {
|
||||
const priceFeedWithoutLeading0x = removeLeading0x(priceFeedId);
|
||||
|
||||
try {
|
||||
const data = (await retry(
|
||||
() =>
|
||||
fetch(
|
||||
`${this.dbApiEndpoint}/vaa?id=${priceFeedWithoutLeading0x}&publishTime=${publishTime}&cluster=${this.dbApiCluster}`
|
||||
).then((res) => res.json()),
|
||||
{ retries: 3 }
|
||||
)) as any[];
|
||||
if (data.length > 0) {
|
||||
vaa = {
|
||||
vaa: data[0].vaa,
|
||||
publishTime: Math.floor(
|
||||
new Date(data[0].publishTime).getTime() / 1000
|
||||
),
|
||||
};
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(`DB API Error: ${e}`);
|
||||
throw RestException.DbApiError();
|
||||
}
|
||||
}
|
||||
|
||||
return vaa;
|
||||
}
|
||||
|
||||
// Extract the price info from an Accumulator update. This is a temporary solution until hermes adoption
|
||||
// to maintain backward compatibility when the db migrates to the new update format.
|
||||
static extractPriceInfoFromAccumulatorUpdate(
|
||||
priceFeedId: string,
|
||||
updateData: Buffer
|
||||
): PriceInfo | undefined {
|
||||
let offset = 0;
|
||||
offset += 4; // magic
|
||||
offset += 1; // major version
|
||||
offset += 1; // minor version
|
||||
|
||||
const trailingHeaderSize = updateData.readUint8(offset);
|
||||
offset += 1 + trailingHeaderSize;
|
||||
|
||||
const updateType = updateData.readUint8(offset);
|
||||
offset += 1;
|
||||
|
||||
// There is a single update type of 0 for now.
|
||||
if (updateType !== 0) {
|
||||
logger.error(`Invalid accumulator update type: ${updateType}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const vaaLength = updateData.readUint16BE(offset);
|
||||
offset += 2;
|
||||
|
||||
const vaaBuffer = updateData.slice(offset, offset + vaaLength);
|
||||
const vaa = parseVaa(vaaBuffer);
|
||||
offset += vaaLength;
|
||||
|
||||
const numUpdates = updateData.readUint8(offset);
|
||||
offset += 1;
|
||||
|
||||
// Iterate through the updates to find the price info with the given id
|
||||
for (let i = 0; i < numUpdates; i++) {
|
||||
const messageLength = updateData.readUint16BE(offset);
|
||||
offset += 2;
|
||||
|
||||
const message = updateData.slice(offset, offset + messageLength);
|
||||
offset += messageLength;
|
||||
|
||||
const proofLength = updateData.readUint8(offset);
|
||||
offset += 1;
|
||||
|
||||
// ignore proofs
|
||||
offset += proofLength;
|
||||
|
||||
// Checket whether the message is a price feed update
|
||||
// from the given price id and if so, extract the price info
|
||||
let messageOffset = 0;
|
||||
const messageType = message.readUint8(messageOffset);
|
||||
messageOffset += 1;
|
||||
|
||||
// MessageType of 0 is a price feed update
|
||||
if (messageType !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const priceId = message
|
||||
.slice(messageOffset, messageOffset + 32)
|
||||
.toString("hex");
|
||||
messageOffset += 32;
|
||||
|
||||
if (priceId !== priceFeedId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const price = message.readBigInt64BE(messageOffset);
|
||||
messageOffset += 8;
|
||||
const conf = message.readBigUint64BE(messageOffset);
|
||||
messageOffset += 8;
|
||||
const expo = message.readInt32BE(messageOffset);
|
||||
messageOffset += 4;
|
||||
const publishTime = message.readBigInt64BE(messageOffset);
|
||||
messageOffset += 8;
|
||||
const prevPublishTime = message.readBigInt64BE(messageOffset);
|
||||
messageOffset += 8;
|
||||
const emaPrice = message.readBigInt64BE(messageOffset);
|
||||
messageOffset += 8;
|
||||
const emaConf = message.readBigUint64BE(messageOffset);
|
||||
|
||||
return {
|
||||
priceFeed: new PriceFeed({
|
||||
id: priceFeedId,
|
||||
price: new Price({
|
||||
price: price.toString(),
|
||||
conf: conf.toString(),
|
||||
expo,
|
||||
publishTime: Number(publishTime),
|
||||
}),
|
||||
emaPrice: new Price({
|
||||
price: emaPrice.toString(),
|
||||
conf: emaConf.toString(),
|
||||
expo,
|
||||
publishTime: Number(publishTime),
|
||||
}),
|
||||
}),
|
||||
publishTime: Number(publishTime),
|
||||
vaa: vaaBuffer,
|
||||
seqNum: Number(vaa.sequence),
|
||||
emitterChainId: vaa.emitterChain,
|
||||
// These are not available in the accumulator update format
|
||||
// but are required by the PriceInfo type.
|
||||
attestationTime: Number(publishTime),
|
||||
lastAttestedPublishTime: Number(prevPublishTime),
|
||||
priceServiceReceiveTime: Number(publishTime),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static vaaToPriceInfo(
|
||||
priceFeedId: string,
|
||||
vaa: Buffer
|
||||
): PriceInfo | undefined {
|
||||
// Vaa could be the update data from the db with the Accumulator format.
|
||||
const ACCUMULATOR_MAGIC = "504e4155";
|
||||
if (vaa.slice(0, 4).toString("hex") === ACCUMULATOR_MAGIC) {
|
||||
return RestAPI.extractPriceInfoFromAccumulatorUpdate(priceFeedId, vaa);
|
||||
}
|
||||
|
||||
const parsedVaa = parseVaa(vaa);
|
||||
|
||||
let batchAttestation;
|
||||
|
||||
try {
|
||||
batchAttestation = parseBatchPriceAttestation(
|
||||
Buffer.from(parsedVaa.payload)
|
||||
);
|
||||
} catch (e: any) {
|
||||
logger.error(e, e.stack);
|
||||
logger.error("Parsing historical VAA failed: %o", parsedVaa);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const priceAttestation of batchAttestation.priceAttestations) {
|
||||
if (priceAttestation.priceId === priceFeedId) {
|
||||
return createPriceInfo(
|
||||
priceAttestation,
|
||||
vaa,
|
||||
parsedVaa.sequence,
|
||||
parsedVaa.emitterChain
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
priceInfoToJson(
|
||||
priceInfo: PriceInfo,
|
||||
verbose: boolean,
|
||||
targetChain: TargetChain | undefined
|
||||
): object {
|
||||
return {
|
||||
...priceInfo.priceFeed.toJson(),
|
||||
...(verbose && {
|
||||
metadata: {
|
||||
emitter_chain: priceInfo.emitterChainId,
|
||||
attestation_time: priceInfo.attestationTime,
|
||||
sequence_number: priceInfo.seqNum,
|
||||
price_service_receive_time: priceInfo.priceServiceReceiveTime,
|
||||
},
|
||||
}),
|
||||
...(targetChain !== undefined && {
|
||||
vaa: encodeVaaForChain(priceInfo.vaa, targetChain),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Run this function without blocking (`await`) if you want to run it async.
|
||||
async createApp() {
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
|
||||
const winstonStream = {
|
||||
write: (text: string) => {
|
||||
logger.info(text);
|
||||
},
|
||||
};
|
||||
|
||||
app.use(morgan(MORGAN_LOG_FORMAT, { stream: winstonStream }));
|
||||
|
||||
const endpoints: string[] = [];
|
||||
|
||||
const latestVaasInputSchema: schema = {
|
||||
query: Joi.object({
|
||||
ids: Joi.array()
|
||||
.items(Joi.string().regex(/^(0x)?[a-f0-9]{64}$/))
|
||||
.required(),
|
||||
target_chain: Joi.string()
|
||||
.valid(...validTargetChains)
|
||||
.optional(),
|
||||
}).required(),
|
||||
};
|
||||
app.get(
|
||||
"/api/latest_vaas",
|
||||
validate(latestVaasInputSchema),
|
||||
(req: Request, res: Response) => {
|
||||
const priceIds = (req.query.ids as string[]).map(removeLeading0x);
|
||||
const targetChain = getOrElse(
|
||||
req.query.target_chain as TargetChain | undefined,
|
||||
defaultTargetChain
|
||||
);
|
||||
|
||||
// Multiple price ids might share same vaa, we use sequence number as
|
||||
// key of a vaa and deduplicate using a map of seqnum to vaa bytes.
|
||||
const vaaMap = new Map<number, Buffer>();
|
||||
|
||||
const notFoundIds: string[] = [];
|
||||
|
||||
for (const id of priceIds) {
|
||||
const latestPriceInfo = this.priceFeedVaaInfo.getLatestPriceInfo(id);
|
||||
|
||||
if (latestPriceInfo === undefined) {
|
||||
notFoundIds.push(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
vaaMap.set(latestPriceInfo.seqNum, latestPriceInfo.vaa);
|
||||
}
|
||||
|
||||
if (notFoundIds.length > 0) {
|
||||
throw RestException.PriceFeedIdNotFound(notFoundIds);
|
||||
}
|
||||
|
||||
const jsonResponse = Array.from(vaaMap.values(), (vaa) =>
|
||||
encodeVaaForChain(vaa, targetChain)
|
||||
);
|
||||
|
||||
res.json(jsonResponse);
|
||||
}
|
||||
);
|
||||
endpoints.push(
|
||||
`api/latest_vaas?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&${targetChainArgString}`
|
||||
);
|
||||
|
||||
const getVaaInputSchema: schema = {
|
||||
query: Joi.object({
|
||||
id: Joi.string()
|
||||
.regex(/^(0x)?[a-f0-9]{64}$/)
|
||||
.required(),
|
||||
publish_time: Joi.number().required(),
|
||||
target_chain: Joi.string()
|
||||
.valid(...validTargetChains)
|
||||
.optional(),
|
||||
}).required(),
|
||||
};
|
||||
|
||||
app.get(
|
||||
"/api/get_vaa",
|
||||
validate(getVaaInputSchema),
|
||||
asyncWrapper(async (req: Request, res: Response) => {
|
||||
const priceFeedId = removeLeading0x(req.query.id as string);
|
||||
const publishTime = Number(req.query.publish_time as string);
|
||||
const targetChain = getOrElse(
|
||||
req.query.target_chain as TargetChain | undefined,
|
||||
defaultTargetChain
|
||||
);
|
||||
|
||||
if (
|
||||
this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined
|
||||
) {
|
||||
throw RestException.PriceFeedIdNotFound([priceFeedId]);
|
||||
}
|
||||
|
||||
const vaaConfig = await this.getVaaWithDbLookup(
|
||||
priceFeedId,
|
||||
publishTime
|
||||
);
|
||||
if (vaaConfig === undefined) {
|
||||
throw RestException.VaaNotFound();
|
||||
} else {
|
||||
vaaConfig.vaa = encodeVaaForChain(vaaConfig.vaa, targetChain);
|
||||
res.json(vaaConfig);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
endpoints.push(
|
||||
`api/get_vaa?id=<price_feed_id>&publish_time=<publish_time_in_unix_timestamp>&${targetChainArgString}`
|
||||
);
|
||||
|
||||
const getVaaCcipInputSchema: schema = {
|
||||
query: Joi.object({
|
||||
data: Joi.string()
|
||||
.regex(/^0x[a-f0-9]{80}$/)
|
||||
.required(),
|
||||
}).required(),
|
||||
};
|
||||
|
||||
// CCIP compatible endpoint. Read more information about it from
|
||||
// https://eips.ethereum.org/EIPS/eip-3668
|
||||
app.get(
|
||||
"/api/get_vaa_ccip",
|
||||
validate(getVaaCcipInputSchema),
|
||||
asyncWrapper(async (req: Request, res: Response) => {
|
||||
const dataHex = req.query.data as string;
|
||||
const data = Buffer.from(removeLeading0x(dataHex), "hex");
|
||||
|
||||
const priceFeedId = data.slice(0, 32).toString("hex");
|
||||
const publishTime = Number(data.readBigInt64BE(32));
|
||||
|
||||
if (
|
||||
this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined
|
||||
) {
|
||||
throw RestException.PriceFeedIdNotFound([priceFeedId]);
|
||||
}
|
||||
|
||||
const vaa = await this.getVaaWithDbLookup(priceFeedId, publishTime);
|
||||
|
||||
if (vaa === undefined) {
|
||||
// Returning Bad Gateway error because CCIP expects a 5xx error if it needs to
|
||||
// retry or try other endpoints. Bad Gateway seems the best choice here as this
|
||||
// is not an internal error and could happen on two scenarios:
|
||||
// 1. DB Api is not responding well (Bad Gateway is appropriate here)
|
||||
// 2. Publish time is a few seconds before current time and a VAA
|
||||
// Will be available in a few seconds. So we want the client to retry.
|
||||
res
|
||||
.status(StatusCodes.BAD_GATEWAY)
|
||||
.json({ "message:": "VAA not found." });
|
||||
} else {
|
||||
const resData = "0x" + Buffer.from(vaa.vaa, "base64").toString("hex");
|
||||
|
||||
res.json({
|
||||
data: resData,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
endpoints.push(
|
||||
"api/get_vaa_ccip?data=<0x<price_feed_id_32_bytes>+<publish_time_unix_timestamp_be_8_bytes>>"
|
||||
);
|
||||
|
||||
const latestPriceFeedsInputSchema: schema = {
|
||||
query: Joi.object({
|
||||
ids: Joi.array()
|
||||
.items(Joi.string().regex(/^(0x)?[a-f0-9]{64}$/))
|
||||
.required(),
|
||||
verbose: Joi.boolean(),
|
||||
binary: Joi.boolean(),
|
||||
target_chain: Joi.string()
|
||||
.valid(...validTargetChains)
|
||||
.optional(),
|
||||
}).required(),
|
||||
};
|
||||
app.get(
|
||||
"/api/latest_price_feeds",
|
||||
validate(latestPriceFeedsInputSchema),
|
||||
(req: Request, res: Response) => {
|
||||
const priceIds = (req.query.ids as string[]).map(removeLeading0x);
|
||||
// verbose is optional, default to false
|
||||
const verbose = req.query.verbose === "true";
|
||||
// The binary and target_chain are somewhat redundant. Binary still exists for backward compatibility reasons.
|
||||
// No VAA will be returned if both arguments are omitted. binary=true is the same as target_chain=default
|
||||
let targetChain = req.query.target_chain as TargetChain | undefined;
|
||||
if (targetChain === undefined && req.query.binary === "true") {
|
||||
targetChain = defaultTargetChain;
|
||||
}
|
||||
|
||||
const responseJson = [];
|
||||
|
||||
const notFoundIds: string[] = [];
|
||||
|
||||
for (const id of priceIds) {
|
||||
const latestPriceInfo = this.priceFeedVaaInfo.getLatestPriceInfo(id);
|
||||
|
||||
if (latestPriceInfo === undefined) {
|
||||
notFoundIds.push(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
responseJson.push(
|
||||
this.priceInfoToJson(latestPriceInfo, verbose, targetChain)
|
||||
);
|
||||
}
|
||||
|
||||
if (notFoundIds.length > 0) {
|
||||
throw RestException.PriceFeedIdNotFound(notFoundIds);
|
||||
}
|
||||
|
||||
res.json(responseJson);
|
||||
}
|
||||
);
|
||||
endpoints.push(
|
||||
"api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&.."
|
||||
);
|
||||
endpoints.push(
|
||||
"api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&verbose=true"
|
||||
);
|
||||
endpoints.push(
|
||||
"api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&verbose=true&binary=true"
|
||||
);
|
||||
endpoints.push(
|
||||
`api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&verbose=true&${targetChainArgString}`
|
||||
);
|
||||
|
||||
const getPriceFeedInputSchema: schema = {
|
||||
query: Joi.object({
|
||||
id: Joi.string()
|
||||
.regex(/^(0x)?[a-f0-9]{64}$/)
|
||||
.required(),
|
||||
publish_time: Joi.number().required(),
|
||||
verbose: Joi.boolean(),
|
||||
binary: Joi.boolean(),
|
||||
target_chain: Joi.string()
|
||||
.valid(...validTargetChains)
|
||||
.optional(),
|
||||
}).required(),
|
||||
};
|
||||
|
||||
app.get(
|
||||
"/api/get_price_feed",
|
||||
validate(getPriceFeedInputSchema),
|
||||
asyncWrapper(async (req: Request, res: Response) => {
|
||||
const priceFeedId = removeLeading0x(req.query.id as string);
|
||||
const publishTime = Number(req.query.publish_time as string);
|
||||
// verbose is optional, default to false
|
||||
const verbose = req.query.verbose === "true";
|
||||
// The binary and target_chain are somewhat redundant. Binary still exists for backward compatibility reasons.
|
||||
// No VAA will be returned if both arguments are omitted. binary=true is the same as target_chain=default
|
||||
let targetChain = req.query.target_chain as TargetChain | undefined;
|
||||
if (targetChain === undefined && req.query.binary === "true") {
|
||||
targetChain = defaultTargetChain;
|
||||
}
|
||||
|
||||
if (
|
||||
this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined
|
||||
) {
|
||||
throw RestException.PriceFeedIdNotFound([priceFeedId]);
|
||||
}
|
||||
|
||||
const vaa = await this.getVaaWithDbLookup(priceFeedId, publishTime);
|
||||
if (vaa === undefined) {
|
||||
throw RestException.VaaNotFound();
|
||||
}
|
||||
|
||||
const priceInfo = RestAPI.vaaToPriceInfo(
|
||||
priceFeedId,
|
||||
Buffer.from(vaa.vaa, "base64")
|
||||
);
|
||||
|
||||
if (priceInfo === undefined) {
|
||||
throw RestException.VaaNotFound();
|
||||
} else {
|
||||
res.json(this.priceInfoToJson(priceInfo, verbose, targetChain));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
endpoints.push(
|
||||
"api/get_price_feed?id=<price_feed_id>&publish_time=<publish_time_in_unix_timestamp>"
|
||||
);
|
||||
endpoints.push(
|
||||
"api/get_price_feed?id=<price_feed_id>&publish_time=<publish_time_in_unix_timestamp>&verbose=true"
|
||||
);
|
||||
endpoints.push(
|
||||
"api/get_price_feed?id=<price_feed_id>&publish_time=<publish_time_in_unix_timestamp>&binary=true"
|
||||
);
|
||||
|
||||
app.get("/api/price_feed_ids", (req: Request, res: Response) => {
|
||||
const availableIds = this.priceFeedVaaInfo.getPriceIds();
|
||||
res.json([...availableIds]);
|
||||
});
|
||||
endpoints.push("api/price_feed_ids");
|
||||
|
||||
const staleFeedsInputSchema: schema = {
|
||||
query: Joi.object({
|
||||
threshold: Joi.number().required(),
|
||||
}).required(),
|
||||
};
|
||||
app.get(
|
||||
"/api/stale_feeds",
|
||||
validate(staleFeedsInputSchema),
|
||||
(req: Request, res: Response) => {
|
||||
const stalenessThresholdSeconds = Number(req.query.threshold as string);
|
||||
|
||||
const currentTime: TimestampInSec = Math.floor(Date.now() / 1000);
|
||||
|
||||
const priceIds = [...this.priceFeedVaaInfo.getPriceIds()];
|
||||
const stalePrices: Record<HexString, number> = {};
|
||||
|
||||
for (const priceId of priceIds) {
|
||||
const latency =
|
||||
currentTime -
|
||||
this.priceFeedVaaInfo.getLatestPriceInfo(priceId)!.attestationTime;
|
||||
if (latency > stalenessThresholdSeconds) {
|
||||
stalePrices[priceId] = latency;
|
||||
}
|
||||
}
|
||||
|
||||
res.json(stalePrices);
|
||||
}
|
||||
);
|
||||
endpoints.push("/api/stale_feeds?threshold=<staleness_threshold_seconds>");
|
||||
|
||||
app.get("/ready", (_, res: Response) => {
|
||||
if (this.isReady === undefined || this.isReady!()) {
|
||||
res.sendStatus(StatusCodes.OK);
|
||||
} else {
|
||||
res.sendStatus(StatusCodes.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
});
|
||||
endpoints.push("ready");
|
||||
|
||||
app.get("/live", (_, res: Response) => {
|
||||
res.sendStatus(StatusCodes.OK);
|
||||
});
|
||||
endpoints.push("live");
|
||||
|
||||
// Websocket endpoint
|
||||
endpoints.push("ws");
|
||||
|
||||
app.get("/", (_, res: Response) => res.json(endpoints));
|
||||
|
||||
app.use((err: any, _: Request, res: Response, next: NextFunction) => {
|
||||
if (err instanceof ValidationError) {
|
||||
return res.status(err.statusCode).json(err);
|
||||
}
|
||||
|
||||
if (err instanceof RestException) {
|
||||
return res.status(err.statusCode).json(err);
|
||||
}
|
||||
|
||||
return next(err);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async run(): Promise<Server> {
|
||||
const app = await this.createApp();
|
||||
return app.listen(this.port, () =>
|
||||
logger.debug("listening on REST port " + this.port)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,118 +0,0 @@
|
|||
import { logger } from "./logging";
|
||||
import { ParsedVaa } from "@certusone/wormhole-sdk";
|
||||
import { GuardianSet } from "@certusone/wormhole-spydk/lib/cjs/proto/publicrpc/v1/publicrpc";
|
||||
import * as secp256k1 from "secp256k1";
|
||||
import * as keccak from "keccak";
|
||||
|
||||
const WormholeClusters = ["localnet", "testnet", "mainnet"] as const;
|
||||
export type WormholeCluster = typeof WormholeClusters[number];
|
||||
|
||||
export function wormholeClusterFromString(s: string): WormholeCluster {
|
||||
if (WormholeClusters.includes(s as WormholeCluster)) {
|
||||
return s as WormholeCluster;
|
||||
}
|
||||
throw new Error(`Invalid wormhole cluster: ${s}`);
|
||||
}
|
||||
|
||||
const guardianSets: Record<WormholeCluster, GuardianSet> = {
|
||||
localnet: {
|
||||
index: 0,
|
||||
addresses: ["0xbeFA429d57cD18b7F8A4d91A2da9AB4AF05d0FBe"],
|
||||
},
|
||||
testnet: {
|
||||
index: 0,
|
||||
addresses: ["0x13947Bd48b18E53fdAeEe77F3473391aC727C638"],
|
||||
},
|
||||
mainnet: {
|
||||
index: 3,
|
||||
addresses: [
|
||||
"0x58CC3AE5C097b213cE3c81979e1B9f9570746AA5",
|
||||
"0xfF6CB952589BDE862c25Ef4392132fb9D4A42157",
|
||||
"0x114De8460193bdf3A2fCf81f86a09765F4762fD1",
|
||||
"0x107A0086b32d7A0977926A205131d8731D39cbEB",
|
||||
"0x8C82B2fd82FaeD2711d59AF0F2499D16e726f6b2",
|
||||
"0x11b39756C042441BE6D8650b69b54EbE715E2343",
|
||||
"0x54Ce5B4D348fb74B958e8966e2ec3dBd4958a7cd",
|
||||
"0x15e7cAF07C4e3DC8e7C469f92C8Cd88FB8005a20",
|
||||
"0x74a3bf913953D695260D88BC1aA25A4eeE363ef0",
|
||||
"0x000aC0076727b35FBea2dAc28fEE5cCB0fEA768e",
|
||||
"0xAF45Ced136b9D9e24903464AE889F5C8a723FC14",
|
||||
"0xf93124b7c738843CBB89E864c862c38cddCccF95",
|
||||
"0xD2CC37A4dc036a8D232b48f62cDD4731412f4890",
|
||||
"0xDA798F6896A3331F64b48c12D1D57Fd9cbe70811",
|
||||
"0x71AA1BE1D36CaFE3867910F99C09e347899C19C3",
|
||||
"0x8192b6E7387CCd768277c17DAb1b7a5027c0b3Cf",
|
||||
"0x178e21ad2E77AE06711549CFBB1f9c7a9d8096e8",
|
||||
"0x5E1487F35515d02A92753504a8D75471b9f49EdB",
|
||||
"0x6FbEBc898F403E4773E95feB15E80C9A99c8348d",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function isValidVaa(vaa: ParsedVaa, cluster: WormholeCluster): boolean {
|
||||
const currentGuardianSet = guardianSets[cluster];
|
||||
if (vaa.guardianSetIndex !== currentGuardianSet.index) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const threshold = Math.ceil((currentGuardianSet.addresses.length * 2) / 3);
|
||||
if (vaa.guardianSignatures.length < threshold) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// It's not possible to call a signature verification function directly
|
||||
// because we only have the addresses of the guardians and not their public
|
||||
// keys. Instead, we compare the address extracted from the public key that
|
||||
// signed the VAA with the corresponding address stored in the guardian set.
|
||||
|
||||
const messageHash = keccak.default("keccak256").update(vaa.hash).digest();
|
||||
let counter = 0;
|
||||
|
||||
try {
|
||||
vaa.guardianSignatures.forEach((sig) => {
|
||||
// Each signature is a 65-byte secp256k1 signature with the recovery ID at
|
||||
// the last byte. It is not the compact representation from EIP-2098.
|
||||
const recoveryID = sig.signature[64] % 2;
|
||||
const signature = sig.signature.slice(0, 64);
|
||||
const publicKey = Buffer.from(
|
||||
secp256k1.ecdsaRecover(signature, recoveryID, messageHash, false)
|
||||
);
|
||||
// The first byte of the public key is the prefix (0x03 or 0x04)
|
||||
// indicating if the public key is compressed. Remove it before hashing.
|
||||
const publicKeyHash = keccak
|
||||
.default("keccak256")
|
||||
.update(publicKey.slice(1))
|
||||
.digest();
|
||||
// The last 20 bytes of the hash are the address.
|
||||
const address = publicKeyHash.slice(-20).toString("hex");
|
||||
|
||||
if (
|
||||
checksumAddress(address) === currentGuardianSet.addresses[sig.index]
|
||||
) {
|
||||
counter++;
|
||||
}
|
||||
});
|
||||
|
||||
return counter === vaa.guardianSignatures.length;
|
||||
} catch (error) {
|
||||
logger.warn("Error validating VAA signatures:", error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function checksumAddress(address: string) {
|
||||
address = address.toLowerCase().replace("0x", "");
|
||||
const hash = keccak.default("keccak256").update(address).digest("hex");
|
||||
let ret = "0x";
|
||||
|
||||
for (let i = 0; i < address.length; i++) {
|
||||
if (parseInt(hash[i], 16) >= 8) {
|
||||
ret += address[i].toUpperCase();
|
||||
} else {
|
||||
ret += address[i];
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
|
@ -1,281 +0,0 @@
|
|||
import { HexString } from "@pythnetwork/price-service-sdk";
|
||||
import * as http from "http";
|
||||
import Joi from "joi";
|
||||
import WebSocket, { RawData, WebSocketServer } from "ws";
|
||||
import { PriceInfo, PriceStore } from "./listen";
|
||||
import { logger } from "./logging";
|
||||
import { PromClient } from "./promClient";
|
||||
|
||||
const ClientMessageSchema: Joi.Schema = Joi.object({
|
||||
type: Joi.string().valid("subscribe", "unsubscribe").required(),
|
||||
ids: Joi.array()
|
||||
.items(Joi.string().regex(/^(0x)?[a-f0-9]{64}$/))
|
||||
.required(),
|
||||
verbose: Joi.boolean(),
|
||||
binary: Joi.boolean(),
|
||||
}).required();
|
||||
|
||||
export type ClientMessage = {
|
||||
type: "subscribe" | "unsubscribe";
|
||||
ids: HexString[];
|
||||
verbose?: boolean;
|
||||
binary?: boolean;
|
||||
};
|
||||
|
||||
export type ServerResponse = {
|
||||
type: "response";
|
||||
status: "success" | "error";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ServerPriceUpdate = {
|
||||
type: "price_update";
|
||||
price_feed: any;
|
||||
};
|
||||
|
||||
export type PriceFeedConfig = {
|
||||
verbose: boolean;
|
||||
binary: boolean;
|
||||
};
|
||||
|
||||
export type ServerMessage = ServerResponse | ServerPriceUpdate;
|
||||
|
||||
export class WebSocketAPI {
|
||||
private wsCounter: number;
|
||||
private priceFeedClients: Map<HexString, Set<WebSocket>>;
|
||||
private priceFeedClientsConfig: Map<
|
||||
HexString,
|
||||
Map<WebSocket, PriceFeedConfig>
|
||||
>;
|
||||
private aliveClients: Set<WebSocket>;
|
||||
private wsId: Map<WebSocket, number>;
|
||||
private priceFeedVaaInfo: PriceStore;
|
||||
private promClient: PromClient | undefined;
|
||||
|
||||
constructor(priceFeedVaaInfo: PriceStore, promClient?: PromClient) {
|
||||
this.priceFeedVaaInfo = priceFeedVaaInfo;
|
||||
this.priceFeedClients = new Map();
|
||||
this.priceFeedClientsConfig = new Map();
|
||||
this.aliveClients = new Set();
|
||||
this.wsCounter = 0;
|
||||
this.wsId = new Map();
|
||||
this.promClient = promClient;
|
||||
}
|
||||
|
||||
private addPriceFeedClient(
|
||||
ws: WebSocket,
|
||||
id: HexString,
|
||||
verbose: boolean = false,
|
||||
binary: boolean = false
|
||||
) {
|
||||
if (!this.priceFeedClients.has(id)) {
|
||||
this.priceFeedClients.set(id, new Set());
|
||||
this.priceFeedClientsConfig.set(id, new Map([[ws, { verbose, binary }]]));
|
||||
} else {
|
||||
this.priceFeedClientsConfig.get(id)!.set(ws, { verbose, binary });
|
||||
}
|
||||
this.priceFeedClients.get(id)!.add(ws);
|
||||
}
|
||||
|
||||
private delPriceFeedClient(ws: WebSocket, id: HexString) {
|
||||
if (!this.priceFeedClients.has(id)) {
|
||||
return;
|
||||
}
|
||||
this.priceFeedClients.get(id)!.delete(ws);
|
||||
this.priceFeedClientsConfig.get(id)!.delete(ws);
|
||||
}
|
||||
|
||||
dispatchPriceFeedUpdate(priceInfo: PriceInfo) {
|
||||
if (this.priceFeedClients.get(priceInfo.priceFeed.id) === undefined) {
|
||||
logger.info(
|
||||
`Sending ${priceInfo.priceFeed.id} price update to no clients.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const clients: Set<WebSocket> = this.priceFeedClients.get(
|
||||
priceInfo.priceFeed.id
|
||||
)!;
|
||||
logger.info(
|
||||
`Sending ${priceInfo.priceFeed.id} price update to ${
|
||||
clients.size
|
||||
} clients: ${Array.from(clients.values()).map((ws, _idx, _arr) =>
|
||||
this.wsId.get(ws)
|
||||
)}`
|
||||
);
|
||||
|
||||
for (const client of clients.values()) {
|
||||
this.promClient?.addWebSocketInteraction("server_update", "ok");
|
||||
|
||||
const config = this.priceFeedClientsConfig
|
||||
.get(priceInfo.priceFeed.id)!
|
||||
.get(client);
|
||||
|
||||
const verbose = config?.verbose;
|
||||
const binary = config?.binary;
|
||||
|
||||
const priceUpdate: ServerPriceUpdate = {
|
||||
type: "price_update",
|
||||
price_feed: {
|
||||
...priceInfo.priceFeed.toJson(),
|
||||
...(verbose && {
|
||||
metadata: {
|
||||
emitter_chain: priceInfo.emitterChainId,
|
||||
attestation_time: priceInfo.attestationTime,
|
||||
sequence_number: priceInfo.seqNum,
|
||||
price_service_receive_time: priceInfo.priceServiceReceiveTime,
|
||||
},
|
||||
}),
|
||||
...(binary && {
|
||||
vaa: priceInfo.vaa.toString("base64"),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
client.send(JSON.stringify(priceUpdate));
|
||||
}
|
||||
}
|
||||
|
||||
clientClose(ws: WebSocket) {
|
||||
for (const clients of this.priceFeedClients.values()) {
|
||||
if (clients.has(ws)) {
|
||||
clients.delete(ws);
|
||||
}
|
||||
}
|
||||
|
||||
this.aliveClients.delete(ws);
|
||||
this.wsId.delete(ws);
|
||||
}
|
||||
|
||||
handleMessage(ws: WebSocket, data: RawData) {
|
||||
try {
|
||||
const jsonData = JSON.parse(data.toString());
|
||||
const validationResult = ClientMessageSchema.validate(jsonData);
|
||||
if (validationResult.error !== undefined) {
|
||||
throw validationResult.error;
|
||||
}
|
||||
|
||||
const message = jsonData as ClientMessage;
|
||||
|
||||
message.ids = message.ids.map((id) => {
|
||||
if (id.startsWith("0x")) {
|
||||
return id.substring(2);
|
||||
}
|
||||
return id;
|
||||
});
|
||||
|
||||
const availableIds = this.priceFeedVaaInfo.getPriceIds();
|
||||
const notFoundIds = message.ids.filter((id) => !availableIds.has(id));
|
||||
|
||||
if (notFoundIds.length > 0) {
|
||||
throw new Error(
|
||||
`Price Feeds with ids ${notFoundIds.join(", ")} not found`
|
||||
);
|
||||
}
|
||||
|
||||
if (message.type === "subscribe") {
|
||||
message.ids.forEach((id) =>
|
||||
this.addPriceFeedClient(
|
||||
ws,
|
||||
id,
|
||||
message.verbose === true,
|
||||
message.binary === true
|
||||
)
|
||||
);
|
||||
} else {
|
||||
message.ids.forEach((id) => this.delPriceFeedClient(ws, id));
|
||||
}
|
||||
} catch (e: any) {
|
||||
const errorResponse: ServerResponse = {
|
||||
type: "response",
|
||||
status: "error",
|
||||
error: e.message,
|
||||
};
|
||||
|
||||
logger.info(
|
||||
`Invalid request ${data.toString()} from client ${this.wsId.get(ws)}`
|
||||
);
|
||||
this.promClient?.addWebSocketInteraction("client_message", "err");
|
||||
|
||||
ws.send(JSON.stringify(errorResponse));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Successful request ${data.toString()} from client ${this.wsId.get(ws)}`
|
||||
);
|
||||
this.promClient?.addWebSocketInteraction("client_message", "ok");
|
||||
|
||||
const response: ServerResponse = {
|
||||
type: "response",
|
||||
status: "success",
|
||||
};
|
||||
|
||||
ws.send(JSON.stringify(response));
|
||||
}
|
||||
|
||||
run(server: http.Server): WebSocketServer {
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
path: "/ws",
|
||||
maxPayload: 100 * 1024, // 100 KiB
|
||||
});
|
||||
|
||||
wss.on("connection", (ws: WebSocket, request: http.IncomingMessage) => {
|
||||
logger.info(
|
||||
`Incoming ws connection from ${request.socket.remoteAddress}, assigned id: ${this.wsCounter}`
|
||||
);
|
||||
|
||||
this.wsId.set(ws, this.wsCounter);
|
||||
this.wsCounter += 1;
|
||||
|
||||
ws.on("message", (data: RawData) => this.handleMessage(ws, data));
|
||||
|
||||
this.aliveClients.add(ws);
|
||||
|
||||
ws.on("pong", (_data) => {
|
||||
this.aliveClients.add(ws);
|
||||
});
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
logger.warn(`Err with client ${this.wsId.get(ws)}: ${err}`);
|
||||
});
|
||||
|
||||
ws.on("close", (_code: number, _reason: Buffer) => {
|
||||
logger.info(`client ${this.wsId.get(ws)} closed the connection.`);
|
||||
this.promClient?.addWebSocketInteraction("close", "ok");
|
||||
|
||||
this.clientClose(ws);
|
||||
});
|
||||
|
||||
this.promClient?.addWebSocketInteraction("connection", "ok");
|
||||
});
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
if (this.aliveClients.has(ws) === false) {
|
||||
logger.info(
|
||||
`client ${this.wsId.get(ws)} timed out. terminating connection`
|
||||
);
|
||||
this.promClient?.addWebSocketInteraction("timeout", "ok");
|
||||
this.clientClose(ws);
|
||||
ws.terminate();
|
||||
return;
|
||||
}
|
||||
|
||||
this.aliveClients.delete(ws);
|
||||
ws.ping();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
wss.on("close", () => {
|
||||
clearInterval(pingInterval);
|
||||
});
|
||||
|
||||
this.priceFeedVaaInfo.addUpdateListener(
|
||||
this.dispatchPriceFeedUpdate.bind(this)
|
||||
);
|
||||
|
||||
return wss;
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"rootDir": "src/",
|
||||
"outDir": "./lib"
|
||||
}
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"extends": ["tslint:recommended", "tslint-config-prettier"],
|
||||
"rules": {
|
||||
"max-classes-per-file": {
|
||||
"severity": "off"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
[package]
|
||||
name = "pyth-wormhole-attester-client"
|
||||
version = "5.0.0"
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "pyth_wormhole_attester_client"
|
||||
|
||||
[[bin]]
|
||||
name = "pwhac"
|
||||
path = "src/main.rs"
|
||||
|
||||
|
||||
[features]
|
||||
default = ["pyth-wormhole-attester/client", "wormhole-bridge-solana/client", "pyth-wormhole-attester/trace"]
|
||||
|
||||
[dependencies]
|
||||
borsh = "=0.9.3"
|
||||
clap = {version = "3.1.18", features = ["derive"]}
|
||||
env_logger = "0.8.4"
|
||||
log = "0.4.14"
|
||||
wormhole-bridge-solana = {git = "https://github.com/wormhole-foundation/wormhole", tag = "v2.14.8"}
|
||||
pyth-wormhole-attester = {path = "../program"}
|
||||
pyth-wormhole-attester-sdk = { path = "../sdk/rust", features=["solana"] }
|
||||
pyth-sdk-solana = "0.6.1"
|
||||
serde = "1"
|
||||
serde_yaml = "0.8"
|
||||
shellexpand = "2.1.0"
|
||||
solana-client = "=1.10.31"
|
||||
solana-program = "=1.10.31"
|
||||
solana-sdk = "=1.10.31"
|
||||
solana-transaction-status = "=1.10.31"
|
||||
solitaire = {git = "https://github.com/wormhole-foundation/wormhole", tag = "v2.14.8"}
|
||||
tokio = {version = "1", features = ["sync", "rt-multi-thread", "time"]}
|
||||
futures = "0.3.21"
|
||||
sha3 = "0.10.6"
|
||||
generic-array = "0.14.6"
|
||||
lazy_static = "1.4.0"
|
||||
prometheus = "0.13.3"
|
||||
warp = "0.3.3"
|
||||
http = "0.2.8"
|
||||
|
||||
[dev-dependencies]
|
||||
solana-program-test = "=1.10.31"
|
||||
solana-sdk = "=1.10.31"
|
||||
serial_test = "1.0.0"
|
|
@ -1,31 +0,0 @@
|
|||
#syntax=docker/dockerfile:1.2@sha256:e2a8561e419ab1ba6b2fe6cbdf49fd92b95912df1cf7d313c3e2230a333fdbcc
|
||||
FROM ghcr.io/certusone/solana:1.10.31@sha256:d31e8db926a1d3fbaa9d9211d9979023692614b7b64912651aba0383e8c01bad AS solana
|
||||
|
||||
RUN apt-get update && apt-get install -yq python3 libudev-dev ncat
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash - && apt-get install -y nodejs
|
||||
|
||||
ADD governance/remote_executor /usr/src/governance/remote_executor
|
||||
ADD wormhole_attester /usr/src/wormhole_attester
|
||||
|
||||
WORKDIR /usr/src/wormhole_attester
|
||||
|
||||
ENV EMITTER_ADDRESS="11111111111111111111111111111115"
|
||||
ENV BRIDGE_ADDRESS="Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=target \
|
||||
cargo test --package pyth-wormhole-attester-client && \
|
||||
cargo build --package pyth-wormhole-attester-client && \
|
||||
mv target/debug/pwhac /usr/local/bin/pwhac
|
||||
|
||||
ADD third_party/pyth/pyth_utils.py /usr/src/pyth/pyth_utils.py
|
||||
ADD third_party/pyth/p2w_autoattest.py /usr/src/pyth/p2w_autoattest.py
|
||||
ADD tilt_devnet/secrets/solana /solana-secrets
|
||||
|
||||
RUN chmod a+rx /usr/src/pyth/*.py
|
||||
|
||||
ENV P2W_OWNER_KEYPAIR="/solana-secrets/p2w_owner.json"
|
||||
ENV P2W_ATTESTATIONS_PORT="4343"
|
||||
ENV PYTH_PUBLISHER_KEYPAIR="/solana-secrets/pyth_publisher.json"
|
||||
ENV PYTH_PROGRAM_KEYPAIR="/solana-secrets/pyth_program.json"
|
|
@ -1,656 +0,0 @@
|
|||
use {
|
||||
crate::{
|
||||
attestation_cfg::SymbolConfig::{
|
||||
Key,
|
||||
Name,
|
||||
},
|
||||
P2WProductAccount,
|
||||
},
|
||||
log::{
|
||||
info,
|
||||
warn,
|
||||
},
|
||||
serde::{
|
||||
de::Error,
|
||||
Deserialize,
|
||||
Deserializer,
|
||||
Serialize,
|
||||
Serializer,
|
||||
},
|
||||
solana_program::pubkey::Pubkey,
|
||||
std::{
|
||||
collections::{
|
||||
HashMap,
|
||||
HashSet,
|
||||
},
|
||||
str::FromStr,
|
||||
},
|
||||
};
|
||||
|
||||
/// Pyth2wormhole config specific to attestation requests
|
||||
#[derive(Clone, Debug, Hash, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AttestationConfig {
|
||||
#[serde(default = "default_min_msg_reuse_interval_ms")]
|
||||
pub min_msg_reuse_interval_ms: u64,
|
||||
#[serde(default = "default_max_msg_accounts")]
|
||||
pub max_msg_accounts: u64,
|
||||
|
||||
/// How many consecutive attestation failures cause the service to
|
||||
/// report as unhealthy.
|
||||
#[serde(default = "default_healthcheck_window_size")]
|
||||
pub healthcheck_window_size: u64,
|
||||
|
||||
#[serde(default = "default_enable_healthcheck")]
|
||||
pub enable_healthcheck: bool,
|
||||
|
||||
/// Optionally, we take a mapping account to add remaining symbols from a Pyth deployments.
|
||||
/// These symbols are processed under `default_attestation_conditions`.
|
||||
#[serde(
|
||||
deserialize_with = "opt_pubkey_string_de",
|
||||
serialize_with = "opt_pubkey_string_ser",
|
||||
default // Uses Option::default() which is None
|
||||
)]
|
||||
pub mapping_addr: Option<Pubkey>,
|
||||
/// The known symbol list will be reloaded based off this
|
||||
/// interval, to account for mapping changes. Note: This interval
|
||||
/// will only work if the mapping address is defined. Whenever
|
||||
/// it's time to look up the mapping, new attestation jobs are
|
||||
/// started lazily, only if mapping contents affected the known
|
||||
/// symbol list, and before stopping the pre-existing obsolete
|
||||
/// jobs to maintain uninterrupted cranking.
|
||||
#[serde(default = "default_mapping_reload_interval_mins")]
|
||||
pub mapping_reload_interval_mins: u64,
|
||||
#[serde(default = "default_min_rpc_interval_ms")]
|
||||
/// Rate-limiting minimum delay between RPC requests in milliseconds
|
||||
pub min_rpc_interval_ms: u64,
|
||||
/// Attestation conditions that will be used for any symbols included in the mapping
|
||||
/// that aren't explicitly in one of the groups below, and any groups without explicitly
|
||||
/// configured attestation conditions.
|
||||
#[serde(default)]
|
||||
pub default_attestation_conditions: AttestationConditions,
|
||||
|
||||
/// Groups of symbols to publish.
|
||||
pub symbol_groups: Vec<SymbolGroupConfig>,
|
||||
}
|
||||
|
||||
impl AttestationConfig {
|
||||
/// Instantiate the batches of symbols to attest by matching the config against the collection
|
||||
/// of on-chain product accounts.
|
||||
pub fn instantiate_batches(
|
||||
&self,
|
||||
product_accounts: &[P2WProductAccount],
|
||||
max_batch_size: usize,
|
||||
) -> Vec<SymbolBatch> {
|
||||
// Construct mapping from the name of each product account to its corresponding symbols
|
||||
let mut name_to_symbols: HashMap<String, Vec<P2WSymbol>> = HashMap::new();
|
||||
for product_account in product_accounts {
|
||||
for price_account_key in &product_account.price_account_keys {
|
||||
if let Some(name) = &product_account.name {
|
||||
let symbol = P2WSymbol {
|
||||
name: Some(name.clone()),
|
||||
product_addr: product_account.key,
|
||||
price_addr: *price_account_key,
|
||||
};
|
||||
|
||||
name_to_symbols
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instantiate batches from the configured symbol groups.
|
||||
let mut configured_batches: Vec<SymbolBatch> = vec![];
|
||||
for group in &self.symbol_groups {
|
||||
let group_symbols: Vec<P2WSymbol> = group
|
||||
.symbols
|
||||
.iter()
|
||||
.flat_map(|symbol| match &symbol {
|
||||
Key {
|
||||
name,
|
||||
product,
|
||||
price,
|
||||
} => {
|
||||
vec![P2WSymbol {
|
||||
name: name.clone(),
|
||||
product_addr: *product,
|
||||
price_addr: *price,
|
||||
}]
|
||||
}
|
||||
Name { name } => {
|
||||
let maybe_matched_symbols: Option<&Vec<P2WSymbol>> =
|
||||
name_to_symbols.get(name);
|
||||
if let Some(matched_symbols) = maybe_matched_symbols {
|
||||
matched_symbols.clone()
|
||||
} else {
|
||||
// It's slightly unfortunate that this is a warning, but it seems better than crashing.
|
||||
// The data in the mapping account can change while the attester is running and trigger this case,
|
||||
// which means that it is not necessarily a configuration problem.
|
||||
// Note that any named symbols in the config which fail to match will still be included
|
||||
// in the remaining_symbols group below.
|
||||
warn!(
|
||||
"Could not find product account for configured symbol {}",
|
||||
name
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let group_conditions = group
|
||||
.conditions
|
||||
.as_ref()
|
||||
.unwrap_or(&self.default_attestation_conditions);
|
||||
configured_batches.extend(AttestationConfig::partition_into_batches(
|
||||
&group.group_name,
|
||||
max_batch_size,
|
||||
group_conditions,
|
||||
group_symbols,
|
||||
))
|
||||
}
|
||||
|
||||
// Find any accounts not included in existing batches and group them into a remainder batch
|
||||
let existing_price_accounts: HashSet<Pubkey> = configured_batches
|
||||
.iter()
|
||||
.flat_map(|batch| batch.symbols.iter().map(|symbol| symbol.price_addr))
|
||||
.chain(
|
||||
configured_batches
|
||||
.iter()
|
||||
.flat_map(|batch| batch.symbols.iter().map(|symbol| symbol.price_addr)),
|
||||
)
|
||||
.collect();
|
||||
|
||||
let mut remaining_symbols: Vec<P2WSymbol> = vec![];
|
||||
for product_account in product_accounts {
|
||||
for price_account_key in &product_account.price_account_keys {
|
||||
if !existing_price_accounts.contains(price_account_key) {
|
||||
let symbol = P2WSymbol {
|
||||
name: product_account.name.clone(),
|
||||
product_addr: product_account.key,
|
||||
price_addr: *price_account_key,
|
||||
};
|
||||
remaining_symbols.push(symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
let remaining_batches = AttestationConfig::partition_into_batches(
|
||||
&"mapping".to_owned(),
|
||||
max_batch_size,
|
||||
&self.default_attestation_conditions,
|
||||
remaining_symbols,
|
||||
);
|
||||
|
||||
let all_batches = configured_batches
|
||||
.into_iter()
|
||||
.chain(remaining_batches.into_iter())
|
||||
.collect::<Vec<SymbolBatch>>();
|
||||
|
||||
for batch in &all_batches {
|
||||
info!(
|
||||
"Batch {:?}, {} symbols",
|
||||
batch.group_name,
|
||||
batch.symbols.len(),
|
||||
);
|
||||
}
|
||||
|
||||
all_batches
|
||||
}
|
||||
|
||||
/// Partition symbols into a collection of batches, each of which contains no more than
|
||||
/// `max_batch_size` symbols.
|
||||
fn partition_into_batches(
|
||||
batch_name: &String,
|
||||
max_batch_size: usize,
|
||||
conditions: &AttestationConditions,
|
||||
symbols: Vec<P2WSymbol>,
|
||||
) -> Vec<SymbolBatch> {
|
||||
symbols
|
||||
.as_slice()
|
||||
.chunks(max_batch_size)
|
||||
.map(move |batch_symbols| SymbolBatch {
|
||||
group_name: batch_name.to_owned(),
|
||||
symbols: batch_symbols.to_vec(),
|
||||
conditions: conditions.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct SymbolGroupConfig {
|
||||
pub group_name: String,
|
||||
/// Attestation conditions applied to all symbols in this group
|
||||
/// If not provided, use the default attestation conditions from `AttestationConfig`.
|
||||
pub conditions: Option<AttestationConditions>,
|
||||
|
||||
/// The symbols to publish in this group.
|
||||
pub symbols: Vec<SymbolConfig>,
|
||||
}
|
||||
|
||||
/// Config entry for a symbol to attest.
|
||||
#[derive(Clone, Debug, Hash, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SymbolConfig {
|
||||
/// A symbol specified by its product name.
|
||||
Name {
|
||||
/// The name of the symbol. This name is matched against the "symbol" field in the product
|
||||
/// account metadata. If multiple price accounts have this name (either because 2 product
|
||||
/// accounts have the same symbol or a single product account has multiple price accounts),
|
||||
/// it matches *all* of them and puts them into this group.
|
||||
name: String,
|
||||
},
|
||||
/// A symbol specified by its product and price account keys.
|
||||
Key {
|
||||
/// Optional human-readable name for the symbol (for logging purposes).
|
||||
/// This field does not need to match the on-chain data for the product.
|
||||
name: Option<String>,
|
||||
|
||||
#[serde(
|
||||
deserialize_with = "pubkey_string_de",
|
||||
serialize_with = "pubkey_string_ser"
|
||||
)]
|
||||
product: Pubkey,
|
||||
#[serde(
|
||||
deserialize_with = "pubkey_string_de",
|
||||
serialize_with = "pubkey_string_ser"
|
||||
)]
|
||||
price: Pubkey,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToString for SymbolConfig {
|
||||
fn to_string(&self) -> String {
|
||||
match &self {
|
||||
Name { name } => name.clone(),
|
||||
Key {
|
||||
name: Some(name),
|
||||
product: _,
|
||||
price: _,
|
||||
} => name.clone(),
|
||||
Key {
|
||||
name: None,
|
||||
product,
|
||||
price: _,
|
||||
} => {
|
||||
format!("Unnamed product {product}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A batch of symbols that's ready to be attested. Includes all necessary information
|
||||
/// (such as price/product account keys).
|
||||
#[derive(Clone, Debug, Hash, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct SymbolBatch {
|
||||
pub group_name: String,
|
||||
/// Attestation conditions applied to all symbols in this group
|
||||
pub conditions: AttestationConditions,
|
||||
pub symbols: Vec<P2WSymbol>,
|
||||
}
|
||||
|
||||
pub const fn default_max_msg_accounts() -> u64 {
|
||||
1_000_000
|
||||
}
|
||||
|
||||
pub const fn default_min_msg_reuse_interval_ms() -> u64 {
|
||||
10_000 // 10s
|
||||
}
|
||||
|
||||
pub const fn default_healthcheck_window_size() -> u64 {
|
||||
100
|
||||
}
|
||||
|
||||
pub const fn default_enable_healthcheck() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub const fn default_mapping_reload_interval_mins() -> u64 {
|
||||
15
|
||||
}
|
||||
|
||||
pub const fn default_min_rpc_interval_ms() -> u64 {
|
||||
150
|
||||
}
|
||||
|
||||
pub const fn default_min_interval_ms() -> u64 {
|
||||
60_000
|
||||
}
|
||||
|
||||
pub const fn default_rate_limit_interval_secs() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
pub const fn default_max_batch_jobs() -> usize {
|
||||
20
|
||||
}
|
||||
|
||||
/// Per-group attestation resend rules. Attestation is triggered if
|
||||
/// any of the active conditions is met. Option<> fields can be
|
||||
/// de-activated with None. All conditions are inactive by default,
|
||||
/// except for the non-Option ones.
|
||||
#[derive(Clone, Debug, Hash, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AttestationConditions {
|
||||
/// Lower bound on attestation rate. Attestation is triggered
|
||||
/// unconditionally whenever the specified interval elapses since
|
||||
/// last attestation.
|
||||
#[serde(default = "default_min_interval_ms")]
|
||||
pub min_interval_ms: u64,
|
||||
|
||||
/// Upper bound on attestation rate. Attesting the same batch
|
||||
/// before this many seconds pass fails the tx. This limit is
|
||||
/// enforced on-chain, letting concurret attesters prevent
|
||||
/// redundant batch resends and tx expenses. NOTE: The client
|
||||
/// logic does not include rate limit failures in monitoring error
|
||||
/// counts. 0 effectively disables this feature.
|
||||
#[serde(default = "default_rate_limit_interval_secs")]
|
||||
pub rate_limit_interval_secs: u32,
|
||||
|
||||
/// Limit concurrent attestation attempts per batch. This setting
|
||||
/// should act only as a failsafe cap on resource consumption and is
|
||||
/// best set well above the expected average number of jobs.
|
||||
#[serde(default = "default_max_batch_jobs")]
|
||||
pub max_batch_jobs: usize,
|
||||
|
||||
/// Trigger attestation if price changes by the specified
|
||||
/// percentage, expressed in integer basis points (1bps = 0.01%)
|
||||
#[serde(default)]
|
||||
pub price_changed_bps: Option<u64>,
|
||||
|
||||
/// Trigger attestation if publish_time advances at least the
|
||||
/// specified amount.
|
||||
#[serde(default)]
|
||||
pub publish_time_min_delta_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl AttestationConditions {
|
||||
/// Used by should_resend() to check if it needs to make the expensive RPC request
|
||||
pub fn need_onchain_lookup(&self) -> bool {
|
||||
// Bug trap for new fields that also need to be included in
|
||||
// the returned expression
|
||||
let AttestationConditions {
|
||||
min_interval_ms: _min_interval_ms,
|
||||
max_batch_jobs: _max_batch_jobs,
|
||||
price_changed_bps,
|
||||
publish_time_min_delta_secs,
|
||||
rate_limit_interval_secs: _,
|
||||
} = self;
|
||||
|
||||
price_changed_bps.is_some() || publish_time_min_delta_secs.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AttestationConditions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_interval_ms: default_min_interval_ms(),
|
||||
max_batch_jobs: default_max_batch_jobs(),
|
||||
price_changed_bps: None,
|
||||
publish_time_min_delta_secs: None,
|
||||
rate_limit_interval_secs: default_rate_limit_interval_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug, Hash, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct P2WSymbol {
|
||||
/// User-defined human-readable name
|
||||
pub name: Option<String>,
|
||||
|
||||
#[serde(
|
||||
deserialize_with = "pubkey_string_de",
|
||||
serialize_with = "pubkey_string_ser"
|
||||
)]
|
||||
pub product_addr: Pubkey,
|
||||
#[serde(
|
||||
deserialize_with = "pubkey_string_de",
|
||||
serialize_with = "pubkey_string_ser"
|
||||
)]
|
||||
pub price_addr: Pubkey,
|
||||
}
|
||||
|
||||
impl ToString for P2WSymbol {
|
||||
fn to_string(&self) -> String {
|
||||
self.name
|
||||
.clone()
|
||||
.unwrap_or(format!("Unnamed product {}", self.product_addr))
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for strinigified SOL addresses
|
||||
|
||||
fn pubkey_string_ser<S>(k: &Pubkey, ser: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
ser.serialize_str(&k.to_string())
|
||||
}
|
||||
|
||||
fn pubkey_string_de<'de, D>(de: D) -> Result<Pubkey, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let pubkey_string = String::deserialize(de)?;
|
||||
let pubkey = Pubkey::from_str(&pubkey_string).map_err(D::Error::custom)?;
|
||||
Ok(pubkey)
|
||||
}
|
||||
|
||||
fn opt_pubkey_string_ser<S>(k_opt: &Option<Pubkey>, ser: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let k_str_opt = (*k_opt).map(|k| k.to_string());
|
||||
|
||||
Option::<String>::serialize(&k_str_opt, ser)
|
||||
}
|
||||
|
||||
fn opt_pubkey_string_de<'de, D>(de: D) -> Result<Option<Pubkey>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
match Option::<String>::deserialize(de)? {
|
||||
Some(k) => Ok(Some(Pubkey::from_str(&k).map_err(D::Error::custom)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use {
|
||||
super::*,
|
||||
crate::attestation_cfg::SymbolConfig::{
|
||||
Key,
|
||||
Name,
|
||||
},
|
||||
solitaire::ErrBox,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_sanity() -> Result<(), ErrBox> {
|
||||
let fastbois = SymbolGroupConfig {
|
||||
group_name: "fast bois".to_owned(),
|
||||
conditions: Some(AttestationConditions {
|
||||
min_interval_ms: 5,
|
||||
..Default::default()
|
||||
}),
|
||||
symbols: vec![
|
||||
Name {
|
||||
name: "ETHUSD".to_owned(),
|
||||
},
|
||||
Key {
|
||||
name: Some("BTCUSD".to_owned()),
|
||||
product: Pubkey::new_unique(),
|
||||
price: Pubkey::new_unique(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let slowbois = SymbolGroupConfig {
|
||||
group_name: "slow bois".to_owned(),
|
||||
conditions: Some(AttestationConditions {
|
||||
min_interval_ms: 200,
|
||||
..Default::default()
|
||||
}),
|
||||
symbols: vec![
|
||||
Name {
|
||||
name: "CNYAUD".to_owned(),
|
||||
},
|
||||
Key {
|
||||
name: None,
|
||||
product: Pubkey::new_unique(),
|
||||
price: Pubkey::new_unique(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let cfg = AttestationConfig {
|
||||
min_msg_reuse_interval_ms: 1000,
|
||||
max_msg_accounts: 100_000,
|
||||
enable_healthcheck: true,
|
||||
healthcheck_window_size: 100,
|
||||
min_rpc_interval_ms: 2123,
|
||||
mapping_addr: None,
|
||||
mapping_reload_interval_mins: 42,
|
||||
default_attestation_conditions: AttestationConditions::default(),
|
||||
symbol_groups: vec![fastbois, slowbois],
|
||||
};
|
||||
|
||||
let serialized = serde_yaml::to_string(&cfg)?;
|
||||
|
||||
let deserialized: AttestationConfig = serde_yaml::from_str(&serialized)?;
|
||||
|
||||
assert_eq!(cfg, deserialized);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_instantiate_batches() -> Result<(), ErrBox> {
|
||||
let btc_product_key = Pubkey::new_unique();
|
||||
let btc_price_key = Pubkey::new_unique();
|
||||
|
||||
let eth_product_key = Pubkey::new_unique();
|
||||
let eth_price_key_1 = Pubkey::new_unique();
|
||||
let eth_price_key_2 = Pubkey::new_unique();
|
||||
|
||||
let unk_product_key = Pubkey::new_unique();
|
||||
let unk_price_key = Pubkey::new_unique();
|
||||
|
||||
let eth_dup_product_key = Pubkey::new_unique();
|
||||
let eth_dup_price_key = Pubkey::new_unique();
|
||||
|
||||
let attestation_conditions_1 = AttestationConditions {
|
||||
min_interval_ms: 5,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let products = vec![
|
||||
P2WProductAccount {
|
||||
name: Some("ETHUSD".to_owned()),
|
||||
key: eth_product_key,
|
||||
price_account_keys: vec![eth_price_key_1, eth_price_key_2],
|
||||
},
|
||||
P2WProductAccount {
|
||||
name: None,
|
||||
key: unk_product_key,
|
||||
price_account_keys: vec![unk_price_key],
|
||||
},
|
||||
];
|
||||
|
||||
let group1 = SymbolGroupConfig {
|
||||
group_name: "group 1".to_owned(),
|
||||
conditions: Some(attestation_conditions_1.clone()),
|
||||
symbols: vec![
|
||||
Key {
|
||||
name: Some("BTCUSD".to_owned()),
|
||||
price: btc_price_key,
|
||||
product: btc_product_key,
|
||||
},
|
||||
Name {
|
||||
name: "ETHUSD".to_owned(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let group2 = SymbolGroupConfig {
|
||||
group_name: "group 2".to_owned(),
|
||||
conditions: None,
|
||||
symbols: vec![Key {
|
||||
name: Some("ETHUSD".to_owned()),
|
||||
price: eth_dup_price_key,
|
||||
product: eth_dup_product_key,
|
||||
}],
|
||||
};
|
||||
|
||||
let default_attestation_conditions = AttestationConditions {
|
||||
min_interval_ms: 1,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = AttestationConfig {
|
||||
min_msg_reuse_interval_ms: 1000,
|
||||
max_msg_accounts: 100_000,
|
||||
healthcheck_window_size: 100,
|
||||
enable_healthcheck: true,
|
||||
min_rpc_interval_ms: 2123,
|
||||
mapping_addr: None,
|
||||
mapping_reload_interval_mins: 42,
|
||||
default_attestation_conditions: default_attestation_conditions.clone(),
|
||||
symbol_groups: vec![group1, group2],
|
||||
};
|
||||
|
||||
let batches = cfg.instantiate_batches(&products, 2);
|
||||
|
||||
assert_eq!(
|
||||
batches,
|
||||
vec![
|
||||
SymbolBatch {
|
||||
group_name: "group 1".to_owned(),
|
||||
conditions: attestation_conditions_1.clone(),
|
||||
symbols: vec![
|
||||
P2WSymbol {
|
||||
name: Some("BTCUSD".to_owned()),
|
||||
product_addr: btc_product_key,
|
||||
price_addr: btc_price_key,
|
||||
},
|
||||
P2WSymbol {
|
||||
name: Some("ETHUSD".to_owned()),
|
||||
product_addr: eth_product_key,
|
||||
price_addr: eth_price_key_1,
|
||||
}
|
||||
],
|
||||
},
|
||||
SymbolBatch {
|
||||
group_name: "group 1".to_owned(),
|
||||
conditions: attestation_conditions_1,
|
||||
symbols: vec![P2WSymbol {
|
||||
name: Some("ETHUSD".to_owned()),
|
||||
product_addr: eth_product_key,
|
||||
price_addr: eth_price_key_2,
|
||||
}],
|
||||
},
|
||||
SymbolBatch {
|
||||
group_name: "group 2".to_owned(),
|
||||
conditions: default_attestation_conditions.clone(),
|
||||
symbols: vec![P2WSymbol {
|
||||
name: Some("ETHUSD".to_owned()),
|
||||
product_addr: eth_dup_product_key,
|
||||
price_addr: eth_dup_price_key,
|
||||
}],
|
||||
},
|
||||
SymbolBatch {
|
||||
group_name: "mapping".to_owned(),
|
||||
conditions: default_attestation_conditions,
|
||||
symbols: vec![P2WSymbol {
|
||||
name: None,
|
||||
product_addr: unk_product_key,
|
||||
price_addr: unk_price_key,
|
||||
}],
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -1,159 +0,0 @@
|
|||
use {
|
||||
crate::{
|
||||
attestation_cfg::SymbolBatch,
|
||||
AttestationConditions,
|
||||
P2WSymbol,
|
||||
},
|
||||
log::{
|
||||
debug,
|
||||
warn,
|
||||
},
|
||||
pyth_sdk_solana::state::PriceAccount,
|
||||
solana_client::nonblocking::rpc_client::RpcClient,
|
||||
std::time::{
|
||||
Duration,
|
||||
Instant,
|
||||
},
|
||||
};
|
||||
|
||||
/// Runtime representation of a batch. It refers to the original group
|
||||
/// from the config.
|
||||
#[derive(Debug)]
|
||||
pub struct BatchState {
|
||||
pub group_name: String,
|
||||
pub symbols: Vec<P2WSymbol>,
|
||||
pub last_known_symbol_states: Vec<Option<PriceAccount>>,
|
||||
pub conditions: AttestationConditions,
|
||||
pub last_job_finished_at: Instant,
|
||||
}
|
||||
|
||||
impl<'a> BatchState {
|
||||
pub fn new(group: &SymbolBatch) -> Self {
|
||||
Self {
|
||||
group_name: group.group_name.clone(),
|
||||
symbols: group.symbols.clone(),
|
||||
conditions: group.conditions.clone(),
|
||||
last_known_symbol_states: vec![None; group.symbols.len()],
|
||||
last_job_finished_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate the configured attestation conditions for this
|
||||
/// batch. RPC is used to update last known state. Returns
|
||||
/// Some("<reason>") if any trigger condition was met. Only the
|
||||
/// first encountered condition is mentioned.
|
||||
pub async fn should_resend(&mut self, c: &RpcClient) -> Option<String> {
|
||||
let mut ret = None;
|
||||
|
||||
let sym_count = self.symbols.len();
|
||||
let pubkeys: Vec<_> = self.symbols.iter().map(|s| s.price_addr).collect();
|
||||
|
||||
// min interval
|
||||
if self.last_job_finished_at.elapsed()
|
||||
> Duration::from_millis(self.conditions.min_interval_ms)
|
||||
{
|
||||
ret = Some(format!(
|
||||
"minimum interval of {}s elapsed since last state change",
|
||||
self.conditions.min_interval_ms
|
||||
));
|
||||
}
|
||||
|
||||
// Only lookup and compare symbols if the conditions require
|
||||
if self.conditions.need_onchain_lookup() {
|
||||
let new_symbol_states: Vec<Option<PriceAccount>> =
|
||||
match c.get_multiple_accounts(&pubkeys).await {
|
||||
Ok(acc_opts) => {
|
||||
acc_opts
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, opt)| {
|
||||
// Take each Some(acc), make it None and log on load_price_account() error
|
||||
opt.and_then(|acc| {
|
||||
pyth_sdk_solana::state::load_price_account(&acc.data)
|
||||
.cloned() // load_price_account() transmutes the data reference into another reference, and owning acc_opts is not enough
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
"Could not parse symbol {}/{}: {}",
|
||||
idx, sym_count, e
|
||||
);
|
||||
e
|
||||
})
|
||||
.ok() // Err becomes None
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not look up any symbols on-chain: {}", e);
|
||||
vec![None; sym_count]
|
||||
}
|
||||
};
|
||||
|
||||
for (idx, old_new_tup) in self
|
||||
.last_known_symbol_states
|
||||
.iter_mut() // Borrow mutably to make the update easier
|
||||
.zip(new_symbol_states.iter())
|
||||
.enumerate()
|
||||
{
|
||||
// Only evaluate this symbol if a triggering condition is not already met
|
||||
if ret.is_some() {
|
||||
break;
|
||||
}
|
||||
match old_new_tup {
|
||||
(Some(old), Some(new)) => {
|
||||
// publish_time_changed
|
||||
if let Some(min_delta_secs) = self.conditions.publish_time_min_delta_secs {
|
||||
if new.timestamp - old.timestamp > min_delta_secs as i64 {
|
||||
ret = Some(format!(
|
||||
"publish_time advanced by at least {}s for {:?}",
|
||||
min_delta_secs,
|
||||
self.symbols[idx].to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
// price_changed_bps
|
||||
} else if let Some(bps) = self.conditions.price_changed_bps {
|
||||
let pct = bps as f64 / 100.0;
|
||||
let price_pct_diff = ((old.agg.price as f64 - new.agg.price as f64)
|
||||
/ old.agg.price as f64
|
||||
* 100.0)
|
||||
.abs();
|
||||
|
||||
if price_pct_diff > pct {
|
||||
ret = Some(format!(
|
||||
"price moved by at least {}% for {:?}",
|
||||
pct,
|
||||
self.symbols[idx].to_string()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
"Symbol {:?} {}/{}, old or new state value is None, skipping...",
|
||||
self.symbols[idx].to_string(),
|
||||
idx + 1,
|
||||
sym_count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update with newer state only if a condition was met. We
|
||||
// don't want to shadow changes that may happen over a larger
|
||||
// period between state lookups.
|
||||
if ret.is_some() {
|
||||
for (old, new) in self
|
||||
.last_known_symbol_states
|
||||
.iter_mut()
|
||||
.zip(new_symbol_states.into_iter())
|
||||
{
|
||||
if new.is_some() {
|
||||
*old = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
}
|
|
@ -1,131 +0,0 @@
|
|||
//! CLI options
|
||||
|
||||
use {
|
||||
clap::{
|
||||
Parser,
|
||||
Subcommand,
|
||||
},
|
||||
solana_program::pubkey::Pubkey,
|
||||
solana_sdk::commitment_config::CommitmentConfig,
|
||||
std::{
|
||||
net::SocketAddr,
|
||||
path::PathBuf,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[clap(
|
||||
about = "A client for the pyth2wormhole Solana program",
|
||||
author = "Pyth Network Contributors"
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[clap(
|
||||
long,
|
||||
help = "Identity JSON file for the entity meant to cover transaction costs",
|
||||
default_value = "~/.config/solana/id.json"
|
||||
)]
|
||||
pub payer: String,
|
||||
#[clap(short, long, default_value = "http://localhost:8899")]
|
||||
pub rpc_url: String,
|
||||
#[clap(long, default_value = "confirmed")]
|
||||
pub commitment: CommitmentConfig,
|
||||
#[clap(long)]
|
||||
pub p2w_addr: Pubkey,
|
||||
#[clap(subcommand)]
|
||||
pub action: Action,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Action {
|
||||
#[clap(about = "Initialize a pyth2wormhole program freshly deployed under <p2w_addr>")]
|
||||
Init {
|
||||
/// The bridge program account
|
||||
#[clap(short = 'w', long = "wh-prog")]
|
||||
wh_prog: Pubkey,
|
||||
#[clap(short = 'o', long = "owner")]
|
||||
owner_addr: Pubkey,
|
||||
#[clap(short = 'p', long = "pyth-owner")]
|
||||
pyth_owner_addr: Pubkey,
|
||||
/// Option<> makes sure not specifying this flag does not imply "false"
|
||||
#[clap(long = "is-active")]
|
||||
is_active: Option<bool>,
|
||||
#[clap(long = "ops-owner")]
|
||||
ops_owner_addr: Option<Pubkey>,
|
||||
},
|
||||
#[clap(
|
||||
about = "Use an existing pyth2wormhole program to attest product price information to another chain"
|
||||
)]
|
||||
// Note: defaults target SOL mainnet-beta conditions at implementation time
|
||||
Attest {
|
||||
#[clap(short = 'f', long = "--config", help = "Attestation YAML config")]
|
||||
attestation_cfg: PathBuf,
|
||||
#[clap(
|
||||
short = 't',
|
||||
long = "--timeout",
|
||||
help = "How many seconds to wait before giving up on tx confirmation.",
|
||||
default_value = "20"
|
||||
)]
|
||||
confirmation_timeout_secs: u64,
|
||||
#[clap(
|
||||
short = 'm',
|
||||
long,
|
||||
help = "Address to use for serving Prometheus metrics.",
|
||||
default_value = "[::]:3000"
|
||||
)]
|
||||
metrics_bind_addr: SocketAddr,
|
||||
},
|
||||
#[clap(about = "Retrieve a pyth2wormhole program's current settings")]
|
||||
GetConfig,
|
||||
#[clap(about = "Update an existing pyth2wormhole program's settings")]
|
||||
SetConfig {
|
||||
/// Current owner keypair path
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "~/.config/solana/id.json",
|
||||
help = "Keypair file for the current config owner"
|
||||
)]
|
||||
owner: String,
|
||||
/// New owner to set
|
||||
#[clap(long = "new-owner")]
|
||||
new_owner_addr: Option<Pubkey>,
|
||||
#[clap(long = "new-wh-prog")]
|
||||
new_wh_prog: Option<Pubkey>,
|
||||
#[clap(long = "new-pyth-owner")]
|
||||
new_pyth_owner_addr: Option<Pubkey>,
|
||||
#[clap(long = "is-active")]
|
||||
is_active: Option<bool>,
|
||||
#[clap(long = "ops-owner")]
|
||||
ops_owner_addr: Option<Pubkey>,
|
||||
#[clap(long = "remove-ops-owner", conflicts_with = "ops-owner-addr")]
|
||||
remove_ops_owner: bool,
|
||||
},
|
||||
#[clap(
|
||||
about = "Migrate existing pyth2wormhole program settings to a newer format version. Client version must match the deployed contract."
|
||||
)]
|
||||
Migrate {
|
||||
/// owner keypair path
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "~/.config/solana/id.json",
|
||||
help = "Keypair file for the current config owner"
|
||||
)]
|
||||
owner: String,
|
||||
},
|
||||
#[clap(about = "Print out emitter address for the specified pyth2wormhole contract")]
|
||||
GetEmitter,
|
||||
#[clap(about = "Set the value of is_active config as ops_owner")]
|
||||
SetIsActive {
|
||||
/// Current ops owner keypair path
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "~/.config/solana/id.json",
|
||||
help = "Keypair file for the current ops owner"
|
||||
)]
|
||||
ops_owner: String,
|
||||
#[clap(
|
||||
index = 1,
|
||||
possible_values = ["true", "false"],
|
||||
)]
|
||||
new_is_active: String,
|
||||
},
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
#[derive(Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
symbols: Vec<P2WSymbol>,
|
||||
}
|
||||
|
||||
/// Config entry for a Pyth2Wormhole product + price pair
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct P2WSymbol {
|
||||
/// Optional human-readable name, never used on-chain; makes
|
||||
/// attester logs and the config easier to understand
|
||||
name: Option<String>,
|
||||
product: Pubkey,
|
||||
price: Pubkey,
|
||||
}
|
||||
|
||||
#[testmod]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_sanity() -> Result<(), ErrBox> {
|
||||
let serialized = r#"
|
||||
symbols:
|
||||
- name: ETH/USD
|
||||
product_addr: 11111111111111111111111111111111
|
||||
price_addr: 11111111111111111111111111111111
|
||||
- name: SOL/EUR
|
||||
product_addr: 4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi
|
||||
price_addr: 4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi
|
||||
- name: BTC/CNY
|
||||
product_addr: 8qbHbw2BbbTHBW1sbeqakYXVKRQM8Ne7pLK7m6CVfeR
|
||||
price_addr: 8qbHbw2BbbTHBW1sbeqakYXVKRQM8Ne7pLK7m6CVfeR
|
||||
- # no name
|
||||
product_addr: 8qbHbw2BbbTHBW1sbeqakYXVKRQM8Ne7pLK7m6CVfeR
|
||||
price_addr: 8qbHbw2BbbTHBW1sbeqakYXVKRQM8Ne7pLK7m6CVfeR
|
||||
"#;
|
||||
let deserialized = serde_yaml::from_str(serialized)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
use {
|
||||
crate::attestation_cfg,
|
||||
std::{
|
||||
collections::VecDeque,
|
||||
convert::TryInto,
|
||||
sync::Arc,
|
||||
},
|
||||
tokio::sync::Mutex,
|
||||
};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref HEALTHCHECK_STATE: Arc<Mutex<HealthCheckState>> = Arc::new(Mutex::new(HealthCheckState::new(attestation_cfg::default_healthcheck_window_size().try_into().expect("could not convert window size to usize"), attestation_cfg::default_enable_healthcheck())));
|
||||
}
|
||||
|
||||
/// Helper structure for deciding service health
|
||||
pub struct HealthCheckState {
|
||||
/// Whether to report the healthy/unhealthy status
|
||||
pub enable: bool,
|
||||
/// Sliding LIFO window over last `max_window_size` attestation results (true = ok, false = error)
|
||||
pub window: VecDeque<bool>,
|
||||
/// Window size
|
||||
pub max_window_size: usize,
|
||||
}
|
||||
|
||||
|
||||
impl HealthCheckState {
|
||||
pub fn new(max_window_size: usize, enable: bool) -> Self {
|
||||
Self {
|
||||
enable,
|
||||
window: VecDeque::with_capacity(max_window_size),
|
||||
max_window_size,
|
||||
}
|
||||
}
|
||||
/// Check service health, return None if not enough data is present
|
||||
pub fn is_healthy(&self) -> Option<bool> {
|
||||
if self.window.len() >= self.max_window_size && self.enable {
|
||||
// If all results are false, return false (unhealthy).
|
||||
Some(self.window.iter().any(|entry| *entry))
|
||||
} else {
|
||||
// The window isn't big enough yet or the healthcheck is disabled
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate the window
|
||||
pub fn add_result(&mut self, res: bool) {
|
||||
self.window.push_front(res);
|
||||
|
||||
// Trim window back to size if needed. truncate() deletes from
|
||||
// the back and has no effect if new size is greater than
|
||||
// current size.
|
||||
self.window.truncate(self.max_window_size);
|
||||
}
|
||||
}
|
|
@ -1,539 +0,0 @@
|
|||
pub mod attestation_cfg;
|
||||
pub mod batch_state;
|
||||
pub mod healthcheck;
|
||||
pub mod message;
|
||||
pub mod util;
|
||||
|
||||
pub use {
|
||||
attestation_cfg::{
|
||||
AttestationConditions,
|
||||
AttestationConfig,
|
||||
P2WSymbol,
|
||||
},
|
||||
batch_state::BatchState,
|
||||
healthcheck::{
|
||||
HealthCheckState,
|
||||
HEALTHCHECK_STATE,
|
||||
},
|
||||
message::P2WMessageQueue,
|
||||
pyth_wormhole_attester::Pyth2WormholeConfig,
|
||||
util::{
|
||||
start_metrics_server,
|
||||
RLMutex,
|
||||
RLMutexGuard,
|
||||
},
|
||||
};
|
||||
use {
|
||||
borsh::{
|
||||
BorshDeserialize,
|
||||
BorshSerialize,
|
||||
},
|
||||
bridge::{
|
||||
accounts::{
|
||||
Bridge,
|
||||
FeeCollector,
|
||||
Sequence,
|
||||
SequenceDerivationData,
|
||||
},
|
||||
types::ConsistencyLevel,
|
||||
},
|
||||
log::{
|
||||
debug,
|
||||
trace,
|
||||
warn,
|
||||
},
|
||||
pyth_sdk_solana::state::{
|
||||
load_mapping_account,
|
||||
load_price_account,
|
||||
load_product_account,
|
||||
},
|
||||
pyth_wormhole_attester::{
|
||||
attestation_state::AttestationStatePDA,
|
||||
config::{
|
||||
OldP2WConfigAccount,
|
||||
P2WConfigAccount,
|
||||
},
|
||||
message::{
|
||||
P2WMessage,
|
||||
P2WMessageDrvData,
|
||||
},
|
||||
AttestData,
|
||||
},
|
||||
pyth_wormhole_attester_sdk::P2WEmitter,
|
||||
solana_client::nonblocking::rpc_client::RpcClient,
|
||||
solana_program::{
|
||||
hash::Hash,
|
||||
instruction::{
|
||||
AccountMeta,
|
||||
Instruction,
|
||||
},
|
||||
pubkey::Pubkey,
|
||||
system_program,
|
||||
sysvar::{
|
||||
clock,
|
||||
rent,
|
||||
},
|
||||
},
|
||||
solana_sdk::{
|
||||
signer::{
|
||||
keypair::Keypair,
|
||||
Signer,
|
||||
},
|
||||
transaction::Transaction,
|
||||
},
|
||||
solitaire::{
|
||||
processors::seeded::Seeded,
|
||||
AccountState,
|
||||
ErrBox,
|
||||
},
|
||||
};
|
||||
|
||||
/// Future-friendly version of solitaire::ErrBox
|
||||
pub type ErrBoxSend = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
pub fn gen_init_tx(
|
||||
payer: Keypair,
|
||||
p2w_addr: Pubkey,
|
||||
config: Pyth2WormholeConfig,
|
||||
latest_blockhash: Hash,
|
||||
) -> Result<Transaction, ErrBox> {
|
||||
let payer_pubkey = payer.pubkey();
|
||||
let acc_metas = vec![
|
||||
// new_config
|
||||
AccountMeta::new(
|
||||
P2WConfigAccount::<{ AccountState::Uninitialized }>::key(None, &p2w_addr),
|
||||
false,
|
||||
),
|
||||
// payer
|
||||
AccountMeta::new(payer.pubkey(), true),
|
||||
// system_program
|
||||
AccountMeta::new(system_program::id(), false),
|
||||
];
|
||||
|
||||
let ix_data = (
|
||||
pyth_wormhole_attester::instruction::Instruction::Initialize,
|
||||
config,
|
||||
);
|
||||
|
||||
let ix = Instruction::new_with_bytes(p2w_addr, ix_data.try_to_vec()?.as_slice(), acc_metas);
|
||||
|
||||
let signers = vec![&payer];
|
||||
|
||||
let tx_signed = Transaction::new_signed_with_payer::<Vec<&Keypair>>(
|
||||
&[ix],
|
||||
Some(&payer_pubkey),
|
||||
&signers,
|
||||
latest_blockhash,
|
||||
);
|
||||
Ok(tx_signed)
|
||||
}
|
||||
|
||||
pub fn get_set_config_ix(
|
||||
p2w_addr: &Pubkey,
|
||||
owner_pubkey: &Pubkey,
|
||||
payer_pubkey: &Pubkey,
|
||||
new_config: Pyth2WormholeConfig,
|
||||
) -> Result<Instruction, ErrBox> {
|
||||
let acc_metas = vec![
|
||||
// config
|
||||
AccountMeta::new(
|
||||
P2WConfigAccount::<{ AccountState::Initialized }>::key(None, p2w_addr),
|
||||
false,
|
||||
),
|
||||
// current_owner
|
||||
AccountMeta::new(*owner_pubkey, true),
|
||||
// payer
|
||||
AccountMeta::new(*payer_pubkey, true),
|
||||
// system_program
|
||||
AccountMeta::new(system_program::id(), false),
|
||||
];
|
||||
let ix_data = (
|
||||
pyth_wormhole_attester::instruction::Instruction::SetConfig,
|
||||
new_config,
|
||||
);
|
||||
Ok(Instruction::new_with_bytes(
|
||||
*p2w_addr,
|
||||
ix_data.try_to_vec()?.as_slice(),
|
||||
acc_metas,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn gen_set_config_tx(
|
||||
payer: Keypair,
|
||||
p2w_addr: Pubkey,
|
||||
owner: Keypair,
|
||||
new_config: Pyth2WormholeConfig,
|
||||
latest_blockhash: Hash,
|
||||
) -> Result<Transaction, ErrBox> {
|
||||
let ix = get_set_config_ix(&p2w_addr, &owner.pubkey(), &payer.pubkey(), new_config)?;
|
||||
|
||||
let signers = vec![&owner, &payer];
|
||||
let tx_signed = Transaction::new_signed_with_payer::<Vec<&Keypair>>(
|
||||
&[ix],
|
||||
Some(&payer.pubkey()),
|
||||
&signers,
|
||||
latest_blockhash,
|
||||
);
|
||||
Ok(tx_signed)
|
||||
}
|
||||
|
||||
pub fn get_set_is_active_ix(
|
||||
p2w_addr: &Pubkey,
|
||||
ops_owner_pubkey: &Pubkey,
|
||||
payer_pubkey: &Pubkey,
|
||||
new_is_active: bool,
|
||||
) -> Result<Instruction, ErrBox> {
|
||||
let acc_metas = vec![
|
||||
// config
|
||||
AccountMeta::new(
|
||||
P2WConfigAccount::<{ AccountState::Initialized }>::key(None, p2w_addr),
|
||||
false,
|
||||
),
|
||||
// ops_owner
|
||||
AccountMeta::new(*ops_owner_pubkey, true),
|
||||
// payer
|
||||
AccountMeta::new(*payer_pubkey, true),
|
||||
];
|
||||
|
||||
let ix_data = (
|
||||
pyth_wormhole_attester::instruction::Instruction::SetIsActive,
|
||||
new_is_active,
|
||||
);
|
||||
Ok(Instruction::new_with_bytes(
|
||||
*p2w_addr,
|
||||
ix_data.try_to_vec()?.as_slice(),
|
||||
acc_metas,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn gen_set_is_active_tx(
|
||||
payer: Keypair,
|
||||
p2w_addr: Pubkey,
|
||||
ops_owner: Keypair,
|
||||
new_is_active: bool,
|
||||
latest_blockhash: Hash,
|
||||
) -> Result<Transaction, ErrBox> {
|
||||
let ix = get_set_is_active_ix(
|
||||
&p2w_addr,
|
||||
&ops_owner.pubkey(),
|
||||
&payer.pubkey(),
|
||||
new_is_active,
|
||||
)?;
|
||||
|
||||
let signers = vec![&ops_owner, &payer];
|
||||
let tx_signed = Transaction::new_signed_with_payer::<Vec<&Keypair>>(
|
||||
&[ix],
|
||||
Some(&payer.pubkey()),
|
||||
&signers,
|
||||
latest_blockhash,
|
||||
);
|
||||
Ok(tx_signed)
|
||||
}
|
||||
|
||||
pub fn gen_migrate_tx(
|
||||
payer: Keypair,
|
||||
p2w_addr: Pubkey,
|
||||
owner: Keypair,
|
||||
latest_blockhash: Hash,
|
||||
) -> Result<Transaction, ErrBox> {
|
||||
let payer_pubkey = payer.pubkey();
|
||||
|
||||
let acc_metas = vec![
|
||||
// new_config
|
||||
AccountMeta::new(
|
||||
P2WConfigAccount::<{ AccountState::Uninitialized }>::key(None, &p2w_addr),
|
||||
false,
|
||||
),
|
||||
// old_config
|
||||
AccountMeta::new(OldP2WConfigAccount::key(None, &p2w_addr), false),
|
||||
// owner
|
||||
AccountMeta::new(owner.pubkey(), true),
|
||||
// payer
|
||||
AccountMeta::new(payer.pubkey(), true),
|
||||
// system_program
|
||||
AccountMeta::new(system_program::id(), false),
|
||||
];
|
||||
|
||||
let ix_data = (
|
||||
pyth_wormhole_attester::instruction::Instruction::Migrate,
|
||||
(),
|
||||
);
|
||||
|
||||
let ix = Instruction::new_with_bytes(p2w_addr, ix_data.try_to_vec()?.as_slice(), acc_metas);
|
||||
|
||||
let signers = vec![&owner, &payer];
|
||||
|
||||
let tx_signed = Transaction::new_signed_with_payer::<Vec<&Keypair>>(
|
||||
&[ix],
|
||||
Some(&payer_pubkey),
|
||||
&signers,
|
||||
latest_blockhash,
|
||||
);
|
||||
Ok(tx_signed)
|
||||
}
|
||||
|
||||
/// Get the current config account data for given p2w program address
|
||||
pub async fn get_config_account(
|
||||
rpc_client: &RpcClient,
|
||||
p2w_addr: &Pubkey,
|
||||
) -> Result<Pyth2WormholeConfig, ErrBox> {
|
||||
let p2w_config_addr = P2WConfigAccount::<{ AccountState::Initialized }>::key(None, p2w_addr);
|
||||
|
||||
let config = Pyth2WormholeConfig::try_from_slice(
|
||||
rpc_client
|
||||
.get_account_data(&p2w_config_addr)
|
||||
.await?
|
||||
.as_slice(),
|
||||
)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Generate an Instruction for making the attest() contract
|
||||
/// call.
|
||||
pub fn gen_attest_tx(
|
||||
p2w_addr: Pubkey,
|
||||
p2w_config: &Pyth2WormholeConfig, // Must be fresh, not retrieved inside to keep side effects away
|
||||
payer: &Keypair,
|
||||
wh_msg_id: u64,
|
||||
symbols: &[P2WSymbol],
|
||||
latest_blockhash: Hash,
|
||||
// Desired rate limit interval. If all of the symbols are over
|
||||
// the limit, the tx will fail. 0 means off.
|
||||
rate_limit_interval_secs: u32,
|
||||
) -> Result<Transaction, ErrBoxSend> {
|
||||
let emitter_addr = P2WEmitter::key(None, &p2w_addr);
|
||||
|
||||
let seq_addr = Sequence::key(
|
||||
&SequenceDerivationData {
|
||||
emitter_key: &emitter_addr,
|
||||
},
|
||||
&p2w_config.wh_prog,
|
||||
);
|
||||
|
||||
let p2w_config_addr = P2WConfigAccount::<{ AccountState::Initialized }>::key(None, &p2w_addr);
|
||||
if symbols.len() > p2w_config.max_batch_size as usize {
|
||||
return Err((format!(
|
||||
"Expected up to {} symbols for batch, {} were found",
|
||||
p2w_config.max_batch_size,
|
||||
symbols.len()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
// Initial attest() accounts
|
||||
let mut acc_metas = vec![
|
||||
// payer
|
||||
AccountMeta::new(payer.pubkey(), true),
|
||||
// system_program
|
||||
AccountMeta::new_readonly(system_program::id(), false),
|
||||
// config
|
||||
AccountMeta::new_readonly(p2w_config_addr, false),
|
||||
];
|
||||
|
||||
// Batch contents and padding if applicable
|
||||
let mut padded_symbols = {
|
||||
let mut not_padded: Vec<_> = symbols
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
let state_address = AttestationStatePDA::key(&s.price_addr, &p2w_addr);
|
||||
vec![
|
||||
AccountMeta::new(state_address, false),
|
||||
AccountMeta::new_readonly(s.price_addr, false),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Align to max batch size with null accounts
|
||||
let mut padding_accounts =
|
||||
vec![
|
||||
AccountMeta::new_readonly(Pubkey::new_from_array([0u8; 32]), false);
|
||||
2 * (p2w_config.max_batch_size as usize - symbols.len())
|
||||
];
|
||||
not_padded.append(&mut padding_accounts);
|
||||
|
||||
not_padded
|
||||
};
|
||||
|
||||
acc_metas.append(&mut padded_symbols);
|
||||
|
||||
// Continue with other pyth_wormhole_attester accounts
|
||||
let mut acc_metas_remainder = vec![
|
||||
// clock
|
||||
AccountMeta::new_readonly(clock::id(), false),
|
||||
// wh_prog
|
||||
AccountMeta::new_readonly(p2w_config.wh_prog, false),
|
||||
// wh_bridge
|
||||
AccountMeta::new(
|
||||
Bridge::<{ AccountState::Initialized }>::key(None, &p2w_config.wh_prog),
|
||||
false,
|
||||
),
|
||||
// wh_message
|
||||
AccountMeta::new(
|
||||
P2WMessage::key(
|
||||
&P2WMessageDrvData {
|
||||
id: wh_msg_id,
|
||||
batch_size: symbols.len() as u16,
|
||||
message_owner: payer.pubkey(),
|
||||
},
|
||||
&p2w_addr,
|
||||
),
|
||||
false,
|
||||
),
|
||||
// wh_emitter
|
||||
AccountMeta::new_readonly(emitter_addr, false),
|
||||
// wh_sequence
|
||||
AccountMeta::new(seq_addr, false),
|
||||
// wh_fee_collector
|
||||
AccountMeta::new(FeeCollector::<'_>::key(None, &p2w_config.wh_prog), false),
|
||||
AccountMeta::new_readonly(rent::id(), false),
|
||||
];
|
||||
|
||||
acc_metas.append(&mut acc_metas_remainder);
|
||||
|
||||
let ix_data = (
|
||||
pyth_wormhole_attester::instruction::Instruction::Attest,
|
||||
AttestData {
|
||||
consistency_level: ConsistencyLevel::Confirmed,
|
||||
message_account_id: wh_msg_id,
|
||||
rate_limit_interval_secs,
|
||||
},
|
||||
);
|
||||
|
||||
let ix = Instruction::new_with_bytes(p2w_addr, ix_data.try_to_vec()?.as_slice(), acc_metas);
|
||||
|
||||
let tx_signed = Transaction::new_signed_with_payer::<Vec<&Keypair>>(
|
||||
&[ix],
|
||||
Some(&payer.pubkey()),
|
||||
&vec![payer],
|
||||
latest_blockhash,
|
||||
);
|
||||
Ok(tx_signed)
|
||||
}
|
||||
|
||||
/// Enumerates all products and their prices in a Pyth mapping.
|
||||
/// Returns map of: product address => [price addresses]
|
||||
pub async fn crawl_pyth_mapping(
|
||||
rpc_client: &RpcClient,
|
||||
first_mapping_addr: &Pubkey,
|
||||
) -> Result<Vec<P2WProductAccount>, ErrBox> {
|
||||
let mut ret: Vec<P2WProductAccount> = vec![];
|
||||
|
||||
let mut n_mappings = 1; // We assume the first one must be valid
|
||||
let mut n_products_total = 0; // Grand total products in all mapping accounts
|
||||
let mut n_prices_total = 0; // Grand total prices in all product accounts in all mapping accounts
|
||||
|
||||
let mut mapping_addr = *first_mapping_addr;
|
||||
|
||||
// loop until the last non-zero MappingAccount.next account
|
||||
loop {
|
||||
let mapping_bytes = rpc_client.get_account_data(&mapping_addr).await?;
|
||||
let mapping = match load_mapping_account(&mapping_bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Mapping: Could not parse account {} as a Pyth mapping, crawling terminated. Error: {:?}",
|
||||
mapping_addr, e
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Products in this mapping account
|
||||
let mut n_mapping_products = 0;
|
||||
|
||||
// loop through all products in this mapping; filter out zeroed-out empty product slots
|
||||
for prod_addr in mapping.products.iter().filter(|p| *p != &Pubkey::default()) {
|
||||
let prod_bytes = rpc_client.get_account_data(prod_addr).await?;
|
||||
let prod = match load_product_account(&prod_bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("Mapping {}: Could not parse account {} as a Pyth product, skipping to next product. Error: {:?}", mapping_addr, prod_addr, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut prod_name = None;
|
||||
for (key, val) in prod.iter() {
|
||||
if key.eq_ignore_ascii_case("symbol") {
|
||||
prod_name = Some(val.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
let mut price_addr = prod.px_acc;
|
||||
let mut n_prod_prices = 0;
|
||||
|
||||
// the product might have no price, can happen in tilt due to race-condition, failed tx to add price, ...
|
||||
if price_addr == Pubkey::default() {
|
||||
debug!(
|
||||
"Found product with addr {} that has no prices. \
|
||||
This should not happen in a production enviornment.",
|
||||
prod_addr
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// loop until the last non-zero PriceAccount.next account
|
||||
let mut price_accounts: Vec<Pubkey> = vec![];
|
||||
loop {
|
||||
let price_bytes = rpc_client.get_account_data(&price_addr).await?;
|
||||
let price = match load_price_account(&price_bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("Product {}: Could not parse account {} as a Pyth price, skipping to next product. Error: {:?}", prod_addr, price_addr, e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
price_accounts.push(price_addr);
|
||||
n_prod_prices += 1;
|
||||
|
||||
if price.next == Pubkey::default() {
|
||||
trace!(
|
||||
"Product {}: processed {} price(s)",
|
||||
prod_addr,
|
||||
n_prod_prices
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
price_addr = price.next;
|
||||
}
|
||||
ret.push(P2WProductAccount {
|
||||
key: *prod_addr,
|
||||
name: prod_name.clone(),
|
||||
price_account_keys: price_accounts,
|
||||
});
|
||||
|
||||
n_prices_total += n_prod_prices;
|
||||
}
|
||||
n_mapping_products += 1;
|
||||
n_products_total += n_mapping_products;
|
||||
|
||||
// Traverse other mapping accounts if applicable
|
||||
if mapping.next == Pubkey::default() {
|
||||
trace!(
|
||||
"Mapping {}: processed {} products",
|
||||
mapping_addr,
|
||||
n_mapping_products
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
mapping_addr = mapping.next;
|
||||
n_mappings += 1;
|
||||
}
|
||||
debug!(
|
||||
"Processed {} price(s) in {} product account(s), in {} mapping account(s)",
|
||||
n_prices_total, n_products_total, n_mappings
|
||||
);
|
||||
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct P2WProductAccount {
|
||||
pub key: Pubkey,
|
||||
pub name: Option<String>,
|
||||
pub price_account_keys: Vec<Pubkey>,
|
||||
}
|
|
@ -1,781 +0,0 @@
|
|||
use {
|
||||
pyth_wormhole_attester::error::AttesterCustomError,
|
||||
pyth_wormhole_attester_client::util::send_and_confirm_transaction_with_config,
|
||||
solana_client::rpc_config::RpcSendTransactionConfig,
|
||||
solana_program::instruction::InstructionError,
|
||||
solana_sdk::transaction::TransactionError,
|
||||
};
|
||||
|
||||
pub mod cli;
|
||||
|
||||
use {
|
||||
clap::Parser,
|
||||
cli::{
|
||||
Action,
|
||||
Cli,
|
||||
},
|
||||
futures::future::{
|
||||
Future,
|
||||
TryFutureExt,
|
||||
},
|
||||
generic_array::GenericArray,
|
||||
lazy_static::lazy_static,
|
||||
log::{
|
||||
debug,
|
||||
error,
|
||||
info,
|
||||
warn,
|
||||
LevelFilter,
|
||||
},
|
||||
prometheus::{
|
||||
register_histogram,
|
||||
register_int_counter,
|
||||
register_int_gauge,
|
||||
Histogram,
|
||||
IntCounter,
|
||||
IntGauge,
|
||||
},
|
||||
pyth_wormhole_attester::{
|
||||
attest::P2W_MAX_BATCH_SIZE,
|
||||
Pyth2WormholeConfig,
|
||||
},
|
||||
pyth_wormhole_attester_client::{
|
||||
attestation_cfg::SymbolBatch,
|
||||
crawl_pyth_mapping,
|
||||
gen_attest_tx,
|
||||
gen_init_tx,
|
||||
gen_migrate_tx,
|
||||
gen_set_config_tx,
|
||||
gen_set_is_active_tx,
|
||||
get_config_account,
|
||||
healthcheck::HealthCheckState,
|
||||
start_metrics_server,
|
||||
AttestationConfig,
|
||||
BatchState,
|
||||
ErrBoxSend,
|
||||
P2WMessageQueue,
|
||||
P2WSymbol,
|
||||
RLMutex,
|
||||
HEALTHCHECK_STATE,
|
||||
},
|
||||
pyth_wormhole_attester_sdk::P2WEmitter,
|
||||
sha3::{
|
||||
Digest,
|
||||
Sha3_256,
|
||||
},
|
||||
solana_client::{
|
||||
nonblocking::rpc_client::RpcClient,
|
||||
rpc_config::RpcTransactionConfig,
|
||||
},
|
||||
solana_program::pubkey::Pubkey,
|
||||
solana_sdk::{
|
||||
commitment_config::CommitmentConfig,
|
||||
signature::read_keypair_file,
|
||||
signer::keypair::Keypair,
|
||||
},
|
||||
solana_transaction_status::UiTransactionEncoding,
|
||||
solitaire::{
|
||||
processors::seeded::Seeded,
|
||||
ErrBox,
|
||||
},
|
||||
std::{
|
||||
fs::File,
|
||||
net::SocketAddr,
|
||||
sync::Arc,
|
||||
time::{
|
||||
Duration,
|
||||
Instant,
|
||||
},
|
||||
},
|
||||
tokio::{
|
||||
sync::{
|
||||
Mutex,
|
||||
Semaphore,
|
||||
},
|
||||
task::JoinHandle,
|
||||
},
|
||||
};
|
||||
|
||||
pub const SEQNO_PREFIX: &str = "Program log: Sequence: ";
|
||||
|
||||
lazy_static! {
|
||||
static ref ATTESTATIONS_OK_CNT: IntCounter =
|
||||
register_int_counter!("attestations_ok", "Number of successful attestations")
|
||||
.expect("FATAL: Could not instantiate ATTESTATIONS_OK_CNT");
|
||||
static ref ATTESTATIONS_ERR_CNT: IntCounter =
|
||||
register_int_counter!("attestations_err", "Number of failed attestations")
|
||||
.expect("FATAL: Could not instantiate ATTESTATIONS_ERR_CNT");
|
||||
static ref LAST_SEQNO_GAUGE: IntGauge = register_int_gauge!(
|
||||
"last_seqno",
|
||||
"Latest sequence number produced by this attester"
|
||||
)
|
||||
.expect("FATAL: Could not instantiate LAST_SEQNO_GAUGE");
|
||||
static ref SOL_RPC_TX_PROCESSING_HIST: Histogram = register_histogram!(
|
||||
"sol_rpc_tx_processing",
|
||||
"How long in seconds it takes to send a transaction to the Solana RPC",
|
||||
vec![0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 10.0, 20.0, 30.0, 60.0] // Buckets, 1.0 = 1 second
|
||||
)
|
||||
.expect("FATAL: Could not instantiate SOL_RPC_TX_PROCESSING_HIST");
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() -> Result<(), ErrBox> {
|
||||
let cli = Cli::parse();
|
||||
init_logging();
|
||||
|
||||
// All other CLI actions make rpc requests, this one's meant to be
|
||||
// off-chain explicitly
|
||||
if let Action::GetEmitter = cli.action {
|
||||
let emitter_addr = P2WEmitter::key(None, &cli.p2w_addr);
|
||||
println!("{emitter_addr}");
|
||||
|
||||
// Exit early
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let payer = read_keypair_file(&*shellexpand::tilde(&cli.payer))?;
|
||||
|
||||
let rpc_client = RpcClient::new_with_commitment(cli.rpc_url.clone(), cli.commitment);
|
||||
|
||||
let p2w_addr = cli.p2w_addr;
|
||||
|
||||
let latest_blockhash = rpc_client.get_latest_blockhash().await?;
|
||||
|
||||
match cli.action {
|
||||
Action::Init {
|
||||
owner_addr,
|
||||
pyth_owner_addr,
|
||||
wh_prog,
|
||||
is_active,
|
||||
ops_owner_addr,
|
||||
} => {
|
||||
let tx = gen_init_tx(
|
||||
payer,
|
||||
p2w_addr,
|
||||
Pyth2WormholeConfig {
|
||||
owner: owner_addr,
|
||||
wh_prog,
|
||||
pyth_owner: pyth_owner_addr,
|
||||
is_active: is_active.unwrap_or(true),
|
||||
max_batch_size: P2W_MAX_BATCH_SIZE,
|
||||
ops_owner: ops_owner_addr,
|
||||
},
|
||||
latest_blockhash,
|
||||
)?;
|
||||
rpc_client
|
||||
.send_and_confirm_transaction_with_spinner(&tx)
|
||||
.await?;
|
||||
println!(
|
||||
"Initialized with config:\n{:?}",
|
||||
get_config_account(&rpc_client, &p2w_addr).await?
|
||||
);
|
||||
}
|
||||
Action::GetConfig => {
|
||||
println!("{:?}", get_config_account(&rpc_client, &p2w_addr).await?);
|
||||
}
|
||||
Action::SetConfig {
|
||||
ref owner,
|
||||
new_owner_addr,
|
||||
new_wh_prog,
|
||||
new_pyth_owner_addr,
|
||||
is_active,
|
||||
ops_owner_addr,
|
||||
remove_ops_owner,
|
||||
} => {
|
||||
let old_config = get_config_account(&rpc_client, &p2w_addr).await?;
|
||||
|
||||
let new_ops_owner = if remove_ops_owner {
|
||||
None
|
||||
} else if let Some(given_ops_owner) = ops_owner_addr {
|
||||
Some(given_ops_owner)
|
||||
} else {
|
||||
old_config.ops_owner
|
||||
};
|
||||
|
||||
let tx = gen_set_config_tx(
|
||||
payer,
|
||||
p2w_addr,
|
||||
read_keypair_file(&*shellexpand::tilde(&owner))?,
|
||||
Pyth2WormholeConfig {
|
||||
owner: new_owner_addr.unwrap_or(old_config.owner),
|
||||
wh_prog: new_wh_prog.unwrap_or(old_config.wh_prog),
|
||||
pyth_owner: new_pyth_owner_addr.unwrap_or(old_config.pyth_owner),
|
||||
is_active: is_active.unwrap_or(old_config.is_active),
|
||||
max_batch_size: P2W_MAX_BATCH_SIZE,
|
||||
ops_owner: new_ops_owner,
|
||||
},
|
||||
latest_blockhash,
|
||||
)?;
|
||||
rpc_client
|
||||
.send_and_confirm_transaction_with_spinner(&tx)
|
||||
.await?;
|
||||
println!(
|
||||
"Applied config:\n{:?}",
|
||||
get_config_account(&rpc_client, &p2w_addr).await?
|
||||
);
|
||||
}
|
||||
Action::Migrate { ref owner } => {
|
||||
let tx = gen_migrate_tx(
|
||||
payer,
|
||||
p2w_addr,
|
||||
read_keypair_file(&*shellexpand::tilde(&owner))?,
|
||||
latest_blockhash,
|
||||
)?;
|
||||
rpc_client
|
||||
.send_and_confirm_transaction_with_spinner(&tx)
|
||||
.await?;
|
||||
println!(
|
||||
"Applied config:\n{:?}",
|
||||
get_config_account(&rpc_client, &p2w_addr).await?
|
||||
);
|
||||
}
|
||||
Action::Attest {
|
||||
ref attestation_cfg,
|
||||
confirmation_timeout_secs,
|
||||
metrics_bind_addr,
|
||||
} => {
|
||||
// Load the attestation config yaml
|
||||
let attestation_cfg: AttestationConfig =
|
||||
serde_yaml::from_reader(File::open(attestation_cfg)?)?;
|
||||
|
||||
// Derive seeded accounts
|
||||
let emitter_addr = P2WEmitter::key(None, &p2w_addr);
|
||||
|
||||
info!("Using emitter addr {}", emitter_addr);
|
||||
// Note: For global rate-limitting of RPC requests, we use a
|
||||
// custom Mutex wrapper which enforces a delay of rpc_interval
|
||||
// between RPC accesses.
|
||||
let rpc_cfg = Arc::new(RLMutex::new(
|
||||
RpcCfg {
|
||||
url: cli.rpc_url,
|
||||
timeout: Duration::from_secs(confirmation_timeout_secs),
|
||||
commitment: cli.commitment,
|
||||
},
|
||||
Duration::from_millis(attestation_cfg.min_rpc_interval_ms),
|
||||
));
|
||||
|
||||
handle_attest(rpc_cfg, payer, p2w_addr, attestation_cfg, metrics_bind_addr).await?;
|
||||
}
|
||||
Action::GetEmitter => unreachable! {}, // It is handled early in this function.
|
||||
Action::SetIsActive {
|
||||
ops_owner,
|
||||
new_is_active,
|
||||
} => {
|
||||
let tx = gen_set_is_active_tx(
|
||||
payer,
|
||||
p2w_addr,
|
||||
read_keypair_file(&*shellexpand::tilde(&ops_owner))?,
|
||||
new_is_active.eq_ignore_ascii_case("true"),
|
||||
latest_blockhash,
|
||||
)?;
|
||||
rpc_client
|
||||
.send_and_confirm_transaction_with_spinner(&tx)
|
||||
.await?;
|
||||
println!(
|
||||
"Applied config:\n{:?}",
|
||||
get_config_account(&rpc_client, &p2w_addr).await?
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Continuously send batch attestations for symbols of an attestation config.
|
||||
async fn handle_attest(
|
||||
rpc_cfg: Arc<RLMutex<RpcCfg>>,
|
||||
payer: Keypair,
|
||||
p2w_addr: Pubkey,
|
||||
attestation_cfg: AttestationConfig,
|
||||
metrics_bind_addr: SocketAddr,
|
||||
) -> Result<(), ErrBox> {
|
||||
// Update healthcheck window size from config
|
||||
if attestation_cfg.healthcheck_window_size == 0 {
|
||||
return Err(format!(
|
||||
"{} must be above 0",
|
||||
stringify!(attestation_cfg.healthcheck_window_size)
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
*HEALTHCHECK_STATE.lock().await = HealthCheckState::new(
|
||||
attestation_cfg.healthcheck_window_size as usize,
|
||||
attestation_cfg.enable_healthcheck,
|
||||
);
|
||||
|
||||
if !attestation_cfg.enable_healthcheck {
|
||||
warn!("WARNING: Healthcheck is disabled");
|
||||
}
|
||||
|
||||
tokio::spawn(start_metrics_server(metrics_bind_addr));
|
||||
|
||||
info!("Started serving metrics on {}", metrics_bind_addr);
|
||||
|
||||
info!(
|
||||
"Crawling mapping {:?} every {} minutes",
|
||||
attestation_cfg.mapping_addr, attestation_cfg.mapping_reload_interval_mins
|
||||
);
|
||||
|
||||
// Used for easier detection of config changes
|
||||
let mut hasher = Sha3_256::new();
|
||||
let mut old_sched_futs_state: Option<(JoinHandle<_>, GenericArray<u8, _>)> = None; // (old_futs_handle, old_config_hash)
|
||||
|
||||
// For enforcing min_msg_reuse_interval_ms, we keep a piece of
|
||||
// state that creates or reuses accounts if enough time had
|
||||
// passed. It is crucial that this queue is reused across mapping
|
||||
// lookups, so that previous symbol set's messages have enough
|
||||
// time to be picked up by Wormhole guardians.
|
||||
let message_q_mtx = Arc::new(Mutex::new(P2WMessageQueue::new(
|
||||
Duration::from_millis(attestation_cfg.min_msg_reuse_interval_ms),
|
||||
attestation_cfg.max_msg_accounts as usize,
|
||||
)));
|
||||
|
||||
let mut batch_cfg = vec![];
|
||||
// This loop cranks attestations without interruption. This is
|
||||
// achieved by spinning up a new up-to-date symbol set before
|
||||
// letting go of the previous one. Additionally, hash of on-chain
|
||||
// and attestation configs is used to prevent needless reloads of
|
||||
// an unchanged symbol set.
|
||||
loop {
|
||||
let start_time = Instant::now(); // Helps timekeep mapping lookups accurately
|
||||
|
||||
let config = match get_config_account(&lock_and_make_rpc(&rpc_cfg).await, &p2w_addr).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Could not look up latest on-chain config in top-level loop: {:?}",
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Use the mapping if specified
|
||||
// If we cannot query the mapping account, retain the existing batch configuration.
|
||||
batch_cfg = attestation_config_to_batches(
|
||||
&rpc_cfg,
|
||||
&attestation_cfg,
|
||||
config.max_batch_size as usize,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(batch_cfg);
|
||||
|
||||
|
||||
// Hash currently known config
|
||||
hasher.update(serde_yaml::to_vec(&batch_cfg)?);
|
||||
hasher.update(borsh::to_vec(&config)?);
|
||||
|
||||
let new_cfg_hash = hasher.finalize_reset();
|
||||
|
||||
if let Some((old_handle, old_cfg_hash)) = old_sched_futs_state.as_ref() {
|
||||
// Ignore unchanged configs
|
||||
if &new_cfg_hash == old_cfg_hash {
|
||||
info!("Note: Attestation config and on-chain config unchanged, not stopping existing attestation sched jobs");
|
||||
} else {
|
||||
// Process changed config into attestation scheduling futures
|
||||
info!("Spinning up attestation sched jobs");
|
||||
// Start the new sched futures
|
||||
let new_sched_futs_handle = tokio::spawn(prepare_attestation_sched_jobs(
|
||||
&batch_cfg,
|
||||
&config,
|
||||
&rpc_cfg,
|
||||
&p2w_addr,
|
||||
&payer,
|
||||
message_q_mtx.clone(),
|
||||
));
|
||||
|
||||
// Quit old sched futures
|
||||
old_handle.abort();
|
||||
|
||||
// The just started futures become the on-going attestation state
|
||||
old_sched_futs_state = Some((new_sched_futs_handle, new_cfg_hash));
|
||||
}
|
||||
} else {
|
||||
// Base case for first attestation attempt
|
||||
old_sched_futs_state = Some((
|
||||
tokio::spawn(prepare_attestation_sched_jobs(
|
||||
&batch_cfg,
|
||||
&config,
|
||||
&rpc_cfg,
|
||||
&p2w_addr,
|
||||
&payer,
|
||||
message_q_mtx.clone(),
|
||||
)),
|
||||
new_cfg_hash,
|
||||
));
|
||||
}
|
||||
|
||||
// Sum up elapsed time, wait for next run accurately
|
||||
let target = Duration::from_secs(attestation_cfg.mapping_reload_interval_mins * 60);
|
||||
let elapsed = start_time.elapsed();
|
||||
|
||||
let remaining = target.saturating_sub(elapsed);
|
||||
|
||||
if remaining == Duration::from_secs(0) {
|
||||
warn!(
|
||||
"Processing took more than desired mapping lookup interval of {} seconds, not sleeping. Consider increasing {}",
|
||||
target.as_secs(),
|
||||
// stringify prints the up-to-date setting name automatically
|
||||
stringify!(attestation_cfg.mapping_reload_interval_mins)
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Processing new mapping took {}.{}s, next config/mapping refresh in {}.{}s",
|
||||
elapsed.as_secs(),
|
||||
elapsed.subsec_millis(),
|
||||
remaining.as_secs(),
|
||||
remaining.subsec_millis()
|
||||
);
|
||||
}
|
||||
|
||||
tokio::time::sleep(remaining).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RpcCfg {
|
||||
pub url: String,
|
||||
pub timeout: Duration,
|
||||
pub commitment: CommitmentConfig,
|
||||
}
|
||||
|
||||
/// Helper function for claiming the rate-limited mutex and constructing an RPC instance
|
||||
async fn lock_and_make_rpc(rlmtx: &RLMutex<RpcCfg>) -> RpcClient {
|
||||
let RpcCfg {
|
||||
url,
|
||||
timeout,
|
||||
commitment,
|
||||
} = rlmtx.lock().await.clone();
|
||||
RpcClient::new_with_timeout_and_commitment(url, timeout, commitment)
|
||||
}
|
||||
|
||||
|
||||
/// Generate batches to attest by retrieving the on-chain product account data and grouping it
|
||||
/// according to the configuration in `attestation_cfg`.
|
||||
async fn attestation_config_to_batches(
|
||||
rpc_cfg: &Arc<RLMutex<RpcCfg>>,
|
||||
attestation_cfg: &AttestationConfig,
|
||||
max_batch_size: usize,
|
||||
) -> Result<Vec<SymbolBatch>, ErrBox> {
|
||||
// Use the mapping if specified
|
||||
let products = if let Some(mapping_addr) = attestation_cfg.mapping_addr.as_ref() {
|
||||
let product_accounts_res =
|
||||
crawl_pyth_mapping(&lock_and_make_rpc(rpc_cfg).await, mapping_addr).await;
|
||||
|
||||
if let Err(err) = &product_accounts_res {
|
||||
error!(
|
||||
"Could not crawl mapping {}: {:?}",
|
||||
attestation_cfg.mapping_addr.unwrap_or_default(),
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
product_accounts_res?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
Ok(attestation_cfg.instantiate_batches(&products, max_batch_size))
|
||||
}
|
||||
|
||||
/// Constructs attestation scheduling jobs from attestation config.
|
||||
fn prepare_attestation_sched_jobs(
|
||||
batch_cfg: &[SymbolBatch],
|
||||
p2w_cfg: &Pyth2WormholeConfig,
|
||||
rpc_cfg: &Arc<RLMutex<RpcCfg>>,
|
||||
p2w_addr: &Pubkey,
|
||||
payer: &Keypair,
|
||||
message_q_mtx: Arc<Mutex<P2WMessageQueue>>,
|
||||
) -> futures::future::JoinAll<impl Future<Output = Result<(), ErrBoxSend>>> {
|
||||
// Flatten attestation config into a plain list of batches
|
||||
let batches: Vec<_> = batch_cfg.iter().map(BatchState::new).collect();
|
||||
|
||||
let batch_count = batches.len();
|
||||
|
||||
// Create attestation scheduling routines; see attestation_sched_job() for details
|
||||
let attestation_sched_futs = batches.into_iter().enumerate().map(|(idx, batch)| {
|
||||
attestation_sched_job(AttestationSchedJobArgs {
|
||||
batch,
|
||||
batch_no: idx + 1,
|
||||
batch_count,
|
||||
rpc_cfg: rpc_cfg.clone(),
|
||||
p2w_addr: *p2w_addr,
|
||||
config: p2w_cfg.clone(),
|
||||
payer: Keypair::from_bytes(&payer.to_bytes()).unwrap(),
|
||||
message_q_mtx: message_q_mtx.clone(),
|
||||
})
|
||||
});
|
||||
|
||||
futures::future::join_all(attestation_sched_futs)
|
||||
}
|
||||
|
||||
/// The argument count on attestation_sched_job got out of hand. This
|
||||
/// helps keep the correct order in check.
|
||||
pub struct AttestationSchedJobArgs {
|
||||
pub batch: BatchState,
|
||||
pub batch_no: usize,
|
||||
pub batch_count: usize,
|
||||
pub rpc_cfg: Arc<RLMutex<RpcCfg>>,
|
||||
pub p2w_addr: Pubkey,
|
||||
pub config: Pyth2WormholeConfig,
|
||||
pub payer: Keypair,
|
||||
pub message_q_mtx: Arc<Mutex<P2WMessageQueue>>,
|
||||
}
|
||||
|
||||
/// A future that decides how a batch is sent in daemon mode.
|
||||
///
|
||||
/// Attestations of the batch are scheduled continuously using
|
||||
/// spawn(), which means that a next attestation of the same batch
|
||||
/// begins immediately when a condition is met without waiting for the
|
||||
/// previous attempt to finish. Subsequent attestations are started
|
||||
/// according to the attestation_conditions field on the
|
||||
/// batch. Concurrent requests per batch are limited by the
|
||||
/// max_batch_jobs field to prevent excess memory usage on network
|
||||
/// slowdowns etc..
|
||||
async fn attestation_sched_job(args: AttestationSchedJobArgs) -> Result<(), ErrBoxSend> {
|
||||
let AttestationSchedJobArgs {
|
||||
mut batch,
|
||||
batch_no,
|
||||
batch_count,
|
||||
rpc_cfg,
|
||||
p2w_addr,
|
||||
config,
|
||||
payer,
|
||||
message_q_mtx,
|
||||
} = args;
|
||||
|
||||
// Stagger this sched job by batch_no * 10 milliseconds. It
|
||||
// mitigates uneven distribution of tx requests which may resolve
|
||||
// RPC timeouts on larger interval-based symbol groups.
|
||||
tokio::time::sleep(Duration::from_millis(batch_no as u64 * 10)).await;
|
||||
|
||||
// Enforces the max batch job count
|
||||
let sema = Arc::new(Semaphore::new(batch.conditions.max_batch_jobs));
|
||||
loop {
|
||||
debug!(
|
||||
"Batch {}/{}, group {:?}: Scheduling attestation job",
|
||||
batch_no, batch_count, batch.group_name
|
||||
);
|
||||
|
||||
// park this routine until a resend condition is met
|
||||
loop {
|
||||
if let Some(reason) = batch
|
||||
.should_resend(&lock_and_make_rpc(&rpc_cfg).await)
|
||||
.await
|
||||
{
|
||||
info!(
|
||||
"Batch {}/{}, group {}: Resending (reason: {:?})",
|
||||
batch_no, batch_count, batch.group_name, reason
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if sema.available_permits() == 0 {
|
||||
warn!(
|
||||
"Batch {}/{}, group {:?}: Ran out of job \
|
||||
permits, some attestation conditions may be \
|
||||
delayed. For better accuracy, increase \
|
||||
max_batch_jobs or adjust attestation \
|
||||
conditions",
|
||||
batch_no, batch_count, batch.group_name
|
||||
);
|
||||
}
|
||||
|
||||
let job = attestation_job(AttestationJobArgs {
|
||||
rlmtx: rpc_cfg.clone(),
|
||||
batch_no,
|
||||
batch_count,
|
||||
group_name: batch.group_name.clone(),
|
||||
p2w_addr,
|
||||
config: config.clone(),
|
||||
payer: Keypair::from_bytes(&payer.to_bytes()).unwrap(), // Keypair has no clone
|
||||
symbols: batch.symbols.to_vec(),
|
||||
max_jobs_sema: sema.clone(),
|
||||
message_q_mtx: message_q_mtx.clone(),
|
||||
rate_limit_interval_secs: batch.conditions.rate_limit_interval_secs,
|
||||
});
|
||||
|
||||
// This short-lived permit prevents scheduling excess
|
||||
// attestation jobs hanging on the max jobs semaphore (which could
|
||||
// eventually eat all memory). It is freed as soon as we leave
|
||||
// this code block.
|
||||
let _permit4sched = sema.acquire().await?;
|
||||
|
||||
// Spawn the job in background
|
||||
let _detached_job: JoinHandle<_> = tokio::spawn(job);
|
||||
|
||||
batch.last_job_finished_at = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Arguments for attestation_job(). This struct rules out same-type
|
||||
/// ordering errors due to the large argument count
|
||||
pub struct AttestationJobArgs {
|
||||
pub rlmtx: Arc<RLMutex<RpcCfg>>,
|
||||
pub batch_no: usize,
|
||||
pub batch_count: usize,
|
||||
pub group_name: String,
|
||||
pub p2w_addr: Pubkey,
|
||||
pub config: Pyth2WormholeConfig,
|
||||
pub payer: Keypair,
|
||||
pub symbols: Vec<P2WSymbol>,
|
||||
pub max_jobs_sema: Arc<Semaphore>,
|
||||
pub rate_limit_interval_secs: u32,
|
||||
pub message_q_mtx: Arc<Mutex<P2WMessageQueue>>,
|
||||
}
|
||||
|
||||
/// A future for a single attempt to attest a batch on Solana.
|
||||
async fn attestation_job(args: AttestationJobArgs) -> Result<(), ErrBoxSend> {
|
||||
let AttestationJobArgs {
|
||||
rlmtx,
|
||||
batch_no,
|
||||
batch_count,
|
||||
group_name,
|
||||
p2w_addr,
|
||||
config,
|
||||
payer,
|
||||
symbols,
|
||||
max_jobs_sema,
|
||||
rate_limit_interval_secs,
|
||||
message_q_mtx,
|
||||
} = args;
|
||||
let batch_no4err_msg = batch_no;
|
||||
let batch_count4err_msg = batch_count;
|
||||
let group_name4err_msg = group_name.clone();
|
||||
|
||||
// The following async block is just wrapping the job in a log
|
||||
// statement and err counter increase in case the job fails. It is
|
||||
// done by using the or_else() future method. No other actions are
|
||||
// performed and the error is propagated up the stack.
|
||||
//
|
||||
// This is necessary to learn about errors in jobs started with
|
||||
// tokio::spawn() because in this package spawned futures are
|
||||
// never explicitly awaited on.
|
||||
//
|
||||
// Previously, the or_else() existed in attestation_sched_job()
|
||||
// which schedules this future. It was moved here for readability,
|
||||
// after introduction of Prometheus metrics and the healthcheck,
|
||||
// which helped keep metrics updates closer together.
|
||||
let job_with_err_msg = (async move {
|
||||
// Will be dropped after attestation is complete
|
||||
let _permit = max_jobs_sema.acquire().await?;
|
||||
|
||||
debug!(
|
||||
"Batch {}/{}, group {:?}: Starting attestation job",
|
||||
batch_no, batch_count, group_name
|
||||
);
|
||||
let rpc = lock_and_make_rpc(&rlmtx).await; // Reuse the same lock for the blockhash/tx/get_transaction
|
||||
let latest_blockhash = rpc
|
||||
.get_latest_blockhash()
|
||||
.map_err(|e| -> ErrBoxSend { e.into() })
|
||||
.await?;
|
||||
|
||||
let wh_msg_id = message_q_mtx.lock().await.get_account()?.id;
|
||||
|
||||
let tx = gen_attest_tx(
|
||||
p2w_addr,
|
||||
&config,
|
||||
&payer,
|
||||
wh_msg_id,
|
||||
symbols.as_slice(),
|
||||
latest_blockhash,
|
||||
rate_limit_interval_secs,
|
||||
)?;
|
||||
|
||||
let tx_processing_start_time = Instant::now();
|
||||
|
||||
let sig = match send_and_confirm_transaction_with_config(&rpc, &tx, RpcSendTransactionConfig {
|
||||
// Decreases probability of rate limit race conditions
|
||||
skip_preflight: true,
|
||||
..Default::default()
|
||||
}).await {
|
||||
Ok(s) => Ok(s),
|
||||
Err(e) => match e.get_transaction_error() {
|
||||
Some(TransactionError::InstructionError(_idx, InstructionError::Custom(code)))
|
||||
if code == AttesterCustomError::AttestRateLimitReached as u32 =>
|
||||
{
|
||||
info!(
|
||||
"Batch {}/{}, group {:?} OK: configured {} second rate limit interval reached, backing off",
|
||||
batch_no, batch_count, group_name, rate_limit_interval_secs,
|
||||
);
|
||||
// Note: We return early if rate limit tx
|
||||
// error is detected. This ensures that we
|
||||
// don't count this attempt in ok/err
|
||||
// monitoring and healthcheck counters.
|
||||
return Ok(());
|
||||
}
|
||||
_other => Err(e),
|
||||
},
|
||||
}?;
|
||||
let tx_data = rpc
|
||||
.get_transaction_with_config(
|
||||
&sig,
|
||||
RpcTransactionConfig {
|
||||
encoding: Some(UiTransactionEncoding::Json),
|
||||
commitment: Some(rpc.commitment()),
|
||||
max_supported_transaction_version: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let tx_processing_duration = tx_processing_start_time.elapsed();
|
||||
|
||||
// Manually insert the value into histogram. NOTE: We're not
|
||||
// using the start_timer() method because it would record
|
||||
// durations even for early returns in error conditions which
|
||||
// would look weird in monitoring.
|
||||
SOL_RPC_TX_PROCESSING_HIST.observe(tx_processing_duration.as_secs_f64());
|
||||
|
||||
let seqno = tx_data
|
||||
.transaction
|
||||
.meta
|
||||
.and_then(|meta| meta.log_messages)
|
||||
.and_then(|logs| {
|
||||
let mut seqno = None;
|
||||
for log in logs {
|
||||
if log.starts_with(SEQNO_PREFIX) {
|
||||
seqno = Some(log.replace(SEQNO_PREFIX, ""));
|
||||
break;
|
||||
}
|
||||
}
|
||||
seqno
|
||||
})
|
||||
.ok_or_else(|| -> ErrBoxSend { "No seqno in program logs".to_string().into() })?;
|
||||
|
||||
info!(
|
||||
"Batch {}/{}, group {:?} OK. Sequence: {}",
|
||||
batch_no, batch_count, group_name, seqno
|
||||
);
|
||||
ATTESTATIONS_OK_CNT.inc();
|
||||
LAST_SEQNO_GAUGE.set(seqno.parse::<i64>()?);
|
||||
|
||||
HEALTHCHECK_STATE.lock().await.add_result(true); // Report this job as successful to healthcheck
|
||||
Result::<(), ErrBoxSend>::Ok(())
|
||||
})
|
||||
.or_else(move |e| async move {
|
||||
// log any errors coming from the job
|
||||
warn!(
|
||||
"Batch {}/{}, group {:?} ERR: {:?}",
|
||||
batch_no4err_msg, batch_count4err_msg, group_name4err_msg, e
|
||||
);
|
||||
|
||||
// Bump counters
|
||||
ATTESTATIONS_ERR_CNT.inc();
|
||||
|
||||
HEALTHCHECK_STATE.lock().await.add_result(false); // Report this job as failed to healthcheck
|
||||
|
||||
Err(e)
|
||||
});
|
||||
|
||||
job_with_err_msg.await
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
if std::env::var("RUST_LOG").is_ok() {
|
||||
env_logger::init()
|
||||
} else {
|
||||
// Default to info if RUST_LOG not set
|
||||
env_logger::builder().filter_level(LevelFilter::Info).init();
|
||||
}
|
||||
}
|
|
@ -1,139 +0,0 @@
|
|||
//! Re-usable message scheme for pyth2wormhole
|
||||
|
||||
use {
|
||||
crate::ErrBoxSend,
|
||||
log::debug,
|
||||
std::{
|
||||
collections::VecDeque,
|
||||
time::{
|
||||
Duration,
|
||||
Instant,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/// One of the accounts tracked by the attestation client.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct P2WMessageAccount {
|
||||
/// Unique ID that lets us derive unique accounts for use on-chain
|
||||
pub id: u64,
|
||||
/// Last time we've posted a message to wormhole with this account
|
||||
pub last_used: Instant,
|
||||
}
|
||||
|
||||
/// An umbrella data structure for tracking all message accounts in use
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct P2WMessageQueue {
|
||||
/// The tracked accounts. Sorted from oldest to newest, as guaranteed by get_account()
|
||||
accounts: VecDeque<P2WMessageAccount>,
|
||||
/// How much time needs to pass between reuses
|
||||
grace_period: Duration,
|
||||
/// A hard cap on how many accounts will be created.
|
||||
max_accounts: usize,
|
||||
}
|
||||
|
||||
impl P2WMessageQueue {
|
||||
pub fn new(grace_period: Duration, max_accounts: usize) -> Self {
|
||||
Self {
|
||||
accounts: VecDeque::new(),
|
||||
grace_period,
|
||||
max_accounts,
|
||||
}
|
||||
}
|
||||
/// Finds or creates an account with last_used at least grace_period in the past.
|
||||
///
|
||||
/// This method governs the self.accounts queue and preserves its sorted state.
|
||||
pub fn get_account(&mut self) -> Result<P2WMessageAccount, ErrBoxSend> {
|
||||
// Pick or add an account to use as message
|
||||
let acc = match self.accounts.pop_front() {
|
||||
// Exists and is old enough for reuse
|
||||
Some(mut existing_acc) if existing_acc.last_used.elapsed() > self.grace_period => {
|
||||
existing_acc.last_used = Instant::now();
|
||||
existing_acc
|
||||
}
|
||||
// Exists but isn't old enough for reuse
|
||||
Some(existing_too_new_acc) => {
|
||||
// Counter-act the pop, this account is still oldest
|
||||
// and will be old enough eventually.
|
||||
self.accounts.push_front(existing_too_new_acc);
|
||||
|
||||
// Make sure we're not going over the limit
|
||||
if self.accounts.len() >= self.max_accounts {
|
||||
return Err(format!(
|
||||
"Max message queue size of {} reached.",
|
||||
self.max_accounts
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Increasing message queue size to {}",
|
||||
self.accounts.len() + 1
|
||||
);
|
||||
|
||||
// Use a new account instead
|
||||
P2WMessageAccount {
|
||||
id: self.accounts.len() as u64,
|
||||
last_used: Instant::now(),
|
||||
}
|
||||
}
|
||||
// Base case: Queue is empty, use a new account
|
||||
None => P2WMessageAccount {
|
||||
id: self.accounts.len() as u64,
|
||||
last_used: Instant::now(),
|
||||
},
|
||||
};
|
||||
// The chosen account becomes the newest, push it to the very end.
|
||||
self.accounts.push_back(acc.clone());
|
||||
Ok(acc)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_grows_only_as_needed() -> Result<(), ErrBoxSend> {
|
||||
let mut q = P2WMessageQueue::new(Duration::from_millis(500), 100_000);
|
||||
|
||||
// Empty -> 1 account
|
||||
let acc = q.get_account()?;
|
||||
|
||||
assert_eq!(q.accounts.len(), 1);
|
||||
assert_eq!(acc.id, 0);
|
||||
|
||||
// 1 -> 2 accounts, not enough time passes
|
||||
let acc2 = q.get_account()?;
|
||||
|
||||
assert_eq!(q.accounts.len(), 2);
|
||||
assert_eq!(acc2.id, 1);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(600));
|
||||
|
||||
// Account 0 should be in front, enough time passed
|
||||
let acc3 = q.get_account()?;
|
||||
|
||||
assert_eq!(q.accounts.len(), 2);
|
||||
assert_eq!(acc3.id, 0);
|
||||
|
||||
// Account 1 also qualifies
|
||||
let acc4 = q.get_account()?;
|
||||
|
||||
assert_eq!(q.accounts.len(), 2);
|
||||
assert_eq!(acc4.id, 1);
|
||||
|
||||
// 2 -> 3 accounts, not enough time passes
|
||||
let acc5 = q.get_account()?;
|
||||
|
||||
assert_eq!(q.accounts.len(), 3);
|
||||
assert_eq!(acc5.id, 2);
|
||||
|
||||
// We should end up with 0, 1 and 2 in order
|
||||
assert_eq!(q.accounts[0].id, 0);
|
||||
assert_eq!(q.accounts[1].id, 1);
|
||||
assert_eq!(q.accounts[2].id, 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -1,266 +0,0 @@
|
|||
use {
|
||||
crate::HEALTHCHECK_STATE,
|
||||
http::status::StatusCode,
|
||||
log::{
|
||||
error,
|
||||
trace,
|
||||
},
|
||||
prometheus::TextEncoder,
|
||||
solana_client::{
|
||||
client_error::Result as SolClientResult,
|
||||
nonblocking::rpc_client::RpcClient,
|
||||
rpc_config::RpcSendTransactionConfig,
|
||||
rpc_request::RpcError,
|
||||
},
|
||||
solana_sdk::{
|
||||
commitment_config::CommitmentConfig,
|
||||
signature::Signature,
|
||||
transaction::{
|
||||
uses_durable_nonce,
|
||||
Transaction,
|
||||
},
|
||||
},
|
||||
std::{
|
||||
net::SocketAddr,
|
||||
ops::{
|
||||
Deref,
|
||||
DerefMut,
|
||||
},
|
||||
time::{
|
||||
Duration,
|
||||
Instant,
|
||||
},
|
||||
},
|
||||
tokio::{
|
||||
sync::{
|
||||
Mutex,
|
||||
MutexGuard,
|
||||
},
|
||||
time::sleep,
|
||||
},
|
||||
warp::{
|
||||
reply,
|
||||
Filter,
|
||||
Rejection,
|
||||
Reply,
|
||||
},
|
||||
};
|
||||
|
||||
/// Rate-limited mutex. Ensures there's a period of minimum rl_interval between lock acquisitions
|
||||
pub struct RLMutex<T> {
|
||||
mtx: Mutex<RLMutexState<T>>,
|
||||
rl_interval: Duration,
|
||||
}
|
||||
|
||||
/// Helper to make the last_released writes also guarded by the mutex
|
||||
pub struct RLMutexState<T> {
|
||||
/// Helps make sure regular passage of time is subtracted from sleep duration
|
||||
last_released: Instant,
|
||||
val: T,
|
||||
}
|
||||
|
||||
impl<T> Deref for RLMutexState<T> {
|
||||
type Target = T;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.val
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DerefMut for RLMutexState<T> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.val
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper wrapper to record lock release times via Drop
|
||||
pub struct RLMutexGuard<'a, T> {
|
||||
guard: MutexGuard<'a, RLMutexState<T>>,
|
||||
}
|
||||
|
||||
impl<'a, T> Drop for RLMutexGuard<'a, T> {
|
||||
fn drop(&mut self) {
|
||||
let state: &mut RLMutexState<T> =
|
||||
MutexGuard::<'a, RLMutexState<T>>::deref_mut(&mut self.guard);
|
||||
state.last_released = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> Deref for RLMutexGuard<'a, T> {
|
||||
type Target = T;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.guard.deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> DerefMut for RLMutexGuard<'a, T> {
|
||||
fn deref_mut(&mut self) -> &mut T {
|
||||
self.guard.deref_mut()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> RLMutex<T> {
|
||||
pub fn new(val: T, rl_interval: Duration) -> Self {
|
||||
Self {
|
||||
mtx: Mutex::new(RLMutexState {
|
||||
last_released: Instant::now().checked_sub(rl_interval).unwrap(),
|
||||
val,
|
||||
}),
|
||||
rl_interval,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn lock(&self) -> RLMutexGuard<'_, T> {
|
||||
let guard = self.mtx.lock().await;
|
||||
let elapsed = guard.last_released.elapsed();
|
||||
if elapsed < self.rl_interval {
|
||||
let sleep_time = self.rl_interval - elapsed;
|
||||
trace!(
|
||||
"RLMutex: Parking lock future for {}.{}s",
|
||||
sleep_time.as_secs(),
|
||||
sleep_time.subsec_millis()
|
||||
);
|
||||
|
||||
tokio::time::sleep(sleep_time).await;
|
||||
}
|
||||
|
||||
RLMutexGuard { guard }
|
||||
}
|
||||
}
|
||||
|
||||
async fn metrics_handler() -> Result<impl Reply, Rejection> {
|
||||
let encoder = TextEncoder::new();
|
||||
match encoder.encode_to_string(&prometheus::gather()) {
|
||||
Ok(encoded_metrics) => Ok(reply::with_status(encoded_metrics, StatusCode::OK)),
|
||||
Err(e) => {
|
||||
error!("Could not serve metrics: {}", e.to_string());
|
||||
Ok(reply::with_status(
|
||||
"".to_string(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shares healthcheck result via HTTP status codes. The idea is to
|
||||
/// get a yes/no health answer using a plain HTTP request. Note: Curl
|
||||
/// does not treat 3xx statuses as errors by default.
|
||||
async fn healthcheck_handler() -> Result<impl Reply, Rejection> {
|
||||
let hc_state = HEALTHCHECK_STATE.lock().await;
|
||||
match hc_state.is_healthy() {
|
||||
// Healthy - 200 OK
|
||||
Some(true) => {
|
||||
let ok_count = hc_state
|
||||
.window
|
||||
.iter()
|
||||
.fold(0usize, |acc, val| if *val { acc + 1 } else { acc });
|
||||
let msg = format!(
|
||||
"healthy, {} of {} last attestations OK",
|
||||
ok_count, hc_state.max_window_size
|
||||
);
|
||||
Ok(reply::with_status(msg, StatusCode::OK))
|
||||
}
|
||||
// Unhealthy - 500 Internal Server Error
|
||||
Some(false) => {
|
||||
let msg = format!(
|
||||
"unhealthy, all of {} latest attestations returned error",
|
||||
hc_state.max_window_size
|
||||
);
|
||||
Ok(reply::with_status(msg, StatusCode::INTERNAL_SERVER_ERROR))
|
||||
}
|
||||
// No data - 503 Service Unavailable
|
||||
None => {
|
||||
let msg = if hc_state.enable {
|
||||
format!(
|
||||
"Not enough data in window, {} of {} min attempts made",
|
||||
hc_state.window.len(),
|
||||
hc_state.max_window_size
|
||||
)
|
||||
} else {
|
||||
"Healthcheck disabled (enable_healthcheck is false)".to_string()
|
||||
};
|
||||
Ok(reply::with_status(msg, StatusCode::SERVICE_UNAVAILABLE))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves Prometheus metrics and the result of the healthcheck
|
||||
pub async fn start_metrics_server(addr: impl Into<SocketAddr> + 'static) {
|
||||
let metrics_route = warp::path("metrics") // The Prometheus metrics subpage is standardized to always be /metrics
|
||||
.and(warp::path::end())
|
||||
.and_then(metrics_handler);
|
||||
let healthcheck_route = warp::path("healthcheck")
|
||||
.and(warp::path::end())
|
||||
.and_then(healthcheck_handler);
|
||||
|
||||
warp::serve(metrics_route.or(healthcheck_route))
|
||||
.bind(addr)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// WARNING: Copied verbatim from v1.10.31, be careful when bumping
|
||||
/// solana crate versions!
|
||||
///
|
||||
/// TODO(2023-03-02): Use an upstream method when
|
||||
/// it's available.
|
||||
///
|
||||
/// This method is almost identical to
|
||||
/// RpcClient::send_and_confirm_transaction(). The only difference is
|
||||
/// that we let the user specify the config and replace
|
||||
/// send_transaction() inside with
|
||||
/// send_transaction_with_config(). This variant is currently missing
|
||||
/// from solana_client.
|
||||
pub async fn send_and_confirm_transaction_with_config(
|
||||
client: &RpcClient,
|
||||
transaction: &Transaction,
|
||||
config: RpcSendTransactionConfig,
|
||||
) -> SolClientResult<Signature> {
|
||||
const SEND_RETRIES: usize = 1;
|
||||
const GET_STATUS_RETRIES: usize = usize::MAX;
|
||||
|
||||
'sending: for _ in 0..SEND_RETRIES {
|
||||
let signature = client
|
||||
.send_transaction_with_config(transaction, config)
|
||||
.await?;
|
||||
|
||||
let recent_blockhash = if uses_durable_nonce(transaction).is_some() {
|
||||
let (recent_blockhash, ..) = client
|
||||
.get_latest_blockhash_with_commitment(CommitmentConfig::processed())
|
||||
.await?;
|
||||
recent_blockhash
|
||||
} else {
|
||||
transaction.message.recent_blockhash
|
||||
};
|
||||
|
||||
for status_retry in 0..GET_STATUS_RETRIES {
|
||||
match client.get_signature_status(&signature).await? {
|
||||
Some(Ok(_)) => return Ok(signature),
|
||||
Some(Err(e)) => return Err(e.into()),
|
||||
None => {
|
||||
if !client
|
||||
.is_blockhash_valid(&recent_blockhash, CommitmentConfig::processed())
|
||||
.await?
|
||||
{
|
||||
// Block hash is not found by some reason
|
||||
break 'sending;
|
||||
} else if cfg!(not(test))
|
||||
// Ignore sleep at last step.
|
||||
&& status_retry < GET_STATUS_RETRIES
|
||||
{
|
||||
// Retry twice a second
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(RpcError::ForUser(
|
||||
"unable to confirm transaction. \
|
||||
This can happen in situations such as transaction expiration \
|
||||
and insufficient fee-payer funds"
|
||||
.to_string(),
|
||||
)
|
||||
.into())
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
pub mod passthrough;
|
||||
pub mod pyth;
|
|
@ -1,24 +0,0 @@
|
|||
//! Trivial program for mocking other programs easily
|
||||
use {
|
||||
solana_program::{
|
||||
account_info::AccountInfo,
|
||||
msg,
|
||||
program_error::ProgramError,
|
||||
},
|
||||
solana_program_test::*,
|
||||
solana_sdk::pubkey::Pubkey,
|
||||
};
|
||||
|
||||
pub fn passthrough_entrypoint(
|
||||
program_id: &Pubkey,
|
||||
account_infos: &[AccountInfo],
|
||||
_data: &[u8],
|
||||
) -> Result<(), ProgramError> {
|
||||
msg!(&format!("Program {program_id}"));
|
||||
msg!(&format!("account_infos {account_infos:?}"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_passthrough(pt: &mut ProgramTest, name: &str, prog_id: Pubkey) {
|
||||
pt.add_program(name, prog_id, processor!(passthrough_entrypoint))
|
||||
}
|
|
@ -1,82 +0,0 @@
|
|||
//! This module contains test fixtures for instantiating plausible
|
||||
//! Pyth accounts for testing purposes.
|
||||
use {
|
||||
pyth_sdk_solana::state::{
|
||||
AccountType,
|
||||
PriceAccount,
|
||||
ProductAccount,
|
||||
MAGIC,
|
||||
PROD_ATTR_SIZE,
|
||||
VERSION,
|
||||
},
|
||||
solana_program_test::*,
|
||||
solana_sdk::{
|
||||
account::Account,
|
||||
pubkey::Pubkey,
|
||||
rent::Rent,
|
||||
},
|
||||
};
|
||||
|
||||
/// Create a pair of brand new product/price accounts that point at each other
|
||||
pub fn add_test_symbol(pt: &mut ProgramTest, owner: &Pubkey) -> (Pubkey, Pubkey) {
|
||||
// Generate pubkeys
|
||||
let prod_id = Pubkey::new_unique();
|
||||
let price_id = Pubkey::new_unique();
|
||||
|
||||
// Instantiate
|
||||
let prod = {
|
||||
ProductAccount {
|
||||
magic: MAGIC,
|
||||
ver: VERSION,
|
||||
atype: AccountType::Product as u32,
|
||||
size: 0,
|
||||
px_acc: price_id,
|
||||
attr: [0u8; PROD_ATTR_SIZE],
|
||||
}
|
||||
};
|
||||
|
||||
let price = PriceAccount {
|
||||
magic: MAGIC,
|
||||
ver: VERSION,
|
||||
atype: AccountType::Price as u32,
|
||||
prod: prod_id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Cast to raw bytes
|
||||
let prod_buf = &[prod];
|
||||
let prod_bytes = unsafe {
|
||||
let (_prefix, bytes, _suffix) = prod_buf.align_to::<u8>();
|
||||
bytes
|
||||
};
|
||||
let price_buf = &[price];
|
||||
let price_bytes = unsafe {
|
||||
let (_prefix, bytes, _suffix) = price_buf.align_to::<u8>();
|
||||
bytes
|
||||
};
|
||||
|
||||
// Compute exemption rent
|
||||
let prod_lamports = Rent::default().minimum_balance(prod_bytes.len());
|
||||
let price_lamports = Rent::default().minimum_balance(price_bytes.len());
|
||||
|
||||
// Populate the accounts
|
||||
let prod_acc = Account {
|
||||
lamports: prod_lamports,
|
||||
data: (*prod_bytes).to_vec(),
|
||||
owner: *owner,
|
||||
rent_epoch: 0,
|
||||
executable: false,
|
||||
};
|
||||
let price_acc = Account {
|
||||
lamports: price_lamports,
|
||||
data: (*price_bytes).to_vec(),
|
||||
owner: *owner,
|
||||
rent_epoch: 0,
|
||||
executable: false,
|
||||
};
|
||||
|
||||
pt.add_account(prod_id, prod_acc);
|
||||
pt.add_account(price_id, price_acc);
|
||||
|
||||
(prod_id, price_id)
|
||||
}
|
|
@ -1,128 +0,0 @@
|
|||
pub mod fixtures;
|
||||
|
||||
use {
|
||||
bridge::accounts::{
|
||||
Bridge,
|
||||
BridgeConfig,
|
||||
BridgeData,
|
||||
},
|
||||
fixtures::{
|
||||
passthrough,
|
||||
pyth,
|
||||
},
|
||||
pyth_wormhole_attester::config::{
|
||||
P2WConfigAccount,
|
||||
Pyth2WormholeConfig,
|
||||
},
|
||||
pyth_wormhole_attester_client as p2wc,
|
||||
solana_program_test::*,
|
||||
solana_sdk::{
|
||||
account::Account,
|
||||
pubkey::Pubkey,
|
||||
rent::Rent,
|
||||
},
|
||||
solitaire::{
|
||||
processors::seeded::Seeded,
|
||||
AccountState,
|
||||
BorshSerialize,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_happy_path() -> Result<(), p2wc::ErrBoxSend> {
|
||||
// Programs
|
||||
let p2w_program_id = Pubkey::new_unique();
|
||||
let wh_fixture_program_id = Pubkey::new_unique();
|
||||
|
||||
// Authorities
|
||||
let p2w_owner = Pubkey::new_unique();
|
||||
let pyth_owner = Pubkey::new_unique();
|
||||
let ops_owner = Pubkey::new_unique();
|
||||
|
||||
// On-chain state
|
||||
let p2w_config = Pyth2WormholeConfig {
|
||||
owner: p2w_owner,
|
||||
wh_prog: wh_fixture_program_id,
|
||||
max_batch_size: pyth_wormhole_attester::attest::P2W_MAX_BATCH_SIZE,
|
||||
pyth_owner,
|
||||
is_active: true,
|
||||
ops_owner: Some(ops_owner),
|
||||
};
|
||||
|
||||
let bridge_config = BridgeData {
|
||||
config: BridgeConfig {
|
||||
fee: 0xdeadbeef,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Populate test environment
|
||||
let mut p2w_test = ProgramTest::new(
|
||||
"pyth_wormhole_attester",
|
||||
p2w_program_id,
|
||||
processor!(pyth_wormhole_attester::instruction::solitaire),
|
||||
);
|
||||
|
||||
// Plant a filled config account
|
||||
let p2w_config_bytes = p2w_config.try_to_vec()?;
|
||||
let p2w_config_account = Account {
|
||||
lamports: Rent::default().minimum_balance(p2w_config_bytes.len()),
|
||||
data: p2w_config_bytes,
|
||||
owner: p2w_program_id,
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
};
|
||||
let p2w_config_addr =
|
||||
P2WConfigAccount::<{ AccountState::Initialized }>::key(None, &p2w_program_id);
|
||||
|
||||
p2w_test.add_account(p2w_config_addr, p2w_config_account);
|
||||
|
||||
// Plant a bridge config
|
||||
let bridge_config_bytes = bridge_config.try_to_vec()?;
|
||||
let wh_bridge_config_account = Account {
|
||||
lamports: Rent::default().minimum_balance(bridge_config_bytes.len()),
|
||||
data: bridge_config_bytes,
|
||||
owner: wh_fixture_program_id,
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
};
|
||||
|
||||
let wh_bridge_config_addr =
|
||||
Bridge::<{ AccountState::Initialized }>::key(None, &wh_fixture_program_id);
|
||||
|
||||
p2w_test.add_account(wh_bridge_config_addr, wh_bridge_config_account);
|
||||
|
||||
passthrough::add_passthrough(&mut p2w_test, "wormhole", wh_fixture_program_id);
|
||||
let (prod_id, price_id) = pyth::add_test_symbol(&mut p2w_test, &pyth_owner);
|
||||
|
||||
let ctx = p2w_test.start_with_context().await;
|
||||
|
||||
let symbols = vec![p2wc::P2WSymbol {
|
||||
name: Some("Mock symbol".to_owned()),
|
||||
product_addr: prod_id,
|
||||
price_addr: price_id,
|
||||
}];
|
||||
|
||||
let _attest_tx = p2wc::gen_attest_tx(
|
||||
p2w_program_id,
|
||||
&p2w_config,
|
||||
&ctx.payer,
|
||||
0,
|
||||
symbols.as_slice(),
|
||||
ctx.last_blockhash,
|
||||
0,
|
||||
)?;
|
||||
|
||||
// NOTE: 2022-09-05
|
||||
// Execution of this transaction is commented out as for some unknown reasons
|
||||
// Solana test suite has some unknown behavior in this transaction. It is probably a
|
||||
// memory leak that causes either segfault or an invalid error (after a reading an unkown
|
||||
// variable from memory). It is probably solved in the following PR:
|
||||
// https://github.com/solana-labs/solana/pull/26507
|
||||
//
|
||||
// TODO: add this check when the above PR is released in our Solana package.
|
||||
// ctx.banks_client.process_transaction(attest_tx).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,177 +0,0 @@
|
|||
//! Checks for migrating the previous config schema into the current one
|
||||
|
||||
pub mod fixtures;
|
||||
|
||||
use {
|
||||
fixtures::passthrough,
|
||||
log::info,
|
||||
pyth_wormhole_attester::config::{
|
||||
OldP2WConfigAccount,
|
||||
OldPyth2WormholeConfig,
|
||||
P2WConfigAccount,
|
||||
Pyth2WormholeConfig,
|
||||
},
|
||||
pyth_wormhole_attester_client as p2wc,
|
||||
serial_test::serial,
|
||||
solana_program::system_program,
|
||||
solana_program_test::*,
|
||||
solana_sdk::{
|
||||
account::Account,
|
||||
pubkey::Pubkey,
|
||||
rent::Rent,
|
||||
signature::Signer,
|
||||
signer::keypair::Keypair,
|
||||
},
|
||||
solitaire::{
|
||||
processors::seeded::Seeded,
|
||||
AccountState,
|
||||
BorshSerialize,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_migrate_works() -> Result<(), solitaire::ErrBox> {
|
||||
info!("Starting");
|
||||
// Programs
|
||||
let p2w_program_id = Pubkey::new_unique();
|
||||
let wh_fixture_program_id = Pubkey::new_unique();
|
||||
|
||||
// Authorities
|
||||
let p2w_owner = Keypair::new();
|
||||
let pyth_owner = Pubkey::new_unique();
|
||||
|
||||
// On-chain state
|
||||
let old_p2w_config = OldPyth2WormholeConfig {
|
||||
owner: p2w_owner.pubkey(),
|
||||
wh_prog: wh_fixture_program_id,
|
||||
max_batch_size: pyth_wormhole_attester::attest::P2W_MAX_BATCH_SIZE,
|
||||
pyth_owner,
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
info!("Before ProgramTest::new()");
|
||||
|
||||
// Populate test environment
|
||||
let mut p2w_test = ProgramTest::new(
|
||||
"pyth_wormhole_attester",
|
||||
p2w_program_id,
|
||||
processor!(pyth_wormhole_attester::instruction::solitaire),
|
||||
);
|
||||
|
||||
// Plant filled config accounts
|
||||
let old_p2w_config_bytes = old_p2w_config.try_to_vec()?;
|
||||
let old_p2w_config_account = Account {
|
||||
lamports: Rent::default().minimum_balance(old_p2w_config_bytes.len()),
|
||||
data: old_p2w_config_bytes,
|
||||
owner: p2w_program_id,
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
};
|
||||
let old_p2w_config_addr = OldP2WConfigAccount::key(None, &p2w_program_id);
|
||||
|
||||
info!("Before add_account() calls");
|
||||
|
||||
p2w_test.add_account(old_p2w_config_addr, old_p2w_config_account);
|
||||
|
||||
// Add system program because the contract creates an account for new configuration account
|
||||
passthrough::add_passthrough(&mut p2w_test, "system", system_program::id());
|
||||
|
||||
info!("System program under {}", system_program::id());
|
||||
|
||||
info!("Before start_with_context");
|
||||
let mut ctx = p2w_test.start_with_context().await;
|
||||
|
||||
let migrate_tx =
|
||||
p2wc::gen_migrate_tx(ctx.payer, p2w_program_id, p2w_owner, ctx.last_blockhash)?;
|
||||
info!("Before process_transaction");
|
||||
|
||||
// Migration should fail because the new config account is already initialized
|
||||
ctx.banks_client.process_transaction(migrate_tx).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_migrate_already_migrated() -> Result<(), solitaire::ErrBox> {
|
||||
info!("Starting");
|
||||
// Programs
|
||||
let p2w_program_id = Pubkey::new_unique();
|
||||
let wh_fixture_program_id = Pubkey::new_unique();
|
||||
|
||||
// Authorities
|
||||
let p2w_owner = Keypair::new();
|
||||
let pyth_owner = Pubkey::new_unique();
|
||||
let ops_owner = Keypair::new();
|
||||
|
||||
// On-chain state
|
||||
let old_p2w_config = OldPyth2WormholeConfig {
|
||||
owner: p2w_owner.pubkey(),
|
||||
wh_prog: wh_fixture_program_id,
|
||||
max_batch_size: pyth_wormhole_attester::attest::P2W_MAX_BATCH_SIZE,
|
||||
pyth_owner,
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
let new_p2w_config = Pyth2WormholeConfig {
|
||||
owner: p2w_owner.pubkey(),
|
||||
wh_prog: wh_fixture_program_id,
|
||||
max_batch_size: pyth_wormhole_attester::attest::P2W_MAX_BATCH_SIZE,
|
||||
pyth_owner,
|
||||
is_active: true,
|
||||
ops_owner: Some(ops_owner.pubkey()),
|
||||
};
|
||||
|
||||
info!("Before ProgramTest::new()");
|
||||
|
||||
// Populate test environment
|
||||
let mut p2w_test = ProgramTest::new(
|
||||
"pyth_wormhole_attester",
|
||||
p2w_program_id,
|
||||
processor!(pyth_wormhole_attester::instruction::solitaire),
|
||||
);
|
||||
|
||||
// Plant filled config accounts
|
||||
let old_p2w_config_bytes = old_p2w_config.try_to_vec()?;
|
||||
let old_p2w_config_account = Account {
|
||||
lamports: Rent::default().minimum_balance(old_p2w_config_bytes.len()),
|
||||
data: old_p2w_config_bytes,
|
||||
owner: p2w_program_id,
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
};
|
||||
let old_p2w_config_addr = OldP2WConfigAccount::key(None, &p2w_program_id);
|
||||
|
||||
let new_p2w_config_bytes = new_p2w_config.try_to_vec()?;
|
||||
let new_p2w_config_account = Account {
|
||||
lamports: Rent::default().minimum_balance(new_p2w_config_bytes.len()),
|
||||
data: new_p2w_config_bytes,
|
||||
owner: p2w_program_id,
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
};
|
||||
let new_p2w_config_addr =
|
||||
P2WConfigAccount::<{ AccountState::Initialized }>::key(None, &p2w_program_id);
|
||||
|
||||
info!("Before add_account() calls");
|
||||
|
||||
p2w_test.add_account(old_p2w_config_addr, old_p2w_config_account);
|
||||
p2w_test.add_account(new_p2w_config_addr, new_p2w_config_account);
|
||||
|
||||
info!("Before start_with_context");
|
||||
let mut ctx = p2w_test.start_with_context().await;
|
||||
|
||||
let migrate_tx =
|
||||
p2wc::gen_migrate_tx(ctx.payer, p2w_program_id, p2w_owner, ctx.last_blockhash)?;
|
||||
info!("Before process_transaction");
|
||||
|
||||
// Migration should fail because the new config account is already initialized
|
||||
assert!(ctx
|
||||
.banks_client
|
||||
.process_transaction(migrate_tx)
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,196 +0,0 @@
|
|||
pub mod fixtures;
|
||||
|
||||
use {
|
||||
pyth_wormhole_attester::config::{
|
||||
P2WConfigAccount,
|
||||
Pyth2WormholeConfig,
|
||||
},
|
||||
pyth_wormhole_attester_client as p2wc,
|
||||
solana_program_test::*,
|
||||
solana_sdk::{
|
||||
account::Account,
|
||||
pubkey::Pubkey,
|
||||
rent::Rent,
|
||||
signature::Signer,
|
||||
signer::keypair::Keypair,
|
||||
},
|
||||
solitaire::{
|
||||
processors::seeded::Seeded,
|
||||
AccountState,
|
||||
BorshSerialize,
|
||||
},
|
||||
};
|
||||
|
||||
fn clone_keypair(keypair: &Keypair) -> Keypair {
|
||||
// Unwrap as we are surely copying a keypair and we are in test env.
|
||||
Keypair::from_bytes(keypair.to_bytes().as_ref()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_setting_is_active_works() -> Result<(), p2wc::ErrBoxSend> {
|
||||
// Programs
|
||||
let p2w_program_id = Pubkey::new_unique();
|
||||
let wh_fixture_program_id = Pubkey::new_unique();
|
||||
|
||||
// Authorities
|
||||
let p2w_owner = Pubkey::new_unique();
|
||||
let pyth_owner = Pubkey::new_unique();
|
||||
let ops_owner = Keypair::new();
|
||||
|
||||
// On-chain state
|
||||
let p2w_config = Pyth2WormholeConfig {
|
||||
owner: p2w_owner,
|
||||
wh_prog: wh_fixture_program_id,
|
||||
max_batch_size: pyth_wormhole_attester::attest::P2W_MAX_BATCH_SIZE,
|
||||
pyth_owner,
|
||||
is_active: true,
|
||||
ops_owner: Some(ops_owner.pubkey()),
|
||||
};
|
||||
|
||||
// Populate test environment
|
||||
let mut p2w_test = ProgramTest::new(
|
||||
"pyth_wormhole_attester",
|
||||
p2w_program_id,
|
||||
processor!(pyth_wormhole_attester::instruction::solitaire),
|
||||
);
|
||||
|
||||
// Plant a filled config account
|
||||
let p2w_config_bytes = p2w_config.try_to_vec()?;
|
||||
let p2w_config_account = Account {
|
||||
lamports: Rent::default().minimum_balance(p2w_config_bytes.len()),
|
||||
data: p2w_config_bytes,
|
||||
owner: p2w_program_id,
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
};
|
||||
let p2w_config_addr =
|
||||
P2WConfigAccount::<{ AccountState::Initialized }>::key(None, &p2w_program_id);
|
||||
|
||||
p2w_test.add_account(p2w_config_addr, p2w_config_account);
|
||||
|
||||
let mut ctx = p2w_test.start_with_context().await;
|
||||
|
||||
// Setting to false should work
|
||||
let set_is_active_false_tx = p2wc::gen_set_is_active_tx(
|
||||
clone_keypair(&ctx.payer),
|
||||
p2w_program_id,
|
||||
clone_keypair(&ops_owner),
|
||||
false,
|
||||
ctx.last_blockhash,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
ctx.banks_client
|
||||
.process_transaction(set_is_active_false_tx)
|
||||
.await?;
|
||||
|
||||
let config = ctx
|
||||
.banks_client
|
||||
.get_account_data_with_borsh::<Pyth2WormholeConfig>(p2w_config_addr)
|
||||
.await?;
|
||||
|
||||
assert!(!config.is_active);
|
||||
|
||||
// Setting to true should work
|
||||
let set_is_active_true_tx = p2wc::gen_set_is_active_tx(
|
||||
clone_keypair(&ctx.payer),
|
||||
p2w_program_id,
|
||||
clone_keypair(&ops_owner),
|
||||
true,
|
||||
ctx.last_blockhash,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
ctx.banks_client
|
||||
.process_transaction(set_is_active_true_tx)
|
||||
.await?;
|
||||
|
||||
let config = ctx
|
||||
.banks_client
|
||||
.get_account_data_with_borsh::<Pyth2WormholeConfig>(p2w_config_addr)
|
||||
.await?;
|
||||
|
||||
assert!(config.is_active);
|
||||
|
||||
// A wrong signer cannot handle it
|
||||
|
||||
let set_is_active_true_tx = p2wc::gen_set_is_active_tx(
|
||||
clone_keypair(&ctx.payer),
|
||||
p2w_program_id,
|
||||
clone_keypair(&ctx.payer),
|
||||
true,
|
||||
ctx.last_blockhash,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
assert!(ctx
|
||||
.banks_client
|
||||
.process_transaction(set_is_active_true_tx)
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_setting_is_active_does_not_work_without_ops_owner() -> Result<(), p2wc::ErrBoxSend> {
|
||||
// Programs
|
||||
let p2w_program_id = Pubkey::new_unique();
|
||||
let wh_fixture_program_id = Pubkey::new_unique();
|
||||
|
||||
// Authorities
|
||||
let p2w_owner = Pubkey::new_unique();
|
||||
let pyth_owner = Keypair::new();
|
||||
|
||||
// On-chain state
|
||||
let p2w_config = Pyth2WormholeConfig {
|
||||
owner: p2w_owner,
|
||||
wh_prog: wh_fixture_program_id,
|
||||
max_batch_size: pyth_wormhole_attester::attest::P2W_MAX_BATCH_SIZE,
|
||||
pyth_owner: pyth_owner.pubkey(),
|
||||
is_active: true,
|
||||
ops_owner: None,
|
||||
};
|
||||
|
||||
// Populate test environment
|
||||
let mut p2w_test = ProgramTest::new(
|
||||
"pyth_wormhole_attester",
|
||||
p2w_program_id,
|
||||
processor!(pyth_wormhole_attester::instruction::solitaire),
|
||||
);
|
||||
|
||||
// Plant a filled config account
|
||||
let p2w_config_bytes = p2w_config.try_to_vec()?;
|
||||
let p2w_config_account = Account {
|
||||
lamports: Rent::default().minimum_balance(p2w_config_bytes.len()),
|
||||
data: p2w_config_bytes,
|
||||
owner: p2w_program_id,
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
};
|
||||
let p2w_config_addr =
|
||||
P2WConfigAccount::<{ AccountState::Initialized }>::key(None, &p2w_program_id);
|
||||
|
||||
p2w_test.add_account(p2w_config_addr, p2w_config_account);
|
||||
|
||||
let mut ctx = p2w_test.start_with_context().await;
|
||||
|
||||
// No one could should be able to handle
|
||||
// For example pyth_owner is used here.
|
||||
let set_is_active_true_tx = p2wc::gen_set_is_active_tx(
|
||||
clone_keypair(&ctx.payer),
|
||||
p2w_program_id,
|
||||
pyth_owner,
|
||||
true,
|
||||
ctx.last_blockhash,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
assert!(ctx
|
||||
.banks_client
|
||||
.process_transaction(set_is_active_true_tx)
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
[package]
|
||||
name = "pyth-wormhole-attester-governance"
|
||||
version = "0.1.0"
|
||||
description = "CLI to generate governance payloads for the attester"
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.65"
|
||||
clap = {version = "3.1.18", features = ["derive"]}
|
||||
solana-sdk = "=1.10.31"
|
||||
pyth-wormhole-attester-client = {path = "../client/"}
|
||||
hex = "0.4.3"
|
||||
remote-executor = {path = "../../governance/remote_executor/programs/remote-executor/"}
|
||||
borsh = "0.9.3"
|
|
@ -1,54 +0,0 @@
|
|||
//! CLI options
|
||||
use {
|
||||
clap::{
|
||||
Parser,
|
||||
Subcommand,
|
||||
},
|
||||
solana_sdk::pubkey::Pubkey,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(
|
||||
about = "A cli for the remote executor",
|
||||
author = "Pyth Network Contributors"
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[clap(subcommand)]
|
||||
pub action: Action,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Action {
|
||||
#[clap(about = "Get set upgrade authority payload for squads-cli")]
|
||||
GetSetConfig {
|
||||
#[clap(long, help = "Program id")]
|
||||
program_id: Pubkey,
|
||||
#[clap(long, help = "Current owner")]
|
||||
owner: Pubkey,
|
||||
#[clap(long, help = "Payer")]
|
||||
payer: Pubkey,
|
||||
#[clap(long, help = "Config : New owner")]
|
||||
new_owner: Pubkey,
|
||||
#[clap(long, help = "Config : Wormhole program id")]
|
||||
wormhole: Pubkey,
|
||||
#[clap(long, help = "Config : Pyth program id")]
|
||||
pyth_owner: Pubkey,
|
||||
#[clap(long, help = "Config : Max batch size")]
|
||||
max_batch_size: u16,
|
||||
#[clap(long, help = "Config : Is active")]
|
||||
is_active: bool,
|
||||
#[clap(long, help = "Config : Ops owner")]
|
||||
ops_owner: Option<Pubkey>,
|
||||
},
|
||||
#[clap(about = "Get upgrade program payload for squads-cli")]
|
||||
GetSetIsActive {
|
||||
#[clap(long, help = "Program id")]
|
||||
program_id: Pubkey,
|
||||
#[clap(long, help = "Current ops owner")]
|
||||
ops_owner: Pubkey,
|
||||
#[clap(long, help = "Payer")]
|
||||
payer: Pubkey,
|
||||
#[clap(long, help = "Config : Is active")]
|
||||
is_active: bool,
|
||||
},
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
use {
|
||||
anyhow::Result,
|
||||
borsh::BorshSerialize,
|
||||
clap::Parser,
|
||||
cli::{
|
||||
Action,
|
||||
Cli,
|
||||
},
|
||||
pyth_wormhole_attester_client::{
|
||||
get_set_config_ix,
|
||||
get_set_is_active_ix,
|
||||
Pyth2WormholeConfig,
|
||||
},
|
||||
remote_executor::state::governance_payload::{
|
||||
ExecutorPayload,
|
||||
GovernanceHeader,
|
||||
InstructionData,
|
||||
},
|
||||
};
|
||||
|
||||
mod cli;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.action {
|
||||
Action::GetSetConfig {
|
||||
program_id,
|
||||
owner,
|
||||
payer,
|
||||
new_owner,
|
||||
wormhole,
|
||||
pyth_owner,
|
||||
max_batch_size,
|
||||
is_active,
|
||||
ops_owner,
|
||||
} => {
|
||||
let new_config = Pyth2WormholeConfig {
|
||||
owner: new_owner,
|
||||
wh_prog: wormhole,
|
||||
pyth_owner,
|
||||
max_batch_size,
|
||||
is_active,
|
||||
ops_owner,
|
||||
};
|
||||
let ix = get_set_config_ix(&program_id, &owner, &payer, new_config).unwrap();
|
||||
let payload = ExecutorPayload {
|
||||
header: GovernanceHeader::executor_governance_header(),
|
||||
instructions: vec![InstructionData::from(&ix)],
|
||||
}
|
||||
.try_to_vec()?;
|
||||
println!("Set config payload : {:?}", hex::encode(payload));
|
||||
Ok(())
|
||||
}
|
||||
Action::GetSetIsActive {
|
||||
program_id,
|
||||
ops_owner,
|
||||
payer,
|
||||
is_active,
|
||||
} => {
|
||||
let ix = get_set_is_active_ix(&program_id, &ops_owner, &payer, is_active).unwrap();
|
||||
let payload = ExecutorPayload {
|
||||
header: GovernanceHeader::executor_governance_header(),
|
||||
instructions: vec![InstructionData::from(&ix)],
|
||||
}
|
||||
.try_to_vec()?;
|
||||
println!("Set is active payload : {:?}", hex::encode(payload));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,859 +0,0 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "739f4a8db6605981345c5654f3a85b056ce52f37a39d34da03f25bf2151ea16e"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b64485778c4f16a6a5a9d335e80d449ac6c70cdd6a06d2af18a6f6f775a125b3"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if 0.1.10",
|
||||
"constant_time_eq",
|
||||
"crypto-mac",
|
||||
"digest 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array 0.14.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae"
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09a7111f797cc721407885a323fb071636aee57f750b1a4ddc27397eba168a74"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "307f3740906bac2c118a8122fe22681232b244f1369273e45f1156b45c43d2dd"
|
||||
dependencies = [
|
||||
"borsh-derive-internal",
|
||||
"borsh-schema-derive-internal",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive-internal"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2104c73179359431cc98e016998f2f23bc7a05bc53e79741bcba705f30047bc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-schema-derive-internal"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae29eb8418fcd46f723f8691a2ac06857d31179d33d2f2d91eb13967de97c728"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"byteorder",
|
||||
"primitive-types",
|
||||
"sha3",
|
||||
"solana-program",
|
||||
"solitaire",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "476e9cd489f9e121e02ffa6014a8ef220ecb15c05ed23fc34cca13925dc283fb"
|
||||
|
||||
[[package]]
|
||||
name = "bv"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8834bb1d8ee5dc048ee3124f2c7c1afcc6bc9aed03f11e9dfd8c69470a5db340"
|
||||
dependencies = [
|
||||
"feature-probe",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.0.68"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a72c244c1ff497a746a7e1fb3d14bd08420ecda70c8f25c7112f2781652d787"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-mac"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab"
|
||||
dependencies = [
|
||||
"generic-array 0.14.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434e1720189a637d44fe464f4df1e6eb900b4835255b14354497c78af37d9bb8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"digest 0.8.1",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5"
|
||||
dependencies = [
|
||||
"generic-array 0.12.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
|
||||
dependencies = [
|
||||
"generic-array 0.14.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3"
|
||||
dependencies = [
|
||||
"atty",
|
||||
"humantime",
|
||||
"log",
|
||||
"regex",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "feature-probe"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da"
|
||||
|
||||
[[package]]
|
||||
name = "fixed-hash"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcf0ed7fe52a17a03854ec54a9f76d6d84508d1c0e66bc1793301c73fc8493c"
|
||||
dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c21572b4949434e4fc1e1978b99c5f77064153c59d998bf13ecd96fb5ecba7"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.97"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12b8adadd720df158f4d70dfe7ccc6adb0472d7c55ca83445f6a5ab3e36f8fb6"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857"
|
||||
|
||||
[[package]]
|
||||
name = "primitive-types"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2415937401cb030a2a0a4d922483f945fa068f52a7dbb22ce0fe5f2b6f6adace"
|
||||
dependencies = [
|
||||
"fixed-hash",
|
||||
"uint",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785"
|
||||
dependencies = [
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038"
|
||||
dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyth-client"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "731e2d5b2b790fc676518b29e41dddf7f69f23c61f27ab25cc9ae5b75ee190ad"
|
||||
|
||||
[[package]]
|
||||
name = "pyth2wormhole"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"bridge",
|
||||
"pyth-client",
|
||||
"rocksalt",
|
||||
"solana-program",
|
||||
"solitaire",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_hc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.6.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
|
||||
|
||||
[[package]]
|
||||
name = "rocksalt"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sha3",
|
||||
"solana-program",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61b3909d758bb75c79f23d4736fac9433868679d3ad2ea7a61e3c25cfda9a088"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403"
|
||||
dependencies = [
|
||||
"semver-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver-parser"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16ae07dd2f88a366f15bd0632ba725227018c69a1c8550a927324f8eb8368bb9"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b362ae5752fd2137731f9fa25fd4d9058af34666ca1966fb969119cc35719f12"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"cfg-if 1.0.0",
|
||||
"cpufeatures",
|
||||
"digest 0.9.0",
|
||||
"opaque-debug",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f81199417d4e5de3f04b1e871023acea7389672c4135918f05aa9cbf2f2fa809"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"digest 0.9.0",
|
||||
"keccak",
|
||||
"opaque-debug",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solana-frozen-abi"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b81e60d88b1fe0322bba6f3fe6b0d7299df2f2ededa8d95ec77b934fabb967b"
|
||||
dependencies = [
|
||||
"bs58",
|
||||
"bv",
|
||||
"generic-array 0.14.4",
|
||||
"log",
|
||||
"memmap2",
|
||||
"rustc_version",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"sha2",
|
||||
"solana-frozen-abi-macro",
|
||||
"solana-logger",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solana-frozen-abi-macro"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f617daa0187bcc4665d63fcf9454c998e9cdad6a33181f6214558d738230bfe2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solana-logger"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b3e2b14bdcbb7b41de9ef5a541ac501ba3fbd07999cbcf7ea9006b3ae28b67b"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"lazy_static",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solana-program"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c5d59f9d358c09db6461fae1fde6075a456685d856c004ef21af092a830e4e7"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"blake3",
|
||||
"borsh",
|
||||
"borsh-derive",
|
||||
"bs58",
|
||||
"bv",
|
||||
"curve25519-dalek",
|
||||
"hex",
|
||||
"itertools",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rustc_version",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_derive",
|
||||
"sha2",
|
||||
"sha3",
|
||||
"solana-frozen-abi",
|
||||
"solana-frozen-abi-macro",
|
||||
"solana-logger",
|
||||
"solana-sdk-macro",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solana-sdk-macro"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27426b2a09676929c5e49df96967bbcffff003183c11a3c3ef11d78bac4aaaa"
|
||||
dependencies = [
|
||||
"bs58",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solitaire"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"byteorder",
|
||||
"rocksalt",
|
||||
"sha3",
|
||||
"solana-program",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.73"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f71489ff30030d2ae598524f61326b902466f72a0fb1a8564c001cc63425bcc7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa6f76457f59514c7eeb4e59d891395fab0b2fd1d40723ae737d64153392e9c6"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a36768c0fbf1bb15eca10defa29526bda730a2376c2ab4393ccfa16fb1a318d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06"
|
||||
|
||||
[[package]]
|
||||
name = "uint"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11fe9a9348741cf134085ad57c249508345fe16411b3d7fb4ff2da2f1d6382e"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crunchy",
|
||||
"hex",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.9.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4756f7db3f7b5574938c3eb1c117038b8e07f95ee6718c0efad4ac21508f1efd"
|
|
@ -1,28 +0,0 @@
|
|||
[package]
|
||||
name = "pyth-wormhole-attester"
|
||||
version = "2.0.1"
|
||||
description = "Pyth-over-Wormhole Solana contract"
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "lib"]
|
||||
name = "pyth_wormhole_attester"
|
||||
|
||||
[features]
|
||||
default = ["wormhole-bridge-solana/no-entrypoint"]
|
||||
client = ["solitaire/client", "no-entrypoint"]
|
||||
trace = ["solitaire/trace", "wormhole-bridge-solana/trace"]
|
||||
no-entrypoint = []
|
||||
|
||||
[dependencies]
|
||||
wormhole-bridge-solana = { git = "https://github.com/wormhole-foundation/wormhole", tag = "v2.14.8" }
|
||||
solitaire = { git = "https://github.com/wormhole-foundation/wormhole", tag = "v2.14.8"}
|
||||
rocksalt = { git = "https://github.com/wormhole-foundation/wormhole", tag = "v2.14.8"}
|
||||
solana-program = "=1.10.31"
|
||||
borsh = "=0.9.3"
|
||||
pyth-client = "0.2.2"
|
||||
pyth-wormhole-attester-sdk = { path = "../sdk/rust", features = ["solana"] }
|
||||
serde = { version = "1", optional = true}
|
||||
serde_derive = { version = "1", optional = true}
|
||||
serde_json = { version = "1", optional = true}
|
||||
pyth-sdk-solana = { version = "0.5.0" }
|
|
@ -1,407 +0,0 @@
|
|||
use {
|
||||
crate::{
|
||||
attestation_state::AttestationStatePDA,
|
||||
config::P2WConfigAccount,
|
||||
error::AttesterCustomError,
|
||||
message::{
|
||||
P2WMessage,
|
||||
P2WMessageDrvData,
|
||||
},
|
||||
},
|
||||
borsh::{
|
||||
BorshDeserialize,
|
||||
BorshSerialize,
|
||||
},
|
||||
bridge::{
|
||||
accounts::BridgeData,
|
||||
types::ConsistencyLevel,
|
||||
},
|
||||
pyth_sdk_solana::state::PriceStatus,
|
||||
pyth_wormhole_attester_sdk::{
|
||||
BatchPriceAttestation,
|
||||
Identifier,
|
||||
P2WEmitter,
|
||||
PriceAttestation,
|
||||
},
|
||||
solana_program::{
|
||||
clock::Clock,
|
||||
program::invoke_signed,
|
||||
program_error::ProgramError,
|
||||
rent::Rent,
|
||||
},
|
||||
solitaire::{
|
||||
trace,
|
||||
AccountState,
|
||||
ExecutionContext,
|
||||
FromAccounts,
|
||||
Info,
|
||||
Keyed,
|
||||
Mut,
|
||||
Peel,
|
||||
Result as SoliResult,
|
||||
Seeded,
|
||||
Signer,
|
||||
SolitaireError,
|
||||
Sysvar,
|
||||
},
|
||||
};
|
||||
|
||||
/// Important: must be manually maintained until native Solitaire
|
||||
/// variable len vector support.
|
||||
///
|
||||
/// The number must reflect how many pyth state/price pairs are
|
||||
/// expected in the Attest struct below. The constant itself is only
|
||||
/// used in the on-chain config in order for attesters to learn the
|
||||
/// correct value dynamically.
|
||||
pub const P2W_MAX_BATCH_SIZE: u16 = 5;
|
||||
|
||||
#[derive(FromAccounts)]
|
||||
pub struct Attest<'b> {
|
||||
// Payer also used for wormhole
|
||||
pub payer: Mut<Signer<Info<'b>>>,
|
||||
pub system_program: Info<'b>,
|
||||
pub config: P2WConfigAccount<'b, { AccountState::Initialized }>,
|
||||
|
||||
// Hardcoded state/price pairs, bypassing Solitaire's variable-length limitations
|
||||
// Any change to the number of accounts must include an appropriate change to P2W_MAX_BATCH_SIZE
|
||||
pub pyth_state: Mut<AttestationStatePDA<'b>>,
|
||||
pub pyth_price: Info<'b>,
|
||||
|
||||
pub pyth_state2: Option<Mut<AttestationStatePDA<'b>>>,
|
||||
pub pyth_price2: Option<Info<'b>>,
|
||||
|
||||
pub pyth_state3: Option<Mut<AttestationStatePDA<'b>>>,
|
||||
pub pyth_price3: Option<Info<'b>>,
|
||||
|
||||
pub pyth_state4: Option<Mut<AttestationStatePDA<'b>>>,
|
||||
pub pyth_price4: Option<Info<'b>>,
|
||||
|
||||
pub pyth_state5: Option<Mut<AttestationStatePDA<'b>>>,
|
||||
pub pyth_price5: Option<Info<'b>>,
|
||||
|
||||
// Did you read the comment near `pyth_state`?
|
||||
// pub pyth_state6: Option<Mut<AttestationStatePDA<'b>>>,
|
||||
// pub pyth_price6: Option<Info<'b>>,
|
||||
|
||||
// pub pyth_state7: Option<Mut<AttestationStatePDA<'b>>>,
|
||||
// pub pyth_price7: Option<Info<'b>>,
|
||||
|
||||
// pub pyth_state8: Option<Mut<AttestationStatePDA<'b>>>,
|
||||
// pub pyth_price8: Option<Info<'b>>,
|
||||
|
||||
// pub pyth_state9: Option<Mut<AttestationStatePDA<'b>>>,
|
||||
// pub pyth_price9: Option<Info<'b>>,
|
||||
|
||||
// pub pyth_state10: Option<Mut<AttestationStatePDA<'b>>>,
|
||||
// pub pyth_price10: Option<Info<'b>>,
|
||||
pub clock: Sysvar<'b, Clock>,
|
||||
|
||||
/// Wormhole program address - must match the config value
|
||||
pub wh_prog: Info<'b>,
|
||||
|
||||
// wormhole's post_message_unreliable accounts
|
||||
//
|
||||
// This contract makes no attempt to exhaustively validate
|
||||
// Wormhole's account inputs. Only the wormhole contract address
|
||||
// is validated (see above).
|
||||
/// Bridge config needed for fee calculation
|
||||
pub wh_bridge: Mut<Info<'b>>,
|
||||
|
||||
/// Account to store the posted message.
|
||||
/// This account is a PDA from the attestation contract
|
||||
/// which is owned by the wormhole core contract.
|
||||
pub wh_message: Mut<Info<'b>>,
|
||||
|
||||
/// Emitter of the VAA
|
||||
pub wh_emitter: P2WEmitter<'b>,
|
||||
|
||||
/// Tracker for the emitter sequence
|
||||
pub wh_sequence: Mut<Info<'b>>,
|
||||
|
||||
// We reuse our payer
|
||||
// pub wh_payer: Mut<Signer<Info<'b>>>,
|
||||
/// Account to collect tx fee
|
||||
pub wh_fee_collector: Mut<Info<'b>>,
|
||||
|
||||
pub wh_rent: Sysvar<'b, Rent>,
|
||||
}
|
||||
|
||||
#[derive(BorshDeserialize, BorshSerialize)]
|
||||
pub struct AttestData {
|
||||
pub consistency_level: ConsistencyLevel,
|
||||
pub message_account_id: u64,
|
||||
/// Fail the transaction if the global attestation rate of all
|
||||
/// symbols in this batch is more frequent than the passed
|
||||
/// interval. This is checked using the attestation time stored in
|
||||
/// attestation state. This enables all of the clients to only
|
||||
/// contribute attestations if their desired interval is not
|
||||
/// already reached. If at least one symbol has been waiting
|
||||
/// longer than this interval, we attest the whole batch. 0
|
||||
/// effectively disables this feature.
|
||||
pub rate_limit_interval_secs: u32,
|
||||
}
|
||||
|
||||
pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> SoliResult<()> {
|
||||
if !accs.config.is_active {
|
||||
// msg instead of trace makes sure we're not silent about this in prod
|
||||
solana_program::msg!("This attester program is disabled!");
|
||||
|
||||
return Err(SolitaireError::Custom(4242));
|
||||
}
|
||||
|
||||
accs.config.verify_derivation(ctx.program_id, None)?;
|
||||
|
||||
if accs.config.wh_prog != *accs.wh_prog.key {
|
||||
trace!(&format!(
|
||||
"Wormhole program account mismatch (expected {:?}, got {:?})",
|
||||
accs.config.wh_prog, accs.wh_prog.key
|
||||
));
|
||||
return Err(ProgramError::InvalidAccountData.into());
|
||||
}
|
||||
|
||||
|
||||
// Make the specified prices iterable
|
||||
let mut price_pair_opts = [
|
||||
(Some(&mut accs.pyth_state), Some(&accs.pyth_price)),
|
||||
(accs.pyth_state2.as_mut(), accs.pyth_price2.as_ref()),
|
||||
(accs.pyth_state3.as_mut(), accs.pyth_price3.as_ref()),
|
||||
(accs.pyth_state4.as_mut(), accs.pyth_price4.as_ref()),
|
||||
(accs.pyth_state5.as_mut(), accs.pyth_price5.as_ref()),
|
||||
// Did you read the comment near `pyth_state`?
|
||||
// (accs.pyth_state6.as_mut(), accs.pyth_price6.as_ref()),
|
||||
// (accs.pyth_state7.as_mut(), accs.pyth_price7.as_ref()),
|
||||
// (accs.pyth_state8.as_mut(), accs.pyth_price8.as_ref()),
|
||||
// (accs.pyth_state9.as_mut(), accs.pyth_price9.as_ref()),
|
||||
// (accs.pyth_state10.as_mut(), accs.pyth_price10.as_ref()),
|
||||
];
|
||||
|
||||
let price_pairs: Vec<(_, _)> = price_pair_opts
|
||||
.iter_mut()
|
||||
.filter_map(|pair| match pair {
|
||||
// Only use this pair if both accounts are Some
|
||||
(Some(state), Some(price)) => Some((state, price)),
|
||||
_other => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
trace!("{} Pyth symbols received", price_pairs.len());
|
||||
|
||||
// Collect the validated symbols here for batch serialization
|
||||
let mut attestations = Vec::with_capacity(price_pairs.len());
|
||||
|
||||
let this_attestation_time = accs.clock.unix_timestamp;
|
||||
|
||||
|
||||
let mut over_rate_limit = true;
|
||||
for (state, price) in price_pairs.into_iter() {
|
||||
// Pyth must own the price
|
||||
if accs.config.pyth_owner != *price.owner {
|
||||
trace!(&format!(
|
||||
"Price {:?}: owner pubkey mismatch (expected pyth_owner {:?}, got unknown price owner {:?})",
|
||||
price, accs.config.pyth_owner, price.owner
|
||||
));
|
||||
return Err(SolitaireError::InvalidOwner(*price.owner));
|
||||
}
|
||||
|
||||
// State pubkey must reproduce from the price id
|
||||
let state_addr_from_price = AttestationStatePDA::key(price.key, ctx.program_id);
|
||||
if state_addr_from_price != *state.0 .0.info().key {
|
||||
trace!(&format!(
|
||||
"Price {:?}: pubkey does not produce the passed state account (expected {:?} from seeds, {:?} was passed)",
|
||||
price.key, state_addr_from_price, state.0.0.info().key
|
||||
));
|
||||
return Err(ProgramError::InvalidAccountData.into());
|
||||
}
|
||||
|
||||
let price_data_ref = price.try_borrow_data()?;
|
||||
|
||||
// Parse the upstream Pyth struct to extract current publish
|
||||
// time for payload construction
|
||||
let price_struct =
|
||||
pyth_sdk_solana::state::load_price_account(&price_data_ref).map_err(|e| {
|
||||
trace!(&e.to_string());
|
||||
ProgramError::InvalidAccountData
|
||||
})?;
|
||||
|
||||
// Retrieve and rotate last_attested_tradind_publish_time
|
||||
|
||||
|
||||
// Pick the value to store for the next attestation of this
|
||||
// symbol. We use the prev_ value if the symbol is not
|
||||
// currently being traded. The oracle marks the last known
|
||||
// trading timestamp with it.
|
||||
let new_last_attested_trading_publish_time = match price_struct.agg.status {
|
||||
PriceStatus::Trading => price_struct.timestamp,
|
||||
_ => price_struct.prev_timestamp,
|
||||
};
|
||||
|
||||
// Retrieve the timestamp saved during the previous
|
||||
// attestation. Use the new_* value if no existind state is
|
||||
// present on-chain
|
||||
let current_last_attested_trading_publish_time = if state.0 .0.is_initialized() {
|
||||
// Use the existing on-chain value
|
||||
state.0 .0 .1.last_attested_trading_publish_time
|
||||
} else {
|
||||
// Fall back to the new value if the state is not initialized
|
||||
new_last_attested_trading_publish_time
|
||||
};
|
||||
|
||||
// Build an attestatioin struct for this symbol using the just decided current value
|
||||
let attestation = PriceAttestation::from_pyth_price_struct(
|
||||
Identifier::new(price.key.to_bytes()),
|
||||
this_attestation_time,
|
||||
current_last_attested_trading_publish_time,
|
||||
price_struct,
|
||||
);
|
||||
|
||||
// Evaluate rate limit - should be smaller than duration from last attestation
|
||||
let trading_publish_time_diff =
|
||||
new_last_attested_trading_publish_time - state.0 .0.last_attested_trading_publish_time;
|
||||
let attestation_time_diff = this_attestation_time - state.0 .0.last_attestation_time;
|
||||
|
||||
// We like to have the rate_limit for trading publish_time because that is the field that
|
||||
// the users consume. Also, when the price is not trading and trading_publish_time is the
|
||||
// same, we still want to send the prices (on a lower frequency).
|
||||
if trading_publish_time_diff >= data.rate_limit_interval_secs as i64
|
||||
|| attestation_time_diff >= 2 * data.rate_limit_interval_secs as i64
|
||||
{
|
||||
over_rate_limit = false;
|
||||
} else {
|
||||
trace!("Price {:?}: over rate limit", price.key);
|
||||
}
|
||||
|
||||
// Save the new value for the next attestation of this symbol
|
||||
state.0 .0.last_attested_trading_publish_time = new_last_attested_trading_publish_time;
|
||||
|
||||
// Update last attestation time
|
||||
state.0 .0.last_attestation_time = this_attestation_time;
|
||||
|
||||
// handling of last_attested_trading_publish_time ends here
|
||||
|
||||
if !state.0 .0.is_initialized() {
|
||||
// Serialize the state to learn account size for creation
|
||||
let state_serialized = state.0 .0 .1.try_to_vec()?;
|
||||
|
||||
let seeds = state.self_bumped_seeds(price.key, ctx.program_id);
|
||||
solitaire::create_account(
|
||||
ctx,
|
||||
state.0 .0.info(),
|
||||
accs.payer.key,
|
||||
solitaire::CreationLamports::Exempt,
|
||||
state_serialized.len(),
|
||||
ctx.program_id,
|
||||
solitaire::IsSigned::SignedWithSeeds(&[seeds
|
||||
.iter()
|
||||
.map(|s| s.as_slice())
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice()]),
|
||||
)?;
|
||||
trace!("Attestation state init OK");
|
||||
}
|
||||
|
||||
|
||||
attestations.push(attestation);
|
||||
}
|
||||
|
||||
// Do not proceed if none of the symbols is under rate limit
|
||||
if over_rate_limit {
|
||||
trace!("All symbols over limit, bailing out");
|
||||
return Err(
|
||||
ProgramError::Custom(AttesterCustomError::AttestRateLimitReached as u32).into(),
|
||||
);
|
||||
}
|
||||
|
||||
let batch_attestation = BatchPriceAttestation {
|
||||
price_attestations: attestations,
|
||||
};
|
||||
|
||||
trace!("Attestations successfully created");
|
||||
|
||||
let bridge_config = BridgeData::try_from_slice(&accs.wh_bridge.try_borrow_mut_data()?)?.config;
|
||||
|
||||
// Pay wormhole fee
|
||||
let transfer_ix = solana_program::system_instruction::transfer(
|
||||
accs.payer.key,
|
||||
accs.wh_fee_collector.info().key,
|
||||
bridge_config.fee,
|
||||
);
|
||||
solana_program::program::invoke(&transfer_ix, ctx.accounts)?;
|
||||
|
||||
let payload = batch_attestation.serialize().map_err(|e| {
|
||||
trace!(&e.to_string());
|
||||
ProgramError::InvalidAccountData
|
||||
})?;
|
||||
|
||||
let wh_msg_drv_data = P2WMessageDrvData {
|
||||
message_owner: *accs.payer.key,
|
||||
batch_size: batch_attestation.price_attestations.len() as u16,
|
||||
id: data.message_account_id,
|
||||
};
|
||||
|
||||
if !P2WMessage::key(&wh_msg_drv_data, ctx.program_id).eq(accs.wh_message.info().key) {
|
||||
trace!(
|
||||
"Invalid seeds for wh message pubkey. Expected {} with given seeds {:?}, got {}",
|
||||
P2WMessage::key(&wh_msg_drv_data, ctx.program_id),
|
||||
P2WMessage::seeds(&wh_msg_drv_data)
|
||||
.iter_mut()
|
||||
.map(|seed| seed.as_slice())
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice(),
|
||||
accs.wh_message.info().key
|
||||
);
|
||||
return Err(ProgramError::InvalidSeeds.into());
|
||||
}
|
||||
|
||||
let ix = bridge::instructions::post_message_unreliable(
|
||||
*accs.wh_prog.info().key,
|
||||
*accs.payer.info().key,
|
||||
*accs.wh_emitter.info().key,
|
||||
*accs.wh_message.info().key,
|
||||
0,
|
||||
payload,
|
||||
data.consistency_level,
|
||||
)?;
|
||||
|
||||
trace!(&format!(
|
||||
"Cross-call Seeds: {:?}",
|
||||
[
|
||||
// message seeds
|
||||
P2WMessage::seeds(&wh_msg_drv_data)
|
||||
.iter_mut()
|
||||
.map(|seed| seed.as_slice())
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice(),
|
||||
// emitter seeds
|
||||
P2WEmitter::seeds(None)
|
||||
.iter_mut()
|
||||
.map(|seed| seed.as_slice())
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice(),
|
||||
]
|
||||
));
|
||||
|
||||
trace!("attest() finished, cross-calling wormhole");
|
||||
|
||||
invoke_signed(
|
||||
&ix,
|
||||
ctx.accounts,
|
||||
[
|
||||
// message seeds
|
||||
P2WMessage::bumped_seeds(&wh_msg_drv_data, ctx.program_id)
|
||||
.iter_mut()
|
||||
.map(|seed| seed.as_slice())
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice(),
|
||||
// emitter seeds
|
||||
P2WEmitter::bumped_seeds(None, ctx.program_id)
|
||||
.iter_mut()
|
||||
.map(|seed| seed.as_slice())
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice(),
|
||||
]
|
||||
.as_slice(),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
//! Implementation of per-symbol on-chain state. Currently used to
|
||||
//! store latest successful attestation time for each price.
|
||||
|
||||
use {
|
||||
borsh::{
|
||||
BorshDeserialize,
|
||||
BorshSerialize,
|
||||
},
|
||||
solana_program::{
|
||||
clock::UnixTimestamp,
|
||||
pubkey::Pubkey,
|
||||
},
|
||||
solitaire::{
|
||||
AccountOwner,
|
||||
AccountState,
|
||||
Data,
|
||||
Owned,
|
||||
Peel,
|
||||
Seeded,
|
||||
},
|
||||
};
|
||||
|
||||
/// On-chain state for a single price attestation
|
||||
#[derive(BorshSerialize, BorshDeserialize, Default)]
|
||||
pub struct AttestationState {
|
||||
/// The last trading publish_time this attester saw
|
||||
pub last_attested_trading_publish_time: UnixTimestamp,
|
||||
/// The last time this symbol was attested
|
||||
pub last_attestation_time: UnixTimestamp,
|
||||
}
|
||||
|
||||
impl Owned for AttestationState {
|
||||
fn owner(&self) -> AccountOwner {
|
||||
AccountOwner::This
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AttestationStatePDA<'b>(
|
||||
pub Data<'b, AttestationState, { AccountState::MaybeInitialized }>,
|
||||
);
|
||||
|
||||
impl Seeded<&Pubkey> for AttestationStatePDA<'_> {
|
||||
fn seeds(symbol_id: &Pubkey) -> Vec<Vec<u8>> {
|
||||
vec![
|
||||
"p2w-attestation-state-v1".as_bytes().to_vec(),
|
||||
symbol_id.to_bytes().to_vec(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'b: 'a> Peel<'a, 'b> for AttestationStatePDA<'b> {
|
||||
fn peel<I>(ctx: &mut solitaire::Context<'a, 'b, I>) -> solitaire::Result<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Ok(Self(Data::peel(ctx)?))
|
||||
}
|
||||
|
||||
fn persist(&self, program_id: &Pubkey) -> solitaire::Result<()> {
|
||||
self.0.persist(program_id)
|
||||
}
|
||||
}
|
|
@ -1,169 +0,0 @@
|
|||
//! On-chain state for the pyth2wormhole SOL contract.
|
||||
//!
|
||||
//! Important: Changes to max batch size must be reflected in the
|
||||
//! instruction logic in attest.rs (look there for more
|
||||
//! details). Mismatches between config and contract logic may confuse
|
||||
//! attesters.
|
||||
//!
|
||||
//! How to add a new config schema:
|
||||
//! X - new config version number
|
||||
//! Y = X - 1; previous config number
|
||||
//! 1. Add a next Pyth2WormholeConfigVX struct,
|
||||
//! e.g. Pyth2WormholeConfigV3,
|
||||
//! 2. Add a P2WConfigAccountVX type alias with a unique seed str
|
||||
//! 3. Implement From<Pyth2WormholeConfigVY> for the new struct,
|
||||
//! e.g. From<Pyth2WormholeConfigV2> for Pyth2WormholeConfigV3
|
||||
//! 4. Advance Pyth2WormholeConfig, P2WConfigAccount,
|
||||
//! OldPyth2WormholeConfig, OldP2WConfigAccount typedefs to use the
|
||||
//! previous and new config structs.
|
||||
//! 5. Deploy and call migrate() to verify
|
||||
//! 6. (optional) Remove/comment out config structs and aliases from
|
||||
//! before version Y.
|
||||
|
||||
use {
|
||||
borsh::{
|
||||
BorshDeserialize,
|
||||
BorshSerialize,
|
||||
},
|
||||
solana_program::pubkey::Pubkey,
|
||||
solitaire::{
|
||||
processors::seeded::AccountOwner,
|
||||
AccountState,
|
||||
Data,
|
||||
Derive,
|
||||
Owned,
|
||||
},
|
||||
};
|
||||
|
||||
/// Aliases for current config schema (to migrate into)
|
||||
pub type Pyth2WormholeConfig = Pyth2WormholeConfigV3;
|
||||
pub type P2WConfigAccount<'b, const IS_INITIALIZED: AccountState> =
|
||||
P2WConfigAccountV3<'b, IS_INITIALIZED>;
|
||||
|
||||
impl Owned for Pyth2WormholeConfig {
|
||||
fn owner(&self) -> AccountOwner {
|
||||
AccountOwner::This
|
||||
}
|
||||
}
|
||||
|
||||
/// Aliases for previous config schema (to migrate from)
|
||||
pub type OldPyth2WormholeConfig = Pyth2WormholeConfigV2;
|
||||
pub type OldP2WConfigAccount<'b> = P2WConfigAccountV2<'b, { AccountState::Initialized }>; // Old config must always be initialized
|
||||
|
||||
impl Owned for OldPyth2WormholeConfig {
|
||||
fn owner(&self) -> AccountOwner {
|
||||
AccountOwner::This
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial config format
|
||||
#[derive(Clone, Default, BorshDeserialize, BorshSerialize)]
|
||||
#[cfg_attr(feature = "client", derive(Debug))]
|
||||
pub struct Pyth2WormholeConfigV1 {
|
||||
/// Authority owning this contract
|
||||
pub owner: Pubkey,
|
||||
/// Wormhole bridge program
|
||||
pub wh_prog: Pubkey,
|
||||
/// Authority owning Pyth price data
|
||||
pub pyth_owner: Pubkey,
|
||||
pub max_batch_size: u16,
|
||||
}
|
||||
|
||||
pub type P2WConfigAccountV1<'b, const IS_INITIALIZED: AccountState> =
|
||||
Derive<Data<'b, Pyth2WormholeConfigV1, { IS_INITIALIZED }>, "pyth2wormhole-config">;
|
||||
|
||||
/// Added is_active
|
||||
#[derive(Clone, Default, BorshDeserialize, BorshSerialize)]
|
||||
#[cfg_attr(feature = "client", derive(Debug))]
|
||||
pub struct Pyth2WormholeConfigV2 {
|
||||
/// Authority owning this contract
|
||||
pub owner: Pubkey,
|
||||
/// Wormhole bridge program
|
||||
pub wh_prog: Pubkey,
|
||||
/// Authority owning Pyth price data
|
||||
pub pyth_owner: Pubkey,
|
||||
/// How many product/price pairs can be sent and attested at once
|
||||
///
|
||||
/// Important: Whenever the corresponding logic in attest.rs
|
||||
/// changes its expected number of symbols per batch, this config
|
||||
/// must be updated accordingly on-chain.
|
||||
pub max_batch_size: u16,
|
||||
|
||||
/// If set to false, attest() will reject all calls unconditionally
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
/// Note: If you get stuck with a pre-existing config account
|
||||
/// (e.g. someone transfers into a PDA that we're not using yet), it's
|
||||
/// usually easier to change the seed slightly
|
||||
/// (e.g. pyth2wormhole-config-v2 -> pyth2wormhole-config-v2.1). This
|
||||
/// saves a lot of time coding around this edge case.
|
||||
pub type P2WConfigAccountV2<'b, const IS_INITIALIZED: AccountState> =
|
||||
Derive<Data<'b, Pyth2WormholeConfigV2, { IS_INITIALIZED }>, "pyth2wormhole-config-v2.1">;
|
||||
|
||||
impl From<Pyth2WormholeConfigV1> for Pyth2WormholeConfigV2 {
|
||||
fn from(old: Pyth2WormholeConfigV1) -> Self {
|
||||
let Pyth2WormholeConfigV1 {
|
||||
owner,
|
||||
wh_prog,
|
||||
pyth_owner,
|
||||
max_batch_size,
|
||||
} = old;
|
||||
|
||||
Self {
|
||||
owner,
|
||||
wh_prog,
|
||||
pyth_owner,
|
||||
max_batch_size,
|
||||
is_active: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Added ops_owner which can toggle the is_active field
|
||||
#[derive(Clone, Default, Hash, BorshDeserialize, BorshSerialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "client", derive(Debug))]
|
||||
pub struct Pyth2WormholeConfigV3 {
|
||||
/// Authority owning this contract
|
||||
pub owner: Pubkey,
|
||||
/// Wormhole bridge program
|
||||
pub wh_prog: Pubkey,
|
||||
/// Authority owning Pyth price data
|
||||
pub pyth_owner: Pubkey,
|
||||
/// How many product/price pairs can be sent and attested at once
|
||||
///
|
||||
/// Important: Whenever the corresponding logic in attest.rs
|
||||
/// changes its expected number of symbols per batch, this config
|
||||
/// must be updated accordingly on-chain.
|
||||
pub max_batch_size: u16,
|
||||
|
||||
/// If set to false, attest() will reject all calls unconditionally
|
||||
pub is_active: bool,
|
||||
|
||||
// If the ops_owner exists, it can toggle the value of `is_active`
|
||||
pub ops_owner: Option<Pubkey>,
|
||||
}
|
||||
|
||||
pub type P2WConfigAccountV3<'b, const IS_INITIALIZED: AccountState> =
|
||||
Derive<Data<'b, Pyth2WormholeConfigV3, { IS_INITIALIZED }>, "pyth2wormhole-config-v3">;
|
||||
|
||||
impl From<Pyth2WormholeConfigV2> for Pyth2WormholeConfigV3 {
|
||||
fn from(old: Pyth2WormholeConfigV2) -> Self {
|
||||
let Pyth2WormholeConfigV2 {
|
||||
owner,
|
||||
wh_prog,
|
||||
pyth_owner,
|
||||
max_batch_size,
|
||||
is_active: _,
|
||||
} = old;
|
||||
|
||||
Self {
|
||||
owner,
|
||||
wh_prog,
|
||||
pyth_owner,
|
||||
max_batch_size,
|
||||
is_active: true,
|
||||
ops_owner: None,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
/// Append-only custom error list.
|
||||
#[repr(u32)]
|
||||
pub enum AttesterCustomError {
|
||||
/// Explicitly checked for in client code, change carefully
|
||||
AttestRateLimitReached = 13,
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
use {
|
||||
crate::config::{
|
||||
P2WConfigAccount,
|
||||
Pyth2WormholeConfig,
|
||||
},
|
||||
solitaire::{
|
||||
trace,
|
||||
AccountState,
|
||||
CreationLamports,
|
||||
ExecutionContext,
|
||||
FromAccounts,
|
||||
Info,
|
||||
Keyed,
|
||||
Mut,
|
||||
Peel,
|
||||
Result as SoliResult,
|
||||
Signer,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(FromAccounts)]
|
||||
pub struct Initialize<'b> {
|
||||
pub new_config: Mut<P2WConfigAccount<'b, { AccountState::Uninitialized }>>,
|
||||
pub payer: Mut<Signer<Info<'b>>>,
|
||||
pub system_program: Info<'b>,
|
||||
}
|
||||
|
||||
/// Must be called right after deployment
|
||||
pub fn initialize(
|
||||
ctx: &ExecutionContext,
|
||||
accs: &mut Initialize,
|
||||
data: Pyth2WormholeConfig,
|
||||
) -> SoliResult<()> {
|
||||
accs.new_config
|
||||
.create(ctx, accs.payer.info().key, CreationLamports::Exempt)?;
|
||||
accs.new_config.1 = data;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
#![allow(incomplete_features)]
|
||||
#![feature(adt_const_params)]
|
||||
pub mod attest;
|
||||
pub mod attestation_state;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod initialize;
|
||||
pub mod message;
|
||||
pub mod migrate;
|
||||
pub mod set_config;
|
||||
pub mod set_is_active;
|
||||
|
||||
use solitaire::solitaire;
|
||||
pub use {
|
||||
attest::{
|
||||
attest,
|
||||
Attest,
|
||||
AttestData,
|
||||
},
|
||||
config::Pyth2WormholeConfig,
|
||||
initialize::{
|
||||
initialize,
|
||||
Initialize,
|
||||
},
|
||||
migrate::{
|
||||
migrate,
|
||||
Migrate,
|
||||
},
|
||||
pyth_client,
|
||||
set_config::{
|
||||
set_config,
|
||||
SetConfig,
|
||||
},
|
||||
set_is_active::{
|
||||
set_is_active,
|
||||
SetIsActive,
|
||||
},
|
||||
};
|
||||
|
||||
solitaire! {
|
||||
Attest => attest,
|
||||
Initialize => initialize,
|
||||
SetConfig => set_config,
|
||||
Migrate => migrate,
|
||||
SetIsActive => set_is_active
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
//! Index-based PDA for storing unreliable wormhole message
|
||||
//!
|
||||
//! The main goal of this PDA is to take advantage of wormhole message
|
||||
//! reuse securely. This is achieved by tying the account derivation
|
||||
//! data to the payer account of the attest() instruction. Inside
|
||||
//! attest(), payer must be a signer, and the message account must be
|
||||
//! derived with their address as message_owner in
|
||||
//! `P2WMessageDrvData`.
|
||||
|
||||
use {
|
||||
borsh::{
|
||||
BorshDeserialize,
|
||||
BorshSerialize,
|
||||
},
|
||||
bridge::PostedMessageUnreliable,
|
||||
solana_program::pubkey::Pubkey,
|
||||
solitaire::{
|
||||
processors::seeded::Seeded,
|
||||
AccountState,
|
||||
Mut,
|
||||
},
|
||||
};
|
||||
|
||||
pub type P2WMessage<'a> = Mut<PostedMessageUnreliable<'a, { AccountState::MaybeInitialized }>>;
|
||||
|
||||
#[derive(BorshDeserialize, BorshSerialize)]
|
||||
pub struct P2WMessageDrvData {
|
||||
/// The key owning this message account
|
||||
pub message_owner: Pubkey,
|
||||
/// Size of the batch. It is important that all messages have the same size
|
||||
///
|
||||
/// NOTE: 2022-09-05
|
||||
/// Currently wormhole does not resize accounts if they have different
|
||||
/// payload sizes; this (along with versioning the seed literal below) is
|
||||
/// a workaround to have different PDAs for different batch sizes.
|
||||
pub batch_size: u16,
|
||||
/// Index for keeping many accounts per owner
|
||||
pub id: u64,
|
||||
}
|
||||
|
||||
impl<'a> Seeded<&P2WMessageDrvData> for P2WMessage<'a> {
|
||||
fn seeds(data: &P2WMessageDrvData) -> Vec<Vec<u8>> {
|
||||
vec![
|
||||
// See the note at 2022-09-05 above.
|
||||
// Change the version in the literal whenever you change the
|
||||
// price attestation data.
|
||||
"p2w-message-v2".as_bytes().to_vec(),
|
||||
data.message_owner.to_bytes().to_vec(),
|
||||
data.batch_size.to_be_bytes().to_vec(),
|
||||
data.id.to_be_bytes().to_vec(),
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
//! Instruction used to migrate on-chain configuration from an older format
|
||||
|
||||
use {
|
||||
crate::config::{
|
||||
OldP2WConfigAccount,
|
||||
OldPyth2WormholeConfig,
|
||||
P2WConfigAccount,
|
||||
Pyth2WormholeConfig,
|
||||
},
|
||||
solana_program::{
|
||||
program_error::ProgramError,
|
||||
system_program,
|
||||
},
|
||||
solitaire::{
|
||||
trace,
|
||||
AccountState,
|
||||
CreationLamports,
|
||||
ExecutionContext,
|
||||
FromAccounts,
|
||||
Info,
|
||||
Keyed,
|
||||
Mut,
|
||||
Peel,
|
||||
Result as SoliResult,
|
||||
Signer,
|
||||
SolitaireError,
|
||||
},
|
||||
};
|
||||
|
||||
/// Migration accounts meant to evolve with subsequent config accounts
|
||||
///
|
||||
/// NOTE: This account struct assumes Solitaire is able to validate the
|
||||
/// Uninitialized requirement on the new_config account
|
||||
#[derive(FromAccounts)]
|
||||
pub struct Migrate<'b> {
|
||||
/// New config account to be populated. Must be unused.
|
||||
pub new_config: Mut<P2WConfigAccount<'b, { AccountState::Uninitialized }>>,
|
||||
/// Old config using the previous format.
|
||||
pub old_config: Mut<OldP2WConfigAccount<'b>>,
|
||||
/// Current owner authority of the program
|
||||
pub current_owner: Mut<Signer<Info<'b>>>,
|
||||
/// Payer account for updating the account data
|
||||
pub payer: Mut<Signer<Info<'b>>>,
|
||||
/// For creating the new config account
|
||||
pub system_program: Info<'b>,
|
||||
}
|
||||
|
||||
pub fn migrate(ctx: &ExecutionContext, accs: &mut Migrate, _data: ()) -> SoliResult<()> {
|
||||
let old_config: &OldPyth2WormholeConfig = &accs.old_config.1;
|
||||
|
||||
if &old_config.owner != accs.current_owner.info().key {
|
||||
trace!(
|
||||
"Current config owner account mismatch (expected {:?})",
|
||||
old_config.owner
|
||||
);
|
||||
return Err(SolitaireError::InvalidSigner(
|
||||
*accs.current_owner.info().key,
|
||||
));
|
||||
}
|
||||
|
||||
if *accs.system_program.key != system_program::id() {
|
||||
trace!(
|
||||
"Invalid system program, expected {:?}), found {}",
|
||||
system_program::id(),
|
||||
accs.system_program.key
|
||||
);
|
||||
return Err(SolitaireError::InvalidSigner(*accs.system_program.key));
|
||||
}
|
||||
|
||||
// Populate new config
|
||||
accs.new_config
|
||||
.create(ctx, accs.payer.info().key, CreationLamports::Exempt)?;
|
||||
accs.new_config.1 = Pyth2WormholeConfig::from(old_config.clone());
|
||||
|
||||
// Reclaim old config lamports
|
||||
|
||||
// Save current balance
|
||||
let old_config_balance_val: u64 = accs.old_config.info().lamports();
|
||||
|
||||
// Drain old config
|
||||
**accs.old_config.info().lamports.borrow_mut() = 0;
|
||||
|
||||
// Credit payer with saved balance
|
||||
let new_payer_balance = accs
|
||||
.payer
|
||||
.info()
|
||||
.lamports
|
||||
.borrow_mut()
|
||||
.checked_add(old_config_balance_val)
|
||||
.ok_or_else(|| {
|
||||
trace!("Overflow on payer balance increase");
|
||||
SolitaireError::ProgramError(ProgramError::Custom(0xDEADBEEF))
|
||||
})?;
|
||||
|
||||
**accs.payer.info().lamports.borrow_mut() = new_payer_balance;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,85 +0,0 @@
|
|||
use {
|
||||
crate::config::{
|
||||
P2WConfigAccount,
|
||||
Pyth2WormholeConfig,
|
||||
},
|
||||
borsh::BorshSerialize,
|
||||
solana_program::{
|
||||
program::invoke,
|
||||
rent::Rent,
|
||||
system_instruction,
|
||||
sysvar::Sysvar,
|
||||
},
|
||||
solitaire::{
|
||||
trace,
|
||||
AccountState,
|
||||
ExecutionContext,
|
||||
FromAccounts,
|
||||
Info,
|
||||
Keyed,
|
||||
Mut,
|
||||
Peel,
|
||||
Result as SoliResult,
|
||||
Signer,
|
||||
SolitaireError,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(FromAccounts)]
|
||||
pub struct SetConfig<'b> {
|
||||
/// Current config used by the program
|
||||
pub config: Mut<P2WConfigAccount<'b, { AccountState::Initialized }>>,
|
||||
/// Current owner authority of the program
|
||||
pub current_owner: Mut<Signer<Info<'b>>>,
|
||||
/// Payer account for updating the account data
|
||||
pub payer: Mut<Signer<Info<'b>>>,
|
||||
/// Used for rent adjustment transfer
|
||||
pub system_program: Info<'b>,
|
||||
}
|
||||
|
||||
/// Alters the current settings of pyth2wormhole
|
||||
pub fn set_config(
|
||||
ctx: &ExecutionContext,
|
||||
accs: &mut SetConfig,
|
||||
data: Pyth2WormholeConfig,
|
||||
) -> SoliResult<()> {
|
||||
let cfg_struct: &Pyth2WormholeConfig = &accs.config; // unpack Data via nested Deref impls
|
||||
if &cfg_struct.owner != accs.current_owner.info().key {
|
||||
trace!(
|
||||
"Current owner account mismatch (expected {:?})",
|
||||
cfg_struct.owner
|
||||
);
|
||||
return Err(SolitaireError::InvalidSigner(
|
||||
*accs.current_owner.info().key,
|
||||
));
|
||||
}
|
||||
|
||||
let old_size = accs.config.info().data_len();
|
||||
let new_size = data.try_to_vec()?.len();
|
||||
|
||||
// Realloc if mismatched
|
||||
if old_size != new_size {
|
||||
accs.config.info().realloc(new_size, false)?;
|
||||
}
|
||||
|
||||
accs.config.1 = data;
|
||||
|
||||
// Adjust lamports
|
||||
let acc_lamports = accs.config.info().lamports();
|
||||
|
||||
let new_lamports = Rent::get()?.minimum_balance(new_size);
|
||||
|
||||
let diff_lamports: u64 = (acc_lamports as i64 - new_lamports as i64).unsigned_abs();
|
||||
|
||||
if acc_lamports < new_lamports {
|
||||
// Less than enough lamports, debit the payer
|
||||
let transfer_ix = system_instruction::transfer(
|
||||
accs.payer.info().key,
|
||||
accs.config.info().key,
|
||||
diff_lamports,
|
||||
);
|
||||
invoke(&transfer_ix, ctx.accounts)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
use {
|
||||
crate::config::{
|
||||
P2WConfigAccount,
|
||||
Pyth2WormholeConfig,
|
||||
},
|
||||
solitaire::{
|
||||
trace,
|
||||
AccountState,
|
||||
ExecutionContext,
|
||||
FromAccounts,
|
||||
Info,
|
||||
Keyed,
|
||||
Mut,
|
||||
Peel,
|
||||
Result as SoliResult,
|
||||
Signer,
|
||||
SolitaireError,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(FromAccounts)]
|
||||
pub struct SetIsActive<'b> {
|
||||
/// Current config used by the program
|
||||
pub config: Mut<P2WConfigAccount<'b, { AccountState::Initialized }>>,
|
||||
/// Current owner authority of the program
|
||||
pub ops_owner: Mut<Signer<Info<'b>>>,
|
||||
/// Payer account for updating the account data
|
||||
pub payer: Mut<Signer<Info<'b>>>,
|
||||
}
|
||||
|
||||
/// Alters the current settings of pyth2wormhole
|
||||
pub fn set_is_active(
|
||||
_ctx: &ExecutionContext,
|
||||
accs: &mut SetIsActive,
|
||||
new_is_active: bool,
|
||||
) -> SoliResult<()> {
|
||||
let cfg_struct: &mut Pyth2WormholeConfig = &mut accs.config; // unpack Data via nested Deref impls
|
||||
match &cfg_struct.ops_owner {
|
||||
None => Err(SolitaireError::InvalidOwner(*accs.ops_owner.info().key)),
|
||||
Some(current_ops_owner) => {
|
||||
if current_ops_owner != accs.ops_owner.info().key {
|
||||
trace!(
|
||||
"Ops owner account mismatch (expected {:?})",
|
||||
current_ops_owner
|
||||
);
|
||||
return Err(SolitaireError::InvalidOwner(*accs.ops_owner.info().key));
|
||||
}
|
||||
|
||||
cfg_struct.is_active = new_is_active;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# ethereum contracts
|
||||
/contracts
|
||||
/src/*-contracts/
|
||||
|
||||
# tsproto output
|
||||
/src/proto
|
||||
|
||||
# build
|
||||
/lib
|
|
@ -1,17 +0,0 @@
|
|||
# Pyth2wormhole SDK
|
||||
|
||||
This project contains a library for interacting with pyth2wormhole and adjacent APIs.
|
||||
|
||||
# Install
|
||||
|
||||
For now, the in-house dependencies are referenced by relative
|
||||
path. The commands below will build those. For an automated version of
|
||||
this process, please refer to `p2w-integration-observer`'s Dockerfile and/or our [Tilt](https://tilt.dev)
|
||||
devnet with `pyth` enabled.
|
||||
|
||||
```shell
|
||||
# Run the commands in this README's directory for --prefix to work
|
||||
$ npm --prefix ../../../ethereum ci && npm --prefix ../../../ethereum run build # ETH contracts
|
||||
$ npm --prefix ../../../sdk/js ci # Wormhole SDK
|
||||
$ npm ci && npm run build # Pyth2wormhole SDK
|
||||
```
|
|
@ -1,5 +0,0 @@
|
|||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
};
|
|
@ -1,46 +0,0 @@
|
|||
{
|
||||
"name": "@pythnetwork/wormhole-attester-sdk",
|
||||
"version": "1.1.0",
|
||||
"description": "Pyth Wormhole Attester SDk",
|
||||
"private": "true",
|
||||
"types": "lib/index.d.ts",
|
||||
"main": "lib/index.js",
|
||||
"files": [
|
||||
"lib/**/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"lint": "tslint -p tsconfig.json",
|
||||
"test": "jest src/",
|
||||
"postversion": "git push && git push --tags",
|
||||
"preversion": "npm run lint",
|
||||
"version": "npm run format && git add -A src"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/pyth-network/pyth-crosschain.git"
|
||||
},
|
||||
"author": "Pyth Data Association",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/long": "^4.0.1",
|
||||
"@types/node": "^16.6.1",
|
||||
"copy-dir": "^1.3.0",
|
||||
"find": "^0.3.0",
|
||||
"jest": "^29.4.1",
|
||||
"prettier": "^2.3.2",
|
||||
"ts-jest": "^29.0.5",
|
||||
"tslint": "^6.1.3",
|
||||
"tslint-config-prettier": "^1.18.0",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/pyth-network/pyth-crosschain/issues"
|
||||
},
|
||||
"homepage": "https://github.com/pyth-network/pyth-crosschain#readme",
|
||||
"dependencies": {
|
||||
"@pythnetwork/price-service-sdk": "*"
|
||||
}
|
||||
}
|
|
@ -1,136 +0,0 @@
|
|||
import {
|
||||
parseBatchPriceAttestation,
|
||||
Price,
|
||||
PriceFeed,
|
||||
PriceAttestation,
|
||||
PriceAttestationStatus,
|
||||
priceAttestationToPriceFeed,
|
||||
} from "../index";
|
||||
|
||||
describe("Deserializing Batch Price Attestation works", () => {
|
||||
test("when batch has 3 price feeds", () => {
|
||||
// Generated from the rust sdk test_batch_serde
|
||||
const fixture =
|
||||
"50325748000300010001020003009D01010101010101010101010101010101010101010101010101010" +
|
||||
"10101010101FEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFEFE0000002B" +
|
||||
"AD2FEED70000000000000065FFFFFFFDFFFFFFFFFFFFFFD6000000000000002A010001E14C0004E6D00" +
|
||||
"000DEADBEEFFADE00000000DADEBEEF00000000DEADBABE0000DEADFACEBEEF000000BADBADBEEF0000" +
|
||||
"DEADBEEFFACE0202020202020202020202020202020202020202020202020202020202020202FDFDFDF" +
|
||||
"DFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFDFD0000002BAD2FEED70000000000" +
|
||||
"000065FFFFFFFDFFFFFFFFFFFFFFD6000000000000002A010001E14C0004E6D00000DEADBEEFFADE000" +
|
||||
"00000DADEBEEF00000000DEADBABE0000DEADFACEBEEF000000BADBADBEEF0000DEADBEEFFACE030303" +
|
||||
"0303030303030303030303030303030303030303030303030303030303FCFCFCFCFCFCFCFCFCFCFCFCF" +
|
||||
"CFCFCFCFCFCFCFCFCFCFCFCFCFCFCFCFCFCFCFC0000002BAD2FEED70000000000000065FFFFFFFDFFFF" +
|
||||
"FFFFFFFFFFD6000000000000002A010001E14C0004E6D00000DEADBEEFFADE00000000DADEBEEF00000" +
|
||||
"000DEADBABE0000DEADFACEBEEF000000BADBADBEEF0000DEADBEEFFACE";
|
||||
|
||||
const data = Buffer.from(fixture, "hex");
|
||||
const batchPriceAttestation = parseBatchPriceAttestation(data);
|
||||
|
||||
expect(batchPriceAttestation.priceAttestations.length).toBe(3);
|
||||
|
||||
// values are from the rust sdk mock_attestation
|
||||
batchPriceAttestation.priceAttestations.forEach((pa, idx) => {
|
||||
expect(pa).toEqual<PriceAttestation>({
|
||||
productId: Buffer.from(Array(32).fill(idx + 1)).toString("hex"),
|
||||
priceId: Buffer.from(Array(32).fill(255 - idx - 1)).toString("hex"),
|
||||
price: (0x2bad2feed7).toString(),
|
||||
conf: "101",
|
||||
emaPrice: "-42",
|
||||
emaConf: "42",
|
||||
expo: -3,
|
||||
status: PriceAttestationStatus.Trading,
|
||||
numPublishers: 123212,
|
||||
maxNumPublishers: 321232,
|
||||
attestationTime: 0xdeadbeeffade,
|
||||
publishTime: 0xdadebeef,
|
||||
prevPublishTime: 0xdeadbabe,
|
||||
prevPrice: (0xdeadfacebeef).toString(),
|
||||
prevConf: (0xbadbadbeef).toString(),
|
||||
lastAttestedPublishTime: 0xdeadbeefface,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Price Attestation to Price Feed works", () => {
|
||||
test("when status is trading", () => {
|
||||
const priceAttestation = {
|
||||
productId: "012345",
|
||||
priceId: "abcde",
|
||||
price: "100",
|
||||
conf: "5",
|
||||
emaPrice: "103",
|
||||
emaConf: "3",
|
||||
expo: -3,
|
||||
status: PriceAttestationStatus.Trading,
|
||||
numPublishers: 1,
|
||||
maxNumPublishers: 2,
|
||||
attestationTime: 1000,
|
||||
publishTime: 1000,
|
||||
prevPublishTime: 998,
|
||||
prevPrice: "101",
|
||||
prevConf: "6",
|
||||
lastAttestedPublishTime: 997,
|
||||
};
|
||||
|
||||
const priceFeed = priceAttestationToPriceFeed(priceAttestation);
|
||||
expect(priceFeed).toEqual(
|
||||
new PriceFeed({
|
||||
id: "abcde",
|
||||
price: new Price({
|
||||
price: "100",
|
||||
conf: "5",
|
||||
expo: -3,
|
||||
publishTime: 1000,
|
||||
}),
|
||||
emaPrice: new Price({
|
||||
price: "103",
|
||||
conf: "3",
|
||||
expo: -3,
|
||||
publishTime: 1000,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("when status is not trading", () => {
|
||||
const priceAttestation = {
|
||||
productId: "012345",
|
||||
priceId: "abcde",
|
||||
price: "100",
|
||||
conf: "5",
|
||||
emaPrice: "103",
|
||||
emaConf: "3",
|
||||
expo: -3,
|
||||
status: PriceAttestationStatus.Unknown,
|
||||
numPublishers: 1,
|
||||
maxNumPublishers: 2,
|
||||
attestationTime: 1000,
|
||||
publishTime: 1000,
|
||||
prevPublishTime: 998,
|
||||
prevPrice: "101",
|
||||
prevConf: "6",
|
||||
lastAttestedPublishTime: 997,
|
||||
};
|
||||
|
||||
const priceFeed = priceAttestationToPriceFeed(priceAttestation);
|
||||
expect(priceFeed).toEqual(
|
||||
new PriceFeed({
|
||||
id: "abcde",
|
||||
price: new Price({
|
||||
price: "101",
|
||||
conf: "6",
|
||||
expo: -3,
|
||||
publishTime: 998,
|
||||
}),
|
||||
emaPrice: new Price({
|
||||
price: "103",
|
||||
conf: "3",
|
||||
expo: -3,
|
||||
publishTime: 998,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,256 +0,0 @@
|
|||
import {
|
||||
PriceFeed,
|
||||
Price,
|
||||
UnixTimestamp,
|
||||
} from "@pythnetwork/price-service-sdk";
|
||||
|
||||
export {
|
||||
PriceFeed,
|
||||
Price,
|
||||
UnixTimestamp,
|
||||
} from "@pythnetwork/price-service-sdk";
|
||||
|
||||
export enum PriceAttestationStatus {
|
||||
Unknown = 0,
|
||||
Trading = 1,
|
||||
Halted = 2,
|
||||
Auction = 3,
|
||||
Ignored = 4,
|
||||
}
|
||||
|
||||
export type PriceAttestation = {
|
||||
productId: string;
|
||||
priceId: string;
|
||||
price: string;
|
||||
conf: string;
|
||||
expo: number;
|
||||
emaPrice: string;
|
||||
emaConf: string;
|
||||
status: PriceAttestationStatus;
|
||||
numPublishers: number;
|
||||
maxNumPublishers: number;
|
||||
attestationTime: UnixTimestamp;
|
||||
publishTime: UnixTimestamp;
|
||||
prevPublishTime: UnixTimestamp;
|
||||
prevPrice: string;
|
||||
prevConf: string;
|
||||
lastAttestedPublishTime: UnixTimestamp;
|
||||
};
|
||||
|
||||
export type BatchPriceAttestation = {
|
||||
priceAttestations: PriceAttestation[];
|
||||
};
|
||||
|
||||
/// Precedes every message implementing the wormhole attester serialization format
|
||||
const P2W_FORMAT_MAGIC: string = "P2WH";
|
||||
const P2W_FORMAT_VER_MAJOR = 3;
|
||||
const P2W_FORMAT_VER_MINOR = 0;
|
||||
const P2W_FORMAT_PAYLOAD_ID = 2;
|
||||
|
||||
export function parsePriceAttestation(bytes: Buffer): PriceAttestation {
|
||||
let offset = 0;
|
||||
|
||||
const productId = bytes.slice(offset, offset + 32).toString("hex");
|
||||
offset += 32;
|
||||
|
||||
const priceId = bytes.slice(offset, offset + 32).toString("hex");
|
||||
offset += 32;
|
||||
|
||||
const price = bytes.readBigInt64BE(offset).toString();
|
||||
offset += 8;
|
||||
|
||||
const conf = bytes.readBigUint64BE(offset).toString();
|
||||
offset += 8;
|
||||
|
||||
const expo = bytes.readInt32BE(offset);
|
||||
offset += 4;
|
||||
|
||||
const emaPrice = bytes.readBigInt64BE(offset).toString();
|
||||
offset += 8;
|
||||
|
||||
const emaConf = bytes.readBigUint64BE(offset).toString();
|
||||
offset += 8;
|
||||
|
||||
const status = bytes.readUint8(offset) as PriceAttestationStatus;
|
||||
offset += 1;
|
||||
|
||||
const numPublishers = bytes.readUint32BE(offset);
|
||||
offset += 4;
|
||||
|
||||
const maxNumPublishers = bytes.readUint32BE(offset);
|
||||
offset += 4;
|
||||
|
||||
const attestationTime = Number(bytes.readBigInt64BE(offset));
|
||||
offset += 8;
|
||||
|
||||
const publishTime = Number(bytes.readBigInt64BE(offset));
|
||||
offset += 8;
|
||||
|
||||
const prevPublishTime = Number(bytes.readBigInt64BE(offset));
|
||||
offset += 8;
|
||||
|
||||
const prevPrice = bytes.readBigInt64BE(offset).toString();
|
||||
offset += 8;
|
||||
|
||||
const prevConf = bytes.readBigUint64BE(offset).toString();
|
||||
offset += 8;
|
||||
|
||||
const lastAttestedPublishTime = Number(bytes.readBigInt64BE(offset));
|
||||
offset += 8;
|
||||
|
||||
return {
|
||||
productId,
|
||||
priceId,
|
||||
price,
|
||||
conf,
|
||||
expo,
|
||||
emaPrice,
|
||||
emaConf,
|
||||
status,
|
||||
numPublishers,
|
||||
maxNumPublishers,
|
||||
attestationTime,
|
||||
publishTime,
|
||||
prevPublishTime,
|
||||
prevPrice,
|
||||
prevConf,
|
||||
lastAttestedPublishTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Read the sdk/rust as the reference implementation and documentation.
|
||||
export function parseBatchPriceAttestation(
|
||||
bytes: Buffer
|
||||
): BatchPriceAttestation {
|
||||
let offset = 0;
|
||||
|
||||
const magic = bytes.slice(offset, offset + 4).toString("utf8");
|
||||
offset += 4;
|
||||
if (magic !== P2W_FORMAT_MAGIC) {
|
||||
throw new Error(`Invalid magic: ${magic}, expected: ${P2W_FORMAT_MAGIC}`);
|
||||
}
|
||||
|
||||
const versionMajor = bytes.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
if (versionMajor !== P2W_FORMAT_VER_MAJOR) {
|
||||
throw new Error(
|
||||
`Unsupported major version: ${versionMajor}, expected: ${P2W_FORMAT_VER_MAJOR}`
|
||||
);
|
||||
}
|
||||
|
||||
const versionMinor = bytes.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
if (versionMinor < P2W_FORMAT_VER_MINOR) {
|
||||
throw new Error(
|
||||
`Unsupported minor version: ${versionMinor}, expected: ${P2W_FORMAT_VER_MINOR}`
|
||||
);
|
||||
}
|
||||
|
||||
// Header size is added for future-compatibility
|
||||
const headerSize = bytes.readUint16BE(offset);
|
||||
offset += 2;
|
||||
|
||||
let headerOffset = 0;
|
||||
|
||||
const payloadId = bytes.readUint8(offset + headerOffset);
|
||||
headerOffset += 1;
|
||||
|
||||
if (payloadId !== P2W_FORMAT_PAYLOAD_ID) {
|
||||
throw new Error(
|
||||
`Invalid payloadId: ${payloadId}, expected: ${P2W_FORMAT_PAYLOAD_ID}`
|
||||
);
|
||||
}
|
||||
|
||||
offset += headerSize;
|
||||
|
||||
const batchLen = bytes.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
|
||||
const attestationSize = bytes.readUint16BE(offset);
|
||||
offset += 2;
|
||||
|
||||
const priceAttestations: PriceAttestation[] = [];
|
||||
|
||||
for (let i = 0; i < batchLen; i += 1) {
|
||||
priceAttestations.push(
|
||||
parsePriceAttestation(bytes.subarray(offset, offset + attestationSize))
|
||||
);
|
||||
offset += attestationSize;
|
||||
}
|
||||
|
||||
if (offset !== bytes.length) {
|
||||
throw new Error(`Invalid length: ${bytes.length}, expected: ${offset}`);
|
||||
}
|
||||
|
||||
return {
|
||||
priceAttestations,
|
||||
};
|
||||
}
|
||||
|
||||
// Returns a hash of all priceIds within the batch, it can be used to identify whether there is a
|
||||
// new batch with exact same symbols (and ignore the old one)
|
||||
export function getBatchAttestationHashKey(
|
||||
batchAttestation: BatchPriceAttestation
|
||||
): string {
|
||||
const priceIds: string[] = batchAttestation.priceAttestations.map(
|
||||
(priceAttestation) => priceAttestation.priceId
|
||||
);
|
||||
priceIds.sort();
|
||||
|
||||
return priceIds.join("#");
|
||||
}
|
||||
|
||||
export function getBatchSummary(batch: BatchPriceAttestation): string {
|
||||
const abstractRepresentation = {
|
||||
num_attestations: batch.priceAttestations.length,
|
||||
prices: batch.priceAttestations.map((priceAttestation) => {
|
||||
const priceFeed = priceAttestationToPriceFeed(priceAttestation);
|
||||
return {
|
||||
price_id: priceFeed.id,
|
||||
price: priceFeed.getPriceUnchecked().getPriceAsNumberUnchecked(),
|
||||
conf: priceFeed.getEmaPriceUnchecked().getConfAsNumberUnchecked(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
return JSON.stringify(abstractRepresentation);
|
||||
}
|
||||
|
||||
export function priceAttestationToPriceFeed(
|
||||
priceAttestation: PriceAttestation
|
||||
): PriceFeed {
|
||||
const emaPrice: Price = new Price({
|
||||
conf: priceAttestation.emaConf,
|
||||
expo: priceAttestation.expo,
|
||||
price: priceAttestation.emaPrice,
|
||||
publishTime: priceAttestation.publishTime,
|
||||
});
|
||||
|
||||
let price: Price;
|
||||
|
||||
if (priceAttestation.status === PriceAttestationStatus.Trading) {
|
||||
// 1 means trading
|
||||
price = new Price({
|
||||
conf: priceAttestation.conf,
|
||||
expo: priceAttestation.expo,
|
||||
price: priceAttestation.price,
|
||||
publishTime: priceAttestation.publishTime,
|
||||
});
|
||||
} else {
|
||||
price = new Price({
|
||||
conf: priceAttestation.prevConf,
|
||||
expo: priceAttestation.expo,
|
||||
price: priceAttestation.prevPrice,
|
||||
publishTime: priceAttestation.prevPublishTime,
|
||||
});
|
||||
|
||||
// emaPrice won't get updated if the status is unknown and hence it uses
|
||||
// the previous publish time
|
||||
emaPrice.publishTime = priceAttestation.prevPublishTime;
|
||||
}
|
||||
|
||||
return new PriceFeed({
|
||||
emaPrice,
|
||||
id: priceAttestation.priceId,
|
||||
price,
|
||||
});
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"rootDir": "src/",
|
||||
"outDir": "./lib"
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"extends": ["tslint:recommended", "tslint-config-prettier"],
|
||||
"linterOptions": {
|
||||
"exclude": ["src/proto/**"]
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue