@usepraxis/sdk 0.1.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,6 +33,12 @@ for (const p of proposals) {
33
33
  }
34
34
  ```
35
35
 
36
+ A proposal stays signable for a week. Its amount is fixed when it is built and
37
+ Aegis enforces the envelope live at submit, so an older card still moves
38
+ exactly what it says — what drifts are the readings on it (fee, simulated
39
+ outcome, remaining daily envelope). Past a week the backend refuses it and you
40
+ ask again.
41
+
36
42
  `ask()` returns once the agent has finished — the API resolves `send` only after
37
43
  the reply is ready, so there is no polling.
38
44
 
@@ -47,6 +53,12 @@ the reply is ready, so there is no polling.
47
53
 
48
54
  The signed-in wallet is the **owner** whose Aegis policy PDA scopes everything.
49
55
 
56
+ Sessions are short on purpose — holding one is enough to move value *within*
57
+ the Aegis envelope, with no further wallet signature — so a long-running
58
+ process will outlive its cookie. When a signer is configured, the SDK runs the
59
+ handshake again on a `401` and retries the call once; you do not need your own
60
+ reconnect loop. Without a signer, the `401` is returned as-is.
61
+
50
62
  ## Signers
51
63
 
52
64
  Provide any `PraxisSigner` — `{ address, signMessage(bytes) }`.
@@ -83,14 +95,35 @@ toBaseUnits("500000000"); // 500000000n
83
95
  |------|---------|
84
96
  | Auth | `connect()`, `session()`, `logout()` |
85
97
  | Conversation | `ask()`, `send()`, `newThread()`, `signProposal()`, `cancelProposal()` |
86
- | Reads | `getPolicy()`, `getThreads()`, `getThread()`, `getProposal()`, `getActivity()`, `getAddressBook()`, `isThinking()`, `getVersion()` |
87
- | Policy (server-key) | `bootstrapPolicy()`, `updatePolicy()`, `configureToken()`, `prepareTokenAccounts()`, `revokeAgent()`, `rotateAgent()`, `addToAllowList()`, `removeFromAllowList()` |
98
+ | Reads | `getPolicy()`, `getThreads()`, `getThread()`, `getProposal()`, `getProposals()`, `getActivity()`, `getAddressBook()`, `getSchedules()`, `getVersion()` |
99
+ | Contacts | `addContact()`, `removeContact()` labels only, no signing power |
100
+ | Recurring | `cancelSchedule()` — stop a recurring buy (fires only ever emit proposals) |
101
+ | Policy (server-key) | `bootstrapPolicy()`, `fundVault()`, `withdrawVault()`, `updatePolicy()`, `configureToken()`, `prepareTokenAccounts()`, `revokeAgent()`, `rotateAgent()`, `addToAllowList()`, `removeFromAllowList()`, `deleteAgent()` |
88
102
  | Owner (wallet-signed) | `buildOwnerTransaction()`, `submitOwnerTransaction()` |
103
+ | Stocks (PreStocks) | `getTokenUniverse()`, `getStockResearch()` — see `examples/stocks-dca.ts` |
104
+
105
+ `session()` returns the current `SessionInfo` or `null` when signed out.
106
+
107
+ > **Owner wallet-signed path.** `buildOwnerTransaction()` returns an *unsigned*
108
+ > transaction; you sign it with a transaction-capable wallet (a browser wallet
109
+ > adapter or `@solana/web3.js`) and submit the result with
110
+ > `submitOwnerTransaction()`. The SDK's `keypairSigner` signs the sign-in
111
+ > *message* only, not transactions — so a pure-Node owner-action flow must bring
112
+ > its own transaction signer.
113
+ >
114
+ > Pass the whole draft back. Since **0.5.0** it carries a `draft` token: the
115
+ > backend refuses to relay a transaction it did not build, which is what keeps
116
+ > the server-side checks on an action (a token balance still in the vault, a
117
+ > mint Aegis cannot drive) from being skippable by assembling your own bytes.
118
+ > Mutate `transaction` only; leave the other fields alone.
89
119
 
90
120
  ## Errors
91
121
 
92
- Non-2xx responses throw `PraxisApiError` with `.status`, `.type`, and helpers
93
- `.isAuth` / `.isRateLimited` / `.isInput`.
122
+ Non-2xx responses throw `PraxisApiError` with `.status`, `.type`, a stable
123
+ `.code` to branch on, and helpers `.isAuth` / `.isRateLimited` / `.isInput` /
124
+ `.isNotFound` / `.isConfig` / `.isConflict` / `.isPolicyNotFound` / `.isServer`. A client-side timeout or connection failure throws
125
+ `PraxisApiError` with `.isTimeout` / `.isNetwork` (and `.status === 0`, with the
126
+ original error on `.cause`). SDK-side misconfiguration throws `PraxisConfigError`.
94
127
 
95
128
  ```ts
96
129
  import { PraxisApiError } from "@usepraxis/sdk";
package/dist/index.cjs CHANGED
@@ -15,11 +15,17 @@ var PraxisApiError = class _PraxisApiError extends Error {
15
15
  status;
16
16
  /** The backend error class name, e.g. "PraxisAuthError", "PraxisRateLimitError". */
17
17
  type;
18
- constructor(status, type, message) {
19
- super(message);
18
+ /** Stable error classification — the field to branch on. */
19
+ code;
20
+ /** Structured facts about the failure, e.g. `{ policyAddress }` for `policy_not_found`. */
21
+ details;
22
+ constructor(status, type, message, options) {
23
+ super(message, options);
20
24
  this.name = "PraxisApiError";
21
25
  this.status = status;
22
26
  this.type = type;
27
+ this.code = options?.code ?? codeFromStatus(status);
28
+ this.details = options?.details;
23
29
  Object.setPrototypeOf(this, _PraxisApiError.prototype);
24
30
  }
25
31
  get isAuth() {
@@ -31,10 +37,48 @@ var PraxisApiError = class _PraxisApiError extends Error {
31
37
  get isInput() {
32
38
  return this.status === 400;
33
39
  }
40
+ /** Resource not found (404). */
41
+ get isNotFound() {
42
+ return this.status === 404;
43
+ }
44
+ /** Server reported a configuration problem (503) — usually transient. */
45
+ get isConfig() {
46
+ return this.status === 503;
47
+ }
48
+ /** The request timed out client-side before any HTTP response. */
49
+ get isTimeout() {
50
+ return this.status === 0 && this.type === "TimeoutError";
51
+ }
52
+ /** A connection-level failure (DNS, refused, TLS) before any HTTP response. */
53
+ get isNetwork() {
54
+ return this.status === 0 && this.type === "NetworkError";
55
+ }
56
+ /** Any server-side failure (HTTP >= 500). */
57
+ get isServer() {
58
+ return this.status >= 500;
59
+ }
60
+ /** The wallet has no Aegis policy yet — the first-run state, not a fault. */
61
+ get isPolicyNotFound() {
62
+ return this.code === "policy_not_found";
63
+ }
64
+ /** A concurrent writer won; the call is safe to retry after a reload. */
65
+ get isConflict() {
66
+ return this.code === "conflict";
67
+ }
34
68
  };
69
+ function codeFromStatus(status) {
70
+ if (status === 400) return "invalid_input";
71
+ if (status === 401) return "unauthorized";
72
+ if (status === 404) return "not_found";
73
+ if (status === 409) return "conflict";
74
+ if (status === 429) return "rate_limited";
75
+ if (status === 503) return "config_error";
76
+ if (status === 0) return "client_error";
77
+ return "internal_error";
78
+ }
35
79
  var PraxisConfigError = class _PraxisConfigError extends Error {
36
- constructor(message) {
37
- super(message);
80
+ constructor(message, options) {
81
+ super(message, options);
38
82
  this.name = "PraxisConfigError";
39
83
  Object.setPrototypeOf(this, _PraxisConfigError.prototype);
40
84
  }
@@ -47,6 +91,7 @@ var PraxisClient = class {
47
91
  signer;
48
92
  fetchImpl;
49
93
  timeoutMs;
94
+ agentTimeoutMs;
50
95
  /** Manual cookie jar — Node's fetch does not persist Set-Cookie across calls. */
51
96
  sessionCookie;
52
97
  constructor(options) {
@@ -59,6 +104,7 @@ var PraxisClient = class {
59
104
  }
60
105
  this.fetchImpl = resolvedFetch;
61
106
  this.timeoutMs = options.timeoutMs ?? 2e4;
107
+ this.agentTimeoutMs = Math.max(options.agentTimeoutMs ?? 6e4, this.timeoutMs);
62
108
  }
63
109
  // --- auth ----------------------------------------------------------------
64
110
  /** The signer's wallet address, if a signer was provided. */
@@ -67,7 +113,8 @@ var PraxisClient = class {
67
113
  }
68
114
  /**
69
115
  * Run the wallet-ownership handshake: request a challenge, sign its message,
70
- * verify it, and store the resulting session cookie. Idempotent.
116
+ * verify it, and store the resulting session cookie. Safe to call again to
117
+ * refresh the session (each call issues a new challenge + cookie).
71
118
  */
72
119
  async connect() {
73
120
  if (!this.signer) {
@@ -83,24 +130,37 @@ var PraxisClient = class {
83
130
  signature: bs582__default.default.encode(signature)
84
131
  });
85
132
  }
86
- /** Current session, or `null` if not signed in. */
133
+ /**
134
+ * Current session, or `null` if not signed in. The endpoint answers `200`
135
+ * with `{ authenticated: false }` when signed out, so this normalizes both
136
+ * that shape and a `401` to `null`.
137
+ */
87
138
  async session() {
88
139
  try {
89
- return await this.get("/auth/session");
140
+ const info = await this.get("/auth/session");
141
+ return info && info.authenticated ? info : null;
90
142
  } catch (error) {
91
143
  if (error instanceof PraxisApiError && error.isAuth) return null;
92
144
  throw error;
93
145
  }
94
146
  }
95
- /** Clear the session (server-side cookie + local jar). */
147
+ /**
148
+ * Clear the session (server-side cookie + local jar). Idempotent: if there is
149
+ * no active session, the local jar is still cleared and no error is thrown.
150
+ */
96
151
  async logout() {
97
- await this.request("DELETE", "/auth/session");
98
- this.sessionCookie = void 0;
152
+ try {
153
+ await this.request("DELETE", "/auth/session");
154
+ } catch (error) {
155
+ if (!(error instanceof PraxisApiError && error.isAuth)) throw error;
156
+ } finally {
157
+ this.sessionCookie = void 0;
158
+ }
99
159
  }
100
160
  // --- conversation --------------------------------------------------------
101
161
  /** Send a line to the agent. Creates a thread when `threadId` is omitted. */
102
162
  send(text, threadId = null) {
103
- return this.post("/send", { text, threadId });
163
+ return this.post("/send", { text, threadId }, this.agentTimeoutMs);
104
164
  }
105
165
  /**
106
166
  * Send a line and return the agent's reply in one call. The API resolves
@@ -114,7 +174,9 @@ var PraxisClient = class {
114
174
  throw new PraxisApiError(500, "Error", "Agent produced no reply message.");
115
175
  }
116
176
  const proposalIds = message.blocks.filter((b) => b.type === "proposal").map((b) => b.proposalId);
117
- const proposals = await Promise.all(proposalIds.map((id) => this.getProposal(id)));
177
+ if (proposalIds.length === 0) return { threadId: tid, message, proposals: [] };
178
+ const byId = new Map((await this.getProposals()).map((p) => [p.id, p]));
179
+ const proposals = proposalIds.map((id) => byId.get(id)).filter((p) => p !== void 0);
118
180
  return { threadId: tid, message, proposals };
119
181
  }
120
182
  newThread(threadId) {
@@ -126,6 +188,22 @@ var PraxisClient = class {
126
188
  cancelProposal(proposalId) {
127
189
  return this.post("/cancel-proposal", { proposalId });
128
190
  }
191
+ /** List recurring-buy schedules (each fire emits one proposal; never signs). */
192
+ getSchedules() {
193
+ return this.get("/get-schedules");
194
+ }
195
+ /** Stop a recurring-buy schedule. Unknown ids are a no-op (idempotent). */
196
+ cancelSchedule(scheduleId) {
197
+ return this.post("/cancel-schedule", { scheduleId });
198
+ }
199
+ /** Save (or rename) an address-book contact. Labels have no signing power. */
200
+ addContact(label, address) {
201
+ return this.post("/add-contact", { label, address });
202
+ }
203
+ /** Remove a contact by address or label (case-insensitive, idempotent). */
204
+ removeContact(key) {
205
+ return this.post("/remove-contact", { key });
206
+ }
129
207
  // --- reads ---------------------------------------------------------------
130
208
  getThreads() {
131
209
  return this.get("/get-threads");
@@ -136,6 +214,14 @@ var PraxisClient = class {
136
214
  getProposal(id) {
137
215
  return this.get("/get-proposal", { id });
138
216
  }
217
+ /**
218
+ * Every proposal this wallet holds, in one request. Prefer this over a loop
219
+ * of {@link getProposal}: a per-id fetch is what trips the read rate limit
220
+ * on a busy thread.
221
+ */
222
+ getProposals() {
223
+ return this.get("/get-proposals");
224
+ }
139
225
  getPolicy() {
140
226
  return this.get("/get-policy");
141
227
  }
@@ -145,16 +231,47 @@ var PraxisClient = class {
145
231
  getAddressBook() {
146
232
  return this.get("/get-address-book");
147
233
  }
148
- isThinking(threadId) {
149
- return this.get("/is-thinking", { threadId });
150
- }
151
234
  getVersion() {
152
235
  return this.get("/get-version");
153
236
  }
237
+ // --- stocks (PreStocks universe; empty unless the server enables it) ------
238
+ /**
239
+ * The server's stock universe (`[]` when `PRAXIS_STOCKS_ENABLED` is off).
240
+ * Read-only; symbols/mints here are the only pre-IPO stocks Praxis will
241
+ * touch (bounty exclusivity is enforced server-side).
242
+ */
243
+ getTokenUniverse() {
244
+ return this.get("/get-stock-universe");
245
+ }
246
+ /**
247
+ * Read-only research for a stock symbol, via the agent (`research <symbol>`).
248
+ * Returns neutral market data — never advice. Throws when the agent has no
249
+ * research to show (unknown symbol or unavailable quotes).
250
+ */
251
+ async getStockResearch(symbol) {
252
+ const { message } = await this.ask(`research ${symbol}`);
253
+ const block = message.blocks.find((b) => b.type === "research");
254
+ if (!block || block.type !== "research") {
255
+ throw new PraxisApiError(404, "NotFound", `No research available for ${symbol}.`);
256
+ }
257
+ return block.data;
258
+ }
154
259
  // --- policy / owner mutations (server-key mode) --------------------------
155
260
  bootstrapPolicy(fundLamports) {
156
261
  return this.post("/bootstrap-policy", fundLamports ? { fundLamports } : {});
157
262
  }
263
+ /** Deposit SOL (lamports, base-unit string) from the owner into the vault. */
264
+ fundVault(amount) {
265
+ return this.post("/fund-vault", { amount });
266
+ }
267
+ /** Withdraw SOL (lamports, base-unit string) from the vault to the owner. */
268
+ withdrawVault(amount) {
269
+ return this.post("/withdraw-vault", { amount });
270
+ }
271
+ /** Tear the agent down — drain the vault and close the policy. Irreversible. */
272
+ deleteAgent() {
273
+ return this.post("/delete-agent", {});
274
+ }
158
275
  updatePolicy(patch) {
159
276
  return this.post("/update-policy", { patch });
160
277
  }
@@ -177,11 +294,16 @@ var PraxisClient = class {
177
294
  return this.post("/remove-from-allow-list", { kind, address });
178
295
  }
179
296
  // --- owner wallet-signed transaction path --------------------------------
180
- /** Build an unsigned owner transaction for the wallet to sign. */
297
+ /**
298
+ * Build an unsigned owner transaction for the wallet to sign. The caller signs
299
+ * the returned base64 `transaction` with a transaction-capable wallet, then
300
+ * passes the result to {@link submitOwnerTransaction}. (The SDK's
301
+ * `keypairSigner` signs sign-in messages only, not transactions.)
302
+ */
181
303
  buildOwnerTransaction(action) {
182
304
  return this.post("/owner/build", { action });
183
305
  }
184
- /** Submit a wallet-signed owner transaction. */
306
+ /** Submit a wallet-signed owner transaction; resolves with its signature. */
185
307
  submitOwnerTransaction(signed) {
186
308
  return this.post("/owner/submit", signed);
187
309
  }
@@ -189,17 +311,41 @@ var PraxisClient = class {
189
311
  get(path, query) {
190
312
  return this.request("GET", path, { query });
191
313
  }
192
- post(path, body) {
193
- return this.request("POST", path, { body });
314
+ post(path, body, timeoutMs) {
315
+ return this.request("POST", path, { body, timeoutMs });
194
316
  }
317
+ /**
318
+ * Run a request, and if the session has expired, sign in again and retry it
319
+ * once.
320
+ *
321
+ * Sessions are deliberately short — holding one is enough to move value
322
+ * within the Aegis envelope — so a long-lived agent process WILL outlive its
323
+ * cookie. Without this, every caller writes the same catch-401-and-reconnect
324
+ * block, and the ones who do not simply stop working after a day. Retried
325
+ * once only, never for the auth endpoints themselves, and only when a signer
326
+ * is configured; without one there is nothing to re-authenticate with and the
327
+ * 401 is the honest answer.
328
+ */
195
329
  async request(method, path, opts = {}) {
330
+ try {
331
+ return await this.send1(method, path, opts);
332
+ } catch (error) {
333
+ const recoverable = error instanceof PraxisApiError && error.isAuth && Boolean(this.signer) && !path.startsWith("/auth/");
334
+ if (!recoverable) throw error;
335
+ this.sessionCookie = void 0;
336
+ await this.connect();
337
+ return this.send1(method, path, opts);
338
+ }
339
+ }
340
+ async send1(method, path, opts = {}) {
196
341
  const url = new URL(this.baseUrl + API_PREFIX + path);
197
342
  for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
198
343
  const headers = { accept: "application/json" };
199
344
  if (opts.body !== void 0) headers["content-type"] = "application/json";
200
345
  if (this.sessionCookie) headers["cookie"] = this.sessionCookie;
346
+ const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
201
347
  const controller = new AbortController();
202
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
348
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
203
349
  let res;
204
350
  try {
205
351
  res = await this.fetchImpl(url.toString(), {
@@ -212,9 +358,13 @@ var PraxisClient = class {
212
358
  });
213
359
  } catch (error) {
214
360
  if (error instanceof Error && error.name === "AbortError") {
215
- throw new PraxisApiError(0, "TimeoutError", `Praxis request timed out after ${this.timeoutMs}ms`);
361
+ throw new PraxisApiError(0, "TimeoutError", `Praxis request timed out after ${timeoutMs}ms`, {
362
+ cause: error
363
+ });
216
364
  }
217
- throw error;
365
+ if (error instanceof PraxisApiError) throw error;
366
+ const detail = error instanceof Error ? error.message : String(error);
367
+ throw new PraxisApiError(0, "NetworkError", `Praxis request failed: ${detail}`, { cause: error });
218
368
  } finally {
219
369
  clearTimeout(timer);
220
370
  }
@@ -224,7 +374,10 @@ var PraxisClient = class {
224
374
  if (!res.ok) {
225
375
  const message = (parsed && typeof parsed === "object" && "error" in parsed && typeof parsed.error === "string" ? parsed.error : void 0) ?? `Praxis API error ${res.status}`;
226
376
  const type = parsed && typeof parsed === "object" && "type" in parsed && typeof parsed.type === "string" ? parsed.type : "Error";
227
- throw new PraxisApiError(res.status, type, message);
377
+ const record = parsed && typeof parsed === "object" ? parsed : void 0;
378
+ const code = typeof record?.code === "string" ? record.code : void 0;
379
+ const details = record?.details && typeof record.details === "object" && !Array.isArray(record.details) ? record.details : void 0;
380
+ throw new PraxisApiError(res.status, type, message, { code, details });
228
381
  }
229
382
  return parsed;
230
383
  }
@@ -276,6 +429,9 @@ function normalizeSecret(secret) {
276
429
  throw new PraxisConfigError("secret key string must be base58-encoded");
277
430
  }
278
431
  } else if (Array.isArray(secret)) {
432
+ if (!secret.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) {
433
+ throw new PraxisConfigError("secret key array must contain only byte values (integers 0\u2013255)");
434
+ }
279
435
  bytes = Uint8Array.from(secret);
280
436
  } else {
281
437
  bytes = secret;
@@ -289,10 +445,19 @@ function normalizeSecret(secret) {
289
445
  }
290
446
 
291
447
  // src/units.ts
448
+ var INTEGER_RE = /^-?\d+$/;
292
449
  function toBaseUnits(value) {
293
- return typeof value === "bigint" ? value : BigInt(value.trim());
450
+ if (typeof value === "bigint") return value;
451
+ const trimmed = value.trim();
452
+ if (!INTEGER_RE.test(trimmed)) {
453
+ throw new Error(`toBaseUnits: expected an integer base-unit string, got "${value}"`);
454
+ }
455
+ return BigInt(trimmed);
294
456
  }
295
457
  function fromBaseUnits(value) {
458
+ if (typeof value === "number" && !Number.isSafeInteger(value)) {
459
+ throw new Error(`fromBaseUnits: number must be a safe integer, got ${value}`);
460
+ }
296
461
  return BigInt(value).toString();
297
462
  }
298
463
  function humanToBaseUnits(amount, decimals) {