Prechádzať zdrojové kódy

feat(lazer): add governance instructions protocol (#2654)

* feat(lazer): add governance instructions protocol

* feat(lazer): more governance improvements

* feat(lazer): add DynamicValue conversions, add source to GovernanceInstruction

* chore: fix ci

* chore(lazer): silence generated doc errors
Pavel Strakhov 6 mesiacov pred
rodič
commit
becf76c0a1

+ 8 - 5
.github/workflows/ci-lazer-rust.yml

@@ -21,16 +21,19 @@ jobs:
           submodules: recursive
       - uses: actions-rust-lang/setup-rust-toolchain@v1
         with:
-          toolchain: 1.81.0
+          toolchain: 1.82.0
+          components: clippy,rustfmt
       - uses: Swatinem/rust-cache@v2
         with:
           workspaces: "lazer -> target"
+      - name: Install Protoc
+        uses: arduino/setup-protoc@v3
+        with:
+          version: "30.2"
       - name: Install Foundry
         uses: foundry-rs/foundry-toolchain@v1
-      - name: install extra tools
-        run: |
-          cargo install --locked taplo-cli@0.9.3
-          sudo apt-get update && sudo apt-get install -y protobuf-compiler
+      - name: install taplo
+        run: cargo install --locked taplo-cli@0.9.3
       - name: Install Solana Cli
         run: |
           sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"

+ 55 - 21
lazer/Cargo.lock

@@ -360,9 +360,9 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.93"
+version = "1.0.98"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775"
+checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
 
 [[package]]
 name = "aquamarine"
@@ -2002,6 +2002,15 @@ version = "2.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa"
 
+[[package]]
+name = "fs-err"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f89bda4c2a21204059a977ed3bfe746677dfd137b83c339e702b0ac91d482aa"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "funty"
 version = "2.0.0"
@@ -2360,9 +2369,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
 
 [[package]]
 name = "humantime"
-version = "2.1.0"
+version = "2.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
+checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f"
 
 [[package]]
 name = "hyper"
@@ -3390,6 +3399,15 @@ dependencies = [
  "thiserror 1.0.69",
 ]
 
+[[package]]
+name = "ordered-float"
+version = "2.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "os_str_bytes"
 version = "6.6.1"
@@ -3523,7 +3541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc"
 dependencies = [
  "memchr",
- "thiserror 2.0.3",
+ "thiserror 2.0.12",
  "ucd-trie",
 ]
 
@@ -3866,8 +3884,14 @@ dependencies = [
 name = "pyth-lazer-publisher-sdk"
 version = "0.1.2"
 dependencies = [
+ "anyhow",
+ "fs-err",
+ "humantime",
  "protobuf",
  "protobuf-codegen",
+ "pyth-lazer-protocol",
+ "serde-value",
+ "tracing",
 ]
 
 [[package]]
@@ -3881,7 +3905,7 @@ dependencies = [
  "pyth-lazer-protocol",
  "solana-program-test",
  "solana-sdk",
- "thiserror 2.0.3",
+ "thiserror 2.0.12",
  "tokio",
 ]
 
@@ -4596,13 +4620,23 @@ dependencies = [
 
 [[package]]
 name = "serde"
-version = "1.0.215"
+version = "1.0.219"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
 dependencies = [
  "serde_derive",
 ]
 
+[[package]]
+name = "serde-value"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
+dependencies = [
+ "ordered-float",
+ "serde",
+]
+
 [[package]]
 name = "serde_bytes"
 version = "0.11.15"
@@ -4614,9 +4648,9 @@ dependencies = [
 
 [[package]]
 name = "serde_derive"
-version = "1.0.215"
+version = "1.0.219"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -6489,11 +6523,11 @@ dependencies = [
 
 [[package]]
 name = "thiserror"
-version = "2.0.3"
+version = "2.0.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa"
+checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
 dependencies = [
- "thiserror-impl 2.0.3",
+ "thiserror-impl 2.0.12",
 ]
 
 [[package]]
@@ -6509,9 +6543,9 @@ dependencies = [
 
 [[package]]
 name = "thiserror-impl"
-version = "2.0.3"
+version = "2.0.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568"
+checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -6795,9 +6829,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
 
 [[package]]
 name = "tracing"
-version = "0.1.40"
+version = "0.1.41"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
+checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
 dependencies = [
  "log",
  "pin-project-lite",
@@ -6807,9 +6841,9 @@ dependencies = [
 
 [[package]]
 name = "tracing-attributes"
-version = "0.1.27"
+version = "0.1.28"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
+checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -6818,9 +6852,9 @@ dependencies = [
 
 [[package]]
 name = "tracing-core"
-version = "0.1.32"
+version = "0.1.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
 dependencies = [
  "once_cell",
  "valuable",

+ 36 - 0
lazer/publisher_sdk/proto/dynamic_value.proto

@@ -0,0 +1,36 @@
+syntax = "proto3";
+
+import "google/protobuf/timestamp.proto";
+import "google/protobuf/duration.proto";
+
+package pyth_lazer_transaction;
+
+// A dynamically typed value similar to `google.protobuf.Value`
+// but supporting more types.
+message DynamicValue {
+    message List {
+        repeated DynamicValue items = 1;
+    }
+    message MapItem {
+        // [required] Must be unique.
+        optional string key = 1;
+        // [required]
+        optional DynamicValue value = 2;
+    }
+    message Map {
+        repeated MapItem items = 1;
+    }
+
+    oneof value {
+        string string_value = 1;
+        double double_value = 2;
+        uint64 uint_value = 3;
+        sint64 int_value = 4;
+        bool bool_value = 5;
+        bytes bytes_value = 6;
+        google.protobuf.Duration duration_value = 7;
+        google.protobuf.Timestamp timestamp_value = 8;
+        List list = 9;
+        Map map = 10;
+    }
+}

+ 350 - 0
lazer/publisher_sdk/proto/governance_instruction.proto

@@ -0,0 +1,350 @@
+syntax = "proto3";
+
+import "google/protobuf/timestamp.proto";
+import "google/protobuf/duration.proto";
+import "google/protobuf/empty.proto";
+
+import "dynamic_value.proto";
+
+// If any field documented as `[required]` is not present in the instruction,
+// the instruction will be rejected.
+
+package pyth_lazer_transaction;
+
+// Representation of a complete governance instruction. This value will be signed
+// by a governance source.
+message GovernanceInstruction {
+    // [required] Governance source that signed this instruction.
+    optional GovernanceSource source = 1;
+    // Action requested by this instruction. For the instruction to be accepted, all directives
+    // must be successfully applied. In case of any failure, the whole instruction is reverted.
+    // However, note that if the instruction targets multiple (or all) shards, each shard will
+    // accept or reject the instruction independently of other shards.
+    repeated GovernanceDirective directives = 2;
+    // [optional] If specified, the instruction will be rejected if the current timestamp
+    // is less than the specified value. In case of rejection, the same instruction can be resubmitted
+    // and executed later once the time requirement is met.
+    optional google.protobuf.Timestamp min_execution_timestamp = 3;
+    // [optional] If specified, the instruction will be rejected if the current timestamp
+    // is greater than the specified value. After `max_execution_timestamp` is in the past,
+    // it will no longer be possible to execute this instruction.
+    optional google.protobuf.Timestamp max_execution_timestamp = 4;
+    // [required] Sequence number of this instruction. It must be greater than 0.
+    // It must always be increasing, but not required to be
+    // strictly sequential (i.e. gaps are allowed). Each shard separately keeps track of the last executed
+    // governance instruction and will reject instructions with the same or smaller sequence no.
+    // Note that if instructions are received out of order, some of them may become permanently
+    // rejected (e.g. if instruction #3 has been successfully processed before instruction #2 was observed,
+    // #2 will always be rejected).
+    // Sequence numbers are assigned and tracked separately for each governance source.
+    optional uint32 governance_sequence_no = 5;
+}
+
+// Specifies which shards the governance instruction applies to.
+message ShardFilter {
+    // The instruction applies to the specified shards.
+    message ShardNames {
+        // Must not be empty.
+        repeated string shard_names = 1;
+    }
+    // The instruction applies to the specified shard groups.
+    message ShardGroups {
+        // Must not be empty.
+        repeated string shard_groups = 1;
+    }
+    // [required]
+    oneof filter {
+        // The instruction applies to all shards.
+        google.protobuf.Empty all_shards = 1;
+        ShardNames shard_names = 2;
+        ShardGroups shard_groups = 3;
+    }
+}
+
+// An item of a governance instruction.
+message GovernanceDirective {
+    // [required] Specifies which shards the governance instruction applies to.
+    // The instruction applies to each shard independently of other shards and may apply
+    // at a different time. The instruction may succeed on some shards and fail on other shards.
+    // Note that each shard has its own list of governance sources and permissions,
+    // and a `GovernanceInstruction` is issued by a single source, so multiple instructions
+    // from different sources may be needed to apply a change to multiple shards or shard groups.
+    optional ShardFilter shard_filter = 1;
+    // [required]
+    // Note: when adding a new variant here, update `Permissions` as well.
+    oneof action {
+        CreateShard create_shard = 101;
+        AddGovernanceSource add_governance_source = 102;
+        UpdateGovernanceSource update_governance_source = 103;
+        SetShardName set_shard_name = 104;
+        SetShardGroup set_shard_group = 105;
+        ResetLastSequenceNo reset_last_sequence_no = 106;
+        AddPublisher add_publisher = 107;
+        UpdatePublisher update_publisher = 108;
+        AddFeed add_feed = 109;
+        UpdateFeed update_feed = 110;
+    }
+}
+
+// Permissions granted to a governance source.
+// bool fields in this message are optional and default to false (no permission).
+message Permissions {
+    enum ShardAction {
+        // Required by protobuf. Instruction will be rejected if this value is encountered.
+        SHARD_ACTION_UNSPECIFIED = 0;
+        CREATE_SHARD = 101;
+        ADD_GOVERNANCE_SOURCE = 102;
+        UPDATE_GOVERNANCE_SOURCE = 103;
+        SET_SHARD_NAME = 104;
+        SET_SHARD_GROUP = 105;
+        RESET_LAST_SEQUENCE_NO = 106;
+        ADD_PUBLISHER = 107;
+        ADD_FEED = 109;
+    }
+
+    enum UpdateGovernanceSourceAction {
+        // Required by protobuf. Instruction will be rejected if this value is encountered.
+        UPDATE_GOVERNANCE_SOURCE_ACTION_UNSPECIFIED = 0;
+        SET_GOVERNANCE_SOURCE_PERMISSIONS = 101;
+        REMOVE_GOVERNANCE_SOURCE = 199;
+    }
+
+    enum UpdatePublisherAction {
+        // Required by protobuf. Instruction will be rejected if this value is encountered.
+        UPDATE_PUBLISHER_ACTION_UNSPECIFIED = 0;
+        SET_PUBLISHER_NAME = 101;
+        ADD_PUBLISHER_PUBLIC_KEYS = 102;
+        REMOVE_PUBLISHER_PUBLIC_KEYS = 103;
+        SET_PUBLISHER_PUBLIC_KEYS = 104;
+        SET_PUBLISHER_ACTIVE = 105;
+        REMOVE_PUBLISHER = 199;
+    }
+
+    enum UpdateFeedAction {
+        // Required by protobuf. Instruction will be rejected if this value is encountered.
+        UPDATE_FEED_ACTION_UNSPECIFIED = 0;
+        UPDATE_FEED_METADATA = 101;
+        ACTIVATE_FEED = 102;
+        DEACTIVATE_FEED = 103;
+        REMOVE_FEED = 199;
+    }
+
+    // All operations, including operations added in the future.
+    optional bool all_actions = 1;
+    repeated ShardAction shard_actions = 2;
+    // All operations under `UpdateGovernanceSource` (update and delete),
+    // including operations added in the future.
+    optional bool all_update_governance_source_actions = 3;
+    repeated UpdateGovernanceSourceAction update_governance_source_actions = 4;
+    // All operations under `UpdatePublisher` (update and delete),
+    // including operations added in the future.
+    optional bool all_update_publisher_action = 5;
+    repeated UpdatePublisherAction update_publisher_actions = 6;
+    // All operations under `UpdateFeed` (update and delete),
+    // including operations added in the future.
+    optional bool all_update_feed_actions = 7;
+    repeated UpdateFeedAction update_feed_actions = 8;
+}
+
+// Specifies the way governance transactions are signed and verified.
+message GovernanceSource {
+    // Governance transactions are signed by a single Ed25519 signature.
+    // This will generally be used in development and testing groups.
+    message SingleEd25519 {
+        // [required] Ed25519 public key that signs governance transactions.
+        optional bytes public_key = 1;
+    }
+
+    // [required]
+    oneof source {
+        SingleEd25519 single_ed25519 = 1;
+        // TODO: wormhole source goes here.
+    }
+}
+
+// Create a new shard. A shard is a partially isolated part of Lazer that has its own state and
+// cannot be directly influenced by other shards. The main purpose of shards in Lazer is
+// to allow horizontal scaling when the number of feeds grows. Feeds can be divided into subsets
+// and each subset will be assigned to a shard.
+//
+// Shard name will be determined by the value of `GovernanceDirective.filter`.
+// This action will be rejected unless `GovernanceDirective.filter` specified a single shard.
+// Shard name must be unique across all shards in all groups.
+// (Warning: it's not possible to enforce this rule within a shard!)
+message CreateShard {
+    // [required] ID of the new shard.
+    // Shard ID must be unique across all shards in all groups.
+    // (Warning: it's not possible to enforce this rule within a shard!)
+    optional uint32 shard_id = 1;
+    // [required] Group name, e.g. "production", "staging", "testing", etc.
+    // Data from shards belonging to the same group can be joined and served to consumers as a whole.
+    // Active feed names must be unique within a group, but not across all groups.
+    optional string shard_group = 2;
+    // [required] Minimal aggregation rate allowed in this shard.
+    optional google.protobuf.Duration min_rate = 3;
+}
+
+message AddGovernanceSource {
+    // [required] Governance source that should be added.
+    optional GovernanceSource new_source = 1;
+    // [required] Permissions granted to this source.
+    optional Permissions permissions = 2;
+}
+
+message UpdateGovernanceSource {
+    // [required] Governance source that should be updated. Rejects if there is no such source.
+    // Rejects if the specified source is the same as the source of the current instruction.
+    optional GovernanceSource source = 1;
+    // [required]
+    // Note: when adding a new variant here, update `Permissions` as well.
+    oneof action {
+        SetGovernanceSourcePermissions set_governance_source_permissions = 101;
+        // Removes a governance source. Note that the last sequence number associated with this source
+        // will be retained in the state to prevent repeated execution of instructions in case
+        // the same source is re-added later.
+        google.protobuf.Empty remove_governance_source = 199;
+    }
+}
+
+message SetGovernanceSourcePermissions {
+    // [required] Permissions granted to this source. Replaces all previous permissions.
+    optional Permissions permissions = 1;
+}
+
+// Set shard name. This action will be rejected if `GovernanceDirective.shard_names` is empty or contains
+// more than one item.
+message SetShardName {
+    // [required] New shard name. Must be unique across all shards in all groups.
+    // (Warning: it's not possible to enforce this rule within a shard!)
+    optional string shard_name = 1;
+}
+
+// Set shard group. This action will be rejected if `GovernanceDirective.shard_names` is empty or contains
+// more than one item.
+message SetShardGroup {
+    // [required] Group name, e.g. "production", "staging", "testing", etc.
+    // Data from shards belonging to the same group can be joined and served to consumers as a whole.
+    // Active feed names must be unique within a group, but not across all groups.
+    optional string shard_group = 1;
+}
+
+// Set `last_sequence_no`. This can be used as a workaround in case some updates are lost and
+// the services are unable to proceed.
+message ResetLastSequenceNo {
+    optional uint64 last_sequence_no = 1;
+}
+
+message AddPublisher {
+    // [required] Publisher ID. Restricted to uint16. Must be different from existing ids.
+    optional uint32 publisher_id = 1;
+    // [required] Publisher name (only for debug/monitoring/management purposes).
+    // Must be different from existing publisher names.
+    optional string name = 2;
+    // Public keys used to sign publisher update transactions.
+    repeated bytes public_keys = 3;
+    // [required] If true, the publisher is active, i.e. it's allowed to publish updates.
+    optional bool is_active = 4;
+}
+
+message UpdatePublisher {
+    // [required] ID of the publisher that is being updated. Rejects if there is no such publisher.
+    optional uint32 publisher_id = 1;
+    // [required]
+    // Note: when adding a new variant here, update `Permissions` as well.
+    oneof action {
+        SetPublisherName set_publisher_name = 101;
+        AddPublisherPublicKeys add_publisher_public_keys = 102;
+        RemovePublisherPublicKeys remove_publisher_public_keys = 103;
+        SetPublisherPublicKeys set_publisher_public_keys = 104;
+        SetPublisherActive set_publisher_active = 105;
+        google.protobuf.Empty remove_publisher = 199;
+    }
+}
+
+message SetPublisherName {
+    // [required] New name.
+    optional string name = 1;
+}
+
+// Add new keys.
+message AddPublisherPublicKeys {
+    // Must not be empty.
+    repeated bytes public_keys = 1;
+}
+
+// Remove existing keys.
+message RemovePublisherPublicKeys {
+    // Must not be empty.
+    repeated bytes public_keys = 1;
+}
+
+// Remove all existing public keys and add new keys (if specified).
+message SetPublisherPublicKeys {
+    repeated bytes public_keys = 1;
+}
+
+message SetPublisherActive {
+    // [required]
+    optional bool is_active = 1;
+}
+
+// Feed is inactive when added, meaning that it will be available to publishers but not to consumers.
+message AddFeed {
+    // [required] ID of the price feed. Must be unique (within the shard).
+    optional uint32 price_feed_id = 1;
+    // [required] Feed metadata. Some properties are required (name, exponent, etc.).
+    // Known properties must have the expected type.
+    // Additional arbitrary properties are allowed.
+    // (TODO: document known metadata properties)
+    optional DynamicValue.Map metadata = 2;
+    // IDs of publishers enabled for this feed.
+    repeated uint32 permissioned_publishers = 3;
+}
+
+message UpdateFeed {
+    // [required] ID of the feed that is being updated. Rejects if there is no such feed.
+    optional uint32 price_feed_id = 1;
+    // [required]
+    // Note: when adding a new variant here, update `Permissions` as well.
+    oneof action {
+        UpdateFeedMetadata update_feed_metadata = 101;
+        ActivateFeed activate_feed = 102;
+        DeactivateFeed deactivate_feed = 103;
+        google.protobuf.Empty remove_feed = 199;
+    }
+}
+
+message UpdateFeedMetadata {
+    // [required] Property name.
+    optional string name = 1;
+    // [optional] Property value. If unset, the property will be removed.
+    optional DynamicValue value = 2;
+}
+
+// Set the feed as active or shedule an activation.
+// If there was already a pending activation or deactivation, it will be cleared
+// when this governance instruction is processed.
+// Warning: there must never be two feeds with the same name active at the same time
+// within a shard group. This cannot be enforced within a shard. When a feed needs to be
+// moved between shards, use `activation_timestamp` and `deactivation_timestamp`
+// to deactivate it in the old shard and activate it in the new shard at the same time.
+message ActivateFeed {
+    // [optional] If provided, the feed will activate at the specified timestamp.
+    // If `activation_timestamp` is already passed or if it's unset,
+    // the feed will be activated immediately when this
+    // governance instruction is processed.
+    optional google.protobuf.Timestamp activation_timestamp = 1;
+}
+
+// Set the feed as inactive or shedule a deactivation.
+// If there was already a pending activation or deactivation, it will be cleared
+// when this governance instruction is processed.
+// See also: `ActivateFeed` docs.
+message DeactivateFeed {
+    // [optional] If provided, the feed will deactivate at the specified timestamp.
+    // If `deactivation_timestamp` is already passed or if it's unset,
+    // the feed will be deactivated immediately when this
+    // governance instruction is processed.
+    optional google.protobuf.Timestamp deactivation_timestamp = 1;
+}
+

+ 7 - 3
lazer/publisher_sdk/proto/pyth_lazer_transaction.proto

@@ -2,6 +2,7 @@ syntax = "proto3";
 package pyth_lazer_transaction;
 
 import "publisher_update.proto";
+import "governance_instruction.proto";
 
 // if any fields marked as [required] are missing, transaction will be rejected
 // if signature does not match payload bytes, transaction will be rejected
@@ -19,7 +20,7 @@ message SignedLazerTransaction {
 
     // [required] lazer transaction encoded as bytes through protobuf
     optional bytes payload = 3;
-    
+
     // TODO: Add public key
 }
 
@@ -33,7 +34,10 @@ enum TransactionSignatureType {
 message LazerTransaction {
     // [required] valid transaction types supported by pyth lazer
     oneof payload {
-      // updates to feeds, sent by authorized publishers
-      PublisherUpdate publisher_update = 1;
+        // Expected transaction sent by Publishers
+        // May contain many individual updates to various feeds
+        PublisherUpdate publisher_update = 1;
+        // Sent by governance.
+        GovernanceInstruction governance_instruction = 2;
     }
 }

+ 6 - 0
lazer/publisher_sdk/rust/Cargo.toml

@@ -7,7 +7,13 @@ license = "Apache-2.0"
 repository = "https://github.com/pyth-network/pyth-crosschain"
 
 [dependencies]
+pyth-lazer-protocol = { version = "0.7.2", path = "../../sdk/rust/protocol" }
+anyhow = "1.0.98"
 protobuf = "3.7.2"
+serde-value = "0.7.0"
+humantime = "2.2.0"
+tracing = "0.1.41"
 
 [build-dependencies]
+fs-err = "3.1.0"
 protobuf-codegen = "3.7.2"

+ 3 - 3
lazer/publisher_sdk/rust/build.rs

@@ -1,5 +1,7 @@
 use std::io::Result;
 
+use fs_err::read_dir;
+
 /// Automatically runs during cargo build.
 /// Proto files for Lazer are defined in the lazer sdk folder in the proto/ subdirectory.
 /// Both JS and Rust SDKs read the proto files for generating types.
@@ -11,9 +13,7 @@ fn main() -> Result<()> {
         .protoc()
         .protoc_extra_arg("--include_source_info")
         .include("../proto")
-        .input("../proto/publisher_update.proto")
-        .input("../proto/pyth_lazer_transaction.proto")
-        .input("../proto/transaction_envelope.proto")
+        .inputs(read_dir("../proto")?.map(|item| item.unwrap().path()))
         .cargo_out_dir("protobuf")
         .run_from_script();
 

+ 147 - 0
lazer/publisher_sdk/rust/src/lib.rs

@@ -1,3 +1,11 @@
+use std::{collections::BTreeMap, time::Duration};
+
+use ::protobuf::MessageField;
+use anyhow::{bail, ensure, Context};
+use humantime::format_duration;
+use protobuf::dynamic_value::{dynamic_value, DynamicValue};
+use pyth_lazer_protocol::router::TimestampUs;
+
 pub mod transaction_envelope {
     pub use crate::protobuf::transaction_envelope::*;
 }
@@ -10,6 +18,145 @@ pub mod publisher_update {
     pub use crate::protobuf::publisher_update::*;
 }
 
+pub mod governance_instruction {
+    pub use crate::protobuf::governance_instruction::*;
+}
+
+#[allow(rustdoc::broken_intra_doc_links)]
 mod protobuf {
     include!(concat!(env!("OUT_DIR"), "/protobuf/mod.rs"));
 }
+
+impl DynamicValue {
+    pub fn try_option_from_serde(value: serde_value::Value) -> anyhow::Result<Option<Self>> {
+        match value {
+            serde_value::Value::Option(value) => {
+                if let Some(value) = value {
+                    Ok(Some((*value).try_into()?))
+                } else {
+                    Ok(None)
+                }
+            }
+            value => Ok(Some(value.try_into()?)),
+        }
+    }
+
+    pub fn to_timestamp(&self) -> anyhow::Result<TimestampUs> {
+        let value = self.value.as_ref().context("missing DynamicValue.value")?;
+        match value {
+            dynamic_value::Value::TimestampValue(ts) => Ok(ts.try_into()?),
+            _ => bail!("expected timestamp, got {:?}", self),
+        }
+    }
+
+    pub fn to_duration(&self) -> anyhow::Result<Duration> {
+        let value = self.value.as_ref().context("missing DynamicValue.value")?;
+        match value {
+            dynamic_value::Value::DurationValue(duration) => Ok(duration.clone().into()),
+            _ => bail!("expected duration, got {:?}", self),
+        }
+    }
+}
+
+impl TryFrom<serde_value::Value> for DynamicValue {
+    type Error = anyhow::Error;
+
+    fn try_from(value: serde_value::Value) -> Result<Self, Self::Error> {
+        let converted = match value {
+            serde_value::Value::Bool(value) => dynamic_value::Value::BoolValue(value),
+            serde_value::Value::U8(value) => dynamic_value::Value::UintValue(value.into()),
+            serde_value::Value::U16(value) => dynamic_value::Value::UintValue(value.into()),
+            serde_value::Value::U32(value) => dynamic_value::Value::UintValue(value.into()),
+            serde_value::Value::U64(value) => dynamic_value::Value::UintValue(value),
+            serde_value::Value::I8(value) => dynamic_value::Value::IntValue(value.into()),
+            serde_value::Value::I16(value) => dynamic_value::Value::IntValue(value.into()),
+            serde_value::Value::I32(value) => dynamic_value::Value::IntValue(value.into()),
+            serde_value::Value::I64(value) => dynamic_value::Value::IntValue(value),
+            serde_value::Value::F32(value) => dynamic_value::Value::DoubleValue(value.into()),
+            serde_value::Value::F64(value) => dynamic_value::Value::DoubleValue(value),
+            serde_value::Value::Char(value) => dynamic_value::Value::StringValue(value.to_string()),
+            serde_value::Value::String(value) => dynamic_value::Value::StringValue(value),
+            serde_value::Value::Bytes(value) => dynamic_value::Value::BytesValue(value),
+            serde_value::Value::Seq(values) => {
+                let mut items = Vec::new();
+                for value in values {
+                    items.push(value.try_into()?);
+                }
+                dynamic_value::Value::List(dynamic_value::List {
+                    items,
+                    special_fields: Default::default(),
+                })
+            }
+            serde_value::Value::Map(values) => {
+                let mut items = Vec::new();
+                for (key, value) in values {
+                    let key = match key {
+                        serde_value::Value::String(key) => key,
+                        _ => bail!("unsupported key type: expected string, got {:?}", key),
+                    };
+                    items.push(dynamic_value::MapItem {
+                        key: Some(key),
+                        value: MessageField::some(value.try_into()?),
+                        special_fields: Default::default(),
+                    })
+                }
+                dynamic_value::Value::Map(dynamic_value::Map {
+                    items,
+                    special_fields: Default::default(),
+                })
+            }
+            serde_value::Value::Unit
+            | serde_value::Value::Option(_)
+            | serde_value::Value::Newtype(_) => bail!("unsupported type: {:?}", value),
+        };
+        Ok(DynamicValue {
+            value: Some(converted),
+            special_fields: Default::default(),
+        })
+    }
+}
+
+impl TryFrom<DynamicValue> for serde_value::Value {
+    type Error = anyhow::Error;
+
+    fn try_from(value: DynamicValue) -> Result<Self, Self::Error> {
+        let value = value.value.context("missing DynamicValue.value")?;
+        match value {
+            dynamic_value::Value::StringValue(value) => Ok(serde_value::Value::String(value)),
+            dynamic_value::Value::DoubleValue(value) => Ok(serde_value::Value::F64(value)),
+            dynamic_value::Value::UintValue(value) => Ok(serde_value::Value::U64(value)),
+            dynamic_value::Value::IntValue(value) => Ok(serde_value::Value::I64(value)),
+            dynamic_value::Value::BoolValue(value) => Ok(serde_value::Value::Bool(value)),
+            dynamic_value::Value::BytesValue(value) => Ok(serde_value::Value::Bytes(value)),
+            dynamic_value::Value::DurationValue(duration) => {
+                let s: Duration = duration.into();
+                Ok(serde_value::Value::String(format_duration(s).to_string()))
+            }
+            dynamic_value::Value::TimestampValue(ts) => {
+                let ts = TimestampUs::try_from(&ts)?;
+                Ok(serde_value::Value::U64(ts.0))
+            }
+            dynamic_value::Value::List(list) => {
+                let mut output = Vec::new();
+                for item in list.items {
+                    output.push(item.try_into()?);
+                }
+                Ok(serde_value::Value::Seq(output))
+            }
+            dynamic_value::Value::Map(map) => {
+                let mut output = BTreeMap::new();
+                for item in map.items {
+                    let key = item.key.context("missing DynamicValue.MapItem.key")?;
+                    let value = item
+                        .value
+                        .into_option()
+                        .context("missing DynamicValue.MapItem.value")?
+                        .try_into()?;
+                    let old = output.insert(serde_value::Value::String(key), value);
+                    ensure!(old.is_none(), "duplicate DynamicValue.MapItem.key");
+                }
+                Ok(serde_value::Value::Map(output))
+            }
+        }
+    }
+}