Merge branch 'feat/parse_invitation' into 'development'

Feat/parse invitation

See merge request GeneralProtocols/xo/utils!22
This commit is contained in:
Kuldeep
2026-10-05 12:59:22 +00:00
19 changed files with 1008 additions and 386 deletions
+3 -1
View File
@@ -17,7 +17,9 @@
"utxotokenamount",
"outputtokenamount",
"outputvalue",
"numequal"
"numequal",
"Prefault",
"cashtoken"
],
"ignorePaths": ["source/template/xo-template.schema.json"]
}
+327 -324
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@xo-cash/utils",
"version": "0.0.9",
"version": "0.0.10",
"description": "XO Cash utilities",
"type": "module",
"types": "./dist/index.d.mts",
@@ -48,7 +48,7 @@
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
"@xo-cash/primitives": "0.0.2",
"@xo-cash/types": "0.0.7",
"@xo-cash/types": "0.0.8",
"zod": "^4.3.6"
},
"overrides": {
+1 -1
View File
@@ -200,7 +200,7 @@ const collectFromParsedScript = (parameters: CollectFromParsedScriptParameters):
* first seen.
* A visited id is skipped on later encounters. Opcodes and literals are ignored. Unrelated scripts
* in the map are not visited. An Identifier inside a nested Push is collected from that inner Push,
* for example `<$(<ownerKey.public_key> OP_HASH160)>` collects `ownerKey.public_key`.
* for example `<$(<someKey.public_key> OP_HASH160)>` collects `someKey.public_key`.
*
* @param {string} evaluation - CashAssembly evaluation or script id text to scan.
* @param {CashAssemblyTemplateScripts} [templateScripts] - Optional template scripts map used to visit nested script ids.
+1 -1
View File
@@ -237,7 +237,7 @@ export const compileCashAssemblyEvaluations = (parameters: CompileCashAssemblyEv
continue;
}
// Libauth WalletData is the name before the first `.`. `ownerKey.public_key` registers as `ownerKey`.
// Libauth WalletData is the name before the first `.`. `someKey.public_key` registers as `someKey`.
variables[resolveIdentifierBaseName(variableName)] = { type: 'WalletData' as const };
}
+32
View File
@@ -1,3 +1,35 @@
import type { $ZodIssue } from 'zod/v4/core';
/**
* Formats the Zod validation failures into a single string with one line each: "- <field>: <message>" and top level failures
* with no field path show as "(root)" for better readability.
*
* @param issues The Zod validation failures to format.
* @returns A human readable error string for better debugging.
*/
export const buildErrorDescription = (issues: $ZodIssue[]): string => {
// Initialize an empty array to store the formatted lines.
const lines: string[] = [];
// Iterate over the issues and format them into a string.
for (const issue of issues) {
// Get the issue path.
const issuePath = issue.path.length > 0 ? issue.path.join('.') : '(root)';
// The prefix that Zod adds to messages.
const messagePrefix = 'Invalid input: ';
// Remove the prefix for better readability.
const issueMessage = issue.message.startsWith(messagePrefix) ? issue.message.slice(messagePrefix.length) : issue.message;
// Add the formatted line to the array.
lines.push(`- ${issuePath}: ${issueMessage}`);
}
// Return the formatted string.
return `\n${lines.join('\n')}`;
};
/**
* Error thrown when a waitFor timeout is reached
*/
+6 -6
View File
@@ -49,10 +49,10 @@ export class EventEmitter<T extends EventMap> {
* @returns An off callback that can be called to stop listening for events.
*/
on<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const { cancel, listener: cancellableListener } = this.cancellable(listener);
const { cancel, listener: cancellableListener } = this.#cancellable(listener);
// Create a wrapped listener so that the debounce can be applied.
const wrappedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
const wrappedListener = debounceMilliseconds > 0 ? this.#debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
// If the listeners map does not have the event type, create a new set.
if (!this.#listeners.has(type)) {
@@ -87,10 +87,10 @@ export class EventEmitter<T extends EventMap> {
};
// Create a cancellable listener.
const { cancel, listener: cancellableListener } = this.cancellable(wrappedListener);
const { cancel, listener: cancellableListener } = this.#cancellable(wrappedListener);
// Create a debounced listener.
const debouncedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
const debouncedListener = debounceMilliseconds > 0 ? this.#debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
// If the listeners map does not have the event type, create a new set.
if (!this.#listeners.has(type)) {
@@ -243,7 +243,7 @@ export class EventEmitter<T extends EventMap> {
* @param wait - The wait time in milliseconds.
* @returns The debounced function.
*/
private debounce<K extends keyof T>(func: Listener<T[K]>, wait: number): Listener<T[K]> {
#debounce<K extends keyof T>(func: Listener<T[K]>, wait: number): Listener<T[K]> {
// Create a timeout variable.
let timeout: ReturnType<typeof setTimeout>;
@@ -264,7 +264,7 @@ export class EventEmitter<T extends EventMap> {
* @param func - The function to make cancelable.
* @returns The cancellable function with a cancel method.
*/
private cancellable<K extends keyof T>(func: Listener<T[K]>): { cancel: () => void; listener: Listener<T[K]> } {
#cancellable<K extends keyof T>(func: Listener<T[K]>): { cancel: () => void; listener: Listener<T[K]> } {
let cancelled = false;
return {
+2 -7
View File
@@ -2,14 +2,9 @@ export * from './errors.ts';
export * from './event-emitter.ts';
export * from './exponential-backoff/index.ts';
export * from './extended-json.ts';
export * from './invitation/index.ts';
export * from './misc.ts';
export * from './script.ts';
export * from './sse-session/index.ts';
export * from './template/errors.ts';
export * from './template/identifier.ts';
export * from './template/parser.ts';
export * from './template/schemas.ts';
export * from './template/index.ts';
export * from './cash-assembly/index.ts';
// Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead.
export { serializeTemplate } from './template/serialization.ts';
+46
View File
@@ -0,0 +1,46 @@
/* eslint-disable max-classes-per-file */
/**
* Thrown when a value does not satisfy the XOInvitation schema.
*/
export class InvitationInvalidError extends Error {
constructor(details: string) {
const defaultMessage = `Invitation invalid, expected a value matching the XOInvitation type, but got: ${details}`;
super(defaultMessage);
this.name = 'InvitationInvalidError';
}
}
/**
* Thrown when invitation share JSON cannot be parsed.
*/
export class InvitationJsonMalformedError extends Error {
/**
* Parser failure forwarded from JSON parsing.
*/
readonly reason: string;
constructor(reason: string) {
const defaultMessage = `Invitation JSON malformed: expected a valid JSON string, but got: ${reason}`;
super(defaultMessage);
this.name = 'InvitationJsonMalformedError';
this.reason = reason;
}
}
/**
* Thrown when invitation serialization fails.
*/
export class InvitationSerializationFailedError extends Error {
/**
* Serializer failure forwarded from the serialization path.
*/
readonly reason: string;
constructor(reason: string) {
const defaultMessage = `Invitation serialization failed: ${reason}`;
super(defaultMessage);
this.name = 'InvitationSerializationFailedError';
this.reason = reason;
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './errors.ts';
export * from './parser.ts';
export * from './schemas.ts';
export * from './serialization.ts';
+27
View File
@@ -0,0 +1,27 @@
import type { XOInvitation } from '@xo-cash/types';
import { buildErrorDescription } from '../errors.ts';
import { InvitationInvalidError } from './errors.ts';
import { xoInvitationSchema } from './schemas.ts';
/**
* Validates an untyped value against the XOInvitation schema and returns it as a typed invitation.
*
* The schema parser returns a rebuilt copy whose keys follow the schema order. Commit signatures cover the JSON text of
* each commit's data, and that text depends on key order, so a rebuilt copy no longer matches its signatures. Only the
* value that was received keeps the order it was signed with.
*
* The received value itself is returned, not a copy, so the key order its commit signatures were made over is kept.
*
* @param {unknown} invitationValue - Value to validate, such as the output of Extended JSON parsing.
* @returns {XOInvitation} - The validated invitation, as the same object that was passed in.
* @throws {@link InvitationInvalidError} - When any field is missing, has the wrong type, or is not part of the invitation type.
*/
export const parseInvitation = (invitationValue: unknown): XOInvitation => {
const parseResult = xoInvitationSchema.safeParse(invitationValue);
if (parseResult.success === false) {
throw new InvitationInvalidError(buildErrorDescription(parseResult.error.issues));
}
return invitationValue as XOInvitation;
};
+241
View File
@@ -0,0 +1,241 @@
/* eslint-disable @stylistic/newline-per-chained-call */
import type { XOInvitation } from '@xo-cash/types';
import { z } from 'zod';
import { satoshisSchema, uint8ArraySchema, xoTemplateNftCapabilitySchema } from '../template/schemas.ts';
/**
* Validation schema for a reference to an input or output in an earlier commit that a later item extends.
*
* ```
* {
* "inputs|outputs": [
* {
* "mergesWith": { "commitIdentifier": "...", "itemIndex": 0 } ← this schema
* }
* ]
* }
* ```
*/
export const xoInvitationMergesWithReferenceSchema = z
.object({
commitIdentifier: z.string().describe('The commit that holds the item being extended.'),
itemIndex: z.number().int().nonnegative().describe('The position of the extended item inside that commit.'),
})
.strict();
/**
* Validation schema for the template action a commit is resolved against when it differs from the invitation root.
*/
export const xoInvitationActionReferenceSchema = z
.object({
templateIdentifier: z.string().describe('The template that defines the action.'),
actionIdentifier: z.string().describe('The action inside that template.'),
})
.strict();
// ============================================================
// Commit data
// ============================================================
/**
* Validation schema for a variable value. Matches every value type an invitation variable can hold.
*/
export const xoInvitationVariableValueSchema = z
.union([ z.bigint(), z.boolean(), z.number(), z.string() ])
.describe('A variable value expressed as a bigint, boolean, number, or string.');
/**
* Validation schema for a variable supplied by a commit.
*/
export const xoInvitationVariableSchema = z
.object({
variableIdentifier: z.string().describe('The template variable this value is for.'),
value: xoInvitationVariableValueSchema,
roleIdentifier: z.string().exactOptional().describe('The role this variable belongs to.'),
mergesWith: xoInvitationMergesWithReferenceSchema.exactOptional(),
})
.strict();
/**
* Validation schema for a cashtoken.
*/
export const outputTokenSchema = z
.object({
category: uint8ArraySchema.describe('The token category.'),
amount: z.bigint().describe('The fungible token amount.'),
nft: z
.object({
capability: xoTemplateNftCapabilitySchema,
commitment: uint8ArraySchema.describe('The NFT commitment.'),
})
.strict()
.exactOptional(),
})
.strict();
/**
* Validation schema for a cashtoken supplied by a commit output. Every field is optional because a later commit may
* supply the fields an earlier commit left unset.
*/
export const xoInvitationTokenSchema = z
.object({
amount: z.bigint().exactOptional().describe('The fungible token amount.'),
category: uint8ArraySchema.exactOptional().describe('The token category.'),
nft: z
.object({
capability: xoTemplateNftCapabilitySchema.exactOptional(),
commitment: uint8ArraySchema.exactOptional().describe('The NFT commitment.'),
})
.strict()
.exactOptional(),
})
.strict();
/**
* Validation schema for a value one participant resolved from its own template key, such as a public key or a signature.
*/
export const xoInvitationResolutionSchema = z
.object({
resolutionIdentifier: z.string().describe('The key operation this value resolves, such as someKey.public_key.'),
value: uint8ArraySchema.describe('The resolved bytes.'),
roleIdentifier: z.string().exactOptional().describe('The role whose key this value was resolved from.'),
})
.strict();
/**
* Validation schema for a transaction input supplied by a commit.
*/
export const xoInvitationInputSchema = z
.object({
outpointIndex: z.number().int().nonnegative().exactOptional().describe('The index of the spent output in its transaction.'),
outpointTransactionHash: uint8ArraySchema.exactOptional().describe('The hash of the transaction that holds the spent output.'),
sequenceNumber: z.number().int().nonnegative().exactOptional().describe('The sequence number of this input.'),
unlockingBytecode: uint8ArraySchema.exactOptional().describe('The script that unlocks the spent output.'),
inputIdentifier: z.string().exactOptional().describe('The template input this input fills.'),
transactionIndex: z.number().int().nonnegative().exactOptional().describe('The position this input must take in the final transaction.'),
roleIdentifier: z.string().exactOptional().describe('The role this input belongs to.'),
resolutions: z.array(xoInvitationResolutionSchema).exactOptional(),
mergesWith: xoInvitationMergesWithReferenceSchema.exactOptional(),
valueSatoshis: satoshisSchema.exactOptional(),
token: outputTokenSchema.exactOptional(),
})
.strict();
/**
* Validation schema for a transaction output supplied by a commit.
*/
export const xoInvitationOutputSchema = z
.object({
lockingBytecode: uint8ArraySchema.exactOptional().describe('The locking bytecode of this output.'),
valueSatoshis: satoshisSchema.exactOptional(),
token: xoInvitationTokenSchema.exactOptional(),
outputIdentifier: z.string().exactOptional().describe('The template output this output fills.'),
transactionIndex: z.number().int().nonnegative().exactOptional().describe('The position this output must take in the final transaction.'),
secretIdentifier: z.string().exactOptional().describe('The secret used to derive this output.'),
roleIdentifier: z.string().exactOptional().describe('The role this output belongs to.'),
mergesWith: xoInvitationMergesWithReferenceSchema.exactOptional(),
})
.strict();
/**
* Validation schema for the data a commit supplies.
*/
export const xoInvitationCommitDataSchema = z
.object({
transactionVersion: z.number().int().nonnegative().exactOptional().describe('The transaction version.'),
transactionLocktime: z.number().int().nonnegative().exactOptional().describe('The transaction locktime.'),
variables: z.array(xoInvitationVariableSchema).exactOptional(),
inputs: z.array(xoInvitationInputSchema).exactOptional(),
outputs: z.array(xoInvitationOutputSchema).exactOptional(),
resolutions: z.array(xoInvitationResolutionSchema).exactOptional(),
roleIdentifier: z.string().exactOptional().describe('The role the committing entity takes in the action with this commit.'),
})
.strict();
/**
* Validation schema for one signed commit in an invitation.
*/
export const xoInvitationCommitSchema = z
.object({
commitIdentifier: z.string().describe('The identifier of this commit.'),
// The type declares this key on every commit, but serialization drops it on the first commit where it is undefined.
// Prefault restores the missing key, in this case it's undefined.
previousCommitIdentifier: z.string().optional().prefault(undefined).describe('The commit this one follows. Undefined on the first commit.'),
entityIdentifier: z.string().describe('The entity that made this commit.'),
createdAtTimestamp: z.number().int().nonnegative().describe('When this commit was created, as a Unix timestamp in milliseconds.'),
actionReference: xoInvitationActionReferenceSchema.exactOptional(),
data: xoInvitationCommitDataSchema,
signature: z.string().describe('The signature over this commit.'),
expiresAtTimestamp: z.number().int().nonnegative().describe('When this commit expires, as a Unix timestamp in milliseconds.'),
memo: z.string().exactOptional().describe('A free text note attached to this commit.'),
})
.strict();
// ============================================================
// Results
// ============================================================
/**
* Validation schema for a transaction result that has been created but not broadcast.
*/
export const xoInvitationTransactionCreatedResultSchema = z
.object({
transactionHash: z.string().describe('The hash of the created transaction.'),
createdAtTimestamp: z.number().int().nonnegative().describe('When the transaction was created, as a Unix timestamp in milliseconds.'),
createdByEntityIdentifier: z.string().describe('The entity that created the transaction.'),
createdBySignature: z.string().describe('The signature over the created result.'),
})
.strict();
/**
* Validation schema for a transaction result that has been created and broadcast.
*/
export const xoInvitationTransactionBroadcastedResultSchema = xoInvitationTransactionCreatedResultSchema
.extend({
broadcastedAtTimestamp: z.number().int().nonnegative().describe('When the transaction was broadcasted, as a Unix timestamp in milliseconds.'),
broadcastedByEntityIdentifier: z.string().describe('The entity that broadcasted the transaction.'),
broadcastedBySignature: z.string().describe('The signature over the broadcasted result.'),
})
.strict();
/**
* Validation schema for a data result created on an invitation.
*/
export const xoInvitationCreatedResultSchema = z
.object({
value: xoInvitationVariableValueSchema,
createdAtTimestamp: z.number().int().nonnegative().describe('When the value was created, as a Unix timestamp in milliseconds.'),
createdByEntityIdentifier: z.string().describe('The entity that created the value.'),
createdBySignature: z.string().describe('The signature over the created value.'),
})
.strict();
/**
* Validation schema for the results created on an invitation.
*/
export const xoInvitationResultSchema = z
.object({
transactions: z
.record(z.string(), z.union([ xoInvitationTransactionBroadcastedResultSchema, xoInvitationTransactionCreatedResultSchema ]))
.exactOptional(),
data: z.record(z.string(), xoInvitationCreatedResultSchema).exactOptional(),
})
.strict();
// ============================================================
// Invitation
// ============================================================
/**
* Validation schema for an invitation.
*/
export const xoInvitationSchema: z.ZodType<XOInvitation> = z
.object({
invitationIdentifier: z.string().describe('The identifier of this invitation.'),
createdAtTimestamp: z.number().int().nonnegative().describe('When this invitation was created, as a Unix timestamp in milliseconds.'),
templateIdentifier: z.string().describe('The template this invitation uses.'),
actionIdentifier: z.string().describe('The template action this invitation performs.'),
commits: z.array(xoInvitationCommitSchema),
result: xoInvitationResultSchema.exactOptional(),
})
.strict();
+46
View File
@@ -0,0 +1,46 @@
import type { XOInvitation } from '@xo-cash/types';
import { fromExtendedJson, toExtendedJson } from '../extended-json.ts';
import { InvitationJsonMalformedError, InvitationSerializationFailedError } from './errors.ts';
import { parseInvitation } from './parser.ts';
/**
* Serializes an invitation for sharing. Encodes bigint and Uint8Array fields using Extended JSON.
*
* @param invitation Shared invitation to export.
* @returns JSON string that {@link deserializeInvitation} can restore.
* @throws {InvitationSerializationFailedError} When JSON serialization fails.
*/
export const serializeInvitation = (invitation: XOInvitation): string => {
try {
return toExtendedJson(invitation);
} catch (serializationError) {
const reason = serializationError instanceof Error ? serializationError.message : 'unknown error while serializing invitation';
throw new InvitationSerializationFailedError(reason);
}
};
/**
* Deserializes a shared invitation JSON string into an invitation.
*
* Every field is checked against its declared type, so the returned invitation can be trusted to match
* {@link XOInvitation}.
*
* @param serializedInvitation JSON produced by {@link serializeInvitation}.
* @returns Parsed invitation whose fields match their declared types.
* @throws {InvitationJsonMalformedError} When the string is not valid JSON.
* @throws {InvitationInvalidError} When the parsed value does not match the XOInvitation type.
*/
export const deserializeInvitation = (serializedInvitation: string): XOInvitation => {
let parsedValue: unknown;
try {
parsedValue = fromExtendedJson(serializedInvitation);
} catch (parsingError) {
const reason = parsingError instanceof Error ? parsingError.message : 'unknown error while deserializing invitation';
throw new InvitationJsonMalformedError(reason);
}
return parseInvitation(parsedValue);
};
+10 -10
View File
@@ -79,7 +79,7 @@ export class SSEEventParser {
* @returns Zero or more complete parsed SSE events from this chunk.
*/
public parseEvents(chunk: Uint8Array): SSEvent[] {
const lines = this.getBufferedLines(chunk);
const lines = this.#getBufferedLines(chunk);
const eventLines = lines.slice(0, -1);
@@ -91,7 +91,7 @@ export class SSEEventParser {
// A blank line indicates the end of an event. If we have received data, we can complete the event
if (line === '') {
if (event.data !== undefined) {
events.push(this.completeEvent(event));
events.push(this.#completeEvent(event));
event = {};
processedLineCount = index + 1;
}
@@ -99,10 +99,10 @@ export class SSEEventParser {
continue;
}
this.parseLine(line, event);
this.#parseLine(line, event);
}
this.storeRemainingLines(lines, processedLineCount);
this.#storeRemainingLines(lines, processedLineCount);
return events;
}
@@ -114,7 +114,7 @@ export class SSEEventParser {
* Accepts `\r\n`, `\r`, and `\n` line endings so events parse correctly
* regardless of server or platform conventions.
*/
private getBufferedLines(chunk: Uint8Array): string[] {
#getBufferedLines(chunk: Uint8Array): string[] {
this.#messageBuffer += this.#textDecoder.decode(chunk, { stream: true });
return this.#messageBuffer.split(SSE_LINE_ENDINGS);
@@ -126,7 +126,7 @@ export class SSEEventParser {
* Lines without a colon are ignored. A single optional space after the colon
* is stripped from the field value, per the SSE spec.
*/
private parseLine(line: string, event: Partial<SSEvent>): void {
#parseLine(line: string, event: Partial<SSEvent>): void {
const colonIndex = line.indexOf(':');
if (colonIndex === -1) return;
@@ -150,7 +150,7 @@ export class SSEEventParser {
return;
case 'retry':
this.parseRetry(value, event);
this.#parseRetry(value, event);
return;
}
@@ -161,7 +161,7 @@ export class SSEEventParser {
*
* Non-numeric values are ignored rather than failing the parse.
*/
private parseRetry(value: string, event: Partial<SSEvent>): void {
#parseRetry(value: string, event: Partial<SSEvent>): void {
const retry = parseInt(value, 10);
if (!isNaN(retry)) {
@@ -175,7 +175,7 @@ export class SSEEventParser {
* Trims a trailing newline from multi-line `data` values so callers receive
* the payload without an extra line break at the end.
*/
private completeEvent(event: Partial<SSEvent>): SSEvent {
#completeEvent(event: Partial<SSEvent>): SSEvent {
return {
...event,
data: event.data?.replace(SSE_TRAILING_NEWLINE_REGEX, ''),
@@ -188,7 +188,7 @@ export class SSEEventParser {
* Only lines that were fully processed (through a completed event boundary)
* are discarded; the remainder is re-encoded into {@link messageBuffer}.
*/
private storeRemainingLines(lines: string[], processedLineCount: number): void {
#storeRemainingLines(lines: string[], processedLineCount: number): void {
this.#messageBuffer = lines.slice(processedLineCount).join(NEW_LINE);
}
}
-32
View File
@@ -1,37 +1,5 @@
/* eslint-disable max-classes-per-file */
import type { $ZodIssue } from 'zod/v4/core';
/**
* Formats the Zod validation failures into a single string with one line each: "- <field>: <message>" and top level failures
* with no field path show as "(root)" for better readability.
*
* @param issues The Zod validation failures to format.
* @returns A human readable error string for better debugging.
*/
export const buildErrorDescription = (issues: $ZodIssue[]): string => {
// Initialize an empty array to store the formatted lines.
const lines: string[] = [];
// Iterate over the issues and format them into a string.
for (const issue of issues) {
// Get the issue path.
const issuePath = issue.path.length > 0 ? issue.path.join('.') : '(root)';
// The prefix that Zod adds to messages.
const messagePrefix = 'Invalid input: ';
// Remove the prefix for better readability.
const issueMessage = issue.message.startsWith(messagePrefix) ? issue.message.slice(messagePrefix.length) : issue.message;
// Add the formatted line to the array.
lines.push(`- ${issuePath}: ${issueMessage}`);
}
// Return the formatted string.
return `\n${lines.join('\n')}`;
};
/**
* Thrown when the provided template does not satisfy the XOTemplate schema.
*/
+7
View File
@@ -0,0 +1,7 @@
export * from './errors.ts';
export * from './identifier.ts';
export * from './parser.ts';
export * from './schemas.ts';
// Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead.
export { serializeTemplate } from './serialization.ts';
+2 -1
View File
@@ -1,6 +1,7 @@
import type { XOTemplate } from '@xo-cash/types';
import { xoTemplateSchema } from './schemas.ts';
import { TemplateInvalidError, buildErrorDescription } from './errors.ts';
import { buildErrorDescription } from '../errors.ts';
import { TemplateInvalidError } from './errors.ts';
import { deserializeTemplate, serializeTemplate } from './serialization.ts';
/**
+3 -1
View File
@@ -863,7 +863,9 @@ export const xoTemplateIconSchema = xoTemplateViewPropertiesSchema
export const xoTemplateDefaultsSchema = z
.object({
change: xoTemplateOutputIntentSchema.optional().describe('Instructions for how to construct automated change output.'),
address: xoTemplateLockingScriptIntentSchema.optional().describe('Configure the default intent structure for creating receiving addresses / locking scripts.'),
address: xoTemplateLockingScriptIntentSchema
.optional()
.describe('Configure the default intent structure for creating receiving addresses / locking scripts.'),
receive: xoTemplateOutputIntentSchema.optional().describe('Configure the default intent structure for creating receive outputs.'),
})
.strict();
+248
View File
@@ -0,0 +1,248 @@
import { expect, test } from 'vitest';
import { binToHex, generateRandomBytes, hexToBin } from '@bitauth/libauth';
import {
COMMIT_IDENTIFIER_BYTE_LENGTH,
INVITATION_IDENTIFIER_BYTE_LENGTH,
TEMPLATE_IDENTIFIER_BYTE_LENGTH,
TOKEN_CATEGORY_BYTE_LENGTH,
TRANSACTION_HASH_BYTE_LENGTH,
} from '@xo-cash/primitives';
import type { XOInvitation } from '@xo-cash/types';
import {
InvitationInvalidError,
InvitationJsonMalformedError,
InvitationSerializationFailedError,
deserializeInvitation,
parseInvitation,
serializeInvitation,
} from '../source/index.ts';
const invitationIdentifier = binToHex(generateRandomBytes(INVITATION_IDENTIFIER_BYTE_LENGTH));
const templateIdentifier = binToHex(generateRandomBytes(TEMPLATE_IDENTIFIER_BYTE_LENGTH));
const commitIdentifier = binToHex(generateRandomBytes(COMMIT_IDENTIFIER_BYTE_LENGTH));
const outpointTransactionHash = binToHex(generateRandomBytes(TRANSACTION_HASH_BYTE_LENGTH));
const tokenCategory = binToHex(generateRandomBytes(TOKEN_CATEGORY_BYTE_LENGTH));
// Valid invitation
const buildValidInvitation = (): XOInvitation => {
return {
invitationIdentifier,
createdAtTimestamp: 1700000000000,
templateIdentifier,
actionIdentifier: 'receive',
commits: [
{
commitIdentifier,
previousCommitIdentifier: undefined,
entityIdentifier: 'xpub661MyMwAqRbcEngHLkoUWCKMBMGeqdqCGkqtzrHaTZub9ALw2oRfHA6fQP5n5X9VHStaNTBYomkSb8BFhUGavwD3RG1qvMkEKceTavTp2Tm',
createdAtTimestamp: 1700000000000,
data: {
transactionVersion: 2,
transactionLocktime: 0,
variables: [{ variableIdentifier: 'amount', value: 18014398509481985n, roleIdentifier: 'sender' }],
inputs: [
{
outpointTransactionHash: hexToBin(outpointTransactionHash),
outpointIndex: 0,
sequenceNumber: 0,
inputIdentifier: 'fundingInput',
roleIdentifier: 'sender',
},
],
outputs: [
{
outputIdentifier: 'receiveOutput',
valueSatoshis: 1000n,
lockingBytecode: hexToBin('76a914000000000000000000000000000000000000000088ac'),
token: { category: hexToBin(tokenCategory), amount: 21n },
roleIdentifier: 'receiver',
},
],
},
signature:
'5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b',
expiresAtTimestamp: 1700003600000,
},
],
};
};
/**
* Tests that parseInvitation accepts a valid invitation and returns the same object it received.
*/
const testParseInvitationReturnsReceivedObject = (): void => {
const invitation = buildValidInvitation();
expect(parseInvitation(invitation)).toBe(invitation);
};
/**
* Tests that an invitation survives a serialize and deserialize round trip.
*/
const testSerializeInvitationRoundTripsBigintAndBytes = (): void => {
const invitation = buildValidInvitation();
const restoredInvitation = deserializeInvitation(serializeInvitation(invitation));
expect(restoredInvitation).toEqual(invitation);
const [ restoredCommit ] = restoredInvitation.commits;
expect(restoredCommit!.data.variables![0]!.value).toBe(18014398509481985n);
expect(restoredCommit!.data.outputs![0]!.valueSatoshis).toBe(1000n);
expect(restoredCommit!.data.outputs![0]!.lockingBytecode).toBeInstanceOf(Uint8Array);
expect(binToHex(restoredCommit!.data.inputs![0]!.outpointTransactionHash!)).toBe(outpointTransactionHash);
};
/**
* Tests that parseInvitation throws InvitationInvalidError listing every missing required field.
*/
const testParseInvitationThrowsOnMissingRequiredFields = (): void => {
let thrownError: unknown;
try {
parseInvitation({});
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
const expectedMessage =
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n'
+ '- invitationIdentifier: expected string, received undefined\n'
+ '- createdAtTimestamp: expected number, received undefined\n'
+ '- templateIdentifier: expected string, received undefined\n'
+ '- actionIdentifier: expected string, received undefined\n'
+ '- commits: expected array, received undefined';
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that parseInvitation throws InvitationInvalidError reporting every field with a wrong type or out of range value.
*/
const testParseInvitationThrowsOnFieldTypeViolations = (): void => {
const invitationWithWrongFieldTypes = { ...buildValidInvitation(), createdAtTimestamp: -1, commits: 'none' };
let thrownError: unknown;
try {
parseInvitation(invitationWithWrongFieldTypes);
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
const expectedMessage =
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n'
+ '- createdAtTimestamp: Too small: expected number to be >=0\n'
+ '- commits: expected array, received string';
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that parseInvitation throws InvitationInvalidError for an unrecognized property at the top level.
*/
const testParseInvitationThrowsOnUnknownProperties = (): void => {
const invitationWithUnknownProperty = { ...buildValidInvitation(), unknownProperty: 'unexpected' };
let thrownError: unknown;
try {
parseInvitation(invitationWithUnknownProperty);
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
const expectedMessage =
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- (root): Unrecognized key: "unknownProperty"';
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that parseInvitation throws InvitationInvalidError with the full path of an unrecognized property inside commit data.
*/
const testParseInvitationThrowsOnDeepUnknownProperties = (): void => {
const invitation = buildValidInvitation();
const [ firstCommit ] = invitation.commits;
const invitationWithDeepUnknown = { ...invitation, commits: [{ ...firstCommit, data: { ...firstCommit!.data, unknownData: 1 } }] };
let thrownError: unknown;
try {
parseInvitation(invitationWithDeepUnknown);
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
const expectedMessage =
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- commits.0.data: Unrecognized key: "unknownData"';
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that deserializeInvitation throws InvitationJsonMalformedError for text that is not valid JSON.
*/
const testDeserializeInvitationThrowsOnMalformedJson = (): void => {
let thrownError: unknown;
try {
deserializeInvitation('{"invitationIdentifier":');
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationJsonMalformedError);
const expectedMessage = 'Invitation JSON malformed: expected a valid JSON string, but got: Unexpected end of JSON input';
expect((thrownError as InvitationJsonMalformedError).message).toBe(expectedMessage);
expect((thrownError as InvitationJsonMalformedError).reason).toBe('Unexpected end of JSON input');
};
/**
* Tests that serializeInvitation throws InvitationSerializationFailedError when the value cannot be written as JSON.
*/
const testSerializeInvitationThrowsOnCircularValue = (): void => {
// A value that refers to itself has no JSON form
const circularInvitation: Record<string, unknown> = { ...buildValidInvitation() };
circularInvitation.self = circularInvitation;
let thrownError: unknown;
try {
serializeInvitation(circularInvitation as XOInvitation);
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationSerializationFailedError);
const expectedMessage =
'Invitation serialization failed: Converting circular structure to JSON\n'
+ " --> starting at object with constructor 'Object'\n"
+ " --- property 'self' closes the circle";
expect((thrownError as InvitationSerializationFailedError).message).toBe(expectedMessage);
};
const runTests = async (): Promise<void> => {
test('parseInvitation: returns the received object for a valid invitation', testParseInvitationReturnsReceivedObject);
test('serializeInvitation: round trips bigint and Uint8Array fields', testSerializeInvitationRoundTripsBigintAndBytes);
test('parseInvitation: throws InvitationInvalidError on missing required fields', testParseInvitationThrowsOnMissingRequiredFields);
test('parseInvitation: throws InvitationInvalidError on field type violations', testParseInvitationThrowsOnFieldTypeViolations);
test('parseInvitation: throws InvitationInvalidError on unknown top-level property', testParseInvitationThrowsOnUnknownProperties);
test('parseInvitation: throws InvitationInvalidError on unknown property inside commit data', testParseInvitationThrowsOnDeepUnknownProperties);
test('deserializeInvitation: throws InvitationJsonMalformedError on malformed JSON', testDeserializeInvitationThrowsOnMalformedJson);
test('serializeInvitation: throws InvitationSerializationFailedError on a circular value', testSerializeInvitationThrowsOnCircularValue);
};
await runTests();