Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions contracts/round/ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ stay in sync with `contracts/round/src/types.rs`.
| 37 | `NoValidBids` | `settle` | `round.winner` is `None` on a round whose status is `Cleared`. | Round has no winner to settle against. | Investigate: under current behavior the contract transitions to `Voided` (with all escrow refunded) when no valid bid is revealed, so this code should not appear in normal flow. If it does, the round is in an inconsistent state and warrants a manual review. |
| 38 | `RoundFull` | `commit` | `round.bidders.len() >= MAX_BIDDERS` (500). | The round has reached its bidder cap. | Start a new round to accept further bidders. |
| 39 | `InvalidLimit` | `get_bidders_page` | `limit == 0` or `limit > 100`. | Page size must be between 1 and 100 (inclusive). | Pass a `limit` in `[1, 100]`; use `next_cursor` from the previous page to walk larger rounds. |
| 40 | `SealRoundTooEarly` | `commit` | `seal_round < round.reveal_round`: the seal names a Drand round before the one the auction committed to open. | The sealed bid is locked to the wrong Drand round. | Reseal the bid to the round published in the `created` event (`reveal_round`) and commit again; no escrow was moved. |
| 41 | `SealRoundTooLate` | `commit` | `seal_round > round.reveal_round`: the seal names a Drand round after the one the auction committed to open. | The sealed bid is locked to the wrong Drand round. | Reseal the bid to the round published in the `created` event (`reveal_round`) and commit again; no escrow was moved. |
| 42 | `InvalidSealRoundZero` | `create_round`, `commit` | `reveal_round == 0` at round creation, or `seal_round == 0` at commit. A zero or overflowing round can never be published by the Drand chain. | The Drand round number is malformed. | Use a positive round the quicknet chain can actually publish (`genesis + period × R` must fit in `u64`); check before locking escrow. |

## Escrow accounting (40–49)

Expand Down
35 changes: 28 additions & 7 deletions contracts/round/src/drand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use soroban_sdk::{
Bytes, BytesN, Env, Vec,
};

use crate::types::GlobalConfig;
use crate::types::{Error, GlobalConfig, Round};

/// Verify a Drand `round` threshold signature on-chain.
///
Expand Down Expand Up @@ -55,10 +55,31 @@ pub fn verify_round(

/// Wall-clock time (unix seconds) at which Drand round `round` is published.
///
/// Saturating arithmetic: an absurdly large `round` clamps to `u64::MAX`, which
/// `create_round` then rejects via its deadline checks rather than panicking.
pub fn time_of_round(config: &GlobalConfig, round: u64) -> u64 {
config
.drand_genesis
.saturating_add(config.drand_period.saturating_mul(round))
/// Returns `None` for a zero round or an intermediate product/sum that would
/// exceed `u64` — i.e. a round number the chain can never publish. Callers must
/// reject `None` instead of comparing a saturating placeholder against real
/// deadlines: an overflowing round must fail validation before any escrow is
/// locked, never silently map to the far future or the epoch.
pub fn checked_time_of_round(config: &GlobalConfig, round: u64) -> Option<u64> {
if round == 0 {
return None;
}
let offset = config.drand_period.checked_mul(round)?;
config.drand_genesis.checked_add(offset)
}

/// Validate that a bidder's seal round equals the round the auction stored.
///
/// The allowed reveal round is always `round.reveal_round` — the value the
/// operator committed to at `create_round` — never a caller-supplied value. A
/// seal for any other round cannot be committed (issue #376). Returns a stable
/// [`Error`] for each failure shape so SDK callers and integrators can branch
/// without parsing strings.
pub fn validate_seal_round(round: &Round, seal_round: u64) -> Result<(), Error> {
match seal_round {
0 => Err(Error::InvalidSealRoundZero),
r if r == round.reveal_round => Ok(()),
r if r > round.reveal_round => Err(Error::SealRoundTooLate),
_ => Err(Error::SealRoundTooEarly),
}
}
106 changes: 72 additions & 34 deletions contracts/round/src/error_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ fn error_path_commit_closed() {
&Bytes::from_array(&f.env, b"c"),
&600,
&Bytes::from_array(&f.env, b"id"),
&2_000,
),
Error::CommitClosed,
);
Expand Down Expand Up @@ -357,6 +358,7 @@ fn error_path_wrong_status() {
&Bytes::from_array(&f.env, b"c"),
&100,
&Bytes::from_array(&f.env, b"id"),
&2_000,
),
Error::WrongStatus,
);
Expand Down Expand Up @@ -449,6 +451,7 @@ fn error_path_invalid_amount() {
&Bytes::from_array(&f.env, b"c"),
&0,
&Bytes::from_array(&f.env, b"id"),
&2_000,
),
Error::InvalidAmount,
);
Expand All @@ -470,6 +473,7 @@ fn error_path_bid_exceeds_escrow() {
&Bytes::from_array(&f.env, b"sealed"),
&500,
&Bytes::from_array(&f.env, b"id-blob"),
&VEC_ROUND,
);
f.env.ledger().with_mut(|l| l.timestamp = t_reveal + 1);
f.client.open_reveal(&id, &real_sig(&f.env));
Expand Down Expand Up @@ -527,6 +531,7 @@ fn error_path_round_full() {
&Bytes::from_array(&f.env, b"c"),
&100,
&Bytes::from_array(&f.env, b"id"),
&2_000,
),
Error::RoundFull,
);
Expand All @@ -548,44 +553,77 @@ fn error_path_invalid_cursor() {
assert_try_contract_err(f.client.try_get_bidders_page(&id, &Some(Bytes::new(&f.env)), &10), Error::InvalidCursor);
}

/// Issue #376: a seal for a round earlier than the stored reveal round is
/// rejected with a stable error before any escrow is locked.
#[test]
fn error_path_escrow_not_conserved() {
let (f, t_reveal, commit_deadline, reveal_deadline) = setup_drand();
fn error_path_seal_round_too_early() {
let (f, _t_reveal, _commit_deadline, _reveal_deadline) = setup_drand();
let operator = Address::generate(&f.env);
let id = drand_round(
&f,
&operator,
commit_deadline,
reveal_deadline,
ClearingRule::HighestBid,
let id = drand_round(&f, &operator, _commit_deadline, _reveal_deadline, ClearingRule::HighestBid);
let bidder = funded_bidder(&f, 1_000);
let nonce = b32(&f.env, 0x01);
let h = commitment(&f.env, 500, &nonce);
assert_try_contract_err(
f.client.try_commit(
&id,
&bidder,
&h,
&Bytes::from_array(&f.env, b"sealed"),
&500,
&Bytes::from_array(&f.env, b"id-blob"),
&(VEC_ROUND - 1),
),
Error::SealRoundTooEarly,
);
let alice = funded_bidder(&f, 1_000);
let a_nonce = commit_bid(&f, id, &alice, 500, 500, 0x01);
f.env.ledger().with_mut(|l| l.timestamp = t_reveal + 1);
f.client.open_reveal(&id, &real_sig(&f.env));
f.client.reveal(&id, &alice, &500, &a_nonce);
f.env
.ledger()
.with_mut(|l| l.timestamp = reveal_deadline + 1);
f.client.clear(&id);

// A winning bid above the winner's escrow would mint tokens out of escrow.
f.env.as_contract(&f.client.address, || {
let mut round = get_round(&f.env, id).unwrap();
round.winning_bid = 900;
set_round(&f.env, id, &round);
});
// No escrow was locked: the bid state must not exist.
assert_try_contract_err(f.client.try_get_bid_state(&id, &bidder), Error::BidNotFound);
}

assert_try_contract_err(f.client.try_settle(&id), Error::EscrowNotConserved);
assert_eq!(
f.usdc_token.balance(&operator),
0,
"a rejected settle must not pay the operator"
/// Issue #376: a seal for a round later than the stored reveal round is
/// rejected with a stable error before any escrow is locked.
#[test]
fn error_path_seal_round_too_late() {
let (f, _t_reveal, _commit_deadline, _reveal_deadline) = setup_drand();
let operator = Address::generate(&f.env);
let id = drand_round(&f, &operator, _commit_deadline, _reveal_deadline, ClearingRule::HighestBid);
let bidder = funded_bidder(&f, 1_000);
let nonce = b32(&f.env, 0x01);
let h = commitment(&f.env, 500, &nonce);
assert_try_contract_err(
f.client.try_commit(
&id,
&bidder,
&h,
&Bytes::from_array(&f.env, b"sealed"),
&500,
&Bytes::from_array(&f.env, b"id-blob"),
&(VEC_ROUND + 1),
),
Error::SealRoundTooLate,
);
assert_eq!(
f.usdc_token.balance(&f.client.address),
500,
"a rejected settle must leave every escrow locked"
assert_try_contract_err(f.client.try_get_bid_state(&id, &bidder), Error::BidNotFound);
}

/// Issue #376: a zero seal round is malformed and rejected before escrow.
#[test]
fn error_path_invalid_seal_round_zero() {
let f = setup();
let operator = Address::generate(&f.env);
let id = open_round(&f, &operator);
let bidder = funded_bidder(&f, 1_000);
let nonce = b32(&f.env, 0x01);
let h = commitment(&f.env, 500, &nonce);
assert_try_contract_err(
f.client.try_commit(
&id,
&bidder,
&h,
&Bytes::from_array(&f.env, b"sealed"),
&500,
&Bytes::from_array(&f.env, b"id-blob"),
&0,
),
Error::InvalidSealRoundZero,
);
assert_eq!(f.client.get_round(&id).status, Status::Cleared);
assert_try_contract_err(f.client.try_get_bid_state(&id, &bidder), Error::BidNotFound);
}
18 changes: 16 additions & 2 deletions contracts/round/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,19 @@ impl SubRosaRound {
bump_instance(&env);

if reveal_round == 0 {
return Err(Error::InvalidAmount);
return Err(Error::InvalidSealRoundZero);
}
if auditor_pubkey.len() > MAX_AUDITOR_PUBKEY {
return Err(Error::PayloadTooLarge);
}

let now = env.ledger().timestamp();
let t_reveal = drand::time_of_round(&config, reveal_round);
// Reject a reveal round the quicknet chain can never publish before any
// deadline comparison: genesis + period×R must fit in u64. A saturating
// placeholder here would let an overflowing round pass the deadline
// checks and strand the round past the void grace window.
let t_reveal = drand::checked_time_of_round(&config, reveal_round)
.ok_or(Error::InvalidSealRoundZero)?;

// Commit must close strictly before R is published, otherwise a bidder
// could decrypt others' sealed bids before committing.
Expand Down Expand Up @@ -134,6 +139,9 @@ impl SubRosaRound {
/// - `escrow` is a public USDC budget and an upper bound on the sealed bid;
/// locked now so the winner can always pay.
/// - `auditor_blob` is the bidder identity encrypted to the auditor key.
/// - `seal_round` must equal the round's stored `reveal_round`: the seal is
/// only meaningful for the Drand round this auction committed to open.
/// Mismatched seals are rejected before any escrow is locked.
pub fn commit(
env: Env,
round_id: u64,
Expand All @@ -142,6 +150,7 @@ impl SubRosaRound {
ciphertext: Bytes,
escrow: i128,
auditor_blob: Bytes,
seal_round: u64,
) -> Result<(), Error> {
bidder.require_auth();
let config = get_config(&env)?;
Expand All @@ -165,6 +174,11 @@ impl SubRosaRound {
if ciphertext.len() > MAX_CIPHERTEXT || auditor_blob.len() > MAX_AUDITOR_BLOB {
return Err(Error::PayloadTooLarge);
}
// The allowed reveal round is the one stored on the auction, not a
// caller-supplied value. Rejecting here — before the escrow transfer —
// means a seal for a different Drand round can never lock funds and the
// bidder keeps their full error budget for honest resubmission.
drand::validate_seal_round(&round, seal_round)?;

let usdc = token::Client::new(&env, &config.usdc);
let contract = env.current_contract_address();
Expand Down
Loading