diff --git a/bindings/src/index.ts b/bindings/src/index.ts index 0ba5a915..810cdf87 100644 --- a/bindings/src/index.ts +++ b/bindings/src/index.ts @@ -551,6 +551,7 @@ export const ContractError = { * No pending oracle rotation proposal to accept or cancel */ 54: {message:"NoPendingRotation"}, + 55: {message:"RotationDelayNotElapsed"}, /** * Invalid archive retention limit */ @@ -558,7 +559,6 @@ export const ContractError = { /** * Commitment hash is malformed (e.g. the all-zero placeholder) */ - 61: {message:"PendingWinningsNotExpired"}, 63: {message:"InvalidCommitment"}, 64: {message:"InvalidSalt"}, 65: {message:"NoRoundTemplate"}, @@ -579,6 +579,7 @@ export const ContractError = { 80: {message:"PositionNotFound"}, 81: {message:"InvalidPhaseForCashout"}, 82: {message:"WrongModeForCashout"}, + 83: {message:"PendingWinningsNotExpired"}, } /** diff --git a/bindings/tests/contract-error-parity.test.js b/bindings/tests/contract-error-parity.test.js index 3939fde0..698cb9b7 100644 --- a/bindings/tests/contract-error-parity.test.js +++ b/bindings/tests/contract-error-parity.test.js @@ -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("has unique Rust discriminants", () => { + const codes = rustVariants.map(({ code }) => code); + expect(new Set(codes).size).toBe(codes.length); + }); + it("has no missing error codes in TS", () => { const missingInTS = []; for (const [code, name] of rustCodes) { diff --git a/contracts/src/errors.rs b/contracts/src/errors.rs index eefe2005..c325189e 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -1,104 +1,104 @@ -// SPDX-License-Identifier: MIT -//! Contract error types for the XLM Price Prediction Market. - -use soroban_sdk::contracterror; - -/// Contract error types -#[contracterror(export = false)] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum ContractError { - AlreadyInitialized = 1, - AdminNotSet = 2, - OracleNotSet = 3, - InvalidBetAmount = 6, - NoActiveRound = 7, - RoundEnded = 8, - InsufficientBalance = 9, - AlreadyBet = 10, - Overflow = 11, - InvalidPrice = 12, - InvalidDuration = 13, - InvalidMode = 14, - WrongModeForPrediction = 15, - RoundNotEnded = 16, - StaleOracleData = 18, - InvalidOracleRound = 19, - RoundAlreadyActive = 20, - ContractPaused = 22, - WindowOutOfRange = 23, - FutureOracleData = 24, - PayoutOverflow = 25, - RoundNotCancellable = 27, - StakeExceedsMax = 28, - ExposureCapExceeded = 29, - PendingWinningsCapExceeded = 30, - InvalidStartPrice = 31, - OracleNonceReused = 33, - InvalidMinParticipants = 35, - InvalidPrecisionCap = 38, - PrecisionCapExceeded = 39, - OracleDeviationExceeded = 41, - UnsupportedSchemaVersion = 42, - MigrationActiveRound = 44, - CommitmentNotFound = 45, - AlreadyRevealed = 46, - InvalidRevealWindow = 47, - HashMismatch = 48, - OracleNetworkMismatch = 49, - InvalidProtocolFeeBps = 51, - MintLimitExceeded = 53, - NoPendingRotation = 54, - /// Oracle rotation delay has not elapsed yet (must wait MIN_ROTATION_DELAY_SECONDS) - RotationDelayNotElapsed = 55, - /// Invalid archive retention limit - InvalidArchiveRetention = 62, - InvalidCommitment = 63, - InvalidSalt = 64, - NoRoundTemplate = 65, - /// Oracle payload timestamp is outside the round-relative economic window - OracleTimestampOutsideWindow = 66, - /// Pending winnings entry exists but has not yet reached the configured - /// expiry threshold — caller must wait before reclaiming. - PendingWinningsNotExpired = 61, - /// Epoch mint budget has been fully consumed - EpochBudgetExceeded = 67, - /// Oracle heartbeat is not live and strict mode blocks settlement (Issue #264) - OracleNotLive = 68, - /// Invalid precision payout policy - InvalidPayoutPolicy = 69, - /// Stake amount is below the configured minimum bet (dust protection, Issue #269) - BelowMinBet = 70, - /// Multi-feed resolution: fewer observations survived outlier rejection - /// than the configured quorum threshold. - InsufficientOracleQuorum = 71, - /// Multi-feed resolution: payload contains fewer observations than the - /// configured minimum. - TooFewObservations = 72, - /// Multi-feed resolution: outlier observations would dominate the result - /// (too many rejected, cannot form quorum). - OracleOutlierRejected = 73, - /// Multi-feed payload contains duplicate source identifiers. - DuplicateOracleSource = 74, - /// Multi-feed payload has observations that are not sorted or sources - /// are out of expected range. - InvalidObservationOrder = 75, - /// The requested data key is not allowed for batch TTL touch operations. - UnsupportedDataKeyForTtlTouch = 76, - /// Pending winnings entry does not exist or expiry is not configured. - 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, - ProposalNotFound = 83, - ProposalExpired = 84, - GovInvalidState = 85, - GovUnauthorized = 86, -} +// SPDX-License-Identifier: MIT +//! Contract error types for the XLM Price Prediction Market. + +use soroban_sdk::contracterror; + +/// Contract error types +#[contracterror(export = false)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ContractError { + AlreadyInitialized = 1, + AdminNotSet = 2, + OracleNotSet = 3, + InvalidBetAmount = 6, + NoActiveRound = 7, + RoundEnded = 8, + InsufficientBalance = 9, + AlreadyBet = 10, + Overflow = 11, + InvalidPrice = 12, + InvalidDuration = 13, + InvalidMode = 14, + WrongModeForPrediction = 15, + RoundNotEnded = 16, + StaleOracleData = 18, + InvalidOracleRound = 19, + RoundAlreadyActive = 20, + ContractPaused = 22, + WindowOutOfRange = 23, + FutureOracleData = 24, + PayoutOverflow = 25, + RoundNotCancellable = 27, + StakeExceedsMax = 28, + ExposureCapExceeded = 29, + PendingWinningsCapExceeded = 30, + InvalidStartPrice = 31, + OracleNonceReused = 33, + InvalidMinParticipants = 35, + InvalidPrecisionCap = 38, + PrecisionCapExceeded = 39, + OracleDeviationExceeded = 41, + UnsupportedSchemaVersion = 42, + MigrationActiveRound = 44, + CommitmentNotFound = 45, + AlreadyRevealed = 46, + InvalidRevealWindow = 47, + HashMismatch = 48, + OracleNetworkMismatch = 49, + InvalidProtocolFeeBps = 51, + MintLimitExceeded = 53, + NoPendingRotation = 54, + /// Oracle rotation delay has not elapsed yet (must wait MIN_ROTATION_DELAY_SECONDS) + RotationDelayNotElapsed = 55, + /// Invalid archive retention limit + InvalidArchiveRetention = 62, + InvalidCommitment = 63, + InvalidSalt = 64, + NoRoundTemplate = 65, + /// Oracle payload timestamp is outside the round-relative economic window + OracleTimestampOutsideWindow = 66, + /// Pending winnings entry exists but has not yet reached the configured + /// expiry threshold — caller must wait before reclaiming. + PendingWinningsNotExpired = 61, + /// Epoch mint budget has been fully consumed + EpochBudgetExceeded = 67, + /// Oracle heartbeat is not live and strict mode blocks settlement (Issue #264) + OracleNotLive = 68, + /// Invalid precision payout policy + InvalidPayoutPolicy = 69, + /// Stake amount is below the configured minimum bet (dust protection, Issue #269) + BelowMinBet = 70, + /// Multi-feed resolution: fewer observations survived outlier rejection + /// than the configured quorum threshold. + InsufficientOracleQuorum = 71, + /// Multi-feed resolution: payload contains fewer observations than the + /// configured minimum. + TooFewObservations = 72, + /// Multi-feed resolution: outlier observations would dominate the result + /// (too many rejected, cannot form quorum). + OracleOutlierRejected = 73, + /// Multi-feed payload contains duplicate source identifiers. + DuplicateOracleSource = 74, + /// Multi-feed payload has observations that are not sorted or sources + /// are out of expected range. + InvalidObservationOrder = 75, + /// The requested data key is not allowed for batch TTL touch operations. + UnsupportedDataKeyForTtlTouch = 76, + /// Pending winnings entry does not exist or expiry is not configured. + 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, + ProposalNotFound = 83, + ProposalExpired = 84, + GovInvalidState = 85, + GovUnauthorized = 86, +} diff --git a/docs/WALLET_ERROR_GUIDE.md b/docs/WALLET_ERROR_GUIDE.md index a74c5565..743e9764 100644 --- a/docs/WALLET_ERROR_GUIDE.md +++ b/docs/WALLET_ERROR_GUIDE.md @@ -1,112 +1,85 @@ # Wallet Error Integration Guide -This guide maps each smart‑contract error defined in `contracts/src/errors.rs` to a consumer‑friendly message and usage example for wallet integrations (e.g., Freighter). +This guide maps each smart-contract error defined in `contracts/src/errors.rs` to a consumer-friendly message and usage example for wallet integrations. ## Error Table -| Hex Code | Decimal | Enum Identifier | Technical Meaning | Consumer‑Facing Message | -|----------|---------|----------------|-------------------|------------------------| -| `0x01` | 1 | AlreadyInitialized | Contract has already been initialized | "Contract already initialized." -| `0x02` | 2 | AdminNotSet | Admin address not set - call initialize first | "Admin not set. Initialize contract first." -| `0x03` | 3 | OracleNotSet | Oracle address not set - call initialize first | "Oracle not set. Initialize contract first." -| `0x04` | 4 | UnauthorizedAdmin | Only admin can perform this action | "Admin only action." -| `0x05` | 5 | UnauthorizedOracle | Only oracle can perform this action | "Oracle only action." -| `0x06` | 6 | InvalidBetAmount | Bet amount must be greater than zero | "Bet amount must be > 0." -| `0x07` | 7 | NoActiveRound | No active round exists | "No active round." -| `0x08` | 8 | RoundEnded | Round has already ended | "Round already ended." -| `0x09` | 9 | InsufficientBalance | User has insufficient balance | "Insufficient balance." -| `0x0a` | 10 | AlreadyBet | User has already placed a bet in this round | "Bet already placed this round." -| `0x0b` | 11 | Overflow | Arithmetic overflow occurred | "Arithmetic overflow." -| `0x0c` | 12 | InvalidPrice | Invalid price value | "Invalid price." -| `0x0d` | 13 | InvalidDuration | Invalid duration value | "Invalid duration." -| `0x0e` | 14 | InvalidMode | Invalid round mode (must be 0 or 1) | "Invalid round mode." -| `0x0f` | 15 | WrongModeForPrediction | Wrong prediction type for current round mode | "Wrong prediction type for round mode." -| `0x10` | 16 | RoundNotEnded | Round has not reached end_ledger yet | "Round not yet ended." -| `0x11` | 17 | InvalidPriceScale | Invalid price scale (must represent 4 decimal places) | "Invalid price scale." -| `0x12` | 18 | StaleOracleData | Oracle data is too old (STALE) | "Stale oracle data." -| `0x13` | 19 | InvalidOracleRound | Oracle payload round_id doesn't match ActiveRound | "Mismatched oracle round ID." -| `0x14` | 20 | RoundAlreadyActive | An active round already exists and cannot be overwritten | "Active round already exists." -| `0x15` | 21 | AdminIsOracle | Admin and Oracle addresses cannot be identical | "Admin cannot be Oracle." -| `0x16` | 22 | ContractPaused | Contract is paused for emergency recovery | "Contract paused." -| `0x17` | 23 | WindowOutOfRange | One or more window values exceed configured maximum bounds | "Window value out of range." -| `0x18` | 24 | FutureOracleData | Oracle payload timestamp is in the future | "Future oracle timestamp." -| `0x19` | 25 | PayoutOverflow | Arithmetic overflow in payout accumulation — no funds moved | "Payout overflow." -| `0x1a` | 26 | RoundCancelled | Round has been cancelled and cannot be resolved | "Round cancelled." -| `0x1b` | 27 | RoundNotCancellable | Round cannot be cancelled (no active round or already resolved) | "Round not cancellable." -| `0x1c` | 28 | StakeExceedsMax | Bet amount exceeds the configured maximum stake | "Bet exceeds max stake." -| `0x1d` | 29 | ExposureCapExceeded | User's cumulative exposure in this round exceeds the configured cap | "Exposure cap exceeded." -| `0x1e` | 30 | PendingWinningsCapExceeded | Pending winnings accumulation would exceed the configured cap | "Pending winnings cap exceeded." -| `0x1f` | 31 | StartPriceTooLow | Start price is below the minimum allowed value | "Start price too low." -| `0x20` | 32 | StartPriceTooHigh | Start price exceeds the maximum allowed value | "Start price too high." -| `0x21` | 33 | OracleNonceReused | Oracle payload nonce was already consumed for this round (replay) | "Oracle nonce reused." -| `0x22` | 34 | InsufficientParticipants | Round has fewer participants than the configured minimum for competitive settlement | "Insufficient participants." -| `0x23` | 35 | InvalidMinParticipants | Minimum participants value is out of valid range (must be 1–10000) | "Invalid min participants."| `0x24` | 36 | InvalidOracleStatus | Oracle heartbeat status is out of range (must be 0, 1, or 2) | "Invalid oracle status." | -| `0x42` | 66 | OracleNotLive | Oracle heartbeat is not live and strict mode blocks settlement | "Oracle heartbeat not live." | `0x25` | 37 | InvalidStaleThreshold | Oracle stale threshold is out of valid range (must be 60–86400 seconds) | "Invalid stale threshold." -| `0x26` | 38 | InvalidOracleDeviationBps | Oracle max deviation bps is invalid (must be > 0) | "Invalid oracle deviation BPS." -| `0x27` | 39 | OracleDeviationExceeded | Oracle final price deviates beyond configured threshold | "Oracle deviation exceeded." -| `0x28` | 40 | UnsupportedSchemaVersion | Stored schema version is unknown or unsupported by this contract build | "Unsupported schema version." -| `0x29` | 41 | InvalidMigrationPath | Migration path is invalid for the stored schema version | "Invalid migration path." -| `0x2a` | 42 | MigrationActiveRound | Migration cannot run while a round is active | "Migration not allowed during active round." -| `0x2b` | 43 | CommitmentNotFound | Commitment for precision prediction not found | "Precision commitment not found." -| `0x2c` | 44 | AlreadyRevealed | Precision prediction has already been revealed | "Prediction already revealed." -| `0x2d` | 45 | InvalidRevealWindow | Attempted to reveal prediction outside the valid window | "Invalid reveal window." -| `0x2e` | 46 | HashMismatch | Revealed prediction hash does not match committed hash | "Hash mismatch." -| `0x2f` | 47 | PrecisionParticipantCapExceeded | Precision round has reached the configured participant cap | "Precision participant cap exceeded." -| `0x30` | 48 | InvalidPrecisionParticipantCap | Precision participant cap is out of range (must be 1–10000) | "Invalid precision participant cap." -| `0x3f` | 63 | InvalidCommitment | Commitment hash is malformed (e.g. all-zero placeholder) | "Invalid commitment hash." -| `0x40` | 64 | InvalidSalt | Reveal salt fails minimum entropy rules | "Invalid reveal salt." -| `0x41` | 65 | NoRoundTemplate | No round template configured | "No round template." -| `0x4f` | 79 | EarlyCashoutDisabled | Early cash-out is disabled or penalty rate is unset | "Early cash-out is currently disabled." -| `0x50` | 80 | PositionNotFound | User has no active position in the round to cash out | "No active position found to cash out." -| `0x51` | 81 | InvalidPhaseForCashout | Early cash-out is only permitted during the running phase | "Early cash-out only available during running phase." -| `0x52` | 82 | WrongModeForCashout | Early cash-out is only supported for UpDown rounds | "Early cash-out is not supported in Precision mode." +| Hex Code | Decimal | Enum Identifier | Technical Meaning | Consumer-Facing Message | +|----------|---------|-----------------|------------------|------------------------| +| `0x01` | 1 | AlreadyInitialized | Contract has already been initialized | "Contract already initialized." | +| `0x02` | 2 | AdminNotSet | Admin address is not set | "Admin not set. Initialize contract first." | +| `0x03` | 3 | OracleNotSet | Oracle address is not set | "Oracle not set. Initialize contract first." | +| `0x06` | 6 | InvalidBetAmount | Bet amount must be greater than zero | "Bet amount must be > 0." | +| `0x07` | 7 | NoActiveRound | No active round exists | "No active round." | +| `0x08` | 8 | RoundEnded | Round has already ended | "Round already ended." | +| `0x09` | 9 | InsufficientBalance | User has insufficient balance | "Insufficient balance." | +| `0x0a` | 10 | AlreadyBet | User already placed a bet | "Bet already placed this round." | +| `0x0b` | 11 | Overflow | Arithmetic overflow occurred | "Arithmetic overflow." | +| `0x0c` | 12 | InvalidPrice | Invalid price value | "Invalid price." | +| `0x0d` | 13 | InvalidDuration | Invalid duration value | "Invalid duration." | +| `0x0e` | 14 | InvalidMode | Invalid round mode | "Invalid round mode." | +| `0x0f` | 15 | WrongModeForPrediction | Wrong prediction type for the round mode | "Wrong prediction type for round mode." | +| `0x10` | 16 | RoundNotEnded | Round has not reached its end ledger | "Round not yet ended." | +| `0x12` | 18 | StaleOracleData | Oracle data is too old | "Stale oracle data." | +| `0x13` | 19 | InvalidOracleRound | Oracle round does not match the active round | "Mismatched oracle round ID." | +| `0x14` | 20 | RoundAlreadyActive | An active round already exists | "Active round already exists." | +| `0x16` | 22 | ContractPaused | Contract is paused | "Contract paused." | +| `0x17` | 23 | WindowOutOfRange | Window value exceeds configured bounds | "Window value out of range." | +| `0x18` | 24 | FutureOracleData | Oracle timestamp is in the future | "Future oracle timestamp." | +| `0x19` | 25 | PayoutOverflow | Payout arithmetic overflowed | "Payout overflow." | +| `0x1b` | 27 | RoundNotCancellable | Round cannot be cancelled | "Round not cancellable." | +| `0x1c` | 28 | StakeExceedsMax | Bet exceeds maximum stake | "Bet exceeds max stake." | +| `0x1d` | 29 | ExposureCapExceeded | User exposure exceeds its cap | "Exposure cap exceeded." | +| `0x1e` | 30 | PendingWinningsCapExceeded | Pending winnings exceed their cap | "Pending winnings cap exceeded." | +| `0x1f` | 31 | InvalidStartPrice | Start price is invalid | "Invalid start price." | +| `0x21` | 33 | OracleNonceReused | Oracle nonce was already consumed | "Oracle nonce reused." | +| `0x23` | 35 | InvalidMinParticipants | Minimum participants value is invalid | "Invalid min participants." | +| `0x26` | 38 | InvalidPrecisionCap | Precision participant cap is invalid | "Invalid precision participant cap." | +| `0x27` | 39 | PrecisionCapExceeded | Precision participant cap was reached | "Precision participant cap exceeded." | +| `0x29` | 41 | OracleDeviationExceeded | Oracle price deviation exceeds the configured threshold | "Oracle deviation exceeded." | +| `0x2a` | 42 | UnsupportedSchemaVersion | Stored schema version is unsupported | "Unsupported schema version." | +| `0x2c` | 44 | MigrationActiveRound | Migration is blocked during an active round | "Migration not allowed during active round." | +| `0x2d` | 45 | CommitmentNotFound | Precision commitment was not found | "Precision commitment not found." | +| `0x2e` | 46 | AlreadyRevealed | Prediction was already revealed | "Prediction already revealed." | +| `0x2f` | 47 | InvalidRevealWindow | Reveal was attempted outside the valid window | "Invalid reveal window." | +| `0x30` | 48 | HashMismatch | Revealed prediction does not match its commitment | "Hash mismatch." | +| `0x31` | 49 | OracleNetworkMismatch | Oracle payload targets another network | "Oracle network mismatch." | +| `0x33` | 51 | InvalidProtocolFeeBps | Protocol fee is outside the allowed range | "Invalid protocol fee." | +| `0x35` | 53 | MintLimitExceeded | Mint rate limit was exceeded | "Mint limit exceeded." | +| `0x36` | 54 | NoPendingRotation | No pending oracle rotation exists | "No pending oracle rotation." | +| `0x37` | 55 | RotationDelayNotElapsed | Oracle rotation delay has not elapsed | "Oracle rotation delay has not elapsed." | +| `0x3e` | 62 | InvalidArchiveRetention | Archive retention limit is invalid | "Invalid archive retention limit." | +| `0x3f` | 63 | InvalidCommitment | Commitment hash is malformed | "Invalid commitment hash." | +| `0x40` | 64 | InvalidSalt | Reveal salt fails minimum entropy rules | "Invalid reveal salt." | +| `0x41` | 65 | NoRoundTemplate | No round template is configured | "No round template." | +| `0x43` | 67 | EpochBudgetExceeded | Epoch mint budget was fully consumed | "Epoch mint budget exceeded." | +| `0x44` | 68 | OracleNotLive | Oracle heartbeat is not live | "Oracle heartbeat not live." | +| `0x45` | 69 | InvalidPayoutPolicy | Precision payout policy is invalid | "Invalid payout policy." | +| `0x46` | 70 | BelowMinBet | Stake is below the configured minimum bet | "Bet is below the minimum amount." | +| `0x47` | 71 | InsufficientOracleQuorum | Too few observations survived outlier rejection | "Insufficient oracle quorum." | +| `0x48` | 72 | TooFewObservations | Oracle payload contains too few observations | "Too few oracle observations." | +| `0x49` | 73 | OracleOutlierRejected | Oracle outlier rejection prevented settlement | "Oracle outlier rejected." | +| `0x4a` | 74 | DuplicateOracleSource | Oracle payload contains duplicate sources | "Duplicate oracle source." | +| `0x4b` | 75 | InvalidObservationOrder | Oracle observations are not in the required order | "Invalid oracle observation order." | +| `0x4c` | 76 | UnsupportedDataKeyForTtlTouch | Data key is not supported for TTL touch | "Unsupported data key for TTL touch." | +| `0x4d` | 77 | PendingWinningsNotFound | Pending winnings entry does not exist | "Pending winnings not found." | +| `0x4e` | 78 | ExpiryNotConfigured | Pending winnings expiry is not configured | "Pending winnings expiry is not configured." | +| `0x4f` | 79 | EarlyCashoutDisabled | Early cash-out is disabled | "Early cash-out is currently disabled." | +| `0x50` | 80 | PositionNotFound | User has no active position to cash out | "No active position found to cash out." | +| `0x51` | 81 | InvalidPhaseForCashout | Cash-out is outside the running phase | "Early cash-out only available during running phase." | +| `0x52` | 82 | WrongModeForCashout | Early cash-out is only supported for UpDown rounds | "Early cash-out is not supported in Precision mode." | ## Integration Walkthroughs -### 1. Handling errors in a Freighter wallet + ```ts import { ContractErrorDecoder } from "@xelma/contracts"; function handleError(error: any) { const code = error.result?.xdr?.value?.val?.code ?? 0; - const message = ContractErrorDecoder(code); - alert(message); + alert(ContractErrorDecoder(code)); } ``` -### 2. Displaying user‑friendly messages in a React UI -```tsx -import { ContractErrorDecoder } from "@xelma/contracts"; - -function ErrorBanner({code}: {code: number}) { - return