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 +89 -68
- package/dist/cli.js +2 -2
- package/dist/evm-signer.js +113 -1
- package/dist/onboard.js +174 -13
- package/dist/server.js +41 -61
- package/llms.txt +29 -26
- package/package.json +4 -5
- package/src/cli.ts +2 -2
- package/src/evm-signer.ts +146 -1
- package/src/onboard.ts +200 -13
- package/src/server.ts +58 -89
- package/dist/founder-check.js +0 -88
- package/dist/smoke.js +0 -87
- package/src/founder-check.ts +0 -99
- package/src/smoke.ts +0 -104
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 **
|
|
6
|
-
|
|
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 #
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
`cyb_`
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
34
|
-
|
|
35
|
-
`
|
|
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,
|
|
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
|
|
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**.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
101
|
-
→ authorize_task
|
|
102
|
-
→
|
|
103
|
-
→
|
|
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
|
-
###
|
|
133
|
+
### Trustless backstop — `reclaim`
|
|
107
134
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
135
|
-
| `
|
|
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
|
-
| `
|
|
139
|
-
| `
|
|
140
|
-
| `
|
|
141
|
-
|
|
142
|
-
The
|
|
143
|
-
|
|
144
|
-
`
|
|
145
|
-
|
|
146
|
-
is
|
|
147
|
-
|
|
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
|
-
> *
|
|
211
|
-
>
|
|
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 `
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
238
|
+
The agent chains `post_task → authorize_task → get_task → review_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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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
|
package/dist/evm-signer.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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.
|
|
143
|
-
"
|
|
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
|
+
}
|