@thru/wallet 0.2.25 → 0.2.28

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 (47) hide show
  1. package/README.md +1 -0
  2. package/dist/{BrowserSDK-CpRFiJsW.d.ts → BrowserSDK-CRQTOT8S.d.ts} +178 -3
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.js +376 -12
  5. package/dist/index.js.map +1 -1
  6. package/dist/native/react/transparent.d.ts +104 -0
  7. package/dist/native/react/transparent.js +2210 -0
  8. package/dist/native/react/transparent.js.map +1 -0
  9. package/dist/native/react.d.ts +5 -90
  10. package/dist/native/react.js +765 -32
  11. package/dist/native/react.js.map +1 -1
  12. package/dist/native.d.ts +105 -1
  13. package/dist/native.js +521 -31
  14. package/dist/native.js.map +1 -1
  15. package/dist/react-ui.js +5 -0
  16. package/dist/react-ui.js.map +1 -1
  17. package/dist/react.d.ts +2 -2
  18. package/dist/react.js +376 -12
  19. package/dist/react.js.map +1 -1
  20. package/package.json +8 -2
  21. package/src/BrowserSDK.ts +32 -1
  22. package/src/encoding.ts +39 -0
  23. package/src/index.ts +5 -1
  24. package/src/interfaces/IThruChain.ts +50 -1
  25. package/src/interfaces/types.ts +52 -0
  26. package/src/native/NativeSDK.test.ts +200 -1
  27. package/src/native/NativeSDK.ts +124 -10
  28. package/src/native/index.ts +12 -0
  29. package/src/native/provider/NativeProvider.ts +106 -5
  30. package/src/native/provider/WebViewBridge.test.ts +22 -1
  31. package/src/native/provider/WebViewBridge.ts +17 -7
  32. package/src/native/provider/chains/ThruChain.ts +215 -5
  33. package/src/native/react/ThruContext.ts +3 -1
  34. package/src/native/react/ThruProvider.tsx +25 -0
  35. package/src/native/react/ThruTransparentWalletBridge.tsx +281 -0
  36. package/src/native/react/hooks/useWallet.ts +12 -1
  37. package/src/native/react/index.ts +11 -0
  38. package/src/native/react/transparent.ts +35 -0
  39. package/src/protocol/postMessage.ts +127 -2
  40. package/src/provider/EmbeddedProvider.ts +7 -1
  41. package/src/provider/IframeManager.test.ts +18 -0
  42. package/src/provider/IframeManager.ts +8 -1
  43. package/src/provider/chains/ThruChain.ts +210 -4
  44. package/src/provider/types/messages.ts +16 -0
  45. package/src/react/index.ts +6 -0
  46. package/src/signing-sessions.test.ts +182 -0
  47. package/src/signing-sessions.ts +204 -0
@@ -2,11 +2,42 @@ import {
2
2
  AddressType,
3
3
  type IThruChain,
4
4
  type ThruSigningContext,
5
+ type ThruSigningSession,
6
+ type ThruSigningSessionCreateOptions,
7
+ type ThruSigningSessionDescriptor,
8
+ type ThruSigningSessionInstruction,
9
+ type ThruSigningSessionInstructionCreateOptions,
10
+ type ThruPasskeyChallengeIntent,
11
+ type ThruPasskeyChallengeSignature,
5
12
  type ThruTransactionIntent,
6
13
  } from "../../interfaces";
7
14
  import { POST_MESSAGE_REQUEST_TYPES, createRequestId } from "../../protocol";
15
+ import { base64ToBytes } from "../../encoding";
8
16
  import type { EmbeddedProvider } from "../EmbeddedProvider";
9
17
  import type { IframeManager } from "../IframeManager";
18
+ import {
19
+ SigningSessionDescriptorStore,
20
+ assertSigningSessionWalletAccountIdx,
21
+ resolveSessionExpirySeconds,
22
+ } from "../../signing-sessions";
23
+
24
+ function descriptorFromWire(session: {
25
+ id: string;
26
+ walletAddress: string;
27
+ publicKey: string;
28
+ authIdx: number;
29
+ expiresAt: string;
30
+ createdAt: string;
31
+ }): ThruSigningSessionDescriptor {
32
+ return {
33
+ id: session.id,
34
+ walletAddress: session.walletAddress,
35
+ publicKey: session.publicKey,
36
+ authIdx: session.authIdx,
37
+ expiresAt: Number(BigInt(session.expiresAt)),
38
+ createdAt: Number(BigInt(session.createdAt)),
39
+ };
40
+ }
10
41
 
11
42
  /**
12
43
  * EmbeddedThruChain - postMessage-backed Thru chain adapter.
@@ -14,10 +45,16 @@ import type { IframeManager } from "../IframeManager";
14
45
  export class EmbeddedThruChain implements IThruChain {
15
46
  private readonly iframeManager: IframeManager;
16
47
  private readonly provider: EmbeddedProvider;
48
+ private readonly signingSessions?: SigningSessionDescriptorStore;
17
49
 
18
- constructor(iframeManager: IframeManager, provider: EmbeddedProvider) {
50
+ constructor(
51
+ iframeManager: IframeManager,
52
+ provider: EmbeddedProvider,
53
+ signingSessions?: SigningSessionDescriptorStore,
54
+ ) {
19
55
  this.iframeManager = iframeManager;
20
56
  this.provider = provider;
57
+ this.signingSessions = signingSessions;
21
58
  }
22
59
 
23
60
  get connected(): boolean {
@@ -58,29 +95,198 @@ export class EmbeddedThruChain implements IThruChain {
58
95
  }
59
96
 
60
97
  async signTransaction(transaction: ThruTransactionIntent): Promise<string> {
61
- if (!this.provider.isConnected()) {
98
+ const signingSessionId = transaction.signingSessionId;
99
+ if (!signingSessionId && !this.provider.isConnected()) {
62
100
  throw new Error("Wallet not connected");
63
101
  }
64
102
 
65
- this.iframeManager.show();
103
+ const session = signingSessionId
104
+ ? await this.requireSigningSession(signingSessionId)
105
+ : null;
106
+ const shouldShowWallet = !signingSessionId;
107
+ if (shouldShowWallet) {
108
+ this.iframeManager.show();
109
+ }
66
110
 
67
111
  try {
68
112
  const response = await this.iframeManager.sendMessage({
69
113
  id: createRequestId(),
70
114
  type: POST_MESSAGE_REQUEST_TYPES.SIGN_TRANSACTION,
71
115
  payload: {
72
- walletAddress: transaction.walletAddress,
116
+ walletAddress: transaction.walletAddress ?? session?.walletAddress,
73
117
  programAddress: transaction.programAddress,
74
118
  instructionData: transaction.instructionData,
75
119
  readWriteAddresses: transaction.readWriteAddresses,
76
120
  readOnlyAddresses: transaction.readOnlyAddresses,
77
121
  review: transaction.review,
122
+ signingSessionId,
78
123
  },
79
124
  origin: window.location.origin,
80
125
  });
81
126
  return response.result.signedTransaction;
127
+ } finally {
128
+ if (shouldShowWallet) {
129
+ this.iframeManager.hide();
130
+ }
131
+ }
132
+ }
133
+
134
+ async signPasskeyChallenge(
135
+ challenge: ThruPasskeyChallengeIntent,
136
+ ): Promise<ThruPasskeyChallengeSignature> {
137
+ if (!this.provider.isConnected()) {
138
+ throw new Error("Wallet not connected");
139
+ }
140
+
141
+ this.iframeManager.show();
142
+ try {
143
+ const response = await this.iframeManager.sendMessage({
144
+ id: createRequestId(),
145
+ type: POST_MESSAGE_REQUEST_TYPES.SIGN_PASSKEY_CHALLENGE,
146
+ payload: {
147
+ challenge: challenge.challenge,
148
+ walletAddress: challenge.walletAddress,
149
+ },
150
+ origin: window.location.origin,
151
+ });
152
+ return response.result;
153
+ } finally {
154
+ this.iframeManager.hide();
155
+ }
156
+ }
157
+
158
+ async createSigningSession(
159
+ options: ThruSigningSessionCreateOptions,
160
+ ): Promise<ThruSigningSession> {
161
+ if (!this.provider.isConnected()) {
162
+ throw new Error("Wallet not connected");
163
+ }
164
+ if (!this.signingSessions) {
165
+ throw new Error("Signing session storage is not available");
166
+ }
167
+
168
+ const expiresAt = resolveSessionExpirySeconds(options);
169
+ this.iframeManager.show();
170
+ try {
171
+ const response = await this.iframeManager.sendMessage({
172
+ id: createRequestId(),
173
+ type: POST_MESSAGE_REQUEST_TYPES.CREATE_SIGNING_SESSION,
174
+ payload: {
175
+ walletAddress: options.walletAddress,
176
+ expiresAt: String(expiresAt),
177
+ review: options.review,
178
+ },
179
+ origin: window.location.origin,
180
+ });
181
+ const descriptor = descriptorFromWire(response.result.session);
182
+ await this.signingSessions.saveReplacingWalletSessions(descriptor);
183
+ return this.toSigningSession(descriptor);
82
184
  } finally {
83
185
  this.iframeManager.hide();
84
186
  }
85
187
  }
188
+
189
+ async createSigningSessionInstruction(
190
+ options: ThruSigningSessionInstructionCreateOptions,
191
+ ): Promise<ThruSigningSessionInstruction> {
192
+ if (!this.provider.isConnected()) {
193
+ throw new Error("Wallet not connected");
194
+ }
195
+ if (!this.signingSessions) {
196
+ throw new Error("Signing session storage is not available");
197
+ }
198
+
199
+ const expiresAt = resolveSessionExpirySeconds(options);
200
+ assertSigningSessionWalletAccountIdx(options.walletAccountIdx);
201
+ const response = await this.iframeManager.sendMessage({
202
+ id: createRequestId(),
203
+ type: POST_MESSAGE_REQUEST_TYPES.CREATE_SIGNING_SESSION_INSTRUCTION,
204
+ payload: {
205
+ walletAddress: options.walletAddress,
206
+ expiresAt: String(expiresAt),
207
+ walletAccountIdx: options.walletAccountIdx,
208
+ },
209
+ origin: window.location.origin,
210
+ });
211
+ const descriptor = descriptorFromWire(response.result.session);
212
+ return {
213
+ session: this.toSigningSession(descriptor),
214
+ programAddress: response.result.programAddress,
215
+ instructionData: base64ToBytes(response.result.instructionData),
216
+ };
217
+ }
218
+
219
+ async confirmSigningSession(id: string): Promise<ThruSigningSession> {
220
+ if (!this.provider.isConnected()) {
221
+ throw new Error("Wallet not connected");
222
+ }
223
+ if (!this.signingSessions) {
224
+ throw new Error("Signing session storage is not available");
225
+ }
226
+
227
+ const response = await this.iframeManager.sendMessage({
228
+ id: createRequestId(),
229
+ type: POST_MESSAGE_REQUEST_TYPES.CONFIRM_SIGNING_SESSION,
230
+ payload: { sessionId: id },
231
+ origin: window.location.origin,
232
+ });
233
+ const descriptor = descriptorFromWire(response.result.session);
234
+ await this.signingSessions.saveReplacingWalletSessions(descriptor);
235
+ return this.toSigningSession(descriptor);
236
+ }
237
+
238
+ async getSigningSession(id: string): Promise<ThruSigningSession | null> {
239
+ if (!this.signingSessions) return null;
240
+ const descriptor = await this.signingSessions.get(id);
241
+ return descriptor ? this.toSigningSession(descriptor) : null;
242
+ }
243
+
244
+ async getSigningSessions(): Promise<ThruSigningSession[]> {
245
+ if (!this.signingSessions) return [];
246
+ return (await this.signingSessions.list()).map((descriptor) =>
247
+ this.toSigningSession(descriptor),
248
+ );
249
+ }
250
+
251
+ async revokeSigningSession(id: string): Promise<void> {
252
+ try {
253
+ await this.iframeManager.sendMessage({
254
+ id: createRequestId(),
255
+ type: POST_MESSAGE_REQUEST_TYPES.REVOKE_SIGNING_SESSION,
256
+ payload: { sessionId: id },
257
+ origin: window.location.origin,
258
+ });
259
+ } finally {
260
+ await this.signingSessions?.remove(id);
261
+ }
262
+ }
263
+
264
+ private async requireSigningSession(
265
+ id: string,
266
+ ): Promise<ThruSigningSessionDescriptor> {
267
+ if (!this.signingSessions) {
268
+ throw new Error("Signing session storage is not available");
269
+ }
270
+ const session = await this.signingSessions.get(id);
271
+ if (!session) {
272
+ throw new Error("Signing session is not known to this app");
273
+ }
274
+ return session;
275
+ }
276
+
277
+ private toSigningSession(
278
+ descriptor: ThruSigningSessionDescriptor,
279
+ ): ThruSigningSession {
280
+ return {
281
+ ...descriptor,
282
+ signTransaction: (transaction) =>
283
+ this.signTransaction({
284
+ ...transaction,
285
+ walletAddress: transaction.walletAddress ?? descriptor.walletAddress,
286
+ signingSessionId: descriptor.id,
287
+ }),
288
+ revoke: () => this.revokeSigningSession(descriptor.id),
289
+ toJSON: () => ({ ...descriptor }),
290
+ };
291
+ }
86
292
  }
@@ -10,13 +10,26 @@ export {
10
10
  type EmbeddedProviderEvent,
11
11
  type PostMessageRequest,
12
12
  type ConnectRequestMessage,
13
+ type CreateSigningSessionPayload,
14
+ type CreateSigningSessionRequestMessage,
15
+ type CreateSigningSessionResult,
16
+ type ConfirmSigningSessionPayload,
17
+ type ConfirmSigningSessionRequestMessage,
18
+ type ConfirmSigningSessionResult,
19
+ type CreateSigningSessionInstructionPayload,
20
+ type CreateSigningSessionInstructionRequestMessage,
21
+ type CreateSigningSessionInstructionResult,
13
22
  type DisconnectRequestMessage,
14
23
  type SignMessageRequestMessage,
15
24
  type SignTransactionRequestMessage,
25
+ type SignPasskeyChallengeRequestMessage,
16
26
  type GetAccountsRequestMessage,
17
27
  type GetSigningContextRequestMessage,
18
28
  type ManageAccountsRequestMessage,
19
29
  type SelectAccountRequestMessage,
30
+ type RevokeSigningSessionPayload,
31
+ type RevokeSigningSessionRequestMessage,
32
+ type RevokeSigningSessionResult,
20
33
  type DisconnectResult,
21
34
  type GetAccountsResult,
22
35
  type GetSigningContextResult,
@@ -34,4 +47,7 @@ export {
34
47
  type SignMessageResult,
35
48
  type SignTransactionPayload,
36
49
  type SignTransactionResult,
50
+ type SignPasskeyChallengePayload,
51
+ type SignPasskeyChallengeResult,
52
+ type SigningSessionDescriptorPayload,
37
53
  } from "../../protocol";
@@ -25,6 +25,12 @@ export type {
25
25
  SignMessageParams,
26
26
  SignMessageResult,
27
27
  ThruSigningContext,
28
+ ThruSigningSession,
29
+ ThruSigningSessionCreateOptions,
30
+ ThruSigningSessionDescriptor,
31
+ ThruSigningSessionTimestamp,
28
32
  ThruTransactionEncoding,
33
+ ThruTransactionIntent,
29
34
  WalletAccount,
30
35
  } from "../interfaces";
36
+ export type { SigningSessionStorage } from "../signing-sessions";
@@ -0,0 +1,182 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ SigningSessionDescriptorStore,
4
+ assertSigningSessionWalletAccountIdx,
5
+ resolveSessionExpirySeconds,
6
+ resolveSigningSessionStorageKey,
7
+ } from "./signing-sessions";
8
+
9
+ class MemoryStorage {
10
+ values = new Map<string, string>();
11
+
12
+ getItem(key: string): string | null {
13
+ return this.values.get(key) ?? null;
14
+ }
15
+
16
+ setItem(key: string, value: string): void {
17
+ this.values.set(key, value);
18
+ }
19
+
20
+ removeItem(key: string): void {
21
+ this.values.delete(key);
22
+ }
23
+ }
24
+
25
+ describe("signing session descriptor storage", () => {
26
+ beforeEach(() => {
27
+ vi.useFakeTimers();
28
+ vi.setSystemTime(new Date("2026-06-11T12:00:00.000Z"));
29
+ });
30
+
31
+ afterEach(() => {
32
+ vi.useRealTimers();
33
+ });
34
+
35
+ it("scopes default storage keys by wallet origin and app origin", () => {
36
+ const appA = resolveSigningSessionStorageKey({
37
+ walletOrigin: "https://wallet.example",
38
+ appOrigin: "https://app-a.example",
39
+ });
40
+ const appB = resolveSigningSessionStorageKey({
41
+ walletOrigin: "https://wallet.example",
42
+ appOrigin: "https://app-b.example",
43
+ });
44
+
45
+ expect(appA).not.toBe(appB);
46
+ expect(appA).toContain(encodeURIComponent("https://wallet.example"));
47
+ expect(appA).toContain(encodeURIComponent("https://app-a.example"));
48
+ });
49
+
50
+ it("stores only active sessions and prunes expired descriptors locally", async () => {
51
+ const storage = new MemoryStorage();
52
+ const store = new SigningSessionDescriptorStore(storage, "sessions");
53
+
54
+ await store.save({
55
+ id: "expired",
56
+ walletAddress: "wallet",
57
+ publicKey: "expired-pubkey",
58
+ authIdx: 1,
59
+ expiresAt: Math.floor(Date.now() / 1000) - 1,
60
+ createdAt: Math.floor(Date.now() / 1000) - 10,
61
+ });
62
+ await store.save({
63
+ id: "active",
64
+ walletAddress: "wallet",
65
+ publicKey: "active-pubkey",
66
+ authIdx: 2,
67
+ expiresAt: Math.floor(Date.now() / 1000) + 60,
68
+ createdAt: Math.floor(Date.now() / 1000),
69
+ });
70
+
71
+ expect(await store.get("expired")).toBeNull();
72
+ expect(await store.get("active")).toMatchObject({
73
+ id: "active",
74
+ publicKey: "active-pubkey",
75
+ authIdx: 2,
76
+ });
77
+ expect(await store.list()).toHaveLength(1);
78
+ });
79
+
80
+ it("replaces a descriptor with the same session id", async () => {
81
+ const store = new SigningSessionDescriptorStore(new MemoryStorage(), "sessions");
82
+ const expiresAt = Math.floor(Date.now() / 1000) + 60;
83
+
84
+ await store.save({
85
+ id: "session",
86
+ walletAddress: "wallet-a",
87
+ publicKey: "pubkey-a",
88
+ authIdx: 1,
89
+ expiresAt,
90
+ createdAt: Math.floor(Date.now() / 1000),
91
+ });
92
+ await store.save({
93
+ id: "session",
94
+ walletAddress: "wallet-b",
95
+ publicKey: "pubkey-b",
96
+ authIdx: 2,
97
+ expiresAt,
98
+ createdAt: Math.floor(Date.now() / 1000),
99
+ });
100
+
101
+ expect(await store.list()).toEqual([
102
+ expect.objectContaining({
103
+ id: "session",
104
+ walletAddress: "wallet-b",
105
+ publicKey: "pubkey-b",
106
+ authIdx: 2,
107
+ }),
108
+ ]);
109
+ });
110
+
111
+ it("replaces older descriptors for the same wallet when saving a usable session", async () => {
112
+ const store = new SigningSessionDescriptorStore(new MemoryStorage(), "sessions");
113
+ const nowSeconds = Math.floor(Date.now() / 1000);
114
+
115
+ await store.save({
116
+ id: "old-wallet-a",
117
+ walletAddress: "wallet-a",
118
+ publicKey: "old-pubkey",
119
+ authIdx: 1,
120
+ expiresAt: nowSeconds + 60,
121
+ createdAt: nowSeconds,
122
+ });
123
+ await store.save({
124
+ id: "wallet-b",
125
+ walletAddress: "wallet-b",
126
+ publicKey: "wallet-b-pubkey",
127
+ authIdx: 2,
128
+ expiresAt: nowSeconds + 60,
129
+ createdAt: nowSeconds,
130
+ });
131
+ await store.saveReplacingWalletSessions({
132
+ id: "new-wallet-a",
133
+ walletAddress: "wallet-a",
134
+ publicKey: "new-pubkey",
135
+ authIdx: 3,
136
+ expiresAt: nowSeconds + 120,
137
+ createdAt: nowSeconds + 1,
138
+ });
139
+
140
+ expect(await store.list()).toEqual([
141
+ expect.objectContaining({
142
+ id: "wallet-b",
143
+ walletAddress: "wallet-b",
144
+ }),
145
+ expect.objectContaining({
146
+ id: "new-wallet-a",
147
+ walletAddress: "wallet-a",
148
+ publicKey: "new-pubkey",
149
+ authIdx: 3,
150
+ }),
151
+ ]);
152
+ expect(await store.get("old-wallet-a")).toBeNull();
153
+ });
154
+
155
+ it("accepts exactly one of durationSeconds or expiresAt", () => {
156
+ const nowSeconds = Math.floor(Date.now() / 1000);
157
+
158
+ expect(resolveSessionExpirySeconds({ durationSeconds: 30 })).toBe(nowSeconds + 30);
159
+ expect(resolveSessionExpirySeconds({ expiresAt: String(nowSeconds + 45) })).toBe(
160
+ nowSeconds + 45,
161
+ );
162
+ expect(() => resolveSessionExpirySeconds({})).toThrow(
163
+ "Provide exactly one of durationSeconds or expiresAt",
164
+ );
165
+ expect(() =>
166
+ resolveSessionExpirySeconds({ durationSeconds: 1, expiresAt: nowSeconds + 1 }),
167
+ ).toThrow("Provide exactly one of durationSeconds or expiresAt");
168
+ });
169
+
170
+ it("rejects invalid signing session wallet account indexes", () => {
171
+ expect(() => assertSigningSessionWalletAccountIdx(2)).not.toThrow();
172
+ expect(() => assertSigningSessionWalletAccountIdx(0)).toThrow(
173
+ "walletAccountIdx must be an account index between 2 and 65535",
174
+ );
175
+ expect(() => assertSigningSessionWalletAccountIdx(1.5)).toThrow(
176
+ "walletAccountIdx must be an account index between 2 and 65535",
177
+ );
178
+ expect(() => assertSigningSessionWalletAccountIdx(0x10000)).toThrow(
179
+ "walletAccountIdx must be an account index between 2 and 65535",
180
+ );
181
+ });
182
+ });
@@ -0,0 +1,204 @@
1
+ import type {
2
+ ThruSigningSessionCreateOptions,
3
+ ThruSigningSessionDescriptor,
4
+ ThruSigningSessionTimestamp,
5
+ } from "./interfaces";
6
+
7
+ export interface SigningSessionStorage {
8
+ getItem: (key: string) => string | null | Promise<string | null>;
9
+ setItem: (key: string, value: string) => void | Promise<void>;
10
+ removeItem: (key: string) => void | Promise<void>;
11
+ }
12
+
13
+ interface SigningSessionStorePayload {
14
+ version: 1;
15
+ sessions: ThruSigningSessionDescriptor[];
16
+ }
17
+
18
+ const STORAGE_VERSION = 1;
19
+ const KEY_PREFIX = "thru.wallet.signing-sessions.v1";
20
+
21
+ function encodeKeyPart(input: string): string {
22
+ return encodeURIComponent(input).replace(/[!'()*]/g, (char) =>
23
+ `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
24
+ );
25
+ }
26
+
27
+ function nowSeconds(): number {
28
+ return Math.floor(Date.now() / 1000);
29
+ }
30
+
31
+ export function resolveSigningSessionStorageKey(params: {
32
+ walletOrigin: string;
33
+ appOrigin: string;
34
+ storageKey?: string;
35
+ }): string {
36
+ if (params.storageKey) return params.storageKey;
37
+ return `${KEY_PREFIX}:${encodeKeyPart(params.walletOrigin)}:${encodeKeyPart(params.appOrigin)}`;
38
+ }
39
+
40
+ export function getDefaultBrowserSigningSessionStorage(): SigningSessionStorage | null {
41
+ if (typeof window === "undefined") return null;
42
+ try {
43
+ return window.localStorage ?? null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function normalizeExpiresAt(
50
+ value: ThruSigningSessionTimestamp,
51
+ label = "expiresAt",
52
+ ): number {
53
+ if (value instanceof Date) {
54
+ const millis = value.getTime();
55
+ if (!Number.isFinite(millis)) throw new Error(`${label} must be a valid Date`);
56
+ return Math.floor(millis / 1000);
57
+ }
58
+
59
+ if (typeof value === "bigint") {
60
+ if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
61
+ throw new Error(`${label} must fit in a JavaScript safe integer`);
62
+ }
63
+ return Number(value);
64
+ }
65
+
66
+ if (typeof value === "string") {
67
+ const trimmed = value.trim();
68
+ if (!/^\d+$/.test(trimmed)) {
69
+ throw new Error(`${label} must be a Unix timestamp in seconds`);
70
+ }
71
+ return normalizeExpiresAt(BigInt(trimmed), label);
72
+ }
73
+
74
+ if (!Number.isFinite(value) || value < 0) {
75
+ throw new Error(`${label} must be a finite positive Unix timestamp`);
76
+ }
77
+ return Math.floor(value);
78
+ }
79
+
80
+ export function resolveSessionExpirySeconds(
81
+ options: ThruSigningSessionCreateOptions,
82
+ ): number {
83
+ const hasDuration = options.durationSeconds !== undefined;
84
+ const hasExpiresAt = options.expiresAt !== undefined;
85
+ if (hasDuration === hasExpiresAt) {
86
+ throw new Error("Provide exactly one of durationSeconds or expiresAt");
87
+ }
88
+
89
+ if (hasDuration) {
90
+ const duration = options.durationSeconds;
91
+ if (
92
+ typeof duration !== "number" ||
93
+ !Number.isFinite(duration) ||
94
+ duration <= 0
95
+ ) {
96
+ throw new Error("durationSeconds must be a positive number");
97
+ }
98
+ return nowSeconds() + Math.floor(duration);
99
+ }
100
+
101
+ return normalizeExpiresAt(options.expiresAt!, "expiresAt");
102
+ }
103
+
104
+ export function assertSigningSessionWalletAccountIdx(walletAccountIdx: number): void {
105
+ if (!Number.isInteger(walletAccountIdx) || walletAccountIdx < 2 || walletAccountIdx > 0xffff) {
106
+ throw new Error("walletAccountIdx must be an account index between 2 and 65535");
107
+ }
108
+ }
109
+
110
+ function normalizeDescriptor(
111
+ descriptor: ThruSigningSessionDescriptor,
112
+ ): ThruSigningSessionDescriptor {
113
+ return {
114
+ id: descriptor.id,
115
+ walletAddress: descriptor.walletAddress,
116
+ publicKey: descriptor.publicKey,
117
+ authIdx: Number(descriptor.authIdx),
118
+ expiresAt: normalizeExpiresAt(descriptor.expiresAt, "descriptor.expiresAt"),
119
+ createdAt: normalizeExpiresAt(descriptor.createdAt, "descriptor.createdAt"),
120
+ };
121
+ }
122
+
123
+ function isActive(descriptor: ThruSigningSessionDescriptor): boolean {
124
+ return nowSeconds() < descriptor.expiresAt;
125
+ }
126
+
127
+ export class SigningSessionDescriptorStore {
128
+ private readonly storage: SigningSessionStorage;
129
+ private readonly key: string;
130
+
131
+ constructor(storage: SigningSessionStorage, key: string) {
132
+ this.storage = storage;
133
+ this.key = key;
134
+ }
135
+
136
+ async list(): Promise<ThruSigningSessionDescriptor[]> {
137
+ const sessions = await this.read();
138
+ const active = sessions.filter(isActive);
139
+ if (active.length !== sessions.length) {
140
+ await this.write(active);
141
+ }
142
+ return active;
143
+ }
144
+
145
+ async get(id: string): Promise<ThruSigningSessionDescriptor | null> {
146
+ const sessions = await this.list();
147
+ return sessions.find((session) => session.id === id) ?? null;
148
+ }
149
+
150
+ async save(descriptor: ThruSigningSessionDescriptor): Promise<void> {
151
+ const normalized = normalizeDescriptor(descriptor);
152
+ const sessions = (await this.list()).filter((session) => session.id !== normalized.id);
153
+ sessions.push(normalized);
154
+ await this.write(sessions);
155
+ }
156
+
157
+ async saveReplacingWalletSessions(
158
+ descriptor: ThruSigningSessionDescriptor,
159
+ ): Promise<void> {
160
+ const normalized = normalizeDescriptor(descriptor);
161
+ const sessions = (await this.list()).filter(
162
+ (session) =>
163
+ session.id === normalized.id ||
164
+ session.walletAddress !== normalized.walletAddress,
165
+ );
166
+ const withoutCurrent = sessions.filter((session) => session.id !== normalized.id);
167
+ withoutCurrent.push(normalized);
168
+ await this.write(withoutCurrent);
169
+ }
170
+
171
+ async remove(id: string): Promise<void> {
172
+ const sessions = (await this.list()).filter((session) => session.id !== id);
173
+ if (sessions.length === 0) {
174
+ await this.storage.removeItem(this.key);
175
+ return;
176
+ }
177
+ await this.write(sessions);
178
+ }
179
+
180
+ private async read(): Promise<ThruSigningSessionDescriptor[]> {
181
+ const raw = await this.storage.getItem(this.key);
182
+ if (!raw) return [];
183
+
184
+ try {
185
+ const parsed = JSON.parse(raw) as Partial<SigningSessionStorePayload>;
186
+ if (parsed.version !== STORAGE_VERSION || !Array.isArray(parsed.sessions)) {
187
+ await this.storage.removeItem(this.key);
188
+ return [];
189
+ }
190
+ return parsed.sessions.map(normalizeDescriptor);
191
+ } catch {
192
+ await this.storage.removeItem(this.key);
193
+ return [];
194
+ }
195
+ }
196
+
197
+ private async write(sessions: ThruSigningSessionDescriptor[]): Promise<void> {
198
+ const payload: SigningSessionStorePayload = {
199
+ version: STORAGE_VERSION,
200
+ sessions: sessions.map(normalizeDescriptor),
201
+ };
202
+ await this.storage.setItem(this.key, JSON.stringify(payload));
203
+ }
204
+ }