@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.
- package/LICENSE +21 -0
- package/README.md +705 -0
- package/dist/client.d.ts +55 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +224 -0
- package/dist/client.js.map +1 -0
- package/dist/detect.d.ts +45 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +238 -0
- package/dist/detect.js.map +1 -0
- package/dist/errors.d.ts +75 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +120 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/mutation.d.ts +46 -0
- package/dist/mutation.d.ts.map +1 -0
- package/dist/mutation.js +67 -0
- package/dist/mutation.js.map +1 -0
- package/dist/provider.d.ts +159 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +142 -0
- package/dist/provider.js.map +1 -0
- package/dist/react.d.ts +76 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +225 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +391 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +38 -0
- package/dist/types.js.map +1 -0
- package/dist/vanilla.d.ts +58 -0
- package/dist/vanilla.d.ts.map +1 -0
- package/dist/vanilla.js +161 -0
- package/dist/vanilla.js.map +1 -0
- package/package.json +70 -0
- package/src/client.ts +342 -0
- package/src/detect.ts +278 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +97 -0
- package/src/mutation.ts +109 -0
- package/src/provider.ts +229 -0
- package/src/react.ts +375 -0
- package/src/types.ts +440 -0
- 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.
|