@idos-network/kwil-infra 1.2.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -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-ADd4EASX.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-UFgWkjYP.d.mts → index-Bqz6trCk.d.mts} +273 -187
- package/dist/index-Bqz6trCk.d.mts.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -415
- package/dist/index.mjs.map +1 -1
- package/dist/signature-verification/index.d.mts +3 -5
- package/dist/signature-verification/index.d.mts.map +1 -1
- package/dist/signature-verification/index.mjs +1 -139
- package/dist/signature-verification/index.mjs.map +1 -1
- package/dist/xrp/utils.d.mts +165 -57
- 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 +162 -153
- 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 +17 -12
- 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/tsdown.config.ts +1 -0
- package/vitest.config.ts +4 -0
- package/.turbo/turbo-build.log +0 -47
- package/.turbo/turbo-test.log +0 -126
- package/.turbo/turbo-typecheck.log +0 -5
- package/dist/facesign-signer-ADd4EASX.d.mts.map +0 -1
- package/dist/facesign-signer-BY9Nti9F.mjs +0 -244
- package/dist/facesign-signer-BY9Nti9F.mjs.map +0 -1
- package/dist/index-UFgWkjYP.d.mts.map +0 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const POST_MESSAGE_DELAY_MS = 300;
|
|
3
3
|
// Safety net: reject proposals if the enclave doesn't respond within this window.
|
|
4
4
|
const PROPOSAL_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
5
|
+
const RESET_TIMEOUT_MS = 15_000;
|
|
5
6
|
|
|
6
7
|
export interface FaceSignMetadata {
|
|
7
8
|
name: string;
|
|
@@ -27,6 +28,8 @@ export class FaceSignSignerProvider {
|
|
|
27
28
|
#resolveSessionProposal: ((response: SessionResponse) => void) | null = null;
|
|
28
29
|
#rejectSessionProposal: ((error: Error) => void) | null = null;
|
|
29
30
|
#resolveAddressRequest: ((address: string | null) => void) | null = null;
|
|
31
|
+
#resolveReset: (() => void) | null = null;
|
|
32
|
+
#rejectReset: ((error: Error) => void) | null = null;
|
|
30
33
|
|
|
31
34
|
#messageListener: ((event: MessageEvent) => void) | null = null;
|
|
32
35
|
#messageListenerInitialized = false;
|
|
@@ -86,6 +89,14 @@ export class FaceSignSignerProvider {
|
|
|
86
89
|
const payload = event.data.data;
|
|
87
90
|
this.#resolveAddressRequest?.(payload?.address ?? null);
|
|
88
91
|
}
|
|
92
|
+
|
|
93
|
+
if (event.data?.type === "reset_complete") {
|
|
94
|
+
if (event.data.data?.ok) {
|
|
95
|
+
this.#resolveReset?.();
|
|
96
|
+
} else {
|
|
97
|
+
this.#rejectReset?.(new Error("FaceSign enclave failed to delete its key"));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
89
100
|
};
|
|
90
101
|
|
|
91
102
|
window.addEventListener("message", this.#messageListener);
|
|
@@ -220,6 +231,43 @@ export class FaceSignSignerProvider {
|
|
|
220
231
|
}
|
|
221
232
|
}
|
|
222
233
|
|
|
234
|
+
async reset(): Promise<void> {
|
|
235
|
+
this.#setupMessageListener();
|
|
236
|
+
const deadline = Date.now() + RESET_TIMEOUT_MS;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
await this.#ensureEnclave(deadline - Date.now());
|
|
240
|
+
await new Promise<void>((resolve, reject) => {
|
|
241
|
+
const timer = setTimeout(
|
|
242
|
+
() => {
|
|
243
|
+
reject(new Error("FaceSign reset timed out"));
|
|
244
|
+
},
|
|
245
|
+
Math.max(deadline - Date.now(), 0),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
this.#resolveReset = () => {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
this.#resolveReset = null;
|
|
251
|
+
this.#rejectReset = null;
|
|
252
|
+
resolve();
|
|
253
|
+
};
|
|
254
|
+
this.#rejectReset = (error) => {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
this.#resolveReset = null;
|
|
257
|
+
this.#rejectReset = null;
|
|
258
|
+
reject(error);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
this.#iframe?.contentWindow?.postMessage(
|
|
262
|
+
{ type: "reset", data: { id: ++this.#proposalId } },
|
|
263
|
+
this.#enclaveOrigin,
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
} finally {
|
|
267
|
+
this.destroy();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
223
271
|
hide(): void {
|
|
224
272
|
this.#cancelPendingProposals();
|
|
225
273
|
this.#hideEnclave();
|
|
@@ -269,7 +317,7 @@ export class FaceSignSignerProvider {
|
|
|
269
317
|
return container;
|
|
270
318
|
}
|
|
271
319
|
|
|
272
|
-
#ensureEnclave(): Promise<void> {
|
|
320
|
+
#ensureEnclave(timeoutMs = PROPOSAL_TIMEOUT_MS): Promise<void> {
|
|
273
321
|
if (this.#iframe?.contentWindow) {
|
|
274
322
|
return Promise.resolve();
|
|
275
323
|
}
|
|
@@ -277,7 +325,7 @@ export class FaceSignSignerProvider {
|
|
|
277
325
|
return new Promise<void>((resolve, reject) => {
|
|
278
326
|
const timer = setTimeout(() => {
|
|
279
327
|
reject(new Error("FaceSign enclave failed to load"));
|
|
280
|
-
},
|
|
328
|
+
}, timeoutMs);
|
|
281
329
|
|
|
282
330
|
if (!this.#container) {
|
|
283
331
|
this.#container = this.#createContainer();
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { base64UrlEncode, utf8Encode } from "@idos-network/utils/codecs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createMmTokenAuth,
|
|
6
|
+
isMmTokenAuth,
|
|
7
|
+
mmTokenCredentialContentUri,
|
|
8
|
+
mmTokenStorageId,
|
|
9
|
+
type MmTokenEnvelope,
|
|
10
|
+
} from "./create-mm-token-kwil-signer.js";
|
|
11
|
+
|
|
12
|
+
const signingPublicKeyBytes = new Uint8Array(Array.from({ length: 32 }, (_, index) => index + 1));
|
|
13
|
+
const signatureBytes = new Uint8Array(Array.from({ length: 64 }, (_, index) => index + 64));
|
|
14
|
+
|
|
15
|
+
function createEnvelope(overrides: Partial<MmTokenEnvelope> = {}): MmTokenEnvelope {
|
|
16
|
+
return {
|
|
17
|
+
payload: {
|
|
18
|
+
version: 1,
|
|
19
|
+
aud: ["metamask:user-storage:ukyc", "idos:kwil"],
|
|
20
|
+
signing_public_key: base64UrlEncode(signingPublicKeyBytes),
|
|
21
|
+
storage_id: "storage-abc",
|
|
22
|
+
},
|
|
23
|
+
signature: base64UrlEncode(signatureBytes),
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function encodeEnvelope(envelope: MmTokenEnvelope): string {
|
|
29
|
+
return base64UrlEncode(utf8Encode(JSON.stringify(envelope)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function signMessage(signer: ReturnType<typeof createMmTokenAuth>): Promise<Uint8Array> {
|
|
33
|
+
return await (signer.signer as (message: Uint8Array) => Promise<Uint8Array>)(
|
|
34
|
+
new Uint8Array([1, 2, 3]),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("createMmTokenAuth", () => {
|
|
39
|
+
it("uses mm_token auth type and decoded signing_public_key bytes as identity", () => {
|
|
40
|
+
const signer = createMmTokenAuth(encodeEnvelope(createEnvelope()));
|
|
41
|
+
|
|
42
|
+
expect(signer.signatureType).toBe("mm_token");
|
|
43
|
+
expect(signer.identifier).toEqual(signingPublicKeyBytes);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns the exact encoded envelope bytes from signMessage", async () => {
|
|
47
|
+
const encoded = encodeEnvelope(createEnvelope());
|
|
48
|
+
const signer = createMmTokenAuth(encoded);
|
|
49
|
+
|
|
50
|
+
expect(await signMessage(signer)).toEqual(utf8Encode(encoded));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("accepts a decoded envelope object and returns its encoded form", async () => {
|
|
54
|
+
const envelope = createEnvelope();
|
|
55
|
+
const signer = createMmTokenAuth(envelope);
|
|
56
|
+
|
|
57
|
+
expect(await signMessage(signer)).toEqual(utf8Encode(encodeEnvelope(envelope)));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects raw JSON envelope strings (must be base64url-encoded)", () => {
|
|
61
|
+
expect(() => createMmTokenAuth(JSON.stringify(createEnvelope()))).toThrow(
|
|
62
|
+
"Invalid mm_token envelope encoding",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects invalid signature length", () => {
|
|
67
|
+
expect(() =>
|
|
68
|
+
createMmTokenAuth(
|
|
69
|
+
encodeEnvelope(createEnvelope({ signature: base64UrlEncode(new Uint8Array(63)) })),
|
|
70
|
+
),
|
|
71
|
+
).toThrow("Invalid mm_token signature length: expected 64, got 63");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects signing_public_key with the wrong byte length", () => {
|
|
75
|
+
expect(() =>
|
|
76
|
+
createMmTokenAuth(
|
|
77
|
+
encodeEnvelope(
|
|
78
|
+
createEnvelope({
|
|
79
|
+
payload: {
|
|
80
|
+
version: 1,
|
|
81
|
+
signing_public_key: base64UrlEncode(new Uint8Array(31)),
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
).toThrow("Invalid mm_token signing_public_key length: expected 32, got 31");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("carries the exact encoded capability as a non-enumerable access token", () => {
|
|
90
|
+
const encoded = encodeEnvelope(createEnvelope());
|
|
91
|
+
const auth = createMmTokenAuth(encoded);
|
|
92
|
+
|
|
93
|
+
expect(auth.accessToken).toBe(encoded);
|
|
94
|
+
expect(isMmTokenAuth(auth)).toBe(true);
|
|
95
|
+
// Blob authorization must not leak through logging or serialization.
|
|
96
|
+
expect(Object.keys(auth)).not.toContain("accessToken");
|
|
97
|
+
expect(JSON.stringify(auth)).not.toContain(encoded);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("re-encodes a decoded envelope object into the access token", () => {
|
|
101
|
+
const envelope = createEnvelope();
|
|
102
|
+
|
|
103
|
+
expect(createMmTokenAuth(envelope).accessToken).toBe(encodeEnvelope(envelope));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does not recognize a non-MM signer as MM authentication", () => {
|
|
107
|
+
expect(isMmTokenAuth({ identifier: "a", signer: () => {}, signatureType: "ed25519" })).toBe(
|
|
108
|
+
false,
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("keeps the token payload and exposes its storage_id", () => {
|
|
113
|
+
const signer = createMmTokenAuth(encodeEnvelope(createEnvelope()));
|
|
114
|
+
|
|
115
|
+
expect(signer.mmTokenPayload.storage_id).toBe("storage-abc");
|
|
116
|
+
expect(mmTokenStorageId(signer)).toBe("storage-abc");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("builds the credential ukyc:// URI from a base64url-encoded token", () => {
|
|
120
|
+
const credentialId = "0198c21d-79cb-7000-8000-000000000001";
|
|
121
|
+
const encodedToken = encodeEnvelope(createEnvelope());
|
|
122
|
+
const signer = createMmTokenAuth(encodedToken);
|
|
123
|
+
const credential = {
|
|
124
|
+
id: credentialId,
|
|
125
|
+
content_uri: mmTokenCredentialContentUri(signer, credentialId),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
expect(credential).toEqual({
|
|
129
|
+
id: credentialId,
|
|
130
|
+
content_uri: `ukyc://storage-abc/blobs/${credentialId}`,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("fails when the token payload has no storage_id", () => {
|
|
135
|
+
const signer = createMmTokenAuth(
|
|
136
|
+
encodeEnvelope(
|
|
137
|
+
createEnvelope({
|
|
138
|
+
payload: {
|
|
139
|
+
version: 1,
|
|
140
|
+
signing_public_key: base64UrlEncode(signingPublicKeyBytes),
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(() => mmTokenStorageId(signer)).toThrow("mm_token payload is missing storage_id");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { KwilSigner } from "@idos-network/kwil-js";
|
|
2
|
+
import { createUkycContentUri } from "@idos-network/utils/blob-gateway";
|
|
3
|
+
import {
|
|
4
|
+
base64UrlDecode,
|
|
5
|
+
base64UrlEncode,
|
|
6
|
+
utf8Decode,
|
|
7
|
+
utf8Encode,
|
|
8
|
+
} from "@idos-network/utils/codecs";
|
|
9
|
+
|
|
10
|
+
const ED25519_PUBLIC_KEY_BYTES = 32;
|
|
11
|
+
const ED25519_SIGNATURE_BYTES = 64;
|
|
12
|
+
|
|
13
|
+
/** Decoded UKYC capability envelope. KGW signature data is base64url(JSON of this). */
|
|
14
|
+
export interface MmTokenEnvelope {
|
|
15
|
+
payload: MmTokenPayload | Record<string, unknown>;
|
|
16
|
+
signature: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MmTokenPayload {
|
|
20
|
+
signing_public_key: string;
|
|
21
|
+
storage_id?: string;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One MM/UKYC capability, valid both as a KGW `mm_token` signer and as the
|
|
27
|
+
* `Authorization: AccessToken …` credential for UKYC blob requests.
|
|
28
|
+
*/
|
|
29
|
+
export type MmTokenAuth = KwilSigner & {
|
|
30
|
+
readonly mmTokenPayload: MmTokenPayload | Record<string, unknown>;
|
|
31
|
+
/** Exact encoded envelope KGW and UKYC storage verify. Non-enumerable, so it stays out of logs. */
|
|
32
|
+
readonly accessToken: string;
|
|
33
|
+
/**
|
|
34
|
+
* A capability signs its own envelope, never an arbitrary message. Declared so callers
|
|
35
|
+
* handling a `Wallet` union can keep asking wallets for a message signature.
|
|
36
|
+
*/
|
|
37
|
+
readonly signMessage?: undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function isMmTokenAuth(value: unknown): value is MmTokenAuth {
|
|
41
|
+
return (
|
|
42
|
+
value !== null &&
|
|
43
|
+
typeof value === "object" &&
|
|
44
|
+
"signatureType" in value &&
|
|
45
|
+
value.signatureType === "mm_token" &&
|
|
46
|
+
"identifier" in value &&
|
|
47
|
+
"signer" in value &&
|
|
48
|
+
typeof (value as Partial<MmTokenAuth>).accessToken === "string"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `storage_id` from the capability token used to construct this signer. */
|
|
53
|
+
export function mmTokenStorageId(signer: KwilSigner): string {
|
|
54
|
+
const storageId = (signer as Partial<MmTokenAuth>).mmTokenPayload?.storage_id;
|
|
55
|
+
if (typeof storageId !== "string" || !storageId.trim()) {
|
|
56
|
+
throw new Error("mm_token payload is missing storage_id");
|
|
57
|
+
}
|
|
58
|
+
return storageId.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function mmTokenCredentialContentUri(signer: KwilSigner, credentialId: string): string {
|
|
62
|
+
return createUkycContentUri(mmTokenStorageId(signer), credentialId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isMmTokenEnvelope(value: unknown): value is MmTokenEnvelope {
|
|
66
|
+
return (
|
|
67
|
+
value !== null &&
|
|
68
|
+
typeof value === "object" &&
|
|
69
|
+
"payload" in value &&
|
|
70
|
+
value.payload !== null &&
|
|
71
|
+
typeof value.payload === "object" &&
|
|
72
|
+
!Array.isArray(value.payload) &&
|
|
73
|
+
"signature" in value &&
|
|
74
|
+
typeof value.signature === "string"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseJson<T>(bytes: Uint8Array, errorMessage: string): T {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(utf8Decode(bytes)) as T;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new Error(errorMessage, { cause: error });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function decodeBase64Url(value: string, errorMessage: string): Uint8Array {
|
|
87
|
+
try {
|
|
88
|
+
return base64UrlDecode(value);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw new Error(errorMessage, { cause: error });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the MM authentication object for one UKYC capability.
|
|
96
|
+
*
|
|
97
|
+
* Pass the MetaMask-issued capability token as a **base64url-encoded** envelope string
|
|
98
|
+
* (what KGW verifies). A decoded `{ payload, signature }` object is also accepted and
|
|
99
|
+
* will be re-encoded.
|
|
100
|
+
*
|
|
101
|
+
* `signMessage` always returns the UTF-8 bytes of that encoded string — KGW ignores the
|
|
102
|
+
* request message and only verifies the envelope.
|
|
103
|
+
*/
|
|
104
|
+
export function createMmTokenAuth(encodedEnvelope: string | MmTokenEnvelope): MmTokenAuth {
|
|
105
|
+
let encodedToken: string;
|
|
106
|
+
let envelope: MmTokenEnvelope;
|
|
107
|
+
|
|
108
|
+
if (typeof encodedEnvelope === "string") {
|
|
109
|
+
const trimmed = encodedEnvelope.trim();
|
|
110
|
+
if (!trimmed) {
|
|
111
|
+
throw new Error("Invalid mm_token envelope: empty");
|
|
112
|
+
}
|
|
113
|
+
encodedToken = trimmed;
|
|
114
|
+
const envelopeBytes = decodeBase64Url(trimmed, "Invalid mm_token envelope encoding");
|
|
115
|
+
const parsed = parseJson<unknown>(envelopeBytes, "Invalid mm_token envelope JSON");
|
|
116
|
+
if (!isMmTokenEnvelope(parsed)) {
|
|
117
|
+
throw new Error("Invalid mm_token envelope: expected payload object and signature string");
|
|
118
|
+
}
|
|
119
|
+
envelope = parsed;
|
|
120
|
+
} else {
|
|
121
|
+
if (!isMmTokenEnvelope(encodedEnvelope)) {
|
|
122
|
+
throw new Error("Invalid mm_token envelope: expected payload object and signature string");
|
|
123
|
+
}
|
|
124
|
+
envelope = encodedEnvelope;
|
|
125
|
+
encodedToken = base64UrlEncode(utf8Encode(JSON.stringify(envelope)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// KGW Verify receives UTF-8 of the base64url string, not the decoded JSON bytes.
|
|
129
|
+
const signatureData = utf8Encode(encodedToken);
|
|
130
|
+
|
|
131
|
+
const tokenSignatureBytes = decodeBase64Url(
|
|
132
|
+
envelope.signature,
|
|
133
|
+
"Invalid mm_token envelope signature encoding",
|
|
134
|
+
);
|
|
135
|
+
if (tokenSignatureBytes.length !== ED25519_SIGNATURE_BYTES) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Invalid mm_token signature length: expected ${ED25519_SIGNATURE_BYTES}, got ${tokenSignatureBytes.length}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const signingPublicKey = envelope.payload.signing_public_key;
|
|
142
|
+
if (typeof signingPublicKey !== "string") {
|
|
143
|
+
throw new Error("Invalid mm_token payload: missing signing_public_key");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const signingPublicKeyBytes = decodeBase64Url(
|
|
147
|
+
signingPublicKey,
|
|
148
|
+
"Invalid mm_token signing_public_key encoding",
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (signingPublicKeyBytes.length !== ED25519_PUBLIC_KEY_BYTES) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Invalid mm_token signing_public_key length: expected ${ED25519_PUBLIC_KEY_BYTES}, got ${signingPublicKeyBytes.length}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const auth = Object.assign(
|
|
158
|
+
new KwilSigner(async () => signatureData.slice(), signingPublicKeyBytes, "mm_token"),
|
|
159
|
+
{ mmTokenPayload: envelope.payload },
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return Object.defineProperty(auth, "accessToken", {
|
|
163
|
+
value: encodedToken,
|
|
164
|
+
enumerable: false,
|
|
165
|
+
}) as MmTokenAuth;
|
|
166
|
+
}
|
|
@@ -119,7 +119,7 @@ function createNearWalletSigner(
|
|
|
119
119
|
|
|
120
120
|
export function implicitAddressFromPublicKey(publicKey: string): string {
|
|
121
121
|
const key_without_prefix = publicKey.replace(/^ed25519:/, "");
|
|
122
|
-
return hexEncode(bs58Decode(key_without_prefix));
|
|
122
|
+
return hexEncode(bs58Decode(key_without_prefix), true);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export async function getNearFullAccessPublicKeys(
|
|
@@ -107,4 +107,22 @@ describe("verifySignature", () => {
|
|
|
107
107
|
|
|
108
108
|
expect(result).toBe(true);
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
it("rejects MM wallets without trusted capability validation", async () => {
|
|
112
|
+
vi.clearAllMocks();
|
|
113
|
+
|
|
114
|
+
const result = await verifySignature({
|
|
115
|
+
address: "mm-signing-public-key",
|
|
116
|
+
signature: "",
|
|
117
|
+
message: "",
|
|
118
|
+
wallet_type: "MM",
|
|
119
|
+
public_key: ["mm-signing-public-key"],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result).toBe(false);
|
|
123
|
+
expect(verifyEvmSignature).not.toHaveBeenCalled();
|
|
124
|
+
expect(verifyNearSignature).not.toHaveBeenCalled();
|
|
125
|
+
expect(verifyRippleSignature).not.toHaveBeenCalled();
|
|
126
|
+
expect(verifyStellarSignature).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
110
128
|
});
|
|
@@ -18,9 +18,15 @@ export interface WalletSignature {
|
|
|
18
18
|
|
|
19
19
|
export const verifySignature = async (walletPayload: WalletSignature): Promise<boolean> => {
|
|
20
20
|
invariant(walletPayload.address, "Wallet address is required");
|
|
21
|
+
invariant(walletPayload.wallet_type, "Wallet type is required");
|
|
22
|
+
|
|
23
|
+
if (walletPayload.wallet_type === "MM") {
|
|
24
|
+
// MM authentication requires capability validation by a trusted service such as KGW.
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
invariant(walletPayload.message, "Wallet message is required");
|
|
22
29
|
invariant(walletPayload.signature, "Wallet signature is required");
|
|
23
|
-
invariant(walletPayload.wallet_type, "Wallet type is required");
|
|
24
30
|
|
|
25
31
|
if (walletPayload.wallet_type !== "EVM") {
|
|
26
32
|
invariant(walletPayload.public_key?.[0], "Wallet public_key is required for non-EVM wallets");
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { waitForKwilTx } from "./wait-for-kwil-tx";
|
|
4
|
+
|
|
5
|
+
describe("waitForKwilTx", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.useRealTimers();
|
|
8
|
+
vi.restoreAllMocks();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("resolves when height > 0 and tx_result.code is 0", async () => {
|
|
12
|
+
const txInfo = vi
|
|
13
|
+
.fn()
|
|
14
|
+
.mockResolvedValueOnce({ data: { height: 0, tx_result: { code: 0, log: "" } } })
|
|
15
|
+
.mockResolvedValueOnce({
|
|
16
|
+
data: { height: 42, tx_result: { code: 0, log: "success" } },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
const waitPromise = waitForKwilTx(txInfo, "0xabc", 5_000);
|
|
21
|
+
await vi.runAllTimersAsync();
|
|
22
|
+
|
|
23
|
+
await expect(waitPromise).resolves.toBeUndefined();
|
|
24
|
+
expect(txInfo).toHaveBeenCalledTimes(2);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("throws KwilTxFailedError when height > 0 and tx_result.code is non-zero", async () => {
|
|
28
|
+
const txInfo = vi.fn().mockResolvedValue({
|
|
29
|
+
data: { height: 10, tx_result: { code: 7, log: "action failed" } },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await expect(waitForKwilTx(txInfo, "0xdef", 5_000)).rejects.toMatchObject({
|
|
33
|
+
name: "KwilTxFailedError",
|
|
34
|
+
txHash: "0xdef",
|
|
35
|
+
code: 7,
|
|
36
|
+
log: "action failed",
|
|
37
|
+
height: 10,
|
|
38
|
+
});
|
|
39
|
+
expect(txInfo).toHaveBeenCalledTimes(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("retries transient txInfo errors until the tx is finalized", async () => {
|
|
43
|
+
const txInfo = vi
|
|
44
|
+
.fn()
|
|
45
|
+
.mockRejectedValueOnce(new Error("not found"))
|
|
46
|
+
.mockResolvedValueOnce({
|
|
47
|
+
data: { height: 3, tx_result: { code: 0, log: "ok" } },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
vi.useFakeTimers();
|
|
51
|
+
const waitPromise = waitForKwilTx(txInfo, "0x123", 5_000);
|
|
52
|
+
await vi.runAllTimersAsync();
|
|
53
|
+
|
|
54
|
+
await expect(waitPromise).resolves.toBeUndefined();
|
|
55
|
+
expect(txInfo).toHaveBeenCalledTimes(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("throws KwilTxPollTimeoutError when the deadline is exceeded", async () => {
|
|
59
|
+
const txInfo = vi.fn().mockResolvedValue({
|
|
60
|
+
data: { height: 0, tx_result: { code: 0, log: "" } },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
const waitPromise = waitForKwilTx(txInfo, "0x999", 1_000);
|
|
65
|
+
const assertion = expect(waitPromise).rejects.toMatchObject({
|
|
66
|
+
name: "KwilTxPollTimeoutError",
|
|
67
|
+
txHash: "0x999",
|
|
68
|
+
deadlineMs: 1_000,
|
|
69
|
+
});
|
|
70
|
+
await vi.runAllTimersAsync();
|
|
71
|
+
|
|
72
|
+
await assertion;
|
|
73
|
+
expect(txInfo.mock.calls.length).toBeGreaterThan(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("includes the last poll error when timing out after persistent failures", async () => {
|
|
77
|
+
const pollError = new Error("network down");
|
|
78
|
+
const txInfo = vi.fn().mockRejectedValue(pollError);
|
|
79
|
+
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
const waitPromise = waitForKwilTx(txInfo, "0xbad", 1_000);
|
|
82
|
+
const assertion = expect(waitPromise).rejects.toMatchObject({
|
|
83
|
+
name: "KwilTxPollTimeoutError",
|
|
84
|
+
txHash: "0xbad",
|
|
85
|
+
message: expect.stringContaining("last error: network down"),
|
|
86
|
+
cause: pollError,
|
|
87
|
+
});
|
|
88
|
+
await vi.runAllTimersAsync();
|
|
89
|
+
|
|
90
|
+
await assertion;
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const KWIL_TX_POLL_DEADLINE_MS = 90_000;
|
|
2
|
+
const INITIAL_POLL_INTERVAL_MS = 300;
|
|
3
|
+
const POLL_BACKOFF_FACTOR = 1.5;
|
|
4
|
+
const MAX_POLL_INTERVAL_MS = 2_000;
|
|
5
|
+
const MAX_POLL_JITTER_MS = 100;
|
|
6
|
+
|
|
7
|
+
export type KwilTxInfo = {
|
|
8
|
+
height: number;
|
|
9
|
+
tx_result: {
|
|
10
|
+
code: number;
|
|
11
|
+
log: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export class KwilTxFailedError extends Error {
|
|
16
|
+
readonly name = "KwilTxFailedError";
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
readonly txHash: string,
|
|
20
|
+
readonly code: number,
|
|
21
|
+
readonly log: string,
|
|
22
|
+
readonly height: number,
|
|
23
|
+
) {
|
|
24
|
+
super(`Kwil transaction ${txHash} failed at height ${height} (code ${code}): ${log}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class KwilTxPollTimeoutError extends Error {
|
|
29
|
+
readonly name = "KwilTxPollTimeoutError";
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
readonly txHash: string,
|
|
33
|
+
readonly deadlineMs: number,
|
|
34
|
+
options?: { cause?: unknown },
|
|
35
|
+
) {
|
|
36
|
+
const lastError =
|
|
37
|
+
options?.cause instanceof Error
|
|
38
|
+
? options.cause.message
|
|
39
|
+
: options?.cause != null
|
|
40
|
+
? String(options.cause)
|
|
41
|
+
: undefined;
|
|
42
|
+
super(
|
|
43
|
+
lastError
|
|
44
|
+
? `Timed out after ${deadlineMs}ms waiting for Kwil transaction ${txHash}: last error: ${lastError}`
|
|
45
|
+
: `Timed out after ${deadlineMs}ms waiting for Kwil transaction ${txHash}`,
|
|
46
|
+
options?.cause !== undefined ? { cause: options.cause } : undefined,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sleep(ms: number): Promise<void> {
|
|
52
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nextPollIntervalMs(currentIntervalMs: number): number {
|
|
56
|
+
return Math.min(currentIntervalMs * POLL_BACKOFF_FACTOR, MAX_POLL_INTERVAL_MS);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pollIntervalWithJitter(intervalMs: number): number {
|
|
60
|
+
return intervalMs + Math.floor(Math.random() * (MAX_POLL_JITTER_MS + 1));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isTxFinalized(txInfo: KwilTxInfo): boolean {
|
|
64
|
+
return txInfo.height > 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function waitForKwilTx(
|
|
68
|
+
txInfo: (txHash: string) => Promise<{ data?: KwilTxInfo }>,
|
|
69
|
+
txHash: string,
|
|
70
|
+
deadlineMs: number = KWIL_TX_POLL_DEADLINE_MS,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const deadlineAt = Date.now() + deadlineMs;
|
|
73
|
+
let intervalMs = INITIAL_POLL_INTERVAL_MS;
|
|
74
|
+
let lastPollError: unknown;
|
|
75
|
+
|
|
76
|
+
while (Date.now() < deadlineAt) {
|
|
77
|
+
let data: KwilTxInfo | undefined;
|
|
78
|
+
|
|
79
|
+
// Transient poll failures (e.g. "not found" before the node indexes the tx) are
|
|
80
|
+
// recorded and retried. Terminal tx failures are decided below, outside this catch.
|
|
81
|
+
try {
|
|
82
|
+
data = (await txInfo(txHash)).data;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
lastPollError = error;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// height > 0 means the tx was included. Then code 0 is success; anything else is final failure.
|
|
88
|
+
if (data && isTxFinalized(data)) {
|
|
89
|
+
if (data.tx_result.code !== 0) {
|
|
90
|
+
throw new KwilTxFailedError(txHash, data.tx_result.code, data.tx_result.log, data.height);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Not finalized yet (or last poll errored) — back off and try again until the deadline.
|
|
96
|
+
const waitMs = pollIntervalWithJitter(intervalMs);
|
|
97
|
+
const remainingMs = deadlineAt - Date.now();
|
|
98
|
+
if (remainingMs <= 0) {
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await sleep(Math.min(waitMs, remainingMs));
|
|
103
|
+
intervalMs = nextPollIntervalMs(intervalMs);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
throw new KwilTxPollTimeoutError(
|
|
107
|
+
txHash,
|
|
108
|
+
deadlineMs,
|
|
109
|
+
lastPollError !== undefined ? { cause: lastPollError } : undefined,
|
|
110
|
+
);
|
|
111
|
+
}
|
package/tsdown.config.ts
CHANGED