From 771968dfbb20ab48e8353e9179eb2defad8c198d Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 15 Jun 2026 18:36:55 +1000 Subject: [PATCH] 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. --- src/utils/resolve-invitation-data.ts | 223 ++++++++++++++++++-- tests/utils/resolve-invitation-data.test.ts | 47 +++++ 2 files changed, 249 insertions(+), 21 deletions(-) diff --git a/src/utils/resolve-invitation-data.ts b/src/utils/resolve-invitation-data.ts index 9dd57b6..5927ea4 100644 --- a/src/utils/resolve-invitation-data.ts +++ b/src/utils/resolve-invitation-data.ts @@ -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[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, diff --git a/tests/utils/resolve-invitation-data.test.ts b/tests/utils/resolve-invitation-data.test.ts index 39f2dda..bffecba 100644 --- a/tests/utils/resolve-invitation-data.test.ts +++ b/tests/utils/resolve-invitation-data.test.ts @@ -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", + }); + }); });