@frak-labs/core-sdk
Version:
Core SDK of the Frak wallet, low level library to interact directly with the frak ecosystem.
235 lines (212 loc) • 7.21 kB
text/typescript
import type {
AttributionParams,
FrakContext,
FrakContextV1,
FrakContextV2,
} from "../types";
import { isV2Context } from "../types";
import { base64urlDecode, base64urlEncode } from "../utils/compression/b64";
import { addressToBytes, bytesToAddress, isAddress } from "./address";
import { decodeFrakContextV2, encodeFrakContextV2 } from "./frakContextV2Codec";
/**
* URL parameter key for the Frak referral context
*/
const contextKey = "fCtx";
/**
* Compress a Frak context into a URL-safe string.
*
* - V2 contexts are encoded using a compact binary layout (see
* {@link encodeFrakContextV2}) then base64url-encoded.
* - V1 contexts encode the wallet address as raw bytes (base64url).
*
* @param context - The context to compress (V1 or V2)
* @returns A compressed base64url string, or undefined on failure
*/
function compress(context?: FrakContextV1 | FrakContextV2): string | undefined {
if (!context) return;
try {
if (isV2Context(context)) {
const encoded = encodeFrakContextV2(context);
if (!encoded) return undefined;
return base64urlEncode(encoded);
}
// V1 legacy: compress wallet address as raw bytes
const bytes = addressToBytes(context.r);
return base64urlEncode(bytes);
} catch (e) {
console.error("Error compressing Frak context", { e, context });
}
return undefined;
}
/**
* Decompress a base64url string back into a Frak context.
*
* V1 (exactly 20 bytes) and V2 (37, 41, or 57 bytes) are distinguished by
* their decoded byte length, so there is no ambiguity.
*
* @param context - The compressed context string
* @returns The decompressed FrakContext, or undefined on failure
*/
function decompress(context?: string): FrakContext | undefined {
if (!context || context.length === 0) return;
try {
const bytes = base64urlDecode(context);
// V1 is a raw 20-byte wallet address; V2 binary is always longer
// and starts with a header byte whose low nibble is the version.
if (bytes.length !== 20) {
const v2 = decodeFrakContextV2(bytes);
if (v2) return v2;
return undefined;
}
const hex = bytesToAddress(bytes);
if (isAddress(hex)) {
return { r: hex };
}
} catch (e) {
console.error("Error decompressing Frak context", { e, context });
}
return undefined;
}
/**
* Parse a URL to extract the Frak referral context from the `fCtx` query parameter.
*
* @param args
* @param args.url - The URL to parse
* @returns The parsed FrakContext, or null if absent
*/
function parse({ url }: { url: string }): FrakContext | null | undefined {
if (!url) return null;
const urlObj = new URL(url);
const frakContext = urlObj.searchParams.get(contextKey);
if (!frakContext) return null;
return decompress(frakContext);
}
/**
* Default utm_source / via value when attribution is requested.
*/
const DEFAULT_ATTRIBUTION_SOURCE = "frak";
/**
* Resolve attribution defaults from the provided context.
*
* V2 contexts expose the merchantId (`m`) and, when anonymous, the clientId
* (`c`), which feed `utm_campaign` and `ref` respectively. When V2 only carries
* a wallet (`w`), `ref` is intentionally left unset — we don't want wallet
* addresses leaking into UTM params. V1 contexts have no equivalent.
*/
function resolveAttributionValues(
overrides: AttributionParams
): Record<string, string | undefined> {
return {
utm_source: overrides.utmSource ?? DEFAULT_ATTRIBUTION_SOURCE,
utm_medium: overrides.utmMedium ?? undefined,
utm_campaign: overrides.utmCampaign ?? undefined,
utm_content: overrides.utmContent,
utm_term: overrides.utmTerm,
via: overrides.via ?? undefined,
ref: overrides.ref ?? undefined,
};
}
/**
* Append attribution query params to a URL using gap-fill semantics.
*
* Existing params on the URL are preserved untouched (so merchant-provided
* UTMs take precedence). Only missing keys are populated.
*/
function applyAttributionParams(
urlObj: URL,
attribution?: AttributionParams
): void {
const values = resolveAttributionValues(attribution ?? {});
for (const [key, value] of Object.entries(values)) {
if (value === undefined || value === "") continue;
if (urlObj.searchParams.has(key)) continue;
urlObj.searchParams.set(key, value);
}
}
/**
* Add or replace the `fCtx` query parameter in a URL with the given context.
*
* Standard affiliation params (`utm_source`, `utm_medium`, `utm_campaign`,
* `ref`, `via`, ...) are always appended using gap-fill semantics: pre-existing
* params on the URL are preserved, defaults are derived from the context when
* applicable, and `attribution` overrides take precedence when provided.
*
* @param args
* @param args.url - The URL to update
* @param args.context - The context to embed (V1 or V2)
* @param args.attribution - Optional attribution overrides. Defaults are applied even when omitted.
* @returns The updated URL string, or null on failure
*/
function update({
url,
context,
attribution,
}: {
url?: string;
context: FrakContextV1 | FrakContextV2;
attribution?: AttributionParams;
}): string | null {
if (!url) return null;
const compressedContext = compress(context);
if (!compressedContext) return null;
const urlObj = new URL(url);
urlObj.searchParams.set(contextKey, compressedContext);
applyAttributionParams(urlObj, attribution);
return urlObj.toString();
}
/**
* Remove the `fCtx` query parameter from a URL.
*
* @param url - The URL to strip the context from
* @returns The cleaned URL string
*/
function remove(url: string): string {
const urlObj = new URL(url);
urlObj.searchParams.delete(contextKey);
return urlObj.toString();
}
/**
* Replace the current browser URL with an updated Frak context.
*
* - If `context` is non-null, embeds it via {@link update}.
* - If `context` is null, strips the context via {@link remove}.
*
* @param args
* @param args.url - Base URL (defaults to `window.location.href`)
* @param args.context - Context to set, or null to remove
*/
function replaceUrl({
url: baseUrl,
context,
}: {
url?: string;
context: FrakContextV1 | FrakContextV2 | null;
}) {
if (!window.location?.href || typeof window === "undefined") {
console.error("No window found, can't update context");
return;
}
const url = baseUrl ?? window.location.href;
let newUrl: string | null;
if (context !== null) {
newUrl = update({ url, context });
} else {
newUrl = remove(url);
}
if (!newUrl) return;
window.history.replaceState(null, "", newUrl.toString());
}
/**
* Manager for Frak referral context in URLs.
*
* Handles compression, decompression, URL parsing, and browser history updates
* for both V1 (wallet address) and V2 (anonymous clientId) referral contexts.
*/
export const FrakContextManager = {
compress,
decompress,
parse,
update,
remove,
replaceUrl,
};