clutch-hub-sdk-js 4.3.0 → 4.4.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.4.0](https://github.com/clutchprotocol/clutch-hub/compare/v4.3.0...v4.4.0) (2026-10-06)
2
+
3
+
4
+ ### Features
5
+
6
+ * **sdk:** sign with TronLink (signMessageV2, TIP-191), and the demo app connects it ([#32](https://github.com/clutchprotocol/clutch-hub/issues/32)) ([57fdd06](https://github.com/clutchprotocol/clutch-hub/commit/57fdd06297ff0b1f434d4b27d4941777772baeec))
7
+
1
8
  ## [4.3.0](https://github.com/clutchprotocol/clutch-hub/compare/v4.2.1...v4.3.0) (2026-10-06)
2
9
 
3
10
 
package/README.md CHANGED
@@ -41,14 +41,14 @@ 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
44
+ ## Wallets: MetaMask, Trust Wallet, TronLink
45
45
 
46
- A wallet keeps the key and signs a short text with `personal_sign`. Use a signer where you used a key:
46
+ A wallet keeps the key and signs a short text. MetaMask and Trust Wallet sign it with `personal_sign` (EIP-191). TronLink signs it with `signMessageV2` (TIP-191), which is the same with the prefix `\x19TRON Signed Message:\n`. A TronLink account is the same kind of key as a Clutch account, so its `T…` address is the Clutch address `0x…` of the same key. Use a signer where you used a key:
47
47
 
48
48
  ```javascript
49
49
  import { ClutchHubSdk, discoverInjectedWallets, connectWallet } from 'clutch-hub-sdk-js';
50
50
 
51
- const [wallet] = await discoverInjectedWallets(); // EIP-6963, then window.ethereum
51
+ const [wallet] = await discoverInjectedWallets(); // EIP-6963 and TIP-6963 (TronLink), then window.ethereum / window.tron
52
52
  const signer = await connectWallet(wallet); // the wallet asks the user to share an account
53
53
 
54
54
  const sdk = new ClutchHubSdk('http://localhost:3000', signer.address, signer, 2077);
@@ -56,11 +56,11 @@ const sdk = new ClutchHubSdk('http://localhost:3000', signer.address, signer, 20
56
56
  const signed = await sdk.signTransaction(unsigned, signer, { type: 'RideRequest', fare: 5_000_000n });
57
57
  ```
58
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`.
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. `wallet.kind` is `'evm'` (MetaMask, Trust Wallet) or `'tron'` (TronLink), and `connectWallet`, `createSignerFor(wallet, account)`, `sharedWalletAccount(wallet)` (the account a wallet already shares, with no prompt) and `watchWalletAccounts(wallet, listener)` work for both. `createWalletSigner(provider, address)` and `createTronLinkSigner(provider, address)` build a signer for a provider you already have, and `createLocalSigner(privateKey)` wraps a key. A user who says no in a wallet gives a rejection (MetaMask and Trust Wallet: `code: 4001`; TronLink: an error with the message `user rejected request`).
60
60
 
61
61
  ## Features
62
62
 
63
- - Client-side signing (private keys never sent to server), or a wallet that keeps the key (MetaMask, Trust Wallet)
63
+ - Client-side signing (private keys never sent to server), or a wallet that keeps the key (MetaMask, Trust Wallet, TronLink)
64
64
  - Full ride lifecycle: request, offer, accept, pay, cancel
65
65
  - GraphQL queries and WebSocket subscriptions
66
66
  - TypeScript types
@@ -70,7 +70,7 @@ Each `signTransaction` and each login opens a prompt in the wallet. The text the
70
70
  | Category | Methods |
71
71
  |----------|---------|
72
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` |
73
+ | Signers | `createLocalSigner`, `createWalletSigner`, `createTronLinkSigner`, `createSignerFor`, `discoverInjectedWallets`, `connectWallet`, `sharedWalletAccount`, `watchWalletAccounts`, `tronAddressToHex`, `addressFromPrivateKey` |
74
74
  | Write | `createUnsignedRide*`, `signTransaction`, `submitTransaction` |
75
75
  | Read | `listRideRequests`, `listRideOffers`, `listActiveTrips`, `getAccountBalance`, … |
76
76
  | Live | `subscribeRideRequests`, `subscribeRideOffers`, `subscribeActiveTrips`, … |
package/dist/signers.d.ts CHANGED
@@ -44,6 +44,8 @@ export interface Eip1193Provider {
44
44
  export declare function walletTransactionText(chainId: number, hashHex: string): string;
45
45
  /** What `personal_sign` hashes and signs: `Keccak256("\x19Ethereum Signed Message:\n" + length + text)`. */
46
46
  export declare function personalSignDigest(text: string): Uint8Array;
47
+ /** What TronLink's `signMessageV2` (TIP-191) hashes and signs: `Keccak256("\x19TRON Signed Message:\n" + length + text)`. */
48
+ export declare function tronSignDigest(text: string): Uint8Array;
47
49
  /**
48
50
  * A signer that asks a wallet to sign with `personal_sign`. The wallet shows the user the text,
49
51
  * and keeps the key. `address` is the account to sign for; it is lowercased, because the hash of a
@@ -54,28 +56,75 @@ export declare function personalSignDigest(text: string): Uint8Array;
54
56
  * that does not say why.
55
57
  */
56
58
  export declare function createWalletSigner(provider: Eip1193Provider, address: string): Signer;
59
+ /**
60
+ * A TRON address as the Clutch address of the same key: `0x` and 40 lowercase hex characters.
61
+ *
62
+ * A TRON account is an Ethereum-type key (the same curve, the same Keccak-256, the same 20 address
63
+ * bytes). TRON writes those bytes in base58 (`T…`): `0x41`, the 20 bytes, and 4 bytes of checksum,
64
+ * and the checksum is checked here. A `0x` address and the `41…` hex form are accepted as they are.
65
+ */
66
+ export declare function tronAddressToHex(address: string): string;
67
+ /** The part of the `tronWeb` that TronLink puts on its provider that the SDK uses. */
68
+ export interface TronWebLike {
69
+ ready?: boolean;
70
+ defaultAddress?: {
71
+ base58?: string | false;
72
+ };
73
+ trx?: {
74
+ signMessageV2?(message: string): Promise<unknown>;
75
+ };
76
+ }
77
+ /**
78
+ * TronLink's provider (`window.tron`, or the one it announces with TIP-6963): EIP-1193 plus a
79
+ * `tronWeb`, which is `false` until the person lets this site use TronLink.
80
+ */
81
+ export interface TronLinkProvider extends Eip1193Provider {
82
+ tronWeb?: TronWebLike | false;
83
+ }
84
+ /**
85
+ * A signer that asks TronLink to sign with `signMessageV2` (TIP-191). TronLink shows the person the
86
+ * text and keeps the key. `address` is the account to sign for (a `0x` address, or the base58 one
87
+ * TronLink shows). It is checked the same way `createWalletSigner` checks: the signature is
88
+ * recovered here, and one from another account is refused before it leaves the SDK.
89
+ *
90
+ * TronLink's documentation is not clear on what `signMessageV2` takes: one page says a hex string
91
+ * and another says plain text or hex. So the text goes in plain first. A TronLink that takes only
92
+ * hex answers "Invalid transaction provided" before it opens a prompt, and then the text goes in
93
+ * again as `0x` hex of its UTF-8 bytes. Any other answer ends the call, because a second try would
94
+ * open a second prompt.
95
+ */
96
+ export declare function createTronLinkSigner(provider: TronLinkProvider, address: string): Signer;
57
97
  /** A wallet found in the page. */
58
98
  export interface InjectedWallet {
59
- /** The EIP-6963 `rdns` (for example `io.metamask`), or `injected-0` for a bare `window.ethereum`. */
99
+ /** The EIP-6963 or TIP-6963 `rdns` (for example `io.metamask`), or `injected-0` for a bare `window.ethereum`. */
60
100
  id: string;
61
101
  name: string;
62
102
  /** A `data:` image address from the wallet, when it announced one. */
63
103
  icon?: string;
104
+ /**
105
+ * `evm` (MetaMask, Trust Wallet: signs with `personal_sign`) or `tron` (TronLink: signs with
106
+ * `signMessageV2`). Default `evm`.
107
+ */
108
+ kind?: 'evm' | 'tron';
109
+ /** For a `tron` wallet this is a {@link TronLinkProvider}. */
64
110
  provider: Eip1193Provider;
65
111
  }
66
112
  export interface WalletDiscoveryOptions {
67
113
  /** Where to look. Default: `window`. */
68
114
  host?: EventTarget & {
69
115
  ethereum?: unknown;
116
+ tron?: unknown;
117
+ tronLink?: unknown;
70
118
  };
71
- /** How long to listen for EIP-6963 announcements, in milliseconds. Default: 300. */
119
+ /** How long to listen for EIP-6963 and TIP-6963 announcements, in milliseconds. Default: 300. */
72
120
  timeoutMs?: number;
73
121
  }
74
122
  /**
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).
123
+ * The wallets in this page. A wallet that follows EIP-6963 (MetaMask, Trust Wallet) or TIP-6963
124
+ * (TronLink, the same idea for TRON) announces itself, so several can be listed side by side. One
125
+ * that only sets a global is added when no announced wallet is that same provider: `window.ethereum`
126
+ * (older wallets, some in-app browsers), or `window.tron` / `window.tronLink` for TronLink. Returns
127
+ * an empty list when there is no wallet, or when there is no page (Node).
79
128
  */
80
129
  export declare function discoverInjectedWallets(options?: WalletDiscoveryOptions): Promise<InjectedWallet[]>;
81
130
  /**
@@ -83,3 +132,20 @@ export declare function discoverInjectedWallets(options?: WalletDiscoveryOptions
83
132
  * Rejects when the user says no (the provider's error, code 4001) or when no account is shared.
84
133
  */
85
134
  export declare function connectWallet(wallet: InjectedWallet): Promise<Signer>;
135
+ /** A signer for `account` on `wallet`: `personal_sign` for MetaMask and Trust Wallet, `signMessageV2` for TronLink. */
136
+ export declare function createSignerFor(wallet: InjectedWallet, account: string): Signer;
137
+ /** The first account in an answer of this wallet (`accountsChanged`), as a Clutch address; `null` when there is none. */
138
+ export declare function walletAccountFrom(wallet: InjectedWallet, accounts: unknown): string | null;
139
+ /**
140
+ * The account a wallet already shares with this page, as a Clutch address. It never opens a
141
+ * prompt, so an app can use it to connect again by itself on the next visit. `null` when the wallet
142
+ * shares none (locked, or the site was never allowed). A TronLink that allowed the site earlier has
143
+ * its `tronWeb` ready; one that did not has `tronWeb` `false`.
144
+ */
145
+ export declare function sharedWalletAccount(wallet: InjectedWallet): Promise<string | null>;
146
+ /**
147
+ * Call `listener` with the new account (a Clutch address) when the person switches account in the
148
+ * wallet, and with `null` when the wallet stops sharing this site (locked, or disconnected).
149
+ * Returns a function that stops listening.
150
+ */
151
+ export declare function watchWalletAccounts(wallet: InjectedWallet, listener: (account: string | null) => void): () => void;
package/dist/signers.js CHANGED
@@ -1,17 +1,21 @@
1
1
  import { Buffer } from 'buffer';
2
2
  import { keccak_256 } from '@noble/hashes/sha3';
3
+ import { sha256 } from '@noble/hashes/sha256';
3
4
  import * as secp from '@noble/secp256k1';
4
5
  /*
5
6
  * Who signs a transaction.
6
7
  *
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.
8
+ * The SDK used to take a private key string and sign with it. A wallet (MetaMask, Trust Wallet,
9
+ * TronLink) never gives its key to a page, so the SDK now takes a `Signer`: an object that can be
10
+ * asked for a signature. A key in memory is one kind of signer (`createLocalSigner`, in sdk.ts); a
11
+ * wallet is another (`createWalletSigner` and `createTronLinkSigner`, here). Everywhere the SDK
12
+ * took a key it still takes a key string.
11
13
  *
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:
14
+ * A wallet will not sign a bare hash, so it signs a short readable text. MetaMask and Trust Wallet
15
+ * use `personal_sign` (EIP-191), which hashes `"\x19Ethereum Signed Message:\n" + length + text`.
16
+ * TronLink uses `signMessageV2` (TIP-191), which hashes `"\x19TRON Signed Message:\n" + length +
17
+ * text`: the same key and the same 20-byte address, with another prefix. The node and the Hub API
18
+ * accept both signatures next to the old one. The texts below are the contract with them:
15
19
  *
16
20
  * transaction clutch-tx:{chainId}:{hash} hash = 64 lowercase hex, no 0x
17
21
  * login clutch-auth:{chainId}:{publicKey}:{timestamp}
@@ -30,6 +34,12 @@ export function personalSignDigest(text) {
30
34
  const prefix = Buffer.from(`\x19Ethereum Signed Message:\n${body.length}`, 'utf8');
31
35
  return keccak_256(Buffer.concat([prefix, body]));
32
36
  }
37
+ /** What TronLink's `signMessageV2` (TIP-191) hashes and signs: `Keccak256("\x19TRON Signed Message:\n" + length + text)`. */
38
+ export function tronSignDigest(text) {
39
+ const body = Buffer.from(text, 'utf8');
40
+ const prefix = Buffer.from(`\x19TRON Signed Message:\n${body.length}`, 'utf8');
41
+ return keccak_256(Buffer.concat([prefix, body]));
42
+ }
33
43
  /**
34
44
  * `r`, `s` and `v` from a wallet's answer: `0x` and 130 hex characters (65 bytes). Some wallets
35
45
  * answer with a recovery id of 0 or 1 instead of 27 or 28; the node reads only 27 and 28.
@@ -90,6 +100,114 @@ export function createWalletSigner(provider, address) {
90
100
  signAuthChallenge: ({ message }) => personalSign(message),
91
101
  };
92
102
  }
103
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
104
+ function base58Decode(text) {
105
+ const digits = []; // base 256, least significant first
106
+ for (const char of text) {
107
+ let carry = BASE58_ALPHABET.indexOf(char);
108
+ if (carry < 0) {
109
+ throw new Error(`"${char}" is not a base58 character`);
110
+ }
111
+ for (let i = 0; i < digits.length; i++) {
112
+ carry += digits[i] * 58;
113
+ digits[i] = carry & 0xff;
114
+ carry >>= 8;
115
+ }
116
+ while (carry > 0) {
117
+ digits.push(carry & 0xff);
118
+ carry >>= 8;
119
+ }
120
+ }
121
+ for (const char of text) {
122
+ if (char !== '1')
123
+ break;
124
+ digits.push(0); // each leading "1" is a zero byte
125
+ }
126
+ return Uint8Array.from(digits.reverse());
127
+ }
128
+ /**
129
+ * A TRON address as the Clutch address of the same key: `0x` and 40 lowercase hex characters.
130
+ *
131
+ * A TRON account is an Ethereum-type key (the same curve, the same Keccak-256, the same 20 address
132
+ * bytes). TRON writes those bytes in base58 (`T…`): `0x41`, the 20 bytes, and 4 bytes of checksum,
133
+ * and the checksum is checked here. A `0x` address and the `41…` hex form are accepted as they are.
134
+ */
135
+ export function tronAddressToHex(address) {
136
+ if (/^0x[0-9a-fA-F]{40}$/.test(address)) {
137
+ return address.toLowerCase();
138
+ }
139
+ if (/^41[0-9a-fA-F]{40}$/.test(address)) {
140
+ return '0x' + address.slice(2).toLowerCase();
141
+ }
142
+ const bytes = base58Decode(address);
143
+ if (bytes.length !== 25 || bytes[0] !== 0x41) {
144
+ throw new Error(`"${address}" is not a TRON address`);
145
+ }
146
+ const checksum = sha256(sha256(bytes.slice(0, 21))).slice(0, 4);
147
+ if (!checksum.every((byte, index) => byte === bytes[21 + index])) {
148
+ throw new Error(`"${address}" is not a TRON address: its checksum is wrong`);
149
+ }
150
+ return '0x' + Buffer.from(bytes.slice(1, 21)).toString('hex');
151
+ }
152
+ /** A TRON address from a wallet's answer as a Clutch address, or `null` when it is not one. */
153
+ function tronAccountOf(raw) {
154
+ try {
155
+ return typeof raw === 'string' ? tronAddressToHex(raw) : null;
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ const INVALID_INPUT = /invalid transaction provided/i;
162
+ /**
163
+ * A signer that asks TronLink to sign with `signMessageV2` (TIP-191). TronLink shows the person the
164
+ * text and keeps the key. `address` is the account to sign for (a `0x` address, or the base58 one
165
+ * TronLink shows). It is checked the same way `createWalletSigner` checks: the signature is
166
+ * recovered here, and one from another account is refused before it leaves the SDK.
167
+ *
168
+ * TronLink's documentation is not clear on what `signMessageV2` takes: one page says a hex string
169
+ * and another says plain text or hex. So the text goes in plain first. A TronLink that takes only
170
+ * hex answers "Invalid transaction provided" before it opens a prompt, and then the text goes in
171
+ * again as `0x` hex of its UTF-8 bytes. Any other answer ends the call, because a second try would
172
+ * open a second prompt.
173
+ */
174
+ export function createTronLinkSigner(provider, address) {
175
+ const account = tronAddressToHex(address);
176
+ async function tronSign(text) {
177
+ const trx = provider.tronWeb ? provider.tronWeb.trx : undefined;
178
+ if (!trx || typeof trx.signMessageV2 !== 'function') {
179
+ throw new Error('TronLink is locked, or has not shared this site: open TronLink and connect again');
180
+ }
181
+ const hexText = '0x' + Buffer.from(text, 'utf8').toString('hex');
182
+ let raw;
183
+ try {
184
+ raw = await trx.signMessageV2(text);
185
+ }
186
+ catch (error) {
187
+ if (!INVALID_INPUT.test(error instanceof Error ? error.message : String(error))) {
188
+ throw error;
189
+ }
190
+ raw = await trx.signMessageV2(hexText);
191
+ }
192
+ const signature = parseWalletSignature(raw);
193
+ const signedBy = recoverAddress(tronSignDigest(text), signature);
194
+ if (signedBy !== account) {
195
+ // A TronLink that read the hex as text would sign the text of the hex. Say so, because that
196
+ // is not the person's fault and switching accounts would not help.
197
+ if (recoverAddress(tronSignDigest(hexText), signature) === account) {
198
+ throw new Error('this TronLink signed the hex text, not the message, so the signature is not valid for Clutch: update TronLink');
199
+ }
200
+ throw new Error(`TronLink signed with ${signedBy ?? 'an account that cannot be read'}, not ${account}: switch to ${account} in TronLink and try again`);
201
+ }
202
+ return signature;
203
+ }
204
+ return {
205
+ address: account,
206
+ interactive: true,
207
+ signTransaction: ({ hashHex, chainId }) => tronSign(walletTransactionText(chainId, hashHex)),
208
+ signAuthChallenge: ({ message }) => tronSign(message),
209
+ };
210
+ }
93
211
  function isProvider(value) {
94
212
  return !!value && typeof value.request === 'function';
95
213
  }
@@ -105,18 +223,20 @@ function legacyName(provider) {
105
223
  return 'Browser wallet';
106
224
  }
107
225
  /**
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).
226
+ * The wallets in this page. A wallet that follows EIP-6963 (MetaMask, Trust Wallet) or TIP-6963
227
+ * (TronLink, the same idea for TRON) announces itself, so several can be listed side by side. One
228
+ * that only sets a global is added when no announced wallet is that same provider: `window.ethereum`
229
+ * (older wallets, some in-app browsers), or `window.tron` / `window.tronLink` for TronLink. Returns
230
+ * an empty list when there is no wallet, or when there is no page (Node).
112
231
  */
113
232
  export async function discoverInjectedWallets(options = {}) {
114
233
  const host = options.host ?? (typeof window !== 'undefined' ? window : undefined);
115
234
  if (!host) {
116
235
  return [];
117
236
  }
118
- const found = new Map();
119
- const onAnnounce = (event) => {
237
+ const announcedEvm = new Map();
238
+ const announcedTron = new Map();
239
+ const onAnnounce = (found, kind) => (event) => {
120
240
  const detail = event.detail;
121
241
  if (!detail || !isProvider(detail.provider)) {
122
242
  return;
@@ -124,38 +244,132 @@ export async function discoverInjectedWallets(options = {}) {
124
244
  const info = detail.info ?? {};
125
245
  const id = info.rdns || info.uuid || info.name || `announced-${found.size}`;
126
246
  if (!found.has(id)) {
127
- found.set(id, { id, name: info.name || id, icon: info.icon, provider: detail.provider });
247
+ found.set(id, { id, name: info.name || id, icon: info.icon, kind, provider: detail.provider });
128
248
  }
129
249
  };
130
- host.addEventListener('eip6963:announceProvider', onAnnounce);
250
+ const onEvm = onAnnounce(announcedEvm, 'evm');
251
+ const onTron = onAnnounce(announcedTron, 'tron');
252
+ host.addEventListener('eip6963:announceProvider', onEvm);
253
+ host.addEventListener('TIP6963:announceProvider', onTron);
131
254
  try {
132
255
  host.dispatchEvent(new Event('eip6963:requestProvider'));
256
+ host.dispatchEvent(new Event('TIP6963:requestProvider'));
133
257
  await new Promise((resolve) => setTimeout(resolve, options.timeoutMs ?? 300));
134
258
  }
135
259
  finally {
136
- host.removeEventListener('eip6963:announceProvider', onAnnounce);
260
+ host.removeEventListener('eip6963:announceProvider', onEvm);
261
+ host.removeEventListener('TIP6963:announceProvider', onTron);
262
+ }
263
+ const tronWallets = [...announcedTron.values()];
264
+ if (tronWallets.length === 0) {
265
+ // `window.tronLink` is the older name of `window.tron`; the two are the same wallet.
266
+ const bare = [host.tron, host.tronLink].find(isProvider);
267
+ if (bare) {
268
+ tronWallets.push({ id: 'injected-tron', name: 'TronLink', kind: 'tron', provider: bare });
269
+ }
137
270
  }
138
- const wallets = [...found.values()];
271
+ // A provider that is TronLink's must not be listed again as an Ethereum wallet.
272
+ const wallets = [...announcedEvm.values()].filter((wallet) => !tronWallets.some((tron) => tron.provider === wallet.provider));
139
273
  const ethereum = host.ethereum;
140
274
  if (isProvider(ethereum)) {
141
275
  const list = Array.isArray(ethereum.providers) && ethereum.providers.length > 0 ? ethereum.providers : [ethereum];
142
276
  list.filter(isProvider).forEach((provider, index) => {
143
- if (!wallets.some((wallet) => wallet.provider === provider)) {
144
- wallets.push({ id: `injected-${index}`, name: legacyName(provider), provider });
277
+ const known = [...wallets, ...tronWallets].some((wallet) => wallet.provider === provider);
278
+ if (!known) {
279
+ wallets.push({ id: `injected-${index}`, name: legacyName(provider), kind: 'evm', provider });
145
280
  }
146
281
  });
147
282
  }
148
- return wallets;
283
+ return [...wallets, ...tronWallets];
284
+ }
285
+ /** An account from an `eth_accounts`-style answer, as `0x` and 40 lowercase hex characters. */
286
+ function evmAccountOf(raw) {
287
+ return typeof raw === 'string' && /^0x[0-9a-fA-F]{40}$/.test(raw) ? raw.toLowerCase() : null;
288
+ }
289
+ /**
290
+ * Ask TronLink to share its account. TronLink answers `['T…']` (base58). An older TronLink does not
291
+ * know `eth_requestAccounts` (error 4200): it has its own `tron_requestAccounts`, which answers
292
+ * `{ code, message }` and puts the account in `tronWeb`.
293
+ */
294
+ async function requestTronAccount(provider) {
295
+ let accounts;
296
+ try {
297
+ accounts = await provider.request({ method: 'eth_requestAccounts' });
298
+ }
299
+ catch (error) {
300
+ if (error?.code !== 4200) {
301
+ throw error;
302
+ }
303
+ const answer = (await provider.request({ method: 'tron_requestAccounts' }));
304
+ if (!answer) {
305
+ throw new Error('TronLink is locked: unlock it and try again');
306
+ }
307
+ if (answer.code === 4001) {
308
+ throw Object.assign(new Error('You said no in TronLink.'), { code: 4001 });
309
+ }
310
+ if (answer.code !== 200) {
311
+ throw new Error(answer.message || 'TronLink did not connect');
312
+ }
313
+ accounts = [provider.tronWeb ? provider.tronWeb.defaultAddress?.base58 : undefined];
314
+ }
315
+ return tronAccountOf(Array.isArray(accounts) ? accounts[0] : undefined);
149
316
  }
150
317
  /**
151
318
  * Ask a wallet to share its account (the wallet shows its own prompt) and return a signer for it.
152
319
  * Rejects when the user says no (the provider's error, code 4001) or when no account is shared.
153
320
  */
154
321
  export async function connectWallet(wallet) {
322
+ if (wallet.kind === 'tron') {
323
+ const account = await requestTronAccount(wallet.provider);
324
+ if (!account) {
325
+ throw new Error('the wallet did not share an account');
326
+ }
327
+ return createTronLinkSigner(wallet.provider, account);
328
+ }
155
329
  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)) {
330
+ const account = evmAccountOf(Array.isArray(accounts) ? accounts[0] : undefined);
331
+ if (!account) {
158
332
  throw new Error('the wallet did not share an account');
159
333
  }
160
- return createWalletSigner(wallet.provider, first);
334
+ return createWalletSigner(wallet.provider, account);
335
+ }
336
+ /** A signer for `account` on `wallet`: `personal_sign` for MetaMask and Trust Wallet, `signMessageV2` for TronLink. */
337
+ export function createSignerFor(wallet, account) {
338
+ return wallet.kind === 'tron'
339
+ ? createTronLinkSigner(wallet.provider, account)
340
+ : createWalletSigner(wallet.provider, account);
341
+ }
342
+ /** The first account in an answer of this wallet (`accountsChanged`), as a Clutch address; `null` when there is none. */
343
+ export function walletAccountFrom(wallet, accounts) {
344
+ const first = Array.isArray(accounts) ? accounts[0] : undefined;
345
+ return wallet.kind === 'tron' ? tronAccountOf(first) : evmAccountOf(first);
346
+ }
347
+ /**
348
+ * The account a wallet already shares with this page, as a Clutch address. It never opens a
349
+ * prompt, so an app can use it to connect again by itself on the next visit. `null` when the wallet
350
+ * shares none (locked, or the site was never allowed). A TronLink that allowed the site earlier has
351
+ * its `tronWeb` ready; one that did not has `tronWeb` `false`.
352
+ */
353
+ export async function sharedWalletAccount(wallet) {
354
+ if (wallet.kind === 'tron') {
355
+ const tronWeb = wallet.provider.tronWeb;
356
+ return !tronWeb || tronWeb.ready === false ? null : tronAccountOf(tronWeb.defaultAddress?.base58);
357
+ }
358
+ return walletAccountFrom(wallet, await wallet.provider.request({ method: 'eth_accounts' }));
359
+ }
360
+ /**
361
+ * Call `listener` with the new account (a Clutch address) when the person switches account in the
362
+ * wallet, and with `null` when the wallet stops sharing this site (locked, or disconnected).
363
+ * Returns a function that stops listening.
364
+ */
365
+ export function watchWalletAccounts(wallet, listener) {
366
+ const provider = wallet.provider;
367
+ if (!provider.on) {
368
+ return () => { };
369
+ }
370
+ const onAccountsChanged = (accounts) => listener(walletAccountFrom(wallet, accounts));
371
+ provider.on('accountsChanged', onAccountsChanged);
372
+ return () => {
373
+ provider.removeListener?.('accountsChanged', onAccountsChanged);
374
+ };
161
375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clutch-hub-sdk-js",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "description": "JavaScript SDK for interacting with the clutch-hub-api",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
package/src/signers.ts CHANGED
@@ -1,19 +1,23 @@
1
1
  import { Buffer } from 'buffer';
2
2
  import { keccak_256 } from '@noble/hashes/sha3';
3
+ import { sha256 } from '@noble/hashes/sha256';
3
4
  import * as secp from '@noble/secp256k1';
4
5
  import type { Signature } from './types.js';
5
6
 
6
7
  /*
7
8
  * Who signs a transaction.
8
9
  *
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.
10
+ * The SDK used to take a private key string and sign with it. A wallet (MetaMask, Trust Wallet,
11
+ * TronLink) never gives its key to a page, so the SDK now takes a `Signer`: an object that can be
12
+ * asked for a signature. A key in memory is one kind of signer (`createLocalSigner`, in sdk.ts); a
13
+ * wallet is another (`createWalletSigner` and `createTronLinkSigner`, here). Everywhere the SDK
14
+ * took a key it still takes a key string.
13
15
  *
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:
16
+ * A wallet will not sign a bare hash, so it signs a short readable text. MetaMask and Trust Wallet
17
+ * use `personal_sign` (EIP-191), which hashes `"\x19Ethereum Signed Message:\n" + length + text`.
18
+ * TronLink uses `signMessageV2` (TIP-191), which hashes `"\x19TRON Signed Message:\n" + length +
19
+ * text`: the same key and the same 20-byte address, with another prefix. The node and the Hub API
20
+ * accept both signatures next to the old one. The texts below are the contract with them:
17
21
  *
18
22
  * transaction clutch-tx:{chainId}:{hash} hash = 64 lowercase hex, no 0x
19
23
  * login clutch-auth:{chainId}:{publicKey}:{timestamp}
@@ -78,6 +82,13 @@ export function personalSignDigest(text: string): Uint8Array {
78
82
  return keccak_256(Buffer.concat([prefix, body]));
79
83
  }
80
84
 
85
+ /** What TronLink's `signMessageV2` (TIP-191) hashes and signs: `Keccak256("\x19TRON Signed Message:\n" + length + text)`. */
86
+ export function tronSignDigest(text: string): Uint8Array {
87
+ const body = Buffer.from(text, 'utf8');
88
+ const prefix = Buffer.from(`\x19TRON Signed Message:\n${body.length}`, 'utf8');
89
+ return keccak_256(Buffer.concat([prefix, body]));
90
+ }
91
+
81
92
  /**
82
93
  * `r`, `s` and `v` from a wallet's answer: `0x` and 130 hex characters (65 bytes). Some wallets
83
94
  * answer with a recovery id of 0 or 1 instead of 27 or 28; the node reads only 27 and 28.
@@ -144,20 +155,156 @@ export function createWalletSigner(provider: Eip1193Provider, address: string):
144
155
  };
145
156
  }
146
157
 
158
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
159
+
160
+ function base58Decode(text: string): Uint8Array {
161
+ const digits: number[] = []; // base 256, least significant first
162
+ for (const char of text) {
163
+ let carry = BASE58_ALPHABET.indexOf(char);
164
+ if (carry < 0) {
165
+ throw new Error(`"${char}" is not a base58 character`);
166
+ }
167
+ for (let i = 0; i < digits.length; i++) {
168
+ carry += digits[i] * 58;
169
+ digits[i] = carry & 0xff;
170
+ carry >>= 8;
171
+ }
172
+ while (carry > 0) {
173
+ digits.push(carry & 0xff);
174
+ carry >>= 8;
175
+ }
176
+ }
177
+ for (const char of text) {
178
+ if (char !== '1') break;
179
+ digits.push(0); // each leading "1" is a zero byte
180
+ }
181
+ return Uint8Array.from(digits.reverse());
182
+ }
183
+
184
+ /**
185
+ * A TRON address as the Clutch address of the same key: `0x` and 40 lowercase hex characters.
186
+ *
187
+ * A TRON account is an Ethereum-type key (the same curve, the same Keccak-256, the same 20 address
188
+ * bytes). TRON writes those bytes in base58 (`T…`): `0x41`, the 20 bytes, and 4 bytes of checksum,
189
+ * and the checksum is checked here. A `0x` address and the `41…` hex form are accepted as they are.
190
+ */
191
+ export function tronAddressToHex(address: string): string {
192
+ if (/^0x[0-9a-fA-F]{40}$/.test(address)) {
193
+ return address.toLowerCase();
194
+ }
195
+ if (/^41[0-9a-fA-F]{40}$/.test(address)) {
196
+ return '0x' + address.slice(2).toLowerCase();
197
+ }
198
+ const bytes = base58Decode(address);
199
+ if (bytes.length !== 25 || bytes[0] !== 0x41) {
200
+ throw new Error(`"${address}" is not a TRON address`);
201
+ }
202
+ const checksum = sha256(sha256(bytes.slice(0, 21))).slice(0, 4);
203
+ if (!checksum.every((byte, index) => byte === bytes[21 + index])) {
204
+ throw new Error(`"${address}" is not a TRON address: its checksum is wrong`);
205
+ }
206
+ return '0x' + Buffer.from(bytes.slice(1, 21)).toString('hex');
207
+ }
208
+
209
+ /** The part of the `tronWeb` that TronLink puts on its provider that the SDK uses. */
210
+ export interface TronWebLike {
211
+ ready?: boolean;
212
+ defaultAddress?: { base58?: string | false };
213
+ trx?: { signMessageV2?(message: string): Promise<unknown> };
214
+ }
215
+
216
+ /**
217
+ * TronLink's provider (`window.tron`, or the one it announces with TIP-6963): EIP-1193 plus a
218
+ * `tronWeb`, which is `false` until the person lets this site use TronLink.
219
+ */
220
+ export interface TronLinkProvider extends Eip1193Provider {
221
+ tronWeb?: TronWebLike | false;
222
+ }
223
+
224
+ /** A TRON address from a wallet's answer as a Clutch address, or `null` when it is not one. */
225
+ function tronAccountOf(raw: unknown): string | null {
226
+ try {
227
+ return typeof raw === 'string' ? tronAddressToHex(raw) : null;
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ const INVALID_INPUT = /invalid transaction provided/i;
234
+
235
+ /**
236
+ * A signer that asks TronLink to sign with `signMessageV2` (TIP-191). TronLink shows the person the
237
+ * text and keeps the key. `address` is the account to sign for (a `0x` address, or the base58 one
238
+ * TronLink shows). It is checked the same way `createWalletSigner` checks: the signature is
239
+ * recovered here, and one from another account is refused before it leaves the SDK.
240
+ *
241
+ * TronLink's documentation is not clear on what `signMessageV2` takes: one page says a hex string
242
+ * and another says plain text or hex. So the text goes in plain first. A TronLink that takes only
243
+ * hex answers "Invalid transaction provided" before it opens a prompt, and then the text goes in
244
+ * again as `0x` hex of its UTF-8 bytes. Any other answer ends the call, because a second try would
245
+ * open a second prompt.
246
+ */
247
+ export function createTronLinkSigner(provider: TronLinkProvider, address: string): Signer {
248
+ const account = tronAddressToHex(address);
249
+
250
+ async function tronSign(text: string): Promise<Signature> {
251
+ const trx = provider.tronWeb ? provider.tronWeb.trx : undefined;
252
+ if (!trx || typeof trx.signMessageV2 !== 'function') {
253
+ throw new Error('TronLink is locked, or has not shared this site: open TronLink and connect again');
254
+ }
255
+ const hexText = '0x' + Buffer.from(text, 'utf8').toString('hex');
256
+ let raw: unknown;
257
+ try {
258
+ raw = await trx.signMessageV2(text);
259
+ } catch (error) {
260
+ if (!INVALID_INPUT.test(error instanceof Error ? error.message : String(error))) {
261
+ throw error;
262
+ }
263
+ raw = await trx.signMessageV2(hexText);
264
+ }
265
+ const signature = parseWalletSignature(raw);
266
+ const signedBy = recoverAddress(tronSignDigest(text), signature);
267
+ if (signedBy !== account) {
268
+ // A TronLink that read the hex as text would sign the text of the hex. Say so, because that
269
+ // is not the person's fault and switching accounts would not help.
270
+ if (recoverAddress(tronSignDigest(hexText), signature) === account) {
271
+ throw new Error('this TronLink signed the hex text, not the message, so the signature is not valid for Clutch: update TronLink');
272
+ }
273
+ throw new Error(
274
+ `TronLink signed with ${signedBy ?? 'an account that cannot be read'}, not ${account}: switch to ${account} in TronLink and try again`
275
+ );
276
+ }
277
+ return signature;
278
+ }
279
+
280
+ return {
281
+ address: account,
282
+ interactive: true,
283
+ signTransaction: ({ hashHex, chainId }) => tronSign(walletTransactionText(chainId, hashHex)),
284
+ signAuthChallenge: ({ message }) => tronSign(message),
285
+ };
286
+ }
287
+
147
288
  /** A wallet found in the page. */
148
289
  export interface InjectedWallet {
149
- /** The EIP-6963 `rdns` (for example `io.metamask`), or `injected-0` for a bare `window.ethereum`. */
290
+ /** The EIP-6963 or TIP-6963 `rdns` (for example `io.metamask`), or `injected-0` for a bare `window.ethereum`. */
150
291
  id: string;
151
292
  name: string;
152
293
  /** A `data:` image address from the wallet, when it announced one. */
153
294
  icon?: string;
295
+ /**
296
+ * `evm` (MetaMask, Trust Wallet: signs with `personal_sign`) or `tron` (TronLink: signs with
297
+ * `signMessageV2`). Default `evm`.
298
+ */
299
+ kind?: 'evm' | 'tron';
300
+ /** For a `tron` wallet this is a {@link TronLinkProvider}. */
154
301
  provider: Eip1193Provider;
155
302
  }
156
303
 
157
304
  export interface WalletDiscoveryOptions {
158
305
  /** Where to look. Default: `window`. */
159
- host?: EventTarget & { ethereum?: unknown };
160
- /** How long to listen for EIP-6963 announcements, in milliseconds. Default: 300. */
306
+ host?: EventTarget & { ethereum?: unknown; tron?: unknown; tronLink?: unknown };
307
+ /** How long to listen for EIP-6963 and TIP-6963 announcements, in milliseconds. Default: 300. */
161
308
  timeoutMs?: number;
162
309
  }
163
310
 
@@ -175,19 +322,21 @@ function legacyName(provider: Eip1193Provider): string {
175
322
  }
176
323
 
177
324
  /**
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).
325
+ * The wallets in this page. A wallet that follows EIP-6963 (MetaMask, Trust Wallet) or TIP-6963
326
+ * (TronLink, the same idea for TRON) announces itself, so several can be listed side by side. One
327
+ * that only sets a global is added when no announced wallet is that same provider: `window.ethereum`
328
+ * (older wallets, some in-app browsers), or `window.tron` / `window.tronLink` for TronLink. Returns
329
+ * an empty list when there is no wallet, or when there is no page (Node).
182
330
  */
183
331
  export async function discoverInjectedWallets(options: WalletDiscoveryOptions = {}): Promise<InjectedWallet[]> {
184
332
  const host = options.host ?? (typeof window !== 'undefined' ? (window as unknown as WalletDiscoveryOptions['host']) : undefined);
185
333
  if (!host) {
186
334
  return [];
187
335
  }
188
- const found = new Map<string, InjectedWallet>();
336
+ const announcedEvm = new Map<string, InjectedWallet>();
337
+ const announcedTron = new Map<string, InjectedWallet>();
189
338
 
190
- const onAnnounce = (event: Event): void => {
339
+ const onAnnounce = (found: Map<string, InjectedWallet>, kind: 'evm' | 'tron') => (event: Event): void => {
191
340
  const detail = (event as Event & { detail?: { info?: Record<string, string>; provider?: unknown } }).detail;
192
341
  if (!detail || !isProvider(detail.provider)) {
193
342
  return;
@@ -195,30 +344,81 @@ export async function discoverInjectedWallets(options: WalletDiscoveryOptions =
195
344
  const info = detail.info ?? {};
196
345
  const id = info.rdns || info.uuid || info.name || `announced-${found.size}`;
197
346
  if (!found.has(id)) {
198
- found.set(id, { id, name: info.name || id, icon: info.icon, provider: detail.provider });
347
+ found.set(id, { id, name: info.name || id, icon: info.icon, kind, provider: detail.provider });
199
348
  }
200
349
  };
350
+ const onEvm = onAnnounce(announcedEvm, 'evm');
351
+ const onTron = onAnnounce(announcedTron, 'tron');
201
352
 
202
- host.addEventListener('eip6963:announceProvider', onAnnounce);
353
+ host.addEventListener('eip6963:announceProvider', onEvm);
354
+ host.addEventListener('TIP6963:announceProvider', onTron);
203
355
  try {
204
356
  host.dispatchEvent(new Event('eip6963:requestProvider'));
357
+ host.dispatchEvent(new Event('TIP6963:requestProvider'));
205
358
  await new Promise<void>((resolve) => setTimeout(resolve, options.timeoutMs ?? 300));
206
359
  } finally {
207
- host.removeEventListener('eip6963:announceProvider', onAnnounce);
360
+ host.removeEventListener('eip6963:announceProvider', onEvm);
361
+ host.removeEventListener('TIP6963:announceProvider', onTron);
208
362
  }
209
363
 
210
- const wallets = [...found.values()];
364
+ const tronWallets = [...announcedTron.values()];
365
+ if (tronWallets.length === 0) {
366
+ // `window.tronLink` is the older name of `window.tron`; the two are the same wallet.
367
+ const bare = [host.tron, host.tronLink].find(isProvider);
368
+ if (bare) {
369
+ tronWallets.push({ id: 'injected-tron', name: 'TronLink', kind: 'tron', provider: bare });
370
+ }
371
+ }
372
+
373
+ // A provider that is TronLink's must not be listed again as an Ethereum wallet.
374
+ const wallets = [...announcedEvm.values()].filter(
375
+ (wallet) => !tronWallets.some((tron) => tron.provider === wallet.provider)
376
+ );
211
377
  const ethereum = host.ethereum as (Eip1193Provider & { providers?: unknown }) | undefined;
212
378
  if (isProvider(ethereum)) {
213
379
  const list: unknown[] =
214
380
  Array.isArray(ethereum.providers) && ethereum.providers.length > 0 ? ethereum.providers : [ethereum];
215
381
  list.filter(isProvider).forEach((provider, index) => {
216
- if (!wallets.some((wallet) => wallet.provider === provider)) {
217
- wallets.push({ id: `injected-${index}`, name: legacyName(provider), provider });
382
+ const known = [...wallets, ...tronWallets].some((wallet) => wallet.provider === provider);
383
+ if (!known) {
384
+ wallets.push({ id: `injected-${index}`, name: legacyName(provider), kind: 'evm', provider });
218
385
  }
219
386
  });
220
387
  }
221
- return wallets;
388
+ return [...wallets, ...tronWallets];
389
+ }
390
+
391
+ /** An account from an `eth_accounts`-style answer, as `0x` and 40 lowercase hex characters. */
392
+ function evmAccountOf(raw: unknown): string | null {
393
+ return typeof raw === 'string' && /^0x[0-9a-fA-F]{40}$/.test(raw) ? raw.toLowerCase() : null;
394
+ }
395
+
396
+ /**
397
+ * Ask TronLink to share its account. TronLink answers `['T…']` (base58). An older TronLink does not
398
+ * know `eth_requestAccounts` (error 4200): it has its own `tron_requestAccounts`, which answers
399
+ * `{ code, message }` and puts the account in `tronWeb`.
400
+ */
401
+ async function requestTronAccount(provider: TronLinkProvider): Promise<string | null> {
402
+ let accounts: unknown;
403
+ try {
404
+ accounts = await provider.request({ method: 'eth_requestAccounts' });
405
+ } catch (error) {
406
+ if ((error as { code?: number } | null)?.code !== 4200) {
407
+ throw error;
408
+ }
409
+ const answer = (await provider.request({ method: 'tron_requestAccounts' })) as { code?: number; message?: string } | '' | null;
410
+ if (!answer) {
411
+ throw new Error('TronLink is locked: unlock it and try again');
412
+ }
413
+ if (answer.code === 4001) {
414
+ throw Object.assign(new Error('You said no in TronLink.'), { code: 4001 });
415
+ }
416
+ if (answer.code !== 200) {
417
+ throw new Error(answer.message || 'TronLink did not connect');
418
+ }
419
+ accounts = [provider.tronWeb ? provider.tronWeb.defaultAddress?.base58 : undefined];
420
+ }
421
+ return tronAccountOf(Array.isArray(accounts) ? accounts[0] : undefined);
222
422
  }
223
423
 
224
424
  /**
@@ -226,10 +426,61 @@ export async function discoverInjectedWallets(options: WalletDiscoveryOptions =
226
426
  * Rejects when the user says no (the provider's error, code 4001) or when no account is shared.
227
427
  */
228
428
  export async function connectWallet(wallet: InjectedWallet): Promise<Signer> {
429
+ if (wallet.kind === 'tron') {
430
+ const account = await requestTronAccount(wallet.provider as TronLinkProvider);
431
+ if (!account) {
432
+ throw new Error('the wallet did not share an account');
433
+ }
434
+ return createTronLinkSigner(wallet.provider as TronLinkProvider, account);
435
+ }
229
436
  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)) {
437
+ const account = evmAccountOf(Array.isArray(accounts) ? accounts[0] : undefined);
438
+ if (!account) {
232
439
  throw new Error('the wallet did not share an account');
233
440
  }
234
- return createWalletSigner(wallet.provider, first);
441
+ return createWalletSigner(wallet.provider, account);
442
+ }
443
+
444
+ /** A signer for `account` on `wallet`: `personal_sign` for MetaMask and Trust Wallet, `signMessageV2` for TronLink. */
445
+ export function createSignerFor(wallet: InjectedWallet, account: string): Signer {
446
+ return wallet.kind === 'tron'
447
+ ? createTronLinkSigner(wallet.provider as TronLinkProvider, account)
448
+ : createWalletSigner(wallet.provider, account);
449
+ }
450
+
451
+ /** The first account in an answer of this wallet (`accountsChanged`), as a Clutch address; `null` when there is none. */
452
+ export function walletAccountFrom(wallet: InjectedWallet, accounts: unknown): string | null {
453
+ const first = Array.isArray(accounts) ? accounts[0] : undefined;
454
+ return wallet.kind === 'tron' ? tronAccountOf(first) : evmAccountOf(first);
455
+ }
456
+
457
+ /**
458
+ * The account a wallet already shares with this page, as a Clutch address. It never opens a
459
+ * prompt, so an app can use it to connect again by itself on the next visit. `null` when the wallet
460
+ * shares none (locked, or the site was never allowed). A TronLink that allowed the site earlier has
461
+ * its `tronWeb` ready; one that did not has `tronWeb` `false`.
462
+ */
463
+ export async function sharedWalletAccount(wallet: InjectedWallet): Promise<string | null> {
464
+ if (wallet.kind === 'tron') {
465
+ const tronWeb = (wallet.provider as TronLinkProvider).tronWeb;
466
+ return !tronWeb || tronWeb.ready === false ? null : tronAccountOf(tronWeb.defaultAddress?.base58);
467
+ }
468
+ return walletAccountFrom(wallet, await wallet.provider.request({ method: 'eth_accounts' }));
469
+ }
470
+
471
+ /**
472
+ * Call `listener` with the new account (a Clutch address) when the person switches account in the
473
+ * wallet, and with `null` when the wallet stops sharing this site (locked, or disconnected).
474
+ * Returns a function that stops listening.
475
+ */
476
+ export function watchWalletAccounts(wallet: InjectedWallet, listener: (account: string | null) => void): () => void {
477
+ const provider = wallet.provider;
478
+ if (!provider.on) {
479
+ return () => {};
480
+ }
481
+ const onAccountsChanged = (accounts: unknown): void => listener(walletAccountFrom(wallet, accounts));
482
+ provider.on('accountsChanged', onAccountsChanged);
483
+ return () => {
484
+ provider.removeListener?.('accountsChanged', onAccountsChanged);
485
+ };
235
486
  }