@candledottv/agent-sdk 0.4.11 → 0.6.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 +196 -8
- package/dist/client.d.ts +478 -21
- package/dist/client.d.ts.map +1 -1
- package/dist/errors.d.ts +156 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +295 -50
- package/package.json +1 -1
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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,6 +118,8 @@ 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
|
|
@@ -132,6 +137,187 @@ const fill = await candle.trade({ mint: "9dXSV8...CNDL", side: "buy", amountRaw:
|
|
|
132
137
|
Limit orders are a REST surface only (`/api/v1/trade/agent/orders`); the SDK has no method for
|
|
133
138
|
them.
|
|
134
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 for a main payer. For linked Hood recovery, see the table below. For a linked Solana payer or `selfLaunch()`, a same-id call 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
|
+
|
|
222
|
+
### Non-TEE Hood linked trade recovery
|
|
223
|
+
|
|
224
|
+
Once any leg is signed, a same-id build is refused: it does not rebuild or broadcast a second trade.
|
|
225
|
+
The sign relay records each signed transaction's hash and nonce. Builds and job reads resolve
|
|
226
|
+
receipts when asked; there is no background resolver. Error `legs` and job `signedLegs` list every
|
|
227
|
+
consumed claim across builds, in build/leg order, as `{ leg, hash?, nonce?, outcome }`.
|
|
228
|
+
An older claim without a leg label is conservatively treated as the trade leg.
|
|
229
|
+
|
|
230
|
+
| Build answer | Meaning and recovery |
|
|
231
|
+
| --- | --- |
|
|
232
|
+
| 500 `SWAP_FAILED`, `chain_read_failed`, retryable | The chain read failed; retry with the same id. |
|
|
233
|
+
| 409 `IDEMPOTENCY_CONFLICT`, `prior_attempt_on_chain`, not retryable | The trade landed. Read the job: for a still-`built` row, call `confirmTrade` with `signature` as `tradeTxHash` and the fee leg hash from `legs`. Pay a fee that did not land first, following `TradeLandedFeeLegError`. For a `failed` row, this id cannot be recorded with `/confirm`; do not retry or rebuild. Retain all hashes for the restoration follow-up. |
|
|
234
|
+
| 409 `TRADE_REVERTED`, `stage: reverted`, not retryable | Every consumed, not refused trade leg has a reverted receipt. This id is settled; a new trade needs a new id. Other builds' open claims are not retired by this answer. |
|
|
235
|
+
| 409 `IDEMPOTENCY_CONFLICT`, `sign_claim_in_flight`, retryable | The relay is asking Privy. Retry after `retryAfter`, bounded at two minutes from that claim's consume. |
|
|
236
|
+
| 409 `IDEMPOTENCY_CONFLICT`, `prior_attempt_in_flight`, retryable | A signed trade or approval has no final answer. Retry after `retryAfter`, within 180 seconds of the newest consumed, not refused claim across this id's builds and legs. |
|
|
237
|
+
| 409 `IDEMPOTENCY_CONFLICT`, `prior_attempt_unprovable`, not retryable | The hash is missing, the signed transaction has no receipt after that window, or retirement cannot be proved. It can still land; inspect the wallet and hashes. Do not retry or rebuild. |
|
|
238
|
+
| 409 `IDEMPOTENCY_CONFLICT`, `prior_attempt_lapsed`, not retryable | A signed transaction's nonce was used and no receipt was found. Check every hash. Do not retry or rebuild from this answer; another leg can still land, and open claims were not retired. |
|
|
239
|
+
| 409 `IDEMPOTENCY_CONFLICT`, `prior_attempt_approval_only`, `stage: not_broadcast`, not retryable | No swap was signed. The build transactionally retired its open claims, so the old artifact cannot be signed. Use a new id. Later builds repeat this answer unchanged. Job reads never retire claims. |
|
|
240
|
+
|
|
241
|
+
A revert beside a pending, forwarding, unknown or lapsed trade leg follows that unsettled leg's
|
|
242
|
+
answer, with the revert still in `legs`: it is not `TRADE_REVERTED` and does not advise a new id.
|
|
243
|
+
Lapsed beside reverted stores no reverted trade-row outcome. A later visible receipt can change the
|
|
244
|
+
answer to landed, or to reverted once every eligible trade leg has a reverted receipt.
|
|
245
|
+
|
|
246
|
+
SDK 0.6.0 exports `TradeLegOutcomeError` for a non-fee Hood leg failure when a hash exists:
|
|
247
|
+
`clientTradeId`, `leg`, `outcome`, `txHash?`, `landedLegs`, and the original `cause`.
|
|
248
|
+
Inspect the hashes, then call `trade()` with the same id and body, or read `getTradeJob(id)`,
|
|
249
|
+
for Candle's view. Never retry an id after a `retryable: false` answer.
|
|
250
|
+
A first leg failure before signing retains the original error behavior.
|
|
251
|
+
`TradeLandedUnconfirmedError` means all legs landed but confirmation failed; it keeps
|
|
252
|
+
`tradeTxHash`, `feeTxHash?`, `clientTradeId`, `stage: executed`, and `cause`.
|
|
253
|
+
Read the job before another `confirmTrade`; a failed row cannot be booked with `/confirm`.
|
|
254
|
+
`operationIds()` handles both errors. `CandleApiError` exposes `reason`, `retryAfter` and `legs`.
|
|
255
|
+
|
|
256
|
+
### `selfLaunch()` and a linked Solana `trade()`
|
|
257
|
+
|
|
258
|
+
You sign and broadcast in these flows, so the id is necessary and **not** sufficient. An error
|
|
259
|
+
after the broadcast may sit on a transaction that landed. So a `CandleOperationError` from these
|
|
260
|
+
flows does not tell you to retry with the id. Read the job first (`getLaunchJob(id)`,
|
|
261
|
+
`getTradeJob(id)`); if it is not `confirmed`, check the wallet's recent transactions before
|
|
262
|
+
sending again.
|
|
263
|
+
|
|
264
|
+
For a linked Solana `trade()`, and for `selfLaunch()` on Hood, calling the method again under the same
|
|
265
|
+
id builds and broadcasts a new transaction.
|
|
266
|
+
|
|
267
|
+
`selfLaunch()` on Solana does not build a second launch. One id has one build at a time:
|
|
268
|
+
|
|
269
|
+
- While the first transaction can still land, the server returns that same transaction again.
|
|
270
|
+
The sign relay signs a build once, so a second `selfLaunch()` call throws instead of launching
|
|
271
|
+
again. Broadcast what was signed, or wait until it has expired.
|
|
272
|
+
- Once it has landed, the build is refused with `IDEMPOTENCY_CONFLICT` and
|
|
273
|
+
`error.reason: "prior_attempt_on_chain"`. Record it with
|
|
274
|
+
`confirmSelfLaunch({ clientLaunchId, signature })`, taking the signature from the wallet's
|
|
275
|
+
recent transactions. This keeps working after the build's `expiresAt`.
|
|
276
|
+
- A new transaction is built only after the chain proves the first one can never land.
|
|
277
|
+
|
|
278
|
+
Inside one `selfLaunch()` call, a blockhash error from your Solana RPC is retried with the bytes
|
|
279
|
+
already signed. They are signed again only when the server returns a different transaction.
|
|
280
|
+
|
|
281
|
+
### `launchAtomic()`
|
|
282
|
+
|
|
283
|
+
`launchAtomic()` returns `timeout` and `failed` as values, not errors, and each carries
|
|
284
|
+
`clientLaunchId`. `bundleId` is dead once `/submit` has been called and is never resubmitted; it
|
|
285
|
+
is on the result and the error for log correlation only. The launch's identity is
|
|
286
|
+
`clientLaunchId`. After a `timeout` result, a `failed` result, or an error that carries
|
|
287
|
+
`clientLaunchId`:
|
|
288
|
+
|
|
289
|
+
1. Read `getLaunchJob(clientLaunchId)`. This needs `launch:read` on the key. The Write and
|
|
290
|
+
Withdraw access levels hold it; a key whose scopes were named one by one holds it only if it
|
|
291
|
+
was named, so add `launch:read` to any key that runs atomic launches. A scope refusal
|
|
292
|
+
(`SCOPE_MISSING`) is not "not found".
|
|
293
|
+
2. `confirmed`: the bundle landed. The mint is on the job. Stop.
|
|
294
|
+
3. 404 `JOB_NOT_FOUND`: the build never began. Call `launchAtomic()` again with the same id.
|
|
295
|
+
4. `failed`: the bundle did not land. Call `launchAtomic()` again with the **same** id; the server
|
|
296
|
+
starts a clean attempt under it.
|
|
297
|
+
5. Any other status: the bundle may still land. Wait, then either read the job again or call
|
|
298
|
+
`launchAtomic()` again with the **same** id and the same body. The server resolves the first
|
|
299
|
+
attempt before doing anything new. It answers 409 `IDEMPOTENCY_CONFLICT` while the first
|
|
300
|
+
attempt is unresolved or has just been found landed, so on that 409 read the job again. It
|
|
301
|
+
builds a fresh bundle only when the first is proven reverted.
|
|
302
|
+
6. **Never send a new id while the job is not `failed`.** That is the one path the server cannot
|
|
303
|
+
refuse, and it is a second token. This includes the 409 whose message says "use a new
|
|
304
|
+
clientLaunchId to launch again": read the job first, because that message is also what you see
|
|
305
|
+
for a launch you do not yet know landed.
|
|
306
|
+
7. One state has no terminal answer today. For an attempt whose blockhash expired with no
|
|
307
|
+
signature on record, the same-id `launchAtomic()` keeps answering 409 `IDEMPOTENCY_CONFLICT`,
|
|
308
|
+
"This launch was sent; its original attempt must be resolved", and the job read is still
|
|
309
|
+
neither `confirmed` nor `failed`. Treat it as this state when that remains true well after the
|
|
310
|
+
bundle's blockhash must have expired: a Solana blockhash lives about 90 seconds and a same-id
|
|
311
|
+
rebuild can itself take about 95 seconds, so a few minutes after the submit. Then stop. Do not
|
|
312
|
+
send a new id on a timer; report the `clientLaunchId` and `bundleId` to whoever operates the
|
|
313
|
+
key.
|
|
314
|
+
8. Treat every `timeout` as unresolved. `retryable` is `false` on every `timeout` the server
|
|
315
|
+
returns today, and it is never permission to relaunch under a new id.
|
|
316
|
+
|
|
317
|
+
A same-id rebuild can take up to about 95 seconds to answer, because the server waits on the
|
|
318
|
+
chain, and a submit can take up to about 160 seconds. Set no client-side timeout shorter than
|
|
319
|
+
that on these calls.
|
|
320
|
+
|
|
135
321
|
## Importing a wallet
|
|
136
322
|
|
|
137
323
|
`importWallet()` drives Candle's ciphertext-only wallet import end to end: it fetches Privy's
|
|
@@ -206,7 +392,9 @@ Every non-2xx response throws `CandleApiError` with `code`, `status`, `retryable
|
|
|
206
392
|
`docsPath` when the envelope carries them. Structured envelopes map straight through; the few
|
|
207
393
|
legacy endpoints without envelopes (activity, users) surface as `code: "HTTP_<status>"`. A
|
|
208
394
|
Solana or EVM JSON-RPC failure throws `JsonRpcError`, with the RPC's numeric `code` and its
|
|
209
|
-
`data` (for a failed simulation, `{ err, logs }`). The
|
|
395
|
+
`data` (for a failed simulation, `{ err, logs }`). The one-call write methods also put the
|
|
396
|
+
idempotency id they sent on these errors, and wrap any other failure in `CandleOperationError`:
|
|
397
|
+
see "Retrying a write that failed or timed out" above. The full code table is on
|
|
210
398
|
[Headless launch](https://docs.candle.tv/developers/headless-launch).
|
|
211
399
|
|
|
212
400
|
## Development
|