Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ pub trait DkgSigningClient {
.await
}

/// The admin escape hatch: forces a DKG reset from any epoch state, including mid-exchange.
async fn trigger_dkg_forced_reset(&self, fee: Option<Fee>) -> Result<ExecuteResult, NyxdError> {
let req = DkgExecuteMsg::TriggerForcedReset {};

self.execute_dkg_contract(fee, req, "trigger forced DKG reset".to_string(), vec![])
.await
}

async fn transfer_ownership(
&self,
transfer_to: String,
Expand Down Expand Up @@ -238,6 +246,7 @@ mod tests {
DkgExecuteMsg::AdvanceEpochState {} => client.advance_dkg_epoch_state(None).ignore(),
DkgExecuteMsg::TriggerReset {} => client.trigger_dkg_reset(None).ignore(),
DkgExecuteMsg::TriggerResharing {} => client.trigger_dkg_resharing(None).ignore(),
DkgExecuteMsg::TriggerForcedReset {} => client.trigger_dkg_forced_reset(None).ignore(),
ExecuteMsg::TransferOwnership { transfer_to } => {
client.transfer_ownership(transfer_to, None).ignore()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@

pub const NODE_INDEX: &str = "node_index";
pub const DKG_PROPOSAL_ID: &str = "proposal_id";

/// Emitted (with the held epoch's id as the value) when an epoch-state advance is held
/// because nobody has registered as a dealer - without it, the held transaction succeeds
/// looking exactly like a real advance, and a ceremony stuck waiting for dealers can only
/// be noticed by diffing successive epoch queries.
pub const AWAITING_DEALERS: &str = "awaiting_dealers";
6 changes: 6 additions & 0 deletions common/cosmwasm-smart-contracts/coconut-dkg/src/msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ pub enum ExecuteMsg {

TriggerResharing {},

/// Admin-only escape hatch: force a reset from any epoch state, including mid-exchange.
/// A ceremony that keeps ending sub-threshold auto-resets straight into the next attempt
/// without ever passing through `InProgress`, which is the only state [`Self::TriggerReset`]
/// accepts - so without this, a looping ceremony can never be stopped or redirected.
TriggerForcedReset {},

/// Transfers ownership of the epoch dealer to another address.
/// This assumes off-chain hand-over of keys
TransferOwnership {
Expand Down
10 changes: 6 additions & 4 deletions common/cosmwasm-smart-contracts/coconut-dkg/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,12 @@ impl Epoch {
///
/// Sitting below the current epoch is not enough: a failed ceremony moves the id on and
/// leaves an epoch behind that concluded nothing, and calling that concluded would let
/// callers cache its empty signer set for good. So the boundary is the epoch in service,
/// which no unconcluded epoch is ever ahead of. Callers working from a cached copy of the
/// current epoch can only get a pessimistic answer out of a stale one, never a premature
/// "yes".
/// callers cache its empty signer set for good. So the boundary is the epoch in service:
/// nothing at or below it can ever change again, and no epoch that can still change reads
/// concluded. (An epoch a failed ceremony abandoned below the boundary reads concluded
/// too - its records are equally frozen, merely empty.) Callers working from a cached
/// copy of the current epoch can only get a pessimistic answer out of a stale one, never
/// a premature "yes".
pub fn is_ceremony_concluded(&self, epoch_id: EpochId) -> bool {
match self.issuing_epoch_id() {
Some(in_service) => epoch_id <= in_service,
Expand Down
52 changes: 49 additions & 3 deletions common/credential-proxy/src/nym_api_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,15 @@ impl CachedEpoch {
#[allow(clippy::unwrap_used)]
let state_end =
OffsetDateTime::from_unix_timestamp(epoch_finish.seconds() as i64).unwrap();
let until_epoch_state_end = state_end - now;
// make it valid until the next epoch transition or next 5min, whichever is smaller
min(until_epoch_state_end, 5 * time::Duration::MINUTE)
if state_end <= now {
// a deadline nothing will ever advance (the settled epoch's lapsed
// self-extension) - an expiry computed against it would land in the
// past and disable the cache for good
5 * time::Duration::MINUTE
} else {
// valid until the next epoch transition or next 5min, whichever is smaller
min(state_end - now, 5 * time::Duration::MINUTE)
}
} else {
5 * time::Duration::MINUTE
};
Expand Down Expand Up @@ -109,3 +115,43 @@ where

Ok(shares)
}

#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::Timestamp;

/// The settled mainnet epoch keeps the deadline of its last self-extension, and since
/// the extension's removal nothing will ever move it. Once that timestamp passes, an
/// expiry computed against it lands in the past - a permanently invalid cache, sending
/// every epoch read to the chain until the next ceremony stores a fresh epoch.
#[test]
fn a_lapsed_deadline_does_not_disable_the_cache() {
let epoch = Epoch {
deadline: Some(Timestamp::from_seconds(1)),
..Default::default()
};

let mut cached = CachedEpoch::default();
cached.update(epoch);

assert!(cached.is_valid());
}

/// The counterpart boundary: a deadline still ahead caps the expiry below the refresh
/// interval, so the copy dies at the state change it cannot see past and never claims
/// a ceremony has concluded when it has not.
#[test]
fn a_nearer_state_end_still_caps_the_cache_validity() {
let now = OffsetDateTime::now_utc().unix_timestamp() as u64;
let epoch = Epoch {
deadline: Some(Timestamp::from_seconds(now + 60)),
..Default::default()
};

let mut cached = CachedEpoch::default();
cached.update(epoch);

assert!(cached.valid_until <= OffsetDateTime::now_utc() + time::Duration::seconds(60));
}
}
10 changes: 5 additions & 5 deletions common/credential-proxy/src/ticketbook_manager/wallet_shares.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,18 @@ use tracing::{debug, error, info, instrument};
use uuid::Uuid;

impl TicketbookManager {
/// `epoch` is resolved once by the caller and used for everything here: the signers asked,
/// the threshold required of them, the auxiliary data returned alongside, the epoch stated on
/// each request, and the epoch the shares are stored under. Resolving it again here would let
/// a ceremony concluding in between hand back shares and auxiliary data from two different
/// epochs, which a client cannot combine.
#[instrument(
skip(self, request_data, request, requested_on),
fields(
expiration_date = %request_data.expiration_date,
ticketbook_type = %request_data.ticketbook_type
)
)]
/// `epoch` is resolved once by the caller and used for everything here: the signers asked,
/// the threshold required of them, the auxiliary data returned alongside, the epoch stated on
/// each request, and the epoch the shares are stored under. Resolving it again here would let
/// a ceremony concluding in between hand back shares and auxiliary data from two different
/// epochs, which a client cannot combine.
pub async fn try_obtain_wallet_shares(
&self,
request: Uuid,
Expand Down
14 changes: 14 additions & 0 deletions contracts/coconut-dkg/schema/nym-coconut-dkg.json
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,20 @@
},
"additionalProperties": false
},
{
"description": "Admin-only escape hatch: force a reset from any epoch state, including mid-exchange. A ceremony that keeps ending sub-threshold auto-resets straight into the next attempt without ever passing through `InProgress`, which is the only state [`Self::TriggerReset`] accepts - so without this, a looping ceremony can never be stopped or redirected.",
"type": "object",
"required": [
"trigger_forced_reset"
],
"properties": {
"trigger_forced_reset": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"description": "Transfers ownership of the epoch dealer to another address. This assumes off-chain hand-over of keys",
"type": "object",
Expand Down
14 changes: 14 additions & 0 deletions contracts/coconut-dkg/schema/raw/execute.json
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,20 @@
},
"additionalProperties": false
},
{
"description": "Admin-only escape hatch: force a reset from any epoch state, including mid-exchange. A ceremony that keeps ending sub-threshold auto-resets straight into the next attempt without ever passing through `InProgress`, which is the only state [`Self::TriggerReset`] accepts - so without this, a looping ceremony can never be stopped or redirected.",
"type": "object",
"required": [
"trigger_forced_reset"
],
"properties": {
"trigger_forced_reset": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"description": "Transfers ownership of the epoch dealer to another address. This assumes off-chain hand-over of keys",
"type": "object",
Expand Down
4 changes: 3 additions & 1 deletion contracts/coconut-dkg/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ use crate::epoch_state::queries::{
};
use crate::epoch_state::storage::{load_current_epoch, save_epoch};
use crate::epoch_state::transactions::{
try_advance_epoch_state, try_initiate_dkg, try_trigger_reset, try_trigger_resharing,
try_advance_epoch_state, try_initiate_dkg, try_trigger_forced_reset, try_trigger_reset,
try_trigger_resharing,
};
use crate::error::ContractError;
use crate::state::queries::query_state;
Expand Down Expand Up @@ -130,6 +131,7 @@ pub fn execute(
ExecuteMsg::AdvanceEpochState {} => try_advance_epoch_state(deps, env),
ExecuteMsg::TriggerReset {} => try_trigger_reset(deps, env, info),
ExecuteMsg::TriggerResharing {} => try_trigger_resharing(deps, env, info),
ExecuteMsg::TriggerForcedReset {} => try_trigger_forced_reset(deps, env, info),
ExecuteMsg::TransferOwnership { transfer_to } => {
try_transfer_ownership(deps, env, info, transfer_to)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ pub fn try_advance_epoch_state(deps: DepsMut<'_>, env: Env) -> Result<Response,
let current_state = current_epoch.state;
let extended = current_epoch.update(current_state, env.block.time);
save_epoch(deps.storage, env.block.height, &extended)?;
return Ok(Response::new());
// the transaction succeeds either way, so the hold has to announce itself - see
// the attribute's doc
return Ok(Response::new().add_attribute(
nym_coconut_dkg_common::event_attributes::AWAITING_DEALERS,
extended.epoch_id.to_string(),
));
}

// `InProgress` is the only state with nothing after it, and `ensure_can_advance_state` has
Expand Down Expand Up @@ -701,7 +706,15 @@ mod tests {
// ceremony would otherwise have walked through, each longer than the longest of them
for _ in 0..5 {
env.block.time = env.block.time.plus_seconds(601);
try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap();
let response = try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap();

// the hold must say so on the transaction itself: it succeeds, and without the
// attribute it would be indistinguishable from a real advance without diffing
// successive epoch queries
assert!(response.attributes.iter().any(|attribute| {
attribute.key == nym_coconut_dkg_common::event_attributes::AWAITING_DEALERS
&& attribute.value == initial_epoch_id.to_string()
}));

let epoch = load_current_epoch(&deps.storage).unwrap();
assert_eq!(
Expand Down Expand Up @@ -752,13 +765,18 @@ mod tests {
});

env.block.time = env.block.time.plus_seconds(601);
try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap();
let response = try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap();
check_epoch_state(
deps.as_ref().storage,
EpochState::DealingExchange { resharing: false },
)
.unwrap();
assert_eq!(THRESHOLD.load(&deps.storage).unwrap(), 1);

// and a real advance does not claim to be waiting
assert!(!response.attributes.iter().any(|attribute| {
attribute.key == nym_coconut_dkg_common::event_attributes::AWAITING_DEALERS
}));
}

/// The same hold applies to resharing, and must not quietly drop the resharing flag.
Expand All @@ -776,14 +794,18 @@ mod tests {
save_epoch(deps.as_mut().storage, env.block.height, &epoch).unwrap();

env.block.time = env.block.time.plus_seconds(601);
try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap();
let response = try_advance_epoch_state(deps.as_mut(), env.clone()).unwrap();

let current = load_current_epoch(&deps.storage).unwrap();
assert_eq!(
current.state,
EpochState::PublicKeySubmission { resharing: true }
);
assert_eq!(current.epoch_id, 7);
assert!(response.attributes.iter().any(|attribute| {
attribute.key == nym_coconut_dkg_common::event_attributes::AWAITING_DEALERS
&& attribute.value == "7"
}));
}

#[test]
Expand Down
Loading
Loading