Skip to content

Commit 85d6b15

Browse files
authored
Drozdziak1/permissioned price accounts (#65)
* Exporter/oracle: only publish permissioned prices, discard others * exporter: Empty the permissioned price channel and use latest value * exporter: use permissioned_updates for early return * exporter: clarify try_recv() * test_integration.py: add an unpermissioned symbol filtering test * test_integration.py: check that no SOL is charged on invalid updates * test_integration.py: tmp_path, give hotload agent 3s to fetch state * exporter.rs: warn -> info for unpermissioned publishing * test_integration.py: don't use unpermissioned symbol in forever test * Filter permissioned prices in exporter * test_integration.py: Give the agent a bit more time between updates
1 parent c790165 commit 85d6b15

File tree

5 files changed

+317
-68
lines changed

5 files changed

+317
-68
lines changed

integration-tests/tests/test_integration.py

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,20 @@
7878
},
7979
"metadata": {"jump_id": "186", "jump_symbol": "AAPL", "price_exp": -5, "min_publishers": 1},
8080
}
81-
ALL_PRODUCTS=[BTC_USD, AAPL_USD]
81+
82+
ETH_USD = {
83+
"account": "",
84+
"attr_dict": {
85+
"symbol": "Crypto.ETH/USD",
86+
"asset_type": "Crypto",
87+
"base": "ETH",
88+
"quote_currency": "USD",
89+
"generic_symbol": "ETHUSD",
90+
"description": "ETH/USD",
91+
},
92+
"metadata": {"jump_id": "78876710", "jump_symbol": "ETHUSD", "price_exp": -8, "min_publishers": 1},
93+
}
94+
ALL_PRODUCTS=[BTC_USD, AAPL_USD, ETH_USD]
8295

8396
asyncio.set_event_loop(asyncio.new_event_loop())
8497

@@ -237,7 +250,7 @@ def refdata_path(self, tmp_path):
237250
def refdata_products(self, refdata_path):
238251
path = os.path.join(refdata_path, 'products.json')
239252
with open(path, 'w') as f:
240-
f.write(json.dumps([BTC_USD, AAPL_USD]))
253+
f.write(json.dumps(ALL_PRODUCTS))
241254
f.flush()
242255
yield f.name
243256

@@ -257,6 +270,7 @@ def refdata_permissions(self, refdata_path):
257270
f.write(json.dumps({
258271
"AAPL": {"price": ["some_publisher"]},
259272
"BTCUSD": {"price": ["some_publisher"]},
273+
"ETHUSD": {"price": []},
260274
}))
261275
f.flush()
262276
yield f.name
@@ -448,6 +462,7 @@ class TestUpdatePrice(PythTest):
448462

449463
@pytest.mark.asyncio
450464
async def test_update_price_simple(self, client: PythAgentClient):
465+
451466
# Fetch all products
452467
products = {product["attr_dict"]["symbol"]: product for product in await client.get_all_products()}
453468

@@ -460,7 +475,7 @@ async def test_update_price_simple(self, client: PythAgentClient):
460475

461476
# Send an "update_price" request
462477
await client.update_price(price_account, 42, 2, "trading")
463-
time.sleep(1)
478+
time.sleep(2)
464479

465480
# Send another "update_price" request to trigger aggregation
466481
await client.update_price(price_account, 81, 1, "trading")
@@ -505,6 +520,7 @@ async def test_update_price_simple(self, client: PythAgentClient):
505520

506521
@pytest.mark.asyncio
507522
async def test_update_price_simple_with_keypair_hotload(self, client_hotload: PythAgentClient):
523+
508524
# Hotload the keypair into running agent
509525
hl_request = requests.post("http://localhost:9001/primary/load_keypair", json=PUBLISHER_KEYPAIR)
510526

@@ -513,15 +529,102 @@ async def test_update_price_simple_with_keypair_hotload(self, client_hotload: Py
513529

514530
LOGGER.info("Publisher keypair hotload OK")
515531

532+
time.sleep(3)
533+
516534
# Continue normally with the existing simple scenario
517535
await self.test_update_price_simple(client_hotload)
518536

537+
@pytest.mark.asyncio
538+
async def test_update_price_discards_unpermissioned(self, client: PythAgentClient, tmp_path):
539+
540+
# Fetch all products
541+
products = {product["attr_dict"]["symbol"]: product for product in await client.get_all_products()}
542+
543+
# Find the product account ID corresponding to the BTC/USD symbol
544+
product = products[BTC_USD["attr_dict"]["symbol"]]
545+
product_account = product["account"]
546+
547+
# Get the price account with which to send updates
548+
price_account = product["price_accounts"][0]["account"]
549+
550+
# Use the unpermissioned ETH/USD symbol to trigger unpermissioned account filtering
551+
product_unperm = products[ETH_USD["attr_dict"]["symbol"]]
552+
product_account_unperm = product_unperm["account"]
553+
price_account_unperm = product_unperm["price_accounts"][0]["account"]
554+
555+
556+
balance_before = self.run(f"solana balance -k {tmp_path}/agent_keystore/publish_key_pair.json -u localhost").stdout
557+
558+
# Send an "update_price" request for the valid symbol
559+
await client.update_price(price_account, 42, 2, "trading")
560+
time.sleep(1)
561+
562+
# Send another "update_price" request to trigger aggregation
563+
await client.update_price(price_account, 81, 1, "trading")
564+
time.sleep(2)
565+
566+
balance_after = self.run(f"solana balance -k {tmp_path}/agent_keystore/publish_key_pair.json -u localhost").stdout
567+
568+
# Confirm that a valid update triggers a transaction that charges the publishing keypair
569+
assert balance_before != balance_after
570+
571+
balance_before_unperm = balance_after
572+
573+
# Send an "update_price" request for the invalid symbol
574+
await client.update_price(price_account_unperm, 48, 2, "trading")
575+
time.sleep(1)
576+
577+
# Send another "update_price" request to "trigger" aggregation
578+
await client.update_price(price_account_unperm, 81, 1, "trading")
579+
time.sleep(2)
580+
581+
balance_after_unperm = self.run(f"solana balance -k {tmp_path}/agent_keystore/publish_key_pair.json -u localhost").stdout
582+
583+
# Confirm that no SOL was charged during unpermissioned symbol updates
584+
assert balance_before_unperm == balance_after_unperm
585+
586+
# Confirm that the valid symbol was updated
587+
final_product_state = await client.get_product(product_account)
588+
589+
final_price_account = final_product_state["price_accounts"][0]
590+
assert final_price_account["price"] == 42
591+
assert final_price_account["conf"] == 2
592+
assert final_price_account["status"] == "trading"
593+
594+
# Sanity-check that the unpermissioned symbol was not updated
595+
final_product_state_unperm = await client.get_product(product_account_unperm)
596+
597+
final_price_account_unperm = final_product_state_unperm["price_accounts"][0]
598+
assert final_price_account_unperm["price"] == 0
599+
assert final_price_account_unperm["conf"] == 0
600+
assert final_price_account_unperm["status"] == "unknown"
601+
602+
# Confirm agent logs contain the relevant WARN log
603+
with open(f"{tmp_path}/agent_logs/stdout") as f:
604+
contents = f.read()
605+
lines_found = 0
606+
for line in contents.splitlines():
607+
608+
if "Attempted to publish a price without permission" in line:
609+
lines_found += 1
610+
expected_unperm_pubkey = final_price_account_unperm["account"]
611+
# Must point at the expected account as all other attempts must be valid
612+
assert f"price_account: {expected_unperm_pubkey}" in line
613+
614+
# Must find at least one log discarding the account
615+
assert lines_found > 0
616+
617+
618+
519619
@pytest.mark.asyncio
520620
@pytest.mark.skip(reason="Test not meant for automatic CI")
521-
async def test_publish_forever(self, client: PythAgentClient):
621+
async def test_publish_forever(self, client: PythAgentClient, tmp_path):
522622
'''
523-
Convenience test routine for manual experiments on a running test setup.
623+
Convenience test routine for manual experiments on a running
624+
test setup. Comment out the skip to enable. use `-k "forever"`
625+
in pytest command line to only run this scenario.
524626
'''
627+
525628
# Fetch all products
526629
products = {product["attr_dict"]["symbol"]: product for product in await client.get_all_products()}
527630

@@ -536,7 +639,3 @@ async def test_publish_forever(self, client: PythAgentClient):
536639
# Send an "update_price" request
537640
await client.update_price(price_account, 47, 2, "trading")
538641
time.sleep(1)
539-
540-
# Send another "update_price" request to trigger aggregation
541-
await client.update_price(price_account, 81, 1, "trading")
542-
time.sleep(2)

src/agent.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,7 @@ pub mod config {
208208
remote_keypair_loader,
209209
solana::network,
210210
},
211-
anyhow::{
212-
anyhow,
213-
Result,
214-
},
211+
anyhow::Result,
215212
config as config_rs,
216213
config_rs::{
217214
Environment,

src/agent/solana.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,19 @@ pub mod network {
7575
keypair_request_tx: mpsc::Sender<KeypairRequest>,
7676
logger: Logger,
7777
) -> Result<Vec<JoinHandle<()>>> {
78+
// Publisher permissions updates between oracle and exporter
79+
let (publisher_permissions_tx, publisher_permissions_rx) =
80+
mpsc::channel(config.oracle.updates_channel_capacity);
81+
7882
// Spawn the Oracle
7983
let mut jhs = oracle::spawn_oracle(
8084
config.oracle.clone(),
8185
&config.rpc_url,
8286
&config.wss_url,
8387
config.rpc_timeout,
84-
KeyStore::new(config.key_store.clone(), &logger)?,
8588
global_store_update_tx.clone(),
89+
publisher_permissions_tx,
90+
KeyStore::new(config.key_store.clone(), &logger)?,
8691
logger.clone(),
8792
);
8893

@@ -91,6 +96,7 @@ pub mod network {
9196
config.exporter,
9297
&config.rpc_url,
9398
config.rpc_timeout,
99+
publisher_permissions_rx,
94100
KeyStore::new(config.key_store.clone(), &logger)?,
95101
local_store_tx,
96102
keypair_request_tx,

0 commit comments

Comments
 (0)