/** * 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 { mergeInvitationCommits } from "@xo-cash/engine"; import { binToHex } from "@bitauth/libauth"; import type { XOInvitation, XOInvitationCommit, 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; } /** * Converts hex or binary invitation bytecode fields to hex strings for display. */ function hexOrBinToHex( value: string | Uint8Array | undefined, ): string | undefined { if (value === undefined) { return undefined; } return typeof value === "string" ? value : binToHex(value); } /** * Normalizes a merged input row for UI display (hex strings, no encoding placeholders). */ function normalizeMergedInputForDisplay(input: XOInvitationInput): XOInvitationInput { const normalized: XOInvitationInput = { ...input }; if (input.outpointTransactionHash !== undefined) { normalized.outpointTransactionHash = hexOrBinToHex( input.outpointTransactionHash, ) as XOInvitationInput["outpointTransactionHash"]; } if (input.unlockingBytecode !== undefined) { const isPlaceholder = input.unlockingBytecode instanceof Uint8Array && input.unlockingBytecode.length === 0; if (isPlaceholder) { delete normalized.unlockingBytecode; } else { normalized.unlockingBytecode = hexOrBinToHex( input.unlockingBytecode, ) as XOInvitationInput["unlockingBytecode"]; } } if (normalized.sequenceNumber === 0) { delete normalized.sequenceNumber; } return normalized; } /** * Normalizes a merged output row for UI display (hex strings). */ function normalizeMergedOutputForDisplay( output: XOInvitationOutput, ): XOInvitationOutput { const normalized: XOInvitationOutput = { ...output }; if (output.lockingBytecode !== undefined) { normalized.lockingBytecode = hexOrBinToHex( output.lockingBytecode, ) as XOInvitationOutput["lockingBytecode"]; } return normalized; } /** * Recovers `outputIdentifier` from the source commit because the merger strips it * after template resolution. */ function findOutputIdentifierForMergedOutput( commit: XOInvitationCommit | undefined, mergedOutput: XOInvitationOutput, ): string | undefined { const outputs = commit?.data?.outputs ?? []; const mergedBytecodeHex = hexOrBinToHex(mergedOutput.lockingBytecode); for (const commitOutput of outputs) { if (commitOutput.outputIdentifier === undefined) { continue; } const commitBytecodeHex = hexOrBinToHex(commitOutput.lockingBytecode); if ( mergedBytecodeHex !== undefined && commitBytecodeHex !== undefined && mergedBytecodeHex === commitBytecodeHex ) { return commitOutput.outputIdentifier; } } const outputsWithIdentifier = outputs.filter( (commitOutput) => commitOutput.outputIdentifier !== undefined, ); if (outputsWithIdentifier.length === 1) { const soleIdentifiedOutput = outputsWithIdentifier[0]; return soleIdentifiedOutput?.outputIdentifier; } return undefined; } /** * Whether two invitation variable rows refer to the same template variable slot. */ function matchesInvitationVariable( left: XOInvitationVariable, right: XOInvitationVariable, ): boolean { return ( left.variableIdentifier === right.variableIdentifier && left.roleIdentifier === right.roleIdentifier ); } /** * Finds the entity that authored a merged variable by scanning invitation commits. * Last matching commit in array order wins. Best-effort until the engine orders * commits internally or exposes source attribution on merged variables. */ function findVariableEntityIdentifier( variable: XOInvitationVariable, commits: XOInvitationCommit[], ): string { let entityIdentifier = ""; for (const commit of commits) { for (const commitVariable of commit.data?.variables ?? []) { if (matchesInvitationVariable(commitVariable, variable)) { entityIdentifier = commit.entityIdentifier; } } } return entityIdentifier; } /** * Returns template-enriched invitation data for UI display. * * Uses {@link mergeInvitationCommits} for inputs and outputs so `mergesWith` * extensions and transaction indices are resolved. Variables come from the merged * result and are enriched with template metadata. Commit ordering is delegated to * the engine merger. * * @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 commits = invitation.commits ?? []; const commitsMap = new Map( commits.map((commit) => [commit.commitIdentifier, commit]), ); const merged = mergeInvitationCommits( invitation as Parameters[0], template, ); if (merged === null) { return { invitationIdentifier: invitation.invitationIdentifier, templateIdentifier: invitation.templateIdentifier, actionIdentifier: invitation.actionIdentifier, variables: [], inputs: [], outputs: [], }; } const variables = merged.variables.map((variable) => resolveVariable( variable, findVariableEntityIdentifier(variable, commits), template, ), ); const inputs = merged.inputs.map((mergedInput) => { const commit = commitsMap.get(mergedInput.sourceCommitIdentifier); const entityIdentifier = commit?.entityIdentifier ?? ""; const { sourceCommitIdentifier: _sourceCommitIdentifier, mergesWith: _mergesWith, ...input } = mergedInput; return resolveInput( normalizeMergedInputForDisplay(input), entityIdentifier, template, ); }); const outputs = merged.outputs.map((mergedOutput) => { const commit = commitsMap.get(mergedOutput.sourceCommitIdentifier); const entityIdentifier = commit?.entityIdentifier ?? ""; const { sourceCommitIdentifier: _sourceCommitIdentifier, mergesWith: _mergesWith, ...output } = mergedOutput; const outputIdentifier = findOutputIdentifierForMergedOutput(commit, output); const outputForDisplay = normalizeMergedOutputForDisplay( outputIdentifier !== undefined ? { ...output, outputIdentifier } : output, ); return resolveOutput(outputForDisplay, entityIdentifier, template); }); return { invitationIdentifier: invitation.invitationIdentifier, templateIdentifier: invitation.templateIdentifier, actionIdentifier: invitation.actionIdentifier, variables, inputs, outputs, }; }