@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/README.md ADDED
@@ -0,0 +1,705 @@
1
+ # @joeywallet/wallet-sdk
2
+
3
+ TypeScript SDK for the [Joey Wallet](https://joeywallet.xyz) browser extension, a
4
+ self-custody XRP Ledger wallet.
5
+
6
+ - **Zero runtime dependencies.** Nothing is pulled into your bundle but this
7
+ package.
8
+ - **Synchronous detection.** `isJoeyAvailable()` and `getJoey()` answer without a
9
+ message round trip, so a cold extension worker never reads as "not installed".
10
+ - **Promises, not envelopes.** Every method resolves a value or throws
11
+ `JoeyRpcError`.
12
+ - **Standards-first.** Joey registers under
13
+ [XLS-72d / Wallet Standard](https://github.com/XRPLF/XRPL-Standards/discussions/206)
14
+ with chains `xrpl:0` / `xrpl:1` / `xrpl:2` and uses EIP-1193 error codes
15
+ verbatim. This SDK is a convenience layer over the injected provider, not a
16
+ replacement for either surface.
17
+
18
+ ```bash
19
+ npm install @joeywallet/wallet-sdk
20
+ # pnpm add @joeywallet/wallet-sdk
21
+ # yarn add @joeywallet/wallet-sdk
22
+ ```
23
+
24
+ Requires an ESM bundler. `react` is an optional peer dependency, needed only for
25
+ `@joeywallet/wallet-sdk/react`.
26
+
27
+ ---
28
+
29
+ ## Detect
30
+
31
+ ```ts
32
+ import { getJoey, isJoeyAvailable, waitForJoey } from '@joeywallet/wallet-sdk'
33
+
34
+ // Synchronous. No message is sent to the extension.
35
+ if (isJoeyAvailable()) {
36
+ const joey = getJoey()! // never null when isJoeyAvailable() is true
37
+ }
38
+ ```
39
+
40
+ `getJoey()` returns `Joey | null` **synchronously**, reading `window.joey` and
41
+ `window.xrpl.joey`. It is also safe to `await` — awaiting a non-promise costs
42
+ one microtask.
43
+
44
+ Your bundle can execute before the extension has finished injecting its
45
+ provider. When that happens, wait for the announcement instead of polling:
46
+
47
+ ```ts
48
+ try {
49
+ const joey = await waitForJoey({ timeoutMs: 3000 })
50
+ } catch {
51
+ // No Joey on this page. Show your "install" call to action.
52
+ }
53
+ ```
54
+
55
+ `waitForJoey()` resolves immediately when the provider is already present, so it
56
+ is safe to use as your only detection call. It settles on the provider's own
57
+ CAIP-294 `wallet_announce` and Wallet Standard `register-wallet` events — and,
58
+ because a wallet that installed before your bundle ran has already dispatched
59
+ both, it also dispatches the app-side prompts (`wallet_prompt` and
60
+ `wallet-standard:app-ready`) so that wallet announces itself again.
61
+
62
+ ### Why detection never awaits the wallet
63
+
64
+ Wallet aggregators give each wallet roughly one second to prove it exists. Joey
65
+ runs its logic in a Manifest V3 background service worker, which Chrome
66
+ terminates after 30 seconds idle; a detection scheme that asks the worker "are
67
+ you there?" pays a cold start on the first call and loses that race
68
+ intermittently. So detection only ever reads page globals.
69
+
70
+ ---
71
+
72
+ ## Connect
73
+
74
+ ```ts
75
+ import { getJoey, isUserRejection } from '@joeywallet/wallet-sdk'
76
+
77
+ const joey = getJoey()
78
+ if (joey === null) throw new Error('Joey is not installed')
79
+
80
+ const { accounts, chain } = await joey.connect({
81
+ // Shown on the approval screen. Without them the user is asked to trust an
82
+ // origin and nothing else. `icon` must be `https:` or `data:image/`.
83
+ name: 'Example Exchange',
84
+ icon: 'https://example.com/icon.png',
85
+ })
86
+ const account = accounts[0]
87
+
88
+ console.log(account?.address) // rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w
89
+ console.log(chain) // 'xrpl:0'
90
+ ```
91
+
92
+ To reconnect on page load without opening an approval window, pass `silent`. It
93
+ resolves with an **empty** `accounts` array when the origin was never
94
+ authorised, rather than throwing — so it reveals nothing about whether a wallet
95
+ is installed, locked, or in use:
96
+
97
+ ```ts
98
+ const { accounts } = await joey.connect({ silent: true })
99
+ if (accounts.length > 0) setAccount(accounts[0])
100
+ ```
101
+
102
+ `joey.disconnect()` revokes this origin's access. `joey.accounts` and
103
+ `joey.isConnected()` read the current grant synchronously, with no round trip.
104
+
105
+ ---
106
+
107
+ ## Send a payment
108
+
109
+ `signAndSubmitTransaction` signs and submits in one approval. Amounts in XRP are
110
+ **drops** (1 XRP = 1,000,000 drops) as a string.
111
+
112
+ ```ts
113
+ const result = await joey.signAndSubmitTransaction({
114
+ tx_json: {
115
+ TransactionType: 'Payment',
116
+ Account: account.address,
117
+ Destination: 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe',
118
+ Amount: '1000000', // 1 XRP
119
+ DestinationTag: 42,
120
+ },
121
+ })
122
+
123
+ console.log(result.hash, result.engine_result) // 'A1B2…', 'tesSUCCESS'
124
+ ```
125
+
126
+ An issued currency uses the object form:
127
+
128
+ ```ts
129
+ Amount: { currency: 'USD', issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', value: '25' }
130
+ ```
131
+
132
+ Joey fills `Fee`, `Sequence` and `LastLedgerSequence` for you. Pass
133
+ `autofill: false` if you have already set them.
134
+
135
+ To get the signed blob back without submitting it, use `signTransaction`:
136
+
137
+ ```ts
138
+ const { tx_blob, hash, tx_json } = await joey.signTransaction({ tx_json: payment })
139
+ ```
140
+
141
+ `tx_json` in the result is the transaction **as signed** — decoded back out of
142
+ `tx_blob`, not echoed from what you sent. It carries the `Fee`, `Sequence` and
143
+ `LastLedgerSequence` the wallet filled in, the `SigningPubKey` and
144
+ `TxnSignature` it produced, and any normalisation the serialiser applied. That
145
+ is what you want to log; if it does not say what you expected, the bytes are
146
+ what it says.
147
+
148
+ ### Choosing the account, and the chain
149
+
150
+ A user may grant your origin more than one address. When they have, sign with
151
+ the one your transaction names — otherwise the wallet uses the first address it
152
+ granted you and the signature does not match the transaction's `Account`:
153
+
154
+ ```ts
155
+ await joey.signTransaction({
156
+ account: account.address,
157
+ chain: 'xrpl:0',
158
+ tx_json: payment,
159
+ })
160
+ ```
161
+
162
+ `chain` is a guard, not a request. If the wallet is on a different network the
163
+ call is refused with `CHAIN_DISCONNECTED` (4901) rather than signed. There is
164
+ deliberately no `switchNetwork`: a page-driven, wallet-wide network switch is a
165
+ phishing surface, so the user changes network in the wallet and your dapp is
166
+ told through the `networkChanged` event.
167
+
168
+ ### How long a call can take
169
+
170
+ ```ts
171
+ import { APPROVAL_TIMEOUT_MS, REQUEST_TIMEOUT_MS } from '@joeywallet/wallet-sdk'
172
+ ```
173
+
174
+ Anything that needs an approval — every signing method, `signIn`, and a
175
+ non-silent `connect` — is waiting on a person and may legitimately be pending
176
+ for `APPROVAL_TIMEOUT_MS` (300,000 ms), plus a few seconds of the page's own
177
+ backstop. Everything else answers within `REQUEST_TIMEOUT_MS` (30,000 ms) or the
178
+ wallet is wedged. Do not put a thirty-second timeout around `signTransaction`:
179
+ you will cancel on a user who was still reading.
180
+
181
+ ### Using xrpl.js types
182
+
183
+ This package does not import from `xrpl` — a published `.d.ts` that did would
184
+ fail to resolve for dapps that do not depend on xrpl.js. Every signing method is
185
+ generic instead, so if you *do* have xrpl.js, annotate your value and keep full
186
+ checking:
187
+
188
+ ```ts
189
+ import type { Payment } from 'xrpl'
190
+
191
+ const payment: Payment = { TransactionType: 'Payment', /* … */ }
192
+ await joey.signAndSubmitTransaction({ tx_json: payment })
193
+ ```
194
+
195
+ ---
196
+
197
+ ## Multisign and bulk signing
198
+
199
+ ```ts
200
+ // One signature toward a multisigned transaction. `tx_signer` is the address
201
+ // signing; the transaction's own Account stays the multisigned account. It has
202
+ // to be an address the user granted you — the wallet refuses with 4100 rather
203
+ // than substituting one of its own.
204
+ const { tx_blob, hash } = await joey.signTransactionFor({
205
+ tx_signer: account.address,
206
+ tx_json: { TransactionType: 'Payment', Account: 'rMultisigAccount', /* … */ },
207
+ })
208
+ ```
209
+
210
+ **`signTransactionFor` does not autofill, and `autofill: true` does not make it.**
211
+ The flag exists on the type because the signing methods share a parameter shape;
212
+ on this one it is ignored. Your `tx_json` must already carry `Fee`, `Sequence`
213
+ and `LastLedgerSequence`, or you get a real signature over a transaction
214
+ `rippled` will refuse — with no error until you submit it.
215
+
216
+ The reason is that a multisign signature is one of several over *identical
217
+ bytes*, and all three fields are inside those bytes. Two signers approving a few
218
+ seconds apart would read two different `LastLedgerSequence` values, and the
219
+ assembled transaction would validate at most one of their signatures. The `Fee`
220
+ is worse: the rule is `base_fee × (1 + signatures)`, a wallet contributes one
221
+ signature and cannot know how many others the signer list requires, and raising
222
+ the `Fee` afterwards discards every signature already collected. The coordinator
223
+ assembling the transaction is the only party that can choose these — that is
224
+ you.
225
+
226
+ ```ts
227
+ import { MAX_BULK_TRANSACTIONS } from '@joeywallet/wallet-sdk'
228
+
229
+ // Up to MAX_BULK_TRANSACTIONS (32), one approval, signed in order.
230
+ const results = await joey.signTransactionBulk({
231
+ tx_list: [{ tx_json: trustSet }, { tx_json: payment }],
232
+ submit: true,
233
+ })
234
+
235
+ for (const entry of results) console.log(entry.hash)
236
+ ```
237
+
238
+ `submit` has no default and the type requires it, because signing and submitting
239
+ are not interchangeable and a dapp that guesses wrong either double-spends or
240
+ never spends. `true` signs every transaction and then broadcasts them strictly
241
+ in order; `false` signs them and broadcasts **nothing**, handing the blobs back
242
+ for you to submit.
243
+
244
+ One approval covers the whole batch and one password unlocks it — the user pages
245
+ through every transaction and then decides once. They cannot approve some and
246
+ refuse others, on this wallet or on Joey mobile: it is one queue entry, one
247
+ approve, one reject. On a Ledger it is still one password, but N confirmations
248
+ on the device, one per transaction, which is what a hardware wallet is for.
249
+
250
+ The wallet numbers the batch **up front**: one reading of the ledger, `Sequence`
251
+ counting up from the account's next number, and 15 more ledgers of validity per
252
+ position so the transaction submitted last is not the one with the least time
253
+ left. A `Sequence` you set yourself is kept as it is and is not renumbered.
254
+
255
+ Every entry also gets **5 extra ledgers per entry beyond the first**, shared
256
+ across the batch. That one is not about position: with `submit: true` the whole
257
+ batch is signed before any of it is broadcast, so the clock starts at that
258
+ single reading and entry 0 is the one with the least time and the longest wait.
259
+ On a Ledger that wait is one physical confirmation per transaction. So entry `i`
260
+ of an `n`-entry batch is signed with
261
+
262
+ ```
263
+ LastLedgerSequence = ledger_current + 20 + 5 * (n - 1) + 15 * i
264
+ ```
265
+
266
+ ### How long the wallet watches, and what `unknown` means at the tail
267
+
268
+ The wallet follows each submission until its `LastLedgerSequence` passes — the
269
+ ledger index is the only thing entitled to say a transaction failed — but it
270
+ stops watching after **10 minutes** on any one entry, because this runs in an
271
+ MV3 service worker with an approval window open.
272
+
273
+ Those two numbers meet in long batches. Ten minutes is about 150 ledgers at a
274
+ four-second close, while a full 32-entry batch gives entry 0 a window of
275
+ `20 + 5·31 = 175` ledgers and entry 31 one of `640` — roughly 43 minutes. So in
276
+ a batch that size, the wallet's watch is what ends first, for every entry.
277
+
278
+ When it does, that entry comes back `status: 'unknown'`, and so does every entry
279
+ behind it that was never broadcast. **This is not a failure and must not be
280
+ retried as one.** The transaction was submitted, the wallet stopped watching,
281
+ and it may still be validated — resolve it by `hash` and only then decide. The
282
+ entries behind an `unknown` are `unknown` for the same reason: nothing about
283
+ their sequence numbers can be settled until that one is.
284
+
285
+ The widths are deliberate in this direction. A shorter window would expire as
286
+ `tefMAX_LEDGER`, which is definite and irrecoverable and would strand the whole
287
+ tail with it; stopping the watch gives up information, not money.
288
+
289
+ ### When a batch fails part way
290
+
291
+ It rejects, and the error's `data` is a `SignTransactionBulkFailure`:
292
+
293
+ ```ts
294
+ import type { JoeyRpcError, SignTransactionBulkFailure } from '@joeywallet/wallet-sdk'
295
+
296
+ try {
297
+ await joey.signTransactionBulk({ tx_list, submit: true })
298
+ } catch (e) {
299
+ const data = (e as JoeyRpcError).data as SignTransactionBulkFailure | undefined
300
+ if (!data) throw e
301
+
302
+ for (const entry of data.results) {
303
+ switch (entry.status) {
304
+ case 'submitted': break // on the ledger; entry.hash is real
305
+ case 'failed': break // definite; entry.engine_result says why
306
+ case 'unknown': break // may yet be validated — resolve by hash
307
+ case 'signed': break // never broadcast; submit it as it stands
308
+ case 'stranded': break // never broadcast and now dead — re-sign
309
+ }
310
+ }
311
+ }
312
+ ```
313
+
314
+ Every blob comes back, including the ones that were never broadcast, so you
315
+ resume from `failedIndex` instead of asking the user to approve the batch again.
316
+
317
+ The `signed` / `stranded` split is the one thing you cannot work out for
318
+ yourself. The wallet reads the account's actual sequence once the batch stops
319
+ and walks the entries it never broadcast **in the order you would resubmit
320
+ them**: an entry is `signed` when the replay protection it holds is what the
321
+ ledger is at, and `stranded` when it is not — either already consumed, or behind
322
+ a gap this batch will never fill. In the ordinary case that follows the failing
323
+ code, because a `tec*` reached a ledger and consumed its sequence number while a
324
+ `tem*`/`tef*`/`tel*` consumed nothing. It does **not** follow the code once you
325
+ set your own `Sequence` values or use tickets, which is why it is computed
326
+ rather than inferred: a ticketed entry holds no sequence at all, so a failure
327
+ ahead of it leaves it perfectly submittable and it comes back `signed`; and two
328
+ entries you numbered identically can never both be `signed`. `unknown` settles
329
+ neither question and must not be treated as `failed`: the transaction may still
330
+ be validated, so resubmitting it is not safe.
331
+
332
+ Submit the `signed` entries in the order they appear. They are a chain — each
333
+ one's number only becomes current once the one before it has applied.
334
+
335
+ `failedIndex` is zero-based and every earlier transaction succeeded, and that
336
+ meaning is identical on Joey mobile over WalletConnect. **The record around it is
337
+ not.** Mobile rejects with `data` as a JSON *string* — WalletConnect types error
338
+ `data` as one — holding `{failedIndex, signedTxs}`, where `signedTxs` is an array
339
+ of bare `tx_json` carrying no `status`, and its `message` is the bare engine
340
+ token (`tecPATH_PARTIAL`) rather than a sentence. Here `data` is an object
341
+ holding `{failedIndex, results}`. A dapp integrating both wallets branches on the
342
+ container and on the array's name; `failedIndex` is what transfers unchanged.
343
+
344
+ The error's `message` counts the same way the field does:
345
+
346
+ ```
347
+ transaction at index 2 of 5 did not succeed: tecUNFUNDED_PAYMENT
348
+ ```
349
+
350
+ Index `2` is the *third* transaction, and the sentence says "at index" so it
351
+ cannot be read as anything else. Do not parse it — `failedIndex` and
352
+ `results[i].status` carry the same facts as data — but when you print it beside
353
+ the field, the two numbers agree.
354
+
355
+ The wallet's own approval window says the same thing to the user counting from
356
+ one ("Transaction 3 of 5"), because there is no zero-based field beside it on
357
+ that screen. Both are unambiguous; only the pair "transaction 2 of 5" next to
358
+ `failedIndex: 2` was not.
359
+
360
+ > **`signTransactionBulk` is not XLS-56 `Batch`.** They are different things and
361
+ > Joey keeps them apart deliberately. A `Batch` is a *single* transaction
362
+ > carrying others inside `RawTransactions`, committed atomically on-ledger; Joey
363
+ > refuses to sign one for a website, because its approval screen renders the
364
+ > outer transaction and a user cannot consent to inner ones they were never
365
+ > shown. `signTransactionBulk` is the opposite arrangement: ordinary, separate
366
+ > transactions, each rendered on its own page of one approval, each signed on
367
+ > its own — and with no atomicity at all. If transaction 3 fails, 1 and 2 have
368
+ > still happened.
369
+
370
+
371
+ ---
372
+
373
+ ## Sign in
374
+
375
+ `signIn` authenticates a user without a transaction. The default mode signs a
376
+ [CAIP-122](https://namespaces.chainagnostic.org/xrpl/caip122) message under a
377
+ non-transaction domain separator, so the signature is cryptographically
378
+ incapable of being replayed as a transaction signature.
379
+
380
+ ```ts
381
+ const result = await joey.signIn({
382
+ statement: 'Sign in to Example Exchange',
383
+ // nonce defaults to one the wallet generates; supply your own if your
384
+ // backend issues it.
385
+ })
386
+
387
+ await fetch('/api/session', {
388
+ method: 'POST',
389
+ headers: { 'content-type': 'application/json' },
390
+ body: JSON.stringify({
391
+ address: result.address,
392
+ publicKey: result.publicKey,
393
+ message: result.message,
394
+ signature: result.signature,
395
+ }),
396
+ })
397
+ ```
398
+
399
+ Verify the signature server-side against `result.message` and check the nonce,
400
+ the domain and the issue time inside that message before trusting it.
401
+
402
+ Pass `resources` to name the scope you are asking for. It is shown on the
403
+ approval screen and written into the message's `Resources:` section, so it is
404
+ covered by the signature — rebuild the message with the same list, in the same
405
+ order, when you verify:
406
+
407
+ ```ts
408
+ const result = await joey.signIn({
409
+ statement: 'Sign in to Example',
410
+ resources: ['https://example.com/terms', 'https://example.com/api'],
411
+ })
412
+ ```
413
+
414
+ There is no Xaman-compatible `mode: 'xaman'`. It signed the
415
+ `{TransactionType:'SignIn'}` pseudo-transaction, and the property that made that
416
+ safe — `rippled` has no such transaction type, so the blob is unsubmittable — is
417
+ also why it could never be produced: `ripple-binary-codec` has no `SignIn`
418
+ either, so serialising one throws. The wallet answers `-32602` naming the mode
419
+ rather than quietly signing a CAIP-122 message in its place, so an existing
420
+ Xaman integration gets one clear error instead of a result with no `tx_blob` in
421
+ it.
422
+
423
+ There is no `signMessage`. A bare signature over an arbitrary string carries no
424
+ domain, nonce or timestamp, which makes it replayable against another site;
425
+ `signIn` is the primitive to use instead.
426
+
427
+ ---
428
+
429
+ ## Events
430
+
431
+ ```ts
432
+ const off = joey.on('accountsChanged', (accounts) => {
433
+ // An empty array means the user revoked this origin.
434
+ setAccount(accounts[0] ?? null)
435
+ })
436
+
437
+ joey.on('networkChanged', (network) => setNetwork(network))
438
+ joey.on('disconnect', () => setAccount(null))
439
+
440
+ off() // or joey.off('accountsChanged', listener)
441
+ ```
442
+
443
+ | Event | Payload |
444
+ | ----------------- | ---------------------------------------- |
445
+ | `connect` | `{ accounts: JoeyAccount[], chain }` — on a *new* grant only; re-connecting an already-authorised origin emits nothing |
446
+ | `disconnect` | `{ reason?: string }` |
447
+ | `accountsChanged` | `JoeyAccount[]` |
448
+ | `networkChanged` | `JoeyNetwork \| null` |
449
+
450
+ Payloads are normalised by this SDK: the raw provider forwards whatever the
451
+ wallet sent, which may be bare address strings or `{ accounts: [...] }`.
452
+
453
+ Registering the same function twice gives you two subscriptions, and each needs
454
+ its own `off()` — the same rule `addEventListener` follows.
455
+
456
+ ---
457
+
458
+ ## Errors
459
+
460
+ Every method rejects with `JoeyRpcError { code, message, data? }`. Codes are
461
+ EIP-1193 numbers.
462
+
463
+ | Code | Meaning |
464
+ | ------- | --------------------------------------------------------- |
465
+ | `4001` | The user rejected the request |
466
+ | `4100` | This origin is not authorised for that method |
467
+ | `4200` | The wallet does not support that method |
468
+ | `4300` | The wallet is locked |
469
+ | `4900` | Not installed, or the provider is not connected |
470
+ | `4901` | The wallet is on a different chain than the one you asked for |
471
+ | `4902` | Not an XRPL chain id at all |
472
+ | `-32005`| Too many requests. Back off; do not retry in a loop |
473
+ | `-32600`| The request was not well formed |
474
+ | `-32602`| Malformed arguments |
475
+ | `-32603`| Anything the SDK could not classify |
476
+
477
+ `-32005` is reachable two ways and a dapp that ignores it looks broken in both:
478
+ the content script caps concurrent in-flight requests, and the wallet blocks an
479
+ origin whose user has rejected three requests in a row.
480
+
481
+ ```ts
482
+ import { JOEY_ERROR_CODES, JoeyRpcError, isUserRejection } from '@joeywallet/wallet-sdk'
483
+
484
+ try {
485
+ await joey.signAndSubmitTransaction({ tx_json })
486
+ } catch (error) {
487
+ if (isUserRejection(error)) {
488
+ return // the user said no; not an error worth reporting
489
+ }
490
+ if (error instanceof JoeyRpcError && error.code === JOEY_ERROR_CODES.LOCKED) {
491
+ return showBanner('Unlock Joey and try again.')
492
+ }
493
+ reportToSentry(error)
494
+ }
495
+ ```
496
+
497
+ Prefer `isUserRejection(error)` over `error.code === 4001`: it also matches
498
+ providers and adapter shims that only carry the word in the message. Note that
499
+ an unconnected origin is told `4001` rather than `4300` even when the wallet is
500
+ in fact locked — the lock state is deliberately not readable by a site the user
501
+ has not connected.
502
+
503
+ ---
504
+
505
+ ## React
506
+
507
+ ```bash
508
+ npm install @joeywallet/wallet-sdk react
509
+ ```
510
+
511
+ ```tsx
512
+ import { JoeyProvider } from '@joeywallet/wallet-sdk/react'
513
+
514
+ export function App() {
515
+ return (
516
+ <JoeyProvider autoConnect>
517
+ <Wallet />
518
+ </JoeyProvider>
519
+ )
520
+ }
521
+ ```
522
+
523
+ `autoConnect` reconnects *silently*: it returns only accounts the user has
524
+ already granted this origin and never opens an approval window on page load.
525
+
526
+ `useJoey()` gives you the connection state:
527
+
528
+ ```tsx
529
+ import { useJoey } from '@joeywallet/wallet-sdk/react'
530
+
531
+ function Wallet() {
532
+ const { isReady, isAvailable, account, network, connect, disconnect } = useJoey()
533
+
534
+ if (!isReady) return null
535
+ if (!isAvailable) return <a href="https://joeywallet.xyz">Install Joey</a>
536
+ if (account === null) return <button onClick={() => void connect()}>Connect</button>
537
+
538
+ return (
539
+ <div>
540
+ {/* network.name is 'Mainnet', 'Testnet' or 'Devnet'. */}
541
+ {account} on {network?.name}
542
+ <button onClick={() => void disconnect()}>Disconnect</button>
543
+ </div>
544
+ )
545
+ }
546
+ ```
547
+
548
+ The mutation hooks are shaped like react-query mutations, without depending on
549
+ react-query:
550
+
551
+ ```tsx
552
+ import { useSignAndSubmit } from '@joeywallet/wallet-sdk/react'
553
+
554
+ function Pay({ from }: { from: string }) {
555
+ const { mutate, isPending, error, data, reset } = useSignAndSubmit({
556
+ onSuccess: (result) => console.log(result.hash),
557
+ })
558
+
559
+ return (
560
+ <>
561
+ <button
562
+ disabled={isPending}
563
+ onClick={() =>
564
+ mutate({
565
+ tx_json: {
566
+ TransactionType: 'Payment',
567
+ Account: from,
568
+ Destination: 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe',
569
+ Amount: '1000000',
570
+ },
571
+ })
572
+ }
573
+ >
574
+ {isPending ? 'Approve in Joey…' : 'Send 1 XRP'}
575
+ </button>
576
+ {error && <p onClick={reset}>{error.message}</p>}
577
+ {data && <p>{data.hash}</p>}
578
+ </>
579
+ )
580
+ }
581
+ ```
582
+
583
+ `mutate` never rejects — read `error`. `mutateAsync` returns the promise if you
584
+ want to `await` it. A response from a superseded call is dropped, so a
585
+ double-click cannot render the first answer next to the second spinner.
586
+
587
+ Available hooks: `useConnect`, `useDisconnect`, `useSignTransaction`,
588
+ `useSignAndSubmit`, `useSignTransactionFor`, `useSignTransactionBulk`,
589
+ `useSignIn`, and `useJoeyMutation` for anything else.
590
+
591
+ ---
592
+
593
+ ## Without a framework
594
+
595
+ ```html
596
+ <button id="connect"></button>
597
+ <script type="module">
598
+ import { bindConnectButton, createJoeySession } from '@joeywallet/wallet-sdk/vanilla'
599
+
600
+ const session = createJoeySession() // detects, and silently reconnects
601
+ bindConnectButton(document.getElementById('connect'), session, {
602
+ installUrl: 'https://joeywallet.xyz',
603
+ onError: (error) => console.warn(error.message),
604
+ })
605
+
606
+ session.subscribe((state) => {
607
+ console.log(state.account, state.network?.name)
608
+ })
609
+ </script>
610
+ ```
611
+
612
+ `session.getState()` is synchronous; `session.subscribe(fn)` calls `fn`
613
+ immediately with the current state and again on every change, and returns an
614
+ unsubscribe function. Call `session.destroy()` when you tear the page down.
615
+
616
+ ---
617
+
618
+ ## Typing `window.joey`
619
+
620
+ This package does not declare `window.joey` globally, because doing so collides
621
+ with other XRPL wallet SDKs that declare `window.xrpl`. Declare it yourself if
622
+ you want it:
623
+
624
+ ```ts
625
+ import type { JoeyInjectedProvider } from '@joeywallet/wallet-sdk'
626
+
627
+ declare global {
628
+ interface Window {
629
+ joey?: JoeyInjectedProvider
630
+ }
631
+ }
632
+ ```
633
+
634
+ ---
635
+
636
+ ## Migrating from GemWallet
637
+
638
+ Use [`@joeywallet/gemwallet-compat`](../gemwallet-compat), which exports GemWallet's
639
+ exact function names and `{ type, result }` envelope over this SDK. Migration is
640
+ a one-line import change.
641
+
642
+ ---
643
+
644
+ ## Reference
645
+
646
+ ### `Joey`
647
+
648
+ | Member | Returns |
649
+ | ------ | ------- |
650
+ | `accounts` | granted addresses, synchronously |
651
+ | `chain` | `JoeyChain \| null`, synchronously |
652
+ | `isConnected()` | `boolean`, synchronously |
653
+ | `connect(params?)` | `{ accounts, chain, networkId }` |
654
+ | `disconnect()` | `void` |
655
+ | `getAccounts()` | `string[]` — `[]` for an unconnected origin, never an error |
656
+ | `getNetwork()` | `{ chain, networkId, name }` — `name` is `'Mainnet'` / `'Testnet'` / `'Devnet'` |
657
+ | `signTransaction({ tx_json, account?, chain?, autofill? })` | `{ tx_json, tx_blob, hash }` |
658
+ | `signAndSubmitTransaction(…same…)` | the above plus `engine_result`, `engine_result_message` |
659
+ | `signTransactionFor({ tx_signer, tx_json, account?, chain?, autofill? })` | `{ tx_json, tx_blob, hash }` |
660
+ | `signTransactionBulk({ tx_list, submit, account?, chain?, autofill? })` | `SignAndSubmitTransactionResult[]` — `engine_result` only when `submit: true` |
661
+ | `signIn(params?)` | `{ address, publicKey, signature, message?, tx_blob? }` |
662
+ | `request({ method, params })` | escape hatch for newer wallet methods |
663
+ | `on(event, listener)` / `off(event, listener)` | subscription |
664
+
665
+ ### Chains
666
+
667
+ | Chain | Network | `NetworkID` |
668
+ | -------- | ------- | ----------- |
669
+ | `xrpl:0` | Mainnet | 0 |
670
+ | `xrpl:1` | Testnet | 1 |
671
+ | `xrpl:2` | Devnet | 2 |
672
+
673
+ ### Transactions Joey will not sign for a dapp
674
+
675
+ Six types, rejected whichever method carries them and at every nesting level,
676
+ with a message saying so. Read the list rather than discovering it by rejection:
677
+
678
+ ```ts
679
+ import { JOEY_DAPP_FORBIDDEN_TRANSACTION_TYPES } from '@joeywallet/wallet-sdk'
680
+ ```
681
+
682
+ | Type | Why |
683
+ | ---- | --- |
684
+ | `SetRegularKey` | Grants permanent signing authority over the account. |
685
+ | `SignerListSet` | The same, by multisign. |
686
+ | `DelegateSet` | The same again (XLS-75). With `Payment` in its `Permissions`, a standing licence to drain every balance. |
687
+ | `AccountDelete` | Irreversible. |
688
+ | `SetHook` | Installs code that runs on every future transaction. |
689
+ | `Batch` | Carries other transactions inside `RawTransactions`, which the approval screen cannot render — see the note under bulk signing. |
690
+
691
+ Two more rules are not expressible as a type name and are enforced anyway:
692
+
693
+ - **`AccountSet` is conditionally refused.** It is permitted for routine flags
694
+ (`asfDefaultRipple` and the rest) and refused when it sets or clears one that
695
+ changes who controls the account — `asfDisableMaster`, `asfRequireAuth`,
696
+ `asfNoFreeze`, `asfDisallowXRP` and that family. A `SetRegularKey` plus a
697
+ disable-master `AccountSet` is permanent, unrevokable takeover.
698
+ - **Pseudo-transactions are refused.** `EnableAmendment`, `SetFee` and
699
+ `UNLModify` are written into a ledger by consensus; no account signs one, and
700
+ `rippled` rejects one submitted over the network.
701
+
702
+ None of these is distinguishable from an ordinary transaction in a confirmation
703
+ dialog someone is skimming, which is why they are refused rather than surfaced
704
+ for approval. Users perform them from the Joey UI, where the wording can be as
705
+ blunt as it needs to be.