@parity/product-sdk-signer 0.8.2 → 0.9.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.
@@ -1,6 +1,11 @@
1
1
  // Copyright 2026 Parity Technologies (UK) Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { deriveH160, ss58Encode } from "@parity/product-sdk-address";
4
+ import {
5
+ getAccountsProvider,
6
+ type RemotePermission,
7
+ requestPermission,
8
+ } from "@parity/product-sdk-host";
4
9
  import { createLogger } from "@parity/product-sdk-logger";
5
10
 
6
11
  import { HostRejectedError, HostUnavailableError, type SignerError } from "../errors.js";
@@ -36,17 +41,19 @@ export interface HostProviderOptions {
36
41
  */
37
42
  dappName?: string;
38
43
  /**
39
- * Custom SDK loader. Defaults to `import("@novasamatech/host-api-wrapper")`.
40
- * Override this for testing or custom SDK setups.
44
+ * Custom accounts-provider loader. Defaults to `@parity/product-sdk-host`'s
45
+ * `getAccountsProvider`, which returns `null` outside a host container.
46
+ * Override for testing or custom host setups.
41
47
  * @internal
42
48
  */
43
- loadSdk?: () => Promise<ProductSdkModule>;
49
+ loadAccountsProvider?: () => Promise<AccountsProvider | null>;
44
50
  /**
45
- * Custom loader for `@novasamatech/host-api` (used to construct the
46
- * `ChainSubmit` permission request). Defaults to dynamic import.
51
+ * Custom `ChainSubmit` permission requester. Defaults to a thin adapter over
52
+ * `@parity/product-sdk-host`'s `requestPermission` that unwraps its `Result`
53
+ * (throwing the typed error on the `err` channel). Override for testing.
47
54
  * @internal
48
55
  */
49
- loadHostApiEnum?: () => Promise<HostApiEnumHelper>;
56
+ requestChainSubmitPermissionFn?: (permission: RemotePermission) => Promise<boolean>;
50
57
  /**
51
58
  * Whether to request the host's `ChainSubmit` permission after a
52
59
  * successful `connect()`. Without this, subsequent signing requests are
@@ -66,7 +73,7 @@ export interface HostProviderOptions {
66
73
  * `dotNsIdentifier`, skipping the legacy fetch entirely. For apps
67
74
  * that sign exclusively with a per-dapp derived account.
68
75
  *
69
- * Signing is pinned to `createTransaction` (see PR #96).
76
+ * Signing goes through the host's `createTransaction` path (see PR #96).
70
77
  */
71
78
  productAccount?: {
72
79
  /** App identifier (e.g., `"playground.dot"`). */
@@ -140,33 +147,17 @@ interface NeverthrowResultAsync<T, E> {
140
147
  match: <A, B = A>(ok: (t: T) => A, err: (e: E) => B) => Promise<A | B>;
141
148
  }
142
149
 
143
- /**
144
- * Pin product-account signing to Nova's `host_create_transaction` path.
145
- *
146
- * The `createTransaction` path forwards opaque signed-extension bytes to
147
- * the host for metadata-driven decoding, so unknown extensions (e.g.
148
- * `AsPgas` on Paseo Next) survive end-to-end. The alternate
149
- * `"signPayload"` path wraps via PJS and throws
150
- * `"PJS does not support this signed-extension: AsPgas"` on those chains.
151
- *
152
- * Nova's `host-api-wrapper@0.8.0` already defaults to `"createTransaction"`,
153
- * so this is a defensive pin rather than an opt-in — it guards against a
154
- * future upstream default flip and makes the routing legible at the call
155
- * site. The legacy-account signer doesn't expose this switch.
156
- */
157
- const PRODUCT_SIGNER_TYPE = "createTransaction" as const;
158
-
159
150
  /** @internal */
160
151
  export interface AccountsProvider {
161
- getLegacyAccountSigner: (account: ProductAccount) => import("polkadot-api").PolkadotSigner;
152
+ getLegacyAccounts: () => NeverthrowResultAsync<RawAccount[], unknown>;
153
+ getLegacyAccountSigner: (account: {
154
+ publicKey: Uint8Array;
155
+ }) => import("polkadot-api").PolkadotSigner;
162
156
  getProductAccount: (
163
157
  dotNsIdentifier: string,
164
158
  derivationIndex?: number,
165
159
  ) => NeverthrowResultAsync<RawAccount, unknown>;
166
- getProductAccountSigner: (
167
- account: ProductAccount,
168
- signerType?: "signPayload" | "createTransaction",
169
- ) => import("polkadot-api").PolkadotSigner;
160
+ getProductAccountSigner: (account: ProductAccount) => import("polkadot-api").PolkadotSigner;
170
161
  getProductAccountAlias: (
171
162
  dotNsIdentifier: string,
172
163
  derivationIndex?: number,
@@ -183,57 +174,33 @@ export interface AccountsProvider {
183
174
  ) => { unsubscribe: () => void } | (() => void);
184
175
  }
185
176
 
186
- /** @internal */
187
- export interface HostApiPermissionBridge {
188
- /**
189
- * Request a Host API permission. Product-sdk's `hostApi.permission(...)`
190
- * takes a tagged enum like `enumValue("v1", { tag: "TransactionSubmit" })`
191
- * and returns a neverthrow ResultAsync.
192
- */
193
- permission: (request: unknown) => NeverthrowResultAsync<unknown, unknown>;
194
- }
195
-
196
- /** @internal */
197
- export interface HostApiEnumHelper {
198
- enumValue: (version: string, value: { tag: string; value?: unknown }) => unknown;
199
- }
200
-
201
- /** @internal */
202
- export interface ProductSdkModule {
203
- createAccountsProvider: () => AccountsProvider;
204
- /** Present from product-sdk ≥ 0.6; used to request TransactionSubmit. */
205
- hostApi?: HostApiPermissionBridge;
206
- /**
207
- * `sandboxTransport.isCorrectEnvironment()` returns `false` when the app
208
- * is loaded outside a Polkadot host container (e.g. a regular browser
209
- * tab). Calling `getLegacyAccounts()` / `getProductAccount()` in that
210
- * state surfaces the upstream `Environment is not correct` exception,
211
- * so we pre-check during `connect()` and raise a specific
212
- * {@link HostUnavailableError} with actionable guidance instead.
213
- */
214
- sandboxTransport?: { isCorrectEnvironment(): boolean };
215
- }
216
-
217
177
  /* @integration */
218
- async function defaultLoadSdk(): Promise<ProductSdkModule> {
219
- return (await import("@novasamatech/host-api-wrapper")) as unknown as ProductSdkModule;
178
+ async function defaultLoadAccountsProvider(): Promise<AccountsProvider | null> {
179
+ // `@parity/product-sdk-host`'s provider is structurally compatible with the
180
+ // (looser) shape declared above; the cast bridges the nominal gap.
181
+ return (await getAccountsProvider()) as unknown as AccountsProvider | null;
220
182
  }
221
183
 
222
- /* @integration */
223
- async function defaultLoadHostApiEnum(): Promise<HostApiEnumHelper> {
224
- return (await import("@novasamatech/host-api")) as unknown as HostApiEnumHelper;
184
+ /**
185
+ * Default `requestChainSubmitPermissionFn`: bridge host's `Result`-returning
186
+ * {@link requestPermission} back to the option's `Promise<boolean>` contract by
187
+ * throwing the typed `HostError` on the `err` channel. The throw is caught
188
+ * (and warned, not fatal) at the connect-time call site.
189
+ */
190
+ async function defaultRequestChainSubmitPermission(permission: RemotePermission): Promise<boolean> {
191
+ const result = await requestPermission(permission);
192
+ if (!result.ok) throw result.error;
193
+ return result.value;
225
194
  }
226
195
 
227
196
  /**
228
197
  * Provider for the Host API (Polkadot Desktop / Android).
229
198
  *
230
- * Dynamically imports `@novasamatech/host-api-wrapper` at runtime. Apps running
231
- * outside a host container — e.g. a plain browser tab during `npm run dev` —
232
- * resolve to {@link HostUnavailableError} with guidance on what to do (open
233
- * the app inside a Polkadot host or pick a non-host provider). The check uses
234
- * the wrapper's `sandboxTransport.isCorrectEnvironment()` predicate and runs
235
- * before any host RPC call, so the user never sees the upstream
236
- * `Environment is not correct` exception leaking through.
199
+ * Backed by `@parity/product-sdk-host`'s `getAccountsProvider`, which talks to
200
+ * the host over `@parity/truapi`. Apps running outside a host container — e.g.
201
+ * a plain browser tab during `npm run dev` get a `HOST_UNAVAILABLE` error
202
+ * (the provider resolves to `null`) with actionable guidance, surfaced before
203
+ * any host RPC call.
237
204
  *
238
205
  * Supports both non-product accounts (user's external wallets) and product
239
206
  * accounts (app-scoped derived accounts managed by the host).
@@ -243,8 +210,10 @@ export class HostProvider implements SignerProvider {
243
210
  private readonly ss58Prefix: number;
244
211
  private readonly maxRetries: number;
245
212
  private readonly retryDelay: number;
246
- private readonly loadSdk: () => Promise<ProductSdkModule>;
247
- private readonly loadHostApiEnum: () => Promise<HostApiEnumHelper>;
213
+ private readonly loadAccountsProvider: () => Promise<AccountsProvider | null>;
214
+ private readonly requestChainSubmitPermissionFn: (
215
+ permission: RemotePermission,
216
+ ) => Promise<boolean>;
248
217
  private readonly requestChainSubmitPermission: boolean;
249
218
  private readonly productAccount: HostProviderOptions["productAccount"];
250
219
  private readonly dappName: string | undefined;
@@ -258,8 +227,9 @@ export class HostProvider implements SignerProvider {
258
227
  this.ss58Prefix = options?.ss58Prefix ?? 42;
259
228
  this.maxRetries = options?.maxRetries ?? 3;
260
229
  this.retryDelay = options?.retryDelay ?? 500;
261
- this.loadSdk = options?.loadSdk ?? defaultLoadSdk;
262
- this.loadHostApiEnum = options?.loadHostApiEnum ?? defaultLoadHostApiEnum;
230
+ this.loadAccountsProvider = options?.loadAccountsProvider ?? defaultLoadAccountsProvider;
231
+ this.requestChainSubmitPermissionFn =
232
+ options?.requestChainSubmitPermissionFn ?? defaultRequestChainSubmitPermission;
263
233
  // New name takes precedence; fall back to the deprecated alias.
264
234
  this.requestChainSubmitPermission =
265
235
  options?.requestChainSubmitPermission ??
@@ -360,10 +330,7 @@ export class HostProvider implements SignerProvider {
360
330
  if (!this.accountsProvider) {
361
331
  throw new Error("Host provider is disconnected");
362
332
  }
363
- return this.accountsProvider.getProductAccountSigner(
364
- productAccount,
365
- PRODUCT_SIGNER_TYPE,
366
- );
333
+ return this.accountsProvider.getProductAccountSigner(productAccount);
367
334
  },
368
335
  });
369
336
  } catch (cause) {
@@ -382,17 +349,15 @@ export class HostProvider implements SignerProvider {
382
349
  * Convenience method for when you already have the product account details.
383
350
  * Requires a prior successful `connect()` call.
384
351
  *
385
- * Routing is pinned to `signerType: "createTransaction"` via
386
- * {@link PRODUCT_SIGNER_TYPE} so unknown signed extensions (e.g. `AsPgas`
387
- * on Paseo Next) are forwarded to the host as opaque bytes for
388
- * metadata-driven decoding, rather than going through the PJS bridge
389
- * that throws on unknown extensions.
352
+ * Signing routes through the host's `createTransaction` path, so unknown
353
+ * signed extensions (e.g. `AsPgas` on Paseo Next) are forwarded to the host
354
+ * as opaque bytes for metadata-driven decoding.
390
355
  */
391
356
  getProductAccountSigner(account: ProductAccount): import("polkadot-api").PolkadotSigner {
392
357
  if (!this.accountsProvider) {
393
358
  throw new Error("Host provider is not connected");
394
359
  }
395
- return this.accountsProvider.getProductAccountSigner(account, PRODUCT_SIGNER_TYPE);
360
+ return this.accountsProvider.getProductAccountSigner(account);
396
361
  }
397
362
 
398
363
  /**
@@ -513,41 +478,33 @@ export class HostProvider implements SignerProvider {
513
478
  // ── Private ──────────────────────────────────────────────────────
514
479
 
515
480
  private async tryConnect(): Promise<Result<SignerAccount[], SignerError>> {
516
- // Step 1: Load product-sdk
517
- let sdk: ProductSdkModule;
481
+ // Step 1: Obtain the host accounts provider. `null` (or a thrown error)
482
+ // means we're not inside a host container.
483
+ let provider: AccountsProvider | null;
518
484
  try {
519
- sdk = await this.loadSdk();
485
+ provider = await this.loadAccountsProvider();
520
486
  } catch (cause) {
521
- log.warn("product-sdk not available", { cause });
487
+ log.warn("host accounts provider unavailable", { cause });
522
488
  return err(
523
489
  new HostUnavailableError(
524
490
  cause instanceof Error
525
- ? `product-sdk import failed: ${cause.message}`
526
- : "product-sdk is not installed",
491
+ ? `host accounts provider failed: ${cause.message}`
492
+ : "host accounts provider is unavailable",
527
493
  ),
528
494
  );
529
495
  }
530
496
 
531
497
  // Step 2: Verify we're actually running inside a host container.
532
498
  //
533
- // The upstream `host-api` transport throws `Error('Environment is not
534
- // correct')` from inside `getLegacyAccounts()` / `getProductAccount()`
535
- // when `sandboxTransport.isCorrectEnvironment()` returns false (i.e.
536
- // we're not in an iframe under Polkadot Desktop, or a WebView under
537
- // Polkadot Mobile). Without this pre-check, that exception used to
538
- // surface as `HostRejectedError("Host rejected account request:
539
- // Environment is not correct")` misleading because no host rejected
540
- // anything; there's no host at all.
541
- //
542
- // Returning `HostUnavailableError` here matches the TSDoc contract
543
- // ("Apps running outside a host container will gracefully get a
544
- // HOST_UNAVAILABLE error") and gives consumers actionable guidance.
545
- //
546
- // The `sandboxTransport` field is optional in `ProductSdkModule` so
547
- // older wrapper versions (or test mocks that don't supply it) keep
548
- // working — we fall through to the existing flow and rely on the
549
- // catch in Step 4 as a safety net.
550
- if (sdk.sandboxTransport && !sdk.sandboxTransport.isCorrectEnvironment()) {
499
+ // `getAccountsProvider()` resolves to `null` when the app isn't loaded
500
+ // inside a Polkadot host container (e.g. a plain browser tab during
501
+ // `npm run dev`, with no iframe under Polkadot Desktop or WebView under
502
+ // Polkadot Mobile). Returning `HostUnavailableError` here before any
503
+ // host RPC call matches the TSDoc contract ("Apps running outside a
504
+ // host container will gracefully get a HOST_UNAVAILABLE error") and
505
+ // gives consumers actionable guidance, rather than letting a later RPC
506
+ // surface a misleading rejection.
507
+ if (!provider) {
551
508
  log.warn("not inside a host container — Host API unavailable");
552
509
  return err(
553
510
  new HostUnavailableError(
@@ -557,12 +514,9 @@ export class HostProvider implements SignerProvider {
557
514
  ),
558
515
  );
559
516
  }
560
-
561
- // Step 3: Create accounts provider
562
- const provider = sdk.createAccountsProvider();
563
517
  this.accountsProvider = provider;
564
518
 
565
- // Step 4: Fetch accounts.
519
+ // Step 3: Fetch accounts.
566
520
  //
567
521
  // Both branches end in `fetchProductSignerAccount`. The difference
568
522
  // is only where the dotNS identifier comes from:
@@ -631,7 +585,7 @@ export class HostProvider implements SignerProvider {
631
585
  signerAccounts = [];
632
586
  }
633
587
 
634
- // Step 5: Request ChainSubmit permission up-front.
588
+ // Step 4: Request ChainSubmit permission up-front.
635
589
  //
636
590
  // The host gates signing on this permission — without it, the
637
591
  // production host rejects every sign request with `PermissionDenied`
@@ -644,30 +598,13 @@ export class HostProvider implements SignerProvider {
644
598
  // We don't fail `connect()` if this step fails: the consumer can still
645
599
  // use the signer for read-only code paths, and the actual sign call
646
600
  // will surface a clear error if permission is missing.
647
- //
648
- // The legal v1 RemotePermission variants per
649
- // `@novasamatech/host-api@0.8.0` are: Remote, WebRtc, ChainSubmit,
650
- // PreimageSubmit, StatementSubmit. ChainSubmit is the chain-tx
651
- // permission (was named TransactionSubmit in earlier host-api
652
- // revisions; renamed in 0.7). `WebRtc` was spelled `WebRTC` before
653
- // 0.8.
654
- if (this.requestChainSubmitPermission && sdk.hostApi) {
601
+ if (this.requestChainSubmitPermission) {
655
602
  try {
656
- const hostApiEnum = await this.loadHostApiEnum();
657
- const request = hostApiEnum.enumValue("v1", {
603
+ const granted = await this.requestChainSubmitPermissionFn({
658
604
  tag: "ChainSubmit",
659
605
  value: undefined,
660
606
  });
661
- await sdk.hostApi.permission(request).match(
662
- () => {
663
- log.debug("ChainSubmit permission granted");
664
- },
665
- (error) => {
666
- log.warn("ChainSubmit permission rejected by host", {
667
- error: formatError(error),
668
- });
669
- },
670
- );
607
+ log.debug("ChainSubmit permission result", { granted });
671
608
  } catch (cause) {
672
609
  log.warn("failed to request ChainSubmit permission", { cause });
673
610
  }
@@ -675,9 +612,11 @@ export class HostProvider implements SignerProvider {
675
612
 
676
613
  log.info("host connected", { accounts: signerAccounts.length });
677
614
 
678
- // Step 6: Subscribe to connection status
615
+ // Step 5: Subscribe to connection status. The host reports a string
616
+ // union (`"Connected"` / `"Disconnected"`); match case-insensitively.
679
617
  const sub = provider.subscribeAccountConnectionStatus((status) => {
680
- const mapped: ConnectionStatus = status === "connected" ? "connected" : "disconnected";
618
+ const mapped: ConnectionStatus =
619
+ String(status).toLowerCase() === "connected" ? "connected" : "disconnected";
681
620
  log.debug("host status changed", { status: mapped });
682
621
  for (const listener of this.statusListeners) {
683
622
  listener(mapped);
@@ -746,7 +685,12 @@ export class HostProvider implements SignerProvider {
746
685
  function formatError(error: unknown): string {
747
686
  if (!error || typeof error !== "object") return String(error);
748
687
  const e = error as Record<string, unknown>;
749
- if (!("tag" in e)) return String(error);
688
+ // truapi GenericError is { reason } with no `tag`.
689
+ if (!("tag" in e)) {
690
+ if (typeof e.reason === "string") return e.reason;
691
+ if (typeof e.message === "string") return e.message;
692
+ return String(error);
693
+ }
750
694
 
751
695
  const outerTag = String(e.tag);
752
696
  const inner = e.value;
@@ -855,78 +799,58 @@ if (import.meta.vitest) {
855
799
  };
856
800
  }
857
801
 
858
- function createMockSdk(
859
- mockProvider: ReturnType<typeof createMockProvider>,
860
- opts?: {
861
- hostApi?: HostApiPermissionBridge;
862
- /**
863
- * When provided, the mock's `sandboxTransport.isCorrectEnvironment()`
864
- * returns this value — exercises the env-check branch added in the
865
- * `connect()` flow. Omit to skip the check entirely (older-wrapper
866
- * compatibility path).
867
- */
868
- isCorrectEnvironment?: boolean;
869
- },
870
- ): ProductSdkModule {
871
- return {
872
- createAccountsProvider: () => mockProvider as unknown as AccountsProvider,
873
- ...(opts?.hostApi ? { hostApi: opts.hostApi } : {}),
874
- ...(opts?.isCorrectEnvironment !== undefined
875
- ? { sandboxTransport: { isCorrectEnvironment: () => opts.isCorrectEnvironment! } }
876
- : {}),
877
- };
802
+ /** Wrap a mock accounts provider as the loader the HostProvider expects. */
803
+ function loadProvider(mockProvider: ReturnType<typeof createMockProvider>) {
804
+ return () => Promise.resolve(mockProvider as unknown as AccountsProvider);
878
805
  }
879
806
 
880
- /**
881
- * A fake neverthrow ResultAsync-like object. Resolves via `onOk` when
882
- * `error === undefined`, otherwise via `onErr`.
883
- */
884
- function fakeResult<T>(value: T, error?: unknown): NeverthrowResultAsync<T, unknown> {
885
- return {
886
- match: async (onOk, onErr) => {
887
- if (error !== undefined) return onErr(error);
888
- return onOk(value);
889
- },
890
- };
807
+ /** A permission requester spy that always grants, unless overridden. */
808
+ function grantPermission() {
809
+ return vi.fn<(permission: RemotePermission) => Promise<boolean>>().mockResolvedValue(true);
891
810
  }
892
811
 
893
- const fakeHostApiEnum: HostApiEnumHelper = {
894
- enumValue: (version, value) => ({ version, value }),
895
- };
896
-
897
812
  beforeEach(() => {
898
813
  vi.restoreAllMocks();
899
814
  });
900
815
 
901
816
  describe("HostProvider", () => {
902
- test("returns HOST_UNAVAILABLE when SDK load fails", async () => {
817
+ test("returns HOST_UNAVAILABLE when the accounts-provider loader throws", async () => {
818
+ const provider = new HostProvider({
819
+ maxRetries: 1,
820
+ loadAccountsProvider: () => Promise.reject(new Error("boom")),
821
+ });
822
+ const result = await provider.connect();
823
+
824
+ expect(result.ok).toBe(false);
825
+ if (!result.ok) {
826
+ expect(result.error).toBeInstanceOf(HostUnavailableError);
827
+ expect(result.error.message).toContain("boom");
828
+ }
829
+ });
830
+
831
+ test("returns HOST_UNAVAILABLE when not inside a host container (provider null)", async () => {
903
832
  const provider = new HostProvider({
904
833
  maxRetries: 1,
905
- loadSdk: () => Promise.reject(new Error("Cannot find module")),
834
+ loadAccountsProvider: () => Promise.resolve(null),
906
835
  });
907
836
  const result = await provider.connect();
908
837
 
909
838
  expect(result.ok).toBe(false);
910
839
  if (!result.ok) {
911
840
  expect(result.error).toBeInstanceOf(HostUnavailableError);
912
- expect(result.error.message).toContain("Cannot find module");
913
841
  }
914
842
  });
915
843
 
916
844
  test("returns HOST_UNAVAILABLE with actionable guidance when not inside a host container", async () => {
917
- // Repro for playground-cli#4: `pg mod foo` + `npm run dev` opens
918
- // localhost in a plain browser tab (no iframe, no WebView).
919
- // sandboxTransport.isCorrectEnvironment() returns false, and
920
- // pre-fix we surfaced the upstream "Environment is not correct"
921
- // as `HostRejectedError("Host rejected account request: ...")`.
922
- // Post-fix: we pre-check during connect() and return a specific
923
- // HostUnavailableError naming the host container and pointing
924
- // the user at the fix path.
845
+ // Outside a host container `getAccountsProvider()` resolves to null;
846
+ // connect() returns a HostUnavailableError naming the host container
847
+ // and pointing the user at the fix path — without making any host
848
+ // RPC call. (Repro for playground-cli#4: `npm run dev` opens
849
+ // localhost in a plain browser tab — no iframe, no WebView.)
925
850
  const mockProvider = createMockProvider({ accounts: [] });
926
851
  const provider = new HostProvider({
927
852
  maxRetries: 1,
928
- loadSdk: () =>
929
- Promise.resolve(createMockSdk(mockProvider, { isCorrectEnvironment: false })),
853
+ loadAccountsProvider: () => Promise.resolve(null),
930
854
  });
931
855
  const result = await provider.connect();
932
856
 
@@ -957,7 +881,8 @@ if (import.meta.vitest) {
957
881
  const provider = new HostProvider({
958
882
  maxRetries: 1,
959
883
  dappName: "my-cli",
960
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
884
+ loadAccountsProvider: loadProvider(mockProvider),
885
+ requestChainSubmitPermissionFn: grantPermission(),
961
886
  });
962
887
  const result = await provider.connect();
963
888
 
@@ -979,7 +904,8 @@ if (import.meta.vitest) {
979
904
  const provider = new HostProvider({
980
905
  maxRetries: 1,
981
906
  dappName: "my-cli.dot",
982
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
907
+ loadAccountsProvider: loadProvider(mockProvider),
908
+ requestChainSubmitPermissionFn: grantPermission(),
983
909
  });
984
910
  const result = await provider.connect();
985
911
 
@@ -987,6 +913,31 @@ if (import.meta.vitest) {
987
913
  expect(mockProvider.getProductAccount).toHaveBeenCalledWith("my-cli.dot", 0);
988
914
  });
989
915
 
916
+ test("connect succeeds when the default ChainSubmit permission request fails", async () => {
917
+ // No `requestChainSubmitPermissionFn` override → the default adapter
918
+ // (`defaultRequestChainSubmitPermission`) calls host's real
919
+ // `requestPermission`, which returns `err` outside a container. The
920
+ // adapter unwraps that and throws; the connect step catches it
921
+ // (warn, non-fatal — see Step 4) so `connect()` still resolves.
922
+ const productPubkey = new Uint8Array(32).fill(0x55);
923
+ const mockProvider = createMockProvider({
924
+ accounts: [{ publicKey: productPubkey, name: undefined }],
925
+ });
926
+ const provider = new HostProvider({
927
+ maxRetries: 1,
928
+ dappName: "my-cli",
929
+ loadAccountsProvider: loadProvider(mockProvider),
930
+ // requestChainSubmitPermission defaults to true; no Fn override.
931
+ });
932
+
933
+ const result = await provider.connect();
934
+
935
+ expect(result.ok).toBe(true);
936
+ if (result.ok) {
937
+ expect(result.value).toHaveLength(1);
938
+ }
939
+ });
940
+
990
941
  test("connect with dappName fallback resolves to [] when host rejects derivation", async () => {
991
942
  // Soft-degrade: when the host can't derive a product account
992
943
  // for the dappName (commonly because the dotNS identifier isn't
@@ -997,7 +948,8 @@ if (import.meta.vitest) {
997
948
  const provider = new HostProvider({
998
949
  maxRetries: 1,
999
950
  dappName: "not-registered",
1000
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
951
+ loadAccountsProvider: loadProvider(mockProvider),
952
+ requestChainSubmitPermissionFn: grantPermission(),
1001
953
  });
1002
954
  const result = await provider.connect();
1003
955
 
@@ -1013,7 +965,8 @@ if (import.meta.vitest) {
1013
965
  const mockProvider = createMockProvider({});
1014
966
  const provider = new HostProvider({
1015
967
  maxRetries: 1,
1016
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
968
+ loadAccountsProvider: loadProvider(mockProvider),
969
+ requestChainSubmitPermissionFn: grantPermission(),
1017
970
  });
1018
971
  const result = await provider.connect();
1019
972
 
@@ -1023,19 +976,19 @@ if (import.meta.vitest) {
1023
976
  }
1024
977
  });
1025
978
 
1026
- test("getProductAccountSigner pins signerType to 'createTransaction'", async () => {
1027
- // Regression guard: the alternate "signPayload" route goes through
1028
- // PJS and throws on unknown signed extensions (e.g. AsPgas on
1029
- // Paseo Next). If a future refactor drops the explicit pin and
1030
- // upstream's default ever flips back to signPayload, this would
1031
- // silently regress.
979
+ test("getProductAccountSigner delegates to the host accounts provider", async () => {
980
+ // The host accounts provider's getProductAccountSigner has a single
981
+ // signing path (the host's `createTransaction`, which forwards opaque
982
+ // signed extensions like AsPgas on Paseo Next). There is no PJS
983
+ // fallback to select, so it's called with just the account.
1032
984
  const rawAccounts: RawAccountTest[] = [
1033
985
  { publicKey: new Uint8Array(32).fill(0xaa), name: "Alice" },
1034
986
  ];
1035
987
  const mockProvider = createMockProvider({ accounts: rawAccounts });
1036
988
  const provider = new HostProvider({
1037
989
  maxRetries: 1,
1038
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
990
+ loadAccountsProvider: loadProvider(mockProvider),
991
+ requestChainSubmitPermissionFn: grantPermission(),
1039
992
  });
1040
993
  await provider.connect();
1041
994
 
@@ -1047,7 +1000,6 @@ if (import.meta.vitest) {
1047
1000
  });
1048
1001
  expect(mockProvider.getProductAccountSigner).toHaveBeenLastCalledWith(
1049
1002
  expect.anything(),
1050
- "createTransaction",
1051
1003
  );
1052
1004
 
1053
1005
  // Path 2: getSigner() returned from HostProvider.getProductAccount(...)
@@ -1057,7 +1009,6 @@ if (import.meta.vitest) {
1057
1009
  productAccountResult.value.getSigner();
1058
1010
  expect(mockProvider.getProductAccountSigner).toHaveBeenLastCalledWith(
1059
1011
  expect.anything(),
1060
- "createTransaction",
1061
1012
  );
1062
1013
  }
1063
1014
  });
@@ -1089,7 +1040,8 @@ if (import.meta.vitest) {
1089
1040
  });
1090
1041
  const provider = new HostProvider({
1091
1042
  maxRetries: 1,
1092
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
1043
+ loadAccountsProvider: loadProvider(mockProvider),
1044
+ requestChainSubmitPermissionFn: grantPermission(),
1093
1045
  productAccount: { dotNsIdentifier: "myapp.dot", derivationIndex: 0 },
1094
1046
  });
1095
1047
  const result = await provider.connect();
@@ -1106,7 +1058,6 @@ if (import.meta.vitest) {
1106
1058
  dotNsIdentifier: "myapp.dot",
1107
1059
  derivationIndex: 0,
1108
1060
  }),
1109
- "createTransaction",
1110
1061
  );
1111
1062
  }
1112
1063
  expect(mockProvider.getProductAccount).toHaveBeenCalledWith("myapp.dot", 0);
@@ -1124,7 +1075,8 @@ if (import.meta.vitest) {
1124
1075
  });
1125
1076
  const provider = new HostProvider({
1126
1077
  maxRetries: 1,
1127
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
1078
+ loadAccountsProvider: loadProvider(mockProvider),
1079
+ requestChainSubmitPermissionFn: grantPermission(),
1128
1080
  productAccount: { dotNsIdentifier: "myapp.dot", requestName: false },
1129
1081
  });
1130
1082
  const result = await provider.connect();
@@ -1150,11 +1102,12 @@ if (import.meta.vitest) {
1150
1102
  match: async (
1151
1103
  _onOk: (v: { primaryUsername: string }) => unknown,
1152
1104
  onErr: (e: unknown) => unknown,
1153
- ) => onErr({ tag: "v1", value: { tag: "GetUserIdErr::PermissionDenied" } }),
1105
+ ) => onErr({ tag: "PermissionDenied" }),
1154
1106
  });
1155
1107
  const provider = new HostProvider({
1156
1108
  maxRetries: 1,
1157
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
1109
+ loadAccountsProvider: loadProvider(mockProvider),
1110
+ requestChainSubmitPermissionFn: grantPermission(),
1158
1111
  productAccount: { dotNsIdentifier: "myapp.dot" },
1159
1112
  });
1160
1113
  const result = await provider.connect();
@@ -1176,7 +1129,8 @@ if (import.meta.vitest) {
1176
1129
  });
1177
1130
  const provider = new HostProvider({
1178
1131
  maxRetries: 1,
1179
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
1132
+ loadAccountsProvider: loadProvider(mockProvider),
1133
+ requestChainSubmitPermissionFn: grantPermission(),
1180
1134
  productAccount: { dotNsIdentifier: "myapp.dot", requestName: false },
1181
1135
  });
1182
1136
  const connectResult = await provider.connect();
@@ -1205,7 +1159,8 @@ if (import.meta.vitest) {
1205
1159
  });
1206
1160
  const provider = new HostProvider({
1207
1161
  maxRetries: 1,
1208
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
1162
+ loadAccountsProvider: loadProvider(mockProvider),
1163
+ requestChainSubmitPermissionFn: grantPermission(),
1209
1164
  productAccount: { dotNsIdentifier: "myapp.dot", requestName: false },
1210
1165
  });
1211
1166
  await provider.connect();
@@ -1238,7 +1193,8 @@ if (import.meta.vitest) {
1238
1193
  });
1239
1194
  const provider = new HostProvider({
1240
1195
  maxRetries: 1,
1241
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
1196
+ loadAccountsProvider: loadProvider(mockProvider),
1197
+ requestChainSubmitPermissionFn: grantPermission(),
1242
1198
  productAccount: { dotNsIdentifier: "playground.dot" },
1243
1199
  });
1244
1200
  const result = await provider.connect();
@@ -1252,157 +1208,58 @@ if (import.meta.vitest) {
1252
1208
  });
1253
1209
 
1254
1210
  describe("ChainSubmit permission request", () => {
1255
- test("sends a v1 ChainSubmit request (regression guard for the TransactionSubmit bug)", async () => {
1256
- const captured: unknown[] = [];
1257
- const hostApi: HostApiPermissionBridge = {
1258
- permission: (request) => {
1259
- captured.push(request);
1260
- return fakeResult(undefined);
1261
- },
1262
- };
1263
- const mockProvider = createMockProvider({
1264
- accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
1265
- });
1266
- const provider = new HostProvider({
1267
- maxRetries: 1,
1268
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
1269
- loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
1270
- });
1271
-
1272
- await provider.connect();
1273
-
1274
- expect(captured).toHaveLength(1);
1275
- // The fake hostApiEnum returns `{ version, value }` so we can
1276
- // assert on the exact wire shape that would reach
1277
- // host-api's RemotePermission codec.
1278
- expect(captured[0]).toEqual({
1279
- version: "v1",
1280
- value: { tag: "ChainSubmit", value: undefined },
1281
- });
1282
- });
1283
-
1284
- test("does NOT send a TransactionSubmit tag (the bug)", async () => {
1285
- const captured: unknown[] = [];
1286
- const hostApi: HostApiPermissionBridge = {
1287
- permission: (request) => {
1288
- captured.push(request);
1289
- return fakeResult(undefined);
1290
- },
1291
- };
1211
+ function providerWithPermission(
1212
+ requestChainSubmitPermissionFn: (permission: RemotePermission) => Promise<boolean>,
1213
+ extra?: Partial<HostProviderOptions>,
1214
+ ) {
1292
1215
  const mockProvider = createMockProvider({
1293
1216
  accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
1294
1217
  });
1295
- const provider = new HostProvider({
1218
+ return new HostProvider({
1296
1219
  maxRetries: 1,
1297
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
1298
- loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
1220
+ loadAccountsProvider: loadProvider(mockProvider),
1221
+ requestChainSubmitPermissionFn,
1222
+ ...extra,
1299
1223
  });
1224
+ }
1300
1225
 
1301
- await provider.connect();
1226
+ test("requests the ChainSubmit permission on connect", async () => {
1227
+ const requestFn = grantPermission();
1228
+ await providerWithPermission(requestFn).connect();
1302
1229
 
1303
- const sent = JSON.stringify(captured[0]);
1304
- expect(sent).not.toContain("TransactionSubmit");
1305
- });
1306
-
1307
- test("skipped when sdk.hostApi is unavailable (older product-sdk)", async () => {
1308
- const mockProvider = createMockProvider({
1309
- accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
1310
- });
1311
- const provider = new HostProvider({
1312
- maxRetries: 1,
1313
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider /* no hostApi */)),
1314
- loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
1315
- });
1316
-
1317
- const result = await provider.connect();
1318
- // Connect should succeed even without the hostApi bridge —
1319
- // permission is best-effort.
1320
- expect(result.ok).toBe(true);
1230
+ expect(requestFn).toHaveBeenCalledTimes(1);
1231
+ expect(requestFn).toHaveBeenCalledWith({ tag: "ChainSubmit", value: undefined });
1321
1232
  });
1322
1233
 
1323
1234
  test("skipped when requestChainSubmitPermission is false", async () => {
1324
- const captured: unknown[] = [];
1325
- const hostApi: HostApiPermissionBridge = {
1326
- permission: (request) => {
1327
- captured.push(request);
1328
- return fakeResult(undefined);
1329
- },
1330
- };
1331
- const mockProvider = createMockProvider({
1332
- accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
1333
- });
1334
- const provider = new HostProvider({
1335
- maxRetries: 1,
1336
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
1337
- loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
1235
+ const requestFn = grantPermission();
1236
+ await providerWithPermission(requestFn, {
1338
1237
  requestChainSubmitPermission: false,
1339
- });
1340
-
1341
- await provider.connect();
1342
- expect(captured).toHaveLength(0);
1238
+ }).connect();
1239
+ expect(requestFn).not.toHaveBeenCalled();
1343
1240
  });
1344
1241
 
1345
1242
  test("deprecated requestTransactionSubmitPermission alias still controls the request", async () => {
1346
- const captured: unknown[] = [];
1347
- const hostApi: HostApiPermissionBridge = {
1348
- permission: (request) => {
1349
- captured.push(request);
1350
- return fakeResult(undefined);
1351
- },
1352
- };
1353
- const mockProvider = createMockProvider({
1354
- accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
1355
- });
1356
- const provider = new HostProvider({
1357
- maxRetries: 1,
1358
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
1359
- loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
1360
- // Old name; new code path should still respect it as `false`.
1243
+ const requestFn = grantPermission();
1244
+ await providerWithPermission(requestFn, {
1361
1245
  requestTransactionSubmitPermission: false,
1362
- });
1363
-
1364
- await provider.connect();
1365
- expect(captured).toHaveLength(0);
1246
+ }).connect();
1247
+ expect(requestFn).not.toHaveBeenCalled();
1366
1248
  });
1367
1249
 
1368
- test("connect succeeds even when permission request rejects", async () => {
1369
- // Whatever the host says about permission, connect() should
1370
- // still return ok — the consumer can sign later with whatever
1371
- // permission they negotiate.
1372
- const hostApi: HostApiPermissionBridge = {
1373
- permission: () => fakeResult(undefined, { tag: "PermissionDenied" }),
1374
- };
1375
- const mockProvider = createMockProvider({
1376
- accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
1377
- });
1378
- const provider = new HostProvider({
1379
- maxRetries: 1,
1380
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
1381
- loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
1382
- });
1383
-
1384
- const result = await provider.connect();
1250
+ test("connect succeeds even when the permission is denied", async () => {
1251
+ const requestFn = vi
1252
+ .fn<(permission: RemotePermission) => Promise<boolean>>()
1253
+ .mockResolvedValue(false);
1254
+ const result = await providerWithPermission(requestFn).connect();
1385
1255
  expect(result.ok).toBe(true);
1386
1256
  });
1387
1257
 
1388
- test("connect succeeds even when the hostApiEnum loader throws (codec drift)", async () => {
1389
- // The original bug: the v1 RemotePermission codec didn't
1390
- // recognize the TransactionSubmit tag and threw client-side.
1391
- // Even when something like that happens, connect() must
1392
- // remain ok permission is best-effort.
1393
- const hostApi: HostApiPermissionBridge = {
1394
- permission: () => fakeResult(undefined),
1395
- };
1396
- const mockProvider = createMockProvider({
1397
- accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
1398
- });
1399
- const provider = new HostProvider({
1400
- maxRetries: 1,
1401
- loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
1402
- loadHostApiEnum: () => Promise.reject(new Error("codec drift")),
1403
- });
1404
-
1405
- const result = await provider.connect();
1258
+ test("connect succeeds even when the permission request throws", async () => {
1259
+ const requestFn = vi
1260
+ .fn<(permission: RemotePermission) => Promise<boolean>>()
1261
+ .mockRejectedValue(new Error("host unreachable"));
1262
+ const result = await providerWithPermission(requestFn).connect();
1406
1263
  expect(result.ok).toBe(true);
1407
1264
  });
1408
1265
  });
@@ -1420,6 +1277,10 @@ if (import.meta.vitest) {
1420
1277
  expect(formatError(undefined)).toBe("undefined");
1421
1278
  });
1422
1279
 
1280
+ test("surfaces GenericError.reason when there is no tag", () => {
1281
+ expect(formatError({ reason: "boom" })).toContain("boom");
1282
+ });
1283
+
1423
1284
  test("surfaces inner Error name + message under the outer tag", () => {
1424
1285
  // Simulates the exact shape the original bug produced:
1425
1286
  // `{ tag: "v1", value: { name: "GenericError", message: "..." } }`
@@ -1463,32 +1324,4 @@ if (import.meta.vitest) {
1463
1324
  expect(formatError({ tag: "v1", value: "code-42" })).toBe("v1 (code-42)");
1464
1325
  });
1465
1326
  });
1466
-
1467
- describe("RemotePermission codec interop", () => {
1468
- // Smoke test that the wire payload we build (`ChainSubmit`) round-trips
1469
- // through the real host-api codec. The previous bug shipped
1470
- // `TransactionSubmit`, which the codec rejects — locking this in here
1471
- // catches a regression at the codec layer without needing the host.
1472
- test("encodes ChainSubmit payload without throwing", async () => {
1473
- const { RemotePermission } = await import("@novasamatech/host-api");
1474
- const payload = { tag: "ChainSubmit" as const, value: undefined };
1475
- const encoded = RemotePermission.enc(payload);
1476
- expect(encoded).toBeInstanceOf(Uint8Array);
1477
- const decoded = RemotePermission.dec(encoded);
1478
- expect(decoded.tag).toBe("ChainSubmit");
1479
- });
1480
-
1481
- test("rejects the legacy TransactionSubmit tag", async () => {
1482
- const { RemotePermission } = await import("@novasamatech/host-api");
1483
- // `TransactionSubmit` is not a valid variant in v1 — the codec
1484
- // should refuse to encode it. This proves the codec actually
1485
- // validates tags (so test 1 isn't a tautology).
1486
- expect(() =>
1487
- RemotePermission.enc({
1488
- tag: "TransactionSubmit",
1489
- value: undefined,
1490
- } as never),
1491
- ).toThrow();
1492
- });
1493
- });
1494
1327
  }