@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 +21 -0
- package/README.md +172 -0
- package/dist/authorization-signature.d.ts +87 -0
- package/dist/authorization-signature.d.ts.map +1 -0
- package/dist/client.d.ts +1192 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/evm-tx.d.ts +100 -0
- package/dist/evm-tx.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1186 -0
- package/dist/internal/encoding.d.ts +19 -0
- package/dist/internal/encoding.d.ts.map +1 -0
- package/dist/keychain-secret-store.d.ts +51 -0
- package/dist/keychain-secret-store.d.ts.map +1 -0
- package/dist/secret-store.d.ts +82 -0
- package/dist/secret-store.d.ts.map +1 -0
- package/dist/wallet-import.d.ts +93 -0
- package/dist/wallet-import.d.ts.map +1 -0
- package/dist/webhooks.d.ts +20 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/package.json +60 -0
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"}
|