@idos-network/kwil-infra 1.5.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +46 -1
  3. package/dist/actions/index.d.mts +2 -2
  4. package/dist/actions/index.mjs +1 -1370
  5. package/dist/actions/index.mjs.map +1 -1
  6. package/dist/facesign/index.d.mts +1 -1
  7. package/dist/facesign/index.mjs +1 -2
  8. package/dist/{facesign-signer-BC24j1eN.d.mts → facesign-signer-5I8juA7p.d.mts} +2 -1
  9. package/dist/facesign-signer-5I8juA7p.d.mts.map +1 -0
  10. package/dist/facesign-signer-z5T312uQ.mjs +2 -0
  11. package/dist/facesign-signer-z5T312uQ.mjs.map +1 -0
  12. package/dist/{index-iqkJfgWf.d.mts → index-Bqz6trCk.d.mts} +223 -136
  13. package/dist/index-Bqz6trCk.d.mts.map +1 -0
  14. package/dist/index.d.mts +2 -2
  15. package/dist/index.mjs +1 -429
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/signature-verification/index.d.mts +3 -4
  18. package/dist/signature-verification/index.d.mts.map +1 -1
  19. package/dist/signature-verification/index.mjs +1 -134
  20. package/dist/signature-verification/index.mjs.map +1 -1
  21. package/dist/xrp/utils.d.mts +130 -20
  22. package/dist/xrp/utils.d.mts.map +1 -1
  23. package/dist/xrp/utils.mjs +1 -104
  24. package/dist/xrp/utils.mjs.map +1 -1
  25. package/dist/xrpl-credentials/index.d.mts +6 -7
  26. package/dist/xrpl-credentials/index.d.mts.map +1 -1
  27. package/dist/xrpl-credentials/index.mjs +1 -223
  28. package/dist/xrpl-credentials/index.mjs.map +1 -1
  29. package/package.json +9 -8
  30. package/src/actions/actions.test.ts +189 -0
  31. package/src/actions/actions.ts +368 -467
  32. package/src/actions/index.ts +38 -0
  33. package/src/create-kgw-authenticated-fetch.test.ts +377 -0
  34. package/src/create-kgw-authenticated-fetch.ts +321 -0
  35. package/src/create-kwil-client.ts +33 -10
  36. package/src/create-kwil-signer.test.ts +80 -0
  37. package/src/create-kwil-signer.ts +31 -5
  38. package/src/facesign/facesign-signer.test.ts +77 -0
  39. package/src/facesign/facesign-signer.ts +50 -2
  40. package/src/index.ts +2 -0
  41. package/src/mm-token/create-mm-token-kwil-signer.test.ts +148 -0
  42. package/src/mm-token/create-mm-token-kwil-signer.ts +166 -0
  43. package/src/near/create-near-wallet-kwil-signer.ts +1 -1
  44. package/src/signature-verification/index.test.ts +18 -0
  45. package/src/signature-verification/index.ts +7 -1
  46. package/src/wait-for-kwil-tx.test.ts +92 -0
  47. package/src/wait-for-kwil-tx.ts +111 -0
  48. package/src/xrp/signer.ts +2 -1
  49. package/src/xrp/utils.ts +7 -25
  50. package/tsdown.config.ts +1 -0
  51. package/dist/facesign-signer-BC24j1eN.d.mts.map +0 -1
  52. package/dist/facesign-signer-qzCcwunk.mjs +0 -244
  53. package/dist/facesign-signer-qzCcwunk.mjs.map +0 -1
  54. package/dist/index-iqkJfgWf.d.mts.map +0 -1
@@ -0,0 +1,321 @@
1
+ import { type KwilSigner, NodeKwil } from "@idos-network/kwil-js";
2
+ import { BlobGateway, type BlobGatewayParams } from "@idos-network/utils/blob-gateway";
3
+
4
+ import type { KwilActionClient } from "./create-kwil-client";
5
+ import type { MmTokenAuth } from "./mm-token/create-mm-token-kwil-signer";
6
+
7
+ import { isMmTokenAuth } from "./mm-token/create-mm-token-kwil-signer";
8
+
9
+ const KGW_AUTH_ERROR_CODE = -901;
10
+
11
+ /** Return true when the response means KGW auth is missing/expired.
12
+ * May read the body only for `application/json` responses (those are probed via a clone). */
13
+ export type KgwAuthFailurePredicate = (response: Response) => Promise<boolean> | boolean;
14
+
15
+ export type CreateKgwAuthenticatedFetchParams = {
16
+ kwilClient: KwilActionClient;
17
+ signer: KwilSigner;
18
+ fetchFn?: typeof fetch;
19
+ isAuthFailure?: KgwAuthFailurePredicate;
20
+ };
21
+
22
+ export type CreateKgwAuthenticatedBlobGatewayParams = CreateKgwAuthenticatedFetchParams & {
23
+ url: string;
24
+ maxFetchBytes?: BlobGatewayParams["maxFetchBytes"];
25
+ /**
26
+ * UKYC storage authority for blob requests. Defaults to `signer` when that signer
27
+ * is itself an MM authentication object, so the capability is never passed twice.
28
+ */
29
+ mmAuth?: MmTokenAuth;
30
+ };
31
+
32
+ type KgwSessionNodeKwil = NodeKwil & {
33
+ authenticateKGWAndSetCookie(signer: KwilSigner): Promise<string>;
34
+ getKgwCookie(): string | undefined;
35
+ };
36
+
37
+ export function createKgwAuthenticatedBlobGateway({
38
+ url,
39
+ maxFetchBytes,
40
+ mmAuth,
41
+ ...fetchParams
42
+ }: CreateKgwAuthenticatedBlobGatewayParams): BlobGateway {
43
+ const storageAuth =
44
+ mmAuth ?? (isMmTokenAuth(fetchParams.signer) ? fetchParams.signer : undefined);
45
+
46
+ return new BlobGateway({
47
+ url,
48
+ maxFetchBytes,
49
+ accessToken: storageAuth?.accessToken,
50
+ fetchFn: createKgwAuthenticatedFetch(fetchParams),
51
+ });
52
+ }
53
+
54
+ export function createKgwAuthenticatedFetch({
55
+ kwilClient,
56
+ signer,
57
+ fetchFn = fetch,
58
+ isAuthFailure = defaultKgwAuthFailurePredicate,
59
+ }: CreateKgwAuthenticatedFetchParams): typeof fetch {
60
+ const nodeKwil = getNodeKwilClient(kwilClient);
61
+ // Coalesce in-flight re-auths. Node isn't multi-threaded, but concurrent awaits still
62
+ // interleave: two fetches can both see an auth failure and both call refreshCookie before
63
+ // either finishes. Without this, that would kick off two authenticateKGWAndSetCookie calls.
64
+ // See "shares one refresh across parallel auth failures".
65
+ let refreshPromise: Promise<string> | undefined;
66
+
67
+ const refreshCookie = async (): Promise<string> => {
68
+ refreshPromise ??= nodeKwil.authenticateKGWAndSetCookie(signer).finally(() => {
69
+ refreshPromise = undefined;
70
+ });
71
+
72
+ return refreshPromise;
73
+ };
74
+
75
+ const ensureCookie = async (): Promise<{ cookie: string; refreshed: boolean }> => {
76
+ const existingCookie = nodeKwil.getKgwCookie();
77
+ if (existingCookie) {
78
+ return { cookie: existingCookie, refreshed: false };
79
+ }
80
+
81
+ return { cookie: await refreshCookie(), refreshed: true };
82
+ };
83
+
84
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
85
+ const { cookie } = await ensureCookie();
86
+ const bufferedRequest = await bufferRequestBody(input, init);
87
+ const response = await fetchFn(
88
+ ...withCookie(input, withMaterializedBody(bufferedRequest), cookie),
89
+ );
90
+
91
+ const probe = authFailureProbe(response);
92
+ if (!(await isAuthFailure(probe.response))) {
93
+ return response;
94
+ }
95
+
96
+ // Discard the failed response so an unread body doesn't keep streaming.
97
+ await discardUnreadBodies(probe.discardResponses);
98
+ const refreshedCookie = await refreshCookie();
99
+ return fetchFn(...withCookie(input, withMaterializedBody(bufferedRequest), refreshedCookie));
100
+ };
101
+ }
102
+
103
+ type AuthFailureProbe = {
104
+ response: Response;
105
+ discardResponses: Response[];
106
+ };
107
+
108
+ /**
109
+ * Body is one-shot. Only clone when the default predicate might call `.json()`
110
+ * (JSON content-type). Cloning a large blob response would tee the stream and
111
+ * risk buffering the download on an abandoned branch — and in Node, canceling
112
+ * that unread tee branch hangs.
113
+ *
114
+ * When a cloned response is thrown away after an auth failure, cancel both tee
115
+ * branches together; canceling only one branch can wait forever for the other.
116
+ */
117
+ function authFailureProbe(response: Response): AuthFailureProbe {
118
+ const contentType = response.headers.get("content-type");
119
+ if (contentType?.includes("application/json")) {
120
+ const probe = response.clone();
121
+ return { response: probe, discardResponses: [response, probe] };
122
+ }
123
+
124
+ return { response, discardResponses: [response] };
125
+ }
126
+
127
+ async function discardUnreadBodies(responses: Response[]): Promise<void> {
128
+ await Promise.all(responses.map(discardUnreadBody));
129
+ }
130
+
131
+ async function discardUnreadBody(response: Response): Promise<void> {
132
+ if (response.bodyUsed || !response.body) {
133
+ return;
134
+ }
135
+
136
+ try {
137
+ await response.body.cancel();
138
+ } catch {
139
+ // Already locked/closed by a concurrent reader.
140
+ }
141
+ }
142
+
143
+ type BufferedFormDataEntry =
144
+ | { key: string; value: string }
145
+ | { key: string; value: ArrayBuffer; fileName: string; type: string };
146
+
147
+ type BufferedBody =
148
+ | { kind: "body"; value: BodyInit }
149
+ | { kind: "form-data"; entries: BufferedFormDataEntry[] };
150
+
151
+ type BufferedRequestBody = {
152
+ requestInit: RequestInit | undefined;
153
+ bufferedBody?: BufferedBody;
154
+ };
155
+
156
+ async function bufferRequestBody(
157
+ input: RequestInfo | URL,
158
+ init?: RequestInit,
159
+ ): Promise<BufferedRequestBody> {
160
+ if (init?.body != null) {
161
+ return {
162
+ requestInit: init,
163
+ bufferedBody: await bufferBody(init.body),
164
+ };
165
+ }
166
+
167
+ if (input instanceof Request && input.body != null) {
168
+ const requestClone = input.clone();
169
+ return {
170
+ requestInit: {
171
+ ...init,
172
+ method: init?.method ?? input.method,
173
+ },
174
+ bufferedBody: await bufferBody(requestClone.body!),
175
+ };
176
+ }
177
+
178
+ return { requestInit: init };
179
+ }
180
+
181
+ function withMaterializedBody({
182
+ requestInit,
183
+ bufferedBody,
184
+ }: BufferedRequestBody): RequestInit | undefined {
185
+ if (!bufferedBody) {
186
+ return requestInit;
187
+ }
188
+
189
+ return {
190
+ ...requestInit,
191
+ body: materializeBody(bufferedBody),
192
+ };
193
+ }
194
+
195
+ async function bufferBody(body: BodyInit): Promise<BufferedBody> {
196
+ if (
197
+ typeof body === "string" ||
198
+ body instanceof Blob ||
199
+ body instanceof ArrayBuffer ||
200
+ body instanceof URLSearchParams
201
+ ) {
202
+ return { kind: "body", value: body };
203
+ }
204
+
205
+ if (ArrayBuffer.isView(body)) {
206
+ return {
207
+ kind: "body",
208
+ value: new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
209
+ };
210
+ }
211
+
212
+ if (body instanceof FormData) {
213
+ const entries: Promise<BufferedFormDataEntry>[] = [];
214
+
215
+ body.forEach((value, key) => {
216
+ entries.push(bufferFormDataEntry(key, value));
217
+ });
218
+
219
+ return { kind: "form-data", entries: await Promise.all(entries) };
220
+ }
221
+
222
+ if (body instanceof ReadableStream) {
223
+ return {
224
+ kind: "body",
225
+ value: await new Response(body).arrayBuffer(),
226
+ };
227
+ }
228
+
229
+ return { kind: "body", value: body };
230
+ }
231
+
232
+ async function bufferFormDataEntry(
233
+ key: string,
234
+ value: FormDataEntryValue,
235
+ ): Promise<BufferedFormDataEntry> {
236
+ if (typeof value === "string") {
237
+ return { key, value };
238
+ }
239
+
240
+ return {
241
+ key,
242
+ value: await new Response(value).arrayBuffer(),
243
+ fileName: value instanceof File ? value.name : "blob",
244
+ type: value.type || "application/octet-stream",
245
+ };
246
+ }
247
+
248
+ function materializeBody(bufferedBody: BufferedBody): BodyInit {
249
+ if (bufferedBody.kind === "body") {
250
+ return bufferedBody.value;
251
+ }
252
+
253
+ const formData = new FormData();
254
+
255
+ for (const entry of bufferedBody.entries) {
256
+ if ("fileName" in entry) {
257
+ formData.append(entry.key, new Blob([entry.value], { type: entry.type }), entry.fileName);
258
+ } else {
259
+ formData.append(entry.key, entry.value);
260
+ }
261
+ }
262
+
263
+ return formData;
264
+ }
265
+
266
+ function getNodeKwilClient(kwilClient: KwilActionClient): KgwSessionNodeKwil {
267
+ if (!(kwilClient.client instanceof NodeKwil)) {
268
+ throw new Error("KGW authenticated fetch can only be created from a NodeKwil client");
269
+ }
270
+
271
+ if (!hasKgwSessionHelpers(kwilClient.client)) {
272
+ throw new Error("NodeKwil client does not expose KGW session cookie helpers");
273
+ }
274
+
275
+ return kwilClient.client;
276
+ }
277
+
278
+ function hasKgwSessionHelpers(client: NodeKwil): client is KgwSessionNodeKwil {
279
+ const maybeClient = client as Partial<KgwSessionNodeKwil>;
280
+
281
+ return (
282
+ typeof maybeClient.getKgwCookie === "function" &&
283
+ typeof maybeClient.authenticateKGWAndSetCookie === "function"
284
+ );
285
+ }
286
+
287
+ function withCookie(
288
+ input: RequestInfo | URL,
289
+ init: RequestInit | undefined,
290
+ cookie: string,
291
+ ): Parameters<typeof fetch> {
292
+ const headers = new Headers(input instanceof Request ? input.headers : undefined);
293
+
294
+ if (init?.headers) {
295
+ new Headers(init.headers).forEach((value, key) => {
296
+ headers.set(key, value);
297
+ });
298
+ }
299
+
300
+ headers.set("Cookie", cookie);
301
+
302
+ return [input, { ...init, headers }];
303
+ }
304
+
305
+ async function defaultKgwAuthFailurePredicate(response: Response): Promise<boolean> {
306
+ if (response.status === 401) {
307
+ return true;
308
+ }
309
+
310
+ const contentType = response.headers.get("content-type");
311
+ if (!contentType?.includes("application/json")) {
312
+ return false;
313
+ }
314
+
315
+ try {
316
+ const body = (await response.json()) as { error?: { code?: number } };
317
+ return body.error?.code === KGW_AUTH_ERROR_CODE;
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
@@ -1,10 +1,6 @@
1
- import type { Config } from "@idos-network/kwil-js/dist/api_client/config";
2
- import type {
3
- ActionBody,
4
- CallBody,
5
- PositionalParams,
6
- } from "@idos-network/kwil-js/dist/core/action";
7
- import type { DataInfo } from "@idos-network/kwil-js/dist/core/database";
1
+ import type { ActionBody, CallBody, PositionalParams } from "@idos-network/kwil-js/action";
2
+ import type { Config } from "@idos-network/kwil-js/config";
3
+ import type { DataInfo } from "@idos-network/kwil-js/database";
8
4
 
9
5
  import { type KwilSigner, NodeKwil, WebKwil } from "@idos-network/kwil-js";
10
6
  import invariant from "tiny-invariant";
@@ -12,6 +8,9 @@ import invariant from "tiny-invariant";
12
8
  import type { ActionSchemaElement } from "./actions";
13
9
 
14
10
  import { actionSchema } from "./actions";
11
+ import { waitForKwilTx } from "./wait-for-kwil-tx";
12
+
13
+ export { KwilTxFailedError, KwilTxPollTimeoutError } from "./wait-for-kwil-tx";
15
14
 
16
15
  type CreateKwilClientParams = {
17
16
  chainId?: string;
@@ -87,12 +86,15 @@ export class KwilActionClient {
87
86
 
88
87
  /**
89
88
  * Executes an action on the kwil nodes. This similar to `POST` like request.
89
+ *
90
+ * When `synchronous` is true (default), the tx is broadcast without blocking the node,
91
+ * then confirmed by polling `txInfo` with exponential backoff.
90
92
  */
91
93
  async execute(
92
94
  params: KwilExecuteActionRequestParams,
93
95
  signer: KwilSigner | undefined = this.signer,
94
96
  synchronous = true,
95
- ): Promise<string | undefined> {
97
+ ): Promise<string> {
96
98
  invariant(signer, "Signer is not set, you must set it before executing an action");
97
99
  const action: ActionBody = {
98
100
  name: params.name,
@@ -101,8 +103,29 @@ export class KwilActionClient {
101
103
  inputs: [this.#createActionInputs(params.name, params.inputs)],
102
104
  types: this.#actionTypes(params.name),
103
105
  };
104
- const response = await this.client.execute(action, signer, synchronous);
105
- return response.data?.tx_hash;
106
+ const response = await this.client.execute(action, signer, false);
107
+ const txHash = response.data?.tx_hash;
108
+
109
+ if (!txHash) {
110
+ throw new Error(`Kwil execute "${params.name}" did not return a transaction hash`);
111
+ }
112
+
113
+ if (synchronous) {
114
+ await this.waitForTx(txHash);
115
+ }
116
+
117
+ return txHash;
118
+ }
119
+
120
+ /**
121
+ * Polls the node until a broadcast transaction is mined, or throws
122
+ * `KwilTxFailedError` / `KwilTxPollTimeoutError`.
123
+ *
124
+ * Called through `this` rather than directly so that it is a seam an
125
+ * instrumentation can wrap — the wait dominates the latency of `execute`.
126
+ */
127
+ async waitForTx(txHash: string): Promise<void> {
128
+ await waitForKwilTx((hash) => this.client.txInfo(hash), txHash);
106
129
  }
107
130
 
108
131
  setSigner(signer: KwilSigner | undefined): void {
@@ -0,0 +1,80 @@
1
+ import { base64UrlEncode, utf8Encode } from "@idos-network/utils/codecs";
2
+ import { MemoryStore } from "@idos-network/utils/store";
3
+ import nacl from "tweetnacl";
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ import type { KwilActionClient } from "./create-kwil-client";
7
+
8
+ import { createClientKwilSigner, createServerKwilSigner } from "./create-kwil-signer";
9
+ import { createMmTokenAuth } from "./mm-token/create-mm-token-kwil-signer";
10
+
11
+ function mmToken(signingKeyByte: number): string {
12
+ return base64UrlEncode(
13
+ utf8Encode(
14
+ JSON.stringify({
15
+ payload: {
16
+ signing_public_key: base64UrlEncode(new Uint8Array(32).fill(signingKeyByte)),
17
+ storage_id: "storage-abc",
18
+ },
19
+ signature: base64UrlEncode(new Uint8Array(64).fill(9)),
20
+ }),
21
+ ),
22
+ );
23
+ }
24
+
25
+ function kwilClientWithLogout(logoutKGW: () => Promise<void>): KwilActionClient {
26
+ return { client: { auth: { logoutKGW } } } as unknown as KwilActionClient;
27
+ }
28
+
29
+ describe("createClientKwilSigner with MM authentication", () => {
30
+ it("reports the MM signing identity as address and public key", async () => {
31
+ const auth = createMmTokenAuth(mmToken(7));
32
+ const logoutKGW = vi.fn(async () => {});
33
+
34
+ const [signer, address, publicKey, walletType] = await createClientKwilSigner(
35
+ new MemoryStore(),
36
+ kwilClientWithLogout(logoutKGW),
37
+ auth,
38
+ );
39
+
40
+ expect(signer).toBe(auth);
41
+ expect(walletType).toBe("MM");
42
+ expect(address).toBe(base64UrlEncode(auth.identifier));
43
+ expect(publicKey).toBe(address);
44
+ });
45
+
46
+ it("clears the KGW session only when the MM signing identity changes", async () => {
47
+ const store = new MemoryStore();
48
+ const logoutKGW = vi.fn(async () => {});
49
+ const kwilClient = kwilClientWithLogout(logoutKGW);
50
+
51
+ await createClientKwilSigner(store, kwilClient, createMmTokenAuth(mmToken(7)));
52
+ expect(logoutKGW).toHaveBeenCalledTimes(1);
53
+
54
+ // Same identity, freshly issued token: the existing KGW cookie stays valid.
55
+ await createClientKwilSigner(store, kwilClient, createMmTokenAuth(mmToken(7)));
56
+ expect(logoutKGW).toHaveBeenCalledTimes(1);
57
+
58
+ await createClientKwilSigner(store, kwilClient, createMmTokenAuth(mmToken(8)));
59
+ expect(logoutKGW).toHaveBeenCalledTimes(2);
60
+ });
61
+ });
62
+
63
+ describe("createServerKwilSigner with MM authentication", () => {
64
+ it("uses the MM authentication object as the Kwil signer", async () => {
65
+ const auth = createMmTokenAuth(mmToken(7));
66
+
67
+ expect(await createServerKwilSigner(auth)).toEqual([auth, base64UrlEncode(auth.identifier)]);
68
+ });
69
+ });
70
+
71
+ describe("createServerKwilSigner with Nacl sign key pair", () => {
72
+ it("emits lowercase hex SignerAddress matching kwild ed25519 @caller format", async () => {
73
+ const keyPair = nacl.sign.keyPair();
74
+ const [kwilSigner, signerAddress] = await createServerKwilSigner(keyPair);
75
+
76
+ expect(signerAddress).toMatch(/^[0-9a-f]{64}$/);
77
+ expect(signerAddress).toBe(signerAddress.toLowerCase());
78
+ expect(kwilSigner).toBeDefined();
79
+ });
80
+ });
@@ -6,13 +6,15 @@ import type { KeyPair as NearKeyPair } from "near-api-js";
6
6
  import type { KeyPair as XrpKeyPair } from "ripple-keypairs/src/types";
7
7
 
8
8
  import { KwilSigner } from "@idos-network/kwil-js";
9
- import { bs58Encode, hexDecode, hexEncode } from "@idos-network/utils/codecs";
9
+ import { base64UrlEncode, bs58Encode, hexDecode, hexEncode } from "@idos-network/utils/codecs";
10
10
  import nacl from "tweetnacl";
11
11
 
12
12
  import type { WalletType } from "./actions";
13
13
  import type { KwilActionClient } from "./create-kwil-client";
14
+ import type { MmTokenAuth } from "./mm-token/create-mm-token-kwil-signer";
14
15
 
15
16
  import { FaceSignSignerProvider } from "./facesign/facesign-signer";
17
+ import { isMmTokenAuth } from "./mm-token/create-mm-token-kwil-signer";
16
18
  import {
17
19
  createNearWalletKwilSigner,
18
20
  implicitAddressFromPublicKey,
@@ -28,6 +30,7 @@ export type Wallet =
28
30
  | JsonRpcSigner
29
31
  | NearWallet
30
32
  | CustomKwilSigner
33
+ | MmTokenAuth
31
34
  | FaceSignSignerProvider;
32
35
 
33
36
  /**
@@ -108,7 +111,8 @@ export type KwilSignerType =
108
111
  | nacl.SignKeyPair
109
112
  | JsonRpcSigner
110
113
  | StellarKeypair
111
- | XrpKeyPair;
114
+ | XrpKeyPair
115
+ | MmTokenAuth;
112
116
 
113
117
  export type SignerAddress = string;
114
118
  export type SignerPublicKey = string | undefined;
@@ -122,6 +126,10 @@ export type SignerPublicKey = string | undefined;
122
126
  export async function createServerKwilSigner(
123
127
  signer: KwilSignerType,
124
128
  ): Promise<[KwilSigner, SignerAddress]> {
129
+ if (isMmTokenAuth(signer)) {
130
+ return [signer, base64UrlEncode(signer.identifier)];
131
+ }
132
+
125
133
  if (isNaclSignKeyPair(signer)) {
126
134
  return [
127
135
  new KwilSigner(
@@ -134,10 +142,11 @@ export async function createServerKwilSigner(
134
142
  }
135
143
 
136
144
  if (isNearKeyPair(signer)) {
137
- const publicKey = implicitAddressFromPublicKey(signer.getPublicKey().toString());
145
+ const rawPublicKey = signer.getPublicKey().toString();
146
+ const hexPublicKey = implicitAddressFromPublicKey(rawPublicKey);
138
147
  return [
139
- new KwilSigner(kwilNep413Signer("idos-issuer")(signer), publicKey, "nep413"),
140
- publicKey,
148
+ new KwilSigner(kwilNep413Signer("idos-issuer")(signer), hexPublicKey, "nep413"),
149
+ rawPublicKey,
141
150
  ];
142
151
  }
143
152
 
@@ -238,6 +247,23 @@ export async function createClientKwilSigner(
238
247
  ];
239
248
  }
240
249
 
250
+ if (isMmTokenAuth(wallet)) {
251
+ const currentAddress = base64UrlEncode(wallet.identifier);
252
+ const storedAddress = await store.get<string>("signer-address");
253
+
254
+ if (storedAddress !== currentAddress) {
255
+ // A different MM signing identity must not re-use the previous KGW cookie.
256
+ store.set("signer-address", currentAddress);
257
+ try {
258
+ await kwilClient.client.auth.logoutKGW();
259
+ } catch (error) {
260
+ console.log("error logoutKGW", error);
261
+ }
262
+ }
263
+
264
+ return [wallet, currentAddress, currentAddress, "MM"];
265
+ }
266
+
241
267
  if (isCustomKwilSigner(wallet) || wallet instanceof FaceSignSignerProvider) {
242
268
  const storedAddress = await store.get<string>("signer-address");
243
269
 
@@ -0,0 +1,77 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { FaceSignSignerProvider } from "./facesign-signer";
6
+
7
+ const ENCLAVE_ORIGIN = "https://facesign.example";
8
+
9
+ function provider() {
10
+ return new FaceSignSignerProvider({
11
+ metadata: { name: "idOS Dashboard", description: "FaceSign signer" },
12
+ enclaveUrl: ENCLAVE_ORIGIN,
13
+ });
14
+ }
15
+
16
+ function iframeWindow(): Window {
17
+ const iframe = document.querySelector("iframe");
18
+ if (!iframe?.contentWindow) {
19
+ throw new Error("FaceSign iframe did not load");
20
+ }
21
+ return iframe.contentWindow;
22
+ }
23
+
24
+ describe("FaceSignSignerProvider.reset", () => {
25
+ afterEach(() => {
26
+ vi.useRealTimers();
27
+ document.body.replaceChildren();
28
+ });
29
+
30
+ it("asks the enclave to delete its key before tearing the iframe down", async () => {
31
+ const signer = provider();
32
+ const posted: unknown[] = [];
33
+ const reset = signer.reset();
34
+
35
+ await vi.waitFor(() => {
36
+ expect(document.querySelector("iframe")).not.toBeNull();
37
+ });
38
+
39
+ const contentWindow = iframeWindow();
40
+ vi.spyOn(contentWindow, "postMessage").mockImplementation((message) => {
41
+ posted.push(message);
42
+ });
43
+
44
+ window.dispatchEvent(
45
+ new MessageEvent("message", {
46
+ origin: ENCLAVE_ORIGIN,
47
+ data: { type: "facesign_ready", hasKey: true },
48
+ }),
49
+ );
50
+
51
+ await vi.waitFor(() => {
52
+ expect(posted).toContainEqual({ type: "reset", data: { id: 1 } });
53
+ });
54
+
55
+ window.dispatchEvent(
56
+ new MessageEvent("message", {
57
+ origin: ENCLAVE_ORIGIN,
58
+ data: { type: "reset_complete", data: { id: 1, ok: true } },
59
+ }),
60
+ );
61
+
62
+ await reset;
63
+ expect(document.querySelector("iframe")).toBeNull();
64
+ });
65
+
66
+ it("rejects within the reset deadline when the enclave never becomes ready", async () => {
67
+ vi.useFakeTimers();
68
+ const signer = provider();
69
+ const reset = signer.reset();
70
+ const rejected = expect(reset).rejects.toThrow("FaceSign enclave failed to load");
71
+
72
+ await vi.advanceTimersByTimeAsync(15_000);
73
+
74
+ await rejected;
75
+ expect(document.querySelector("iframe")).toBeNull();
76
+ });
77
+ });