clutch-hub-sdk-js 4.2.1 → 4.3.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,10 @@
1
+ ## [4.3.0](https://github.com/clutchprotocol/clutch-hub/compare/v4.2.1...v4.3.0) (2026-10-06)
2
+
3
+
4
+ ### Features
5
+
6
+ * **sdk:** sign with a wallet (MetaMask, Trust Wallet); the demo app holds no key ([#30](https://github.com/clutchprotocol/clutch-hub/issues/30)) ([db41df0](https://github.com/clutchprotocol/clutch-hub/commit/db41df077f19340afe72fac314f36e7773486f58))
7
+
1
8
  ## [4.2.1](https://github.com/clutchprotocol/clutch-hub-sdk-js/compare/v4.2.0...v4.2.1) (2026-09-18)
2
9
 
3
10
 
package/README.md CHANGED
@@ -41,9 +41,26 @@ await sdk.submitTransaction(signed.rawTransaction);
41
41
 
42
42
  Hash arguments (`listRideOffers`, `subscribeRideOffers`) accept the `0x`-prefixed form that `signTransaction` returns; the SDK normalizes them to the form the hub matches on.
43
43
 
44
+ ## Wallets: MetaMask, Trust Wallet
45
+
46
+ A wallet keeps the key and signs a short text with `personal_sign`. Use a signer where you used a key:
47
+
48
+ ```javascript
49
+ import { ClutchHubSdk, discoverInjectedWallets, connectWallet } from 'clutch-hub-sdk-js';
50
+
51
+ const [wallet] = await discoverInjectedWallets(); // EIP-6963, then window.ethereum
52
+ const signer = await connectWallet(wallet); // the wallet asks the user to share an account
53
+
54
+ const sdk = new ClutchHubSdk('http://localhost:3000', signer.address, signer, 2077);
55
+ // ... create the unsigned transaction as above ...
56
+ const signed = await sdk.signTransaction(unsigned, signer, { type: 'RideRequest', fare: 5_000_000n });
57
+ ```
58
+
59
+ Each `signTransaction` and each login opens a prompt in the wallet. The text the wallet shows is `clutch-tx:{chainId}:{hash}` for a transaction and `clutch-auth:{chainId}:{address}:{timestamp}` for the login. The node and the Hub API accept this signature next to the signature of a private key. `createWalletSigner(provider, address)` builds a signer for a provider you already have, and `createLocalSigner(privateKey)` wraps a key. A user who says no in the wallet gives a rejection with `code: 4001`.
60
+
44
61
  ## Features
45
62
 
46
- - Client-side signing (private keys never sent to server)
63
+ - Client-side signing (private keys never sent to server), or a wallet that keeps the key (MetaMask, Trust Wallet)
47
64
  - Full ride lifecycle: request, offer, accept, pay, cancel
48
65
  - GraphQL queries and WebSocket subscriptions
49
66
  - TypeScript types
@@ -52,7 +69,8 @@ Hash arguments (`listRideOffers`, `subscribeRideOffers`) accept the `0x`-prefixe
52
69
 
53
70
  | Category | Methods |
54
71
  |----------|---------|
55
- | Auth | Auto `generateToken` via `ensureAuth()` (signed challenge; needs the private key), `setPrivateKey`, `signAuthChallenge` |
72
+ | Auth | Auto `generateToken` via `ensureAuth()` (signed challenge; needs a private key or a signer), `setPrivateKey`, `setSigner`, `signAuthChallenge` |
73
+ | Signers | `createLocalSigner`, `createWalletSigner`, `discoverInjectedWallets`, `connectWallet`, `addressFromPrivateKey` |
56
74
  | Write | `createUnsignedRide*`, `signTransaction`, `submitTransaction` |
57
75
  | Read | `listRideRequests`, `listRideOffers`, `listActiveTrips`, `getAccountBalance`, … |
58
76
  | Live | `subscribeRideRequests`, `subscribeRideOffers`, `subscribeActiveTrips`, … |
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './types.js';
2
2
  export * from './sdk.js';
3
+ export * from './signers.js';
3
4
  export { hubGraphqlWsUrl, RIDE_REQUEST_GQL_FIELDS, RIDE_OFFER_GQL_FIELDS, ACTIVE_TRIP_GQL_FIELDS, RECENT_TRIP_GQL_FIELDS, createHubSubscriptionClient, } from './subscriptions.js';
4
5
  export type { SubscriptionHandlers } from './subscriptions.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './types.js';
2
2
  export * from './sdk.js';
3
+ export * from './signers.js';
3
4
  export { hubGraphqlWsUrl, RIDE_REQUEST_GQL_FIELDS, RIDE_OFFER_GQL_FIELDS, ACTIVE_TRIP_GQL_FIELDS, RECENT_TRIP_GQL_FIELDS, createHubSubscriptionClient, } from './subscriptions.js';
package/dist/sdk.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from 'buffer';
2
2
  import { type SubscriptionHandlers } from './subscriptions.js';
3
3
  import { AvailableRideRequest, AvailableRideOffer, AvailableActiveTrip, AvailableCompletedTrip, AvailableRecentTrip, BurnArgs, MapBounds, RideRequestArgs, RideOfferArgs, RideAcceptanceArgs, RidePayArgs, RideCancelArgs, RideRequestCancelArgs, Signature } from './types.js';
4
+ import type { Signer } from './signers.js';
4
5
  /** Strip 0x/0X prefix - hex parsers (e.g. @noble/secp256k1) do not accept it. Exported for consumers. */
5
6
  export declare function stripHexPrefix(hex: string): string;
6
7
  /**
@@ -46,6 +47,13 @@ export declare function authChallengeHashHex(chainId: number, publicKey: string,
46
47
  * @param timestamp Unix seconds; the Hub API rejects timestamps more than ±120s from server time.
47
48
  */
48
49
  export declare function signAuthChallenge(chainId: number, publicKey: string, timestamp: number, privateKey: string): Promise<Signature>;
50
+ /** The address of a private key: `0x` and 40 lowercase hex characters. */
51
+ export declare function addressFromPrivateKey(privateKey: string): string;
52
+ /**
53
+ * A signer for a private key held in memory. It signs the hash string, as the SDK always has.
54
+ * A wallet cannot hand over its key: use `createWalletSigner` for that.
55
+ */
56
+ export declare function createLocalSigner(privateKey: string): Signer;
49
57
  /**
50
58
  * Represents an unsigned transaction returned by the GraphQL API.
51
59
  */
@@ -120,9 +128,10 @@ export declare class ClutchHubSdk {
120
128
  /**
121
129
  * @param apiUrl Hub API base URL.
122
130
  * @param publicKey Wallet address (0x + 40 hex) or uncompressed public key (130 hex).
123
- * @param privateKey Optional wallet private key, required to obtain JWTs: `generateToken`
124
- * demands a signed proof-of-key-ownership challenge. May also be provided later via
125
- * {@link setPrivateKey}. Never sent to the API — only used for local signing.
131
+ * @param privateKey Optional private key, or a {@link Signer} (for example a wallet's, see
132
+ * `createWalletSigner`), required to obtain JWTs: `generateToken` demands a signed
133
+ * proof-of-key-ownership challenge. May also be provided later via {@link setPrivateKey}
134
+ * or {@link setSigner}. A key is never sent to the API — only used for local signing.
126
135
  * @param chainId This chain's id (e.g. 2077 for the app's own config), used for the
127
136
  * chain-bound auth challenge and as the default `expected.chainId` pin in
128
137
  * {@link signTransaction}'s `verifyUnsignedTransaction` check. Get this from app config,
@@ -133,24 +142,33 @@ export declare class ClutchHubSdk {
133
142
  * @param options Optional settings — see {@link ClutchHubSdkOptions}. Today that is the HTTP
134
143
  * timeout (`timeoutMs`, default {@link DEFAULT_HTTP_TIMEOUT_MS}).
135
144
  */
136
- constructor(apiUrl: string, publicKey: string, privateKey?: string, chainId?: number, options?: ClutchHubSdkOptions);
145
+ constructor(apiUrl: string, publicKey: string, privateKey?: string | Signer, chainId?: number, options?: ClutchHubSdkOptions);
137
146
  /**
138
147
  * Get the current public key associated with this SDK instance.
139
148
  * @returns The public key string
140
149
  */
141
150
  getPublicKey(): string;
142
151
  /**
143
- * Provide (or replace) the private key used to sign `generateToken` auth challenges for
144
- * this SDK's public key. Stored in a module-global map keyed by publicKey — like the JWT
145
- * cache — so every SDK instance and shared WebSocket connection for this wallet can
146
- * authenticate. In-memory only; never sent to the API.
152
+ * Provide (or replace) the private key — or the {@link Signer} — used to sign `generateToken`
153
+ * auth challenges for this SDK's public key. Stored in a module-global map keyed by
154
+ * publicKey — like the JWT cache — so every SDK instance and shared WebSocket connection for
155
+ * this wallet can authenticate. In-memory only; a key is never sent to the API.
147
156
  */
148
- setPrivateKey(privateKey: string): void;
157
+ setPrivateKey(privateKey: string | Signer): void;
158
+ /** Same as {@link setPrivateKey}, for a signer such as a wallet's (`createWalletSigner`). */
159
+ setSigner(signer: Signer): void;
149
160
  /**
150
161
  * Check if the SDK is currently authenticated.
151
162
  * @returns True if authenticated and token is not expired
152
163
  */
153
164
  isAuthenticated(): boolean;
165
+ /**
166
+ * True when a token for this account is cached and still good, so the next authenticated call
167
+ * opens no sign-in prompt. `isAuthenticated` looks at this instance only; this looks at the
168
+ * cache that every instance of the account shares. A poll should check it first: a wallet's
169
+ * sign-in prompt belongs to something the user did, never to a timer.
170
+ */
171
+ hasValidToken(): boolean;
154
172
  private get authHeaders();
155
173
  /**
156
174
  * WebSocket URL for GraphQL subscriptions (same host as REST/GraphQL HTTP).
@@ -161,6 +179,15 @@ export declare class ClutchHubSdk {
161
179
  * Call `release` when unsubscribing; last release disposes the socket.
162
180
  */
163
181
  private acquireGraphqlWsClient;
182
+ /**
183
+ * The `connection_init` payload of the shared socket: a token when there is one to send.
184
+ *
185
+ * The subscriptions are public, so a token is optional here. This runs at every connect and
186
+ * every reconnect (the socket retries for ever), with nobody watching, so a signer that opens a
187
+ * prompt (a wallet) is not asked for a signature: a token from an earlier, explicit action is
188
+ * still sent. A key signs silently and is asked as before.
189
+ */
190
+ private wsConnectionParams;
164
191
  /**
165
192
  * Shared graphql-ws list subscription: one multiplexed client via {@link acquireGraphqlWsClient}.
166
193
  */
@@ -208,8 +235,12 @@ export declare class ClutchHubSdk {
208
235
  createUnsignedBurn(args: BurnArgs): Promise<UnsignedTransaction>;
209
236
  /**
210
237
  * Signs a transaction and returns the signature and raw RLP-encoded payload.
238
+ *
239
+ * `privateKey` is a private key, or a {@link Signer}. A key signs the hash string. A wallet's
240
+ * signer asks the wallet to sign `clutch-tx:{chainId}:{hash}` with `personal_sign`: the user
241
+ * sees a prompt, so call this only where that is expected.
211
242
  */
212
- signTransaction(unsignedTx: UnsignedTransaction, privateKey: string, expected?: ExpectedTx): Promise<Signature & {
243
+ signTransaction(unsignedTx: UnsignedTransaction, privateKey: string | Signer, expected?: ExpectedTx): Promise<Signature & {
213
244
  rawTransaction: string;
214
245
  txHash: string;
215
246
  }>;
package/dist/sdk.js CHANGED
@@ -54,12 +54,13 @@ const globalTokenCache = new Map();
54
54
  */
55
55
  const inFlightTokenRequests = new Map();
56
56
  /**
57
- * Module-global private-key store keyed by `publicKey` (parallel to the JWT cache).
57
+ * Module-global signer store keyed by `publicKey` (parallel to the JWT cache).
58
58
  * `generateToken` requires proof of key ownership (a signed challenge), so token issuance
59
- * needs the wallet's private key. Keys are kept in memory only and are **never** sent to
60
- * the Hub API — only the challenge signature is.
59
+ * needs a signer for the account: a private key held in memory (`createLocalSigner`) or a
60
+ * wallet that signs for it (`createWalletSigner`). A key is kept in memory only and is
61
+ * **never** sent to the Hub API — only the challenge signature is.
61
62
  */
62
- const globalPrivateKeys = new Map();
63
+ const globalSigners = new Map();
63
64
  /** Prefix of the canonical proof-of-key-ownership message signed for `generateToken`. */
64
65
  export const AUTH_CHALLENGE_PREFIX = 'clutch-auth';
65
66
  /**
@@ -106,6 +107,30 @@ async function signHashHex(hashHex, privateKey) {
106
107
  export async function signAuthChallenge(chainId, publicKey, timestamp, privateKey) {
107
108
  return signHashHex(authChallengeHashHex(chainId, publicKey, timestamp), privateKey);
108
109
  }
110
+ /** The address of a private key: `0x` and 40 lowercase hex characters. */
111
+ export function addressFromPrivateKey(privateKey) {
112
+ const publicKey = secp.getPublicKey(stripHexPrefix(privateKey), false); // 0x04 || X || Y
113
+ return '0x' + Buffer.from(keccak_256(publicKey.slice(1)).slice(-20)).toString('hex');
114
+ }
115
+ /**
116
+ * A signer for a private key held in memory. It signs the hash string, as the SDK always has.
117
+ * A wallet cannot hand over its key: use `createWalletSigner` for that.
118
+ */
119
+ export function createLocalSigner(privateKey) {
120
+ return {
121
+ // A getter, so that a malformed key still fails when something is signed, as it always did,
122
+ // and not when the SDK is built.
123
+ get address() {
124
+ return addressFromPrivateKey(privateKey);
125
+ },
126
+ signTransaction: ({ hashHex }) => signHashHex(hashHex, privateKey),
127
+ signAuthChallenge: ({ hashHex }) => signHashHex(hashHex, privateKey),
128
+ };
129
+ }
130
+ /** Everywhere the SDK takes a private key it also takes a signer. */
131
+ function toSigner(keyOrSigner) {
132
+ return typeof keyOrSigner === 'string' ? createLocalSigner(keyOrSigner) : keyOrSigner;
133
+ }
109
134
  /**
110
135
  * One graphql-ws connection per hub URL + wallet; multiplex all subscriptions on it.
111
136
  * Without this, each subscribe* call opened a new socket (`lazy: false`), which explodes
@@ -120,9 +145,9 @@ function sharedGraphqlWsCacheKey(baseURL, publicKey) {
120
145
  * via the `generateToken` mutation when needed. Shared by `ensureAuth` and the WebSocket
121
146
  * `connectionParams` so all SDK instances and subscriptions share tokens.
122
147
  *
123
- * Token issuance signs the proof-of-key-ownership challenge, so a private key for
124
- * `publicKey` must have been provided (constructor or `setPrivateKey`) unless a cached
125
- * token is still valid.
148
+ * Token issuance signs the proof-of-key-ownership challenge, so a signer (or a private key)
149
+ * for `publicKey` must have been provided (constructor, `setPrivateKey` or `setSigner`)
150
+ * unless a cached token is still valid. With a wallet this is the prompt the user sees.
126
151
  */
127
152
  async function ensureTokenInCacheForPublicKey(publicKey, apiClient, chainId) {
128
153
  const now = Date.now();
@@ -135,9 +160,9 @@ async function ensureTokenInCacheForPublicKey(publicKey, apiClient, chainId) {
135
160
  if (existingInFlight) {
136
161
  return existingInFlight;
137
162
  }
138
- const privateKey = globalPrivateKeys.get(publicKey);
139
- if (!privateKey) {
140
- throw new Error(`ClutchHubSdk: generateToken requires proof of key ownership; provide the private key for ${publicKey} via the ClutchHubSdk constructor or setPrivateKey().`);
163
+ const signer = globalSigners.get(publicKey);
164
+ if (!signer) {
165
+ throw new Error(`ClutchHubSdk: generateToken requires proof of key ownership; provide the private key (or a signer) for ${publicKey} via the ClutchHubSdk constructor, setPrivateKey() or setSigner().`);
141
166
  }
142
167
  const query = `
143
168
  mutation GenerateToken($publicKey: String!, $timestamp: Int!, $signature: AuthSignatureInput!) {
@@ -149,7 +174,10 @@ async function ensureTokenInCacheForPublicKey(publicKey, apiClient, chainId) {
149
174
  `;
150
175
  const requestPromise = (async () => {
151
176
  const timestamp = Math.floor(Date.now() / 1000);
152
- const signature = await signAuthChallenge(chainId, publicKey, timestamp, privateKey);
177
+ const signature = await signer.signAuthChallenge({
178
+ message: buildAuthChallengeMessage(chainId, publicKey, timestamp),
179
+ hashHex: authChallengeHashHex(chainId, publicKey, timestamp),
180
+ });
153
181
  const response = await apiClient.post('/graphql', {
154
182
  query,
155
183
  variables: {
@@ -274,9 +302,10 @@ export class ClutchHubSdk {
274
302
  /**
275
303
  * @param apiUrl Hub API base URL.
276
304
  * @param publicKey Wallet address (0x + 40 hex) or uncompressed public key (130 hex).
277
- * @param privateKey Optional wallet private key, required to obtain JWTs: `generateToken`
278
- * demands a signed proof-of-key-ownership challenge. May also be provided later via
279
- * {@link setPrivateKey}. Never sent to the API — only used for local signing.
305
+ * @param privateKey Optional private key, or a {@link Signer} (for example a wallet's, see
306
+ * `createWalletSigner`), required to obtain JWTs: `generateToken` demands a signed
307
+ * proof-of-key-ownership challenge. May also be provided later via {@link setPrivateKey}
308
+ * or {@link setSigner}. A key is never sent to the API — only used for local signing.
280
309
  * @param chainId This chain's id (e.g. 2077 for the app's own config), used for the
281
310
  * chain-bound auth challenge and as the default `expected.chainId` pin in
282
311
  * {@link signTransaction}'s `verifyUnsignedTransaction` check. Get this from app config,
@@ -298,7 +327,7 @@ export class ClutchHubSdk {
298
327
  this.chainId = chainId ?? 0;
299
328
  this.chainIdConfigured = chainId !== undefined;
300
329
  if (privateKey) {
301
- globalPrivateKeys.set(publicKey, privateKey);
330
+ globalSigners.set(publicKey, toSigner(privateKey));
302
331
  }
303
332
  }
304
333
  /**
@@ -309,13 +338,17 @@ export class ClutchHubSdk {
309
338
  return this.publicKey;
310
339
  }
311
340
  /**
312
- * Provide (or replace) the private key used to sign `generateToken` auth challenges for
313
- * this SDK's public key. Stored in a module-global map keyed by publicKey — like the JWT
314
- * cache — so every SDK instance and shared WebSocket connection for this wallet can
315
- * authenticate. In-memory only; never sent to the API.
341
+ * Provide (or replace) the private key — or the {@link Signer} — used to sign `generateToken`
342
+ * auth challenges for this SDK's public key. Stored in a module-global map keyed by
343
+ * publicKey — like the JWT cache — so every SDK instance and shared WebSocket connection for
344
+ * this wallet can authenticate. In-memory only; a key is never sent to the API.
316
345
  */
317
346
  setPrivateKey(privateKey) {
318
- globalPrivateKeys.set(this.publicKey, privateKey);
347
+ globalSigners.set(this.publicKey, toSigner(privateKey));
348
+ }
349
+ /** Same as {@link setPrivateKey}, for a signer such as a wallet's (`createWalletSigner`). */
350
+ setSigner(signer) {
351
+ globalSigners.set(this.publicKey, signer);
319
352
  }
320
353
  /**
321
354
  * Check if the SDK is currently authenticated.
@@ -326,6 +359,16 @@ export class ClutchHubSdk {
326
359
  const bufferTime = 30000; // 30 seconds
327
360
  return !!(this.token && now < (this.tokenExpireTime - bufferTime));
328
361
  }
362
+ /**
363
+ * True when a token for this account is cached and still good, so the next authenticated call
364
+ * opens no sign-in prompt. `isAuthenticated` looks at this instance only; this looks at the
365
+ * cache that every instance of the account shares. A poll should check it first: a wallet's
366
+ * sign-in prompt belongs to something the user did, never to a timer.
367
+ */
368
+ hasValidToken() {
369
+ const cached = globalTokenCache.get(this.publicKey);
370
+ return !!cached && Date.now() < cached.expireTimeMs - 30000;
371
+ }
329
372
  get authHeaders() {
330
373
  return this.token ? { Authorization: `Bearer ${this.token}` } : {};
331
374
  }
@@ -351,21 +394,9 @@ export class ClutchHubSdk {
351
394
  const key = sharedGraphqlWsCacheKey(base, this.publicKey);
352
395
  let entry = sharedGraphqlWsClients.get(key);
353
396
  if (!entry) {
354
- const pk = this.publicKey;
355
- const apiClient = this.apiClient;
356
- const chainId = this.chainId;
357
397
  const client = createHubSubscriptionClient({
358
398
  url: hubGraphqlWsUrl(base),
359
- connectionParams: async () => {
360
- try {
361
- await ensureTokenInCacheForPublicKey(pk, apiClient, chainId);
362
- }
363
- catch {
364
- /* public list subscriptions work without JWT */
365
- }
366
- const c = globalTokenCache.get(pk);
367
- return c?.token ? { Authorization: `Bearer ${c.token}` } : {};
368
- },
399
+ connectionParams: () => this.wsConnectionParams(),
369
400
  });
370
401
  entry = { client, refcount: 0 };
371
402
  sharedGraphqlWsClients.set(key, entry);
@@ -384,6 +415,29 @@ export class ClutchHubSdk {
384
415
  };
385
416
  return { client: entry.client, release };
386
417
  }
418
+ /**
419
+ * The `connection_init` payload of the shared socket: a token when there is one to send.
420
+ *
421
+ * The subscriptions are public, so a token is optional here. This runs at every connect and
422
+ * every reconnect (the socket retries for ever), with nobody watching, so a signer that opens a
423
+ * prompt (a wallet) is not asked for a signature: a token from an earlier, explicit action is
424
+ * still sent. A key signs silently and is asked as before.
425
+ */
426
+ async wsConnectionParams() {
427
+ const pk = this.publicKey;
428
+ if (!globalSigners.get(pk)?.interactive) {
429
+ try {
430
+ await ensureTokenInCacheForPublicKey(pk, this.apiClient, this.chainId);
431
+ }
432
+ catch {
433
+ /* public list subscriptions work without JWT */
434
+ }
435
+ }
436
+ const cached = globalTokenCache.get(pk);
437
+ return cached?.token && Date.now() < cached.expireTimeMs
438
+ ? { Authorization: `Bearer ${cached.token}` }
439
+ : {};
440
+ }
387
441
  /**
388
442
  * Shared graphql-ws list subscription: one multiplexed client via {@link acquireGraphqlWsClient}.
389
443
  */
@@ -579,6 +633,10 @@ export class ClutchHubSdk {
579
633
  }
580
634
  /**
581
635
  * Signs a transaction and returns the signature and raw RLP-encoded payload.
636
+ *
637
+ * `privateKey` is a private key, or a {@link Signer}. A key signs the hash string. A wallet's
638
+ * signer asks the wallet to sign `clutch-tx:{chainId}:{hash}` with `personal_sign`: the user
639
+ * sees a prompt, so call this only where that is expected.
582
640
  */
583
641
  async signTransaction(unsignedTx, privateKey, expected) {
584
642
  if (expected) {
@@ -619,7 +677,10 @@ export class ClutchHubSdk {
619
677
  const hashBytes = keccak_256(unsignedPayload);
620
678
  const rawHashHex = Buffer.from(hashBytes).toString('hex');
621
679
  // Sign the transaction hash
622
- const signature = await signHashHex(rawHashHex, privateKey);
680
+ const signature = await toSigner(privateKey).signTransaction({
681
+ hashHex: rawHashHex,
682
+ chainId: unsignedTx.chain_id,
683
+ });
623
684
  const rNo0x = stripHexPrefix(signature.r);
624
685
  const sNo0x = stripHexPrefix(signature.s);
625
686
  // RLP-encode full signed transaction to match Rust: [from, nonce, chain_id, r, s, v, hash, data]
@@ -0,0 +1,85 @@
1
+ import type { Signature } from './types.js';
2
+ /** What a signer is asked to sign to send a transaction. */
3
+ export interface TransactionSigningRequest {
4
+ /** Keccak-256 of the unsigned transaction: 64 lowercase hex characters, no `0x`. */
5
+ hashHex: string;
6
+ /** The chain the transaction is for. A wallet's prompt names it. */
7
+ chainId: number;
8
+ }
9
+ /** What a signer is asked to sign to prove it owns a key to the Hub API. */
10
+ export interface AuthChallengeSigningRequest {
11
+ /** The readable challenge, `clutch-auth:{chainId}:{publicKey}:{timestamp}`. */
12
+ message: string;
13
+ /** Keccak-256 of `message`: 64 lowercase hex characters, no `0x`. */
14
+ hashHex: string;
15
+ }
16
+ /** Something that can sign for one account. */
17
+ export interface Signer {
18
+ /** The account this signer signs for: `0x` and 40 lowercase hex characters. */
19
+ readonly address: string;
20
+ /**
21
+ * True when signing opens a prompt for a person (a wallet). The SDK never asks such a signer for
22
+ * a signature in the background, for example when a subscription reconnects; only a call the
23
+ * app makes (create or sign a transaction) can open a prompt.
24
+ */
25
+ readonly interactive?: boolean;
26
+ /** Sign a transaction. A key signs the hash string; a wallet signs `walletTransactionText`. */
27
+ signTransaction(request: TransactionSigningRequest): Promise<Signature>;
28
+ /** Sign the Hub API's proof-of-key-ownership challenge. A key signs its hash; a wallet signs the message. */
29
+ signAuthChallenge(request: AuthChallengeSigningRequest): Promise<Signature>;
30
+ }
31
+ /**
32
+ * The part of an EIP-1193 provider (`window.ethereum`, an EIP-6963 provider) that the SDK uses.
33
+ * `on` and `removeListener` are there for apps that watch `accountsChanged`.
34
+ */
35
+ export interface Eip1193Provider {
36
+ request(args: {
37
+ method: string;
38
+ params?: unknown[];
39
+ }): Promise<unknown>;
40
+ on?(event: string, listener: (...args: any[]) => void): void;
41
+ removeListener?(event: string, listener: (...args: any[]) => void): void;
42
+ }
43
+ /** The text a wallet signs for a transaction. The hash is written without `0x`, in lower case. */
44
+ export declare function walletTransactionText(chainId: number, hashHex: string): string;
45
+ /** What `personal_sign` hashes and signs: `Keccak256("\x19Ethereum Signed Message:\n" + length + text)`. */
46
+ export declare function personalSignDigest(text: string): Uint8Array;
47
+ /**
48
+ * A signer that asks a wallet to sign with `personal_sign`. The wallet shows the user the text,
49
+ * and keeps the key. `address` is the account to sign for; it is lowercased, because the hash of a
50
+ * transaction commits to `from` and the node reads `from` in lower case.
51
+ *
52
+ * The signature is checked here before it is returned: a wallet that signs with another account
53
+ * (the user switched accounts) would otherwise be found out later, as a refusal from the node
54
+ * that does not say why.
55
+ */
56
+ export declare function createWalletSigner(provider: Eip1193Provider, address: string): Signer;
57
+ /** A wallet found in the page. */
58
+ export interface InjectedWallet {
59
+ /** The EIP-6963 `rdns` (for example `io.metamask`), or `injected-0` for a bare `window.ethereum`. */
60
+ id: string;
61
+ name: string;
62
+ /** A `data:` image address from the wallet, when it announced one. */
63
+ icon?: string;
64
+ provider: Eip1193Provider;
65
+ }
66
+ export interface WalletDiscoveryOptions {
67
+ /** Where to look. Default: `window`. */
68
+ host?: EventTarget & {
69
+ ethereum?: unknown;
70
+ };
71
+ /** How long to listen for EIP-6963 announcements, in milliseconds. Default: 300. */
72
+ timeoutMs?: number;
73
+ }
74
+ /**
75
+ * The wallets in this page. A wallet that follows EIP-6963 announces itself, so several can be
76
+ * listed side by side; one that only sets `window.ethereum` (older wallets, some in-app browsers)
77
+ * is added when no announced wallet is that same provider. Returns an empty list when there is no
78
+ * wallet, or when there is no page (Node).
79
+ */
80
+ export declare function discoverInjectedWallets(options?: WalletDiscoveryOptions): Promise<InjectedWallet[]>;
81
+ /**
82
+ * Ask a wallet to share its account (the wallet shows its own prompt) and return a signer for it.
83
+ * Rejects when the user says no (the provider's error, code 4001) or when no account is shared.
84
+ */
85
+ export declare function connectWallet(wallet: InjectedWallet): Promise<Signer>;
@@ -0,0 +1,161 @@
1
+ import { Buffer } from 'buffer';
2
+ import { keccak_256 } from '@noble/hashes/sha3';
3
+ import * as secp from '@noble/secp256k1';
4
+ /*
5
+ * Who signs a transaction.
6
+ *
7
+ * The SDK used to take a private key string and sign with it. A wallet (MetaMask, Trust Wallet)
8
+ * never gives its key to a page, so the SDK now takes a `Signer`: an object that can be asked for
9
+ * a signature. A key in memory is one kind of signer (`createLocalSigner`, in sdk.ts); a wallet is
10
+ * another (`createWalletSigner`, here). Everywhere the SDK took a key it still takes a key string.
11
+ *
12
+ * A wallet will not sign a bare hash, so it signs a short readable text with `personal_sign`
13
+ * (EIP-191), which hashes `"\x19Ethereum Signed Message:\n" + length + text`. The node and the Hub
14
+ * API accept that signature next to the old one. The texts below are the contract with them:
15
+ *
16
+ * transaction clutch-tx:{chainId}:{hash} hash = 64 lowercase hex, no 0x
17
+ * login clutch-auth:{chainId}:{publicKey}:{timestamp}
18
+ */
19
+ /** Strip a 0x/0X prefix. A copy of `stripHexPrefix` in sdk.ts, which imports this file. */
20
+ function strip0x(hex) {
21
+ return hex.replace(/^0x/i, '');
22
+ }
23
+ /** The text a wallet signs for a transaction. The hash is written without `0x`, in lower case. */
24
+ export function walletTransactionText(chainId, hashHex) {
25
+ return `clutch-tx:${chainId}:${strip0x(hashHex).toLowerCase()}`;
26
+ }
27
+ /** What `personal_sign` hashes and signs: `Keccak256("\x19Ethereum Signed Message:\n" + length + text)`. */
28
+ export function personalSignDigest(text) {
29
+ const body = Buffer.from(text, 'utf8');
30
+ const prefix = Buffer.from(`\x19Ethereum Signed Message:\n${body.length}`, 'utf8');
31
+ return keccak_256(Buffer.concat([prefix, body]));
32
+ }
33
+ /**
34
+ * `r`, `s` and `v` from a wallet's answer: `0x` and 130 hex characters (65 bytes). Some wallets
35
+ * answer with a recovery id of 0 or 1 instead of 27 or 28; the node reads only 27 and 28.
36
+ */
37
+ function parseWalletSignature(raw) {
38
+ if (typeof raw !== 'string' || !/^0x[0-9a-fA-F]{130}$/.test(raw)) {
39
+ throw new Error('the wallet answered with a signature the SDK cannot read (expected 65 bytes of hex)');
40
+ }
41
+ const hex = raw.slice(2).toLowerCase();
42
+ let v = parseInt(hex.slice(128, 130), 16);
43
+ if (v < 27) {
44
+ v += 27;
45
+ }
46
+ if (v !== 27 && v !== 28) {
47
+ throw new Error(`the wallet answered with an unexpected recovery id (${v})`);
48
+ }
49
+ return { r: '0x' + hex.slice(0, 64), s: '0x' + hex.slice(64, 128), v };
50
+ }
51
+ /** The address a signature over `digest` came from, or `null` when it does not recover to one. */
52
+ function recoverAddress(digest, signature) {
53
+ try {
54
+ const compact = strip0x(signature.r).padStart(64, '0') + strip0x(signature.s).padStart(64, '0');
55
+ const point = secp.Signature.fromCompact(compact)
56
+ .addRecoveryBit(signature.v - 27)
57
+ .recoverPublicKey(digest);
58
+ return '0x' + Buffer.from(keccak_256(point.toRawBytes(false).slice(1)).slice(-20)).toString('hex');
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * A signer that asks a wallet to sign with `personal_sign`. The wallet shows the user the text,
66
+ * and keeps the key. `address` is the account to sign for; it is lowercased, because the hash of a
67
+ * transaction commits to `from` and the node reads `from` in lower case.
68
+ *
69
+ * The signature is checked here before it is returned: a wallet that signs with another account
70
+ * (the user switched accounts) would otherwise be found out later, as a refusal from the node
71
+ * that does not say why.
72
+ */
73
+ export function createWalletSigner(provider, address) {
74
+ const account = address.toLowerCase();
75
+ async function personalSign(text) {
76
+ // Always hex: a text that starts with "0x" would be read by the wallet as bytes, not text.
77
+ const message = '0x' + Buffer.from(text, 'utf8').toString('hex');
78
+ const raw = await provider.request({ method: 'personal_sign', params: [message, account] });
79
+ const signature = parseWalletSignature(raw);
80
+ const signedBy = recoverAddress(personalSignDigest(text), signature);
81
+ if (signedBy !== account) {
82
+ throw new Error(`the wallet signed with ${signedBy ?? 'an account that cannot be read'}, not ${account}: switch to ${account} in the wallet and try again`);
83
+ }
84
+ return signature;
85
+ }
86
+ return {
87
+ address: account,
88
+ interactive: true,
89
+ signTransaction: ({ hashHex, chainId }) => personalSign(walletTransactionText(chainId, hashHex)),
90
+ signAuthChallenge: ({ message }) => personalSign(message),
91
+ };
92
+ }
93
+ function isProvider(value) {
94
+ return !!value && typeof value.request === 'function';
95
+ }
96
+ /** A name for a provider that did not announce one. Trust Wallet sets `isMetaMask` too, so it is checked first. */
97
+ function legacyName(provider) {
98
+ const flags = provider;
99
+ if (flags.isTrust || flags.isTrustWallet)
100
+ return 'Trust Wallet';
101
+ if (flags.isMetaMask)
102
+ return 'MetaMask';
103
+ if (flags.isCoinbaseWallet)
104
+ return 'Coinbase Wallet';
105
+ return 'Browser wallet';
106
+ }
107
+ /**
108
+ * The wallets in this page. A wallet that follows EIP-6963 announces itself, so several can be
109
+ * listed side by side; one that only sets `window.ethereum` (older wallets, some in-app browsers)
110
+ * is added when no announced wallet is that same provider. Returns an empty list when there is no
111
+ * wallet, or when there is no page (Node).
112
+ */
113
+ export async function discoverInjectedWallets(options = {}) {
114
+ const host = options.host ?? (typeof window !== 'undefined' ? window : undefined);
115
+ if (!host) {
116
+ return [];
117
+ }
118
+ const found = new Map();
119
+ const onAnnounce = (event) => {
120
+ const detail = event.detail;
121
+ if (!detail || !isProvider(detail.provider)) {
122
+ return;
123
+ }
124
+ const info = detail.info ?? {};
125
+ const id = info.rdns || info.uuid || info.name || `announced-${found.size}`;
126
+ if (!found.has(id)) {
127
+ found.set(id, { id, name: info.name || id, icon: info.icon, provider: detail.provider });
128
+ }
129
+ };
130
+ host.addEventListener('eip6963:announceProvider', onAnnounce);
131
+ try {
132
+ host.dispatchEvent(new Event('eip6963:requestProvider'));
133
+ await new Promise((resolve) => setTimeout(resolve, options.timeoutMs ?? 300));
134
+ }
135
+ finally {
136
+ host.removeEventListener('eip6963:announceProvider', onAnnounce);
137
+ }
138
+ const wallets = [...found.values()];
139
+ const ethereum = host.ethereum;
140
+ if (isProvider(ethereum)) {
141
+ const list = Array.isArray(ethereum.providers) && ethereum.providers.length > 0 ? ethereum.providers : [ethereum];
142
+ list.filter(isProvider).forEach((provider, index) => {
143
+ if (!wallets.some((wallet) => wallet.provider === provider)) {
144
+ wallets.push({ id: `injected-${index}`, name: legacyName(provider), provider });
145
+ }
146
+ });
147
+ }
148
+ return wallets;
149
+ }
150
+ /**
151
+ * Ask a wallet to share its account (the wallet shows its own prompt) and return a signer for it.
152
+ * Rejects when the user says no (the provider's error, code 4001) or when no account is shared.
153
+ */
154
+ export async function connectWallet(wallet) {
155
+ const accounts = await wallet.provider.request({ method: 'eth_requestAccounts' });
156
+ const first = Array.isArray(accounts) ? accounts[0] : undefined;
157
+ if (typeof first !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(first)) {
158
+ throw new Error('the wallet did not share an account');
159
+ }
160
+ return createWalletSigner(wallet.provider, first);
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clutch-hub-sdk-js",
3
- "version": "4.2.1",
3
+ "version": "4.3.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",
@@ -26,13 +26,13 @@
26
26
  "license": "MIT",
27
27
  "repository": {
28
28
  "type": "git",
29
- "url": "https://github.com/clutchprotocol/clutch-hub-sdk-js.git",
29
+ "url": "https://github.com/clutchprotocol/clutch-hub.git",
30
30
  "directory": "packages/sdk"
31
31
  },
32
32
  "bugs": {
33
- "url": "https://github.com/clutchprotocol/clutch-hub-sdk-js/issues"
33
+ "url": "https://github.com/clutchprotocol/clutch-hub/issues"
34
34
  },
35
- "homepage": "https://github.com/clutchprotocol/clutch-hub-sdk-js/tree/main/packages/sdk#readme",
35
+ "homepage": "https://github.com/clutchprotocol/clutch-hub/tree/main/packages/sdk#readme",
36
36
  "dependencies": {
37
37
  "@noble/hashes": "^1.8.0",
38
38
  "@noble/secp256k1": "^2.2.3",
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './types.js';
2
2
  export * from './sdk.js';
3
+ export * from './signers.js';
3
4
  export {
4
5
  hubGraphqlWsUrl,
5
6
  RIDE_REQUEST_GQL_FIELDS,
package/src/sdk.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  RideRequestCancelArgs,
30
30
  Signature,
31
31
  } from './types.js';
32
+ import type { Signer } from './signers.js';
32
33
 
33
34
  /** Strip 0x/0X prefix - hex parsers (e.g. @noble/secp256k1) do not accept it. Exported for consumers. */
34
35
  export function stripHexPrefix(hex: string): string {
@@ -106,12 +107,13 @@ const globalTokenCache = new Map<string, TokenCacheEntry>();
106
107
  const inFlightTokenRequests = new Map<string, Promise<TokenCacheEntry>>();
107
108
 
108
109
  /**
109
- * Module-global private-key store keyed by `publicKey` (parallel to the JWT cache).
110
+ * Module-global signer store keyed by `publicKey` (parallel to the JWT cache).
110
111
  * `generateToken` requires proof of key ownership (a signed challenge), so token issuance
111
- * needs the wallet's private key. Keys are kept in memory only and are **never** sent to
112
- * the Hub API — only the challenge signature is.
112
+ * needs a signer for the account: a private key held in memory (`createLocalSigner`) or a
113
+ * wallet that signs for it (`createWalletSigner`). A key is kept in memory only and is
114
+ * **never** sent to the Hub API — only the challenge signature is.
113
115
  */
114
- const globalPrivateKeys = new Map<string, string>();
116
+ const globalSigners = new Map<string, Signer>();
115
117
 
116
118
  /** Prefix of the canonical proof-of-key-ownership message signed for `generateToken`. */
117
119
  export const AUTH_CHALLENGE_PREFIX = 'clutch-auth';
@@ -169,6 +171,33 @@ export async function signAuthChallenge(
169
171
  return signHashHex(authChallengeHashHex(chainId, publicKey, timestamp), privateKey);
170
172
  }
171
173
 
174
+ /** The address of a private key: `0x` and 40 lowercase hex characters. */
175
+ export function addressFromPrivateKey(privateKey: string): string {
176
+ const publicKey = secp.getPublicKey(stripHexPrefix(privateKey), false); // 0x04 || X || Y
177
+ return '0x' + Buffer.from(keccak_256(publicKey.slice(1)).slice(-20)).toString('hex');
178
+ }
179
+
180
+ /**
181
+ * A signer for a private key held in memory. It signs the hash string, as the SDK always has.
182
+ * A wallet cannot hand over its key: use `createWalletSigner` for that.
183
+ */
184
+ export function createLocalSigner(privateKey: string): Signer {
185
+ return {
186
+ // A getter, so that a malformed key still fails when something is signed, as it always did,
187
+ // and not when the SDK is built.
188
+ get address() {
189
+ return addressFromPrivateKey(privateKey);
190
+ },
191
+ signTransaction: ({ hashHex }) => signHashHex(hashHex, privateKey),
192
+ signAuthChallenge: ({ hashHex }) => signHashHex(hashHex, privateKey),
193
+ };
194
+ }
195
+
196
+ /** Everywhere the SDK takes a private key it also takes a signer. */
197
+ function toSigner(keyOrSigner: string | Signer): Signer {
198
+ return typeof keyOrSigner === 'string' ? createLocalSigner(keyOrSigner) : keyOrSigner;
199
+ }
200
+
172
201
  type SharedGraphqlWsEntry = { client: Client; refcount: number };
173
202
 
174
203
  /**
@@ -187,9 +216,9 @@ function sharedGraphqlWsCacheKey(baseURL: string, publicKey: string): string {
187
216
  * via the `generateToken` mutation when needed. Shared by `ensureAuth` and the WebSocket
188
217
  * `connectionParams` so all SDK instances and subscriptions share tokens.
189
218
  *
190
- * Token issuance signs the proof-of-key-ownership challenge, so a private key for
191
- * `publicKey` must have been provided (constructor or `setPrivateKey`) unless a cached
192
- * token is still valid.
219
+ * Token issuance signs the proof-of-key-ownership challenge, so a signer (or a private key)
220
+ * for `publicKey` must have been provided (constructor, `setPrivateKey` or `setSigner`)
221
+ * unless a cached token is still valid. With a wallet this is the prompt the user sees.
193
222
  */
194
223
  async function ensureTokenInCacheForPublicKey(
195
224
  publicKey: string,
@@ -209,10 +238,10 @@ async function ensureTokenInCacheForPublicKey(
209
238
  return existingInFlight;
210
239
  }
211
240
 
212
- const privateKey = globalPrivateKeys.get(publicKey);
213
- if (!privateKey) {
241
+ const signer = globalSigners.get(publicKey);
242
+ if (!signer) {
214
243
  throw new Error(
215
- `ClutchHubSdk: generateToken requires proof of key ownership; provide the private key for ${publicKey} via the ClutchHubSdk constructor or setPrivateKey().`
244
+ `ClutchHubSdk: generateToken requires proof of key ownership; provide the private key (or a signer) for ${publicKey} via the ClutchHubSdk constructor, setPrivateKey() or setSigner().`
216
245
  );
217
246
  }
218
247
 
@@ -227,7 +256,10 @@ async function ensureTokenInCacheForPublicKey(
227
256
 
228
257
  const requestPromise: Promise<TokenCacheEntry> = (async () => {
229
258
  const timestamp = Math.floor(Date.now() / 1000);
230
- const signature = await signAuthChallenge(chainId, publicKey, timestamp, privateKey);
259
+ const signature = await signer.signAuthChallenge({
260
+ message: buildAuthChallengeMessage(chainId, publicKey, timestamp),
261
+ hashHex: authChallengeHashHex(chainId, publicKey, timestamp),
262
+ });
231
263
  const response = await apiClient.post<{ data?: unknown; errors?: { message: string }[] }>(
232
264
  '/graphql',
233
265
  {
@@ -426,9 +458,10 @@ export class ClutchHubSdk {
426
458
  /**
427
459
  * @param apiUrl Hub API base URL.
428
460
  * @param publicKey Wallet address (0x + 40 hex) or uncompressed public key (130 hex).
429
- * @param privateKey Optional wallet private key, required to obtain JWTs: `generateToken`
430
- * demands a signed proof-of-key-ownership challenge. May also be provided later via
431
- * {@link setPrivateKey}. Never sent to the API — only used for local signing.
461
+ * @param privateKey Optional private key, or a {@link Signer} (for example a wallet's, see
462
+ * `createWalletSigner`), required to obtain JWTs: `generateToken` demands a signed
463
+ * proof-of-key-ownership challenge. May also be provided later via {@link setPrivateKey}
464
+ * or {@link setSigner}. A key is never sent to the API — only used for local signing.
432
465
  * @param chainId This chain's id (e.g. 2077 for the app's own config), used for the
433
466
  * chain-bound auth challenge and as the default `expected.chainId` pin in
434
467
  * {@link signTransaction}'s `verifyUnsignedTransaction` check. Get this from app config,
@@ -442,7 +475,7 @@ export class ClutchHubSdk {
442
475
  constructor(
443
476
  apiUrl: string,
444
477
  publicKey: string,
445
- privateKey?: string,
478
+ privateKey?: string | Signer,
446
479
  chainId?: number,
447
480
  options: ClutchHubSdkOptions = {}
448
481
  ) {
@@ -454,7 +487,7 @@ export class ClutchHubSdk {
454
487
  this.chainId = chainId ?? 0;
455
488
  this.chainIdConfigured = chainId !== undefined;
456
489
  if (privateKey) {
457
- globalPrivateKeys.set(publicKey, privateKey);
490
+ globalSigners.set(publicKey, toSigner(privateKey));
458
491
  }
459
492
  }
460
493
 
@@ -467,13 +500,18 @@ export class ClutchHubSdk {
467
500
  }
468
501
 
469
502
  /**
470
- * Provide (or replace) the private key used to sign `generateToken` auth challenges for
471
- * this SDK's public key. Stored in a module-global map keyed by publicKey — like the JWT
472
- * cache — so every SDK instance and shared WebSocket connection for this wallet can
473
- * authenticate. In-memory only; never sent to the API.
503
+ * Provide (or replace) the private key — or the {@link Signer} — used to sign `generateToken`
504
+ * auth challenges for this SDK's public key. Stored in a module-global map keyed by
505
+ * publicKey — like the JWT cache — so every SDK instance and shared WebSocket connection for
506
+ * this wallet can authenticate. In-memory only; a key is never sent to the API.
474
507
  */
475
- public setPrivateKey(privateKey: string): void {
476
- globalPrivateKeys.set(this.publicKey, privateKey);
508
+ public setPrivateKey(privateKey: string | Signer): void {
509
+ globalSigners.set(this.publicKey, toSigner(privateKey));
510
+ }
511
+
512
+ /** Same as {@link setPrivateKey}, for a signer such as a wallet's (`createWalletSigner`). */
513
+ public setSigner(signer: Signer): void {
514
+ globalSigners.set(this.publicKey, signer);
477
515
  }
478
516
 
479
517
  /**
@@ -486,6 +524,17 @@ export class ClutchHubSdk {
486
524
  return !!(this.token && now < (this.tokenExpireTime - bufferTime));
487
525
  }
488
526
 
527
+ /**
528
+ * True when a token for this account is cached and still good, so the next authenticated call
529
+ * opens no sign-in prompt. `isAuthenticated` looks at this instance only; this looks at the
530
+ * cache that every instance of the account shares. A poll should check it first: a wallet's
531
+ * sign-in prompt belongs to something the user did, never to a timer.
532
+ */
533
+ public hasValidToken(): boolean {
534
+ const cached = globalTokenCache.get(this.publicKey);
535
+ return !!cached && Date.now() < cached.expireTimeMs - 30000;
536
+ }
537
+
489
538
  private get authHeaders(): Record<string, string> {
490
539
  return this.token ? { Authorization: `Bearer ${this.token}` } : {};
491
540
  }
@@ -513,20 +562,9 @@ export class ClutchHubSdk {
513
562
  const key = sharedGraphqlWsCacheKey(base, this.publicKey);
514
563
  let entry = sharedGraphqlWsClients.get(key);
515
564
  if (!entry) {
516
- const pk = this.publicKey;
517
- const apiClient = this.apiClient;
518
- const chainId = this.chainId;
519
565
  const client = createHubSubscriptionClient({
520
566
  url: hubGraphqlWsUrl(base),
521
- connectionParams: async () => {
522
- try {
523
- await ensureTokenInCacheForPublicKey(pk, apiClient, chainId);
524
- } catch {
525
- /* public list subscriptions work without JWT */
526
- }
527
- const c = globalTokenCache.get(pk);
528
- return c?.token ? { Authorization: `Bearer ${c.token}` } : {};
529
- },
567
+ connectionParams: () => this.wsConnectionParams(),
530
568
  });
531
569
  entry = { client, refcount: 0 };
532
570
  sharedGraphqlWsClients.set(key, entry);
@@ -546,6 +584,29 @@ export class ClutchHubSdk {
546
584
  return { client: entry.client, release };
547
585
  }
548
586
 
587
+ /**
588
+ * The `connection_init` payload of the shared socket: a token when there is one to send.
589
+ *
590
+ * The subscriptions are public, so a token is optional here. This runs at every connect and
591
+ * every reconnect (the socket retries for ever), with nobody watching, so a signer that opens a
592
+ * prompt (a wallet) is not asked for a signature: a token from an earlier, explicit action is
593
+ * still sent. A key signs silently and is asked as before.
594
+ */
595
+ private async wsConnectionParams(): Promise<Record<string, string>> {
596
+ const pk = this.publicKey;
597
+ if (!globalSigners.get(pk)?.interactive) {
598
+ try {
599
+ await ensureTokenInCacheForPublicKey(pk, this.apiClient, this.chainId);
600
+ } catch {
601
+ /* public list subscriptions work without JWT */
602
+ }
603
+ }
604
+ const cached = globalTokenCache.get(pk);
605
+ return cached?.token && Date.now() < cached.expireTimeMs
606
+ ? { Authorization: `Bearer ${cached.token}` }
607
+ : {};
608
+ }
609
+
549
610
  /**
550
611
  * Shared graphql-ws list subscription: one multiplexed client via {@link acquireGraphqlWsClient}.
551
612
  */
@@ -785,10 +846,14 @@ export class ClutchHubSdk {
785
846
 
786
847
  /**
787
848
  * Signs a transaction and returns the signature and raw RLP-encoded payload.
849
+ *
850
+ * `privateKey` is a private key, or a {@link Signer}. A key signs the hash string. A wallet's
851
+ * signer asks the wallet to sign `clutch-tx:{chainId}:{hash}` with `personal_sign`: the user
852
+ * sees a prompt, so call this only where that is expected.
788
853
  */
789
854
  public async signTransaction(
790
855
  unsignedTx: UnsignedTransaction,
791
- privateKey: string,
856
+ privateKey: string | Signer,
792
857
  expected?: ExpectedTx
793
858
  ): Promise<Signature & { rawTransaction: string, txHash: string }> {
794
859
  if (expected) {
@@ -835,7 +900,10 @@ export class ClutchHubSdk {
835
900
  const rawHashHex = Buffer.from(hashBytes).toString('hex');
836
901
 
837
902
  // Sign the transaction hash
838
- const signature = await signHashHex(rawHashHex, privateKey);
903
+ const signature = await toSigner(privateKey).signTransaction({
904
+ hashHex: rawHashHex,
905
+ chainId: unsignedTx.chain_id,
906
+ });
839
907
  const rNo0x = stripHexPrefix(signature.r);
840
908
  const sNo0x = stripHexPrefix(signature.s);
841
909
 
package/src/signers.ts ADDED
@@ -0,0 +1,235 @@
1
+ import { Buffer } from 'buffer';
2
+ import { keccak_256 } from '@noble/hashes/sha3';
3
+ import * as secp from '@noble/secp256k1';
4
+ import type { Signature } from './types.js';
5
+
6
+ /*
7
+ * Who signs a transaction.
8
+ *
9
+ * The SDK used to take a private key string and sign with it. A wallet (MetaMask, Trust Wallet)
10
+ * never gives its key to a page, so the SDK now takes a `Signer`: an object that can be asked for
11
+ * a signature. A key in memory is one kind of signer (`createLocalSigner`, in sdk.ts); a wallet is
12
+ * another (`createWalletSigner`, here). Everywhere the SDK took a key it still takes a key string.
13
+ *
14
+ * A wallet will not sign a bare hash, so it signs a short readable text with `personal_sign`
15
+ * (EIP-191), which hashes `"\x19Ethereum Signed Message:\n" + length + text`. The node and the Hub
16
+ * API accept that signature next to the old one. The texts below are the contract with them:
17
+ *
18
+ * transaction clutch-tx:{chainId}:{hash} hash = 64 lowercase hex, no 0x
19
+ * login clutch-auth:{chainId}:{publicKey}:{timestamp}
20
+ */
21
+
22
+ /** Strip a 0x/0X prefix. A copy of `stripHexPrefix` in sdk.ts, which imports this file. */
23
+ function strip0x(hex: string): string {
24
+ return hex.replace(/^0x/i, '');
25
+ }
26
+
27
+ /** What a signer is asked to sign to send a transaction. */
28
+ export interface TransactionSigningRequest {
29
+ /** Keccak-256 of the unsigned transaction: 64 lowercase hex characters, no `0x`. */
30
+ hashHex: string;
31
+ /** The chain the transaction is for. A wallet's prompt names it. */
32
+ chainId: number;
33
+ }
34
+
35
+ /** What a signer is asked to sign to prove it owns a key to the Hub API. */
36
+ export interface AuthChallengeSigningRequest {
37
+ /** The readable challenge, `clutch-auth:{chainId}:{publicKey}:{timestamp}`. */
38
+ message: string;
39
+ /** Keccak-256 of `message`: 64 lowercase hex characters, no `0x`. */
40
+ hashHex: string;
41
+ }
42
+
43
+ /** Something that can sign for one account. */
44
+ export interface Signer {
45
+ /** The account this signer signs for: `0x` and 40 lowercase hex characters. */
46
+ readonly address: string;
47
+ /**
48
+ * True when signing opens a prompt for a person (a wallet). The SDK never asks such a signer for
49
+ * a signature in the background, for example when a subscription reconnects; only a call the
50
+ * app makes (create or sign a transaction) can open a prompt.
51
+ */
52
+ readonly interactive?: boolean;
53
+ /** Sign a transaction. A key signs the hash string; a wallet signs `walletTransactionText`. */
54
+ signTransaction(request: TransactionSigningRequest): Promise<Signature>;
55
+ /** Sign the Hub API's proof-of-key-ownership challenge. A key signs its hash; a wallet signs the message. */
56
+ signAuthChallenge(request: AuthChallengeSigningRequest): Promise<Signature>;
57
+ }
58
+
59
+ /**
60
+ * The part of an EIP-1193 provider (`window.ethereum`, an EIP-6963 provider) that the SDK uses.
61
+ * `on` and `removeListener` are there for apps that watch `accountsChanged`.
62
+ */
63
+ export interface Eip1193Provider {
64
+ request(args: { method: string; params?: unknown[] }): Promise<unknown>;
65
+ on?(event: string, listener: (...args: any[]) => void): void;
66
+ removeListener?(event: string, listener: (...args: any[]) => void): void;
67
+ }
68
+
69
+ /** The text a wallet signs for a transaction. The hash is written without `0x`, in lower case. */
70
+ export function walletTransactionText(chainId: number, hashHex: string): string {
71
+ return `clutch-tx:${chainId}:${strip0x(hashHex).toLowerCase()}`;
72
+ }
73
+
74
+ /** What `personal_sign` hashes and signs: `Keccak256("\x19Ethereum Signed Message:\n" + length + text)`. */
75
+ export function personalSignDigest(text: string): Uint8Array {
76
+ const body = Buffer.from(text, 'utf8');
77
+ const prefix = Buffer.from(`\x19Ethereum Signed Message:\n${body.length}`, 'utf8');
78
+ return keccak_256(Buffer.concat([prefix, body]));
79
+ }
80
+
81
+ /**
82
+ * `r`, `s` and `v` from a wallet's answer: `0x` and 130 hex characters (65 bytes). Some wallets
83
+ * answer with a recovery id of 0 or 1 instead of 27 or 28; the node reads only 27 and 28.
84
+ */
85
+ function parseWalletSignature(raw: unknown): Signature {
86
+ if (typeof raw !== 'string' || !/^0x[0-9a-fA-F]{130}$/.test(raw)) {
87
+ throw new Error('the wallet answered with a signature the SDK cannot read (expected 65 bytes of hex)');
88
+ }
89
+ const hex = raw.slice(2).toLowerCase();
90
+ let v = parseInt(hex.slice(128, 130), 16);
91
+ if (v < 27) {
92
+ v += 27;
93
+ }
94
+ if (v !== 27 && v !== 28) {
95
+ throw new Error(`the wallet answered with an unexpected recovery id (${v})`);
96
+ }
97
+ return { r: '0x' + hex.slice(0, 64), s: '0x' + hex.slice(64, 128), v };
98
+ }
99
+
100
+ /** The address a signature over `digest` came from, or `null` when it does not recover to one. */
101
+ function recoverAddress(digest: Uint8Array, signature: Signature): string | null {
102
+ try {
103
+ const compact = strip0x(signature.r).padStart(64, '0') + strip0x(signature.s).padStart(64, '0');
104
+ const point = secp.Signature.fromCompact(compact)
105
+ .addRecoveryBit(signature.v - 27)
106
+ .recoverPublicKey(digest);
107
+ return '0x' + Buffer.from(keccak_256(point.toRawBytes(false).slice(1)).slice(-20)).toString('hex');
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * A signer that asks a wallet to sign with `personal_sign`. The wallet shows the user the text,
115
+ * and keeps the key. `address` is the account to sign for; it is lowercased, because the hash of a
116
+ * transaction commits to `from` and the node reads `from` in lower case.
117
+ *
118
+ * The signature is checked here before it is returned: a wallet that signs with another account
119
+ * (the user switched accounts) would otherwise be found out later, as a refusal from the node
120
+ * that does not say why.
121
+ */
122
+ export function createWalletSigner(provider: Eip1193Provider, address: string): Signer {
123
+ const account = address.toLowerCase();
124
+
125
+ async function personalSign(text: string): Promise<Signature> {
126
+ // Always hex: a text that starts with "0x" would be read by the wallet as bytes, not text.
127
+ const message = '0x' + Buffer.from(text, 'utf8').toString('hex');
128
+ const raw = await provider.request({ method: 'personal_sign', params: [message, account] });
129
+ const signature = parseWalletSignature(raw);
130
+ const signedBy = recoverAddress(personalSignDigest(text), signature);
131
+ if (signedBy !== account) {
132
+ throw new Error(
133
+ `the wallet signed with ${signedBy ?? 'an account that cannot be read'}, not ${account}: switch to ${account} in the wallet and try again`
134
+ );
135
+ }
136
+ return signature;
137
+ }
138
+
139
+ return {
140
+ address: account,
141
+ interactive: true,
142
+ signTransaction: ({ hashHex, chainId }) => personalSign(walletTransactionText(chainId, hashHex)),
143
+ signAuthChallenge: ({ message }) => personalSign(message),
144
+ };
145
+ }
146
+
147
+ /** A wallet found in the page. */
148
+ export interface InjectedWallet {
149
+ /** The EIP-6963 `rdns` (for example `io.metamask`), or `injected-0` for a bare `window.ethereum`. */
150
+ id: string;
151
+ name: string;
152
+ /** A `data:` image address from the wallet, when it announced one. */
153
+ icon?: string;
154
+ provider: Eip1193Provider;
155
+ }
156
+
157
+ export interface WalletDiscoveryOptions {
158
+ /** Where to look. Default: `window`. */
159
+ host?: EventTarget & { ethereum?: unknown };
160
+ /** How long to listen for EIP-6963 announcements, in milliseconds. Default: 300. */
161
+ timeoutMs?: number;
162
+ }
163
+
164
+ function isProvider(value: unknown): value is Eip1193Provider {
165
+ return !!value && typeof (value as Eip1193Provider).request === 'function';
166
+ }
167
+
168
+ /** A name for a provider that did not announce one. Trust Wallet sets `isMetaMask` too, so it is checked first. */
169
+ function legacyName(provider: Eip1193Provider): string {
170
+ const flags = provider as unknown as Record<string, unknown>;
171
+ if (flags.isTrust || flags.isTrustWallet) return 'Trust Wallet';
172
+ if (flags.isMetaMask) return 'MetaMask';
173
+ if (flags.isCoinbaseWallet) return 'Coinbase Wallet';
174
+ return 'Browser wallet';
175
+ }
176
+
177
+ /**
178
+ * The wallets in this page. A wallet that follows EIP-6963 announces itself, so several can be
179
+ * listed side by side; one that only sets `window.ethereum` (older wallets, some in-app browsers)
180
+ * is added when no announced wallet is that same provider. Returns an empty list when there is no
181
+ * wallet, or when there is no page (Node).
182
+ */
183
+ export async function discoverInjectedWallets(options: WalletDiscoveryOptions = {}): Promise<InjectedWallet[]> {
184
+ const host = options.host ?? (typeof window !== 'undefined' ? (window as unknown as WalletDiscoveryOptions['host']) : undefined);
185
+ if (!host) {
186
+ return [];
187
+ }
188
+ const found = new Map<string, InjectedWallet>();
189
+
190
+ const onAnnounce = (event: Event): void => {
191
+ const detail = (event as Event & { detail?: { info?: Record<string, string>; provider?: unknown } }).detail;
192
+ if (!detail || !isProvider(detail.provider)) {
193
+ return;
194
+ }
195
+ const info = detail.info ?? {};
196
+ const id = info.rdns || info.uuid || info.name || `announced-${found.size}`;
197
+ if (!found.has(id)) {
198
+ found.set(id, { id, name: info.name || id, icon: info.icon, provider: detail.provider });
199
+ }
200
+ };
201
+
202
+ host.addEventListener('eip6963:announceProvider', onAnnounce);
203
+ try {
204
+ host.dispatchEvent(new Event('eip6963:requestProvider'));
205
+ await new Promise<void>((resolve) => setTimeout(resolve, options.timeoutMs ?? 300));
206
+ } finally {
207
+ host.removeEventListener('eip6963:announceProvider', onAnnounce);
208
+ }
209
+
210
+ const wallets = [...found.values()];
211
+ const ethereum = host.ethereum as (Eip1193Provider & { providers?: unknown }) | undefined;
212
+ if (isProvider(ethereum)) {
213
+ const list: unknown[] =
214
+ Array.isArray(ethereum.providers) && ethereum.providers.length > 0 ? ethereum.providers : [ethereum];
215
+ list.filter(isProvider).forEach((provider, index) => {
216
+ if (!wallets.some((wallet) => wallet.provider === provider)) {
217
+ wallets.push({ id: `injected-${index}`, name: legacyName(provider), provider });
218
+ }
219
+ });
220
+ }
221
+ return wallets;
222
+ }
223
+
224
+ /**
225
+ * Ask a wallet to share its account (the wallet shows its own prompt) and return a signer for it.
226
+ * Rejects when the user says no (the provider's error, code 4001) or when no account is shared.
227
+ */
228
+ export async function connectWallet(wallet: InjectedWallet): Promise<Signer> {
229
+ const accounts = await wallet.provider.request({ method: 'eth_requestAccounts' });
230
+ const first = Array.isArray(accounts) ? accounts[0] : undefined;
231
+ if (typeof first !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(first)) {
232
+ throw new Error('the wallet did not share an account');
233
+ }
234
+ return createWalletSigner(wallet.provider, first);
235
+ }