Files
xo-cli/src/tui/screens/action-wizard/hooks/useInvitationManager.ts

296 lines
9.3 KiB
TypeScript

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";
interface InvitationManagerDeps {
appService: AppService;
showError: (msg: string) => void;
showInfo: (msg: string) => void;
setStatus: (msg: string) => void;
}
/**
* Manages the full invitation lifecycle for transaction-based actions:
* creation, variable persistence, output generation, input addition,
* signing, and broadcasting.
*
* Only relevant for TransactionWizardFlow — data flows bypass this entirely.
*/
export function useInvitationManager(deps: InvitationManagerDeps) {
const { appService, showError, showInfo, setStatus } = deps;
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
const [invitationId, setInvitationId] = useState<string | null>(null);
const [requirementsComplete, setRequirementsComplete] = useState(false);
const [hasSignedAndBroadcasted, setHasSignedAndBroadcasted] = useState(false);
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 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],
);
/**
* Create an invitation, persist variable values, and add
* template-required transaction outputs.
*
* @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;
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,
};
});
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;
}
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");
}
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);
}
},
[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;
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");
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],
);
/** Sign the invitation and broadcast the transaction. */
const signAndBroadcast = useCallback(async (): Promise<boolean> => {
if (!invitationId || !appService) return false;
setIsProcessing(true);
setStatus("Signing invitation...");
try {
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === invitationId,
);
if (!instance) throw new Error("Invitation not found");
const complete = await refreshRequirements(invitationId);
if (!complete) {
showError("Invitation requirements are not complete yet.");
return false;
}
await instance.sign();
setStatus("Broadcasting transaction...");
await instance.broadcast();
setHasSignedAndBroadcasted(true);
setStatus("Transaction signed and broadcasted");
showInfo("Transaction signed and broadcasted.");
await refreshRequirements(invitationId);
return true;
} catch (error) {
showError(
`Failed to sign and broadcast: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
} finally {
setIsProcessing(false);
}
}, [
invitationId,
appService,
setStatus,
showError,
showInfo,
refreshRequirements,
]);
return {
invitation,
invitationId,
requirementsComplete,
hasSignedAndBroadcasted,
isProcessing,
setIsProcessing,
refreshRequirements,
createWithVariables,
addInputsAndOutputs,
signAndBroadcast,
} as const;
}
export type InvitationManagerState = ReturnType<typeof useInvitationManager>;