Use engine-version of resolveInivitationReferences

This commit is contained in:
2026-06-29 21:03:42 +10:00
parent cfcba02bb3
commit 051fc0c9ac
4 changed files with 9 additions and 755 deletions
+8 -11
View File
@@ -7,8 +7,10 @@ import {
generateTemplateIdentifier,
hasInvitationExpired,
mergeInvitationCommits,
resolveCommitReferences,
serializeInvitation,
deserializeInvitation,
type ResolvedInvitationData,
} from "@xo-cash/engine";
import type {
XOInvitation,
@@ -35,13 +37,9 @@ import type { BlockchainService } from "./electrum.js";
import { EventEmitter } from "../utils/event-emitter.js";
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
import {
resolveCommitReferences,
type ResolvedInvitationData,
} from "../utils/resolve-invitation-data.js";
import { compileCashAssemblyString } from "@xo-cash/engine";
export type { ResolvedInvitationData } from "../utils/resolve-invitation-data.js";
export type { ResolvedInvitationData } from "@xo-cash/engine";
export type InvitationEventMap = {
"invitation-updated": XOInvitation;
@@ -613,10 +611,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Append the commit to the invitation
this.updateInvitationData(
await this.engine.appendInvitation(
this.data.invitationIdentifier,
data,
),
await this.engine.appendInvitation(this.data.invitationIdentifier, data),
);
// Sync the invitation to the sync server
@@ -894,13 +889,15 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/**
* Removes the invitation from the Local SQLite db as well as the Engine's internal DB
* NOTE: This uses methods that are marked "DANGEROUSLY" inside the engine and behaviour may change
*/
*/
public async delete() {
// Remove the invitation from our local db
this.storage.remove(this.data.invitationIdentifier);
// Remove the invitation from the engine's internal db
await this.engine.DANGEROUS_deleteStoredInvitation(this.data.invitationIdentifier);
await this.engine.DANGEROUS_deleteStoredInvitation(
this.data.invitationIdentifier,
);
this.emit("invitation-removed", this.data.invitationIdentifier);
@@ -29,7 +29,7 @@ import {
formatInvitationListItem,
formatInvitationId,
} from '../../../utils/invitation-utils.js';
import type { ResolvedInvitationVariable } from '../../../utils/resolve-invitation-data.js';
import type { ResolvedInvitationVariable } from '@xo-cash/engine';
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
import { compileCashAssemblyString } from '@xo-cash/engine';
-452
View File
@@ -1,452 +0,0 @@
/**
* 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";
/**
* 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,
{ name?: string; description?: string; icon?: string }
>;
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.
*/
export const pickTemplateViewMetadata = (definition?: {
name?: string;
description?: string;
icon?: string;
}) => {
if (!definition) return {};
// Only copy fields that are present so absent template metadata does not
// overwrite committed values when this object is spread onto a commit row.
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.
*/
export const pickTemplateVariableMetadata = (
definition?: XOTemplateVariable,
) => {
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.
*/
export const pickTemplateInputMetadata = (definition?: XOTemplateInput) => {
if (!definition) return {};
return {
...pickTemplateViewMetadata(definition),
...(definition.unlockingScript !== undefined && {
unlockingScript: definition.unlockingScript,
}),
...(definition.omitChangeAmounts !== undefined && {
omitChangeAmounts: definition.omitChangeAmounts,
}),
};
};
/**
* 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.
*/
export const pickTemplateOutputMetadata = (definition?: XOTemplateOutput) => {
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,
}),
// Keep CashAssembly expressions (e.g. "$(<totalSatoshis>)") for UI compilation;
// committed bigint values on the output row take precedence when spread later.
...(definition.valueSatoshis !== undefined && {
valueSatoshis: definition.valueSatoshis,
}),
...(definition.token !== undefined && { token: definition.token }),
};
};
/**
* Enriches a committed variable with its template definition.
*/
export const resolveVariable = (
variable: XOInvitationVariable,
entityIdentifier: string,
template: XOTemplate,
): ResolvedInvitationVariable => ({
entityIdentifier,
variableIdentifier: variable.variableIdentifier,
...(variable.roleIdentifier !== undefined && {
roleIdentifier: variable.roleIdentifier,
}),
value: variable.value,
...pickTemplateVariableMetadata(
template.variables?.[variable.variableIdentifier],
),
});
/**
* Enriches a committed input with its template definition when an identifier is present.
*/
export const resolveInput = (
input: XOInvitationInput,
entityIdentifier: string,
template: XOTemplate,
): ResolvedInvitationInput => ({
entityIdentifier,
...input,
...pickTemplateInputMetadata(
input.inputIdentifier
? template.inputs?.[input.inputIdentifier]
: undefined,
),
});
/**
* Enriches a committed output with its template definition when an identifier is present.
*
* Template metadata is spread after commit fields so display expressions (e.g.
* `valueSatoshis: "$(<totalSatoshis>)"`) layer on for the UI even when the merger
* already resolved a bigint for transaction encoding.
*/
export const resolveOutput = (
output: XOInvitationOutput,
entityIdentifier: string,
template: XOTemplate,
): ResolvedInvitationOutput =>
({
entityIdentifier,
...output,
...pickTemplateOutputMetadata(
output.outputIdentifier
? template.outputs?.[output.outputIdentifier]
: undefined,
),
// Template valueSatoshis may be a CashAssembly string while XOInvitationOutput
// expects bigint — the read model intentionally allows both for display.
}) as ResolvedInvitationOutput;
/**
* Converts hex or binary invitation bytecode fields to hex strings for display.
*/
export const hexOrBinToHex = (value?: string | Uint8Array) => {
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).
*
* The engine merger returns libauth-ready binary fields and fills in encoding
* defaults (empty unlocking bytecode, sequence 0) that are not useful in the TUI.
*/
export const normalizeMergedInputForDisplay = (input: XOInvitationInput) => {
const normalized = { ...input };
if (input.outpointTransactionHash !== undefined) {
normalized.outpointTransactionHash = hexOrBinToHex(
input.outpointTransactionHash,
) as XOInvitationInput["outpointTransactionHash"];
}
if (input.unlockingBytecode !== undefined) {
// Engine uses an empty Uint8Array as a placeholder until the input is signed.
const isPlaceholder =
input.unlockingBytecode instanceof Uint8Array &&
input.unlockingBytecode.length === 0;
if (isPlaceholder) {
delete normalized.unlockingBytecode;
} else {
normalized.unlockingBytecode = hexOrBinToHex(
input.unlockingBytecode,
) as XOInvitationInput["unlockingBytecode"];
}
}
// Default sequence from the merger is not meaningful for display.
if (normalized.sequenceNumber === 0) {
delete normalized.sequenceNumber;
}
return normalized;
};
/**
* Normalizes a merged output row for UI display (hex strings).
*/
export const normalizeMergedOutputForDisplay = (output: XOInvitationOutput) => {
const normalized = { ...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.
*/
export const findOutputIdentifierForMergedOutput = (
commit: XOInvitationCommit | undefined,
mergedOutput: XOInvitationOutput,
) => {
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);
// Match merged binary bytecode back to the committed row that carried the identifier.
if (
mergedBytecodeHex !== undefined &&
commitBytecodeHex !== undefined &&
mergedBytecodeHex === commitBytecodeHex
) {
return commitOutput.outputIdentifier;
}
}
// Fall back when the commit has a single identified output (common case).
const outputsWithIdentifier = outputs.filter(
(commitOutput) => commitOutput.outputIdentifier !== undefined,
);
if (outputsWithIdentifier.length === 1) {
return outputsWithIdentifier[0]?.outputIdentifier;
}
return undefined;
};
/**
* Whether two invitation variable rows refer to the same template variable slot.
*/
export const matchesInvitationVariable = (
left: XOInvitationVariable,
right: XOInvitationVariable,
) =>
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.
*/
export const findVariableEntityIdentifier = (
variable: XOInvitationVariable,
commits: XOInvitationCommit[],
) => {
let entityIdentifier = "";
// Merged variables do not carry sourceCommitIdentifier today; walk commits and
// let the last array match win (ordering deferred to the engine merger).
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.
*/
export const resolveCommitReferences = (
invitation: XOInvitation,
template: XOTemplate,
): ResolvedInvitationData => {
const commits = invitation.commits ?? [];
const commitsMap = new Map(
commits.map((commit) => [commit.commitIdentifier, commit]),
);
// Merge rather than flatten so mergesWith input extensions and transactionIndex
// ordering are handled by the engine (see signing flow in engine.append/sign).
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 entityIdentifier =
commitsMap.get(mergedInput.sourceCommitIdentifier)?.entityIdentifier ??
"";
// Strip merger-only fields before normalization and template enrichment.
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,
);
// Re-attach outputIdentifier so pickTemplateOutputMetadata can resolve names/roles.
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,
};
};