Format with prettier. Use screen mode for invitation import - dialog mode is broken.

This commit is contained in:
2026-03-23 10:15:48 +00:00
parent 7fd89c5663
commit b475b23beb
47 changed files with 1718 additions and 1098 deletions

View File

@@ -1,12 +1,16 @@
import { useState, useCallback } from 'react';
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
import type { VariableInput, SelectableUTXO } from '../types.js';
import { useState, useCallback } from "react";
import type {
XOTemplate,
XOInvitation,
XOTemplateTransactionOutput,
} from "@xo-cash/types";
import type { VariableInput, SelectableUTXO } from "../types.js";
import {
getTransactionOutputIdentifier,
isInvitationRequirementsComplete,
resolveProvidedLockingBytecodeHex,
} from '../../../../utils/invitation-flow.js';
import type { AppService } from '../../../../services/app.js';
} from "../../../../utils/invitation-flow.js";
import type { AppService } from "../../../../services/app.js";
interface InvitationManagerDeps {
appService: AppService;
@@ -32,26 +36,27 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
const [isProcessing, setIsProcessing] = useState(false);
/** Re-check whether all invitation requirements are satisfied. */
const refreshRequirements = useCallback(async (
identifier: string | null = invitationId,
): Promise<boolean> => {
if (!identifier || !appService) {
setRequirementsComplete(false);
return false;
}
const refreshRequirements = useCallback(
async (identifier: string | null = invitationId): Promise<boolean> => {
if (!identifier || !appService) {
setRequirementsComplete(false);
return false;
}
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === identifier,
);
if (!instance) {
setRequirementsComplete(false);
return false;
}
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === identifier,
);
if (!instance) {
setRequirementsComplete(false);
return false;
}
const complete = await isInvitationRequirementsComplete(instance);
setRequirementsComplete(complete);
return complete;
}, [appService, invitationId]);
const complete = await isInvitationRequirementsComplete(instance);
setRequirementsComplete(complete);
return complete;
},
[appService, invitationId],
);
/**
* Create an invitation, persist variable values, and add
@@ -59,177 +64,201 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
*
* @returns The invitation identifier on success, or null on failure.
*/
const createWithVariables = useCallback(async (
templateIdentifier: string,
actionIdentifier: string,
roleIdentifier: string,
template: XOTemplate,
variables: VariableInput[],
): Promise<string | null> => {
if (!appService) return null;
const createWithVariables = useCallback(
async (
templateIdentifier: string,
actionIdentifier: string,
roleIdentifier: string,
template: XOTemplate,
variables: VariableInput[],
): Promise<string | null> => {
if (!appService) return null;
setIsProcessing(true);
setStatus('Creating invitation...');
setIsProcessing(true);
setStatus("Creating invitation...");
try {
// Create via the engine
const xoInvitation = await appService.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
// Wrap and track
const invitationInstance = await appService.createInvitation(xoInvitation);
let inv = invitationInstance.data;
const invId = inv.invitationIdentifier;
setInvitationId(invId);
// Persist variable values
if (variables.length > 0) {
setStatus('Adding variables...');
const variableData = variables.map((v) => {
const isNumeric =
['integer', 'number', 'satoshis'].includes(v.type) ||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
return {
variableIdentifier: v.id,
roleIdentifier,
value: isNumeric ? BigInt(v.value || '0') : v.value,
};
try {
// Create via the engine
const xoInvitation = await appService.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
await invitationInstance.addVariables(variableData);
inv = invitationInstance.data;
}
// Build variable values lookup for output resolution
const variableValuesByIdentifier = variables.reduce((acc, variable) => {
if (typeof variable.value === 'string' && variable.value.trim().length > 0) {
acc[variable.id] = variable.value;
// Wrap and track
const invitationInstance =
await appService.createInvitation(xoInvitation);
let inv = invitationInstance.data;
const invId = inv.invitationIdentifier;
setInvitationId(invId);
// Persist variable values
if (variables.length > 0) {
setStatus("Adding variables...");
const variableData = variables.map((v) => {
const isNumeric =
["integer", "number", "satoshis"].includes(v.type) ||
(v.hint && ["satoshis", "amount"].includes(v.hint));
return {
variableIdentifier: v.id,
roleIdentifier,
value: isNumeric ? BigInt(v.value || "0") : v.value,
};
});
await invitationInstance.addVariables(variableData);
inv = invitationInstance.data;
}
return acc;
}, {} as Record<string, string>);
// Add template-required transaction outputs
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
? template.transactions?.[act.transaction]
: null;
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus('Adding required outputs...');
const outputsToAdd = await Promise.all(
transaction.outputs.map(async (output: XOTemplateTransactionOutput) => {
const outputIdentifier = getTransactionOutputIdentifier(output);
if (!outputIdentifier) {
throw new Error('Invalid transaction output definition');
// Build variable values lookup for output resolution
const variableValuesByIdentifier = variables.reduce(
(acc, variable) => {
if (
typeof variable.value === "string" &&
variable.value.trim().length > 0
) {
acc[variable.id] = variable.value;
}
const providedHex = resolveProvidedLockingBytecodeHex(
template,
outputIdentifier,
variableValuesByIdentifier,
);
const lockingBytecodeHex =
providedHex ?? await invitationInstance.generateLockingBytecode(outputIdentifier, roleIdentifier);
return { outputIdentifier, lockingBytecode: lockingBytecodeHex };
}),
return acc;
},
{} as Record<string, string>,
);
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOutputs accept a hex string. 3. Have addOutputs handle lockscript generation.
await invitationInstance.addOutputs(
outputsToAdd.map((output) => ({
outputIdentifier: output.outputIdentifier,
lockingBytecode: new Uint8Array(Buffer.from(output.lockingBytecode, 'hex')),
})),
);
// Add template-required transaction outputs
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
? template.transactions?.[act.transaction]
: null;
inv = invitationInstance.data;
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus("Adding required outputs...");
const outputsToAdd = await Promise.all(
transaction.outputs.map(
async (output: XOTemplateTransactionOutput) => {
const outputIdentifier = getTransactionOutputIdentifier(output);
if (!outputIdentifier) {
throw new Error("Invalid transaction output definition");
}
const providedHex = resolveProvidedLockingBytecodeHex(
template,
outputIdentifier,
variableValuesByIdentifier,
);
const lockingBytecodeHex =
providedHex ??
(await invitationInstance.generateLockingBytecode(
outputIdentifier,
roleIdentifier,
));
return {
outputIdentifier,
lockingBytecode: lockingBytecodeHex,
};
},
),
);
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOutputs accept a hex string. 3. Have addOutputs handle lockscript generation.
await invitationInstance.addOutputs(
outputsToAdd.map((output) => ({
outputIdentifier: output.outputIdentifier,
lockingBytecode: new Uint8Array(
Buffer.from(output.lockingBytecode, "hex"),
),
})),
);
inv = invitationInstance.data;
}
setInvitation(inv);
await refreshRequirements(invId);
setStatus("Invitation created");
return invId;
} catch (error) {
showError(
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
} finally {
setIsProcessing(false);
}
setInvitation(inv);
await refreshRequirements(invId);
setStatus('Invitation created');
return invId;
} catch (error) {
showError(
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
} finally {
setIsProcessing(false);
}
}, [appService, showError, setStatus, refreshRequirements]);
},
[appService, showError, setStatus, refreshRequirements],
);
/**
* Add the selected UTXOs as inputs and a change output to the invitation.
*
* @returns true on success, false on failure.
*/
const addInputsAndOutputs = useCallback(async (
selectedUtxos: SelectableUTXO[],
changeAmount: bigint,
): Promise<boolean> => {
if (!invitationId || !appService) return false;
const addInputsAndOutputs = useCallback(
async (
selectedUtxos: SelectableUTXO[],
changeAmount: bigint,
): Promise<boolean> => {
if (!invitationId || !appService) return false;
setIsProcessing(true);
setStatus('Adding inputs and outputs...');
setIsProcessing(true);
setStatus("Adding inputs and outputs...");
try {
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === invitationId,
);
if (!instance) throw new Error('Invitation not found');
try {
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === invitationId,
);
if (!instance) throw new Error("Invitation not found");
const inputs = selectedUtxos.map((utxo) => ({
outpointTransactionHash: new Uint8Array(
Buffer.from(utxo.outpointTransactionHash, 'hex'),
),
outpointIndex: utxo.outpointIndex,
}));
const inputs = selectedUtxos.map((utxo) => ({
outpointTransactionHash: new Uint8Array(
Buffer.from(utxo.outpointTransactionHash, "hex"),
),
outpointIndex: utxo.outpointIndex,
}));
await instance.addInputs(inputs);
await instance.addOutputs([{ valueSatoshis: changeAmount }]);
await refreshRequirements(invitationId);
setStatus('Inputs and outputs added');
return true;
} catch (error) {
showError(
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, showError, setStatus, refreshRequirements]);
await instance.addInputs(inputs);
await instance.addOutputs([{ valueSatoshis: changeAmount }]);
await refreshRequirements(invitationId);
setStatus("Inputs and outputs added");
return true;
} catch (error) {
showError(
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
} finally {
setIsProcessing(false);
}
},
[invitationId, appService, showError, setStatus, refreshRequirements],
);
/** Sign the invitation and broadcast the transaction. */
const signAndBroadcast = useCallback(async (): Promise<boolean> => {
if (!invitationId || !appService) return false;
setIsProcessing(true);
setStatus('Signing invitation...');
setStatus("Signing invitation...");
try {
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === invitationId,
);
if (!instance) throw new Error('Invitation not found');
if (!instance) throw new Error("Invitation not found");
const complete = await refreshRequirements(invitationId);
if (!complete) {
showError('Invitation requirements are not complete yet.');
showError("Invitation requirements are not complete yet.");
return false;
}
await instance.sign();
setStatus('Broadcasting transaction...');
setStatus("Broadcasting transaction...");
await instance.broadcast();
setHasSignedAndBroadcasted(true);
setStatus('Transaction signed and broadcasted');
showInfo('Transaction signed and broadcasted.');
setStatus("Transaction signed and broadcasted");
showInfo("Transaction signed and broadcasted.");
await refreshRequirements(invitationId);
return true;
} catch (error) {
@@ -240,7 +269,14 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, setStatus, showError, showInfo, refreshRequirements]);
}, [
invitationId,
appService,
setStatus,
showError,
showInfo,
refreshRequirements,
]);
return {
invitation,