[WIP] Pr/drozdziak1/p2w batching/5e704f8b (#877)

* ethereum: p2w contract -> p2w emitter, fill in essential envs

Change-Id: I6fa9364a96738d2cc02ec829a31fedba0586d8e8

commit-id:0a56f1f8

* Add p2w-relay, a p2w-sdk integration test

commit-id:6bfab639

* p2w-sdk: Expand README

Change-Id: I17cb547d6aaddc240588923561c26d11a787df2e

commit-id:6ebd6a22

* p2w-sdk: don't build ETH contracts, only the types

Change-Id: I7cbd18328227700635d7688aa24a9671e8919fcd

commit-id:adf079f7

* p2w: configurability and sane envs

commit-id:f10fd90e

* Solitaire: Implement Option<T> support in structs

commit-id:31aa12d6

* bridge/governance.rs: Stop pestering about EMITTER_ADDRESS

commit-id:d5bd7234

* p2w-attest: price batching

This commit introduces support for multiple Pyth product/price pairs
per call. The initial maximum batch size is 5 and is enforced using a
`P2W_MAX_BATCH_SIZE` constant.

solana/pyth2wormhole/program:
* On-chain batching logic
* Batch message parsing logic

solana/pyth2wormhole/client:
* Off-chain batching logic - divides any number of symbols into
largest possible batches
* Use a multi-symbol config file instead of CLI arguments

third_party/pyth/p2w-sdk:
* Expose batch parsing logic

third_party/pyth/p2w-relay:
* Comment out target chain calls until ETH contract supports batching
* Test the batch parsing function

third_party/pyth/p2w_autoattest.py:
* Generate and use the symbol config file  with pyth2wormhole-client

third_party/pyth/pyth_publisher.py:
* Add a configurable number of mock Pyth symbols
* Adjust HTTP endpoint for multiple symbols

commit-id:73787a61

* p2w-attest: mention attestation size in batch

This commit ensures that no matter the attestation format, a batch
will never contain attestations of different sizes. This guarantee
enables forward compatibility by adding new constant-size fields at
the end of a batch at all times. An older implementation will simply
not consume the remaining newer values while respecting the stated
batch member alignment.

commit-id:210da230

* pyth2wormhole-client: use fresh blockhashes, harden batch errors

This commit makes sure we don't have to deal with expired transactions
due to stale blockhashes. The problem existed with larger symbol
configs as well as on Solana mainnet. Additionally, the attestation logic
now treats transaction errors as non-critical - a failure for a batch
does not prevent attestation attempts for batches farther in the queue

commit-id:5e704f8b
This commit is contained in:
Stanisław Drozd 2022-02-23 19:12:16 +01:00 committed by GitHub
parent 3b10f124a1
commit 2ea41b8176
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 12437 additions and 3598 deletions

1
.envrc
View File

@ -1 +0,0 @@
eval "$(lorri direnv)"

View File

@ -89,7 +89,7 @@ local_resource(
local_resource(
name = "proto-gen-web",
deps = proto_deps,
deps = proto_deps + ["buf.gen.web.yaml"],
resource_deps = ["proto-gen"],
cmd = "tilt docker build -- --target node-export -f Dockerfile.proto -o type=local,dest=. .",
env = {"DOCKER_BUILDKIT": "1"},
@ -273,6 +273,13 @@ if pyth:
ignore = ["./solana/*/target"],
)
# Automatic pyth2wormhole relay, showcasing p2w-sdk
docker_build(
ref = "p2w-relay",
context = ".",
dockerfile = "./third_party/pyth/p2w-relay/Dockerfile",
)
k8s_yaml_with_ns("devnet/p2w-attest.yaml")
k8s_resource(
"p2w-attest",
@ -282,6 +289,13 @@ if pyth:
trigger_mode = trigger_mode,
)
k8s_yaml_with_ns("devnet/p2w-relay.yaml")
k8s_resource(
"p2w-relay",
resource_deps = ["solana-devnet", "eth-devnet", "pyth", "guardian", "p2w-attest", "proto-gen-web", "wasm-gen"],
port_forwards = [],
)
k8s_yaml_with_ns("devnet/eth-devnet.yaml")
k8s_resource(

View File

@ -37,6 +37,9 @@ spec:
command:
- python3
- /usr/src/pyth/p2w_autoattest.py
env:
- name: P2W_INITIALIZE_SOL_CONTRACT
value: "1"
tty: true
readinessProbe:
tcpSocket:

View File

@ -31,8 +31,9 @@ spec:
- name: p2w-relay
image: p2w-relay
command:
- node
- /usr/src/third_party/pyth/p2w-sdk/lib/autorelayer.js
- npm
- start
workingDir: /usr/src/third_party/pyth/p2w-relay/
tty: true
readinessProbe:
tcpSocket:

View File

@ -21,5 +21,5 @@ BRIDGE_INIT_WETH= # 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
PYTH_INIT_CHAIN_ID= # 0x2
PYTH_INIT_GOV_CHAIN_ID= # 0x3
PYTH_INIT_GOV_CONTRACT= # 0x0000000000000000000000000000000000000000000000000000000000000004
PYTH_TO_WORMHOLE_CHAIN_ID= # 0x5
PYTH_TO_WORMHOLE_CONTRACT= # 0x0000000000000000000000000000000000000000000000000000000000000006
PYTH_TO_WORMHOLE_CHAIN_ID= # 0x1
PYTH_TO_WORMHOLE_EMITTER= # 8fuAZUxHecYLMC76ZNjYzwRybUiDv9LhkRQsAccEykLr

View File

@ -14,5 +14,5 @@ BRIDGE_INIT_WETH=0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E
PYTH_INIT_CHAIN_ID=0x2
PYTH_INIT_GOV_CHAIN_ID=0x3
PYTH_INIT_GOV_CONTRACT=0x0000000000000000000000000000000000000000000000000000000000000004
PYTH_TO_WORMHOLE_CHAIN_ID=0x5
PYTH_TO_WORMHOLE_CONTRACT=0x0000000000000000000000000000000000000000000000000000000000000006
PYTH_TO_WORMHOLE_CHAIN_ID=0x1
PYTH_TO_WORMHOLE_EMITTER=8fuAZUxHecYLMC76ZNjYzwRybUiDv9LhkRQsAccEykLr

View File

@ -24,6 +24,6 @@ RUN --mount=type=cache,uid=1000,gid=1000,target=/home/node/.npm \
# Amusingly, Debian's coreutils version has a bug where mv believes that
# the target is on a different fs and does a full recursive copy for what
# could be a renameat syscall. Alpine does not have this bug.
RUN rmdir node_modules && mv node_modules_cache node_modules
RUN rm -rf node_modules && mv node_modules_cache node_modules
ADD --chown=node:node . .

View File

@ -34,7 +34,7 @@ contract Pyth is PythGovernance {
if (vm.emitterChainId != pyth2WormholeChainId()) {
return false;
}
if (vm.emitterAddress != pyth2WormholeContract()) {
if (vm.emitterAddress != pyth2WormholeEmitter()) {
return false;
}
return true;

View File

@ -36,11 +36,11 @@ contract PythGetters is PythState {
return _state.provider.pyth2WormholeChainId;
}
function pyth2WormholeContract() public view returns (bytes32){
return _state.provider.pyth2WormholeContract;
function pyth2WormholeEmitter() public view returns (bytes32){
return _state.provider.pyth2WormholeEmitter;
}
function latestAttestation(bytes32 product, uint8 priceType) public view returns (PythStructs.PriceAttestation memory attestation){
return _state.latestAttestations[product][priceType];
}
}
}

View File

@ -30,8 +30,8 @@ contract PythSetters is PythState {
_state.provider.pyth2WormholeChainId = chainId;
}
function setPyth2WormholeContract(bytes32 contractAddr) internal {
_state.provider.pyth2WormholeContract = contractAddr;
function setPyth2WormholeEmitter(bytes32 emitterAddr) internal {
_state.provider.pyth2WormholeEmitter = emitterAddr;
}
function setWormhole(address wh) internal {
@ -41,4 +41,4 @@ contract PythSetters is PythState {
function setLatestAttestation(bytes32 product, uint8 priceType, PythStructs.PriceAttestation memory attestation) internal {
_state.latestAttestations[product][priceType] = attestation;
}
}
}

View File

@ -19,7 +19,7 @@ contract PythSetup is PythSetters, ERC1967Upgrade {
bytes32 governanceContract,
uint16 pyth2WormholeChainId,
bytes32 pyth2WormholeContract
bytes32 pyth2WormholeEmitter
) public {
setChainId(chainId);
@ -29,7 +29,7 @@ contract PythSetup is PythSetters, ERC1967Upgrade {
setGovernanceContract(governanceContract);
setPyth2WormholeChainId(pyth2WormholeChainId);
setPyth2WormholeContract(pyth2WormholeContract);
setPyth2WormholeEmitter(pyth2WormholeEmitter);
_upgradeTo(implementation);
}

View File

@ -13,7 +13,7 @@ contract PythStorage {
bytes32 governanceContract;
uint16 pyth2WormholeChainId;
bytes32 pyth2WormholeContract;
bytes32 pyth2WormholeEmitter;
}
struct State {
@ -35,4 +35,4 @@ contract PythStorage {
contract PythState {
PythStorage.State _state;
}
}

View File

@ -0,0 +1 @@
myth like bonus scare over problem client lizard pioneer submit female collect

View File

@ -1,4 +1,5 @@
require('dotenv').config({ path: "../.env" });
const bs58 = require("bs58");
const PythDataBridge = artifacts.require("PythDataBridge");
const PythImplementation = artifacts.require("PythImplementation");
@ -9,7 +10,9 @@ const chainId = process.env.PYTH_INIT_CHAIN_ID;
const governanceChainId = process.env.PYTH_INIT_GOV_CHAIN_ID;
const governanceContract = process.env.PYTH_INIT_GOV_CONTRACT; // bytes32
const pyth2WormholeChainId = process.env.PYTH_TO_WORMHOLE_CHAIN_ID;
const pyth2WormholeContract = process.env.PYTH_TO_WORMHOLE_CONTRACT; // bytes32
const pyth2WormholeEmitter = bs58.decode(process.env.PYTH_TO_WORMHOLE_EMITTER); // base58, must fit into bytes32
console.log("Deploying Pyth with emitter", pyth2WormholeEmitter.toString("hex"))
module.exports = async function (deployer) {
// deploy implementation
@ -29,7 +32,7 @@ module.exports = async function (deployer) {
governanceContract,
pyth2WormholeChainId,
pyth2WormholeContract,
"0x" + pyth2WormholeEmitter.toString("hex"),
).encodeABI();
// deploy proxy

View File

@ -20,7 +20,7 @@ contract("Pyth", function () {
const testGovernanceChainId = "3";
const testGovernanceContract = "0x0000000000000000000000000000000000000000000000000000000000000004";
const testPyth2WormholeChainId = "5";
const testPyth2WormholeContract = "0x0000000000000000000000000000000000000000000000000000000000000006";
const testPyth2WormholeEmitter = "0x0000000000000000000000000000000000000000000000000000000000000006";
it("should be initialized with the correct signers and values", async function(){
@ -39,8 +39,8 @@ contract("Pyth", function () {
// pyth2wormhole
const pyth2wormChain = await initialized.methods.pyth2WormholeChainId().call();
assert.equal(pyth2wormChain, testPyth2WormholeChainId);
const pyth2wormContract = await initialized.methods.pyth2WormholeContract().call();
assert.equal(pyth2wormContract, testPyth2WormholeContract);
const pyth2wormEmitter = await initialized.methods.pyth2WormholeEmitter().call();
assert.equal(pyth2wormEmitter, testPyth2WormholeEmitter);
})
it("should accept a valid upgrade", async function() {
@ -132,7 +132,7 @@ contract("Pyth", function () {
1,
1,
testPyth2WormholeChainId,
testPyth2WormholeContract,
testPyth2WormholeEmitter,
0,
testUpdate,
[
@ -237,4 +237,4 @@ function zeroPadBytes(value, length) {
value = "0" + value;
}
return value;
}
}

View File

@ -1,7 +1,9 @@
use solitaire::*;
use solana_program::{
log::sol_log,
program::invoke_signed,
program_error::ProgramError,
pubkey::Pubkey,
sysvar::{
clock::Clock,
@ -40,7 +42,10 @@ fn verify_governance<'a, T>(vaa: &ClaimableVAA<'a, T>) -> Result<()>
where
T: DeserializePayload,
{
let expected_emitter = std::env!("EMITTER_ADDRESS");
let expected_emitter = std::option_env!("EMITTER_ADDRESS").ok_or_else(|| {
sol_log("EMITTER_ADDRESS not set at compile-time");
ProgramError::UninitializedAccount
})?;
let current_emitter = format!(
"{}",
Pubkey::new_from_array(vaa.message.meta().emitter_address)

View File

@ -1785,6 +1785,8 @@ dependencies = [
"env_logger 0.8.4",
"log",
"pyth2wormhole",
"serde",
"serde_yaml",
"shellexpand",
"solana-client",
"solana-program",

View File

@ -15,6 +15,8 @@ env_logger = "0.8.4"
log = "0.4.14"
wormhole-bridge-solana = {path = "../../bridge/program"}
pyth2wormhole = {path = "../program"}
serde = "1"
serde_yaml = "0.8"
shellexpand = "2.1.0"
solana-client = "=1.9.4"
solana-program = "=1.9.4"

View File

@ -0,0 +1,84 @@
use std::str::FromStr;
use serde::{
de::Error,
Deserialize,
Deserializer,
Serialize,
Serializer,
};
use solana_program::pubkey::Pubkey;
use solitaire::ErrBox;
/// Pyth2wormhole config specific to attestation requests
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct AttestationConfig {
pub symbols: Vec<P2WSymbol>,
}
/// Config entry for a Pyth product + price pair
#[derive(Debug, 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,
}
// 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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanity() -> Result<(), ErrBox> {
let initial = AttestationConfig {
symbols: vec![
P2WSymbol {
name: Some("ETH/USD".to_owned()),
product_addr: Default::default(),
price_addr: Default::default(),
},
P2WSymbol {
name: None,
product_addr: Pubkey::new(&[42u8; 32]),
price_addr: Default::default(),
},
],
};
let serialized = serde_yaml::to_string(&initial)?;
eprintln!("Serialized:\n{}", serialized);
let deserialized: AttestationConfig = serde_yaml::from_str(&serialized)?;
assert_eq!(initial, deserialized);
Ok(())
}
}

View File

@ -1,6 +1,7 @@
//! CLI options
use solana_program::pubkey::Pubkey;
use std::path::PathBuf;
use clap::Clap;
#[derive(Clap)]
@ -22,7 +23,7 @@ pub struct Cli {
default_value = "~/.config/solana/id.json"
)]
pub payer: String,
#[clap(long, default_value = "http://localhost:8899")]
#[clap(short, long, default_value = "http://localhost:8899")]
pub rpc_url: String,
#[clap(long)]
pub p2w_addr: Pubkey,
@ -35,23 +36,19 @@ pub enum Action {
#[clap(about = "Initialize a pyth2wormhole program freshly deployed under <p2w_addr>")]
Init {
/// The bridge program account
#[clap(long = "wh-prog")]
#[clap(short = 'w', long = "wh-prog")]
wh_prog: Pubkey,
#[clap(long = "owner")]
#[clap(short = 'o', long = "owner")]
owner_addr: Pubkey,
#[clap(long = "pyth-owner")]
#[clap(short = 'p', long = "pyth-owner")]
pyth_owner_addr: Pubkey,
},
#[clap(
about = "Use an existing pyth2wormhole program to attest product price information to another chain"
)]
Attest {
#[clap(long = "product")]
product_addr: Pubkey,
#[clap(long = "price")]
price_addr: Pubkey,
#[clap(long)]
nonce: u32,
#[clap(short = 'f', long = "--config", about = "Attestation YAML config")]
attestation_cfg: PathBuf,
},
#[clap(about = "Update an existing pyth2wormhole program's settings (currently set owner only)")]
SetConfig {

View File

@ -0,0 +1,38 @@
#[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(())
}
}

View File

@ -1,11 +1,23 @@
pub mod attestation_cfg;
pub mod cli;
use std::{
fs::File,
path::{
Path,
PathBuf,
},
};
use borsh::{
BorshDeserialize,
BorshSerialize,
};
use clap::Clap;
use log::{
debug,
error,
info,
warn,
LevelFilter,
};
@ -59,7 +71,10 @@ use bridge::{
};
use pyth2wormhole::{
attest::P2WEmitter,
attest::{
P2WEmitter,
P2W_MAX_BATCH_SIZE,
},
config::P2WConfigAccount,
initialize::InitializeAccounts,
set_config::SetConfigAccounts,
@ -68,6 +83,8 @@ use pyth2wormhole::{
Pyth2WormholeConfig,
};
use crate::attestation_cfg::AttestationConfig;
pub type ErrBox = Box<dyn std::error::Error>;
pub const SEQNO_PREFIX: &'static str = "Program log: Sequence: ";
@ -81,65 +98,49 @@ fn main() -> Result<(), ErrBox> {
let p2w_addr = cli.p2w_addr;
let (recent_blockhash, _) = rpc_client.get_recent_blockhash()?;
let latest_blockhash = rpc_client.get_latest_blockhash()?;
let tx = match cli.action {
match cli.action {
Action::Init {
owner_addr,
pyth_owner_addr,
wh_prog,
} => handle_init(
payer,
p2w_addr,
owner_addr,
wh_prog,
pyth_owner_addr,
recent_blockhash,
)?,
} => {
let tx = handle_init(
payer,
p2w_addr,
owner_addr,
wh_prog,
pyth_owner_addr,
latest_blockhash,
)?;
rpc_client.send_and_confirm_transaction_with_spinner(&tx)?;
}
Action::SetConfig {
ref owner,
new_owner_addr,
new_wh_prog,
new_pyth_owner_addr,
} => handle_set_config(
payer,
p2w_addr,
read_keypair_file(&*shellexpand::tilde(&owner))?,
new_owner_addr,
new_wh_prog,
new_pyth_owner_addr,
recent_blockhash,
)?,
} => {
let tx = handle_set_config(
payer,
p2w_addr,
read_keypair_file(&*shellexpand::tilde(&owner))?,
new_owner_addr,
new_wh_prog,
new_pyth_owner_addr,
latest_blockhash,
)?;
rpc_client.send_and_confirm_transaction_with_spinner(&tx)?;
}
Action::Attest {
product_addr,
price_addr,
nonce,
} => handle_attest(
&rpc_client,
payer,
p2w_addr,
product_addr,
price_addr,
nonce,
recent_blockhash,
)?,
};
ref attestation_cfg,
} => {
// Load the attestation config yaml
let attestation_cfg: AttestationConfig =
serde_yaml::from_reader(File::open(attestation_cfg)?)?;
let sig = rpc_client.send_and_confirm_transaction_with_spinner(&tx)?;
// To complete attestation, retrieve sequence number from transaction logs
if let Action::Attest { .. } = cli.action {
let this_tx = rpc_client.get_transaction(&sig, UiTransactionEncoding::Json)?;
if let Some(logs) = this_tx.transaction.meta.and_then(|meta| meta.log_messages) {
for log in logs {
if log.starts_with(SEQNO_PREFIX) {
let seqno = log.replace(SEQNO_PREFIX, "");
println!("Sequence number: {}", seqno);
}
}
} else {
warn!("Could not get program logs for attestation");
handle_attest(&rpc_client, payer, p2w_addr, &attestation_cfg)?;
}
}
@ -152,7 +153,7 @@ fn handle_init(
new_owner_addr: Pubkey,
wh_prog: Pubkey,
pyth_owner_addr: Pubkey,
recent_blockhash: Hash,
latest_blockhash: Hash,
) -> Result<Transaction, ErrBox> {
use AccEntry::*;
@ -164,6 +165,7 @@ fn handle_init(
};
let config = Pyth2WormholeConfig {
max_batch_size: P2W_MAX_BATCH_SIZE,
owner: new_owner_addr,
wh_prog: wh_prog,
pyth_owner: pyth_owner_addr,
@ -176,7 +178,7 @@ fn handle_init(
&[ix],
Some(&payer_pubkey),
signers.iter().collect::<Vec<_>>().as_ref(),
recent_blockhash,
latest_blockhash,
);
Ok(tx_signed)
}
@ -188,14 +190,12 @@ fn handle_set_config(
new_owner_addr: Pubkey,
new_wh_prog: Pubkey,
new_pyth_owner_addr: Pubkey,
recent_blockhash: Hash,
latest_blockhash: Hash,
) -> Result<Transaction, ErrBox> {
use AccEntry::*;
let payer_pubkey = payer.pubkey();
println!("Canary!");
let accs = SetConfigAccounts {
payer: Signer(payer),
current_owner: Signer(owner),
@ -203,6 +203,7 @@ fn handle_set_config(
};
let config = Pyth2WormholeConfig {
max_batch_size: P2W_MAX_BATCH_SIZE,
owner: new_owner_addr,
wh_prog: new_wh_prog,
pyth_owner: new_pyth_owner_addr,
@ -215,30 +216,28 @@ fn handle_set_config(
&[ix],
Some(&payer_pubkey),
signers.iter().collect::<Vec<_>>().as_ref(),
recent_blockhash,
latest_blockhash,
);
Ok(tx_signed)
}
fn handle_attest(
rpc: &RpcClient, // Needed for reading Pyth account data
rpc_client: &RpcClient, // Needed for reading Pyth account data
payer: Keypair,
p2w_addr: Pubkey,
product_addr: Pubkey,
price_addr: Pubkey,
nonce: u32,
recent_blockhash: Hash,
) -> Result<Transaction, ErrBox> {
let message_keypair = Keypair::new();
attestation_cfg: &AttestationConfig,
) -> Result<(), ErrBox> {
// Derive seeded accounts
let emitter_addr = P2WEmitter::key(None, &p2w_addr);
info!("Using emitter addr {}", emitter_addr);
let p2w_config_addr = P2WConfigAccount::<{ AccountState::Initialized }>::key(None, &p2w_addr);
let config =
Pyth2WormholeConfig::try_from_slice(rpc.get_account_data(&p2w_config_addr)?.as_slice())?;
let config = Pyth2WormholeConfig::try_from_slice(
rpc_client.get_account_data(&p2w_config_addr)?.as_slice(),
)?;
// Derive dynamic seeded accounts
let seq_addr = Sequence::key(
&SequenceDerivationData {
emitter_key: &emitter_addr,
@ -246,58 +245,167 @@ fn handle_attest(
&config.wh_prog,
);
// Arrange Attest accounts
let 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),
// pyth_product
AccountMeta::new_readonly(product_addr, false),
// pyth_price
AccountMeta::new_readonly(price_addr, false),
// clock
AccountMeta::new_readonly(clock::id(), false),
// wh_prog
AccountMeta::new_readonly(config.wh_prog, false),
// wh_bridge
AccountMeta::new(
Bridge::<{ AccountState::Initialized }>::key(None, &config.wh_prog),
false,
),
// wh_message
AccountMeta::new(message_keypair.pubkey(), true),
// wh_emitter
AccountMeta::new_readonly(emitter_addr, false),
// wh_sequence
AccountMeta::new(seq_addr, false),
// wh_fee_collector
AccountMeta::new(FeeCollector::<'_>::key(None, &config.wh_prog), false),
AccountMeta::new_readonly(rent::id(), false),
];
// Read the current max batch size from the contract's settings
let max_batch_size = config.max_batch_size;
let ix_data = (
pyth2wormhole::instruction::Instruction::Attest,
AttestData {
nonce,
consistency_level: ConsistencyLevel::Finalized,
},
let batch_count = {
let whole_batches = attestation_cfg.symbols.len() / config.max_batch_size as usize;
// Include partial batch if there is a remainder
if attestation_cfg.symbols.len() % config.max_batch_size as usize > 0 {
whole_batches + 1
} else {
whole_batches
}
};
debug!("Symbol config:\n{:#?}", attestation_cfg);
info!(
"{} symbols read, max batch size {}, dividing into {} batches",
attestation_cfg.symbols.len(),
max_batch_size,
batch_count
);
let ix = Instruction::new_with_bytes(p2w_addr, ix_data.try_to_vec()?.as_slice(), acc_metas);
for (idx, symbols) in attestation_cfg
.symbols
.as_slice()
.chunks(max_batch_size as usize)
.enumerate()
{
let batch_no = idx + 1;
let sym_msg_keypair = Keypair::new();
info!(
"Batch {}/{} contents: {:?}",
batch_no,
batch_count,
symbols
.iter()
.map(|s| s
.name
.clone()
.unwrap_or(format!("unnamed product {:?}", s.product_addr)))
.collect::<Vec<_>>()
);
// Signers that use off-chain keypairs
let signer_keypairs = vec![&payer, &message_keypair];
let mut sym_metas_vec: Vec<_> = symbols
.iter()
.map(|s| {
vec![
AccountMeta::new_readonly(s.product_addr, false),
AccountMeta::new_readonly(s.price_addr, false),
]
})
.flatten()
.collect();
let tx_signed = Transaction::new_signed_with_payer::<Vec<&Keypair>>(
&[ix],
Some(&payer.pubkey()),
&signer_keypairs,
recent_blockhash,
);
Ok(tx_signed)
// Align to max batch size with null accounts
let mut blank_accounts =
vec![
AccountMeta::new_readonly(Pubkey::new_from_array([0u8; 32]), false);
2 * (max_batch_size as usize - symbols.len())
];
sym_metas_vec.append(&mut blank_accounts);
// Arrange 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),
];
// Insert max_batch_size metas
acc_metas.append(&mut sym_metas_vec);
// Continue with other pyth2wormhole accounts
let mut acc_metas_remainder = vec![
// clock
AccountMeta::new_readonly(clock::id(), false),
// wh_prog
AccountMeta::new_readonly(config.wh_prog, false),
// wh_bridge
AccountMeta::new(
Bridge::<{ AccountState::Initialized }>::key(None, &config.wh_prog),
false,
),
// wh_message
AccountMeta::new(sym_msg_keypair.pubkey(), true),
// wh_emitter
AccountMeta::new_readonly(emitter_addr, false),
// wh_sequence
AccountMeta::new(seq_addr, false),
// wh_fee_collector
AccountMeta::new(FeeCollector::<'_>::key(None, &config.wh_prog), false),
AccountMeta::new_readonly(rent::id(), false),
];
acc_metas.append(&mut acc_metas_remainder);
let ix_data = (
pyth2wormhole::instruction::Instruction::Attest,
AttestData {
consistency_level: ConsistencyLevel::Finalized,
},
);
let ix = Instruction::new_with_bytes(p2w_addr, ix_data.try_to_vec()?.as_slice(), acc_metas);
// Execute the transaction, obtain the resulting sequence
// number. The and_then() calls enforce error handling
// location near loop end.
let res = rpc_client
.get_latest_blockhash()
.and_then(|latest_blockhash| {
let tx_signed = Transaction::new_signed_with_payer::<Vec<&Keypair>>(
&[ix],
Some(&payer.pubkey()),
&vec![&payer, &sym_msg_keypair],
latest_blockhash,
);
rpc_client.send_and_confirm_transaction_with_spinner(&tx_signed)
})
.and_then(|sig| rpc_client.get_transaction(&sig, UiTransactionEncoding::Json))
.map_err(|e| -> ErrBox { e.into() })
.and_then(|this_tx| {
this_tx
.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(|| format!("No seqno in program logs").into())
});
// Individual batch errors mustn't prevent other batches from being sent.
match res {
Ok(seqno) => {
println!("Sequence number: {}", seqno);
info!("Batch {}/{}: OK, seqno {}", batch_no, batch_count, seqno);
}
Err(e) => {
error!(
"Batch {}/{} tx error: {}",
batch_no,
batch_count,
e.to_string()
);
}
}
}
Ok(())
}
fn init_logging(verbosity: u32) {

View File

@ -1,6 +1,6 @@
use crate::{
config::P2WConfigAccount,
types::PriceAttestation,
types::{PriceAttestation, batch_serialize},
};
use borsh::{
BorshDeserialize,
@ -28,6 +28,7 @@ use bridge::{
};
use solitaire::{
invoke_seeded,
trace,
AccountState,
Derive,
@ -40,7 +41,6 @@ use solitaire::{
Peel,
Result as SoliResult,
Seeded,
invoke_seeded,
Signer,
SolitaireError,
Sysvar,
@ -49,20 +49,66 @@ use solitaire::{
pub type P2WEmitter<'b> = Derive<Info<'b>, "p2w-emitter">;
/// Important: must be manually maintained until native Solitaire
/// variable len vector support.
///
/// The number must reflect how many pyth product/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, ToInstruction)]
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 product/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_product: Info<'b>,
pub pyth_price: Info<'b>,
pub pyth_product2: Option<Info<'b>>,
pub pyth_price2: Option<Info<'b>>,
pub pyth_product3: Option<Info<'b>>,
pub pyth_price3: Option<Info<'b>>,
pub pyth_product4: Option<Info<'b>>,
pub pyth_price4: Option<Info<'b>>,
pub pyth_product5: Option<Info<'b>>,
pub pyth_price5: Option<Info<'b>>,
// Did you read the comment near `pyth_product`?
// pub pyth_product6: Option<Info<'b>>,
// pub pyth_price6: Option<Info<'b>>,
// pub pyth_product7: Option<Info<'b>>,
// pub pyth_price7: Option<Info<'b>>,
// pub pyth_product8: Option<Info<'b>>,
// pub pyth_price8: Option<Info<'b>>,
// pub pyth_product9: Option<Info<'b>>,
// pub pyth_price9: Option<Info<'b>>,
// pub pyth_product10: Option<Info<'b>>,
// pub pyth_price10: Option<Info<'b>>,
pub clock: Sysvar<'b, Clock>,
// post_message accounts
/// Wormhole program address
/// Wormhole program address - must match the config value
pub wh_prog: Info<'b>,
// wormhole's post_message 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>>,
@ -85,7 +131,6 @@ pub struct Attest<'b> {
#[derive(BorshDeserialize, BorshSerialize)]
pub struct AttestData {
pub nonce: u32,
pub consistency_level: ConsistencyLevel,
}
@ -98,16 +143,6 @@ impl<'b> InstructionContext<'b> for Attest<'b> {
pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> SoliResult<()> {
accs.config.verify_derivation(ctx.program_id, None)?;
if accs.config.pyth_owner != *accs.pyth_price.owner
|| accs.config.pyth_owner != *accs.pyth_product.owner
{
trace!(&format!(
"pyth_owner pubkey mismatch (expected {:?}, got price owner {:?} and product owner {:?}",
accs.config.pyth_owner, accs.pyth_price.owner, accs.pyth_product.owner
));
return Err(SolitaireError::InvalidOwner(accs.pyth_price.owner.clone()).into());
}
if accs.config.wh_prog != *accs.wh_prog.key {
trace!(&format!(
"Wormhole program account mismatch (expected {:?}, got {:?})",
@ -115,20 +150,91 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
));
}
let price_attestation = PriceAttestation::from_pyth_price_bytes(
accs.pyth_price.key.clone(),
accs.clock.unix_timestamp,
&*accs.pyth_price.try_borrow_data()?,
)?;
// Make the specified prices iterable
let price_pair_opts = [
Some(&accs.pyth_product),
Some(&accs.pyth_price),
accs.pyth_product2.as_ref(),
accs.pyth_price2.as_ref(),
accs.pyth_product3.as_ref(),
accs.pyth_price3.as_ref(),
accs.pyth_product4.as_ref(),
accs.pyth_price4.as_ref(),
accs.pyth_product5.as_ref(),
accs.pyth_price5.as_ref(),
if &price_attestation.product_id != accs.pyth_product.key {
// Did you read the comment near `pyth_product`?
// accs.pyth_product6.as_ref(),
// accs.pyth_price6.as_ref(),
// accs.pyth_product7.as_ref(),
// accs.pyth_price7.as_ref(),
// accs.pyth_product8.as_ref(),
// accs.pyth_price8.as_ref(),
// accs.pyth_product9.as_ref(),
// accs.pyth_price9.as_ref(),
// accs.pyth_product10.as_ref(),
// accs.pyth_price10.as_ref(),
];
let price_pairs: Vec<_> = price_pair_opts.into_iter().filter_map(|acc| *acc).collect();
if price_pairs.len() % 2 != 0 {
trace!(&format!(
"Price's product_id does not match the pased account (points at {:?} instead)",
price_attestation.product_id
"Uneven product/price count detected: {}",
price_pairs.len()
));
return Err(ProgramError::InvalidAccountData.into());
}
trace!("{} Pyth symbols received", price_pairs.len() / 2);
// Collect the validated symbols for batch serialization
let mut attestations = Vec::with_capacity(price_pairs.len() / 2);
for pair in price_pairs.as_slice().chunks_exact(2) {
let product = pair[0];
let price = pair[1];
if accs.config.pyth_owner != *price.owner
|| accs.config.pyth_owner != *product.owner
{
trace!(&format!(
"Pair {:?} - {:?}: pyth_owner pubkey mismatch (expected {:?}, got product owner {:?} and price owner {:?}",
product, price,
accs.config.pyth_owner, product.owner, price.owner
));
return Err(SolitaireError::InvalidOwner(accs.pyth_price.owner.clone()).into());
}
let attestation = PriceAttestation::from_pyth_price_bytes(
price.key.clone(),
accs.clock.unix_timestamp,
&*price.try_borrow_data()?,
)?;
// The following check is crucial against poorly ordered
// account inputs, e.g. [Some(prod1), Some(price1),
// Some(prod2), None, None, Some(price)], interpreted by
// earlier logic as [(prod1, price1), (prod2, price3)].
//
// Failing to verify the product/price relationship could lead
// to mismatched product/price metadata, which would result in
// a false attestation.
if &attestation.product_id != product.key {
trace!(&format!(
"Price's product_id does not match the pased account (points at {:?} instead)",
attestation.product_id
));
return Err(ProgramError::InvalidAccountData.into());
}
attestations.push(attestation);
}
trace!("Attestations successfully created");
let bridge_config = BridgeData::try_from_slice(&accs.wh_bridge.try_borrow_mut_data()?)?.config;
// Pay wormhole fee
@ -143,9 +249,12 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
let post_message_data = (
bridge::instruction::Instruction::PostMessage,
PostMessageData {
nonce: data.nonce,
payload: price_attestation.serialize(),
consistency_level: data.consistency_level,
nonce: 0, // Superseded by the sequence number
payload: batch_serialize(attestations.as_slice().iter()).map_err(|e| {
trace!(e.to_string());
ProgramError::InvalidAccountData
})?,
consistency_level: data.consistency_level,
},
);

View File

@ -1,3 +1,10 @@
//! On-chain state for the pyth2wormhole SOL contract.
//!
//! Important: A config init/update should be performed on every
//! deployment/upgrade of this Solana program. Doing so prevents
//! problems related to max batch size mismatches between config and
//! contract logic. See attest.rs for details.
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::pubkey::Pubkey;
use solitaire::{processors::seeded::AccountOwner, AccountState, Data, Derive, Owned};
@ -10,6 +17,12 @@ pub struct Pyth2WormholeConfig {
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,
}
impl Owned for Pyth2WormholeConfig {

View File

@ -1,11 +1,21 @@
//! Constants and values common to every p2w custom-serialized message.
//!
//! The format makes no attempt to provide human-readable symbol names
//! in favor of explicit product/price Solana account addresses
//! (IDs). This choice was made to disambiguate any symbols with
//! similar human-readable names and provide a failsafe for some of
//! the probable adversarial scenarios.
pub mod pyth_extensions;
use std::{
borrow::Borrow,
convert::{
TryFrom,
TryInto,
},
io::Read,
iter::Iterator,
mem,
};
@ -37,26 +47,34 @@ use self::pyth_extensions::{
P2WPriceType,
};
// Constants and values common to every p2w custom-serialized message
/// Precedes every message implementing the p2w serialization format
pub const P2W_MAGIC: &'static [u8] = b"P2WH";
/// Format version used and understood by this codebase
pub const P2W_FORMAT_VERSION: u16 = 1;
pub const P2W_FORMAT_VERSION: u16 = 2;
pub const PUBKEY_LEN: usize = 32;
/// Decides the format of following bytes
#[repr(u8)]
pub enum PayloadId {
PriceAttestation = 1,
PriceAttestation = 1, // Not in use, currently batch attestations imply PriceAttestation messages inside
PriceBatchAttestation,
}
// On-chain data types
/// The main attestation data type.
///
/// Important: For maximum security, *both* product_id and price_id
/// should be used as storage keys for known attestations in target
/// chain logic.
#[derive(Clone, Default, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "wasm", derive(serde_derive::Serialize, serde_derive::Deserialize))]
#[cfg_attr(
feature = "wasm",
derive(serde_derive::Serialize, serde_derive::Deserialize)
)]
pub struct PriceAttestation {
pub product_id: Pubkey,
pub price_id: Pubkey,
@ -71,6 +89,123 @@ pub struct PriceAttestation {
pub timestamp: UnixTimestamp,
}
/// Turn a bunch of attestations into a combined payload.
///
/// Batches assume constant-size attestations within a single batch.
pub fn batch_serialize(
attestations: impl Iterator<Item = impl Borrow<PriceAttestation>>,
) -> Result<Vec<u8>, ErrBox> {
// magic
let mut buf = P2W_MAGIC.to_vec();
// version
buf.extend_from_slice(&P2W_FORMAT_VERSION.to_be_bytes()[..]);
// payload_id
buf.push(PayloadId::PriceBatchAttestation as u8);
let collected: Vec<_> = attestations.collect();
// n_attestations
buf.extend_from_slice(&(collected.len() as u16).to_be_bytes()[..]);
let mut attestation_size = 0; // Will be determined as we serialize attestations
let mut serialized_attestations = Vec::with_capacity(collected.len());
for (idx, a) in collected.iter().enumerate() {
// Learn the current attestation's size
let serialized = PriceAttestation::serialize(a.borrow());
let a_len = serialized.len();
// Verify it's the same as the first one we saw for the batch, assign if we're first.
if attestation_size > 0 {
if a_len != attestation_size {
return Err(format!(
"attestation {} serializes to {} bytes, {} expected",
idx + 1,
a_len,
attestation_size
)
.into());
}
} else {
attestation_size = a_len;
}
serialized_attestations.push(serialized);
}
// attestation_size
buf.extend_from_slice(&(attestation_size as u16).to_be_bytes()[..]);
for mut s in serialized_attestations.into_iter() {
buf.append(&mut s)
}
Ok(buf)
}
/// Undo `batch_serialize`
pub fn batch_deserialize(mut bytes: impl Read) -> Result<Vec<PriceAttestation>, ErrBox> {
let mut magic_vec = vec![0u8; P2W_MAGIC.len()];
bytes.read_exact(magic_vec.as_mut_slice())?;
if magic_vec.as_slice() != P2W_MAGIC {
return Err(format!(
"Invalid magic {:02X?}, expected {:02X?}",
magic_vec, P2W_MAGIC,
)
.into());
}
let mut version_vec = vec![0u8; mem::size_of_val(&P2W_FORMAT_VERSION)];
bytes.read_exact(version_vec.as_mut_slice())?;
let version = u16::from_be_bytes(version_vec.as_slice().try_into()?);
if version != P2W_FORMAT_VERSION {
return Err(format!(
"Unsupported format version {}, expected {}",
version, P2W_FORMAT_VERSION
)
.into());
}
let mut payload_id_vec = vec![0u8; mem::size_of::<PayloadId>()];
bytes.read_exact(payload_id_vec.as_mut_slice())?;
if payload_id_vec[0] != PayloadId::PriceBatchAttestation as u8 {
return Err(format!(
"Invalid Payload ID {}, expected {}",
payload_id_vec[0],
PayloadId::PriceBatchAttestation as u8,
)
.into());
}
let mut batch_len_vec = vec![0u8; 2];
bytes.read_exact(batch_len_vec.as_mut_slice())?;
let batch_len = u16::from_be_bytes(batch_len_vec.as_slice().try_into()?);
let mut attestation_size_vec = vec![0u8; 2];
bytes.read_exact(attestation_size_vec.as_mut_slice())?;
let attestation_size = u16::from_be_bytes(attestation_size_vec.as_slice().try_into()?);
let mut ret = Vec::with_capacity(batch_len as usize);
for i in 0..batch_len {
let mut attestation_buf = vec![0u8; attestation_size as usize];
bytes.read_exact(attestation_buf.as_mut_slice())?;
dbg!(&attestation_buf.len());
match PriceAttestation::deserialize(attestation_buf.as_slice()) {
Ok(attestation) => ret.push(attestation),
Err(e) => return Err(format!("PriceAttestation {}/{}: {}", i + 1, batch_len, e).into()),
}
}
Ok(ret)
}
impl PriceAttestation {
pub fn from_pyth_price_bytes(
price_id: Pubkey,
@ -161,7 +296,6 @@ impl PriceAttestation {
use P2WPriceStatus::*;
use P2WPriceType::*;
println!("Using {} bytes for magic", P2W_MAGIC.len());
let mut magic_vec = vec![0u8; P2W_MAGIC.len()];
bytes.read_exact(magic_vec.as_mut_slice())?;
@ -176,7 +310,7 @@ impl PriceAttestation {
let mut version_vec = vec![0u8; mem::size_of_val(&P2W_FORMAT_VERSION)];
bytes.read_exact(version_vec.as_mut_slice())?;
let mut version = u16::from_be_bytes(version_vec.as_slice().try_into()?);
let version = u16::from_be_bytes(version_vec.as_slice().try_into()?);
if version != P2W_FORMAT_VERSION {
return Err(format!(
@ -217,20 +351,21 @@ impl PriceAttestation {
};
let mut price_vec = vec![0u8; mem::size_of::<i64>()];
bytes.read_exact(price_vec.as_mut_slice())?;
let price = i64::from_be_bytes(price_vec.as_slice().try_into()?);
bytes.read_exact(price_vec.as_mut_slice())?;
let price = i64::from_be_bytes(price_vec.as_slice().try_into()?);
let mut expo_vec = vec![0u8; mem::size_of::<i32>()];
bytes.read_exact(expo_vec.as_mut_slice())?;
let expo = i32::from_be_bytes(expo_vec.as_slice().try_into()?);
bytes.read_exact(expo_vec.as_mut_slice())?;
let expo = i32::from_be_bytes(expo_vec.as_slice().try_into()?);
let twap = P2WEma::deserialize(&mut bytes)?;
let twac = P2WEma::deserialize(&mut bytes)?;
let twap = P2WEma::deserialize(&mut bytes)?;
let twac = P2WEma::deserialize(&mut bytes)?;
println!("twac OK");
println!("twac OK");
let mut confidence_interval_vec = vec![0u8; mem::size_of::<u64>()];
bytes.read_exact(confidence_interval_vec.as_mut_slice())?;
let confidence_interval = u64::from_be_bytes(confidence_interval_vec.as_slice().try_into()?);
bytes.read_exact(confidence_interval_vec.as_mut_slice())?;
let confidence_interval =
u64::from_be_bytes(confidence_interval_vec.as_slice().try_into()?);
let mut status_vec = vec![0u8; mem::size_of::<P2WPriceType>()];
bytes.read_exact(status_vec.as_mut_slice())?;
@ -244,7 +379,6 @@ impl PriceAttestation {
}
};
let mut corp_act_vec = vec![0u8; mem::size_of::<P2WPriceType>()];
bytes.read_exact(corp_act_vec.as_mut_slice())?;
let corp_act = match corp_act_vec[0] {
@ -254,23 +388,23 @@ impl PriceAttestation {
}
};
let mut timestamp_vec = vec![0u8; mem::size_of::<UnixTimestamp>()];
bytes.read_exact(timestamp_vec.as_mut_slice())?;
let timestamp = UnixTimestamp::from_be_bytes(timestamp_vec.as_slice().try_into()?);
let mut timestamp_vec = vec![0u8; mem::size_of::<UnixTimestamp>()];
bytes.read_exact(timestamp_vec.as_mut_slice())?;
let timestamp = UnixTimestamp::from_be_bytes(timestamp_vec.as_slice().try_into()?);
Ok( Self {
product_id,
price_id,
price_type,
price,
expo,
twap,
twac,
confidence_interval,
status,
corp_act,
timestamp
})
Ok(Self {
product_id,
price_id,
price_type,
price,
expo,
twap,
twac,
confidence_interval,
status,
corp_act,
timestamp,
})
}
}
@ -405,38 +539,10 @@ mod tests {
};
}
#[test]
fn test_parse_pyth_price_wrong_size_slices() {
assert!(parse_pyth_price(&[]).is_err());
assert!(parse_pyth_price(vec![0u8; 1].as_slice()).is_err());
}
#[test]
fn test_normal_values() -> SoliResult<()> {
let price = Price {
expo: 5,
agg: PriceInfo {
price: 42,
..empty_priceinfo!()
},
..empty_price!()
};
let price_vec = vec![price];
// use the C repr to mock pyth's format
let (_, bytes, _) = unsafe { price_vec.as_slice().align_to::<u8>() };
parse_pyth_price(bytes)?;
Ok(())
}
#[test]
fn test_serialize_deserialize() -> Result<(), ErrBox> {
let product_id_bytes = [21u8; 32];
let price_id_bytes = [222u8; 32];
println!("Hex product_id: {:02X?}", &product_id_bytes);
println!("Hex price_id: {:02X?}", &price_id_bytes);
let attestation: PriceAttestation = PriceAttestation {
fn mock_attestation(prod: Option<[u8; 32]>, price: Option<[u8; 32]>) -> PriceAttestation {
let product_id_bytes = prod.unwrap_or([21u8; 32]);
let price_id_bytes = prod.unwrap_or([222u8; 32]);
PriceAttestation {
product_id: Pubkey::new_from_array(product_id_bytes),
price_id: Pubkey::new_from_array(price_id_bytes),
price: (0xdeadbeefdeadbabe as u64) as i64,
@ -456,14 +562,93 @@ mod tests {
confidence_interval: 101,
corp_act: P2WCorpAction::NoCorpAct,
timestamp: 123456789i64,
}
}
#[test]
fn test_parse_pyth_price_wrong_size_slices() {
assert!(parse_pyth_price(&[]).is_err());
assert!(parse_pyth_price(vec![0u8; 1].as_slice()).is_err());
}
#[test]
fn test_parse_pyth_price() -> SoliResult<()> {
let price = Price {
expo: 5,
agg: PriceInfo {
price: 42,
..empty_priceinfo!()
},
..empty_price!()
};
let price_vec = vec![price];
// use the C repr to mock pyth's format
let (_, bytes, _) = unsafe { price_vec.as_slice().align_to::<u8>() };
parse_pyth_price(bytes)?;
Ok(())
}
#[test]
fn test_attestation_serde() -> Result<(), ErrBox> {
let product_id_bytes = [21u8; 32];
let price_id_bytes = [222u8; 32];
let attestation: PriceAttestation =
mock_attestation(Some(product_id_bytes), Some(price_id_bytes));
println!("Hex product_id: {:02X?}", &product_id_bytes);
println!("Hex price_id: {:02X?}", &price_id_bytes);
println!("Regular: {:#?}", &attestation);
println!("Hex: {:#02X?}", &attestation);
let bytes = attestation.serialize();
let bytes = attestation.serialize();
println!("Hex Bytes: {:02X?}", bytes);
assert_eq!(PriceAttestation::deserialize(bytes.as_slice())?, attestation);
assert_eq!(
PriceAttestation::deserialize(bytes.as_slice())?,
attestation
);
Ok(())
}
#[test]
fn test_attestation_serde_wrong_size() -> Result<(), ErrBox> {
assert!(PriceAttestation::deserialize(&[][..]).is_err());
assert!(PriceAttestation::deserialize(vec![0u8; 1].as_slice()).is_err());
Ok(())
}
#[test]
fn test_batch_serde() -> Result<(), ErrBox> {
let attestations: Vec<_> = (0..65535)
.map(|i| mock_attestation(Some([(i % 256) as u8; 32]), None))
.collect();
let serialized = batch_serialize(attestations.iter())?;
let deserialized = batch_deserialize(serialized.as_slice())?;
assert_eq!(attestations, deserialized);
Ok(())
}
#[test]
fn test_batch_serde_wrong_size() -> Result<(), ErrBox> {
assert!(batch_deserialize(&[][..]).is_err());
assert!(batch_deserialize(vec![0u8; 1].as_slice()).is_err());
let attestations: Vec<_> = (0..20)
.map(|i| mock_attestation(Some([(i % 256) as u8; 32]), None))
.collect();
let serialized = batch_serialize(attestations.iter())?;
// Missing last byte in last attestation must be an error
let len = serialized.len();
assert!(batch_deserialize(&serialized.as_slice()[..len - 1]).is_err());
Ok(())
}
}

View File

@ -4,14 +4,7 @@ use wasm_bindgen::prelude::*;
use std::str::FromStr;
use crate::{attest::P2WEmitter, types::PriceAttestation};
/// sanity check for wasm compilation, TODO(sdrozd): remove after
/// meaningful endpoints are added
#[wasm_bindgen]
pub fn hello_p2w() -> String {
"Ciao mondo!".to_owned()
}
use crate::{attest::P2WEmitter, types};
#[wasm_bindgen]
pub fn get_emitter_address(program_id: String) -> Vec<u8> {
@ -23,7 +16,15 @@ pub fn get_emitter_address(program_id: String) -> Vec<u8> {
#[wasm_bindgen]
pub fn parse_attestation(bytes: Vec<u8>) -> JsValue {
let a = PriceAttestation::deserialize(bytes.as_slice()).unwrap();
let a = types::PriceAttestation::deserialize(bytes.as_slice()).unwrap();
JsValue::from_serde(&a).unwrap()
}
#[wasm_bindgen]
pub fn parse_batch_attestation(bytes: Vec<u8>) -> JsValue {
let a = types::batch_deserialize(bytes.as_slice()).unwrap();
JsValue::from_serde(&a).unwrap()
}

View File

@ -66,6 +66,9 @@ pub enum AccEntry {
Derived(Pubkey),
/// Key derived from constants and/or program address, read-only.
DerivedRO(Pubkey),
/// Empty value for nullables
Empty,
}
/// Types implementing Wrap are those that can be turned into a
@ -85,6 +88,15 @@ pub trait Wrap {
}
}
impl<T: Wrap> Wrap for Option<T> {
fn wrap(a: &AccEntry) -> StdResult<Vec<AccountMeta>, ErrBox> {
match a {
AccEntry::Empty => Ok(vec![AccountMeta::new_readonly(Pubkey::new_from_array([0u8; 32]), false)]),
other => T::wrap(other)
}
}
}
impl<'a, 'b: 'a, T> Wrap for Signer<T>
where
T: Keyed<'a, 'b>,

View File

@ -17,6 +17,7 @@ use solana_program::{
use std::marker::PhantomData;
use crate::{
trace,
processors::seeded::{
AccountOwner,
Owned,
@ -41,6 +42,32 @@ pub trait Peel<'a, 'b: 'a, 'c> {
fn persist(&self, program_id: &Pubkey) -> Result<()>;
}
/// Peel a nullable value (0-account means None)
impl<'a, 'b: 'a, 'c, T: Peel<'a, 'b, 'c>> Peel<'a, 'b, 'c> for Option<T> {
fn peel<I>(ctx: &'c mut Context<'a, 'b, 'c, I>) -> Result<Self> {
// Check for 0-account
if ctx.info().key == &Pubkey::new_from_array([0u8; 32]) {
trace!(&format!("Peeled {} is None, returning", std::any::type_name::<Option<T>>()));
Ok(None)
} else {
Ok(Some(T::peel(ctx)?))
}
}
fn deps() -> Vec<Pubkey> {
T::deps()
}
fn persist(&self, program_id: &Pubkey) -> Result<()> {
if let Some(s) = self.as_ref() {
T::persist(s, program_id)
} else {
trace!(&format!("Peeled {} is None, not persisting", std::any::type_name::<Option<T>>()));
Ok(())
}
}
}
/// Peel a Derived Key
impl<'a, 'b: 'a, 'c, T: Peel<'a, 'b, 'c>, const Seed: &'static str> Peel<'a, 'b, 'c>
for Derive<T, Seed>

View File

@ -16,3 +16,4 @@ ENV P2W_OWNER_KEYPAIR="/usr/src/solana/keys/p2w_owner.json"
ENV P2W_ATTESTATIONS_PORT="4343"
ENV PYTH_PUBLISHER_KEYPAIR="/usr/src/solana/keys/pyth_publisher.json"
ENV PYTH_PROGRAM_KEYPAIR="/usr/src/solana/keys/pyth_program.json"
ENV SOL_AIRDROP_AMT="100"

View File

@ -44,3 +44,4 @@ USER pyth
ENV PYTH=$PYTH_SRC_ROOT/build/pyth
ENV READINESS_PORT=2000
ENV SOL_AIRDROP_AMT=100

33
third_party/pyth/p2w-relay/.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# 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

40
third_party/pyth/p2w-relay/Dockerfile vendored Normal file
View File

@ -0,0 +1,40 @@
FROM node:16-alpine@sha256:004dbac84fed48e20f9888a23e32fa7cf83c2995e174a78d41d9a9dd1e051a20
# npm needs a Python for some of the deps
RUN apk add git python3 make build-base
# Build ETH
WORKDIR /usr/src/ethereum
ADD ethereum .
RUN --mount=type=cache,target=/home/node/.npm \
npm ci
# Build Wormhole SDK
WORKDIR /usr/src/sdk/js
ADD sdk/js/ .
RUN --mount=type=cache,target=/home/node/.npm \
npm ci && npm run build
# Build p2w-sdk in dir preserving directory structure
WORKDIR /usr/src/third_party/pyth/p2w-sdk
COPY third_party/pyth/p2w-sdk/package.json third_party/pyth/p2w-sdk/package-lock.json .
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/root/.npm \
npm ci
COPY third_party/pyth/p2w-sdk .
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/root/.npm \
npm run build
# Build p2w-relay
WORKDIR /usr/src/third_party/pyth/p2w-relay
COPY third_party/pyth/p2w-relay/package.json third_party/pyth/p2w-relay/package-lock.json .
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/root/.npm \
npm ci
COPY third_party/pyth/p2w-relay .
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/root/.npm \
npm run build

24
third_party/pyth/p2w-relay/README.md vendored Normal file
View File

@ -0,0 +1,24 @@
# Pyth2wormhole relay example
IMPORTANT: This is not ready for production.
This package is an example Pyth2wormhole relayer implementation. The
main focus is to provide an automated integration test that will
perform last-mile delivery of Pyth2wormhole price attestations.
# How it works
## Relayer recap
When attesting with Wormhole, the final step consists of a query for
the guardian-signed attestation data on the guardian public RPC,
followed by posting the data to each desired target chain
contract. Each target chain contract lets callers verify the payload's
signatures, thus proving its validity. This activity means being
a Wormhole **relayer**.
## How this package relays attestations
`p2w-relay` is a Node.js relayer script targeting ETH that will
periodically query its source-chain counterpart for new sequence
numbers to query from the guardians. Any pending sequence numbers will
stick around in a global state until their corresponding messages are
successfully retrieved from the guardians. Later, target chain calls
are made and a given seqno is deleted from the pool. Failed target
chain calls will not be retried.

5038
third_party/pyth/p2w-relay/package-lock.json generated vendored Normal file

File diff suppressed because it is too large Load Diff

53
third_party/pyth/p2w-relay/package.json vendored Normal file
View File

@ -0,0 +1,53 @@
{
"name": "@certusone/p2w-relay",
"version": "0.1.0",
"description": "p2w-sdk integration test; not intended for production use",
"private": true,
"types": "lib/index.d.ts",
"main": "lib/index.js",
"files": [
"lib/**/*"
],
"scripts": {
"start": "node -r esm lib/index.js",
"build": "npm run build-eth-types && npm run build-lib",
"build-lib": "npm run copy-artifacts && tsc",
"build-watch": "npm run build-eth-types && npm run copy-artifacts && tsc --watch",
"build-eth-types": "node scripts/copyEthContracts.cjs && typechain --target=ethers-v5 --out-dir=src/ethers-contracts contracts/*.json",
"copy-artifacts": "node scripts/copyWasm.cjs && node scripts/copyEthersTypes.cjs",
"lint": "tslint -p tsconfig.json",
"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/certusone/wormhole.git"
},
"author": "https://certus.one",
"license": "MIT",
"devDependencies": {
"@openzeppelin/contracts": "^4.2.0",
"@typechain/ethers-v5": "^7.1.2",
"@types/long": "^4.0.1",
"@types/node": "^16.6.1",
"copy-dir": "^1.3.0",
"esm": "^3.2.25",
"ethers": "^5.4.7",
"find": "^0.3.0",
"prettier": "^2.3.2",
"ts-loader": "^9.2.5",
"tslint": "^6.1.3",
"tslint-config-prettier": "^1.18.0",
"typescript": "^4.3.5"
},
"dependencies": {
"@certusone/p2w-sdk": "file:../p2w-sdk",
"@certusone/wormhole-sdk": "file:../../../sdk/js",
"@improbable-eng/grpc-web-node-http-transport": "^0.14.1"
},
"bugs": {
"url": "https://github.com/certusone/wormhole/issues"
},
"homepage": "https://github.com/certusone/wormhole#readme"
}

View File

@ -0,0 +1,2 @@
const copydir = require("copy-dir");
copydir.sync("../../../ethereum/build/contracts", "./contracts");

View File

@ -0,0 +1,17 @@
const find = require("find");
const fs = require("fs");
const path = require("path");
const SOURCE_ROOT = "src";
const TARGET_ROOT = "lib";
find.eachfile(/\.d\.ts(\..*)?/, SOURCE_ROOT, fname => {
fname_copy = fname.replace(SOURCE_ROOT, TARGET_ROOT);
console.log("copying types:", fname, "to", fname_copy);
fs.mkdirSync(path.dirname(fname_copy), {recursive: true});
fs.copyFileSync(fname, fname_copy);
});

View File

@ -0,0 +1,17 @@
const find = require("find");
const fs = require("fs");
const path = require("path");
const SOURCE_ROOT = "src";
const TARGET_ROOT = "lib";
find.eachfile(/\.wasm(\..*)?/, SOURCE_ROOT, fname => {
fname_copy = fname.replace(SOURCE_ROOT, TARGET_ROOT);
console.log("copyWasm:", fname, "to", fname_copy);
fs.mkdirSync(path.dirname(fname_copy), {recursive: true});
fs.copyFileSync(fname, fname_copy);
});

198
third_party/pyth/p2w-relay/src/index.ts vendored Normal file
View File

@ -0,0 +1,198 @@
import { NodeHttpTransport } from "@improbable-eng/grpc-web-node-http-transport";
import {PythImplementation__factory} from "./ethers-contracts";
import * as http from "http";
import * as net from "net";
import fs from "fs";
import {ethers} from "ethers";
import {getSignedAttestation, parseBatchAttestation, p2w_core, sol_addr2buf} from "@certusone/p2w-sdk";
import {setDefaultWasm, importCoreWasm} from "@certusone/wormhole-sdk/lib/cjs/solana/wasm";
interface NewAttestationsResponse {
pendingSeqnos: Array<number>,
}
async function readinessProbeRoutine(port: number) {
let srv = net.createServer();
return await srv.listen(port);
}
(async () => {
// p2w-attest exposes an HTTP endpoint that shares the currently pending sequence numbers
const P2W_ATTESTATIONS_HOST = process.env.P2W_ATTESTATIONS_HOST || "p2w-attest";
const P2W_ATTESTATIONS_PORT = Number(process.env.P2W_ATTESTATIONS_PORT || "4343");
const P2W_ATTESTATIONS_POLL_INTERVAL_MS = Number(process.env.P2W_ATTESTATIONS_POLL_INTERVAL_MS || "5000");
const P2W_SOL_ADDRESS = process.env.P2W_SOL_ADDRESS || "P2WH424242424242424242424242424242424242424";
const READINESS_PROBE_PORT = Number(process.env.READINESS_PROBE_PORT || "2000");
const P2W_RELAY_RETRY_COUNT = Number(process.env.P2W_RELAY_RETRY_COUNT || "3");
// ETH node connection details; Currently, we expect to read BIP44
// wallet recovery mnemonics from a text file.
const ETH_NODE_URL = process.env.ETH_NODE_URL || "ws://eth-devnet:8545";
const ETH_P2W_CONTRACT = process.env.ETH_P2W_CONTRACT || "0xA94B7f0465E98609391C623d0560C5720a3f2D33";
const ETH_MNEMONIC_FILE = process.env.ETH_MNEMONIC_FILE || "../../../ethereum/devnet_mnemonic.txt";
const ETH_HD_WALLET_PATH = process.env.ETH_HD_WALLET_PATH || "m/44'/60'/0'/0/0";
// Public RPC address for use with signed attestation queries
const GUARDIAN_RPC_HOST_PORT = process.env.GUARDIAN_RPC_HOST_PORT || "http://guardian:7071";
let readinessProbe = null;
let seqnoPool: Map<number, number> = new Map();
console.log(`Polling attestations endpoint every ${P2W_ATTESTATIONS_POLL_INTERVAL_MS / 1000} seconds`);
setDefaultWasm("node");
const {parse_vaa} = await importCoreWasm();
let p2w_eth: any;
// Connect to ETH
try {
let provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
let mnemonic: string = fs.readFileSync(ETH_MNEMONIC_FILE).toString("utf-8").trim();
let wallet = ethers.Wallet.fromMnemonic(mnemonic, ETH_HD_WALLET_PATH);
console.log(`Using ETH wallet pubkey: ${wallet.publicKey}`);
let signer = new ethers.Wallet(wallet.privateKey, provider);
let balance = await signer.getBalance();
console.log(`Account balance is ${balance}`);
let factory = new PythImplementation__factory(signer);
p2w_eth = factory.attach(ETH_P2W_CONTRACT);
}
catch(e) {
console.error(`Error: Could not instantiate ETH contract:`, e);
throw e;
}
while (true) {
http.get({
hostname: P2W_ATTESTATIONS_HOST,
port: P2W_ATTESTATIONS_PORT,
path: "/",
agent: false
}, (res) => {
if (res.statusCode != 200) {
console.error("Could not reach attestations endpoint", res);
} else {
let chunks: string[] = [];
res.setEncoding("utf-8");
res.on('data', (chunk) => {
chunks.push(chunk);
});
res.on('end', () => {
let body = chunks.join('');
let response: NewAttestationsResponse = JSON.parse(body);
console.log(`Got ${response.pendingSeqnos.length} new seqnos: ${response.pendingSeqnos}`);
for (let seqno of response.pendingSeqnos) {
seqnoPool.set(seqno, 0);
}
});
}
}).on('error', (e) => {
console.error(`Got error: ${e.message}`);
});
console.log("Processing seqnos:", seqnoPool);
for (let poolEntry of seqnoPool) {
let seqno = poolEntry[0];
let attempts = poolEntry[1];
if (attempts >= P2W_RELAY_RETRY_COUNT) {
console.warn(`[seqno ${poolEntry}] Exceeded retry count, removing from list`);
seqnoPool.delete(seqno);
continue;
}
let vaaResponse: any;
try {
vaaResponse = await getSignedAttestation(
GUARDIAN_RPC_HOST_PORT,
P2W_SOL_ADDRESS,
seqno,
{
transport: NodeHttpTransport()
}
);
}
catch(e) {
console.error(`[seqno ${poolEntry}] Error: Could not call getSignedAttestation:`, e);
seqnoPool.set(seqno, attempts + 1);
continue;
}
console.log(`[seqno ${poolEntry}] Price attestation VAA bytes:\n`, vaaResponse.vaaBytes);
let parsedVaa = parse_vaa(vaaResponse.vaaBytes);
console.log(`[seqno ${poolEntry}] Parsed VAA:\n`, parsedVaa);
let parsedAttestations = await parseBatchAttestation(parsedVaa.payload);
console.log(`[seqno ${poolEntry}] Parsed ${parsedAttestations.length} price attestations:\n`, parsedAttestations);
// try {
// let tx = await p2w_eth.attestPrice(vaaResponse.vaaBytes, {gasLimit: 1000000});
// let retval = await tx.wait();
// console.log(`[seqno ${poolEntry}] attestPrice() output:\n`, retval);
// } catch(e) {
// console.error(`[seqno ${poolEntry}, {parsedAttestations.length} symbols] Error: Could not call attestPrice() on ETH:`, e);
// seqnoPool.set(seqno, attempts + 1);
// continue;
// }
console.warn("TODO: implement relayer ETH call");
// for (let att of parsedAttestations) {
// let product_id = att.product_id;
// let price_type = att.price_type == "Price" ? 1 : 0;
// let latest_attestation: any;
// try {
// let p2w = await p2w_core();
// console.log(`Looking up latestAttestation for `, product_id, price_type);
// latest_attestation = await p2w_eth.latestAttestation(product_id, price_type);
// } catch(e) {
// console.error(`[seqno ${poolEntry}] Error: Could not call latestAttestation() on ETH:`, e);
// seqnoPool.set(seqno, attempts + 1);
// continue;
// }
// console.log(`[seqno ${poolEntry}] Latest price type ${price_type} attestation of ${product_id} is ${latest_attestation}`);
// }
if (!readinessProbe) {
console.log(`[seqno ${poolEntry}] Attestation successful. Starting readiness probe.`);
readinessProbe = readinessProbeRoutine(READINESS_PROBE_PORT);
}
seqnoPool.delete(seqno); // Everything went well, seqno no longer pending.
}
await new Promise(f => {setTimeout(f, P2W_ATTESTATIONS_POLL_INTERVAL_MS);});
}
})();

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"declaration": true,
"outDir": "./lib",
"strict": true,
"esModuleInterop": true,
"downlevelIteration": true,
"allowJs": true,
},
"include": ["src", "types"],
"exclude": ["node_modules", "**/__tests__/*"]
}

View File

@ -0,0 +1,9 @@
{
"extends": ["tslint:recommended", "tslint-config-prettier"],
"linterOptions": {
"exclude": [
"src/proto/**"
]
}
}

View File

@ -24,7 +24,7 @@ yarn-error.log*
# ethereum contracts
/contracts
/src/ethers-contracts
/src/*-contracts/
# tsproto output
/src/proto

View File

@ -2,7 +2,14 @@
This project contains a library for interacting with pyth2wormhole and adjacent APIs.
# Install
Firstly, please follow instructions in `//bridge_ui/README.md` where
`//` is the Wormhole project root.
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-relay`'s Dockerfile and/or our [Tilt](https://tilt.dev)
devnet with `pyth` enabled.
# Usage
```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
```

File diff suppressed because it is too large Load Diff

View File

@ -8,12 +8,14 @@
"lib/**/*"
],
"scripts": {
"build": "tsc && node scripts/copyWasm.js",
"build-test": "webpack",
"build": "npm run build-eth-types && npm run build-lib",
"build-eth-types": "node scripts/copyEthContracts.cjs && typechain --target=ethers-v5 --out-dir=src/ethers-contracts contracts/*.json",
"build-lib": "npm run copy-artifacts && tsc",
"build-watch": "npm run copy-artifacts && tsc --watch",
"copy-artifacts": "node scripts/copyWasm.cjs && node scripts/copyEthersTypes.cjs",
"lint": "tslint -p tsconfig.json",
"postversion": "git push && git push --tags",
"preversion": "npm run lint",
"test": "node lib/test.js",
"version": "npm run format && git add -A src"
},
"repository": {
@ -24,22 +26,22 @@
"license": "MIT",
"devDependencies": {
"@openzeppelin/contracts": "^4.2.0",
"@typechain/ethers-v5": "^7.0.1",
"@typechain/ethers-v5": "^7.1.2",
"@types/long": "^4.0.1",
"@types/node": "^16.6.1",
"copy-dir": "^1.3.0",
"ethers": "^5.4.4",
"find": "^0.3.0",
"prettier": "^2.3.2",
"ts-loader": "^9.2.5",
"tslint": "^6.1.3",
"tslint-config-prettier": "^1.18.0",
"typescript": "^4.3.5",
"webpack-cli": "^4.8.0"
"typescript": "^4.3.5"
},
"peerDependencies": {
"@solana/web3.js": "^1.24.0"
},
"dependencies": {
"@certusone/wormhole-sdk": "file:../../../sdk/js",
"@solana/web3.js": "^1.26.0"
"@improbable-eng/grpc-web-node-http-transport": "^0.14.1"
},
"bugs": {
"url": "https://github.com/certusone/wormhole/issues"

View File

@ -0,0 +1,2 @@
const copydir = require("copy-dir");
copydir.sync("../../../ethereum/build/contracts", "./contracts");

View File

@ -0,0 +1,17 @@
const find = require("find");
const fs = require("fs");
const path = require("path");
const SOURCE_ROOT = "src";
const TARGET_ROOT = "lib";
find.eachfile(/\.d\.ts(\..*)?/, SOURCE_ROOT, fname => {
fname_copy = fname.replace(SOURCE_ROOT, TARGET_ROOT);
console.log("copying types:", fname, "to", fname_copy);
fs.mkdirSync(path.dirname(fname_copy), {recursive: true});
fs.copyFileSync(fname, fname_copy);
});

View File

@ -0,0 +1,17 @@
const find = require("find");
const fs = require("fs");
const path = require("path");
const SOURCE_ROOT = "src";
const TARGET_ROOT = "lib";
find.eachfile(/\.wasm(\..*)?/, SOURCE_ROOT, fname => {
fname_copy = fname.replace(SOURCE_ROOT, TARGET_ROOT);
console.log("copyWasm:", fname, "to", fname_copy);
fs.mkdirSync(path.dirname(fname_copy), {recursive: true});
fs.copyFileSync(fname, fname_copy);
});

View File

@ -1,14 +0,0 @@
const find = require("find");
const fs = require("fs");
const SOURCE_ROOT = "src";
const TARGET_ROOT = "lib";
find.eachfile(/\.wasm(\..*)?/, SOURCE_ROOT, file => {
copy = file.replace(SOURCE_ROOT, TARGET_ROOT);
console.log("copyWasm:", file, "to", copy);
fs.copyFileSync(file, copy);
});

View File

@ -1,10 +1,42 @@
// import {Connection, PublicKey, SystemProgram} from "@solana/web3.js";
import { ixFromRust} from "@certusone/wormhole-sdk";
import { getSignedVAA, CHAIN_ID_SOLANA} from "@certusone/wormhole-sdk";
import { zeroPad } from "ethers/lib/utils";
import { PublicKey} from "@solana/web3.js";
async function p2wHello() {
const p2w = await import("./solana/p2w-core/pyth2wormhole");
let s = p2w.hello_p2w();
console.log(s);
var P2W_INSTANCE: any = undefined;
// Import p2w wasm bindings; be smart about it
export async function p2w_core(): Promise<any> {
// Only import once if P2W wasm is needed
if (!P2W_INSTANCE) {
P2W_INSTANCE = await import("./solana/p2w-core/pyth2wormhole");
}
return P2W_INSTANCE;
}
p2wHello();
export function sol_addr2buf(addr: string): Buffer {
return Buffer.from(zeroPad(new PublicKey(addr).toBytes(), 32));
}
export async function getSignedAttestation(host: string, p2w_addr: string, sequence: number, extraGrpcOpts = {}): Promise<any>
{
const p2w = await p2w_core();
let emitter = p2w.get_emitter_address(p2w_addr);
let emitterHex = sol_addr2buf(emitter).toString("hex");
return await getSignedVAA(host, CHAIN_ID_SOLANA, emitterHex, "" + sequence, extraGrpcOpts);
}
export async function parseAttestation(vaa_payload: Uint8Array): Promise<any> {
const p2w = await p2w_core();
return await p2w.parse_attestation(vaa_payload);
}
export async function parseBatchAttestation(vaa_payload: Uint8Array): Promise<any> {
const p2w = await p2w_core();
console.log("p2w.parse_batch_attestaion is", p2w.parse_batch_attestation);
return await p2w.parse_batch_attestation(vaa_payload);
}

View File

@ -6,10 +6,12 @@
"declaration": true,
"outDir": "./lib",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"downlevelIteration": true,
"allowJs": true,
},
"types": [],
"include": ["src", "types"],
"exclude": ["node_modules", "**/__tests__/*"]
}

View File

@ -1,26 +0,0 @@
const path = require('path');
module.exports = {
entry: './src/index.ts',
experiments: {
asyncWebAssembly: true,
},
mode: 'development',
target: 'node',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'test.js',
path: path.resolve(__dirname, 'lib'),
},
};

View File

@ -1,44 +1,56 @@
#!/usr/bin/env python3
# This script sets up a simple loop for periodical attestation of Pyth data
from pyth_utils import *
from http.client import HTTPConnection
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import logging
import os
import re
import subprocess
import time
import sys
import threading
import time
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, HTTPServer
from pyth_utils import *
P2W_ADDRESS = "P2WH424242424242424242424242424242424242424"
logging.basicConfig(
level=logging.DEBUG, format="%(asctime)s | %(module)s | %(levelname)s | %(message)s"
)
P2W_SOL_ADDRESS = os.environ.get(
"P2W_SOL_ADDRESS", "P2WH424242424242424242424242424242424242424"
)
P2W_ATTEST_INTERVAL = float(os.environ.get("P2W_ATTEST_INTERVAL", 5))
P2W_OWNER_KEYPAIR = os.environ.get(
"P2W_OWNER_KEYPAIR", f"/usr/src/solana/keys/p2w_owner.json")
"P2W_OWNER_KEYPAIR", "/usr/src/solana/keys/p2w_owner.json"
)
P2W_ATTESTATIONS_PORT = int(os.environ.get("P2W_ATTESTATIONS_PORT", 4343))
P2W_INITIALIZE_SOL_CONTRACT = os.environ.get("P2W_INITIALIZE_SOL_CONTRACT", None)
PYTH_ACCOUNTS_HOST = "pyth"
PYTH_ACCOUNTS_PORT = 4242
PYTH_TEST_ACCOUNTS_HOST = "pyth"
PYTH_TEST_ACCOUNTS_PORT = 4242
WORMHOLE_ADDRESS = "Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o"
P2W_ATTESTATION_CFG = os.environ.get("P2W_ATTESTATION_CFG", None)
WORMHOLE_ADDRESS = os.environ.get(
"WORMHOLE_ADDRESS", "Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o"
)
ATTESTATIONS = {
"pendingSeqnos": [],
}
class P2WAutoattestStatusEndpoint(BaseHTTPRequestHandler):
"""
A dumb endpoint for last attested price metadata.
"""
def do_GET(self):
print(f"Got path {self.path}")
logging.info(f"Got path {self.path}")
sys.stdout.flush()
data = json.dumps(ATTESTATIONS).encode("utf-8")
print(f"Sending:\n{data}")
logging.debug(f"Sending: {data}")
ATTESTATIONS["pendingSeqnos"] = []
@ -49,91 +61,144 @@ class P2WAutoattestStatusEndpoint(BaseHTTPRequestHandler):
self.wfile.write(data)
self.wfile.flush()
def serve_attestations():
"""
Run a barebones HTTP server to share Pyth2wormhole attestation history
"""
server_address = ('', P2W_ATTESTATIONS_PORT)
server_address = ("", P2W_ATTESTATIONS_PORT)
httpd = HTTPServer(server_address, P2WAutoattestStatusEndpoint)
httpd.serve_forever()
if SOL_AIRDROP_AMT > 0:
# Fund the p2w owner
sol_run_or_die("airdrop", [
str(SOL_AIRDROP_AMT),
"--keypair", P2W_OWNER_KEYPAIR,
"--commitment", "finalized",
])
# Get actor pubkeys
P2W_OWNER_ADDRESS = sol_run_or_die(
"address", ["--keypair", P2W_OWNER_KEYPAIR], capture_output=True).stdout.strip()
PYTH_OWNER_ADDRESS = sol_run_or_die(
"address", ["--keypair", PYTH_PROGRAM_KEYPAIR], capture_output=True).stdout.strip()
if P2W_INITIALIZE_SOL_CONTRACT is not None:
# Get actor pubkeys
P2W_OWNER_ADDRESS = sol_run_or_die(
"address", ["--keypair", P2W_OWNER_KEYPAIR], capture_output=True
).stdout.strip()
PYTH_OWNER_ADDRESS = sol_run_or_die(
"address", ["--keypair", PYTH_PROGRAM_KEYPAIR], capture_output=True
).stdout.strip()
init_result = run_or_die(
[
"pyth2wormhole-client",
"--log-level",
"4",
"--p2w-addr",
P2W_SOL_ADDRESS,
"--rpc-url",
SOL_RPC_URL,
"--payer",
P2W_OWNER_KEYPAIR,
"init",
"--wh-prog",
WORMHOLE_ADDRESS,
"--owner",
P2W_OWNER_ADDRESS,
"--pyth-owner",
PYTH_OWNER_ADDRESS,
],
capture_output=True,
die=False,
)
# Top up pyth2wormhole owner
sol_run_or_die("airdrop", [
str(SOL_AIRDROP_AMT),
"--keypair", P2W_OWNER_KEYPAIR,
"--commitment", "finalized",
], capture_output=True)
if init_result.returncode != 0:
logging.error(
"NOTE: pyth2wormhole-client init failed, retrying with set_config"
)
run_or_die(
[
"pyth2wormhole-client",
"--log-level",
"4",
"--p2w-addr",
P2W_SOL_ADDRESS,
"--rpc-url",
SOL_RPC_URL,
"--payer",
P2W_OWNER_KEYPAIR,
"set-config",
"--owner",
P2W_OWNER_KEYPAIR,
"--new-owner",
P2W_OWNER_ADDRESS,
"--new-wh-prog",
WORMHOLE_ADDRESS,
"--new-pyth-owner",
PYTH_OWNER_ADDRESS,
],
capture_output=True,
)
# Initialize pyth2wormhole
init_result = run_or_die([
"pyth2wormhole-client",
"--log-level", "4",
"--p2w-addr", P2W_ADDRESS,
"--rpc-url", SOL_RPC_URL,
"--payer", P2W_OWNER_KEYPAIR,
"init",
"--wh-prog", WORMHOLE_ADDRESS,
"--owner", P2W_OWNER_ADDRESS,
"--pyth-owner", PYTH_OWNER_ADDRESS,
], capture_output=True, die=False)
# Retrieve available symbols from the test pyth publisher if not provided in envs
if P2W_ATTESTATION_CFG is None:
P2W_ATTESTATION_CFG = "./attestation_cfg_test.yaml"
conn = HTTPConnection(PYTH_TEST_ACCOUNTS_HOST, PYTH_TEST_ACCOUNTS_PORT)
if init_result.returncode != 0:
print("NOTE: pyth2wormhole-client init failed, retrying with set_config")
run_or_die([
conn.request("GET", "/")
res = conn.getresponse()
pyth_accounts = None
if res.getheader("Content-Type") == "application/json":
pyth_accounts = json.load(res)
else:
logging.error("Bad Content type")
sys.exit(1)
cfg_yaml = f"""
---
symbols:"""
logging.info(f"Retrieved {len(pyth_accounts)} Pyth accounts from endpoint: {pyth_accounts}")
for acc in pyth_accounts:
name = acc["name"]
price = acc["price"]
product = acc["product"]
cfg_yaml += f"""
- name: {name}
price_addr: {price}
product_addr: {product}"""
with open(P2W_ATTESTATION_CFG, "w") as f:
f.write(cfg_yaml)
f.flush()
attest_result = run_or_die(
[
"pyth2wormhole-client",
"--log-level", "4",
"--p2w-addr", P2W_ADDRESS,
"--rpc-url", SOL_RPC_URL,
"--payer", P2W_OWNER_KEYPAIR,
"set-config",
"--owner", P2W_OWNER_KEYPAIR,
"--new-owner", P2W_OWNER_ADDRESS,
"--new-wh-prog", WORMHOLE_ADDRESS,
"--new-pyth-owner", PYTH_OWNER_ADDRESS,
], capture_output=True)
"--log-level",
"4",
"--p2w-addr",
P2W_SOL_ADDRESS,
"--rpc-url",
SOL_RPC_URL,
"--payer",
P2W_OWNER_KEYPAIR,
"attest",
"-f",
P2W_ATTESTATION_CFG
],
capture_output=True,
)
# Retrieve current price/product pubkeys from the pyth publisher
conn = HTTPConnection(PYTH_ACCOUNTS_HOST, PYTH_ACCOUNTS_PORT)
conn.request("GET", "/")
res = conn.getresponse()
pyth_accounts = None
if res.getheader("Content-Type") == "application/json":
pyth_accounts = json.load(res)
else:
print(f"Bad Content type {res.getheader('Content-Type')}", file=sys.stderr)
sys.exit(1)
price_addr = pyth_accounts["price"]
product_addr = pyth_accounts["product"]
nonce = 0
attest_result = run_or_die([
"pyth2wormhole-client",
"--log-level", "4",
"--p2w-addr", P2W_ADDRESS,
"--rpc-url", SOL_RPC_URL,
"--payer", P2W_OWNER_KEYPAIR,
"attest",
"--price", price_addr,
"--product", product_addr,
"--nonce", str(nonce),
], capture_output=True)
print("p2w_autoattest ready to roll.")
print(f"ACCOUNTS: {pyth_accounts}")
print(f"Attest Interval: {P2W_ATTEST_INTERVAL}")
logging.info("p2w_autoattest ready to roll!")
logging.info(f"Attest Interval: {P2W_ATTEST_INTERVAL}")
# Serve p2w endpoint
endpoint_thread = threading.Thread(target=serve_attestations, daemon=True)
@ -143,34 +208,32 @@ endpoint_thread.start()
readiness_thread = threading.Thread(target=readiness, daemon=True)
readiness_thread.start()
seqno_regex = re.compile(r"^Sequence number: (\d+)")
seqno_regex = re.compile(r"Sequence number: (\d+)")
nonce = 1
while True:
attest_result = run_or_die([
"pyth2wormhole-client",
"--log-level", "4",
"--p2w-addr", P2W_ADDRESS,
"--rpc-url", SOL_RPC_URL,
"--payer", P2W_OWNER_KEYPAIR,
"attest",
"--price", price_addr,
"--product", product_addr,
"--nonce", str(nonce),
], capture_output=True)
matches = seqno_regex.findall(attest_result.stdout)
seqnos = list(map(lambda m: int(m), matches))
ATTESTATIONS["pendingSeqnos"] += seqnos
logging.info(f"{len(seqnos)} batch seqno(s) received: {seqnos})")
attest_result = run_or_die(
[
"pyth2wormhole-client",
"--log-level",
"4",
"--p2w-addr",
P2W_SOL_ADDRESS,
"--rpc-url",
SOL_RPC_URL,
"--payer",
P2W_OWNER_KEYPAIR,
"attest",
"-f",
P2W_ATTESTATION_CFG
],
capture_output=True,
)
time.sleep(P2W_ATTEST_INTERVAL)
matches = seqno_regex.match(attest_result.stdout)
if matches is not None:
seqno = int(matches.group(1))
print(f"Got seqno {seqno}")
ATTESTATIONS["pendingSeqnos"].append(seqno)
else:
print(f"Warning: Could not get sequence number")
nonce += 1
readiness_thread.join()

View File

@ -5,11 +5,13 @@ from pyth_utils import *
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import os
import random
import sys
import threading
import time
PYTH_TEST_SYMBOL_COUNT = int(os.environ.get("PYTH_TEST_SYMBOL_COUNT", "9"))
class PythAccEndpoint(BaseHTTPRequestHandler):
"""
@ -19,7 +21,7 @@ class PythAccEndpoint(BaseHTTPRequestHandler):
def do_GET(self):
print(f"Got path {self.path}")
sys.stdout.flush()
data = json.dumps(ACCOUNTS).encode("utf-8")
data = json.dumps(TEST_SYMBOLS).encode("utf-8")
print(f"Sending:\n{data}")
self.send_response(200)
@ -30,7 +32,7 @@ class PythAccEndpoint(BaseHTTPRequestHandler):
self.wfile.flush()
ACCOUNTS = dict()
TEST_SYMBOLS = []
def publisher_random_update(price_pubkey):
@ -65,35 +67,50 @@ sol_run_or_die("airdrop", [
# Create a mapping
pyth_run_or_die("init_mapping")
# Add a product
prod_pubkey = pyth_run_or_die(
"add_product", capture_output=True).stdout.strip()
print(f"Added product {prod_pubkey}")
# Add a price
price_pubkey = pyth_run_or_die(
"add_price",
args=[prod_pubkey, "price"],
confirm=False,
capture_output=True
).stdout.strip()
print(f"Added price {price_pubkey}")
print(f"Creating {PYTH_TEST_SYMBOL_COUNT} test Pyth symbols")
publisher_pubkey = sol_run_or_die("address", args=[
"--keypair", PYTH_PUBLISHER_KEYPAIR
], capture_output=True).stdout.strip()
# Become a publisher
pyth_run_or_die(
"add_publisher", args=[publisher_pubkey, price_pubkey],
confirm=False,
debug=True,
capture_output=True)
print(f"Added publisher {publisher_pubkey}")
for i in range(PYTH_TEST_SYMBOL_COUNT):
symbol_name = f"Test symbol {i}"
# Add a product
prod_pubkey = pyth_run_or_die(
"add_product", capture_output=True).stdout.strip()
# Update the price as the newly added publisher
publisher_random_update(price_pubkey)
print(f"{symbol_name}: Added product {prod_pubkey}")
# Add a price
price_pubkey = pyth_run_or_die(
"add_price",
args=[prod_pubkey, "price"],
confirm=False,
capture_output=True
).stdout.strip()
print(f"{symbol_name}: Added price {price_pubkey}")
# Become a publisher for the new price
pyth_run_or_die(
"add_publisher", args=[publisher_pubkey, price_pubkey],
confirm=False,
debug=True,
capture_output=True)
print(f"{symbol_name}: Added publisher {publisher_pubkey}")
# Update the prices as the newly added publisher
publisher_random_update(price_pubkey)
sym = {
"name": symbol_name,
"product": prod_pubkey,
"price": price_pubkey
}
TEST_SYMBOLS.append(sym)
sys.stdout.flush()
print(
f"Mock updates ready to roll. Updating every {str(PYTH_PUBLISHER_INTERVAL)} seconds")
@ -101,17 +118,16 @@ print(
# Spin off the readiness probe endpoint into a separate thread
readiness_thread = threading.Thread(target=readiness, daemon=True)
# Start an HTTP endpoint for looking up product/price address
# Start an HTTP endpoint for looking up test product/price addresses
http_service = threading.Thread(target=accounts_endpoint, daemon=True)
ACCOUNTS["product"] = prod_pubkey
ACCOUNTS["price"] = price_pubkey
readiness_thread.start()
http_service.start()
while True:
publisher_random_update(price_pubkey)
for sym in TEST_SYMBOLS:
publisher_random_update(sym["price"])
time.sleep(PYTH_PUBLISHER_INTERVAL)
sys.stdout.flush()

View File

@ -1,22 +1,31 @@
import os
import socketserver
import sys
import subprocess
import sys
# Settings specific to local devnet Pyth instance
PYTH = os.environ.get("PYTH", "./pyth")
PYTH_KEY_STORE = os.environ.get("PYTH_KEY_STORE", "/home/pyth/.pythd")
PYTH_PROGRAM_KEYPAIR = os.environ.get(
"PYTH_PROGRAM_KEYPAIR", f"{PYTH_KEY_STORE}/publish_key_pair.json")
"PYTH_PROGRAM_KEYPAIR", f"{PYTH_KEY_STORE}/publish_key_pair.json"
)
PYTH_PROGRAM_SO_PATH = os.environ.get("PYTH_PROGRAM_SO", "../target/oracle.so")
PYTH_PUBLISHER_KEYPAIR = os.environ.get(
"PYTH_PUBLISHER_KEYPAIR", f"{PYTH_KEY_STORE}/publish_key_pair.json")
"PYTH_PUBLISHER_KEYPAIR", f"{PYTH_KEY_STORE}/publish_key_pair.json"
)
PYTH_PUBLISHER_INTERVAL = float(os.environ.get("PYTH_PUBLISHER_INTERVAL", "5"))
SOL_AIRDROP_AMT = 100
SOL_RPC_HOST = "solana-devnet"
SOL_RPC_PORT = 8899
SOL_RPC_URL = f"http://{SOL_RPC_HOST}:{str(SOL_RPC_PORT)}"
# 0 setting disables airdropping
SOL_AIRDROP_AMT = int(os.environ.get("SOL_AIRDROP_AMT", 0))
# SOL RPC settings
SOL_RPC_HOST = os.environ.get("SOL_RPC_HOST", "solana-devnet")
SOL_RPC_PORT = int(os.environ.get("SOL_RPC_PORT", 8899))
SOL_RPC_URL = os.environ.get(
"SOL_RPC_URL", "http://{0}:{1}".format(SOL_RPC_HOST, SOL_RPC_PORT)
)
# A TCP port we open when a service is ready
READINESS_PORT = int(os.environ.get("READINESS_PORT", "2000"))
@ -24,12 +33,12 @@ def run_or_die(args, die=True, **kwargs):
"""
Opinionated subprocess.run() call with fancy logging
"""
args_readable = ' '.join(args)
args_readable = " ".join(args)
print(f"CMD RUN\t{args_readable}", file=sys.stderr)
sys.stderr.flush()
ret = subprocess.run(args, text=True, **kwargs)
if ret.returncode is not 0:
if ret.returncode != 0:
print(f"CMD FAIL {ret.returncode}\t{args_readable}", file=sys.stderr)
out = ret.stdout if ret.stdout is not None else "<not captured>"
@ -41,7 +50,7 @@ def run_or_die(args, die=True, **kwargs):
if die:
sys.exit(ret.returncode)
else:
print(f"CMD DIE FALSE", file=sys.stderr)
print(f'{"CMD DIE FALSE"}', file=sys.stderr)
else:
print(f"CMD OK\t{args_readable}", file=sys.stderr)
@ -54,23 +63,21 @@ def pyth_run_or_die(subcommand, args=[], debug=False, confirm=True, **kwargs):
Pyth boilerplate in front of run_or_die
"""
return run_or_die(
[PYTH, subcommand]
+ args
+ (["-d"] if debug else [])
[PYTH, subcommand] + args + (["-d"] if debug else [])
# Note: not all pyth subcommands accept -n
+ ([] if confirm else ["-n"])
+ ["-k", PYTH_KEY_STORE]
+ ["-r", SOL_RPC_HOST]
+ ["-c", "finalized"], **kwargs)
+ ["-c", "finalized"],
**kwargs,
)
def sol_run_or_die(subcommand, args=[], **kwargs):
"""
Solana boilerplate in front of run_or_die
"""
return run_or_die(["solana", subcommand]
+ args
+ ["--url", SOL_RPC_URL], **kwargs)
return run_or_die(["solana", subcommand] + args + ["--url", SOL_RPC_URL], **kwargs)
class ReadinessTCPHandler(socketserver.StreamRequestHandler):
@ -83,6 +90,7 @@ def readiness():
"""
Accept connections from readiness probe
"""
with socketserver.TCPServer(("0.0.0.0", READINESS_PORT), ReadinessTCPHandler) as srv:
with socketserver.TCPServer(
("0.0.0.0", READINESS_PORT), ReadinessTCPHandler
) as srv:
srv.serve_forever()
# run_or_die(["nc", "-k", "-l", "-p", READINESS_PORT])