@parity/product-sdk-host 0.17.0 → 0.18.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/dist/index.d.ts +45 -12
- package/dist/index.js +166 -69
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/accounts.ts +311 -109
- package/src/errors.ts +46 -0
- package/src/index.ts +2 -0
- package/src/truapi.ts +139 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parity/product-sdk-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Host container detection and storage access for Polkadot Desktop and Mobile environments",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"@polkadot-api/substrate-bindings": "^0.20.3",
|
|
28
28
|
"neverthrow": "^8.2.0",
|
|
29
29
|
"polkadot-api": "^2.1.6",
|
|
30
|
-
"@parity/product-sdk-
|
|
30
|
+
"@parity/product-sdk-errors": "0.2.0",
|
|
31
31
|
"@parity/result": "0.2.0",
|
|
32
|
-
"@parity/product-sdk-
|
|
32
|
+
"@parity/product-sdk-logger": "0.1.1"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"tsup": "^8.5.1",
|
package/src/accounts.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { decAnyMetadata, unifyMetadata } from "@polkadot-api/substrate-bindings";
|
|
26
|
-
import
|
|
26
|
+
import { errAsync, okAsync, ResultAsync } from "neverthrow";
|
|
27
27
|
import { AccountId, type PolkadotSigner } from "polkadot-api";
|
|
28
28
|
|
|
29
29
|
import type {
|
|
@@ -55,6 +55,7 @@ import type {
|
|
|
55
55
|
} from "@parity/truapi";
|
|
56
56
|
|
|
57
57
|
import { getClient, subscribeWithInterrupt } from "./transport.js";
|
|
58
|
+
import { HostResponseDecodeError } from "./errors.js";
|
|
58
59
|
import { fromHex, toHex, unwrapHostResult } from "./truapi.js";
|
|
59
60
|
import type { HostSubscription } from "./types.js";
|
|
60
61
|
|
|
@@ -223,6 +224,14 @@ export type VrfTranscriptItem = { [K in keyof WireVrfTranscriptItem]: Uint8Array
|
|
|
223
224
|
*/
|
|
224
225
|
export type VrfSignature = { [K in keyof WireVrfSignature]: Uint8Array };
|
|
225
226
|
|
|
227
|
+
/**
|
|
228
|
+
* A call's declared `Err` channel, plus {@link HostResponseDecodeError}: any
|
|
229
|
+
* host reply can fail to decode if the host and the product's `@parity/truapi`
|
|
230
|
+
* client are on different protocol versions, so every decoded call can surface
|
|
231
|
+
* it in addition to its own typed errors.
|
|
232
|
+
*/
|
|
233
|
+
export type WithDecodeError<E> = E | HostResponseDecodeError;
|
|
234
|
+
|
|
226
235
|
/**
|
|
227
236
|
* Accounts provider handle, backed by `truApi.account.*` / `truApi.signing.*`.
|
|
228
237
|
* Surfaces the user's wallet accounts, app-scoped product accounts, Ring VRF,
|
|
@@ -231,20 +240,29 @@ export type VrfSignature = { [K in keyof WireVrfSignature]: Uint8Array };
|
|
|
231
240
|
* Lookup methods return a neverthrow `ResultAsync` (use `.match(ok, err)`);
|
|
232
241
|
* the signer factories return a synchronous PAPI `PolkadotSigner`. The `err`
|
|
233
242
|
* channel carries truapi's canonical `CallErrorValue` envelope around the
|
|
234
|
-
* per-call versioned domain error, exactly as the generated client returns it
|
|
243
|
+
* per-call versioned domain error, exactly as the generated client returns it,
|
|
244
|
+
* plus a {@link HostResponseDecodeError} for the case where the host's reply
|
|
245
|
+
* cannot be decoded at all (a host/client protocol-version skew) — see
|
|
246
|
+
* {@link WithDecodeError}.
|
|
235
247
|
*/
|
|
236
248
|
export interface AccountsProvider {
|
|
237
249
|
getUserId(): ResultAsync<
|
|
238
250
|
{ primaryUsername: string },
|
|
239
|
-
scale.CallErrorValue<VersionedHostGetUserIdError
|
|
251
|
+
WithDecodeError<scale.CallErrorValue<VersionedHostGetUserIdError>>
|
|
240
252
|
>;
|
|
241
253
|
requestLogin(
|
|
242
254
|
reason?: string,
|
|
243
|
-
): ResultAsync<
|
|
255
|
+
): ResultAsync<
|
|
256
|
+
HostRequestLoginResponse,
|
|
257
|
+
WithDecodeError<scale.CallErrorValue<VersionedHostRequestLoginError>>
|
|
258
|
+
>;
|
|
244
259
|
getProductAccount(
|
|
245
260
|
dotNsIdentifier: string,
|
|
246
261
|
derivationIndex?: number,
|
|
247
|
-
): ResultAsync<
|
|
262
|
+
): ResultAsync<
|
|
263
|
+
ProductAccount,
|
|
264
|
+
WithDecodeError<scale.CallErrorValue<VersionedHostAccountGetError>>
|
|
265
|
+
>;
|
|
248
266
|
/**
|
|
249
267
|
* Register a ring-VRF key owned by the calling product.
|
|
250
268
|
*
|
|
@@ -259,7 +277,7 @@ export interface AccountsProvider {
|
|
|
259
277
|
ring: RingLocation,
|
|
260
278
|
): ResultAsync<
|
|
261
279
|
RingVrfPublicKey,
|
|
262
|
-
scale.CallErrorValue<VersionedHostAccountRegisterRingVrfKeyError
|
|
280
|
+
WithDecodeError<scale.CallErrorValue<VersionedHostAccountRegisterRingVrfKeyError>>
|
|
263
281
|
>;
|
|
264
282
|
/** List an owner's registered ring-VRF keys. */
|
|
265
283
|
listRingVrfKeys(
|
|
@@ -267,17 +285,20 @@ export interface AccountsProvider {
|
|
|
267
285
|
disclosure?: RingVrfKeyDisclosure,
|
|
268
286
|
): ResultAsync<
|
|
269
287
|
RegisteredRingVrfKey[],
|
|
270
|
-
scale.CallErrorValue<VersionedHostAccountListRingVrfKeysError
|
|
288
|
+
WithDecodeError<scale.CallErrorValue<VersionedHostAccountListRingVrfKeysError>>
|
|
271
289
|
>;
|
|
272
290
|
/** Derive a contextual alias with an explicitly registered ring-VRF key. */
|
|
273
291
|
getProductAccountAlias(
|
|
274
292
|
keyHandle: RingVrfKeyHandle,
|
|
275
293
|
context: ProductProofContext,
|
|
276
294
|
location: RingLocation,
|
|
277
|
-
): ResultAsync<
|
|
295
|
+
): ResultAsync<
|
|
296
|
+
ContextualAlias,
|
|
297
|
+
WithDecodeError<scale.CallErrorValue<VersionedHostAccountGetAliasError>>
|
|
298
|
+
>;
|
|
278
299
|
getLegacyAccounts(): ResultAsync<
|
|
279
300
|
HostAccount[],
|
|
280
|
-
scale.CallErrorValue<VersionedHostGetLegacyAccountsError
|
|
301
|
+
WithDecodeError<scale.CallErrorValue<VersionedHostGetLegacyAccountsError>>
|
|
281
302
|
>;
|
|
282
303
|
/**
|
|
283
304
|
* Generate a Ring VRF proof with an explicitly registered key, binding
|
|
@@ -288,7 +309,10 @@ export interface AccountsProvider {
|
|
|
288
309
|
context: ProductProofContext,
|
|
289
310
|
location: RingLocation,
|
|
290
311
|
message: Uint8Array,
|
|
291
|
-
): ResultAsync<
|
|
312
|
+
): ResultAsync<
|
|
313
|
+
RingVRFProof,
|
|
314
|
+
WithDecodeError<scale.CallErrorValue<VersionedHostAccountCreateProofError>>
|
|
315
|
+
>;
|
|
292
316
|
/**
|
|
293
317
|
* Sign `message` directly with an explicitly registered ring-VRF key.
|
|
294
318
|
*
|
|
@@ -299,7 +323,10 @@ export interface AccountsProvider {
|
|
|
299
323
|
ringVrfSign(
|
|
300
324
|
keyHandle: RingVrfKeyHandle,
|
|
301
325
|
message: Uint8Array,
|
|
302
|
-
): ResultAsync<
|
|
326
|
+
): ResultAsync<
|
|
327
|
+
Uint8Array,
|
|
328
|
+
WithDecodeError<scale.CallErrorValue<VersionedHostAccountRingVrfSignError>>
|
|
329
|
+
>;
|
|
303
330
|
/**
|
|
304
331
|
* Produce an sr25519 VRF signature from a product account (RFC-0023).
|
|
305
332
|
*
|
|
@@ -323,7 +350,10 @@ export interface AccountsProvider {
|
|
|
323
350
|
account: ProductAccountLookup,
|
|
324
351
|
transcriptLabel: Uint8Array,
|
|
325
352
|
items: VrfTranscriptItem[],
|
|
326
|
-
): ResultAsync<
|
|
353
|
+
): ResultAsync<
|
|
354
|
+
VrfSignature,
|
|
355
|
+
WithDecodeError<scale.CallErrorValue<VersionedHostAccountSignVrfError>>
|
|
356
|
+
>;
|
|
327
357
|
/**
|
|
328
358
|
* Build a `PolkadotSigner` for a product account. Signing routes through the
|
|
329
359
|
* host's `createTransaction` path: the host decodes the metadata and forwards
|
|
@@ -341,30 +371,57 @@ export interface AccountsProvider {
|
|
|
341
371
|
): HostSubscription;
|
|
342
372
|
}
|
|
343
373
|
|
|
374
|
+
/** The transaction-extension version used by V5 general transactions. */
|
|
375
|
+
const GENERAL_TX_EXT_VERSION = 5;
|
|
376
|
+
|
|
344
377
|
/**
|
|
345
|
-
*
|
|
378
|
+
* Choose the wire `txExtVersion` — the **transaction-extension** version the
|
|
379
|
+
* host must assemble under, which is distinct from the extrinsic *format*
|
|
380
|
+
* version (4 / 5).
|
|
381
|
+
*
|
|
382
|
+
* - `formatVersions` — `metadata.extrinsic.version`, the extrinsic *formats* the
|
|
383
|
+
* runtime accepts (e.g. `[4]`, `[5]`, `[4, 5]`).
|
|
384
|
+
* - `txExtVersions` — the keys of `metadata.extrinsic.signedExtensions`, the
|
|
385
|
+
* transaction-extension versions the runtime supports (its v16
|
|
386
|
+
* `transactionExtensionsByVersion`; always includes `0`).
|
|
346
387
|
*
|
|
347
|
-
* V4
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
388
|
+
* A V4 signed extrinsic always uses transaction-extension version `0` (a fixed
|
|
389
|
+
* sentinel, independent of what the extension map contains). Prefer V4 while it
|
|
390
|
+
* is offered — it carries the account signature in its envelope, and the host
|
|
391
|
+
* can build it. Only when V4 is absent do we fall to a V5 general transaction,
|
|
392
|
+
* whose transaction-extension version is `5` (the key the runtime and host
|
|
393
|
+
* agree on for the general format); require it to actually be present in the
|
|
394
|
+
* map rather than assuming it.
|
|
395
|
+
*
|
|
396
|
+
* Passing the extrinsic *format* number here was the bug (host-rust-core#528):
|
|
397
|
+
* `5` is both a format version and the general extension version, so it looked
|
|
398
|
+
* right, but the two are unrelated in general and the host decodes extension
|
|
399
|
+
* values by this number.
|
|
353
400
|
*/
|
|
354
|
-
function selectHostTxExtVersion(
|
|
355
|
-
|
|
401
|
+
function selectHostTxExtVersion(
|
|
402
|
+
formatVersions: readonly number[],
|
|
403
|
+
txExtVersions: readonly number[],
|
|
404
|
+
): number {
|
|
405
|
+
if (formatVersions.length === 0) {
|
|
356
406
|
throw new Error("No extrinsic version found in metadata");
|
|
357
407
|
}
|
|
358
|
-
if (
|
|
408
|
+
if (formatVersions.includes(4)) {
|
|
359
409
|
return 0;
|
|
360
410
|
}
|
|
361
|
-
|
|
411
|
+
if (txExtVersions.includes(GENERAL_TX_EXT_VERSION)) {
|
|
412
|
+
return GENERAL_TX_EXT_VERSION;
|
|
413
|
+
}
|
|
414
|
+
throw new Error(
|
|
415
|
+
`Runtime offers no V4 extrinsic and no transaction-extension version ${GENERAL_TX_EXT_VERSION} ` +
|
|
416
|
+
`(supported: ${txExtVersions.join(", ") || "none"}); cannot select a txExtVersion the host can assemble.`,
|
|
417
|
+
);
|
|
362
418
|
}
|
|
363
419
|
|
|
364
420
|
/** Derive the host's transaction-extension version from SCALE metadata. */
|
|
365
421
|
function deriveTxExtVersion(metadata: Uint8Array): number {
|
|
366
|
-
const
|
|
367
|
-
|
|
422
|
+
const extrinsic = unifyMetadata(decAnyMetadata(metadata)).extrinsic;
|
|
423
|
+
const txExtVersions = Object.keys(extrinsic.signedExtensions).map(Number);
|
|
424
|
+
return selectHostTxExtVersion(extrinsic.version, txExtVersions);
|
|
368
425
|
}
|
|
369
426
|
|
|
370
427
|
/** Internal seam so `import.meta.vitest` can stub the metadata decode. @internal */
|
|
@@ -400,6 +457,28 @@ function toWireProductAccountId({
|
|
|
400
457
|
return { dotNsIdentifier, derivationIndex: { tag: "Index", value: derivationIndex } };
|
|
401
458
|
}
|
|
402
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Route a thrown/rejected response-decode error onto the `Result` err channel.
|
|
462
|
+
*
|
|
463
|
+
* The truapi client catches a decode throw in its message handler and turns it
|
|
464
|
+
* into a promise rejection, then wraps each call with
|
|
465
|
+
* `ResultAsync.fromSafePromise`, which installs no rejection handler — so when
|
|
466
|
+
* the host's reply doesn't match the client's codec (a version skew), that
|
|
467
|
+
* rejection escapes the `Result` channel rather than landing on its err side,
|
|
468
|
+
* surfacing as a raw `RangeError`. Wrapping the call re-homes that rejection as
|
|
469
|
+
* a typed {@link HostResponseDecodeError} that names the call, while ok values
|
|
470
|
+
* and the call's own typed `Err` values pass through untouched.
|
|
471
|
+
*/
|
|
472
|
+
function guardDecode<T, E>(
|
|
473
|
+
call: string,
|
|
474
|
+
result: ResultAsync<T, E>,
|
|
475
|
+
): ResultAsync<T, E | HostResponseDecodeError> {
|
|
476
|
+
return ResultAsync.fromPromise(
|
|
477
|
+
Promise.resolve(result),
|
|
478
|
+
(cause) => new HostResponseDecodeError(call, cause),
|
|
479
|
+
).andThen((inner) => inner);
|
|
480
|
+
}
|
|
481
|
+
|
|
403
482
|
/** Build an {@link AccountsProvider} over a TruAPI client's `account` / `signing` domains. */
|
|
404
483
|
function adaptAccountsProvider(client: TrUApiClient): AccountsProvider {
|
|
405
484
|
const account = client.account;
|
|
@@ -407,98 +486,128 @@ function adaptAccountsProvider(client: TrUApiClient): AccountsProvider {
|
|
|
407
486
|
|
|
408
487
|
return {
|
|
409
488
|
getUserId() {
|
|
410
|
-
return
|
|
411
|
-
|
|
412
|
-
|
|
489
|
+
return guardDecode(
|
|
490
|
+
"getUserId",
|
|
491
|
+
account.getUserId().map((response) => ({
|
|
492
|
+
primaryUsername: response.primaryUsername,
|
|
493
|
+
})),
|
|
494
|
+
);
|
|
413
495
|
},
|
|
414
496
|
requestLogin(reason) {
|
|
415
|
-
return account.requestLogin({ reason });
|
|
497
|
+
return guardDecode("requestLogin", account.requestLogin({ reason }));
|
|
416
498
|
},
|
|
417
499
|
getProductAccount(dotNsIdentifier, derivationIndex = 0) {
|
|
418
|
-
return
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
500
|
+
return guardDecode(
|
|
501
|
+
"getProductAccount",
|
|
502
|
+
account
|
|
503
|
+
.getAccount({
|
|
504
|
+
productAccountId: toWireProductAccountId({
|
|
505
|
+
dotNsIdentifier,
|
|
506
|
+
derivationIndex,
|
|
507
|
+
}),
|
|
508
|
+
})
|
|
509
|
+
.map((response) => ({
|
|
510
|
+
publicKey: fromHex(response.account.publicKey),
|
|
511
|
+
dotNsIdentifier,
|
|
512
|
+
derivationIndex,
|
|
513
|
+
})),
|
|
514
|
+
);
|
|
427
515
|
},
|
|
428
516
|
registerRingVrfKey(index, ring) {
|
|
429
|
-
return
|
|
430
|
-
|
|
431
|
-
|
|
517
|
+
return guardDecode(
|
|
518
|
+
"registerRingVrfKey",
|
|
519
|
+
account
|
|
520
|
+
.registerRingVrfKey({ index: { tag: "Index", value: index }, ring })
|
|
521
|
+
.map(fromHex),
|
|
522
|
+
);
|
|
432
523
|
},
|
|
433
524
|
listRingVrfKeys(owner, disclosure = "Anonymized") {
|
|
434
|
-
return
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
525
|
+
return guardDecode(
|
|
526
|
+
"listRingVrfKeys",
|
|
527
|
+
account.listRingVrfKeys({ owner, disclosure }).map((keys) =>
|
|
528
|
+
keys.map((key) => ({
|
|
529
|
+
...key,
|
|
530
|
+
handle: key.handle as unknown as RingVrfKeyHandle,
|
|
531
|
+
publicKey: key.publicKey === undefined ? undefined : fromHex(key.publicKey),
|
|
532
|
+
})),
|
|
533
|
+
),
|
|
440
534
|
);
|
|
441
535
|
},
|
|
442
536
|
getProductAccountAlias(keyHandle, context, location) {
|
|
443
|
-
return
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
537
|
+
return guardDecode(
|
|
538
|
+
"getProductAccountAlias",
|
|
539
|
+
account
|
|
540
|
+
.getAccountAlias({
|
|
541
|
+
keyHandle: keyHandle as unknown as ProductAccountId,
|
|
542
|
+
context,
|
|
543
|
+
ringLocation: location,
|
|
544
|
+
})
|
|
545
|
+
.map((response) => ({
|
|
546
|
+
context: fromHex(response.context),
|
|
547
|
+
alias: fromHex(response.alias),
|
|
548
|
+
})),
|
|
549
|
+
);
|
|
453
550
|
},
|
|
454
551
|
getLegacyAccounts() {
|
|
455
|
-
return
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
552
|
+
return guardDecode(
|
|
553
|
+
"getLegacyAccounts",
|
|
554
|
+
account.getLegacyAccounts().map((response) =>
|
|
555
|
+
response.accounts.map((a) => ({
|
|
556
|
+
publicKey: fromHex(a.publicKey),
|
|
557
|
+
name: a.name,
|
|
558
|
+
})),
|
|
559
|
+
),
|
|
460
560
|
);
|
|
461
561
|
},
|
|
462
562
|
createRingVRFProof(keyHandle, context, location, message) {
|
|
463
|
-
return
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
563
|
+
return guardDecode(
|
|
564
|
+
"createRingVRFProof",
|
|
565
|
+
account
|
|
566
|
+
.createAccountProof({
|
|
567
|
+
keyHandle: keyHandle as unknown as ProductAccountId,
|
|
568
|
+
context,
|
|
569
|
+
ringLocation: location,
|
|
570
|
+
message: toHex(message),
|
|
571
|
+
})
|
|
572
|
+
.map((response) => ({
|
|
573
|
+
proof: fromHex(response.proof),
|
|
574
|
+
contextualAlias: {
|
|
575
|
+
context: fromHex(response.contextualAlias.context),
|
|
576
|
+
alias: fromHex(response.contextualAlias.alias),
|
|
577
|
+
},
|
|
578
|
+
ringIndex: response.ringIndex,
|
|
579
|
+
ringRevision: response.ringRevision,
|
|
580
|
+
})),
|
|
581
|
+
);
|
|
479
582
|
},
|
|
480
583
|
ringVrfSign(keyHandle, message) {
|
|
481
|
-
return
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
584
|
+
return guardDecode(
|
|
585
|
+
"ringVrfSign",
|
|
586
|
+
account
|
|
587
|
+
.ringVrfSign({
|
|
588
|
+
keyHandle: keyHandle as unknown as ProductAccountId,
|
|
589
|
+
message: toHex(message),
|
|
590
|
+
})
|
|
591
|
+
.map(fromHex),
|
|
592
|
+
);
|
|
487
593
|
},
|
|
488
594
|
signVrf(account_, transcriptLabel, items) {
|
|
489
|
-
return
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
595
|
+
return guardDecode(
|
|
596
|
+
"signVrf",
|
|
597
|
+
account
|
|
598
|
+
.signVrf({
|
|
599
|
+
account: toWireProductAccountId(account_),
|
|
600
|
+
transcriptLabel: toHex(transcriptLabel),
|
|
601
|
+
items: items.map(({ label, value }) => ({
|
|
602
|
+
label: toHex(label),
|
|
603
|
+
value: toHex(value),
|
|
604
|
+
})),
|
|
605
|
+
})
|
|
606
|
+
.map((response) => ({
|
|
607
|
+
preOutput: fromHex(response.preOutput),
|
|
608
|
+
proof: fromHex(response.proof),
|
|
496
609
|
})),
|
|
497
|
-
|
|
498
|
-
.map((response) => ({
|
|
499
|
-
preOutput: fromHex(response.preOutput),
|
|
500
|
-
proof: fromHex(response.proof),
|
|
501
|
-
}));
|
|
610
|
+
);
|
|
502
611
|
},
|
|
503
612
|
getProductAccountSigner(account_) {
|
|
504
613
|
const productAccountId = toWireProductAccountId(account_);
|
|
@@ -593,34 +702,44 @@ export async function getAccountsProvider(): Promise<AccountsProvider | null> {
|
|
|
593
702
|
}
|
|
594
703
|
|
|
595
704
|
if (import.meta.vitest) {
|
|
596
|
-
const { test, expect, vi } = import.meta.vitest;
|
|
705
|
+
const { test, expect, vi, describe } = import.meta.vitest;
|
|
597
706
|
|
|
598
|
-
test("host signing prefers V4 on a dual V4/V5 runtime", () => {
|
|
599
|
-
|
|
707
|
+
test("host signing prefers V4 (tx-ext version 0) on a dual V4/V5 runtime", () => {
|
|
708
|
+
// txExtVersion 0 regardless of what the extension map lists.
|
|
709
|
+
expect(selectHostTxExtVersion([4, 5], [0])).toBe(0);
|
|
600
710
|
});
|
|
601
711
|
|
|
602
|
-
test("host signing uses
|
|
603
|
-
|
|
712
|
+
test("host signing uses the general tx-ext version 5 when V4 is unavailable", () => {
|
|
713
|
+
// The value comes from the extension-version map, not the format list.
|
|
714
|
+
expect(selectHostTxExtVersion([5], [0, 5])).toBe(5);
|
|
604
715
|
});
|
|
605
716
|
|
|
606
717
|
test("host signing maps a V4-only runtime to the wire sentinel", () => {
|
|
607
|
-
expect(selectHostTxExtVersion([4])).toBe(0);
|
|
718
|
+
expect(selectHostTxExtVersion([4], [0])).toBe(0);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("host signing does not confuse extrinsic format 5 with a tx-ext version", () => {
|
|
722
|
+
// V5-only runtime that only supports tx-ext version 0 (no general
|
|
723
|
+
// extension set): the old code returned 5 (the format number); now this
|
|
724
|
+
// must throw rather than send an unsupported txExtVersion.
|
|
725
|
+
expect(() => selectHostTxExtVersion([5], [0])).toThrow(/no.*version 5/i);
|
|
608
726
|
});
|
|
609
727
|
|
|
610
728
|
test("host signing rejects metadata with no extrinsic version", () => {
|
|
611
|
-
expect(() => selectHostTxExtVersion([])).toThrow(
|
|
729
|
+
expect(() => selectHostTxExtVersion([], [0])).toThrow(
|
|
730
|
+
"No extrinsic version found in metadata",
|
|
731
|
+
);
|
|
612
732
|
});
|
|
613
733
|
|
|
614
734
|
/** Minimal fake of the truapi account/signing domains used to test the adapter. */
|
|
615
735
|
function makeFakeClient(opts: { onCall?: (method: string, args: unknown) => void } = {}) {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
});
|
|
736
|
+
// A real neverthrow `okAsync`, not a hand-rolled `{ map, match }` stub:
|
|
737
|
+
// a stub with no `.then` would be passed through un-awaited by
|
|
738
|
+
// `guardDecode`'s `Promise.resolve(result)`, so the tests would bypass
|
|
739
|
+
// the guard's real path. A genuine `ResultAsync` exercises it.
|
|
621
740
|
const method = (name: string, response: unknown) => (args: unknown) => {
|
|
622
741
|
opts.onCall?.(name, args);
|
|
623
|
-
return
|
|
742
|
+
return okAsync(response);
|
|
624
743
|
};
|
|
625
744
|
return {
|
|
626
745
|
account: {
|
|
@@ -1073,4 +1192,87 @@ if (import.meta.vitest) {
|
|
|
1073
1192
|
expect(signed).toEqual(fromHex("0xfeed"));
|
|
1074
1193
|
vi.restoreAllMocks();
|
|
1075
1194
|
});
|
|
1195
|
+
|
|
1196
|
+
describe("response-decode boundary (guardDecode)", () => {
|
|
1197
|
+
// A client whose `createAccountProof` returns a REAL neverthrow
|
|
1198
|
+
// `ResultAsync` — the hand-rolled `okMatch` fake can't reject, and
|
|
1199
|
+
// rejection (a thrown SCALE decode) is exactly what this boundary
|
|
1200
|
+
// exists to catch. `createRingVRFProof` is the reported call (#270).
|
|
1201
|
+
function clientWithProof(result: ResultAsync<unknown, unknown>): TrUApiClient {
|
|
1202
|
+
return {
|
|
1203
|
+
account: { createAccountProof: () => result },
|
|
1204
|
+
} as unknown as TrUApiClient;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const KEY_HANDLE = {
|
|
1208
|
+
dotNsIdentifier: "people.dot",
|
|
1209
|
+
derivationIndex: { tag: "Index", value: 0 },
|
|
1210
|
+
} as unknown as RingVrfKeyHandle;
|
|
1211
|
+
const CONTEXT = {
|
|
1212
|
+
productId: "app.dot",
|
|
1213
|
+
suffix: { tag: "Index", value: 0 },
|
|
1214
|
+
} as ProductProofContext;
|
|
1215
|
+
const RING: RingLocation = { chainId: "0x01", junctions: [] };
|
|
1216
|
+
const MESSAGE = new Uint8Array([1, 2, 3]);
|
|
1217
|
+
|
|
1218
|
+
const callProof = (result: ResultAsync<unknown, unknown>) =>
|
|
1219
|
+
adaptAccountsProvider(clientWithProof(result)).createRingVRFProof(
|
|
1220
|
+
KEY_HANDLE,
|
|
1221
|
+
CONTEXT,
|
|
1222
|
+
RING,
|
|
1223
|
+
MESSAGE,
|
|
1224
|
+
);
|
|
1225
|
+
|
|
1226
|
+
test("a thrown decode error (RangeError) becomes a HostResponseDecodeError naming the call", async () => {
|
|
1227
|
+
const rangeError = new RangeError("Offset is outside the bounds of the DataView");
|
|
1228
|
+
const result = await callProof(ResultAsync.fromSafePromise(Promise.reject(rangeError)));
|
|
1229
|
+
|
|
1230
|
+
expect(result.isErr()).toBe(true);
|
|
1231
|
+
const error = result._unsafeUnwrapErr();
|
|
1232
|
+
expect(error).toBeInstanceOf(HostResponseDecodeError);
|
|
1233
|
+
expect((error as HostResponseDecodeError).call).toBe("createRingVRFProof");
|
|
1234
|
+
// The original error is preserved as `cause` so a bug report can see it.
|
|
1235
|
+
expect((error as HostResponseDecodeError).cause).toBe(rangeError);
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
test("a synchronous throw in the response mapping is caught too", async () => {
|
|
1239
|
+
// e.g. a malformed hex field reaching `fromHex` inside `.map`.
|
|
1240
|
+
const result = await callProof(
|
|
1241
|
+
okAsync({
|
|
1242
|
+
proof: "not-hex",
|
|
1243
|
+
contextualAlias: { context: "0x01", alias: "0x02" },
|
|
1244
|
+
ringIndex: 0,
|
|
1245
|
+
ringRevision: 0,
|
|
1246
|
+
}),
|
|
1247
|
+
);
|
|
1248
|
+
expect(result.isErr()).toBe(true);
|
|
1249
|
+
expect(result._unsafeUnwrapErr()).toBeInstanceOf(HostResponseDecodeError);
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
test("a well-formed response passes through unchanged", async () => {
|
|
1253
|
+
const result = await callProof(
|
|
1254
|
+
okAsync({
|
|
1255
|
+
proof: "0xc0ffee",
|
|
1256
|
+
contextualAlias: { context: "0x01", alias: "0x02" },
|
|
1257
|
+
ringIndex: 3,
|
|
1258
|
+
ringRevision: 7,
|
|
1259
|
+
}),
|
|
1260
|
+
);
|
|
1261
|
+
expect(result.isOk()).toBe(true);
|
|
1262
|
+
const proof = result._unsafeUnwrap();
|
|
1263
|
+
expect(proof.proof).toEqual(fromHex("0xc0ffee"));
|
|
1264
|
+
expect(proof.ringIndex).toBe(3);
|
|
1265
|
+
expect(proof.ringRevision).toBe(7);
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
test("the call's own typed Err passes through, not wrapped as a decode error", async () => {
|
|
1269
|
+
const typedErr = { tag: "Domain", value: { tag: "RingNotFound" } };
|
|
1270
|
+
const result = await callProof(errAsync(typedErr));
|
|
1271
|
+
|
|
1272
|
+
expect(result.isErr()).toBe(true);
|
|
1273
|
+
const error = result._unsafeUnwrapErr();
|
|
1274
|
+
expect(error).not.toBeInstanceOf(HostResponseDecodeError);
|
|
1275
|
+
expect(error).toEqual(typedErr);
|
|
1276
|
+
});
|
|
1277
|
+
});
|
|
1076
1278
|
}
|
package/src/errors.ts
CHANGED
|
@@ -144,6 +144,38 @@ export class HostCallFailedError extends HostError {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A host call could not be processed to completion: the `ResultAsync` the
|
|
149
|
+
* truapi client returns rejected instead of resolving to an ok/err. The usual
|
|
150
|
+
* cause is a response the client's SCALE codec can't decode (a
|
|
151
|
+
* `RangeError: Offset is outside the bounds of the DataView`) because the host
|
|
152
|
+
* and the `@parity/truapi` version the product is built against disagree on the
|
|
153
|
+
* wire shape of that call — a protocol-version skew. A host channel that closed
|
|
154
|
+
* mid-call looks identical from here, so this does not assert the skew; the
|
|
155
|
+
* real error is preserved on {@link cause}.
|
|
156
|
+
*
|
|
157
|
+
* The truapi client catches the decode throw in its message handler and turns
|
|
158
|
+
* it into a promise rejection, then wraps the call with
|
|
159
|
+
* `ResultAsync.fromSafePromise`, which installs no rejection handler — so the
|
|
160
|
+
* rejection escapes the `Result` channel rather than landing on its err side.
|
|
161
|
+
* Without this boundary that surfaces as a raw `RangeError` with a stack naming
|
|
162
|
+
* neither the call nor the cause. This names the call, so a bug report has
|
|
163
|
+
* somewhere to start.
|
|
164
|
+
*/
|
|
165
|
+
export class HostResponseDecodeError extends HostError {
|
|
166
|
+
/** The host-API call whose response failed to decode, e.g. `"createRingVRFProof"`. */
|
|
167
|
+
readonly call: string;
|
|
168
|
+
|
|
169
|
+
constructor(call: string, cause: unknown) {
|
|
170
|
+
super(
|
|
171
|
+
`Could not process the host's response to ${call}: ${formatHostError(cause)}. The usual cause is a protocol-version skew between the host app and the @parity/truapi version this product is built against; a host channel that closed mid-call looks the same.`,
|
|
172
|
+
{ cause },
|
|
173
|
+
);
|
|
174
|
+
this.name = "HostResponseDecodeError";
|
|
175
|
+
this.call = call;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
147
179
|
/** Check whether a value is any {@link HostError}. */
|
|
148
180
|
export function isHostError(error: unknown): error is HostError {
|
|
149
181
|
return error instanceof HostError;
|
|
@@ -192,9 +224,23 @@ if (import.meta.vitest) {
|
|
|
192
224
|
expect(e.message).toBe("submit failed: timeout");
|
|
193
225
|
});
|
|
194
226
|
|
|
227
|
+
test("HostResponseDecodeError names the call, interpolates the cause, and preserves it", () => {
|
|
228
|
+
const cause = new RangeError("Offset is outside the bounds of the DataView");
|
|
229
|
+
const e = new HostResponseDecodeError("createRingVRFProof", cause);
|
|
230
|
+
expect(e).toBeInstanceOf(HostError);
|
|
231
|
+
expect(e.name).toBe("HostResponseDecodeError");
|
|
232
|
+
expect(e.call).toBe("createRingVRFProof");
|
|
233
|
+
expect(e.cause).toBe(cause);
|
|
234
|
+
expect(e.message).toContain("createRingVRFProof");
|
|
235
|
+
// The rendered cause is in the message, not just on `.cause`.
|
|
236
|
+
expect(e.message).toContain("Offset is outside the bounds of the DataView");
|
|
237
|
+
expect(e.message).toContain("protocol-version skew");
|
|
238
|
+
});
|
|
239
|
+
|
|
195
240
|
test("isHostError narrows host errors only", () => {
|
|
196
241
|
expect(isHostError(new HostUnavailableError())).toBe(true);
|
|
197
242
|
expect(isHostError(new HostCallFailedError("x", { tag: "Denied" }))).toBe(true);
|
|
243
|
+
expect(isHostError(new HostResponseDecodeError("c", new Error("boom")))).toBe(true);
|
|
198
244
|
expect(isHostError(new Error("plain"))).toBe(false);
|
|
199
245
|
expect(isHostError("string")).toBe(false);
|
|
200
246
|
});
|