Compare commits
4 Commits
c7e1d69e2d
...
771968dfbb
| Author | SHA1 | Date | |
|---|---|---|---|
|
771968dfbb
|
|||
|
d2c37fd957
|
|||
|
bca736dab4
|
|||
|
69adee180a
|
@@ -174,7 +174,7 @@ _{{FUNC_NAME}}_completions() {
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
append|sign|broadcast|requirements|export|inspect)
|
||||
append|sign|broadcast|requirements|export|inspect|delete)
|
||||
# These subcommands expect an invitation identifier as first arg.
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
|
||||
@@ -298,6 +298,7 @@ ${bold("Sub-commands:")}
|
||||
- requirements <invitation-id> ${dim("Show requirements for an invitation")}
|
||||
- import <invitation-file> ${dim("Import an invitation from a file")}
|
||||
- export <invitation-id> [output-file] ${dim("Export an invitation to stdout or a file")}
|
||||
- delete <invitation-id> ${dim("Delete an invitation")}
|
||||
- inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
|
||||
- list ${dim("List all invitations")}
|
||||
|
||||
@@ -955,6 +956,47 @@ export const handleInvitationCommand = async (
|
||||
return handleInvitationExportCommand(deps, args.slice(1), options);
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
// Get the invitation identifier from the arguments
|
||||
const invitationIdentifier = args[1];
|
||||
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
|
||||
|
||||
// If they didnt provide us with an invitation identifier, print the help message and throw an error
|
||||
// TODO: Should probably print a specific help message for this command?
|
||||
if (!invitationIdentifier) {
|
||||
deps.io.verbose("No invitation identifier provided");
|
||||
printInvitationHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"invitation.delete.identifier_missing",
|
||||
"No invitation identifier provided",
|
||||
);
|
||||
}
|
||||
|
||||
// Find the invitation instance in our list of invitations
|
||||
const invitation = deps.app.invitations.find(
|
||||
(candidate) =>
|
||||
candidate.data.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
|
||||
// If the invitation is not found, print an error and throw an error
|
||||
if (!invitation) {
|
||||
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
|
||||
throw new CommandError(
|
||||
"invitation.delete.not_found",
|
||||
`Invitation not found: ${invitationIdentifier}`,
|
||||
);
|
||||
}
|
||||
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
|
||||
|
||||
// Delete the invitation
|
||||
await invitation.delete();
|
||||
deps.io.verbose(`Invitation deleted: ${formatObject(invitation.data)}`);
|
||||
deps.io.out(`Invitation deleted: ${invitationIdentifier}`);
|
||||
|
||||
// Return the invitation identifier
|
||||
return { invitationIdentifier };
|
||||
}
|
||||
|
||||
case "list": {
|
||||
// List all the invitations
|
||||
const invitations = await Promise.all(
|
||||
|
||||
@@ -67,6 +67,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
{
|
||||
onUpdated: (invitation: XOInvitation) => void;
|
||||
onStatusChanged: (status: string) => void;
|
||||
onRemoved: () => void;
|
||||
}
|
||||
>();
|
||||
|
||||
@@ -241,13 +242,25 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
invitationIdentifier,
|
||||
});
|
||||
};
|
||||
const onRemoved = () => {
|
||||
this.detachInvitationListeners(invitationIdentifier);
|
||||
this.invitations.splice(this.invitations.indexOf(invitation), 1);
|
||||
this.bumpInvitationRevision(invitationIdentifier);
|
||||
this.emit("invitation-removed", invitation);
|
||||
this.emit("wallet-state-changed", {
|
||||
reason: "invitation-removed",
|
||||
invitationIdentifier: invitationIdentifier,
|
||||
});
|
||||
};
|
||||
|
||||
invitation.on("invitation-updated", onUpdated);
|
||||
invitation.on("invitation-status-changed", onStatusChanged);
|
||||
invitation.on("invitation-removed", onRemoved);
|
||||
|
||||
this.invitationEventCleanup.set(invitationIdentifier, {
|
||||
onUpdated,
|
||||
onStatusChanged,
|
||||
onRemoved,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
XOInvitationOutput,
|
||||
XOInvitationVariable,
|
||||
XOInvitationVariableValue,
|
||||
XOTemplate,
|
||||
} from "@xo-cash/types";
|
||||
import type { UnspentOutputData } from "@xo-cash/state";
|
||||
import {
|
||||
@@ -34,11 +35,18 @@ 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 InvitationEventMap = {
|
||||
"invitation-updated": XOInvitation;
|
||||
"invitation-status-changed": string;
|
||||
"invitation-removed": void;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
@@ -103,11 +111,33 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
);
|
||||
|
||||
// Create the invitation
|
||||
const invitationInstance = new Invitation(engineInvitation, dependencies);
|
||||
const invitationInstance = new Invitation(
|
||||
engineInvitation,
|
||||
dependencies,
|
||||
template,
|
||||
);
|
||||
|
||||
return invitationInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened, template-enriched view of {@link Invitation.data}.
|
||||
* Updated automatically whenever invitation data changes.
|
||||
*/
|
||||
public resolvedData: ResolvedInvitationData = {
|
||||
invitationIdentifier: "",
|
||||
templateIdentifier: "",
|
||||
actionIdentifier: "",
|
||||
variables: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* The template used to enrich {@link resolvedData}.
|
||||
*/
|
||||
private template: XOTemplate;
|
||||
|
||||
/**
|
||||
* The invitation data.
|
||||
*/
|
||||
@@ -145,14 +175,19 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* Create an invitation and start the SSE Session required for it.
|
||||
*/
|
||||
constructor(invitation: XOInvitation, dependencies: InvitationDependencies) {
|
||||
constructor(
|
||||
invitation: XOInvitation,
|
||||
dependencies: InvitationDependencies,
|
||||
template: XOTemplate,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.data = invitation;
|
||||
this.template = template;
|
||||
this.engine = dependencies.engine;
|
||||
this.syncServer = dependencies.syncServer;
|
||||
this.storage = dependencies.storage;
|
||||
this.electrum = dependencies.electrum;
|
||||
this.updateInvitationData(invitation);
|
||||
|
||||
// Apply SSE updates serially so each engine update sees the latest history.
|
||||
this.syncServer.on("message", (event) => {
|
||||
@@ -167,6 +202,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates raw invitation data and recomputes {@link resolvedData}.
|
||||
*/
|
||||
private updateInvitationData(invitation: XOInvitation): void {
|
||||
this.data = invitation;
|
||||
this.resolvedData = resolveCommitReferences(invitation, this.template);
|
||||
}
|
||||
|
||||
private enqueueSyncUpdate(update: () => Promise<void>): Promise<void> {
|
||||
const queuedUpdate = this.sseUpdateQueue.then(update);
|
||||
this.sseUpdateQueue = queuedUpdate.catch(() => {});
|
||||
@@ -197,19 +240,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
|
||||
try {
|
||||
// Prefer keeping the engine's local invitation state in sync.
|
||||
this.data = stripLocalInvitationMetadata(
|
||||
await this.engine.updateInvitation({
|
||||
...this.data,
|
||||
...invitation,
|
||||
commits: combinedCommits,
|
||||
}),
|
||||
this.updateInvitationData(
|
||||
stripLocalInvitationMetadata(
|
||||
await this.engine.updateInvitation({
|
||||
...this.data,
|
||||
...invitation,
|
||||
commits: combinedCommits,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
this.emit(
|
||||
"error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
this.data = { ...this.data, commits: combinedCommits };
|
||||
this.updateInvitationData({ ...this.data, commits: combinedCommits });
|
||||
}
|
||||
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
@@ -243,19 +288,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
|
||||
|
||||
try {
|
||||
this.data = stripLocalInvitationMetadata(
|
||||
await this.engine.updateInvitation({
|
||||
...this.data,
|
||||
...invitation,
|
||||
commits: newCommits,
|
||||
}),
|
||||
this.updateInvitationData(
|
||||
stripLocalInvitationMetadata(
|
||||
await this.engine.updateInvitation({
|
||||
...this.data,
|
||||
...invitation,
|
||||
commits: newCommits,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
this.emit(
|
||||
"error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
this.data = { ...this.data, commits: newCommits };
|
||||
this.updateInvitationData({ ...this.data, commits: newCommits });
|
||||
}
|
||||
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
@@ -488,7 +535,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
*/
|
||||
async accept(acceptParams?: InvitationParameters): Promise<void> {
|
||||
// Accept the invitation
|
||||
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
||||
this.updateInvitationData(
|
||||
await this.engine.acceptInvitation(this.data, acceptParams),
|
||||
);
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
await this.publishInvitation(this.data);
|
||||
@@ -529,7 +578,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
// Store the signed invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
||||
|
||||
this.data = signedInvitation;
|
||||
this.updateInvitationData(signedInvitation);
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
@@ -563,9 +612,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
await this.ensureAccepted();
|
||||
|
||||
// Append the commit to the invitation
|
||||
this.data = await this.engine.appendInvitation(
|
||||
this.data.invitationIdentifier,
|
||||
data,
|
||||
this.updateInvitationData(
|
||||
await this.engine.appendInvitation(
|
||||
this.data.invitationIdentifier,
|
||||
data,
|
||||
),
|
||||
);
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
@@ -839,4 +890,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
|
||||
return totalSats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
this.emit("invitation-removed", this.data.invitationIdentifier);
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,10 @@ import type { XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '
|
||||
import {
|
||||
getInvitationState,
|
||||
getStateColorName,
|
||||
getInvitationInputs,
|
||||
getInvitationOutputs,
|
||||
getInvitationVariables,
|
||||
formatInvitationListItem,
|
||||
formatInvitationId,
|
||||
} from '../../../utils/invitation-utils.js';
|
||||
import type { ResolvedInvitationVariable } from '../../../utils/resolve-invitation-data.js';
|
||||
|
||||
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
|
||||
import { compileCashAssemblyString } from '@xo-cash/engine';
|
||||
@@ -65,6 +63,7 @@ const actionItems: ListItemData<string>[] = [
|
||||
{ key: 'sign', label: 'Sign Transaction', value: 'sign' },
|
||||
{ key: 'broadcast', label: 'Broadcast Transaction', value: 'broadcast' },
|
||||
{ key: 'copy', label: 'Copy Invitation ID', value: 'copy' },
|
||||
{ key: 'delete', label: 'Delete Invitation', value: 'delete' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -356,6 +355,28 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Delete the selected invitation from both our SQLite db and the engine's db
|
||||
* NOTE: This uses methods marked "DANGEROUSLY" internally, and may change in the future.
|
||||
*/
|
||||
const deleteInvitation = useCallback(async () => {
|
||||
if (!selectedInvitation) return;
|
||||
|
||||
setIsLoading(true)
|
||||
setStatus('Removing invitation...')
|
||||
|
||||
try {
|
||||
await selectedInvitation.delete();
|
||||
showInfo('Invitation successfully deleted')
|
||||
setStatus('Ready')
|
||||
} catch (error) {
|
||||
showError(`Failed to delete invitation: ${error instanceof Error ? error.message : String(error)}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setStatus('Ready')
|
||||
}
|
||||
})
|
||||
|
||||
const copyId = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
@@ -401,16 +422,11 @@ export function InvitationScreen(): React.ReactElement {
|
||||
setStatus('Analyzing invitation...');
|
||||
|
||||
let requiredAmount = 0n;
|
||||
const commits = selectedInvitation.data.commits || [];
|
||||
for (const commit of commits) {
|
||||
const variables = commit.data?.variables || [];
|
||||
for (const variable of variables) {
|
||||
if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) {
|
||||
requiredAmount = BigInt(variable.value?.toString() || '0');
|
||||
break;
|
||||
}
|
||||
for (const variable of selectedInvitation.resolvedData.variables) {
|
||||
if (variable.variableIdentifier.toLowerCase().includes('satoshi')) {
|
||||
requiredAmount = BigInt(variable.value?.toString() || '0');
|
||||
break;
|
||||
}
|
||||
if (requiredAmount > 0n) break;
|
||||
}
|
||||
|
||||
const fee = 500n;
|
||||
@@ -516,6 +532,9 @@ export function InvitationScreen(): React.ReactElement {
|
||||
case 'broadcast':
|
||||
broadcastTransaction();
|
||||
break;
|
||||
case 'delete':
|
||||
deleteInvitation();
|
||||
break;
|
||||
}
|
||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, broadcastTransaction, navigate]);
|
||||
|
||||
@@ -595,14 +614,17 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
const state = getInvitationState(selectedInvitation);
|
||||
const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier];
|
||||
const inputs = getInvitationInputs(selectedInvitation);
|
||||
const outputs = getInvitationOutputs(selectedInvitation);
|
||||
const variables = getInvitationVariables(selectedInvitation);
|
||||
const { inputs, outputs, variables } = selectedInvitation.resolvedData;
|
||||
const userEntityId = ownInvitationContext.entityIdentifier;
|
||||
const userRole = ownInvitationContext.roleIdentifier;
|
||||
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||
|
||||
const variableValues = variables.reduce((acc, variable) => {
|
||||
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
|
||||
return acc;
|
||||
}, {} as Record<string, XOInvitationVariableValue>);
|
||||
|
||||
const getFiatSuffix = (satoshis: bigint): string => {
|
||||
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||
return fiatValue ? ` (~${fiatValue})` : '';
|
||||
@@ -625,11 +647,10 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
};
|
||||
|
||||
const isSatoshisVariable = (variableIdentifier: string): boolean => {
|
||||
const templateVariable = selectedTemplate?.variables?.[variableIdentifier];
|
||||
const templateType = templateVariable?.type?.toLowerCase();
|
||||
const templateHint = templateVariable?.hint?.toLowerCase();
|
||||
const identifier = variableIdentifier.toLowerCase();
|
||||
const isSatoshisVariable = (variable: ResolvedInvitationVariable): boolean => {
|
||||
const templateHint = variable.hint?.toLowerCase();
|
||||
const templateType = variable.type?.toLowerCase();
|
||||
const identifier = variable.variableIdentifier.toLowerCase();
|
||||
|
||||
if (templateHint?.includes('satoshi')) {
|
||||
return true;
|
||||
@@ -641,6 +662,20 @@ export function InvitationScreen(): React.ReactElement {
|
||||
);
|
||||
};
|
||||
|
||||
const compileResolvedDescription = (description?: string): string | null => {
|
||||
if (!description) return null;
|
||||
|
||||
try {
|
||||
return compileCashAssemblyString({
|
||||
cashAssemblyText: description,
|
||||
variables: variableValues,
|
||||
evaluationDecodeMode: 'bigint',
|
||||
});
|
||||
} catch {
|
||||
return description;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Type & Status */}
|
||||
@@ -693,28 +728,21 @@ export function InvitationScreen(): React.ReactElement {
|
||||
) : (
|
||||
inputs.map((input, idx) => {
|
||||
const isUserInput = input.entityIdentifier === userEntityId;
|
||||
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
|
||||
const inputSatoshis = (
|
||||
'valueSatoshis' in input && input.valueSatoshis !== undefined
|
||||
)
|
||||
? parseNumberishToBigInt(input.valueSatoshis)
|
||||
: null;
|
||||
const inputDescription = compileResolvedDescription(input.description);
|
||||
return (
|
||||
<Text
|
||||
key={`input-${idx}`}
|
||||
color={isUserInput ? colors.success : colors.text}
|
||||
>
|
||||
{/* Indicator for whether this is the user's input */}
|
||||
{' '}{isUserInput ? '• ' : '○ '}
|
||||
|
||||
{/* TODO: Why doesnt this stuff work? It just cant resolve inputs? */}
|
||||
{/* Input name */}
|
||||
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||
|
||||
{/* Input role */}
|
||||
{input.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||
|
||||
{/* Input value */}
|
||||
{inputDescription && ` - ${inputDescription}`}
|
||||
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
|
||||
</Text>
|
||||
);
|
||||
@@ -729,33 +757,18 @@ export function InvitationScreen(): React.ReactElement {
|
||||
) : (
|
||||
outputs.map((output, idx) => {
|
||||
const isUserOutput = output.entityIdentifier === userEntityId;
|
||||
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
|
||||
const outputSatoshis = output.valueSatoshis !== undefined
|
||||
? parseNumberishToBigInt(output.valueSatoshis)
|
||||
: null;
|
||||
const outputDescription = compileResolvedDescription(output.description);
|
||||
return (
|
||||
<Text
|
||||
key={`output-${idx}`}
|
||||
color={isUserOutput ? colors.success : colors.text}
|
||||
>
|
||||
{/* Indicator for whether this is the user's output */}
|
||||
{' '}{isUserOutput ? '• ' : '○ '}
|
||||
|
||||
{/* Output name */}
|
||||
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||
|
||||
{/* Output description */}
|
||||
{outputTemplate?.description && ' - ' + compileCashAssemblyString({
|
||||
cashAssemblyText: outputTemplate?.description,
|
||||
variables: variables.reduce((acc, variable) => {
|
||||
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, XOInvitationVariableValue>),
|
||||
evaluationDecodeMode: 'bigint'
|
||||
})}
|
||||
|
||||
{/* Output value */}
|
||||
{output.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||
{outputDescription && ` - ${outputDescription}`}
|
||||
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
|
||||
</Text>
|
||||
);
|
||||
@@ -772,11 +785,10 @@ export function InvitationScreen(): React.ReactElement {
|
||||
) : (
|
||||
variables.map((variable, idx) => {
|
||||
const isUserVariable = variable.entityIdentifier === userEntityId;
|
||||
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
|
||||
const displayValue = typeof variable.value === 'bigint'
|
||||
? variable.value.toString()
|
||||
: String(variable.value);
|
||||
const parsedVariableSatoshis = isSatoshisVariable(variable.variableIdentifier)
|
||||
const parsedVariableSatoshis = isSatoshisVariable(variable)
|
||||
? parseNumberishToBigInt(variable.value)
|
||||
: null;
|
||||
return (
|
||||
@@ -785,11 +797,11 @@ export function InvitationScreen(): React.ReactElement {
|
||||
color={isUserVariable ? colors.success : colors.text}
|
||||
>
|
||||
{' '}{isUserVariable ? '• ' : '○ '}
|
||||
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||
{variable.name ?? variable.variableIdentifier}: {displayValue}
|
||||
{parsedVariableSatoshis !== null &&
|
||||
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
|
||||
{varTemplate?.description && (
|
||||
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
|
||||
{variable.description && (
|
||||
<Text color={colors.textMuted} dimColor> - {variable.description}</Text>
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -14,12 +14,29 @@ import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||
import {
|
||||
getInvitationState,
|
||||
getStateColorName,
|
||||
getInvitationInputs,
|
||||
getInvitationOutputs,
|
||||
getInvitationVariables,
|
||||
} from '../../../../../utils/invitation-utils.js';
|
||||
import type { PreviewStepProps } from '../types.js';
|
||||
|
||||
/**
|
||||
* Map a semantic color name to an actual theme color value.
|
||||
*/
|
||||
function parseNumberishToBigInt(value: unknown): bigint | null {
|
||||
if (typeof value === 'bigint') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const asString = String(value).trim();
|
||||
if (!/^[-]?\d+$/.test(asString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return BigInt(asString);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a semantic color name to an actual theme color value.
|
||||
*/
|
||||
@@ -51,16 +68,18 @@ export function PreviewInvitationStep({
|
||||
|
||||
const state = getInvitationState(invitation);
|
||||
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||
const inputs = getInvitationInputs(invitation);
|
||||
const outputs = getInvitationOutputs(invitation);
|
||||
const variables = getInvitationVariables(invitation);
|
||||
const { inputs, outputs, variables } = invitation.resolvedData;
|
||||
|
||||
// Collect role identifiers that appear across all commits
|
||||
// Collect role identifiers that appear across resolved invitation data
|
||||
const filledRoles = new Set<string>();
|
||||
for (const commit of invitation.data.commits ?? []) {
|
||||
for (const input of commit.data?.inputs ?? []) {
|
||||
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
|
||||
}
|
||||
for (const input of inputs) {
|
||||
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
|
||||
}
|
||||
for (const output of outputs) {
|
||||
if (output.roleIdentifier) filledRoles.add(output.roleIdentifier);
|
||||
}
|
||||
for (const variable of variables) {
|
||||
if (variable.roleIdentifier) filledRoles.add(variable.roleIdentifier);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -143,11 +162,10 @@ export function PreviewInvitationStep({
|
||||
</Box>
|
||||
) : (
|
||||
inputs.map((input, idx) => {
|
||||
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
|
||||
return (
|
||||
<Box key={`input-${idx}`}>
|
||||
<Text color={colors.text}>
|
||||
{' '}• {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||
{' '}• {input.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -170,15 +188,17 @@ export function PreviewInvitationStep({
|
||||
</Box>
|
||||
) : (
|
||||
outputs.map((output, idx) => {
|
||||
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
|
||||
const fiatValue = output.valueSatoshis !== undefined
|
||||
? formatSatoshisToFiat(output.valueSatoshis)
|
||||
: null;
|
||||
const outputSatoshis = output.valueSatoshis !== undefined
|
||||
? parseNumberishToBigInt(output.valueSatoshis)
|
||||
: null;
|
||||
return (
|
||||
<Box key={`output-${idx}`}>
|
||||
<Text color={colors.text}>
|
||||
{' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
||||
{' '}• {output.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)})`}
|
||||
{fiatValue && ` (~${fiatValue})`}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -201,14 +221,13 @@ export function PreviewInvitationStep({
|
||||
</Box>
|
||||
) : (
|
||||
variables.map((variable, idx) => {
|
||||
const varTemplate = template?.variables?.[variable.variableIdentifier];
|
||||
const displayValue = typeof variable.value === 'bigint'
|
||||
? variable.value.toString()
|
||||
: String(variable.value);
|
||||
return (
|
||||
<Box key={`var-${idx}`}>
|
||||
<Text color={colors.text}>
|
||||
{' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||
{' '}• {variable.name ?? variable.variableIdentifier}: {displayValue}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
476
src/utils/resolve-invitation-data.ts
Normal file
476
src/utils/resolve-invitation-data.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
287
tests/utils/resolve-invitation-data.test.ts
Normal file
287
tests/utils/resolve-invitation-data.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { XOInvitation } from "@xo-cash/types";
|
||||
|
||||
import { vendingMachineTemplate } from "../../src/templates/vending-machine.js";
|
||||
import { resolveCommitReferences } from "../../src/utils/resolve-invitation-data.js";
|
||||
|
||||
const MERCHANT_ENTITY =
|
||||
"xpub6EUk69HMQk83Ay3QEFWhYgvqLvT6tGTnzWK33fao2fvnDyzhbBeoSc6JbQkvnKq33bH7HjqQmZ9H29hsesC53ZgxQfGBadBZL5jmSa7kbTD";
|
||||
const CUSTOMER_ENTITY =
|
||||
"xpub6FHRsCb1ma6VFGZpRYZL8A3X1Gwwc8JjRcaDJR2vgirrttmdvJX5VNYceA84RDVjy1c2a2oYEwuayLDZ9gssDgU52UXDGFTDa19z5ceXfFh";
|
||||
|
||||
/**
|
||||
* Minimal reproduction of OriginalInvitation.json for the vending machine flow.
|
||||
*/
|
||||
const originalInvitation: XOInvitation = {
|
||||
invitationIdentifier: "c57b1f8f8534df28b359e323c5fbd5ba",
|
||||
createdAtTimestamp: 1779488689379,
|
||||
templateIdentifier:
|
||||
"feadd05c6566c5eded68f321efe7150cb765fda070d027c89f285e5b42a00652",
|
||||
actionIdentifier: "purchaseItems",
|
||||
commits: [
|
||||
{
|
||||
commitIdentifier: "76b935a35ca45f1065f9c66769d1a957",
|
||||
previousCommitIdentifier: undefined,
|
||||
entityIdentifier: MERCHANT_ENTITY,
|
||||
data: {},
|
||||
signature: "5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b",
|
||||
expiresAtTimestamp: 1779506689379,
|
||||
},
|
||||
{
|
||||
commitIdentifier: "cbf2d6242144f6761d0efc3bbbbf6660",
|
||||
previousCommitIdentifier: "76b935a35ca45f1065f9c66769d1a957",
|
||||
entityIdentifier: MERCHANT_ENTITY,
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
variableIdentifier: "totalSatoshis",
|
||||
roleIdentifier: "merchant",
|
||||
value: 3000,
|
||||
},
|
||||
{
|
||||
variableIdentifier: "orderId",
|
||||
roleIdentifier: "merchant",
|
||||
value: "eb5a30b3-ec8c-4b81-89dd-c53371f55a0e",
|
||||
},
|
||||
{
|
||||
variableIdentifier: "merchantName",
|
||||
roleIdentifier: "merchant",
|
||||
value: "XO Snack Machine",
|
||||
},
|
||||
{
|
||||
variableIdentifier: "receiptSummary",
|
||||
roleIdentifier: "merchant",
|
||||
value: "2× Chips",
|
||||
},
|
||||
{
|
||||
variableIdentifier: "lineItemsJson",
|
||||
roleIdentifier: "merchant",
|
||||
value:
|
||||
'[{"id":"225e37f4-14f2-4b33-86fd-763018bbfd7c","name":"Chips","quantity":2,"price":1500}]',
|
||||
},
|
||||
],
|
||||
},
|
||||
signature: "7cfc53860ec81403a79a03521a7674ee8d2a11365ee031e4f7f2e36a045bd6e2999510264b29045582a74e1190f0176950a855361f02bc67ff7877fabcf794f4",
|
||||
expiresAtTimestamp: 1779506689390,
|
||||
},
|
||||
{
|
||||
commitIdentifier: "583208aa304c0aa9841d1400efe6b6aa",
|
||||
previousCommitIdentifier: "cbf2d6242144f6761d0efc3bbbbf6660",
|
||||
entityIdentifier: MERCHANT_ENTITY,
|
||||
data: {
|
||||
outputs: [
|
||||
{
|
||||
outputIdentifier: "purchaseOutput",
|
||||
lockingBytecode:
|
||||
"76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac",
|
||||
},
|
||||
],
|
||||
},
|
||||
signature: "d9bdd3b24fef6afd13f12da92e832672c6c1b83fb372506faeb7fa4ea0e39e3a32ad74493fbe7a393aed58bc18226431dabae09948ce371ad3f77b0219cb3831",
|
||||
expiresAtTimestamp: 1779506689412,
|
||||
},
|
||||
{
|
||||
commitIdentifier: "4f3f9a3361c8070ab589cc44248a6a80",
|
||||
previousCommitIdentifier: "583208aa304c0aa9841d1400efe6b6aa",
|
||||
entityIdentifier: CUSTOMER_ENTITY,
|
||||
data: {},
|
||||
signature: "63be8af81622da4fccc7eb6b81c6174879fe6aa113b8dae794bd42d4d5c87ae550a18be1e6cb5edf231e774bdc7883eb5a78bd02188579dce58da0d449c43865",
|
||||
expiresAtTimestamp: 1779506979194,
|
||||
},
|
||||
{
|
||||
commitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114",
|
||||
previousCommitIdentifier: "4f3f9a3361c8070ab589cc44248a6a80",
|
||||
entityIdentifier: CUSTOMER_ENTITY,
|
||||
data: {
|
||||
inputs: [
|
||||
{
|
||||
outpointTransactionHash:
|
||||
"b1e8f77cdc60efac19f668fc5c7177ace42a46e2532f230979559c7190c3c80a",
|
||||
outpointIndex: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
signature: "e36942eb5f147e620659d20b7059630da871944e74fe5ffb3c4ff0298a5aedb101bc7468b19750114cbcfa56b99bd4a080453a31084f18173adcd9442fca4303",
|
||||
expiresAtTimestamp: 1779507006272,
|
||||
},
|
||||
{
|
||||
commitIdentifier: "7823f7ae7a365f87f6acdfee8896f508",
|
||||
previousCommitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114",
|
||||
entityIdentifier: CUSTOMER_ENTITY,
|
||||
data: {
|
||||
outputs: [
|
||||
{
|
||||
valueSatoshis: 74881n,
|
||||
lockingBytecode:
|
||||
"76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac",
|
||||
},
|
||||
],
|
||||
},
|
||||
signature: "2c1d1ed1259a2e4b1bc7187b93029e99e590a4e92ff9c39031319766b7fbcdabab9c3dc20b3d27d05eee198cbc717b9aedfbef92bd3e519c62c60e4731bd936a",
|
||||
expiresAtTimestamp: 1779507008169,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 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(
|
||||
originalInvitation,
|
||||
vendingMachineTemplate,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
invitationIdentifier: "c57b1f8f8534df28b359e323c5fbd5ba",
|
||||
templateIdentifier:
|
||||
"feadd05c6566c5eded68f321efe7150cb765fda070d027c89f285e5b42a00652",
|
||||
actionIdentifier: "purchaseItems",
|
||||
variables: [
|
||||
{
|
||||
entityIdentifier: MERCHANT_ENTITY,
|
||||
name: "Total Price",
|
||||
description: "Total purchase price in satoshis",
|
||||
type: "integer",
|
||||
hint: "satoshis",
|
||||
variableIdentifier: "totalSatoshis",
|
||||
roleIdentifier: "merchant",
|
||||
value: 3000,
|
||||
},
|
||||
{
|
||||
entityIdentifier: MERCHANT_ENTITY,
|
||||
name: "Order ID",
|
||||
description: "Unique order identifier",
|
||||
type: "string",
|
||||
variableIdentifier: "orderId",
|
||||
roleIdentifier: "merchant",
|
||||
value: "eb5a30b3-ec8c-4b81-89dd-c53371f55a0e",
|
||||
},
|
||||
{
|
||||
entityIdentifier: MERCHANT_ENTITY,
|
||||
name: "Merchant Name",
|
||||
description: "Display name of the vending machine",
|
||||
type: "string",
|
||||
variableIdentifier: "merchantName",
|
||||
roleIdentifier: "merchant",
|
||||
value: "XO Snack Machine",
|
||||
},
|
||||
{
|
||||
entityIdentifier: MERCHANT_ENTITY,
|
||||
name: "Receipt Summary",
|
||||
description: "Human-readable list of purchased items",
|
||||
type: "string",
|
||||
variableIdentifier: "receiptSummary",
|
||||
roleIdentifier: "merchant",
|
||||
value: "2× Chips",
|
||||
},
|
||||
{
|
||||
entityIdentifier: MERCHANT_ENTITY,
|
||||
name: "Line Items",
|
||||
description: "JSON-encoded line items for the purchase",
|
||||
type: "string",
|
||||
variableIdentifier: "lineItemsJson",
|
||||
roleIdentifier: "merchant",
|
||||
value:
|
||||
'[{"id":"225e37f4-14f2-4b33-86fd-763018bbfd7c","name":"Chips","quantity":2,"price":1500}]',
|
||||
},
|
||||
],
|
||||
inputs: [
|
||||
{
|
||||
entityIdentifier: CUSTOMER_ENTITY,
|
||||
outpointTransactionHash:
|
||||
"b1e8f77cdc60efac19f668fc5c7177ace42a46e2532f230979559c7190c3c80a",
|
||||
outpointIndex: 1,
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
entityIdentifier: MERCHANT_ENTITY,
|
||||
outputIdentifier: "purchaseOutput",
|
||||
lockingBytecode:
|
||||
"76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac",
|
||||
name: "Purchase Payment",
|
||||
description: "$(<totalSatoshis>) sats to $(<merchantName>)",
|
||||
icon: "request",
|
||||
roles: {
|
||||
merchant: {
|
||||
name: "Payment Received",
|
||||
description:
|
||||
"Received $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||
},
|
||||
customer: {
|
||||
name: "Payment Sent",
|
||||
description:
|
||||
"Sent $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||
},
|
||||
},
|
||||
lockingScript: "merchantReceivingLockingScript",
|
||||
valueSatoshis: "$(<totalSatoshis>)",
|
||||
token: null,
|
||||
},
|
||||
{
|
||||
entityIdentifier: CUSTOMER_ENTITY,
|
||||
valueSatoshis: 74881n,
|
||||
lockingBytecode:
|
||||
"76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unidentified inputs and outputs without template metadata", () => {
|
||||
const resolved = resolveCommitReferences(
|
||||
originalInvitation,
|
||||
vendingMachineTemplate,
|
||||
);
|
||||
|
||||
expect(resolved.inputs[0]).not.toHaveProperty("name");
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user