@projectsolo/solo-mission-mcp 0.21.9 → 0.21.11
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/dist/index.js +32 -12
- package/dist/wallet-V4T4NTCM.js +143 -0
- package/package.json +1 -1
- package/src/solana/fixtures/test-keypair.json.enc +2 -0
- package/src/solana/fixtures/test-keypair.pubkey +1 -0
- package/src/solana/wallet.test.ts +45 -0
- package/src/solana/wallet.ts +101 -7
- package/src/tools/missions.ts +54 -18
- package/src/tools/solana.ts +16 -1
- package/dist/wallet-IUQWBW6F.js +0 -90
package/dist/index.js
CHANGED
|
@@ -19,25 +19,38 @@ import {
|
|
|
19
19
|
var RESPONSE_SCHEMA_PROPERTY = {
|
|
20
20
|
type: "array",
|
|
21
21
|
maxItems: 20,
|
|
22
|
-
description:
|
|
22
|
+
description: 'Structured completion questions a human answers to complete the mission. Question ids must be unique within one schema \u2014 a duplicate id is rejected at creation/update, not merged or silently overwritten. When set, finalize_qualification auto-derives qualified UIDs from completion instead of requiring an explicit qualified_human_uids list \u2014 same mechanism media_review has always used for track ratings, generalized to any mission type. media_review defaults to a canonical two-question schema when this is omitted at creation \u2014 literally { id: "rating", kind: "stars", required: true } and { id: "comment", kind: "long", required: false } \u2014 so read/append against those exact ids if you rely on the default rather than supplying your own schema. Every other type has no default (stays fully manual/chat-based unless you set this).',
|
|
23
23
|
items: {
|
|
24
24
|
type: "object",
|
|
25
25
|
properties: {
|
|
26
|
-
id: { type: "string", description: "Unique id within this schema \u2014 the key answers are stored under." },
|
|
26
|
+
id: { type: "string", description: "Unique id within this schema \u2014 the key answers are stored under. Duplicates are rejected, not merged." },
|
|
27
27
|
kind: {
|
|
28
28
|
type: "string",
|
|
29
|
-
enum: [
|
|
30
|
-
|
|
29
|
+
enum: [
|
|
30
|
+
"single",
|
|
31
|
+
"multi",
|
|
32
|
+
"likert",
|
|
33
|
+
"stars",
|
|
34
|
+
"short",
|
|
35
|
+
"long",
|
|
36
|
+
"dropdown",
|
|
37
|
+
"checkbox",
|
|
38
|
+
"timestamp_tag",
|
|
39
|
+
"image_region",
|
|
40
|
+
"video_region_duration"
|
|
41
|
+
],
|
|
42
|
+
description: 'single=one choice, multi=several choices, likert=a scale rendered as a row of buttons (NOT fixed at 5 points \u2014 the number of options you supply IS the number of points, so give exactly as many option labels as the scale should have; a common convention is 5 labels like "1 Strongly disagree".."5 Strongly agree" but nothing enforces that), stars=1-5 rating (fixed, no options), short=one line of text, long=paragraph text (see min_length), dropdown=one choice from a long list, checkbox=a checkbox that must be explicitly true (usable for ANY yes/no question, not just consent-flavored ones) \u2014 the checkbox\'s own clickable text is `label` itself (there is no separate heading rendered above it, unlike every other kind); use `help` for any additional wording you want shown near it.\n\nMedia-anchored kinds \u2014 media_review only, need a track to annotate (options/min_length don\'t apply; see min_count instead):\n timestamp_tag: the human flags a moment in an audio/video track while it plays. Each flag is { t (seconds), tag (category \u2014 one of `options` if you set it, otherwise any non-empty string), note? }. `options` is OPTIONAL here (unlike the choice kinds above) \u2014 omit it for free-form tagging, or set it to offer a closed category list.\n image_region: the human draws a rectangle on an image track and comments on it. Each region is { x, y, width, height (all 0-1, fraction of the image, not pixels), comment }.\n video_region_duration: like image_region, but the rectangle is held for a single time range rather than a single point \u2014 { start, end (seconds, end > start), x, y, width, height, comment }. One fixed box for the whole range, not tracked frame by frame.'
|
|
31
43
|
},
|
|
32
|
-
label: { type: "string", description: "Question text shown to the human (max 200 chars)." },
|
|
44
|
+
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." },
|
|
33
45
|
help: { type: "string", description: "Optional helper text shown under the label (max 500 chars)." },
|
|
34
46
|
required: { type: "boolean", description: "Whether this question must be answered to complete the mission." },
|
|
35
47
|
options: {
|
|
36
48
|
type: "array",
|
|
37
49
|
items: { type: "string" },
|
|
38
|
-
description: "Required for single/multi/dropdown/likert (max 20 options, 50 chars each). Not accepted by other
|
|
50
|
+
description: "Required (non-empty) for single/multi/dropdown/likert (max 20 options, 50 chars each). Optional for timestamp_tag (a closed category list; omit for free-form tagging). Not accepted by any other kind."
|
|
39
51
|
},
|
|
40
|
-
min_length: { type: "number", description: "Minimum answer length
|
|
52
|
+
min_length: { type: "number", description: "Minimum answer length. Only read for the 'long' kind \u2014 silently ignored (not rejected) if set on any other kind." },
|
|
53
|
+
min_count: { type: "number", description: "Minimum number of annotations required. Only read for timestamp_tag/image_region/video_region_duration (defaults to 1 if required and unset) \u2014 silently ignored on any other kind." }
|
|
41
54
|
},
|
|
42
55
|
required: ["id", "kind", "label", "required"]
|
|
43
56
|
}
|
|
@@ -90,7 +103,7 @@ var missionTools = [
|
|
|
90
103
|
},
|
|
91
104
|
{
|
|
92
105
|
name: "update_mission_questions",
|
|
93
|
-
description: "Set or replace a mission's response_schema after creation. Locked once hiring starts \u2014 same window as add_mission_track (on-chain: only before funding; off-chain: only while active with zero hired participants yet). Replaces the entire schema, not a merge.",
|
|
106
|
+
description: "Set or replace a mission's response_schema after creation. Locked once hiring starts \u2014 same window as add_mission_track (on-chain: only before funding; off-chain: only while active with zero hired participants yet). This window is what makes a schema replacement safe: a human can only submit answers once hired, and the schema can no longer change once anyone has been hired \u2014 there is no state where a human has answered under one schema and the mission later presents a different one. Replaces the entire schema, not a merge.",
|
|
94
107
|
inputSchema: {
|
|
95
108
|
type: "object",
|
|
96
109
|
properties: {
|
|
@@ -1046,7 +1059,7 @@ async function handleSolanaTool(name, args) {
|
|
|
1046
1059
|
case "get_solana_config":
|
|
1047
1060
|
return apiGet2("/agent/solana/config");
|
|
1048
1061
|
case "get_solana_wallet": {
|
|
1049
|
-
const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-
|
|
1062
|
+
const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-V4T4NTCM.js");
|
|
1050
1063
|
if (!hasSolanaWallet()) {
|
|
1051
1064
|
return {
|
|
1052
1065
|
configured: false,
|
|
@@ -1092,7 +1105,7 @@ async function handleSolanaTool(name, args) {
|
|
|
1092
1105
|
};
|
|
1093
1106
|
}
|
|
1094
1107
|
case "fund_solana_mission": {
|
|
1095
|
-
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-
|
|
1108
|
+
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-V4T4NTCM.js");
|
|
1096
1109
|
const { verifyFundingTransaction } = await import("./verify-KAETIGV5.js");
|
|
1097
1110
|
const wallet = await loadSolanaWallet();
|
|
1098
1111
|
const cfg = await apiGet2("/agent/solana/config");
|
|
@@ -1152,10 +1165,17 @@ async function handleSolanaTool(name, args) {
|
|
|
1152
1165
|
return { funded: true, verified: true, ...confirmed };
|
|
1153
1166
|
}
|
|
1154
1167
|
case "refund_solana_mission": {
|
|
1155
|
-
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-
|
|
1168
|
+
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-V4T4NTCM.js");
|
|
1156
1169
|
const wallet = await loadSolanaWallet();
|
|
1157
1170
|
const cfg = await apiGet2("/agent/solana/config");
|
|
1158
|
-
const
|
|
1171
|
+
const mission = await apiGet2(`/agent/missions/${args.mission_id}`);
|
|
1172
|
+
const mint = mission.mission?.token_address;
|
|
1173
|
+
if (!mint) {
|
|
1174
|
+
return {
|
|
1175
|
+
refunded: false,
|
|
1176
|
+
error: `Could not resolve mission ${args.mission_id}'s token_address \u2014 call get_mission to check it exists and is funded.`
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1159
1179
|
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
1160
1180
|
if (!args.action) {
|
|
1161
1181
|
try {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// src/solana/wallet.ts
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { createDecipheriv, pbkdf2Sync } from "crypto";
|
|
4
|
+
var SolanaWalletUnavailable = class extends Error {
|
|
5
|
+
constructor(reason) {
|
|
6
|
+
super(
|
|
7
|
+
`Solana wallet unavailable: ${reason}
|
|
8
|
+
|
|
9
|
+
Set one of:
|
|
10
|
+
SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE
|
|
11
|
+
- recommended: an openssl-aes-256-cbc-pbkdf2-encrypted
|
|
12
|
+
keyfile plus a separate passphrase file (chmod 600 both)
|
|
13
|
+
SOLO_SOLANA_KEYPAIR - JSON byte array, as \`solana-keygen\` writes it (plaintext)
|
|
14
|
+
SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json), plaintext
|
|
15
|
+
|
|
16
|
+
The wallet needs SOL for rent and fees, and USDC for the mission budget. Rent is a
|
|
17
|
+
refundable deposit, not a fee: most of it returns when the task is closed.`
|
|
18
|
+
);
|
|
19
|
+
this.name = "SolanaWalletUnavailable";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function decryptOpenSslAes256CbcPbkdf2(encrypted, password) {
|
|
23
|
+
const MAGIC = "Salted__";
|
|
24
|
+
if (encrypted.length < 16 || encrypted.subarray(0, 8).toString("utf8") !== MAGIC) {
|
|
25
|
+
throw new SolanaWalletUnavailable('encrypted keyfile is missing the OpenSSL "Salted__" header');
|
|
26
|
+
}
|
|
27
|
+
const salt = encrypted.subarray(8, 16);
|
|
28
|
+
const ciphertext = encrypted.subarray(16);
|
|
29
|
+
const derived = pbkdf2Sync(password, salt, 1e4, 48, "sha256");
|
|
30
|
+
const key = derived.subarray(0, 32);
|
|
31
|
+
const iv = derived.subarray(32, 48);
|
|
32
|
+
const decipher = createDecipheriv("aes-256-cbc", key, iv);
|
|
33
|
+
try {
|
|
34
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
throw new SolanaWalletUnavailable(
|
|
37
|
+
`failed to decrypt keyfile \u2014 wrong passphrase, or the file wasn't produced by 'openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256' (${e.message})`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function parseKeypairBytes(raw) {
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
if (!trimmed.startsWith("[")) {
|
|
44
|
+
throw new SolanaWalletUnavailable(
|
|
45
|
+
"value is not a JSON byte array \u2014 this is the format `solana-keygen new` writes"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(trimmed);
|
|
51
|
+
} catch {
|
|
52
|
+
throw new SolanaWalletUnavailable("value looks like a JSON array but does not parse");
|
|
53
|
+
}
|
|
54
|
+
if (!Array.isArray(parsed) || !parsed.every((n) => typeof n === "number")) {
|
|
55
|
+
throw new SolanaWalletUnavailable("JSON array must contain only numbers");
|
|
56
|
+
}
|
|
57
|
+
const bytes = Uint8Array.from(parsed);
|
|
58
|
+
if (bytes.length !== 64) {
|
|
59
|
+
throw new SolanaWalletUnavailable(
|
|
60
|
+
`expected 64 bytes, got ${bytes.length}` + (bytes.length === 32 ? " \u2014 this is the seed alone, not the full keypair" : "")
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return bytes;
|
|
64
|
+
}
|
|
65
|
+
function expandHome(path) {
|
|
66
|
+
return path.replace(/^~/, process.env.HOME ?? "~");
|
|
67
|
+
}
|
|
68
|
+
var cachedWallet;
|
|
69
|
+
async function loadSolanaWallet() {
|
|
70
|
+
if (cachedWallet) return cachedWallet;
|
|
71
|
+
const encryptedPath = process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH;
|
|
72
|
+
const passwordFile = process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE;
|
|
73
|
+
const inline = process.env.SOLO_SOLANA_KEYPAIR;
|
|
74
|
+
const path = process.env.SOLO_SOLANA_KEYPAIR_PATH;
|
|
75
|
+
let raw;
|
|
76
|
+
if (encryptedPath && encryptedPath.trim() !== "" || passwordFile && passwordFile.trim() !== "") {
|
|
77
|
+
if (!encryptedPath || !passwordFile) {
|
|
78
|
+
throw new SolanaWalletUnavailable(
|
|
79
|
+
"SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH and SOLO_SOLANA_KEYPAIR_PASSWORD_FILE must both be set \u2014 only one was found"
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
let encrypted;
|
|
83
|
+
let password;
|
|
84
|
+
try {
|
|
85
|
+
encrypted = readFileSync(expandHome(encryptedPath));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
throw new SolanaWalletUnavailable(`cannot read ${encryptedPath}: ${e.message}`);
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
password = readFileSync(expandHome(passwordFile), "utf8").replace(/\r?\n$/, "");
|
|
91
|
+
} catch (e) {
|
|
92
|
+
throw new SolanaWalletUnavailable(`cannot read ${passwordFile}: ${e.message}`);
|
|
93
|
+
}
|
|
94
|
+
raw = decryptOpenSslAes256CbcPbkdf2(encrypted, password).toString("utf8");
|
|
95
|
+
} else if (inline && inline.trim() !== "") {
|
|
96
|
+
raw = inline;
|
|
97
|
+
} else if (path && path.trim() !== "") {
|
|
98
|
+
try {
|
|
99
|
+
raw = readFileSync(expandHome(path), "utf8");
|
|
100
|
+
} catch (e) {
|
|
101
|
+
throw new SolanaWalletUnavailable(`cannot read ${path}: ${e.message}`);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
throw new SolanaWalletUnavailable(
|
|
105
|
+
"no wallet source configured (checked SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH, SOLO_SOLANA_KEYPAIR, SOLO_SOLANA_KEYPAIR_PATH)"
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const secretKey = parseKeypairBytes(raw);
|
|
109
|
+
const { Keypair } = await import("@solana/web3.js");
|
|
110
|
+
const kp = Keypair.fromSecretKey(secretKey);
|
|
111
|
+
cachedWallet = { publicKey: kp.publicKey.toBase58(), secretKey };
|
|
112
|
+
return cachedWallet;
|
|
113
|
+
}
|
|
114
|
+
function hasSolanaWallet() {
|
|
115
|
+
return Boolean(
|
|
116
|
+
cachedWallet || (process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH ?? "").trim() && (process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE ?? "").trim() || (process.env.SOLO_SOLANA_KEYPAIR ?? "").trim() || (process.env.SOLO_SOLANA_KEYPAIR_PATH ?? "").trim()
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
async function associatedTokenAddress(mint, owner) {
|
|
120
|
+
const { PublicKey } = await import("@solana/web3.js");
|
|
121
|
+
const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
|
|
122
|
+
const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
|
|
123
|
+
const [address] = PublicKey.findProgramAddressSync(
|
|
124
|
+
[new PublicKey(owner).toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), new PublicKey(mint).toBuffer()],
|
|
125
|
+
ASSOCIATED_TOKEN_PROGRAM_ID
|
|
126
|
+
);
|
|
127
|
+
return address.toBase58();
|
|
128
|
+
}
|
|
129
|
+
async function signTransaction(transactionBase64, wallet) {
|
|
130
|
+
const { Keypair, Transaction } = await import("@solana/web3.js");
|
|
131
|
+
const kp = Keypair.fromSecretKey(wallet.secretKey);
|
|
132
|
+
const tx = Transaction.from(Buffer.from(transactionBase64, "base64"));
|
|
133
|
+
tx.partialSign(kp);
|
|
134
|
+
return tx.serialize().toString("base64");
|
|
135
|
+
}
|
|
136
|
+
export {
|
|
137
|
+
SolanaWalletUnavailable,
|
|
138
|
+
associatedTokenAddress,
|
|
139
|
+
decryptOpenSslAes256CbcPbkdf2,
|
|
140
|
+
hasSolanaWallet,
|
|
141
|
+
loadSolanaWallet,
|
|
142
|
+
signTransaction
|
|
143
|
+
};
|
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
GXkK4gbmGPGFmB7vobHeY82ktbAw9VPnHZJits1P2Qjw
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `decryptOpenSslAes256CbcPbkdf2` reimplements OpenSSL's KDF natively rather than shelling out, so
|
|
3
|
+
* it is tested against a REAL `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256` fixture —
|
|
4
|
+
* same reasoning as `verify.test.ts`: a hand-rolled ciphertext would encode my assumptions about the
|
|
5
|
+
* format twice and could only ever agree with itself, not with the `openssl enc` command the docs
|
|
6
|
+
* actually tell operators to run.
|
|
7
|
+
*
|
|
8
|
+
* `fixtures/test-keypair.json.enc` is a throwaway, never-funded devnet keypair — safe to check in.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import { readFileSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { decryptOpenSslAes256CbcPbkdf2, SolanaWalletUnavailable } from './wallet';
|
|
14
|
+
|
|
15
|
+
const FIXTURE_DIR = join(__dirname, 'fixtures');
|
|
16
|
+
const encrypted = readFileSync(join(FIXTURE_DIR, 'test-keypair.json.enc'));
|
|
17
|
+
const expectedPubkey = readFileSync(join(FIXTURE_DIR, 'test-keypair.pubkey'), 'utf8').trim();
|
|
18
|
+
const PASSPHRASE = 'test-only-fixture-passphrase';
|
|
19
|
+
|
|
20
|
+
describe('decryptOpenSslAes256CbcPbkdf2', () => {
|
|
21
|
+
it('decrypts a real openssl-produced ciphertext back to the original JSON keypair', async () => {
|
|
22
|
+
const plaintext = decryptOpenSslAes256CbcPbkdf2(encrypted, PASSPHRASE).toString('utf8');
|
|
23
|
+
const bytes = JSON.parse(plaintext);
|
|
24
|
+
expect(Array.isArray(bytes)).toBe(true);
|
|
25
|
+
expect(bytes).toHaveLength(64);
|
|
26
|
+
|
|
27
|
+
// Confirm it's not just 64 numbers — it's the actual keypair, by deriving the same public key
|
|
28
|
+
// `solana-keygen pubkey` reported when the fixture was generated.
|
|
29
|
+
const { Keypair } = await import('@solana/web3.js');
|
|
30
|
+
const kp = Keypair.fromSecretKey(Uint8Array.from(bytes));
|
|
31
|
+
expect(kp.publicKey.toBase58()).toBe(expectedPubkey);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('rejects a wrong passphrase rather than silently returning garbage', () => {
|
|
35
|
+
expect(() => decryptOpenSslAes256CbcPbkdf2(encrypted, 'definitely-not-it')).toThrow(
|
|
36
|
+
SolanaWalletUnavailable,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects a file missing the OpenSSL "Salted__" header', () => {
|
|
41
|
+
expect(() => decryptOpenSslAes256CbcPbkdf2(Buffer.from('not an openssl file'), PASSPHRASE)).toThrow(
|
|
42
|
+
/Salted__/,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/solana/wallet.ts
CHANGED
|
@@ -19,9 +19,28 @@
|
|
|
19
19
|
* the Solo API — the backend builds transactions and submits them, but only the agent can authorise
|
|
20
20
|
* one. That is the whole point of the partial-signing flow: a compromised backend cannot move a
|
|
21
21
|
* sponsor's funds, because it never holds the signing key.
|
|
22
|
+
*
|
|
23
|
+
* AT REST, prefer SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE over the
|
|
24
|
+
* plain SOLO_SOLANA_KEYPAIR / SOLO_SOLANA_KEYPAIR_PATH pair. `solana-keygen new`'s own output is a
|
|
25
|
+
* plaintext JSON byte array — chmod 600 protects it from other users on the box, but not from
|
|
26
|
+
* whoever gets that one file (a compromised account, a stray backup, a copied disk image). The
|
|
27
|
+
* encrypted form splits the secret in two: the ciphertext and the passphrase file are only useful
|
|
28
|
+
* together, matching what `references/wallet-setup.md` already does for the Base private key
|
|
29
|
+
* (openssl-encrypt at rest, decrypt only transiently). Encrypt with the exact command below so the
|
|
30
|
+
* decryptor here (which reimplements OpenSSL's KDF, not shells out to it) derives the same key:
|
|
31
|
+
*
|
|
32
|
+
* openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256 \
|
|
33
|
+
* -in ~/.config/solana/solo-sponsor.json -out solo-sponsor.json.enc
|
|
34
|
+
* chmod 600 solo-sponsor.json.enc
|
|
35
|
+
* echo -n "your-passphrase" > keystore.pass && chmod 600 keystore.pass
|
|
36
|
+
* # then delete the plaintext solo-sponsor.json
|
|
37
|
+
*
|
|
38
|
+
* The plain SOLO_SOLANA_KEYPAIR / SOLO_SOLANA_KEYPAIR_PATH pair still works, unchanged, for
|
|
39
|
+
* quick-start / devnet use where this doesn't matter.
|
|
22
40
|
*/
|
|
23
41
|
|
|
24
42
|
import { readFileSync } from 'fs';
|
|
43
|
+
import { createDecipheriv, pbkdf2Sync } from 'crypto';
|
|
25
44
|
|
|
26
45
|
export interface SolanaWallet {
|
|
27
46
|
publicKey: string;
|
|
@@ -34,8 +53,11 @@ export class SolanaWalletUnavailable extends Error {
|
|
|
34
53
|
super(
|
|
35
54
|
`Solana wallet unavailable: ${reason}\n\n` +
|
|
36
55
|
'Set one of:\n' +
|
|
37
|
-
'
|
|
38
|
-
'
|
|
56
|
+
' SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE\n' +
|
|
57
|
+
' - recommended: an openssl-aes-256-cbc-pbkdf2-encrypted\n' +
|
|
58
|
+
' keyfile plus a separate passphrase file (chmod 600 both)\n' +
|
|
59
|
+
' SOLO_SOLANA_KEYPAIR - JSON byte array, as `solana-keygen` writes it (plaintext)\n' +
|
|
60
|
+
' SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json), plaintext\n\n' +
|
|
39
61
|
'The wallet needs SOL for rent and fees, and USDC for the mission budget. Rent is a\n' +
|
|
40
62
|
'refundable deposit, not a fee: most of it returns when the task is closed.',
|
|
41
63
|
);
|
|
@@ -43,6 +65,39 @@ export class SolanaWalletUnavailable extends Error {
|
|
|
43
65
|
}
|
|
44
66
|
}
|
|
45
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Decrypts a file produced by `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256`.
|
|
70
|
+
*
|
|
71
|
+
* Reimplemented natively rather than shelling out to `openssl` — this codebase has no
|
|
72
|
+
* child_process/exec anywhere, and a decrypt helper is not where that invariant should end. OpenSSL
|
|
73
|
+
* prefixes salted output with the 8-byte literal "Salted__" followed by an 8-byte salt; with
|
|
74
|
+
* `-pbkdf2` it derives key||iv (32 + 16 bytes for aes-256-cbc) from the passphrase and that salt in
|
|
75
|
+
* one PBKDF2-HMAC-SHA256 call. Verified byte-for-byte against real `openssl enc`/`openssl enc -d`
|
|
76
|
+
* output in `wallet.test.ts`.
|
|
77
|
+
*/
|
|
78
|
+
export function decryptOpenSslAes256CbcPbkdf2(encrypted: Buffer, password: string): Buffer {
|
|
79
|
+
const MAGIC = 'Salted__';
|
|
80
|
+
if (encrypted.length < 16 || encrypted.subarray(0, 8).toString('utf8') !== MAGIC) {
|
|
81
|
+
throw new SolanaWalletUnavailable('encrypted keyfile is missing the OpenSSL "Salted__" header');
|
|
82
|
+
}
|
|
83
|
+
const salt = encrypted.subarray(8, 16);
|
|
84
|
+
const ciphertext = encrypted.subarray(16);
|
|
85
|
+
const derived = pbkdf2Sync(password, salt, 10000, 48, 'sha256');
|
|
86
|
+
const key = derived.subarray(0, 32);
|
|
87
|
+
const iv = derived.subarray(32, 48);
|
|
88
|
+
const decipher = createDecipheriv('aes-256-cbc', key, iv);
|
|
89
|
+
try {
|
|
90
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
// Wrong passphrase almost always surfaces here (bad padding), not as a clean "auth failed" —
|
|
93
|
+
// CBC has no built-in integrity check, unlike GCM.
|
|
94
|
+
throw new SolanaWalletUnavailable(
|
|
95
|
+
`failed to decrypt keyfile — wrong passphrase, or the file wasn't produced by ` +
|
|
96
|
+
`'openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256' (${(e as Error).message})`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
46
101
|
function parseKeypairBytes(raw: string): Uint8Array {
|
|
47
102
|
const trimmed = raw.trim();
|
|
48
103
|
if (!trimmed.startsWith('[')) {
|
|
@@ -71,6 +126,16 @@ function parseKeypairBytes(raw: string): Uint8Array {
|
|
|
71
126
|
return bytes;
|
|
72
127
|
}
|
|
73
128
|
|
|
129
|
+
function expandHome(path: string): string {
|
|
130
|
+
return path.replace(/^~/, process.env.HOME ?? '~');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Cached after first successful load — avoids re-deriving the PBKDF2 key (and re-reading the
|
|
134
|
+
// passphrase file) on every tool call. Never cache a failure: a missing/misconfigured wallet on
|
|
135
|
+
// one call should not wrongly still look missing after the operator fixes it mid-session, and
|
|
136
|
+
// vice versa a transient read error shouldn't get baked in as permanent.
|
|
137
|
+
let cachedWallet: SolanaWallet | undefined;
|
|
138
|
+
|
|
74
139
|
/**
|
|
75
140
|
* Loads the agent's wallet.
|
|
76
141
|
*
|
|
@@ -78,32 +143,61 @@ function parseKeypairBytes(raw: string): Uint8Array {
|
|
|
78
143
|
* with no wallet configured at all — an agent running Base missions should not need one.
|
|
79
144
|
*/
|
|
80
145
|
export async function loadSolanaWallet(): Promise<SolanaWallet> {
|
|
146
|
+
if (cachedWallet) return cachedWallet;
|
|
147
|
+
|
|
148
|
+
const encryptedPath = process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH;
|
|
149
|
+
const passwordFile = process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE;
|
|
81
150
|
const inline = process.env.SOLO_SOLANA_KEYPAIR;
|
|
82
151
|
const path = process.env.SOLO_SOLANA_KEYPAIR_PATH;
|
|
83
152
|
|
|
84
153
|
let raw: string;
|
|
85
|
-
if (
|
|
154
|
+
if ((encryptedPath && encryptedPath.trim() !== '') || (passwordFile && passwordFile.trim() !== '')) {
|
|
155
|
+
if (!encryptedPath || !passwordFile) {
|
|
156
|
+
throw new SolanaWalletUnavailable(
|
|
157
|
+
'SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH and SOLO_SOLANA_KEYPAIR_PASSWORD_FILE must both be set — only one was found',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
let encrypted: Buffer;
|
|
161
|
+
let password: string;
|
|
162
|
+
try {
|
|
163
|
+
encrypted = readFileSync(expandHome(encryptedPath));
|
|
164
|
+
} catch (e) {
|
|
165
|
+
throw new SolanaWalletUnavailable(`cannot read ${encryptedPath}: ${(e as Error).message}`);
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
password = readFileSync(expandHome(passwordFile), 'utf8').replace(/\r?\n$/, '');
|
|
169
|
+
} catch (e) {
|
|
170
|
+
throw new SolanaWalletUnavailable(`cannot read ${passwordFile}: ${(e as Error).message}`);
|
|
171
|
+
}
|
|
172
|
+
raw = decryptOpenSslAes256CbcPbkdf2(encrypted, password).toString('utf8');
|
|
173
|
+
} else if (inline && inline.trim() !== '') {
|
|
86
174
|
raw = inline;
|
|
87
175
|
} else if (path && path.trim() !== '') {
|
|
88
176
|
try {
|
|
89
|
-
raw = readFileSync(path
|
|
177
|
+
raw = readFileSync(expandHome(path), 'utf8');
|
|
90
178
|
} catch (e) {
|
|
91
179
|
throw new SolanaWalletUnavailable(`cannot read ${path}: ${(e as Error).message}`);
|
|
92
180
|
}
|
|
93
181
|
} else {
|
|
94
|
-
throw new SolanaWalletUnavailable(
|
|
182
|
+
throw new SolanaWalletUnavailable(
|
|
183
|
+
'no wallet source configured (checked SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH, SOLO_SOLANA_KEYPAIR, SOLO_SOLANA_KEYPAIR_PATH)',
|
|
184
|
+
);
|
|
95
185
|
}
|
|
96
186
|
|
|
97
187
|
const secretKey = parseKeypairBytes(raw);
|
|
98
188
|
const { Keypair } = await import('@solana/web3.js');
|
|
99
189
|
const kp = Keypair.fromSecretKey(secretKey);
|
|
100
|
-
|
|
190
|
+
cachedWallet = { publicKey: kp.publicKey.toBase58(), secretKey };
|
|
191
|
+
return cachedWallet;
|
|
101
192
|
}
|
|
102
193
|
|
|
103
194
|
/** Whether a Solana wallet is configured, without throwing. */
|
|
104
195
|
export function hasSolanaWallet(): boolean {
|
|
105
196
|
return Boolean(
|
|
106
|
-
|
|
197
|
+
cachedWallet ||
|
|
198
|
+
((process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH ?? '').trim() &&
|
|
199
|
+
(process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE ?? '').trim()) ||
|
|
200
|
+
(process.env.SOLO_SOLANA_KEYPAIR ?? '').trim() ||
|
|
107
201
|
(process.env.SOLO_SOLANA_KEYPAIR_PATH ?? '').trim(),
|
|
108
202
|
);
|
|
109
203
|
}
|
package/src/tools/missions.ts
CHANGED
|
@@ -3,41 +3,74 @@ import { apiGet, apiPatch, apiPost } from '../api/client.js';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Shared JSON-schema for Mission.response_schema, used by both create_mission (set at creation)
|
|
6
|
-
* and update_mission_questions (set/replace afterward, pre-hiring).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* and update_mission_questions (set/replace afterward, pre-hiring).
|
|
7
|
+
*
|
|
8
|
+
* Two families of kind:
|
|
9
|
+
* - Generic form kinds (single/multi/likert/stars/short/long/dropdown/checkbox) — usable on any
|
|
10
|
+
* mission type, self-contained text/choice/rating input.
|
|
11
|
+
* - Media-anchored kinds (timestamp_tag/image_region/video_region_duration) — need an actual
|
|
12
|
+
* track to annotate (a point in time, a region of an image, a region over a time range of a
|
|
13
|
+
* video), so they're only accepted on media_review missions. create_mission/
|
|
14
|
+
* update_mission_questions reject them on every other type.
|
|
15
|
+
* Kinds needing infra this platform doesn't have yet ('ab'/'slider'/'rank'/'tags'/'upload') aren't
|
|
16
|
+
* supported.
|
|
9
17
|
*/
|
|
10
18
|
const RESPONSE_SCHEMA_PROPERTY = {
|
|
11
19
|
type: 'array' as const,
|
|
12
20
|
maxItems: 20,
|
|
13
21
|
description:
|
|
14
|
-
'Structured completion questions a human answers to complete the mission.
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
19
|
-
'
|
|
22
|
+
'Structured completion questions a human answers to complete the mission. Question ids must ' +
|
|
23
|
+
'be unique within one schema — a duplicate id is rejected at creation/update, not merged or ' +
|
|
24
|
+
'silently overwritten. When set, finalize_qualification auto-derives qualified UIDs from ' +
|
|
25
|
+
'completion instead of requiring an explicit qualified_human_uids list — same mechanism ' +
|
|
26
|
+
'media_review has always used for track ratings, generalized to any mission type. ' +
|
|
27
|
+
'media_review defaults to a canonical two-question schema when this is omitted at creation — ' +
|
|
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 ' +
|
|
30
|
+
'than supplying your own schema. Every other type has no default (stays fully manual/chat-based ' +
|
|
31
|
+
'unless you set this).',
|
|
20
32
|
items: {
|
|
21
33
|
type: 'object',
|
|
22
34
|
properties: {
|
|
23
|
-
id: { type: 'string', description: 'Unique id within this schema — the key answers are stored under.' },
|
|
35
|
+
id: { type: 'string', description: 'Unique id within this schema — the key answers are stored under. Duplicates are rejected, not merged.' },
|
|
24
36
|
kind: {
|
|
25
37
|
type: 'string',
|
|
26
|
-
enum: [
|
|
38
|
+
enum: [
|
|
39
|
+
'single', 'multi', 'likert', 'stars', 'short', 'long', 'dropdown', 'checkbox',
|
|
40
|
+
'timestamp_tag', 'image_region', 'video_region_duration',
|
|
41
|
+
],
|
|
27
42
|
description:
|
|
28
|
-
'single=one choice, multi=several choices, likert=
|
|
29
|
-
'
|
|
30
|
-
'
|
|
43
|
+
'single=one choice, multi=several choices, likert=a scale rendered as a row of buttons (NOT ' +
|
|
44
|
+
'fixed at 5 points — the number of options you supply IS the number of points, so give exactly ' +
|
|
45
|
+
'as many option labels as the scale should have; a common convention is 5 labels like "1 ' +
|
|
46
|
+
'Strongly disagree".."5 Strongly agree" but nothing enforces that), stars=1-5 rating (fixed, ' +
|
|
47
|
+
'no options), short=one line of text, long=paragraph text (see min_length), dropdown=one ' +
|
|
48
|
+
'choice from a long list, checkbox=a checkbox that must be explicitly true (usable for ANY ' +
|
|
49
|
+
'yes/no question, not just consent-flavored ones) — the checkbox\'s own clickable text is ' +
|
|
50
|
+
'`label` itself (there is no separate heading rendered above it, unlike every other kind); ' +
|
|
51
|
+
'use `help` for any additional wording you want shown near it.\n\n' +
|
|
52
|
+
'Media-anchored kinds — media_review only, need a track to annotate (options/min_length ' +
|
|
53
|
+
'don\'t apply; see min_count instead):\n' +
|
|
54
|
+
' timestamp_tag: the human flags a moment in an audio/video track while it plays. Each ' +
|
|
55
|
+
'flag is { t (seconds), tag (category — one of `options` if you set it, otherwise any ' +
|
|
56
|
+
'non-empty string), note? }. `options` is OPTIONAL here (unlike the choice kinds above) — ' +
|
|
57
|
+
'omit it for free-form tagging, or set it to offer a closed category list.\n' +
|
|
58
|
+
' image_region: the human draws a rectangle on an image track and comments on it. Each ' +
|
|
59
|
+
'region is { x, y, width, height (all 0-1, fraction of the image, not pixels), comment }.\n' +
|
|
60
|
+
' video_region_duration: like image_region, but the rectangle is held for a single time ' +
|
|
61
|
+
'range rather than a single point — { start, end (seconds, end > start), x, y, width, ' +
|
|
62
|
+
'height, comment }. One fixed box for the whole range, not tracked frame by frame.',
|
|
31
63
|
},
|
|
32
|
-
label: { type: 'string', description: 'Question text shown to the human (max 200 chars).' },
|
|
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.' },
|
|
33
65
|
help: { type: 'string', description: 'Optional helper text shown under the label (max 500 chars).' },
|
|
34
66
|
required: { type: 'boolean', description: 'Whether this question must be answered to complete the mission.' },
|
|
35
67
|
options: {
|
|
36
68
|
type: 'array',
|
|
37
69
|
items: { type: 'string' },
|
|
38
|
-
description: 'Required for single/multi/dropdown/likert (max 20 options, 50 chars each). Not accepted by other
|
|
70
|
+
description: 'Required (non-empty) for single/multi/dropdown/likert (max 20 options, 50 chars each). Optional for timestamp_tag (a closed category list; omit for free-form tagging). Not accepted by any other kind.',
|
|
39
71
|
},
|
|
40
|
-
min_length: { type: 'number', description: "Minimum answer length
|
|
72
|
+
min_length: { type: 'number', description: "Minimum answer length. Only read for the 'long' kind — silently ignored (not rejected) if set on any other kind." },
|
|
73
|
+
min_count: { type: 'number', description: 'Minimum number of annotations required. Only read for timestamp_tag/image_region/video_region_duration (defaults to 1 if required and unset) — silently ignored on any other kind.' },
|
|
41
74
|
},
|
|
42
75
|
required: ['id', 'kind', 'label', 'required'],
|
|
43
76
|
},
|
|
@@ -94,7 +127,10 @@ export const missionTools: Tool[] = [
|
|
|
94
127
|
description:
|
|
95
128
|
"Set or replace a mission's response_schema after creation. Locked once hiring starts — " +
|
|
96
129
|
'same window as add_mission_track (on-chain: only before funding; off-chain: only while ' +
|
|
97
|
-
'active with zero hired participants yet).
|
|
130
|
+
'active with zero hired participants yet). This window is what makes a schema replacement ' +
|
|
131
|
+
'safe: a human can only submit answers once hired, and the schema can no longer change once ' +
|
|
132
|
+
'anyone has been hired — there is no state where a human has answered under one schema and ' +
|
|
133
|
+
'the mission later presents a different one. Replaces the entire schema, not a merge.',
|
|
98
134
|
inputSchema: {
|
|
99
135
|
type: 'object',
|
|
100
136
|
properties: {
|
package/src/tools/solana.ts
CHANGED
|
@@ -282,7 +282,22 @@ export async function handleSolanaTool(
|
|
|
282
282
|
program_id: string;
|
|
283
283
|
mints: Record<string, string>;
|
|
284
284
|
};
|
|
285
|
-
|
|
285
|
+
// The mission's ACTUAL mint, not a guessed default — get_solana_config's mints always
|
|
286
|
+
// includes both the production payout mint (USDC) and TEST_USDC, and a mission is funded
|
|
287
|
+
// in exactly one of them. Guessing (the previous code preferred TEST_USDC whenever present)
|
|
288
|
+
// computes the wrong associated-token-account for any mission funded in the other one, and
|
|
289
|
+
// the refund/cancel/emergency-refund transaction then fails on-chain with
|
|
290
|
+
// AccountNotInitialized — confirmed live against a real USDC-funded mission.
|
|
291
|
+
const mission = (await apiGet(`/agent/missions/${args.mission_id}`)) as {
|
|
292
|
+
mission?: { token_address?: string };
|
|
293
|
+
};
|
|
294
|
+
const mint = mission.mission?.token_address;
|
|
295
|
+
if (!mint) {
|
|
296
|
+
return {
|
|
297
|
+
refunded: false,
|
|
298
|
+
error: `Could not resolve mission ${args.mission_id}'s token_address — call get_mission to check it exists and is funded.`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
286
301
|
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
287
302
|
|
|
288
303
|
// No action: ask what is legal rather than guessing and getting a 409. Uses a deliberately
|
package/dist/wallet-IUQWBW6F.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
// src/solana/wallet.ts
|
|
2
|
-
import { readFileSync } from "fs";
|
|
3
|
-
var SolanaWalletUnavailable = class extends Error {
|
|
4
|
-
constructor(reason) {
|
|
5
|
-
super(
|
|
6
|
-
`Solana wallet unavailable: ${reason}
|
|
7
|
-
|
|
8
|
-
Set one of:
|
|
9
|
-
SOLO_SOLANA_KEYPAIR - JSON byte array, as \`solana-keygen\` writes it
|
|
10
|
-
SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json)
|
|
11
|
-
|
|
12
|
-
The wallet needs SOL for rent and fees, and USDC for the mission budget. Rent is a
|
|
13
|
-
refundable deposit, not a fee: most of it returns when the task is closed.`
|
|
14
|
-
);
|
|
15
|
-
this.name = "SolanaWalletUnavailable";
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
|
-
function parseKeypairBytes(raw) {
|
|
19
|
-
const trimmed = raw.trim();
|
|
20
|
-
if (!trimmed.startsWith("[")) {
|
|
21
|
-
throw new SolanaWalletUnavailable(
|
|
22
|
-
"value is not a JSON byte array \u2014 this is the format `solana-keygen new` writes"
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
let parsed;
|
|
26
|
-
try {
|
|
27
|
-
parsed = JSON.parse(trimmed);
|
|
28
|
-
} catch {
|
|
29
|
-
throw new SolanaWalletUnavailable("value looks like a JSON array but does not parse");
|
|
30
|
-
}
|
|
31
|
-
if (!Array.isArray(parsed) || !parsed.every((n) => typeof n === "number")) {
|
|
32
|
-
throw new SolanaWalletUnavailable("JSON array must contain only numbers");
|
|
33
|
-
}
|
|
34
|
-
const bytes = Uint8Array.from(parsed);
|
|
35
|
-
if (bytes.length !== 64) {
|
|
36
|
-
throw new SolanaWalletUnavailable(
|
|
37
|
-
`expected 64 bytes, got ${bytes.length}` + (bytes.length === 32 ? " \u2014 this is the seed alone, not the full keypair" : "")
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
return bytes;
|
|
41
|
-
}
|
|
42
|
-
async function loadSolanaWallet() {
|
|
43
|
-
const inline = process.env.SOLO_SOLANA_KEYPAIR;
|
|
44
|
-
const path = process.env.SOLO_SOLANA_KEYPAIR_PATH;
|
|
45
|
-
let raw;
|
|
46
|
-
if (inline && inline.trim() !== "") {
|
|
47
|
-
raw = inline;
|
|
48
|
-
} else if (path && path.trim() !== "") {
|
|
49
|
-
try {
|
|
50
|
-
raw = readFileSync(path.replace(/^~/, process.env.HOME ?? "~"), "utf8");
|
|
51
|
-
} catch (e) {
|
|
52
|
-
throw new SolanaWalletUnavailable(`cannot read ${path}: ${e.message}`);
|
|
53
|
-
}
|
|
54
|
-
} else {
|
|
55
|
-
throw new SolanaWalletUnavailable("neither SOLO_SOLANA_KEYPAIR nor SOLO_SOLANA_KEYPAIR_PATH is set");
|
|
56
|
-
}
|
|
57
|
-
const secretKey = parseKeypairBytes(raw);
|
|
58
|
-
const { Keypair } = await import("@solana/web3.js");
|
|
59
|
-
const kp = Keypair.fromSecretKey(secretKey);
|
|
60
|
-
return { publicKey: kp.publicKey.toBase58(), secretKey };
|
|
61
|
-
}
|
|
62
|
-
function hasSolanaWallet() {
|
|
63
|
-
return Boolean(
|
|
64
|
-
(process.env.SOLO_SOLANA_KEYPAIR ?? "").trim() || (process.env.SOLO_SOLANA_KEYPAIR_PATH ?? "").trim()
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
async function associatedTokenAddress(mint, owner) {
|
|
68
|
-
const { PublicKey } = await import("@solana/web3.js");
|
|
69
|
-
const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
|
|
70
|
-
const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
|
|
71
|
-
const [address] = PublicKey.findProgramAddressSync(
|
|
72
|
-
[new PublicKey(owner).toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), new PublicKey(mint).toBuffer()],
|
|
73
|
-
ASSOCIATED_TOKEN_PROGRAM_ID
|
|
74
|
-
);
|
|
75
|
-
return address.toBase58();
|
|
76
|
-
}
|
|
77
|
-
async function signTransaction(transactionBase64, wallet) {
|
|
78
|
-
const { Keypair, Transaction } = await import("@solana/web3.js");
|
|
79
|
-
const kp = Keypair.fromSecretKey(wallet.secretKey);
|
|
80
|
-
const tx = Transaction.from(Buffer.from(transactionBase64, "base64"));
|
|
81
|
-
tx.partialSign(kp);
|
|
82
|
-
return tx.serialize().toString("base64");
|
|
83
|
-
}
|
|
84
|
-
export {
|
|
85
|
-
SolanaWalletUnavailable,
|
|
86
|
-
associatedTokenAddress,
|
|
87
|
-
hasSolanaWallet,
|
|
88
|
-
loadSolanaWallet,
|
|
89
|
-
signTransaction
|
|
90
|
-
};
|