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(null); const [invitationId, setInvitationId] = useState(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 => { 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 => { 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, ); // 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 => { 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 => { 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;