8 Commits

Author SHA1 Message Date
051fc0c9ac Use engine-version of resolveInivitationReferences 2026-06-29 21:03:42 +10:00
cfcba02bb3 Document methods in resolveInvitationData 2026-06-15 20:07:31 +10:00
3ee2d53766 Formatting and reduce explicit typing 2026-06-15 20:04:51 +10:00
d089e909f8 Use arrow function syntax. Export all methods. 2026-06-15 20:00:40 +10:00
771968dfbb 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.
2026-06-15 18:36:55 +10:00
d2c37fd957 Add invitation delete to cli 2026-06-08 13:26:41 +02:00
bca736dab4 Add removeInvitation to the invitation screen 2026-06-08 13:22:13 +02:00
69adee180a Add resolveCommitReferences method 2026-06-08 13:09:38 +02:00
6 changed files with 245 additions and 94 deletions

View File

@@ -174,7 +174,7 @@ _{{FUNC_NAME}}_completions() {
fi fi
fi fi
;; ;;
append|sign|broadcast|requirements|export|inspect) append|sign|broadcast|requirements|export|inspect|delete)
# These subcommands expect an invitation identifier as first arg. # These subcommands expect an invitation identifier as first arg.
local pos=$((cword - subcmd_idx)) local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then

View File

@@ -298,6 +298,7 @@ ${bold("Sub-commands:")}
- requirements <invitation-id> ${dim("Show requirements for an invitation")} - requirements <invitation-id> ${dim("Show requirements for an invitation")}
- import <invitation-file> ${dim("Import an invitation from a file")} - import <invitation-file> ${dim("Import an invitation from a file")}
- export <invitation-id> [output-file] ${dim("Export an invitation to stdout or 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")} - inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
- list ${dim("List all invitations")} - list ${dim("List all invitations")}
@@ -955,6 +956,47 @@ export const handleInvitationCommand = async (
return handleInvitationExportCommand(deps, args.slice(1), options); 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": { case "list": {
// List all the invitations // List all the invitations
const invitations = await Promise.all( const invitations = await Promise.all(

View File

@@ -67,6 +67,7 @@ export class AppService extends EventEmitter<AppEventMap> {
{ {
onUpdated: (invitation: XOInvitation) => void; onUpdated: (invitation: XOInvitation) => void;
onStatusChanged: (status: string) => void; onStatusChanged: (status: string) => void;
onRemoved: () => void;
} }
>(); >();
@@ -241,13 +242,25 @@ export class AppService extends EventEmitter<AppEventMap> {
invitationIdentifier, 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-updated", onUpdated);
invitation.on("invitation-status-changed", onStatusChanged); invitation.on("invitation-status-changed", onStatusChanged);
invitation.on("invitation-removed", onRemoved);
this.invitationEventCleanup.set(invitationIdentifier, { this.invitationEventCleanup.set(invitationIdentifier, {
onUpdated, onUpdated,
onStatusChanged, onStatusChanged,
onRemoved,
}); });
} }

View File

@@ -7,8 +7,10 @@ import {
generateTemplateIdentifier, generateTemplateIdentifier,
hasInvitationExpired, hasInvitationExpired,
mergeInvitationCommits, mergeInvitationCommits,
resolveCommitReferences,
serializeInvitation, serializeInvitation,
deserializeInvitation, deserializeInvitation,
type ResolvedInvitationData,
} from "@xo-cash/engine"; } from "@xo-cash/engine";
import type { import type {
XOInvitation, XOInvitation,
@@ -17,6 +19,7 @@ import type {
XOInvitationOutput, XOInvitationOutput,
XOInvitationVariable, XOInvitationVariable,
XOInvitationVariableValue, XOInvitationVariableValue,
XOTemplate,
} from "@xo-cash/types"; } from "@xo-cash/types";
import type { UnspentOutputData } from "@xo-cash/state"; import type { UnspentOutputData } from "@xo-cash/state";
import { import {
@@ -36,9 +39,12 @@ import { EventEmitter } from "../utils/event-emitter.js";
import { decodeExtendedJsonObject } from "../utils/ext-json.js"; import { decodeExtendedJsonObject } from "../utils/ext-json.js";
import { compileCashAssemblyString } from "@xo-cash/engine"; import { compileCashAssemblyString } from "@xo-cash/engine";
export type { ResolvedInvitationData } from "@xo-cash/engine";
export type InvitationEventMap = { export type InvitationEventMap = {
"invitation-updated": XOInvitation; "invitation-updated": XOInvitation;
"invitation-status-changed": string; "invitation-status-changed": string;
"invitation-removed": void;
error: Error; error: Error;
}; };
@@ -103,11 +109,33 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
); );
// Create the invitation // Create the invitation
const invitationInstance = new Invitation(engineInvitation, dependencies); const invitationInstance = new Invitation(
engineInvitation,
dependencies,
template,
);
return invitationInstance; 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. * The invitation data.
*/ */
@@ -145,14 +173,19 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/** /**
* Create an invitation and start the SSE Session required for it. * Create an invitation and start the SSE Session required for it.
*/ */
constructor(invitation: XOInvitation, dependencies: InvitationDependencies) { constructor(
invitation: XOInvitation,
dependencies: InvitationDependencies,
template: XOTemplate,
) {
super(); super();
this.data = invitation; this.template = template;
this.engine = dependencies.engine; this.engine = dependencies.engine;
this.syncServer = dependencies.syncServer; this.syncServer = dependencies.syncServer;
this.storage = dependencies.storage; this.storage = dependencies.storage;
this.electrum = dependencies.electrum; this.electrum = dependencies.electrum;
this.updateInvitationData(invitation);
// Apply SSE updates serially so each engine update sees the latest history. // Apply SSE updates serially so each engine update sees the latest history.
this.syncServer.on("message", (event) => { this.syncServer.on("message", (event) => {
@@ -167,6 +200,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> { private enqueueSyncUpdate(update: () => Promise<void>): Promise<void> {
const queuedUpdate = this.sseUpdateQueue.then(update); const queuedUpdate = this.sseUpdateQueue.then(update);
this.sseUpdateQueue = queuedUpdate.catch(() => {}); this.sseUpdateQueue = queuedUpdate.catch(() => {});
@@ -197,19 +238,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
try { try {
// Prefer keeping the engine's local invitation state in sync. // Prefer keeping the engine's local invitation state in sync.
this.data = stripLocalInvitationMetadata( this.updateInvitationData(
await this.engine.updateInvitation({ stripLocalInvitationMetadata(
...this.data, await this.engine.updateInvitation({
...invitation, ...this.data,
commits: combinedCommits, ...invitation,
}), commits: combinedCommits,
}),
),
); );
} catch (error) { } catch (error) {
this.emit( this.emit(
"error", "error",
error instanceof Error ? error : new Error(String(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); await this.storage.set(this.data.invitationIdentifier, this.data);
@@ -243,19 +286,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const newCommits = this.mergeCommits(this.data.commits, invitation.commits); const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
try { try {
this.data = stripLocalInvitationMetadata( this.updateInvitationData(
await this.engine.updateInvitation({ stripLocalInvitationMetadata(
...this.data, await this.engine.updateInvitation({
...invitation, ...this.data,
commits: newCommits, ...invitation,
}), commits: newCommits,
}),
),
); );
} catch (error) { } catch (error) {
this.emit( this.emit(
"error", "error",
error instanceof Error ? error : new Error(String(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); await this.storage.set(this.data.invitationIdentifier, this.data);
@@ -488,7 +533,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
*/ */
async accept(acceptParams?: InvitationParameters): Promise<void> { async accept(acceptParams?: InvitationParameters): Promise<void> {
// Accept the invitation // 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 // Sync the invitation to the sync server
await this.publishInvitation(this.data); await this.publishInvitation(this.data);
@@ -529,7 +576,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Store the signed invitation in the storage // Store the signed invitation in the storage
await this.storage.set(this.data.invitationIdentifier, signedInvitation); await this.storage.set(this.data.invitationIdentifier, signedInvitation);
this.data = signedInvitation; this.updateInvitationData(signedInvitation);
// Update the status of the invitation // Update the status of the invitation
await this.updateStatus(); await this.updateStatus();
@@ -563,9 +610,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.ensureAccepted(); await this.ensureAccepted();
// Append the commit to the invitation // Append the commit to the invitation
this.data = await this.engine.appendInvitation( this.updateInvitationData(
this.data.invitationIdentifier, await this.engine.appendInvitation(this.data.invitationIdentifier, data),
data,
); );
// Sync the invitation to the sync server // Sync the invitation to the sync server
@@ -839,4 +885,23 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
return totalSats; 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();
}
} }

View File

@@ -26,12 +26,10 @@ import type { XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '
import { import {
getInvitationState, getInvitationState,
getStateColorName, getStateColorName,
getInvitationInputs,
getInvitationOutputs,
getInvitationVariables,
formatInvitationListItem, formatInvitationListItem,
formatInvitationId, formatInvitationId,
} from '../../../utils/invitation-utils.js'; } from '../../../utils/invitation-utils.js';
import type { ResolvedInvitationVariable } from '@xo-cash/engine';
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js'; import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
import { compileCashAssemblyString } from '@xo-cash/engine'; import { compileCashAssemblyString } from '@xo-cash/engine';
@@ -65,6 +63,7 @@ const actionItems: ListItemData<string>[] = [
{ key: 'sign', label: 'Sign Transaction', value: 'sign' }, { key: 'sign', label: 'Sign Transaction', value: 'sign' },
{ key: 'broadcast', label: 'Broadcast Transaction', value: 'broadcast' }, { key: 'broadcast', label: 'Broadcast Transaction', value: 'broadcast' },
{ key: 'copy', label: 'Copy Invitation ID', value: 'copy' }, { 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]); }, [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 () => { const copyId = useCallback(async () => {
if (!selectedInvitation) { if (!selectedInvitation) {
showError('No invitation selected'); showError('No invitation selected');
@@ -401,16 +422,11 @@ export function InvitationScreen(): React.ReactElement {
setStatus('Analyzing invitation...'); setStatus('Analyzing invitation...');
let requiredAmount = 0n; let requiredAmount = 0n;
const commits = selectedInvitation.data.commits || []; for (const variable of selectedInvitation.resolvedData.variables) {
for (const commit of commits) { if (variable.variableIdentifier.toLowerCase().includes('satoshi')) {
const variables = commit.data?.variables || []; requiredAmount = BigInt(variable.value?.toString() || '0');
for (const variable of variables) { break;
if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) {
requiredAmount = BigInt(variable.value?.toString() || '0');
break;
}
} }
if (requiredAmount > 0n) break;
} }
const fee = 500n; const fee = 500n;
@@ -516,6 +532,9 @@ export function InvitationScreen(): React.ReactElement {
case 'broadcast': case 'broadcast':
broadcastTransaction(); broadcastTransaction();
break; break;
case 'delete':
deleteInvitation();
break;
} }
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, broadcastTransaction, navigate]); }, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, broadcastTransaction, navigate]);
@@ -595,14 +614,17 @@ export function InvitationScreen(): React.ReactElement {
const state = getInvitationState(selectedInvitation); const state = getInvitationState(selectedInvitation);
const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier]; const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier];
const inputs = getInvitationInputs(selectedInvitation); const { inputs, outputs, variables } = selectedInvitation.resolvedData;
const outputs = getInvitationOutputs(selectedInvitation);
const variables = getInvitationVariables(selectedInvitation);
const userEntityId = ownInvitationContext.entityIdentifier; const userEntityId = ownInvitationContext.entityIdentifier;
const userRole = ownInvitationContext.roleIdentifier; const userRole = ownInvitationContext.roleIdentifier;
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole]; const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; 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 getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis); const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : ''; return fiatValue ? ` (~${fiatValue})` : '';
@@ -625,11 +647,10 @@ export function InvitationScreen(): React.ReactElement {
} }
}; };
const isSatoshisVariable = (variableIdentifier: string): boolean => { const isSatoshisVariable = (variable: ResolvedInvitationVariable): boolean => {
const templateVariable = selectedTemplate?.variables?.[variableIdentifier]; const templateHint = variable.hint?.toLowerCase();
const templateType = templateVariable?.type?.toLowerCase(); const templateType = variable.type?.toLowerCase();
const templateHint = templateVariable?.hint?.toLowerCase(); const identifier = variable.variableIdentifier.toLowerCase();
const identifier = variableIdentifier.toLowerCase();
if (templateHint?.includes('satoshi')) { if (templateHint?.includes('satoshi')) {
return true; 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 ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{/* Type & Status */} {/* Type & Status */}
@@ -693,28 +728,21 @@ export function InvitationScreen(): React.ReactElement {
) : ( ) : (
inputs.map((input, idx) => { inputs.map((input, idx) => {
const isUserInput = input.entityIdentifier === userEntityId; const isUserInput = input.entityIdentifier === userEntityId;
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
const inputSatoshis = ( const inputSatoshis = (
'valueSatoshis' in input && input.valueSatoshis !== undefined 'valueSatoshis' in input && input.valueSatoshis !== undefined
) )
? parseNumberishToBigInt(input.valueSatoshis) ? parseNumberishToBigInt(input.valueSatoshis)
: null; : null;
const inputDescription = compileResolvedDescription(input.description);
return ( return (
<Text <Text
key={`input-${idx}`} key={`input-${idx}`}
color={isUserInput ? colors.success : colors.text} color={isUserInput ? colors.success : colors.text}
> >
{/* Indicator for whether this is the user's input */}
{' '}{isUserInput ? '• ' : '○ '} {' '}{isUserInput ? '• ' : '○ '}
{input.name ?? input.inputIdentifier ?? `Input ${idx}`}
{/* TODO: Why doesnt this stuff work? It just cant resolve inputs? */}
{/* Input name */}
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{/* Input role */}
{input.roleIdentifier && ` (${input.roleIdentifier})`} {input.roleIdentifier && ` (${input.roleIdentifier})`}
{inputDescription && ` - ${inputDescription}`}
{/* Input value */}
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`} {inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
</Text> </Text>
); );
@@ -729,33 +757,18 @@ export function InvitationScreen(): React.ReactElement {
) : ( ) : (
outputs.map((output, idx) => { outputs.map((output, idx) => {
const isUserOutput = output.entityIdentifier === userEntityId; const isUserOutput = output.entityIdentifier === userEntityId;
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
const outputSatoshis = output.valueSatoshis !== undefined const outputSatoshis = output.valueSatoshis !== undefined
? parseNumberishToBigInt(output.valueSatoshis) ? parseNumberishToBigInt(output.valueSatoshis)
: null; : null;
const outputDescription = compileResolvedDescription(output.description);
return ( return (
<Text <Text
key={`output-${idx}`} key={`output-${idx}`}
color={isUserOutput ? colors.success : colors.text} color={isUserOutput ? colors.success : colors.text}
> >
{/* Indicator for whether this is the user's output */}
{' '}{isUserOutput ? '• ' : '○ '} {' '}{isUserOutput ? '• ' : '○ '}
{output.name ?? output.outputIdentifier ?? `Output ${idx}`}
{/* Output name */} {outputDescription && ` - ${outputDescription}`}
{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 */}
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
</Text> </Text>
); );
@@ -772,11 +785,10 @@ export function InvitationScreen(): React.ReactElement {
) : ( ) : (
variables.map((variable, idx) => { variables.map((variable, idx) => {
const isUserVariable = variable.entityIdentifier === userEntityId; const isUserVariable = variable.entityIdentifier === userEntityId;
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
const displayValue = typeof variable.value === 'bigint' const displayValue = typeof variable.value === 'bigint'
? variable.value.toString() ? variable.value.toString()
: String(variable.value); : String(variable.value);
const parsedVariableSatoshis = isSatoshisVariable(variable.variableIdentifier) const parsedVariableSatoshis = isSatoshisVariable(variable)
? parseNumberishToBigInt(variable.value) ? parseNumberishToBigInt(variable.value)
: null; : null;
return ( return (
@@ -785,11 +797,11 @@ export function InvitationScreen(): React.ReactElement {
color={isUserVariable ? colors.success : colors.text} color={isUserVariable ? colors.success : colors.text}
> >
{' '}{isUserVariable ? '• ' : '○ '} {' '}{isUserVariable ? '• ' : '○ '}
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue} {variable.name ?? variable.variableIdentifier}: {displayValue}
{parsedVariableSatoshis !== null && {parsedVariableSatoshis !== null &&
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`} ` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
{varTemplate?.description && ( {variable.description && (
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text> <Text color={colors.textMuted} dimColor> - {variable.description}</Text>
)} )}
</Text> </Text>
); );

View File

@@ -14,12 +14,29 @@ import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import { import {
getInvitationState, getInvitationState,
getStateColorName, getStateColorName,
getInvitationInputs,
getInvitationOutputs,
getInvitationVariables,
} from '../../../../../utils/invitation-utils.js'; } from '../../../../../utils/invitation-utils.js';
import type { PreviewStepProps } from '../types.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. * Map a semantic color name to an actual theme color value.
*/ */
@@ -51,16 +68,18 @@ export function PreviewInvitationStep({
const state = getInvitationState(invitation); const state = getInvitationState(invitation);
const action = template?.actions?.[invitation.data.actionIdentifier]; const action = template?.actions?.[invitation.data.actionIdentifier];
const inputs = getInvitationInputs(invitation); const { inputs, outputs, variables } = invitation.resolvedData;
const outputs = getInvitationOutputs(invitation);
const variables = getInvitationVariables(invitation);
// Collect role identifiers that appear across all commits // Collect role identifiers that appear across resolved invitation data
const filledRoles = new Set<string>(); const filledRoles = new Set<string>();
for (const commit of invitation.data.commits ?? []) { for (const input of inputs) {
for (const input of commit.data?.inputs ?? []) { if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
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 ( return (
@@ -143,11 +162,10 @@ export function PreviewInvitationStep({
</Box> </Box>
) : ( ) : (
inputs.map((input, idx) => { inputs.map((input, idx) => {
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
return ( return (
<Box key={`input-${idx}`}> <Box key={`input-${idx}`}>
<Text color={colors.text}> <Text color={colors.text}>
{' '} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} {' '} {input.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`} {input.roleIdentifier && ` (${input.roleIdentifier})`}
</Text> </Text>
</Box> </Box>
@@ -170,15 +188,17 @@ export function PreviewInvitationStep({
</Box> </Box>
) : ( ) : (
outputs.map((output, idx) => { outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
const fiatValue = output.valueSatoshis !== undefined const fiatValue = output.valueSatoshis !== undefined
? formatSatoshisToFiat(output.valueSatoshis) ? formatSatoshisToFiat(output.valueSatoshis)
: null; : null;
const outputSatoshis = output.valueSatoshis !== undefined
? parseNumberishToBigInt(output.valueSatoshis)
: null;
return ( return (
<Box key={`output-${idx}`}> <Box key={`output-${idx}`}>
<Text color={colors.text}> <Text color={colors.text}>
{' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {' '} {output.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)})`}
{fiatValue && ` (~${fiatValue})`} {fiatValue && ` (~${fiatValue})`}
</Text> </Text>
</Box> </Box>
@@ -201,14 +221,13 @@ export function PreviewInvitationStep({
</Box> </Box>
) : ( ) : (
variables.map((variable, idx) => { variables.map((variable, idx) => {
const varTemplate = template?.variables?.[variable.variableIdentifier];
const displayValue = typeof variable.value === 'bigint' const displayValue = typeof variable.value === 'bigint'
? variable.value.toString() ? variable.value.toString()
: String(variable.value); : String(variable.value);
return ( return (
<Box key={`var-${idx}`}> <Box key={`var-${idx}`}>
<Text color={colors.text}> <Text color={colors.text}>
{' '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue} {' '} {variable.name ?? variable.variableIdentifier}: {displayValue}
</Text> </Text>
</Box> </Box>
); );