@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.
- package/CHANGELOG.md +73 -0
- package/README.md +46 -1
- package/dist/actions/index.d.mts +2 -2
- package/dist/actions/index.mjs +1 -1370
- package/dist/actions/index.mjs.map +1 -1
- package/dist/facesign/index.d.mts +1 -1
- package/dist/facesign/index.mjs +1 -2
- package/dist/{facesign-signer-BC24j1eN.d.mts → facesign-signer-5I8juA7p.d.mts} +2 -1
- package/dist/facesign-signer-5I8juA7p.d.mts.map +1 -0
- package/dist/facesign-signer-z5T312uQ.mjs +2 -0
- package/dist/facesign-signer-z5T312uQ.mjs.map +1 -0
- package/dist/{index-iqkJfgWf.d.mts → index-Bqz6trCk.d.mts} +223 -136
- package/dist/index-Bqz6trCk.d.mts.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -429
- package/dist/index.mjs.map +1 -1
- package/dist/signature-verification/index.d.mts +3 -4
- package/dist/signature-verification/index.d.mts.map +1 -1
- package/dist/signature-verification/index.mjs +1 -134
- package/dist/signature-verification/index.mjs.map +1 -1
- package/dist/xrp/utils.d.mts +130 -20
- package/dist/xrp/utils.d.mts.map +1 -1
- package/dist/xrp/utils.mjs +1 -104
- package/dist/xrp/utils.mjs.map +1 -1
- package/dist/xrpl-credentials/index.d.mts +6 -7
- package/dist/xrpl-credentials/index.d.mts.map +1 -1
- package/dist/xrpl-credentials/index.mjs +1 -223
- package/dist/xrpl-credentials/index.mjs.map +1 -1
- package/package.json +9 -8
- package/src/actions/actions.test.ts +189 -0
- package/src/actions/actions.ts +368 -467
- package/src/actions/index.ts +38 -0
- package/src/create-kgw-authenticated-fetch.test.ts +377 -0
- package/src/create-kgw-authenticated-fetch.ts +321 -0
- package/src/create-kwil-client.ts +33 -10
- package/src/create-kwil-signer.test.ts +80 -0
- package/src/create-kwil-signer.ts +31 -5
- package/src/facesign/facesign-signer.test.ts +77 -0
- package/src/facesign/facesign-signer.ts +50 -2
- package/src/index.ts +2 -0
- package/src/mm-token/create-mm-token-kwil-signer.test.ts +148 -0
- package/src/mm-token/create-mm-token-kwil-signer.ts +166 -0
- package/src/near/create-near-wallet-kwil-signer.ts +1 -1
- package/src/signature-verification/index.test.ts +18 -0
- package/src/signature-verification/index.ts +7 -1
- package/src/wait-for-kwil-tx.test.ts +92 -0
- package/src/wait-for-kwil-tx.ts +111 -0
- package/src/xrp/signer.ts +2 -1
- package/src/xrp/utils.ts +7 -25
- package/tsdown.config.ts +1 -0
- package/dist/facesign-signer-BC24j1eN.d.mts.map +0 -1
- package/dist/facesign-signer-qzCcwunk.mjs +0 -244
- package/dist/facesign-signer-qzCcwunk.mjs.map +0 -1
- 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 {
|
|
2
|
-
import type {
|
|
3
|
-
|
|
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
|
|
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,
|
|
105
|
-
|
|
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
|
|
145
|
+
const rawPublicKey = signer.getPublicKey().toString();
|
|
146
|
+
const hexPublicKey = implicitAddressFromPublicKey(rawPublicKey);
|
|
138
147
|
return [
|
|
139
|
-
new KwilSigner(kwilNep413Signer("idos-issuer")(signer),
|
|
140
|
-
|
|
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
|
+
});
|