From 6ed3bd975fdebc9ae45e82e53e141e17562851c9 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Thu, 18 Apr 2024 14:55:50 -0700 Subject: [PATCH] dex: correct swap claim bsod check This is a cherry-pick of PR #4239, by: Author: Lucas Meier Date: Thu Apr 18 14:55:50 2024 -0700 This PR: 1. fix a bug in the swap claim circuit 2. includes a migration routine for testnet 71 Swap claim proofs take a `BatchSwapOutputData` as part of their public inputs. This data is used to compute a user's pro-rated share of a batch swap at a given block. For that reason, it is important the output data used in the proof be correct. A swap claim must be bound to the specific BSOD that was produced by that user's swap. The swap claim circuit's validation only checked that the supplied BSOD was produced at the same relative block height as the user's swap. It did not check that the epochs were the same. As a result, it was possible to generate swap claim proofs using a BSOD produced during a different epoch, on potentially much more advantageous terms. --- Cargo.lock | 1 + crates/bench/benches/swap_claim.rs | 2 +- crates/bin/pcli/tests/proof.rs | 2 +- crates/bin/pd/Cargo.toml | 1 + crates/bin/pd/src/main.rs | 4 +- crates/bin/pd/src/migrate.rs | 12 +- crates/bin/pd/src/migrate/testnet72.rs | 206 ++++++++++++++++++ crates/core/app/src/app/mod.rs | 2 +- .../dex/src/batch_swap_output_data.rs | 67 ++++-- .../src/component/circuit_breaker/value.rs | 4 +- .../core/component/dex/src/component/dex.rs | 3 - .../src/component/router/route_and_fill.rs | 24 +- .../dex/src/component/router/tests.rs | 4 +- .../core/component/dex/src/component/tests.rs | 6 +- .../component/dex/src/swap_claim/proof.rs | 33 +-- .../proof-params/src/gen/swapclaim_id.rs | 4 +- .../proof-params/src/gen/swapclaim_pk.bin | 4 +- .../proof-params/src/gen/swapclaim_vk.param | Bin 2888 -> 2888 bytes .../src/gen/penumbra.core.component.dex.v1.rs | 4 + .../penumbra.core.component.dex.v1.serde.rs | 21 ++ .../proto/src/gen/proto_descriptor.bin.no_lfs | Bin 400453 -> 400672 bytes .../penumbra/core/component/dex/v1/dex.proto | 4 +- 22 files changed, 337 insertions(+), 71 deletions(-) create mode 100644 crates/bin/pd/src/migrate/testnet72.rs diff --git a/Cargo.lock b/Cargo.lock index e933e95564..cb8da8dc26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4549,6 +4549,7 @@ dependencies = [ "penumbra-sct", "penumbra-shielded-pool", "penumbra-stake", + "penumbra-tct", "penumbra-tendermint-proxy", "penumbra-tower-trace", "penumbra-transaction", diff --git a/crates/bench/benches/swap_claim.rs b/crates/bench/benches/swap_claim.rs index 2ba03515bb..cb1e3503ad 100644 --- a/crates/bench/benches/swap_claim.rs +++ b/crates/bench/benches/swap_claim.rs @@ -70,7 +70,7 @@ fn swap_claim_proving_time(c: &mut Criterion) { unfilled_2: Amount::from(50u64), height: height.into(), trading_pair: swap_plaintext.trading_pair, - epoch_starting_height: (epoch_duration * position.epoch()).into(), + sct_position_prefix: position, }; let (lambda_1, lambda_2) = output_data.pro_rata_outputs((delta_1_i, delta_2_i)); diff --git a/crates/bin/pcli/tests/proof.rs b/crates/bin/pcli/tests/proof.rs index adaa09277e..e84b1b2a0d 100644 --- a/crates/bin/pcli/tests/proof.rs +++ b/crates/bin/pcli/tests/proof.rs @@ -278,7 +278,7 @@ fn swap_claim_parameters_vs_current_swap_claim_circuit() { unfilled_2: Amount::from(50u64), height: height.into(), trading_pair: swap_plaintext.trading_pair, - epoch_starting_height: (epoch_duration * position.epoch()).into(), + sct_position_prefix: position, }; let (lambda_1, lambda_2) = output_data.pro_rata_outputs((delta_1_i, delta_2_i)); diff --git a/crates/bin/pd/Cargo.toml b/crates/bin/pd/Cargo.toml index 4447152a5c..bc53fc968c 100644 --- a/crates/bin/pd/Cargo.toml +++ b/crates/bin/pd/Cargo.toml @@ -80,6 +80,7 @@ penumbra-proto = { workspace = true, default-features = true } penumbra-sct = { workspace = true, default-features = true } penumbra-shielded-pool = { workspace = true, features = ["parallel"], default-features = true } penumbra-stake = { workspace = true, features = ["parallel"], default-features = true } +penumbra-tct = { workspace = true, default-features = true } penumbra-tendermint-proxy = { path = "../../util/tendermint-proxy" } penumbra-tower-trace = { path = "../../util/tower-trace" } penumbra-transaction = { workspace = true, default-features = true } diff --git a/crates/bin/pd/src/main.rs b/crates/bin/pd/src/main.rs index 368d4066a3..bc919c5a04 100644 --- a/crates/bin/pd/src/main.rs +++ b/crates/bin/pd/src/main.rs @@ -12,7 +12,7 @@ use cnidarium::{StateDelta, Storage}; use metrics_exporter_prometheus::PrometheusBuilder; use pd::{ cli::{Opt, RootCommand, TestnetCommand}, - migrate::Migration::Testnet70, + migrate::Migration::Testnet72, testnet::{ config::{get_testnet_dir, parse_tm_address, url_has_necessary_parts}, generate::TestnetConfig, @@ -432,7 +432,7 @@ async fn main() -> anyhow::Result<()> { migrate_archive, } => { tracing::info!("migrating state in {}", target_directory.display()); - Testnet70 + Testnet72 .migrate(target_directory.clone(), genesis_start) .await .context("failed to upgrade state")?; diff --git a/crates/bin/pd/src/migrate.rs b/crates/bin/pd/src/migrate.rs index 922d0bc001..038d67da68 100644 --- a/crates/bin/pd/src/migrate.rs +++ b/crates/bin/pd/src/migrate.rs @@ -4,6 +4,8 @@ //! node operators must coordinate to perform a chain upgrade. //! This module declares how local `pd` state should be altered, if at all, //! in order to be compatible with the network post-chain-upgrade. +mod testnet72; + use anyhow::Context; use futures::StreamExt as _; use std::path::PathBuf; @@ -28,6 +30,9 @@ pub enum Migration { SimpleMigration, /// Testnet-70 migration: move swap executions from the jmt to nv-storage. Testnet70, + /// Testnet-72 migration: + /// - Migrate `BatchSwapOutputData` to new protobuf, replacing epoch height with index. + Testnet72, } impl Migration { @@ -37,7 +42,7 @@ impl Migration { genesis_start: Option, ) -> anyhow::Result<()> { match self { - Migration::Noop => (), + Migration::Noop => Ok(()), Migration::SimpleMigration => { let rocksdb_dir = path_to_export.join("rocksdb"); let storage = Storage::load(rocksdb_dir, SUBSTORE_PREFIXES.to_vec()).await?; @@ -101,6 +106,7 @@ impl Migration { crate::testnet::generate::TestnetValidator::initial_state(); std::fs::write(validator_state_path, fresh_validator_state) .expect("can write validator state"); + Ok(()) } Migration::Testnet70 => { // Our goal is to fetch all swap executions from the jmt and store them in nv-storage. @@ -189,9 +195,11 @@ impl Migration { duration = migration_duration.as_secs(), "successful migration!" ); + + Ok(()) } + Migration::Testnet72 => testnet72::migrate(path_to_export, genesis_start).await, } - Ok(()) } } diff --git a/crates/bin/pd/src/migrate/testnet72.rs b/crates/bin/pd/src/migrate/testnet72.rs new file mode 100644 index 0000000000..4af2b34378 --- /dev/null +++ b/crates/bin/pd/src/migrate/testnet72.rs @@ -0,0 +1,206 @@ +//! Contains functions related to the migration script of Testnet72 + +use anyhow; +use cnidarium::{Snapshot, StateDelta, StateRead, StateWrite, Storage}; +use futures::StreamExt as _; +use jmt::RootHash; +use penumbra_app::app::StateReadExt as _; +use penumbra_app::SUBSTORE_PREFIXES; +use penumbra_proto::core::component::sct::v1::query_service_server::QueryService; +use penumbra_proto::penumbra::core::component as pb; +use penumbra_proto::StateWriteProto; +use penumbra_sct::component::clock::{EpochManager, EpochRead}; +use penumbra_sct::component::rpc::Server as SctServer; +use penumbra_tct::Position; +use prost::Message; +use std::path::PathBuf; +use std::sync::Arc; +use tonic::IntoRequest; + +use crate::testnet::generate::TestnetConfig; + +/// The context holding various query services we need to help perform the migration. +#[derive(Clone)] +struct Context { + sct_server: Arc, +} + +impl Context { + /// Create a new context from the state storage. + fn new(storage: Storage) -> Self { + Self { + sct_server: Arc::new(SctServer::new(storage)), + } + } + + /// Use storage to lookup the index of an epoch based on its starting heights + async fn epoch_height_to_index(&self, epoch_starting_height: u64) -> anyhow::Result { + Ok(self + .sct_server + .epoch_by_height( + pb::sct::v1::EpochByHeightRequest { + height: epoch_starting_height, + } + .into_request(), + ) + .await? + .into_inner() + .epoch + .expect(&format!( + "epoch at height {} should be present", + epoch_starting_height + )) + .index) + } + + /// Translate the protobuf for a BSOD by populating the correct data and emptying the + /// deprecated field. + #[allow(deprecated)] + async fn translate_bsod( + &self, + bsod: pb::dex::v1::BatchSwapOutputData, + ) -> anyhow::Result { + let sct_position_prefix: u64 = { + let epoch = self + .epoch_height_to_index(bsod.epoch_starting_height) + .await?; + Position::from(( + u16::try_from(epoch).expect("epoch should fit in 16 bits"), + u16::try_from(bsod.height - bsod.epoch_starting_height) + .expect("block index should fit in 16 bits"), + 0, + )) + .into() + }; + Ok(pb::dex::v1::BatchSwapOutputData { + sct_position_prefix, + epoch_starting_height: Default::default(), + ..bsod + }) + } + + async fn translate_compact_block( + &self, + compact_block: pb::compact_block::v1::CompactBlock, + ) -> anyhow::Result { + let mut swap_outputs = Vec::with_capacity(compact_block.swap_outputs.len()); + for bsod in compact_block.swap_outputs { + swap_outputs.push(self.translate_bsod(bsod).await?); + } + Ok(pb::compact_block::v1::CompactBlock { + swap_outputs, + ..compact_block + }) + } +} + +/// Translate all of the BSODs inside dex storage to the new format. +async fn translate_dex_storage( + ctx: Context, + delta: &mut StateDelta, +) -> anyhow::Result<()> { + let mut stream = delta.prefix_raw("dex/output/"); + while let Some(r) = stream.next().await { + let (key, bsod_bytes) = r?; + let bsod = pb::dex::v1::BatchSwapOutputData::decode(bsod_bytes.as_slice())?; + let bsod = ctx.translate_bsod(bsod).await?; + delta.put_proto(key, bsod); + } + Ok(()) +} + +/// Translate all of the compact block storage to hold the new BSOD data inside the compact blocks. +async fn translate_compact_block_storage( + ctx: Context, + delta: &mut StateDelta, +) -> anyhow::Result<()> { + let mut stream = delta.nonverifiable_prefix_raw("compactblock/".as_bytes()); + while let Some(r) = stream.next().await { + let (key, compactblock_bytes) = r?; + let block = pb::compact_block::v1::CompactBlock::decode(compactblock_bytes.as_slice())?; + let block = ctx.translate_compact_block(block).await?; + delta.nonverifiable_put_raw(key, block.encode_to_vec()); + } + Ok(()) +} + +/// Run the full migration, given an export path and a start time for genesis. +pub async fn migrate( + path_to_export: PathBuf, + genesis_start: Option, +) -> anyhow::Result<()> { + let rocksdb_dir = path_to_export.join("rocksdb"); + let storage = Storage::load(rocksdb_dir.clone(), SUBSTORE_PREFIXES.to_vec()).await?; + let export_state = storage.latest_snapshot(); + let root_hash = export_state.root_hash().await.expect("can get root hash"); + let pre_upgrade_root_hash: RootHash = root_hash.into(); + let pre_upgrade_height = export_state + .get_block_height() + .await + .expect("can get block height"); + let post_upgrade_height = pre_upgrade_height.wrapping_add(1); + + let mut delta = StateDelta::new(export_state); + let (migration_duration, post_upgrade_root_hash) = { + let start_time = std::time::SystemTime::now(); + let ctx = Context::new(storage.clone()); + + // Translate inside dex storage. + translate_dex_storage(ctx.clone(), &mut delta).await?; + // Translate inside compact block storage. + translate_compact_block_storage(ctx.clone(), &mut delta).await?; + + delta.put_block_height(0u64); + let post_upgrade_root_hash = storage.commit_in_place(delta).await?; + tracing::info!(?post_upgrade_root_hash, "post-upgrade root hash"); + + (start_time.elapsed().unwrap(), post_upgrade_root_hash) + }; + + storage.release().await; + let storage = Storage::load(rocksdb_dir, SUBSTORE_PREFIXES.to_vec()).await?; + let migrated_state = storage.latest_snapshot(); + + // The migration is complete, now we need to generate a genesis file. To do this, we need + // to lookup a validator view from the chain, and specify the post-upgrade app hash and + // initial height. + let chain_id = migrated_state.get_chain_id().await?; + let app_state = penumbra_genesis::Content { + chain_id, + ..Default::default() + }; + let mut genesis = TestnetConfig::make_genesis(app_state.clone()).expect("can make genesis"); + genesis.app_hash = post_upgrade_root_hash + .0 + .to_vec() + .try_into() + .expect("infaillible conversion"); + genesis.initial_height = post_upgrade_height as i64; + genesis.genesis_time = genesis_start.unwrap_or_else(|| { + let now = tendermint::time::Time::now(); + tracing::info!(%now, "no genesis time provided, detecting a testing setup"); + now + }); + let checkpoint = post_upgrade_root_hash.0.to_vec(); + let genesis = TestnetConfig::make_checkpoint(genesis, Some(checkpoint)); + + let genesis_json = serde_json::to_string(&genesis).expect("can serialize genesis"); + tracing::info!("genesis: {}", genesis_json); + let genesis_path = path_to_export.join("genesis.json"); + std::fs::write(genesis_path, genesis_json).expect("can write genesis"); + + let validator_state_path = path_to_export.join("priv_validator_state.json"); + let fresh_validator_state = crate::testnet::generate::TestnetValidator::initial_state(); + std::fs::write(validator_state_path, fresh_validator_state).expect("can write validator state"); + + tracing::info!( + pre_upgrade_height, + post_upgrade_height, + ?pre_upgrade_root_hash, + ?post_upgrade_root_hash, + duration = migration_duration.as_secs(), + "successful migration!" + ); + + Ok(()) +} diff --git a/crates/core/app/src/app/mod.rs b/crates/core/app/src/app/mod.rs index f918e1c642..150047225b 100644 --- a/crates/core/app/src/app/mod.rs +++ b/crates/core/app/src/app/mod.rs @@ -634,7 +634,7 @@ impl App { /// /// Increment this manually after fixing the root cause for a chain halt: updated nodes will then be /// able to proceed past the block height of the halt. -const TOTAL_HALT_COUNT: u64 = 1; +const TOTAL_HALT_COUNT: u64 = 2; #[async_trait] pub trait StateReadExt: StateRead { diff --git a/crates/core/component/dex/src/batch_swap_output_data.rs b/crates/core/component/dex/src/batch_swap_output_data.rs index f40a446b46..33afb327f5 100644 --- a/crates/core/component/dex/src/batch_swap_output_data.rs +++ b/crates/core/component/dex/src/batch_swap_output_data.rs @@ -8,6 +8,7 @@ use ark_r1cs_std::{ use ark_relations::r1cs::{ConstraintSystemRef, SynthesisError}; use decaf377::{r1cs::FqVar, Fq}; use penumbra_proto::{penumbra::core::component::dex::v1 as pb, DomainType}; +use penumbra_tct::Position; use serde::{Deserialize, Serialize}; use penumbra_num::fixpoint::{bit_constrain, U128x128, U128x128Var}; @@ -36,8 +37,8 @@ pub struct BatchSwapOutputData { pub height: u64, /// The trading pair associated with the batch swap. pub trading_pair: TradingPair, - /// The starting block height of the epoch for which the batch swap data is valid. - pub epoch_starting_height: u64, + /// The position prefix where this batch swap occurred. The commitment index must be 0. + pub sct_position_prefix: Position, } impl BatchSwapOutputData { @@ -117,19 +118,19 @@ impl ToConstraintField for BatchSwapOutputData { .expect("U128x128 types are Bls12-377 field members"), ); public_inputs.extend( - Fq::from(self.height) + self.trading_pair .to_field_elements() - .expect("Fq types are Bls12-377 field members"), + .expect("trading_pair is a Bls12-377 field member"), ); public_inputs.extend( - self.trading_pair + Fq::from(self.sct_position_prefix.epoch()) .to_field_elements() - .expect("trading_pair is a Bls12-377 field member"), + .expect("Position types are Bls12-377 field members"), ); public_inputs.extend( - Fq::from(self.epoch_starting_height) + Fq::from(self.sct_position_prefix.block()) .to_field_elements() - .expect("Fq types are Bls12-377 field members"), + .expect("Position types are Bls12-377 field members"), ); Some(public_inputs) } @@ -142,9 +143,9 @@ pub struct BatchSwapOutputDataVar { pub lambda_2: U128x128Var, pub unfilled_1: U128x128Var, pub unfilled_2: U128x128Var, - pub height: FqVar, pub trading_pair: TradingPairVar, - pub epoch_starting_height: FqVar, + pub epoch: FqVar, + pub block_within_epoch: FqVar, } impl AllocVar for BatchSwapOutputDataVar { @@ -168,18 +169,23 @@ impl AllocVar for BatchSwapOutputDataVar { let unfilled_1 = U128x128Var::new_variable(cs.clone(), || Ok(unfilled_1_fixpoint), mode)?; let unfilled_2_fixpoint: U128x128 = output_data.unfilled_2.into(); let unfilled_2 = U128x128Var::new_variable(cs.clone(), || Ok(unfilled_2_fixpoint), mode)?; - let height = FqVar::new_variable(cs.clone(), || Ok(Fq::from(output_data.height)), mode)?; - // Check the height is 64 bits - let _ = bit_constrain(height.clone(), 64); let trading_pair = TradingPairVar::new_variable_unchecked( cs.clone(), || Ok(output_data.trading_pair), mode, )?; - let epoch_starting_height = - FqVar::new_variable(cs, || Ok(Fq::from(output_data.epoch_starting_height)), mode)?; - // Check the epoch starting height is 64 bits - let _ = bit_constrain(epoch_starting_height.clone(), 64); + let epoch = FqVar::new_variable( + cs.clone(), + || Ok(Fq::from(output_data.sct_position_prefix.epoch())), + mode, + )?; + bit_constrain(epoch.clone(), 16)?; + let block_within_epoch = FqVar::new_variable( + cs.clone(), + || Ok(Fq::from(output_data.sct_position_prefix.block())), + mode, + )?; + bit_constrain(block_within_epoch.clone(), 16)?; Ok(Self { delta_1, @@ -189,8 +195,8 @@ impl AllocVar for BatchSwapOutputDataVar { unfilled_1, unfilled_2, trading_pair, - height, - epoch_starting_height, + epoch, + block_within_epoch, }) } } @@ -201,6 +207,7 @@ impl DomainType for BatchSwapOutputData { impl From for pb::BatchSwapOutputData { fn from(s: BatchSwapOutputData) -> Self { + #[allow(deprecated)] pb::BatchSwapOutputData { delta_1: Some(s.delta_1.into()), delta_2: Some(s.delta_2.into()), @@ -209,8 +216,12 @@ impl From for pb::BatchSwapOutputData { unfilled_1: Some(s.unfilled_1.into()), unfilled_2: Some(s.unfilled_2.into()), height: s.height, - epoch_starting_height: s.epoch_starting_height, trading_pair: Some(s.trading_pair.into()), + sct_position_prefix: s.sct_position_prefix.into(), + // Deprecated fields we explicitly fill with defaults. + // We could instead use a `..Default::default()` here, but that would silently + // work if we were to add fields to the domain type. + epoch_starting_height: Default::default(), } } } @@ -276,6 +287,14 @@ impl From for pb::BatchSwapOutputDataResponse { impl TryFrom for BatchSwapOutputData { type Error = anyhow::Error; fn try_from(s: pb::BatchSwapOutputData) -> Result { + let sct_position_prefix = { + let prefix = Position::from(s.sct_position_prefix); + anyhow::ensure!( + prefix.commitment() == 0, + "sct_position_prefix.commitment() != 0" + ); + prefix + }; Ok(Self { delta_1: s .delta_1 @@ -306,7 +325,7 @@ impl TryFrom for BatchSwapOutputData { .trading_pair .ok_or_else(|| anyhow!("Missing trading_pair"))? .try_into()?, - epoch_starting_height: s.epoch_starting_height, + sct_position_prefix, }) } } @@ -421,9 +440,9 @@ mod tests { lambda_2: Amount::from(1u32), unfilled_1: Amount::from(1u32), unfilled_2: Amount::from(1u32), - height: 1, + height: 0, trading_pair, - epoch_starting_height: 1, + sct_position_prefix: 0u64.into(), }, } } @@ -444,7 +463,7 @@ mod tests { unfilled_2: Amount::from(50u64), height: 0u64, trading_pair, - epoch_starting_height: 0u64, + sct_position_prefix: 0u64.into(), }; // Now suppose our user's contribution is: diff --git a/crates/core/component/dex/src/component/circuit_breaker/value.rs b/crates/core/component/dex/src/component/circuit_breaker/value.rs index bc5c06a8b1..f911aa8b12 100644 --- a/crates/core/component/dex/src/component/circuit_breaker/value.rs +++ b/crates/core/component/dex/src/component/circuit_breaker/value.rs @@ -161,7 +161,7 @@ mod tests { unfilled_2: 0u64.into(), height: 1, trading_pair: pair_1.into_directed_trading_pair().into(), - epoch_starting_height: 0, + sct_position_prefix: Default::default(), }, None, None, @@ -250,7 +250,7 @@ mod tests { let routing_params = state.routing_params().await.unwrap(); // This call should panic due to the outflow of gn not being covered by the circuit breaker. state - .handle_batch_swaps(trading_pair, swap_flow, 0, 0, routing_params) + .handle_batch_swaps(trading_pair, swap_flow, 0, routing_params) .await .expect("unable to process batch swaps"); } diff --git a/crates/core/component/dex/src/component/dex.rs b/crates/core/component/dex/src/component/dex.rs index bdb9ada0c5..0172e34c62 100644 --- a/crates/core/component/dex/src/component/dex.rs +++ b/crates/core/component/dex/src/component/dex.rs @@ -7,7 +7,6 @@ use cnidarium_component::Component; use penumbra_asset::{asset, Value, STAKING_TOKEN_ASSET_ID}; use penumbra_num::Amount; use penumbra_proto::{StateReadProto, StateWriteProto}; -use penumbra_sct::component::clock::EpochRead; use tendermint::v0_37::abci; use tracing::instrument; @@ -56,7 +55,6 @@ impl Component for Dex { // 2. For each batch swap during the block, calculate clearing prices and set in the JMT. - let current_epoch = state.get_current_epoch().await.expect("epoch is set"); let routing_params = state.routing_params().await.expect("dex params are set"); for (trading_pair, swap_flows) in state.swap_flows() { @@ -69,7 +67,6 @@ impl Component for Dex { .height .try_into() .expect("height is part of the end block data"), - current_epoch.start_height, // Always include both ends of the target pair as fixed candidates. routing_params .clone() diff --git a/crates/core/component/dex/src/component/router/route_and_fill.rs b/crates/core/component/dex/src/component/router/route_and_fill.rs index b18a786200..41445ae23c 100644 --- a/crates/core/component/dex/src/component/router/route_and_fill.rs +++ b/crates/core/component/dex/src/component/router/route_and_fill.rs @@ -5,6 +5,7 @@ use async_trait::async_trait; use cnidarium::StateWrite; use penumbra_asset::{asset, Value}; use penumbra_num::Amount; +use penumbra_sct::component::clock::EpochRead; use tracing::instrument; use crate::{ @@ -23,21 +24,13 @@ use super::fill_route::FillError; /// a block's batch swap flows. #[async_trait] pub trait HandleBatchSwaps: StateWrite + Sized { - #[instrument(skip( - self, - trading_pair, - batch_data, - block_height, - epoch_starting_height, - params - ))] + #[instrument(skip(self, trading_pair, batch_data, block_height, params))] async fn handle_batch_swaps( self: &mut Arc, trading_pair: TradingPair, batch_data: SwapFlow, - // TODO: why not read these 2 from the state? + // This will be read from the ABCI request block_height: u64, - epoch_starting_height: u64, params: RoutingParams, ) -> Result<()> where @@ -95,9 +88,9 @@ pub trait HandleBatchSwaps: StateWrite + Sized { ), None => (0u64.into(), delta_2), }; + let epoch = self.get_current_epoch().await.expect("epoch is set"); let output_data = BatchSwapOutputData { height: block_height, - epoch_starting_height, trading_pair, delta_1, delta_2, @@ -105,6 +98,15 @@ pub trait HandleBatchSwaps: StateWrite + Sized { lambda_2, unfilled_1, unfilled_2, + sct_position_prefix: ( + u16::try_from(epoch.index).expect("epoch index should be small enough"), + // The block index is determined by looking at how many blocks have elapsed since + // the start of the epoch. + u16::try_from(block_height - epoch.start_height) + .expect("block index should be small enough"), + 0, + ) + .into(), }; // Fetch the swap execution object that should have been modified during the routing and filling. diff --git a/crates/core/component/dex/src/component/router/tests.rs b/crates/core/component/dex/src/component/router/tests.rs index ccb530b738..0c26b602f6 100644 --- a/crates/core/component/dex/src/component/router/tests.rs +++ b/crates/core/component/dex/src/component/router/tests.rs @@ -1024,7 +1024,7 @@ async fn best_position_route_and_fill() -> anyhow::Result<()> { .unwrap(); let routing_params = state.routing_params().await.unwrap(); state - .handle_batch_swaps(trading_pair, swap_flow, 0u32.into(), 0, routing_params) + .handle_batch_swaps(trading_pair, swap_flow, 0u32.into(), routing_params) .await .expect("unable to process batch swaps"); @@ -1165,7 +1165,7 @@ async fn multi_hop_route_and_fill() -> anyhow::Result<()> { .unwrap(); let routing_params = state.routing_params().await.unwrap(); state - .handle_batch_swaps(trading_pair, swap_flow, 0u32.into(), 0, routing_params) + .handle_batch_swaps(trading_pair, swap_flow, 0u32.into(), routing_params) .await .expect("unable to process batch swaps"); diff --git a/crates/core/component/dex/src/component/tests.rs b/crates/core/component/dex/src/component/tests.rs index b910c0998b..cfebe53716 100644 --- a/crates/core/component/dex/src/component/tests.rs +++ b/crates/core/component/dex/src/component/tests.rs @@ -632,7 +632,7 @@ async fn swap_execution_tests() -> anyhow::Result<()> { .unwrap(); let routing_params = state.routing_params().await.unwrap(); state - .handle_batch_swaps(trading_pair, swap_flow, 0, 0, routing_params) + .handle_batch_swaps(trading_pair, swap_flow, 0, routing_params) .await .expect("unable to process batch swaps"); @@ -740,7 +740,7 @@ async fn swap_execution_tests() -> anyhow::Result<()> { .unwrap(); let routing_params = state.routing_params().await.unwrap(); state - .handle_batch_swaps(trading_pair, swap_flow, 0u32.into(), 0, routing_params) + .handle_batch_swaps(trading_pair, swap_flow, 0u32.into(), routing_params) .await .expect("unable to process batch swaps"); @@ -756,8 +756,8 @@ async fn swap_execution_tests() -> anyhow::Result<()> { unfilled_1: 0u32.into(), unfilled_2: 0u32.into(), height: 0, - epoch_starting_height: 0, trading_pair, + sct_position_prefix: Default::default(), } ); diff --git a/crates/core/component/dex/src/swap_claim/proof.rs b/crates/core/component/dex/src/swap_claim/proof.rs index c8698d131c..7d86071e65 100644 --- a/crates/core/component/dex/src/swap_claim/proof.rs +++ b/crates/core/component/dex/src/swap_claim/proof.rs @@ -123,11 +123,16 @@ fn check_satisfaction( anyhow::bail!("claim fee did not match public input"); } - let block: u64 = private.state_commitment_proof.position().block().into(); - let note_commitment_block_height: u64 = public.output_data.epoch_starting_height + block; - if note_commitment_block_height != public.output_data.height { - anyhow::bail!("swap commitment height did not match public input"); - } + anyhow::ensure!( + private.state_commitment_proof.position().block() + == public.output_data.sct_position_prefix.block(), + "scm block did not match batch swap" + ); + anyhow::ensure!( + private.state_commitment_proof.position().epoch() + == public.output_data.sct_position_prefix.epoch(), + "scm epoch did not match batch swap" + ); if private.swap_plaintext.trading_pair != public.output_data.trading_pair { anyhow::bail!("trading pair did not match public input"); @@ -255,12 +260,12 @@ impl ConstraintSynthesizer for SwapClaimCircuit { claimed_fee_var.enforce_equal(&swap_plaintext_var.claim_fee)?; // Validate the swap commitment's height matches the output data's height (i.e. the clearing price height). - let block = position_var.block()?; - let note_commitment_block_height_var = - output_data_var.epoch_starting_height.clone() + block; output_data_var - .height - .enforce_equal(¬e_commitment_block_height_var)?; + .block_within_epoch + .enforce_equal(&position_var.block()?)?; + output_data_var + .epoch + .enforce_equal(&position_var.epoch()?)?; // Validate that the output data's trading pair matches the note commitment's trading pair. output_data_var @@ -359,7 +364,7 @@ impl DummyWitness for SwapClaimCircuit { unfilled_2: Amount::from(10u64), height: 0, trading_pair: swap_plaintext.trading_pair, - epoch_starting_height: 0, + sct_position_prefix: Default::default(), }; let note_blinding_1 = Fq::from(1); let note_blinding_2 = Fq::from(1); @@ -642,7 +647,7 @@ mod tests { unfilled_2: test_bsod.unfilled_2, height: height.into(), trading_pair: swap_plaintext.trading_pair, - epoch_starting_height: (epoch_duration * position.epoch()).into(), + sct_position_prefix: Default::default(), }; let (lambda_1, lambda_2) = output_data.pro_rata_outputs((delta_1_i, delta_2_i)); @@ -774,7 +779,7 @@ mod tests { unfilled_2: test_bsod.unfilled_2, height: height.into(), trading_pair: swap_plaintext.trading_pair, - epoch_starting_height: (epoch_duration * position.epoch()).into(), + sct_position_prefix: Default::default() }; let (lambda_1, lambda_2) = output_data.pro_rata_outputs((delta_1_i, delta_2_i)); @@ -874,7 +879,7 @@ mod tests { unfilled_2: test_bsod.unfilled_2, height: height.into(), trading_pair: swap_plaintext.trading_pair, - epoch_starting_height: (epoch_duration * dummy_position.epoch()).into(), + sct_position_prefix: Default::default() }; let (lambda_1, lambda_2) = output_data.pro_rata_outputs((delta_1_i, delta_2_i)); diff --git a/crates/crypto/proof-params/src/gen/swapclaim_id.rs b/crates/crypto/proof-params/src/gen/swapclaim_id.rs index 0293098666..e90d28aa7c 100644 --- a/crates/crypto/proof-params/src/gen/swapclaim_id.rs +++ b/crates/crypto/proof-params/src/gen/swapclaim_id.rs @@ -1,3 +1,3 @@ -pub const PROVING_KEY_ID: &'static str = "groth16pk1vs60etmlvwfzmn2ve0ljz0vfkzjlrhjpue5svm5ry6l076qukjcsw566rp"; -pub const VERIFICATION_KEY_ID: &'static str = "groth16vk18qjn0kxmypk8gmfc6zhjukhyxk0agmunfnhpxmf3yxq266q6sgaqwe94rc"; +pub const PROVING_KEY_ID: &'static str = "groth16pk1pfpj2hullzpeqzzyfqw85q03zz8mthht07zd3vkc562lfe776xgsvu3mfy"; +pub const VERIFICATION_KEY_ID: &'static str = "groth16vk1qyhwaxh5kq6lk2tm6fnxctynqqf7vt5j64u92zm8d8pndy7yap4qsyw855"; diff --git a/crates/crypto/proof-params/src/gen/swapclaim_pk.bin b/crates/crypto/proof-params/src/gen/swapclaim_pk.bin index 96b1d164b6..a401b19bc9 100644 --- a/crates/crypto/proof-params/src/gen/swapclaim_pk.bin +++ b/crates/crypto/proof-params/src/gen/swapclaim_pk.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8501f1dad9ac85d80c6421b17b838f8c6478431babfa836d3d33b5398fa6b6ad -size 26003952 +oid sha256:1190707f9815bf0135169547b888716d6731cdbe1bc4ea2fbd22655a03fe56cd +size 25957872 diff --git a/crates/crypto/proof-params/src/gen/swapclaim_vk.param b/crates/crypto/proof-params/src/gen/swapclaim_vk.param index 4bd2b584a5dd9a63a3f92eace8fdf9fb648339ac..72be8023cd42cbce6574223f1cad11c3e74e1765 100644 GIT binary patch delta 2725 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, /// The starting block height of the epoch for which the batch swap data is valid. + #[deprecated] #[prost(uint64, tag = "9")] pub epoch_starting_height: u64, + /// The prefix (epoch, block) of the position where this batch swap occurred. + #[prost(uint64, tag = "10")] + pub sct_position_prefix: u64, } impl ::prost::Name for BatchSwapOutputData { const NAME: &'static str = "BatchSwapOutputData"; diff --git a/crates/proto/src/gen/penumbra.core.component.dex.v1.serde.rs b/crates/proto/src/gen/penumbra.core.component.dex.v1.serde.rs index b6efda7985..4dd79b0281 100644 --- a/crates/proto/src/gen/penumbra.core.component.dex.v1.serde.rs +++ b/crates/proto/src/gen/penumbra.core.component.dex.v1.serde.rs @@ -614,6 +614,9 @@ impl serde::Serialize for BatchSwapOutputData { if self.epoch_starting_height != 0 { len += 1; } + if self.sct_position_prefix != 0 { + len += 1; + } let mut struct_ser = serializer.serialize_struct("penumbra.core.component.dex.v1.BatchSwapOutputData", len)?; if let Some(v) = self.delta_1.as_ref() { struct_ser.serialize_field("delta1", v)?; @@ -644,6 +647,10 @@ impl serde::Serialize for BatchSwapOutputData { #[allow(clippy::needless_borrow)] struct_ser.serialize_field("epochStartingHeight", ToString::to_string(&self.epoch_starting_height).as_str())?; } + if self.sct_position_prefix != 0 { + #[allow(clippy::needless_borrow)] + struct_ser.serialize_field("sctPositionPrefix", ToString::to_string(&self.sct_position_prefix).as_str())?; + } struct_ser.end() } } @@ -671,6 +678,8 @@ impl<'de> serde::Deserialize<'de> for BatchSwapOutputData { "tradingPair", "epoch_starting_height", "epochStartingHeight", + "sct_position_prefix", + "sctPositionPrefix", ]; #[allow(clippy::enum_variant_names)] @@ -684,6 +693,7 @@ impl<'de> serde::Deserialize<'de> for BatchSwapOutputData { Height, TradingPair, EpochStartingHeight, + SctPositionPrefix, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -715,6 +725,7 @@ impl<'de> serde::Deserialize<'de> for BatchSwapOutputData { "height" => Ok(GeneratedField::Height), "tradingPair" | "trading_pair" => Ok(GeneratedField::TradingPair), "epochStartingHeight" | "epoch_starting_height" => Ok(GeneratedField::EpochStartingHeight), + "sctPositionPrefix" | "sct_position_prefix" => Ok(GeneratedField::SctPositionPrefix), _ => Ok(GeneratedField::__SkipField__), } } @@ -743,6 +754,7 @@ impl<'de> serde::Deserialize<'de> for BatchSwapOutputData { let mut height__ = None; let mut trading_pair__ = None; let mut epoch_starting_height__ = None; + let mut sct_position_prefix__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::Delta1 => { @@ -803,6 +815,14 @@ impl<'de> serde::Deserialize<'de> for BatchSwapOutputData { Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) ; } + GeneratedField::SctPositionPrefix => { + if sct_position_prefix__.is_some() { + return Err(serde::de::Error::duplicate_field("sctPositionPrefix")); + } + sct_position_prefix__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -818,6 +838,7 @@ impl<'de> serde::Deserialize<'de> for BatchSwapOutputData { height: height__.unwrap_or_default(), trading_pair: trading_pair__, epoch_starting_height: epoch_starting_height__.unwrap_or_default(), + sct_position_prefix: sct_position_prefix__.unwrap_or_default(), }) } } diff --git a/crates/proto/src/gen/proto_descriptor.bin.no_lfs b/crates/proto/src/gen/proto_descriptor.bin.no_lfs index 1614cc7fbdea2ed0f651ac6f23c935d929866462..a79c879bc8db4fcc8b0173afadaec62a194b35c9 100644 GIT binary patch delta 9418 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 delta 9285 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