Skip to content
Open
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
47 changes: 47 additions & 0 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const ACTIVE_JOB_BUMP_AMOUNT: u32 = 518_400;
const ARCHIVAL_JOB_BUMP_AMOUNT: u32 = 120_960;
const FEE_BPS: i128 = 250;
const MAX_DESC_PAYLOAD_LEN: u32 = 4096;
const DEFAULT_MAX_ACTIVE_JOBS: u32 = 50;

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -526,6 +527,9 @@ impl Escrow {
env.storage()
.instance()
.set(&DataKey::WhitelistMode, &false);
env.storage()
.instance()
.set(&DataKey::MaxActiveJobsPerClient, &DEFAULT_MAX_ACTIVE_JOBS);

env.events()
.publish((symbol_short!("init"),), (admin, native_token));
Expand All @@ -544,6 +548,7 @@ impl Escrow {
) -> u64 {
client.require_auth();
check_access(&env, &client);
enforce_client_active_job_limit(&env, &client);
if amount <= 0 { panic!("invalid amount"); }
if description_payload_len > Self::get_desc_payload_max(env.clone()) { panic!("payload too large"); }
if deadline <= current_ledger(&env) { panic!("deadline too soon"); }
Expand Down Expand Up @@ -616,6 +621,7 @@ impl Escrow {
pub fn accept_job(env: Env, freelancer: Address, job_id: u64) {
freelancer.require_auth();
check_access(&env, &freelancer);
enforce_client_active_job_limit(&env, &freelancer);
let mut job = get_job(&env, job_id);
if job.status != JobStatus::Open { panic!("job not open"); }
if current_ledger(&env) > job.deadline { panic!("deadline passed"); }
Expand Down Expand Up @@ -1277,6 +1283,23 @@ impl Escrow {
env.storage().instance().get(&DataKey::JobCount).unwrap_or(0)
}

pub fn get_jobs_batch(env: Env, start: u64, limit: u32) -> Vec<Job> {
let count: u64 = env.storage().instance().get(&DataKey::JobCount).unwrap_or(0);
let mut jobs = Vec::new(&env);
if start == 0 || limit == 0 || start > count {
return jobs;
}
let end = core::cmp::min(count, start.saturating_add(limit as u64).saturating_sub(1));
let mut cursor = start;
while cursor <= end {
if let Some(job) = env.storage().persistent().get::<_, Job>(&DataKey::Job(cursor)) {
jobs.push_back(job);
}
cursor = cursor.saturating_add(1);
}
jobs
}

pub fn get_completed_jobs_count(env: Env) -> u64 {
env.storage().instance().get(&DataKey::CompletedJobsCount).unwrap_or(0)
}
Expand Down Expand Up @@ -1468,6 +1491,30 @@ impl Escrow {
Ok(())
}

pub fn set_max_active_jobs_per_client(env: Env, admin: Address, limit: u32) -> Result<(), Error> {
check_admin(&env);
env.storage()
.instance()
.set(&DataKey::MaxActiveJobsPerClient, &limit);
env.events()
.publish(
(symbol_short!("max_jobs"),),
(admin, limit),
);
Ok(())
}

pub fn get_max_active_jobs_per_client(env: Env) -> u32 {
env.storage()
.instance()
.get::<DataKey, u32>(&DataKey::MaxActiveJobsPerClient)
.unwrap_or(DEFAULT_MAX_ACTIVE_JOBS)
}

pub fn get_client_active_jobs_count(env: Env, client: Address) -> u32 {
count_client_active_jobs(&env, &client)
}

pub fn add_allowed_token(env: Env, token: Address) -> Result<(), Error> {
check_admin(&env);
env.storage()
Expand Down
39 changes: 25 additions & 14 deletions frontend/app/compare/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { getJob } from "@/lib/contract";
import { getJob, getJobsBatch } from "@/lib/contract";
import { formatDeadline, toXlm } from "@/lib/format";
import type { Job } from "@/lib/types";
import Link from "next/link";
Expand Down Expand Up @@ -64,20 +64,31 @@ export default function ComparePage() {
setLoading(true);
setError(null);

Promise.all(
ids.map(async (id) => {
const job = await getJob(String(id));
return job ? { id, job } : null;
}),
)
.then((results) => {
const valid = results.filter((r): r is JobEntry => r !== null);
setEntries(valid);
})
.catch((e) => {
(async () => {
try {
const validEntries: JobEntry[] = [];
if (ids.length > 0) {
const maxId = Math.max(...ids);
const minId = Math.min(...ids);
const jobs = await getJobsBatch(minId, maxId - minId + 1);
const jobMap = new Map<number, Job>();
jobs.forEach((job, idx) => {
jobMap.set(minId + idx, job);
});
for (const id of ids) {
const job = jobMap.get(id);
if (job) {
validEntries.push({ id, job });
}
}
}
setEntries(validEntries);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load jobs for comparison.");
})
.finally(() => setLoading(false));
} finally {
setLoading(false);
}
})();
}, [searchParams]);

const ids = searchParams.get("ids");
Expand Down
33 changes: 19 additions & 14 deletions frontend/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getJob,
getJobCount,
getCompletedJobsCount,
getJobsBatch,
submitWork,
enforceDeadline,
} from "@/lib/contract";
Expand Down Expand Up @@ -107,10 +108,13 @@ export default function DashboardPage() {
try {
const count = await getJobCount();
const fetched: Array<{ id: number; job: Job }> = [];
for (let id = 1; id <= count; id += 1) {
const job = await getJob(String(id));
if (job && (job.client === wallet || job.freelancer === wallet)) {
fetched.push({ id, job });
if (count > 0) {
const jobs = await getJobsBatch(1, count);
for (let id = 1; id <= jobs.length; id++) {
const job = jobs[id - 1];
if (job && (job.client === wallet || job.freelancer === wallet)) {
fetched.push({ id, job });
}
}
}
setAllJobs(fetched);
Expand Down Expand Up @@ -150,16 +154,17 @@ export default function DashboardPage() {
}
setBookmarkedLoading(true);
try {
const results = await Promise.all(
ids.map(async (id) => {
try {
const job = await getJob(String(id));
return job ? { id, job } : null;
} catch {
return null;
}
}),
);
const maxId = Math.max(...ids);
const minId = Math.min(...ids);
const jobs = await getJobsBatch(minId, maxId - minId + 1);
const jobMap = new Map<number, Job>();
jobs.forEach((job, idx) => {
jobMap.set(minId + idx, job);
});
const results = ids.map((id) => {
const job = jobMap.get(id);
return job ? { id, job } : null;
});
setBookmarkedJobs(
results.filter(
(item): item is { id: number; job: Job } => item !== null,
Expand Down
10 changes: 3 additions & 7 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import dynamic from "next/dynamic";
import ClientComponents from "@/components/ClientComponents";
import { WalletProvider } from "@/lib/wallet-context";
import { ToastProvider } from "@/components/ToastProvider";
import { NotificationProvider } from "@/lib/notifications-context";
Expand All @@ -22,9 +23,6 @@ import "./globals.css";
const CommandPalette = dynamic(() => import("@/components/CommandPalette"), { ssr: false });
const ShortcutCheatSheet = dynamic(() => import("@/components/ShortcutCheatSheet"), { ssr: false });
const OnboardingProvider = dynamic(() => import("@/components/OnboardingProvider"), { ssr: false });
const InstallPrompt = dynamic(() => import("@/components/InstallPrompt"), { ssr: false });
const ServiceWorkerRegistration = dynamic(() => import("@/components/ServiceWorkerRegistration"), { ssr: false });
const AnnouncementBanner = dynamic(() => import("@/components/AnnouncementBanner"), { ssr: false });

const geistSans = Geist({
variable: "--font-geist-sans",
Expand Down Expand Up @@ -150,8 +148,7 @@ export default async function RootLayout({
>
Skip to main content
</a>
<ServiceWorkerRegistration />
<AnnouncementBanner />
<ClientComponents />
<OfflineIndicator />
<Navigation />
<CommandPalette />
Expand Down Expand Up @@ -187,15 +184,14 @@ export default async function RootLayout({
</div>
</div>
</footer>
<InstallPrompt />
<AppFooter />
</ToastProvider>
</MeetingsProvider>
</MessagingProvider>
</NotificationProvider>
</WalletProvider>
</TypographyProvider>
</NetworkProvider>
</TypographyProvider>
</ThemeProvider>
</NextIntlClientProvider>
</body>
Expand Down
22 changes: 6 additions & 16 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import JobCardSkeleton from "@/components/JobCardSkeleton";
import SectionCard from "@/components/SectionCard";
import ComparisonBar from "@/components/ComparisonBar";
import JobFilterPanel, { DEFAULT_FILTERS, type JobFilters } from "@/components/JobFilterPanel";
import { acceptJob, getDescriptionCid, getJob, getJobCount } from "@/lib/contract";
import { acceptJob, getDescriptionCid, getJob, getJobCount, getJobsBatch } from "@/lib/contract";
import { fetchFromIpfs } from "@/lib/ipfs-service";
import { useNotifications } from "@/lib/notifications-context";
import {
Expand Down Expand Up @@ -272,21 +272,11 @@ export default function HomePage() {
}
}

const idsToFetch: string[] = [];
for (let id = 1; id <= count; id += 1) {
idsToFetch.push(String(id));
}

const results = await Promise.all(
idsToFetch.map(async (id) => {
try {
const job = await getJob(id);
return job ? { id: Number(id), job } : null;
} catch {
return null;
}
}),
);
const fetchedJobs = await getJobsBatch(1, count);
const results = fetchedJobs.map((job, index) => ({
id: index + 1,
job,
}));

const fetched = results.filter(
(item): item is { id: number; job: Job } =>
Expand Down
15 changes: 9 additions & 6 deletions frontend/app/profile/[address]/profile-page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import ErrorBanner from "@/components/ErrorBanner";
import StatusPill from "@/components/StatusPill";
import { getJob, getJobCount, isBlacklisted, isWhitelisted, isWhitelistModeEnabled } from "@/lib/contract";
import { getJob, getJobCount, getJobsBatch, isBlacklisted, isWhitelisted, isWhitelistModeEnabled } from "@/lib/contract";
import { toXlm } from "@/lib/format";
import {
MAX_BIO_LENGTH,
Expand Down Expand Up @@ -347,11 +347,14 @@ export default function ProfilePageClient({ address }: { address: string }) {

const count = await getJobCount();
const fetched: ProfileJob[] = [];
for (let id = 1; id <= count; id += 1) {
const job = await getJob(String(id));
if (!job) continue;
if (job.client === address) fetched.push({ id, job, role: "client" });
else if (job.freelancer === address) fetched.push({ id, job, role: "freelancer" });
if (count > 0) {
const jobs = await getJobsBatch(1, count);
for (let id = 1; id <= jobs.length; id++) {
const job = jobs[id - 1];
if (!job) continue;
if (job.client === address) fetched.push({ id, job, role: "client" });
else if (job.freelancer === address) fetched.push({ id, job, role: "freelancer" });
}
}
setJobs(fetched);
} catch (e) {
Expand Down
13 changes: 8 additions & 5 deletions frontend/app/transactions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import ErrorBanner from "@/components/ErrorBanner";
import NoResultsState from "@/components/NoResultsState";
import SectionCard from "@/components/SectionCard";
import TransactionRowSkeleton from "@/components/TransactionRowSkeleton";
import { getJob, getJobCount } from "@/lib/contract";
import { getJob, getJobCount, getJobsBatch } from "@/lib/contract";
import { useWallet } from "@/lib/wallet-context";
import {
ALL_TX_TYPES,
Expand Down Expand Up @@ -101,10 +101,13 @@ export default function TransactionsPage() {
try {
const count = await getJobCount();
const results: Array<{ id: number; job: Job }> = [];
for (let id = 1; id <= count; id++) {
const job = await getJob(String(id));
if (job && (job.client === wallet || job.freelancer === wallet)) {
results.push({ id, job });
if (count > 0) {
const jobs = await getJobsBatch(1, count);
for (let id = 1; id <= jobs.length; id++) {
const job = jobs[id - 1];
if (job && (job.client === wallet || job.freelancer === wallet)) {
results.push({ id, job });
}
}
}
setAllJobs(results);
Expand Down
17 changes: 17 additions & 0 deletions frontend/components/ClientComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use client";

import dynamic from "next/dynamic";

const AnnouncementBanner = dynamic(() => import("@/components/AnnouncementBanner"), { ssr: false });
const InstallPrompt = dynamic(() => import("@/components/InstallPrompt"), { ssr: false });
const ServiceWorkerRegistration = dynamic(() => import("@/components/ServiceWorkerRegistration"), { ssr: false });

export default function ClientComponents() {
return (
<>
<ServiceWorkerRegistration />
<AnnouncementBanner />
<InstallPrompt />
</>
);
}
1 change: 1 addition & 0 deletions frontend/components/ToastProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
<ToastItem key={toast.id} toast={toast} onDismiss={dismiss} />
))}
</div>
</div>
<div
aria-live="polite"
aria-relevant="additions"
Expand Down
13 changes: 13 additions & 0 deletions frontend/lib/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,19 @@ export async function getJob(jobId: string): Promise<Job | null> {
return (response.data as Job) ?? null;
}

export async function getJobsBatch(startId: number, limit: number): Promise<Job[]> {
const response = await callContract(
getActiveContractId(),
"get_jobs_batch",
[
nativeToScVal(String(startId), { type: "u64" }),
nativeToScVal(limit, { type: "u32" }),
],
{ readOnly: true },
);
return (response.data as Job[]) ?? [];
}

export async function getJobCount(): Promise<number> {
const response = await callContract(
getActiveContractId(),
Expand Down
7 changes: 4 additions & 3 deletions frontend/lib/disputes-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,16 @@ export type DisputesPageData = {
};

export async function loadDisputesPageData(wallet: string): Promise<DisputesPageData> {
const { getJobCount, getJob } = await import("@/lib/contract");
const { getJobCount, getJobsBatch } = await import("@/lib/contract");
const count = await getJobCount();

const disputes: Dispute[] = [];
const eligibleJobs: EligibleJob[] = [];
const now = new Date().toISOString();

for (let id = 1; id <= count; id++) {
const job = await getJob(String(id));
const allJobs = count > 0 ? await getJobsBatch(1, count) : [];
for (let id = 1; id <= allJobs.length; id++) {
const job = allJobs[id - 1];
if (!job) continue;

if (job.client !== wallet && job.freelancer !== wallet) continue;
Expand Down
Loading