Add serialization helpers to @wizardconnect/core

Canonical encoding for BigInt (<bigint: Xn>) and Uint8Array (hex or
<Uint8Array: 0x...>) 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.
This commit is contained in:
Dagur Valberg Johannsson 2026-03-22 22:51:20 +01:00
parent a22d98a6b2
commit 889dd566d1
No known key found for this signature in database
GPG key ID: FD701804AEE88107
8 changed files with 547 additions and 0 deletions

View file

@ -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

132
docs/serialization.md Normal file
View file

@ -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) | `"<Uint8Array: 0x76a914...>"` |
| `BigInt` | extended format | `"<bigint: 200000n>"` |
Both extended formats are accepted by the deserialization helpers. The serialization helpers
produce hex strings for `Uint8Array` and `<bigint: Xn>` 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 → "<bigint: 200000n>"
lockingBytecode: scriptBytes, // Uint8Array → hex string
token: { // optional
category: categoryBytes, // Uint8Array → hex string
amount: 1000n, // BigInt → "<bigint: 1000n>"
},
});
// 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("<Uint8Array: 0x76a914...88ac>"); // 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("<bigint: 200000n>"); // 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":"<bigint: 200000n>","data":"<Uint8Array: 0xab>"}');
// obj.value === 200000n
// obj.data instanceof Uint8Array
```
### `isExtendedJsonFormat(str)`
Returns `true` if a string contains `<bigint: ...>` or `<Uint8Array: ...>` 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.

View file

@ -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": [

View file

@ -35,3 +35,10 @@ export {
binToBech32Padded,
bech32PaddedToBin,
} from "@bitauth/libauth";
export {
parseExtendedJson,
parseExtendedJsonValue,
isExtendedJsonFormat,
toUint8Array,
toBigInt,
} from "./serialize.js";

View file

@ -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("<bigint: 200000n>");
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("<bigint: 1000n>");
});
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);
});
});
});

View file

@ -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 `<bigint: Xn>`.
*/
export function sourceOutputToRelay(so: SourceOutput): any {
const result: any = {
outpointTransactionHash: binToHex(so.outpointTransactionHash),
outpointIndex: so.outpointIndex,
unlockingBytecode: binToHex(so.unlockingBytecode),
sequenceNumber: so.sequenceNumber,
valueSatoshis: `<bigint: ${so.valueSatoshis}n>`,
lockingBytecode: binToHex(so.lockingBytecode),
};
if (so.token) {
result.token = {
category: binToHex(so.token.category),
amount: `<bigint: ${so.token.amount}n>`,
...(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 }));
}

View file

@ -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("<bigint: 546n>")).toBe(546n);
expect(parseExtendedJsonValue("<bigint: 0n>")).toBe(0n);
expect(parseExtendedJsonValue("<bigint: 100000000n>")).toBe(100000000n);
});
it("parses Uint8Array format", () => {
const result = parseExtendedJsonValue("<Uint8Array: 0xabcd>");
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("<bigint: 100n>")).toBe(true);
});
it("detects Uint8Array format", () => {
expect(isExtendedJsonFormat("<Uint8Array: 0xabcd>")).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: "<bigint: 200000n>",
data: "<Uint8Array: 0x76a9>",
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("<Uint8Array: 0xabcd>");
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("<bigint: 546n>")).toBe(546n);
});
});
});

View file

@ -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 `<Uint8Array: 0x...>` extended format)
* - BigInt `<bigint: Xn>` 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 = /^<bigint: (?<bigint>[0-9]*)n>$/;
const UINT8_RE = /^<Uint8Array: 0x(?<hex>[0-9a-f]*)>$/u;
/**
* Parse a full JSON string that may contain extended-format values.
* Handles both `<Uint8Array: 0x...>` and `<bigint: ...n>` 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 (`<Uint8Array: 0x...>`)
*/
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 (`<bigint: Xn>`)
*/
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);
}