From 148e17b9329aecd7b441b70faa7f98fae772b800 Mon Sep 17 00:00:00 2001 From: tokenjunkielabs Date: Sun, 20 Sep 2026 18:58:41 -0400 Subject: [PATCH 1/6] fix(vesting): make claim/clawback race terminal states explicit --- contracts/vesting_escrow/src/lib.rs | 51 ++++++++++++++++++----------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/contracts/vesting_escrow/src/lib.rs b/contracts/vesting_escrow/src/lib.rs index 4e3af21f..3291f472 100644 --- a/contracts/vesting_escrow/src/lib.rs +++ b/contracts/vesting_escrow/src/lib.rs @@ -17,8 +17,10 @@ pub enum ContractError { Unauthorized = 3, UpgradeAlreadyPending = 4, NoPendingUpgrade = 5, - TimelockNotExpired = 6, - TimestampOverflow = 7, + TimelockNotExpired = 6, + TimestampOverflow = 7, + ClaimUnavailableAfterClawback = 8, + ClawbackUnavailableAfterFullClaim = 9, } impl From for ContractError { @@ -150,54 +152,63 @@ impl VestingContract { client.transfer(&funder, &e.current_contract_address(), &amount); } - pub fn claim(e: Env) { + pub fn claim(e: Env) -> Result<(), ContractError> { let mut config: VestingConfig = e.storage().instance().get(&DataKey::Config).expect("Not initialized"); - + config.beneficiary.require_auth(); - + let vested = Self::calc_vested(&e, &config); let claimable = vested - config.claimed_amount; if claimable <= 0 { - // Nothing to claim, just return - return; + // Once clawback has capped the schedule, an exhausted grant is a + // terminal state rather than a silent no-op. This is what a claim + // observes when a full clawback wins the race. + if !config.is_active { + return Err(ContractError::ClaimUnavailableAfterClawback); + } + return Ok(()); } - // Update state config.claimed_amount += claimable; e.storage().instance().set(&DataKey::Config, &config); - // Transfer tokens let client = token::Client::new(&e, &config.token); client.transfer(&e.current_contract_address(), &config.beneficiary, &claimable); + Ok(()) } - - pub fn clawback(e: Env) { + + pub fn clawback(e: Env) -> Result<(), ContractError> { let mut config: VestingConfig = e.storage().instance().get(&DataKey::Config).expect("Not initialized"); - + config.clawback_admin.require_auth(); - + if !config.is_active { panic!("Already revoked/inactive"); } - // Calculate what has vested so far + // If a full claim wins the race there is nothing left for the admin to + // recover. Surface that terminal state explicitly instead of silently + // deactivating an already-settled grant. + if config.claimed_amount >= config.total_amount { + return Err(ContractError::ClawbackUnavailableAfterFullClaim); + } + let vested = Self::calc_vested(&e, &config); - - // The unvested amount is the total scheduled minus what has vested let unvested = config.total_amount - vested; - - // Update config to stop future vesting - // We set total_amount to vested, so effectively the grant is capped at what was vested at this moment + + // Freeze vesting at the amount earned when clawback lands. Anything + // already vested but not yet claimed remains beneficiary-owned. config.total_amount = vested; config.is_active = false; e.storage().instance().set(&DataKey::Config, &config); if unvested > 0 { - // Return unvested tokens to admin let client = token::Client::new(&e, &config.token); client.transfer(&e.current_contract_address(), &config.clawback_admin, &unvested); } + + Ok(()) } pub fn get_vested_amount(e: Env) -> i128 { From 162cd0c04d565f1dfa9af9d834a501a6d96a3632 Mon Sep 17 00:00:00 2001 From: tokenjunkielabs Date: Sun, 20 Sep 2026 18:58:43 -0400 Subject: [PATCH 2/6] test(vesting): cover claim and clawback race ordering --- contracts/vesting_escrow/src/test.rs | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/contracts/vesting_escrow/src/test.rs b/contracts/vesting_escrow/src/test.rs index 4cbcf07a..428eb018 100644 --- a/contracts/vesting_escrow/src/test.rs +++ b/contracts/vesting_escrow/src/test.rs @@ -179,6 +179,71 @@ fn setup_upgradeable() -> UpgradeFixture { } } +#[test] +fn test_claim_after_full_clawback_returns_clear_error() { + let f = setup_upgradeable(); + + // Before the cliff, clawback is full because nothing has vested. + f.client.clawback(); + + assert_eq!( + f.client.try_claim(), + Err(Ok(ContractError::ClaimUnavailableAfterClawback)) + ); + + let config = f.client.get_config(); + assert_eq!(config.total_amount, 0); + assert_eq!(config.claimed_amount, 0); + assert!(!config.is_active); +} + +#[test] +fn test_clawback_after_full_claim_returns_clear_error() { + let f = setup_upgradeable(); + let token_client = token::Client::new(&f.e, &f.token); + + f.e.ledger().set_timestamp(f.start_time + 1_000); + f.client.claim(); + assert_eq!(token_client.balance(&f.beneficiary), 10_000); + + assert_eq!( + f.client.try_clawback(), + Err(Ok(ContractError::ClawbackUnavailableAfterFullClaim)) + ); + + let config = f.client.get_config(); + assert_eq!(config.claimed_amount, 10_000); + assert_eq!(config.total_amount, 10_000); + assert!(config.is_active); +} + +#[test] +fn test_partial_claim_before_clawback_preserves_remaining_vested_claim() { + let f = setup_upgradeable(); + let token_client = token::Client::new(&f.e, &f.token); + + f.e.ledger().set_timestamp(f.start_time + 200); + f.client.claim(); + assert_eq!(token_client.balance(&f.beneficiary), 2_000); + + f.e.ledger().set_timestamp(f.start_time + 500); + f.client.clawback(); + + let frozen = f.client.get_config(); + assert_eq!(frozen.claimed_amount, 2_000); + assert_eq!(frozen.total_amount, 5_000); + assert!(!frozen.is_active); + assert_eq!(token_client.balance(&f.clawback_admin), 5_000); + + // The 3_000 that vested before clawback remains claimable. + f.client.claim(); + assert_eq!(token_client.balance(&f.beneficiary), 5_000); + assert_eq!( + f.client.try_claim(), + Err(Ok(ContractError::ClaimUnavailableAfterClawback)) + ); +} + fn new_wasm_hash(e: &Env) -> BytesN<32> { e.deployer() .upload_contract_wasm(Bytes::from_slice(e, upgraded_vesting::WASM)) From e1ec22e0fe0d27eed12fb2236c60f19707648431 Mon Sep 17 00:00:00 2001 From: tokenjunkielabs Date: Sun, 20 Sep 2026 19:00:12 -0400 Subject: [PATCH 3/6] fix: reuse executor database connection for schedule updates --- backend/src/services/scheduleService.ts | 53 ++++++++++++++----------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/backend/src/services/scheduleService.ts b/backend/src/services/scheduleService.ts index 5072bd34..862a992b 100644 --- a/backend/src/services/scheduleService.ts +++ b/backend/src/services/scheduleService.ts @@ -1,4 +1,5 @@ import { DateTime } from 'luxon'; +import type { PoolClient } from 'pg'; import { default as pool } from '../config/database.js'; import type { Schedule, @@ -361,10 +362,15 @@ export class ScheduleService { async updateAfterExecution( scheduleId: number, executionResult: ExecutionResult, + client?: PoolClient, ): Promise { - const client = await pool.connect(); + const ownsClient = !client; + const dbClient = client ?? await pool.connect(); + try { - await client.query('BEGIN'); + if (ownsClient) { + await dbClient.query('BEGIN'); + } // Query the schedule to get its frequency and configuration const selectQuery = ` @@ -379,7 +385,7 @@ export class ScheduleService { WHERE id = $1 `; - const selectResult = await client.query(selectQuery, [scheduleId]); + const selectResult = await dbClient.query(selectQuery, [scheduleId]); if (selectResult.rows.length === 0) { throw new Error(`Schedule with ID ${scheduleId} not found`); @@ -393,27 +399,20 @@ export class ScheduleService { let nextRunTimestamp: Date | null = null; if (!executionResult.success) { - // If execution failed, set status to 'failed' newStatus = 'failed'; + } else if (schedule.frequency === 'once') { + newStatus = 'completed'; } else { - // Execution succeeded - if (schedule.frequency === 'once') { - // For one-time schedules, set status to 'completed' - newStatus = 'completed'; - } else { - // For recurring schedules, calculate new next_run_timestamp and keep status 'active' - newStatus = 'active'; - nextRunTimestamp = this.calculateNextRun( - schedule.frequency, - schedule.timeOfDay, - new Date(schedule.startDate), - schedule.timezone, - executionTime, // Use execution time as lastRun - ); - } + newStatus = 'active'; + nextRunTimestamp = this.calculateNextRun( + schedule.frequency, + schedule.timeOfDay, + new Date(schedule.startDate), + schedule.timezone, + executionTime, + ); } - // Update the schedule in the database const updateQuery = ` UPDATE schedules SET @@ -424,19 +423,25 @@ export class ScheduleService { WHERE id = $4 `; - await client.query(updateQuery, [ + await dbClient.query(updateQuery, [ executionTime, newStatus, nextRunTimestamp, scheduleId, ]); - await client.query('COMMIT'); + if (ownsClient) { + await dbClient.query('COMMIT'); + } } catch (error) { - await client.query('ROLLBACK'); + if (ownsClient) { + await dbClient.query('ROLLBACK'); + } throw error; } finally { - client.release(); + if (ownsClient) { + dbClient.release(); + } } } } From b5c6778367d65669cdd64bd994fa3efb8260ddae Mon Sep 17 00:00:00 2001 From: tokenjunkielabs Date: Sun, 20 Sep 2026 19:00:41 -0400 Subject: [PATCH 4/6] fix: make schedule lock lifecycle connection-owned --- backend/src/services/scheduleExecutor.ts | 63 ++++++++++++++---------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/backend/src/services/scheduleExecutor.ts b/backend/src/services/scheduleExecutor.ts index f34c242a..6b1fc9c3 100644 --- a/backend/src/services/scheduleExecutor.ts +++ b/backend/src/services/scheduleExecutor.ts @@ -6,6 +6,7 @@ import { scheduleService } from './scheduleService.js'; import type { Schedule, ExecutionResult, PaymentRecipient } from '../types/schedule.js'; import { Operation, Asset, Memo, Keypair } from '@stellar/stellar-sdk'; import os from 'node:os'; +import type { PoolClient } from 'pg'; export class ScheduleExecutor { private cronJob: ScheduledTask | null = null; @@ -117,7 +118,7 @@ export class ScheduleExecutor { const executionResult = await this.executeSchedule(schedule); - await this.recordExecution(schedule.id, executionResult); + await this.recordExecution(schedule.id, executionResult, client); if (executionResult.success) { successCount++; @@ -143,7 +144,7 @@ export class ScheduleExecutor { message: error instanceof Error ? error.message : 'System error in executor', details: error as any, }, - }); + }, client); } catch (recordError) { console.error( `[ScheduleExecutor] Failed to record execution error for schedule ID ${scheduleRow.id}:`, @@ -152,7 +153,7 @@ export class ScheduleExecutor { } } finally { // Always release the claim so the row is available for the next cycle - await this.releaseClaim(scheduleRow.id); + await this.releaseClaim(scheduleRow.id, client); } } @@ -172,12 +173,21 @@ export class ScheduleExecutor { /** * Release the row-level claim after execution (success or failure). */ - private async releaseClaim(scheduleId: number): Promise { + private async releaseClaim(scheduleId: number, client: PoolClient): Promise { try { - await pool.query( - 'UPDATE schedules SET locked_by = NULL, locked_at = NULL WHERE id = $1', - [scheduleId] + const result = await client.query( + `UPDATE schedules + SET locked_by = NULL, locked_at = NULL + WHERE id = $1 AND locked_by = $2 + RETURNING id`, + [scheduleId, this.podId] ); + + if (result.rowCount !== 1) { + console.warn( + `[ScheduleExecutor] Claim for schedule ID ${scheduleId} was not released because it is no longer owned by ${this.podId}` + ); + } } catch (error) { console.error(`[ScheduleExecutor] Failed to release claim for schedule ID ${scheduleId}:`, error); } @@ -298,15 +308,18 @@ export class ScheduleExecutor { * @param scheduleId - The schedule ID * @param result - The execution result */ - async recordExecution(scheduleId: number, result: ExecutionResult): Promise { - const client = await pool.connect(); + async recordExecution( + scheduleId: number, + result: ExecutionResult, + client?: PoolClient, + ): Promise { + const ownsClient = !client; + const dbClient = client ?? await pool.connect(); + try { - await client.query('BEGIN'); + await dbClient.query('BEGIN'); - // Determine execution status const status = result.success ? 'success' : 'failed'; - - // Insert into execution_history const insertQuery = ` INSERT INTO execution_history ( schedule_id, @@ -323,7 +336,7 @@ export class ScheduleExecutor { const insertValues = [ scheduleId, - new Date(), // executed_at + new Date(), status, result.transactionHash || null, result.success ? JSON.stringify({ hash: result.transactionHash }) : null, @@ -331,23 +344,21 @@ export class ScheduleExecutor { result.error?.details ? JSON.stringify(result.error.details) : null, ]; - await client.query(insertQuery, insertValues); + await dbClient.query(insertQuery, insertValues); - // Update schedule state using ScheduleService - await scheduleService.updateAfterExecution(scheduleId, result); - - // Clear the lock now that execution is recorded - await client.query( - 'UPDATE schedules SET locked_by = NULL, locked_at = NULL WHERE id = $1', - [scheduleId] - ); + // Keep execution history and schedule-state mutation on the same transaction + // and physical connection that owns the schedule claim. + await scheduleService.updateAfterExecution(scheduleId, result, dbClient); - await client.query('COMMIT'); + // Commit execution state before making the schedule claim available again. + await dbClient.query('COMMIT'); } catch (error) { - await client.query('ROLLBACK'); + await dbClient.query('ROLLBACK'); throw error; } finally { - client.release(); + if (ownsClient) { + dbClient.release(); + } } } } From 8eba034187570577c06cc1ec8e1fd1457f6bf594 Mon Sep 17 00:00:00 2001 From: tokenjunkielabs Date: Sun, 20 Sep 2026 20:02:49 -0400 Subject: [PATCH 5/6] feat(frontend): add performance bonus modal --- frontend/src/pages/EmployeeEntry.tsx | 239 ++++++++++++++++++++++++++- 1 file changed, 236 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/EmployeeEntry.tsx b/frontend/src/pages/EmployeeEntry.tsx index 88b6f085..9c0f29f6 100644 --- a/frontend/src/pages/EmployeeEntry.tsx +++ b/frontend/src/pages/EmployeeEntry.tsx @@ -21,6 +21,7 @@ interface EmployeeFormState { interface EmployeeItem { id: string; + organizationId: number; name: string; email: string; imageUrl?: string; @@ -39,6 +40,7 @@ const initialFormState: EmployeeFormState = { interface BackendEmployee { id: number; + organization_id: number; first_name: string; last_name: string; email: string; @@ -48,11 +50,27 @@ interface BackendEmployee { status: string; } +interface PayrollRunRecord { + id: number; + batch_id: string; + status: 'draft' | 'pending' | 'processing' | 'completed' | 'failed'; + period_start: string; + period_end: string; + asset_code: string; + created_at: string; +} + export default function EmployeeEntry() { const [isAdding, setIsAdding] = useState(false); const [formData, setFormData] = useState(initialFormState); const [employees, setEmployees] = useState([]); const [loading, setLoading] = useState(false); + const [bonusEmployee, setBonusEmployee] = useState(null); + const [bonusRun, setBonusRun] = useState(null); + const [bonusAmount, setBonusAmount] = useState(''); + const [bonusDescription, setBonusDescription] = useState(''); + const [bonusLoading, setBonusLoading] = useState(false); + const [bonusError, setBonusError] = useState(null); const [notification, setNotification] = useState<{ message: string; secretKey?: string; @@ -60,7 +78,7 @@ export default function EmployeeEntry() { employeeName?: string; } | null>(null); - const { notifySuccess } = useNotification(); + const { notifySuccess, notifyError } = useNotification(); const { saving, lastSaved, loadSavedData } = useAutosave( 'employee-entry-draft', formData @@ -74,6 +92,7 @@ export default function EmployeeEntry() { const employeeRows = Array.isArray(response.data?.data) ? response.data.data : []; const mapped: EmployeeItem[] = employeeRows.map((emp: BackendEmployee) => ({ id: String(emp.id), + organizationId: emp.organization_id, name: `${emp.first_name} ${emp.last_name}`, email: emp.email, position: emp.position ?? emp.job_title ?? 'Employee', @@ -158,6 +177,101 @@ export default function EmployeeEntry() { } }; + const closeBonusModal = () => { + setBonusEmployee(null); + setBonusRun(null); + setBonusAmount(''); + setBonusDescription(''); + setBonusError(null); + }; + + const handleEmployeeClick = async (employee: EmployeeItem) => { + setBonusEmployee(employee); + setBonusRun(null); + setBonusAmount(''); + setBonusDescription(''); + setBonusError(null); + setBonusLoading(true); + + try { + const response = await api.get<{ + success: boolean; + data: { data: PayrollRunRecord[]; total: number }; + }>('/v1/payroll-bonus/runs', { + params: { + organizationId: employee.organizationId, + page: 1, + limit: 50, + }, + }); + + const eligibleRuns = (response.data?.data?.data ?? []) + .filter((run) => run.status === 'draft' || run.status === 'pending') + .sort((a, b) => { + const aTime = new Date(a.period_start || a.created_at).getTime(); + const bTime = new Date(b.period_start || b.created_at).getTime(); + return aTime - bTime; + }); + + const nextRun = eligibleRuns[0] ?? null; + setBonusRun(nextRun); + if (!nextRun) { + setBonusError( + "No draft or pending payroll run is available for this employee's organization." + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load payroll runs'; + setBonusError(message); + notifyError('Could not load the next payroll run', message); + } finally { + setBonusLoading(false); + } + }; + + const handleBonusSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault(); + if (!bonusEmployee || !bonusRun) return; + + const numericAmount = Number(bonusAmount); + if (!Number.isFinite(numericAmount) || numericAmount <= 0) { + setBonusError('Enter a bonus amount greater than zero.'); + return; + } + + setBonusLoading(true); + setBonusError(null); + + try { + await api.post( + '/v1/payroll-bonus/items/bonus', + { + payrollRunId: bonusRun.id, + employeeId: Number(bonusEmployee.id), + amount: bonusAmount.trim(), + description: bonusDescription.trim() || 'Performance bonus', + }, + { + headers: { + 'Idempotency-Key': `performance-bonus-${bonusEmployee.id}-${bonusRun.id}-${Date.now()}`, + }, + } + ); + + notifySuccess( + `Performance bonus added for ${bonusEmployee.name}`, + `Included in next payroll batch ${bonusRun.batch_id}.` + ); + closeBonusModal(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to add performance bonus'; + setBonusError(message); + notifyError('Performance bonus was not saved', message); + } finally { + setBonusLoading(false); + } + }; + if (isAdding) { return (
console.log('Clicked:', employee.name)} - onAddEmployee={(employee: EmployeeItem) => console.log('Added:', employee)} + onEmployeeClick={(employee: EmployeeItem) => { + void handleEmployeeClick(employee); + }} + onAddEmployee={(employee: EmployeeItem) => { + void handleEmployeeClick(employee); + }} /> )} + + {bonusEmployee && ( +
{ + if (event.target === event.currentTarget && !bonusLoading) closeBonusModal(); + }} + > +
+
+
+

+ One-time payroll adjustment +

+

+ Add performance bonus +

+

{bonusEmployee.name}

+
+ +
+ + {bonusLoading && !bonusRun ? ( +

Loading the next payroll run...

+ ) : null} + + {bonusRun ? ( +
+

+ Next scheduled payment run +

+

{bonusRun.batch_id}

+
+ Status: {bonusRun.status} + + Period starts:{' '} + + {new Date(bonusRun.period_start).toLocaleDateString()} + + + Asset: {bonusRun.asset_code} +
+
+ ) : null} + +
{ + void handleBonusSubmit(event); + }} + className="space-y-4" + > + ) => + setBonusAmount(event.target.value) + } + placeholder="0.00" + disabled={!bonusRun || bonusLoading} + required + /> + +
+ +