From 889dd566d1afd593ea759702ac338a10da1b8b59 Mon Sep 17 00:00:00 2001 From: Dagur Valberg Johannsson Date: Sun, 22 Mar 2026 22:51:20 +0100 Subject: [PATCH] Add serialization helpers to @wizardconnect/core Canonical encoding for BigInt () and Uint8Array (hex or ) used in the relay protocol. Provides both serialization (sourceOutputToRelay, transactionToHex) and deserialization (parseExtendedJson, toUint8Array, toBigInt) so dapps and wallets don't have to implement this independently. --- CLAUDE.md | 1 + docs/serialization.md | 132 ++++++++++++++++++ packages/core/package.json | 10 ++ packages/core/src/index.ts | 7 + .../protocols/hdwalletv1-serialize.test.ts | 114 +++++++++++++++ .../src/protocols/hdwalletv1-serialize.ts | 90 ++++++++++++ packages/core/src/serialize.test.ts | 101 ++++++++++++++ packages/core/src/serialize.ts | 92 ++++++++++++ 8 files changed, 547 insertions(+) create mode 100644 docs/serialization.md create mode 100644 packages/core/src/protocols/hdwalletv1-serialize.test.ts create mode 100644 packages/core/src/protocols/hdwalletv1-serialize.ts create mode 100644 packages/core/src/serialize.test.ts create mode 100644 packages/core/src/serialize.ts diff --git a/CLAUDE.md b/CLAUDE.md index 12af01b..ae72bdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ Protocol and architecture documentation lives in `docs/`. Keep it up to date whe | `docs/react.md` | React components, hooks, or QR dialog API changes | | `docs/pubkey-derivation.md` | xpub delivery, `DappPubkeyStateManager`, or gap-fill logic changes | | `docs/xpub-sharing.md` | xpub sharing rationale, security model, or comparison with other protocols changes | +| `docs/serialization.md` | Relay JSON encoding (`sourceOutputToRelay`, `toBigInt`, `toUint8Array`, etc.) changes | | `docs/index.md` | New top-level docs files are added | ## Packages diff --git a/docs/serialization.md b/docs/serialization.md new file mode 100644 index 0000000..64f0ded --- /dev/null +++ b/docs/serialization.md @@ -0,0 +1,132 @@ +# Relay serialization + +The WizardConnect relay transmits messages as JSON, which cannot represent `BigInt` or +`Uint8Array` natively. `@wizardconnect/core` provides canonical encoding helpers so dapps +and wallets always agree on the wire format. + +The helpers are split into two layers: + +- **Generic** (`serialize.ts`) — relay-level type coercion (`toUint8Array`, `toBigInt`, `parseExtendedJson`). Useful for any protocol. +- **hdwalletv1** (`protocols/hdwalletv1-serialize.ts`) — transaction-specific serialization (`sourceOutputToRelay`, `transactionToHex`). Tied to the sign-transaction flow. + +## Encoding conventions + +| Native type | Relay format | Example | +|---------------|-------------------------------------|---------------------------------| +| `Uint8Array` | hex string | `"76a914...88ac"` | +| `Uint8Array` | extended format (libauth stringify) | `""` | +| `BigInt` | extended format | `""` | + +Both extended formats are accepted by the deserialization helpers. The serialization helpers +produce hex strings for `Uint8Array` and `` for `BigInt`. + +## hdwalletv1 serialization (dapp → relay) + +### `sourceOutputToRelay(sourceOutput)` + +Converts a source output with native types to relay-safe JSON: + +```typescript +import { sourceOutputToRelay } from "@wizardconnect/core/hdwalletv1-serialize"; + +const relayOutput = sourceOutputToRelay({ + outpointTransactionHash: txidBytes, // Uint8Array → hex string + outpointIndex: 0, // number (unchanged) + unlockingBytecode: new Uint8Array(0), // Uint8Array → hex string + sequenceNumber: 0xffffffff, // number (unchanged) + valueSatoshis: 200000n, // BigInt → "" + lockingBytecode: scriptBytes, // Uint8Array → hex string + token: { // optional + category: categoryBytes, // Uint8Array → hex string + amount: 1000n, // BigInt → "" + }, +}); + +// relayOutput is JSON-serializable (no BigInt, no Uint8Array) +JSON.stringify(relayOutput); // works +``` + +### `transactionToHex(inputs, outputs, version?, locktime?)` + +Encodes a transaction to hex using libauth's `encodeTransaction`: + +```typescript +import { transactionToHex } from "@wizardconnect/core/hdwalletv1-serialize"; + +const txHex = transactionToHex(inputs, outputs); +// txHex is a hex string ready for the relay +``` + +Note: libauth's `encodeTransaction` reverses `outpointTransactionHash` to wire format +internally. Pass txids in **display order** (big-endian, as returned by electrum/explorers). + +## Deserialization (relay → wallet) + +### `toUint8Array(value)` + +Converts hex strings, extended JSON format, or `Uint8Array` to `Uint8Array`: + +```typescript +import { toUint8Array } from "@wizardconnect/core"; + +toUint8Array("76a914...88ac"); // hex string +toUint8Array(""); // extended format +toUint8Array(existingBytes); // pass-through +``` + +### `toBigInt(value)` + +Converts numeric strings, extended JSON format, numbers, or `bigint` to `bigint`: + +```typescript +import { toBigInt } from "@wizardconnect/core"; + +toBigInt(""); // extended format +toBigInt("200000"); // numeric string +toBigInt(200000); // number +toBigInt(200000n); // pass-through +``` + +### `parseExtendedJson(jsonString)` + +Parses a full JSON string, converting all extended-format values in one pass: + +```typescript +import { parseExtendedJson } from "@wizardconnect/core"; + +const obj = parseExtendedJson('{"value":"","data":""}'); +// obj.value === 200000n +// obj.data instanceof Uint8Array +``` + +### `isExtendedJsonFormat(str)` + +Returns `true` if a string contains `` or `` markers. + +## Usage in sign requests + +A typical dapp builds a sign request like this: + +```typescript +import { RelayMsgAction } from "@wizardconnect/core"; +import { sourceOutputToRelay, transactionToHex } from "@wizardconnect/core/hdwalletv1-serialize"; + +const txHex = transactionToHex(inputs, outputs); +const sourceOutputs = inputs.map((input, i) => + sourceOutputToRelay({ + ...input, + valueSatoshis: utxos[i].value, + lockingBytecode: utxos[i].script, + }) +); + +const signReq = { + action: RelayMsgAction.SignTransactionRequest, + time: Math.floor(Date.now() / 1000), + sequence: manager.nextSequence(), + transaction: { transaction: txHex, sourceOutputs, broadcast: false }, + inputPaths: [[0, "receive", 0]], +}; +``` + +The wallet deserializes using `toUint8Array` and `toBigInt` on the received fields. diff --git a/packages/core/package.json b/packages/core/package.json index 74eda44..b7591c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,6 +8,16 @@ "url": "https://gitlab.com/riftenlabs/lib/wizardconnect", "directory": "packages/core" }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./hdwalletv1-serialize": { + "types": "./dist/protocols/hdwalletv1-serialize.d.ts", + "default": "./dist/protocols/hdwalletv1-serialize.js" + } + }, "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0bab2ee..25c2584 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,3 +35,10 @@ export { binToBech32Padded, bech32PaddedToBin, } from "@bitauth/libauth"; +export { + parseExtendedJson, + parseExtendedJsonValue, + isExtendedJsonFormat, + toUint8Array, + toBigInt, +} from "./serialize.js"; diff --git a/packages/core/src/protocols/hdwalletv1-serialize.test.ts b/packages/core/src/protocols/hdwalletv1-serialize.test.ts new file mode 100644 index 0000000..77debf0 --- /dev/null +++ b/packages/core/src/protocols/hdwalletv1-serialize.test.ts @@ -0,0 +1,114 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { describe, it, expect } from "vitest"; +import { hexToBin } from "@bitauth/libauth"; +import { + sourceOutputToRelay, + transactionToHex, +} from "./hdwalletv1-serialize.js"; +import { toUint8Array, toBigInt } from "../serialize.js"; + +describe("hdwalletv1-serialize", () => { + describe("sourceOutputToRelay", () => { + it("converts native types to relay-safe JSON", () => { + const so = { + outpointTransactionHash: hexToBin("ab".repeat(32)), + outpointIndex: 0, + unlockingBytecode: new Uint8Array(0), + sequenceNumber: 0xffffffff, + valueSatoshis: 200000n, + lockingBytecode: hexToBin("76a914" + "00".repeat(20) + "88ac"), + }; + + const result = sourceOutputToRelay(so); + + expect(result.outpointTransactionHash).toBe("ab".repeat(32)); + expect(result.outpointIndex).toBe(0); + expect(result.unlockingBytecode).toBe(""); + expect(result.sequenceNumber).toBe(0xffffffff); + expect(result.valueSatoshis).toBe(""); + expect(result.lockingBytecode).toBe("76a914" + "00".repeat(20) + "88ac"); + }); + + it("is JSON-serializable", () => { + const so = { + outpointTransactionHash: new Uint8Array(32), + outpointIndex: 0, + unlockingBytecode: new Uint8Array(0), + sequenceNumber: 0xffffffff, + valueSatoshis: 100000000n, + lockingBytecode: new Uint8Array([0x76, 0xa9]), + }; + + const result = sourceOutputToRelay(so); + expect(() => JSON.stringify(result)).not.toThrow(); + }); + + it("includes token data when present", () => { + const so = { + outpointTransactionHash: new Uint8Array(32), + outpointIndex: 0, + unlockingBytecode: new Uint8Array(0), + sequenceNumber: 0xffffffff, + valueSatoshis: 546n, + lockingBytecode: new Uint8Array([0x76, 0xa9]), + token: { + category: hexToBin("cc".repeat(32)), + amount: 1000n, + }, + }; + + const result = sourceOutputToRelay(so); + expect(result.token).toBeDefined(); + expect(result.token.category).toBe("cc".repeat(32)); + expect(result.token.amount).toBe(""); + }); + + it("round-trips through toBigInt/toUint8Array", () => { + const so = { + outpointTransactionHash: hexToBin("ab".repeat(32)), + outpointIndex: 2, + unlockingBytecode: new Uint8Array(0), + sequenceNumber: 0xffffffff, + valueSatoshis: 200000n, + lockingBytecode: hexToBin("76a914" + "00".repeat(20) + "88ac"), + }; + + const relay = sourceOutputToRelay(so); + + // Simulate what the wallet does: parse the relay JSON + expect(toUint8Array(relay.outpointTransactionHash)).toEqual( + so.outpointTransactionHash, + ); + expect(toBigInt(relay.valueSatoshis)).toBe(so.valueSatoshis); + expect(toUint8Array(relay.lockingBytecode)).toEqual(so.lockingBytecode); + }); + }); + + describe("transactionToHex", () => { + it("encodes a simple transaction", () => { + const inputs = [ + { + outpointTransactionHash: new Uint8Array(32), + outpointIndex: 0, + unlockingBytecode: new Uint8Array(0), + sequenceNumber: 0xffffffff, + }, + ]; + const outputs = [ + { + valueSatoshis: 0n, + lockingBytecode: new Uint8Array([0x6a]), // OP_RETURN + }, + ]; + + const hex = transactionToHex(inputs, outputs); + expect(typeof hex).toBe("string"); + expect(hex.length).toBeGreaterThan(0); + // Should start with version 2 in little-endian + expect(hex.startsWith("02000000")).toBe(true); + }); + }); +}); diff --git a/packages/core/src/protocols/hdwalletv1-serialize.ts b/packages/core/src/protocols/hdwalletv1-serialize.ts new file mode 100644 index 0000000..7251d3f --- /dev/null +++ b/packages/core/src/protocols/hdwalletv1-serialize.ts @@ -0,0 +1,90 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * Serialization helpers specific to the hdwalletv1 sign-transaction flow. + * + * These convert between native libauth transaction types and the relay-safe + * JSON used in SignTransactionRequest / SignTransactionResponse messages. + */ + +import { binToHex, encodeTransaction } from "@bitauth/libauth"; + +// --------------------------------------------------------------------------- +// Interfaces (mirror libauth transaction shapes) +// --------------------------------------------------------------------------- + +export interface SourceOutput { + outpointTransactionHash: Uint8Array; + outpointIndex: number; + unlockingBytecode: Uint8Array; + sequenceNumber: number; + valueSatoshis: bigint; + lockingBytecode: Uint8Array; + token?: { + category: Uint8Array; + amount: bigint; + nft?: { capability?: string; commitment?: Uint8Array }; + }; +} + +interface TxInput { + outpointTransactionHash: Uint8Array; + outpointIndex: number; + unlockingBytecode: Uint8Array; + sequenceNumber: number; +} + +interface TxOutput { + valueSatoshis: bigint; + lockingBytecode: Uint8Array; +} + +// --------------------------------------------------------------------------- +// Serialization (native types → relay JSON) +// --------------------------------------------------------------------------- + +/** + * Convert a source output to relay-safe JSON format. + * Uint8Array fields become hex strings, BigInt becomes ``. + */ +export function sourceOutputToRelay(so: SourceOutput): any { + const result: any = { + outpointTransactionHash: binToHex(so.outpointTransactionHash), + outpointIndex: so.outpointIndex, + unlockingBytecode: binToHex(so.unlockingBytecode), + sequenceNumber: so.sequenceNumber, + valueSatoshis: ``, + lockingBytecode: binToHex(so.lockingBytecode), + }; + if (so.token) { + result.token = { + category: binToHex(so.token.category), + amount: ``, + ...(so.token.nft && { + nft: { + ...(so.token.nft.capability !== undefined && { + capability: so.token.nft.capability, + }), + ...(so.token.nft.commitment !== undefined && { + commitment: binToHex(so.token.nft.commitment), + }), + }, + }), + }; + } + return result; +} + +/** + * Encode a transaction to hex for relay transport. + */ +export function transactionToHex( + inputs: TxInput[], + outputs: TxOutput[], + version = 2, + locktime = 0, +): string { + return binToHex(encodeTransaction({ inputs, outputs, version, locktime })); +} diff --git a/packages/core/src/serialize.test.ts b/packages/core/src/serialize.test.ts new file mode 100644 index 0000000..974d08e --- /dev/null +++ b/packages/core/src/serialize.test.ts @@ -0,0 +1,101 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +import { describe, it, expect } from "vitest"; +import { binToHex } from "@bitauth/libauth"; +import { + parseExtendedJson, + parseExtendedJsonValue, + isExtendedJsonFormat, + toUint8Array, + toBigInt, +} from "./serialize.js"; + +describe("wc-serialize", () => { + describe("parseExtendedJsonValue", () => { + it("parses bigint format", () => { + expect(parseExtendedJsonValue("")).toBe(546n); + expect(parseExtendedJsonValue("")).toBe(0n); + expect(parseExtendedJsonValue("")).toBe(100000000n); + }); + + it("parses Uint8Array format", () => { + const result = parseExtendedJsonValue(""); + expect(result).toBeInstanceOf(Uint8Array); + expect(binToHex(result as Uint8Array)).toBe("abcd"); + }); + + it("returns original string for non-extended values", () => { + expect(parseExtendedJsonValue("hello")).toBe("hello"); + expect(parseExtendedJsonValue("123")).toBe("123"); + }); + }); + + describe("isExtendedJsonFormat", () => { + it("detects bigint format", () => { + expect(isExtendedJsonFormat("")).toBe(true); + }); + + it("detects Uint8Array format", () => { + expect(isExtendedJsonFormat("")).toBe(true); + }); + + it("rejects plain strings", () => { + expect(isExtendedJsonFormat("hello")).toBe(false); + expect(isExtendedJsonFormat("123")).toBe(false); + }); + }); + + describe("parseExtendedJson", () => { + it("parses JSON with mixed extended values", () => { + const json = JSON.stringify({ + value: "", + data: "", + name: "test", + count: 42, + }); + const result = parseExtendedJson(json); + expect(result.value).toBe(200000n); + expect(result.data).toBeInstanceOf(Uint8Array); + expect(binToHex(result.data)).toBe("76a9"); + expect(result.name).toBe("test"); + expect(result.count).toBe(42); + }); + }); + + describe("toUint8Array", () => { + it("passes through Uint8Array", () => { + const bytes = new Uint8Array([1, 2, 3]); + expect(toUint8Array(bytes)).toBe(bytes); + }); + + it("converts hex string", () => { + const result = toUint8Array("abcd"); + expect(binToHex(result)).toBe("abcd"); + }); + + it("converts extended JSON format", () => { + const result = toUint8Array(""); + expect(binToHex(result)).toBe("abcd"); + }); + }); + + describe("toBigInt", () => { + it("passes through bigint", () => { + expect(toBigInt(546n)).toBe(546n); + }); + + it("converts number", () => { + expect(toBigInt(546)).toBe(546n); + }); + + it("converts numeric string", () => { + expect(toBigInt("546")).toBe(546n); + }); + + it("converts extended JSON format", () => { + expect(toBigInt("")).toBe(546n); + }); + }); +}); diff --git a/packages/core/src/serialize.ts b/packages/core/src/serialize.ts new file mode 100644 index 0000000..d5932d5 --- /dev/null +++ b/packages/core/src/serialize.ts @@ -0,0 +1,92 @@ +// Copyright (C) 2026 Whiterun LLC, +// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html + +/** + * Serialization helpers for converting between native libauth types and the + * relay-safe JSON format used by the WizardConnect protocol. + * + * The relay transmits messages as JSON, which cannot represent BigInt or + * Uint8Array natively. This module provides the canonical encoding: + * + * - Uint8Array → hex string (or `` extended format) + * - BigInt → `` string + * + * Both dapps and wallets should use these helpers to ensure interoperability. + */ + +import { hexToBin } from "@bitauth/libauth"; + +// --------------------------------------------------------------------------- +// Deserialization (relay JSON → native types) +// --------------------------------------------------------------------------- + +const BIGINT_RE = /^[0-9]*)n>$/; +const UINT8_RE = /^[0-9a-f]*)>$/u; + +/** + * Parse a full JSON string that may contain extended-format values. + * Handles both `` and `` formats. + */ +export function parseExtendedJson(jsonString: string): any { + return JSON.parse(jsonString, (_key, value) => { + if (typeof value === "string") { + const bigintMatch = value.match(BIGINT_RE); + if (bigintMatch) return BigInt(bigintMatch[1]); + const uint8Match = value.match(UINT8_RE); + if (uint8Match) return hexToBin(uint8Match[1]); + } + return value; + }); +} + +/** + * Check if a string contains extended JSON markers. + */ +export function isExtendedJsonFormat(str: string): boolean { + return UINT8_RE.test(str) || BIGINT_RE.test(str); +} + +/** + * Parse a single extended JSON value string to its native type. + * Returns the original string if it doesn't match any known format. + */ +export function parseExtendedJsonValue( + value: string, +): Uint8Array | bigint | string { + const bigintMatch = value.match(BIGINT_RE); + if (bigintMatch) return BigInt(bigintMatch[1]); + const uint8Match = value.match(UINT8_RE); + if (uint8Match) return hexToBin(uint8Match[1]); + return value; +} + +/** + * Convert a value to Uint8Array. Accepts: + * - Uint8Array (returned as-is) + * - hex string + * - extended JSON format string (``) + */ +export function toUint8Array(value: string | Uint8Array): Uint8Array { + if (value instanceof Uint8Array) return value; + if (isExtendedJsonFormat(value)) + return parseExtendedJsonValue(value) as Uint8Array; + return hexToBin(value); +} + +/** + * Convert a value to bigint. Accepts: + * - bigint (returned as-is) + * - number + * - numeric string + * - extended JSON format string (``) + */ +export function toBigInt(value: string | number | bigint): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "string") { + if (isExtendedJsonFormat(value)) + return parseExtendedJsonValue(value) as bigint; + return BigInt(value); + } + return BigInt(value); +}