Feat/parse invitation

This commit is contained in:
Kuldeep
2026-10-05 12:59:22 +00:00
parent d907946804
commit 0c95b9b2b1
19 changed files with 1008 additions and 386 deletions
+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);
};