diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index d1a9866..1b10031 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1404,6 +1404,46 @@ impl EscrowContract { .publish((Symbol::new(&e, "job_cancelled"),), (job_id, client)); } + + /// SC-83: allow the original client to add funds to an existing job escrow. + /// Eligible statuses: Open, InProgress, SubmittedForReview. + pub fn top_up_escrow(e: Env, client: Address, job_id: u64, additional_amount: i128) { + if additional_amount <= 0 { + panic_with_error!(&e, Error::InvalidAmount); + } + + let mut job = get_job_or_panic(&e, job_id); + client.require_auth(); + require_active_access(&e, &client); + + if job.client != client { + panic_with_error!(&e, Error::Unauthorized); + } + + match job.status { + JobStatus::Open | JobStatus::InProgress | JobStatus::SubmittedForReview => {} + _ => panic_with_error!(&e, Error::InvalidStatus), + } + + let old_amount = job.amount; + let new_amount = match old_amount.checked_add(additional_amount) { + Some(v) => v, + None => panic_with_error!(&e, Error::InsufficientFunds), + }; + + let token_client = token::Client::new(&e, &job.token); + token_client.transfer(&client, &e.current_contract_address(), &additional_amount); + + job.amount = new_amount; + set_job(&e, job_id, &job); + bump_instance_ttl(&e); + + e.events().publish( + (Symbol::new(&e, "EscrowToppedUp"),), + (job_id, old_amount, new_amount), + ); + } + pub fn freelancer_cancel_job(e: Env, freelancer: Address, job_id: u64) { let mut job = get_job_or_panic(&e, job_id); freelancer.require_auth(); @@ -10457,4 +10497,98 @@ mod test { let stored = client.get_available_splits(&job_id); assert_eq!(stored.len(), 0); } + + // ── SC-83: top_up_escrow ──────────────────────────────────────────────── + + #[test] + fn top_up_escrow_increases_amount_and_emits_event() { + let (env, client, _, user, _, native_token) = setup(); + let token_client = token::Client::new(&env, &native_token); + let job_id = + client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + + let client_pre = token_client.balance(&user); + client.top_up_escrow(&user, &job_id, &250_000i128); + + let job = client.get_job(&job_id); + assert_eq!(job.amount, 1_250_000); + assert_eq!(token_client.balance(&user), client_pre - 250_000); + + let events = env.events().all(); + assert!(events.len() > 0); + } + + #[test] + fn top_up_escrow_works_in_progress_and_submitted() { + let (env, client, _, user, freelancer, native_token) = setup(); + let job_id = + client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + client.accept_job(&freelancer, &job_id); + client.top_up_escrow(&user, &job_id, &100_000i128); + assert_eq!(client.get_job(&job_id).amount, 1_100_000); + + client.submit_work(&freelancer, &job_id); + client.top_up_escrow(&user, &job_id, &50_000i128); + assert_eq!(client.get_job(&job_id).amount, 1_150_000); + } + + #[test] + fn top_up_escrow_multiple_top_ups_accumulate() { + let (env, client, _, user, _, native_token) = setup(); + let job_id = + client.post_job(&user, &500_000i128, &hash(&env), &32u32, &0u64, &native_token); + client.top_up_escrow(&user, &job_id, &100_000i128); + client.top_up_escrow(&user, &job_id, &200_000i128); + assert_eq!(client.get_job(&job_id).amount, 800_000); + } + + #[test] + #[should_panic(expected = "Error(Contract, #2)")] + fn top_up_escrow_rejects_non_owner() { + let (env, client, _, user, freelancer, native_token) = setup(); + let job_id = + client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + client.top_up_escrow(&freelancer, &job_id, &100_000i128); + } + + #[test] + #[should_panic(expected = "Error(Contract, #3)")] + fn top_up_escrow_rejects_completed_status() { + let (env, client, _, user, freelancer, native_token) = setup(); + let job_id = + client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + client.accept_job(&freelancer, &job_id); + client.submit_work(&freelancer, &job_id); + client.approve_work(&user, &job_id); + client.top_up_escrow(&user, &job_id, &100_000i128); + } + + #[test] + #[should_panic(expected = "Error(Contract, #3)")] + fn top_up_escrow_rejects_cancelled_status() { + let (env, client, _, user, _, native_token) = setup(); + let job_id = + client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + client.cancel_job(&user, &job_id); + client.top_up_escrow(&user, &job_id, &100_000i128); + } + + #[test] + #[should_panic(expected = "Error(Contract, #11)")] + fn top_up_escrow_rejects_zero_amount() { + let (env, client, _, user, _, native_token) = setup(); + let job_id = + client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + client.top_up_escrow(&user, &job_id, &0i128); + } + + #[test] + #[should_panic(expected = "Error(Contract, #11)")] + fn top_up_escrow_rejects_negative_amount() { + let (env, client, _, user, _, native_token) = setup(); + let job_id = + client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + client.top_up_escrow(&user, &job_id, &-1i128); + } + } diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index c0e5c41..b03e454 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -157,6 +157,24 @@ Client cancels an open job. Refunds full escrow to client. Transitions to `Cance --- +#### `top_up_escrow(client: Address, job_id: u64, additional_amount: i128)` + +Client adds funds to an existing job escrow. Transfers `additional_amount` from the client into the contract and updates `job.amount`. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `client` | `Address` | Must be the original job client. Must authorize. | +| `job_id` | `u64` | ID of the job to top up. | +| `additional_amount` | `i128` | Extra escrow amount. Must be > 0. | + +**Allowed statuses:** `Open`, `InProgress`, `SubmittedForReview`. + +**Errors:** `JobNotFound` (1), `Unauthorized` (2), `InvalidStatus` (3), `InsufficientFunds` (4), `InvalidAmount` (11) + +**Event:** `EscrowToppedUp` — data: `(job_id, old_amount, new_amount)` + +--- + #### `freelancer_cancel_job(freelancer: Address, job_id: u64)` Freelancer cancels an in-progress job. Returns full escrow to client. Transitions to `Cancelled`. @@ -302,6 +320,7 @@ Admin resolves a disputed job. Distributes funds based on `client_bps` share. | `job_approved` | `("job_approved",)` | `(job_id, client, freelancer, payout)` | | `job_rejected` | `("job_rejected",)` | `(job_id, client, revision_count)` | | `job_cancelled` | `("job_cancelled",)` | `(job_id, client)` | +| `EscrowToppedUp` | `("EscrowToppedUp",)` | `(job_id, old_amount, new_amount)` | | `job_freelancer_cancelled` | `("job_freelancer_cancelled",)` | `(job_id, freelancer, client, amount)` | | `job_mutually_cancelled` | `("job_mutually_cancelled",)` | `(job_id, client, freelancer, client_share, freelancer_share)` | | `deadline_enforced` | `("deadline_enforced",)` | `(job_id, client)` | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index c3ce465..90475ea 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -72,3 +72,7 @@ To bypass hooks (use sparingly): ```bash git commit -m "..." --no-verify ``` + +## 5. Escrow Top-Up (SC-83) + +Clients can add funds to an existing job via `top_up_escrow(client, job_id, additional_amount)` while the job is `Open`, `InProgress`, or `SubmittedForReview`. Only the original client may call it. The job detail page exposes an **Add Funds** action that confirms the new total before submitting. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c3ce465..90475ea 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -72,3 +72,7 @@ To bypass hooks (use sparingly): ```bash git commit -m "..." --no-verify ``` + +## 5. Escrow Top-Up (SC-83) + +Clients can add funds to an existing job via `top_up_escrow(client, job_id, additional_amount)` while the job is `Open`, `InProgress`, or `SubmittedForReview`. Only the original client may call it. The job detail page exposes an **Add Funds** action that confirms the new total before submitting. diff --git a/frontend/app/job/[id]/page.tsx b/frontend/app/job/[id]/page.tsx index ab41944..9e86332 100644 --- a/frontend/app/job/[id]/page.tsx +++ b/frontend/app/job/[id]/page.tsx @@ -23,6 +23,7 @@ import { getJobViews, recordJobView, submitWork, + topUpEscrow, } from "@/lib/contract"; import { fetchFromIpfs } from "@/lib/ipfs-service"; import { @@ -36,6 +37,7 @@ import { formatDeadline, formatXlmFiatRateTooltip, formatXlmWithFiat, + toXlm, getCachedXlmFiatRates, getPreferredFiatCurrency, type FiatCurrency, @@ -54,10 +56,25 @@ type PendingAction = | "cancelJob" | "approveWork" | "submitWork" - | "freelancerCancelJob"; + | "freelancerCancelJob" + | "topUpEscrow"; const BOOKMARK_STORAGE_KEY = "stellarwork:bookmarked-jobs"; + +const STROOPS_PER_XLM = 10_000_000n; + +function parseXlmToStroops(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed || !/^\d+(\.\d{1,7})?$/.test(trimmed)) return null; + const [whole, fraction = ""] = trimmed.split("."); + const fracPadded = (fraction + "0000000").slice(0, 7); + const stroops = BigInt(whole) * STROOPS_PER_XLM + BigInt(fracPadded); + if (stroops <= 0n) return null; + return stroops.toString(); +} + + function getAutoApprovalCountdown(submittedAtStr: string | undefined) { if (!submittedAtStr) return null; const submittedAtNum = Number(submittedAtStr); @@ -128,6 +145,9 @@ function JobDetailPageContent() { const [slotDate, setSlotDate] = useState(""); const [slotStart, setSlotStart] = useState(""); const [slotEnd, setSlotEnd] = useState(""); + const [showTopUpForm, setShowTopUpForm] = useState(false); + const [topUpAmountXlm, setTopUpAmountXlm] = useState(""); + const [topUpStroops, setTopUpStroops] = useState(null); const numericId = Number(id); const isIdValid = @@ -274,9 +294,21 @@ function JobDetailPageContent() { const canFreelancerCancel = Boolean( isFreelancer && job?.status === "InProgress", ); + const canTopUp = Boolean( + isClient && + job && + (job.status === "Open" || + job.status === "InProgress" || + job.status === "SubmittedForReview"), + ); const hasPrimaryActions = !wallet ? Boolean(job && ["Open", "InProgress", "SubmittedForReview"].includes(job.status)) - : canAccept || canSubmit || canApprove || canCancel || canFreelancerCancel; + : canAccept || + canSubmit || + canApprove || + canCancel || + canFreelancerCancel || + canTopUp; async function handleAction( action: () => Promise<{ hash?: string }>, @@ -331,6 +363,7 @@ function JobDetailPageContent() { approveWork: CONFIRM_KEYS.approveWork, submitWork: CONFIRM_KEYS.submitWork, freelancerCancelJob: CONFIRM_KEYS.freelancerCancelJob, + topUpEscrow: CONFIRM_KEYS.topUpEscrow, }; if (isConfirmSuppressed(keyMap[action])) { void executeAction(action); @@ -379,6 +412,21 @@ function JobDetailPageContent() { "Job cancelled. Full refund returned to client.", ); break; + case "topUpEscrow": { + const stroops = topUpStroops ?? parseXlmToStroops(topUpAmountXlm); + if (!stroops) { + setError("Enter a valid top-up amount greater than 0."); + break; + } + await handleAction( + () => topUpEscrow(wallet, id, stroops), + "Escrow topped up successfully.", + ); + setShowTopUpForm(false); + setTopUpAmountXlm(""); + setTopUpStroops(null); + break; + } } } @@ -425,6 +473,28 @@ function JobDetailPageContent() { } } + + function requestTopUpConfirm() { + const stroops = parseXlmToStroops(topUpAmountXlm); + if (!stroops || !wallet || !id) { + setError("Enter a valid top-up amount greater than 0."); + return; + } + setTopUpStroops(stroops); + if (isConfirmSuppressed(CONFIRM_KEYS.topUpEscrow)) { + void handleAction( + () => topUpEscrow(wallet, id, stroops), + "Escrow topped up successfully.", + ).then(() => { + setShowTopUpForm(false); + setTopUpAmountXlm(""); + setTopUpStroops(null); + }); + return; + } + setPendingAction("topUpEscrow"); + } + // ── Confirm dialog configs ────────────────────────────────────────────── const amountXlm = job @@ -435,6 +505,14 @@ function JobDetailPageContent() { fiatRates?.rates, fiatRates?.fetchedAt, ); + const topUpNewTotalStroops = + job && topUpStroops + ? (BigInt(job.amount) + BigInt(topUpStroops)).toString() + : null; + const topUpImpactLine = + job && topUpStroops && topUpNewTotalStroops + ? `Current ${toXlm(job.amount)} XLM + ${toXlm(topUpStroops)} XLM = ${toXlm(topUpNewTotalStroops)} XLM total escrow` + : undefined; const DIALOG_CONFIG: Record< PendingAction, @@ -501,6 +579,20 @@ function JobDetailPageContent() { variant: "danger", suppressKey: CONFIRM_KEYS.freelancerCancelJob, }, + topUpEscrow: { + title: "Add funds to escrow?", + description: + "Additional funds will be transferred from your wallet into this job's escrow. The freelancer relationship and job status stay the same.", + consequences: [ + "Only the escrowed amount increases.", + "You can top up again later while the job remains eligible.", + ], + impactLine: topUpImpactLine, + confirmLabel: "Yes, add funds", + variant: "primary", + suppressKey: CONFIRM_KEYS.topUpEscrow, + }, + }; // ── Render ────────────────────────────────────────────────────────────── @@ -1058,6 +1150,18 @@ function JobDetailPageContent() { )} + + {canTopUp && ( + + )} + )} @@ -1065,6 +1169,59 @@ function JobDetailPageContent() { )} + + {canTopUp && showTopUpForm && ( +
+

Add funds to escrow

+

+ Current escrow: {formatXlmWithFiat(job!.amount, fiatCurrency, fiatRates?.rates)} +

+ + setTopUpAmountXlm(e.target.value)} + placeholder="e.g. 10.5" + className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + /> + {parseXlmToStroops(topUpAmountXlm) && ( +

+ New total:{" "} + {formatXlmWithFiat( + (BigInt(job!.amount) + BigInt(parseXlmToStroops(topUpAmountXlm)!)).toString(), + fiatCurrency, + fiatRates?.rates, + )} +

+ )} +
+ + +
+
+ )} + {/* Confirmation dialogs */} {pendingAction === "cancelJob" ? (