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
13 changes: 13 additions & 0 deletions sdk/javascript/rustchain-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const client = new RustChainClient({
const health = await client.health();
const epoch = await client.epoch();
const miners = await client.miners({ limit: 10 });
const openJobs = await client.listJobs({ category: "code", limit: 10 });

console.log({ health, epoch, miners });
```
Expand All @@ -47,6 +48,18 @@ Options:
- `attestChallenge(payload)` -> `POST /attest/challenge`
- `submitAttestation(payload)` -> `POST /attest/submit`
- `transferHistory(wallet, { limit })` -> `GET /wallet/history?miner_id=...`
- `listJobs({ category, status, limit, offset, minReward })` -> `GET /agent/jobs`
- `getJob(jobId)` -> `GET /agent/jobs/:jobId`
- `postJob({ posterWallet, title, description, category, rewardRtc, ttlSeconds, tags })` -> `POST /agent/jobs`
- `claimJob(jobId, workerWallet)` -> `POST /agent/jobs/:jobId/claim`
- `deliverJob(jobId, { workerWallet, deliverableUrl, deliverableHash, resultSummary })` -> `POST /agent/jobs/:jobId/deliver`
- `acceptJob(jobId, { posterWallet, rating })` -> `POST /agent/jobs/:jobId/accept`
- `disputeJob(jobId, posterWallet, reason)` -> `POST /agent/jobs/:jobId/dispute`
- `cancelJob(jobId, posterWallet)` -> `POST /agent/jobs/:jobId/cancel`
- `reputation(walletId)` -> `GET /agent/reputation/:walletId`
- `agentStats()` -> `GET /agent/stats`

Agent-economy methods mirror the RIP-302 marketplace API and return the parsed JSON envelope, including pagination and payment metadata where the server provides it. They do not create production jobs or send funds unless the caller explicitly invokes those methods with real wallet data.

## Example

Expand Down
159 changes: 159 additions & 0 deletions sdk/javascript/rustchain-sdk/src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
const DEFAULT_BASE_URL = "https://rustchain.org";
const DEFAULT_TIMEOUT_MS = 30000;

export const AGENT_JOB_CATEGORIES = Object.freeze([
"research",
"code",
"video",
"audio",
"writing",
"translation",
"data",
"design",
"testing",
"other"
]);

export class RustChainError extends Error {
constructor(message, options = {}) {
super(message);
Expand Down Expand Up @@ -127,6 +140,119 @@ export class RustChainClient {
return [];
}

async listJobs(options = {}) {
const params = new URLSearchParams();
if (options.category !== undefined) {
assertAgentJobCategory(options.category);
params.set("category", options.category);
}
if (options.status !== undefined) {
assertNonEmptyString(options.status, "status");
params.set("status", options.status);
}
if (options.limit !== undefined) {
const limit = validateNonNegativeInteger(options.limit, "limit");
if (limit > 100) throw new RustChainValidationError("limit must be at most 100");
params.set("limit", String(limit));
}
if (options.offset !== undefined) {
params.set("offset", String(validateNonNegativeInteger(options.offset, "offset")));
}
if (options.minReward !== undefined) {
params.set("min_reward", String(validateNonNegativeNumber(options.minReward, "minReward")));
}
return this.request("GET", withQuery("/agent/jobs", params));
}

async getJob(jobId) {
assertNonEmptyString(jobId, "jobId");
return this.request("GET", `/agent/jobs/${encodeURIComponent(jobId)}`);
}

async postJob(options = {}) {
assertObject(options, "postJob options");
assertNonEmptyString(options.posterWallet, "posterWallet");
assertMinLength(options.title, 5, "title");
assertMinLength(options.description, 20, "description");
assertAgentJobCategory(options.category ?? "other");
const rewardRtc = validateRangeNumber(options.rewardRtc, "rewardRtc", 0.01, 10000);
const ttlSeconds = validatePositiveInteger(options.ttlSeconds, "ttlSeconds");

return this.request("POST", "/agent/jobs", {
poster_wallet: options.posterWallet,
title: options.title,
description: options.description,
category: options.category ?? "other",
reward_rtc: rewardRtc,
ttl_seconds: ttlSeconds,
...(options.tags !== undefined ? { tags: normalizeTags(options.tags) } : {})
});
}

async claimJob(jobId, workerWallet) {
assertNonEmptyString(jobId, "jobId");
assertNonEmptyString(workerWallet, "workerWallet");
return this.request("POST", `/agent/jobs/${encodeURIComponent(jobId)}/claim`, {
worker_wallet: workerWallet
});
}

async deliverJob(jobId, options = {}) {
assertNonEmptyString(jobId, "jobId");
assertObject(options, "deliverJob options");
assertNonEmptyString(options.workerWallet, "workerWallet");
if (options.deliverableUrl === undefined && options.resultSummary === undefined) {
throw new RustChainValidationError("deliverableUrl or resultSummary is required");
}
const body = {
worker_wallet: options.workerWallet,
...(options.deliverableUrl !== undefined ? { deliverable_url: String(options.deliverableUrl) } : {}),
...(options.deliverableHash !== undefined ? { deliverable_hash: String(options.deliverableHash) } : {}),
...(options.resultSummary !== undefined ? { result_summary: String(options.resultSummary) } : {})
};
return this.request("POST", `/agent/jobs/${encodeURIComponent(jobId)}/deliver`, body);
}

async acceptJob(jobId, options = {}) {
assertNonEmptyString(jobId, "jobId");
assertObject(options, "acceptJob options");
assertNonEmptyString(options.posterWallet, "posterWallet");
const body = { poster_wallet: options.posterWallet };
if (options.rating !== undefined) {
const rating = validateRangeNumber(options.rating, "rating", 1, 5);
if (!Number.isInteger(rating)) throw new RustChainValidationError("rating must be an integer from 1 to 5");
body.rating = rating;
}
return this.request("POST", `/agent/jobs/${encodeURIComponent(jobId)}/accept`, body);
}

async disputeJob(jobId, posterWallet, reason) {
assertNonEmptyString(jobId, "jobId");
assertNonEmptyString(posterWallet, "posterWallet");
assertNonEmptyString(reason, "reason");
return this.request("POST", `/agent/jobs/${encodeURIComponent(jobId)}/dispute`, {
poster_wallet: posterWallet,
reason
});
}

async cancelJob(jobId, posterWallet) {
assertNonEmptyString(jobId, "jobId");
assertNonEmptyString(posterWallet, "posterWallet");
return this.request("POST", `/agent/jobs/${encodeURIComponent(jobId)}/cancel`, {
poster_wallet: posterWallet
});
}

async reputation(walletId) {
assertNonEmptyString(walletId, "walletId");
return this.request("GET", `/agent/reputation/${encodeURIComponent(walletId)}`);
}

async agentStats() {
return this.request("GET", "/agent/stats");
}

async request(method, endpoint, body) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
Expand Down Expand Up @@ -211,6 +337,31 @@ function assertNonEmptyString(value, name) {
}
}

function assertObject(value, name) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new RustChainValidationError(`${name} must be an object`);
}
}

function assertMinLength(value, minLength, name) {
assertNonEmptyString(value, name);
if (value.trim().length < minLength) {
throw new RustChainValidationError(`${name} must be at least ${minLength} characters`);
}
}

function assertAgentJobCategory(value) {
assertNonEmptyString(value, "category");
if (!AGENT_JOB_CATEGORIES.includes(value)) {
throw new RustChainValidationError(`category must be one of: ${AGENT_JOB_CATEGORIES.join(", ")}`);
}
}

function normalizeTags(value) {
if (Array.isArray(value)) return value.map((tag) => String(tag));
return String(value);
}

function isRtcAddress(value) {
return typeof value === "string" && /^RTC[0-9a-fA-F]{40}$/.test(value);
}
Expand Down Expand Up @@ -246,3 +397,11 @@ function validateNonNegativeNumber(value, name) {
}
return number;
}

function validateRangeNumber(value, name, minimum, maximum) {
const number = Number(value);
if (!Number.isFinite(number) || number < minimum || number > maximum) {
throw new RustChainValidationError(`${name} must be between ${minimum} and ${maximum}`);
}
return number;
}
100 changes: 100 additions & 0 deletions sdk/javascript/rustchain-sdk/test/rustchain.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,106 @@ test("normalizes legacy array transfer history responses", async () => {
assert.deepEqual(await client.transferHistory("alice"), [{ tx_hash: "legacy", amount: 1 }]);
});

test("lists agent jobs with pagination and filters", async () => {
const client = new RustChainClient({
fetch: mockFetch((url, init) => {
assert.equal(url, "https://rustchain.org/agent/jobs?category=code&status=open&limit=10&offset=2&min_reward=5");
assert.equal(init.method, "GET");
return { status: 200, body: JSON.stringify({ ok: true, jobs: [{ job_id: "job-1" }], total: 1 }) };
})
});

assert.deepEqual(await client.listJobs({ category: "code", status: "open", limit: 10, offset: 2, minReward: 5 }), {
ok: true,
jobs: [{ job_id: "job-1" }],
total: 1
});
});

test("posts an agent job with the RIP-302 payload", async () => {
const client = new RustChainClient({
fetch: mockFetch((url, init) => {
assert.equal(url, "https://rustchain.org/agent/jobs");
assert.equal(init.method, "POST");
assert.deepEqual(JSON.parse(init.body), {
poster_wallet: "RTCposter",
title: "Find a useful product",
description: "Research and compare three useful products for a client.",
category: "research",
reward_rtc: 12.5,
ttl_seconds: 86400,
tags: ["research", "shopping"]
});
return { status: 201, body: JSON.stringify({ ok: true, job_id: "job-1" }) };
})
});

assert.deepEqual(await client.postJob({
posterWallet: "RTCposter",
title: "Find a useful product",
description: "Research and compare three useful products for a client.",
category: "research",
rewardRtc: 12.5,
ttlSeconds: 86400,
tags: ["research", "shopping"]
}), { ok: true, job_id: "job-1" });
});

test("supports the RIP-302 job lifecycle endpoints", async () => {
const calls = [];
const client = new RustChainClient({
fetch: mockFetch((url, init) => {
calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined });
return { status: 200, body: JSON.stringify({ ok: true }) };
})
});

await client.getJob("job/1");
await client.claimJob("job/1", "RTCworker");
await client.deliverJob("job/1", {
workerWallet: "RTCworker",
deliverableUrl: "https://example.test/result",
deliverableHash: "sha256:abc",
resultSummary: "Completed"
});
await client.acceptJob("job/1", { posterWallet: "RTCposter", rating: 5 });
await client.disputeJob("job/1", "RTCposter", "Needs review");
await client.cancelJob("job/1", "RTCposter");
await client.reputation("RTCworker/id");
await client.agentStats();

assert.deepEqual(calls, [
{ url: "https://rustchain.org/agent/jobs/job%2F1", method: "GET", body: undefined },
{ url: "https://rustchain.org/agent/jobs/job%2F1/claim", method: "POST", body: { worker_wallet: "RTCworker" } },
{
url: "https://rustchain.org/agent/jobs/job%2F1/deliver",
method: "POST",
body: {
worker_wallet: "RTCworker",
deliverable_url: "https://example.test/result",
deliverable_hash: "sha256:abc",
result_summary: "Completed"
}
},
{ url: "https://rustchain.org/agent/jobs/job%2F1/accept", method: "POST", body: { poster_wallet: "RTCposter", rating: 5 } },
{ url: "https://rustchain.org/agent/jobs/job%2F1/dispute", method: "POST", body: { poster_wallet: "RTCposter", reason: "Needs review" } },
{ url: "https://rustchain.org/agent/jobs/job%2F1/cancel", method: "POST", body: { poster_wallet: "RTCposter" } },
{ url: "https://rustchain.org/agent/reputation/RTCworker%2Fid", method: "GET", body: undefined },
{ url: "https://rustchain.org/agent/stats", method: "GET", body: undefined }
]);
});

test("validates agent job inputs before making requests", async () => {
const client = new RustChainClient({ fetch: mockFetch(() => ({ status: 200 })) });

await assert.rejects(
() => client.postJob({ posterWallet: "RTCposter", title: "No", description: "This description is long enough.", category: "code", rewardRtc: 1, ttlSeconds: 3600 }),
RustChainValidationError
);
await assert.rejects(() => client.listJobs({ category: "invalid" }), RustChainValidationError);
await assert.rejects(() => client.deliverJob("job-1", { workerWallet: "RTCworker" }), RustChainValidationError);
});

test("throws API errors with status and endpoint", async () => {
const client = new RustChainClient({
fetch: mockFetch(() => ({ status: 500, body: JSON.stringify({ error: "boom" }) }))
Expand Down