3 Commits

2 changed files with 115 additions and 135 deletions

View File

@@ -22,24 +22,6 @@ import type {
XOTemplateVariable, XOTemplateVariable,
} from "@xo-cash/types"; } 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. * A variable from invitation commits enriched with its template definition.
*/ */
@@ -74,7 +56,10 @@ export type ResolvedInvitationOutput = XOInvitationOutput & {
name?: string; name?: string;
description?: string; description?: string;
icon?: string; icon?: string;
roles?: Record<string, ResolvedInvitationOutputRoleMetadata>; roles?: Record<
string,
{ name?: string; description?: string; icon?: string }
>;
lockingScript?: string; lockingScript?: string;
}; };
@@ -93,11 +78,15 @@ export interface ResolvedInvitationData {
/** /**
* Picks human-readable view fields from a template definition. * Picks human-readable view fields from a template definition.
*/ */
function pickTemplateViewMetadata( export const pickTemplateViewMetadata = (definition?: {
definition: TemplateViewMetadata | undefined, name?: string;
): TemplateViewMetadata { description?: string;
icon?: string;
}) => {
if (!definition) return {}; 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 { return {
...(definition.name !== undefined && { name: definition.name }), ...(definition.name !== undefined && { name: definition.name }),
...(definition.description !== undefined && { ...(definition.description !== undefined && {
@@ -105,14 +94,14 @@ function pickTemplateViewMetadata(
}), }),
...(definition.icon !== undefined && { icon: definition.icon }), ...(definition.icon !== undefined && { icon: definition.icon }),
}; };
} };
/** /**
* Picks variable metadata from a template variable definition. * Picks variable metadata from a template variable definition.
*/ */
function pickTemplateVariableMetadata( export const pickTemplateVariableMetadata = (
definition: XOTemplateVariable | undefined, definition?: XOTemplateVariable,
): Pick<ResolvedInvitationVariable, "name" | "description" | "type" | "hint"> { ) => {
if (!definition) return {}; if (!definition) return {};
return { return {
@@ -120,17 +109,12 @@ function pickTemplateVariableMetadata(
...(definition.type !== undefined && { type: definition.type }), ...(definition.type !== undefined && { type: definition.type }),
...(definition.hint !== undefined && { hint: definition.hint }), ...(definition.hint !== undefined && { hint: definition.hint }),
}; };
} };
/** /**
* Picks input metadata from a template input definition. * Picks input metadata from a template input definition.
*/ */
function pickTemplateInputMetadata( export const pickTemplateInputMetadata = (definition?: XOTemplateInput) => {
definition: XOTemplateInput | undefined,
): Pick<
ResolvedInvitationInput,
"name" | "description" | "icon" | "unlockingScript" | "omitChangeAmounts"
> {
if (!definition) return {}; if (!definition) return {};
return { return {
@@ -142,20 +126,7 @@ function pickTemplateInputMetadata(
omitChangeAmounts: definition.omitChangeAmounts, 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. * Picks output metadata from a template output definition.
@@ -164,9 +135,7 @@ interface TemplateOutputMetadata {
* defaults; display-oriented fields like name, description, and template * defaults; display-oriented fields like name, description, and template
* valueSatoshis expressions are layered on for UI rendering. * valueSatoshis expressions are layered on for UI rendering.
*/ */
function pickTemplateOutputMetadata( export const pickTemplateOutputMetadata = (definition?: XOTemplateOutput) => {
definition: XOTemplateOutput | undefined,
): TemplateOutputMetadata {
if (!definition) return {}; if (!definition) return {};
const roles = definition.roles const roles = definition.roles
@@ -184,91 +153,94 @@ function pickTemplateOutputMetadata(
...(definition.lockingScript !== undefined && { ...(definition.lockingScript !== undefined && {
lockingScript: definition.lockingScript, 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 && { ...(definition.valueSatoshis !== undefined && {
valueSatoshis: definition.valueSatoshis, valueSatoshis: definition.valueSatoshis,
}), }),
...(definition.token !== undefined && { token: definition.token }), ...(definition.token !== undefined && { token: definition.token }),
}; };
} };
/** /**
* Enriches a committed variable with its template definition. * Enriches a committed variable with its template definition.
*/ */
function resolveVariable( export const resolveVariable = (
variable: XOInvitationVariable, variable: XOInvitationVariable,
entityIdentifier: string, entityIdentifier: string,
template: XOTemplate, template: XOTemplate,
): ResolvedInvitationVariable { ): ResolvedInvitationVariable => ({
const definition = template.variables?.[variable.variableIdentifier]; entityIdentifier,
variableIdentifier: variable.variableIdentifier,
return { ...(variable.roleIdentifier !== undefined && {
entityIdentifier, roleIdentifier: variable.roleIdentifier,
variableIdentifier: variable.variableIdentifier, }),
...(variable.roleIdentifier !== undefined && { value: variable.value,
roleIdentifier: variable.roleIdentifier, ...pickTemplateVariableMetadata(
}), template.variables?.[variable.variableIdentifier],
value: variable.value, ),
...pickTemplateVariableMetadata(definition), });
};
}
/** /**
* Enriches a committed input with its template definition when an identifier is present. * Enriches a committed input with its template definition when an identifier is present.
*/ */
function resolveInput( export const resolveInput = (
input: XOInvitationInput, input: XOInvitationInput,
entityIdentifier: string, entityIdentifier: string,
template: XOTemplate, template: XOTemplate,
): ResolvedInvitationInput { ): ResolvedInvitationInput => ({
const definition = input.inputIdentifier entityIdentifier,
? template.inputs?.[input.inputIdentifier] ...input,
: undefined; ...pickTemplateInputMetadata(
input.inputIdentifier
return { ? template.inputs?.[input.inputIdentifier]
entityIdentifier, : undefined,
...input, ),
...pickTemplateInputMetadata(definition), });
};
}
/** /**
* Enriches a committed output with its template definition when an identifier is present. * 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.
*/ */
function resolveOutput( export const resolveOutput = (
output: XOInvitationOutput, output: XOInvitationOutput,
entityIdentifier: string, entityIdentifier: string,
template: XOTemplate, template: XOTemplate,
): ResolvedInvitationOutput { ): ResolvedInvitationOutput =>
const definition = output.outputIdentifier ({
? template.outputs?.[output.outputIdentifier]
: undefined;
const templateMetadata = pickTemplateOutputMetadata(definition);
return {
entityIdentifier, entityIdentifier,
...output, ...output,
...templateMetadata, ...pickTemplateOutputMetadata(
} as ResolvedInvitationOutput; 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. * Converts hex or binary invitation bytecode fields to hex strings for display.
*/ */
function hexOrBinToHex( export const hexOrBinToHex = (value?: string | Uint8Array) => {
value: string | Uint8Array | undefined,
): string | undefined {
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;
} }
return typeof value === "string" ? value : binToHex(value); return typeof value === "string" ? value : binToHex(value);
} };
/** /**
* Normalizes a merged input row for UI display (hex strings, no encoding placeholders). * 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.
*/ */
function normalizeMergedInputForDisplay(input: XOInvitationInput): XOInvitationInput { export const normalizeMergedInputForDisplay = (input: XOInvitationInput) => {
const normalized: XOInvitationInput = { ...input }; const normalized = { ...input };
if (input.outpointTransactionHash !== undefined) { if (input.outpointTransactionHash !== undefined) {
normalized.outpointTransactionHash = hexOrBinToHex( normalized.outpointTransactionHash = hexOrBinToHex(
@@ -277,6 +249,7 @@ function normalizeMergedInputForDisplay(input: XOInvitationInput): XOInvitationI
} }
if (input.unlockingBytecode !== undefined) { if (input.unlockingBytecode !== undefined) {
// Engine uses an empty Uint8Array as a placeholder until the input is signed.
const isPlaceholder = const isPlaceholder =
input.unlockingBytecode instanceof Uint8Array && input.unlockingBytecode instanceof Uint8Array &&
input.unlockingBytecode.length === 0; input.unlockingBytecode.length === 0;
@@ -290,20 +263,19 @@ function normalizeMergedInputForDisplay(input: XOInvitationInput): XOInvitationI
} }
} }
// Default sequence from the merger is not meaningful for display.
if (normalized.sequenceNumber === 0) { if (normalized.sequenceNumber === 0) {
delete normalized.sequenceNumber; delete normalized.sequenceNumber;
} }
return normalized; return normalized;
} };
/** /**
* Normalizes a merged output row for UI display (hex strings). * Normalizes a merged output row for UI display (hex strings).
*/ */
function normalizeMergedOutputForDisplay( export const normalizeMergedOutputForDisplay = (output: XOInvitationOutput) => {
output: XOInvitationOutput, const normalized = { ...output };
): XOInvitationOutput {
const normalized: XOInvitationOutput = { ...output };
if (output.lockingBytecode !== undefined) { if (output.lockingBytecode !== undefined) {
normalized.lockingBytecode = hexOrBinToHex( normalized.lockingBytecode = hexOrBinToHex(
@@ -312,16 +284,16 @@ function normalizeMergedOutputForDisplay(
} }
return normalized; return normalized;
} };
/** /**
* Recovers `outputIdentifier` from the source commit because the merger strips it * Recovers `outputIdentifier` from the source commit because the merger strips it
* after template resolution. * after template resolution.
*/ */
function findOutputIdentifierForMergedOutput( export const findOutputIdentifierForMergedOutput = (
commit: XOInvitationCommit | undefined, commit: XOInvitationCommit | undefined,
mergedOutput: XOInvitationOutput, mergedOutput: XOInvitationOutput,
): string | undefined { ) => {
const outputs = commit?.data?.outputs ?? []; const outputs = commit?.data?.outputs ?? [];
const mergedBytecodeHex = hexOrBinToHex(mergedOutput.lockingBytecode); const mergedBytecodeHex = hexOrBinToHex(mergedOutput.lockingBytecode);
@@ -332,6 +304,7 @@ function findOutputIdentifierForMergedOutput(
const commitBytecodeHex = hexOrBinToHex(commitOutput.lockingBytecode); const commitBytecodeHex = hexOrBinToHex(commitOutput.lockingBytecode);
// Match merged binary bytecode back to the committed row that carried the identifier.
if ( if (
mergedBytecodeHex !== undefined && mergedBytecodeHex !== undefined &&
commitBytecodeHex !== undefined && commitBytecodeHex !== undefined &&
@@ -341,42 +314,41 @@ function findOutputIdentifierForMergedOutput(
} }
} }
// Fall back when the commit has a single identified output (common case).
const outputsWithIdentifier = outputs.filter( const outputsWithIdentifier = outputs.filter(
(commitOutput) => commitOutput.outputIdentifier !== undefined, (commitOutput) => commitOutput.outputIdentifier !== undefined,
); );
if (outputsWithIdentifier.length === 1) { if (outputsWithIdentifier.length === 1) {
const soleIdentifiedOutput = outputsWithIdentifier[0]; return outputsWithIdentifier[0]?.outputIdentifier;
return soleIdentifiedOutput?.outputIdentifier;
} }
return undefined; return undefined;
} };
/** /**
* Whether two invitation variable rows refer to the same template variable slot. * Whether two invitation variable rows refer to the same template variable slot.
*/ */
function matchesInvitationVariable( export const matchesInvitationVariable = (
left: XOInvitationVariable, left: XOInvitationVariable,
right: XOInvitationVariable, right: XOInvitationVariable,
): boolean { ) =>
return ( left.variableIdentifier === right.variableIdentifier &&
left.variableIdentifier === right.variableIdentifier && left.roleIdentifier === right.roleIdentifier;
left.roleIdentifier === right.roleIdentifier
);
}
/** /**
* Finds the entity that authored a merged variable by scanning invitation commits. * 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 * Last matching commit in array order wins. Best-effort until the engine orders
* commits internally or exposes source attribution on merged variables. * commits internally or exposes source attribution on merged variables.
*/ */
function findVariableEntityIdentifier( export const findVariableEntityIdentifier = (
variable: XOInvitationVariable, variable: XOInvitationVariable,
commits: XOInvitationCommit[], commits: XOInvitationCommit[],
): string { ) => {
let entityIdentifier = ""; 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 commit of commits) {
for (const commitVariable of commit.data?.variables ?? []) { for (const commitVariable of commit.data?.variables ?? []) {
if (matchesInvitationVariable(commitVariable, variable)) { if (matchesInvitationVariable(commitVariable, variable)) {
@@ -386,7 +358,7 @@ function findVariableEntityIdentifier(
} }
return entityIdentifier; return entityIdentifier;
} };
/** /**
* Returns template-enriched invitation data for UI display. * Returns template-enriched invitation data for UI display.
@@ -395,20 +367,18 @@ function findVariableEntityIdentifier(
* extensions and transaction indices are resolved. Variables come from the merged * extensions and transaction indices are resolved. Variables come from the merged
* result and are enriched with template metadata. Commit ordering is delegated to * result and are enriched with template metadata. Commit ordering is delegated to
* the engine merger. * 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( export const resolveCommitReferences = (
invitation: XOInvitation, invitation: XOInvitation,
template: XOTemplate, template: XOTemplate,
): ResolvedInvitationData { ): ResolvedInvitationData => {
const commits = invitation.commits ?? []; const commits = invitation.commits ?? [];
const commitsMap = new Map( const commitsMap = new Map(
commits.map((commit) => [commit.commitIdentifier, commit]), 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( const merged = mergeInvitationCommits(
invitation as Parameters<typeof mergeInvitationCommits>[0], invitation as Parameters<typeof mergeInvitationCommits>[0],
template, template,
@@ -434,8 +404,10 @@ export function resolveCommitReferences(
); );
const inputs = merged.inputs.map((mergedInput) => { const inputs = merged.inputs.map((mergedInput) => {
const commit = commitsMap.get(mergedInput.sourceCommitIdentifier); const entityIdentifier =
const entityIdentifier = commit?.entityIdentifier ?? ""; commitsMap.get(mergedInput.sourceCommitIdentifier)?.entityIdentifier ??
"";
// Strip merger-only fields before normalization and template enrichment.
const { const {
sourceCommitIdentifier: _sourceCommitIdentifier, sourceCommitIdentifier: _sourceCommitIdentifier,
mergesWith: _mergesWith, mergesWith: _mergesWith,
@@ -457,7 +429,11 @@ export function resolveCommitReferences(
mergesWith: _mergesWith, mergesWith: _mergesWith,
...output ...output
} = mergedOutput; } = mergedOutput;
const outputIdentifier = findOutputIdentifierForMergedOutput(commit, output); const outputIdentifier = findOutputIdentifierForMergedOutput(
commit,
output,
);
// Re-attach outputIdentifier so pickTemplateOutputMetadata can resolve names/roles.
const outputForDisplay = normalizeMergedOutputForDisplay( const outputForDisplay = normalizeMergedOutputForDisplay(
outputIdentifier !== undefined ? { ...output, outputIdentifier } : output, outputIdentifier !== undefined ? { ...output, outputIdentifier } : output,
); );
@@ -473,4 +449,4 @@ export function resolveCommitReferences(
inputs, inputs,
outputs, outputs,
}; };
} };

View File

@@ -24,7 +24,8 @@ const originalInvitation: XOInvitation = {
previousCommitIdentifier: undefined, previousCommitIdentifier: undefined,
entityIdentifier: MERCHANT_ENTITY, entityIdentifier: MERCHANT_ENTITY,
data: {}, data: {},
signature: "5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b", signature:
"5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b",
expiresAtTimestamp: 1779506689379, expiresAtTimestamp: 1779506689379,
}, },
{ {
@@ -61,7 +62,8 @@ const originalInvitation: XOInvitation = {
}, },
], ],
}, },
signature: "7cfc53860ec81403a79a03521a7674ee8d2a11365ee031e4f7f2e36a045bd6e2999510264b29045582a74e1190f0176950a855361f02bc67ff7877fabcf794f4", signature:
"7cfc53860ec81403a79a03521a7674ee8d2a11365ee031e4f7f2e36a045bd6e2999510264b29045582a74e1190f0176950a855361f02bc67ff7877fabcf794f4",
expiresAtTimestamp: 1779506689390, expiresAtTimestamp: 1779506689390,
}, },
{ {
@@ -77,7 +79,8 @@ const originalInvitation: XOInvitation = {
}, },
], ],
}, },
signature: "d9bdd3b24fef6afd13f12da92e832672c6c1b83fb372506faeb7fa4ea0e39e3a32ad74493fbe7a393aed58bc18226431dabae09948ce371ad3f77b0219cb3831", signature:
"d9bdd3b24fef6afd13f12da92e832672c6c1b83fb372506faeb7fa4ea0e39e3a32ad74493fbe7a393aed58bc18226431dabae09948ce371ad3f77b0219cb3831",
expiresAtTimestamp: 1779506689412, expiresAtTimestamp: 1779506689412,
}, },
{ {
@@ -85,7 +88,8 @@ const originalInvitation: XOInvitation = {
previousCommitIdentifier: "583208aa304c0aa9841d1400efe6b6aa", previousCommitIdentifier: "583208aa304c0aa9841d1400efe6b6aa",
entityIdentifier: CUSTOMER_ENTITY, entityIdentifier: CUSTOMER_ENTITY,
data: {}, data: {},
signature: "63be8af81622da4fccc7eb6b81c6174879fe6aa113b8dae794bd42d4d5c87ae550a18be1e6cb5edf231e774bdc7883eb5a78bd02188579dce58da0d449c43865", signature:
"63be8af81622da4fccc7eb6b81c6174879fe6aa113b8dae794bd42d4d5c87ae550a18be1e6cb5edf231e774bdc7883eb5a78bd02188579dce58da0d449c43865",
expiresAtTimestamp: 1779506979194, expiresAtTimestamp: 1779506979194,
}, },
{ {
@@ -101,7 +105,8 @@ const originalInvitation: XOInvitation = {
}, },
], ],
}, },
signature: "e36942eb5f147e620659d20b7059630da871944e74fe5ffb3c4ff0298a5aedb101bc7468b19750114cbcfa56b99bd4a080453a31084f18173adcd9442fca4303", signature:
"e36942eb5f147e620659d20b7059630da871944e74fe5ffb3c4ff0298a5aedb101bc7468b19750114cbcfa56b99bd4a080453a31084f18173adcd9442fca4303",
expiresAtTimestamp: 1779507006272, expiresAtTimestamp: 1779507006272,
}, },
{ {
@@ -117,7 +122,8 @@ const originalInvitation: XOInvitation = {
}, },
], ],
}, },
signature: "2c1d1ed1259a2e4b1bc7187b93029e99e590a4e92ff9c39031319766b7fbcdabab9c3dc20b3d27d05eee198cbc717b9aedfbef92bd3e519c62c60e4731bd936a", signature:
"2c1d1ed1259a2e4b1bc7187b93029e99e590a4e92ff9c39031319766b7fbcdabab9c3dc20b3d27d05eee198cbc717b9aedfbef92bd3e519c62c60e4731bd936a",
expiresAtTimestamp: 1779507008169, expiresAtTimestamp: 1779507008169,
}, },
], ],
@@ -226,8 +232,7 @@ describe("resolveCommitReferences", () => {
{ {
entityIdentifier: MERCHANT_ENTITY, entityIdentifier: MERCHANT_ENTITY,
outputIdentifier: "purchaseOutput", outputIdentifier: "purchaseOutput",
lockingBytecode: lockingBytecode: "76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac",
"76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac",
name: "Purchase Payment", name: "Purchase Payment",
description: "$(<totalSatoshis>) sats to $(<merchantName>)", description: "$(<totalSatoshis>) sats to $(<merchantName>)",
icon: "request", icon: "request",
@@ -250,8 +255,7 @@ describe("resolveCommitReferences", () => {
{ {
entityIdentifier: CUSTOMER_ENTITY, entityIdentifier: CUSTOMER_ENTITY,
valueSatoshis: 74881n, valueSatoshis: 74881n,
lockingBytecode: lockingBytecode: "76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac",
"76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac",
}, },
], ],
}); });