Delegate input/output merging to the engine so mergesWith extensions and transaction indices resolve correctly instead of flattening raw commits.
477 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|