@projectsolo/solo-mission-mcp 0.21.13 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/DEVELOPER_README.md +1 -1
  2. package/README.md +96 -12
  3. package/dist/chunk-NUGGTARW.js +196 -0
  4. package/dist/{chunk-TKRT2V2W.js → chunk-OP4QYTL4.js} +5 -0
  5. package/dist/{client-LDWK5HLP.js → client-NS6J6IO2.js} +1 -1
  6. package/dist/deployment-UCUWVIPJ.js +43 -0
  7. package/dist/escrowErrors-4XSAYLGT.js +56 -0
  8. package/dist/index.js +244 -162
  9. package/dist/verify-7EUVRUAS.js +445 -0
  10. package/dist/{wallet-V4T4NTCM.js → wallet-LX7SI5RE.js} +28 -14
  11. package/dist/wire-RLDY4AJR.js +24 -0
  12. package/package.json +1 -1
  13. package/src/config.ts +11 -0
  14. package/src/index.ts +1 -1
  15. package/src/scripts/check-tools-against-spec.ts +3 -3
  16. package/src/solana/deployment.test.ts +61 -0
  17. package/src/solana/deployment.ts +89 -0
  18. package/src/solana/escrowErrors.ts +78 -0
  19. package/src/solana/fixtures/config-account.json +6 -0
  20. package/src/solana/fixtures/funding-transaction-v2-lottery.json +49 -0
  21. package/src/solana/fixtures/funding-transaction-v2-plain.json +46 -0
  22. package/src/solana/fixtures/solo_escrow.v2.idl-excerpt.json +683 -0
  23. package/src/solana/verify.test.ts +605 -78
  24. package/src/solana/verify.ts +545 -84
  25. package/src/solana/wallet.test.ts +85 -2
  26. package/src/solana/wallet.ts +74 -31
  27. package/src/solana/wire.test.ts +129 -0
  28. package/src/solana/wire.ts +264 -0
  29. package/src/tools/missions.ts +56 -119
  30. package/src/tools/solana.test.ts +356 -0
  31. package/src/tools/solana.ts +309 -70
  32. package/src/tools/tracks.ts +1 -1
  33. package/dist/verify-KAETIGV5.js +0 -136
  34. /package/src/solana/fixtures/{funding-transaction.json → funding-transaction-v1.json} +0 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The Solana legacy transaction wire format, decoded and signed without re-serialising anything.
3
+ *
4
+ * WHY NOT `Transaction.from(...).partialSign(...).serialize()`. web3.js rebuilds the message from
5
+ * its own model of the instructions and only reuses the original bytes while that model still
6
+ * matches. For a v2 lottery funding transaction the platform Operator has ALREADY signed the exact
7
+ * message; any re-compilation that reorders an account or drops a signature slot invalidates the
8
+ * co-signature, and the program then rejects the create with `LotteryRequiresOperator` or the
9
+ * runtime with a signature failure. Working on the wire bytes makes the guarantee structural: the
10
+ * agent writes 64 bytes into its own slot and nothing else changes.
11
+ *
12
+ * Layout (legacy only; a versioned message has the high bit of its first byte set):
13
+ *
14
+ * shortvec(n) | n × 64-byte signatures | message
15
+ * message = numRequiredSignatures u8 | numReadonlySigned u8 | numReadonlyUnsigned u8
16
+ * | shortvec(k) | k × 32-byte keys | recentBlockhash 32
17
+ * | shortvec(i) | i × (programIdIndex u8 | shortvec(a) | a × u8 | shortvec(d) | d bytes)
18
+ *
19
+ * Signatures are ed25519 over the exact message bytes, in the order of the first
20
+ * `numRequiredSignatures` account keys. The first key is the fee payer.
21
+ */
22
+
23
+ import { createPrivateKey, createPublicKey, sign, verify } from 'crypto';
24
+
25
+ export interface WireInstruction {
26
+ programIdIndex: number;
27
+ accountIndexes: number[];
28
+ data: Buffer;
29
+ }
30
+
31
+ export interface WireTransaction {
32
+ signatures: Buffer[];
33
+ /** Byte offset of the first signature in the serialized transaction. */
34
+ signatureOffset: number;
35
+ /** The exact bytes every signature covers. */
36
+ message: Buffer;
37
+ header: {
38
+ numRequiredSignatures: number;
39
+ numReadonlySigned: number;
40
+ numReadonlyUnsigned: number;
41
+ };
42
+ /** Base58. The first `numRequiredSignatures` are the signers, in signature order. */
43
+ accountKeys: string[];
44
+ recentBlockhash: string;
45
+ instructions: WireInstruction[];
46
+ }
47
+
48
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
49
+
50
+ /** Bitcoin-alphabet base58, as Solana uses for keys, signatures and blockhashes. */
51
+ export function base58Encode(bytes: Uint8Array): string {
52
+ let zeros = 0;
53
+ while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
54
+ const digits: number[] = [];
55
+ for (let i = zeros; i < bytes.length; i++) {
56
+ let carry = bytes[i];
57
+ for (let j = 0; j < digits.length; j++) {
58
+ carry += digits[j] << 8;
59
+ digits[j] = carry % 58;
60
+ carry = (carry / 58) | 0;
61
+ }
62
+ while (carry > 0) {
63
+ digits.push(carry % 58);
64
+ carry = (carry / 58) | 0;
65
+ }
66
+ }
67
+ let out = '1'.repeat(zeros);
68
+ for (let i = digits.length - 1; i >= 0; i--) out += BASE58_ALPHABET[digits[i]];
69
+ return out;
70
+ }
71
+
72
+ export function base58Decode(s: string): Buffer {
73
+ let zeros = 0;
74
+ while (zeros < s.length && s[zeros] === '1') zeros++;
75
+ const bytes: number[] = [];
76
+ for (let i = zeros; i < s.length; i++) {
77
+ let carry = BASE58_ALPHABET.indexOf(s[i]);
78
+ if (carry < 0) throw new Error(`invalid base58 character ${JSON.stringify(s[i])}`);
79
+ for (let j = 0; j < bytes.length; j++) {
80
+ carry += bytes[j] * 58;
81
+ bytes[j] = carry & 0xff;
82
+ carry >>= 8;
83
+ }
84
+ while (carry > 0) {
85
+ bytes.push(carry & 0xff);
86
+ carry >>= 8;
87
+ }
88
+ }
89
+ return Buffer.concat([Buffer.alloc(zeros), Buffer.from(bytes.reverse())]);
90
+ }
91
+
92
+ function readShortVec(buf: Buffer, offset: number): { value: number; next: number } {
93
+ let value = 0;
94
+ for (let i = 0; i < 3; i++) {
95
+ const byte = buf[offset + i];
96
+ if (byte === undefined) throw new Error('truncated transaction');
97
+ value |= (byte & 0x7f) << (7 * i);
98
+ if ((byte & 0x80) === 0) return { value, next: offset + i + 1 };
99
+ }
100
+ throw new Error('malformed compact-u16 length');
101
+ }
102
+
103
+ function take(buf: Buffer, offset: number, length: number): Buffer {
104
+ if (offset + length > buf.length) throw new Error('truncated transaction');
105
+ return buf.subarray(offset, offset + length);
106
+ }
107
+
108
+ /**
109
+ * Decodes a serialized legacy transaction. Throws on anything malformed, versioned, or carrying
110
+ * trailing bytes — a verifier must not accept bytes it only partly understood.
111
+ */
112
+ export function parseWireTransaction(raw: Buffer): WireTransaction {
113
+ const { value: sigCount, next: signatureOffset } = readShortVec(raw, 0);
114
+ const signatures: Buffer[] = [];
115
+ let o = signatureOffset;
116
+ for (let i = 0; i < sigCount; i++, o += 64) signatures.push(take(raw, o, 64));
117
+
118
+ const message = raw.subarray(o);
119
+ if (message.length < 3) throw new Error('truncated transaction');
120
+ if ((message[0] & 0x80) !== 0) {
121
+ throw new Error('versioned transaction message — only legacy funding transactions are expected');
122
+ }
123
+ const header = {
124
+ numRequiredSignatures: message[0],
125
+ numReadonlySigned: message[1],
126
+ numReadonlyUnsigned: message[2],
127
+ };
128
+ if (header.numRequiredSignatures !== sigCount) {
129
+ throw new Error(
130
+ `transaction carries ${sigCount} signature slots for ${header.numRequiredSignatures} required signers`,
131
+ );
132
+ }
133
+
134
+ let m = 3;
135
+ const keys = readShortVec(message, m);
136
+ m = keys.next;
137
+ const accountKeys: string[] = [];
138
+ for (let i = 0; i < keys.value; i++, m += 32) accountKeys.push(base58Encode(take(message, m, 32)));
139
+ if (header.numRequiredSignatures > accountKeys.length) {
140
+ throw new Error('more required signers than account keys');
141
+ }
142
+ const recentBlockhash = base58Encode(take(message, m, 32));
143
+ m += 32;
144
+
145
+ const ixCount = readShortVec(message, m);
146
+ m = ixCount.next;
147
+ const instructions: WireInstruction[] = [];
148
+ for (let i = 0; i < ixCount.value; i++) {
149
+ const programIdIndex = take(message, m, 1)[0];
150
+ m += 1;
151
+ const accts = readShortVec(message, m);
152
+ m = accts.next;
153
+ const accountIndexes = [...take(message, m, accts.value)];
154
+ m += accts.value;
155
+ const dataLen = readShortVec(message, m);
156
+ m = dataLen.next;
157
+ const data = Buffer.from(take(message, m, dataLen.value));
158
+ m += dataLen.value;
159
+ for (const idx of [programIdIndex, ...accountIndexes]) {
160
+ if (idx >= accountKeys.length) throw new Error(`instruction references account ${idx} of ${accountKeys.length}`);
161
+ }
162
+ instructions.push({ programIdIndex, accountIndexes, data });
163
+ }
164
+ if (m !== message.length) {
165
+ throw new Error(`${message.length - m} unexpected trailing bytes after the message`);
166
+ }
167
+
168
+ return { signatures, signatureOffset, message, header, accountKeys, recentBlockhash, instructions };
169
+ }
170
+
171
+ /** Whether the key at `index` must sign, per the legacy message header. */
172
+ export function isSignerIndex(tx: WireTransaction, index: number): boolean {
173
+ return index < tx.header.numRequiredSignatures;
174
+ }
175
+
176
+ /** Whether the key at `index` is writable, per the legacy message header. */
177
+ export function isWritableIndex(tx: WireTransaction, index: number): boolean {
178
+ const { numRequiredSignatures, numReadonlySigned, numReadonlyUnsigned } = tx.header;
179
+ if (index < numRequiredSignatures) return index < numRequiredSignatures - numReadonlySigned;
180
+ return index < tx.accountKeys.length - numReadonlyUnsigned;
181
+ }
182
+
183
+ export function isEmptySignature(sig: Buffer): boolean {
184
+ return sig.every((b) => b === 0);
185
+ }
186
+
187
+ // DER prefixes that wrap a raw 32-byte ed25519 key for node:crypto (RFC 8410).
188
+ const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
189
+ const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
190
+
191
+ /** ed25519 verification of `signature` over `message` by the base58 `publicKey`. */
192
+ export function ed25519Verify(signature: Buffer, message: Buffer, publicKey: string): boolean {
193
+ try {
194
+ const raw = base58Decode(publicKey);
195
+ if (raw.length !== 32 || signature.length !== 64) return false;
196
+ const key = createPublicKey({
197
+ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]),
198
+ format: 'der',
199
+ type: 'spki',
200
+ });
201
+ return verify(null, message, key, signature);
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ /** ed25519 signature over `message` with a Solana 64-byte secret key (seed ‖ public key). */
208
+ export function ed25519Sign(message: Buffer, secretKey: Uint8Array): Buffer {
209
+ const key = createPrivateKey({
210
+ key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from(secretKey.subarray(0, 32))]),
211
+ format: 'der',
212
+ type: 'pkcs8',
213
+ });
214
+ return sign(null, message, key);
215
+ }
216
+
217
+ /** The base58 public key a 64-byte Solana secret key's seed actually derives. */
218
+ export function publicKeyOfSecret(secretKey: Uint8Array): string {
219
+ const key = createPrivateKey({
220
+ key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from(secretKey.subarray(0, 32))]),
221
+ format: 'der',
222
+ type: 'pkcs8',
223
+ });
224
+ const spki = createPublicKey(key).export({ format: 'der', type: 'spki' });
225
+ return base58Encode(spki.subarray(spki.length - 32));
226
+ }
227
+
228
+ /**
229
+ * Adds `secretKey`'s signature to its own slot of a serialized transaction and returns the new
230
+ * bytes. Every other byte — the message and every other signature, including a co-signature
231
+ * already present — is left exactly as it was, and that is asserted before returning.
232
+ */
233
+ export function addOwnSignature(raw: Buffer, secretKey: Uint8Array, publicKey: string): Buffer {
234
+ if (publicKeyOfSecret(secretKey) !== publicKey) {
235
+ throw new Error('the wallet secret key does not derive its own public key — refusing to sign');
236
+ }
237
+ const tx = parseWireTransaction(raw);
238
+ const signers = tx.accountKeys.slice(0, tx.header.numRequiredSignatures);
239
+ const index = signers.indexOf(publicKey);
240
+ if (index < 0) {
241
+ throw new Error(
242
+ `refusing to sign: ${publicKey} is not a required signer of this transaction ` +
243
+ `(signers: ${signers.join(', ') || 'none'})`,
244
+ );
245
+ }
246
+ const signature = ed25519Sign(tx.message, secretKey);
247
+ const out = Buffer.from(raw);
248
+ signature.copy(out, tx.signatureOffset + 64 * index);
249
+
250
+ // Post-conditions: same length, same message, every other slot untouched, ours verifies.
251
+ const after = parseWireTransaction(out);
252
+ if (out.length !== raw.length || !after.message.equals(tx.message)) {
253
+ throw new Error('signing altered the transaction message — refusing to return it');
254
+ }
255
+ for (let i = 0; i < tx.signatures.length; i++) {
256
+ if (i !== index && !after.signatures[i].equals(tx.signatures[i])) {
257
+ throw new Error(`signing altered signature slot ${i} — refusing to return it`);
258
+ }
259
+ }
260
+ if (!ed25519Verify(after.signatures[index], after.message, publicKey)) {
261
+ throw new Error('the new signature does not verify — refusing to return it');
262
+ }
263
+ return out;
264
+ }
@@ -26,7 +26,7 @@ const RESPONSE_SCHEMA_PROPERTY = {
26
26
  'media_review has always used for track ratings, generalized to any mission type. ' +
27
27
  'media_review defaults to a canonical two-question schema when this is omitted at creation — ' +
28
28
  'literally { id: "rating", kind: "stars", required: true } and { id: "comment", kind: "long", ' +
29
- 'required: false } — so read/append against those exact ids if you rely on the default rather ' +
29
+ 'required: true } — so read/append against those exact ids if you rely on the default rather ' +
30
30
  'than supplying your own schema. Every other type has no default (stays fully manual/chat-based ' +
31
31
  'unless you set this).',
32
32
  items: {
@@ -63,7 +63,16 @@ const RESPONSE_SCHEMA_PROPERTY = {
63
63
  },
64
64
  label: { type: 'string', description: 'Question text shown to the human (max 200 chars). For the checkbox kind this is the checkbox\'s own clickable text, not a separate heading.' },
65
65
  help: { type: 'string', description: 'Optional helper text shown under the label (max 500 chars).' },
66
- required: { type: 'boolean', description: 'Whether this question must be answered to complete the mission.' },
66
+ required: {
67
+ type: 'boolean',
68
+ description:
69
+ 'Whether this question must be answered to complete the mission. A required: false question ' +
70
+ 'does NOT block mission completion or finalize_qualification\'s auto-qualification — both ' +
71
+ 'derive purely from the required questions, so an optional question left blank (or still ' +
72
+ 'being typed into) will not stop either from firing the instant the required ones are ' +
73
+ 'answered. If every question in the schema should actually be answered, mark them all ' +
74
+ 'required: true; reserve required: false for a genuinely optional/skippable field.',
75
+ },
67
76
  options: {
68
77
  type: 'array',
69
78
  items: { type: 'string' },
@@ -79,7 +88,22 @@ const RESPONSE_SCHEMA_PROPERTY = {
79
88
  export const missionTools: Tool[] = [
80
89
  {
81
90
  name: 'create_mission',
82
- description: 'Create a new mission. Off-chain missions (no budget field) are always free — no payment, no escrow. For paid missions, include the budget field AND chain: \'solana\' — the response will contain funding_params for fund_solana_mission. Base (EscrowVault) on-chain missions are closed to new creation during the Solana private beta migration: any budget-bearing request that resolves to chain \'base\' (including the pre-Solana default of omitting chain) now returns 503, not a created mission — always pass chain: \'solana\' explicitly for a paid mission.',
91
+ description:
92
+ 'Create a new mission. Off-chain missions (no budget field) are always free — no payment, no escrow. ' +
93
+ 'Include the budget field for a paid mission escrowed on Solana: it is created in pending_funding, and ' +
94
+ 'you then fund it with fund_solana_mission (pass the returned mission id). Solana is the only chain ' +
95
+ '(the backend also defaults a missing chain to solana, and this tool always sends it). ' +
96
+ 'A paid mission\'s response includes funding_params: chain (\'solana\'), cluster, program_id, rpc_url, ' +
97
+ 'mint, token_address (same as mint), token_decimals, amount_raw, base_pool, ' +
98
+ 'lottery_reward_per_winner_raw, lottery_winner_count, qualify_deadline, settlement_deadline, seed_commit ' +
99
+ 'and expires_at. There is no escrow address or task id yet: the program assigns those when the mission ' +
100
+ 'is funded. expires_at is creation + 24 hours — a mission still unfunded then is cancelled automatically. ' +
101
+ 'You do not need to build anything from funding_params; fund_solana_mission does the whole funding step. ' +
102
+ 'The response\'s mission.solana_quoted_fee_bps is the platform fee rate you were quoted: the escrow ' +
103
+ 'transaction carries it as max_fee_bps, the most the program may charge, and fund_solana_mission refuses ' +
104
+ 'to sign any other value. A paid lottery (lottery_winner_count > 0) is funded with a transaction the platform ' +
105
+ 'Operator has already co-signed; fund_solana_mission verifies that co-signature before adding yours. ' +
106
+ 'Paid-mission deadlines follow the escrow program\'s rules — see hiring_duration_hours and work_duration_hours.',
83
107
  inputSchema: {
84
108
  type: 'object',
85
109
  properties: {
@@ -90,8 +114,8 @@ export const missionTools: Tool[] = [
90
114
  },
91
115
  chain: {
92
116
  type: 'string',
93
- enum: ['base', 'solana'],
94
- description: 'Which chain escrows a paid (budget-bearing) mission. \'base\' is currently closed to new creation and returns 503 — pass \'solana\' for any on-chain mission. Ignored for off-chain missions (no budget field).',
117
+ enum: ['solana'],
118
+ description: 'Optional. Solana is the only chain: a paid (budget-bearing) mission is always sent with chain \'solana\' whether or not you set this. Ignored for off-chain missions (no budget field).',
95
119
  },
96
120
  title: { type: 'string', description: 'Mission title (max 100 chars)' },
97
121
  description: { type: 'string', description: 'Detailed mission description (max 2000 chars). Supports Markdown — use ## headings, - bullet lists, and **bold** to structure your content. The platform renders it as formatted text.' },
@@ -108,14 +132,14 @@ export const missionTools: Tool[] = [
108
132
  reward_usdt: { type: 'number', minimum: 0, description: 'Deprecated — ignored for off-chain missions (which are always free). Has no effect. Omit this field.' },
109
133
  max_participants: { type: 'number', minimum: 1, description: 'Maximum number of participants' },
110
134
  expires_in_hours: { type: 'number', minimum: 1, description: 'Hours until mission expires' },
111
- budget: { type: 'number', description: 'Total mission budget in USDC. Enables on-chain escrow — pair with chain: \'solana\' (Base is closed to new creation, see chain). Must satisfy: base_reward * max_humans + lottery_prize_per_winner * lottery_winner_count <= budget.' },
135
+ budget: { type: 'number', description: 'Total mission budget in USDC. Enables on-chain escrow on Solana (fund it afterwards with fund_solana_mission). Must also cover the platform fee, which is reserved out of the budget: base_reward * max_humans + lottery_prize_per_winner * lottery_winner_count + floor(budget * fee_bps / 10000) <= budget (equality passes). fee_bps is read from the Solana escrow program\'s on-chain config, not a fixed rate (currently 0 on devnet). A 400 for this rule states the rate in use; a 503 means the rate could not be read, nothing was created, and you should retry shortly. Both the backend and the Solana escrow program enforce this.' },
112
136
  max_humans: { type: 'number', minimum: 1, description: 'On-chain: maximum number of participants (both base-reward and lottery entrants).' },
113
137
  base_reward: { type: 'number', minimum: 0, description: 'Per-participant base reward in USDC paid to every qualified human. Defaults to 0. Set to 0 for pure-lottery missions.' },
114
138
  reward_per_human: { type: 'number', minimum: 0, description: 'Deprecated — use base_reward instead.' },
115
- lottery_winner_count: { type: 'integer', minimum: 1, description: 'Number of winners randomly selected from all qualified participants. Must be <= max_humans. Must be paired with lottery_prize_per_winner. Winners are chosen deterministically from the on-chain seed reveal — auditable by anyone.' },
139
+ lottery_winner_count: { type: 'integer', minimum: 1, description: 'Number of winners randomly selected from all qualified participants. Must be <= max_humans. Must be paired with lottery_prize_per_winner. For a paid mission the draw happens on chain at settlement: final_entropy = keccak256(seed_reveal ‖ entropy_hash), where seed_reveal opens the seed_commit fixed at funding and entropy_hash is the SlotHashes sysvar bank hash of the first produced slot after finalize_qualification, stored on the Task by record_entropy (settle captures it if nobody recorded it). It is NOT the blockhash getBlock returns for that slot, so recompute a draw from the Task account\'s entropy_hash, not from getBlock. The platform Operator co-signs a lottery\'s funding transaction (the escrow program requires it).' },
116
140
  lottery_prize_per_winner: { type: 'number', minimum: 0, description: 'Additional prize in USDC paid to each lottery winner on top of base_reward. Must be paired with lottery_winner_count.' },
117
- hiring_duration_hours: { type: 'number', minimum: 0.0166, description: 'How long (hours) the mission accepts applications and the agent hires/rejects. The hiring window closes at now + hiring_duration_hours. Finalize-qualification cannot be called before this. Backend floor is 60s (0.0166h), same for every chain — see work_duration_hours for the reasoning.' },
118
- work_duration_hours: { type: 'number', minimum: 0.0166, description: 'How long (hours) hired participants have to complete the work. Agent must call settle_mission before this period ends. Backend floor is 60s (0.0166h) for every chain — this is a flat sanity check against a near-zero window, not a guarantee of a legal settlement window on every chain: Base\'s own EscrowVault contract separately enforces its own fixed 1-hour minimum regardless of what this floor allows through, so a too-short Base mission still fails downstream (at finalize_qualification, or on-chain) instead of being caught at creation.' },
141
+ hiring_duration_hours: { type: 'number', minimum: 0.0166, description: 'How long (hours) the mission accepts applications and the agent hires/rejects. The hiring window closes at now + hiring_duration_hours. Finalize-qualification cannot be called before this. Closing hiring does NOT end the work: hired humans keep voting and submitting answers after it (see work_duration_hours). Backend floor is 60s (0.0166h) — see work_duration_hours for what that floor does and does not guarantee. For a paid mission the end of the hiring window is the escrow\'s qualify_deadline: it may be at most 180 days ahead, and it is the cancel cut-off — refund_solana_mission\'s cancel is refused on chain from qualify_deadline on (TooLateToCancel).' },
142
+ work_duration_hours: { type: 'number', minimum: 0.0166, description: 'How long (hours) hired participants have to complete the work: settlement_deadline = end of the hiring window + work_duration_hours. Agent must call settle_mission before this period ends. Humans can vote and submit answers while the mission is active: on-chain until max(qualify_deadline, settlement_deadline − 1260s), i.e. about 21 minutes before settlement_deadline; off-chain until finalize. Either way, finalize_qualification closes submissions. Backend floor is 60s (0.0166h) — a sanity check against a near-zero window, not a guarantee the escrow accepts it. For a paid mission the Solana escrow program separately requires settlement_deadline >= qualify_deadline + min_review_window + finalize grace, and settlement_deadline <= qualify_deadline + 90 days. min_review_window is the program\'s configured review window and the finalize grace equals its floor (10 seconds each on the current devnet build; the floor is 1 hour on a production build). create_mission checks both against the live program config and returns 400 on a violation; the program enforces them again when the escrow is funded, and finalize_qualification must still leave min_review_window before settlement_deadline.' },
119
143
  auto_accept_applicants: { type: 'boolean', description: 'Defaults to true — applicants are automatically hired when they apply, no manual hire_participant call needed, first-come first-served up to max_humans, face verification still required. Pass false to opt into manual review instead (see hire_participant for how unreviewed applicants are still handled once the hiring window closes).' },
120
144
  response_schema: RESPONSE_SCHEMA_PROPERTY,
121
145
  },
@@ -167,21 +191,9 @@ export const missionTools: Tool[] = [
167
191
  required: ['mission_id'],
168
192
  },
169
193
  },
170
- {
171
- name: 'confirm_funding',
172
- description: 'Base (EscrowVault) only — Solana missions use fund_solana_mission instead, which confirms as part of the same call. Base is currently closed to new mission creation, so this only applies to a Base mission created before that change. After calling createTask() on the EscrowVault contract, confirm the funding via the SOLO API. The backend verifies the transaction on-chain. Mission transitions from pending_funding → active. tx_hash is optional — backend reconciles from the contract if omitted. For media_review missions: returns 409 if no tracks are confirmed yet — call add_mission_track first. Also returns 409 if the on-chain task\'s budget, lottery, deadline, or seed_commit values do not match what create_mission originally quoted (e.g. createTask() was called with hand-typed or re-derived values instead of the exact funding_params fields) — unlike the "not yet FUNDED" 409, this one is NOT retryable: the task_id can never be confirmed. Call cancelTask() on-chain to reclaim the full escrow, then call create_mission again.',
173
- inputSchema: {
174
- type: 'object',
175
- properties: {
176
- mission_id: { type: 'string' },
177
- tx_hash: { type: 'string', description: 'Transaction hash of createTask() call (optional)' },
178
- },
179
- required: ['mission_id'],
180
- },
181
- },
182
194
  {
183
195
  name: 'hire_participant',
184
- description: 'Accept a human applicant for a mission. Only applied humans can be hired. Hired humans can start work via conversations. Only valid while mission is active. IMPORTANT: silence is not neutral — an applicant you never call this (or reject_participant) on is automatically hired once the hiring window closes (up to max_humans, oldest applied_at first). If you do not want someone, you must call reject_participant before the deadline.',
196
+ description: 'Accept a human applicant for a mission. Only applied humans can be hired. Hired humans can start work via conversations. Only valid while mission is active. IMPORTANT: silence is not neutral — an applicant you never call this (or reject_participant) on is automatically hired (up to max_humans, oldest applied_at first). That does NOT happen when the hiring window closes. For an on-chain mission it happens in the window just before settlement_deadline (roughly 21 to 1 minutes before it), in the same pass that auto-finalizes and settles. For an off-chain mission it happens at expires_at. If you do not want someone, call reject_participant before then.',
185
197
  inputSchema: {
186
198
  type: 'object',
187
199
  properties: {
@@ -193,7 +205,7 @@ export const missionTools: Tool[] = [
193
205
  },
194
206
  {
195
207
  name: 'reject_participant',
196
- description: 'Reject a human applicant or hired participant. Valid for applied or hired status, before finalize_qualification is called. This is the ONLY way to exclude someone — the platform auto-hires overdue applicants and auto-qualifies overdue hired participants by default (silence = go), so if you do not explicitly reject someone before their deadline, they end up hired/qualified/paid.',
208
+ description: 'Reject a human applicant or hired participant. Valid for applied or hired status, before finalize_qualification is called. This is the ONLY way to exclude someone — the platform auto-hires remaining applicants and auto-qualifies hired participants by default (silence = go), so anyone you do not explicitly reject ends up hired, qualified and paid. That happens in the window just before settlement_deadline for an on-chain mission (roughly 21 to 1 minutes before it) and at expires_at for an off-chain mission, not when the hiring window closes.',
197
209
  inputSchema: {
198
210
  type: 'object',
199
211
  properties: {
@@ -205,7 +217,7 @@ export const missionTools: Tool[] = [
205
217
  },
206
218
  {
207
219
  name: 'finalize_qualification',
208
- description: 'Lock in the qualified participants after reviewing their work. If the mission has a response_schema (media_review always does, by default or explicitly; any other type only if you set one via create_mission/update_mission_questions), pass an empty body — qualified_human_uids is ignored and the backend auto-qualifies anyone who completed it (every ready track for media_review, a full submission for other types). Otherwise provide an explicit list of UIDs whose work was accepted. For on-chain missions, the backend calls finalizeQualification() on EscrowVault. Mission transitions to qualifying. If you never call this, the platform does it for you shortly after the hiring window closes, qualifying every hired participant you did not explicitly reject_participant (silence = go) — call reject_participant first for anyone whose work should NOT be paid.',
220
+ description: 'Lock in the qualified participants after reviewing their work. If the mission has a response_schema (media_review always does, by default or explicitly; any other type only if you set one via create_mission/update_mission_questions), pass an empty body — qualified_human_uids is ignored and the backend auto-qualifies anyone who completed it (every ready track for media_review, a full submission for other types). Otherwise provide an explicit list of UIDs whose work was accepted. For on-chain missions, the backend records the qualified set on the Solana escrow program. Mission transitions to qualifying. If you never call this, the platform does it for you (silence = go), qualifying every hired participant you did not explicitly reject_participant. It does NOT happen when the hiring window closes. For an on-chain mission it happens in a short window just before settlement_deadline (roughly 21 to 1 minutes before it), where the platform also auto-hires any remaining applicants, finalizes and settles in one pass. For an off-chain mission it happens at expires_at. Call reject_participant first for anyone whose work should NOT be paid.',
209
221
  inputSchema: {
210
222
  type: 'object',
211
223
  properties: {
@@ -221,7 +233,7 @@ export const missionTools: Tool[] = [
221
233
  },
222
234
  {
223
235
  name: 'settle_mission',
224
- description: 'Settle the mission after finalize_qualification. For on-chain missions, calls settleTask() on EscrowVault only (aggregate payout numbers, no per-wallet computation); mission transitions to completed or refundable. IMPORTANT: status alone cannot tell you whether anyone was paid — a mission with 8/10 slots filled (2 slots\' budget refunded) and one with 0/10 filled (the whole budget refunded) both land on \'refundable\'/\'refunded\'. Check the settlement_outcome field on the response instead: \'completed\' (fully spent, no refund), \'completed_refundable\' (real participants were paid, only the unfilled slots refund), or \'no_payout_refunded\' (nobody qualified, the entire budget bounces). Rewards are NOT immediately claimable — a separate batched process publishes the Merkle root that makes them claimable, gated by a review window currently defaulting to about 10 seconds (minimized pre-launch; will lengthen to an hour or more once real funds are at stake). Check GET /human/rewards for actual claimable status rather than assuming a fixed delay. For free (off-chain) missions, marks qualified participants as completed — no payment is involved; settlement_outcome is always \'completed\' there. If you never call this, the platform settles on your behalf shortly after finalize_qualification (auto or manual) — you do not get a second chance to change who is qualified at that point, so reject anyone unwanted before finalize, not after.',
236
+ description: 'Settle the mission after finalize_qualification. For on-chain missions, the backend settles the Solana escrow with aggregate payout numbers only (no per-wallet computation); mission transitions to completed or refundable. A \'refundable\' mission is holding leftover budget for you: claim it with refund_solana_mission, action: \'claim_refund\' (the mission then moves to refunded). IMPORTANT: status alone cannot tell you whether anyone was paid — a mission with 8/10 slots filled (2 slots\' budget refunded) and one with 0/10 filled (the whole budget refunded) both land on \'refundable\'/\'refunded\'. Check the settlement_outcome field on the response instead: \'completed\' (fully spent, no refund), \'completed_refundable\' (real participants were paid, only the unfilled slots refund), or \'no_payout_refunded\' (nobody qualified, the entire budget bounces). Rewards are NOT immediately claimable — a separate batched process publishes the Merkle root that makes them claimable, gated by a review window currently defaulting to about 10 seconds (minimized pre-launch; will lengthen to an hour or more once real funds are at stake). Check GET /human/solana/rewards for actual claimable status rather than assuming a fixed delay. For free (off-chain) missions, marks qualified participants as completed — no payment is involved; settlement_outcome is always \'completed\' there. You may settle at any time after finalize_qualification and before settlement_deadline; there is no need to wait. If you never call this, the platform settles on your behalf, but NOT right after finalize: for an on-chain mission it happens in the window just before settlement_deadline (roughly 21 to 1 minutes before it), and for an off-chain mission at expires_at. Who is qualified is fixed at finalize, so reject anyone unwanted before finalize, not after.',
225
237
  inputSchema: {
226
238
  type: 'object',
227
239
  properties: {
@@ -232,7 +244,16 @@ export const missionTools: Tool[] = [
232
244
  },
233
245
  {
234
246
  name: 'cancel_mission',
235
- description: 'Cancel an off-chain mission directly. Valid when status is active or qualifying. For on-chain missions, use get_cancel_params instead.',
247
+ description:
248
+ 'Cancel a mission that holds no escrowed funds: an off-chain mission (status active or qualifying), or ' +
249
+ 'an unfunded Solana mission still in pending_funding. For a Solana mission the backend first checks the ' +
250
+ 'chain: if it finds an escrow that was created but never confirmed, it returns 409 with that task_id and ' +
251
+ 'does not cancel — use refund_solana_mission with action: \'cancel\' then. For a funded mission also use ' +
252
+ 'refund_solana_mission with action: \'cancel\': it returns the escrowed budget to you and moves the mission ' +
253
+ 'to cancelled, but only strictly before qualify_deadline (the end of the hiring window) — the escrow program ' +
254
+ 'refuses a cancel from then on (TooLateToCancel), and the remaining exit is emergency_refund once ' +
255
+ 'settlement_deadline passes without a settlement. An unfunded Solana mission you leave alone is cancelled automatically at its ' +
256
+ 'funding_params.expires_at (creation + 24 hours).',
236
257
  inputSchema: {
237
258
  type: 'object',
238
259
  properties: {
@@ -241,78 +262,9 @@ export const missionTools: Tool[] = [
241
262
  required: ['mission_id'],
242
263
  },
243
264
  },
244
- {
245
- name: 'get_cancel_params',
246
- description: 'Get on-chain transaction parameters to cancel a funded mission via cancelTask() on EscrowVault. Only valid before qualify_deadline. After qualify_deadline, use get_emergency_refund_params instead.',
247
- inputSchema: {
248
- type: 'object',
249
- properties: {
250
- mission_id: { type: 'string' },
251
- },
252
- required: ['mission_id'],
253
- },
254
- },
255
- {
256
- name: 'confirm_cancel',
257
- description: 'After executing cancelTask() on EscrowVault, confirm the cancellation on the SOLO platform. Mission transitions to cancelled.',
258
- inputSchema: {
259
- type: 'object',
260
- properties: {
261
- mission_id: { type: 'string' },
262
- tx_hash: { type: 'string', description: 'Transaction hash of cancelTask() (optional)' },
263
- },
264
- required: ['mission_id'],
265
- },
266
- },
267
- {
268
- name: 'get_emergency_refund_params',
269
- description: 'Get on-chain transaction parameters to force-refund a mission after the settlement_deadline has passed without settlement. Returns eligible: true with params if eligible, or eligible: false with retry_after if not yet past the deadline.',
270
- inputSchema: {
271
- type: 'object',
272
- properties: {
273
- mission_id: { type: 'string' },
274
- },
275
- required: ['mission_id'],
276
- },
277
- },
278
- {
279
- name: 'confirm_emergency_refund',
280
- description: 'After executing emergencyRefund() on EscrowVault, confirm on the SOLO platform. Mission transitions to cancelled.',
281
- inputSchema: {
282
- type: 'object',
283
- properties: {
284
- mission_id: { type: 'string' },
285
- tx_hash: { type: 'string', description: 'Transaction hash of emergencyRefund() (optional)' },
286
- },
287
- required: ['mission_id'],
288
- },
289
- },
290
- {
291
- name: 'get_refund_params',
292
- description: 'Get on-chain transaction parameters to claim unused budget via claimRefund() on EscrowVault. Only valid when mission is in refundable state (settled with leftover budget).',
293
- inputSchema: {
294
- type: 'object',
295
- properties: {
296
- mission_id: { type: 'string' },
297
- },
298
- required: ['mission_id'],
299
- },
300
- },
301
- {
302
- name: 'confirm_refund',
303
- description: 'After executing claimRefund() on EscrowVault, confirm the refund on the SOLO platform. Mission transitions to refunded.',
304
- inputSchema: {
305
- type: 'object',
306
- properties: {
307
- mission_id: { type: 'string' },
308
- tx_hash: { type: 'string', description: 'Transaction hash of claimRefund() (optional)' },
309
- },
310
- required: ['mission_id'],
311
- },
312
- },
313
265
  {
314
266
  name: 'rate_participant',
315
- description: 'Leave a rating and optional comment for a mission participant. Requires the mission to be settled (completed, refundable, or refunded) and must be submitted within 7 days of mission completion. Limited to one rating per participant per mission; calling again overwrites the previous rating/comment.',
267
+ description: 'Leave a rating and optional comment for a mission participant. Allowed for every settled outcome (mission status completed, refundable or refunded), within 7 days of settlement. Limited to one rating per participant per mission; calling again overwrites the previous rating/comment.',
316
268
  inputSchema: {
317
269
  type: 'object',
318
270
  properties: {
@@ -328,8 +280,14 @@ export const missionTools: Tool[] = [
328
280
 
329
281
  export async function handleMissionTool(name: string, args: Record<string, any>): Promise<unknown> {
330
282
  switch (name) {
331
- case 'create_mission':
332
- return apiPost('/agent/missions', args);
283
+ case 'create_mission': {
284
+ // Solana is the only chain. The backend still treats a missing `chain` on a budget-bearing
285
+ // request as the retired default and rejects it with 503, so a paid mission always carries
286
+ // chain: 'solana' no matter what the caller passed. `budget !== undefined` mirrors the
287
+ // backend's own on-chain test; `chain` is dropped from an off-chain request, where it is unused.
288
+ const { chain: _chain, ...body } = args;
289
+ return apiPost('/agent/missions', body.budget !== undefined ? { ...body, chain: 'solana' } : body);
290
+ }
333
291
 
334
292
  case 'list_missions': {
335
293
  const params: Record<string, any> = {};
@@ -345,9 +303,6 @@ export async function handleMissionTool(name: string, args: Record<string, any>)
345
303
  case 'update_mission_questions':
346
304
  return apiPatch(`/agent/missions/${args.mission_id}/questions`, { response_schema: args.response_schema });
347
305
 
348
- case 'confirm_funding':
349
- return apiPost(`/agent/missions/${args.mission_id}/confirm-funding`, { tx_hash: args.tx_hash });
350
-
351
306
  case 'hire_participant':
352
307
  return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/hire`);
353
308
 
@@ -365,24 +320,6 @@ export async function handleMissionTool(name: string, args: Record<string, any>)
365
320
  case 'cancel_mission':
366
321
  return apiPost(`/agent/missions/${args.mission_id}/cancel`);
367
322
 
368
- case 'get_cancel_params':
369
- return apiGet(`/agent/missions/${args.mission_id}/cancel-params`);
370
-
371
- case 'confirm_cancel':
372
- return apiPost(`/agent/missions/${args.mission_id}/confirm-cancel`, { tx_hash: args.tx_hash });
373
-
374
- case 'get_emergency_refund_params':
375
- return apiGet(`/agent/missions/${args.mission_id}/emergency-refund-params`);
376
-
377
- case 'confirm_emergency_refund':
378
- return apiPost(`/agent/missions/${args.mission_id}/confirm-emergency-refund`, { tx_hash: args.tx_hash });
379
-
380
- case 'get_refund_params':
381
- return apiGet(`/agent/missions/${args.mission_id}/refund-params`);
382
-
383
- case 'confirm_refund':
384
- return apiPost(`/agent/missions/${args.mission_id}/confirm-refund`, { tx_hash: args.tx_hash });
385
-
386
323
  case 'rate_participant':
387
324
  return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/comment`, {
388
325
  rating: args.rating,