diff --git a/contracts/admin/src/lib.rs b/contracts/admin/src/lib.rs index 188b2117..e6cd500d 100644 --- a/contracts/admin/src/lib.rs +++ b/contracts/admin/src/lib.rs @@ -322,6 +322,10 @@ pub enum AdminError { RoleNotHeldForRenounce = 28, /// The last SuperAdmin tried to renounce; at least one must remain (#915). LastSuperAdmin = 29, + /// A role proposal could not be found for the requested proposal ID. + RoleProposalNotFound = 30, + /// A role proposal must be executed through `execute_role_proposal`. + RoleProposalRequiresExecution = 31, } /// Storage keys for the access-control layer. @@ -394,6 +398,9 @@ pub enum AdminKey { /// [`get_instance_ttl`] derives a non-negative remaining lifetime from this /// value. It is not one of the five monitored singleton keys. InstanceLiveUntil, + /// Role grant action attached to a legacy proposal, keyed by proposal ID. + /// Appended to preserve every existing storage-key discriminant. + RoleGrantProposal(u64), } /// Instance TTL and the count of known singleton admin keys. @@ -594,6 +601,16 @@ mod tests { super::create_proposal(&env, creator, description) } + pub fn create_role_proposal( + env: Env, + creator: Address, + role: Role, + target: Address, + description: String, + ) -> u64 { + super::create_role_proposal(&env, creator, role, target, description) + } + pub fn approve_proposal(env: Env, admin: Address, proposal_id: u64) { super::approve_proposal(&env, admin, proposal_id); } @@ -606,6 +623,21 @@ mod tests { super::mark_executed(&env, proposal_id); } + pub fn execute_role_proposal( + env: Env, + executor: Address, + proposal_id: u64, + ) -> Result<(), AdminError> { + super::execute_role_proposal(&env, executor, proposal_id) + } + + pub fn get_role_proposal( + env: Env, + proposal_id: u64, + ) -> Option { + super::get_role_proposal(&env, proposal_id) + } + pub fn cancel_legacy_proposal( env: Env, caller: Address, diff --git a/contracts/admin/src/multisig.rs b/contracts/admin/src/multisig.rs index cdc3de83..54a7d26f 100644 --- a/contracts/admin/src/multisig.rs +++ b/contracts/admin/src/multisig.rs @@ -15,7 +15,7 @@ use soroban_sdk::{contracttype, vec, Address, BytesN, Env, Map, String, Vec}; use crate::address::require_non_zero_address; use crate::events; -use crate::rbac::{get_admin, has_admin, require_admin}; +use crate::rbac::{get_admin, has_admin, require_admin, require_super_admin, Role}; use crate::reentrancy_guard; use crate::{extend_instance_ttl, extend_storage_ttl_for_key, AdminError, AdminKey}; @@ -60,6 +60,27 @@ pub struct Proposal { pub cancelled: bool, } +/// Role grant bound to a legacy multisig proposal without changing the +/// persisted `Proposal` layout. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct RoleGrantAction { + pub role: Role, + pub target: Address, +} + +/// Role proposal information needed by the voting interface. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct RoleGrantProposalDetails { + pub description: String, + pub role: Role, + pub target: Address, + pub approvals: u32, + pub quorum: u32, + pub executed: bool, +} + /// Lifecycle state of an [`UpgradeProposal`]. /// /// A single enum rather than a set of booleans: the upgrade flow needs @@ -419,6 +440,59 @@ pub fn create_proposal(env: &Env, creator: Address, description: String) -> u64 id } +/// Creates a multisig proposal that grants `role` to `target` when executed. +pub fn create_role_proposal( + env: &Env, + creator: Address, + role: Role, + target: Address, + description: String, +) -> u64 { + require_non_zero_address(env, &target); + if matches!(role, Role::Admin) { + soroban_sdk::panic_with_error!(env, AdminError::InvalidRole); + } + if crate::rbac::has_role(env, role, &target) { + soroban_sdk::panic_with_error!(env, AdminError::RoleAlreadyGranted); + } + let id = create_proposal(env, creator, description); + env.storage() + .instance() + .set(&AdminKey::RoleGrantProposal(id), &RoleGrantAction { role, target }); + extend_instance_ttl(env); + id +} + +/// Returns proposal details for the voting panel. +pub fn get_proposal(env: &Env, proposal_id: u64) -> Option { + let proposal = env + .storage() + .instance() + .get(&AdminKey::Proposal(proposal_id)); + if proposal.is_some() { + extend_instance_ttl(env); + } + proposal +} + +/// Returns a role proposal with its live approval tally, or `None` for a +/// generic proposal or unknown ID. +pub fn get_role_proposal(env: &Env, proposal_id: u64) -> Option { + let action: RoleGrantAction = env + .storage() + .instance() + .get(&AdminKey::RoleGrantProposal(proposal_id))?; + let proposal = get_proposal(env, proposal_id)?; + Some(RoleGrantProposalDetails { + description: proposal.description, + role: action.role, + target: action.target, + approvals: proposal.approvals.len(), + quorum: get_threshold(env), + executed: proposal.executed, + }) +} + /// Approves a multi-sig governance proposal. /// /// @notice Approves proposal `proposal_id` on behalf of `admin`. @@ -488,6 +562,14 @@ pub fn mark_executed(env: &Env, proposal_id: u64) { let admin = get_admin(env); admin.require_auth(); + if env + .storage() + .instance() + .has(&AdminKey::RoleGrantProposal(proposal_id)) + { + soroban_sdk::panic_with_error!(env, AdminError::RoleProposalRequiresExecution); + } + let mut proposal: Proposal = env .storage() .instance() @@ -514,6 +596,51 @@ pub fn mark_executed(env: &Env, proposal_id: u64) { extend_instance_ttl(env); } +/// Executes a quorum-approved role grant proposal. +pub fn execute_role_proposal( + env: &Env, + executor: Address, + proposal_id: u64, +) -> Result<(), AdminError> { + let _reentrancy_guard = reentrancy_guard::enter(env); + executor.require_auth(); + if !get_admin_pool(env).contains(&executor) { + return Err(AdminError::UnauthorizedRole); + } + require_super_admin(env, &executor); + + let mut proposal: Proposal = env + .storage() + .instance() + .get(&AdminKey::Proposal(proposal_id)) + .ok_or(AdminError::ProposalNotFound)?; + if proposal.executed { + return Err(AdminError::ProposalAlreadyExecuted); + } + if proposal.cancelled { + return Err(AdminError::ProposalCancelled); + } + if _proposal_expired(env, &proposal) { + return Err(AdminError::ProposalExpired); + } + if !is_proposal_ready(env, proposal_id) { + return Err(AdminError::ThresholdNotMet); + } + + let action: RoleGrantAction = env + .storage() + .instance() + .get(&AdminKey::RoleGrantProposal(proposal_id)) + .ok_or(AdminError::RoleProposalNotFound)?; + crate::rbac::_grant_role(env, &executor, action.role, &action.target); + proposal.executed = true; + env.storage() + .instance() + .set(&AdminKey::Proposal(proposal_id), &proposal); + extend_instance_ttl(env); + Ok(()) +} + fn _proposal_expired(env: &Env, proposal: &Proposal) -> bool { match proposal.expiry_ledger { Some(expiry_ledger) => env.ledger().sequence() > expiry_ledger, @@ -547,6 +674,9 @@ pub fn cancel_legacy_proposal( } proposal.cancelled = true; + env.storage() + .instance() + .remove(&AdminKey::RoleGrantProposal(proposal_id)); env.storage() .instance() .set(&AdminKey::Proposal(proposal_id), &proposal); diff --git a/contracts/admin/src/rbac.rs b/contracts/admin/src/rbac.rs index b3c75aa0..dec25147 100644 --- a/contracts/admin/src/rbac.rs +++ b/contracts/admin/src/rbac.rs @@ -487,7 +487,7 @@ pub fn grant_role(env: &Env, caller: &Address, role: Role, address: &Address) { /// - [`AdminError::RoleAlreadyGranted`] — `address` already holds `role` (#768). /// # Events /// Emits `role_grnt` with data `(admin, role, address)`. -fn _grant_role(env: &Env, admin: &Address, role: Role, address: &Address) { +pub(crate) fn _grant_role(env: &Env, admin: &Address, role: Role, address: &Address) { require_non_zero_address(env, address); let bit = match role_bit(role) { Some(bit) => bit, @@ -644,9 +644,36 @@ pub fn revoke_role( require_valid_role(env, role); require_non_zero_address(env, address); + if role == Role::SuperAdmin && is_last_super_admin(env, address) { + return Err(AdminError::LastSuperAdmin); + } + _revoke_role(env, role, address) } +/// Returns whether removing this holder would leave the configured admin set +/// without any effective SuperAdmin. +pub(crate) fn is_last_super_admin(env: &Env, address: &Address) -> bool { + if !has_role(env, Role::SuperAdmin, address) { + return false; + } + let admin = get_admin(env); + if admin != *address { + return false; + } + let pool = crate::multisig::get_admin_pool(env); + for i in 0..pool.len() { + let member = pool.get(i).expect("index in range"); + if member != *address { + let mask = load_raw_role_mask(env, &member); + if mask & (ROLE_BIT_ADMIN | ROLE_BIT_SUPER_ADMIN) != 0 { + return false; + } + } + } + true +} + /// Removes a role assignment without performing authorization. /// /// This helper is intentionally private. Callers exposed by a contract must diff --git a/contracts/admin/src/tests/issue_914_918.rs b/contracts/admin/src/tests/issue_914_918.rs index b31cd6de..f6d9b6cd 100644 --- a/contracts/admin/src/tests/issue_914_918.rs +++ b/contracts/admin/src/tests/issue_914_918.rs @@ -32,6 +32,60 @@ fn wasm_hash(env: &Env, byte: u8) -> BytesN<32> { BytesN::from_array(env, &[byte; 32]) } +#[test] +fn test_role_proposal_requires_quorum_and_executes_the_bound_grant() { + let env = Env::default(); + let (client, admin) = setup(&env); + let second_admin = Address::generate(&env); + let target = Address::generate(&env); + client.set_admin_pool(&vec![&env, admin.clone(), second_admin.clone()], &2); + + let proposal_id = client.create_role_proposal( + &admin, + &Role::Minter, + &target, + &String::from_str(&env, "Grant Minter to target"), + ); + let details = client + .get_role_proposal(&proposal_id) + .expect("role proposal details are available"); + assert_eq!(details.role, Role::Minter); + assert_eq!(details.target, target); + assert_eq!(details.approvals, 1); + assert_eq!(details.quorum, 2); + assert!(!details.executed); + + assert_eq!( + client.try_execute_role_proposal(&admin, &proposal_id), + Err(Ok(AdminError::ThresholdNotMet)), + ); + assert_eq!( + client.try_mark_executed(&proposal_id), + Err(Ok(AdminError::RoleProposalRequiresExecution)), + ); + + client.approve_proposal(&second_admin, &proposal_id); + client.execute_role_proposal(&admin, &proposal_id); + assert!(client.has_role(&Role::Minter, &target)); + assert!( + client + .get_role_proposal(&proposal_id) + .expect("executed proposal remains readable") + .executed + ); +} + +#[test] +fn test_revoke_role_rejects_removing_the_only_configured_super_admin() { + let env = Env::default(); + let (client, admin) = setup(&env); + + assert_eq!( + client.try_revoke_role(&admin, &Role::SuperAdmin, &admin), + Err(Ok(AdminError::LastSuperAdmin)), + ); +} + // ───────────────────────────────────────────────────────────────────────────── // #914 — privileged-action timelock // ───────────────────────────────────────────────────────────────────────────── diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 0ff76c0f..0d7844aa 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -1136,6 +1136,41 @@ impl BcForgeToken { admin::create_proposal(&env, creator, description) } + /// Checks whether an address holds a role. + pub fn has_role(env: Env, role: admin::Role, address: Address) -> bool { + Self::ensure_initialized(&env).expect("token must be initialized"); + admin::has_role(&env, role, &address) + } + + /// Grants a role to an address through the shared admin module. + pub fn grant_role(env: Env, caller: Address, role: admin::Role, address: Address) { + Self::ensure_initialized(&env).expect("token must be initialized"); + admin::grant_role(&env, &caller, role, &address); + } + + /// Revokes a role from an address through the shared admin module. + pub fn revoke_role( + env: Env, + caller: Address, + role: admin::Role, + address: Address, + ) -> Result<(), admin::AdminError> { + Self::ensure_initialized(&env).expect("token must be initialized"); + admin::revoke_role(&env, &caller, role, &address) + } + + /// Creates a multisig proposal that grants a role after approval. + pub fn create_role_proposal( + env: Env, + creator: Address, + role: admin::Role, + target: Address, + description: String, + ) -> u64 { + Self::ensure_initialized(&env).expect("token must be initialized"); + admin::create_role_proposal(&env, creator, role, target, description) + } + /// Approves a multi-sig governance proposal. /// /// @notice Records `admin`'s approval for `proposal_id`. @@ -1151,12 +1186,55 @@ impl BcForgeToken { admin::is_proposal_ready(&env, proposal_id) } + /// Returns the details for a legacy multisig proposal. + pub fn get_proposal(env: Env, proposal_id: u64) -> Option { + Self::ensure_initialized(&env).expect("token must be initialized"); + admin::get_proposal(&env, proposal_id) + } + + /// Returns role action details and approval tally for a proposal. + pub fn get_role_proposal( + env: Env, + proposal_id: u64, + ) -> Option { + Self::ensure_initialized(&env).expect("token must be initialized"); + admin::get_role_proposal(&env, proposal_id) + } + + /// Lists pending and approved legacy multisig proposals by page. + pub fn list_legacy_proposals(env: Env, start_after: u64) -> admin::LegacyProposalLists { + Self::ensure_initialized(&env).expect("token must be initialized"); + admin::list_legacy_proposals(&env, start_after) + } + + /// Executes a role grant after its multisig proposal reaches quorum. + pub fn execute_role_proposal( + env: Env, + executor: Address, + proposal_id: u64, + ) -> Result<(), admin::AdminError> { + Self::ensure_initialized(&env).expect("token must be initialized"); + admin::execute_role_proposal(&env, executor, proposal_id) + } + /// Configures the multi-sig admin pool and approval threshold for upgrades. pub fn set_admin_pool(env: Env, pool: Vec
, threshold: u32) { Self::ensure_initialized(&env).expect("token must be initialized"); admin::set_admin_pool(&env, pool, threshold); } + /// Returns the configured multisig admin pool. + pub fn get_admin_pool(env: Env) -> Vec
{ + Self::ensure_initialized(&env).expect("token must be initialized"); + admin::get_admin_pool(&env) + } + + /// Returns the configured multisig approval threshold. + pub fn get_threshold(env: Env) -> u32 { + Self::ensure_initialized(&env).expect("token must be initialized"); + admin::get_threshold(&env) + } + /// Executes a quorum-approved WASM upgrade on this token contract. /// /// @notice Applies `wasm_hash` after the referenced proposal meets quorum. diff --git a/react/src/components/APYChart.tsx b/react/src/components/APYChart.tsx index 998db9de..ca4d5594 100644 --- a/react/src/components/APYChart.tsx +++ b/react/src/components/APYChart.tsx @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -import React, { useCallback, useEffect, useState } from 'react'; -import { calculateApy } from '@bc-forge/sdk'; -import type { ApyOptions, ApyResult } from '@bc-forge/sdk'; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { calculateApy } from "@bc-forge/sdk"; +import type { ApyOptions, ApyResult } from "@bc-forge/sdk"; -import { formatApy } from '../utils'; -import { Alert } from './Alert'; +import { formatApy } from "../utils"; +import { Alert } from "./Alert"; /** One labelled sample of the series the chart plots. */ export interface ApyChartPoint { @@ -44,30 +44,38 @@ export interface APYChartProps { } const PLOT_PADDING = 8; +type ApyChartRange = "7d" | "30d" | "90d"; + +const RANGE_LOOKBACK_LEDGERS: Record = { + "7d": 120_960, + "30d": 518_400, + "90d": 1_555_200, +}; +const APY_RANGES: ApyChartRange[] = ["7d", "30d", "90d"]; const CARD_STYLE: React.CSSProperties = { - display: 'flex', - flexDirection: 'column', + display: "flex", + flexDirection: "column", gap: 8, padding: 16, - border: '1px solid #e5e7eb', + border: "1px solid #e5e7eb", borderRadius: 8, - backgroundColor: '#f9fafb', - fontFamily: 'inherit', + backgroundColor: "#f9fafb", + fontFamily: "inherit", }; const CAPTION_STYLE: React.CSSProperties = { - display: 'flex', - justifyContent: 'space-between', + display: "flex", + justifyContent: "space-between", fontSize: 12, - color: '#6b7280', + color: "#6b7280", }; const AXIS_LABEL_STYLE: React.CSSProperties = { fontSize: 12, - color: '#6b7280', - textTransform: 'uppercase', - letterSpacing: '0.04em', + color: "#6b7280", + textTransform: "uppercase", + letterSpacing: "0.04em", }; function extent(values: number[]): { min: number; max: number; flat: boolean } { @@ -85,7 +93,11 @@ function toPolyline( points: ApyChartPoint[], width: number, height: number, -): { polyline: string; yForValue: (value: number) => number; xForIndex: (index: number) => number } { +): { + polyline: string; + yForValue: (value: number) => number; + xForIndex: (index: number) => number; +} { const { min, max, flat } = extent(points.map((p) => p.value)); const span = flat ? 1 : max - min; const drawableWidth = width - PLOT_PADDING * 2; @@ -98,8 +110,11 @@ function toPolyline( PLOT_PADDING + drawableHeight * (1 - (flat ? 0.5 : (value - min) / span)); const polyline = points - .map((point, index) => `${xForIndex(index).toFixed(2)},${yForValue(point.value).toFixed(2)}`) - .join(' '); + .map( + (point, index) => + `${xForIndex(index).toFixed(2)},${yForValue(point.value).toFixed(2)}`, + ) + .join(" "); return { polyline, yForValue, xForIndex }; } @@ -111,9 +126,9 @@ function toAreaPath( width: number, height: number, ): string { - if (points.length < 2) return ''; + if (points.length < 2) return ""; const baseline = height - PLOT_PADDING; - return `M ${polyline.replace(/ /g, ' L ')} L ${width - PLOT_PADDING},${baseline} L ${PLOT_PADDING},${baseline} Z`; + return `M ${polyline.replace(/ /g, " L ")} L ${width - PLOT_PADDING},${baseline} L ${PLOT_PADDING},${baseline} Z`; } /** @@ -142,9 +157,9 @@ export const APYChart: React.FC = ({ options, height = 180, width = 640, - title = 'Vault APY', + title = "Vault APY", formatValue = formatApy, - emptyMessage = 'No APY data available.', + emptyMessage = "No APY data available.", style, className, }) => { @@ -152,21 +167,29 @@ export const APYChart: React.FC = ({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [attempt, setAttempt] = useState(0); + const [range, setRange] = useState("30d"); const shouldFetch = !points && !result && Boolean(options); + const fetchOptions = useMemo( + () => + options + ? { ...options, lookbackLedgers: RANGE_LOOKBACK_LEDGERS[range] } + : undefined, + [options, range], + ); const load = useCallback(async () => { - if (!options) return; + if (!fetchOptions) return; try { setLoading(true); setError(null); - setFetched(await calculateApy(options)); + setFetched(await calculateApy(fetchOptions)); } catch (err) { setError(err instanceof Error ? err : new Error(String(err))); } finally { setLoading(false); } - }, [options]); + }, [fetchOptions]); useEffect(() => { if (!shouldFetch) return; @@ -176,15 +199,47 @@ export const APYChart: React.FC = ({ }, [shouldFetch, load, attempt]); const retry = useCallback(() => setAttempt((n) => n + 1), []); + const rangeSelector = shouldFetch ? ( +
+ {APY_RANGES.map((option) => ( + + ))} +
+ ) : null; const activeResult = result ?? fetched ?? undefined; - const source = points ?? (activeResult ? pointsFromResult(activeResult) : undefined); + const source = + points ?? (activeResult ? pointsFromResult(activeResult) : undefined); const usable = (source ?? []).filter((point) => Number.isFinite(point.value)); if (shouldFetch && loading && usable.length === 0) { return (
-
Loading APY…
+ {rangeSelector} +
Loading APY…
); } @@ -192,6 +247,7 @@ export const APYChart: React.FC = ({ if (error) { return (
+ {rangeSelector} {error.message} @@ -200,13 +256,13 @@ export const APYChart: React.FC = ({ onClick={retry} style={{ marginTop: 8, - padding: '6px 12px', - backgroundColor: '#ffffff', - color: '#374151', - border: '1px solid #d1d5db', + padding: "6px 12px", + backgroundColor: "#ffffff", + color: "#374151", + border: "1px solid #d1d5db", borderRadius: 6, - cursor: 'pointer', - fontFamily: 'inherit', + cursor: "pointer", + fontFamily: "inherit", }} > Retry @@ -218,7 +274,11 @@ export const APYChart: React.FC = ({ if (usable.length === 0) { return (
-
+ {rangeSelector} +
{emptyMessage}
@@ -234,26 +294,35 @@ export const APYChart: React.FC = ({ const { polyline, yForValue, xForIndex } = toPolyline(usable, width, height); const areaPath = toAreaPath(polyline, usable, width, height); - const summary = `${title}: ${usable.length} point${usable.length === 1 ? '' : 's'} from ${formatValue( + const summary = `${title}: ${usable.length} point${usable.length === 1 ? "" : "s"} from ${formatValue( min, )} to ${formatValue(max)}, latest ${formatValue(latest.value)}`; return ( -
+
{title} - + {formatValue(latest.value)}
+ {rangeSelector} + = ({ width="100%" height={height} preserveAspectRatio="none" - style={{ display: 'block' }} + style={{ display: "block" }} > {areaPath ? : null} {usable.length === 1 ? ( @@ -310,16 +379,16 @@ export const APYChart: React.FC = ({ onClick={retry} disabled={loading} style={{ - border: 'none', - background: 'transparent', - color: '#2563eb', - cursor: loading ? 'progress' : 'pointer', - fontFamily: 'inherit', + border: "none", + background: "transparent", + color: "#2563eb", + cursor: loading ? "progress" : "pointer", + fontFamily: "inherit", fontSize: 11, padding: 0, }} > - {loading ? 'Refreshing…' : 'Refresh'} + {loading ? "Refreshing…" : "Refresh"} ) : ( {latest.label} diff --git a/react/src/components/ProductComponents.test.tsx b/react/src/components/ProductComponents.test.tsx index 5382aaa7..e8e26913 100644 --- a/react/src/components/ProductComponents.test.tsx +++ b/react/src/components/ProductComponents.test.tsx @@ -1,19 +1,20 @@ // SPDX-License-Identifier: MIT -import '@testing-library/jest-dom'; -import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; -import type { VaultClient } from '@bc-forge/sdk'; - -import { BcForgeProvider } from '../context'; -import { APYChart } from './APYChart'; -import { ProposalVotingPanel } from './ProposalVotingPanel'; -import { TokenCard } from './TokenCard'; -import { TransactionToast } from './TransactionToast'; -import { VaultDepositWidget } from './VaultDepositWidget'; +import "@testing-library/jest-dom"; +import React from "react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import type { VaultClient } from "@bc-forge/sdk"; + +import { BcForgeProvider } from "../context"; +import { APYChart } from "./APYChart"; +import { ProposalVotingPanel } from "./ProposalVotingPanel"; +import { TokenCard } from "./TokenCard"; +import { TransactionToast } from "./TransactionToast"; +import { VaultDepositWidget } from "./VaultDepositWidget"; const mockCalculateApy = jest.fn(); const mockBcForgeClient = jest.fn(); -jest.mock('@bc-forge/sdk', () => ({ +jest.mock("@bc-forge/sdk", () => ({ calculateApy: (...args: unknown[]) => mockCalculateApy(...args), // A plain function so `new bcForgeClient(config)` in BcForgeProvider works. bcForgeClient: function bcForgeClient(...args: unknown[]) { @@ -22,14 +23,14 @@ jest.mock('@bc-forge/sdk', () => ({ })); const config = { - rpcUrl: 'https://soroban-testnet.stellar.org', - networkPassphrase: 'Test SDF Network ; September 2015', - contractId: 'CA123', + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "CA123", }; -describe('Product components (#950)', () => { - describe('TokenCard', () => { - it('renders a fully prop-driven card without any provider', () => { +describe("Product components (#950)", () => { + describe("TokenCard", () => { + it("renders a fully prop-driven card without any provider", () => { render( { />, ); - expect(screen.getByTestId('token-card')).toBeInTheDocument(); - expect(screen.getByTestId('token-card-name')).toHaveTextContent('Bitcoin'); - expect(screen.getByTestId('token-card-symbol')).toHaveTextContent('BTC'); - expect(screen.getByTestId('token-card-balance')).toHaveTextContent('2500 BTC'); - expect(screen.getByText('8 decimals')).toBeInTheDocument(); - expect(screen.getByTestId('token-card-contract')).toHaveTextContent('CAS3…Z4X8'); + expect(screen.getByTestId("token-card")).toBeInTheDocument(); + expect(screen.getByTestId("token-card-name")).toHaveTextContent( + "Bitcoin", + ); + expect(screen.getByTestId("token-card-symbol")).toHaveTextContent("BTC"); + expect(screen.getByTestId("token-card-balance")).toHaveTextContent( + "2500 BTC", + ); + expect(screen.getByText("8 decimals")).toBeInTheDocument(); + expect(screen.getByTestId("token-card-contract")).toHaveTextContent( + "CAS3…Z4X8", + ); }); - it('shows a placeholder balance when no client and no balance are available', () => { + it("shows a placeholder balance when no client and no balance are available", () => { render(); - expect(screen.getByTestId('token-card-balance')).toHaveTextContent('—'); + expect(screen.getByTestId("token-card-balance")).toHaveTextContent("—"); }); - it('falls back to the SDK client and reports its balance failure', async () => { + it("falls back to the SDK client and reports its balance failure", async () => { mockBcForgeClient.mockImplementation(() => ({ - getName: jest.fn().mockResolvedValue('Bitcoin'), - getSymbol: jest.fn().mockResolvedValue('BTC'), + getName: jest.fn().mockResolvedValue("Bitcoin"), + getSymbol: jest.fn().mockResolvedValue("BTC"), getDecimals: jest.fn().mockResolvedValue(8), - getBalance: jest.fn().mockRejectedValue(new Error('RPC unavailable')), + getBalance: jest.fn().mockRejectedValue(new Error("RPC unavailable")), setWalletAdapter: jest.fn(), getWalletAdapter: jest.fn(), })); @@ -69,83 +76,170 @@ describe('Product components (#950)', () => { , ); - expect(await screen.findByTestId('token-card-name')).toHaveTextContent('Bitcoin'); - expect(screen.getByTestId('token-card-symbol')).toHaveTextContent('BTC'); - expect(await screen.findByTestId('token-card-error')).toHaveTextContent('RPC unavailable'); + expect(await screen.findByTestId("token-card-name")).toHaveTextContent( + "Bitcoin", + ); + expect(screen.getByTestId("token-card-symbol")).toHaveTextContent("BTC"); + expect(await screen.findByTestId("token-card-error")).toHaveTextContent( + "RPC unavailable", + ); }); }); - describe('APYChart', () => { - it('draws a multi-point series and reports min/max/latest', () => { + describe("APYChart", () => { + it("draws a multi-point series and reports min/max/latest", () => { render( , ); - const chart = screen.getByTestId('apy-chart'); - const img = chart.querySelector('svg'); - expect(img).toHaveAttribute('role', 'img'); - expect(img).toHaveAttribute('aria-label', expect.stringContaining('3 points')); - expect(screen.getByTestId('apy-chart-latest')).toHaveTextContent('2.00%'); - expect(screen.getByTestId('apy-chart-min')).toHaveTextContent('Min 1.00%'); - expect(screen.getByTestId('apy-chart-max')).toHaveTextContent('Max 3.00%'); + const chart = screen.getByTestId("apy-chart"); + const img = chart.querySelector("svg"); + expect(img).toHaveAttribute("role", "img"); + expect(img).toHaveAttribute( + "aria-label", + expect.stringContaining("3 points"), + ); + expect(screen.getByTestId("apy-chart-latest")).toHaveTextContent("2.00%"); + expect(screen.getByTestId("apy-chart-min")).toHaveTextContent( + "Min 1.00%", + ); + expect(screen.getByTestId("apy-chart-max")).toHaveTextContent( + "Max 3.00%", + ); }); - it('centres a single reading as a flat line', () => { - render(); - expect(screen.getByTestId('apy-chart-latest')).toHaveTextContent('5.00%'); - expect(screen.getByTestId('apy-chart')).toContainElement(screen.getByRole('img')); + it("centres a single reading as a flat line", () => { + render(); + expect(screen.getByTestId("apy-chart-latest")).toHaveTextContent("5.00%"); + expect(screen.getByTestId("apy-chart")).toContainElement( + screen.getByRole("img"), + ); }); - it('shows the empty message with nothing to plot', () => { + it("shows the empty message with nothing to plot", () => { render(); - expect(screen.getByTestId('apy-chart-empty')).toHaveTextContent('Nothing yet'); + expect(screen.getByTestId("apy-chart-empty")).toHaveTextContent( + "Nothing yet", + ); }); - it('fetches the APY itself when options are provided', async () => { + it("fetches the APY itself when options are provided", async () => { mockCalculateApy.mockResolvedValue({ apy: 0.071, windowDays: 30, current: { ledger: 1234 }, }); - render(); + render( + , + ); - expect(await screen.findByTestId('apy-chart-latest')).toHaveTextContent('7.10%'); + expect(await screen.findByTestId("apy-chart-latest")).toHaveTextContent( + "7.10%", + ); expect(mockCalculateApy).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('apy-chart-window')).toHaveTextContent('30.00 day window'); + expect(screen.getByTestId("apy-chart-window")).toHaveTextContent( + "30.00 day window", + ); + }); + + it("recalculates APY with the selected historical range", async () => { + mockCalculateApy.mockResolvedValue({ + apy: 0.04, + windowDays: 7, + current: { ledger: 1234 }, + }); + + render( + , + ); + + await screen.findByTestId("apy-chart-latest"); + expect(screen.getByTestId("apy-range-30d")).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(mockCalculateApy).toHaveBeenLastCalledWith( + expect.objectContaining({ lookbackLedgers: 518_400 }), + ); + + fireEvent.click(screen.getByTestId("apy-range-7d")); + await waitFor(() => + expect(mockCalculateApy).toHaveBeenLastCalledWith( + expect.objectContaining({ lookbackLedgers: 120_960 }), + ), + ); + + fireEvent.click(screen.getByTestId("apy-range-90d")); + await waitFor(() => + expect(mockCalculateApy).toHaveBeenLastCalledWith( + expect.objectContaining({ lookbackLedgers: 1_555_200 }), + ), + ); }); - it('surfaces a fetch error with a working retry', async () => { + it("surfaces a fetch error with a working retry", async () => { mockCalculateApy - .mockRejectedValueOnce(new Error('node down')) - .mockResolvedValueOnce({ apy: 0.02, windowDays: 7, current: { ledger: 9 } }); + .mockRejectedValueOnce(new Error("node down")) + .mockResolvedValueOnce({ + apy: 0.02, + windowDays: 7, + current: { ledger: 9 }, + }); - render(); + render( + , + ); - expect(await screen.findByRole('alert')).toHaveTextContent('node down'); - fireEvent.click(screen.getByRole('button', { name: 'Retry' })); - expect(await screen.findByTestId('apy-chart-latest')).toHaveTextContent('2.00%'); + expect(await screen.findByRole("alert")).toHaveTextContent("node down"); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByTestId("apy-chart-latest")).toHaveTextContent( + "2.00%", + ); }); }); - describe('VaultDepositWidget', () => { + describe("VaultDepositWidget", () => { const makeClient = () => { const client = { getDecimals: jest.fn().mockResolvedValue(7), getShareBalance: jest.fn().mockResolvedValue(5000n), calculateRewards: jest.fn().mockResolvedValue(1000n), - deposit: jest.fn().mockResolvedValue({ success: true, hash: 'VAULTDEPOSIT123' }), + deposit: jest + .fn() + .mockResolvedValue({ success: true, hash: "VAULTDEPOSIT123" }), }; return client as unknown as VaultClient; }; - const renderWidget = (client: VaultClient, props: Record = {}) => + const renderWidget = ( + client: VaultClient, + props: Record = {}, + ) => render( { />, ); - it('previews the shares the contract would mint', async () => { + it("previews the shares the contract would mint", async () => { const client = makeClient(); renderWidget(client); - fireEvent.change(screen.getByPlaceholderText(/positive integer amount/i), { - target: { value: '10' }, - }); + fireEvent.change( + screen.getByPlaceholderText(/positive integer amount/i), + { + target: { value: "10" }, + }, + ); - expect(await screen.findByTestId('vault-share-estimate')).toHaveTextContent('≈ 1000 shares'); + expect( + await screen.findByTestId("vault-share-estimate"), + ).toHaveTextContent("≈ 1000 shares"); expect(client.calculateRewards).toHaveBeenCalledWith(10n); }); - it('submits a signed deposit and reports success in the toast', async () => { + it("submits a signed deposit and reports success in the toast", async () => { const client = makeClient(); renderWidget(client); - fireEvent.change(screen.getByPlaceholderText(/positive integer amount/i), { - target: { value: '10' }, - }); - fireEvent.click(screen.getByRole('button', { name: 'Deposit' })); + fireEvent.change( + screen.getByPlaceholderText(/positive integer amount/i), + { + target: { value: "10" }, + }, + ); + fireEvent.click(screen.getByRole("button", { name: "Deposit" })); - expect(await screen.findByTestId('transaction-toast')).toHaveAttribute('data-status', 'success'); - expect(screen.getByTestId('transaction-toast-hash')).toHaveTextContent('VAULTDEPOSIT123'); + expect(await screen.findByTestId("transaction-toast")).toHaveAttribute( + "data-status", + "success", + ); + expect(screen.getByTestId("transaction-toast-hash")).toHaveTextContent( + "VAULTDEPOSIT123", + ); // No keypair source: the connected wallet adapter signs. expect(client.deposit).toHaveBeenCalledWith( - 'GAVTHAJHFEHX4PZ7GR6O5V2DSJ6G6J4U5XQZ4XK7X5XQZ', + "GAVTHAJHFEHX4PZ7GR6O5V2DSJ6G6J4U5XQZ4XK7X5XQZ", 10n, undefined, undefined, ); - await waitFor(() => expect(screen.getByPlaceholderText(/positive integer amount/i)).toHaveValue('')); + await waitFor(() => + expect( + screen.getByPlaceholderText(/positive integer amount/i), + ).toHaveValue(""), + ); }); - it('keeps the button disabled when no account is available', () => { + it("keeps the button disabled when no account is available", () => { const client = makeClient(); renderWidget(client, { address: undefined }); - fireEvent.change(screen.getByPlaceholderText(/positive integer amount/i), { - target: { value: '10' }, - }); - expect(screen.getByRole('button', { name: 'Deposit' })).toBeDisabled(); + fireEvent.change( + screen.getByPlaceholderText(/positive integer amount/i), + { + target: { value: "10" }, + }, + ); + expect(screen.getByRole("button", { name: "Deposit" })).toBeDisabled(); }); - it('rejects a fractional amount before touching the contract', async () => { + it("rejects a fractional amount before touching the contract", async () => { const client = makeClient(); renderWidget(client); - fireEvent.change(screen.getByPlaceholderText(/positive integer amount/i), { - target: { value: '1.5' }, - }); - fireEvent.submit(screen.getByTestId('vault-deposit-widget').querySelector('form') as HTMLFormElement); + fireEvent.change( + screen.getByPlaceholderText(/positive integer amount/i), + { + target: { value: "1.5" }, + }, + ); + fireEvent.submit( + screen + .getByTestId("vault-deposit-widget") + .querySelector("form") as HTMLFormElement, + ); - expect(await screen.findByRole('alert')).toHaveTextContent('must be a positive integer'); + expect(await screen.findByRole("alert")).toHaveTextContent( + "must be a positive integer", + ); expect(client.deposit).not.toHaveBeenCalled(); }); }); - describe('ProposalVotingPanel', () => { + describe("ProposalVotingPanel", () => { const proposals = [ - { id: 1, action: 'Mint', description: 'Mint 100 tokens to the treasury', approvals: 2, quorum: 3 }, + { + id: 1, + action: "Mint", + description: "Mint 100 tokens to the treasury", + approvals: 2, + quorum: 3, + }, { id: 2, executed: true }, ]; - it('routes votes through onVote outside a provider', async () => { + it("routes votes through onVote outside a provider", async () => { const onVote = jest.fn(async () => {}); const onExecute = jest.fn(async () => {}); render( - , + , ); const rows = screen.getAllByTestId(/^proposal-\d+$/); expect(rows).toHaveLength(2); - expect(screen.getByText('Proposal #1 · Mint')).toBeInTheDocument(); - expect(screen.getByText('2 of 3 approvals')).toBeInTheDocument(); - expect(screen.getByText('Executed')).toBeInTheDocument(); + expect(screen.getByText("Proposal #1 · Mint")).toBeInTheDocument(); + expect(screen.getByText("2 of 3 approvals")).toBeInTheDocument(); + expect(screen.getByText("Executed")).toBeInTheDocument(); - fireEvent.click(withinRow(1).getByRole('button', { name: 'Approve' })); + fireEvent.click(withinRow(1).getByRole("button", { name: "Approve" })); await waitFor(() => expect(onVote).toHaveBeenCalledWith(1n)); }); - it('routes execution through onExecute when quorum is reached', async () => { + it("routes execution through onExecute when quorum is reached", async () => { const onExecute = jest.fn(async () => {}); render( { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Execute' })); + fireEvent.click(screen.getByRole("button", { name: "Execute" })); await waitFor(() => expect(onExecute).toHaveBeenCalledWith(7n)); }); - it('keeps every control disabled with no provider and no handlers', () => { + it("keeps every control disabled with no provider and no handlers", () => { render(); - expect(screen.getByRole('alert')).toHaveTextContent('SDK unavailable'); - screen.getAllByRole('button', { name: 'Approve' }).forEach((b: HTMLElement) => expect(b).toBeDisabled()); - screen.getAllByRole('button', { name: 'Execute' }).forEach((b: HTMLElement) => expect(b).toBeDisabled()); + expect(screen.getByRole("alert")).toHaveTextContent("SDK unavailable"); + screen + .getAllByRole("button", { name: "Approve" }) + .forEach((b: HTMLElement) => expect(b).toBeDisabled()); + screen + .getAllByRole("button", { name: "Execute" }) + .forEach((b: HTMLElement) => expect(b).toBeDisabled()); }); - it('shows the empty message with no proposals', () => { + it("shows the empty message with no proposals", () => { render(); - expect(screen.getByTestId('proposal-voting-empty')).toHaveTextContent('All clear'); + expect(screen.getByTestId("proposal-voting-empty")).toHaveTextContent( + "All clear", + ); }); }); - describe('TransactionToast', () => { - it('is importable from the component index and honours an explorer link', () => { + describe("TransactionToast", () => { + it("is importable from the component index and honours an explorer link", () => { render( { />, ); - const link = screen.getByRole('link'); + const link = screen.getByRole("link"); expect(link).toHaveAttribute( - 'href', - 'https://stellar.expert/explorer/testnet/tx/ABCDEF', + "href", + "https://stellar.expert/explorer/testnet/tx/ABCDEF", ); }); }); @@ -285,4 +425,4 @@ describe('Product components (#950)', () => { function withinRow(id: number) { return within(screen.getByTestId(`proposal-${id}`)); -} \ No newline at end of file +} diff --git a/react/src/components/ProposalVotingPanel.tsx b/react/src/components/ProposalVotingPanel.tsx index 90c324eb..9cfe2797 100644 --- a/react/src/components/ProposalVotingPanel.tsx +++ b/react/src/components/ProposalVotingPanel.tsx @@ -1,13 +1,16 @@ // SPDX-License-Identifier: MIT -import React, { useState } from 'react'; -import type { Keypair } from '@stellar/stellar-sdk'; -import type { TransactionResult } from '@bc-forge/sdk'; +import React, { useState } from "react"; +import type { Keypair } from "@stellar/stellar-sdk"; +import type { TransactionResult } from "@bc-forge/sdk"; -import { useProposalVoting, useWallet } from '../hooks'; -import { parsePositiveInteger } from '../utils'; -import { Alert } from './Alert'; -import { Badge, type BadgeVariant } from './Badge'; -import { TransactionToast, type TransactionToastStatus } from './TransactionToast'; +import { useProposalVoting, useWallet } from "../hooks"; +import { parsePositiveInteger } from "../utils"; +import { Alert } from "./Alert"; +import { Badge, type BadgeVariant } from "./Badge"; +import { + TransactionToast, + type TransactionToastStatus, +} from "./TransactionToast"; /** On-chain identifier of a multi-sig proposal. */ export type ProposalId = bigint | number | string; @@ -29,9 +32,8 @@ export interface ProposalSummary { export interface ProposalVotingPanelProps { /** - * Proposals to render. The SDK exposes `approve_proposal` and - * `execute_proposal` but no proposal listing, so the ids come from the - * parent — an indexer query, or whatever already tracks them on-chain. + * Role proposals to render. The parent loads IDs from the paginated listing + * and resolves their details and approval tallies through the SDK. */ proposals: ProposalSummary[]; /** Voting admin address. Defaults to the connected wallet's public key. */ @@ -53,44 +55,45 @@ export interface ProposalVotingPanelProps { } const BUTTON_STYLE: React.CSSProperties = { - padding: '6px 12px', - backgroundColor: '#2563eb', - color: '#ffffff', - border: 'none', + padding: "6px 12px", + backgroundColor: "#2563eb", + color: "#ffffff", + border: "none", borderRadius: 6, fontWeight: 500, - fontFamily: 'inherit', + fontFamily: "inherit", fontSize: 13, - cursor: 'pointer', + cursor: "pointer", }; const SECONDARY_BUTTON_STYLE: React.CSSProperties = { ...BUTTON_STYLE, - backgroundColor: '#ffffff', - color: '#1f2937', - border: '1px solid #d1d5db', + backgroundColor: "#ffffff", + color: "#1f2937", + border: "1px solid #d1d5db", }; const DISABLED_BUTTON_STYLE: React.CSSProperties = { ...BUTTON_STYLE, - backgroundColor: '#9ca3af', - cursor: 'not-allowed', + backgroundColor: "#9ca3af", + cursor: "not-allowed", }; const ROW_STYLE: React.CSSProperties = { - display: 'flex', - flexDirection: 'column', + display: "flex", + flexDirection: "column", gap: 8, padding: 14, - border: '1px solid #e5e7eb', + border: "1px solid #e5e7eb", borderRadius: 8, - backgroundColor: '#ffffff', + backgroundColor: "#ffffff", }; /** A proposal id is only usable as a `u64` if it parses as a positive integer. */ function toProposalBigInt(id: ProposalId): bigint | null { - if (typeof id === 'bigint') return id > 0n ? id : null; - if (typeof id === 'number') return Number.isInteger(id) && id > 0 ? BigInt(id) : null; + if (typeof id === "bigint") return id > 0n ? id : null; + if (typeof id === "number") + return Number.isInteger(id) && id > 0 ? BigInt(id) : null; return parsePositiveInteger(id); } @@ -102,14 +105,20 @@ function isReady(proposal: ProposalSummary): boolean { ); } -function statusOf(proposal: ProposalSummary): { label: string; variant: BadgeVariant } { - if (proposal.executed) return { label: 'Executed', variant: 'success' }; +function statusOf(proposal: ProposalSummary): { + label: string; + variant: BadgeVariant; +} { + if (proposal.executed) return { label: "Executed", variant: "success" }; if (proposal.quorum !== undefined && proposal.approvals !== undefined) { return isReady(proposal) - ? { label: 'Ready to execute', variant: 'success' } - : { label: `${proposal.approvals} of ${proposal.quorum} approvals`, variant: 'warning' }; + ? { label: "Ready to execute", variant: "success" } + : { + label: `${proposal.approvals} of ${proposal.quorum} approvals`, + variant: "warning", + }; } - return { label: 'Pending', variant: 'default' }; + return { label: "Pending", variant: "default" }; } /** Turns an SDK result into toast state, distinguishing revert from success. */ @@ -118,15 +127,19 @@ function toastFor( failureMessage: (hash: string) => string, ): { status: TransactionToastStatus; hash?: string; message?: string } { return result.success - ? { status: 'success', hash: result.hash } - : { status: 'error', hash: result.hash, message: failureMessage(result.hash) }; + ? { status: "success", hash: result.hash } + : { + status: "error", + hash: result.hash, + message: failureMessage(result.hash), + }; } /** * Multi-sig voting panel. * * Approve goes to `bcForgeClient.approveProposal` and Execute to - * `bcForgeClient.executeProposal`, signed by the connected wallet adapter when + * `bcForgeClient.executeRoleProposal`, signed by the connected wallet adapter when * no `source` keypair is given. Rendered outside a `BcForgeProvider`, pass * `onVote` / `onExecute` to route the ids yourself; with neither available the * controls stay disabled rather than silently doing nothing. @@ -138,7 +151,7 @@ export const ProposalVotingPanel: React.FC = ({ showExecute = true, onVote, onExecute, - emptyMessage = 'No pending proposals.', + emptyMessage = "No pending proposals.", style, className, }) => { @@ -150,25 +163,37 @@ export const ProposalVotingPanel: React.FC = ({ hash?: string; message?: string; } | null>(null); - const [failure, setFailure] = useState<{ id: string; message: string } | null>(null); + const [failure, setFailure] = useState<{ + id: string; + message: string; + } | null>(null); - const votingAdmin = admin ?? (connected ? (publicKey ?? undefined) : undefined); + const votingAdmin = + admin ?? (connected ? (publicKey ?? undefined) : undefined); // A vote can be routed to the SDK (needs an admin address) or to an `onVote` // handler (which resolves the id itself). Without either route there is // nothing to do, so the controls stay disabled. const routeToSdk = available && onVote === undefined; const voteDisabled = - !available && onVote === undefined ? true : routeToSdk && votingAdmin === undefined; + !available && onVote === undefined + ? true + : routeToSdk && votingAdmin === undefined; const sdkUnavailable = !available && onVote === undefined; const guard = (proposal: ProposalSummary, needsAdmin: boolean) => { const bigintId = toProposalBigInt(proposal.id); if (bigintId === null) { - setFailure({ id: String(proposal.id), message: 'Proposal id must be a positive integer.' }); + setFailure({ + id: String(proposal.id), + message: "Proposal id must be a positive integer.", + }); return null; } if (needsAdmin && !votingAdmin) { - setFailure({ id: String(proposal.id), message: 'Connect a wallet or pass an admin address.' }); + setFailure({ + id: String(proposal.id), + message: "Connect a wallet or pass an admin address.", + }); return null; } return bigintId; @@ -179,22 +204,30 @@ export const ProposalVotingPanel: React.FC = ({ if (onVote) { const bigintId = guard(proposal, false); if (bigintId === null) return; - setToast({ status: 'pending' }); + setToast({ status: "pending" }); try { await onVote(bigintId); - setToast({ status: 'success' }); + setToast({ status: "success" }); } catch (err) { - setToast({ status: 'error', message: err instanceof Error ? err.message : String(err) }); + setToast({ + status: "error", + message: err instanceof Error ? err.message : String(err), + }); } return; } const bigintId = guard(proposal, true); if (bigintId === null) return; - setToast({ status: 'pending' }); + setToast({ status: "pending" }); try { const result = await approve(votingAdmin as string, bigintId, source); - setToast(toastFor(result, (hash) => `Approval failed with transaction hash: ${hash}`)); + setToast( + toastFor( + result, + (hash) => `Approval failed with transaction hash: ${hash}`, + ), + ); } catch (err) { setFailure({ id: String(proposal.id), @@ -209,22 +242,30 @@ export const ProposalVotingPanel: React.FC = ({ if (onExecute) { const bigintId = guard(proposal, false); if (bigintId === null) return; - setToast({ status: 'pending' }); + setToast({ status: "pending" }); try { await onExecute(bigintId); - setToast({ status: 'success' }); + setToast({ status: "success" }); } catch (err) { - setToast({ status: 'error', message: err instanceof Error ? err.message : String(err) }); + setToast({ + status: "error", + message: err instanceof Error ? err.message : String(err), + }); } return; } - const bigintId = guard(proposal, false); + const bigintId = guard(proposal, true); if (bigintId === null) return; - setToast({ status: 'pending' }); + setToast({ status: "pending" }); try { - const result = await execute(bigintId, source); - setToast(toastFor(result, (hash) => `Execution failed with transaction hash: ${hash}`)); + const result = await execute(votingAdmin as string, bigintId, source); + setToast( + toastFor( + result, + (hash) => `Execution failed with transaction hash: ${hash}`, + ), + ); } catch (err) { setFailure({ id: String(proposal.id), @@ -236,8 +277,15 @@ export const ProposalVotingPanel: React.FC = ({ if (proposals.length === 0) { return ( -
-
+
+
{emptyMessage}
@@ -249,21 +297,26 @@ export const ProposalVotingPanel: React.FC = ({ className={className} data-testid="proposal-voting-panel" style={{ - display: 'flex', - flexDirection: 'column', + display: "flex", + flexDirection: "column", gap: 12, - fontFamily: 'inherit', + fontFamily: "inherit", ...style, }} > {sdkUnavailable ? ( - Render inside a BcForgeProvider, or pass an onVote handler, to vote on-chain. + Render inside a BcForgeProvider, or pass an onVote handler, to vote + on-chain. ) : null} {failure ? ( - setFailure(null)}> + setFailure(null)} + > {`Proposal ${failure.id}: ${failure.message}`} ) : error ? ( @@ -273,7 +326,12 @@ export const ProposalVotingPanel: React.FC = ({ ) : null} {toast ? ( - setToast(null)} /> + setToast(null)} + /> ) : null} {proposals.map((proposal) => { @@ -283,37 +341,40 @@ export const ProposalVotingPanel: React.FC = ({ const done = Boolean(proposal.executed); const ready = isReady(proposal); const disabled = voteDisabled || busy || done; - const executeDisabled = !ready || done || busy; + const executeDisabled = + (!onExecute && !votingAdmin) || !ready || done || busy; return (
Proposal #{key} - {proposal.action ? ` · ${proposal.action}` : ''} + {proposal.action ? ` · ${proposal.action}` : ""} {status.label}
{proposal.description ? ( -

{proposal.description}

+

+ {proposal.description} +

) : null} -
+
{showExecute ? ( @@ -321,7 +382,11 @@ export const ProposalVotingPanel: React.FC = ({ type="button" disabled={executeDisabled} onClick={() => void handleExecute(proposal)} - style={executeDisabled ? DISABLED_BUTTON_STYLE : SECONDARY_BUTTON_STYLE} + style={ + executeDisabled + ? DISABLED_BUTTON_STYLE + : SECONDARY_BUTTON_STYLE + } > Execute diff --git a/react/src/hooks.ts b/react/src/hooks.ts index 30b0df11..f0634e82 100644 --- a/react/src/hooks.ts +++ b/react/src/hooks.ts @@ -1458,7 +1458,7 @@ export function useVaultShareBalance( /** * Multi-sig proposal actions, backed by `bcForgeClient.approveProposal` and - * `bcForgeClient.executeProposal`. + * `bcForgeClient.executeRoleProposal`. * * Uses the optional context client so {@link ProposalVotingPanel} can also be * driven entirely by its `onVote` / `onExecute` props. @@ -1501,12 +1501,12 @@ export function useProposalVoting() { ); const execute = useCallback( - async (proposalId: bigint, source?: Keypair) => { + async (admin: string, proposalId: bigint, source?: Keypair) => { if (!client) { throw new Error("useProposalVoting requires a BcForgeProvider client"); } return run(proposalId.toString(), () => - client.executeProposal(proposalId, source), + client.executeRoleProposal(admin, proposalId, source), ); }, [client, run], diff --git a/react/src/screens/RolesScreen.test.tsx b/react/src/screens/RolesScreen.test.tsx index b6371626..542502d1 100644 --- a/react/src/screens/RolesScreen.test.tsx +++ b/react/src/screens/RolesScreen.test.tsx @@ -16,6 +16,8 @@ import { exportRbacReport } from "../utils"; const TARGET = "GAVTHAJHFEHX4PZ7GR6O5V2DSJ6G6J4U5XQZ4XK7X5XQZ4XK7X5XQZ4A"; const CALLER = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVCBCY42YOXT"; +const SECOND_SUPER_ADMIN = + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; const mockHasRole = jest.fn(); const mockGrantMinter = jest.fn(); @@ -23,6 +25,13 @@ const mockRevokeMinter = jest.fn(); const mockGrantSuperAdmin = jest.fn(); const mockRevokeSuperAdmin = jest.fn(); const mockGetEvents = jest.fn().mockResolvedValue([]); +const mockGetAdminPool = jest.fn(); +const mockGetAdmin = jest.fn(); +const mockListLegacyProposals = jest.fn(); +const mockGetRoleProposal = jest.fn(); +const mockCreateRoleProposal = jest.fn(); +const mockExecuteRoleProposal = jest.fn(); +const mockApproveProposal = jest.fn(); jest.mock("@bc-forge/sdk", () => ({ Role: { @@ -46,6 +55,16 @@ jest.mock("@bc-forge/sdk", () => ({ grantSuperAdmin: (...args: unknown[]) => mockGrantSuperAdmin(...args), revokeSuperAdmin: (...args: unknown[]) => mockRevokeSuperAdmin(...args), getEvents: (...args: unknown[]) => mockGetEvents(...args), + getAdminPool: (...args: unknown[]) => mockGetAdminPool(...args), + getAdmin: (...args: unknown[]) => mockGetAdmin(...args), + listLegacyProposals: (...args: unknown[]) => + mockListLegacyProposals(...args), + getRoleProposal: (...args: unknown[]) => mockGetRoleProposal(...args), + createRoleProposal: (...args: unknown[]) => + mockCreateRoleProposal(...args), + executeRoleProposal: (...args: unknown[]) => + mockExecuteRoleProposal(...args), + approveProposal: (...args: unknown[]) => mockApproveProposal(...args), setWalletAdapter: jest.fn(), getContractId: () => config?.contractId ?? "", }; @@ -72,7 +91,8 @@ jest.mock("@bc-forge/sdk", () => ({ connected = false; publicKey?: string; async connect() { - this.publicKey = "GAVTHAJHFEHX4PZ7GR6O5V2DSJ6G6J4U5XQZ"; + this.publicKey = + "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVCBCY42YOXT"; this.connected = true; } async disconnect() { @@ -85,54 +105,85 @@ jest.mock("@bc-forge/sdk", () => ({ }, })); +jest.mock("../hooks", () => { + const actual = jest.requireActual("../hooks"); + return { + ...actual, + useProposalVote: () => ({ + vote: (admin: string, id: bigint) => mockApproveProposal(admin, id), + }), + }; +}); + const config = { rpcUrl: "https://soroban-testnet.stellar.org", networkPassphrase: "Test SDF Network ; September 2015", - contractId: "CA123", + contractId: "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526", }; -/** Establishes a wallet connection on mount when `connected` is true. */ -const ConnectOnMount: React.FC<{ connected: boolean }> = ({ connected }) => { - const { connect } = useWallet(); +const AutoConnect: React.FC<{ connectOnMount: boolean }> = ({ + connectOnMount, +}) => { + const { connect, status } = useWallet(); React.useEffect(() => { - if (connected) { + if (connectOnMount && status === "disconnected") { void connect("freighter"); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [connectOnMount, connect, status]); return null; }; -const renderWithWallet = (connected: boolean) => +const renderWithWallet = (connectOnMount: boolean) => render( - + + - , ); -describe("RolesScreen", () => { +describe("RolesScreen (#906, #1224, #1225, #1227, #1228, #1231, #1232, #1233)", () => { beforeEach(() => { - [ - mockHasRole, - mockGrantMinter, - mockRevokeMinter, - mockGrantSuperAdmin, - mockRevokeSuperAdmin, - ].forEach((m) => m.mockReset()); - // Caller is SuperAdmin; target holds Minter only. - mockHasRole.mockImplementation(async (role: Role, addr: string) => - addr === CALLER ? role === Role.SuperAdmin : role === Role.Minter, - ); + jest.clearAllMocks(); + mockHasRole.mockImplementation(async (role: Role, address: string) => { + if (address === TARGET) return role === Role.Minter; + if (address === CALLER) + return role === Role.Admin || role === Role.SuperAdmin; + return false; + }); mockGrantMinter.mockResolvedValue({ success: true, hash: "GRANTHASH1" }); mockRevokeMinter.mockResolvedValue({ success: true, hash: "REVOKEHASH1" }); - mockGrantSuperAdmin.mockResolvedValue({ success: true, hash: "SAGRANT1" }); + mockGrantSuperAdmin.mockResolvedValue({ + success: true, + hash: "SAGRANTHASH1", + }); mockRevokeSuperAdmin.mockResolvedValue({ success: true, hash: "SAREVOKE1", }); + mockGetEvents.mockResolvedValue([]); + mockGetAdminPool.mockResolvedValue([]); + mockGetAdmin.mockResolvedValue(null); + mockListLegacyProposals.mockResolvedValue({ + pendingIds: [], + approvedIds: [], + nextCursor: null, + }); + mockGetRoleProposal.mockResolvedValue(null); + mockCreateRoleProposal.mockResolvedValue({ + success: true, + hash: "PROPHASH1", + returnValue: 9n, + }); + mockExecuteRoleProposal.mockResolvedValue({ + success: true, + hash: "EXECPROPHASH1", + }); + mockApproveProposal.mockResolvedValue({ + success: true, + hash: "APPROVEHASH1", + }); }); const typeTarget = (value: string) => @@ -335,6 +386,177 @@ describe("RolesScreen", () => { URL.createObjectURL = originalCreate; URL.revokeObjectURL = revokeObjectURL; }); + + it("blocks revoking the last active SuperAdmin", async () => { + mockGetAdminPool.mockResolvedValue([TARGET]); + mockGetAdmin.mockResolvedValue(TARGET); + mockHasRole.mockImplementation( + async (role: Role, address: string) => + (role === Role.SuperAdmin && address === TARGET) || + (role === Role.Admin && address === CALLER) || + (role === Role.SuperAdmin && address === CALLER), + ); + renderWithWallet(true); + + await waitFor(() => { + expect(screen.getByTestId("roles-connected-account")).toHaveTextContent( + "GBRP…YOXT", + ); + }); + fireEvent.change(screen.getByPlaceholderText(/target/i), { + target: { value: TARGET }, + }); + await waitFor(() => { + expect(screen.getByTestId("revoke-super-admin")).toBeEnabled(); + }); + fireEvent.click(screen.getByTestId("revoke-super-admin")); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Cannot revoke last active Super Admin", + ); + expect(mockRevokeSuperAdmin).not.toHaveBeenCalled(); + }); + + it("allows revoking a SuperAdmin when another configured holder remains", async () => { + mockGetAdminPool.mockResolvedValue([TARGET, SECOND_SUPER_ADMIN]); + mockHasRole.mockImplementation( + async (role: Role) => role === Role.SuperAdmin, + ); + renderWithWallet(true); + await waitFor(() => { + expect(screen.getByTestId("roles-connected-account")).toHaveTextContent( + "GBRP…YOXT", + ); + }); + fireEvent.change(screen.getByPlaceholderText(/target/i), { + target: { value: TARGET }, + }); + await waitFor(() => { + expect(screen.getByTestId("revoke-super-admin")).toBeEnabled(); + }); + fireEvent.click(screen.getByTestId("revoke-super-admin")); + + await waitFor(() => + expect(mockRevokeSuperAdmin).toHaveBeenCalledWith(TARGET), + ); + }); + + it("creates a proposal for the selected role and target", async () => { + renderWithWallet(true); + await waitFor(() => { + expect(screen.getByTestId("roles-connected-account")).toHaveTextContent( + "GBRP…YOXT", + ); + }); + fireEvent.change(screen.getByPlaceholderText(/target/i), { + target: { value: TARGET }, + }); + fireEvent.change(screen.getByLabelText("Proposal role"), { + target: { value: Role.SuperAdmin }, + }); + fireEvent.click(screen.getByTestId("create-role-proposal")); + + await waitFor(() => + expect(mockCreateRoleProposal).toHaveBeenCalledWith( + "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVCBCY42YOXT", + Role.SuperAdmin, + TARGET, + `Grant SuperAdmin to ${TARGET}`, + ), + ); + expect(await screen.findByRole("status")).toHaveTextContent( + "Proposal #9 created", + ); + }); + + it("loads pending proposals and routes approval through the SDK client", async () => { + mockListLegacyProposals.mockResolvedValue({ + pendingIds: [4n], + approvedIds: [], + nextCursor: null, + }); + mockGetRoleProposal.mockResolvedValue({ + role: Role.Minter, + target: TARGET, + description: `Grant Minter to ${TARGET}`, + approvals: 1, + quorum: 2, + executed: false, + }); + renderWithWallet(true); + await waitFor(() => { + expect(screen.getByTestId("roles-connected-account")).toHaveTextContent( + "GBRP…YOXT", + ); + }); + + fireEvent.click(await screen.findByRole("button", { name: "Approve" })); + await waitFor(() => + expect(mockApproveProposal).toHaveBeenCalledWith( + "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVCBCY42YOXT", + 4n, + ), + ); + expect(screen.getByText("1 of 2 approvals")).toBeInTheDocument(); + }); + + it("executes a ready role proposal and refreshes its state", async () => { + mockListLegacyProposals.mockResolvedValue({ + pendingIds: [], + approvedIds: [4n], + nextCursor: null, + }); + mockGetRoleProposal.mockResolvedValue({ + role: Role.Minter, + target: TARGET, + description: `Grant Minter to ${TARGET}`, + approvals: 2, + quorum: 2, + executed: false, + }); + renderWithWallet(true); + await waitFor(() => { + expect(screen.getByTestId("roles-connected-account")).toHaveTextContent( + "GBRP…YOXT", + ); + }); + + fireEvent.click(await screen.findByRole("button", { name: "Execute" })); + await waitFor(() => + expect(mockExecuteRoleProposal).toHaveBeenCalledWith( + "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVCBCY42YOXT", + 4n, + ), + ); + }); + + it("loads proposal IDs across listing pages", async () => { + mockListLegacyProposals + .mockResolvedValueOnce({ + pendingIds: [4n], + approvedIds: [], + nextCursor: 50n, + }) + .mockResolvedValueOnce({ + pendingIds: [51n], + approvedIds: [], + nextCursor: null, + }); + mockGetRoleProposal.mockImplementation(async (id: bigint) => ({ + role: Role.Minter, + target: TARGET, + description: `Proposal ${id}`, + approvals: 1, + quorum: 2, + executed: false, + })); + renderWithWallet(false); + + expect(await screen.findByTestId("proposal-4")).toBeInTheDocument(); + expect(screen.getByTestId("proposal-51")).toBeInTheDocument(); + expect(mockListLegacyProposals).toHaveBeenCalledTimes(2); + expect(mockListLegacyProposals).toHaveBeenLastCalledWith(50n); + }); }); describe("exportRbacReport", () => { diff --git a/react/src/screens/RolesScreen.tsx b/react/src/screens/RolesScreen.tsx index 0805a59f..f29c3d3a 100644 --- a/react/src/screens/RolesScreen.tsx +++ b/react/src/screens/RolesScreen.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react"; import { Role } from "@bc-forge/sdk"; import type { bcForgeClient, TransactionResult } from "@bc-forge/sdk"; import { useBcForgeClient, truncatePublicKey } from "../context"; -import { useWallet } from "../hooks"; +import { useProposalVote, useWallet } from "../hooks"; import { Alert } from "../components/Alert"; import { Badge } from "../components/Badge"; import { theme } from "../theme"; @@ -11,6 +11,8 @@ import { GrantRoleModal } from "./GrantRoleModal"; import type { GrantableRole } from "./GrantRoleModal"; import { RevokeRoleModal } from "./RevokeRoleModal"; import type { RevocableRole } from "./RevokeRoleModal"; +import { ProposalVotingPanel } from "../components/ProposalVotingPanel"; +import type { ProposalSummary } from "../components/ProposalVotingPanel"; import { exportRbacReport } from "../utils"; import type { RbacReportFormat } from "../utils"; @@ -95,20 +97,18 @@ const buttonStyle = ( cursor: disabled ? "not-allowed" : "pointer", }); +const actionButtonStyle = (disabled: boolean): React.CSSProperties => ({ + padding: "8px 14px", + backgroundColor: disabled ? "#9ca3af" : "#2563eb", + color: "#ffffff", + border: "none", + borderRadius: "6px", + fontWeight: 500, + cursor: disabled ? "not-allowed" : "pointer", +}); + /** - * Role management dashboard (#906, #1224, #1225, #1227, #1228). - * - * - Role cards: description, permission summary and, for the inspected - * address, whether the role is held plus a held-roles count. - * - Inspection search: a valid `G...` address triggers a debounced - * `hasRole` lookup for every role; an invalid one shows a validation error - * and clears the matrix. - * - Grant/revoke go through {@link GrantRoleModal} and {@link RevokeRoleModal} - * and are enabled only for a connected wallet that holds Admin or SuperAdmin. - * - * State: `target` (input), `debouncedTarget` (address being looked up), - * `roleState` (lookup result or null), `adminAccount` (account verified as admin of the - * connected wallet), `grantOpen` / `revokeRole` (open modals). + * Role management dashboard (#906, #1224, #1225, #1227, #1228, #1231, #1232, #1233). */ export const RolesScreen: React.FC = ({ style, @@ -117,6 +117,7 @@ export const RolesScreen: React.FC = ({ }) => { const client = useBcForgeClient(); const { publicKey, connected } = useWallet(); + const { vote } = useProposalVote(); const [target, setTarget] = useState(""); const [debouncedTarget, setDebouncedTarget] = useState(""); @@ -130,6 +131,14 @@ export const RolesScreen: React.FC = ({ const [revokeRole, setRevokeRole] = useState(null); const [reloadKey, setReloadKey] = useState(0); const [reportFormat, setReportFormat] = useState("csv"); + const [busy, setBusy] = useState(false); + + const [proposals, setProposals] = useState([]); + const [proposalRole, setProposalRole] = useState(Role.Minter); + + const trimmedTarget = target.trim(); + const targetValid = isValidStellarAddress(trimmedTarget); + const targetInvalid = trimmedTarget.length > 0 && !targetValid; const handleExport = async () => { if (!roleState || !targetValid) return; @@ -163,10 +172,6 @@ export const RolesScreen: React.FC = ({ } }; - const trimmedTarget = target.trim(); - const targetValid = isValidStellarAddress(trimmedTarget); - const targetInvalid = trimmedTarget.length > 0 && !targetValid; - useEffect(() => { if (!targetValid) return; const id = setTimeout(() => setDebouncedTarget(trimmedTarget), debounceMs); @@ -194,15 +199,18 @@ export const RolesScreen: React.FC = ({ }, [client, debouncedTarget, trimmedTarget, reloadKey]); useEffect(() => { - if (!connected || !publicKey) return; let cancelled = false; - async function check(caller: string) { + async function check(key: string | null) { + if (!key) { + setAdminAccount(null); + return; + } try { - const [admin, superAdmin] = await Promise.all([ - client.hasRole(Role.Admin, caller), - client.hasRole(Role.SuperAdmin, caller), + const [isAdmin, isSuperAdmin] = await Promise.all([ + client.hasRole(Role.Admin, key), + client.hasRole(Role.SuperAdmin, key), ]); - if (!cancelled) setAdminAccount(admin || superAdmin ? caller : null); + if (!cancelled) setAdminAccount(isAdmin || isSuperAdmin ? key : null); } catch { if (!cancelled) setAdminAccount(null); } @@ -213,6 +221,52 @@ export const RolesScreen: React.FC = ({ }; }, [client, connected, publicKey, reloadKey]); + useEffect(() => { + let cancelled = false; + async function loadProposals() { + try { + const pendingIds: bigint[] = []; + const approvedIds: bigint[] = []; + let cursor: bigint | number = 0; + let nextCursor: bigint | null; + do { + const lists = await client.listLegacyProposals(cursor); + pendingIds.push(...lists.pendingIds); + approvedIds.push(...lists.approvedIds); + nextCursor = lists.nextCursor; + if (nextCursor !== null) cursor = nextCursor; + } while (nextCursor !== null); + if (!cancelled) { + const details = await Promise.all( + [...pendingIds, ...approvedIds].map(async (id) => { + const detail = await client.getRoleProposal(id); + return detail + ? { + id, + action: `Grant ${detail.role}`, + description: detail.description, + approvals: detail.approvals, + quorum: detail.quorum, + executed: detail.executed, + } + : null; + }), + ); + setProposals( + details.flatMap((proposal) => (proposal ? [proposal] : [])), + ); + } + } catch (err) { + if (!cancelled) + setError(err instanceof Error ? err.message : String(err)); + } + } + void loadProposals(); + return () => { + cancelled = true; + }; + }, [client, reloadKey]); + const onDone = (label: string, result: TransactionResult) => { setSuccess(`${label} succeeded. Tx: ${result.hash}`); setReloadKey((key) => key + 1); @@ -222,11 +276,105 @@ export const RolesScreen: React.FC = ({ role === Role.Minter ? client.grantMinter(address) : client.grantSuperAdmin(address); + const revoke = (role: RevocableRole, address: string) => role === Role.Minter ? client.revokeMinter(address) : client.revokeSuperAdmin(address); + const handleRevokeClick = async (role: Role) => { + setError(null); + setSuccess(null); + if (role === Role.SuperAdmin) { + let addresses: string[]; + try { + const [pool, admin] = await Promise.all([ + client.getAdminPool(), + client.getAdmin().catch(() => null), + ]); + addresses = Array.from( + new Set([...pool, ...(admin ? [admin] : []), trimmedTarget]), + ); + } catch { + setError( + "Unable to verify the active Super Admin count. Try again when the network is available.", + ); + return; + } + try { + const roles = await Promise.all( + addresses.map((address) => client.hasRole(Role.SuperAdmin, address)), + ); + const targetIsSuperAdmin = await client.hasRole( + Role.SuperAdmin, + trimmedTarget, + ); + if (targetIsSuperAdmin && roles.filter(Boolean).length <= 1) { + setError("Cannot revoke last active Super Admin"); + return; + } + } catch { + setError( + "Unable to verify the active Super Admin count. Try again when the network is available.", + ); + return; + } + setBusy(true); + try { + const result = await client.revokeSuperAdmin(trimmedTarget); + if (result.success) { + setSuccess(`Revoke Super Admin succeeded. Tx: ${result.hash}`); + setReloadKey((key) => key + 1); + } else { + setError( + `Revoke Super Admin failed with transaction hash: ${result.hash}`, + ); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + return; + } + setRevokeRole(role as RevocableRole); + }; + + const createRoleProposal = async () => { + if (!publicKey || !targetValid) return; + setError(null); + setSuccess(null); + if (roleState?.[proposalRole]) { + setError(`Target already holds ${proposalRole}.`); + return; + } + setBusy(true); + try { + const result = await client.createRoleProposal( + publicKey, + proposalRole, + trimmedTarget, + `Grant ${proposalRole} to ${trimmedTarget}`, + ); + if (result.success) { + const proposalId = + result.returnValue === undefined + ? "" + : ` #${String(result.returnValue)}`; + setSuccess(`Proposal${proposalId} created. Tx: ${result.hash}`); + setReloadKey((key) => key + 1); + } else { + setError( + `Proposal creation failed with transaction hash: ${result.hash}`, + ); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + const callerIsAdmin = connected && !!publicKey && adminAccount === publicKey; const canAct = connected && callerIsAdmin && targetValid; const heldCount = @@ -246,6 +394,7 @@ export const RolesScreen: React.FC = ({ maxWidth: "800px", margin: "0 auto", fontFamily: "inherit", + color: theme.text, ...style, }} > @@ -258,16 +407,68 @@ export const RolesScreen: React.FC = ({

-
- Signing account:{" "} - +
+ Signing account:{" "} + + {connected && publicKey + ? truncatePublicKey(publicKey) + : "Not connected"} + +
+ +
- {connected && publicKey - ? truncatePublicKey(publicKey) - : "Not connected"} - + + + +
{error && ( @@ -286,6 +487,15 @@ export const RolesScreen: React.FC = ({ )} + {connected && !callerIsAdmin && ( +
+ + Your connected wallet is not an administrator. Grant and revoke + actions are disabled. + +
+ )} + @@ -400,14 +614,18 @@ export const RolesScreen: React.FC = ({ {([Role.Minter, Role.SuperAdmin] as const).map((role) => { - const disabled = !canAct || !roleState?.[role]; + const disabled = + !connected || + !targetValid || + busy || + (roleState !== null && !roleState[role]); return ( @@ -423,65 +641,107 @@ export const RolesScreen: React.FC = ({ })}
- {!connected && ( - - Connect a wallet to grant or revoke roles. - - )} - {connected && !callerIsAdmin && ( - - The connected account is not an Admin or SuperAdmin, so role changes - are disabled. - - )} - -
- + +
+ { + const result = await vote(publicKey, id); + if (!result.success) + throw new Error(`Approval failed: ${result.hash}`); + setReloadKey((key) => key + 1); + } + : undefined + } + onExecute={ + publicKey + ? async (id) => { + const result = await client.executeRoleProposal( + publicKey, + id, + ); + if (!result.success) + throw new Error(`Execution failed: ${result.hash}`); + setReloadKey((key) => key + 1); + } + : undefined + } + emptyMessage="No pending proposals." + /> + + + {!connected && ( + + Connect a wallet to grant or revoke roles. + + )} setGrantOpen(false)} - onGrant={grant} + onGrant={(role, address) => grant(role, address)} onGranted={(role, result) => onDone(`Grant ${role}`, result)} /> + {revokeRole && ( setRevokeRole(null)} - onRevoke={revoke} + onRevoke={(role, address) => revoke(role, address)} onRevoked={(role, result) => onDone(`Revoke ${role}`, result)} /> )} diff --git a/sdk/src/client.rbac.test.ts b/sdk/src/client.rbac.test.ts index b53b1c83..b516f9f8 100644 --- a/sdk/src/client.rbac.test.ts +++ b/sdk/src/client.rbac.test.ts @@ -220,12 +220,105 @@ describe('bcForgeClient RBAC init', () => { }); }); + describe('getAdminPool', () => { + it('reads and decodes the configured admin addresses', async () => { + const pool = [Keypair.random().publicKey(), Keypair.random().publicKey()]; + const queryContract = jest.fn(async () => + xdr.ScVal.scvVec(pool.map((address) => addressToScVal(address))), + ); + (client as unknown as { queryContract: typeof queryContract }).queryContract = queryContract; + + await expect(client.getAdminPool()).resolves.toEqual(pool); + expect(queryContract).toHaveBeenCalledWith('get_admin_pool', []); + }); + }); + + describe('role grant proposals', () => { + it('creates a proposal bound to the requested role and target', async () => { + const target = Keypair.random().publicKey(); + const invokeContract = jest.fn(async () => ({ + success: true, + hash: 'proposal-hash', + returnValue: 3n, + })); + (client as unknown as { invokeContract: typeof invokeContract }).invokeContract = + invokeContract; + + await client.createRoleProposal( + adminKeypair.publicKey(), + Role.Minter, + target, + 'Grant Minter', + adminKeypair, + ); + + const [method, args] = invokeContract.mock.calls[0] as unknown as [string, xdr.ScVal[]]; + expect(method).toBe('create_role_proposal'); + expect(args[0].toXDR('base64')).toBe( + addressToScVal(adminKeypair.publicKey()).toXDR('base64'), + ); + expect(args[1].sym().toString()).toBe(Role.Minter); + expect(args[2].toXDR('base64')).toBe(addressToScVal(target).toXDR('base64')); + }); + + it('reads the proposal action and current approval tally', async () => { + const target = Keypair.random().publicKey(); + const queryContract = jest.fn(async () => + xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('description'), + val: xdr.ScVal.scvString('Grant Minter'), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('role'), + val: xdr.ScVal.scvSymbol('Minter'), + }), + new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol('target'), val: addressToScVal(target) }), + new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol('approvals'), val: xdr.ScVal.scvU32(2) }), + new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol('quorum'), val: xdr.ScVal.scvU32(3) }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('executed'), + val: xdr.ScVal.scvBool(false), + }), + ]), + ); + (client as unknown as { queryContract: typeof queryContract }).queryContract = queryContract; + + await expect(client.getRoleProposal(7n)).resolves.toEqual({ + description: 'Grant Minter', + role: Role.Minter, + target, + approvals: 2, + quorum: 3, + executed: false, + }); + }); + + it('executes the approved role proposal as the connected admin', async () => { + const invokeContract = jest.fn(async () => ({ success: true, hash: 'execute-hash' })); + (client as unknown as { invokeContract: typeof invokeContract }).invokeContract = + invokeContract; + + await client.executeRoleProposal(adminKeypair.publicKey(), 7n, adminKeypair); + + const [method, args] = invokeContract.mock.calls[0] as unknown as [string, xdr.ScVal[]]; + expect(method).toBe('execute_role_proposal'); + expect(args[0].toXDR('base64')).toBe( + addressToScVal(adminKeypair.publicKey()).toXDR('base64'), + ); + expect(args[1].u64().toString()).toBe('7'); + }); + }); + describe('proposal listings (#917)', () => { function listingMap() { return xdr.ScVal.scvMap([ new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol('pending_ids'), - val: xdr.ScVal.scvVec([xdr.ScVal.scvU64(new xdr.Uint64(1)), xdr.ScVal.scvU64(new xdr.Uint64(3))]), + val: xdr.ScVal.scvVec([ + xdr.ScVal.scvU64(new xdr.Uint64(1)), + xdr.ScVal.scvU64(new xdr.Uint64(3)), + ]), }), new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol('approved_ids'), diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 69937c68..1084be90 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -187,6 +187,16 @@ export interface ProposalLists { nextCursor: bigint | null; } +/** Details and live approval tally for an on-chain role grant proposal. */ +export interface RoleGrantProposalDetails { + description: string; + role: Role; + target: string; + approvals: number; + quorum: number; + executed: boolean; +} + /** Normalize a native-decoded proposal-listing struct into {@link ProposalLists}. */ function parseProposalLists(native: Record): ProposalLists { const asIds = (value: unknown): bigint[] => @@ -1206,6 +1216,39 @@ export class bcForgeClient { ); } + /** Create a multi-signature proposal bound to a specific role grant. */ + async createRoleProposal( + creator: string, + role: Role, + target: string, + description: string, + source?: Keypair, + ): Promise { + return this.invokeContract( + 'create_role_proposal', + [ + addressToScVal(creator), + roleToScVal(role), + addressToScVal(target), + stringToScVal(description), + ], + source, + ); + } + + /** Approve a role grant after its multisig quorum has been reached. */ + async executeRoleProposal( + executor: string, + proposalId: bigint, + source?: Keypair, + ): Promise { + return this.invokeContract( + 'execute_role_proposal', + [addressToScVal(executor), nativeToScVal(proposalId, { type: 'u64' })], + source, + ); + } + /** * Execute a proposal once quorum is reached. */ @@ -1365,6 +1408,41 @@ export class bcForgeClient { } } + /** Get the configured multi-signature admin pool. */ + async getAdminPool(): Promise { + const result = await this.queryContract('get_admin_pool', []); + const pool = scValToNative(result); + if (!Array.isArray(pool)) { + throw new Error('Invalid admin pool returned by contract'); + } + return pool.map((address) => String(address)); + } + + /** Get the configured approval threshold. */ + async getThreshold(): Promise { + const result = await this.queryContract('get_threshold', []); + return Number(scValToNative(result)); + } + + /** Read a role proposal and its current approval tally. */ + async getRoleProposal(proposalId: bigint | number): Promise { + const result = await this.queryContract('get_role_proposal', [ + nativeToScVal(BigInt(proposalId), { type: 'u64' }), + ]); + const native = scValToNative(result); + if (!native || typeof native !== 'object') return null; + const fields = native as Record; + const pick = (snake: string, camel: string) => fields[snake] ?? fields[camel]; + return { + description: String(pick('description', 'description') ?? ''), + role: String(pick('role', 'role')) as Role, + target: String(pick('target', 'target')), + approvals: Number(pick('approvals', 'approvals')), + quorum: Number(pick('quorum', 'quorum')), + executed: Boolean(pick('executed', 'executed')), + }; + } + /** * Check whether an address holds a specific role on-chain. *