cyberdyne-mcp 0.6.3 → 0.6.5

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 CHANGED
@@ -2,8 +2,15 @@
2
2
 
3
3
  This is the **agent-facing** side of CYBERDYNE. The app at
4
4
  [app.cyberdyne-os.xyz](https://app.cyberdyne-os.xyz) is what a human sees; this is
5
- the door an AI agent walks through to **discover, hire, verify and pay** that
6
- human — no human clicking buttons required.
5
+ the door an AI agent walks through to **post bounties, verify and pay** verified
6
+ humans — no human clicking buttons required.
7
+
8
+ CYBERDYNE is **one non-custodial FCFS bounty rail**. There is **no direct hire**.
9
+ Every task is an open first-come-first-served bounty: you freeze a budget, **any**
10
+ eligible human submits, and you approve (pay one unit) or reject (reopen the slot)
11
+ each submission. If CYBERDYNE's operator is ever down, you can **`reclaim`** your
12
+ unfilled budget directly from the audited escrow yourself — the deepest
13
+ non-custodial guarantee.
7
14
 
8
15
  It's a [Model Context Protocol](https://modelcontextprotocol.io) server. Any
9
16
  MCP-capable agent (Claude Desktop, Claude Code, or a custom client) connects over
@@ -17,26 +24,46 @@ An agent can go from **nothing** to **wallet + API key + ready to post/pay** wit
17
24
  one command — no web dashboard, no manual key copy/paste:
18
25
 
19
26
  ```bash
20
- npx -y cyberdyne-mcp onboard # generates a wallet + mints your cyb_ API key (no dashboard)
27
+ npx -y cyberdyne-mcp onboard # create a wallet (or import yours) + mint your cyb_ API key (no dashboard)
21
28
  claude mcp add cyberdyne -- npx -y cyberdyne-mcp # the MCP now auto-uses the saved key
22
29
  ```
23
30
 
24
- `onboard` resolves a wallet (uses `CYBERDYNE_EVM_PRIVATE_KEY` if set, else a wallet
25
- it generated on a prior run, else it **generates a fresh one**), signs in to
26
- CYBERDYNE with it (SIWE just a signature, no gas, no transaction), mints your
27
- `cyb_` agent key, and saves **both** to `~/.cyberdyne/config.json` (mode `0600`).
28
- It prints your wallet address and the `cyb_` key **once**. The same generated
29
- wallet is then used automatically for pool-budget signing — zero env vars.
31
+ `onboard` resolves a wallet, signs in to CYBERDYNE with it (SIWE — just a
32
+ signature, no gas, no transaction), mints your `cyb_` agent key, and saves **both**
33
+ to `~/.cyberdyne/config.json` (mode `0600`). It prints your wallet address and the
34
+ `cyb_` key **once**. The same wallet is then used automatically for pool-budget
35
+ signing and `reclaim` zero env vars.
36
+
37
+ **Import your own wallet, or create a fresh one:**
38
+
39
+ ```bash
40
+ # Import a private key or a BIP-39 mnemonic — pipe it (most private, off shell history):
41
+ echo 0xYOUR_PRIVATE_KEY | npx -y cyberdyne-mcp onboard --import
42
+ echo "twelve word mnemonic …" | npx -y cyberdyne-mcp onboard --import
43
+ CYBERDYNE_IMPORT_KEY=0xYOUR_KEY npx -y cyberdyne-mcp onboard --import # or via env
44
+ npx -y cyberdyne-mcp onboard --import 0xYOUR_KEY # works too, but lands in shell history (you'll be warned)
45
+
46
+ # Generate a brand-new wallet:
47
+ npx -y cyberdyne-mcp onboard --create
48
+ ```
49
+
50
+ With **no flag in a terminal**, `onboard` asks: paste an existing key/mnemonic, or
51
+ press enter to create a fresh wallet. In a non-interactive/CI shell with no flag or
52
+ `CYBERDYNE_IMPORT_KEY`, it defaults to **create**. A mnemonic derives account index
53
+ 0 (`m/44'/60'/0'/0/0`). An imported key is validated (0x + 64 hex, or a valid BIP-39
54
+ mnemonic) before any network call. `CYBERDYNE_EVM_PRIVATE_KEY` (env) still works as a
55
+ no-flag default and overrides the saved wallet.
30
56
 
31
57
  An agent already running inside an LLM with this MCP connected can **self-onboard
32
58
  with zero web interaction** by calling the `onboard` tool — it's the one tool that
33
- works without an existing key and bootstraps everything else. After that it can
34
- fund (`get_deposit_address` send USDC on Base `deposit`) and `post_task` and
35
- `withdraw_treasury` to recover unspent treasury to your wallet.
59
+ works without an existing key and bootstraps everything else. (The `onboard` tool
60
+ generates/reuses a wallet; to **import** your own, use the `--import` CLI.) After
61
+ that it can fund (`get_deposit_address` send USDC on Base `deposit`) and
62
+ `post_task` — and `withdraw_treasury` to recover unspent treasury to your wallet.
36
63
 
37
64
  > Mirrors the Bankr CLI UX (`bankr login` → wallet + API key in one shot). The
38
65
  > human **submit-proof** step is intentionally still in the app (human-only); every
39
- > agent-side action — onboard, fund, post, assign, authorize, review, close — is
66
+ > agent-side action — onboard, fund, post, authorize, review, close, reclaim — is
40
67
  > headless.
41
68
 
42
69
  ## CLI
@@ -80,43 +107,41 @@ No key is hardcoded anywhere. `list_categories` and `onboard` work without a tok
80
107
  (`onboard` mints one); every other tool returns a clear error until a key is set —
81
108
  via `CYBERDYNE_IDENTITY_TOKEN`, a saved `onboard`/`login`, or the `onboard` tool.
82
109
 
83
- ## The flows
110
+ ## The flow
84
111
 
85
112
  An agent cannot submit proof on a human's behalf — the **submit-proof step is
86
- human-only and happens in the app/UI**. Funding is the same for both flows: on the
87
- **live** rail fund with real USDC — `get_deposit_address` returns where to send,
88
- then `deposit` credits your treasury from the tx hash. (`fund_treasury` is a
89
- **testnet/demo** top-up and is disabled when the platform is live.)
90
-
91
- There are two settlement flows:
113
+ human-only and happens in the app/UI**. On the **live** rail fund with real USDC —
114
+ `get_deposit_address` returns where to send, then `deposit` credits your treasury
115
+ from the tx hash. (`fund_treasury` is a **testnet/demo** top-up and is disabled when
116
+ the platform is live.)
92
117
 
93
- ### Flow A direct hire (the path live today)
94
-
95
- Real USDC on Base via the **custodial rail** (deposit escrow → withdraw). You
96
- pick one human, open the hold, then pay on a valid proof.
118
+ There is **one** settlement model: the **non-custodial FCFS pool bounty**. You
119
+ freeze a budget once; **any** eligible human submits first-come-first-served; you
120
+ approve each unit (pay one) or reject it (the slot reopens). `post_task` returns an
121
+ `authIntent` (the whole-budget authorization to sign) plus a separate `deployFee`
122
+ (a non-refundable 2.5% USDC / 5% other-token fee tx).
97
123
 
98
124
  ```
99
- get_deposit_address → send USDC → deposit (fund the treasury)
100
- → post_task search_humansassign_task (pick a human)
101
- → authorize_task (open the escrow hold; custodial = just task_id)
102
- poll get_task until a submission is pending
103
- release_payment (approve → capture/pay; else reject → refund)
125
+ get_deposit_address → send USDC → deposit (optional, fund the treasury)
126
+ → post_task ({ …, quantity }) { task, authIntent, deployFee }
127
+ → authorize_task ({ task_id, auth_intent, deploy_fee }) (sign the budget + pay the deploy fee; freeze the whole budget on the audited escrow)
128
+ humans submit FCFS poll get_task
129
+ review_submission per pending submission (approve → capture one unit, full reward in-token; reject → slot reopens)
130
+ → close_task (operator voids the unfilled budget back to you; the deploy fee is non-refundable)
104
131
  ```
105
132
 
106
- ### Flow Bpool / FCFS bounty
133
+ ### Trustless backstop`reclaim`
107
134
 
108
- One frozen budget, many humans claim and submit first-come-first-served; you
109
- approve each unit. This **non-custodial pool escrow rail is built but gated off**
110
- on the server today (env `ESCROW_POOL` is not enabled), pending certification —
111
- so real-money non-custodial pool payouts are **not live yet**. When the operator
112
- enables it, `post_task` returns an `authIntent` plus a separate `deployFee`:
135
+ `close_task` asks CYBERDYNE's operator to void the unfilled budget. If the operator
136
+ is ever **down**, you don't need it: after the on-chain **authorization deadline**,
137
+ your own wallet (the budget's `payer`) calls the audited escrow's payer-only
138
+ `reclaim(paymentInfo)` **directly**, with zero platform involvement, and recovers
139
+ the unfilled budget itself. This is the deepest non-custodial guarantee.
113
140
 
114
141
  ```
115
- post_task (quantity > 1) { task, authIntent, deployFee }
116
- authorize_task ({ auth_intent, deploy_fee }) (sign the budget + pay the deploy fee; freeze the whole budget)
117
- humans claim + submit FCFS poll get_task
118
- → review_submission per pending submission (approve → capture one unit; reject → slot reopens)
119
- → close_task (refund unfilled units; the deploy fee is non-refundable)
142
+ reclaim ({ task_id }) your MCP wallet reads escrow_payment_info, reconstructs the exact
143
+ PaymentInfo struct, and calls reclaim() on the audited escrow on Base.
144
+ Errors clearly if it's too early, already settled, or you're not the payer.
120
145
  ```
121
146
 
122
147
  ## Tools → live endpoints
@@ -131,20 +156,20 @@ post_task (quantity > 1) → { task, authIntent, depl
131
156
  | `get_deposit_address` | `GET /api/treasury/deposit` | Where to send real USDC to fund the treasury (live rail). |
132
157
  | `deposit` | `POST /api/treasury/deposit` | Credit the treasury from a real on-chain USDC deposit (tx hash). |
133
158
  | `withdraw_treasury` | `POST /api/treasury/withdraw` | Recover unspent treasury to your wallet — pull USDC back to your own verified deposit wallet on Base (live rail; no destination param, so funds can only go to you). |
134
- | `post_task` | `POST /api/tasks` | Open a task. `reward_usd` is the budget; not charged until authorize. Pool/FCFS response also carries `authIntent` + `deployFee`. |
135
- | `assign_task` | `POST /api/tasks/[id]/assign` | Direct hire: assign to a human; returns `{ task, authIntent }` (authIntent is `null` on the custodial/manual rail). |
136
- | `authorize_task` | `POST /api/tasks/[id]/authorize` | Open the escrow hold. Custodial/manual: just `task_id`. On-chain direct hire: `auth_intent`/`signed_payment`. Pool/FCFS: also `deploy_fee`/`fee_tx_hash`. |
159
+ | `post_task` | `POST /api/tasks` | Open an FCFS pool bounty. `reward_usd` is the total budget; `quantity` units; not charged until authorize. Response carries `authIntent` + `deployFee`. |
160
+ | `authorize_task` | `POST /api/tasks/[id]/authorize` | Freeze the whole budget on the audited escrow. With a signing wallet: pass `auth_intent` + `deploy_fee` (the MCP signs + pays the fee); or pre-made `signed_payment` + `fee_tx_hash`. |
137
161
  | `get_task` | `GET /api/tasks/[id]` | Task + the submissions/claims the poster may see. Poll for a `pending` submission. |
138
- | `release_payment` | `POST /api/tasks/[id]/release` | **Direct hire** settle: `approve:true` → capture (pay net of fee); `approve:false` → reject/refund. Auto-resolves the pending `submission_id` if omitted. |
139
- | `review_submission` | `POST /api/submissions/[id]/review` | **Pool/FCFS** settle: `approve:true` capture one unit; `approve:false` reject (slot reopens). |
140
- | `close_task` | `POST /api/tasks/[id]/close` | Close a (multi-unit) bounty; refund still-held units. |
141
-
142
- The live rail today is the **custodial USDC escrow** (real deposit → escrow →
143
- withdraw on Base mainnet): at `authorize_task` the budget is held; on
144
- `release_payment` it is captured to the human (net of the platform fee) or
145
- refunded. The **non-custodial pool/FCFS rail** (deploy-fee + `review_submission`)
146
- is built but gated off on the server until certification. `search_humans` goes
147
- through the a2a JSON-RPC gateway because the REST `GET /api/humans` is session-only.
162
+ | `review_submission` | `POST /api/submissions/[id]/review` | Settle one submission: `approve:true` → capture one unit (full reward in-token); `approve:false` → reject (slot reopens). This is how you pay humans. |
163
+ | `close_task` | `POST /api/tasks/[id]/close` | Close the bounty; the operator voids the unfilled remainder back to you. The deploy fee is non-refundable. |
164
+ | `reclaim` | on-chain `reclaim(paymentInfo)` | **Trustless backstop.** After the authorization deadline, your wallet (the payer) recovers the unfilled budget directly from the audited escrow — no CYBERDYNE operator. Returns `{ ok, tx_hash, reclaimed }`. |
165
+
166
+ The settlement model is the **non-custodial FCFS pool escrow** (freeze-at-deploy on
167
+ the audited base/commerce-payments `AuthCaptureEscrow`): at `authorize_task` the whole
168
+ budget is frozen; `review_submission` captures one unit to the human (full reward,
169
+ in-token); `close_task` voids the unfilled remainder via the operator, and `reclaim`
170
+ is your own payer-only on-chain recovery if the operator is ever unavailable.
171
+ `search_humans` (discovery only there is no direct hire) goes through the a2a
172
+ JSON-RPC gateway because the REST `GET /api/humans` is session-only.
148
173
 
149
174
  ## Run it
150
175
 
@@ -207,28 +232,24 @@ Bundles the MCP gateway **and** the usage skill. Once connected, run
207
232
 
208
233
  Then ask the agent, e.g.:
209
234
 
210
- > *Find a Spanish-speaking human who can record audio, post a $3.50 task to read
211
- > 10 phrases, assign it, authorize the hold, then verify and pay.*
235
+ > *Post a $3.50 FCFS bounty for a human to record 10 phrases, freeze the budget,
236
+ > then verify the first valid submission and pay it.*
212
237
 
213
- The agent chains `search_humanspost_taskassign_taskauthorize_task
214
- get_task → release_payment` on its own (Flow A, direct hire). For an open
215
- first-come bounty it instead posts with `quantity > 1` and settles each unit with
216
- `review_submission` (Flow B, pool/FCFS active once the pool rail is enabled).
238
+ The agent chains `post_taskauthorize_taskget_taskreview_submission
239
+ close_task` on its own. If CYBERDYNE's operator is ever down, it can `reclaim` the
240
+ unfilled budget directly from the escrow after the authorization deadline. There is
241
+ **no direct hire**every task is an open FCFS pool bounty.
217
242
 
218
243
  ## Honesty / accuracy
219
244
 
220
245
  State only what is independently verifiable. This repository, its code, and the
221
246
  fact that the tools run and call the documented endpoints are verifiable. The
222
- backend is **pre-launch**. The live settlement rail today is the **custodial USDC
223
- rail** (real deposit → escrow → withdraw on Base mainnet). The **non-custodial
224
- pool/FCFS rail** (deploy-fee + `review_submission`) is built but **gated off** on
225
- the server pending certification so real-money non-custodial pool payouts are
226
- **not live yet**. Do **not** assert funding, valuation, investors, revenue or user
227
- metrics, any token/airdrop, named individuals, partnerships, or compliance status
228
- — none are established.
247
+ backend is **pre-launch**. The settlement model is the **non-custodial FCFS pool
248
+ escrow** (freeze-at-deploy on the audited base/commerce-payments `AuthCaptureEscrow`,
249
+ with the agent's own payer-only `reclaim` backstop). Do **not** assert funding,
250
+ valuation, investors, revenue or user metrics, any token/airdrop, named individuals,
251
+ partnerships, or compliance status none are established.
229
252
 
230
253
  ## Follow-ups (not in this server)
231
254
 
232
- - The **paid `hire` path** (x402 402→pay→200 over `POST /api/a2a`) is implemented
233
- on the platform but not surfaced here — it needs an x402 signing client.
234
255
  - A **remote/HTTP MCP** variant (vs. stdio) for hosted agents.
package/dist/cli.js CHANGED
@@ -160,9 +160,9 @@ export async function runPost(argv) {
160
160
  const res = await c.rest("POST", "/api/tasks", { body });
161
161
  const taskId = res.task?.id;
162
162
  console.error(` ✓ posted — task ${taskId}`);
163
- // Custodial rail (no authIntent): nothing else to do; the hold opens later.
163
+ // Custodial/testnet rail (no authIntent): nothing else to do; the hold opens later.
164
164
  if (!res.authIntent || !res.deployFee) {
165
- console.error(`\n✓ Task ${taskId} is open (custodial rail). Next: assign a human + authorize, then release on a valid proof.`);
165
+ console.error(`\n✓ Task ${taskId} is open. Humans submit FCFS; review each submission to capture a unit (review_submission), then close_task.`);
166
166
  process.exit(0);
167
167
  }
168
168
  // POOL rail — the autonomous `bankr launch` path. Sign the budget with the saved
@@ -13,7 +13,7 @@
13
13
  * Nothing here moves funds or pays gas; it only produces a signature.
14
14
  */
15
15
  import { privateKeyToAccount } from "viem/accounts";
16
- import { createPublicClient, createWalletClient, http, parseUnits } from "viem";
16
+ import { createPublicClient, createWalletClient, getAddress, http, parseUnits } from "viem";
17
17
  import { base, baseSepolia } from "viem/chains";
18
18
  import { AuthCaptureEvmScheme, toClientEvmSigner } from "@x402/evm";
19
19
  import { readSavedWalletKey } from "./client.js";
@@ -99,3 +99,115 @@ export async function signAuthCapture(requirements) {
99
99
  const result = await scheme().createPaymentPayload(2, requirements);
100
100
  return Buffer.from(JSON.stringify(result)).toString("base64");
101
101
  }
102
+ // ── Trustless self-recovery: reclaim ────────────────────────────────────────
103
+ // The deepest non-custodial guarantee. The AGENT (payer) calls the AUDITED
104
+ // AuthCaptureEscrow `reclaim(paymentInfo)` DIRECTLY — no CYBERDYNE operator. The
105
+ // contract requires `msg.sender == paymentInfo.payer` and `block.timestamp >=
106
+ // authorizationExpiry`, then returns the uncaptured remainder to the payer. This
107
+ // is the same PaymentInfo tuple shape used by authorize/capture/void.
108
+ /** The canonical audited base/commerce-payments AuthCaptureEscrow on Base. */
109
+ export const AUTH_CAPTURE_ESCROW_ADDRESS = "0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff";
110
+ /** PaymentInfo tuple components — EXACTLY the order the escrow hashes/expects. */
111
+ const PAYMENT_INFO_COMPONENTS = [
112
+ { name: "operator", type: "address" },
113
+ { name: "payer", type: "address" },
114
+ { name: "receiver", type: "address" },
115
+ { name: "token", type: "address" },
116
+ { name: "maxAmount", type: "uint120" },
117
+ { name: "preApprovalExpiry", type: "uint48" },
118
+ { name: "authorizationExpiry", type: "uint48" },
119
+ { name: "refundExpiry", type: "uint48" },
120
+ { name: "minFeeBps", type: "uint16" },
121
+ { name: "maxFeeBps", type: "uint16" },
122
+ { name: "feeReceiver", type: "address" },
123
+ { name: "salt", type: "uint256" },
124
+ ];
125
+ const paymentInfoArg = { name: "paymentInfo", type: "tuple", components: PAYMENT_INFO_COMPONENTS };
126
+ /** Minimal ABI: just `reclaim(PaymentInfo)` — payer-callable after authorizationExpiry. */
127
+ export const reclaimAbi = [
128
+ { type: "function", name: "reclaim", stateMutability: "nonpayable", inputs: [paymentInfoArg], outputs: [] },
129
+ ];
130
+ /** Decode the base64 signed payment → { payer (authorization.from), validBefore, salt }. */
131
+ function decodeSignedPayment(signedPayment) {
132
+ let payload;
133
+ try {
134
+ const decoded = JSON.parse(Buffer.from(signedPayment, "base64").toString("utf8"));
135
+ payload = (decoded.payload ?? decoded);
136
+ }
137
+ catch {
138
+ throw new Error("malformed_signed_payment");
139
+ }
140
+ const auth = payload.authorization;
141
+ const permit = payload.permit2Authorization;
142
+ const from = auth?.from ?? permit?.from;
143
+ const validBefore = auth?.validBefore ?? permit?.deadline;
144
+ const salt = payload.salt;
145
+ if (!from || !validBefore || !salt)
146
+ throw new Error("incomplete_signed_payment (need authorization.from + validBefore + salt)");
147
+ return { payer: from, preApprovalExpiry: Number(validBefore), salt, value: auth?.value };
148
+ }
149
+ /**
150
+ * Reconstruct the on-chain PaymentInfo struct EXACTLY like the platform's
151
+ * `structFor` (lib/payments/escrow-pool.ts) — the contract recomputes the hash, so
152
+ * every field must match the one signed at deploy or `reclaim` reverts.
153
+ */
154
+ function structFor(info, payer, preApprovalExpiry, salt, value) {
155
+ const x = info.extra;
156
+ return {
157
+ operator: getAddress(x.captureAuthorizer),
158
+ payer: getAddress(payer),
159
+ receiver: getAddress(info.payTo),
160
+ token: getAddress(info.asset),
161
+ maxAmount: BigInt(value ?? info.amount),
162
+ preApprovalExpiry,
163
+ authorizationExpiry: x.captureDeadline,
164
+ refundExpiry: x.refundDeadline,
165
+ minFeeBps: x.minFeeBps ?? 0,
166
+ maxFeeBps: x.maxFeeBps ?? 0,
167
+ feeReceiver: getAddress(x.feeRecipient),
168
+ salt: BigInt(salt),
169
+ };
170
+ }
171
+ /**
172
+ * TRUSTLESS SELF-RECOVERY. The agent (payer) calls `reclaim(paymentInfo)` on the
173
+ * audited escrow directly to recover its OWN unfilled budget — no operator. Asserts
174
+ * the MCP wallet == payer (reclaim is payer-only on-chain) and that the authorization
175
+ * deadline has passed, then signs+sends from the agent wallet and waits for the receipt.
176
+ * Returns { ok, tx_hash, reclaimed } (reclaimed = the human-readable atomic maxAmount).
177
+ */
178
+ export async function reclaimBudget(info) {
179
+ const signed = info.signedPayment;
180
+ if (!signed)
181
+ throw new Error("no signedPayment on this task — it was never frozen on the escrow (nothing to reclaim)");
182
+ const { payer, preApprovalExpiry, salt, value } = decodeSignedPayment(signed);
183
+ // reclaim is payer-only on-chain — assert this wallet IS the payer before sending.
184
+ const me = evmAddress();
185
+ if (me.toLowerCase() !== payer.toLowerCase()) {
186
+ throw new Error(`wallet ${me} is not the payer (${payer}) of this budget — reclaim is payer-only on-chain. ` +
187
+ "Use the same wallet that authorized/froze the budget.");
188
+ }
189
+ const paymentInfo = structFor(info, payer, preApprovalExpiry, salt, value);
190
+ // reclaim requires block.timestamp >= authorizationExpiry — check first for a clear error.
191
+ const now = Math.floor(Date.now() / 1000);
192
+ if (now < paymentInfo.authorizationExpiry) {
193
+ const when = new Date(paymentInfo.authorizationExpiry * 1000).toISOString();
194
+ throw new Error(`too_early: the authorization deadline has not passed yet (reclaim opens at ${when}, ` +
195
+ `in ~${Math.ceil((paymentInfo.authorizationExpiry - now) / 60)} min). ` +
196
+ "Until then, close_task asks CYBERDYNE's operator to void the unfilled budget back to you.");
197
+ }
198
+ const wallet = createWalletClient({ account: account(), chain: chain(), transport: http(process.env.CYBERDYNE_RPC_URL) });
199
+ const pub = createPublicClient({ chain: chain(), transport: http(process.env.CYBERDYNE_RPC_URL) });
200
+ const hash = await wallet.writeContract({
201
+ address: AUTH_CAPTURE_ESCROW_ADDRESS,
202
+ abi: reclaimAbi,
203
+ functionName: "reclaim",
204
+ args: [paymentInfo],
205
+ chain: chain(),
206
+ });
207
+ const receipt = await pub.waitForTransactionReceipt({ hash });
208
+ if (receipt.status !== "success") {
209
+ throw new Error("reclaim reverted on-chain — the budget may already be fully captured/settled, already reclaimed, " +
210
+ "or the deadline window is wrong. Nothing was recovered.");
211
+ }
212
+ return { ok: true, tx_hash: hash, reclaimed: String(paymentInfo.maxAmount) };
213
+ }
package/dist/onboard.js CHANGED
@@ -16,33 +16,81 @@
16
16
  * Nothing here moves funds or pays gas — onboarding is just a SIWE signature and
17
17
  * a DB row. The wallet private key is persisted but NEVER logged to stdout.
18
18
  */
19
- import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
19
+ import { readFileSync } from "node:fs";
20
+ import { createInterface } from "node:readline";
21
+ import { generatePrivateKey, privateKeyToAccount, mnemonicToAccount } from "viem/accounts";
22
+ import { bytesToHex } from "viem";
23
+ import { validateMnemonic } from "@scure/bip39";
24
+ import { wordlist } from "@scure/bip39/wordlists/english";
20
25
  import { readConfig, readSavedWalletKey, saveTokenAndWallet } from "./client.js";
21
26
  /** Normalise a private key to the 0x-prefixed form viem expects. */
22
27
  function normalizeKey(pk) {
23
28
  return (pk.startsWith("0x") ? pk : `0x${pk}`);
24
29
  }
30
+ /** A 0x-prefixed (or bare) 32-byte hex private key. */
31
+ function isHexPrivateKey(s) {
32
+ return /^(0x)?[0-9a-fA-F]{64}$/.test(s.trim());
33
+ }
34
+ /** Word-count-shaped (12/15/18/21/24 alpha words) — full BIP-39 checksum is validated on use. */
35
+ function looksLikeMnemonic(s) {
36
+ const words = s.trim().split(/\s+/);
37
+ return [12, 15, 18, 21, 24].includes(words.length) && words.every((w) => /^[a-zA-Z]+$/.test(w));
38
+ }
39
+ /**
40
+ * Resolve a user-provided wallet secret (hex private key OR BIP-39 mnemonic) into
41
+ * the raw 0x private key. For a mnemonic we VALIDATE the BIP-39 checksum (scure) and
42
+ * derive account index 0 (m/44'/60'/0'/0/0). Throws a clear error if the secret is
43
+ * neither a valid key nor a checksum-valid mnemonic.
44
+ */
45
+ export function privateKeyFromSecret(secret) {
46
+ const s = secret.trim();
47
+ if (isHexPrivateKey(s))
48
+ return normalizeKey(s);
49
+ if (looksLikeMnemonic(s)) {
50
+ const phrase = s.split(/\s+/).join(" ").toLowerCase();
51
+ if (!validateMnemonic(phrase, wordlist)) {
52
+ throw new Error("invalid BIP-39 mnemonic — the words or checksum don't validate. Check the phrase and try again.");
53
+ }
54
+ const pk = mnemonicToAccount(phrase, { addressIndex: 0 }).getHdKey().privateKey;
55
+ if (!pk)
56
+ throw new Error("could not derive a private key from that mnemonic");
57
+ return bytesToHex(pk);
58
+ }
59
+ throw new Error("malformed wallet secret — expected a 0x-prefixed 64-hex-char private key, or a valid BIP-39 mnemonic (12–24 words).");
60
+ }
25
61
  /**
26
62
  * Resolve the onboarding wallet, most-explicit first:
63
+ * 0. an explicitly IMPORTED secret (key/mnemonic) from the CLI/env/stdin/prompt
27
64
  * 1. CYBERDYNE_EVM_PRIVATE_KEY (env) — operator-supplied
28
65
  * 2. saved walletKey in config — generated on a prior onboard
29
- * 3. generate a fresh key — first run, zero config
66
+ * 3. CREATE a fresh key — first run, zero config
30
67
  * Always returns a usable account + the private key. Persisting a freshly
31
- * generated key is the caller's job (onboard() does it atomically with the token).
68
+ * generated/imported key is the caller's job (onboard() does it atomically with the token).
69
+ *
70
+ * `opts.importSecret` (hex key or mnemonic) forces import; `opts.forceCreate` forces a
71
+ * brand-new wallet even if env/config already has one (used by `onboard --create`).
32
72
  */
33
- export function resolveWallet(env = process.env) {
73
+ export function resolveWallet(env = process.env, opts = {}) {
74
+ if (opts.importSecret) {
75
+ const privateKey = privateKeyFromSecret(opts.importSecret);
76
+ return { account: privateKeyToAccount(privateKey), privateKey, generated: false, imported: true };
77
+ }
78
+ if (opts.forceCreate) {
79
+ const privateKey = generatePrivateKey();
80
+ return { account: privateKeyToAccount(privateKey), privateKey, generated: true, imported: false };
81
+ }
34
82
  const fromEnv = env.CYBERDYNE_EVM_PRIVATE_KEY?.trim();
35
83
  if (fromEnv) {
36
84
  const privateKey = normalizeKey(fromEnv);
37
- return { account: privateKeyToAccount(privateKey), privateKey, generated: false };
85
+ return { account: privateKeyToAccount(privateKey), privateKey, generated: false, imported: false };
38
86
  }
39
87
  const saved = readSavedWalletKey();
40
88
  if (saved) {
41
89
  const privateKey = normalizeKey(saved);
42
- return { account: privateKeyToAccount(privateKey), privateKey, generated: false };
90
+ return { account: privateKeyToAccount(privateKey), privateKey, generated: false, imported: false };
43
91
  }
44
92
  const privateKey = generatePrivateKey();
45
- return { account: privateKeyToAccount(privateKey), privateKey, generated: true };
93
+ return { account: privateKeyToAccount(privateKey), privateKey, generated: true, imported: false };
46
94
  }
47
95
  /** Pull every Set-Cookie off a response into a `name=value; name=value` Cookie header. */
48
96
  function collectCookies(res, jar) {
@@ -66,11 +114,12 @@ function cookieHeader(jar) {
66
114
  /**
67
115
  * Run the full SIWE → mint chain and persist the credentials. Throws on any
68
116
  * non-2xx step (with the endpoint + status) so the caller can surface a clear error.
117
+ * Pass `opts.importSecret` to bring your own wallet, or `opts.forceCreate` for a fresh one.
69
118
  */
70
- export async function onboard(env = process.env) {
119
+ export async function onboard(env = process.env, opts = {}) {
71
120
  const { apiUrl } = readConfig(env);
72
121
  const HOST = new URL(apiUrl).host;
73
- const { account, privateKey, generated } = resolveWallet(env);
122
+ const { account, privateKey, generated, imported } = resolveWallet(env, opts);
74
123
  const address = account.address;
75
124
  const jar = new Map();
76
125
  // 1. nonce
@@ -131,15 +180,127 @@ export async function onboard(env = process.env) {
131
180
  }
132
181
  // 5. persist BOTH the token and the wallet key atomically (0600)
133
182
  const configPath = saveTokenAndWallet(apiKey, privateKey);
134
- return { address, apiKey, generated, configPath };
183
+ return { address, apiKey, generated, imported, configPath };
135
184
  }
136
185
  /** The multi-line "next steps" block shared by the CLI + the MCP tool. */
137
186
  export function nextStepsText() {
138
187
  return [
139
188
  "Next steps (no dashboard needed):",
140
189
  " 1. fund: get_deposit_address → send USDC on Base to that address from this wallet → deposit({ tx_hash }).",
141
- " 2. post_task({ title, category, reward_usd, duration_min, difficulty }).",
142
- " 3. search_humans assign_taskauthorize_task → poll get_task → release_payment.",
143
- "The same generated wallet auto-signs pool budgets (no env vars needed).",
190
+ " 2. post_task({ title, category, reward_usd, quantity, duration_min, difficulty }).",
191
+ " 3. authorize_task (sign budget + pay deploy fee + freeze) humans submit FCFS → poll get_task.",
192
+ " 4. review_submission per pending submission (approve pays a unit; reject reopens it) → close_task refunds the rest.",
193
+ "The same wallet auto-signs pool budgets (no env vars needed). Trustless backstop: `reclaim` recovers an unfilled budget yourself after the deadline.",
144
194
  ].join("\n");
145
195
  }
196
+ // ── CLI front-end for `onboard` (import / create / prompt) ───────────────────
197
+ // Resolves the wallet secret to import (if any), most-private first, then runs
198
+ // onboard(). Non-interactive-safe: with no flag/env in a non-TTY (CI), defaults to
199
+ // create — exactly the prior behaviour.
200
+ // --import <secret> import an explicit key/mnemonic (lands in shell history)
201
+ // --import (no value) read the secret from stdin (piped) or CYBERDYNE_IMPORT_KEY
202
+ // --create force a fresh wallet
203
+ // (none, interactive TTY) prompt: paste a key/mnemonic, or press enter to create
204
+ /** Tiny flag reader for the onboard args (supports `--import`, `--import=x`, `--create`). */
205
+ function parseOnboardFlags(argv) {
206
+ let importFlag = false;
207
+ let importValue;
208
+ let create = false;
209
+ for (let i = 0; i < argv.length; i++) {
210
+ const tok = argv[i];
211
+ if (tok === "--create")
212
+ create = true;
213
+ else if (tok === "--import") {
214
+ importFlag = true;
215
+ const next = argv[i + 1];
216
+ if (next !== undefined && !next.startsWith("--")) {
217
+ importValue = next;
218
+ i++;
219
+ }
220
+ }
221
+ else if (tok.startsWith("--import=")) {
222
+ importFlag = true;
223
+ importValue = tok.slice("--import=".length);
224
+ }
225
+ }
226
+ return { importFlag, importValue, create };
227
+ }
228
+ /** Read a single line from stdin (used for the interactive import/create prompt). */
229
+ function promptLine(question) {
230
+ return new Promise((resolve) => {
231
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
232
+ rl.question(question, (answer) => {
233
+ rl.close();
234
+ resolve(answer);
235
+ });
236
+ });
237
+ }
238
+ export const ONBOARD_USAGE = [
239
+ "Usage: cyberdyne-mcp onboard [--import <0xPRIVATEKEY | mnemonic words> | --create]",
240
+ "",
241
+ " --import <secret> bring your OWN wallet — a 0x 64-hex private key or a BIP-39 mnemonic.",
242
+ " Most private: omit the value and pipe it, or set CYBERDYNE_IMPORT_KEY:",
243
+ " echo 0x<key> | npx cyberdyne-mcp onboard --import",
244
+ " CYBERDYNE_IMPORT_KEY=0x<key> npx cyberdyne-mcp onboard --import",
245
+ " (passing it as an argument leaves the secret in your shell history.)",
246
+ " --create generate a fresh wallet (default in a non-interactive / CI shell).",
247
+ " (no flag, in a terminal) you'll be prompted: paste a key/mnemonic, or press enter to create.",
248
+ "",
249
+ "Either way: SIWE sign-in → mint your cyb_ key → save wallet + key to ~/.cyberdyne/config.json (0600).",
250
+ ].join("\n");
251
+ /**
252
+ * Resolve the onboard mode from argv/env/stdin/prompt and run onboard().
253
+ * Precedence for an IMPORT secret (most private first):
254
+ * piped stdin (with --import) → CYBERDYNE_IMPORT_KEY → --import <value> argv → TTY prompt.
255
+ */
256
+ export async function onboardCli(argv, env = process.env) {
257
+ const { importFlag, importValue, create } = parseOnboardFlags(argv);
258
+ if (create)
259
+ return onboard(env, { forceCreate: true });
260
+ // Determine the import secret, if the user asked to import.
261
+ let secret;
262
+ let fromArgv = false;
263
+ if (importFlag) {
264
+ // 1. piped stdin (echo … | onboard --import) — most private
265
+ if (!process.stdin.isTTY) {
266
+ try {
267
+ const piped = readFileSync(0, "utf8").trim();
268
+ if (piped)
269
+ secret = piped;
270
+ }
271
+ catch {
272
+ /* nothing piped */
273
+ }
274
+ }
275
+ // 2. env 3. argv value
276
+ secret = secret || env.CYBERDYNE_IMPORT_KEY?.trim() || importValue?.trim() || undefined;
277
+ if (importValue && secret === importValue.trim()) {
278
+ fromArgv = true;
279
+ }
280
+ if (!secret) {
281
+ throw new Error("--import needs a key/mnemonic. Pipe it (echo 0x<key> | npx cyberdyne-mcp onboard --import), " +
282
+ "set CYBERDYNE_IMPORT_KEY, or pass it after --import.");
283
+ }
284
+ }
285
+ else if (env.CYBERDYNE_IMPORT_KEY?.trim()) {
286
+ // Env-only import (no flag) — convenient + private.
287
+ secret = env.CYBERDYNE_IMPORT_KEY.trim();
288
+ }
289
+ else if (process.stdin.isTTY) {
290
+ // 3. Interactive: ask. Empty → create; else → import the pasted secret.
291
+ const answer = (await promptLine("Import an existing wallet (paste private key / mnemonic) or press enter to create a new one: ")).trim();
292
+ if (answer)
293
+ secret = answer;
294
+ }
295
+ // else: non-TTY, no flag, no env → default to create (unchanged CI behaviour).
296
+ if (fromArgv) {
297
+ console.error("⚠ Heads-up: passing the wallet secret as an argument leaves it in your shell history.\n" +
298
+ " Next time, pipe it instead: echo 0x<key> | npx cyberdyne-mcp onboard --import");
299
+ }
300
+ if (secret) {
301
+ // Validate early with a clear error before any network call.
302
+ privateKeyFromSecret(secret);
303
+ return onboard(env, { importSecret: secret });
304
+ }
305
+ return onboard(env); // create / reuse-saved, as before
306
+ }