@candledottv/agent-sdk 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Candle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # @candledottv/agent-sdk
2
+
3
+ A typed TypeScript SDK for the Candle agent rail. It wraps the REST surface documented in
4
+ `docs/headless-launch.md` (headless launches, jobs, dry runs, market state, quotes, feeds,
5
+ verification, presets, agent profiles, image uploads), ships the webhook signature verifier,
6
+ and drives the client-side HPKE seal behind linked-wallet import, so an agent integrates
7
+ against typed methods instead of hand-rolled HTTP.
8
+
9
+ Built on the global `fetch`; runs on Bun and Node 18+. Three runtime dependencies --
10
+ `@hpke/core`, `@hpke/chacha20poly1305`, and `@scure/base` -- exist solely to power
11
+ `importWallet()`'s client-side HPKE seal and base58 decode (see "Importing a wallet" below);
12
+ nothing else in the SDK needs them. The one `node:` builtin used is `node:crypto` (webhook
13
+ verification only), which Bun also provides. Edge runtimes without `node:crypto` would need a
14
+ Web Crypto port of the verifier; that is a deliberate later concern.
15
+
16
+ > **Not yet published to npm.** Publishing `@candledottv/agent-sdk` (and `@candledottv/mcp`)
17
+ > is the post-Phase-2 follow-up. Until then, consume it as a workspace package:
18
+ > `import { CandleClient } from "@candledottv/agent-sdk"`.
19
+
20
+ ## Quick start
21
+
22
+ ```ts
23
+ import { CandleClient } from "@candledottv/agent-sdk"
24
+
25
+ const candle = new CandleClient({
26
+ apiUrl: "https://api.alpha.candle.tv",
27
+ apiKey: process.env.CANDLE_AGENT_API_KEY, // cndl_live_... / cndl_test_...
28
+ })
29
+
30
+ // Public reads need no key.
31
+ const market = await candle.getMarket("solana", "So11...mint")
32
+ const quote = await candle.getQuote("solana", "So11...mint", { side: "buy", amountIn: "1000000000" })
33
+ const feed = await candle.getFeed("new", "solana")
34
+ const verdict = await candle.verify("hood", "0xToken")
35
+
36
+ // Presets: fetch once, expand locally into a launch body.
37
+ const presets = await candle.getPresets()
38
+ const request = candle.expandPreset(presets, "solana-open-sol", {
39
+ name: "Trend Coin",
40
+ symbol: "TREND",
41
+ imageUrl: "https://example.com/logo.png",
42
+ })
43
+ ```
44
+
45
+ The API key is attached as `x-api-key` on every request when configured. The keyed methods
46
+ (`launch`, `launchAsync`, `dryRunLaunch`, `getLaunchJob`, `reportActivity`, `uploadImage`)
47
+ refuse to fetch without one and throw a plain `Error` locally instead of a server 401.
48
+
49
+ ## Launching with built-in idempotent retries
50
+
51
+ `launch()` fills in a `clientLaunchId` (`"sdk-" + crypto.randomUUID()`) when you omit one, and
52
+ retries transient failures by re-sending the SAME id, which the server's idempotency ledger
53
+ resolves safely (no double mint, ever). Retries cover network errors, non-envelope 5xx
54
+ responses, retryable 5xx envelopes, and the retryable in-flight 409; a non-retryable envelope
55
+ (validation errors, an id reused with a different body, `LAUNCH_DISABLED`) is thrown
56
+ immediately. Backoff is 250ms doubling per attempt, jittered, capped at 8s, bounded by
57
+ `maxRetries` (default 3).
58
+
59
+ ```ts
60
+ import { CandleApiError } from "@candledottv/agent-sdk"
61
+
62
+ try {
63
+ const result = await candle.launch({
64
+ name: "Trend Coin",
65
+ symbol: "TREND",
66
+ imageUrl: "https://example.com/logo.png",
67
+ chain: "solana",
68
+ buyAmount: 100_000_000, // lamports
69
+ })
70
+ console.log("minted", result.mint, "explorer:", result.links.explorer)
71
+ } catch (error) {
72
+ if (error instanceof CandleApiError) {
73
+ // Branch on code, never on message. error.status, error.retryable, error.field ride along.
74
+ console.error("launch failed:", error.code)
75
+ } else {
76
+ throw error
77
+ }
78
+ }
79
+ ```
80
+
81
+ Prefer not to block? `launchAsync()` sends `async: true`, returns the 202 body, and
82
+ `waitForLaunch()` polls the jobs endpoint until the attempt is terminal:
83
+
84
+ ```ts
85
+ const accepted = await candle.launchAsync({ name: "Trend Coin", symbol: "TREND", imageUrl: "https://..." })
86
+ const job = await candle.waitForLaunch(accepted.clientLaunchId, { timeoutMs: 180_000, pollMs: 2_000 })
87
+ if (job.status === "confirmed") console.log("minted", job.mint)
88
+ else console.error("failed:", job.errorCode)
89
+ ```
90
+
91
+ Need a hosted image first? `uploadImage(bytes, contentType)` posts raw bytes to
92
+ `/api/v1/uploads/agent-image` and returns `{ imageUrl }`, ready for the launch body.
93
+
94
+ ## Importing a wallet
95
+
96
+ `importWallet()` drives Candle's ciphertext-only wallet import end to end: it fetches Privy's
97
+ HPKE receiver public key (`/wallets/import/init`), seals the private key locally with
98
+ `encryptWalletKeyForImport()` (RFC 9180 Base mode, `DHKEM(P-256, HKDF-SHA256)` /
99
+ `HKDF-SHA256` / `ChaCha20-Poly1305`), and submits only the resulting ciphertext and
100
+ encapsulated key (`/wallets/import/submit`). **The plaintext private key never leaves the
101
+ calling process, and Candle never receives, stores, or logs it at any point** -- the server is
102
+ a ciphertext-only proxy to Privy's HPKE endpoint.
103
+
104
+ ```ts
105
+ import { CandleClient, generateSignerKeypair } from "@candledottv/agent-sdk"
106
+
107
+ const candle = new CandleClient({
108
+ apiUrl: "https://api.alpha.candle.tv",
109
+ apiKey: process.env.CANDLE_AGENT_API_KEY,
110
+ })
111
+
112
+ // A fresh P-256 (ECDSA) signer keypair. Only the public half ever leaves this process.
113
+ const { privateKeyPem, publicKeyDerBase64 } = await generateSignerKeypair()
114
+ // privateKeyPem is yours to store and sign with later; the SDK never transmits it anywhere.
115
+
116
+ const result = await candle.importWallet({
117
+ chain: "solana", // or "evm"
118
+ address: "9xQe...wallet",
119
+ privateKey: existingWalletPrivateKey, // base58 for "solana", hex ("0x"-optional) for "evm"
120
+ signerPublicKey: publicKeyDerBase64,
121
+ label: "trading wallet",
122
+ })
123
+ console.log("linked", result.id, result.privyWalletId)
124
+ ```
125
+
126
+ `generateSignerKeypair()` generates the signer Privy registers as the imported wallet's 1-of-1
127
+ key quorum; `importWallet()` is the only place in this SDK that ever holds the wallet's
128
+ plaintext private key, and only for the duration of the local HPKE seal.
129
+
130
+ ## Verifying webhooks
131
+
132
+ Candle signs every webhook delivery with
133
+ `x-candle-signature: t=<unix seconds>,v1=<hex hmac-sha256(secret, "<t>.<body>")>`. Verify the
134
+ RAW request body string (do not re-serialize parsed JSON; key order changes break the digest):
135
+
136
+ ```ts
137
+ import { verifyWebhookSignature } from "@candledottv/agent-sdk"
138
+
139
+ // Example with a Bun/Node fetch-style handler:
140
+ async function handleWebhook(req: Request): Promise<Response> {
141
+ const rawBody = await req.text()
142
+ const ok = verifyWebhookSignature(
143
+ process.env.CANDLE_WEBHOOK_SECRET ?? "",
144
+ req.headers.get("x-candle-signature"),
145
+ rawBody,
146
+ Math.floor(Date.now() / 1000),
147
+ 300, // tolerance in seconds (default)
148
+ )
149
+ if (!ok) return new Response("invalid signature", { status: 401 })
150
+
151
+ const event = JSON.parse(rawBody)
152
+ // handle launch.confirmed, launch.failed, curve.graduated, migration.completed, migration.delayed
153
+ return new Response("ok")
154
+ }
155
+ ```
156
+
157
+ The verifier never throws: malformed headers, stale timestamps, wrong secrets, and tampered
158
+ bodies all return `false`. Comparison is constant-time (`timingSafeEqual`).
159
+
160
+ ## Errors
161
+
162
+ Every non-2xx response throws `CandleApiError` with `code`, `status`, `retryable`, and
163
+ (for field-level validation) `field`. Structured envelopes map straight through; the few
164
+ legacy endpoints without envelopes (activity, users) surface as `code: "HTTP_<status>"`.
165
+ The full code table lives in `docs/headless-launch.md`.
166
+
167
+ ## Development
168
+
169
+ ```bash
170
+ bun test # pure tests against an injected fake fetch; no server, no network
171
+ bun run typecheck # tsc --noEmit
172
+ ```
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Locally computes Privy's wallet-RPC "authorization signature" (Agent Pilot Phase 3, Task 2):
3
+ * the client half of the linked-wallet signing relay Task 1 built server-side
4
+ * (apps/api/src/routes/agent.ts's `POST /wallets/:id/sign`, and
5
+ * apps/api/src/services/privy-wallets.ts's `relaySignWithLinkedWallet`, which forwards this
6
+ * signature to Privy unchanged and MUST NEVER compute its own). An agent holds its own P-256
7
+ * signer key locally -- the private half of `generateSignerKeypair`'s output (wallet-import.ts)
8
+ * -- and must produce the SAME signature Privy's own SDK would produce for that key, over the
9
+ * SAME canonical request payload, WITHOUT the key ever leaving this process. That is the entire
10
+ * point of the relay.
11
+ *
12
+ * The reference implementation this must match is `generateAuthorizationSignature` from
13
+ * `@privy-io/server-auth/wallet-api` (installed at
14
+ * node_modules/@privy-io/server-auth/dist/esm/wallet-api/{index,utils}.mjs, v1.32.5, each
15
+ * minified to a single line, so described here by the compiled local variable names rather than
16
+ * source line numbers). Reading that source (not guessing) turned up three load-bearing details:
17
+ *
18
+ * 1. Canonicalization is RFC 8785 JSON Canonicalization Scheme (JCS), via the `canonicalize` npm
19
+ * package -- utils.mjs's `y = e => Buffer.from(o(e))` where `o` is `canonicalize`'s default
20
+ * export, called on `{version:1, method, url, body, headers}` (utils.mjs's `h`, exported as
21
+ * `formatAuthorizationSignatureRequest`). JCS is NOT plain `JSON.stringify`: it sorts every
22
+ * object's keys, recurses through arrays/nested objects, and has its own number-formatting and
23
+ * undefined-skipping rules. Reimplementing JCS by hand risks a byte-level mismatch on some edge
24
+ * case that would silently produce a signature Privy rejects. `canonicalize` is already a
25
+ * transitive dependency of this monorepo's `@privy-io/server-auth` (the same situation
26
+ * wallet-import.ts's module doc describes for the HPKE packages), so this module takes it as a
27
+ * small, zero-dependency, real runtime dependency instead of hand-rolling JCS: it is the
28
+ * literal function Privy's own SDK canonicalizes with, so using it is not a reimplementation
29
+ * risk at all, unlike a from-scratch JCS implementation would be.
30
+ * 2. The signed message is SHA-256 of those canonical UTF-8 bytes, ECDSA/P-256 over that hash --
31
+ * utils.mjs's `c = (e,r) => n.sign(a(e), r).toDERRawBytes()` (`n` = `@noble/curves/p256`, `a` =
32
+ * `@noble/hashes/sha256`), called with `prehash` left at its default `false`, i.e. `a(e)` (the
33
+ * SHA-256 hash) IS the "message" `n.sign` signs directly -- @noble's own `validateMsgAndHash`
34
+ * only re-hashes when `prehash: true` is passed. `crypto.subtle.sign({name:"ECDSA",
35
+ * hash:"SHA-256"}, ...)` computes that identical SHA-256-then-ECDSA-P-256 relationship, so
36
+ * WebCrypto is a faithful stand-in for signing. It is NOT bit-identical: WebCrypto uses a
37
+ * random per-signature nonce where @noble/curves' reference impl uses deterministic RFC 6979,
38
+ * so the two will never produce the same signature bytes for the same input -- but both are
39
+ * valid ECDSA signatures over the same (hash, key) pair, and verify identically against the
40
+ * public key. This is exactly why this module's pinning test (authorization-signature.test.ts)
41
+ * verifies signatures cryptographically rather than comparing signature strings.
42
+ * 3. Privy expects the signature DER-encoded (`.toDERRawBytes()`), base64-encoded, in the
43
+ * `privy-authorization-signature` header. WebCrypto's ECDSA `sign` only ever produces the raw
44
+ * IEEE P1363 format (a P-256 signature's 32-byte `r` immediately followed by its 32-byte `s`)
45
+ * -- never DER -- so this module converts that raw signature to DER itself
46
+ * (`rawEcdsaSignatureToDer`) before base64-encoding. Skipping this step would produce a
47
+ * signature Privy's server does not recognize as valid, even though the same bytes verify fine
48
+ * against the public key when treated as "raw".
49
+ *
50
+ * See authorization-signature.test.ts for the pinning test: it feeds the SAME generated P-256 key
51
+ * to both this module (as the PEM `buildPrivyAuthorizationSignature` takes) and the reference
52
+ * `generateAuthorizationSignature` (as `"wallet-auth:" + base64(pkcs8 DER)`, the form Privy's own
53
+ * key-normalization expects -- see `normalizeP256PrivateKeyToScalar` in utils.mjs, which strips
54
+ * that prefix and base64-decodes back to the same PKCS8 DER bytes `pemToPkcs8Bytes` extracts from
55
+ * the PEM here), then verifies BOTH resulting signatures cryptographically against the same
56
+ * public key (never compares the signature strings -- see point 2 above) and separately asserts
57
+ * the exact canonical byte payload this module hashes is identical to the reference's own
58
+ * `formatRequestForAuthorizationSignature` output for the same input.
59
+ */
60
+ export interface BuildPrivyAuthorizationSignatureParams {
61
+ /** PKCS8 PEM private key for the agent's P-256 signer, as produced by `generateSignerKeypair` (wallet-import.ts). */
62
+ privateKeyPem: string;
63
+ /** The Privy wallet this signature authorizes an RPC call against; becomes the URL path segment. */
64
+ privyWalletId: string;
65
+ /** Privy's app id: sent as the `privy-app-id` header and folded into the canonical payload. */
66
+ appId: string;
67
+ /** The exact RPC request body being authorized. Must be byte-identical to what is actually sent -- see module doc, point 1. */
68
+ body: unknown;
69
+ }
70
+ /**
71
+ * The exact UTF-8 bytes this module hashes and signs: RFC 8785 JCS canonicalization of
72
+ * `{version, method, url, body, headers}`, matching Privy's own
73
+ * `formatRequestForAuthorizationSignature` byte-for-byte (both call the same `canonicalize`
74
+ * function -- see module doc, point 1). Exported so the pinning test can assert this equals the
75
+ * reference's own output, independently of whether the resulting signatures verify.
76
+ */
77
+ export declare function canonicalAuthorizationPayloadBytes(params: Omit<BuildPrivyAuthorizationSignatureParams, "privateKeyPem">): Uint8Array;
78
+ /** Strips PEM armor (BEGIN/END lines and newlines) and returns the raw PKCS8 DER bytes underneath, for WebCrypto's `importKey("pkcs8", ...)`. */
79
+ export declare function pemToPkcs8Bytes(pem: string): Uint8Array;
80
+ /**
81
+ * Builds the `privy-authorization-signature` header value for a Privy wallet-RPC request,
82
+ * computed entirely locally from `privateKeyPem` -- the key never leaves this function call, and
83
+ * this module makes no network request of any kind. See this module's doc comment for how each
84
+ * step matches `@privy-io/server-auth/wallet-api`'s `generateAuthorizationSignature`.
85
+ */
86
+ export declare function buildPrivyAuthorizationSignature(params: BuildPrivyAuthorizationSignatureParams): Promise<string>;
87
+ //# sourceMappingURL=authorization-signature.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authorization-signature.d.ts","sourceRoot":"","sources":["../src/authorization-signature.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AAOH,MAAM,WAAW,sCAAsC;IACrD,qHAAqH;IACrH,aAAa,EAAE,MAAM,CAAA;IACrB,oGAAoG;IACpG,aAAa,EAAE,MAAM,CAAA;IACrB,+FAA+F;IAC/F,KAAK,EAAE,MAAM,CAAA;IACb,+HAA+H;IAC/H,IAAI,EAAE,OAAO,CAAA;CACd;AAuBD;;;;;;GAMG;AACH,wBAAgB,kCAAkC,CAChD,MAAM,EAAE,IAAI,CAAC,sCAAsC,EAAE,eAAe,CAAC,GACpE,UAAU,CAQZ;AAED,iJAAiJ;AACjJ,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAOvD;AA6CD;;;;;GAKG;AACH,wBAAsB,gCAAgC,CACpD,MAAM,EAAE,sCAAsC,GAC7C,OAAO,CAAC,MAAM,CAAC,CAMjB"}