296 lines
9.3 KiB
TypeScript
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>;
|