@frak-labs/core-sdk 1.0.0 → 1.0.1
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/cdn/bundle.js +3 -3
- package/dist/actions-BlCQVBQJ.js +1 -0
- package/dist/actions-Bwj4zSdB.cjs +1 -0
- package/dist/actions.cjs +1 -1
- package/dist/actions.d.cts +2 -2
- package/dist/actions.d.ts +2 -2
- package/dist/actions.js +1 -1
- package/dist/bundle.cjs +1 -1
- package/dist/bundle.d.cts +4 -4
- package/dist/bundle.d.ts +4 -4
- package/dist/bundle.js +1 -1
- package/dist/{index-Dwmo109y.d.cts → index-9TdOc_ub.d.ts} +169 -44
- package/dist/{index-BphwTmKA.d.cts → index-BWic1g0J.d.cts} +1 -1
- package/dist/{index-BV5D9DsW.d.ts → index-DPIqLMCR.d.cts} +169 -44
- package/dist/{index-_f8EuN_1.d.ts → index-Du4nB3qO.d.ts} +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/{openSso-BwEK2M98.d.cts → openSso-3YqtmSkM.d.ts} +116 -10
- package/dist/{openSso-C1Wzl5-i.d.ts → openSso-SP6T9cHA.d.cts} +115 -9
- package/dist/sdkConfigStore-BXzz5PlK.js +1 -0
- package/dist/sdkConfigStore-DDL_fjYX.cjs +1 -0
- package/dist/src-CqKED785.cjs +13 -0
- package/dist/src-u4vW9qh0.js +13 -0
- package/package.json +1 -1
- package/src/actions/referral/processReferral.test.ts +129 -8
- package/src/actions/referral/processReferral.ts +27 -17
- package/src/clients/createIFrameFrakClient.ts +84 -3
- package/src/index.ts +8 -1
- package/src/types/config.ts +9 -0
- package/src/types/context.ts +16 -4
- package/src/types/index.ts +2 -0
- package/src/types/lifecycle/client.ts +7 -0
- package/src/types/resolvedConfig.ts +10 -0
- package/src/types/rpc/displaySharingPage.ts +18 -0
- package/src/types/rpc/interaction.ts +1 -1
- package/src/types/tracking.ts +37 -1
- package/src/utils/FrakContext.test.ts +239 -9
- package/src/utils/FrakContext.ts +83 -21
- package/src/utils/analytics/events/component.ts +58 -0
- package/src/utils/analytics/events/index.ts +20 -0
- package/src/utils/analytics/events/lifecycle.ts +26 -0
- package/src/utils/analytics/events/referral.ts +11 -0
- package/src/utils/analytics/index.ts +8 -0
- package/src/utils/{trackEvent.test.ts → analytics/trackEvent.test.ts} +22 -30
- package/src/utils/analytics/trackEvent.ts +34 -0
- package/src/utils/frakContextV2Codec.test.ts +241 -0
- package/src/utils/frakContextV2Codec.ts +197 -0
- package/src/utils/index.ts +5 -1
- package/src/utils/mergeAttribution.test.ts +153 -0
- package/src/utils/mergeAttribution.ts +75 -0
- package/dist/actions-D4aBXbdp.cjs +0 -1
- package/dist/actions-Dq_uN-wn.js +0 -1
- package/dist/src-B1eliIi6.cjs +0 -13
- package/dist/src-C0UH1GsN.js +0 -13
- package/dist/trackEvent-BqJqRZ-u.cjs +0 -1
- package/dist/trackEvent-Bqq4jd6R.js +0 -1
- package/src/utils/trackEvent.ts +0 -41
|
@@ -25,10 +25,12 @@ describe("FrakContextManager", () => {
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
describe("V2 context", () => {
|
|
28
|
+
const MERCHANT_ID = "550e8400-e29b-41d4-a716-446655440000";
|
|
29
|
+
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
|
28
30
|
const v2Context: FrakContextV2 = {
|
|
29
31
|
v: 2,
|
|
30
|
-
c:
|
|
31
|
-
m:
|
|
32
|
+
c: CLIENT_ID,
|
|
33
|
+
m: MERCHANT_ID,
|
|
32
34
|
t: 1709654400,
|
|
33
35
|
};
|
|
34
36
|
|
|
@@ -42,7 +44,7 @@ describe("FrakContextManager", () => {
|
|
|
42
44
|
expect(result).not.toMatch(/[+/=]/);
|
|
43
45
|
});
|
|
44
46
|
|
|
45
|
-
it("should return undefined when v2 context
|
|
47
|
+
it("should return undefined when v2 context has neither clientId nor wallet", () => {
|
|
46
48
|
const partial = { v: 2 as const, m: "m", t: 123 };
|
|
47
49
|
const result = FrakContextManager.compress(
|
|
48
50
|
partial as FrakContextV2
|
|
@@ -50,8 +52,35 @@ describe("FrakContextManager", () => {
|
|
|
50
52
|
expect(result).toBeUndefined();
|
|
51
53
|
});
|
|
52
54
|
|
|
55
|
+
it("should compress v2 context with wallet only (no clientId)", () => {
|
|
56
|
+
const v2WithWalletOnly: FrakContextV2 = {
|
|
57
|
+
v: 2,
|
|
58
|
+
m: MERCHANT_ID,
|
|
59
|
+
t: 1709654400,
|
|
60
|
+
w: "0x1234567890123456789012345678901234567890" as Address,
|
|
61
|
+
};
|
|
62
|
+
const result = FrakContextManager.compress(v2WithWalletOnly);
|
|
63
|
+
expect(result).toBeDefined();
|
|
64
|
+
const decompressed = FrakContextManager.decompress(result);
|
|
65
|
+
expect(decompressed).toEqual(v2WithWalletOnly);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should compress v2 context with both clientId and wallet", () => {
|
|
69
|
+
const v2Hybrid: FrakContextV2 = {
|
|
70
|
+
v: 2,
|
|
71
|
+
c: CLIENT_ID,
|
|
72
|
+
m: MERCHANT_ID,
|
|
73
|
+
t: 1709654400,
|
|
74
|
+
w: "0x1234567890123456789012345678901234567890" as Address,
|
|
75
|
+
};
|
|
76
|
+
const result = FrakContextManager.compress(v2Hybrid);
|
|
77
|
+
expect(result).toBeDefined();
|
|
78
|
+
const decompressed = FrakContextManager.decompress(result);
|
|
79
|
+
expect(decompressed).toEqual(v2Hybrid);
|
|
80
|
+
});
|
|
81
|
+
|
|
53
82
|
it("should return undefined when v2 context is missing merchantId", () => {
|
|
54
|
-
const partial = { v: 2 as const, c:
|
|
83
|
+
const partial = { v: 2 as const, c: CLIENT_ID, t: 123 };
|
|
55
84
|
const result = FrakContextManager.compress(
|
|
56
85
|
partial as FrakContextV2
|
|
57
86
|
);
|
|
@@ -59,12 +88,46 @@ describe("FrakContextManager", () => {
|
|
|
59
88
|
});
|
|
60
89
|
|
|
61
90
|
it("should return undefined when v2 context is missing timestamp", () => {
|
|
62
|
-
const partial = { v: 2 as const, c:
|
|
91
|
+
const partial = { v: 2 as const, c: CLIENT_ID, m: MERCHANT_ID };
|
|
63
92
|
const result = FrakContextManager.compress(
|
|
64
93
|
partial as FrakContextV2
|
|
65
94
|
);
|
|
66
95
|
expect(result).toBeUndefined();
|
|
67
96
|
});
|
|
97
|
+
|
|
98
|
+
it("should reject v2 context with a malformed wallet address", () => {
|
|
99
|
+
const partial = {
|
|
100
|
+
v: 2 as const,
|
|
101
|
+
m: MERCHANT_ID,
|
|
102
|
+
t: 1709654400,
|
|
103
|
+
w: "0xnot-a-valid-address" as Address,
|
|
104
|
+
};
|
|
105
|
+
const result = FrakContextManager.compress(
|
|
106
|
+
partial as FrakContextV2
|
|
107
|
+
);
|
|
108
|
+
// Invalid wallet → falls back to clientId requirement; absent here → undefined
|
|
109
|
+
expect(result).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should drop a malformed wallet but keep a valid clientId", () => {
|
|
113
|
+
const hybrid = {
|
|
114
|
+
v: 2 as const,
|
|
115
|
+
c: CLIENT_ID,
|
|
116
|
+
m: MERCHANT_ID,
|
|
117
|
+
t: 1709654400,
|
|
118
|
+
w: "0xnot-a-valid-address" as Address,
|
|
119
|
+
};
|
|
120
|
+
const compressed = FrakContextManager.compress(
|
|
121
|
+
hybrid as FrakContextV2
|
|
122
|
+
);
|
|
123
|
+
const decompressed = FrakContextManager.decompress(compressed);
|
|
124
|
+
expect(decompressed).toEqual({
|
|
125
|
+
v: 2,
|
|
126
|
+
c: CLIENT_ID,
|
|
127
|
+
m: MERCHANT_ID,
|
|
128
|
+
t: 1709654400,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
68
131
|
});
|
|
69
132
|
|
|
70
133
|
describe("decompress", () => {
|
|
@@ -74,6 +137,22 @@ describe("FrakContextManager", () => {
|
|
|
74
137
|
|
|
75
138
|
expect(decompressed).toEqual(v2Context);
|
|
76
139
|
});
|
|
140
|
+
|
|
141
|
+
it("should reject payloads whose header reserved bits are set", async () => {
|
|
142
|
+
// Craft a valid V2 binary payload then flip a reserved bit
|
|
143
|
+
// in the header — decompress must refuse to parse it (forward-compat guard).
|
|
144
|
+
const { encodeFrakContextV2 } = await import(
|
|
145
|
+
"./frakContextV2Codec"
|
|
146
|
+
);
|
|
147
|
+
const { base64urlEncode } = await import("./compression/b64");
|
|
148
|
+
const encoded = encodeFrakContextV2(v2Context);
|
|
149
|
+
expect(encoded).toBeDefined();
|
|
150
|
+
const tampered = new Uint8Array(encoded as Uint8Array);
|
|
151
|
+
tampered[0] |= 0x40; // set a reserved bit
|
|
152
|
+
const payload = base64urlEncode(tampered);
|
|
153
|
+
const result = FrakContextManager.decompress(payload);
|
|
154
|
+
expect(result).toBeUndefined();
|
|
155
|
+
});
|
|
77
156
|
});
|
|
78
157
|
|
|
79
158
|
describe("parse", () => {
|
|
@@ -86,8 +165,8 @@ describe("FrakContextManager", () => {
|
|
|
86
165
|
expect(result).toBeDefined();
|
|
87
166
|
expect(result).toHaveProperty("v", 2);
|
|
88
167
|
const v2 = result as FrakContextV2;
|
|
89
|
-
expect(v2.c).toBe(
|
|
90
|
-
expect(v2.m).toBe(
|
|
168
|
+
expect(v2.c).toBe(CLIENT_ID);
|
|
169
|
+
expect(v2.m).toBe(MERCHANT_ID);
|
|
91
170
|
expect(v2.t).toBe(1709654400);
|
|
92
171
|
});
|
|
93
172
|
});
|
|
@@ -121,6 +200,157 @@ describe("FrakContextManager", () => {
|
|
|
121
200
|
expect(result).toContain("baz=qux");
|
|
122
201
|
expect(result).toContain("fCtx=");
|
|
123
202
|
});
|
|
203
|
+
|
|
204
|
+
describe("update with attribution", () => {
|
|
205
|
+
const url = "https://example.com/product";
|
|
206
|
+
|
|
207
|
+
it("should apply default attribution params when attribution is omitted", () => {
|
|
208
|
+
const result = FrakContextManager.update({
|
|
209
|
+
url,
|
|
210
|
+
context: v2Context,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(result).toBeDefined();
|
|
214
|
+
expect(result).toContain("fCtx=");
|
|
215
|
+
const parsedUrl = new URL(result!);
|
|
216
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
217
|
+
"frak"
|
|
218
|
+
);
|
|
219
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
220
|
+
"referral"
|
|
221
|
+
);
|
|
222
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
223
|
+
v2Context.m
|
|
224
|
+
);
|
|
225
|
+
expect(parsedUrl.searchParams.get("via")).toBe("frak");
|
|
226
|
+
expect(parsedUrl.searchParams.get("ref")).toBe(v2Context.c);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("should apply default attribution params when attribution is an empty object", () => {
|
|
230
|
+
const result = FrakContextManager.update({
|
|
231
|
+
url,
|
|
232
|
+
context: v2Context,
|
|
233
|
+
attribution: {},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result).toBeDefined();
|
|
237
|
+
const parsedUrl = new URL(result!);
|
|
238
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
239
|
+
"frak"
|
|
240
|
+
);
|
|
241
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
242
|
+
"referral"
|
|
243
|
+
);
|
|
244
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
245
|
+
v2Context.m
|
|
246
|
+
);
|
|
247
|
+
expect(parsedUrl.searchParams.get("via")).toBe("frak");
|
|
248
|
+
expect(parsedUrl.searchParams.get("ref")).toBe(v2Context.c);
|
|
249
|
+
expect(
|
|
250
|
+
parsedUrl.searchParams.get("utm_content")
|
|
251
|
+
).toBeNull();
|
|
252
|
+
expect(parsedUrl.searchParams.get("utm_term")).toBeNull();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("should honor overrides over defaults", () => {
|
|
256
|
+
const result = FrakContextManager.update({
|
|
257
|
+
url,
|
|
258
|
+
context: v2Context,
|
|
259
|
+
attribution: {
|
|
260
|
+
utmSource: "newsletter",
|
|
261
|
+
utmMedium: "email",
|
|
262
|
+
utmCampaign: "spring-sale",
|
|
263
|
+
utmContent: "hero-banner",
|
|
264
|
+
utmTerm: "wallet",
|
|
265
|
+
via: "partner",
|
|
266
|
+
ref: "alice",
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const parsedUrl = new URL(result!);
|
|
271
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
272
|
+
"newsletter"
|
|
273
|
+
);
|
|
274
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
275
|
+
"email"
|
|
276
|
+
);
|
|
277
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
278
|
+
"spring-sale"
|
|
279
|
+
);
|
|
280
|
+
expect(parsedUrl.searchParams.get("utm_content")).toBe(
|
|
281
|
+
"hero-banner"
|
|
282
|
+
);
|
|
283
|
+
expect(parsedUrl.searchParams.get("utm_term")).toBe(
|
|
284
|
+
"wallet"
|
|
285
|
+
);
|
|
286
|
+
expect(parsedUrl.searchParams.get("via")).toBe("partner");
|
|
287
|
+
expect(parsedUrl.searchParams.get("ref")).toBe("alice");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("should preserve merchant-provided UTMs on the base URL (gap-fill)", () => {
|
|
291
|
+
const baseUrl =
|
|
292
|
+
"https://example.com/product?utm_source=google&utm_campaign=merchant-spring";
|
|
293
|
+
const result = FrakContextManager.update({
|
|
294
|
+
url: baseUrl,
|
|
295
|
+
context: v2Context,
|
|
296
|
+
attribution: {},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const parsedUrl = new URL(result!);
|
|
300
|
+
// Merchant-provided values preserved
|
|
301
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
302
|
+
"google"
|
|
303
|
+
);
|
|
304
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
305
|
+
"merchant-spring"
|
|
306
|
+
);
|
|
307
|
+
// Missing ones filled by Frak defaults
|
|
308
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
309
|
+
"referral"
|
|
310
|
+
);
|
|
311
|
+
expect(parsedUrl.searchParams.get("ref")).toBe(v2Context.c);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should skip fields with empty-string overrides", () => {
|
|
315
|
+
const result = FrakContextManager.update({
|
|
316
|
+
url,
|
|
317
|
+
context: v2Context,
|
|
318
|
+
attribution: { utmContent: "", utmTerm: "" },
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const parsedUrl = new URL(result!);
|
|
322
|
+
expect(parsedUrl.searchParams.has("utm_content")).toBe(
|
|
323
|
+
false
|
|
324
|
+
);
|
|
325
|
+
expect(parsedUrl.searchParams.has("utm_term")).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("should skip context-derived defaults for V1 (no merchantId/clientId)", () => {
|
|
329
|
+
const v1Context: FrakContextV1 = {
|
|
330
|
+
r: "0x1234567890123456789012345678901234567890" as Address,
|
|
331
|
+
};
|
|
332
|
+
const result = FrakContextManager.update({
|
|
333
|
+
url,
|
|
334
|
+
context: v1Context,
|
|
335
|
+
attribution: {},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const parsedUrl = new URL(result!);
|
|
339
|
+
// Static defaults still applied
|
|
340
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
341
|
+
"frak"
|
|
342
|
+
);
|
|
343
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
344
|
+
"referral"
|
|
345
|
+
);
|
|
346
|
+
expect(parsedUrl.searchParams.get("via")).toBe("frak");
|
|
347
|
+
// No derivable values from V1
|
|
348
|
+
expect(parsedUrl.searchParams.has("utm_campaign")).toBe(
|
|
349
|
+
false
|
|
350
|
+
);
|
|
351
|
+
expect(parsedUrl.searchParams.has("ref")).toBe(false);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
124
354
|
});
|
|
125
355
|
});
|
|
126
356
|
|
|
@@ -416,8 +646,8 @@ describe("FrakContextManager", () => {
|
|
|
416
646
|
const url = "https://example.com/test";
|
|
417
647
|
const context: FrakContextV2 = {
|
|
418
648
|
v: 2,
|
|
419
|
-
c: "
|
|
420
|
-
m: "
|
|
649
|
+
c: "550e8400-e29b-41d4-a716-446655440001",
|
|
650
|
+
m: "550e8400-e29b-41d4-a716-446655440000",
|
|
421
651
|
t: 1709654400,
|
|
422
652
|
};
|
|
423
653
|
|
package/src/utils/FrakContext.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { type Address, bytesToHex, hexToBytes, isAddress } from "viem";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AttributionParams,
|
|
4
|
+
FrakContext,
|
|
5
|
+
FrakContextV1,
|
|
6
|
+
FrakContextV2,
|
|
7
|
+
} from "../types";
|
|
3
8
|
import { isV2Context } from "../types";
|
|
4
9
|
import { base64urlDecode, base64urlEncode } from "./compression/b64";
|
|
5
|
-
import {
|
|
6
|
-
import { decompressJsonFromB64 } from "./compression/decompress";
|
|
10
|
+
import { decodeFrakContextV2, encodeFrakContextV2 } from "./frakContextV2Codec";
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* URL parameter key for the Frak referral context
|
|
@@ -13,7 +17,8 @@ const contextKey = "fCtx";
|
|
|
13
17
|
/**
|
|
14
18
|
* Compress a Frak context into a URL-safe string.
|
|
15
19
|
*
|
|
16
|
-
* - V2 contexts are
|
|
20
|
+
* - V2 contexts are encoded using a compact binary layout (see
|
|
21
|
+
* {@link encodeFrakContextV2}) then base64url-encoded.
|
|
17
22
|
* - V1 contexts encode the wallet address as raw bytes (base64url).
|
|
18
23
|
*
|
|
19
24
|
* @param context - The context to compress (V1 or V2)
|
|
@@ -23,14 +28,9 @@ function compress(context?: FrakContextV1 | FrakContextV2): string | undefined {
|
|
|
23
28
|
if (!context) return;
|
|
24
29
|
try {
|
|
25
30
|
if (isV2Context(context)) {
|
|
26
|
-
|
|
27
|
-
if (!
|
|
28
|
-
return
|
|
29
|
-
v: 2,
|
|
30
|
-
c: context.c,
|
|
31
|
-
m: context.m,
|
|
32
|
-
t: context.t,
|
|
33
|
-
});
|
|
31
|
+
const encoded = encodeFrakContextV2(context);
|
|
32
|
+
if (!encoded) return undefined;
|
|
33
|
+
return base64urlEncode(encoded);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// V1 legacy: compress wallet address as raw bytes
|
|
@@ -45,7 +45,8 @@ function compress(context?: FrakContextV1 | FrakContextV2): string | undefined {
|
|
|
45
45
|
/**
|
|
46
46
|
* Decompress a base64url string back into a Frak context.
|
|
47
47
|
*
|
|
48
|
-
*
|
|
48
|
+
* V1 (exactly 20 bytes) and V2 (37, 41, or 57 bytes) are distinguished by
|
|
49
|
+
* their decoded byte length, so there is no ambiguity.
|
|
49
50
|
*
|
|
50
51
|
* @param context - The compressed context string
|
|
51
52
|
* @returns The decompressed FrakContext, or undefined on failure
|
|
@@ -53,17 +54,16 @@ function compress(context?: FrakContextV1 | FrakContextV2): string | undefined {
|
|
|
53
54
|
function decompress(context?: string): FrakContext | undefined {
|
|
54
55
|
if (!context || context.length === 0) return;
|
|
55
56
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
const bytes = base64urlDecode(context);
|
|
58
|
+
|
|
59
|
+
// V1 is a raw 20-byte wallet address; V2 binary is always longer
|
|
60
|
+
// and starts with a header byte whose low nibble is the version.
|
|
61
|
+
if (bytes.length !== 20) {
|
|
62
|
+
const v2 = decodeFrakContextV2(bytes);
|
|
63
|
+
if (v2) return v2;
|
|
62
64
|
return undefined;
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
// Fall back to V1: raw 20-byte address
|
|
66
|
-
const bytes = base64urlDecode(context);
|
|
67
67
|
const hex = bytesToHex(bytes, { size: 20 }) as Address;
|
|
68
68
|
if (isAddress(hex)) {
|
|
69
69
|
return { r: hex };
|
|
@@ -91,20 +91,81 @@ function parse({ url }: { url: string }): FrakContext | null | undefined {
|
|
|
91
91
|
return decompress(frakContext);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Default UTM medium value when attribution is requested.
|
|
96
|
+
*/
|
|
97
|
+
const DEFAULT_UTM_MEDIUM = "referral";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Default utm_source / via value when attribution is requested.
|
|
101
|
+
*/
|
|
102
|
+
const DEFAULT_ATTRIBUTION_SOURCE = "frak";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve attribution defaults from the provided context.
|
|
106
|
+
*
|
|
107
|
+
* V2 contexts expose the merchantId (`m`) and, when anonymous, the clientId
|
|
108
|
+
* (`c`), which feed `utm_campaign` and `ref` respectively. When V2 only carries
|
|
109
|
+
* a wallet (`w`), `ref` is intentionally left unset — we don't want wallet
|
|
110
|
+
* addresses leaking into UTM params. V1 contexts have no equivalent.
|
|
111
|
+
*/
|
|
112
|
+
function resolveAttributionValues(
|
|
113
|
+
context: FrakContextV1 | FrakContextV2,
|
|
114
|
+
overrides: AttributionParams
|
|
115
|
+
): Record<string, string | undefined> {
|
|
116
|
+
const isV2 = isV2Context(context);
|
|
117
|
+
return {
|
|
118
|
+
utm_source: overrides.utmSource ?? DEFAULT_ATTRIBUTION_SOURCE,
|
|
119
|
+
utm_medium: overrides.utmMedium ?? DEFAULT_UTM_MEDIUM,
|
|
120
|
+
utm_campaign: overrides.utmCampaign ?? (isV2 ? context.m : undefined),
|
|
121
|
+
utm_content: overrides.utmContent,
|
|
122
|
+
utm_term: overrides.utmTerm,
|
|
123
|
+
via: overrides.via ?? DEFAULT_ATTRIBUTION_SOURCE,
|
|
124
|
+
ref: overrides.ref ?? (isV2 ? context.c : undefined),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Append attribution query params to a URL using gap-fill semantics.
|
|
130
|
+
*
|
|
131
|
+
* Existing params on the URL are preserved untouched (so merchant-provided
|
|
132
|
+
* UTMs take precedence). Only missing keys are populated.
|
|
133
|
+
*/
|
|
134
|
+
function applyAttributionParams(
|
|
135
|
+
urlObj: URL,
|
|
136
|
+
context: FrakContextV1 | FrakContextV2,
|
|
137
|
+
attribution?: AttributionParams
|
|
138
|
+
): void {
|
|
139
|
+
const values = resolveAttributionValues(context, attribution ?? {});
|
|
140
|
+
for (const [key, value] of Object.entries(values)) {
|
|
141
|
+
if (value === undefined || value === "") continue;
|
|
142
|
+
if (urlObj.searchParams.has(key)) continue;
|
|
143
|
+
urlObj.searchParams.set(key, value);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
94
147
|
/**
|
|
95
148
|
* Add or replace the `fCtx` query parameter in a URL with the given context.
|
|
96
149
|
*
|
|
150
|
+
* Standard affiliation params (`utm_source`, `utm_medium`, `utm_campaign`,
|
|
151
|
+
* `ref`, `via`, ...) are always appended using gap-fill semantics: pre-existing
|
|
152
|
+
* params on the URL are preserved, defaults are derived from the context when
|
|
153
|
+
* applicable, and `attribution` overrides take precedence when provided.
|
|
154
|
+
*
|
|
97
155
|
* @param args
|
|
98
156
|
* @param args.url - The URL to update
|
|
99
157
|
* @param args.context - The context to embed (V1 or V2)
|
|
158
|
+
* @param args.attribution - Optional attribution overrides. Defaults are applied even when omitted.
|
|
100
159
|
* @returns The updated URL string, or null on failure
|
|
101
160
|
*/
|
|
102
161
|
function update({
|
|
103
162
|
url,
|
|
104
163
|
context,
|
|
164
|
+
attribution,
|
|
105
165
|
}: {
|
|
106
166
|
url?: string;
|
|
107
167
|
context: FrakContextV1 | FrakContextV2;
|
|
168
|
+
attribution?: AttributionParams;
|
|
108
169
|
}): string | null {
|
|
109
170
|
if (!url) return null;
|
|
110
171
|
|
|
@@ -113,6 +174,7 @@ function update({
|
|
|
113
174
|
|
|
114
175
|
const urlObj = new URL(url);
|
|
115
176
|
urlObj.searchParams.set(contextKey, compressedContext);
|
|
177
|
+
applyAttributionParams(urlObj, context, attribution);
|
|
116
178
|
return urlObj.toString();
|
|
117
179
|
}
|
|
118
180
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
type ButtonBaseProps = {
|
|
2
|
+
placement?: string;
|
|
3
|
+
target_interaction?: string;
|
|
4
|
+
has_reward?: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type BannerVariant = "referral" | "inapp";
|
|
8
|
+
type BannerOutcome = "clicked" | "dismissed";
|
|
9
|
+
type PostPurchaseVariant = "referrer" | "referee";
|
|
10
|
+
type ShareClickAction = "share-modal" | "embedded-wallet" | "sharing-page";
|
|
11
|
+
|
|
12
|
+
export type SdkComponentEventMap = {
|
|
13
|
+
// Share button — click carries the resolved action + reward presence so
|
|
14
|
+
// we can compare per-merchant configuration impact on conversion.
|
|
15
|
+
share_button_clicked: ButtonBaseProps & {
|
|
16
|
+
click_action: ShareClickAction;
|
|
17
|
+
};
|
|
18
|
+
share_modal_error: ButtonBaseProps & {
|
|
19
|
+
error?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Wallet button (floating) — NOT actively used in production. No tracking.
|
|
23
|
+
|
|
24
|
+
// Open in app — path lets us compare deep-link destinations once we add more.
|
|
25
|
+
open_in_app_clicked: {
|
|
26
|
+
placement?: string;
|
|
27
|
+
path: string;
|
|
28
|
+
};
|
|
29
|
+
app_not_installed: {
|
|
30
|
+
placement?: string;
|
|
31
|
+
path: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Banner — referral vs in-app variants share the funnel shape.
|
|
35
|
+
banner_impression: {
|
|
36
|
+
placement?: string;
|
|
37
|
+
variant: BannerVariant;
|
|
38
|
+
has_reward?: boolean;
|
|
39
|
+
};
|
|
40
|
+
banner_resolved: {
|
|
41
|
+
placement?: string;
|
|
42
|
+
variant: BannerVariant;
|
|
43
|
+
outcome: BannerOutcome;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Post-purchase — the card drives the highest-intent entry into the
|
|
47
|
+
// referral loop; variant tells us whether we upsold a new share or
|
|
48
|
+
// celebrated an existing referee.
|
|
49
|
+
post_purchase_impression: {
|
|
50
|
+
placement?: string;
|
|
51
|
+
variant: PostPurchaseVariant;
|
|
52
|
+
has_reward?: boolean;
|
|
53
|
+
};
|
|
54
|
+
post_purchase_clicked: {
|
|
55
|
+
placement?: string;
|
|
56
|
+
variant: PostPurchaseVariant;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SdkComponentEventMap } from "./component";
|
|
2
|
+
import type { SdkLifecycleEventMap } from "./lifecycle";
|
|
3
|
+
import type { SdkReferralEventMap } from "./referral";
|
|
4
|
+
|
|
5
|
+
export type { SdkComponentEventMap } from "./component";
|
|
6
|
+
export type {
|
|
7
|
+
SdkHandshakeFailureReason,
|
|
8
|
+
SdkLifecycleEventMap,
|
|
9
|
+
} from "./lifecycle";
|
|
10
|
+
export type { SdkReferralEventMap } from "./referral";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Merged SDK event map. Consumed by the SDK's typed `trackEvent`.
|
|
14
|
+
* Stays isolated from wallet-shared because the SDK ships in partner
|
|
15
|
+
* bundles (different OpenPanel client id, no wallet-shared dependency
|
|
16
|
+
* allowed — see `packages/wallet-shared/AGENTS.md`).
|
|
17
|
+
*/
|
|
18
|
+
export type SdkEventMap = SdkLifecycleEventMap &
|
|
19
|
+
SdkComponentEventMap &
|
|
20
|
+
SdkReferralEventMap;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type SdkHandshakeFailureReason =
|
|
2
|
+
| "timeout"
|
|
3
|
+
| "origin"
|
|
4
|
+
| "asset_push"
|
|
5
|
+
| "unknown";
|
|
6
|
+
|
|
7
|
+
export type SdkLifecycleEventMap = {
|
|
8
|
+
sdk_initialized: {
|
|
9
|
+
sdkVersion?: string;
|
|
10
|
+
};
|
|
11
|
+
sdk_iframe_connected: {
|
|
12
|
+
handshake_duration_ms: number;
|
|
13
|
+
};
|
|
14
|
+
sdk_iframe_handshake_failed: {
|
|
15
|
+
reason: SdkHandshakeFailureReason;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Emitted by the CDN bootstrap when `initFrakSdk()` throws before a
|
|
19
|
+
* client is available. Uses a transient OpenPanel instance so broken
|
|
20
|
+
* partner integrations are still captured.
|
|
21
|
+
*/
|
|
22
|
+
sdk_init_failed: {
|
|
23
|
+
reason: string;
|
|
24
|
+
config_missing?: boolean;
|
|
25
|
+
};
|
|
26
|
+
};
|