@projectsolo/solo-mission-mcp 0.21.14 → 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.
- package/README.md +96 -12
- package/dist/chunk-NUGGTARW.js +196 -0
- package/dist/{chunk-TKRT2V2W.js → chunk-OP4QYTL4.js} +5 -0
- package/dist/{client-LDWK5HLP.js → client-NS6J6IO2.js} +1 -1
- package/dist/deployment-UCUWVIPJ.js +43 -0
- package/dist/escrowErrors-4XSAYLGT.js +56 -0
- package/dist/index.js +239 -160
- package/dist/verify-7EUVRUAS.js +445 -0
- package/dist/{wallet-V4T4NTCM.js → wallet-LX7SI5RE.js} +28 -14
- package/dist/wire-RLDY4AJR.js +24 -0
- package/package.json +1 -1
- package/src/config.ts +11 -0
- package/src/index.ts +1 -1
- package/src/scripts/check-tools-against-spec.ts +3 -3
- package/src/solana/deployment.test.ts +61 -0
- package/src/solana/deployment.ts +89 -0
- package/src/solana/escrowErrors.ts +78 -0
- package/src/solana/fixtures/config-account.json +6 -0
- package/src/solana/fixtures/funding-transaction-v2-lottery.json +49 -0
- package/src/solana/fixtures/funding-transaction-v2-plain.json +46 -0
- package/src/solana/fixtures/solo_escrow.v2.idl-excerpt.json +683 -0
- package/src/solana/verify.test.ts +605 -78
- package/src/solana/verify.ts +545 -84
- package/src/solana/wallet.test.ts +85 -2
- package/src/solana/wallet.ts +74 -31
- package/src/solana/wire.test.ts +129 -0
- package/src/solana/wire.ts +264 -0
- package/src/tools/missions.ts +45 -117
- package/src/tools/solana.test.ts +356 -0
- package/src/tools/solana.ts +309 -70
- package/src/tools/tracks.ts +1 -1
- package/dist/verify-KAETIGV5.js +0 -136
- /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
|
+
}
|
package/src/tools/missions.ts
CHANGED
|
@@ -88,7 +88,22 @@ const RESPONSE_SCHEMA_PROPERTY = {
|
|
|
88
88
|
export const missionTools: Tool[] = [
|
|
89
89
|
{
|
|
90
90
|
name: 'create_mission',
|
|
91
|
-
description:
|
|
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.',
|
|
92
107
|
inputSchema: {
|
|
93
108
|
type: 'object',
|
|
94
109
|
properties: {
|
|
@@ -99,8 +114,8 @@ export const missionTools: Tool[] = [
|
|
|
99
114
|
},
|
|
100
115
|
chain: {
|
|
101
116
|
type: 'string',
|
|
102
|
-
enum: ['
|
|
103
|
-
description: '
|
|
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).',
|
|
104
119
|
},
|
|
105
120
|
title: { type: 'string', description: 'Mission title (max 100 chars)' },
|
|
106
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.' },
|
|
@@ -117,14 +132,14 @@ export const missionTools: Tool[] = [
|
|
|
117
132
|
reward_usdt: { type: 'number', minimum: 0, description: 'Deprecated — ignored for off-chain missions (which are always free). Has no effect. Omit this field.' },
|
|
118
133
|
max_participants: { type: 'number', minimum: 1, description: 'Maximum number of participants' },
|
|
119
134
|
expires_in_hours: { type: 'number', minimum: 1, description: 'Hours until mission expires' },
|
|
120
|
-
budget: { type: 'number', description: 'Total mission budget in USDC. Enables on-chain escrow
|
|
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.' },
|
|
121
136
|
max_humans: { type: 'number', minimum: 1, description: 'On-chain: maximum number of participants (both base-reward and lottery entrants).' },
|
|
122
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.' },
|
|
123
138
|
reward_per_human: { type: 'number', minimum: 0, description: 'Deprecated — use base_reward instead.' },
|
|
124
|
-
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.
|
|
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).' },
|
|
125
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.' },
|
|
126
|
-
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)
|
|
127
|
-
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)
|
|
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.' },
|
|
128
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).' },
|
|
129
144
|
response_schema: RESPONSE_SCHEMA_PROPERTY,
|
|
130
145
|
},
|
|
@@ -176,21 +191,9 @@ export const missionTools: Tool[] = [
|
|
|
176
191
|
required: ['mission_id'],
|
|
177
192
|
},
|
|
178
193
|
},
|
|
179
|
-
{
|
|
180
|
-
name: 'confirm_funding',
|
|
181
|
-
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.',
|
|
182
|
-
inputSchema: {
|
|
183
|
-
type: 'object',
|
|
184
|
-
properties: {
|
|
185
|
-
mission_id: { type: 'string' },
|
|
186
|
-
tx_hash: { type: 'string', description: 'Transaction hash of createTask() call (optional)' },
|
|
187
|
-
},
|
|
188
|
-
required: ['mission_id'],
|
|
189
|
-
},
|
|
190
|
-
},
|
|
191
194
|
{
|
|
192
195
|
name: 'hire_participant',
|
|
193
|
-
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
|
|
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.',
|
|
194
197
|
inputSchema: {
|
|
195
198
|
type: 'object',
|
|
196
199
|
properties: {
|
|
@@ -202,7 +205,7 @@ export const missionTools: Tool[] = [
|
|
|
202
205
|
},
|
|
203
206
|
{
|
|
204
207
|
name: 'reject_participant',
|
|
205
|
-
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
|
|
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.',
|
|
206
209
|
inputSchema: {
|
|
207
210
|
type: 'object',
|
|
208
211
|
properties: {
|
|
@@ -214,7 +217,7 @@ export const missionTools: Tool[] = [
|
|
|
214
217
|
},
|
|
215
218
|
{
|
|
216
219
|
name: 'finalize_qualification',
|
|
217
|
-
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
|
|
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.',
|
|
218
221
|
inputSchema: {
|
|
219
222
|
type: 'object',
|
|
220
223
|
properties: {
|
|
@@ -230,7 +233,7 @@ export const missionTools: Tool[] = [
|
|
|
230
233
|
},
|
|
231
234
|
{
|
|
232
235
|
name: 'settle_mission',
|
|
233
|
-
description: 'Settle the mission after finalize_qualification. For on-chain missions,
|
|
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.',
|
|
234
237
|
inputSchema: {
|
|
235
238
|
type: 'object',
|
|
236
239
|
properties: {
|
|
@@ -241,7 +244,16 @@ export const missionTools: Tool[] = [
|
|
|
241
244
|
},
|
|
242
245
|
{
|
|
243
246
|
name: 'cancel_mission',
|
|
244
|
-
description:
|
|
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).',
|
|
245
257
|
inputSchema: {
|
|
246
258
|
type: 'object',
|
|
247
259
|
properties: {
|
|
@@ -250,78 +262,9 @@ export const missionTools: Tool[] = [
|
|
|
250
262
|
required: ['mission_id'],
|
|
251
263
|
},
|
|
252
264
|
},
|
|
253
|
-
{
|
|
254
|
-
name: 'get_cancel_params',
|
|
255
|
-
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.',
|
|
256
|
-
inputSchema: {
|
|
257
|
-
type: 'object',
|
|
258
|
-
properties: {
|
|
259
|
-
mission_id: { type: 'string' },
|
|
260
|
-
},
|
|
261
|
-
required: ['mission_id'],
|
|
262
|
-
},
|
|
263
|
-
},
|
|
264
|
-
{
|
|
265
|
-
name: 'confirm_cancel',
|
|
266
|
-
description: 'After executing cancelTask() on EscrowVault, confirm the cancellation on the SOLO platform. Mission transitions to cancelled.',
|
|
267
|
-
inputSchema: {
|
|
268
|
-
type: 'object',
|
|
269
|
-
properties: {
|
|
270
|
-
mission_id: { type: 'string' },
|
|
271
|
-
tx_hash: { type: 'string', description: 'Transaction hash of cancelTask() (optional)' },
|
|
272
|
-
},
|
|
273
|
-
required: ['mission_id'],
|
|
274
|
-
},
|
|
275
|
-
},
|
|
276
|
-
{
|
|
277
|
-
name: 'get_emergency_refund_params',
|
|
278
|
-
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.',
|
|
279
|
-
inputSchema: {
|
|
280
|
-
type: 'object',
|
|
281
|
-
properties: {
|
|
282
|
-
mission_id: { type: 'string' },
|
|
283
|
-
},
|
|
284
|
-
required: ['mission_id'],
|
|
285
|
-
},
|
|
286
|
-
},
|
|
287
|
-
{
|
|
288
|
-
name: 'confirm_emergency_refund',
|
|
289
|
-
description: 'After executing emergencyRefund() on EscrowVault, confirm on the SOLO platform. Mission transitions to cancelled.',
|
|
290
|
-
inputSchema: {
|
|
291
|
-
type: 'object',
|
|
292
|
-
properties: {
|
|
293
|
-
mission_id: { type: 'string' },
|
|
294
|
-
tx_hash: { type: 'string', description: 'Transaction hash of emergencyRefund() (optional)' },
|
|
295
|
-
},
|
|
296
|
-
required: ['mission_id'],
|
|
297
|
-
},
|
|
298
|
-
},
|
|
299
|
-
{
|
|
300
|
-
name: 'get_refund_params',
|
|
301
|
-
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).',
|
|
302
|
-
inputSchema: {
|
|
303
|
-
type: 'object',
|
|
304
|
-
properties: {
|
|
305
|
-
mission_id: { type: 'string' },
|
|
306
|
-
},
|
|
307
|
-
required: ['mission_id'],
|
|
308
|
-
},
|
|
309
|
-
},
|
|
310
|
-
{
|
|
311
|
-
name: 'confirm_refund',
|
|
312
|
-
description: 'After executing claimRefund() on EscrowVault, confirm the refund on the SOLO platform. Mission transitions to refunded.',
|
|
313
|
-
inputSchema: {
|
|
314
|
-
type: 'object',
|
|
315
|
-
properties: {
|
|
316
|
-
mission_id: { type: 'string' },
|
|
317
|
-
tx_hash: { type: 'string', description: 'Transaction hash of claimRefund() (optional)' },
|
|
318
|
-
},
|
|
319
|
-
required: ['mission_id'],
|
|
320
|
-
},
|
|
321
|
-
},
|
|
322
265
|
{
|
|
323
266
|
name: 'rate_participant',
|
|
324
|
-
description: 'Leave a rating and optional comment for a mission participant.
|
|
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.',
|
|
325
268
|
inputSchema: {
|
|
326
269
|
type: 'object',
|
|
327
270
|
properties: {
|
|
@@ -337,8 +280,14 @@ export const missionTools: Tool[] = [
|
|
|
337
280
|
|
|
338
281
|
export async function handleMissionTool(name: string, args: Record<string, any>): Promise<unknown> {
|
|
339
282
|
switch (name) {
|
|
340
|
-
case 'create_mission':
|
|
341
|
-
|
|
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
|
+
}
|
|
342
291
|
|
|
343
292
|
case 'list_missions': {
|
|
344
293
|
const params: Record<string, any> = {};
|
|
@@ -354,9 +303,6 @@ export async function handleMissionTool(name: string, args: Record<string, any>)
|
|
|
354
303
|
case 'update_mission_questions':
|
|
355
304
|
return apiPatch(`/agent/missions/${args.mission_id}/questions`, { response_schema: args.response_schema });
|
|
356
305
|
|
|
357
|
-
case 'confirm_funding':
|
|
358
|
-
return apiPost(`/agent/missions/${args.mission_id}/confirm-funding`, { tx_hash: args.tx_hash });
|
|
359
|
-
|
|
360
306
|
case 'hire_participant':
|
|
361
307
|
return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/hire`);
|
|
362
308
|
|
|
@@ -374,24 +320,6 @@ export async function handleMissionTool(name: string, args: Record<string, any>)
|
|
|
374
320
|
case 'cancel_mission':
|
|
375
321
|
return apiPost(`/agent/missions/${args.mission_id}/cancel`);
|
|
376
322
|
|
|
377
|
-
case 'get_cancel_params':
|
|
378
|
-
return apiGet(`/agent/missions/${args.mission_id}/cancel-params`);
|
|
379
|
-
|
|
380
|
-
case 'confirm_cancel':
|
|
381
|
-
return apiPost(`/agent/missions/${args.mission_id}/confirm-cancel`, { tx_hash: args.tx_hash });
|
|
382
|
-
|
|
383
|
-
case 'get_emergency_refund_params':
|
|
384
|
-
return apiGet(`/agent/missions/${args.mission_id}/emergency-refund-params`);
|
|
385
|
-
|
|
386
|
-
case 'confirm_emergency_refund':
|
|
387
|
-
return apiPost(`/agent/missions/${args.mission_id}/confirm-emergency-refund`, { tx_hash: args.tx_hash });
|
|
388
|
-
|
|
389
|
-
case 'get_refund_params':
|
|
390
|
-
return apiGet(`/agent/missions/${args.mission_id}/refund-params`);
|
|
391
|
-
|
|
392
|
-
case 'confirm_refund':
|
|
393
|
-
return apiPost(`/agent/missions/${args.mission_id}/confirm-refund`, { tx_hash: args.tx_hash });
|
|
394
|
-
|
|
395
323
|
case 'rate_participant':
|
|
396
324
|
return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/comment`, {
|
|
397
325
|
rating: args.rating,
|