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
4 changes: 2 additions & 2 deletions bindings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ suitable for wallet UX.
import { decodeContractError, formatContractError } from "bindings"

// Structured result for programmatic handling
const decoded = decodeContractError(4)
const decoded = decodeContractError(79)
if (decoded) {
console.log(`Error ${decoded.code}: ${decoded.variant} — ${decoded.message}`)
// => "Error 4: UnauthorizedAdminUnauthorizedAdmin"
// => "Error 79: AccessDeniedAccessDenied"
}

// One-liner for display in a wallet or bot
Expand Down
12 changes: 12 additions & 0 deletions bindings/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ export class NoRoundTemplateError extends XelmaError {
}
}

export class AccessDeniedError extends XelmaError {
constructor() {
super(
"Participant is blocked by the active access-control policy",
79,
"AccessDenied",
);
this.name = "AccessDeniedError";
}
}

// ─── Error Mapping ─────────────────────────────────────────────

const ERROR_CODE_TO_CLASS: Record<number, new () => XelmaError> = {
Expand All @@ -80,6 +91,7 @@ const ERROR_CODE_TO_CLASS: Record<number, new () => XelmaError> = {
28: StakeExceedsMaxError,
29: ExposureCapExceededError,
65: NoRoundTemplateError,
79: AccessDeniedError,
};

/**
Expand Down
50 changes: 45 additions & 5 deletions bindings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,10 @@ export const ContractError = {
* No pending oracle rotation proposal to accept or cancel
*/
54: {message:"NoPendingRotation"},
/**
* Oracle rotation delay has not elapsed
*/
55: {message:"RotationDelayNotElapsed"},
/**
* Invalid archive retention limit
*/
Expand All @@ -561,24 +565,60 @@ export const ContractError = {
61: {message:"PendingWinningsNotExpired"},
63: {message:"InvalidCommitment"},
64: {message:"InvalidSalt"},
/**
* No round template is configured
*/
65: {message:"NoRoundTemplate"},
/**
* Oracle timestamp is outside the round-relative economic window
*/
66: {message:"OracleTimestampOutsideWindow"},
/**
* Epoch mint budget has been fully consumed
*/
67: {message:"EpochBudgetExceeded"},
/**
* Oracle heartbeat is not live and strict mode blocks settlement (Issue #264)
*/
68: {message:"OracleNotLive"},
/**
* Invalid precision payout policy
*/
69: {message:"InvalidPayoutPolicy"},
/** Stake is below the configured minimum bet. */
70: {message:"BelowMinBet"},
/** Too few oracle observations survived quorum validation. */
71: {message:"InsufficientOracleQuorum"},
/** Multi-feed payload has too few observations. */
72: {message:"TooFewObservations"},
/** Oracle observation was rejected as an outlier. */
73: {message:"OracleOutlierRejected"},
/** Multi-feed payload contains a duplicate source. */
74: {message:"DuplicateOracleSource"},
/** Multi-feed observations are in an invalid order. */
75: {message:"InvalidObservationOrder"},
/** Data key is not allowed for batch TTL touch. */
76: {message:"UnsupportedDataKeyForTtlTouch"},
/** Pending winnings entry was not found. */
77: {message:"PendingWinningsNotFound"},
/** Pending winnings expiry is not configured. */
78: {message:"ExpiryNotConfigured"},
79: {message:"EarlyCashoutDisabled"},
80: {message:"PositionNotFound"},
81: {message:"InvalidPhaseForCashout"},
82: {message:"WrongModeForCashout"},
/** Participant is blocked by the active access-control policy. */
79: {message:"AccessDenied"},
/** Governance proposal was not found. */
80: {message:"ProposalNotFound"},
/** Governance proposal has expired. */
81: {message:"ProposalExpired"},
/** Governance proposal is in an invalid state. */
82: {message:"GovInvalidState"},
/** Caller is unauthorized by the governance policy. */
83: {message:"GovUnauthorized"},
/** Action is invalid in the current round lifecycle phase. */
84: {message:"IllegalPhaseTransition"},
/** Oracle heartbeat failed the configured health policy. */
85: {message:"OracleHeartbeatUnhealthy"},
/** Pending winnings have not reached their expiry threshold. */
86: {message:"PendingWinningsNotExpired"}
}

/**
Expand Down Expand Up @@ -1437,4 +1477,4 @@ export class Client extends ContractClient {
get_season_leaderboard_by_wins: this.txFromJSON<Array<SeasonLeaderboardEntry>>,
get_season_leaderboard_by_streak: this.txFromJSON<Array<SeasonLeaderboardEntry>>
}
}
}
5 changes: 5 additions & 0 deletions bindings/tests/contract-error-parity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ while ((entry = tsEntryRegex.exec(tsMapMatch[1])) !== null) {
const rustCodes = new Map(rustVariants.map(v => [v.code, v.name]));

describe("Contract Error Parity", () => {
it("maps AccessDenied to its stable contract code", () => {
expect(rustCodes.get(79)).toBe("AccessDenied");
expect(tsCodes.get(79)).toBe("AccessDenied");
});

it("has no missing error codes in TS", () => {
const missingInTS = [];
for (const [code, name] of rustCodes) {
Expand Down
7 changes: 7 additions & 0 deletions bindings/tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
StakeExceedsMaxError,
ExposureCapExceededError,
NoRoundTemplateError,
AccessDeniedError,
wrapContractError,
type MintResult,
type ClaimResult,
Expand Down Expand Up @@ -211,6 +212,12 @@ describe("wrapContractError", () => {
const err = wrapContractError({ code: 65 });
expect(err).toBeInstanceOf(NoRoundTemplateError);
});

it("maps access-control rejection code 79 to AccessDeniedError", () => {
const err = wrapContractError({ code: 79 });
expect(err).toBeInstanceOf(AccessDeniedError);
expect((err as AccessDeniedError).variant).toBe("AccessDenied");
});
});

// ─── simulateBet ───────────────────────────────────────────────
Expand Down
5 changes: 4 additions & 1 deletion contracts/src/access_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ pub fn get_access_policy(env: Env, user: Address) -> (bool, AccessState) {
/// must win even on open deployments.
/// 2. If allowlist mode is enabled, an `user` that is not allowlisted is rejected.
/// 3. Otherwise, the call proceeds (default open).
///
/// Both rejection paths return the stable, dedicated
/// [`ContractError::AccessDenied`] error (code `79`).
pub fn _enforce_access_control(env: &Env, user: &Address) -> Result<(), ContractError> {
if env
.storage()
Expand Down Expand Up @@ -263,4 +266,4 @@ fn _emit_list_changed(env: &Env, list: &str, action: &str, user: &Address, recon
(symbol_short!("access"), detail),
(user.clone(), reconciled),
);
}
}
6 changes: 3 additions & 3 deletions contracts/src/betting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ pub fn commit_prediction(
// Reject clearly invalid commitment placeholders early (before balance
// reads / deductions) so griefing commits cannot lock liquidity.
if is_zero_bytes32(&env, &hash) {
return Err(ContractError::InvalidPrice);
return Err(ContractError::InvalidCommitment);
}

if amount <= 0 {
Expand Down Expand Up @@ -602,7 +602,7 @@ pub fn reveal_prediction(
// Enforce salt entropy before any storage reads so malformed reveals fail
// fast with an explicit error (not HashMismatch after a wasted lookup).
if !salt_has_minimum_entropy(&salt) {
return Err(ContractError::InvalidPrice);
return Err(ContractError::InvalidSalt);
}

// Single read of the active round
Expand Down Expand Up @@ -937,4 +937,4 @@ pub fn mint_initial(env: Env, user: Address) -> i128 {
);

initial_amount
}
}
24 changes: 15 additions & 9 deletions contracts/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ pub enum ContractError {
OracleTimestampOutsideWindow = 66,
/// Pending winnings entry exists but has not yet reached the configured
/// expiry threshold — caller must wait before reclaiming.
PendingWinningsNotExpired = 61,
PendingWinningsNotExpired = 86,
/// Epoch mint budget has been fully consumed
EpochBudgetExceeded = 67,
/// Oracle heartbeat is not live and strict mode blocks settlement (Issue #264)
Expand Down Expand Up @@ -89,12 +89,18 @@ pub enum ContractError {
PendingWinningsNotFound = 77,
/// Pending winnings expiry is not configured (value is 0).
ExpiryNotConfigured = 78,
/// Early cash-out feature is disabled or not configured
EarlyCashoutDisabled = 79,
/// User does not have an active position to cash out
PositionNotFound = 80,
/// Early cash-out attempted outside the valid running phase
InvalidPhaseForCashout = 81,
/// Early cash-out is only supported for UpDown rounds
WrongModeForCashout = 82,
/// Participant is blocked by the active allowlist or denylist policy.
AccessDenied = 79,
/// Governance proposal does not exist.
ProposalNotFound = 80,
/// Governance proposal is past its execution deadline.
ProposalExpired = 81,
/// Governance proposal cannot transition from its current state.
GovInvalidState = 82,
/// Caller is not authorized by the configured governance policy.
GovUnauthorized = 83,
/// Requested action is not valid in the round's current lifecycle phase.
IllegalPhaseTransition = 84,
/// Oracle heartbeat failed the configured freshness or health policy.
OracleHeartbeatUnhealthy = 85,
}
Loading