Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 121 additions & 82 deletions contracts/vesting/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ impl VestingContract {
env.events().publish((symbol_short!("acc_adm"),), pending);
}

/// Cancel a proposed admin transfer. Must be called by the current admin.
pub fn cancel_admin_proposal(env: Env) {
Self::_require_admin(&env);
env.storage().instance().remove(&DataKey::PendingAdmin);
}

/// Create a cliff + linear vesting schedule for `recipient`.
///
/// `cliff_ledger` — ledger number when tokens start unlocking.
Expand Down Expand Up @@ -603,6 +609,12 @@ impl VestingContract {
.unwrap_or_else(|| panic_with_error!(&env, VestingError::NotInitialized))
}

/// Returns the address proposed via `propose_admin` that has not yet
/// accepted the role, or `None` when no transfer is in progress.
pub fn pending_admin(env: Env) -> Option<Address> {
env.storage().instance().get(&DataKey::PendingAdmin)
}

/// Returns the token contract address managed by this vesting contract.
pub fn get_token_contract(env: Env) -> Address {
env.storage()
Expand Down Expand Up @@ -1243,107 +1255,134 @@ mod test {
assert!(!schedule.revoked);
}

#[test]
fn test_total_committed_tracks_schedule_lifecycle() {
let env = Env::default();
env.mock_all_auths();
#[test]
fn test_pending_admin_getter_and_cancel() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register_contract(None, VestingContract);
let client = VestingContractClient::new(&env, &contract_id);
let contract_id = env.register_contract(None, VestingContract);
let client = VestingContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let recipient = Address::generate(&env);
let other = Address::generate(&env);
let token_addr = env.register_stellar_asset_contract(admin.clone());
let token_client = soroban_sdk::token::Client::new(&env, &token_addr);
let asset_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr);
let admin = Address::generate(&env);
let proposed = Address::generate(&env);
let token = Address::generate(&env);

client.initialize(&admin, &token_addr);
asset_client.mint(&admin, &5_000);
client.initialize(&admin, &token);

assert_eq!(client.total_committed(), 0);
assert_eq!(client.pending_admin(), None);

client.create_schedule(&recipient, &1_000, &100, &200);
assert_eq!(client.total_committed(), 1_000);
assert_eq!(token_client.balance(&contract_id), 1_000);
assert_eq!(
client.solvency(),
Solvency {
token_balance: 1_000,
total_committed: 1_000,
solvent: true,
}
);
client.propose_admin(&proposed);
assert_eq!(client.pending_admin(), Some(proposed.clone()));

env.ledger().set_sequence_number(150);
release_latest(&client, &recipient);
assert_eq!(client.total_committed(), 500);
assert_eq!(token_client.balance(&contract_id), 500);
client.cancel_admin_proposal();
assert_eq!(client.pending_admin(), None);

client.create_schedule(&other, &300, &200, &300);
assert_eq!(client.total_committed(), 800);
assert_eq!(token_client.balance(&contract_id), 800);
client.propose_admin(&proposed);
client.accept_admin();
assert_eq!(client.pending_admin(), None);
}

revoke_latest(&client, &recipient);
assert_eq!(client.total_committed(), 300);
assert_eq!(token_client.balance(&contract_id), 300);
}
#[test]
fn test_total_committed_tracks_schedule_lifecycle() {
let env = Env::default();
env.mock_all_auths();

#[test]
fn test_solvency_reports_external_token_drain() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, VestingContract);
let client = VestingContractClient::new(&env, &contract_id);

let contract_id = env.register_contract(None, VestingContract);
let client = VestingContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let recipient = Address::generate(&env);
let other = Address::generate(&env);
let token_addr = env.register_stellar_asset_contract(admin.clone());
let token_client = soroban_sdk::token::Client::new(&env, &token_addr);
let asset_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr);

let admin = Address::generate(&env);
let recipient = Address::generate(&env);
let token_addr = env.register_stellar_asset_contract(admin.clone());
let token_client = soroban_sdk::token::Client::new(&env, &token_addr);
let asset_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr);
client.initialize(&admin, &token_addr);
asset_client.mint(&admin, &5_000);

client.initialize(&admin, &token_addr);
asset_client.mint(&admin, &1_000);
client.create_schedule(&recipient, &1_000, &100, &200);
assert_eq!(client.total_committed(), 0);

// Models any token-side admin action that drains already committed funds
// from the vesting contract, such as clawback on a clawbackable token.
token_client.transfer(&contract_id, &admin, &400);
client.create_schedule(&recipient, &1_000, &100, &200);
assert_eq!(client.total_committed(), 1_000);
assert_eq!(token_client.balance(&contract_id), 1_000);
assert_eq!(
client.solvency(),
Solvency {
token_balance: 1_000,
total_committed: 1_000,
solvent: true,
}
);

assert_eq!(
client.solvency(),
Solvency {
token_balance: 600,
total_committed: 1_000,
solvent: false,
}
);
}
env.ledger().set_sequence_number(150);
release_latest(&client, &recipient);
assert_eq!(client.total_committed(), 500);
assert_eq!(token_client.balance(&contract_id), 500);

#[test]
#[should_panic(expected = "vesting contract underfunded")]
fn test_create_schedule_rejects_when_existing_commitments_are_underfunded() {
let env = Env::default();
env.mock_all_auths();
client.create_schedule(&other, &300, &200, &300);
assert_eq!(client.total_committed(), 800);
assert_eq!(token_client.balance(&contract_id), 800);

let contract_id = env.register_contract(None, VestingContract);
let client = VestingContractClient::new(&env, &contract_id);
revoke_latest(&client, &recipient);
assert_eq!(client.total_committed(), 300);
assert_eq!(token_client.balance(&contract_id), 300);
}

let admin = Address::generate(&env);
let recipient = Address::generate(&env);
let other = Address::generate(&env);
let token_addr = env.register_stellar_asset_contract(admin.clone());
let token_client = soroban_sdk::token::Client::new(&env, &token_addr);
let asset_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr);
#[test]
fn test_solvency_reports_external_token_drain() {
let env = Env::default();
env.mock_all_auths();

client.initialize(&admin, &token_addr);
asset_client.mint(&admin, &2_000);
client.create_schedule(&recipient, &1_000, &100, &200);
token_client.transfer(&contract_id, &admin, &400);
let contract_id = env.register_contract(None, VestingContract);
let client = VestingContractClient::new(&env, &contract_id);

client.create_schedule(&other, &100, &100, &200);
}
let admin = Address::generate(&env);
let recipient = Address::generate(&env);
let token_addr = env.register_stellar_asset_contract(admin.clone());
let token_client = soroban_sdk::token::Client::new(&env, &token_addr);
let asset_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr);

client.initialize(&admin, &token_addr);
asset_client.mint(&admin, &1_000);
client.create_schedule(&recipient, &1_000, &100, &200);

// Models any token-side admin action that drains already committed funds
// from the vesting contract, such as clawback on a clawbackable token.
token_client.transfer(&contract_id, &admin, &400);

assert_eq!(
client.solvency(),
Solvency {
token_balance: 600,
total_committed: 1_000,
solvent: false,
}
);
}

#[test]
#[should_panic(expected = "vesting contract underfunded")]
fn test_create_schedule_rejects_when_existing_commitments_are_underfunded() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register_contract(None, VestingContract);
let client = VestingContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let recipient = Address::generate(&env);
let other = Address::generate(&env);
let token_addr = env.register_stellar_asset_contract(admin.clone());
let token_client = soroban_sdk::token::Client::new(&env, &token_addr);
let asset_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr);

client.initialize(&admin, &token_addr);
asset_client.mint(&admin, &2_000);
client.create_schedule(&recipient, &1_000, &100, &200);
token_client.transfer(&contract_id, &admin, &400);

client.create_schedule(&other, &100, &100, &200);
}

#[test]
fn test_vested_before_cliff() {
Expand Down
37 changes: 28 additions & 9 deletions frontend/app/claim/ClaimVesting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@

import { useCallback, useState } from "react";
import { Button } from "@/components/ui/Button";
import { PendingAdminBanner } from "@/components/PendingAdminBanner";
import { VestingSolvencyBadge } from "@/components/VestingSolvencyBadge";
import { useToast } from "@/app/providers/ToastProvider";
import { useWallet } from "@/app/hooks/useWallet";
import {
fetchVestingAdminState,
fetchAllVestingSchedules,
fetchVestingInfo,
fetchVestingSolvency,
buildReleaseTx,
buildReleaseAllTx,
submitTx,
formatTokenAmount,
truncateAddress,
type VestingAdminState,
type VestingInfo,
type VestingSolvency,
} from "@/lib/vesting";
Expand All @@ -28,7 +30,8 @@ export function ClaimVesting() {
const [contractId, setContractId] = useState("");
// One VestingInfo entry per schedule index
const [schedules, setSchedules] = useState<VestingInfo[]>([]);
const [solvency, setSolvency] = useState<
const [adminState, setAdminState] = useState<VestingAdminState | null>(null);
const [solvency, setSolvency] = useState
VestingSolvency | null | undefined
>();
const [loading, setLoading] = useState(false);
Expand All @@ -55,19 +58,29 @@ export function ClaimVesting() {

setError(null);
setSchedules([]);
setAdminState(null);
setSolvency(undefined);
setLoading(true);

try {
// Use get_all_schedules (single contract call, not N+1) + fetch solvency
const [allSchedules, solvencyState] = await Promise.all([
// Use get_all_schedules (single contract call, not N+1) + admin/solvency state
const [allSchedules, vestingAdmin, solvencyState] = await Promise.all([
fetchAllVestingSchedules(trimmed, publicKey),
fetchVestingAdminState(trimmed),
fetchVestingSolvency(trimmed),
]);
setAdminState(vestingAdmin);
setSolvency(solvencyState);

if (allSchedules.length === 0) {
setError("No vesting schedule found for your wallet on this contract.");
if (
vestingAdmin.pendingAdmin === publicKey ||
vestingAdmin.admin === publicKey
) {
setError(null);
} else {
setError("No vesting schedule found for your wallet on this contract.");
}
return;
}

Expand Down Expand Up @@ -192,7 +205,6 @@ export function ClaimVesting() {
/* ── Render ────────────────────────────────────────────────────────── */
return (
<>
{/* ── Wallet gate ──────────────────────────────────────────────── */}
{!connected && (
<div className="glass-card p-8 text-center">
<p className="mb-4 text-gray-400">
Expand All @@ -202,7 +214,6 @@ export function ClaimVesting() {
</div>
)}

{/* ── Contract ID input ────────────────────────────────────────── */}
{connected && (
<div className="space-y-8">
<div className="glass-card p-6">
Expand Down Expand Up @@ -245,11 +256,18 @@ export function ClaimVesting() {
)}
</div>

{adminState?.pendingAdmin && (
<PendingAdminBanner
pendingAdmin={adminState.pendingAdmin}
connectedWallet={publicKey}
nonPendingMessage="The current admin can cancel the proposal from the contract's admin section."
/>
)}

{solvency !== undefined && (
<VestingSolvencyBadge solvency={solvency} />
)}

{/* ── One card per schedule ─────────────────────────────────── */}
{schedules.map((info, idx) => (
<ScheduleCard
key={idx}
Expand All @@ -261,7 +279,6 @@ export function ClaimVesting() {
/>
))}

{/* ── Release all button (when multiple schedules) ──────────── */}
{schedules.length > 1 && (
<Button
onClick={handleReleaseAll}
Expand All @@ -279,6 +296,8 @@ export function ClaimVesting() {
);
}

/* ── remaining components (ScheduleCard, StatCard, DetailRow) unchanged ── */

/* ── Per-schedule card ─────────────────────────────────────────────── */

function ScheduleCard({
Expand Down
Loading
Loading