Files
xo-cli/src/utils/resolve-invitation-data.ts
Harvey Zuccon 771968dfbb Use mergeInvitationCommits in resolveCommitReferences for correct commit merging.
Delegate input/output merging to the engine so mergesWith extensions and
transaction indices resolve correctly instead of flattening raw commits.
2026-06-15 18:36:55 +10:00

477 lines
13 KiB
TypeScript

/**
* 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<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;
}
/**
* 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<typeof mergeInvitationCommits>[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,
};
}