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.
This commit is contained in:
2026-06-15 18:36:55 +10:00
parent d2c37fd957
commit 771968dfbb
2 changed files with 249 additions and 21 deletions

View File

@@ -7,8 +7,11 @@
* (names, descriptions, icons, roles, etc.).
*/
import { mergeInvitationCommits } from "@xo-cash/engine";
import { binToHex } from "@bitauth/libauth";
import type {
XOInvitation,
XOInvitationCommit,
XOInvitationInput,
XOInvitationOutput,
XOInvitationVariable,
@@ -249,12 +252,149 @@ function resolveOutput(
}
/**
* Returns flattened, template-enriched invitation data for UI display.
* 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.
*
* 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.
* 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.
@@ -264,26 +404,67 @@ export function resolveCommitReferences(
invitation: XOInvitation,
template: XOTemplate,
): ResolvedInvitationData {
const variables: ResolvedInvitationVariable[] = [];
const inputs: ResolvedInvitationInput[] = [];
const outputs: ResolvedInvitationOutput[] = [];
const commits = invitation.commits ?? [];
const commitsMap = new Map(
commits.map((commit) => [commit.commitIdentifier, commit]),
);
for (const commit of invitation.commits ?? []) {
for (const variable of commit.data?.variables ?? []) {
variables.push(
resolveVariable(variable, commit.entityIdentifier, template),
);
}
const merged = mergeInvitationCommits(
invitation as Parameters<typeof mergeInvitationCommits>[0],
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));
}
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,

View File

@@ -123,6 +123,36 @@ const originalInvitation: XOInvitation = {
],
};
/**
* Customer input commit extended with unlocking bytecode via mergesWith (signing flow).
*/
const invitationWithSignedInput: XOInvitation = {
...originalInvitation,
commits: [
...originalInvitation.commits.slice(0, 5),
{
commitIdentifier: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
previousCommitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114",
entityIdentifier: CUSTOMER_ENTITY,
data: {
inputs: [
{
mergesWith: {
commitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114",
index: 0,
},
unlockingBytecode:
"41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db0",
},
],
},
signature:
"3045022001a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456789022100fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
expiresAtTimestamp: 1779507008000,
},
],
};
describe("resolveCommitReferences", () => {
it("flattens commits and enriches items with template metadata", () => {
const resolved = resolveCommitReferences(
@@ -237,4 +267,21 @@ describe("resolveCommitReferences", () => {
expect(resolved.outputs[1]).not.toHaveProperty("name");
expect(resolved.outputs[1]).not.toHaveProperty("outputIdentifier");
});
it("merges input extension commits via mergesWith into a single input", () => {
const resolved = resolveCommitReferences(
invitationWithSignedInput,
vendingMachineTemplate,
);
expect(resolved.inputs).toHaveLength(1);
expect(resolved.inputs[0]).toMatchObject({
entityIdentifier: CUSTOMER_ENTITY,
outpointTransactionHash:
"b1e8f77cdc60efac19f668fc5c7177ace42a46e2532f230979559c7190c3c80a",
outpointIndex: 1,
unlockingBytecode:
"41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db0",
});
});
});