@candledottv/agent-sdk 0.4.10 → 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
@@ -53,11 +53,13 @@ refuse to fetch without one and throw a plain `Error` locally instead of a serve
53
53
 
54
54
  `launch()` fills in a `clientLaunchId` (`"sdk-" + crypto.randomUUID()`) when you omit one, and
55
55
  retries transient failures by re-sending the SAME id, which the server's idempotency ledger
56
- resolves safely (no double mint, ever). Retries cover network errors, non-envelope 5xx
57
- responses, retryable 5xx envelopes, and the retryable in-flight 409; a non-retryable envelope
58
- (validation errors, an id reused with a different body, `LAUNCH_DISABLED`) is thrown
59
- immediately. Backoff is 250ms doubling per attempt, jittered, capped at 8s, bounded by
60
- `maxRetries` (default 3).
56
+ resolves safely: no double mint inside one `launch()` call. That holds only inside the call. A
57
+ second `launch()` call with no id is a new launch under a new id, so to retry after `launch()`
58
+ throws, pass the id back: see "Retrying a write that failed or timed out" below. Retries cover
59
+ network errors, non-envelope 5xx responses, retryable 5xx envelopes, and the retryable in-flight
60
+ 409; a non-retryable envelope (validation errors, an id reused with a different body,
61
+ `LAUNCH_DISABLED`) is thrown immediately. Backoff is 250ms doubling per attempt, jittered, capped
62
+ at 8s, bounded by `maxRetries` (default 3).
61
63
 
62
64
  ```ts
63
65
  import { CandleApiError } from "@candledottv/agent-sdk"
@@ -70,11 +72,12 @@ try {
70
72
  chain: "solana",
71
73
  buyAmount: 100_000_000, // lamports
72
74
  })
73
- console.log("minted", result.mint, "explorer:", result.links.explorer)
75
+ console.log("minted", result.mint, "explorer:", result.links.explorer, "id:", result.clientLaunchId)
74
76
  } catch (error) {
75
77
  if (error instanceof CandleApiError) {
76
78
  // Branch on code, never on message. error.status, error.retryable, error.field ride along.
77
- console.error("launch failed:", error.code)
79
+ // error.clientLaunchId is the id this call sent: keep it for the retry.
80
+ console.error("launch failed:", error.code, error.clientLaunchId)
78
81
  } else {
79
82
  throw error
80
83
  }
@@ -115,10 +118,15 @@ const fill = await candle.trade({ mint: "9dXSV8...CNDL", side: "buy", amountRaw:
115
118
  or from a linked wallet the caller signs for. Amounts are raw units. On Solana,
116
119
  `amounts.actualOutRaw` is the delivered amount, decoded from the payer's balance change; book
117
120
  positions from it rather than from `expectedOutRaw`. It is absent on Hood.
121
+ - `getTradeJob(clientTradeId)` reads one trade from the idempotency ledger without sending
122
+ anything: the read to make after a `trade()` that threw or timed out. Needs `swap:write`.
118
123
  - `swap()` converts base assets (`SOL`, `USDC`, `CNDL`, `ETH`, `USDG`); a pair that spans Solana
119
124
  and Hood is a bridge.
120
125
  - `getPlans()` (no key) returns every plan's price, fees, limits and capabilities as the server
121
126
  serves them.
127
+ - `listAgents({ sort, strategy, query, limit, cursor })` (no key) reads the agent directory: every
128
+ published agent with its strategy, declared models, all-time P&L and trade and launch counts,
129
+ including one that has never traded. `sort` is `newest`, `active` or `pnl`.
122
130
  - `getPortfolio()`, `getProfilePnl(keyPrefix)`, `getProfileTrades(keyPrefix)` and
123
131
  `getSpendLimits()` read the account's holdings, a profile's P&L and fills, and this key's caps.
124
132
  - `fees.list()` and `fees.claim(mint, { dryRun })` read what the account's launched tokens can pay in creator fees and claim it to the wallet fixed at launch (off until the server enables it).
@@ -129,6 +137,135 @@ const fill = await candle.trade({ mint: "9dXSV8...CNDL", side: "buy", amountRaw:
129
137
  Limit orders are a REST surface only (`/api/v1/trade/agent/orders`); the SDK has no method for
130
138
  them.
131
139
 
140
+ ## Retrying a write that failed or timed out
141
+
142
+ Six methods send an idempotency id, and generate one (`"sdk-" + crypto.randomUUID()`) when you
143
+ pass none: `trade()`, `launch()`, `launchAsync()`, `selfLaunch()`, `buildAtomicLaunch()` and
144
+ `launchAtomic()`. The server treats that id as the whole identity of the operation: the same id
145
+ is a replay, and a new id is a second, independent trade or launch. A call that does not come
146
+ back with a normal answer (a lost response, a proxy timeout, a 5xx) may still have executed, so
147
+ what you do next depends on having the id.
148
+
149
+ 1. **Pass your own id for anything that moves funds, and store it before the call.** An error can
150
+ carry the id only if the process is still running to catch it. A crash or a kill mid-call
151
+ leaves nothing, and only an id you stored first survives that.
152
+ 2. **If you did not, read the id from the result or the error**, and send it back on the retry
153
+ with the same request body. Every one of the six methods exposes the id it sent on every
154
+ outcome: on the result (`result.clientTradeId`, `result.clientLaunchId`) and on every error
155
+ thrown after its first request was sent (`operationIds(error)`). This is the whole procedure
156
+ for the flows Candle executes. For `selfLaunch()` and a linked `trade()` it is not enough: see
157
+ below.
158
+ 3. **Never retry without the id.** A call with no id, or a new one, is a second trade or launch.
159
+ The SDK does not remember ids between calls and adds no retry of its own to `trade()`,
160
+ `selfLaunch()` or `launchAtomic()`; only `launch()` retries, inside its one call.
161
+
162
+ What the error is:
163
+
164
+ | The method threw | Class | The id |
165
+ | --- | --- | --- |
166
+ | a Candle error response | `CandleApiError`, unchanged | `error.clientTradeId` or `error.clientLaunchId`, and `error.bundleId` from `launchAtomic()` once its build answered |
167
+ | a JSON-RPC failure from a broadcast | `JsonRpcError`, unchanged | the same fields |
168
+ | a Hood trade whose fee leg failed after the trade landed | `TradeLandedFeeLegError`, unchanged | `error.clientTradeId` |
169
+ | anything else after the first request was sent: a rejected `fetch`, a 2xx body that is not JSON, a plain `Error` from one of the method's own steps | `CandleOperationError`, with the original as `error.cause` | the same fields |
170
+ | an error before anything was sent, such as no API key | the plain error | none: nothing reached the server, so there is nothing to replay |
171
+
172
+ `operationIds(error)` returns `{ clientTradeId?, clientLaunchId?, bundleId? }` from any of these and
173
+ `{}` from anything else, so a `catch (error: unknown)` block needs no cast. The ids are not secrets
174
+ and may be logged. A `CandleOperationError`'s message starts with the original message, names the
175
+ id, and says what to do with it.
176
+
177
+ **Changed in 0.5.0:** a transport or local failure from these six methods used to surface as the
178
+ raw error (a `TypeError` from `fetch`, for example). It is now a `CandleOperationError` with the
179
+ original as `error.cause`. Code that matched `error instanceof TypeError` or
180
+ `error.name === "AbortError"` on these calls should read `error.cause`. `launch()`, `selfLaunch()`,
181
+ `confirmSelfLaunch()` and `buildAtomicLaunch()` results gained `clientLaunchId`, and
182
+ `launchAtomic()` returns `LaunchAtomicResult`, its old result plus `clientLaunchId`.
183
+
184
+ A main-payer trade (`from: "main"`), where the retry is safe once you have the id:
185
+
186
+ ```ts
187
+ import { operationIds } from "@candledottv/agent-sdk"
188
+
189
+ try {
190
+ return await candle.trade(req)
191
+ } catch (error) {
192
+ const { clientTradeId } = operationIds(error)
193
+ if (!clientTradeId) throw error // nothing was sent
194
+ return await candle.trade({ ...req, clientTradeId }) // from: "main" only: a replay, not a second trade
195
+ }
196
+ ```
197
+
198
+ This example is not safe for a linked payer or for `selfLaunch()`: a same-id call there can
199
+ broadcast again.
200
+
201
+ ### `trade()`
202
+
203
+ With the id: read `getTradeJob(id)`, or call `trade()` again with the same id and body. Both are
204
+ safe for a main payer.
205
+
206
+ | The job read says | Meaning | Do |
207
+ | --- | --- | --- |
208
+ | 404 `JOB_NOT_FOUND` (thrown as `CandleApiError`) | the write never began | call again with the same id |
209
+ | `confirmed` | the trade landed; `signature` is on the job | stop. A same-id `trade()` replays the result |
210
+ | `failed`, `errorCode: "TRADE_REVERTED"` | it landed and reverted | a new trade needs a new id |
211
+ | `failed`, other | not confirmed. A row with broadcast evidence may still be on chain, and a same-id call answers `stage: "unconfirmed"` for it. The same-id call is the check | call again with the same id |
212
+ | `built` | in flight or unresolved | a same-id `trade()` answers `IDEMPOTENCY_CONFLICT`. If it carries `stage: "unconfirmed"` and `retryable: false`, check its `signature` on chain and do not retry |
213
+
214
+ ### `launch()`, `launchAsync()`
215
+
216
+ Retry with the same `clientLaunchId` and body, or read `getLaunchJob(id)` (`waitForLaunch(id)`
217
+ polls it). The rules are the ones under "Idempotency" in
218
+ [Headless launch](https://docs.candle.tv/developers/headless-launch#idempotency); what is new is
219
+ that you always have the id.
220
+
221
+ ### `selfLaunch()` and a linked `trade()`
222
+
223
+ You sign and broadcast in these flows, so the id is necessary and **not** sufficient. An error
224
+ after the broadcast may sit on a transaction that landed, and calling the method again under the
225
+ same id builds and broadcasts a new one. So a `CandleOperationError` from these flows does not
226
+ tell you to retry with the id. Read the job first (`getLaunchJob(id)`, `getTradeJob(id)`); if it
227
+ is not `confirmed`, check the wallet's recent transactions before sending again.
228
+
229
+ ### `launchAtomic()`
230
+
231
+ `launchAtomic()` returns `timeout` and `failed` as values, not errors, and each carries
232
+ `clientLaunchId`. `bundleId` is dead once `/submit` has been called and is never resubmitted; it
233
+ is on the result and the error for log correlation only. The launch's identity is
234
+ `clientLaunchId`. After a `timeout` result, a `failed` result, or an error that carries
235
+ `clientLaunchId`:
236
+
237
+ 1. Read `getLaunchJob(clientLaunchId)`. This needs `launch:read` on the key. The Write and
238
+ Withdraw access levels hold it; a key whose scopes were named one by one holds it only if it
239
+ was named, so add `launch:read` to any key that runs atomic launches. A scope refusal
240
+ (`SCOPE_MISSING`) is not "not found".
241
+ 2. `confirmed`: the bundle landed. The mint is on the job. Stop.
242
+ 3. 404 `JOB_NOT_FOUND`: the build never began. Call `launchAtomic()` again with the same id.
243
+ 4. `failed`: the bundle did not land. Call `launchAtomic()` again with the **same** id; the server
244
+ starts a clean attempt under it.
245
+ 5. Any other status: the bundle may still land. Wait, then either read the job again or call
246
+ `launchAtomic()` again with the **same** id and the same body. The server resolves the first
247
+ attempt before doing anything new. It answers 409 `IDEMPOTENCY_CONFLICT` while the first
248
+ attempt is unresolved or has just been found landed, so on that 409 read the job again. It
249
+ builds a fresh bundle only when the first is proven reverted.
250
+ 6. **Never send a new id while the job is not `failed`.** That is the one path the server cannot
251
+ refuse, and it is a second token. This includes the 409 whose message says "use a new
252
+ clientLaunchId to launch again": read the job first, because that message is also what you see
253
+ for a launch you do not yet know landed.
254
+ 7. One state has no terminal answer today. For an attempt whose blockhash expired with no
255
+ signature on record, the same-id `launchAtomic()` keeps answering 409 `IDEMPOTENCY_CONFLICT`,
256
+ "This launch was sent; its original attempt must be resolved", and the job read is still
257
+ neither `confirmed` nor `failed`. Treat it as this state when that remains true well after the
258
+ bundle's blockhash must have expired: a Solana blockhash lives about 90 seconds and a same-id
259
+ rebuild can itself take about 95 seconds, so a few minutes after the submit. Then stop. Do not
260
+ send a new id on a timer; report the `clientLaunchId` and `bundleId` to whoever operates the
261
+ key.
262
+ 8. Treat every `timeout` as unresolved. `retryable` is `false` on every `timeout` the server
263
+ returns today, and it is never permission to relaunch under a new id.
264
+
265
+ A same-id rebuild can take up to about 95 seconds to answer, because the server waits on the
266
+ chain, and a submit can take up to about 160 seconds. Set no client-side timeout shorter than
267
+ that on these calls.
268
+
132
269
  ## Importing a wallet
133
270
 
134
271
  `importWallet()` drives Candle's ciphertext-only wallet import end to end: it fetches Privy's
@@ -203,7 +340,9 @@ Every non-2xx response throws `CandleApiError` with `code`, `status`, `retryable
203
340
  `docsPath` when the envelope carries them. Structured envelopes map straight through; the few
204
341
  legacy endpoints without envelopes (activity, users) surface as `code: "HTTP_<status>"`. A
205
342
  Solana or EVM JSON-RPC failure throws `JsonRpcError`, with the RPC's numeric `code` and its
206
- `data` (for a failed simulation, `{ err, logs }`). The full code table is on
343
+ `data` (for a failed simulation, `{ err, logs }`). The one-call write methods also put the
344
+ idempotency id they sent on these errors, and wrap any other failure in `CandleOperationError`:
345
+ see "Retrying a write that failed or timed out" above. The full code table is on
207
346
  [Headless launch](https://docs.candle.tv/developers/headless-launch).
208
347
 
209
348
  ## Development
@@ -242,3 +381,20 @@ capped: a partial net must not be quoted as a lifetime figure. On upstream, wall
242
381
  malformed-data failure the section is `{ read: false, reason }`, without amounts; spot PnL still
243
382
  answers. An empty successful read is `{ read: true, ... }` with zero amounts. `--json` preserves
244
383
  the section and its coverage fields. Agent boards are outside this slice.
384
+
385
+
386
+ ## CNDL staking
387
+
388
+ Solana staking is available through the agent rail while the deployment enables it (off by
389
+ default). There is no instant unstake. Read the pool wait and wallet eligibility notes first:
390
+ pending withdrawal CNDL counts toward nothing for Pro; outside the primary wallet sCNDL earns
391
+ Solana rewards but no Hood rewards, and Pro follows the served tier mode.
392
+
393
+ Use `client.staking.positions()`, `.stake({ amountRaw, clientStakeId, dryRun })`,
394
+ `.unstake({ amountRaw, clientStakeId, dryRun })`, `.finish({ withdrawal, clientStakeId, dryRun })`
395
+ and `.claimReward(rewardId)`. Builds default to the embedded wallet and MOVE REAL FUNDS after
396
+ approval. `payer: "linked"` with `linkedWalletId` returns a build for external/TEE signing;
397
+ `.submit({ clientStakeId, signedTransaction })` or `.confirm({ clientStakeId, signature })`
398
+ records its landing. Preserve the unstake's withdrawal co-signature. Reuse the same operation id
399
+ and body on retry; reward retries use the same rewardId. Amounts are raw CNDL on stake and raw
400
+ sCNDL on unstake.