Massive changes. I dont know what happens. Rewrote the action wizard again. Fixed several bugs related to the utxo selection. QR codes were added for address. Add support for data results. Experiment with other methods of role extraction

This commit is contained in:
2026-03-22 13:20:46 +00:00
parent be52f73e64
commit a28d43a68b
35 changed files with 2226 additions and 1169 deletions

View File

@@ -0,0 +1,259 @@
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>;