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 +7 -0
- package/README.md +20 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sdk.d.ts +41 -10
- package/dist/sdk.js +95 -34
- package/dist/signers.d.ts +85 -0
- package/dist/signers.js +161 -0
- package/package.json +4 -4
- package/src/index.ts +1 -0
- package/src/sdk.ts +104 -36
- package/src/signers.ts +235 -0
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
|
|
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
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
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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`
|
|
144
|
-
* this SDK's public key. Stored in a module-global map keyed by
|
|
145
|
-
* cache — so every SDK instance and shared WebSocket connection for
|
|
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
|
|
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
|
|
60
|
-
*
|
|
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
|
|
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
|
|
124
|
-
* `publicKey` must have been provided (constructor or `
|
|
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
|
|
139
|
-
if (!
|
|
140
|
-
throw new Error(`ClutchHubSdk: generateToken requires proof of key ownership; provide the private key for ${publicKey} via the ClutchHubSdk constructor or
|
|
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(
|
|
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
|
|
278
|
-
*
|
|
279
|
-
*
|
|
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
|
-
|
|
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`
|
|
313
|
-
* this SDK's public key. Stored in a module-global map keyed by
|
|
314
|
-
* cache — so every SDK instance and shared WebSocket connection for
|
|
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
|
-
|
|
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:
|
|
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
|
|
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>;
|
package/dist/signers.js
ADDED
|
@@ -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.
|
|
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
|
|
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
|
|
33
|
+
"url": "https://github.com/clutchprotocol/clutch-hub/issues"
|
|
34
34
|
},
|
|
35
|
-
"homepage": "https://github.com/clutchprotocol/clutch-hub
|
|
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
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
|
|
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
|
|
112
|
-
*
|
|
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
|
|
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
|
|
191
|
-
* `publicKey` must have been provided (constructor or `
|
|
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
|
|
213
|
-
if (!
|
|
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
|
|
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(
|
|
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
|
|
430
|
-
*
|
|
431
|
-
*
|
|
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
|
-
|
|
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`
|
|
471
|
-
* this SDK's public key. Stored in a module-global map keyed by
|
|
472
|
-
* cache — so every SDK instance and shared WebSocket connection for
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
+
}
|