Skip to content
Open
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
11 changes: 11 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Execution-sensitive paths require maintainer review to prevent unauthorized changes
# that could affect build integrity, runtime behavior, or asset delivery.

.github/workflows/** @karagozemin
.vscode/** @karagozemin
package.json @karagozemin
pnpm-lock.yaml @karagozemin
Cargo.toml @karagozemin
Cargo.lock @karagozemin
scripts/** @karagozemin
**/public/fonts/** @karagozemin
6 changes: 5 additions & 1 deletion .github/workflows/bindings-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ jobs:
- name: Install stellar CLI
# 22.8.2 is the latest stable stellar-cli that still supports soroban-sdk 22.
# CLI 23.0.0 and newer require soroban-sdk 23.
run: cargo install stellar-cli --version 22.8.2 --locked
# Compile with Rust 1.85.0 (stellar-cli 22.8.2 MSRV) because its locked
# ethnum 1.5.0 dependency fails with E0512 on newer stable rustc.
run: |
rustup toolchain install 1.85.0 --profile minimal
RUSTUP_TOOLCHAIN=1.85.0 cargo install stellar-cli --version 22.8.2 --locked

- name: Print Stellar CLI version
run: stellar --version
Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/components/PasskeyPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { publicErrorMessage } from "@sub-rosa/logging/errors";
import { normalizeError, publicErrorMessage } from "@sub-rosa/logging/errors";
// Copyright (c) 2026 Sub Rosa contributors
import { useMemo, useState } from "react";
import { SubRosaClient, validatePasskeySession } from "@sub-rosa/sdk";
Expand Down Expand Up @@ -131,8 +131,8 @@ export function PasskeyPanel({
);
} catch (e) {
setStatus("error");
const err = e instanceof Error ? e : new Error(String(e));
setPanelError(err);
const normalized = normalizeError(e);
setPanelError(new Error(normalized.message));
setMessage(publicErrorMessage(e));
}
}
Expand Down Expand Up @@ -173,8 +173,8 @@ export function PasskeyPanel({
setMessage(`Smart wallet deployed on testnet: ${created.contractId}`);
} catch (e) {
setStatus("error");
const err = e instanceof Error ? e : new Error(String(e));
setPanelError(err);
const normalized = normalizeError(e);
setPanelError(new Error(normalized.message));
const detail = publicErrorMessage(e);
setMessage(
`Deploy failed: ${detail}. Try again after refresh; if it persists, sponsor funding on testnet may be missing (Create passkey alone is enough for the demo).`,
Expand Down Expand Up @@ -233,10 +233,10 @@ export function PasskeyPanel({
setMessage("Passkey commit submitted successfully.");
} catch (e: unknown) {
setStatus("error");
const err = e instanceof Error ? e : new Error(String(e));
setPanelError(err);
const normalized = normalizeError(e);
setPanelError(new Error(normalized.message));
// Ensure fixture secret seed is kept strictly out of the error message
const scrubbed = err.message.replace(/\bS[A-Z2-7]{55}\b/g, "[REDACTED]");
const scrubbed = normalized.message.replace(/\bS[A-Z2-7]{55}\b/g, "[REDACTED]");
setMessage(scrubbed);
}
}
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/hooks/useTraceHealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useMemo } from "react";
import { DEMO_TRACE } from "../demo/trace";
import { assertDemoTrace } from "../demo/trace-health-check";
import { verifyMilestones } from "../demo/demo-trace.checksum";
import { normalizeError } from "@sub-rosa/logging/errors";

export type TraceHealthState =
| { ok: true }
Expand All @@ -24,8 +25,8 @@ export function useTraceHealth(): TraceHealthState {
try {
assertDemoTrace(DEMO_TRACE);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { ok: false, errorCode: `TRACE_INVALID: ${message}` };
const normalized = normalizeError(e);
return { ok: false, errorCode: `TRACE_INVALID: ${normalized.message}` };
}

const phases = (DEMO_TRACE as { lifecycle: Array<{ phase: string }> }).lifecycle.map(
Expand Down
7 changes: 4 additions & 3 deletions contracts/round/ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ 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. |
| 41 | `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. |
| 42 | `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. |
| 43 | `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. |
| 44 | `InvalidCursor` | `get_bidders_page` | Cursor has the wrong length, version, checksum, scope, or bounds. | The bidder cursor is invalid for this round and contract. | Restart with no cursor; pass each returned token unchanged. |

## Escrow accounting (40–49)

Expand Down
48 changes: 47 additions & 1 deletion contracts/round/src/error_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ const ERROR_PATH_REGISTRY: &[(Error, &'static str)] = &[
(Error::RoundFull, "error_path_round_full"),
(Error::InvalidLimit, "error_path_invalid_limit"),
(Error::EscrowNotConserved, "error_path_escrow_not_conserved"),
(Error::SealRoundTooEarly, "error_path_seal_round_too_early"),
(Error::SealRoundTooLate, "error_path_seal_round_too_late"),
(Error::InvalidSealRoundZero, "error_path_invalid_seal_round_zero"),
(Error::InvalidCursor, "error_path_invalid_cursor"),
];

fn oversized_bytes(env: &Env, len: u32) -> Bytes {
Expand Down Expand Up @@ -88,7 +92,7 @@ fn settle_happy_path(f: &Fixture, t_reveal: u64, commit_deadline: u64, reveal_de
fn error_paths_registry_covers_every_variant() {
assert_eq!(
ERROR_PATH_REGISTRY.len(),
28,
32,
"update ERROR_PATH_REGISTRY when adding/removing Error variants"
);
for (variant, name) in ERROR_PATH_REGISTRY {
Expand Down Expand Up @@ -553,6 +557,48 @@ fn error_path_invalid_cursor() {
assert_try_contract_err(f.client.try_get_bidders_page(&id, &Some(Bytes::new(&f.env)), &10), Error::InvalidCursor);
}

#[test]
fn error_path_escrow_not_conserved() {
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 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);
});

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"
);
assert_eq!(
f.usdc_token.balance(&f.client.address),
500,
"a rejected settle must leave every escrow locked"
);
assert_eq!(f.client.get_round(&id).status, Status::Cleared);
}

/// 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]
Expand Down
62 changes: 61 additions & 1 deletion contracts/round/src/storage.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,66 @@
use soroban_sdk::{xdr::ToXdr, Address, Bytes, Env, Vec};

use crate::types::{BidState, DataKey, Error, EscrowLedger, GlobalConfig, Round, Seal};
use crate::types::{BidState, BiddersPage, DataKey, Error, EscrowLedger, GlobalConfig, Round, Seal};

// v1: version (1 byte), next offset (4-byte BE), snapshot count (4-byte BE),
// SHA-256 checksum (32 bytes). See ERRORS.md for the checksum preimage.
fn bidder_cursor(env: &Env, round_id: u64, offset: u32, total: u32) -> Bytes {
let mut header = [0u8; 9];
header[0] = 1;
header[1..5].copy_from_slice(&offset.to_be_bytes());
header[5..9].copy_from_slice(&total.to_be_bytes());
let mut cursor = Bytes::from_array(env, &header);
let preimage = (env.current_contract_address(), round_id, cursor.clone()).to_xdr(env);
cursor.append(&Bytes::from(env.crypto().sha256(&preimage).to_bytes()));
cursor
}

pub fn bidders_page(
env: &Env,
round_id: u64,
cursor: Option<Bytes>,
limit: u32,
) -> Result<BiddersPage, Error> {
let bidders = get_round(env, round_id)?.bidders;
let (start, total) = match cursor {
None => (0, bidders.len()),
Some(cursor) => {
if cursor.len() != 41 || cursor.get(0) != Some(1) {
return Err(Error::InvalidCursor);
}
let mut offset = [0u8; 4];
let mut count = [0u8; 4];
cursor.slice(1..5).copy_into_slice(&mut offset);
cursor.slice(5..9).copy_into_slice(&mut count);
let start = u32::from_be_bytes(offset);
let total = u32::from_be_bytes(count);
if start == 0
|| start > total
|| total > bidders.len()
|| cursor != bidder_cursor(env, round_id, start, total)
{
return Err(Error::InvalidCursor);
}
(start, total)
}
};
let end = (start + limit).min(total);
let mut data = Vec::new(env);
for i in start..end {
data.push_back(bidders.get(i).unwrap());
}
let has_more = end < total;
Ok(BiddersPage {
data,
next_cursor: if has_more {
Some(bidder_cursor(env, round_id, end, total))
} else {
None
},
has_more,
total,
})
}

// TTL policy. Ledger close time on Stellar is ~5s, so these are generous for a
// hackathon-scale round while keeping ephemeral seal data short-lived.
Expand Down
24 changes: 17 additions & 7 deletions contracts/round/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ use crate::storage::{
TEMP_THRESHOLD,
};
use crate::types::{
BidState, ClearingRule, DataKey, Error, EscrowLedger, GlobalConfig, Round, Status,
BidState, ClearingRule, DataKey, Error, EscrowLedger, GlobalConfig, Round, RoundAssetConfig,
Status,
};
use crate::{conserved, SubRosaRound, SubRosaRoundClient};

Expand Down Expand Up @@ -179,7 +180,6 @@ pub fn native_xlm(env: &Env) -> RoundAssetConfig {
}

fn open_round(f: &Fixture, operator: &Address) -> u64 {
let asset_config = sac_asset_config(&f.env);
f.client.create_round(
operator,
&b32(&f.env, 1),
Expand Down Expand Up @@ -342,7 +342,7 @@ fn create_round_rejects_overflowing_reveal_round() {
let operator = Address::generate(&f.env);
let res = f.client.try_create_round(
&operator, &b32(&f.env, 1), &u64::MAX, &ClearingRule::HighestBid,
&1_500, &2_500, &Bytes::from_array(&f.env, b"a"),
&1_500, &2_500, &Bytes::from_array(&f.env, b"a"), &native_xlm(&f.env),
);
assert!(res.is_err());
}
Expand Down Expand Up @@ -1847,7 +1847,7 @@ fn many_bidders_across_multiple_pages_settle_conserving_escrow() {
// Walk the bidder index in three pages; the pages must agree with the full
// list and report a stable total before anything is revealed.
let mut paged: Vec<Address> = Vec::new(&f.env);
let mut cursor: u32 = 0;
let mut cursor: Option<Bytes> = None;
loop {
let page = f.client.get_bidders_page(&id, &cursor, &3);
assert_eq!(
Expand All @@ -1857,7 +1857,7 @@ fn many_bidders_across_multiple_pages_settle_conserving_escrow() {
for i in 0..page.data.len() {
paged.push_back(page.data.get(i).unwrap());
}
if page.next_cursor == 0 {
if !page.has_more {
break;
}
cursor = page.next_cursor;
Expand Down Expand Up @@ -2283,8 +2283,12 @@ pub(super) const DOCUMENTED_ERROR_CODES: &[(Error, u32)] = &[
(Error::NoValidBids, 37),
(Error::RoundFull, 38),
(Error::InvalidLimit, 39),
// ── 40–49: escrow accounting ──
// ── 40–49: escrow accounting & seal/cursor validation ──
(Error::EscrowNotConserved, 40),
(Error::SealRoundTooEarly, 41),
(Error::SealRoundTooLate, 42),
(Error::InvalidSealRoundZero, 43),
(Error::InvalidCursor, 44),
];

/// Convert an `Error` to its on-chain discriminant using the [`repr(u32)`]
Expand Down Expand Up @@ -2324,6 +2328,10 @@ pub(super) fn variant_name(e: Error) -> &'static str {
Error::RoundFull => "RoundFull",
Error::InvalidLimit => "InvalidLimit",
Error::EscrowNotConserved => "EscrowNotConserved",
Error::SealRoundTooEarly => "SealRoundTooEarly",
Error::SealRoundTooLate => "SealRoundTooLate",
Error::InvalidSealRoundZero => "InvalidSealRoundZero",
Error::InvalidCursor => "InvalidCursor",
}
}

Expand Down Expand Up @@ -2371,7 +2379,7 @@ fn error_table_enumerates_every_variant() {
// DOCUMENTED_ERROR_CODES.
assert_eq!(
DOCUMENTED_ERROR_CODES.len(),
28,
32,
"DOCUMENTED_ERROR_CODES appears missing entries. The exhaustive \
`variant_name` match already enforces parity at compile time — \
update it together with this list and contracts/round/ERRORS.md."
Expand Down Expand Up @@ -2415,6 +2423,7 @@ fn shared_pagination_round() -> (Fixture, u64, Vec<Address>) {
&Bytes::from_array(&f.env, b"c"),
&100,
&Bytes::new(&f.env),
&2_000,
);
bidders.push_back(bidder);
}
Expand Down Expand Up @@ -2513,6 +2522,7 @@ fn bidder_cursor_snapshot_survives_append_and_overwrite() {
&Bytes::from_array(&f.env, b"c"),
&100,
&Bytes::new(&f.env),
&2_000,
);
}
let second = f.client.get_bidders_page(&id, &first.next_cursor, &3);
Expand Down
6 changes: 5 additions & 1 deletion contracts/round/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ pub enum Error {
NoValidBids = 37,
RoundFull = 38,
InvalidLimit = 39,
// Escrow accounting
// Escrow accounting & seal/cursor validation
EscrowNotConserved = 40,
SealRoundTooEarly = 41,
SealRoundTooLate = 42,
InvalidSealRoundZero = 43,
InvalidCursor = 44,
}

/// Round lifecycle. Mirrors the state machine in PRD §6.
Expand Down
Loading
Loading