@parity/product-sdk-signer 0.1.0 → 0.2.1
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 +11 -5
- package/dist/index.js +40 -701
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/index.ts +12 -0
- package/src/providers/dev.ts +1 -0
- package/src/providers/host.ts +321 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parity/product-sdk-signer",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Signer manager for Polkadot — Host API and dev accounts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -18,15 +18,15 @@
|
|
|
18
18
|
"src"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"polkadot-api": "^1.
|
|
22
|
-
"@parity/product-sdk-
|
|
23
|
-
"@parity/product-sdk-
|
|
24
|
-
"@parity/product-sdk-
|
|
25
|
-
"@parity/product-sdk-
|
|
21
|
+
"polkadot-api": "^2.1.2",
|
|
22
|
+
"@parity/product-sdk-keys": "0.2.0",
|
|
23
|
+
"@parity/product-sdk-logger": "0.1.1",
|
|
24
|
+
"@parity/product-sdk-address": "0.1.1",
|
|
25
|
+
"@parity/product-sdk-host": "0.2.0"
|
|
26
26
|
},
|
|
27
27
|
"optionalDependencies": {
|
|
28
|
-
"@novasamatech/product-sdk": "^0.
|
|
29
|
-
"@novasamatech/host-api": "^0.7.
|
|
28
|
+
"@novasamatech/product-sdk": "^0.7.7",
|
|
29
|
+
"@novasamatech/host-api": "^0.7.7"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"tsup": "^8.4.0",
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @parity/product-sdk-signer — Account connection and signing, decoupled from where the keys actually live.
|
|
3
|
+
*
|
|
4
|
+
* `SignerManager` wraps one or more `SignerProvider` implementations behind a
|
|
5
|
+
* `Result`-typed API for connecting, listing accounts, and reacting to status or
|
|
6
|
+
* account changes. The two built-in providers — `HostProvider` (Polkadot
|
|
7
|
+
* Desktop/Mobile) and `DevProvider` (the well-known Alice/Bob accounts) — let
|
|
8
|
+
* the same call sites work in production and in tests.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
|
|
1
13
|
// Core manager
|
|
2
14
|
export { SignerManager } from "./signer-manager.js";
|
|
3
15
|
|
package/src/providers/dev.ts
CHANGED
|
@@ -14,6 +14,7 @@ const DEV_PHRASE = "bottom drive obey lake curtain smoke basket hold race lonely
|
|
|
14
14
|
/** Standard Substrate dev account names. */
|
|
15
15
|
const DEFAULT_DEV_NAMES = ["Alice", "Bob", "Charlie", "Dave", "Eve", "Ferdie"] as const;
|
|
16
16
|
|
|
17
|
+
/** A well-known Substrate development account name (Alice, Bob, …) used to derive deterministic dev accounts from the standard Substrate dev mnemonic. */
|
|
17
18
|
export type DevAccountName = (typeof DEFAULT_DEV_NAMES)[number];
|
|
18
19
|
|
|
19
20
|
/** Supported key types for dev account derivation. */
|
package/src/providers/host.ts
CHANGED
|
@@ -30,18 +30,23 @@ export interface HostProviderOptions {
|
|
|
30
30
|
loadSdk?: () => Promise<ProductSdkModule>;
|
|
31
31
|
/**
|
|
32
32
|
* Custom loader for `@novasamatech/host-api` (used to construct the
|
|
33
|
-
* `
|
|
33
|
+
* `ChainSubmit` permission request). Defaults to dynamic import.
|
|
34
34
|
* @internal
|
|
35
35
|
*/
|
|
36
36
|
loadHostApiEnum?: () => Promise<HostApiEnumHelper>;
|
|
37
37
|
/**
|
|
38
|
-
* Whether to request the host's `
|
|
38
|
+
* Whether to request the host's `ChainSubmit` permission after a
|
|
39
39
|
* successful `connect()`. Without this, subsequent signing requests are
|
|
40
40
|
* rejected by the host with `PermissionDenied`. Default: `true`.
|
|
41
41
|
*
|
|
42
42
|
* Set to `false` if your app needs to defer the permission prompt or
|
|
43
43
|
* drives it manually.
|
|
44
|
+
*
|
|
45
|
+
* (Previously named `requestTransactionSubmitPermission` — alias kept
|
|
46
|
+
* for backwards compatibility but the new wire format uses `ChainSubmit`.)
|
|
44
47
|
*/
|
|
48
|
+
requestChainSubmitPermission?: boolean;
|
|
49
|
+
/** @deprecated Renamed to `requestChainSubmitPermission`. */
|
|
45
50
|
requestTransactionSubmitPermission?: boolean;
|
|
46
51
|
}
|
|
47
52
|
|
|
@@ -97,8 +102,8 @@ interface NeverthrowResultAsync<T, E> {
|
|
|
97
102
|
|
|
98
103
|
/** @internal */
|
|
99
104
|
export interface AccountsProvider {
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
getLegacyAccounts: () => NeverthrowResultAsync<RawAccount[], unknown>;
|
|
106
|
+
getLegacyAccountSigner: (account: ProductAccount) => import("polkadot-api").PolkadotSigner;
|
|
102
107
|
getProductAccount: (
|
|
103
108
|
dotNsIdentifier: string,
|
|
104
109
|
derivationIndex?: number,
|
|
@@ -168,7 +173,7 @@ export class HostProvider implements SignerProvider {
|
|
|
168
173
|
private readonly retryDelay: number;
|
|
169
174
|
private readonly loadSdk: () => Promise<ProductSdkModule>;
|
|
170
175
|
private readonly loadHostApiEnum: () => Promise<HostApiEnumHelper>;
|
|
171
|
-
private readonly
|
|
176
|
+
private readonly requestChainSubmitPermission: boolean;
|
|
172
177
|
|
|
173
178
|
private accountsProvider: AccountsProvider | null = null;
|
|
174
179
|
private statusCleanup: (() => void) | null = null;
|
|
@@ -181,7 +186,11 @@ export class HostProvider implements SignerProvider {
|
|
|
181
186
|
this.retryDelay = options?.retryDelay ?? 500;
|
|
182
187
|
this.loadSdk = options?.loadSdk ?? defaultLoadSdk;
|
|
183
188
|
this.loadHostApiEnum = options?.loadHostApiEnum ?? defaultLoadHostApiEnum;
|
|
184
|
-
|
|
189
|
+
// New name takes precedence; fall back to the deprecated alias.
|
|
190
|
+
this.requestChainSubmitPermission =
|
|
191
|
+
options?.requestChainSubmitPermission ??
|
|
192
|
+
options?.requestTransactionSubmitPermission ??
|
|
193
|
+
true;
|
|
185
194
|
}
|
|
186
195
|
|
|
187
196
|
async connect(signal?: AbortSignal): Promise<Result<SignerAccount[], SignerError>> {
|
|
@@ -404,7 +413,7 @@ export class HostProvider implements SignerProvider {
|
|
|
404
413
|
// Step 3: Fetch non-product accounts
|
|
405
414
|
let rawAccounts: RawAccount[];
|
|
406
415
|
try {
|
|
407
|
-
rawAccounts = (await provider.
|
|
416
|
+
rawAccounts = (await provider.getLegacyAccounts().match(
|
|
408
417
|
(accounts) => accounts,
|
|
409
418
|
(error) => {
|
|
410
419
|
throw new Error(`Host rejected account request: ${formatError(error)}`);
|
|
@@ -424,35 +433,42 @@ export class HostProvider implements SignerProvider {
|
|
|
424
433
|
return err(new NoAccountsError("host"));
|
|
425
434
|
}
|
|
426
435
|
|
|
427
|
-
// Step 4: Request
|
|
436
|
+
// Step 4: Request ChainSubmit permission up-front.
|
|
428
437
|
//
|
|
429
|
-
// The host gates signing on this permission — without it
|
|
430
|
-
// (and the production host) rejects every sign
|
|
431
|
-
// `PermissionDenied`, which typically manifests as a
|
|
432
|
-
// Doing it once during connect() matches what
|
|
433
|
-
// spares consumers the boilerplate.
|
|
438
|
+
// The host gates signing on this permission — without it
|
|
439
|
+
// `handleSignPayload` (and the production host) rejects every sign
|
|
440
|
+
// request with `PermissionDenied`, which typically manifests as a
|
|
441
|
+
// silently-hanging tx. Doing it once during connect() matches what
|
|
442
|
+
// production apps need and spares consumers the boilerplate.
|
|
434
443
|
//
|
|
435
444
|
// We don't fail `connect()` if this step fails: the consumer can still
|
|
436
|
-
// use the signer for read-only code paths, and the actual sign call
|
|
437
|
-
// surface a clear error if permission is missing.
|
|
438
|
-
|
|
445
|
+
// use the signer for read-only code paths, and the actual sign call
|
|
446
|
+
// will surface a clear error if permission is missing.
|
|
447
|
+
//
|
|
448
|
+
// The legal v1 RemotePermission variants per
|
|
449
|
+
// `@novasamatech/host-api@0.7.7` are: Remote, WebRTC, ChainSubmit,
|
|
450
|
+
// PreimageSubmit, StatementSubmit. ChainSubmit is the chain-tx
|
|
451
|
+
// permission (was named TransactionSubmit in earlier host-api
|
|
452
|
+
// revisions; renamed in 0.7).
|
|
453
|
+
if (this.requestChainSubmitPermission && sdk.hostApi) {
|
|
439
454
|
try {
|
|
440
455
|
const hostApiEnum = await this.loadHostApiEnum();
|
|
441
456
|
const request = hostApiEnum.enumValue("v1", {
|
|
442
|
-
tag: "
|
|
457
|
+
tag: "ChainSubmit",
|
|
458
|
+
value: undefined,
|
|
443
459
|
});
|
|
444
460
|
await sdk.hostApi.permission(request).match(
|
|
445
461
|
() => {
|
|
446
|
-
log.debug("
|
|
462
|
+
log.debug("ChainSubmit permission granted");
|
|
447
463
|
},
|
|
448
464
|
(error) => {
|
|
449
|
-
log.warn("
|
|
465
|
+
log.warn("ChainSubmit permission rejected by host", {
|
|
450
466
|
error: formatError(error),
|
|
451
467
|
});
|
|
452
468
|
},
|
|
453
469
|
);
|
|
454
470
|
} catch (cause) {
|
|
455
|
-
log.warn("failed to request
|
|
471
|
+
log.warn("failed to request ChainSubmit permission", { cause });
|
|
456
472
|
}
|
|
457
473
|
}
|
|
458
474
|
|
|
@@ -487,7 +503,7 @@ export class HostProvider implements SignerProvider {
|
|
|
487
503
|
if (!this.accountsProvider) {
|
|
488
504
|
throw new Error("Host provider is disconnected");
|
|
489
505
|
}
|
|
490
|
-
return this.accountsProvider.
|
|
506
|
+
return this.accountsProvider.getLegacyAccountSigner({
|
|
491
507
|
dotNsIdentifier: "",
|
|
492
508
|
derivationIndex: 0,
|
|
493
509
|
publicKey: raw.publicKey,
|
|
@@ -498,11 +514,48 @@ export class HostProvider implements SignerProvider {
|
|
|
498
514
|
}
|
|
499
515
|
}
|
|
500
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Format a host-error for logging.
|
|
519
|
+
*
|
|
520
|
+
* host-api errors come back as `{ tag: "v1", value: <inner> }` where the
|
|
521
|
+
* inner can be either another tagged enum (with its own tag/value) or a
|
|
522
|
+
* plain `Error`-shaped object surfacing client-side codec failures
|
|
523
|
+
* (e.g. `GenericError: inner[tag] is not a function` when the SDK
|
|
524
|
+
* encodes a request the codec doesn't understand).
|
|
525
|
+
*
|
|
526
|
+
* Walking the value side as well as the tag means schema drift between
|
|
527
|
+
* host-api versions and the SDK produces something more diagnostic than
|
|
528
|
+
* just the outermost wrapper tag.
|
|
529
|
+
*/
|
|
501
530
|
function formatError(error: unknown): string {
|
|
502
|
-
if (error
|
|
503
|
-
|
|
531
|
+
if (!error || typeof error !== "object") return String(error);
|
|
532
|
+
const e = error as Record<string, unknown>;
|
|
533
|
+
if (!("tag" in e)) return String(error);
|
|
534
|
+
|
|
535
|
+
const outerTag = String(e.tag);
|
|
536
|
+
const inner = e.value;
|
|
537
|
+
|
|
538
|
+
// Inner is an Error-shaped object with name/message — surface those.
|
|
539
|
+
if (inner && typeof inner === "object") {
|
|
540
|
+
const innerObj = inner as Record<string, unknown>;
|
|
541
|
+
if (typeof innerObj.message === "string") {
|
|
542
|
+
const innerName =
|
|
543
|
+
typeof innerObj.name === "string" && innerObj.name !== "Error"
|
|
544
|
+
? `${innerObj.name}: `
|
|
545
|
+
: "";
|
|
546
|
+
return `${outerTag} → ${innerName}${innerObj.message}`;
|
|
547
|
+
}
|
|
548
|
+
// Inner is a nested tagged-enum — recurse.
|
|
549
|
+
if ("tag" in innerObj) {
|
|
550
|
+
return `${outerTag} → ${formatError(inner)}`;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Inner is a primitive or absent — fall back to the outer tag alone.
|
|
555
|
+
if (inner !== undefined) {
|
|
556
|
+
return `${outerTag} (${String(inner)})`;
|
|
504
557
|
}
|
|
505
|
-
return
|
|
558
|
+
return outerTag;
|
|
506
559
|
}
|
|
507
560
|
|
|
508
561
|
if (import.meta.vitest) {
|
|
@@ -527,7 +580,7 @@ if (import.meta.vitest) {
|
|
|
527
580
|
} as unknown as import("polkadot-api").PolkadotSigner;
|
|
528
581
|
|
|
529
582
|
return {
|
|
530
|
-
|
|
583
|
+
getLegacyAccounts: vi.fn().mockReturnValue({
|
|
531
584
|
match: async (
|
|
532
585
|
onOk: (v: RawAccountTest[]) => unknown,
|
|
533
586
|
onErr: (e: unknown) => unknown,
|
|
@@ -538,7 +591,7 @@ if (import.meta.vitest) {
|
|
|
538
591
|
return onOk(accounts);
|
|
539
592
|
},
|
|
540
593
|
}),
|
|
541
|
-
|
|
594
|
+
getLegacyAccountSigner: vi.fn().mockReturnValue(mockSigner),
|
|
542
595
|
getProductAccount: vi.fn().mockReturnValue({
|
|
543
596
|
match: async (
|
|
544
597
|
onOk: (v: RawAccountTest) => unknown,
|
|
@@ -622,7 +675,7 @@ if (import.meta.vitest) {
|
|
|
622
675
|
}
|
|
623
676
|
});
|
|
624
677
|
|
|
625
|
-
test("returns HOST_REJECTED when
|
|
678
|
+
test("returns HOST_REJECTED when getLegacyAccounts fails", async () => {
|
|
626
679
|
const mockProvider = createMockProvider({ shouldReject: true, error: "Rejected" });
|
|
627
680
|
const provider = new HostProvider({
|
|
628
681
|
maxRetries: 1,
|
|
@@ -691,4 +744,245 @@ if (import.meta.vitest) {
|
|
|
691
744
|
unsub();
|
|
692
745
|
});
|
|
693
746
|
});
|
|
747
|
+
|
|
748
|
+
describe("ChainSubmit permission request", () => {
|
|
749
|
+
test("sends a v1 ChainSubmit request (regression guard for the TransactionSubmit bug)", async () => {
|
|
750
|
+
const captured: unknown[] = [];
|
|
751
|
+
const hostApi: HostApiPermissionBridge = {
|
|
752
|
+
permission: (request) => {
|
|
753
|
+
captured.push(request);
|
|
754
|
+
return fakeResult(undefined);
|
|
755
|
+
},
|
|
756
|
+
};
|
|
757
|
+
const mockProvider = createMockProvider({
|
|
758
|
+
accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
|
|
759
|
+
});
|
|
760
|
+
const provider = new HostProvider({
|
|
761
|
+
maxRetries: 1,
|
|
762
|
+
loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
|
|
763
|
+
loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
await provider.connect();
|
|
767
|
+
|
|
768
|
+
expect(captured).toHaveLength(1);
|
|
769
|
+
// The fake hostApiEnum returns `{ version, value }` so we can
|
|
770
|
+
// assert on the exact wire shape that would reach
|
|
771
|
+
// host-api's RemotePermission codec.
|
|
772
|
+
expect(captured[0]).toEqual({
|
|
773
|
+
version: "v1",
|
|
774
|
+
value: { tag: "ChainSubmit", value: undefined },
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("does NOT send a TransactionSubmit tag (the bug)", async () => {
|
|
779
|
+
const captured: unknown[] = [];
|
|
780
|
+
const hostApi: HostApiPermissionBridge = {
|
|
781
|
+
permission: (request) => {
|
|
782
|
+
captured.push(request);
|
|
783
|
+
return fakeResult(undefined);
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
const mockProvider = createMockProvider({
|
|
787
|
+
accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
|
|
788
|
+
});
|
|
789
|
+
const provider = new HostProvider({
|
|
790
|
+
maxRetries: 1,
|
|
791
|
+
loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
|
|
792
|
+
loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
await provider.connect();
|
|
796
|
+
|
|
797
|
+
const sent = JSON.stringify(captured[0]);
|
|
798
|
+
expect(sent).not.toContain("TransactionSubmit");
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("skipped when sdk.hostApi is unavailable (older product-sdk)", async () => {
|
|
802
|
+
const mockProvider = createMockProvider({
|
|
803
|
+
accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
|
|
804
|
+
});
|
|
805
|
+
const provider = new HostProvider({
|
|
806
|
+
maxRetries: 1,
|
|
807
|
+
loadSdk: () => Promise.resolve(createMockSdk(mockProvider /* no hostApi */)),
|
|
808
|
+
loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
const result = await provider.connect();
|
|
812
|
+
// Connect should succeed even without the hostApi bridge —
|
|
813
|
+
// permission is best-effort.
|
|
814
|
+
expect(result.ok).toBe(true);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("skipped when requestChainSubmitPermission is false", async () => {
|
|
818
|
+
const captured: unknown[] = [];
|
|
819
|
+
const hostApi: HostApiPermissionBridge = {
|
|
820
|
+
permission: (request) => {
|
|
821
|
+
captured.push(request);
|
|
822
|
+
return fakeResult(undefined);
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
const mockProvider = createMockProvider({
|
|
826
|
+
accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
|
|
827
|
+
});
|
|
828
|
+
const provider = new HostProvider({
|
|
829
|
+
maxRetries: 1,
|
|
830
|
+
loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
|
|
831
|
+
loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
|
|
832
|
+
requestChainSubmitPermission: false,
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
await provider.connect();
|
|
836
|
+
expect(captured).toHaveLength(0);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test("deprecated requestTransactionSubmitPermission alias still controls the request", async () => {
|
|
840
|
+
const captured: unknown[] = [];
|
|
841
|
+
const hostApi: HostApiPermissionBridge = {
|
|
842
|
+
permission: (request) => {
|
|
843
|
+
captured.push(request);
|
|
844
|
+
return fakeResult(undefined);
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
const mockProvider = createMockProvider({
|
|
848
|
+
accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
|
|
849
|
+
});
|
|
850
|
+
const provider = new HostProvider({
|
|
851
|
+
maxRetries: 1,
|
|
852
|
+
loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
|
|
853
|
+
loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
|
|
854
|
+
// Old name; new code path should still respect it as `false`.
|
|
855
|
+
requestTransactionSubmitPermission: false,
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
await provider.connect();
|
|
859
|
+
expect(captured).toHaveLength(0);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
test("connect succeeds even when permission request rejects", async () => {
|
|
863
|
+
// Whatever the host says about permission, connect() should
|
|
864
|
+
// still return ok — the consumer can sign later with whatever
|
|
865
|
+
// permission they negotiate.
|
|
866
|
+
const hostApi: HostApiPermissionBridge = {
|
|
867
|
+
permission: () => fakeResult(undefined, { tag: "PermissionDenied" }),
|
|
868
|
+
};
|
|
869
|
+
const mockProvider = createMockProvider({
|
|
870
|
+
accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
|
|
871
|
+
});
|
|
872
|
+
const provider = new HostProvider({
|
|
873
|
+
maxRetries: 1,
|
|
874
|
+
loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
|
|
875
|
+
loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
const result = await provider.connect();
|
|
879
|
+
expect(result.ok).toBe(true);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test("connect succeeds even when the hostApiEnum loader throws (codec drift)", async () => {
|
|
883
|
+
// The original bug: the v1 RemotePermission codec didn't
|
|
884
|
+
// recognize the TransactionSubmit tag and threw client-side.
|
|
885
|
+
// Even when something like that happens, connect() must
|
|
886
|
+
// remain ok — permission is best-effort.
|
|
887
|
+
const hostApi: HostApiPermissionBridge = {
|
|
888
|
+
permission: () => fakeResult(undefined),
|
|
889
|
+
};
|
|
890
|
+
const mockProvider = createMockProvider({
|
|
891
|
+
accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
|
|
892
|
+
});
|
|
893
|
+
const provider = new HostProvider({
|
|
894
|
+
maxRetries: 1,
|
|
895
|
+
loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
|
|
896
|
+
loadHostApiEnum: () => Promise.reject(new Error("codec drift")),
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
const result = await provider.connect();
|
|
900
|
+
expect(result.ok).toBe(true);
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
describe("formatError", () => {
|
|
905
|
+
// Direct unit tests for the helper. The previous implementation
|
|
906
|
+
// collapsed any tagged-enum error to its outer tag — losing the
|
|
907
|
+
// inner reason. The fix surfaces the inner Error-shape (name +
|
|
908
|
+
// message) and recurses through nested tagged enums.
|
|
909
|
+
|
|
910
|
+
test("returns a string for a primitive error", () => {
|
|
911
|
+
expect(formatError("Rejected")).toBe("Rejected");
|
|
912
|
+
expect(formatError(42)).toBe("42");
|
|
913
|
+
expect(formatError(null)).toBe("null");
|
|
914
|
+
expect(formatError(undefined)).toBe("undefined");
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test("surfaces inner Error name + message under the outer tag", () => {
|
|
918
|
+
// Simulates the exact shape the original bug produced:
|
|
919
|
+
// `{ tag: "v1", value: { name: "GenericError", message: "..." } }`
|
|
920
|
+
const wrapped = {
|
|
921
|
+
tag: "v1",
|
|
922
|
+
value: {
|
|
923
|
+
name: "GenericError",
|
|
924
|
+
message: "Unknown error: inner[tag] is not a function",
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
const out = formatError(wrapped);
|
|
928
|
+
expect(out).toContain("v1");
|
|
929
|
+
expect(out).toContain("GenericError");
|
|
930
|
+
expect(out).toContain("inner[tag] is not a function");
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
test("strips the redundant 'Error' name when the inner is a plain Error", () => {
|
|
934
|
+
const wrapped = {
|
|
935
|
+
tag: "v1",
|
|
936
|
+
value: { name: "Error", message: "boom" },
|
|
937
|
+
};
|
|
938
|
+
expect(formatError(wrapped)).toBe("v1 → boom");
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test("recurses through nested tagged-enum errors", () => {
|
|
942
|
+
const wrapped = {
|
|
943
|
+
tag: "v1",
|
|
944
|
+
value: { tag: "Inner", value: { name: "NestedErr", message: "deep" } },
|
|
945
|
+
};
|
|
946
|
+
expect(formatError(wrapped)).toContain("v1");
|
|
947
|
+
expect(formatError(wrapped)).toContain("Inner");
|
|
948
|
+
expect(formatError(wrapped)).toContain("NestedErr");
|
|
949
|
+
expect(formatError(wrapped)).toContain("deep");
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
test("returns just the outer tag when value is undefined", () => {
|
|
953
|
+
expect(formatError({ tag: "PermissionDenied" })).toBe("PermissionDenied");
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("formats a primitive inner value alongside the tag", () => {
|
|
957
|
+
expect(formatError({ tag: "v1", value: "code-42" })).toBe("v1 (code-42)");
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
describe("RemotePermission codec interop", () => {
|
|
962
|
+
// Smoke test that the wire payload we build (`ChainSubmit`) round-trips
|
|
963
|
+
// through the real host-api codec. The previous bug shipped
|
|
964
|
+
// `TransactionSubmit`, which the codec rejects — locking this in here
|
|
965
|
+
// catches a regression at the codec layer without needing the host.
|
|
966
|
+
test("encodes ChainSubmit payload without throwing", async () => {
|
|
967
|
+
const { RemotePermission } = await import("@novasamatech/host-api");
|
|
968
|
+
const payload = { tag: "ChainSubmit" as const, value: undefined };
|
|
969
|
+
const encoded = RemotePermission.enc(payload);
|
|
970
|
+
expect(encoded).toBeInstanceOf(Uint8Array);
|
|
971
|
+
const decoded = RemotePermission.dec(encoded);
|
|
972
|
+
expect(decoded.tag).toBe("ChainSubmit");
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test("rejects the legacy TransactionSubmit tag", async () => {
|
|
976
|
+
const { RemotePermission } = await import("@novasamatech/host-api");
|
|
977
|
+
// `TransactionSubmit` is not a valid variant in v1 — the codec
|
|
978
|
+
// should refuse to encode it. This proves the codec actually
|
|
979
|
+
// validates tags (so test 1 isn't a tautology).
|
|
980
|
+
expect(() =>
|
|
981
|
+
RemotePermission.enc({
|
|
982
|
+
tag: "TransactionSubmit",
|
|
983
|
+
value: undefined,
|
|
984
|
+
} as never),
|
|
985
|
+
).toThrow();
|
|
986
|
+
});
|
|
987
|
+
});
|
|
694
988
|
}
|