@frak-labs/core-sdk
Version:
Core SDK of the Frak wallet, low level library to interact directly with the frak ecosystem.
198 lines (169 loc) • 6.22 kB
text/typescript
/**
* Binary codec for {@link FrakContextV2}.
*
* Produces a compact, URL-safe byte layout (~65% smaller than the previous
* JSON+base64url format). See the layout below.
*
* ## Wire layout
*
* ```text
* byte 0: header
* bits 0-3 version (= 2)
* bit 4 has_c flag
* bit 5 has_w flag
* bits 6-7 reserved (must be 0)
* bytes 1..16: merchant UUID (16 bytes, mandatory)
* bytes 17..20: timestamp (uint32 big-endian, Unix seconds)
* bytes 21..36: client UUID (16 bytes, only when has_c is set)
* bytes 37..56: wallet address (20 bytes, only when has_w is set)
* ```
*
* Size variants (before base64url):
* - has_c only: 37 bytes
* - has_w only: 41 bytes
* - has_c + has_w: 57 bytes
*
* V1 payloads are exactly 20 bytes (raw wallet address); the byte lengths never
* overlap, so the outer decoder can disambiguate purely on length.
*
* @ignore
*/
import type { Address } from "viem";
import type { FrakContextV2 } from "../types";
import { addressToBytes, bytesToAddress, isAddress } from "./address";
const VERSION_V2 = 0x02;
const VERSION_MASK = 0x0f;
const FLAG_HAS_C = 1 << 4;
const FLAG_HAS_W = 1 << 5;
const RESERVED_MASK = 0xc0;
const UUID_BYTES = 16;
const TIMESTAMP_BYTES = 4;
const ADDRESS_BYTES = 20;
const HEADER_BYTES = 1;
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Strict lower-case UUID validation (RFC 4122 shape, any version/variant). */
function isUuid(value: unknown): value is string {
return typeof value === "string" && UUID_RE.test(value);
}
/** Parse a canonical UUID string into 16 raw bytes. */
function uuidToBytes(uuid: string): Uint8Array {
const hex = uuid.replace(/-/g, "");
const out = new Uint8Array(UUID_BYTES);
for (let i = 0; i < UUID_BYTES; i++) {
out[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
/** Format 16 raw bytes as a canonical 8-4-4-4-12 UUID string. */
function bytesToUuid(bytes: Uint8Array): string {
let hex = "";
for (let i = 0; i < UUID_BYTES; i++) {
hex += bytes[i].toString(16).padStart(2, "0");
}
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
/**
* Encode a {@link FrakContextV2} into its binary wire format.
*
* Returns `null` when the context fails runtime validation (missing fields,
* malformed UUIDs, timestamp outside uint32 range, invalid wallet).
*/
export function encodeFrakContextV2(ctx: FrakContextV2): Uint8Array | null {
if (!isUuid(ctx.m)) return null;
if (!Number.isInteger(ctx.t) || ctx.t < 0 || ctx.t > 0xff_ff_ff_ff)
return null;
const hasC = typeof ctx.c === "string" && ctx.c.length > 0;
const hasW = typeof ctx.w === "string" && isAddress(ctx.w);
if (!hasC && !hasW) return null;
if (hasC && !isUuid(ctx.c)) return null;
const size =
HEADER_BYTES +
UUID_BYTES +
TIMESTAMP_BYTES +
(hasC ? UUID_BYTES : 0) +
(hasW ? ADDRESS_BYTES : 0);
const buf = new Uint8Array(size);
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
let offset = 0;
buf[offset++] =
VERSION_V2 | (hasC ? FLAG_HAS_C : 0) | (hasW ? FLAG_HAS_W : 0);
buf.set(uuidToBytes(ctx.m), offset);
offset += UUID_BYTES;
view.setUint32(offset, ctx.t, false);
offset += TIMESTAMP_BYTES;
if (hasC) {
buf.set(uuidToBytes(ctx.c as string), offset);
offset += UUID_BYTES;
}
if (hasW) {
buf.set(addressToBytes(ctx.w as Address), offset);
offset += ADDRESS_BYTES;
}
return buf;
}
/**
* Decode a binary {@link FrakContextV2} payload.
*
* Returns `null` when:
* - the header version nibble is not V2
* - reserved header bits are set (guards against future-version payloads)
* - neither flag is set (invalid: V2 must carry `c` and/or `w`)
* - the byte length does not match the length implied by the header flags
* - the decoded wallet does not pass `isAddress` (defense-in-depth against
* crafted payloads that round-trip by length but carry junk)
*/
export function decodeFrakContextV2(buf: Uint8Array): FrakContextV2 | null {
if (buf.length < HEADER_BYTES + UUID_BYTES + TIMESTAMP_BYTES) return null;
const header = buf[0];
if ((header & VERSION_MASK) !== VERSION_V2) return null;
if ((header & RESERVED_MASK) !== 0) return null;
const hasC = (header & FLAG_HAS_C) !== 0;
const hasW = (header & FLAG_HAS_W) !== 0;
if (!hasC && !hasW) return null;
const expected =
HEADER_BYTES +
UUID_BYTES +
TIMESTAMP_BYTES +
(hasC ? UUID_BYTES : 0) +
(hasW ? ADDRESS_BYTES : 0);
if (buf.length !== expected) return null;
let offset = HEADER_BYTES;
const m = bytesToUuid(buf.subarray(offset, offset + UUID_BYTES));
offset += UUID_BYTES;
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const t = view.getUint32(offset, false);
offset += TIMESTAMP_BYTES;
const out: FrakContextV2 = { v: 2, m, t };
if (hasC) {
out.c = bytesToUuid(buf.subarray(offset, offset + UUID_BYTES));
offset += UUID_BYTES;
}
if (hasW) {
const walletHex = bytesToAddress(
buf.subarray(offset, offset + ADDRESS_BYTES)
);
if (!isAddress(walletHex)) return null;
out.w = walletHex;
offset += ADDRESS_BYTES;
}
return out;
}
/**
* Quick length-based probe to tell V1 (20-byte wallet address) apart from a V2
* binary payload. Exposed so the outer decoder can branch without re-parsing.
*/
export function isV2BinaryLength(byteLength: number): boolean {
return (
byteLength ===
HEADER_BYTES + UUID_BYTES + TIMESTAMP_BYTES + UUID_BYTES ||
byteLength ===
HEADER_BYTES + UUID_BYTES + TIMESTAMP_BYTES + ADDRESS_BYTES ||
byteLength ===
HEADER_BYTES +
UUID_BYTES +
TIMESTAMP_BYTES +
UUID_BYTES +
ADDRESS_BYTES
);
}