From cbcc120255c067362cf75039d928f250fe6e9d67 Mon Sep 17 00:00:00 2001 From: pchmirenko Date: Tue, 14 Apr 2026 14:27:23 +0200 Subject: [PATCH 1/2] feat: expose task job spec claim helpers --- src/__tests__/tasks.test.ts | 162 +++++++++++++++++++++- src/constants.ts | 7 + src/index.ts | 8 ++ src/tasks.ts | 262 +++++++++++++++++++++++++++++++++++- 4 files changed, 435 insertions(+), 4 deletions(-) diff --git a/src/__tests__/tasks.test.ts b/src/__tests__/tasks.test.ts index 97bca19..a16d617 100644 --- a/src/__tests__/tasks.test.ts +++ b/src/__tests__/tasks.test.ts @@ -8,7 +8,7 @@ * 4. cancelTask and other task function types are exported */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { PublicKey, Keypair } from "@solana/web3.js"; import { TaskState, @@ -18,7 +18,11 @@ import { deriveTaskPda, deriveClaimPda, deriveTaskValidationConfigPda, + deriveTaskJobSpecPda, deriveTaskAttestorConfigPda, + setTaskJobSpec, + claimTaskWithJobSpec, + getTaskJobSpec, deriveTaskSubmissionPda, deriveTaskValidationVotePda, deriveEscrowPda, @@ -270,6 +274,20 @@ describe("PDA derivation", () => { }); }); + describe("deriveTaskJobSpecPda", () => { + it('uses correct seeds: ["task_job_spec", taskPda]', () => { + const taskPda = Keypair.generate().publicKey; + + const result = deriveTaskJobSpecPda(taskPda); + + const [expected] = PublicKey.findProgramAddressSync( + [SEEDS.TASK_JOB_SPEC, taskPda.toBuffer()], + PROGRAM_ID, + ); + expect(result.equals(expected)).toBe(true); + }); + }); + describe("deriveTaskAttestorConfigPda", () => { it('uses correct seeds: ["task_attestor", taskPda]', () => { const taskPda = Keypair.generate().publicKey; @@ -339,3 +357,145 @@ describe("calculateEscrowFee", () => { expect(() => calculateEscrowFee(Number.MAX_SAFE_INTEGER)).toThrow(); }); }); + + +describe("task job spec helpers", () => { + it("sets task job spec metadata with the derived PDA", async () => { + const creator = Keypair.generate(); + const taskPda = Keypair.generate().publicKey; + const jobSpecHash = new Uint8Array(32).fill(7); + const jobSpecUri = "agenc://job-spec/sha256/test"; + const rpc = vi.fn().mockResolvedValue("set-job-spec-tx"); + const signers = vi.fn().mockReturnValue({ rpc }); + const preInstructions = vi.fn().mockReturnValue({ signers }); + const accountsPartial = vi.fn().mockReturnValue({ preInstructions }); + const setTaskJobSpecMethod = vi.fn().mockReturnValue({ accountsPartial }); + const program = { + programId: PROGRAM_ID, + methods: { setTaskJobSpec: setTaskJobSpecMethod }, + } as any; + const connection = { + confirmTransaction: vi.fn().mockResolvedValue({}), + } as any; + + const result = await setTaskJobSpec(connection, program, creator, taskPda, { + jobSpecHash, + jobSpecUri, + }); + + const expectedPda = deriveTaskJobSpecPda(taskPda); + expect(result.txSignature).toBe("set-job-spec-tx"); + expect(result.taskJobSpecPda.equals(expectedPda)).toBe(true); + expect(setTaskJobSpecMethod).toHaveBeenCalledWith( + Array.from(jobSpecHash), + jobSpecUri, + ); + expect(accountsPartial).toHaveBeenCalledWith( + expect.objectContaining({ + task: taskPda, + taskJobSpec: expectedPda, + creator: creator.publicKey, + }), + ); + expect(connection.confirmTransaction).toHaveBeenCalledWith( + "set-job-spec-tx", + "confirmed", + ); + }); + + it("claims task with the verified task job spec PDA", async () => { + const worker = Keypair.generate(); + const workerAgentId = new Uint8Array(32).fill(5); + const taskPda = Keypair.generate().publicKey; + const rpc = vi.fn().mockResolvedValue("claim-with-job-spec-tx"); + const signers = vi.fn().mockReturnValue({ rpc }); + const preInstructions = vi.fn().mockReturnValue({ signers }); + const accountsPartial = vi.fn().mockReturnValue({ preInstructions }); + const claimTaskWithJobSpecMethod = vi + .fn() + .mockReturnValue({ accountsPartial }); + const program = { + programId: PROGRAM_ID, + methods: { claimTaskWithJobSpec: claimTaskWithJobSpecMethod }, + } as any; + const connection = { + confirmTransaction: vi.fn().mockResolvedValue({}), + } as any; + + const result = await claimTaskWithJobSpec( + connection, + program, + worker, + workerAgentId, + taskPda, + ); + + const expectedTaskJobSpecPda = deriveTaskJobSpecPda(taskPda); + const [expectedWorkerAgentPda] = PublicKey.findProgramAddressSync( + [SEEDS.AGENT, workerAgentId], + PROGRAM_ID, + ); + const expectedClaimPda = deriveClaimPda(taskPda, expectedWorkerAgentPda); + + expect(result.txSignature).toBe("claim-with-job-spec-tx"); + expect(result.taskJobSpecPda.equals(expectedTaskJobSpecPda)).toBe(true); + expect(claimTaskWithJobSpecMethod).toHaveBeenCalledOnce(); + expect(accountsPartial).toHaveBeenCalledWith( + expect.objectContaining({ + task: taskPda, + taskJobSpec: expectedTaskJobSpecPda, + claim: expectedClaimPda, + worker: expectedWorkerAgentPda, + authority: worker.publicKey, + }), + ); + expect(connection.confirmTransaction).toHaveBeenCalledWith( + "claim-with-job-spec-tx", + "confirmed", + ); + }); + + it("reads task job spec metadata and normalizes account values", async () => { + const taskPda = Keypair.generate().publicKey; + const creator = Keypair.generate().publicKey; + const hash = new Uint8Array(32).fill(9); + const fetch = vi.fn().mockResolvedValue({ + task: taskPda, + creator, + jobSpecHash: Array.from(hash), + jobSpecUri: "agenc://job-spec/sha256/read", + createdAt: { toNumber: () => 11 }, + updatedAt: { toNumber: () => 22 }, + bump: 3, + }); + const program = { + programId: PROGRAM_ID, + account: { taskJobSpec: { fetch } }, + } as any; + + const pointer = await getTaskJobSpec(program, taskPda); + + expect(fetch).toHaveBeenCalledWith(deriveTaskJobSpecPda(taskPda)); + expect(pointer?.task.equals(taskPda)).toBe(true); + expect(pointer?.creator.equals(creator)).toBe(true); + expect(pointer?.jobSpecHash).toEqual(hash); + expect(pointer?.jobSpecUri).toBe("agenc://job-spec/sha256/read"); + expect(pointer?.createdAt).toBe(11); + expect(pointer?.updatedAt).toBe(22); + expect(pointer?.bump).toBe(3); + }); + + it("returns null when task job spec metadata account does not exist", async () => { + const taskPda = Keypair.generate().publicKey; + const program = { + programId: PROGRAM_ID, + account: { + taskJobSpec: { + fetch: vi.fn().mockRejectedValue(new Error("Account does not exist")), + }, + }, + } as any; + + await expect(getTaskJobSpec(program, taskPda)).resolves.toBeNull(); + }); +}); diff --git a/src/constants.ts b/src/constants.ts index e98acf9..b26799e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -118,6 +118,9 @@ export const RECOMMENDED_CU_CREATE_DEPENDENT_TASK = 60_000; /** CU budget for claim_task instruction */ export const RECOMMENDED_CU_CLAIM_TASK = 45_000; +/** CU budget for claim_task_with_job_spec instruction */ +export const RECOMMENDED_CU_CLAIM_TASK_WITH_JOB_SPEC = 50_000; + /** CU budget for expire_claim instruction */ export const RECOMMENDED_CU_EXPIRE_CLAIM = 40_000; @@ -130,6 +133,9 @@ export const RECOMMENDED_CU_COMPLETE_TASK_PRIVATE = 200_000; /** CU budget for configure_task_validation instruction */ export const RECOMMENDED_CU_CONFIGURE_TASK_VALIDATION = 40_000; +/** CU budget for set_task_job_spec instruction */ +export const RECOMMENDED_CU_SET_TASK_JOB_SPEC = 30_000; + /** CU budget for submit_task_result instruction */ export const RECOMMENDED_CU_SUBMIT_TASK_RESULT = 70_000; @@ -250,6 +256,7 @@ export const SEEDS = { TASK: Buffer.from("task"), CLAIM: Buffer.from("claim"), TASK_VALIDATION: Buffer.from("task_validation"), + TASK_JOB_SPEC: Buffer.from("task_job_spec"), TASK_ATTESTOR: Buffer.from("task_attestor"), TASK_SUBMISSION: Buffer.from("task_submission"), TASK_VALIDATION_VOTE: Buffer.from("task_validation_vote"), diff --git a/src/index.ts b/src/index.ts index 8f3b537..b82f44d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,7 +37,9 @@ export { export { createTask, createDependentTask, + setTaskJobSpec, claimTask, + claimTaskWithJobSpec, expireClaim, completeTask, completeTaskPrivate, @@ -50,11 +52,13 @@ export { validateTaskResult, cancelTask, getTask, + getTaskJobSpec, getTasksByCreator, getTaskLifecycleSummary, deriveTaskPda, deriveClaimPda, deriveTaskValidationConfigPda, + deriveTaskJobSpecPda, deriveTaskAttestorConfigPda, deriveTaskSubmissionPda, deriveTaskValidationVotePda, @@ -64,6 +68,9 @@ export { calculateEscrowFee, TaskParams, DependentTaskParams, + TaskCreationResult, + SetTaskJobSpecParams, + TaskJobSpecPointer, TaskState, TaskValidationMode, TaskSubmissionStatus, @@ -208,6 +215,7 @@ export { RECOMMENDED_CU_CREATE_TASK, RECOMMENDED_CU_CREATE_DEPENDENT_TASK, RECOMMENDED_CU_CLAIM_TASK, + RECOMMENDED_CU_CLAIM_TASK_WITH_JOB_SPEC, RECOMMENDED_CU_COMPLETE_TASK, RECOMMENDED_CU_COMPLETE_TASK_PRIVATE, RECOMMENDED_CU_CONFIGURE_TASK_VALIDATION, diff --git a/src/tasks.ts b/src/tasks.ts index 585f402..c92f0f5 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -37,12 +37,14 @@ import { RECOMMENDED_CU_CREATE_TASK_TOKEN, RECOMMENDED_CU_CREATE_DEPENDENT_TASK, RECOMMENDED_CU_CLAIM_TASK, + RECOMMENDED_CU_CLAIM_TASK_WITH_JOB_SPEC, RECOMMENDED_CU_EXPIRE_CLAIM, RECOMMENDED_CU_COMPLETE_TASK, RECOMMENDED_CU_COMPLETE_TASK_TOKEN, RECOMMENDED_CU_COMPLETE_TASK_PRIVATE, RECOMMENDED_CU_COMPLETE_TASK_PRIVATE_TOKEN, RECOMMENDED_CU_CONFIGURE_TASK_VALIDATION, + RECOMMENDED_CU_SET_TASK_JOB_SPEC, RECOMMENDED_CU_SUBMIT_TASK_RESULT, RECOMMENDED_CU_ACCEPT_TASK_RESULT, RECOMMENDED_CU_ACCEPT_TASK_RESULT_TOKEN, @@ -109,6 +111,10 @@ export interface TaskParams { rewardMint?: PublicKey | null; /** Creator's token account. Required when rewardMint is set. If omitted, derived as ATA. */ creatorTokenAccount?: PublicKey; + /** SHA-256 hash of the canonicalized full job specification payload. */ + jobSpecHash?: Uint8Array | number[] | null; + /** Fetchable URI for the canonicalized full job specification payload. */ + jobSpecUri?: string | null; } export interface DependentTaskParams extends TaskParams { @@ -116,6 +122,41 @@ export interface DependentTaskParams extends TaskParams { dependencyType: number; } +export interface TaskCreationResult { + /** Created task PDA */ + taskPda: PublicKey; + /** Create task transaction signature */ + txSignature: string; + /** Task job spec PDA when a job spec pointer was attached */ + taskJobSpecPda?: PublicKey; + /** Set task job spec transaction signature when attached */ + jobSpecTxSignature?: string; +} + +export interface SetTaskJobSpecParams { + /** SHA-256 hash of the canonicalized full job specification payload */ + jobSpecHash: Uint8Array | number[]; + /** Fetchable URI for the canonicalized full job specification payload */ + jobSpecUri: string; +} + +export interface TaskJobSpecPointer { + /** Task PDA this pointer belongs to */ + task: PublicKey; + /** Task creator that set the pointer */ + creator: PublicKey; + /** SHA-256 hash of the canonicalized full job specification payload */ + jobSpecHash: Uint8Array; + /** Fetchable URI for the canonicalized full job specification payload */ + jobSpecUri: string; + /** Account creation timestamp */ + createdAt: number; + /** Last update timestamp */ + updatedAt: number; + /** PDA bump */ + bump: number; +} + export interface TaskStatus { /** Task ID bytes */ taskId: Uint8Array; @@ -352,6 +393,21 @@ export function deriveTaskValidationConfigPda( return pda; } +/** + * Derive task job spec PDA from task. + * Seeds: ["task_job_spec", task_pda] + */ +export function deriveTaskJobSpecPda( + taskPda: PublicKey, + programId: PublicKey = PROGRAM_ID, +): PublicKey { + const [pda] = PublicKey.findProgramAddressSync( + [SEEDS.TASK_JOB_SPEC, taskPda.toBuffer()], + programId, + ); + return pda; +} + /** * Derive task attestor config PDA from task. * Seeds: ["task_attestor", task_pda] @@ -491,6 +547,58 @@ function normalizeTaskId(taskId: Uint8Array | number[]): Uint8Array { return taskId instanceof Uint8Array ? taskId : Uint8Array.from(taskId); } +function normalizeJobSpecHash(jobSpecHash: Uint8Array | number[]): Uint8Array { + return Uint8Array.from( + toFixedBytes( + jobSpecHash instanceof Uint8Array + ? jobSpecHash + : Uint8Array.from(jobSpecHash), + HASH_SIZE, + "jobSpecHash", + ), + ); +} + +function hasTaskJobSpecParams(params: TaskParams): boolean { + return params.jobSpecHash != null || params.jobSpecUri != null; +} + +function validateTaskJobSpecParams(params: TaskParams): SetTaskJobSpecParams | null { + if (!hasTaskJobSpecParams(params)) { + return null; + } + + if (params.jobSpecHash == null || params.jobSpecUri == null) { + throw new Error("Both jobSpecHash and jobSpecUri are required when attaching a task job spec"); + } + + const jobSpecHash = normalizeJobSpecHash(params.jobSpecHash); + const jobSpecUri = params.jobSpecUri.trim(); + if (!jobSpecUri) { + throw new Error("jobSpecUri cannot be empty"); + } + + return { jobSpecHash, jobSpecUri }; +} + +function isMissingAccountError(error: unknown): boolean { + const errorMessage = error instanceof Error ? error.message : String(error); + return ( + errorMessage.includes("Account does not exist") || + errorMessage.includes("could not find account") + ); +} + +function numberFromAnchorValue(value: unknown): number { + if (typeof value === "number") { + return value; + } + if (value && typeof (value as { toNumber?: unknown }).toNumber === "function") { + return (value as { toNumber: () => number }).toNumber(); + } + return 0; +} + function buildTaskTokenAccounts( mint: PublicKey | null, creator: PublicKey, @@ -814,7 +922,7 @@ async function fetchWorkerReviewContext( async function submitTaskCreationTransaction( connection: Connection, - operation: "createTask" | "createDependentTask", + operation: "createTask" | "createDependentTask" | "setTaskJobSpec", send: () => Promise, ): Promise { try { @@ -846,7 +954,8 @@ export async function createTask( creator: Keypair, creatorAgentId: Uint8Array | number[], params: TaskParams, -): Promise<{ taskPda: PublicKey; txSignature: string }> { +): Promise { + const jobSpecParams = validateTaskJobSpecParams(params); const context = buildTaskCreationContext( program.programId, creator, @@ -890,7 +999,62 @@ export async function createTask( .rpc(), ); - return { taskPda: context.taskPda, txSignature: tx }; + if (!jobSpecParams) { + return { taskPda: context.taskPda, txSignature: tx }; + } + + const jobSpecResult = await setTaskJobSpec( + connection, + program, + creator, + context.taskPda, + jobSpecParams, + ); + + return { + taskPda: context.taskPda, + txSignature: tx, + taskJobSpecPda: jobSpecResult.taskJobSpecPda, + jobSpecTxSignature: jobSpecResult.txSignature, + }; +} + +/** + * Attach or update a content-addressed full job specification pointer for a task. + */ +export async function setTaskJobSpec( + connection: Connection, + program: Program, + creator: Keypair, + taskPda: PublicKey, + params: SetTaskJobSpecParams, +): Promise<{ taskJobSpecPda: PublicKey; txSignature: string }> { + const jobSpecHash = normalizeJobSpecHash(params.jobSpecHash); + const jobSpecUri = params.jobSpecUri.trim(); + if (!jobSpecUri) { + throw new Error("jobSpecUri cannot be empty"); + } + + const taskJobSpecPda = deriveTaskJobSpecPda(taskPda, program.programId); + const tx = await submitTaskCreationTransaction(connection, "setTaskJobSpec", () => + program.methods + .setTaskJobSpec(Array.from(jobSpecHash), jobSpecUri) + .accountsPartial({ + task: taskPda, + taskJobSpec: taskJobSpecPda, + creator: creator.publicKey, + systemProgram: SystemProgram.programId, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ + units: RECOMMENDED_CU_SET_TASK_JOB_SPEC, + }), + ]) + .signers([creator]) + .rpc(), + ); + + return { taskJobSpecPda, txSignature: tx }; } /** @@ -991,6 +1155,46 @@ export async function claimTask( return { txSignature: tx }; } +/** + * Claim a task only when the on-chain task job spec pointer exists. + */ +export async function claimTaskWithJobSpec( + connection: Connection, + program: Program, + worker: Keypair, + workerAgentId: Uint8Array | number[], + taskPda: PublicKey, +): Promise<{ txSignature: string; taskJobSpecPda: PublicKey }> { + const programId = program.programId; + const workerAgentPda = deriveAgentPda(workerAgentId, programId); + const claimPda = deriveClaimPda(taskPda, workerAgentPda, programId); + const protocolPda = deriveProtocolPda(programId); + const taskJobSpecPda = deriveTaskJobSpecPda(taskPda, programId); + + const tx = await program.methods + .claimTaskWithJobSpec() + .accountsPartial({ + task: taskPda, + taskJobSpec: taskJobSpecPda, + claim: claimPda, + protocolConfig: protocolPda, + worker: workerAgentPda, + authority: worker.publicKey, + systemProgram: SystemProgram.programId, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ + units: RECOMMENDED_CU_CLAIM_TASK_WITH_JOB_SPEC, + }), + ]) + .signers([worker]) + .rpc(); + + await connection.confirmTransaction(tx, "confirmed"); + + return { txSignature: tx, taskJobSpecPda }; +} + /** * Expire a claim after its deadline. */ @@ -1933,10 +2137,62 @@ function parseTaskAccountData(data: TaskAccountData): TaskStatus | null { }; } +interface TaskJobSpecAccountData { + task?: PublicKey; + creator?: PublicKey; + jobSpecHash?: number[] | Uint8Array; + jobSpecUri?: string; + createdAt?: unknown; + updatedAt?: unknown; + bump?: number; +} + +function parseTaskJobSpecAccountData( + data: TaskJobSpecAccountData, +): TaskJobSpecPointer | null { + if ( + data.task === undefined || + data.creator === undefined || + data.jobSpecHash === undefined || + data.jobSpecUri === undefined + ) { + return null; + } + + return { + task: data.task, + creator: data.creator, + jobSpecHash: normalizeJobSpecHash(data.jobSpecHash), + jobSpecUri: data.jobSpecUri, + createdAt: numberFromAnchorValue(data.createdAt), + updatedAt: numberFromAnchorValue(data.updatedAt), + bump: data.bump ?? 0, + }; +} + // ============================================================================ // Query Functions // ============================================================================ +/** + * Get a task's content-addressed full job specification pointer by task PDA. + */ +export async function getTaskJobSpec( + program: Program, + taskPda: PublicKey, +): Promise { + try { + const taskJobSpecPda = deriveTaskJobSpecPda(taskPda, program.programId); + const account = await getAccount(program, "taskJobSpec").fetch(taskJobSpecPda); + return parseTaskJobSpecAccountData(account as TaskJobSpecAccountData); + } catch (error) { + if (isMissingAccountError(error)) { + return null; + } + throw error; + } +} + /** * Get task status by PDA. */ From 9e4b4e62442fe50782d7a3dbb15535d3b801985d Mon Sep 17 00:00:00 2001 From: pchmirenko Date: Tue, 14 Apr 2026 14:39:15 +0200 Subject: [PATCH 2/2] fix: preserve create task api signature --- src/tasks.ts | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/tasks.ts b/src/tasks.ts index c92f0f5..2800ba8 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -954,7 +954,7 @@ export async function createTask( creator: Keypair, creatorAgentId: Uint8Array | number[], params: TaskParams, -): Promise { +): Promise<{ taskPda: PublicKey; txSignature: string }> { const jobSpecParams = validateTaskJobSpecParams(params); const context = buildTaskCreationContext( program.programId, @@ -999,24 +999,17 @@ export async function createTask( .rpc(), ); - if (!jobSpecParams) { - return { taskPda: context.taskPda, txSignature: tx }; + if (jobSpecParams) { + await setTaskJobSpec( + connection, + program, + creator, + context.taskPda, + jobSpecParams, + ); } - const jobSpecResult = await setTaskJobSpec( - connection, - program, - creator, - context.taskPda, - jobSpecParams, - ); - - return { - taskPda: context.taskPda, - txSignature: tx, - taskJobSpecPda: jobSpecResult.taskJobSpecPda, - jobSpecTxSignature: jobSpecResult.txSignature, - }; + return { taskPda: context.taskPda, txSignature: tx }; } /**