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 +16 -0
- package/CLAUDE.md +119 -0
- package/README.md +36 -84
- package/dist/sdk.d.ts +34 -9
- package/dist/sdk.js +101 -76
- package/package.json +1 -1
- package/src/sdk.ts +116 -89
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
|

|
|
6
6
|

|
|
7
7
|
[](https://www.npmjs.com/package/clutch-hub-sdk-js)
|
|
8
|
-
[](https://www.npmjs.com/package/clutch-hub-sdk-js)
|
|
9
8
|
|
|
10
|
-
> ⚠️ **ALPHA SOFTWARE**
|
|
9
|
+
> ⚠️ **ALPHA SOFTWARE** — APIs may change without notice.
|
|
11
10
|
|
|
12
|
-
JavaScript SDK for
|
|
11
|
+
JavaScript/TypeScript SDK for the Clutch Hub API and Clutch blockchain.
|
|
13
12
|
|
|
14
|
-
**
|
|
15
|
-
|
|
16
|
-
## Overview
|
|
13
|
+
**Documentation:** https://docs.clutchprotocol.io/clutch-hub-sdk-js/overview
|
|
17
14
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
```js
|
|
23
|
+
```javascript
|
|
44
24
|
import { ClutchHubSdk } from 'clutch-hub-sdk-js';
|
|
45
25
|
|
|
46
|
-
|
|
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
|
-
//
|
|
49
|
-
|
|
30
|
+
// Fund test wallet
|
|
31
|
+
await sdk.requestFaucet(publicKey);
|
|
50
32
|
|
|
51
|
-
//
|
|
52
|
-
const
|
|
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
|
-
|
|
39
|
+
const signed = await sdk.signTransaction(unsigned, privateKey);
|
|
40
|
+
await sdk.submitTransaction(signed.rawTransaction);
|
|
70
41
|
```
|
|
71
42
|
|
|
72
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
git commit -m "docs: update README with new examples"
|
|
95
|
-
git commit -m "chore: update dependencies"
|
|
96
|
-
```
|
|
63
|
+
## Security
|
|
97
64
|
|
|
98
|
-
|
|
65
|
+
**Never expose private keys.** Client-side signing only. See [Security](https://docs.clutchprotocol.io/reference/security).
|
|
99
66
|
|
|
100
|
-
|
|
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
|
-
|
|
69
|
+
Uses [semantic-release](https://semantic-release.gitbook.io/) with conventional commits.
|
|
105
70
|
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
56
|
-
*
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
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
|
-
*
|
|
99
|
-
*
|
|
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<
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
*/
|