Feat/parse invitation
This commit is contained in:
+3
-1
@@ -17,7 +17,9 @@
|
||||
"utxotokenamount",
|
||||
"outputtokenamount",
|
||||
"outputvalue",
|
||||
"numequal"
|
||||
"numequal",
|
||||
"Prefault",
|
||||
"cashtoken"
|
||||
],
|
||||
"ignorePaths": ["source/template/xo-template.schema.json"]
|
||||
}
|
||||
|
||||
Generated
+327
-324
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './errors.ts';
|
||||
export * from './parser.ts';
|
||||
export * from './schemas.ts';
|
||||
export * from './serialization.ts';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user