Add resolveCommitReferences method

This commit is contained in:
2026-06-08 13:09:38 +02:00
parent c7e1d69e2d
commit 69adee180a
5 changed files with 683 additions and 93 deletions

View File

@@ -17,6 +17,7 @@ import type {
XOInvitationOutput,
XOInvitationVariable,
XOInvitationVariableValue,
XOTemplate,
} from "@xo-cash/types";
import type { UnspentOutputData } from "@xo-cash/state";
import {
@@ -34,8 +35,14 @@ import type { BlockchainService } from "./electrum.js";
import { EventEmitter } from "../utils/event-emitter.js";
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
import {
resolveCommitReferences,
type ResolvedInvitationData,
} from "../utils/resolve-invitation-data.js";
import { compileCashAssemblyString } from "@xo-cash/engine";
export type { ResolvedInvitationData } from "../utils/resolve-invitation-data.js";
export type InvitationEventMap = {
"invitation-updated": XOInvitation;
"invitation-status-changed": string;
@@ -103,11 +110,33 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
);
// Create the invitation
const invitationInstance = new Invitation(engineInvitation, dependencies);
const invitationInstance = new Invitation(
engineInvitation,
dependencies,
template,
);
return invitationInstance;
}
/**
* Flattened, template-enriched view of {@link Invitation.data}.
* Updated automatically whenever invitation data changes.
*/
public resolvedData: ResolvedInvitationData = {
invitationIdentifier: "",
templateIdentifier: "",
actionIdentifier: "",
variables: [],
inputs: [],
outputs: [],
};
/**
* The template used to enrich {@link resolvedData}.
*/
private template: XOTemplate;
/**
* The invitation data.
*/
@@ -145,14 +174,19 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/**
* Create an invitation and start the SSE Session required for it.
*/
constructor(invitation: XOInvitation, dependencies: InvitationDependencies) {
constructor(
invitation: XOInvitation,
dependencies: InvitationDependencies,
template: XOTemplate,
) {
super();
this.data = invitation;
this.template = template;
this.engine = dependencies.engine;
this.syncServer = dependencies.syncServer;
this.storage = dependencies.storage;
this.electrum = dependencies.electrum;
this.updateInvitationData(invitation);
// Apply SSE updates serially so each engine update sees the latest history.
this.syncServer.on("message", (event) => {
@@ -167,6 +201,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
});
}
/**
* Updates raw invitation data and recomputes {@link resolvedData}.
*/
private updateInvitationData(invitation: XOInvitation): void {
this.data = invitation;
this.resolvedData = resolveCommitReferences(invitation, this.template);
}
private enqueueSyncUpdate(update: () => Promise<void>): Promise<void> {
const queuedUpdate = this.sseUpdateQueue.then(update);
this.sseUpdateQueue = queuedUpdate.catch(() => {});
@@ -197,19 +239,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
try {
// Prefer keeping the engine's local invitation state in sync.
this.data = stripLocalInvitationMetadata(
await this.engine.updateInvitation({
...this.data,
...invitation,
commits: combinedCommits,
}),
this.updateInvitationData(
stripLocalInvitationMetadata(
await this.engine.updateInvitation({
...this.data,
...invitation,
commits: combinedCommits,
}),
),
);
} catch (error) {
this.emit(
"error",
error instanceof Error ? error : new Error(String(error)),
);
this.data = { ...this.data, commits: combinedCommits };
this.updateInvitationData({ ...this.data, commits: combinedCommits });
}
await this.storage.set(this.data.invitationIdentifier, this.data);
@@ -243,19 +287,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
try {
this.data = stripLocalInvitationMetadata(
await this.engine.updateInvitation({
...this.data,
...invitation,
commits: newCommits,
}),
this.updateInvitationData(
stripLocalInvitationMetadata(
await this.engine.updateInvitation({
...this.data,
...invitation,
commits: newCommits,
}),
),
);
} catch (error) {
this.emit(
"error",
error instanceof Error ? error : new Error(String(error)),
);
this.data = { ...this.data, commits: newCommits };
this.updateInvitationData({ ...this.data, commits: newCommits });
}
await this.storage.set(this.data.invitationIdentifier, this.data);
@@ -488,7 +534,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
*/
async accept(acceptParams?: InvitationParameters): Promise<void> {
// Accept the invitation
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
this.updateInvitationData(
await this.engine.acceptInvitation(this.data, acceptParams),
);
// Sync the invitation to the sync server
await this.publishInvitation(this.data);
@@ -529,7 +577,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Store the signed invitation in the storage
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
this.data = signedInvitation;
this.updateInvitationData(signedInvitation);
// Update the status of the invitation
await this.updateStatus();
@@ -563,9 +611,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.ensureAccepted();
// Append the commit to the invitation
this.data = await this.engine.appendInvitation(
this.data.invitationIdentifier,
data,
this.updateInvitationData(
await this.engine.appendInvitation(
this.data.invitationIdentifier,
data,
),
);
// Sync the invitation to the sync server

View File

@@ -26,12 +26,10 @@ import type { XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '
import {
getInvitationState,
getStateColorName,
getInvitationInputs,
getInvitationOutputs,
getInvitationVariables,
formatInvitationListItem,
formatInvitationId,
} from '../../../utils/invitation-utils.js';
import type { ResolvedInvitationVariable } from '../../../utils/resolve-invitation-data.js';
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
import { compileCashAssemblyString } from '@xo-cash/engine';
@@ -401,16 +399,11 @@ export function InvitationScreen(): React.ReactElement {
setStatus('Analyzing invitation...');
let requiredAmount = 0n;
const commits = selectedInvitation.data.commits || [];
for (const commit of commits) {
const variables = commit.data?.variables || [];
for (const variable of variables) {
if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) {
requiredAmount = BigInt(variable.value?.toString() || '0');
break;
}
for (const variable of selectedInvitation.resolvedData.variables) {
if (variable.variableIdentifier.toLowerCase().includes('satoshi')) {
requiredAmount = BigInt(variable.value?.toString() || '0');
break;
}
if (requiredAmount > 0n) break;
}
const fee = 500n;
@@ -595,14 +588,17 @@ export function InvitationScreen(): React.ReactElement {
const state = getInvitationState(selectedInvitation);
const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier];
const inputs = getInvitationInputs(selectedInvitation);
const outputs = getInvitationOutputs(selectedInvitation);
const variables = getInvitationVariables(selectedInvitation);
const { inputs, outputs, variables } = selectedInvitation.resolvedData;
const userEntityId = ownInvitationContext.entityIdentifier;
const userRole = ownInvitationContext.roleIdentifier;
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
const variableValues = variables.reduce((acc, variable) => {
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
return acc;
}, {} as Record<string, XOInvitationVariableValue>);
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
@@ -625,11 +621,10 @@ export function InvitationScreen(): React.ReactElement {
}
};
const isSatoshisVariable = (variableIdentifier: string): boolean => {
const templateVariable = selectedTemplate?.variables?.[variableIdentifier];
const templateType = templateVariable?.type?.toLowerCase();
const templateHint = templateVariable?.hint?.toLowerCase();
const identifier = variableIdentifier.toLowerCase();
const isSatoshisVariable = (variable: ResolvedInvitationVariable): boolean => {
const templateHint = variable.hint?.toLowerCase();
const templateType = variable.type?.toLowerCase();
const identifier = variable.variableIdentifier.toLowerCase();
if (templateHint?.includes('satoshi')) {
return true;
@@ -641,6 +636,20 @@ export function InvitationScreen(): React.ReactElement {
);
};
const compileResolvedDescription = (description?: string): string | null => {
if (!description) return null;
try {
return compileCashAssemblyString({
cashAssemblyText: description,
variables: variableValues,
evaluationDecodeMode: 'bigint',
});
} catch {
return description;
}
};
return (
<Box flexDirection="column">
{/* Type & Status */}
@@ -693,28 +702,21 @@ export function InvitationScreen(): React.ReactElement {
) : (
inputs.map((input, idx) => {
const isUserInput = input.entityIdentifier === userEntityId;
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
const inputSatoshis = (
'valueSatoshis' in input && input.valueSatoshis !== undefined
)
? parseNumberishToBigInt(input.valueSatoshis)
: null;
const inputDescription = compileResolvedDescription(input.description);
return (
<Text
key={`input-${idx}`}
color={isUserInput ? colors.success : colors.text}
>
{/* Indicator for whether this is the user's input */}
{' '}{isUserInput ? '• ' : '○ '}
{/* TODO: Why doesnt this stuff work? It just cant resolve inputs? */}
{/* Input name */}
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{/* Input role */}
{input.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`}
{/* Input value */}
{inputDescription && ` - ${inputDescription}`}
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
</Text>
);
@@ -729,33 +731,18 @@ export function InvitationScreen(): React.ReactElement {
) : (
outputs.map((output, idx) => {
const isUserOutput = output.entityIdentifier === userEntityId;
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
const outputSatoshis = output.valueSatoshis !== undefined
? parseNumberishToBigInt(output.valueSatoshis)
: null;
const outputDescription = compileResolvedDescription(output.description);
return (
<Text
key={`output-${idx}`}
color={isUserOutput ? colors.success : colors.text}
>
{/* Indicator for whether this is the user's output */}
{' '}{isUserOutput ? '• ' : '○ '}
{/* Output name */}
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{/* Output description */}
{outputTemplate?.description && ' - ' + compileCashAssemblyString({
cashAssemblyText: outputTemplate?.description,
variables: variables.reduce((acc, variable) => {
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
return acc;
}, {} as Record<string, XOInvitationVariableValue>),
evaluationDecodeMode: 'bigint'
})}
{/* Output value */}
{output.name ?? output.outputIdentifier ?? `Output ${idx}`}
{outputDescription && ` - ${outputDescription}`}
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
</Text>
);
@@ -772,11 +759,10 @@ export function InvitationScreen(): React.ReactElement {
) : (
variables.map((variable, idx) => {
const isUserVariable = variable.entityIdentifier === userEntityId;
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
const displayValue = typeof variable.value === 'bigint'
? variable.value.toString()
: String(variable.value);
const parsedVariableSatoshis = isSatoshisVariable(variable.variableIdentifier)
const parsedVariableSatoshis = isSatoshisVariable(variable)
? parseNumberishToBigInt(variable.value)
: null;
return (
@@ -785,11 +771,11 @@ export function InvitationScreen(): React.ReactElement {
color={isUserVariable ? colors.success : colors.text}
>
{' '}{isUserVariable ? '• ' : '○ '}
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
{variable.name ?? variable.variableIdentifier}: {displayValue}
{parsedVariableSatoshis !== null &&
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
{varTemplate?.description && (
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
{variable.description && (
<Text color={colors.textMuted} dimColor> - {variable.description}</Text>
)}
</Text>
);

View File

@@ -14,12 +14,29 @@ import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import {
getInvitationState,
getStateColorName,
getInvitationInputs,
getInvitationOutputs,
getInvitationVariables,
} from '../../../../../utils/invitation-utils.js';
import type { PreviewStepProps } from '../types.js';
/**
* Map a semantic color name to an actual theme color value.
*/
function parseNumberishToBigInt(value: unknown): bigint | null {
if (typeof value === 'bigint') {
return value;
}
const asString = String(value).trim();
if (!/^[-]?\d+$/.test(asString)) {
return null;
}
try {
return BigInt(asString);
} catch {
return null;
}
}
/**
* Map a semantic color name to an actual theme color value.
*/
@@ -51,16 +68,18 @@ export function PreviewInvitationStep({
const state = getInvitationState(invitation);
const action = template?.actions?.[invitation.data.actionIdentifier];
const inputs = getInvitationInputs(invitation);
const outputs = getInvitationOutputs(invitation);
const variables = getInvitationVariables(invitation);
const { inputs, outputs, variables } = invitation.resolvedData;
// Collect role identifiers that appear across all commits
// Collect role identifiers that appear across resolved invitation data
const filledRoles = new Set<string>();
for (const commit of invitation.data.commits ?? []) {
for (const input of commit.data?.inputs ?? []) {
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
}
for (const input of inputs) {
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
}
for (const output of outputs) {
if (output.roleIdentifier) filledRoles.add(output.roleIdentifier);
}
for (const variable of variables) {
if (variable.roleIdentifier) filledRoles.add(variable.roleIdentifier);
}
return (
@@ -143,11 +162,10 @@ export function PreviewInvitationStep({
</Box>
) : (
inputs.map((input, idx) => {
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
return (
<Box key={`input-${idx}`}>
<Text color={colors.text}>
{' '} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{' '} {input.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`}
</Text>
</Box>
@@ -170,15 +188,17 @@ export function PreviewInvitationStep({
</Box>
) : (
outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
const fiatValue = output.valueSatoshis !== undefined
? formatSatoshisToFiat(output.valueSatoshis)
: null;
const outputSatoshis = output.valueSatoshis !== undefined
? parseNumberishToBigInt(output.valueSatoshis)
: null;
return (
<Box key={`output-${idx}`}>
<Text color={colors.text}>
{' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
{' '} {output.name ?? output.outputIdentifier ?? `Output ${idx}`}
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)})`}
{fiatValue && ` (~${fiatValue})`}
</Text>
</Box>
@@ -201,14 +221,13 @@ export function PreviewInvitationStep({
</Box>
) : (
variables.map((variable, idx) => {
const varTemplate = template?.variables?.[variable.variableIdentifier];
const displayValue = typeof variable.value === 'bigint'
? variable.value.toString()
: String(variable.value);
return (
<Box key={`var-${idx}`}>
<Text color={colors.text}>
{' '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
{' '} {variable.name ?? variable.variableIdentifier}: {displayValue}
</Text>
</Box>
);

View File

@@ -0,0 +1,295 @@
/**
* Transforms a raw XO invitation into a flattened, template-enriched structure
* suitable for UI display without manually resolving template references.
*
* The original invitation format is unchanged in storage and transport; this
* function produces a read model that merges commit data with template metadata
* (names, descriptions, icons, roles, etc.).
*/
import type {
XOInvitation,
XOInvitationInput,
XOInvitationOutput,
XOInvitationVariable,
XOInvitationVariableValue,
XOTemplate,
XOTemplateInput,
XOTemplateOutput,
XOTemplateVariable,
} from "@xo-cash/types";
/**
* View metadata copied from a template definition onto a resolved invitation item.
*/
interface TemplateViewMetadata {
name?: string;
description?: string;
icon?: string;
}
/**
* Role-specific view metadata from a template output definition.
*/
export interface ResolvedInvitationOutputRoleMetadata {
name?: string;
description?: string;
icon?: string;
}
/**
* A variable from invitation commits enriched with its template definition.
*/
export interface ResolvedInvitationVariable {
entityIdentifier: string;
variableIdentifier: string;
roleIdentifier?: string;
value: XOInvitationVariableValue;
name?: string;
description?: string;
type?: string;
hint?: string;
}
/**
* A transaction input from invitation commits enriched with its template definition.
*/
export type ResolvedInvitationInput = XOInvitationInput & {
entityIdentifier: string;
name?: string;
description?: string;
icon?: string;
unlockingScript?: string;
omitChangeAmounts?: XOTemplateInput["omitChangeAmounts"];
};
/**
* A transaction output from invitation commits enriched with its template definition.
*/
export type ResolvedInvitationOutput = XOInvitationOutput & {
entityIdentifier: string;
name?: string;
description?: string;
icon?: string;
roles?: Record<string, ResolvedInvitationOutputRoleMetadata>;
lockingScript?: string;
};
/**
* Flattened, template-enriched invitation data for UI consumption.
*/
export interface ResolvedInvitationData {
invitationIdentifier: string;
templateIdentifier: string;
actionIdentifier: string;
variables: ResolvedInvitationVariable[];
inputs: ResolvedInvitationInput[];
outputs: ResolvedInvitationOutput[];
}
/**
* Picks human-readable view fields from a template definition.
*/
function pickTemplateViewMetadata(
definition: TemplateViewMetadata | undefined,
): TemplateViewMetadata {
if (!definition) return {};
return {
...(definition.name !== undefined && { name: definition.name }),
...(definition.description !== undefined && {
description: definition.description,
}),
...(definition.icon !== undefined && { icon: definition.icon }),
};
}
/**
* Picks variable metadata from a template variable definition.
*/
function pickTemplateVariableMetadata(
definition: XOTemplateVariable | undefined,
): Pick<ResolvedInvitationVariable, "name" | "description" | "type" | "hint"> {
if (!definition) return {};
return {
...pickTemplateViewMetadata(definition),
...(definition.type !== undefined && { type: definition.type }),
...(definition.hint !== undefined && { hint: definition.hint }),
};
}
/**
* Picks input metadata from a template input definition.
*/
function pickTemplateInputMetadata(
definition: XOTemplateInput | undefined,
): Pick<
ResolvedInvitationInput,
"name" | "description" | "icon" | "unlockingScript" | "omitChangeAmounts"
> {
if (!definition) return {};
return {
...pickTemplateViewMetadata(definition),
...(definition.unlockingScript !== undefined && {
unlockingScript: definition.unlockingScript,
}),
...(definition.omitChangeAmounts !== undefined && {
omitChangeAmounts: definition.omitChangeAmounts,
}),
};
}
/**
* Template display metadata layered onto a committed output.
*/
interface TemplateOutputMetadata {
name?: string;
description?: string;
icon?: string;
roles?: Record<string, ResolvedInvitationOutputRoleMetadata>;
lockingScript?: string;
valueSatoshis?: bigint | string;
token?: XOTemplateOutput["token"];
}
/**
* Picks output metadata from a template output definition.
*
* Committed output values (e.g. lockingBytecode) take precedence over template
* defaults; display-oriented fields like name, description, and template
* valueSatoshis expressions are layered on for UI rendering.
*/
function pickTemplateOutputMetadata(
definition: XOTemplateOutput | undefined,
): TemplateOutputMetadata {
if (!definition) return {};
const roles = definition.roles
? Object.fromEntries(
Object.entries(definition.roles).map(([roleId, roleDefinition]) => [
roleId,
pickTemplateViewMetadata(roleDefinition),
]),
)
: undefined;
return {
...pickTemplateViewMetadata(definition),
...(roles !== undefined && Object.keys(roles).length > 0 && { roles }),
...(definition.lockingScript !== undefined && {
lockingScript: definition.lockingScript,
}),
...(definition.valueSatoshis !== undefined && {
valueSatoshis: definition.valueSatoshis,
}),
...(definition.token !== undefined && { token: definition.token }),
};
}
/**
* Enriches a committed variable with its template definition.
*/
function resolveVariable(
variable: XOInvitationVariable,
entityIdentifier: string,
template: XOTemplate,
): ResolvedInvitationVariable {
const definition = template.variables?.[variable.variableIdentifier];
return {
entityIdentifier,
variableIdentifier: variable.variableIdentifier,
...(variable.roleIdentifier !== undefined && {
roleIdentifier: variable.roleIdentifier,
}),
value: variable.value,
...pickTemplateVariableMetadata(definition),
};
}
/**
* Enriches a committed input with its template definition when an identifier is present.
*/
function resolveInput(
input: XOInvitationInput,
entityIdentifier: string,
template: XOTemplate,
): ResolvedInvitationInput {
const definition = input.inputIdentifier
? template.inputs?.[input.inputIdentifier]
: undefined;
return {
entityIdentifier,
...input,
...pickTemplateInputMetadata(definition),
};
}
/**
* Enriches a committed output with its template definition when an identifier is present.
*/
function resolveOutput(
output: XOInvitationOutput,
entityIdentifier: string,
template: XOTemplate,
): ResolvedInvitationOutput {
const definition = output.outputIdentifier
? template.outputs?.[output.outputIdentifier]
: undefined;
const templateMetadata = pickTemplateOutputMetadata(definition);
return {
entityIdentifier,
...output,
...templateMetadata,
} as ResolvedInvitationOutput;
}
/**
* Returns flattened, template-enriched invitation data for UI display.
*
* Commits are walked in order; variables, inputs, and outputs are collected
* into top-level arrays with `entityIdentifier` and template metadata attached.
* Items without a template identifier (e.g. ad-hoc change outputs) keep only
* their committed fields.
*
* @param invitation - The raw invitation in standard XO format.
* @param template - The template referenced by the invitation.
* @returns Resolved invitation data ready for display.
*/
export function resolveCommitReferences(
invitation: XOInvitation,
template: XOTemplate,
): ResolvedInvitationData {
const variables: ResolvedInvitationVariable[] = [];
const inputs: ResolvedInvitationInput[] = [];
const outputs: ResolvedInvitationOutput[] = [];
for (const commit of invitation.commits ?? []) {
for (const variable of commit.data?.variables ?? []) {
variables.push(
resolveVariable(variable, commit.entityIdentifier, template),
);
}
for (const input of commit.data?.inputs ?? []) {
inputs.push(resolveInput(input, commit.entityIdentifier, template));
}
for (const output of commit.data?.outputs ?? []) {
outputs.push(resolveOutput(output, commit.entityIdentifier, template));
}
}
return {
invitationIdentifier: invitation.invitationIdentifier,
templateIdentifier: invitation.templateIdentifier,
actionIdentifier: invitation.actionIdentifier,
variables,
inputs,
outputs,
};
}

View File

@@ -0,0 +1,240 @@
import { describe, expect, it } from "vitest";
import type { XOInvitation } from "@xo-cash/types";
import { vendingMachineTemplate } from "../../src/templates/vending-machine.js";
import { resolveCommitReferences } from "../../src/utils/resolve-invitation-data.js";
const MERCHANT_ENTITY =
"xpub6EUk69HMQk83Ay3QEFWhYgvqLvT6tGTnzWK33fao2fvnDyzhbBeoSc6JbQkvnKq33bH7HjqQmZ9H29hsesC53ZgxQfGBadBZL5jmSa7kbTD";
const CUSTOMER_ENTITY =
"xpub6FHRsCb1ma6VFGZpRYZL8A3X1Gwwc8JjRcaDJR2vgirrttmdvJX5VNYceA84RDVjy1c2a2oYEwuayLDZ9gssDgU52UXDGFTDa19z5ceXfFh";
/**
* Minimal reproduction of OriginalInvitation.json for the vending machine flow.
*/
const originalInvitation: XOInvitation = {
invitationIdentifier: "c57b1f8f8534df28b359e323c5fbd5ba",
createdAtTimestamp: 1779488689379,
templateIdentifier:
"feadd05c6566c5eded68f321efe7150cb765fda070d027c89f285e5b42a00652",
actionIdentifier: "purchaseItems",
commits: [
{
commitIdentifier: "76b935a35ca45f1065f9c66769d1a957",
previousCommitIdentifier: undefined,
entityIdentifier: MERCHANT_ENTITY,
data: {},
signature: "5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b",
expiresAtTimestamp: 1779506689379,
},
{
commitIdentifier: "cbf2d6242144f6761d0efc3bbbbf6660",
previousCommitIdentifier: "76b935a35ca45f1065f9c66769d1a957",
entityIdentifier: MERCHANT_ENTITY,
data: {
variables: [
{
variableIdentifier: "totalSatoshis",
roleIdentifier: "merchant",
value: 3000,
},
{
variableIdentifier: "orderId",
roleIdentifier: "merchant",
value: "eb5a30b3-ec8c-4b81-89dd-c53371f55a0e",
},
{
variableIdentifier: "merchantName",
roleIdentifier: "merchant",
value: "XO Snack Machine",
},
{
variableIdentifier: "receiptSummary",
roleIdentifier: "merchant",
value: "2× Chips",
},
{
variableIdentifier: "lineItemsJson",
roleIdentifier: "merchant",
value:
'[{"id":"225e37f4-14f2-4b33-86fd-763018bbfd7c","name":"Chips","quantity":2,"price":1500}]',
},
],
},
signature: "7cfc53860ec81403a79a03521a7674ee8d2a11365ee031e4f7f2e36a045bd6e2999510264b29045582a74e1190f0176950a855361f02bc67ff7877fabcf794f4",
expiresAtTimestamp: 1779506689390,
},
{
commitIdentifier: "583208aa304c0aa9841d1400efe6b6aa",
previousCommitIdentifier: "cbf2d6242144f6761d0efc3bbbbf6660",
entityIdentifier: MERCHANT_ENTITY,
data: {
outputs: [
{
outputIdentifier: "purchaseOutput",
lockingBytecode:
"76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac",
},
],
},
signature: "d9bdd3b24fef6afd13f12da92e832672c6c1b83fb372506faeb7fa4ea0e39e3a32ad74493fbe7a393aed58bc18226431dabae09948ce371ad3f77b0219cb3831",
expiresAtTimestamp: 1779506689412,
},
{
commitIdentifier: "4f3f9a3361c8070ab589cc44248a6a80",
previousCommitIdentifier: "583208aa304c0aa9841d1400efe6b6aa",
entityIdentifier: CUSTOMER_ENTITY,
data: {},
signature: "63be8af81622da4fccc7eb6b81c6174879fe6aa113b8dae794bd42d4d5c87ae550a18be1e6cb5edf231e774bdc7883eb5a78bd02188579dce58da0d449c43865",
expiresAtTimestamp: 1779506979194,
},
{
commitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114",
previousCommitIdentifier: "4f3f9a3361c8070ab589cc44248a6a80",
entityIdentifier: CUSTOMER_ENTITY,
data: {
inputs: [
{
outpointTransactionHash:
"b1e8f77cdc60efac19f668fc5c7177ace42a46e2532f230979559c7190c3c80a",
outpointIndex: 1,
},
],
},
signature: "e36942eb5f147e620659d20b7059630da871944e74fe5ffb3c4ff0298a5aedb101bc7468b19750114cbcfa56b99bd4a080453a31084f18173adcd9442fca4303",
expiresAtTimestamp: 1779507006272,
},
{
commitIdentifier: "7823f7ae7a365f87f6acdfee8896f508",
previousCommitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114",
entityIdentifier: CUSTOMER_ENTITY,
data: {
outputs: [
{
valueSatoshis: 74881n,
lockingBytecode:
"76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac",
},
],
},
signature: "2c1d1ed1259a2e4b1bc7187b93029e99e590a4e92ff9c39031319766b7fbcdabab9c3dc20b3d27d05eee198cbc717b9aedfbef92bd3e519c62c60e4731bd936a",
expiresAtTimestamp: 1779507008169,
},
],
};
describe("resolveCommitReferences", () => {
it("flattens commits and enriches items with template metadata", () => {
const resolved = resolveCommitReferences(
originalInvitation,
vendingMachineTemplate,
);
expect(resolved).toEqual({
invitationIdentifier: "c57b1f8f8534df28b359e323c5fbd5ba",
templateIdentifier:
"feadd05c6566c5eded68f321efe7150cb765fda070d027c89f285e5b42a00652",
actionIdentifier: "purchaseItems",
variables: [
{
entityIdentifier: MERCHANT_ENTITY,
name: "Total Price",
description: "Total purchase price in satoshis",
type: "integer",
hint: "satoshis",
variableIdentifier: "totalSatoshis",
roleIdentifier: "merchant",
value: 3000,
},
{
entityIdentifier: MERCHANT_ENTITY,
name: "Order ID",
description: "Unique order identifier",
type: "string",
variableIdentifier: "orderId",
roleIdentifier: "merchant",
value: "eb5a30b3-ec8c-4b81-89dd-c53371f55a0e",
},
{
entityIdentifier: MERCHANT_ENTITY,
name: "Merchant Name",
description: "Display name of the vending machine",
type: "string",
variableIdentifier: "merchantName",
roleIdentifier: "merchant",
value: "XO Snack Machine",
},
{
entityIdentifier: MERCHANT_ENTITY,
name: "Receipt Summary",
description: "Human-readable list of purchased items",
type: "string",
variableIdentifier: "receiptSummary",
roleIdentifier: "merchant",
value: "2× Chips",
},
{
entityIdentifier: MERCHANT_ENTITY,
name: "Line Items",
description: "JSON-encoded line items for the purchase",
type: "string",
variableIdentifier: "lineItemsJson",
roleIdentifier: "merchant",
value:
'[{"id":"225e37f4-14f2-4b33-86fd-763018bbfd7c","name":"Chips","quantity":2,"price":1500}]',
},
],
inputs: [
{
entityIdentifier: CUSTOMER_ENTITY,
outpointTransactionHash:
"b1e8f77cdc60efac19f668fc5c7177ace42a46e2532f230979559c7190c3c80a",
outpointIndex: 1,
},
],
outputs: [
{
entityIdentifier: MERCHANT_ENTITY,
outputIdentifier: "purchaseOutput",
lockingBytecode:
"76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac",
name: "Purchase Payment",
description: "$(<totalSatoshis>) sats to $(<merchantName>)",
icon: "request",
roles: {
merchant: {
name: "Payment Received",
description:
"Received $(<totalSatoshis>) sats for $(<receiptSummary>)",
},
customer: {
name: "Payment Sent",
description:
"Sent $(<totalSatoshis>) sats for $(<receiptSummary>)",
},
},
lockingScript: "merchantReceivingLockingScript",
valueSatoshis: "$(<totalSatoshis>)",
token: null,
},
{
entityIdentifier: CUSTOMER_ENTITY,
valueSatoshis: 74881n,
lockingBytecode:
"76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac",
},
],
});
});
it("leaves unidentified inputs and outputs without template metadata", () => {
const resolved = resolveCommitReferences(
originalInvitation,
vendingMachineTemplate,
);
expect(resolved.inputs[0]).not.toHaveProperty("name");
expect(resolved.outputs[1]).not.toHaveProperty("name");
expect(resolved.outputs[1]).not.toHaveProperty("outputIdentifier");
});
});