Feat/parse invitation
This commit is contained in:
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user