diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 73c22c3..ebe6630 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -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 { ); // 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 { /** * 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 { }); } + /** + * 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): Promise { const queuedUpdate = this.sseUpdateQueue.then(update); this.sseUpdateQueue = queuedUpdate.catch(() => {}); @@ -197,19 +239,21 @@ export class Invitation extends EventEmitter { 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 { 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 { */ async accept(acceptParams?: InvitationParameters): Promise { // 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 { // 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 { 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 diff --git a/src/tui/screens/invitations/InvitationScreen.tsx b/src/tui/screens/invitations/InvitationScreen.tsx index a137d42..2a84517 100644 --- a/src/tui/screens/invitations/InvitationScreen.tsx +++ b/src/tui/screens/invitations/InvitationScreen.tsx @@ -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); + 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 ( {/* 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 ( - {/* 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)}`} ); @@ -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 ( - {/* 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), - evaluationDecodeMode: 'bigint' - })} - - {/* Output value */} + {output.name ?? output.outputIdentifier ?? `Output ${idx}`} + {outputDescription && ` - ${outputDescription}`} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`} ); @@ -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 && ( - - {varTemplate.description} + {variable.description && ( + - {variable.description} )} ); diff --git a/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx b/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx index 2740a07..1bda2c4 100644 --- a/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx +++ b/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx @@ -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(); - 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({ ) : ( inputs.map((input, idx) => { - const inputTemplate = template?.inputs?.[input.inputIdentifier ?? '']; return ( - {' '}• {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} + {' '}• {input.name ?? input.inputIdentifier ?? `Input ${idx}`} {input.roleIdentifier && ` (${input.roleIdentifier})`} @@ -170,15 +188,17 @@ export function PreviewInvitationStep({ ) : ( 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 ( - {' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} - {output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} + {' '}• {output.name ?? output.outputIdentifier ?? `Output ${idx}`} + {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)})`} {fiatValue && ` (~${fiatValue})`} @@ -201,14 +221,13 @@ export function PreviewInvitationStep({ ) : ( variables.map((variable, idx) => { - const varTemplate = template?.variables?.[variable.variableIdentifier]; const displayValue = typeof variable.value === 'bigint' ? variable.value.toString() : String(variable.value); return ( - {' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue} + {' '}• {variable.name ?? variable.variableIdentifier}: {displayValue} ); diff --git a/src/utils/resolve-invitation-data.ts b/src/utils/resolve-invitation-data.ts new file mode 100644 index 0000000..9dd57b6 --- /dev/null +++ b/src/utils/resolve-invitation-data.ts @@ -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; + 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 { + 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; + 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, + }; +} diff --git a/tests/utils/resolve-invitation-data.test.ts b/tests/utils/resolve-invitation-data.test.ts new file mode 100644 index 0000000..39f2dda --- /dev/null +++ b/tests/utils/resolve-invitation-data.test.ts @@ -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: "$() sats to $()", + icon: "request", + roles: { + merchant: { + name: "Payment Received", + description: + "Received $() sats for $()", + }, + customer: { + name: "Payment Sent", + description: + "Sent $() sats for $()", + }, + }, + lockingScript: "merchantReceivingLockingScript", + valueSatoshis: "$()", + 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"); + }); +});