@joeywallet/wallet-sdk 0.2.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +705 -0
  3. package/dist/client.d.ts +55 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +224 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/detect.d.ts +45 -0
  8. package/dist/detect.d.ts.map +1 -0
  9. package/dist/detect.js +238 -0
  10. package/dist/detect.js.map +1 -0
  11. package/dist/errors.d.ts +75 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +120 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/index.d.ts +13 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +13 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/mutation.d.ts +46 -0
  20. package/dist/mutation.d.ts.map +1 -0
  21. package/dist/mutation.js +67 -0
  22. package/dist/mutation.js.map +1 -0
  23. package/dist/provider.d.ts +159 -0
  24. package/dist/provider.d.ts.map +1 -0
  25. package/dist/provider.js +142 -0
  26. package/dist/provider.js.map +1 -0
  27. package/dist/react.d.ts +76 -0
  28. package/dist/react.d.ts.map +1 -0
  29. package/dist/react.js +225 -0
  30. package/dist/react.js.map +1 -0
  31. package/dist/types.d.ts +391 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/types.js +38 -0
  34. package/dist/types.js.map +1 -0
  35. package/dist/vanilla.d.ts +58 -0
  36. package/dist/vanilla.d.ts.map +1 -0
  37. package/dist/vanilla.js +161 -0
  38. package/dist/vanilla.js.map +1 -0
  39. package/package.json +70 -0
  40. package/src/client.ts +342 -0
  41. package/src/detect.ts +278 -0
  42. package/src/errors.ts +133 -0
  43. package/src/index.ts +97 -0
  44. package/src/mutation.ts +109 -0
  45. package/src/provider.ts +229 -0
  46. package/src/react.ts +375 -0
  47. package/src/types.ts +440 -0
  48. package/src/vanilla.ts +239 -0
package/src/types.ts ADDED
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Public types for `@joeywallet/wallet-sdk`.
3
+ *
4
+ * These mirror the injected provider's surface field for field
5
+ * (`apps/extension/src/provider/`). Where the two could drift, the provider
6
+ * wins: this package is a convenience layer, not a second definition of the
7
+ * protocol.
8
+ *
9
+ * Nothing here imports from `xrpl`. A published `.d.ts` that did would fail to
10
+ * resolve for the (many) dapps that talk to a wallet without depending on
11
+ * xrpl.js, and `skipLibCheck` only hides that, it does not fix it. Instead the
12
+ * transaction argument of every signing method is generic over
13
+ * {@link TransactionLike}, so a caller who *does* have xrpl.js can pass a
14
+ * `Payment` / `TrustSet` / `SubmittableTransaction` and keep full checking on
15
+ * their own side. `test/xrpl-types.test.ts` pins that assignability against the
16
+ * real xrpl.js types.
17
+ */
18
+
19
+ /* ------------------------------------------------------------------- chains */
20
+
21
+ /** CAIP-2 chain ids, as XLS-72d defines them. Mainnet 0, testnet 1, devnet 2. */
22
+ export const JOEY_CHAINS = ['xrpl:0', 'xrpl:1', 'xrpl:2'] as const
23
+
24
+ export type JoeyChain = (typeof JOEY_CHAINS)[number]
25
+
26
+ export interface JoeyNetwork {
27
+ chain: JoeyChain
28
+ /** The XRPL `NetworkID`. Same number as the chain id suffix. */
29
+ networkId: number
30
+ /** The wallet's own name for the network, e.g. `mainnet`. */
31
+ name: string
32
+ }
33
+
34
+ export function isJoeyChain(value: unknown): value is JoeyChain {
35
+ return typeof value === 'string' && (JOEY_CHAINS as readonly string[]).includes(value)
36
+ }
37
+
38
+ /** `xrpl:0` for network id 0, and so on. Throws for anything else. */
39
+ export function chainForNetworkId(networkId: number): JoeyChain {
40
+ const chain = `xrpl:${networkId}`
41
+ if (!isJoeyChain(chain)) {
42
+ throw new RangeError(`Unrecognised XRPL network id: ${networkId}`)
43
+ }
44
+ return chain
45
+ }
46
+
47
+ /** The `NetworkID` a chain id refers to, or `null` when it is not an XRPL chain. */
48
+ export function networkIdForChain(chain: string): number | null {
49
+ if (!isJoeyChain(chain)) return null
50
+ return Number(chain.slice('xrpl:'.length))
51
+ }
52
+
53
+ /* ----------------------------------------------------------------- accounts */
54
+
55
+ /** An account as the wallet reports it. Never carries secret material. */
56
+ export interface JoeyAccount {
57
+ /** Classic `r...` address. */
58
+ address: string
59
+ /** Hex-encoded public key. Absent for a watch-only account, which cannot sign. */
60
+ publicKey?: string
61
+ /** The nickname the user gave the account, when they chose to share it. */
62
+ label?: string
63
+ }
64
+
65
+ /* ------------------------------------------------------- XRPL JSON primitives */
66
+
67
+ export interface IssuedCurrencyAmount {
68
+ /** Three-character code or 40-character hex. */
69
+ currency: string
70
+ issuer: string
71
+ /** Decimal string. Never a JS number — XRPL values exceed float64 precision. */
72
+ value: string
73
+ }
74
+
75
+ export interface MPTAmount {
76
+ mpt_issuance_id: string
77
+ value: string
78
+ }
79
+
80
+ /** A bare string is drops of XRP. */
81
+ export type Amount = string | IssuedCurrencyAmount | MPTAmount
82
+
83
+ export interface Memo {
84
+ Memo: {
85
+ /** Hex-encoded. */
86
+ MemoData?: string
87
+ MemoType?: string
88
+ MemoFormat?: string
89
+ }
90
+ }
91
+
92
+ export interface Signer {
93
+ Signer: {
94
+ Account: string
95
+ TxnSignature: string
96
+ SigningPubKey: string
97
+ }
98
+ }
99
+
100
+ export interface PathStep {
101
+ account?: string
102
+ currency?: string
103
+ issuer?: string
104
+ type?: number
105
+ type_hex?: string
106
+ }
107
+
108
+ export type Path = PathStep[]
109
+
110
+ /**
111
+ * The common fields of every XRPL transaction, used only as a generic
112
+ * constraint.
113
+ *
114
+ * `Flags` is deliberately `number | object` rather than a flags interface:
115
+ * xrpl.js models per-transaction flags as separate interfaces, and a TypeScript
116
+ * interface is not assignable to an index-signature type, so anything narrower
117
+ * here would reject a real `Payment`.
118
+ */
119
+ export interface TransactionLike {
120
+ TransactionType: string
121
+ Account?: string
122
+ Fee?: string
123
+ Sequence?: number
124
+ AccountTxnID?: string
125
+ Flags?: number | object
126
+ LastLedgerSequence?: number
127
+ Memos?: Memo[]
128
+ NetworkID?: number
129
+ Signers?: Signer[]
130
+ SourceTag?: number
131
+ SigningPubKey?: string
132
+ TicketSequence?: number
133
+ TxnSignature?: string
134
+ }
135
+
136
+ /**
137
+ * A transaction written inline, with no xrpl.js types to hand. The index
138
+ * signature is what lets an object literal carry `Destination`, `Amount` and
139
+ * the rest without an excess-property error.
140
+ */
141
+ export interface AnyTransaction extends TransactionLike {
142
+ [field: string]: unknown
143
+ }
144
+
145
+ /* ---------------------------------------------------------- method arguments */
146
+
147
+ export interface ConnectParams {
148
+ /** Chain the dapp wants. Rejected with 4902 when it is not an XRPL chain. */
149
+ chain?: JoeyChain
150
+ /**
151
+ * Only return accounts this origin has already been granted, with no prompt.
152
+ * Resolves with an empty `accounts` array rather than an error, so it says
153
+ * nothing about whether a wallet is installed, locked, or in use.
154
+ */
155
+ silent?: boolean
156
+ /** Your dapp's name, shown on the approval screen. Up to 128 characters. */
157
+ name?: string
158
+ /**
159
+ * An `https:` or `data:` URL for your dapp's icon, shown beside the name.
160
+ * Anything else is ignored rather than rendered.
161
+ */
162
+ icon?: string
163
+ }
164
+
165
+ /**
166
+ * The fields every signing method accepts on top of its own.
167
+ *
168
+ * Both are optional and both are worth sending. Omitting `account` is only safe
169
+ * for an origin the user granted exactly one address; omitting `chain` means
170
+ * you are signing whatever network the wallet happens to be on.
171
+ */
172
+ export interface SigningContextParams {
173
+ /**
174
+ * Which granted address signs. Defaults to the first the user granted.
175
+ *
176
+ * A user may grant several, so send this whenever your transaction carries an
177
+ * `Account`. The wallet refuses to sign a transaction whose `Account` is not
178
+ * the signing address — `INVALID_PARAMS` (-32602), before the user is
179
+ * prompted — rather than producing a valid signature over somebody else's
180
+ * transaction and resolving as if it had worked.
181
+ *
182
+ * `signTransactionFor` is the exception, and there `tx_signer` says who
183
+ * signs: a multisign entry is by definition a signature over a transaction
184
+ * belonging to another account.
185
+ */
186
+ account?: string
187
+ /**
188
+ * The chain you believe you are on.
189
+ *
190
+ * When it is not the chain the wallet is on, the request is refused with
191
+ * `CHAIN_DISCONNECTED` (4901) rather than signed. Joey has no
192
+ * `switchNetwork`: a page-driven, wallet-wide network switch is a phishing
193
+ * surface, so the user changes network in the wallet and the dapp is told
194
+ * through `networkChanged`.
195
+ */
196
+ chain?: JoeyChain
197
+ }
198
+
199
+ export interface ConnectResult {
200
+ accounts: JoeyAccount[]
201
+ /** `null` when the wallet granted no accounts, so there is no chain to report. */
202
+ chain: JoeyChain | null
203
+ networkId: number | null
204
+ }
205
+
206
+ export interface SignTransactionParams<TTx extends TransactionLike = AnyTransaction>
207
+ extends SigningContextParams {
208
+ tx_json: TTx
209
+ /** Let the wallet fill Fee / Sequence / LastLedgerSequence. Default true. */
210
+ autofill?: boolean
211
+ }
212
+
213
+ export interface SignTransactionResult {
214
+ /**
215
+ * The transaction as signed — decoded back out of `tx_blob`, not echoed from
216
+ * what you sent.
217
+ *
218
+ * That is the point of it: it carries the `Fee`, `Sequence` and
219
+ * `LastLedgerSequence` the wallet filled in, the `SigningPubKey` and
220
+ * `TxnSignature` it produced, and any normalisation the serialiser applied.
221
+ * If it does not say what you expected, the bytes are what it says and not
222
+ * what you sent.
223
+ */
224
+ tx_json: Record<string, unknown>
225
+ /** Hex-encoded signed transaction blob, ready to submit. */
226
+ tx_blob: string
227
+ /** Hash of the signed blob. Not a confirmation on its own. */
228
+ hash: string
229
+ }
230
+
231
+ export interface SignAndSubmitTransactionResult extends SignTransactionResult {
232
+ /** Preliminary engine result, e.g. `tesSUCCESS`. Not final until validated. */
233
+ engine_result?: string
234
+ engine_result_message?: string
235
+ }
236
+
237
+ export interface SignTransactionForParams<TTx extends TransactionLike = AnyTransaction>
238
+ extends SigningContextParams {
239
+ /**
240
+ * The address whose signature is being produced, which is *not* the
241
+ * transaction's `Account` — that stays the multisigned account.
242
+ *
243
+ * It must be one of the addresses the user granted this origin; the wallet
244
+ * answers `UNAUTHORIZED` (4100) rather than substituting one of its own.
245
+ */
246
+ tx_signer: string
247
+ tx_json: TTx
248
+ /**
249
+ * **Ignored on this method.** Joey never autofills a multisign entry, and
250
+ * passing `true` does not make it.
251
+ *
252
+ * The field is here because the parameter shape is shared with the other
253
+ * signing methods, not because it does anything. Your `tx_json` is signed
254
+ * exactly as you sent it, so it must already carry `Fee`, `Sequence` and
255
+ * `LastLedgerSequence` — otherwise you get a real signature over a
256
+ * transaction `rippled` will not accept, and no error until you submit it.
257
+ *
258
+ * The reason is that a multisign signature is one of several over *identical
259
+ * bytes*. All three fields are inside the signed bytes, so two signers who
260
+ * approve a few seconds apart would read two different `LastLedgerSequence`
261
+ * values and the assembled transaction would validate at most one of their
262
+ * signatures. The `Fee` is worse: the rule is `base_fee x (1 + signatures)`,
263
+ * a wallet contributes one signature and cannot know how many others the
264
+ * signer list requires, and a coordinator cannot raise a `Fee` afterwards
265
+ * without discarding every signature it has already collected. The one party
266
+ * who can choose these is the coordinator assembling the transaction — you.
267
+ */
268
+ autofill?: boolean
269
+ }
270
+
271
+ /**
272
+ * N independent transactions, one approval, signed in order.
273
+ *
274
+ * **This is not XLS-56 `Batch`, and the two must not be confused.** A `Batch`
275
+ * is a single transaction that carries others inside `RawTransactions` and
276
+ * commits them atomically on-ledger; Joey refuses to sign one for a website
277
+ * (see {@link JOEY_DAPP_FORBIDDEN_TRANSACTION_TYPES}) because its approval
278
+ * screen renders the outer transaction and a user cannot consent to inner ones
279
+ * they were never shown. `signTransactionBulk` is the opposite arrangement:
280
+ * ordinary, separate transactions, each rendered on its own page of the
281
+ * approval, each signed on its own, with no on-ledger atomicity at all. If
282
+ * transaction 3 fails, 1 and 2 have still happened.
283
+ */
284
+ export interface SignTransactionBulkParams<TTx extends TransactionLike = AnyTransaction>
285
+ extends SigningContextParams {
286
+ /** At most `MAX_BULK_TRANSACTIONS` entries; the wallet rejects a longer list. */
287
+ tx_list: Array<{ tx_json: TTx }>
288
+ autofill?: boolean
289
+ /**
290
+ * Whether the wallet broadcasts each transaction after signing it, or hands
291
+ * the signed blobs back for you to submit.
292
+ *
293
+ * Required, with no default, because the two are not interchangeable and a
294
+ * dapp that guesses wrong either double-spends or never spends. Joey mobile
295
+ * defaults this to `true` over WalletConnect and the extension defaults it to
296
+ * `false`; state your intent and neither default applies to you.
297
+ */
298
+ submit: boolean
299
+ }
300
+
301
+ /**
302
+ * What became of one transaction of a bulk request that failed part way.
303
+ *
304
+ * The five values divide on two questions only the wallet can answer: did this
305
+ * transaction get a definite answer, and is the replay protection it holds —
306
+ * its sequence number, or its ticket — still reachable?
307
+ *
308
+ * - `submitted` — validated `tesSUCCESS`. It happened; `hash` is on the ledger.
309
+ * - `failed` — a definite answer that is not success. `engine_result` says
310
+ * which. Resubmitting this blob is pointless.
311
+ * - `unknown` — **do not treat this as `failed`.** Either the submission got
312
+ * no answer at all, or it sits behind one that did not. It may yet be
313
+ * validated, so resubmitting is not safe. Resolve it by its `hash` first.
314
+ * - `signed` — signed, never broadcast, and still submittable exactly as it
315
+ * stands: it spends a ticket nothing touched, or it holds the sequence
316
+ * number the account is now at. Submit the `signed` entries in the order
317
+ * they appear — they are a chain.
318
+ * - `stranded` — signed, never broadcast, and dead. The sequence it holds is
319
+ * either already consumed or sits behind a gap this batch will never fill,
320
+ * so it can never apply. Discard it and ask the user again.
321
+ *
322
+ * The last two are decided per entry against the account's actual sequence, not
323
+ * per batch off the failing transaction's code — the code alone is right only
324
+ * for a contiguous run the wallet numbered itself, and both a ticket and a
325
+ * `Sequence` you set yourself break that assumption, in opposite directions.
326
+ */
327
+ export type BulkEntryStatus = 'submitted' | 'failed' | 'unknown' | 'signed' | 'stranded'
328
+
329
+ /** One entry of {@link SignTransactionBulkFailure.results}. */
330
+ export interface BulkEntryResult extends SignTransactionResult {
331
+ status: BulkEntryStatus
332
+ /** The ledger's own token for this transaction, when it produced one. */
333
+ engine_result?: string
334
+ engine_result_message?: string
335
+ }
336
+
337
+ /**
338
+ * The `data` on the error a partially-executed `signTransactionBulk` rejects
339
+ * with.
340
+ *
341
+ * A bulk request with `submit: true` signs every transaction before it
342
+ * broadcasts any of them, so the blobs exist whatever happens at index 3 and
343
+ * you get all of them back. Resume from `failedIndex` rather than asking the
344
+ * user to approve the whole batch again.
345
+ *
346
+ * ```ts
347
+ * try {
348
+ * await joey.signTransactionBulk({ tx_list, submit: true })
349
+ * } catch (error) {
350
+ * const data = (error as JoeyRpcError).data as SignTransactionBulkFailure | undefined
351
+ * if (data) {
352
+ * // data.results[i].status tells you what to do with entry i.
353
+ * }
354
+ * }
355
+ * ```
356
+ *
357
+ * Joey mobile rejects a bulk request over WalletConnect with the same
358
+ * `failedIndex` — zero-based, every earlier transaction succeeded, and the one
359
+ * thing that transfers between the two wallets unchanged. The record carrying
360
+ * it does not: mobile's `data` is a JSON *string* (WalletConnect types error
361
+ * `data` as one) holding `{failedIndex, signedTxs}`, where `signedTxs` is bare
362
+ * `tx_json` with no `status` on it, and its `message` is the engine token
363
+ * alone. Branch on the container and the array name; do not write one handler
364
+ * for both and expect it to parse.
365
+ */
366
+ export interface SignTransactionBulkFailure {
367
+ /**
368
+ * Zero-based index of the first entry that did not succeed.
369
+ *
370
+ * The error's `message` names the same number the same way — "transaction at
371
+ * index 2 of 5 did not succeed: tecUNFUNDED_PAYMENT" — so the sentence and
372
+ * the field cannot be read as disagreeing. It said "transaction 2 of 5" for
373
+ * that case until this was written, which is the third transaction and the
374
+ * one wording that can be read two ways.
375
+ */
376
+ failedIndex: number
377
+ /** Every transaction in the batch, in the order you sent them. */
378
+ results: BulkEntryResult[]
379
+ }
380
+
381
+ /**
382
+ * Sign-in modes.
383
+ *
384
+ * One, and it is the safe one: `caip122` signs a human-readable CAIP-122 /
385
+ * EIP-4361 string under a non-transaction domain separator, so the signature is
386
+ * cryptographically incapable of being a transaction signature.
387
+ *
388
+ * **`xaman` has been removed.** It signed the `{TransactionType:'SignIn'}`
389
+ * pseudo-transaction, and the property that made it safe — `rippled` has no
390
+ * such type, so the blob is unsubmittable — is exactly why it could not be
391
+ * produced: `ripple-binary-codec` has no `SignIn` either, so serialising one
392
+ * threw, every time, *after* the user had approved. The wallet refuses
393
+ * `mode: 'xaman'` by name rather than silently signing a CAIP-122 message in
394
+ * its place, so an existing Xaman integration gets one clear error instead of a
395
+ * result with no `tx_blob` in it.
396
+ */
397
+ export type SignInMode = 'caip122'
398
+
399
+ export interface SignInParams {
400
+ /** Default, and the only value. */
401
+ mode?: SignInMode
402
+ /** Human-readable line the wallet shows. At most 512 characters. */
403
+ statement?: string
404
+ /** The wallet generates one when omitted. At most 128 characters. */
405
+ nonce?: string
406
+ /**
407
+ * Up to 16 URI strings naming the scope you are asking for.
408
+ *
409
+ * Shown on the approval screen and written into the message's `Resources:`
410
+ * section, so they are part of what the signature covers: rebuild the message
411
+ * with the same list, in the same order, to verify one.
412
+ */
413
+ resources?: string[]
414
+ }
415
+
416
+ export interface SignInResult {
417
+ address: string
418
+ publicKey: string
419
+ /** Hex-encoded signature. */
420
+ signature: string
421
+ /** The exact string that was signed. Rebuild it to verify the signature. */
422
+ message: string
423
+ }
424
+
425
+ /* -------------------------------------------------------------------- events */
426
+
427
+ export interface JoeyEventMap {
428
+ /** The origin became authorised. */
429
+ connect: { accounts: JoeyAccount[]; chain: JoeyChain | null }
430
+ /** The origin lost authorisation, or the wallet locked. */
431
+ disconnect: { reason?: string }
432
+ /** The granted account set changed. Empty means the grant was revoked. */
433
+ accountsChanged: JoeyAccount[]
434
+ /** `null` when the wallet reported a chain this SDK does not recognise. */
435
+ networkChanged: JoeyNetwork | null
436
+ }
437
+
438
+ export type JoeyEventName = keyof JoeyEventMap
439
+
440
+ export type JoeyEventListener<K extends JoeyEventName> = (payload: JoeyEventMap[K]) => void
package/src/vanilla.ts ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * `@joeywallet/wallet-sdk/vanilla`.
3
+ *
4
+ * A connection session with a subscribe/getState store, for pages that have no
5
+ * framework. It holds the two pieces of state every dapp ends up re-writing by
6
+ * hand — the connected account and the current network — and keeps them correct
7
+ * when the user switches account or network in the wallet.
8
+ */
9
+ import type { Joey } from './client.js'
10
+ import { getJoey, waitForJoey } from './detect.js'
11
+ import { JOEY_ERROR_CODES, JoeyRpcError } from './errors.js'
12
+ import type { ConnectParams, JoeyNetwork } from './types.js'
13
+
14
+ export interface JoeySessionState {
15
+ /** `null` while detection is still running, and if it never finds a provider. */
16
+ joey: Joey | null
17
+ isAvailable: boolean
18
+ isReady: boolean
19
+ isConnecting: boolean
20
+ accounts: string[]
21
+ /** The first granted address. `null` when this origin has no grant. */
22
+ account: string | null
23
+ network: JoeyNetwork | null
24
+ error: JoeyRpcError | null
25
+ }
26
+
27
+ export interface JoeySession {
28
+ getState(): JoeySessionState
29
+ /** Called immediately with the current state, then on every change. */
30
+ subscribe(listener: (state: JoeySessionState) => void): () => void
31
+ connect(params?: ConnectParams): Promise<string | null>
32
+ disconnect(): Promise<void>
33
+ /** Removes every listener this session registered. */
34
+ destroy(): void
35
+ }
36
+
37
+ export interface CreateJoeySessionOptions {
38
+ /** Try a silent reconnect once the provider is found. Default true. */
39
+ autoConnect?: boolean
40
+ /** How long to wait for a late-injected provider. Default 3000ms. */
41
+ detectTimeoutMs?: number
42
+ }
43
+
44
+ export function createJoeySession(options: CreateJoeySessionOptions = {}): JoeySession {
45
+ const { autoConnect = true, detectTimeoutMs = 3000 } = options
46
+
47
+ const initial = getJoey()
48
+ let state: JoeySessionState = {
49
+ joey: initial,
50
+ isAvailable: initial !== null,
51
+ isReady: initial !== null,
52
+ isConnecting: false,
53
+ accounts: [],
54
+ account: null,
55
+ network: null,
56
+ error: null,
57
+ }
58
+
59
+ const listeners = new Set<(state: JoeySessionState) => void>()
60
+ const teardown: Array<() => void> = []
61
+ let destroyed = false
62
+
63
+ const set = (patch: Partial<JoeySessionState>): void => {
64
+ const next = { ...state, ...patch }
65
+ // `account` is always derived, never set directly, so the two can't drift.
66
+ next.account = next.accounts[0] ?? null
67
+ state = next
68
+ for (const listener of listeners) listener(state)
69
+ }
70
+
71
+ const attach = (joey: Joey): void => {
72
+ teardown.push(
73
+ joey.on('accountsChanged', (accounts) =>
74
+ set({ accounts: accounts.map((account) => account.address) }),
75
+ ),
76
+ )
77
+ teardown.push(joey.on('networkChanged', (network) => set({ network })))
78
+ teardown.push(
79
+ joey.on('connect', (result) =>
80
+ set({ accounts: result.accounts.map((account) => account.address) }),
81
+ ),
82
+ )
83
+ teardown.push(joey.on('disconnect', () => set({ accounts: [] })))
84
+ }
85
+
86
+ const hydrate = async (joey: Joey): Promise<void> => {
87
+ const [accounts, network] = await Promise.all([
88
+ joey.getAccounts().catch(() => [] as string[]),
89
+ joey.getNetwork().catch(() => null),
90
+ ])
91
+ if (!destroyed) set({ accounts, network })
92
+ }
93
+
94
+ const adopt = async (joey: Joey): Promise<void> => {
95
+ if (destroyed) return
96
+ set({ joey, isAvailable: true, isReady: true })
97
+ attach(joey)
98
+ if (autoConnect) {
99
+ try {
100
+ const result = await joey.connect({ silent: true })
101
+ if (destroyed) return
102
+ if (result.accounts.length > 0) {
103
+ set({ accounts: result.accounts.map((account) => account.address) })
104
+ const network = await joey.getNetwork().catch(() => null)
105
+ if (!destroyed) set({ network })
106
+ return
107
+ }
108
+ } catch {
109
+ // Not yet authorised. Expected on a first visit.
110
+ }
111
+ }
112
+ await hydrate(joey)
113
+ }
114
+
115
+ if (initial !== null) {
116
+ void adopt(initial)
117
+ } else {
118
+ void waitForJoey({ timeoutMs: detectTimeoutMs })
119
+ .then(adopt)
120
+ .catch(() => {
121
+ if (!destroyed) set({ isReady: true })
122
+ })
123
+ }
124
+
125
+ return {
126
+ getState: () => state,
127
+
128
+ subscribe(listener) {
129
+ listeners.add(listener)
130
+ listener(state)
131
+ return () => listeners.delete(listener)
132
+ },
133
+
134
+ async connect(params) {
135
+ const joey = state.joey ?? getJoey()
136
+ if (joey === null) {
137
+ const error = new JoeyRpcError(
138
+ JOEY_ERROR_CODES.DISCONNECTED,
139
+ 'Joey Wallet is not installed, or its provider has not been injected into this page.',
140
+ )
141
+ set({ error })
142
+ throw error
143
+ }
144
+ set({ isConnecting: true, error: null })
145
+ try {
146
+ const result = await joey.connect(params)
147
+ const accounts = result.accounts.map((account) => account.address)
148
+ const network = await joey.getNetwork().catch(() => state.network)
149
+ set({ accounts, network, isConnecting: false })
150
+ return accounts[0] ?? null
151
+ } catch (cause) {
152
+ const error = JoeyRpcError.from(cause)
153
+ set({ isConnecting: false, error })
154
+ throw error
155
+ }
156
+ },
157
+
158
+ async disconnect() {
159
+ const joey = state.joey
160
+ if (joey === null) return
161
+ await joey.disconnect()
162
+ set({ accounts: [] })
163
+ },
164
+
165
+ destroy() {
166
+ destroyed = true
167
+ for (const off of teardown.splice(0)) off()
168
+ listeners.clear()
169
+ },
170
+ }
171
+ }
172
+
173
+ export interface BindConnectButtonOptions {
174
+ /** Text while disconnected. Default "Connect Joey". */
175
+ connectLabel?: string
176
+ /** Rendered with the connected address. Default is a truncated address. */
177
+ connectedLabel?(address: string): string
178
+ /** Text when no provider was found. Default "Install Joey". */
179
+ notInstalledLabel?: string
180
+ /** Where to send a user with no wallet. Opened in a new tab when clicked. */
181
+ installUrl?: string
182
+ onError?(error: JoeyRpcError): void
183
+ }
184
+
185
+ /**
186
+ * Wire a `<button>` to a session: label, disabled state and click handler.
187
+ *
188
+ * The whole point of the vanilla entry point — this is the twenty lines every
189
+ * script-tag dapp writes, and gets subtly wrong around the not-installed case.
190
+ */
191
+ export function bindConnectButton(
192
+ button: HTMLButtonElement,
193
+ session: JoeySession,
194
+ options: BindConnectButtonOptions = {},
195
+ ): () => void {
196
+ const {
197
+ connectLabel = 'Connect Joey',
198
+ connectedLabel = (address) => `${address.slice(0, 6)}…${address.slice(-4)}`,
199
+ notInstalledLabel = 'Install Joey',
200
+ installUrl,
201
+ onError,
202
+ } = options
203
+
204
+ const render = (state: JoeySessionState): void => {
205
+ if (!state.isReady) {
206
+ button.disabled = true
207
+ button.textContent = connectLabel
208
+ return
209
+ }
210
+ if (!state.isAvailable) {
211
+ button.disabled = installUrl === undefined
212
+ button.textContent = notInstalledLabel
213
+ return
214
+ }
215
+ button.disabled = state.isConnecting
216
+ button.textContent = state.account !== null ? connectedLabel(state.account) : connectLabel
217
+ }
218
+
219
+ const onClick = (): void => {
220
+ const state = session.getState()
221
+ if (!state.isAvailable) {
222
+ if (installUrl !== undefined) window.open(installUrl, '_blank', 'noopener,noreferrer')
223
+ return
224
+ }
225
+ // `session.connect` already stored the error in state; this catch only
226
+ // exists so the rejection is not unhandled.
227
+ void session.connect().catch((error: unknown) => {
228
+ onError?.(JoeyRpcError.from(error))
229
+ })
230
+ }
231
+
232
+ button.addEventListener('click', onClick)
233
+ const unsubscribe = session.subscribe(render)
234
+
235
+ return () => {
236
+ button.removeEventListener('click', onClick)
237
+ unsubscribe()
238
+ }
239
+ }