clutch-hub-sdk-js 1.22.1 → 2.0.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/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [2.0.0](https://github.com/clutchprotocol/clutch-hub-sdk-js/compare/v1.22.1...v2.0.0) (2026-07-02)
2
+
3
+
4
+ ### ⚠ BREAKING CHANGES
5
+
6
+ * authenticated methods (createUnsigned*, submitTransaction,
7
+ getAccountBalance) now require a private key via the constructor or
8
+ setPrivateKey(), and generateToken requires timestamp + signature. Requires
9
+ clutch-hub-api with the matching auth challenge.
10
+
11
+ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12
+
13
+ ### Features
14
+
15
+ * sign auth challenge for generateToken ([02c9f1e](https://github.com/clutchprotocol/clutch-hub-sdk-js/commit/02c9f1efa9db5cdecdb2ebc52744a9714254420b))
16
+
1
17
  ## [1.22.1](https://github.com/clutchprotocol/clutch-hub-sdk-js/compare/v1.22.0...v1.22.1) (2026-05-28)
2
18
 
3
19
  ## [1.22.0](https://github.com/clutchprotocol/clutch-hub-sdk-js/compare/v1.21.0...v1.22.0) (2026-05-22)
package/CLAUDE.md ADDED
@@ -0,0 +1,119 @@
1
+ # clutch-hub-sdk-js
2
+
3
+ TypeScript client SDK for Clutch Protocol (npm: `clutch-hub-sdk-js`). Signs transactions
4
+ client-side (secp256k1 + keccak-256 + RLP) and talks to the Hub API over GraphQL HTTP and
5
+ graphql-ws subscriptions. See the parent `D:\source\clutch\CLAUDE.md` for the workspace overview.
6
+
7
+ ## Source Layout
8
+
9
+ Only four source files — the SDK is deliberately small:
10
+
11
+ - `src/sdk.ts` — everything important: `ClutchHubSdk` class, JWT auth caching, signing/hashing,
12
+ RLP encoding (`encodeFunctionCall`), all GraphQL queries/mutations/subscriptions inline as
13
+ template strings, faucet helper. Also exports `stripHexPrefix`, `normalizeTxHashForRlp`,
14
+ `UnsignedTransaction`.
15
+ - `src/subscriptions.ts` — `hubGraphqlWsUrl()` (HTTP base URL → `ws(s)://…/graphql/ws`),
16
+ `createHubSubscriptionClient()` (graphql-ws client: `lazy: false`, infinite retry, 10s keepAlive),
17
+ shared GraphQL field-selection constants (`RIDE_REQUEST_GQL_FIELDS` etc.), `SubscriptionHandlers<T>`.
18
+ - `src/types.ts` — arg/result interfaces (`RideRequestArgs`, `AvailableActiveTrip`, `MapBounds`,
19
+ `Signature`, `FaucetResponse`, …).
20
+ - `src/index.ts` — barrel re-exports. New public symbols must be reachable from here.
21
+
22
+ There is no test suite. `test_rlp_fix.{js,mjs}` are ad-hoc manual scripts run against `dist/` after
23
+ `npm run build` — not wired into CI.
24
+
25
+ ## Transaction Lifecycle (client side)
26
+
27
+ 1. **Build unsigned**: `createUnsignedRideRequest/Offer/Acceptance/Pay/Cancel/RequestCancel` call
28
+ the corresponding Hub API mutation (after `ensureAuth`) and get back
29
+ `{ data, from, nonce }` (`UnsignedTransaction`).
30
+ 2. **Encode call data**: `encodeFunctionCall(data)` maps the function-call type to a nested array
31
+ `[tag, args]` for RLP. Tags must match the Rust node: RideRequest=1, RideOffer=2,
32
+ RideAcceptance=3, RidePay=4, RideCancel=5, RideRequestCancel=8 (6/7 reserved elsewhere).
33
+ 3. **Hash**: RLP-encode `[from (no 0x), nonce, callDataArray]`, keccak-256 it → `rawHashHex`.
34
+ 4. **Sign**: `signHash` does **not** sign the hash bytes directly — the Rust node verifies
35
+ `Keccak256(hash_string.as_utf8_bytes())`, so the SDK keccaks the *hex string's UTF-8 bytes*,
36
+ then `secp.signAsync`. Recovery id + 27 → `v`.
37
+ 5. **Encode signed**: RLP `[from, nonce, r, s, v, hash, callDataArray]` (all hex without 0x) →
38
+ `rawTransaction: '0x…'`.
39
+ 6. **Submit**: `submitTransaction(rawTransaction)` → `sendRawTransaction` mutation → tx hash.
40
+
41
+ Signing quirks to preserve: floats (lat/lng) are encoded as IEEE-754 big-endian u64 bits via
42
+ `float64ToUint64` (BigInt); tx-hash args go through `normalizeTxHashForRlp` (strips 0x *and*
43
+ legacy JSON-string quoting); empty referrer encodes as `''`.
44
+
45
+ ## Public API Surface (`ClutchHubSdk`)
46
+
47
+ - **Constructor / identity**: `new ClutchHubSdk(apiUrl, publicKey, privateKey?)`, `getPublicKey()`,
48
+ `setPrivateKey(privateKey)`, `isAuthenticated()`. The private key (constructor arg or
49
+ `setPrivateKey`) is required for token issuance — `generateToken` demands a signed
50
+ proof-of-key-ownership challenge. It is kept in a module-global map keyed by publicKey
51
+ (like the JWT cache) and never sent to the API.
52
+ - **Auth (internal)**: `ensureAuth()` builds the challenge `clutch-auth:{publicKey}:{timestamp}`
53
+ (unix seconds), signs it via `signAuthChallenge` (Keccak-256 the message to a hex string, then
54
+ the usual `signHashHex` convention — see Transaction Lifecycle step 4), and calls the
55
+ `generateToken(publicKey, timestamp, signature)` mutation. The Hub API rejects timestamps more
56
+ than ±120s from server time. JWTs are cached in a **module-global** map keyed by publicKey with
57
+ 30s expiry buffer and in-flight dedup, so multiple SDK instances share tokens; `ensureAuth`
58
+ throws if no cached token is valid and no private key was provided. Exported helpers:
59
+ `buildAuthChallengeMessage`, `authChallengeHashHex`, `signAuthChallenge` — these must stay
60
+ byte-for-byte in sync with `clutch-hub-api`'s `hub/auth.rs`.
61
+ - **Unsigned tx builders**: `createUnsignedRideRequest/RideOffer/RideAcceptance/RidePay/RideCancel/RideRequestCancel`.
62
+ - **Sign & submit**: `signTransaction(unsignedTx, privateKey)` → `{ r, s, v, rawTransaction, txHash }`;
63
+ `submitTransaction(rawTransaction)`.
64
+ - **Queries**: `listRideRequests(bounds?)`, `listRideOffers(hash)`, `listActiveTrips`,
65
+ `listCompletedTrips`, `listRecentTrips`, `getAccountBalance(publicKey?)`.
66
+ - **Subscriptions** (each returns a dispose function): `subscribeRideRequests`,
67
+ `subscribeRideOffers`, `subscribeActiveTrips`, `subscribeCompletedTrips`, `subscribeRecentTrips`,
68
+ `subscribeAccountBalance`. All multiplex over **one shared graphql-ws socket per
69
+ (hub URL, publicKey)**, refcounted in a module-global map; the last dispose closes the socket.
70
+ Always call the returned dispose function or sockets/refcounts leak.
71
+ - **Misc**: `requestFaucet(address)` — plain `POST /faucet` (no JWT), returns
72
+ `{ ok: false, error }` instead of throwing; `getGraphqlWsUrl()`.
73
+
74
+ ## Adding a New Transaction Type
75
+
76
+ 1. Add the arg interface to `src/types.ts`; export lands via `src/index.ts` automatically.
77
+ 2. Add `createUnsignedXxx` in `src/sdk.ts` mirroring existing ones (inline mutation string,
78
+ `ensureAuth`, `executeGraphQL`).
79
+ 3. Add a `case` in `encodeFunctionCall` with the **same tag number and argument order as the Rust
80
+ node's FunctionCall enum** (`clutch-node`) — a mismatch produces valid-looking txs the node
81
+ rejects. Support both snake_case (`ride_offer_transaction_hash`) and camelCase arg keys, as the
82
+ Hub API has returned both shapes.
83
+ 4. Upstream first: node RPC → `clutch-hub-api` GraphQL mutation must exist before the SDK method
84
+ works. Then update `clutch-hub-demo-app` and `clutch-docs`.
85
+
86
+ For a new query/subscription: add types + field constant (in `subscriptions.ts` if shared between
87
+ query and subscription), then a `listXxx` using `executeGraphQL` and/or a `subscribeXxx` using
88
+ `subscribeGraphqlListField` (list payloads) or the manual pattern in `subscribeAccountBalance`
89
+ (scalar payloads).
90
+
91
+ ## Build & Release
92
+
93
+ - `npm run build` = `tsc` → `dist/` (declarations included). `prepare` also builds, which is what
94
+ makes the `file:` install work. No lint or test scripts exist despite CONTRIBUTING.md mentioning them.
95
+ - tsconfig: ES2020 target, `module: ESNext`, `strict: true`, DOM lib included (browser-first).
96
+ - **semantic-release** on push to `main` (`.github/workflows/npm-publish.yml` + `.releaserc.json`):
97
+ Conventional Commits required. `feat:` → minor, `fix:`/`perf:`/`refactor:` → patch,
98
+ `feat!:` or a `BREAKING CHANGE:` footer → major; `docs:`/`chore:`/`ci:`/`test:`/`build:`/`style:`
99
+ release nothing. Non-releasing pushes to `main` publish a `-canary.<sha>` build under the
100
+ `canary` dist-tag. A `beta` branch does prereleases. CHANGELOG.md and package.json version are
101
+ bot-committed (`chore(release): x.y.z [skip ci]`) — never bump the version by hand.
102
+ - The demo app consumes this repo via `"clutch-hub-sdk-js": "file:../clutch-hub-sdk-js"`; its
103
+ `predev`/`prebuild` run `npm run build --prefix ../clutch-hub-sdk-js`. So SDK source changes
104
+ reach the demo app on its next `npm run dev` — but if Vite is already running you may need to
105
+ restart / clear `node_modules/.vite` to pick up the rebuilt dist.
106
+
107
+ ## Gotchas
108
+
109
+ - **Browser + Node dual use**: `sdk.ts` imports `buffer` (npm polyfill) and assigns
110
+ `window.Buffer` if missing. Don't use Node-only APIs; keep DOM usage guarded by
111
+ `typeof window !== 'undefined'`.
112
+ - `@noble/secp256k1` v2 hex parsers reject `0x` prefixes — always run keys/hashes through
113
+ `stripHexPrefix` before passing them to noble.
114
+ - Auth state (JWT cache, in-flight dedup, shared WS clients) is module-global, not per-instance —
115
+ tests or multi-wallet apps share it by design.
116
+ - WS subscriptions silently continue without a JWT if `generateToken` fails — including when no
117
+ private key was supplied for the wallet (public list subscriptions are allowed unauthenticated).
118
+ - GraphQL operations are inline strings with hand-written TS result types — there is no codegen;
119
+ keep field constants and `types.ts` in sync with the Hub API schema manually.
package/README.md CHANGED
@@ -5,115 +5,67 @@
5
5
  ![License](https://img.shields.io/badge/license-MIT-blue.svg)
6
6
  ![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)
7
7
  [![npm](https://img.shields.io/npm/v/clutch-hub-sdk-js.svg)](https://www.npmjs.com/package/clutch-hub-sdk-js)
8
- [![npm downloads](https://img.shields.io/npm/dm/clutch-hub-sdk-js.svg)](https://www.npmjs.com/package/clutch-hub-sdk-js)
9
8
 
10
- > ⚠️ **ALPHA SOFTWARE** - This project is in active development and is considered experimental. Use at your own risk. APIs may change without notice.
9
+ > ⚠️ **ALPHA SOFTWARE** APIs may change without notice.
11
10
 
12
- JavaScript SDK for interacting with the clutch-hub-api
11
+ JavaScript/TypeScript SDK for the Clutch Hub API and Clutch blockchain.
13
12
 
14
- **Created and maintained by [Mehran Mazhar](https://github.com/MehranMazhar)**
15
-
16
- ## Overview
13
+ **Documentation:** https://docs.clutchprotocol.io/clutch-hub-sdk-js/overview
17
14
 
18
- `clutch-hub-sdk-js` is a JavaScript/TypeScript SDK for building decentralized applications (dApps) that interact with the [clutch-hub-api](https://github.com/your-org/clutch-hub-api) and the Clutch custom blockchain. This SDK helps you:
19
- - Connect to the hub API
20
- - Build and sign transactions client-side (keeping private keys secure)
21
- - Submit signed transactions to the blockchain via the API
22
- - Query chain state (e.g., get nonce, balances, etc.)
15
+ ## Install
23
16
 
24
- ## Features
25
- - **Client-side signing:** Never expose your private key to the server; all signing is done in the browser or mobile app.
26
- - **Transaction helpers:** Easily build, encode, and sign custom Clutch transactions (e.g., ride requests).
27
- - **API integration:** Fetch chain state and submit signed transactions to the hub API.
28
- - **GraphQL subscriptions:** `subscribeRideRequests`, `subscribeRideOffers`, `subscribeActiveTrips`, and `subscribeCompletedTrips` use [`graphql-ws`](https://github.com/enisdenjo/graphql-ws) against `wss://…/graphql/ws` (see `hubGraphqlWsUrl()`). Each call opens a WebSocket, sends optional JWT from `connection_init`, and returns a **dispose** function for cleanup.
29
- - **TypeScript support:** Type-safe interfaces for all major methods and transaction types.
30
-
31
- ## Installation
32
17
  ```bash
33
18
  npm install clutch-hub-sdk-js
34
19
  ```
35
20
 
36
- ### Latest Version
37
- You can also install the latest canary version for cutting-edge features:
38
- ```bash
39
- npm install clutch-hub-sdk-js@canary
40
- ```
21
+ ## Usage
41
22
 
42
- ## Basic Usage
43
- ```js
23
+ ```javascript
44
24
  import { ClutchHubSdk } from 'clutch-hub-sdk-js';
45
25
 
46
- const sdk = new ClutchHubSdk('https://your-hub-api-url');
26
+ // privateKey is needed for authenticated calls: generateToken requires a signed
27
+ // proof-of-key-ownership challenge (the key stays local, it is never sent).
28
+ const sdk = new ClutchHubSdk('http://localhost:3000', publicKey, privateKey);
47
29
 
48
- // 1. Fetch the next nonce for the user
49
- const nonce = await sdk.getNextNonce(userAddress);
30
+ // Fund test wallet
31
+ await sdk.requestFaucet(publicKey);
50
32
 
51
- // 2. Build a ride request transaction
52
- const tx = sdk.buildRideRequestTx({
33
+ // Create, sign, and submit a ride request
34
+ const unsigned = await sdk.createUnsignedRideRequest({
53
35
  pickup: { latitude: 35.7, longitude: 51.4 },
54
36
  dropoff: { latitude: 35.8, longitude: 51.5 },
55
- fare: 1000
56
- }, userAddress, nonce);
57
-
58
- // 3. Sign the transaction (using user's private key)
59
- const { r, s, v } = await sdk.signTx(tx, userPrivateKey);
60
-
61
- // 4. Submit the signed transaction
62
- const receipt = await sdk.sendTransaction({
63
- from: userAddress,
64
- nonce,
65
- payload: tx,
66
- r, s, v
37
+ fare: 1000,
67
38
  });
68
-
69
- console.log('Transaction receipt:', receipt);
39
+ const signed = await sdk.signTransaction(unsigned, privateKey);
40
+ await sdk.submitTransaction(signed.rawTransaction);
70
41
  ```
71
42
 
72
- ## Security Note
73
- **Never share or expose your private key.** The SDK is designed for client-side signing only. For best security, integrate with browser wallets, hardware wallets, or secure mobile keystores.
74
-
75
- ## Development & Releases
76
-
77
- This project uses [semantic-release](https://semantic-release.gitbook.io/) for automated versioning and publishing.
78
-
79
- ### Commit Message Format
43
+ ## Features
80
44
 
81
- Use [Conventional Commits](https://conventionalcommits.org/) for automatic version bumping:
45
+ - Client-side signing (private keys never sent to server)
46
+ - Full ride lifecycle: request, offer, accept, pay, cancel
47
+ - GraphQL queries and WebSocket subscriptions
48
+ - Testnet faucet helper
49
+ - TypeScript types
82
50
 
83
- ```bash
84
- # Patch release (0.1.0 → 0.1.1)
85
- git commit -m "fix: resolve memory leak in transaction processing"
51
+ ## API methods
86
52
 
87
- # Minor release (0.1.0 → 0.2.0)
88
- git commit -m "feat: add ride cancellation functionality"
53
+ | Category | Methods |
54
+ |----------|---------|
55
+ | Auth | Auto `generateToken` via `ensureAuth()` (signed challenge; needs the private key), `setPrivateKey`, `signAuthChallenge` |
56
+ | Write | `createUnsignedRide*`, `signTransaction`, `submitTransaction` |
57
+ | Read | `listRideRequests`, `listRideOffers`, `listActiveTrips`, `getAccountBalance`, … |
58
+ | Live | `subscribeRideRequests`, `subscribeRideOffers`, `subscribeActiveTrips`, … |
59
+ | Faucet | `requestFaucet(recipientAddress)` |
89
60
 
90
- # Major release (0.1.0 → 1.0.0)
91
- git commit -m "feat!: change API signature for ClutchHubSdk constructor"
61
+ Full reference: https://docs.clutchprotocol.io/clutch-hub-sdk-js/api-reference
92
62
 
93
- # No release
94
- git commit -m "docs: update README with new examples"
95
- git commit -m "chore: update dependencies"
96
- ```
63
+ ## Security
97
64
 
98
- ### Release Process
65
+ **Never expose private keys.** Client-side signing only. See [Security](https://docs.clutchprotocol.io/reference/security).
99
66
 
100
- 1. **Automatic Releases**: Merge commits to `main` with conventional commit messages
101
- 2. **Canary Releases**: Non-conventional commits create canary versions (`0.1.0-canary.abc1234`)
102
- 3. **Manual Releases**: Push git tags (`v1.0.0`) for manual version control
67
+ ## Releases
103
68
 
104
- ### Commit Message Template
69
+ Uses [semantic-release](https://semantic-release.gitbook.io/) with conventional commits.
105
70
 
106
- Set up the commit message template:
107
- ```bash
108
- git config commit.template .gitmessage
109
- ```
110
-
111
- ## Author & Maintainer
112
-
113
- **Mehran Mazhar**
114
- - GitHub: [@MehranMazhar](https://github.com/MehranMazhar)
115
- - Website: [MehranMazhar.com](https://MehranMazhar.com)
116
- - Email: mehran.mazhar@gmail.com
117
-
118
- ## License
119
- MIT
71
+ **Created and maintained by [Mehran Mazhar](https://github.com/MehranMazhar)**
package/dist/sdk.d.ts CHANGED
@@ -13,6 +13,25 @@ declare global {
13
13
  Buffer: typeof Buffer;
14
14
  }
15
15
  }
16
+ /** Prefix of the canonical proof-of-key-ownership message signed for `generateToken`. */
17
+ export declare const AUTH_CHALLENGE_PREFIX = "clutch-auth";
18
+ /**
19
+ * Canonical auth challenge message for `generateToken`. Must match clutch-hub-api
20
+ * (`hub::auth::build_auth_challenge_message`) byte-for-byte: the exact `publicKey` string
21
+ * sent as the mutation argument and the timestamp in decimal unix seconds.
22
+ */
23
+ export declare function buildAuthChallengeMessage(publicKey: string, timestamp: number): string;
24
+ /**
25
+ * Keccak-256 of the canonical auth message as 64-char lowercase hex (no 0x).
26
+ * The signature is then computed over the UTF-8 bytes of this hex string (see `signHashHex`),
27
+ * the same convention used for transaction hashes.
28
+ */
29
+ export declare function authChallengeHashHex(publicKey: string, timestamp: number): string;
30
+ /**
31
+ * Sign the `generateToken` proof-of-key-ownership challenge.
32
+ * @param timestamp Unix seconds; the Hub API rejects timestamps more than ±120s from server time.
33
+ */
34
+ export declare function signAuthChallenge(publicKey: string, timestamp: number, privateKey: string): Promise<Signature>;
16
35
  /**
17
36
  * Represents an unsigned transaction returned by the GraphQL API.
18
37
  */
@@ -30,12 +49,26 @@ export declare class ClutchHubSdk {
30
49
  private publicKey;
31
50
  private token;
32
51
  private tokenExpireTime;
33
- constructor(apiUrl: string, publicKey: string);
52
+ /**
53
+ * @param apiUrl Hub API base URL.
54
+ * @param publicKey Wallet address (0x + 40 hex) or uncompressed public key (130 hex).
55
+ * @param privateKey Optional wallet private key, required to obtain JWTs: `generateToken`
56
+ * demands a signed proof-of-key-ownership challenge. May also be provided later via
57
+ * {@link setPrivateKey}. Never sent to the API — only used for local signing.
58
+ */
59
+ constructor(apiUrl: string, publicKey: string, privateKey?: string);
34
60
  /**
35
61
  * Get the current public key associated with this SDK instance.
36
62
  * @returns The public key string
37
63
  */
38
64
  getPublicKey(): string;
65
+ /**
66
+ * Provide (or replace) the private key used to sign `generateToken` auth challenges for
67
+ * this SDK's public key. Stored in a module-global map keyed by publicKey — like the JWT
68
+ * cache — so every SDK instance and shared WebSocket connection for this wallet can
69
+ * authenticate. In-memory only; never sent to the API.
70
+ */
71
+ setPrivateKey(privateKey: string): void;
39
72
  /**
40
73
  * Check if the SDK is currently authenticated.
41
74
  * @returns True if authenticated and token is not expired
@@ -173,14 +206,6 @@ export declare class ClutchHubSdk {
173
206
  * `faucet_private_key` on the server. No GraphQL auth token required for this HTTP endpoint.
174
207
  */
175
208
  requestFaucet(recipientAddress: string): Promise<FaucetResponse>;
176
- /**
177
- * Signs the message that the Rust node verifies.
178
- *
179
- * Rust does:
180
- * - message_hash = Keccak256(tx.hash.as_bytes())
181
- * - then uses ECDSA recoverable signature with that message_hash
182
- */
183
- private signHash;
184
209
  /**
185
210
  * Builds the nested array representing the function call for RLP encoding.
186
211
  */
package/dist/sdk.js CHANGED
@@ -42,6 +42,57 @@ const globalTokenCache = new Map();
42
42
  * Deduplicate concurrent `generateToken` calls per `publicKey`.
43
43
  */
44
44
  const inFlightTokenRequests = new Map();
45
+ /**
46
+ * Module-global private-key store keyed by `publicKey` (parallel to the JWT cache).
47
+ * `generateToken` requires proof of key ownership (a signed challenge), so token issuance
48
+ * needs the wallet's private key. Keys are kept in memory only and are **never** sent to
49
+ * the Hub API — only the challenge signature is.
50
+ */
51
+ const globalPrivateKeys = new Map();
52
+ /** Prefix of the canonical proof-of-key-ownership message signed for `generateToken`. */
53
+ export const AUTH_CHALLENGE_PREFIX = 'clutch-auth';
54
+ /**
55
+ * Canonical auth challenge message for `generateToken`. Must match clutch-hub-api
56
+ * (`hub::auth::build_auth_challenge_message`) byte-for-byte: the exact `publicKey` string
57
+ * sent as the mutation argument and the timestamp in decimal unix seconds.
58
+ */
59
+ export function buildAuthChallengeMessage(publicKey, timestamp) {
60
+ return `${AUTH_CHALLENGE_PREFIX}:${publicKey}:${timestamp}`;
61
+ }
62
+ /**
63
+ * Keccak-256 of the canonical auth message as 64-char lowercase hex (no 0x).
64
+ * The signature is then computed over the UTF-8 bytes of this hex string (see `signHashHex`),
65
+ * the same convention used for transaction hashes.
66
+ */
67
+ export function authChallengeHashHex(publicKey, timestamp) {
68
+ const message = buildAuthChallengeMessage(publicKey, timestamp);
69
+ return Buffer.from(keccak_256(Buffer.from(message, 'utf8'))).toString('hex');
70
+ }
71
+ /**
72
+ * Signs a hash-hex string the way the Rust node/hub verify:
73
+ * - message_hash = Keccak256(hashHex.as_utf8_bytes()) — i.e. over the hex *string*, not its bytes
74
+ * - recoverable secp256k1 over that message_hash; v = recovery id + 27
75
+ */
76
+ async function signHashHex(hashHex, privateKey) {
77
+ const privKeyClean = stripHexPrefix(privateKey);
78
+ const messageHash = keccak_256(Buffer.from(hashHex, 'utf8'));
79
+ const sig = await secp.signAsync(messageHash, privKeyClean);
80
+ const r = sig.r.toString(16).padStart(64, '0');
81
+ const s = sig.s.toString(16).padStart(64, '0');
82
+ const v = (typeof sig.recovery === 'number' ? sig.recovery : 0) + 27;
83
+ return {
84
+ r: '0x' + r,
85
+ s: '0x' + s,
86
+ v,
87
+ };
88
+ }
89
+ /**
90
+ * Sign the `generateToken` proof-of-key-ownership challenge.
91
+ * @param timestamp Unix seconds; the Hub API rejects timestamps more than ±120s from server time.
92
+ */
93
+ export async function signAuthChallenge(publicKey, timestamp, privateKey) {
94
+ return signHashHex(authChallengeHashHex(publicKey, timestamp), privateKey);
95
+ }
45
96
  /**
46
97
  * One graphql-ws connection per hub URL + wallet; multiplex all subscriptions on it.
47
98
  * Without this, each subscribe* call opened a new socket (`lazy: false`), which explodes
@@ -52,31 +103,48 @@ function sharedGraphqlWsCacheKey(baseURL, publicKey) {
52
103
  return `${baseURL.replace(/\/$/, '')}\0${publicKey}`;
53
104
  }
54
105
  /**
55
- * Same token resolution as `ensureAuth`, but only updates the global cache (no `this`).
56
- * Used by shared WebSocket `connectionParams` so any subscriber shares one connection.
106
+ * Resolve a valid JWT for `publicKey` into the global cache (and return it), generating one
107
+ * via the `generateToken` mutation when needed. Shared by `ensureAuth` and the WebSocket
108
+ * `connectionParams` so all SDK instances and subscriptions share tokens.
109
+ *
110
+ * Token issuance signs the proof-of-key-ownership challenge, so a private key for
111
+ * `publicKey` must have been provided (constructor or `setPrivateKey`) unless a cached
112
+ * token is still valid.
57
113
  */
58
114
  async function ensureTokenInCacheForPublicKey(publicKey, apiClient) {
59
115
  const now = Date.now();
60
116
  const bufferTime = 30000;
61
117
  const cached = globalTokenCache.get(publicKey);
62
118
  if (cached && now < cached.expireTimeMs - bufferTime) {
63
- return;
119
+ return cached;
64
120
  }
65
121
  const existingInFlight = inFlightTokenRequests.get(publicKey);
66
122
  if (existingInFlight) {
67
- await existingInFlight;
68
- return;
123
+ return existingInFlight;
124
+ }
125
+ const privateKey = globalPrivateKeys.get(publicKey);
126
+ if (!privateKey) {
127
+ throw new Error(`ClutchHubSdk: generateToken requires proof of key ownership; provide the private key for ${publicKey} via the ClutchHubSdk constructor or setPrivateKey().`);
69
128
  }
70
129
  const query = `
71
- mutation GenerateToken($publicKey: String!) {
72
- generateToken(publicKey: $publicKey) {
130
+ mutation GenerateToken($publicKey: String!, $timestamp: Int!, $signature: AuthSignatureInput!) {
131
+ generateToken(publicKey: $publicKey, timestamp: $timestamp, signature: $signature) {
73
132
  token
74
133
  expiresAt
75
134
  }
76
135
  }
77
136
  `;
78
137
  const requestPromise = (async () => {
79
- const response = await apiClient.post('/graphql', { query, variables: { publicKey } });
138
+ const timestamp = Math.floor(Date.now() / 1000);
139
+ const signature = await signAuthChallenge(publicKey, timestamp, privateKey);
140
+ const response = await apiClient.post('/graphql', {
141
+ query,
142
+ variables: {
143
+ publicKey,
144
+ timestamp,
145
+ signature: { r: signature.r, s: signature.s, v: signature.v },
146
+ },
147
+ });
80
148
  const body = response.data;
81
149
  if (body.errors?.length) {
82
150
  throw new Error(body.errors.map((e) => e.message).join('\n'));
@@ -93,7 +161,7 @@ async function ensureTokenInCacheForPublicKey(publicKey, apiClient) {
93
161
  })();
94
162
  inFlightTokenRequests.set(publicKey, requestPromise);
95
163
  try {
96
- await requestPromise;
164
+ return await requestPromise;
97
165
  }
98
166
  finally {
99
167
  inFlightTokenRequests.delete(publicKey);
@@ -104,11 +172,21 @@ async function ensureTokenInCacheForPublicKey(publicKey, apiClient) {
104
172
  * Provides client-side transaction signing and blockchain interaction capabilities.
105
173
  */
106
174
  export class ClutchHubSdk {
107
- constructor(apiUrl, publicKey) {
175
+ /**
176
+ * @param apiUrl Hub API base URL.
177
+ * @param publicKey Wallet address (0x + 40 hex) or uncompressed public key (130 hex).
178
+ * @param privateKey Optional wallet private key, required to obtain JWTs: `generateToken`
179
+ * demands a signed proof-of-key-ownership challenge. May also be provided later via
180
+ * {@link setPrivateKey}. Never sent to the API — only used for local signing.
181
+ */
182
+ constructor(apiUrl, publicKey, privateKey) {
108
183
  this.token = null;
109
184
  this.tokenExpireTime = 0;
110
185
  this.apiClient = axios.create({ baseURL: apiUrl });
111
186
  this.publicKey = publicKey;
187
+ if (privateKey) {
188
+ globalPrivateKeys.set(publicKey, privateKey);
189
+ }
112
190
  }
113
191
  /**
114
192
  * Get the current public key associated with this SDK instance.
@@ -117,6 +195,15 @@ export class ClutchHubSdk {
117
195
  getPublicKey() {
118
196
  return this.publicKey;
119
197
  }
198
+ /**
199
+ * Provide (or replace) the private key used to sign `generateToken` auth challenges for
200
+ * this SDK's public key. Stored in a module-global map keyed by publicKey — like the JWT
201
+ * cache — so every SDK instance and shared WebSocket connection for this wallet can
202
+ * authenticate. In-memory only; never sent to the API.
203
+ */
204
+ setPrivateKey(privateKey) {
205
+ globalPrivateKeys.set(this.publicKey, privateKey);
206
+ }
120
207
  /**
121
208
  * Check if the SDK is currently authenticated.
122
209
  * @returns True if authenticated and token is not expired
@@ -215,49 +302,9 @@ export class ClutchHubSdk {
215
302
  return response.data.data;
216
303
  }
217
304
  async ensureAuth() {
218
- const now = Date.now();
219
- // Add buffer time to prevent race conditions near token expiration
220
- const bufferTime = 30000; // 30 seconds
221
- const cached = globalTokenCache.get(this.publicKey);
222
- if (cached && now < cached.expireTimeMs - bufferTime) {
223
- this.token = cached.token;
224
- this.tokenExpireTime = cached.expireTimeMs;
225
- return;
226
- }
227
- // If another SDK instance is already generating a token, await it.
228
- const existingInFlight = inFlightTokenRequests.get(this.publicKey);
229
- if (existingInFlight) {
230
- const entry = await existingInFlight;
231
- this.token = entry.token;
232
- this.tokenExpireTime = entry.expireTimeMs;
233
- return;
234
- }
235
- const query = `
236
- mutation GenerateToken($publicKey: String!) {
237
- generateToken(publicKey: $publicKey) {
238
- token
239
- expiresAt
240
- }
241
- }
242
- `;
243
- const requestPromise = (async () => {
244
- const data = await this.executeGraphQL(query, { publicKey: this.publicKey });
245
- const entry = {
246
- token: data.generateToken.token,
247
- expireTimeMs: data.generateToken.expiresAt * 1000,
248
- };
249
- globalTokenCache.set(this.publicKey, entry);
250
- return entry;
251
- })();
252
- inFlightTokenRequests.set(this.publicKey, requestPromise);
253
- try {
254
- const entry = await requestPromise;
255
- this.token = entry.token;
256
- this.tokenExpireTime = entry.expireTimeMs;
257
- }
258
- finally {
259
- inFlightTokenRequests.delete(this.publicKey);
260
- }
305
+ const entry = await ensureTokenInCacheForPublicKey(this.publicKey, this.apiClient);
306
+ this.token = entry.token;
307
+ this.tokenExpireTime = entry.expireTimeMs;
261
308
  }
262
309
  /**
263
310
  * Fetches an unsigned ride request transaction from the GraphQL API.
@@ -406,7 +453,7 @@ export class ClutchHubSdk {
406
453
  const hashBytes = keccak_256(unsignedPayload);
407
454
  const rawHashHex = Buffer.from(hashBytes).toString('hex');
408
455
  // Sign the transaction hash
409
- const signature = await this.signHash(rawHashHex, privateKey);
456
+ const signature = await signHashHex(rawHashHex, privateKey);
410
457
  const rNo0x = stripHexPrefix(signature.r);
411
458
  const sNo0x = stripHexPrefix(signature.s);
412
459
  // RLP-encode full signed transaction to match Rust: [from, nonce, r, s, v, hash, data]
@@ -682,28 +729,6 @@ export class ClutchHubSdk {
682
729
  return { ok: false, error: String(msg) };
683
730
  }
684
731
  }
685
- /**
686
- * Signs the message that the Rust node verifies.
687
- *
688
- * Rust does:
689
- * - message_hash = Keccak256(tx.hash.as_bytes())
690
- * - then uses ECDSA recoverable signature with that message_hash
691
- */
692
- async signHash(hashHex, privateKey) {
693
- const privKeyClean = stripHexPrefix(privateKey);
694
- // `hashHex` is the exact string stored in the Rust transaction `hash` field.
695
- // The node verifies by hashing its UTF-8 bytes with Keccak-256.
696
- const messageHash = keccak_256(Buffer.from(hashHex, 'utf8'));
697
- const sig = await secp.signAsync(messageHash, privKeyClean);
698
- const r = sig.r.toString(16).padStart(64, '0');
699
- const s = sig.s.toString(16).padStart(64, '0');
700
- const v = (typeof sig.recovery === 'number' ? sig.recovery : 0) + 27;
701
- return {
702
- r: '0x' + r,
703
- s: '0x' + s,
704
- v,
705
- };
706
- }
707
732
  /**
708
733
  * Builds the nested array representing the function call for RLP encoding.
709
734
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clutch-hub-sdk-js",
3
- "version": "1.22.1",
3
+ "version": "2.0.0",
4
4
  "description": "JavaScript SDK for interacting with the clutch-hub-api",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
package/src/sdk.ts CHANGED
@@ -81,6 +81,67 @@ const globalTokenCache = new Map<string, TokenCacheEntry>();
81
81
  */
82
82
  const inFlightTokenRequests = new Map<string, Promise<TokenCacheEntry>>();
83
83
 
84
+ /**
85
+ * Module-global private-key store keyed by `publicKey` (parallel to the JWT cache).
86
+ * `generateToken` requires proof of key ownership (a signed challenge), so token issuance
87
+ * needs the wallet's private key. Keys are kept in memory only and are **never** sent to
88
+ * the Hub API — only the challenge signature is.
89
+ */
90
+ const globalPrivateKeys = new Map<string, string>();
91
+
92
+ /** Prefix of the canonical proof-of-key-ownership message signed for `generateToken`. */
93
+ export const AUTH_CHALLENGE_PREFIX = 'clutch-auth';
94
+
95
+ /**
96
+ * Canonical auth challenge message for `generateToken`. Must match clutch-hub-api
97
+ * (`hub::auth::build_auth_challenge_message`) byte-for-byte: the exact `publicKey` string
98
+ * sent as the mutation argument and the timestamp in decimal unix seconds.
99
+ */
100
+ export function buildAuthChallengeMessage(publicKey: string, timestamp: number): string {
101
+ return `${AUTH_CHALLENGE_PREFIX}:${publicKey}:${timestamp}`;
102
+ }
103
+
104
+ /**
105
+ * Keccak-256 of the canonical auth message as 64-char lowercase hex (no 0x).
106
+ * The signature is then computed over the UTF-8 bytes of this hex string (see `signHashHex`),
107
+ * the same convention used for transaction hashes.
108
+ */
109
+ export function authChallengeHashHex(publicKey: string, timestamp: number): string {
110
+ const message = buildAuthChallengeMessage(publicKey, timestamp);
111
+ return Buffer.from(keccak_256(Buffer.from(message, 'utf8'))).toString('hex');
112
+ }
113
+
114
+ /**
115
+ * Signs a hash-hex string the way the Rust node/hub verify:
116
+ * - message_hash = Keccak256(hashHex.as_utf8_bytes()) — i.e. over the hex *string*, not its bytes
117
+ * - recoverable secp256k1 over that message_hash; v = recovery id + 27
118
+ */
119
+ async function signHashHex(hashHex: string, privateKey: string): Promise<Signature> {
120
+ const privKeyClean = stripHexPrefix(privateKey);
121
+ const messageHash = keccak_256(Buffer.from(hashHex, 'utf8'));
122
+ const sig = await secp.signAsync(messageHash, privKeyClean);
123
+ const r = sig.r.toString(16).padStart(64, '0');
124
+ const s = sig.s.toString(16).padStart(64, '0');
125
+ const v = (typeof sig.recovery === 'number' ? sig.recovery : 0) + 27;
126
+ return {
127
+ r: '0x' + r,
128
+ s: '0x' + s,
129
+ v,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Sign the `generateToken` proof-of-key-ownership challenge.
135
+ * @param timestamp Unix seconds; the Hub API rejects timestamps more than ±120s from server time.
136
+ */
137
+ export async function signAuthChallenge(
138
+ publicKey: string,
139
+ timestamp: number,
140
+ privateKey: string
141
+ ): Promise<Signature> {
142
+ return signHashHex(authChallengeHashHex(publicKey, timestamp), privateKey);
143
+ }
144
+
84
145
  type SharedGraphqlWsEntry = { client: Client; refcount: number };
85
146
 
86
147
  /**
@@ -95,30 +156,41 @@ function sharedGraphqlWsCacheKey(baseURL: string, publicKey: string): string {
95
156
  }
96
157
 
97
158
  /**
98
- * Same token resolution as `ensureAuth`, but only updates the global cache (no `this`).
99
- * Used by shared WebSocket `connectionParams` so any subscriber shares one connection.
159
+ * Resolve a valid JWT for `publicKey` into the global cache (and return it), generating one
160
+ * via the `generateToken` mutation when needed. Shared by `ensureAuth` and the WebSocket
161
+ * `connectionParams` so all SDK instances and subscriptions share tokens.
162
+ *
163
+ * Token issuance signs the proof-of-key-ownership challenge, so a private key for
164
+ * `publicKey` must have been provided (constructor or `setPrivateKey`) unless a cached
165
+ * token is still valid.
100
166
  */
101
167
  async function ensureTokenInCacheForPublicKey(
102
168
  publicKey: string,
103
169
  apiClient: AxiosInstance
104
- ): Promise<void> {
170
+ ): Promise<TokenCacheEntry> {
105
171
  const now = Date.now();
106
172
  const bufferTime = 30000;
107
173
 
108
174
  const cached = globalTokenCache.get(publicKey);
109
175
  if (cached && now < cached.expireTimeMs - bufferTime) {
110
- return;
176
+ return cached;
111
177
  }
112
178
 
113
179
  const existingInFlight = inFlightTokenRequests.get(publicKey);
114
180
  if (existingInFlight) {
115
- await existingInFlight;
116
- return;
181
+ return existingInFlight;
182
+ }
183
+
184
+ const privateKey = globalPrivateKeys.get(publicKey);
185
+ if (!privateKey) {
186
+ throw new Error(
187
+ `ClutchHubSdk: generateToken requires proof of key ownership; provide the private key for ${publicKey} via the ClutchHubSdk constructor or setPrivateKey().`
188
+ );
117
189
  }
118
190
 
119
191
  const query = `
120
- mutation GenerateToken($publicKey: String!) {
121
- generateToken(publicKey: $publicKey) {
192
+ mutation GenerateToken($publicKey: String!, $timestamp: Int!, $signature: AuthSignatureInput!) {
193
+ generateToken(publicKey: $publicKey, timestamp: $timestamp, signature: $signature) {
122
194
  token
123
195
  expiresAt
124
196
  }
@@ -126,9 +198,18 @@ async function ensureTokenInCacheForPublicKey(
126
198
  `;
127
199
 
128
200
  const requestPromise: Promise<TokenCacheEntry> = (async () => {
201
+ const timestamp = Math.floor(Date.now() / 1000);
202
+ const signature = await signAuthChallenge(publicKey, timestamp, privateKey);
129
203
  const response = await apiClient.post<{ data?: unknown; errors?: { message: string }[] }>(
130
204
  '/graphql',
131
- { query, variables: { publicKey } }
205
+ {
206
+ query,
207
+ variables: {
208
+ publicKey,
209
+ timestamp,
210
+ signature: { r: signature.r, s: signature.s, v: signature.v },
211
+ },
212
+ }
132
213
  );
133
214
  const body = response.data as { errors?: { message: string }[]; data?: { generateToken: { token: string; expiresAt: number } } };
134
215
  if (body.errors?.length) {
@@ -147,7 +228,7 @@ async function ensureTokenInCacheForPublicKey(
147
228
 
148
229
  inFlightTokenRequests.set(publicKey, requestPromise);
149
230
  try {
150
- await requestPromise;
231
+ return await requestPromise;
151
232
  } finally {
152
233
  inFlightTokenRequests.delete(publicKey);
153
234
  }
@@ -172,9 +253,19 @@ export class ClutchHubSdk {
172
253
  private token: string | null = null;
173
254
  private tokenExpireTime: number = 0;
174
255
 
175
- constructor(apiUrl: string, publicKey: string) {
256
+ /**
257
+ * @param apiUrl Hub API base URL.
258
+ * @param publicKey Wallet address (0x + 40 hex) or uncompressed public key (130 hex).
259
+ * @param privateKey Optional wallet private key, required to obtain JWTs: `generateToken`
260
+ * demands a signed proof-of-key-ownership challenge. May also be provided later via
261
+ * {@link setPrivateKey}. Never sent to the API — only used for local signing.
262
+ */
263
+ constructor(apiUrl: string, publicKey: string, privateKey?: string) {
176
264
  this.apiClient = axios.create({ baseURL: apiUrl });
177
265
  this.publicKey = publicKey;
266
+ if (privateKey) {
267
+ globalPrivateKeys.set(publicKey, privateKey);
268
+ }
178
269
  }
179
270
 
180
271
  /**
@@ -185,6 +276,16 @@ export class ClutchHubSdk {
185
276
  return this.publicKey;
186
277
  }
187
278
 
279
+ /**
280
+ * Provide (or replace) the private key used to sign `generateToken` auth challenges for
281
+ * this SDK's public key. Stored in a module-global map keyed by publicKey — like the JWT
282
+ * cache — so every SDK instance and shared WebSocket connection for this wallet can
283
+ * authenticate. In-memory only; never sent to the API.
284
+ */
285
+ public setPrivateKey(privateKey: string): void {
286
+ globalPrivateKeys.set(this.publicKey, privateKey);
287
+ }
288
+
188
289
  /**
189
290
  * Check if the SDK is currently authenticated.
190
291
  * @returns True if authenticated and token is not expired
@@ -300,57 +401,9 @@ export class ClutchHubSdk {
300
401
  }
301
402
 
302
403
  private async ensureAuth(): Promise<void> {
303
- const now = Date.now();
304
- // Add buffer time to prevent race conditions near token expiration
305
- const bufferTime = 30000; // 30 seconds
306
-
307
- const cached = globalTokenCache.get(this.publicKey);
308
- if (cached && now < cached.expireTimeMs - bufferTime) {
309
- this.token = cached.token;
310
- this.tokenExpireTime = cached.expireTimeMs;
311
- return;
312
- }
313
-
314
- // If another SDK instance is already generating a token, await it.
315
- const existingInFlight = inFlightTokenRequests.get(this.publicKey);
316
- if (existingInFlight) {
317
- const entry = await existingInFlight;
318
- this.token = entry.token;
319
- this.tokenExpireTime = entry.expireTimeMs;
320
- return;
321
- }
322
-
323
- const query = `
324
- mutation GenerateToken($publicKey: String!) {
325
- generateToken(publicKey: $publicKey) {
326
- token
327
- expiresAt
328
- }
329
- }
330
- `;
331
-
332
- const requestPromise: Promise<TokenCacheEntry> = (async () => {
333
- const data = await this.executeGraphQL<{
334
- generateToken: { token: string; expiresAt: number }
335
- }>(query, { publicKey: this.publicKey });
336
-
337
- const entry: TokenCacheEntry = {
338
- token: data.generateToken.token,
339
- expireTimeMs: data.generateToken.expiresAt * 1000,
340
- };
341
-
342
- globalTokenCache.set(this.publicKey, entry);
343
- return entry;
344
- })();
345
-
346
- inFlightTokenRequests.set(this.publicKey, requestPromise);
347
- try {
348
- const entry = await requestPromise;
349
- this.token = entry.token;
350
- this.tokenExpireTime = entry.expireTimeMs;
351
- } finally {
352
- inFlightTokenRequests.delete(this.publicKey);
353
- }
404
+ const entry = await ensureTokenInCacheForPublicKey(this.publicKey, this.apiClient);
405
+ this.token = entry.token;
406
+ this.tokenExpireTime = entry.expireTimeMs;
354
407
  }
355
408
 
356
409
  /**
@@ -530,7 +583,7 @@ export class ClutchHubSdk {
530
583
  const rawHashHex = Buffer.from(hashBytes).toString('hex');
531
584
 
532
585
  // Sign the transaction hash
533
- const signature = await this.signHash(rawHashHex, privateKey);
586
+ const signature = await signHashHex(rawHashHex, privateKey);
534
587
  const rNo0x = stripHexPrefix(signature.r);
535
588
  const sNo0x = stripHexPrefix(signature.s);
536
589
 
@@ -900,32 +953,6 @@ export class ClutchHubSdk {
900
953
  }
901
954
  }
902
955
 
903
- /**
904
- * Signs the message that the Rust node verifies.
905
- *
906
- * Rust does:
907
- * - message_hash = Keccak256(tx.hash.as_bytes())
908
- * - then uses ECDSA recoverable signature with that message_hash
909
- */
910
- private async signHash(
911
- hashHex: string,
912
- privateKey: string
913
- ): Promise<Signature> {
914
- const privKeyClean = stripHexPrefix(privateKey);
915
- // `hashHex` is the exact string stored in the Rust transaction `hash` field.
916
- // The node verifies by hashing its UTF-8 bytes with Keccak-256.
917
- const messageHash = keccak_256(Buffer.from(hashHex, 'utf8'));
918
- const sig = await secp.signAsync(messageHash, privKeyClean);
919
- const r = sig.r.toString(16).padStart(64, '0');
920
- const s = sig.s.toString(16).padStart(64, '0');
921
- const v = (typeof sig.recovery === 'number' ? sig.recovery : 0) + 27;
922
- return {
923
- r: '0x' + r,
924
- s: '0x' + s,
925
- v,
926
- };
927
- }
928
-
929
956
  /**
930
957
  * Builds the nested array representing the function call for RLP encoding.
931
958
  */