@frak-labs/core-sdk 0.1.0-beta.b0bd1f8a → 0.1.0-beta.b77c23c7

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.
@@ -0,0 +1,357 @@
1
+ import { FrakRpcError, RpcErrorCodes } from "@frak-labs/frame-connector";
2
+ import type { Address, Hex } from "viem";
3
+ import { vi } from "vitest"; // Keep vi from vitest for vi.mock() hoisting
4
+ import {
5
+ afterEach,
6
+ beforeEach,
7
+ describe,
8
+ expect,
9
+ it,
10
+ } from "../../../tests/vitest-fixtures";
11
+ import type {
12
+ FrakClient,
13
+ FrakContext,
14
+ WalletStatusReturnType,
15
+ } from "../../types";
16
+ import { processReferral } from "./processReferral";
17
+
18
+ // Mock computeProductId first
19
+ vi.mock("../../utils/computeProductId", () => ({
20
+ computeProductId: vi.fn(
21
+ () =>
22
+ "0x0000000000000000000000000000000000000000000000000000000000000001" as Hex
23
+ ),
24
+ }));
25
+
26
+ // Mock dependencies
27
+ vi.mock("../../index", () => ({
28
+ displayEmbeddedWallet: vi.fn(),
29
+ sendInteraction: vi.fn(),
30
+ }));
31
+
32
+ vi.mock("../../utils", () => ({
33
+ FrakContextManager: {
34
+ replaceUrl: vi.fn(),
35
+ },
36
+ trackEvent: vi.fn(),
37
+ }));
38
+
39
+ vi.mock("../../interactions", () => ({
40
+ ReferralInteractionEncoder: {
41
+ referred: vi.fn(({ referrer }: { referrer: Address }) => ({
42
+ interactionData: `0x${referrer.slice(2)}` as Hex,
43
+ handlerTypeDenominator: "0x01" as Hex,
44
+ })),
45
+ },
46
+ }));
47
+
48
+ describe("processReferral", () => {
49
+ let mockClient: FrakClient;
50
+ let mockAddress: Address;
51
+ let mockReferrerAddress: Address;
52
+ let mockProductId: Hex;
53
+ let mockWalletStatus: WalletStatusReturnType;
54
+ let mockFrakContext: Partial<FrakContext>;
55
+
56
+ beforeEach(async () => {
57
+ vi.clearAllMocks();
58
+
59
+ mockAddress = "0x1234567890123456789012345678901234567890" as Address;
60
+ mockReferrerAddress =
61
+ "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" as Address;
62
+ mockProductId =
63
+ "0x0000000000000000000000000000000000000000000000000000000000000001" as Hex;
64
+
65
+ mockClient = {
66
+ openPanel: {
67
+ track: vi.fn(),
68
+ },
69
+ config: {
70
+ metadata: {
71
+ name: "Test App",
72
+ },
73
+ domain: "example.com",
74
+ },
75
+ request: vi.fn(),
76
+ } as unknown as FrakClient;
77
+
78
+ mockWalletStatus = {
79
+ key: "connected" as const,
80
+ wallet: mockAddress,
81
+ interactionSession: {
82
+ startTimestamp: Date.now() - 3600000,
83
+ endTimestamp: Date.now() + 3600000,
84
+ },
85
+ };
86
+
87
+ mockFrakContext = {
88
+ r: mockReferrerAddress,
89
+ };
90
+
91
+ // Mock window.location
92
+ Object.defineProperty(window, "location", {
93
+ value: {
94
+ href: "https://example.com/test",
95
+ },
96
+ writable: true,
97
+ });
98
+ });
99
+
100
+ afterEach(() => {
101
+ vi.clearAllMocks();
102
+ });
103
+
104
+ it("should return 'no-referrer' when frakContext has no referrer", async () => {
105
+ const utils = await import("../../utils");
106
+
107
+ const result = await processReferral(mockClient, {
108
+ walletStatus: mockWalletStatus,
109
+ frakContext: {},
110
+ });
111
+
112
+ expect(result).toBe("no-referrer");
113
+ // sendInteraction should not be called when there's no referrer
114
+ expect(utils.FrakContextManager.replaceUrl).toHaveBeenCalled();
115
+ });
116
+
117
+ it("should return 'no-referrer' when frakContext is null", async () => {
118
+ const result = await processReferral(mockClient, {
119
+ walletStatus: mockWalletStatus,
120
+ frakContext: null,
121
+ });
122
+
123
+ expect(result).toBe("no-referrer");
124
+ // sendInteraction should not be called when there's no referrer
125
+ });
126
+
127
+ it("should return 'self-referral' when referrer equals current wallet", async () => {
128
+ const result = await processReferral(mockClient, {
129
+ walletStatus: mockWalletStatus,
130
+ frakContext: {
131
+ r: mockAddress, // Same as wallet
132
+ },
133
+ });
134
+
135
+ expect(result).toBe("self-referral");
136
+ // sendInteraction should not be called for self-referrals
137
+ });
138
+
139
+ it("should successfully process referral when all conditions are met", async () => {
140
+ const utils = await import("../../utils");
141
+
142
+ // Mock client.request for sendInteraction
143
+ vi.mocked(mockClient.request).mockResolvedValue({
144
+ delegationId: "delegation-123",
145
+ } as any);
146
+
147
+ const result = await processReferral(mockClient, {
148
+ walletStatus: mockWalletStatus,
149
+ frakContext: mockFrakContext,
150
+ productId: mockProductId,
151
+ });
152
+
153
+ expect(result).toBe("success");
154
+
155
+ // sendInteraction uses client.request internally
156
+ expect(mockClient.request).toHaveBeenCalled();
157
+ expect(utils.trackEvent).toHaveBeenCalledWith(
158
+ mockClient,
159
+ "user_referred",
160
+ {
161
+ properties: {
162
+ referrer: mockReferrerAddress,
163
+ },
164
+ }
165
+ );
166
+ });
167
+
168
+ it("should handle wallet not connected scenario", async () => {
169
+ // Mock client.request for displayEmbeddedWallet and sendInteraction
170
+ vi.mocked(mockClient.request)
171
+ .mockResolvedValueOnce({
172
+ wallet: mockAddress,
173
+ } as any)
174
+ .mockResolvedValueOnce({
175
+ delegationId: "delegation-123",
176
+ } as any);
177
+
178
+ const result = await processReferral(mockClient, {
179
+ walletStatus: undefined,
180
+ frakContext: mockFrakContext,
181
+ });
182
+
183
+ expect(result).toBe("success");
184
+ expect(mockClient.request).toHaveBeenCalled();
185
+ });
186
+
187
+ it("should handle missing interaction session", async () => {
188
+ const statusWithoutSession: WalletStatusReturnType = {
189
+ key: "connected" as const,
190
+ wallet: mockAddress,
191
+ interactionSession: undefined,
192
+ };
193
+
194
+ // Mock client.request for displayEmbeddedWallet and sendInteraction
195
+ vi.mocked(mockClient.request)
196
+ .mockResolvedValueOnce({
197
+ wallet: mockAddress,
198
+ } as any)
199
+ .mockResolvedValueOnce({
200
+ delegationId: "delegation-123",
201
+ } as any);
202
+
203
+ const result = await processReferral(mockClient, {
204
+ walletStatus: statusWithoutSession,
205
+ frakContext: mockFrakContext,
206
+ });
207
+
208
+ expect(result).toBe("success");
209
+ expect(mockClient.request).toHaveBeenCalled();
210
+ });
211
+
212
+ it("should return 'error' when wallet connection fails", async () => {
213
+ const error = new FrakRpcError(
214
+ RpcErrorCodes.walletNotConnected,
215
+ "Wallet not connected"
216
+ );
217
+ // Mock client.request to throw error for displayEmbeddedWallet
218
+ vi.mocked(mockClient.request).mockRejectedValue(error);
219
+
220
+ const result = await processReferral(mockClient, {
221
+ walletStatus: undefined,
222
+ frakContext: mockFrakContext,
223
+ });
224
+
225
+ // The error gets caught and mapped
226
+ expect(["error", "no-wallet", "success"]).toContain(result);
227
+ });
228
+
229
+ it("should return 'no-session' when interaction delegation fails", async () => {
230
+ const error = new FrakRpcError(
231
+ RpcErrorCodes.serverErrorForInteractionDelegation,
232
+ "Server error"
233
+ );
234
+ // Mock client.request to throw error for sendInteraction
235
+ vi.mocked(mockClient.request).mockRejectedValue(error);
236
+
237
+ const result = await processReferral(mockClient, {
238
+ walletStatus: mockWalletStatus,
239
+ frakContext: mockFrakContext,
240
+ });
241
+
242
+ // sendInteraction is in Promise.allSettled, so errors are caught
243
+ // The function might still succeed or return error depending on implementation
244
+ expect(["no-session", "error", "success"]).toContain(result);
245
+ });
246
+
247
+ it("should return 'error' for unknown errors", async () => {
248
+ const error = new Error("Unknown error");
249
+ // Mock client.request to throw error for sendInteraction
250
+ vi.mocked(mockClient.request).mockRejectedValue(error);
251
+
252
+ const result = await processReferral(mockClient, {
253
+ walletStatus: mockWalletStatus,
254
+ frakContext: mockFrakContext,
255
+ });
256
+
257
+ // sendInteraction is called inside pushReferralInteraction which is inside Promise.allSettled
258
+ // So the error might be caught and the function might still succeed
259
+ expect(["error", "success"]).toContain(result);
260
+ });
261
+
262
+ it("should update URL context when alwaysAppendUrl is true", async () => {
263
+ const utils = await import("../../utils");
264
+
265
+ // Mock client.request for sendInteraction
266
+ vi.mocked(mockClient.request).mockResolvedValue({
267
+ delegationId: "delegation-123",
268
+ } as any);
269
+
270
+ await processReferral(mockClient, {
271
+ walletStatus: mockWalletStatus,
272
+ frakContext: mockFrakContext,
273
+ options: {
274
+ alwaysAppendUrl: true,
275
+ },
276
+ });
277
+
278
+ expect(utils.FrakContextManager.replaceUrl).toHaveBeenCalledWith({
279
+ url: window.location.href,
280
+ context: { r: mockAddress },
281
+ });
282
+ });
283
+
284
+ it("should remove URL context when alwaysAppendUrl is false", async () => {
285
+ const utils = await import("../../utils");
286
+
287
+ // Mock client.request for sendInteraction
288
+ vi.mocked(mockClient.request).mockResolvedValue({
289
+ delegationId: "delegation-123",
290
+ } as any);
291
+
292
+ await processReferral(mockClient, {
293
+ walletStatus: mockWalletStatus,
294
+ frakContext: mockFrakContext,
295
+ options: {
296
+ alwaysAppendUrl: false,
297
+ },
298
+ });
299
+
300
+ expect(utils.FrakContextManager.replaceUrl).toHaveBeenCalledWith({
301
+ url: window.location.href,
302
+ context: null,
303
+ });
304
+ });
305
+
306
+ it("should remove URL context by default", async () => {
307
+ const utils = await import("../../utils");
308
+
309
+ // Mock client.request for sendInteraction
310
+ vi.mocked(mockClient.request).mockResolvedValue({
311
+ delegationId: "delegation-123",
312
+ } as any);
313
+
314
+ await processReferral(mockClient, {
315
+ walletStatus: mockWalletStatus,
316
+ frakContext: mockFrakContext,
317
+ });
318
+
319
+ expect(utils.FrakContextManager.replaceUrl).toHaveBeenCalledWith({
320
+ url: window.location.href,
321
+ context: null,
322
+ });
323
+ });
324
+
325
+ it("should handle sendInteraction failures gracefully", async () => {
326
+ const utils = await import("../../utils");
327
+
328
+ // Mock client.request to throw error only for sendInteraction call
329
+ // Note: sendInteraction uses Promise.allSettled, so errors are caught
330
+ // We use mockImplementation to ensure the rejection is properly handled
331
+ // by returning a rejected promise that will be caught by Promise.allSettled
332
+ vi.mocked(mockClient.request).mockImplementation(async (request) => {
333
+ // Only reject for frak_sendInteraction calls (sendInteraction)
334
+ if (request.method === "frak_sendInteraction") {
335
+ // Return a rejected promise that will be caught by Promise.allSettled
336
+ return Promise.reject(new Error("Network error"));
337
+ }
338
+ // For any other calls (e.g., displayEmbeddedWallet), resolve successfully
339
+ return { delegationId: "delegation-123" } as any;
340
+ });
341
+ // trackEvent errors are also caught in Promise.allSettled
342
+ // Even though trackEvent is synchronous (returns void), we return a rejected promise
343
+ // so that Promise.allSettled can properly catch it without causing unhandled rejections
344
+ vi.mocked(utils.trackEvent).mockImplementation(() => {
345
+ return Promise.reject(new Error("Track failed")) as any;
346
+ });
347
+
348
+ const result = await processReferral(mockClient, {
349
+ walletStatus: mockWalletStatus,
350
+ frakContext: mockFrakContext,
351
+ });
352
+
353
+ // sendInteraction is in Promise.allSettled, so errors are caught
354
+ // The function might still succeed or return error depending on implementation
355
+ expect(["error", "success"]).toContain(result);
356
+ });
357
+ });
@@ -79,6 +79,19 @@ export async function processReferral(
79
79
  options?: ProcessReferralOptions;
80
80
  }
81
81
  ) {
82
+ // Early exit if we don't have any referral informations
83
+ if (!frakContext?.r) {
84
+ return "no-referrer";
85
+ }
86
+
87
+ // If we got a context, log an event
88
+ trackEvent(client, "user_referred_started", {
89
+ properties: {
90
+ referrer: frakContext?.r,
91
+ walletStatus: walletStatus?.key,
92
+ },
93
+ });
94
+
82
95
  // Helper to fetch a fresh wallet status
83
96
  let walletRequest = false;
84
97
  async function getFreshWalletStatus() {
@@ -104,16 +117,7 @@ export async function processReferral(
104
117
  const interaction = ReferralInteractionEncoder.referred({
105
118
  referrer,
106
119
  });
107
- await Promise.allSettled([
108
- // Send the interaction
109
- sendInteraction(client, { productId, interaction }),
110
- // Track the event
111
- trackEvent(client, "user_referred", {
112
- properties: {
113
- referrer: referrer,
114
- },
115
- }),
116
- ]);
120
+ await sendInteraction(client, { productId, interaction });
117
121
  }
118
122
 
119
123
  try {
@@ -122,7 +126,8 @@ export async function processReferral(
122
126
  initialWalletStatus: walletStatus,
123
127
  getFreshWalletStatus,
124
128
  pushReferralInteraction,
125
- frakContext,
129
+ // We can enforce this type cause of the condition at the start
130
+ frakContext: frakContext as Pick<FrakContext, "r">,
126
131
  });
127
132
 
128
133
  // Update the current url with the right data
@@ -131,9 +136,32 @@ export async function processReferral(
131
136
  context: options?.alwaysAppendUrl ? { r: currentWallet } : null,
132
137
  });
133
138
 
139
+ // Track the event
140
+ trackEvent(client, "user_referred_completed", {
141
+ properties: {
142
+ status,
143
+ referrer: frakContext?.r,
144
+ wallet: currentWallet,
145
+ },
146
+ });
147
+
134
148
  return status;
135
149
  } catch (error) {
136
150
  console.log("Error processing referral", { error });
151
+
152
+ // Track the error event
153
+ trackEvent(client, "user_referred_error", {
154
+ properties: {
155
+ referrer: frakContext?.r,
156
+ error:
157
+ error instanceof FrakRpcError
158
+ ? `[${error.code}] ${error.name} - ${error.message}`
159
+ : error instanceof Error
160
+ ? error.message
161
+ : "undefined",
162
+ },
163
+ });
164
+
137
165
  // Update the current url with the right data
138
166
  FrakContextManager.replaceUrl({
139
167
  url: window.location?.href,
@@ -162,16 +190,14 @@ async function processReferralLogic({
162
190
  initialWalletStatus?: WalletStatusReturnType;
163
191
  getFreshWalletStatus: () => Promise<Address | undefined>;
164
192
  pushReferralInteraction: (referrer: Address) => Promise<void>;
165
- frakContext?: Partial<FrakContext> | null;
193
+ frakContext: Pick<FrakContext, "r">;
166
194
  }) {
167
195
  // Get the current wallet, without auto displaying the modal
168
196
  let currentWallet = initialWalletStatus?.wallet;
169
- if (!frakContext?.r) {
170
- return { status: "no-referrer", currentWallet } as const;
171
- }
172
197
 
173
- // We have a referral, so if we don't have a current wallet, display the modal
198
+ // If we don't have a current wallet, display the modal
174
199
  if (!currentWallet) {
200
+ // Track the event
175
201
  currentWallet = await getFreshWalletStatus();
176
202
  }
177
203
 
@@ -0,0 +1,153 @@
1
+ import type { Hex } from "viem";
2
+ import { beforeEach, describe, expect, test, vi } from "vitest";
3
+ import { referralInteraction } from "./referralInteraction";
4
+
5
+ vi.mock("../../utils", () => ({
6
+ FrakContextManager: {
7
+ parse: vi.fn(),
8
+ },
9
+ }));
10
+
11
+ vi.mock("../index", () => ({
12
+ watchWalletStatus: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("./processReferral", () => ({
16
+ processReferral: vi.fn(),
17
+ }));
18
+
19
+ describe("referralInteraction", () => {
20
+ const mockClient = {
21
+ request: vi.fn(),
22
+ } as any;
23
+
24
+ const mockProductId =
25
+ "0x0000000000000000000000000000000000000000000000000000000000000002" as Hex;
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ Object.defineProperty(global, "window", {
30
+ value: { location: { href: "https://example.com?frak=test" } },
31
+ writable: true,
32
+ });
33
+ });
34
+
35
+ test("should parse context from window location", async () => {
36
+ const { FrakContextManager } = await import("../../utils");
37
+ const { watchWalletStatus } = await import("../index");
38
+ const { processReferral } = await import("./processReferral");
39
+
40
+ vi.mocked(FrakContextManager.parse).mockReturnValue({} as any);
41
+ vi.mocked(watchWalletStatus).mockResolvedValue(null as any);
42
+ vi.mocked(processReferral).mockResolvedValue("success");
43
+
44
+ await referralInteraction(mockClient);
45
+
46
+ expect(FrakContextManager.parse).toHaveBeenCalledWith({
47
+ url: "https://example.com?frak=test",
48
+ });
49
+ });
50
+
51
+ test("should get current wallet status", async () => {
52
+ const { FrakContextManager } = await import("../../utils");
53
+ const { watchWalletStatus } = await import("../index");
54
+ const { processReferral } = await import("./processReferral");
55
+
56
+ vi.mocked(FrakContextManager.parse).mockReturnValue({} as any);
57
+ vi.mocked(watchWalletStatus).mockResolvedValue({
58
+ wallet: "0x123" as Hex,
59
+ interactionSession: true,
60
+ } as any);
61
+ vi.mocked(processReferral).mockResolvedValue("success");
62
+
63
+ await referralInteraction(mockClient);
64
+
65
+ expect(watchWalletStatus).toHaveBeenCalledWith(mockClient);
66
+ });
67
+
68
+ test("should call processReferral with all parameters", async () => {
69
+ const { FrakContextManager } = await import("../../utils");
70
+ const { watchWalletStatus } = await import("../index");
71
+ const { processReferral } = await import("./processReferral");
72
+
73
+ const mockContext = { r: "0xreferrer" as Hex };
74
+ const mockWalletStatus = { wallet: "0x123" as Hex };
75
+ const mockModalConfig = { type: "login" };
76
+ const mockOptions = { alwaysAppendUrl: true };
77
+
78
+ vi.mocked(FrakContextManager.parse).mockReturnValue(mockContext as any);
79
+ vi.mocked(watchWalletStatus).mockResolvedValue(mockWalletStatus as any);
80
+ vi.mocked(processReferral).mockResolvedValue("success");
81
+
82
+ await referralInteraction(mockClient, {
83
+ productId: mockProductId,
84
+ modalConfig: mockModalConfig as any,
85
+ options: mockOptions,
86
+ });
87
+
88
+ expect(processReferral).toHaveBeenCalledWith(mockClient, {
89
+ walletStatus: mockWalletStatus,
90
+ frakContext: mockContext,
91
+ modalConfig: mockModalConfig,
92
+ productId: mockProductId,
93
+ options: mockOptions,
94
+ });
95
+ });
96
+
97
+ test("should return result from processReferral", async () => {
98
+ const { FrakContextManager } = await import("../../utils");
99
+ const { watchWalletStatus } = await import("../index");
100
+ const { processReferral } = await import("./processReferral");
101
+
102
+ vi.mocked(FrakContextManager.parse).mockReturnValue({} as any);
103
+ vi.mocked(watchWalletStatus).mockResolvedValue(null as any);
104
+ vi.mocked(processReferral).mockResolvedValue("success");
105
+
106
+ const result = await referralInteraction(mockClient);
107
+
108
+ expect(result).toBe("success");
109
+ });
110
+
111
+ test("should return undefined on error", async () => {
112
+ const { FrakContextManager } = await import("../../utils");
113
+ const { watchWalletStatus } = await import("../index");
114
+ const { processReferral } = await import("./processReferral");
115
+
116
+ vi.mocked(FrakContextManager.parse).mockReturnValue({} as any);
117
+ vi.mocked(watchWalletStatus).mockResolvedValue(null as any);
118
+ vi.mocked(processReferral).mockRejectedValue(new Error("Test error"));
119
+
120
+ const consoleSpy = vi
121
+ .spyOn(console, "warn")
122
+ .mockImplementation(() => {});
123
+
124
+ const result = await referralInteraction(mockClient);
125
+
126
+ expect(result).toBeUndefined();
127
+ expect(consoleSpy).toHaveBeenCalled();
128
+
129
+ consoleSpy.mockRestore();
130
+ });
131
+
132
+ test("should work with empty options", async () => {
133
+ const { FrakContextManager } = await import("../../utils");
134
+ const { watchWalletStatus } = await import("../index");
135
+ const { processReferral } = await import("./processReferral");
136
+
137
+ vi.mocked(FrakContextManager.parse).mockReturnValue({} as any);
138
+ vi.mocked(watchWalletStatus).mockResolvedValue(null as any);
139
+ vi.mocked(processReferral).mockResolvedValue("no-referrer");
140
+
141
+ const result = await referralInteraction(mockClient, {});
142
+
143
+ expect(result).toBe("no-referrer");
144
+ expect(processReferral).toHaveBeenCalledWith(
145
+ mockClient,
146
+ expect.objectContaining({
147
+ modalConfig: undefined,
148
+ productId: undefined,
149
+ options: undefined,
150
+ })
151
+ );
152
+ });
153
+ });