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 +7 -0
- package/README.md +6 -6
- package/dist/signers.d.ts +72 -6
- package/dist/signers.js +237 -23
- package/package.json +1 -1
- package/src/signers.ts +277 -26
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)`
|
|
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
|
|
76
|
-
*
|
|
77
|
-
* is added when no announced wallet is that same provider.
|
|
78
|
-
*
|
|
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
|
|
9
|
-
* a signature. A key in memory is one kind of signer (`createLocalSigner`, in sdk.ts); a
|
|
10
|
-
* another (`createWalletSigner`, here). Everywhere the SDK
|
|
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
|
|
13
|
-
* (EIP-191), which hashes `"\x19Ethereum Signed Message:\n" + length + text`.
|
|
14
|
-
*
|
|
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
|
|
109
|
-
*
|
|
110
|
-
* is added when no announced wallet is that same provider.
|
|
111
|
-
*
|
|
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
|
|
119
|
-
const
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
|
157
|
-
if (
|
|
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,
|
|
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
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
|
|
11
|
-
* a signature. A key in memory is one kind of signer (`createLocalSigner`, in sdk.ts); a
|
|
12
|
-
* another (`createWalletSigner`, here). Everywhere the SDK
|
|
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
|
|
15
|
-
* (EIP-191), which hashes `"\x19Ethereum Signed Message:\n" + length + text`.
|
|
16
|
-
*
|
|
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
|
|
179
|
-
*
|
|
180
|
-
* is added when no announced wallet is that same provider.
|
|
181
|
-
*
|
|
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
|
|
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',
|
|
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',
|
|
360
|
+
host.removeEventListener('eip6963:announceProvider', onEvm);
|
|
361
|
+
host.removeEventListener('TIP6963:announceProvider', onTron);
|
|
208
362
|
}
|
|
209
363
|
|
|
210
|
-
const
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
231
|
-
if (
|
|
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,
|
|
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
|
}
|