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
50 changes: 17 additions & 33 deletions contracts/round/ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ stay in sync with `contracts/round/src/types.rs`.
| --- | --- |
| 1–4 | Initialization & state lookup |
| 10–22 | Lifecycle & timing |
| 30–40 | Cryptography & validation |
| 30–39 | Cryptography & validation |
| 40–49 | Escrow accounting |

## Initialization & state lookup (1–4)

Expand Down Expand Up @@ -71,38 +72,21 @@ stay in sync with `contracts/round/src/types.rs`.
| 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 | `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. |

## Bidder cursor encoding (v1)

`get_bidders_page(round_id, cursor, limit)` takes `Option<Bytes>`: `None`
starts an enumeration. Pass the returned `next_cursor` unchanged while
`has_more` is true. The terminal page has `has_more = false` and
`next_cursor = None`; an empty round returns an empty terminal page.
Limits remain 1–100 (`InvalidLimit`, code 39).

The 41-byte token contains version `0x01`, next unread zero-based offset
(4 bytes, big endian), snapshot bidder count (4 bytes, big endian), then a
32-byte SHA-256 checksum. The checksum preimage is the canonical Soroban XDR
`ScVal` tuple `(current_contract_address, round_id: u64, header: Bytes)`,
where `header` is the first nine bytes. This binds the token to its contract,
round, offset, and count. Invalid tokens return `InvalidCursor` (40), rather
than silently clamping an offset. Continuation offsets must be positive and
at most the snapshot count, which must not exceed the current bidder count.

The bidder index is append-only, ordered by first commit; overwriting a bid
does not add another index entry. The first page fixes the count. Later
commits are excluded from that enumeration; restart to include them. Cursors
are deterministic and replayable for the same snapshot. The checksum detects
corruption and foreign scope; it is public, not an authentication mechanism
or a claim that a caller has read earlier pages. This changes the pagination
ABI from numeric offsets; deploy matching contract and generated bindings
together. Existing deployed contracts require their previous SDK version.

The SDK iterator follows `has_more`, checks stable counts and forward progress,
and rejects repeated bidder IDs before yielding the affected page with
`SubRosaPaginationError` (`reason = "repeated_bidder"`). Contract and SDK tests
consume the same ordered addresses in `fixtures/bidder-pagination.txt`.
## Escrow accounting (40–49)

Escrow conservation is one predicate, enforced by `reveal`, `clear`, `void`,
and `settle`: committed escrow equals the settled payout plus refunds plus the
balance still locked, and every locked dollar is backed by an unsettled bid in
the round's bidder index.

| Code | Variant | Raised by | Trigger | User-facing message | Suggested next action |
| ---: | --- | --- | --- | --- | --- |
| 40 | `EscrowNotConserved` | `reveal`, `clear`, `settle`, `void` | The round's escrow ledger does not satisfy `committed == payout + refunds + locked`; or the escrow it reports locked is not matched by indexed, unsettled bids; or a planned payout plus refunds would not drain exactly the locked balance. Concretely: a bidder index that drifted from the escrowed set (dropped, duplicated, or phantom bidder), a payout above the winner's escrow, a bid already marked settled, or a locked balance that survives a terminal round. | The round's escrow cannot be reconciled with the bids it was taken against, so no funds were moved. | Do not retry — the round's state is inconsistent. Export a receipt (`exportReceipt` / `getRound` + `getBidState` per bidder) and compare the bidder index against the escrowed set. If the index is intact, re-simulate from a fresh ledger view before escalating. |

Because the check runs before and after the transfers inside a single
invocation, a rejection reverts every transfer the call had already made: a
settle that fails conservation cannot mint, drop, or double-pay escrow even
partially.

## How to use this table

Expand Down
44 changes: 43 additions & 1 deletion contracts/round/src/error_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const ERROR_PATH_REGISTRY: &[(Error, &'static str)] = &[
(Error::NoValidBids, "error_path_no_valid_bids"),
(Error::RoundFull, "error_path_round_full"),
(Error::InvalidLimit, "error_path_invalid_limit"),
(Error::InvalidCursor, "error_path_invalid_cursor"),
(Error::EscrowNotConserved, "error_path_escrow_not_conserved"),
];

fn oversized_bytes(env: &Env, len: u32) -> Bytes {
Expand Down Expand Up @@ -547,3 +547,45 @@ fn error_path_invalid_cursor() {
let id = open_round(&f, &Address::generate(&f.env));
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);
}
Loading